The Swagger header was a list of features. That is what the project does, not what a caller needs. It now opens with the three things anyone integrating against this API asks first: how do I get a token, what does an error look like, and when do I get rate limited -- plus the field-naming and date conventions, and a note that the schema is the contract and clients should be generated from it rather than written from memory. Swagger UI and Redoc both render Markdown, so it is structured rather than one long paragraph, which also fixes how badly the old mixed Persian/Latin line wrapped right-to-left. Two defects surfaced while checking the rendered schema: - /api/health was absent from the schema entirely. The trailing-slash alias was excluded with extend_schema(exclude=True)(HealthView), but that decorator mutates the class, and both routes pointed at the same class, so the canonical route disappeared with it. A subclass now carries the exclusion, and the endpoint is back, exactly once. Endpoint count is 56, not 55. - One endpoint was tagged 'محتوا (content)' while every other content endpoint used 'Curriculum (content)', which split the group into two sections. Tags are now declared with descriptions and ordered by the path a real user takes, rather than alphabetically. Comment pass over the codebase, keeping the substance and dropping the noise: the "⚠️ اضافه شده" prefixes dated comments against a moment no current reader shares; "فاز ۲ / بخش ۴" referenced a plan that no longer exists and only repeated the app name on the next line; "فاز اول - ۴ از ۱۱ اپ" had drifted (there are fourteen). INSTALLED_APPS is now grouped by responsibility. The TEST_LOGIN_PHONES block in settings shrank from 29 lines to 8 now that the same explanation lives in docs/operations.md and .env.example -- a comment beside the code should carry what a code reader needs, not a copy of the runbook. Checked mechanically that no comment references an identifier that no longer exists: zero. The dated measurements in rag/services/ingest.py and generate_questions_from_chunks.py were deliberately left alone -- a measurement carrying the date it was taken is the useful kind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
12 KiB
Python
231 lines
12 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.throttling import ScopedRateThrottle
|
||
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
|
||
|
||
|
||
class GuestAwareDiscountThrottle(ScopedRateThrottle):
|
||
"""
|
||
همان مشکلِ GuestAwareAIThrottle (ai_tutor/views.py) و
|
||
GuestAwareFamilyThrottle (family/views.py) اینجا هم هست.
|
||
ScopedRateThrottle استاندارد برای درخواستِ احرازهویتشده از
|
||
request.user.pk بهعنوانِ کلیدِ throttle استفاده میکنه؛ چون
|
||
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
|
||
میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان (تا سقفِ
|
||
guest_session=20/hour)، برای هر حساب یک bucket تازه از throttle_scope
|
||
'discount_redeem' بگیره. ریسکِ این endpoint از بقیه هم بیشتره، چون
|
||
DiscountCode یک رکوردِ سراسری با max_uses مشترکِ بینِ همهی کاربرهاست؛
|
||
یک حدسِ درست صرفاً اطلاعات لو نمیده، بلکه یک Redemption واقعی
|
||
میسازه و مبلغِ سفارش رو کم میکنه — یعنی throttle دورزدهشده به
|
||
مهاجم guessهای واقعاً بیشتری روی یک کدِ سراسری میده.
|
||
|
||
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
|
||
cache از pk کاربر به IP کلاینت (get_ident) تغییر میکنه، پس همهی
|
||
حسابهای مهمانِ ساختهشده از یک IP یک bucket مشترک دارن و چرخشِ حساب
|
||
دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرهای غیرمهمان
|
||
(ثبتنامشده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی میمونه؛ منطقِ
|
||
lookup/redemption/order دستنخوردهست.
|
||
"""
|
||
|
||
def get_cache_key(self, request, view):
|
||
user = request.user
|
||
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
||
ident = f'guest-ip:{self.get_ident(request)}'
|
||
elif user and user.is_authenticated:
|
||
ident = user.pk
|
||
else:
|
||
ident = self.get_ident(request)
|
||
|
||
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
||
|
||
|
||
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]
|
||
|
||
# این endpoint type/value کد رو برمیگردونه، پس بدونِ throttle یک
|
||
# کاربر میتونست بینهایت کد رو probe کنه؛ نرخ کمی بالاتر از
|
||
# offer_redeem چون این یک چکِ read-only پیش از خریده. GuestAwareDiscountThrottle
|
||
# مشترک (هم اینجا هم RedeemDiscountView/RedeemOfferCodeView) برای
|
||
# مهمانها کلید رو بر اساسِ IP میسازه تا چرخشِ حساب bucket تازه نده.
|
||
throttle_scope = 'discount_validate'
|
||
throttle_classes = [GuestAwareDiscountThrottle]
|
||
|
||
@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]
|
||
|
||
# همون oracleِ probe مثلِ ValidateDiscountView، ولی ریسکش بیشتره: حدسِ
|
||
# درست یه کدِ واقعی رو مصرف میکنه (سهمِ max_uses مشترک) و مبلغِ سفارش
|
||
# رو واقعاً کم میکنه، ضمن اینکه هر تلاش دو select_for_update و یه
|
||
# تراکنشه. GuestAwareDiscountThrottle برای مهمانها کلید رو بر اساسِ IP
|
||
# میسازه تا چرخشِ حساب bucket تازه نده.
|
||
throttle_scope = 'discount_redeem'
|
||
throttle_classes = [GuestAwareDiscountThrottle]
|
||
|
||
@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]
|
||
|
||
# بدونِ throttle، کدهای آفر قابلِ probe بودن؛ اینجا حساستره چون
|
||
# Offer.code سراسری/یکتاست و redeem_offer_code() فقط جلوی تکرارِ همون
|
||
# کاربر رو میگیره — یک حسابِ مهمانِ چرخشی میتونست همون آفرِ سراسری رو
|
||
# دوباره بگیره. GuestAwareDiscountThrottle برای مهمانها کلید رو بر
|
||
# اساسِ IP میسازه تا چرخشِ حساب bucket تازه نده.
|
||
throttle_scope = 'offer_redeem'
|
||
throttle_classes = [GuestAwareDiscountThrottle]
|
||
|
||
@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)
|