- remove the family app from the root (separate parent accounts, invite codes, parental settings); progress.0006 drops its tables, permissions and migration history, and Profile.parent_phone, the two family notification types and ?userId= on /api/progress/nodes go with it - GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes, questions, accuracy, islands, tutor questions) for the "parent report" page - profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it - GET /api/payment/invoices; orders now snapshot list price and plan title - tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11), refusal marker replaced with fixed text; self-harm gets a help line instead - streamed answers now record token usage (it was always 0) - daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota - delete_users management command for wiping test accounts across every app - docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
10 KiB
Python
222 lines
10 KiB
Python
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.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 has_unlimited_hearts
|
|
|
|
|
|
class GuestAwarePurchaseThrottle(ScopedRateThrottle):
|
|
"""همون الگوی GuestAwareAIThrottle/GuestAwareDiscountThrottle/etc: چرخشِ
|
|
حساب مهمان نباید bucket تازهی purchase بده — این endpoint حساسِ مالیه
|
|
(Subscription میسازه/آپدیت میکنه، create_order رو صدا میزنه). برای
|
|
مهمانها کلیدِ cache بر اساسِ IP هست، نه pk."""
|
|
|
|
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}
|
|
|
|
|
|
# پلنها تقریباً هیچوقت عوض نمیشوند و روی صفحهی اشتراک هر بار خوانده میشوند.
|
|
# با هر save/delete روی Plan کش باطل میشود (subscription/receivers.py).
|
|
PLANS_CACHE_KEY = 'subscription:plans:v1'
|
|
PLANS_CACHE_TTL = 3600
|
|
|
|
|
|
class PlansListView(APIView):
|
|
"""GET /api/subscription/plans — list available plans."""
|
|
|
|
@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,
|
|
}
|
|
for p in Plan.objects.all()
|
|
]
|
|
}
|
|
|
|
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 است چون اون فقط جلوی تکرارِ *همون*
|
|
# کلید رو میگیره، نه حجمِ درخواستهای متفاوت. کاربرهای مهمان با هر
|
|
# deviceId یک pk تازه میگیرن، پس کلیدِ per-user ساده قابلدورزدنه؛
|
|
# GuestAwarePurchaseThrottle برای مهمانها بر اساسِ IP کلید میسازه
|
|
# (مثلِ GuestAwareAIThrottle/DiscountThrottle) و برای
|
|
# کاربرهای غیرمهمان رفتارِ per-user را حفظ میکنه.
|
|
throttle_scope = 'purchase'
|
|
throttle_classes = [GuestAwarePurchaseThrottle]
|
|
|
|
@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'])
|
|
idempotency_key = request.headers.get('Idempotency-Key')
|
|
|
|
# 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_unlimited_hearts(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."""
|
|
|
|
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_unlimited_hearts(request.user.id),
|
|
'planId': str(subscription.plan_id) if subscription and subscription.plan_id else None,
|
|
'expiresAt': subscription.expires_at if subscription else None,
|
|
}
|
|
return Response(SubscriptionStatusResponseSerializer(data).data)
|