torob
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user