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 ( RedeemOfferCodeRequestSerializer, RedeemRequestSerializer, RedeemResponseSerializer, UserOfferResponseSerializer, ValidateResponseSerializer, ) from .services import redeem_offer_code 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= — اعتبارسنجی کد پیش از خرید""" # این ویو مثل RedeemDiscountView به IsAuthenticated نیاز دارد؛ در غیر این # صورت کلاینت بدون احراز هویت می‌تواند کدهای تخفیف را probe کند. permission_classes = [IsAuthenticated] @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 — ثبت مصرف کد هنگام خرید شرط‌های usable بودن (max_uses/expires_at) دوباره در لحظه‌ی redeem زیر select_for_update چک می‌شود تا از over-redemption هم‌زمان جلوگیری شود. هر کاربر برای یک کد حداکثر یک‌بار می‌تواند redeem کند (قید یکتایی مدل). discountAmount با یک خواندنِ مستقیم از payment.models.Order پر می‌شود (بدون FK واقعی، مطابق قرارداد پروژه برای ارجاع بین اپ‌ها). """ 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 # سفارش باید متعلق به همان کاربر احرازهویت‌شده باشد (جلوگیری از اعمال تخفیف روی سفارش دیگران). 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': 'شما قبلاً این کد را استفاده کرده‌اید.'}) # سفارش زیر قفل خوانده می‌شود و چک pending بودن *بعد* از قفل روی همان # ردیف انجام می‌شود، تا با یک webhook هم‌زمان که سفارش را paid کرده race نکند. order_locked = Order.objects.select_for_update().get(pk=order.id) if order_locked.status != 'pending': raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'}) 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, ) # مبلغ سفارش واقعاً کم می‌شود تا مرحله‌ی پرداخت مبلغ تخفیف‌خورده را ببیند. order_locked.amount = max(order_locked.amount - discount_amount, 0) order_locked.save(update_fields=['amount']) except IntegrityError: # fallback برای رقابت هم‌زمان روی قید یکتایی، حتی اگر قفل select_for_update رد شود. raise ValidationError({'code': 'این کد دیگر معتبر نیست یا سقف استفاده‌اش پر شده.'}) data = {'success': True, 'discountAmount': discount_amount} return Response(RedeemResponseSerializer(data).data) class RedeemOfferCodeView(APIView): """ POST /api/discount/offers/redeem — اعمال آفر (اشتراک رایگان/محدود) با کد، برخلاف auto_assign_on_signup که خودکارِ هنگام ثبت‌نام‌ه، این برای زمانی‌ه که سینیور بخواد یک آفر رو بعداً (نه فقط هنگام ثبت‌نام) با یک کد به کاربر بده — مثلاً یک کمپین تبلیغاتی. """ permission_classes = [IsAuthenticated] @extend_schema( request=RedeemOfferCodeRequestSerializer, responses=UserOfferResponseSerializer, tags=['Discount (discount)'], ) def post(self, request): serializer = RedeemOfferCodeRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) user_offer = redeem_offer_code(user_id=request.user.id, code=serializer.validated_data['code']) data = { 'id': user_offer.id, 'offerName': user_offer.offer.name, 'kind': user_offer.offer.kind, 'status': user_offer.status, 'grantedAt': user_offer.granted_at, 'expiresAt': user_offer.expires_at, } return Response(UserOfferResponseSerializer(data).data) class MyOffersView(APIView): """GET /api/discount/offers/mine — لیست آفرهایی که کاربر گرفته (خودکار یا با کد)""" permission_classes = [IsAuthenticated] @extend_schema(responses=UserOfferResponseSerializer(many=True), tags=['Discount (discount)']) def get(self, request): from .models import UserOffer user_offers = UserOffer.objects.filter(user_id=request.user.id).select_related('offer') data = [ { 'id': uo.id, 'offerName': uo.offer.name, 'kind': uo.offer.kind, 'status': uo.status, 'grantedAt': uo.granted_at, 'expiresAt': uo.expires_at, } for uo in user_offers ] return Response(UserOfferResponseSerializer(data, many=True).data)