179 lines
6.0 KiB
Python
179 lines
6.0 KiB
Python
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) |