Merge pull request #1 from Byeto-Company/copilot/vscode-mpaygw0b-8h0f
merge changes
This commit is contained in:
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')),
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,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
@@ -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': 'سفارش مورد نظر یافت نشد'},
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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="زمان آخرین بروزرسانی تنوع",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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)}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user