This commit is contained in:
Parsa Nazer
2026-05-18 14:26:00 +03:30
parent ca478d71e1
commit 337e0723a8
15 changed files with 940 additions and 4 deletions
@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('order', '0045_alter_ordermodel_created_at'),
]
operations = [
migrations.AddField(
model_name='ordermodel',
name='updated_at',
field=models.DateTimeField(auto_now=True, verbose_name='تاریخ آخرین بروزرسانی'),
),
migrations.AddField(
model_name='ordermodel',
name='torob_clid',
field=models.CharField(blank=True, db_index=True, max_length=128, null=True, verbose_name='شناسه Torob'),
),
]
+2
View File
@@ -173,7 +173,9 @@ class OrderModel(models.Model):
related_name='orders', null=True, verbose_name='ادرس')
created_at = models.DateTimeField(
auto_now_add=True, verbose_name="تاریخ ثبت سفارش")
updated_at = models.DateTimeField(auto_now=True, 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(
DiscountCode, on_delete=models.PROTECT, null=True, blank=True, verbose_name="کدتخفیف")
special_discount_code = models.ForeignKey(
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
from datetime import datetime, timezone as dt_timezone
import jwt
from django.conf import settings
from django.db.models import Q
from django.db.models import Prefetch
from django.utils.dateparse import parse_datetime
from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from account.models import ShopModel
from .models import OrderModel, OrderItemModel
TOROB_PUBLIC_KEY = """
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAt6Mu4T0pBORY11W+QeM35UsmLO3vsf+6yKpFDEImFk0=
-----END PUBLIC KEY-----
"""
class TorobTokenError(Exception):
pass
class TorobOrderQuerySerializer(serializers.Serializer):
purchase_timestamp_gt = serializers.CharField(required=True)
limit = serializers.IntegerField(required=True, min_value=1, max_value=1000)
def validate_purchase_timestamp_gt(self, value):
parsed = parse_datetime(value.replace("Z", "+00:00"))
if parsed is None:
raise serializers.ValidationError("purchase_timestamp_gt must be a valid ISO 8601 timestamp.")
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=dt_timezone.utc)
return parsed.astimezone(dt_timezone.utc)
def _utc_iso(value: datetime) -> str:
return value.astimezone(dt_timezone.utc).isoformat().replace("+00:00", "Z")
def _shop_product_url(request, product) -> str:
domain = getattr(settings, "DOMAIN", None) or getattr(settings, "API_DOMAIN", None) or request.get_host()
if domain.startswith("http://") or domain.startswith("https://"):
base = domain.rstrip("/")
else:
base = f"https://{domain}".rstrip("/")
return f"{base}/product/{product.slug}/"
def _get_hostname_from_request(request) -> str:
"""Extract hostname for JWT audience validation.
Returns the full host including port if present, as JWT audience
should match exactly what Torob expects in the token.
"""
return request.get_host()
def _validate_torob_token(request) -> None:
token = request.headers.get("X-Torob-Token")
version = request.headers.get("X-Torob-Token-Version")
if version != "1":
raise TorobTokenError("invalid token version")
if not token:
raise TorobTokenError("missing token")
jwt.decode(
token,
key=TOROB_PUBLIC_KEY,
algorithms=["EdDSA"],
audience=_get_hostname_from_request(request),
)
def _resolve_order_status(order: OrderModel) -> str | None:
if order.status in {"RECEIVED", "POSTED"}:
return "completed"
if order.status == "CANCELED":
return "cancelled"
return None
def _order_shop_enabled(order: OrderModel) -> bool:
shop_ids = set()
for item in order.items.select_related("product__product__shop").all():
shop = getattr(item.product.product, "shop", None)
if shop is not None:
shop_ids.add(shop.id)
if not shop_ids:
return False
return ShopModel.objects.filter(id__in=shop_ids, torob_order_tracking_enabled=True).count() == len(shop_ids)
def _serialize_order(order: OrderModel, request) -> dict:
products = []
for item in order.items.select_related("product__product", "product__product__category").all():
product = item.product.product
products.append(
{
"product_url": _shop_product_url(request, product),
"product_price": int(item.price_after_special_discount() // item.quantity) if item.quantity else int(item.price),
"quantity": int(item.quantity),
}
)
payload = {
"order_id": str(order.id),
"purchase_timestamp": _utc_iso(order.created_at),
"last_updated_timestamp": _utc_iso(order.updated_at or order.created_at),
"torob_clid": order.torob_clid,
"status": _resolve_order_status(order),
"order_value": int(order.final_price or 0),
"phone_number": order.user.phone if order.user else None,
"products": products,
}
return {key: value for key, value in payload.items() if value is not None}
class TorobOrderTrackingView(APIView):
authentication_classes = []
permission_classes = []
def get(self, request):
if not settings.TOROB_ORDER_TRACKING_ENABLED:
return Response({"error": "order tracking is disabled"}, status=status.HTTP_403_FORBIDDEN)
try:
_validate_torob_token(request)
except TorobTokenError as exc:
return Response({"error": str(exc)}, status=status.HTTP_401_UNAUTHORIZED)
except jwt.PyJWTError:
return Response({"error": "invalid token"}, status=status.HTTP_401_UNAUTHORIZED)
serializer = TorobOrderQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
since = serializer.validated_data["purchase_timestamp_gt"]
limit = serializer.validated_data["limit"]
orders = (
OrderModel.objects.select_related("user")
.prefetch_related(
Prefetch(
"items",
queryset=OrderItemModel.objects.select_related("product__product", "product__product__shop"),
)
)
.filter(Q(created_at__gt=since) | Q(updated_at__gt=since))
.order_by("created_at", "id")
)
serialized = []
disabled_match_found = False
for order in orders:
status_value = _resolve_order_status(order)
if not status_value:
continue
if not order.torob_clid:
continue
if not _order_shop_enabled(order):
disabled_match_found = True
continue
serialized.append(_serialize_order(order, request))
if len(serialized) >= limit:
break
if not serialized and disabled_match_found:
return Response({"error": "order tracking access is disabled for this shop"}, status=status.HTTP_403_FORBIDDEN)
return Response({"success": True, "data": serialized}, status=status.HTTP_200_OK)
+2
View File
@@ -2,8 +2,10 @@ from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include
from .views import *
from .torob_api import TorobOrderTrackingView
urlpatterns = [
path('torob/v1/orders', TorobOrderTrackingView.as_view(), name='torob-order-tracking'),
path('all', OrderlistView.as_view(), name='order-list'),
path('cart', CartView.as_view()),
path('cart/set-address', SetAddressForCartView.as_view()),
+8
View File
@@ -251,6 +251,13 @@ class PaymentView(APIView):
permission_classes = [IsAuthenticated]
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(
description="choices=['BMI', 'SEP', 'ZARINPAL', 'IDPAY', 'ZIBAL', 'BAHAMTA', 'MELLAT', 'PAYV1']",
tags=['order payment']
@@ -338,6 +345,7 @@ class PaymentView(APIView):
user=request.user,
address=cart.address,
created_at=timezone.now().date(),
torob_clid=self._get_torob_clid(request),
discount_code=cart.discount_code,
special_discount_code=cart.special_discount_code,
discount_amount=cart.discount_code_amount,