Merge branch 'main' into storefront-nuxt

This commit is contained in:
Egor Pavlovich Gorbunov 2025-10-17 11:16:07 +03:00
commit f289ea1e8e
12 changed files with 221 additions and 64 deletions

View file

@ -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:

View file

@ -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)})

View file

@ -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"
)

View file

@ -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):

View file

@ -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()

16
core/utils/vendors.py Normal file
View file

@ -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

View file

@ -1,9 +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
@ -59,6 +65,14 @@ class VendorError(Exception):
pass
class VendorDebuggingError(VendorError):
"""
Custom exception raised when a debugging operation fails
"""
pass
class VendorInactiveError(VendorError):
pass
@ -84,6 +98,41 @@ class AbstractVendor:
self.currency = currency
self.blocked_attributes: list[Any] = []
def __str__(self) -> str:
return self.vendor_name or self.get_vendor_instance().name
def save_response(self, data: dict[Any, Any] | list[Any]) -> None:
with suppress(Exception):
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")
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:

View file

@ -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)
@ -387,3 +390,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. "
)

View file

@ -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):
@ -716,8 +725,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 +735,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 +754,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 +773,17 @@ 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 +791,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

View file

@ -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")),

View file

@ -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)

View file

@ -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