187 lines
9.0 KiB
Python
187 lines
9.0 KiB
Python
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= — اعتبارسنجی کد پیش از خرید"""
|
|
|
|
@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)
|
|
|
|
|
|
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)
|