torob
This commit is contained in:
@@ -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='ادرس')
|
||||
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(
|
||||
|
||||
@@ -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.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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user