- 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>
140 lines
6.6 KiB
Python
140 lines
6.6 KiB
Python
from django.shortcuts import get_object_or_404
|
||
from drf_spectacular.utils import extend_schema
|
||
from rest_framework.exceptions import PermissionDenied, 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 AffiliateAccount, Conversion
|
||
from .serializers import (
|
||
AffiliateMeResponseSerializer,
|
||
ConversionListResponseSerializer,
|
||
TrackRequestSerializer,
|
||
TrackResponseSerializer,
|
||
)
|
||
|
||
|
||
class GuestAwareAffiliateThrottle(ScopedRateThrottle):
|
||
"""
|
||
همان الگویِ GuestAwareDiscountThrottle
|
||
و بقیه؛ چرخشِ حسابِ مهمان (deviceId جدید → pk جدید) یک ScopedRateThrottleِ
|
||
سادهٔ per-user رو دور میزد. اینجا حساستره چون referralCode یک
|
||
شناسهی حدسزدنیست (404 برای کدِ نامعتبر = oracle) و یک حدسِ درست
|
||
یک Conversion واقعی میسازه که به کمیسیونِ مالیِ affiliate صاحبِ کد
|
||
منجر میشه؛ یعنی بدونِ این throttle، چرخشِ حساب مهمان به مهاجم
|
||
guessهای عملاً نامحدودی برای enumerate/fraud کردنِ کدهای رفرال
|
||
میداد. برای کاربرهای مهمان کلیدِ cache از pk به IP کلاینت تغییر
|
||
میکنه؛ رفتارِ throttle برای کاربرهای غیرمهمان (per-user، مثلِ
|
||
قبل) دستنخورده میمونه.
|
||
"""
|
||
|
||
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 _reject_guest(user):
|
||
"""
|
||
مهمان نه کدِ معرف میگیرد نه معرفیشده حساب میشود: حسابِ مهمان با هر
|
||
deviceId تازه ساخته میشود، پس شمردنش فقط آمار را باد میکرد. بعد از ورود
|
||
با شماره، همان کاربر (با همان پیشرفت) میتواند کد را ثبت کند.
|
||
"""
|
||
if getattr(user, 'is_guest', False):
|
||
raise PermissionDenied('برنامهی معرفی بعد از ورود با شمارهی موبایل فعال میشود.')
|
||
|
||
|
||
class AffiliateMeView(APIView):
|
||
"""GET /api/affiliate/me — حساب و کد ارجاع کاربر"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(responses=AffiliateMeResponseSerializer, tags=['Affiliate (affiliate)'])
|
||
def get(self, request):
|
||
_reject_guest(request.user)
|
||
account, _ = AffiliateAccount.objects.get_or_create(user_id=request.user.id)
|
||
paid = account.conversions.filter(status='paid')
|
||
data = {
|
||
'referralCode': account.referral_code,
|
||
'commissionRate': account.commission_rate,
|
||
'referredCount': account.conversions.count(),
|
||
'paidCount': paid.count(),
|
||
'totalCommission': sum(paid.values_list('commission_amount', flat=True)),
|
||
}
|
||
return Response(AffiliateMeResponseSerializer(data).data)
|
||
|
||
|
||
class TrackConversionView(APIView):
|
||
"""
|
||
POST /api/affiliate/track — ثبت کلیک/ارجاع
|
||
|
||
طبق سند، این اپ فقط با referralCode به AffiliateAccount وصل میشه.
|
||
referredUserId دیگه ورودیِ کلاینت نیست: چون این درخواست باید توسطِ
|
||
خودِ کاربرِ تازهارجاعشده (بعد از لاگین/ثبتنام) زده بشه، هویتش رو
|
||
از توکن احرازشده (request.user) میگیریم، نه از بدنهی درخواست.
|
||
|
||
# هویت از request.user گرفته میشه (نه بدنهی درخواست) چون یک
|
||
# referredUserIdِ کلاینتدار یعنی هر کلاینتِ ناشناس میتونست برای هر
|
||
# کاربرِ دیگهای Conversion بسازه یا با اشغالِ همهی userId ها (قیدِ
|
||
# unique رو دورزدن) مسیرِ ارجاعِ واقعی رو مسدود کنه.
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
# referralCode نامعتبر ۴۰۴ میگیره (یک oracle قابلprobe)، پس throttle
|
||
# مجزا هم probe رو کند میکنه هم (با GuestAwareAffiliateThrottle) جلویِ
|
||
# bucket تازه از چرخشِ حساب مهمان رو میگیره.
|
||
throttle_scope = 'affiliate_track'
|
||
throttle_classes = [GuestAwareAffiliateThrottle]
|
||
|
||
@extend_schema(
|
||
request=TrackRequestSerializer, responses=TrackResponseSerializer, tags=['Affiliate (affiliate)']
|
||
)
|
||
def post(self, request):
|
||
serializer = TrackRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
v = serializer.validated_data
|
||
_reject_guest(request.user)
|
||
account = get_object_or_404(AffiliateAccount, referral_code=v['referralCode'])
|
||
referred_user_id = request.user.id
|
||
|
||
if referred_user_id == account.user_id:
|
||
raise ValidationError({'referredUserId': 'امکان ثبت ارجاع برای خودِ صاحب کد وجود ندارد.'})
|
||
|
||
conversion, _ = Conversion.objects.get_or_create(
|
||
referred_user_id=referred_user_id,
|
||
defaults={'affiliate_account': account},
|
||
)
|
||
|
||
data = {'success': True, 'conversionId': str(conversion.pk), 'status': conversion.status}
|
||
return Response(TrackResponseSerializer(data).data)
|
||
|
||
|
||
class ConversionsListView(APIView):
|
||
"""GET /api/affiliate/conversions — لیست تبدیلها و کمیسیون"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(responses=ConversionListResponseSerializer, tags=['Affiliate (affiliate)'])
|
||
def get(self, request):
|
||
account = AffiliateAccount.objects.filter(user_id=request.user.id).first()
|
||
conversions = account.conversions.all() if account else Conversion.objects.none()
|
||
|
||
items = [
|
||
{
|
||
'id': str(c.pk),
|
||
'referredUserId': str(c.referred_user_id),
|
||
'status': c.status,
|
||
'commissionAmount': c.commission_amount,
|
||
}
|
||
for c in conversions
|
||
]
|
||
data = {'conversions': items}
|
||
return Response(ConversionListResponseSerializer(data).data)
|