Files
MathApp/subscription/views.py
Zareian 7ecb337263 Finishing a lesson or island needs recorded answers and an unlocked island; auto DB backup before migrate; throttles on 15 more routes
- content: POST nodes/{id}/quiz/complete and lessons/{id}/complete refuse a locked island
  (403 node_locked, same rule as /api/home) and a user who has not answered every question
  (409 answers_missing). Before, only a user with zero hearts and no answers was refused, so
  anyone else finished any island, locked or not, and finishing a locked island's lessons
  unlocked it. A node or lesson with no questions still passes when unlocked, with no XP.
  The client can no longer understate totalQuestions to buy stars.
- throttle_scope on 15 routes that had none (question bank without login, intro, hearts,
  notifications, tickets, plans, subscription status, orders, offers, affiliate, assistant
  history and feedback, logout).
- admin: OTPCode and Session can only be added/changed by a superuser.
- deploy.sh: integrity-checked DB backup before migrate (keeps the last 2); no migrate without it.
- docs and CLAUDE.md updated to say what is verified and what is not.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 13:30:24 +03:30

243 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django.conf import settings
from django.core.cache import cache
from django.db import transaction
from django.db.models import F
from django.shortcuts import get_object_or_404
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 payment.models import Order
from payment.services import create_order, reconcile_user_orders
from .models import Plan, Subscription
from .serializers import (
PlansListResponseSerializer,
PurchaseRequestSerializer,
PurchaseResponseSerializer,
SubscriptionStatusResponseSerializer,
)
from .services import credit_balance, has_active_subscription, hearts_pass_until
# پلن‌ها تقریباً هیچ‌وقت عوض نمی‌شوند و روی صفحه‌ی اشتراک هر بار خوانده می‌شوند.
# با هر save/delete روی Plan کش باطل می‌شود (subscription/receivers.py).
PLANS_CACHE_KEY = 'subscription:plans:v3' # v3: بسته‌های سوال
PLANS_CACHE_TTL = 3600
class PlansListView(APIView):
"""GET /api/subscription/plans — list available plans."""
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است.
throttle_scope = 'read'
throttle_classes = [ScopedRateThrottle]
@extend_schema(responses=PlansListResponseSerializer, tags=['Subscription (subscription)'])
def get(self, request):
def build():
return {
'plans': [
{
'id': str(p.id),
'title': p.title,
'price': p.price,
'currency': p.currency,
'popular': p.popular,
'discountPercent': p.discount_percent,
'durationDays': p.duration_days,
'features': p.features or [],
'badge': p.badge,
'aiDailyQuota': p.ai_daily_quota,
'kind': p.kind,
'questionCredits': p.question_credits,
'heartsDays': p.hearts_days,
'bazaarSku': p.bazaar_sku,
}
for p in Plan.objects.filter(on_sale=True).order_by('kind', 'price')
],
'freeAiDailyQuota': settings.AI_DAILY_QUOTA_FREE,
}
data = cache.get_or_set(PLANS_CACHE_KEY, build, PLANS_CACHE_TTL)
return Response(PlansListResponseSerializer(data).data)
class PurchaseView(APIView):
"""POST /api/subscription/purchase — starts a purchase (Idempotency-Key
header supported). Creates/updates a pending Subscription, then calls
the payment app's service directly (in-process, not a separate HTTP call)
to create an Order."""
permission_classes = [IsAuthenticated]
# این throttle مستقلِ Idempotency-Key است چون اون فقط جلوی تکرارِ *همون*
# کلید رو می‌گیره، نه حجمِ درخواست‌های متفاوت.
throttle_scope = 'purchase'
throttle_classes = [ScopedRateThrottle]
@extend_schema(
request=PurchaseRequestSerializer,
responses=PurchaseResponseSerializer,
parameters=[
OpenApiParameter(
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
description=(
'کلید یکتای idempotency برای جلوگیری از ساخت سفارش تکراری هنگام retry. '
'در صورت عدم ارسال، یک کلید تصادفی ساخته می‌شود.'
),
),
],
tags=['Subscription (subscription)'],
)
def post(self, request):
serializer = PurchaseRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
user = request.user
# پلنی که دیگر فروخته نمی‌شود (اشتراک‌های ماهانه/سالانه پس از آمدنِ بسته‌ها) خریدنی نیست.
plan = get_object_or_404(Plan, pk=v['planId'], on_sale=True)
idempotency_key = request.headers.get('Idempotency-Key')
if v['paymentMethod'] == 'bazaar':
# فقط بسته: اشتراک‌ها دیگر فروخته نمی‌شوند و مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز
# با خریدِ واقعی آزموده نشده. SKU از پلن، نه از کلاینت.
if plan.kind != 'pack':
raise ValidationError({'paymentMethod': 'خرید از بازار فقط برای بسته‌های سوال است.'})
if not plan.bazaar_sku:
raise ValidationError({'planId': 'این بسته هنوز در کافه‌بازار تعریف نشده است.'})
order, _ = create_order(
user_id=user.id, amount=plan.price, currency=plan.currency,
idempotency_key=idempotency_key, plan_id=plan.id, plan_title=plan.title,
provider='bazaar', bazaar_product_sku=plan.bazaar_sku,
)
return Response(PurchaseResponseSerializer(
{'orderId': str(order.id), 'paymentUrl': None, 'bazaarSku': order.bazaar_product_sku}).data)
if plan.kind == 'pack':
# بسته به Subscription دست نمی‌زند: اعتبار و روزهای قلب بعد از پرداخت از روی
# خودِ سفارش داده می‌شوند (payment.services → subscription.services.grant_pack).
order, payment_url = create_order(
user_id=user.id, amount=plan.price, currency=plan.currency,
idempotency_key=idempotency_key, plan_id=plan.id, plan_title=plan.title,
)
return Response(PurchaseResponseSerializer({'orderId': str(order.id), 'paymentUrl': payment_url}).data)
# Three-phase flow so create_order never runs inside a DB transaction/lock.
# (create_order itself no longer calls Zarinpal — PaymentRedirectView
# does, on first visit — so this split is now defensive.) Phase 1 creates/updates
# the pending Subscription, phase 2 calls create_order and restores the
# prior state on failure, phase 3 attaches order_id atomically.
existing_order = None
if idempotency_key:
existing_order = Order.objects.filter(idempotency_key=idempotency_key).first()
prior_subscription = Subscription.objects.filter(user_id=user.id).first()
prior_snapshot = None
if prior_subscription is not None:
prior_snapshot = {
'status': prior_subscription.status,
'plan_id': prior_subscription.plan_id,
'order_id': prior_subscription.order_id,
}
# اشتراکِ فعال با باز کردنِ صفحه‌ی خرید دست نمی‌خورد. پیش‌تر وضعیتش
# همین‌جا «pending» می‌شد و کاربرِ پولی اگر خرید را تمام نمی‌کرد
# مزیتش را از دست می‌داد. پلن و تمدید از خودِ سفارش می‌آیند
# (activate_subscription)، پس لازم نیست Subscription الان عوض شود.
currently_active = prior_subscription is not None and has_active_subscription(user.id)
if currently_active:
subscription = prior_subscription
subscription_is_new = False
prior_snapshot = None
elif existing_order is not None:
# Retry path: leave an existing subscription untouched, only
# create one if it's genuinely missing.
if prior_subscription is None:
# Fresh row: version starts at 1 to give it a CAS token.
subscription = Subscription.objects.create(
user_id=user.id, plan=plan, status='pending', version=1,
)
subscription_is_new = True
else:
subscription = prior_subscription
subscription_is_new = False
else:
subscription, subscription_is_new = Subscription.objects.update_or_create(
user_id=user.id,
defaults={'plan': plan, 'status': 'pending'},
)
# Two concurrent requests can reach an identical intermediate state,
# so version is bumped atomically here to give this write a unique
# CAS token the cleanup below can match against.
Subscription.objects.filter(pk=subscription.pk).update(version=F('version') + 1)
subscription.refresh_from_db(fields=['version'])
try:
order, payment_url = create_order(
user_id=user.id,
amount=plan.price,
currency=plan.currency,
subscription_id=subscription.id,
idempotency_key=idempotency_key,
plan_id=plan.id,
plan_title=plan.title,
)
except Exception:
# Guard by pk+status+plan_id+order_id+version so this cleanup only
# matches the exact row state phase 1 wrote; any concurrent writer
# (another request, a payment webhook, etc.) changes the match and
# the delete/update below becomes a no-op instead of clobbering it.
guard = {
'pk': subscription.pk,
'status': subscription.status,
'plan_id': subscription.plan_id,
'order_id': subscription.order_id,
'version': subscription.version,
}
with transaction.atomic():
if subscription_is_new:
Subscription.objects.filter(**guard).delete()
elif prior_snapshot is not None:
Subscription.objects.filter(**guard).update(**prior_snapshot)
raise
if subscription.order_id != order.id and not currently_active:
with transaction.atomic():
subscription.order_id = order.id
subscription.save(update_fields=['order_id'])
data = {'orderId': str(order.id), 'paymentUrl': payment_url}
return Response(PurchaseResponseSerializer(data).data)
class SubscriptionStatusView(APIView):
"""GET /api/subscription/status — current subscription status."""
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است.
throttle_scope = 'read'
throttle_classes = [ScopedRateThrottle]
permission_classes = [IsAuthenticated]
@extend_schema(responses=SubscriptionStatusResponseSerializer, tags=['Subscription (subscription)'])
def get(self, request):
# پرداختی که تأییدش در کال‌بک گم شده، اینجا دوباره سنجیده می‌شود.
reconcile_user_orders(request.user.id)
subscription = Subscription.objects.filter(user_id=request.user.id).first()
data = {
# وضعیتِ «active» با گذشتِ تاریخ خودبه‌خود عوض نمی‌شود؛ همان شرطی که
# قلبِ نامحدود را می‌دهد ملاک است، وگرنه اشتراکِ منقضی «فعال» دیده می‌شد.
'active': has_active_subscription(request.user.id),
'planId': str(subscription.plan_id) if subscription and subscription.plan_id else None,
'expiresAt': subscription.expires_at if subscription else None,
'questionCredits': credit_balance(request.user.id),
'unlimitedHeartsUntil': hearts_pass_until(request.user.id),
}
return Response(SubscriptionStatusResponseSerializer(data).data)