Merge pull request #1 from Byeto-Company/copilot/vscode-mpaygw0b-8h0f

merge changes
This commit is contained in:
Parsa Nazer
2026-05-18 14:44:43 +03:30
committed by GitHub
23 changed files with 1119 additions and 31 deletions
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('account', '0035_shopmodel_customer_pickup_description_and_more'),
]
operations = [
migrations.AddField(
model_name='shopmodel',
name='torob_order_tracking_enabled',
field=models.BooleanField(default=False, verbose_name='فعال سازی پیگیری سفارش Torob'),
),
]
+1
View File
@@ -147,6 +147,7 @@ class ShopModel(models.Model):
shop_description = models.TextField(verbose_name='توضیحات فروشگاه') shop_description = models.TextField(verbose_name='توضیحات فروشگاه')
commission_percent = models.DecimalField(max_digits=5, decimal_places=2, verbose_name='درصد کمیسیون') commission_percent = models.DecimalField(max_digits=5, decimal_places=2, verbose_name='درصد کمیسیون')
telegram_chat_id = models.CharField(max_length=100, blank=True, null=True, verbose_name='شناسه چت تلگرام', help_text='برای ارسال خودکار فاکتورها به تلگرام') telegram_chat_id = models.CharField(max_length=100, blank=True, null=True, verbose_name='شناسه چت تلگرام', help_text='برای ارسال خودکار فاکتورها به تلگرام')
torob_order_tracking_enabled = models.BooleanField(default=False, verbose_name='فعال سازی پیگیری سفارش Torob')
customer_pickup_title = models.CharField(max_length=40, verbose_name='عنوان دریافت حضوری', blank=True, null=True) customer_pickup_title = models.CharField(max_length=40, verbose_name='عنوان دریافت حضوری', blank=True, null=True)
customer_pickup_description = models.CharField(max_length=500, verbose_name='توضیحات دریافت حضوری', blank=True, null=True) customer_pickup_description = models.CharField(max_length=500, verbose_name='توضیحات دریافت حضوری', blank=True, null=True)
+8 -4
View File
@@ -110,10 +110,14 @@ class Zarinpal(BaseBank):
super(Zarinpal, self).verify(transaction_code) super(Zarinpal, self).verify(transaction_code)
data = self.get_verify_data() data = self.get_verify_data()
client = self._get_client(timeout=10) client = self._get_client(timeout=10)
result = client.service.PaymentVerification(**data) try:
if result.Status in [100, 101]: result = client.service.PaymentVerification(**data)
self._set_payment_status(PaymentStatus.COMPLETE) if result.Status in [100, 101]:
else: self._set_payment_status(PaymentStatus.COMPLETE)
else:
self._set_payment_status(PaymentStatus.CANCEL_BY_USER)
logging.debug("Zarinpal gateway unapprove payment")
except:
self._set_payment_status(PaymentStatus.CANCEL_BY_USER) self._set_payment_status(PaymentStatus.CANCEL_BY_USER)
logging.debug("Zarinpal gateway unapprove payment") logging.debug("Zarinpal gateway unapprove payment")
+10 -7
View File
@@ -20,6 +20,10 @@ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
TELEGRAM_BOT_TOKEN = '7068288679:AAGecMnyt9A6R78OQu8nQeISMK1LepX718g' TELEGRAM_BOT_TOKEN = '7068288679:AAGecMnyt9A6R78OQu8nQeISMK1LepX718g'
VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY") VAPID_PRIVATE_KEY = os.getenv("VAPID_PRIVATE_KEY")
TOROB_ORDER_TRACKING_ENABLED = os.getenv("TOROB_ORDER_TRACKING_ENABLED", "false").lower() == "true"
TOROB_PRODUCT_WEBHOOK_URL = os.getenv("TOROB_PRODUCT_WEBHOOK_URL", "https://api.torob.com/update/webhook/v1/")
TOROB_PRODUCT_WEBHOOK_TOKEN = os.getenv("TOROB_PRODUCT_WEBHOOK_TOKEN")
# Email Configuration # Email Configuration
EMAIL_BACKEND = os.getenv("EMAIL_BACKEND") EMAIL_BACKEND = os.getenv("EMAIL_BACKEND")
EMAIL_HOST = os.getenv("EMAIL_HOST") EMAIL_HOST = os.getenv("EMAIL_HOST")
@@ -31,7 +35,7 @@ DEFAULT_FROM_EMAIL = os.getenv("SECRET_KEY")
# Security and Debugging # Security and Debugging
SECRET_KEY = os.getenv("SECRET_KEY") SECRET_KEY = os.getenv("SECRET_KEY")
DEBUG = True DEBUG = False
BASE_DIR = Path(__file__).resolve().parent.parent.parent BASE_DIR = Path(__file__).resolve().parent.parent.parent
# ============================================================================== # ==============================================================================
@@ -70,7 +74,7 @@ INSTALLED_APPS = [
'django_celery_beat', 'django_celery_beat',
'azbankgateways', 'azbankgateways',
# Custom Apps # Custom Apps
"product", "product.apps.ProductConfig",
"account", "account",
"ticket", "ticket",
"chat", "chat",
@@ -243,19 +247,18 @@ AWS_S3_OBJECT_PARAMETERS = {
AZ_IRANIAN_BANK_GATEWAYS = { AZ_IRANIAN_BANK_GATEWAYS = {
"GATEWAYS": { "GATEWAYS": {
"ZIBAL": { "ZARINPAL": {
"MERCHANT_CODE": "zibal", "MERCHANT_CODE": "f1d0afad-6bbc-4494-a060-555dca675a29",
"SANDBOX": True
} }
}, },
"IS_SAMPLE_FORM_ENABLE": True, "IS_SAMPLE_FORM_ENABLE": True,
"DEFAULT": "ZIBAL", "DEFAULT": "ZARINPAL",
"CURRENCY": "IRT", "CURRENCY": "IRT",
"TRACKING_CODE_QUERY_PARAM": "tc", "TRACKING_CODE_QUERY_PARAM": "tc",
"TRACKING_CODE_LENGTH": 16, "TRACKING_CODE_LENGTH": 16,
"SETTING_VALUE_READER_CLASS": "azbankgateways.readers.DefaultReader", "SETTING_VALUE_READER_CLASS": "azbankgateways.readers.DefaultReader",
"BANK_PRIORITIES": [ "BANK_PRIORITIES": [
"ZIBAL", "ZARINPAL",
], ],
"IS_SAFE_GET_GATEWAY_PAYMENT": True # better to be True "IS_SAFE_GET_GATEWAY_PAYMENT": True # better to be True
} }
+4
View File
@@ -5,10 +5,12 @@ from drf_spectacular.views import SpectacularSwaggerView, SpectacularAPIView
from django.conf import settings from django.conf import settings
from rest_framework_simplejwt.views import TokenObtainPairView from rest_framework_simplejwt.views import TokenObtainPairView
from product import views from product import views
from product.torob_api import TorobProductSyncView
from account.views import CustomTokenObtainPairView, TokenRefreshView from account.views import CustomTokenObtainPairView, TokenRefreshView
from home.views import HomeView from home.views import HomeView
from .views import FakeAdminLoginView from .views import FakeAdminLoginView
from azbankgateways.urls import az_bank_gateways_urls from azbankgateways.urls import az_bank_gateways_urls
from order.torob_api import TorobOrderTrackingView
admin.autodiscover() admin.autodiscover()
@@ -24,6 +26,8 @@ urlpatterns = [
path('admin/', FakeAdminLoginView.as_view()), # Fake admin path('admin/', FakeAdminLoginView.as_view()), # Fake admin
path('secret-admin/', admin.site.urls), # Real admin path('secret-admin/', admin.site.urls), # Real admin
path('schema/', SpectacularAPIView.as_view(), name='schema'), path('schema/', SpectacularAPIView.as_view(), name='schema'),
path('torob_api/v3/products', TorobProductSyncView.as_view(), name='torob-product-sync'),
path('torob/v1/orders', TorobOrderTrackingView.as_view(), name='torob-order-tracking-root'),
path('products/', include('product.urls')), path('products/', include('product.urls')),
path('accounts/', include('account.urls')), path('accounts/', include('account.urls')),
path('chat/', include('chat.urls')), path('chat/', include('chat.urls')),
+2
View File
@@ -269,6 +269,8 @@ class OrderAdmin(ModelAdmin, ImportExportModelAdmin):
bank_record = bank_models.Bank.objects.get(tracking_code=item.tracking_code) bank_record = bank_models.Bank.objects.get(tracking_code=item.tracking_code)
if bank_record.is_success: if bank_record.is_success:
logging.debug("This record is verify now.", extra={"pk": bank_record.pk}) logging.debug("This record is verify now.", extra={"pk": bank_record.pk})
elif bank_record.order and not bank_record.order.is_paid:
bank_record.order.rollback_stock()
messages.success(request, f"با موفقیت اپدیت شد") messages.success(request, f"با موفقیت اپدیت شد")
return redirect("admin:order_ordermodel_changelist") return redirect("admin:order_ordermodel_changelist")
@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0045_alter_ordermodel_created_at'),
]
operations = [
migrations.AddField(
model_name='ordermodel',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='تاریخ آخرین بروزرسانی'),
),
migrations.AddField(
model_name='ordermodel',
name='torob_clid',
field=models.CharField(blank=True, db_index=True, max_length=128, null=True, verbose_name='شناسه Torob'),
),
]
+2
View File
@@ -173,7 +173,9 @@ class OrderModel(models.Model):
related_name='orders', null=True, verbose_name='ادرس') related_name='orders', null=True, verbose_name='ادرس')
created_at = models.DateTimeField( created_at = models.DateTimeField(
auto_now_add=True, verbose_name="تاریخ ثبت سفارش") auto_now_add=True, verbose_name="تاریخ ثبت سفارش")
updated_at = models.DateTimeField(auto_now=True, verbose_name="تاریخ آخرین بروزرسانی")
is_paid = models.BooleanField(default=False, verbose_name="وضعیت پرداخت") is_paid = models.BooleanField(default=False, verbose_name="وضعیت پرداخت")
torob_clid = models.CharField(max_length=128, blank=True, null=True, db_index=True, verbose_name='شناسه Torob')
discount_code = models.ForeignKey( discount_code = models.ForeignKey(
DiscountCode, on_delete=models.PROTECT, null=True, blank=True, verbose_name="کدتخفیف") DiscountCode, on_delete=models.PROTECT, null=True, blank=True, verbose_name="کدتخفیف")
special_discount_code = models.ForeignKey( special_discount_code = models.ForeignKey(
+30 -1
View File
@@ -9,7 +9,8 @@ import ghasedak_sms
from .tasks import send_change_status_notif, send_change_status_sms, send_shop_order_invoice_telegram_task from .tasks import send_change_status_notif, send_change_status_sms, send_shop_order_invoice_telegram_task
from django.conf import settings from django.conf import settings
from decimal import Decimal from decimal import Decimal
import logging
logger = logging.getLogger(__name__)
@receiver(pre_save, sender=OrderModel) @receiver(pre_save, sender=OrderModel)
def order_status_changed(sender, instance, **kwargs): def order_status_changed(sender, instance, **kwargs):
@@ -215,6 +216,34 @@ def create_shop_orders_on_payment(sender, instance: OrderModel, created, **kwarg
discount_amount=int(item_discount_amount), discount_amount=int(item_discount_amount),
special_discount_amount=int(it.special_discount_amount or 0), special_discount_amount=int(it.special_discount_amount or 0),
) )
shop_list = []
for order_item in instance.items.all():
shop_to_add = order_item.product.product.shop
shop_list.append(shop_to_add)
shop_list = set(shop_list)
for shop in shop_list:
sms_api = ghasedak_sms.Ghasedak(api_key="8f7396f1e3c39e3a4621009c558d955336eea6d21cf257dd74ae262d6f22a458XdoDjH6egJsiZsy8")
newotpcommand = ghasedak_sms.SendOtpInput(
receptors=[
ghasedak_sms.SendOtpReceptorDto(
mobile=f'{shop.user.phone}',
client_reference_id=str(shop.user.id)
)
],
template_name='neworder',
inputs=[
ghasedak_sms.SendOtpInput.OtpInput(param='ShopName', value=f'{shop.shop_name}'),
],
udh=False
)
response = sms_api.send_otp_sms(newotpcommand)
if response['statusCode'] == 200:
logger.error("sent order notice to shop owner")
else:
logger.error(f"faield to send order id {response}")
@receiver(post_save, sender=ShopOrderModel) @receiver(post_save, sender=ShopOrderModel)
+2 -2
View File
@@ -22,12 +22,12 @@ def udpate_bank_status():
) )
bank.verify(item.tracking_code) bank.verify(item.tracking_code)
bank_record = bank_models.Bank.objects.get(tracking_code=item.tracking_code) bank_record = bank_models.Bank.objects.get(tracking_code=item.tracking_code)
if bank_record.is_success: if bank_record.is_success and bank_record.order:
bank_record.order.cart.clear_cart() bank_record.order.cart.clear_cart()
bank_record.order.is_paid = True bank_record.order.is_paid = True
bank_record.order.save() bank_record.order.save()
logging.debug("This record is verify now.", extra={"pk": bank_record.pk}) logging.debug("This record is verify now.", extra={"pk": bank_record.pk})
else: elif bank_record.order:
order = bank_record.order order = bank_record.order
order.rollback_stock() order.rollback_stock()
return 'update bank record is done' return 'update bank record is done'
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
from datetime import datetime, timezone as dt_timezone
import jwt
from django.conf import settings
from django.db.models import Q
from django.db.models import Prefetch
from django.utils.dateparse import parse_datetime
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from account.models import ShopModel
from .models import OrderModel, OrderItemModel
TOROB_PUBLIC_KEY = """
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAt6Mu4T0pBORY11W+QeM35UsmLO3vsf+6yKpFDEImFk0=
-----END PUBLIC KEY-----
"""
class TorobTokenError(Exception):
pass
class TorobOrderQuerySerializer(serializers.Serializer):
purchase_timestamp_gt = serializers.CharField(required=True)
limit = serializers.IntegerField(required=True, min_value=1, max_value=1000)
def validate_purchase_timestamp_gt(self, value):
parsed = parse_datetime(value.replace("Z", "+00:00"))
if parsed is None:
raise serializers.ValidationError("purchase_timestamp_gt must be a valid ISO 8601 timestamp.")
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=dt_timezone.utc)
return parsed.astimezone(dt_timezone.utc)
def _utc_iso(value: datetime) -> str:
return value.astimezone(dt_timezone.utc).isoformat().replace("+00:00", "Z")
def _shop_product_url(request, product) -> str:
domain = getattr(settings, "DOMAIN", None) or getattr(settings, "API_DOMAIN", None) or request.get_host()
if domain.startswith("http://") or domain.startswith("https://"):
base = domain.rstrip("/")
else:
base = f"https://{domain}".rstrip("/")
return f"{base}/product/{product.slug}/"
def _get_hostname_from_request(request) -> str:
"""Extract hostname for JWT audience validation.
Returns the full host including port if present, as JWT audience
should match exactly what Torob expects in the token.
"""
return request.get_host()
def _validate_torob_token(request) -> None:
token = request.headers.get("X-Torob-Token")
version = request.headers.get("X-Torob-Token-Version")
if version != "1":
raise TorobTokenError("invalid token version")
if not token:
raise TorobTokenError("missing token")
jwt.decode(
token,
key=TOROB_PUBLIC_KEY,
algorithms=["EdDSA"],
audience=_get_hostname_from_request(request),
)
def _resolve_order_status(order: OrderModel) -> str | None:
if order.status in {"RECEIVED", "POSTED"}:
return "completed"
if order.status == "CANCELED":
return "cancelled"
return None
def _order_shop_enabled(order: OrderModel) -> bool:
shop_ids = set()
for item in order.items.select_related("product__product__shop").all():
shop = getattr(item.product.product, "shop", None)
if shop is not None:
shop_ids.add(shop.id)
if not shop_ids:
return False
return ShopModel.objects.filter(id__in=shop_ids, torob_order_tracking_enabled=True).count() == len(shop_ids)
def _serialize_order(order: OrderModel, request) -> dict:
products = []
for item in order.items.select_related("product__product", "product__product__category").all():
product = item.product.product
products.append(
{
"product_url": _shop_product_url(request, product),
"product_price": int(item.price_after_special_discount() // item.quantity) if item.quantity else int(item.price),
"quantity": int(item.quantity),
}
)
payload = {
"order_id": str(order.id),
"purchase_timestamp": _utc_iso(order.created_at),
"last_updated_timestamp": _utc_iso(order.updated_at or order.created_at),
"torob_clid": order.torob_clid,
"status": _resolve_order_status(order),
"order_value": int(order.final_price or 0),
"phone_number": order.user.phone if order.user else None,
"products": products,
}
return {key: value for key, value in payload.items() if value is not None}
class TorobOrderTrackingView(APIView):
authentication_classes = []
permission_classes = []
def get(self, request):
if not settings.TOROB_ORDER_TRACKING_ENABLED:
return Response({"error": "order tracking is disabled"}, status=status.HTTP_403_FORBIDDEN)
try:
_validate_torob_token(request)
except TorobTokenError as exc:
return Response({"error": str(exc)}, status=status.HTTP_401_UNAUTHORIZED)
except jwt.PyJWTError:
return Response({"error": "invalid token"}, status=status.HTTP_401_UNAUTHORIZED)
serializer = TorobOrderQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
since = serializer.validated_data["purchase_timestamp_gt"]
limit = serializer.validated_data["limit"]
orders = (
OrderModel.objects.select_related("user")
.prefetch_related(
Prefetch(
"items",
queryset=OrderItemModel.objects.select_related("product__product", "product__product__shop"),
)
)
.filter(Q(created_at__gt=since) | Q(updated_at__gt=since))
.order_by("created_at", "id")
)
serialized = []
disabled_match_found = False
for order in orders:
status_value = _resolve_order_status(order)
if not status_value:
continue
if not order.torob_clid:
continue
if not _order_shop_enabled(order):
disabled_match_found = True
continue
serialized.append(_serialize_order(order, request))
if len(serialized) >= limit:
break
if not serialized and disabled_match_found:
return Response({"error": "order tracking access is disabled for this shop"}, status=status.HTTP_403_FORBIDDEN)
return Response({"success": True, "data": serialized}, status=status.HTTP_200_OK)
+2
View File
@@ -2,8 +2,10 @@ from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from .views import * from .views import *
from .torob_api import TorobOrderTrackingView
urlpatterns = [ urlpatterns = [
path('torob/v1/orders', TorobOrderTrackingView.as_view(), name='torob-order-tracking'),
path('all', OrderlistView.as_view(), name='order-list'), path('all', OrderlistView.as_view(), name='order-list'),
path('cart', CartView.as_view()), path('cart', CartView.as_view()),
path('cart/set-address', SetAddressForCartView.as_view()), path('cart/set-address', SetAddressForCartView.as_view()),
+14 -4
View File
@@ -24,7 +24,8 @@ from utils.pagination import StructurePagination
from order.models import OrderModel from order.models import OrderModel
from django.urls import reverse from django.urls import reverse
from account.models import UserAddressModel from account.models import UserAddressModel
import logging
logger = logging.getLogger(__name__)
# try: # try:
# pass # pass
@@ -250,6 +251,13 @@ class PaymentView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
serializer_class = BankTypeSerializer serializer_class = BankTypeSerializer
def _get_torob_clid(self, request):
return (
request.data.get('torob_clid')
or request.COOKIES.get('torob_clid')
or request.headers.get('X-Torob-Clid')
)
@extend_schema( @extend_schema(
description="choices=['BMI', 'SEP', 'ZARINPAL', 'IDPAY', 'ZIBAL', 'BAHAMTA', 'MELLAT', 'PAYV1']", description="choices=['BMI', 'SEP', 'ZARINPAL', 'IDPAY', 'ZIBAL', 'BAHAMTA', 'MELLAT', 'PAYV1']",
tags=['order payment'] tags=['order payment']
@@ -337,6 +345,7 @@ class PaymentView(APIView):
user=request.user, user=request.user,
address=cart.address, address=cart.address,
created_at=timezone.now().date(), created_at=timezone.now().date(),
torob_clid=self._get_torob_clid(request),
discount_code=cart.discount_code, discount_code=cart.discount_code,
special_discount_code=cart.special_discount_code, special_discount_code=cart.special_discount_code,
discount_amount=cart.discount_code_amount, discount_amount=cart.discount_code_amount,
@@ -371,8 +380,7 @@ class PaymentView(APIView):
# Setup payment gateway # Setup payment gateway
user_mobile_number = request.user.phone user_mobile_number = request.user.phone
factory = bankfactories.BankFactory() factory = bankfactories.BankFactory()
bank = factory.create(bank_models.BankType.ZARINPAL)
bank = factory.create(bank_models.BankType.ZIBAL)
bank.set_request(request) bank.set_request(request)
# Use final_price instead of hardcoded amount # Use final_price instead of hardcoded amount
bank.set_amount(cart.final_price) bank.set_amount(cart.final_price)
@@ -533,7 +541,9 @@ class UserOrderInvoiceView(APIView):
from .models import OrderModel from .models import OrderModel
try: try:
order = OrderModel.objects.get(pk=order_id) bank_detail = Bank.objects.get(tracking_code=order_id)
order = bank_detail.order
order_id = order.id
except OrderModel.DoesNotExist: except OrderModel.DoesNotExist:
return Response( return Response(
{'detail': 'سفارش مورد نظر یافت نشد'}, {'detail': 'سفارش مورد نظر یافت نشد'},
+79 -6
View File
@@ -1,7 +1,9 @@
from django.contrib import admin, messages from django.contrib import admin, messages
from django import forms from django import forms
import logging import logging
from django.contrib.postgres.aggregates import StringAgg
from django.db.models import Value
from django.db.models.functions import Coalesce
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# from product.tasks import update_prices # from product.tasks import update_prices
from .models import * from .models import *
@@ -346,13 +348,66 @@ class ProductVariantAdmin(ProductVariantAdminPermission, ModelAdmin, ImportExpor
cache.delete(HOME_CACHE_KEY) cache.delete(HOME_CACHE_KEY)
from unfold.sections import TableSection
class VariantsTableSection(TableSection):
verbose_name = 'تنوع های محصولی'
related_name = "variants"
height = 380
fields = [
'get_name',
"price",
'in_stock',
'sell',
]
# @display(description='برگذار شده', label=True)
# def get_done(self, obj):
# from django.utils import timezone
# now = timezone.localtime()
# today = now.date()
# time = now.time()
# return obj.date > today or (obj.date == today and obj.time > time)
@display(description='نام تنوع')
def get_name(self, obj):
attrs = getattr(obj, "attrs", "")
return f"{obj.product.name} - {attrs}"
def get_queryset(self, request):
qs = super().get_queryset(request)
qs = qs.select_related("product").annotate(
attrs=Coalesce(
StringAgg("product_attributes__value", delimiter=", "),
Value("")
)
)
return qs.only(
"id",
"product__name",
"price",
"in_stock",
"sell",
)
@admin.register(ProductModel) @admin.register(ProductModel)
class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdmin): class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdmin):
list_sections = [VariantsTableSection]
import_form_class = ImportForm import_form_class = ImportForm
export_form_class = ExportForm export_form_class = ExportForm
inlines = [ProductVariantInLine] inlines = [ProductVariantInLine]
readonly_fields = ('slug', 'created_at') readonly_fields = ('slug', 'created_at')
search_fields = ['name', 'description', ] search_fields = ['name', 'description', ]
list_per_page = 10
list_filter = [('category', RelatedDropdownFilter), 'show_in_bot', ('category__parent', RelatedDropdownFilter)] list_filter = [('category', RelatedDropdownFilter), 'show_in_bot', ('category__parent', RelatedDropdownFilter)]
list_filter_submit = True list_filter_submit = True
autocomplete_fields = ['related_products', 'shop', 'category'] autocomplete_fields = ['related_products', 'shop', 'category']
@@ -360,7 +415,7 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
warn_unsaved_form = True warn_unsaved_form = True
# list_per_page = 2 # list_per_page = 2
actions_list = ['redirect_to_learn', 'update_products_price'] actions_list = ['redirect_to_learn', 'update_products_price']
list_display = ['display_image', 'shop__shop_name','display_price', 'view', 'rating', 'category', 'created_at' ,'show_in_website', ] list_display = ['display_image', 'shop__shop_name', 'view', 'rating', 'category', 'created_at' ,'show_in_website', ]
fieldsets = ( fieldsets = (
('فیلد های اصلی', {'fields': ('name', 'description', 'category', 'image', 'related_products','show_in_trends', 'show_in_most_viewed', 'show_in_lot_of_discount', 'show_in_top_seller', 'shop', 'show_in_bot', 'bot_banner'), "classes": ["tab"],}), ('فیلد های اصلی', {'fields': ('name', 'description', 'category', 'image', 'related_products','show_in_trends', 'show_in_most_viewed', 'show_in_lot_of_discount', 'show_in_top_seller', 'shop', 'show_in_bot', 'bot_banner'), "classes": ["tab"],}),
('فیلد های سيو', {'fields': ('meta_description', 'meta_keywords', 'meta_rating', 'slug'), "classes": ["tab"],}), ('فیلد های سيو', {'fields': ('meta_description', 'meta_keywords', 'meta_rating', 'slug'), "classes": ["tab"],}),
@@ -388,10 +443,28 @@ class ProductModelAdmin(ProductAdminPermission, ModelAdmin, ImportExportModelAdm
super().save_model(request, obj, form, change) super().save_model(request, obj, form, change)
cache.delete(HOME_CACHE_KEY) cache.delete(HOME_CACHE_KEY)
def display_price(self, obj): def get_queryset(self, request):
if obj.variants.all().first(): qs = super().get_queryset(request)
return obj.variants.all().first().price
display_price.short_description = 'قیمت تومانی' qs = qs.select_related(
"shop",
"category",
"category__parent",
).only(
"id",
"name",
"description",
"image",
"view",
"rating",
"created_at",
"show_in_bot",
"category",
"shop",
)
return qs
def show_in_website(self, obj): def show_in_website(self, obj):
url = f"https://heymlz.com/product/{obj.slug}" url = f"https://heymlz.com/product/{obj.slug}"
+3
View File
@@ -5,3 +5,6 @@ class ProductConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField' default_auto_field = 'django.db.models.BigAutoField'
name = 'product' name = 'product'
verbose_name = 'محصول' verbose_name = 'محصول'
def ready(self):
from . import signals # noqa: F401
@@ -0,0 +1,31 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("product", "0073_productrating"),
]
operations = [
migrations.AddField(
model_name="productmodel",
name="updated_at",
field=models.DateTimeField(
auto_now=True,
blank=True,
null=True,
verbose_name="زمان آخرین بروزرسانی محصول",
),
),
migrations.AddField(
model_name="productvariant",
name="updated_at",
field=models.DateTimeField(
auto_now=True,
blank=True,
null=True,
verbose_name="زمان آخرین بروزرسانی تنوع",
),
),
]
+7 -2
View File
@@ -199,6 +199,8 @@ class ProductModel(models.Model):
default=5, help_text='امتیاز محصول', verbose_name='متا ریتینگ') default=5, help_text='امتیاز محصول', verbose_name='متا ریتینگ')
created_at = models.DateTimeField( created_at = models.DateTimeField(
auto_now_add=True, verbose_name='زمان ثبت محصول') auto_now_add=True, verbose_name='زمان ثبت محصول')
updated_at = models.DateTimeField(
auto_now=True, null=True, blank=True, verbose_name='زمان آخرین بروزرسانی محصول')
category = models.ForeignKey(SubCategoryModel, null=True, on_delete=models.SET_NULL, category = models.ForeignKey(SubCategoryModel, null=True, on_delete=models.SET_NULL,
related_name='products', verbose_name='دسته بندی محصول') related_name='products', verbose_name='دسته بندی محصول')
related_products = models.ManyToManyField( related_products = models.ManyToManyField(
@@ -410,6 +412,11 @@ class ProductVariant(DirtyFieldsMixin, models.Model):
ShowCaseSlider, verbose_name='دسته بندی پورسانتی', blank=True, null=True, on_delete=models.CASCADE) ShowCaseSlider, verbose_name='دسته بندی پورسانتی', blank=True, null=True, on_delete=models.CASCADE)
created_at = models.DateTimeField( created_at = models.DateTimeField(
auto_now_add=True, verbose_name='زمان ثبت محصول') auto_now_add=True, verbose_name='زمان ثبت محصول')
updated_at = models.DateTimeField(
auto_now=True, null=True, blank=True, verbose_name='زمان آخرین بروزرسانی تنوع')
def __str__(self):
return f"{self.product.name} - {', '.join(str(attr) for attr in self.product_attributes.all())}"
class Meta: class Meta:
verbose_name = 'تنوع محصول' verbose_name = 'تنوع محصول'
@@ -433,8 +440,6 @@ class ProductVariant(DirtyFieldsMixin, models.Model):
name='variant_product_discount_idx'), name='variant_product_discount_idx'),
] ]
def __str__(self):
return f"{self.product.name} - {', '.join(str(attr) for attr in self.product_attributes.all())}"
@property @property
def price_before_discount(self): def price_before_discount(self):
+20
View File
@@ -3,6 +3,7 @@ from rest_framework import serializers
from django.utils import timezone from django.utils import timezone
from datetime import timedelta from datetime import timedelta
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from account.models import User
class DetailSerializer(serializers.ModelSerializer): class DetailSerializer(serializers.ModelSerializer):
@@ -323,12 +324,31 @@ class DynamicProductSerializer(serializers.ModelSerializer):
return serializer.data return serializer.data
class UserCommentSerializer(serializers.ModelSerializer):
"""Lightweight user serializer for comment author info"""
class Meta:
model = User
fields = ['id', 'first_name', 'last_name', 'profile_photo', 'phone']
class CommentSerializer(serializers.ModelSerializer): class CommentSerializer(serializers.ModelSerializer):
user = UserCommentSerializer(read_only=True)
user_rating = serializers.SerializerMethodField()
class Meta: class Meta:
model = CommentModel model = CommentModel
exclude = ('review_status', ) exclude = ('review_status', )
read_only_fields = ('review_status', 'product', 'user') read_only_fields = ('review_status', 'product', 'user')
def get_user_rating(self, obj):
"""
Get user's rating for this product using cached data to avoid N+1 queries.
The cache is provided by the CommentView.get() method.
"""
user_ratings_cache = self.context.get('user_ratings_cache', {})
# O(1) lookup in cache instead of database query
return user_ratings_cache.get(obj.user_id)
class BotProductSerializer(serializers.ModelSerializer): class BotProductSerializer(serializers.ModelSerializer):
class Meta: class Meta:
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from django.conf import settings
from django.db.models.signals import post_save
from django.db import transaction
from django.dispatch import receiver
from .models import ProductModel, ProductVariant
from .tasks import send_torob_product_webhook
@receiver(post_save, sender=ProductModel)
def notify_torob_product_save(sender, instance, **kwargs):
if settings.TOROB_PRODUCT_WEBHOOK_TOKEN:
transaction.on_commit(lambda: send_torob_product_webhook.delay([instance.id]))
@receiver(post_save, sender=ProductVariant)
def notify_torob_variant_save(sender, instance, **kwargs):
if settings.TOROB_PRODUCT_WEBHOOK_TOKEN:
transaction.on_commit(lambda: send_torob_product_webhook.delay([instance.product_id]))
+157 -3
View File
@@ -1,6 +1,82 @@
from celery import shared_task from celery import shared_task
from order.models import OrderItemModel, OrderModel from django.conf import settings
from product.models import DollorModel, ProductVariant from django.db.models import Prefetch
import requests
import logging
import time
from urllib.parse import urlparse
from product.models import DollorModel, ProductVariant, ProductModel
logger = logging.getLogger(__name__)
TOROB_WEBHOOK_BATCH_SIZE = 100
TOROB_WEBHOOK_MIN_INTERVAL_SECONDS = 3.1
TOROB_WEBHOOK_MAX_RETRIES = 3
def _shop_product_url(product: ProductModel) -> str:
domain = getattr(settings, "DOMAIN", None) or getattr(settings, "API_DOMAIN", None) or ""
if domain.startswith("http://") or domain.startswith("https://"):
base = domain.rstrip("/")
else:
base = f"https://{domain}".rstrip("/") if domain else ""
return f"{base}/product/{product.slug}/"
def _variant_page_unique(product: ProductModel, variant: ProductVariant) -> str:
return f"{product.pk}_{variant.pk}"
def _chunks(items, size):
for idx in range(0, len(items), size):
yield items[idx: idx + size]
def _post_webhook_batch(url: str, headers: dict, batch_items: list[dict]) -> bool:
payload = {"items": batch_items}
backoff_seconds = 1.0
for attempt in range(1, TOROB_WEBHOOK_MAX_RETRIES + 1):
try:
response = requests.post(url, json=payload, headers=headers, timeout=10)
if response.status_code < 400:
return True
if response.status_code in (429, 500, 502, 503, 504):
logger.warning(
"Torob webhook transient failure (%s), attempt %s/%s",
response.status_code,
attempt,
TOROB_WEBHOOK_MAX_RETRIES,
)
if attempt < TOROB_WEBHOOK_MAX_RETRIES:
time.sleep(backoff_seconds)
backoff_seconds *= 2
continue
logger.error(
"Torob webhook failed with status %s: %s",
response.status_code,
response.text,
)
return False
except requests.RequestException as exc:
logger.warning(
"Torob webhook request exception on attempt %s/%s: %s",
attempt,
TOROB_WEBHOOK_MAX_RETRIES,
exc,
)
if attempt < TOROB_WEBHOOK_MAX_RETRIES:
time.sleep(backoff_seconds)
backoff_seconds *= 2
continue
return False
return False
@shared_task @shared_task
def update_prices(): def update_prices():
@@ -13,4 +89,82 @@ def update_prices():
products = list(ProductVariant.objects.all()) products = list(ProductVariant.objects.all())
for product in products: for product in products:
product.set_or_update_price(dollor_price=dollor_price) product.set_or_update_price(dollor_price=dollor_price)
ProductVariant.objects.bulk_update(products, ['price'], batch_size=1000) ProductVariant.objects.bulk_update(products, ['price'], batch_size=1000)
@shared_task
def send_torob_product_webhook(product_ids):
if not settings.TOROB_PRODUCT_WEBHOOK_TOKEN:
return {"sent": 0, "reason": "missing token"}
products = (
ProductModel.objects.filter(id__in=product_ids)
.select_related("category", "shop")
.prefetch_related(
Prefetch(
"variants",
queryset=ProductVariant.objects.prefetch_related(
"images",
"product_attributes__attribute_type"
).order_by("-discount", "price", "-created_at"),
)
)
)
url = settings.TOROB_PRODUCT_WEBHOOK_URL
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {settings.TOROB_PRODUCT_WEBHOOK_TOKEN}",
}
items = []
hosts = set()
for product in products:
if not product.slug:
continue
page_url = _shop_product_url(product)
parsed = urlparse(page_url)
if not (parsed.scheme in {"http", "https"} and parsed.netloc):
logger.warning("Skipping product %s due to invalid page_url: %s", product.pk, page_url)
continue
hosts.add(parsed.netloc.lower())
variants = list(product.variants.all())
if not variants:
continue
for variant in variants:
# Validate variant has images before sending to Torob
# Per spec: image_links is required, so skip variants without images
images = list(variant.images.all())
has_product_image = bool(product.image)
has_variant_images = bool(images)
if not (has_product_image or has_variant_images):
logger.debug(f"Skipping variant {variant.pk} for product {product.pk} - no images available")
continue
items.append(
{
"page_url": page_url,
"page_unique": _variant_page_unique(product, variant),
}
)
if not items:
return {"sent": 0, "reason": "no items"}
if len(hosts) > 1:
logger.error("Torob webhook items contain mixed domains: %s", sorted(hosts))
return {"sent": 0, "reason": "mixed domains"}
sent = 0
for batch in _chunks(items, TOROB_WEBHOOK_BATCH_SIZE):
ok = _post_webhook_batch(url=url, headers=headers, batch_items=batch)
if ok:
sent += len(batch)
time.sleep(TOROB_WEBHOOK_MIN_INTERVAL_SECONDS)
return {"sent": sent, "total": len(items)}
+486
View File
@@ -0,0 +1,486 @@
from __future__ import annotations
import logging
import math
from urllib.parse import urlparse
import jwt
from django.conf import settings
from django.core.paginator import EmptyPage, Paginator
from django.db.models import F, Prefetch
from django.db.models.functions import Coalesce
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import ProductModel, ProductVariant
logger = logging.getLogger(__name__)
TOROB_API_VERSION = "torob_api_v3"
TOROB_PAGE_SIZE = 100
TOROB_PUBLIC_KEY = """
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAt6Mu4T0pBORY11W+QeM35UsmLO3vsf+6yKpFDEImFk0=
-----END PUBLIC KEY-----
"""
class TorobTokenError(Exception):
pass
class TorobProductsRequestSerializer(serializers.Serializer):
page_urls = serializers.ListField(
child=serializers.URLField(),
min_length=1,
required=False,
)
page_uniques = serializers.ListField(
child=serializers.CharField(max_length=200),
min_length=1,
required=False,
)
page = serializers.IntegerField(min_value=1, required=False)
sort = serializers.ChoiceField(
choices=("date_added_desc", "date_updated_desc"),
required=False,
)
def validate(self, attrs):
modes = [name for name in ("page_urls", "page_uniques", "page") if name in attrs]
if len(modes) != 1:
raise serializers.ValidationError(
"invalid request body"
)
if "page" in attrs and "sort" not in attrs:
raise serializers.ValidationError({"sort": "sort parameter is not provided"})
if "page" not in attrs and "sort" in attrs:
raise serializers.ValidationError({"sort": "sort parameter is invalid"})
return attrs
def _normalize_url(value: str) -> str:
parsed = urlparse(value)
path = parsed.path.rstrip("/")
return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{path}"
def _extract_slug_from_url(value: str) -> str | None:
path = urlparse(value).path.strip("/")
if not path:
return None
return path.split("/")[-1]
def _variant_page_unique(product: ProductModel, variant: ProductVariant) -> str:
return f"{product.pk}_{variant.pk}"
def _parse_page_unique(value: str) -> tuple[str | None, str | None]:
text = str(value).strip()
if not text:
return None, None
if "_" in text:
product_id, variant_id = text.split("_", 1)
return product_id.strip() or None, variant_id.strip() or None
return text, None
def _get_hostname_from_request(request) -> str:
"""Extract hostname for JWT audience validation.
Returns the full host including port if present, as JWT audience
should match exactly what Torob expects in the token.
"""
return request.get_host()
def _absolute_url(request, value: str) -> str:
if value.startswith("http://") or value.startswith("https://"):
return value
return request.build_absolute_uri(value)
def _shop_product_url(request, product: ProductModel) -> str:
domain = getattr(settings, "DOMAIN", None) or getattr(settings, "API_DOMAIN", None) or request.get_host()
if domain.startswith("http://") or domain.startswith("https://"):
base = domain.rstrip("/")
else:
base = f"https://{domain}".rstrip("/")
url = f"{base}/product/{product.slug}/"
# Per spec: page_url max 1500 chars
return url[:1500]
def _truncate_text(value: str | None, max_length: int) -> str | None:
if not value:
return None
return str(value)[:max_length]
def _variant_spec(variant: ProductVariant | None) -> dict:
if not variant:
return {}
spec: dict = {}
for attribute in variant.product_attributes.all():
attribute_type = getattr(attribute, "attribute_type", None)
key = getattr(attribute_type, "name", None) or "attribute"
if attribute.value is not None:
spec[key] = attribute.value
if variant.color:
spec.setdefault("color", variant.color)
if variant.in_stock is not None:
spec.setdefault("in_stock", variant.in_stock)
return spec
def _product_image_links(request, product: ProductModel, variant: ProductVariant) -> list[str]:
links: list[str] = []
max_image_url_length = 1000 # Per spec: image_links items max 1000 chars
def add_image_url(image_url: str | None) -> None:
if not image_url:
return
absolute = _absolute_url(request, image_url)
# Truncate to max 1000 chars per spec and avoid duplicates
if len(absolute) <= max_image_url_length and absolute not in links:
links.append(absolute)
if product.image:
add_image_url(product.image.url)
for image in variant.images.all():
if getattr(image, "image", None):
add_image_url(image.image.url)
return links
def _variant_date_added(product: ProductModel, variant: ProductVariant) -> str:
if variant.created_at:
return variant.created_at.isoformat()
return product.created_at.isoformat()
def _variant_date_updated(product: ProductModel, variant: ProductVariant) -> str:
if getattr(variant, "updated_at", None):
return variant.updated_at.isoformat()
if getattr(product, "updated_at", None):
return product.updated_at.isoformat()
return _variant_date_added(product, variant)
def _variant_sort_key(variant: ProductVariant) -> tuple:
return (-variant.discount, variant.price or 0, -(variant.created_at.timestamp() if variant.created_at else 0))
def _serialize_variant(request, product: ProductModel, variant: ProductVariant) -> dict:
# Robust price and availability check
has_valid_price = variant.price is not None and variant.price > 0
# Availability is True if:
# 1. Has valid price AND
# 2. Either stock is not tracked (None) OR stock > 0
if has_valid_price:
if variant.in_stock is None:
# Stock not tracked - assume available if has price
availability = True
else:
# Stock is tracked - check if > 0
availability = variant.in_stock > 0
else:
availability = False
if availability:
old_price = int(variant.price) if variant.discount else None
price_after_discount = variant.price_after_discount
current_price = int(round(price_after_discount)) if price_after_discount else int(variant.price)
else:
old_price = None
current_price = 0
payload = {
"page_unique": _variant_page_unique(product, variant),
"page_url": _shop_product_url(request, product),
"product_group_id": str(product.pk),
"title": _truncate_text(product.name, 500),
"subtitle": _truncate_text(product.meta_description, 500),
"current_price": current_price,
"availability": availability,
"category_name": _truncate_text(product.category.name if product.category else None, 200),
"image_links": _product_image_links(request, product, variant),
"spec": _variant_spec(variant),
"guarantee": None,
"short_desc": _truncate_text(product.description, 500),
"date_added": _variant_date_added(product, variant),
"date_updated": _variant_date_updated(product, variant),
"seller_name": product.shop.shop_name if product.shop else None,
"seller_city": _truncate_text(product.shop.city if product.shop else None, 200),
}
if old_price is not None and old_price > current_price:
payload["old_price"] = old_price
return payload
def _validate_torob_token(request) -> None:
token = request.headers.get("X-Torob-Token")
version = request.headers.get("X-Torob-Token-Version")
if version != "1":
logger.warning(f"Invalid token version: {version}")
raise TorobTokenError("invalid token version")
if not token:
logger.warning("Missing X-Torob-Token header")
raise TorobTokenError("missing token")
try:
jwt.decode(
token,
key=TOROB_PUBLIC_KEY,
algorithms=["EdDSA"],
audience=_get_hostname_from_request(request),
)
logger.debug("Token validated successfully")
except jwt.ExpiredSignatureError:
logger.warning("Token has expired")
raise
except jwt.InvalidAudienceError:
logger.warning(f"Audience mismatch for request from {request.get_host()}")
raise
def _extract_error_message(errors: dict | list | str) -> str:
"""Extract the first error message from serializer errors."""
if isinstance(errors, dict):
# Get first error from dict
first_key = next(iter(errors.keys()), None)
if first_key:
first_value = errors[first_key]
if isinstance(first_value, list) and first_value:
return str(first_value[0])
return str(first_value)
return "Invalid request"
elif isinstance(errors, list) and errors:
return str(errors[0])
return str(errors) if errors else "Invalid request"
class TorobProductSyncView(APIView):
authentication_classes = []
permission_classes = []
def post(self, request):
# Validate Content-Type header
content_type = request.META.get('CONTENT_TYPE', '').split(';')[0].strip()
if content_type != 'application/json':
logger.warning(f"Invalid Content-Type: {content_type}")
return Response(
{"error": "Content-Type must be application/json"},
status=status.HTTP_400_BAD_REQUEST
)
try:
_validate_torob_token(request)
except TorobTokenError as exc:
return Response({"error": str(exc)}, status=status.HTTP_401_UNAUTHORIZED)
except jwt.PyJWTError as exc:
logger.warning(f"JWT validation failed: {exc}")
return Response({"error": "invalid token"}, status=status.HTTP_401_UNAUTHORIZED)
serializer = TorobProductsRequestSerializer(data=request.data)
if not serializer.is_valid():
error_message = _extract_error_message(serializer.errors)
logger.warning(f"Request validation failed: {error_message}")
return Response({"error": error_message}, status=status.HTTP_400_BAD_REQUEST)
data = serializer.validated_data
products_qs = ProductModel.objects.select_related(
"category",
"category__parent",
"shop",
).filter(slug__isnull=False).exclude(slug="")
base_qs = products_qs.prefetch_related(
Prefetch(
"variants",
queryset=ProductVariant.objects.select_related("product").prefetch_related(
"product_attributes__attribute_type",
"images",
).order_by("-created_at", "-pk"),
)
)
if "page_urls" in data:
requested_urls = data["page_urls"]
requested_slugs = [
slug for slug in (_extract_slug_from_url(url) for url in requested_urls)
if slug
]
products = list(base_qs.filter(slug__in=requested_slugs))
product_by_slug = {product.slug: product for product in products}
product_by_url = {
_normalize_url(_shop_product_url(request, product)): product
for product in products
}
ordered_products = []
for url in requested_urls:
slug = _extract_slug_from_url(url)
normalized_url = _normalize_url(url)
product = product_by_slug.get(slug) if slug else None
if product is None:
product = product_by_url.get(normalized_url)
if product is not None and product not in ordered_products:
ordered_products.append(product)
serialized_products = []
for product in ordered_products:
variants = list(product.variants.all())
if not variants:
continue
variants.sort(key=_variant_sort_key)
for variant in variants:
image_links = _product_image_links(request, product, variant)
# Skip variants without images as per spec requirement
if not image_links:
continue
serialized_products.append(_serialize_variant(request, product, variant))
return Response(
{
"api_version": TOROB_API_VERSION,
"current_page": 1,
"total": len(serialized_products),
"max_pages": max(1, math.ceil(len(serialized_products) / TOROB_PAGE_SIZE)),
"products": serialized_products,
},
status=status.HTTP_200_OK,
)
if "page_uniques" in data:
requested_uniques = [str(unique) for unique in data["page_uniques"]]
parsed_uniques = [_parse_page_unique(unique) for unique in requested_uniques]
product_ids = {product_id for product_id, _ in parsed_uniques if product_id}
variant_ids = {variant_id for _, variant_id in parsed_uniques if variant_id}
products = list(base_qs.filter(pk__in=product_ids))
product_by_id = {str(product.pk): product for product in products}
variant_map: dict[str, dict[str, ProductVariant]] = {}
for product in products:
variant_map[str(product.pk)] = {str(variant.pk): variant for variant in product.variants.all()}
serialized_products = []
seen = set()
for product_id, variant_id in parsed_uniques:
if not product_id:
continue
product = product_by_id.get(product_id)
if product is None:
continue
if variant_id:
variant = variant_map.get(product_id, {}).get(variant_id)
if not variant:
continue
image_links = _product_image_links(request, product, variant)
# Skip variants without images
if not image_links:
continue
page_unique = _variant_page_unique(product, variant)
if page_unique in seen:
continue
seen.add(page_unique)
serialized_products.append(_serialize_variant(request, product, variant))
continue
variants = list(product.variants.all())
if not variants:
continue
variants.sort(key=_variant_sort_key)
for variant in variants:
image_links = _product_image_links(request, product, variant)
# Skip variants without images
if not image_links:
continue
page_unique = _variant_page_unique(product, variant)
if page_unique in seen:
continue
seen.add(page_unique)
serialized_products.append(_serialize_variant(request, product, variant))
return Response(
{
"api_version": TOROB_API_VERSION,
"current_page": 1,
"total": len(serialized_products),
"max_pages": max(1, math.ceil(len(serialized_products) / TOROB_PAGE_SIZE)),
"products": serialized_products,
},
status=status.HTTP_200_OK,
)
sort = data["sort"]
page_number = data["page"]
variants_qs = (
ProductVariant.objects.select_related(
"product",
"product__category",
"product__category__parent",
"product__shop",
)
.prefetch_related("product_attributes__attribute_type", "images")
.filter(product__slug__isnull=False)
.exclude(product__slug="")
.annotate(
product_created_at=F("product__created_at"),
variant_updated_sort=Coalesce("updated_at", "created_at"),
)
)
if sort == "date_updated_desc":
variants_qs = variants_qs.order_by("-variant_updated_sort", "-pk")
else:
variants_qs = variants_qs.order_by("-created_at", "-pk")
paginator = Paginator(variants_qs, TOROB_PAGE_SIZE)
total = paginator.count
max_pages = max(1, math.ceil(total / TOROB_PAGE_SIZE))
try:
page_obj = paginator.page(page_number)
page_variants = list(page_obj.object_list)
except EmptyPage:
page_variants = []
serialized_products = [
_serialize_variant(request, variant.product, variant)
for variant in page_variants
]
return Response(
{
"api_version": TOROB_API_VERSION,
"current_page": page_number,
"total": total,
"max_pages": max_pages,
"products": serialized_products,
},
status=status.HTTP_200_OK,
)
+23 -1
View File
@@ -590,8 +590,30 @@ class CommentView(APIView):
).select_related('user') ).select_related('user')
paginator = self.pagination_class() paginator = self.pagination_class()
paginated_comments = paginator.paginate_queryset(comments, request) paginated_comments = paginator.paginate_queryset(comments, request)
# OPTIMIZATION: Fetch all user ratings in a single query
# Get user IDs from paginated comments to only fetch ratings for displayed comments
user_ids = [comment.user_id for comment in paginated_comments]
# Single query to get all ratings for these users on this product
user_ratings = ProductRating.objects.filter(
product=product,
user_id__in=user_ids
).values_list('user_id', 'rating')
# Build cache dictionary for O(1) lookups in serializer
user_ratings_cache = {user_id: rating for user_id, rating in user_ratings}
# Pass cache to serializer context to avoid N queries
comments_ser = self.serializer_class( comments_ser = self.serializer_class(
instance=paginated_comments, many=True) instance=paginated_comments,
many=True,
context={
'request': request,
'user_ratings_cache': user_ratings_cache,
'product': product
}
)
return paginator.get_paginated_response(comments_ser.data) return paginator.get_paginated_response(comments_ser.data)
def post(self, request, slug): def post(self, request, slug):
+1 -1
View File
@@ -49,7 +49,7 @@ class TicketSerializer(serializers.ModelSerializer):
ticket_category = serializers.SerializerMethodField() ticket_category = serializers.SerializerMethodField()
messages = MessageAttachmentSerializer(many=True, read_only=True) messages = MessageAttachmentSerializer(many=True, read_only=True)
message = MessageForTicketSerializer(write_only=True) message = MessageForTicketSerializer(write_only=True)
order_id = serializers.PrimaryKeyRelatedField(queryset=OrderModel.objects.all(), write_only=True, source='order') order_id = serializers.PrimaryKeyRelatedField(queryset=OrderModel.objects.all(), write_only=True, source='order', required=False)
order = OrderListSerializer(read_only=True) order = OrderListSerializer(read_only=True)
class Meta: class Meta:
model = Ticket model = Ticket