torob
This commit is contained in:
@@ -5,3 +5,6 @@ class ProductConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'product'
|
||||
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='متا ریتینگ')
|
||||
created_at = models.DateTimeField(
|
||||
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,
|
||||
related_name='products', verbose_name='دسته بندی محصول')
|
||||
related_products = models.ManyToManyField(
|
||||
@@ -410,6 +412,8 @@ class ProductVariant(DirtyFieldsMixin, models.Model):
|
||||
ShowCaseSlider, verbose_name='دسته بندی پورسانتی', blank=True, null=True, on_delete=models.CASCADE)
|
||||
created_at = models.DateTimeField(
|
||||
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())}"
|
||||
|
||||
@@ -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 order.models import OrderItemModel, OrderModel
|
||||
from product.models import DollorModel, ProductVariant
|
||||
from django.conf import settings
|
||||
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
|
||||
def update_prices():
|
||||
@@ -13,4 +89,82 @@ def update_prices():
|
||||
products = list(ProductVariant.objects.all())
|
||||
for product in products:
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user