from django.db import IntegrityError, transaction from django.shortcuts import get_object_or_404 from django.utils import timezone from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from .models import DiscountCode, Redemption from .serializers import ( RedeemRequestSerializer, RedeemResponseSerializer, ValidateResponseSerializer, ) def _is_usable(discount: DiscountCode) -> bool: return bool( discount.redemptions.count() < discount.max_uses and (discount.expires_at is None or discount.expires_at >= timezone.now()) ) class ValidateDiscountView(APIView): """GET /api/discount/validate?code= — اعتبارسنجی کد پیش از خرید""" @extend_schema( parameters=[OpenApiParameter('code', OpenApiTypes.STR, required=True)], responses=ValidateResponseSerializer, tags=['Discount (discount)'], ) def get(self, request): code = request.query_params.get('code', '') discount = DiscountCode.objects.filter(code=code).first() if not discount or not _is_usable(discount): return Response(ValidateResponseSerializer({'valid': False}).data) data = {'valid': True, 'code': discount.code, 'type': discount.type, 'value': discount.value} return Response(ValidateResponseSerializer(data).data) class RedeemDiscountView(APIView): """ POST /api/discount/redeem — ثبت مصرف کد هنگام خرید قبلاً فقط ValidateDiscountView چک max_uses/expires_at رو انجام می‌داد؛ خود Redeem هیچ‌کدوم از این محدودیت‌ها رو دوباره چک نمی‌کرد و بین validate و redeem هم race condition ممکن بود (دو درخواست هم‌زمان هر دو از سد شمارش رد بشن). اینجا: - همون شرط‌های usable بودن (max_uses/expires_at) دوباره در لحظه‌ی redeem چک می‌شه، نه فقط در validate. - با select_for_update روی DiscountCode، شمارش و ساخت Redemption در یک تراکنش اتمیک و با قفل ردیف انجام می‌شه تا از over-redemption هم‌زمان جلوگیری بشه. - هر کاربر برای یک کد، حداکثر یک‌بار می‌تونه redeem کنه (unique constraint روی discount_code+user_id در سطح مدل/migration). طبق سند، این اپ هیچ محاسبه‌ی مبلغی رو دوباره خودش «ذخیره» نمی‌کنه؛ اما برای پر کردن discountAmount در پاسخ، باید مبلغ سفارش رو از یک جایی بخونه. چون هیچ اپ دیگه‌ای به این اپ ارجاع نمی‌ده (نه برعکس)، اینجا فقط یک خواندن با id از payment.models.Order انجام می‌شه — دقیقاً همون الگویی که progress/views.py برای خوندن نام کاربر از accounts.models.User استفاده کرده (import مستقیم + فیلتر با id، نه FK واقعی و نه join). """ permission_classes = [IsAuthenticated] @extend_schema(request=RedeemRequestSerializer, responses=RedeemResponseSerializer, tags=['Discount (discount)']) def post(self, request): serializer = RedeemRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) v = serializer.validated_data order_id = int(v['orderId']) from payment.models import Order # ⚠️ اصلاح امنیتی: قبلاً مالکیتِ سفارش چک نمی‌شد، پس هر کاربر # لاگین‌کرده‌ای می‌تونست با حدس‌زدن یک orderId، کد تخفیف رو روی # سفارشِ کاربر دیگری اعمال کنه. حالا سفارش باید متعلق به همون # کاربر احرازهویت‌شده باشه. order = get_object_or_404(Order, pk=order_id, user_id=request.user.id) try: with transaction.atomic(): discount = get_object_or_404( DiscountCode.objects.select_for_update(), code=v['code'] ) if not _is_usable(discount): raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفاده‌اش پر شده.'}) if discount.redemptions.filter(user_id=request.user.id).exists(): raise ValidationError({'code': 'شما قبلاً این کد را استفاده کرده‌اید.'}) # فقط سفارش‌های در انتظار پرداخت می‌تونن تخفیف بگیرن؛ سفارش # پرداخت‌شده/ناموفق نباید عقب‌گرد تغییر کنه. if order.status != 'pending': raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'}) order_locked = Order.objects.select_for_update().get(pk=order.id) if discount.type == 'percent': discount_amount = order_locked.amount * discount.value // 100 else: discount_amount = min(discount.value, order_locked.amount) Redemption.objects.create( discount_code=discount, user_id=request.user.id, order_id=order_id, ) # ⚠️ اصلاح: قبلاً discountAmount فقط محاسبه و در پاسخ # برگردونده می‌شد ولی هیچ‌وقت روی خودِ Order اعمال نمی‌شد، # یعنی جریان واقعی پرداخت مبلغ تخفیف‌نخورده رو دریافت می‌کرد. # حالا مبلغ سفارش واقعاً کم می‌شه تا مرحله‌ی پرداخت همین # مبلغ تخفیف‌خورده رو ببینه. order_locked.amount = max(order_locked.amount - discount_amount, 0) order_locked.save(update_fields=['amount']) except IntegrityError: # fallback در برابر race condition روی unique constraint، حتی # اگه به هر دلیلی قفل select_for_update رد بشه. raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفاده‌اش پر شده.'}) data = {'success': True, 'discountAmount': discount_amount} return Response(RedeemResponseSerializer(data).data)