From 6fa037390c8456156e6b735289e0ee85371bd707 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Wed, 15 Oct 2025 14:25:10 +0300 Subject: [PATCH 1/9] Features: 1) Add `get_gateways_integrations` utility for payment gateway integrations; 2) Add `get_vendors_integrations` utility for vendor integrations; 3) Add `version` API endpoint to return eVibes version; 4) Implement `__str__` method for `AbstractVendor`; Fixes: 1) Update return type of `create_object` to `Any`; Extra: 1) Remove unused fields (`icon`, `priority`, `hide`) from `blog.apps`; 2) Update API URLs to include `version` endpoint; 3) Miscellaneous cleanup and comments. --- blog/apps.py | 3 --- core/utils/vendors.py | 16 ++++++++++++++++ core/vendors/__init__.py | 3 +++ core/views.py | 10 ++++++++++ evibes/api_urls.py | 2 ++ evibes/utils/misc.py | 2 +- payments/utils/gateways.py | 16 ++++++++++++++++ 7 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 core/utils/vendors.py create mode 100644 payments/utils/gateways.py diff --git a/blog/apps.py b/blog/apps.py index 2783a0aa..689b2aaf 100644 --- a/blog/apps.py +++ b/blog/apps.py @@ -6,9 +6,6 @@ class BlogConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "blog" verbose_name = _("blog") - icon = "fa fa-solid fa-book" - priority = 86 - hide = False # noinspection PyUnresolvedReferences def ready(self) -> None: diff --git a/core/utils/vendors.py b/core/utils/vendors.py new file mode 100644 index 00000000..f68a4c31 --- /dev/null +++ b/core/utils/vendors.py @@ -0,0 +1,16 @@ +from typing import Type + +from core.models import Vendor +from core.vendors import AbstractVendor +from evibes.utils.misc import create_object + + +def get_vendors_integrations(name: str | None = None) -> list[Type[AbstractVendor]]: + vendors_integrations: list[Type[AbstractVendor]] = [] + vendors = Vendor.objects.filter(is_active=True, name=name) if name else Vendor.objects.filter(is_active=True) + for vendor in vendors: + if vendor.integration_path: + module_name = ".".join(vendor.integration_path.split(".")[:-1]) + class_name = vendor.integration_path.split(".")[-1] + vendors_integrations.append(create_object(module_name, class_name)) + return vendors_integrations diff --git a/core/vendors/__init__.py b/core/vendors/__init__.py index da68d537..a460d850 100644 --- a/core/vendors/__init__.py +++ b/core/vendors/__init__.py @@ -84,6 +84,9 @@ class AbstractVendor: self.currency = currency self.blocked_attributes: list[Any] = [] + def __str__(self) -> str: + return self.vendor_name or self.get_vendor_instance().name + @staticmethod def chunk_data(data: list[Any] | None = None, num_chunks: int = 20) -> list[list[Any]] | list[Any]: if not data: diff --git a/core/views.py b/core/views.py index 68396451..a7b4a9aa 100644 --- a/core/views.py +++ b/core/views.py @@ -387,3 +387,13 @@ index.__doc__ = _( # type: ignore [assignment] "admin interface index page. It uses Django's `redirect` function for handling " "the HTTP redirection." ) + + +def version(request: HttpRequest, *args, **kwargs) -> HttpResponse: + return JsonResponse(camelize({"version": settings.EVIBES_VERSION}), status=200) + + +# noinspection PyTypeChecker +version.__doc__ = _( # type: ignore [assignment] + "Returns current version of the eVibes. " +) diff --git a/evibes/api_urls.py b/evibes/api_urls.py index 016efca4..b7ee3cd1 100644 --- a/evibes/api_urls.py +++ b/evibes/api_urls.py @@ -13,6 +13,7 @@ from core.views import ( CustomSwaggerView, favicon_view, index, + version, ) from evibes.settings import SPECTACULAR_PLATFORM_SETTINGS @@ -39,6 +40,7 @@ urlpatterns = [ path(r"i18n/", include("django.conf.urls.i18n")), path(r"favicon.ico", favicon_view), path(r"", index), + path(r"", version), path(r"", include("core.api_urls", namespace="core")), path(r"auth/", include("vibes_auth.urls", namespace="vibes_auth")), path(r"payments/", include("payments.urls", namespace="payments")), diff --git a/evibes/utils/misc.py b/evibes/utils/misc.py index 63c60fe3..4768de44 100644 --- a/evibes/utils/misc.py +++ b/evibes/utils/misc.py @@ -2,7 +2,7 @@ from importlib import import_module from typing import Any -def create_object(module_name: str, class_name: str, *args: list[Any], **kwargs: dict[Any, Any]) -> object: +def create_object(module_name: str, class_name: str, *args: list[Any], **kwargs: dict[Any, Any]) -> Any: module = import_module(module_name) cls = getattr(module, class_name) diff --git a/payments/utils/gateways.py b/payments/utils/gateways.py new file mode 100644 index 00000000..522adc68 --- /dev/null +++ b/payments/utils/gateways.py @@ -0,0 +1,16 @@ +from typing import Type + +from evibes.utils.misc import create_object +from payments.gateways import AbstractGateway +from payments.models import Gateway + + +def get_gateways_integrations(name: str | None = None) -> list[Type[AbstractGateway]]: + gateways_integrations: list[Type[AbstractGateway]] = [] + gateways = Gateway.objects.filter(is_active=True, name=name) if name else Gateway.objects.filter(is_active=True) + for gateway in gateways: + if gateway.integration_path: + module_name = ".".join(gateway.integration_path.split(".")[:-1]) + class_name = gateway.integration_path.split(".")[-1] + gateways_integrations.append(create_object(module_name, class_name)) + return gateways_integrations From 996362d9f19bd8c8d42df2e6ab2c1ec1a3f9250b Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Wed, 15 Oct 2025 16:15:23 +0300 Subject: [PATCH 2/9] Features: 1) Add `save_response` method to save vendor responses with optional gzip compression; 2) Introduce `VendorDebuggingError` for debugging-specific exceptions; 3) Add imports for `config` and `settings` for enhanced configuration support; Fixes: None; Extra: Improve exception handling with `suppress(Exception)` block; add detailed docstring to `VendorDebuggingError`. --- core/vendors/__init__.py | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/core/vendors/__init__.py b/core/vendors/__init__.py index a460d850..444b3750 100644 --- a/core/vendors/__init__.py +++ b/core/vendors/__init__.py @@ -4,6 +4,8 @@ from decimal import Decimal from math import ceil, log10 from typing import Any +from constance import config +from django.conf import settings from django.db import IntegrityError, transaction from django.db.models import QuerySet @@ -59,6 +61,14 @@ class VendorError(Exception): pass +class VendorDebuggingError(VendorError): + """ + Custom exception raised when a debugging operation fails + """ + + pass + + class VendorInactiveError(VendorError): pass @@ -87,6 +97,39 @@ class AbstractVendor: def __str__(self) -> str: return self.vendor_name or self.get_vendor_instance().name + def save_response(self, data: dict[Any, Any]) -> None: + with suppress(Exception): + if settings.DEBUG or config.SAVE_VENDORS_RESPONSES: + import gzip + from io import BytesIO + from django.core.files.base import ContentFile + from datetime import datetime + + vendor_instance = self.get_vendor_instance() + + json_data = json.dumps(data, indent=2, ensure_ascii=False, default=str) + json_bytes = json_data.encode("utf-8") + + size_threshold = 1024 * 1024 # 1MB + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + if len(json_bytes) > size_threshold: + buffer = BytesIO() + with gzip.GzipFile(fileobj=buffer, mode="wb", compresslevel=9) as gz_file: + gz_file.write(json_bytes) + + compressed_data = buffer.getvalue() + filename = f"response_{timestamp}.json.gz" + content = ContentFile(compressed_data) + else: + filename = f"response_{timestamp}.json" + content = ContentFile(json_bytes) + + vendor_instance.last_processing_response.save(filename, content, save=True) + + return + raise VendorDebuggingError("Could not save response") + @staticmethod def chunk_data(data: list[Any] | None = None, num_chunks: int = 20) -> list[list[Any]] | list[Any]: if not data: From d35504575539115b796ec2d58e176ada0ecc0b28 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Wed, 15 Oct 2025 16:17:03 +0300 Subject: [PATCH 3/9] Features: 1) Update `save_response` method to support data as both dictionary or list; Fixes: ; Extra: ; --- core/vendors/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/vendors/__init__.py b/core/vendors/__init__.py index 444b3750..f70f5f8c 100644 --- a/core/vendors/__init__.py +++ b/core/vendors/__init__.py @@ -97,7 +97,7 @@ class AbstractVendor: def __str__(self) -> str: return self.vendor_name or self.get_vendor_instance().name - def save_response(self, data: dict[Any, Any]) -> None: + def save_response(self, data: dict[Any, Any] | list[Any]) -> None: with suppress(Exception): if settings.DEBUG or config.SAVE_VENDORS_RESPONSES: import gzip From 3b1d20ff148fe6ffe0c5e3078621ef2436ff0bc0 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Wed, 15 Oct 2025 16:21:35 +0300 Subject: [PATCH 4/9] Features: 1) Import `gzip`, `BytesIO`, `datetime`, and `ContentFile` at the top level for consolidation and reuse; Fixes: 1) Remove redundant in-function imports for `gzip`, `BytesIO`, `datetime`, and `ContentFile`; Extra: 1) Minor reorganization to improve readability and adhere to import best practices. --- core/vendors/__init__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/core/vendors/__init__.py b/core/vendors/__init__.py index f70f5f8c..124e6dc6 100644 --- a/core/vendors/__init__.py +++ b/core/vendors/__init__.py @@ -1,11 +1,15 @@ +import gzip import json from contextlib import suppress +from datetime import datetime from decimal import Decimal +from io import BytesIO from math import ceil, log10 from typing import Any from constance import config from django.conf import settings +from django.core.files.base import ContentFile from django.db import IntegrityError, transaction from django.db.models import QuerySet @@ -100,11 +104,6 @@ class AbstractVendor: def save_response(self, data: dict[Any, Any] | list[Any]) -> None: with suppress(Exception): if settings.DEBUG or config.SAVE_VENDORS_RESPONSES: - import gzip - from io import BytesIO - from django.core.files.base import ContentFile - from datetime import datetime - vendor_instance = self.get_vendor_instance() json_data = json.dumps(data, indent=2, ensure_ascii=False, default=str) From 8889429a02778d32f28ce714bfdbfdcaf9b473d3 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Wed, 15 Oct 2025 17:17:36 +0300 Subject: [PATCH 5/9] Features: 1) Delete vendor's last processing response if present before saving new data; Fixes: none; Extra: none; --- core/vendors/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/vendors/__init__.py b/core/vendors/__init__.py index 124e6dc6..b4eeafbd 100644 --- a/core/vendors/__init__.py +++ b/core/vendors/__init__.py @@ -106,6 +106,10 @@ class AbstractVendor: if settings.DEBUG or config.SAVE_VENDORS_RESPONSES: vendor_instance = self.get_vendor_instance() + if vendor_instance.last_processing_response: + with suppress(Exception): + vendor_instance.last_processing_response.delete(save=False) + json_data = json.dumps(data, indent=2, ensure_ascii=False, default=str) json_bytes = json_data.encode("utf-8") From 0bfc4c2984c2311638bb5dac64bcaf9be536099d Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Thu, 16 Oct 2025 09:58:13 +0300 Subject: [PATCH 6/9] Features: 1) Add detailed error responses with exception messages for `Order.DoesNotExist` and `ValueError` in order-related actions; 2) Improve `create_promocode_on_user_referring` with stricter type check for user attributes; Fixes: 1) Move `get_object()` call into `try` block to prevent unhandled exceptions during order retrieval; Extra: 1) Minor code reformatting for improved readability. --- core/signals.py | 2 +- core/viewsets.py | 31 ++++++++++++++++++++----------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/core/signals.py b/core/signals.py index a6486047..a8a3945e 100644 --- a/core/signals.py +++ b/core/signals.py @@ -53,7 +53,7 @@ def create_wishlist_on_user_creation_signal(instance: User, created: bool, **kwa @receiver(post_save, sender=User) def create_promocode_on_user_referring(instance: User, created: bool, **kwargs: dict[Any, Any]) -> None: try: - if not instance.attributes: + if type(instance.attributes) is not dict: instance.attributes = {} instance.save() diff --git a/core/viewsets.py b/core/viewsets.py index 1fb12e6b..12a463ca 100644 --- a/core/viewsets.py +++ b/core/viewsets.py @@ -716,8 +716,8 @@ class OrderViewSet(EvibesViewSet): def add_order_product(self, request: Request, *args, **kwargs) -> Response: serializer = AddOrderProductSerializer(data=request.data) serializer.is_valid(raise_exception=True) - order = self.get_object() try: + order = self.get_object() if not (request.user.has_perm("core.add_orderproduct") or request.user == order.user): raise PermissionDenied(permission_denied_message) @@ -726,15 +726,17 @@ class OrderViewSet(EvibesViewSet): attributes=format_attributes(serializer.validated_data.get("attributes")), ) return Response(status=status.HTTP_200_OK, data=OrderDetailSerializer(order).data) - except Order.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) + except Order.DoesNotExist as dne: + return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)}) + except ValueError as ve: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ve)}) @action(detail=True, methods=["post"], url_path="remove_order_product") def remove_order_product(self, request: Request, *args, **kwargs) -> Response: serializer = RemoveOrderProductSerializer(data=request.data) serializer.is_valid(raise_exception=True) - order = self.get_object() try: + order = self.get_object() if not (request.user.has_perm("core.delete_orderproduct") or request.user == order.user): raise PermissionDenied(permission_denied_message) @@ -743,8 +745,10 @@ class OrderViewSet(EvibesViewSet): attributes=format_attributes(serializer.validated_data.get("attributes")), ) return Response(status=status.HTTP_200_OK, data=OrderDetailSerializer(order).data) - except Order.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) + except Order.DoesNotExist as dne: + return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)}) + except ValueError as ve: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ve)}) @action(detail=True, methods=["post"], url_path="bulk_add_order_products") def bulk_add_order_products(self, request: Request, *args, **kwargs) -> Response: @@ -760,15 +764,18 @@ class OrderViewSet(EvibesViewSet): products=serializer.validated_data.get("products"), ) return Response(status=status.HTTP_200_OK, data=OrderDetailSerializer(order).data) - except Order.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) + except Order.DoesNotExist as dne: + return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)}) + except ValueError as ve: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ve)}) + @action(detail=True, methods=["post"], url_path="bulk_remove_order_products") def bulk_remove_order_products(self, request: Request, *args, **kwargs) -> Response: serializer = BulkRemoveOrderProductsSerializer(data=request.data) serializer.is_valid(raise_exception=True) - order = self.get_object() try: + order = self.get_object() if not (request.user.has_perm("core.delete_orderproduct") or request.user == order.user): raise PermissionDenied(permission_denied_message) @@ -776,8 +783,10 @@ class OrderViewSet(EvibesViewSet): products=serializer.validated_data.get("products"), ) return Response(status=status.HTTP_200_OK, data=OrderDetailSerializer(order).data) - except Order.DoesNotExist: - return Response(status=status.HTTP_404_NOT_FOUND) + except Order.DoesNotExist as dne: + return Response(status=status.HTTP_404_NOT_FOUND, data={"detail": str(dne)}) + except ValueError as ve: + return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ve)}) # noinspection PyUnusedLocal From 00e94a2b29838c63b200ea27f5f40f7bb5bd5a1f Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Thu, 16 Oct 2025 10:01:34 +0300 Subject: [PATCH 7/9] Features: 1) Add validation to ensure `order_product_uuid` is required in `download_digital_asset_view`. Fixes: 1) Remove unnecessary blank line in `core/viewsets.py`. Extra: 1) Minor cleanup in `core/views.py` to improve clarity. --- core/views.py | 5 ++++- core/viewsets.py | 1 - 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/core/views.py b/core/views.py index a7b4a9aa..355664fe 100644 --- a/core/views.py +++ b/core/views.py @@ -304,7 +304,10 @@ class BuyAsBusinessView(APIView): def download_digital_asset_view(request: HttpRequest, *args, **kwargs) -> FileResponse | JsonResponse: try: logger.debug(f"download_digital_asset_view: {kwargs}") - uuid = urlsafe_base64_decode(str(kwargs.get("order_product_uuid"))).decode("utf-8") + op_uuid = str(kwargs.get("order_product_uuid")) + if not op_uuid: + raise BadRequest(_("order_product_uuid is required")) + uuid = urlsafe_base64_decode(op_uuid).decode("utf-8") download = DigitalAssetDownload.objects.get(order_product__uuid=uuid) diff --git a/core/viewsets.py b/core/viewsets.py index 12a463ca..b2051a6b 100644 --- a/core/viewsets.py +++ b/core/viewsets.py @@ -769,7 +769,6 @@ class OrderViewSet(EvibesViewSet): except ValueError as ve: return Response(status=status.HTTP_400_BAD_REQUEST, data={"detail": str(ve)}) - @action(detail=True, methods=["post"], url_path="bulk_remove_order_products") def bulk_remove_order_products(self, request: Request, *args, **kwargs) -> Response: serializer = BulkRemoveOrderProductsSerializer(data=request.data) From e894affad76056c118c6943f91bd6f6d3082a632 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Thu, 16 Oct 2025 14:04:18 +0300 Subject: [PATCH 8/9] Features: 1) Add `with_related` method to managers for optimized prefetching; 2) Add indexes to models for enhanced query performance; 3) Introduce `cached_property` for feedback count and product quantity; 4) Implement `Exists` and `OuterRef` filters for active stock validation in viewsets. Fixes: 1) Simplify rating and feedback count logic by removing caching layers; 2) Refactor price and quantity calculations for improved accuracy and simplicity; 3) Optimize total price and quantity aggregations in orders by leveraging Django ORM tools. Extra: Adjusted import statements, removed redundant cache logic, and cleaned up methods for better readability and maintainability. --- core/managers.py | 5 ++++ core/models.py | 71 +++++++++++++++++++++--------------------------- core/viewsets.py | 23 +++++++++++----- 3 files changed, 52 insertions(+), 47 deletions(-) diff --git a/core/managers.py b/core/managers.py index f120cad7..caf5f934 100644 --- a/core/managers.py +++ b/core/managers.py @@ -84,3 +84,8 @@ class ProductManager(MultilingualManager): stocks__vendor__is_active=True, stocks__quantity__gt=0, ) + + def with_related(self): + return self.select_related("category", "brand").prefetch_related( + "tags", "stocks", "images", "attributes__attribute__group" + ) diff --git a/core/models.py b/core/models.py index 1ab647c6..6b4b0811 100644 --- a/core/models.py +++ b/core/models.py @@ -20,6 +20,7 @@ from django.db.models import ( CharField, DateTimeField, DecimalField, + F, FileField, FloatField, ForeignKey, @@ -31,6 +32,7 @@ from django.db.models import ( OneToOneField, PositiveIntegerField, QuerySet, + Sum, TextField, URLField, ) @@ -38,6 +40,7 @@ from django.db.models.indexes import Index from django.http import Http404 from django.utils import timezone from django.utils.encoding import force_bytes +from django.utils.functional import cached_property from django.utils.http import urlsafe_base64_encode from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import AutoSlugField @@ -177,6 +180,7 @@ class Vendor(ExportModelOperationsMixin("vendor"), NiceModel): # type: ignore [ verbose_name_plural = _("vendors") indexes = [ GinIndex(fields=["authentication"]), + Index(fields=["name"]), ] @@ -512,6 +516,7 @@ class Product(ExportModelOperationsMixin("product"), NiceModel): # type: ignore max_length=255, help_text=_("provide a clear identifying name for the product"), verbose_name=_("product name"), + db_index=True, ) description = TextField( blank=True, @@ -556,49 +561,37 @@ class Product(ExportModelOperationsMixin("product"), NiceModel): # type: ignore class Meta: verbose_name = _("product") verbose_name_plural = _("products") + indexes = [ + Index(fields=["is_active", "brand", "category"]), + Index(fields=["slug"]), + Index(fields=["sku"]), + ] def __str__(self): return self.name @property def rating(self) -> float: - cache_key = f"product_rating_{self.pk}" - rating = cache.get(cache_key) - if rating is None: - feedbacks = Feedback.objects.filter(order_product__product_id=self.pk) - rating = feedbacks.aggregate(Avg("rating"))["rating__avg"] or 0 - cache.set(cache_key, rating, 86400) + feedbacks = Feedback.objects.filter(order_product__product_id=self.pk) + rating = feedbacks.aggregate(Avg("rating"))["rating__avg"] or 0 return float(round(rating, 2)) @rating.setter def rating(self, value: float): self.__dict__["rating"] = value - @property + @cached_property def feedbacks_count(self) -> int: - cache_key = f"product_feedbacks_count_{self.pk}" - feedbacks_count = cache.get(cache_key) - if feedbacks_count is None: - feedbacks_count = Feedback.objects.filter(order_product__product_id=self.pk).count() - cache.set(cache_key, feedbacks_count, 604800) - return feedbacks_count + return Feedback.objects.filter(order_product__product_id=self.pk).count() @property def price(self: Self) -> float: - stock = self.stocks.all().order_by("-price").only("price").first() - price = stock.price if stock else 0.0 - return round(price, 2) + stock = self.stocks.only("price").order_by("-price").first() + return round(stock.price, 2) if stock else 0.0 - @property + @cached_property def quantity(self) -> int: - cache_key = f"product_quantity_{self.pk}" - quantity = cache.get(cache_key, 0) - if not quantity: - stocks = self.stocks.only("quantity") - for stock in stocks: - quantity += stock.quantity - cache.set(cache_key, quantity, 3600) - return quantity + return self.stocks.aggregate(total=Sum("quantity"))["total"] or 0 @property def total_orders(self): @@ -1178,6 +1171,10 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi class Meta: verbose_name = _("order") verbose_name_plural = _("orders") + indexes = [ + Index(fields=["user", "status"]), + Index(fields=["status", "buy_time"]), + ] def __str__(self) -> str: return f"#{self.human_readable_id} for {self.user.email if self.user else 'unregistered user'}" @@ -1225,24 +1222,16 @@ class Order(ExportModelOperationsMixin("order"), NiceModel): # type: ignore [mi @property def total_price(self) -> float: - return ( - round( - sum( - ( - order_product.buy_price * order_product.quantity - if order_product.status not in FAILED_STATUSES and order_product.buy_price is not None - else 0.0 - ) - for order_product in self.order_products.all() - ), - 2, - ) - or 0.0 - ) + total = self.order_products.exclude(status__in=FAILED_STATUSES).aggregate( + total=Sum(F("buy_price") * F("quantity"), output_field=FloatField()) + )["total"] + + return round(total or 0.0, 2) @property def total_quantity(self) -> int: - return sum([op.quantity for op in self.order_products.all()]) + total = self.order_products.aggregate(total=Sum("quantity"))["total"] + return total or 0 def add_product( self, @@ -1676,6 +1665,8 @@ class OrderProduct(ExportModelOperationsMixin("order_product"), NiceModel): # t verbose_name_plural = _("order products") indexes = [ GinIndex(fields=["notifications", "attributes"]), + Index(fields=["order", "status"]), + Index(fields=["product", "status"]), ] def return_balance_back(self): diff --git a/core/viewsets.py b/core/viewsets.py index b2051a6b..2a20d75e 100644 --- a/core/viewsets.py +++ b/core/viewsets.py @@ -5,7 +5,7 @@ from uuid import UUID from constance import config from django.conf import settings -from django.db.models import Prefetch, Q +from django.db.models import Prefetch, Q, OuterRef, Exists from django.http import Http404 from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator @@ -452,14 +452,23 @@ class ProductViewSet(EvibesViewSet): def get_queryset(self): qs = super().get_queryset() + + qs = qs.select_related("brand", "category") + if self.request.user.has_perm("core.view_product"): return qs - return qs.filter( - is_active=True, - brand__is_active=True, - category__is_active=True, - stocks__isnull=False, - stocks__vendor__is_active=True, + + active_stocks = Stock.objects.filter(product_id=OuterRef("pk"), vendor__is_active=True) + + return ( + qs.filter( + is_active=True, + brand__is_active=True, + category__is_active=True, + ) + .annotate(has_active_stocks=Exists(active_stocks)) + .filter(has_active_stocks=True) + .distinct() ) def get_object(self): From c2631824147b10484baa2f44a36251fe8e876708 Mon Sep 17 00:00:00 2001 From: Egor fureunoir Gorbunov Date: Thu, 16 Oct 2025 15:19:13 +0300 Subject: [PATCH 9/9] Features: 1) Add `seen_keys` mechanism to avoid duplicate hits in Elasticsearch query results; 2) Introduce `_collect_hits` helper function for processing and storing hits; 3) Add exact-match queries for categories, brands, and products to improve search accuracy. Fixes: 1) Prevent duplicate entries in hit processing by checking `seen_keys`. Extra: Refactor query-building logic for consistency and readability; minor performance optimizations in query execution. --- core/elasticsearch/__init__.py | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/core/elasticsearch/__init__.py b/core/elasticsearch/__init__.py index e695c99a..b3eb3c84 100644 --- a/core/elasticsearch/__init__.py +++ b/core/elasticsearch/__init__.py @@ -228,12 +228,63 @@ def process_query( uuids_by_index: dict[str, list[dict[str, Any]]] = {"products": [], "categories": [], "brands": []} hit_cache: list[Any] = [] + seen_keys: set[tuple[str, str]] = set() + + def _hit_key(hittee: Any) -> tuple[str, str]: + return hittee.meta.index, str(getattr(hittee, "uuid", None) or hittee.meta.id) + + def _collect_hits(hits: list[Any]) -> None: + for hh in hits: + key = _hit_key(hh) + if key in seen_keys: + continue + hit_cache.append(hh) + seen_keys.add(key) + if getattr(hh, "uuid", None): + uuids_by_index.setdefault(hh.meta.index, []).append({"uuid": str(hh.uuid)}) + + exact_queries_by_index: dict[str, list[Any]] = { + "categories": [ + Q("term", **{"name.raw": {"value": query}}), + Q("term", **{"slug": {"value": slugify(query)}}), + ], + "brands": [ + Q("term", **{"name.raw": {"value": query}}), + Q("term", **{"slug": {"value": slugify(query)}}), + ], + "products": [ + Q("term", **{"name.raw": {"value": query}}), + Q("term", **{"slug": {"value": slugify(query)}}), + Q("term", **{"sku.raw": {"value": query.lower()}}), + Q("term", **{"partnumber.raw": {"value": query.lower()}}), + ], + } + + for idx_name in ("categories", "brands", "products"): + if idx_name in indexes: + shoulds = exact_queries_by_index[idx_name] + s_exact = ( + Search(index=[idx_name]) + .query(Q("bool", should=shoulds, minimum_should_match=1)) + .extra(size=5, track_total_hits=False) + ) + try: + resp_exact = s_exact.execute() + except NotFoundError: + resp_exact = None + if resp_exact is not None and getattr(resp_exact, "hits", None): + _collect_hits(list(resp_exact.hits)) + for h in ( list(resp_cats.hits[:12] if resp_cats else []) + list(resp_brands.hits[:12] if resp_brands else []) + list(resp_products.hits[:26] if resp_products else []) ): + k = _hit_key(h) + if k in seen_keys: + continue hit_cache.append(h) + seen_keys.add(k) if getattr(h, "uuid", None): uuids_by_index.setdefault(h.meta.index, []).append({"uuid": str(h.uuid)})