public user IDs, plans priced by tutor cost, age-based tutor tone, self-service account deletion

- profile publicId ("User482917", accounts/public_id.py); admin shows and searches it
- plans API reports aiDailyQuota per plan and freeAiDailyQuota; migration 0007 sets the
  temporary prices (monthly 99,000 / yearly 990,000 toman, 5 tutor questions a day) unless
  an admin already edited the yearly plan
- tutor tone follows the learner's age (18+ gets an adult tone at any grade); notation
  still follows the grade's book; answer cache keyed by tone (prompt tutor-v12-age-tone)
- POST /api/user/delete: phone users confirm with a fresh SMS code (same checks as login,
  now shared in verify_phone_otp), guests type "حذف"; payment records are kept, demo
  accounts refused
- delete_users --skip-paid

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 06:10:17 +03:30
parent e9d676ce68
commit 63cd1af6f7
18 changed files with 468 additions and 78 deletions

View File

@@ -51,8 +51,23 @@ class SendSMSForm(forms.Form):
@admin.register(User) @admin.register(User)
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at') list_display = ('id', 'public_id', 'name', 'phone', 'device_id', 'is_guest', 'grade_id', 'created_at')
search_fields = ('name', 'phone', 'device_id') search_fields = ('name', 'phone', 'device_id')
@admin.display(description='شناسه‌ی نمایشی')
def public_id(self, obj):
from .public_id import encode
return encode(obj.pk)
def get_search_results(self, request, queryset, search_term):
"""«User482917» (همان که کاربر در پروفایلش می‌بیند) هم کاربر را پیدا می‌کند."""
from .public_id import decode
results, may_have_duplicates = super().get_search_results(request, queryset, search_term)
user_id = decode(search_term)
if user_id:
results |= queryset.filter(pk=user_id)
return results, may_have_duplicates
list_filter = ('is_guest',) list_filter = ('is_guest',)
actions = ['send_sms_to_selected'] actions = ['send_sms_to_selected']

View File

@@ -92,3 +92,15 @@ def parse(value: str) -> date:
def to_string(d: date) -> str: def to_string(d: date) -> str:
jy, jm, jd = from_gregorian(d) jy, jm, jd = from_gregorian(d)
return f'{jy:04d}-{jm:02d}-{jd:02d}' return f'{jy:04d}-{jm:02d}-{jd:02d}'
def age_of(birth_date: date | None, birth_year: int | None, today: date | None = None) -> int | None:
"""سنِ کامل از تاریخِ تولد؛ برای کاربری که فقط سالِ شمسی داده، تقریبی (سال منهای سال)."""
from django.utils import timezone
today = today or timezone.localdate()
if birth_date:
return today.year - birth_date.year - ((today.month, today.day) < (birth_date.month, birth_date.day))
if birth_year:
return from_gregorian(today)[0] - birth_year
return None

View File

@@ -54,10 +54,13 @@ def plan(user_ids):
return counts return counts
def delete_users(user_ids): def delete_users(user_ids, keep=()):
"""keep: برچسبِ مدل‌هایی که نمی‌مانند پاک شوند (مثلاً 'payment.Order' برای حذفِ حسابِ خودِ کاربر)."""
phones = list(User.objects.filter(pk__in=user_ids).exclude(phone=None).values_list('phone', flat=True)) phones = list(User.objects.filter(pk__in=user_ids).exclude(phone=None).values_list('phone', flat=True))
with transaction.atomic(): with transaction.atomic():
for model, column in user_scoped_models(): for model, column in user_scoped_models():
if model._meta.label in keep:
continue
model.objects.filter(**{f'{column}__in': user_ids}).delete() model.objects.filter(**{f'{column}__in': user_ids}).delete()
OTPCode.objects.filter(phone__in=phones).delete() OTPCode.objects.filter(phone__in=phones).delete()
User.objects.filter(pk__in=user_ids).delete() User.objects.filter(pk__in=user_ids).delete()
@@ -74,6 +77,8 @@ class Command(BaseCommand):
parser.add_argument('--yes', action='store_true', help='واقعاً پاک کن.') parser.add_argument('--yes', action='store_true', help='واقعاً پاک کن.')
parser.add_argument('--include-paid', action='store_true', parser.add_argument('--include-paid', action='store_true',
help='کاربرِ دارای سفارشِ پرداخت‌شده را هم پاک کن.') help='کاربرِ دارای سفارشِ پرداخت‌شده را هم پاک کن.')
parser.add_argument('--skip-paid', action='store_true',
help='کاربرِ دارای سفارشِ پرداخت‌شده را کنار بگذار و بقیه را پاک کن.')
def handle(self, *args, **options): def handle(self, *args, **options):
phones = [normalize_phone(p) or p for p in options['phones']] phones = [normalize_phone(p) or p for p in options['phones']]
@@ -93,7 +98,12 @@ class Command(BaseCommand):
from payment.models import Order from payment.models import Order
paid = set(Order.objects.filter(user_id__in=[u.pk for u in users], status='paid') paid = set(Order.objects.filter(user_id__in=[u.pk for u in users], status='paid')
.values_list('user_id', flat=True)) .values_list('user_id', flat=True))
if paid and not options['include_paid']: if paid and options['skip_paid']:
self.stdout.write(f'کنار گذاشته شدند (سفارشِ پرداخت‌شده دارند): {sorted(paid)}')
users = [u for u in users if u.pk not in paid]
if not users:
raise CommandError('بعد از کنار گذاشتنِ پرداخت‌کننده‌ها کسی نماند.')
elif paid and not options['include_paid']:
raise CommandError(f'کاربرهای {sorted(paid)} سفارشِ پرداخت‌شده دارند؛ با --include-paid پاک می‌شوند.') raise CommandError(f'کاربرهای {sorted(paid)} سفارشِ پرداخت‌شده دارند؛ با --include-paid پاک می‌شوند.')
ids = [u.pk for u in users] ids = [u.pk for u in users]

32
accounts/public_id.py Normal file
View File

@@ -0,0 +1,32 @@
"""
شناسه‌ی نمایشیِ کاربر («User482917») به‌جای شماره‌ی ردیفِ دیتابیس.
شناسه‌ی خام («۱۰») هم کوچک و بی‌اعتبار دیده می‌شد، هم می‌گفت اپ چند کاربر دارد. این شکل
شش‌رقمی و ثابت است و از خودِ id ساخته می‌شود، پس ستونی در دیتابیس لازم ندارد:
code = 100000 + (id × 7919) mod 900000
۷۹۱۹ با ۹۰۰۰۰۰ هیچ مقسومِ علیهِ مشترکی ندارد، پس تا ۹۰۰ هزار کاربر هیچ دو کاربری یک کد
نمی‌گیرند و کد به id برمی‌گردد (`decode`)؛ پنلِ ادمین با همین کاربر را پیدا می‌کند. این رمز
نیست: هر کس فرمول را بداند id را درمی‌آورد. فقط برای نمایش و پشتیبانی است، نه دسترسی.
"""
import re
PREFIX = 'User'
_MOD = 900_000
_MUL = 7919
_INV = pow(_MUL, -1, _MOD)
_PATTERN = re.compile(rf'^\s*(?:{PREFIX})?\s*(\d{{6}})\s*$', re.IGNORECASE)
def encode(user_id: int) -> str:
return f'{PREFIX}{100_000 + (user_id * _MUL) % _MOD}'
def decode(code: str) -> int | None:
"""«User482917» یا «482917» → id؛ None اگر شکلش این نیست."""
match = _PATTERN.match(code or '')
if not match:
return None
user_id = ((int(match.group(1)) - 100_000) * _INV) % _MOD
return user_id or None

View File

@@ -159,9 +159,10 @@ class RequestOTPResponseSerializer(serializers.Serializer):
class ProfileResponseSerializer(serializers.Serializer): class ProfileResponseSerializer(serializers.Serializer):
"""پاسخ GET /api/user/profile — فقط داده‌ی لازم برای تب Profile اپ موبایل.""" """پاسخ GET /api/user/profile — فقط داده‌ی لازم برای تب Profile اپ موبایل."""
# شناسه‌ی کاربر همان شماره‌ی ثبتِ اوست (کاربرِ چندم)؛ اپ آن را زیرِ نام نشان می‌دهد
# تا در پشتیبانی بشود حساب را بی‌شماره‌ی تلفن پیدا کرد.
userId = serializers.CharField() userId = serializers.CharField()
# شناسه‌ی نمایشی («User482917»، accounts/public_id.py)؛ اپ آن را زیرِ نام نشان می‌دهد
# و پشتیبانی با همین در پنلِ ادمین حساب را پیدا می‌کند.
publicId = serializers.CharField()
phone = serializers.CharField(allow_null=True) phone = serializers.CharField(allow_null=True)
name = serializers.CharField() name = serializers.CharField()
avatar = serializers.CharField() avatar = serializers.CharField()
@@ -172,6 +173,17 @@ class ProfileResponseSerializer(serializers.Serializer):
bio = serializers.CharField() bio = serializers.CharField()
class DeleteAccountRequestSerializer(serializers.Serializer):
# کاربرِ شماره‌دار: کدِ پیامکیِ تازه (از POST /api/auth/otp/request با همان شماره).
otp = serializers.CharField(max_length=6, required=False, allow_blank=True)
# مهمان شماره‌ای ندارد که کد بگیرد؛ باید همین کلمه را تایپ کرده باشد.
confirm = serializers.CharField(max_length=10, required=False, allow_blank=True)
class DeleteAccountResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
class LogoutRequestSerializer(serializers.Serializer): class LogoutRequestSerializer(serializers.Serializer):
refreshToken = serializers.CharField(required=False, allow_blank=True) refreshToken = serializers.CharField(required=False, allow_blank=True)

View File

@@ -88,3 +88,17 @@ class ProfileBirthDateApiTests(TestCase):
def test_parent_phone_is_gone(self): def test_parent_phone_is_gone(self):
self.assertNotIn('parentPhone', self.client.get('/api/user/profile').json()) self.assertNotIn('parentPhone', self.client.get('/api/user/profile').json())
class PublicIdTests(TestCase):
def test_profile_shows_a_six_digit_code_that_maps_back_to_the_user(self):
from accounts.public_id import decode
user = User.objects.create(device_id='public-id-device', is_guest=True)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
code = client.get('/api/user/profile').json()['publicId']
self.assertRegex(code, r'^User\d{6}$')
self.assertEqual(decode(code), user.id)
self.assertEqual(decode(code[4:]), user.id)
self.assertIsNone(decode('User12'))

View File

@@ -0,0 +1,66 @@
"""
POST /api/user/delete — حذفِ حسابِ خود (۲۰۲۶-۱۰-۰۲). راه‌های شکستن: حذف با توکنِ تنها
(بی‌کد)، حدسِ کد، حذفِ حسابِ دمو، و پاک شدنِ سندِ مالی.
"""
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import OTPCode, User
from payment.models import Order
from progress.models import UserStats
def _client(user):
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
return client
class DeleteAccountTests(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09121115001', is_guest=False)
UserStats.objects.create(user_id=self.user.id, xp=50)
self.client = _client(self.user)
def code(self, value='482913'):
OTPCode.objects.create(phone=self.user.phone, code=value,
expires_at=timezone.now() + timezone.timedelta(minutes=5))
return value
def delete(self, **body):
return self.client.post('/api/user/delete', body, format='json')
def test_a_token_alone_is_not_enough(self):
self.assertEqual(self.delete().status_code, 400)
self.assertTrue(User.objects.filter(pk=self.user.pk).exists())
def test_a_wrong_code_deletes_nothing(self):
self.code()
self.assertEqual(self.delete(otp='111111').status_code, 400)
self.assertTrue(User.objects.filter(pk=self.user.pk).exists())
def test_the_right_code_deletes_everything_but_payment_records(self):
Order.objects.create(user_id=self.user.id, amount=99000, status='paid', idempotency_key='k')
response = self.delete(otp=self.code())
self.assertEqual(response.status_code, 200, response.content)
self.assertFalse(User.objects.filter(pk=self.user.pk).exists())
self.assertFalse(UserStats.objects.filter(user_id=self.user.pk).exists())
self.assertTrue(Order.objects.filter(user_id=self.user.pk).exists())
# توکنِ قبلی دیگر به هیچ کاربری نمی‌رسد.
self.assertIn(self.client.get('/api/user/profile').status_code, (401, 403))
def test_guest_confirms_by_typing_the_word(self):
guest = User.objects.create(device_id='guest-del', is_guest=True)
client = _client(guest)
self.assertEqual(client.post('/api/user/delete', {'confirm': 'باشه'}, format='json').status_code, 400)
self.assertEqual(client.post('/api/user/delete', {'confirm': 'حذف'}, format='json').status_code, 200)
self.assertFalse(User.objects.filter(pk=guest.pk).exists())
@override_settings(TEST_LOGIN_PHONES={'09121115001': '4321'})
def test_demo_accounts_cannot_be_deleted(self):
self.assertEqual(self.delete(otp='4321').status_code, 403)
self.assertTrue(User.objects.filter(pk=self.user.pk).exists())

View File

@@ -57,3 +57,9 @@ class DeleteUsersTests(TestCase):
self.assertFalse(User.objects.filter(pk=self.doomed.pk).exists()) self.assertFalse(User.objects.filter(pk=self.doomed.pk).exists())
self.assertTrue(User.objects.filter(pk=self.kept.pk).exists()) self.assertTrue(User.objects.filter(pk=self.kept.pk).exists())
self.assertTrue(User.objects.filter(pk=guest.pk).exists()) self.assertTrue(User.objects.filter(pk=guest.pk).exists())
def test_skip_paid_keeps_the_payer_and_deletes_the_rest(self):
Order.objects.create(user_id=self.doomed.id, amount=1000, status='paid', idempotency_key='k2')
self.run_command('--ids', str(self.doomed.pk), str(self.kept.pk), '--yes', '--skip-paid')
self.assertTrue(User.objects.filter(pk=self.doomed.pk).exists())
self.assertFalse(User.objects.filter(pk=self.kept.pk).exists())

View File

@@ -1,6 +1,7 @@
from django.urls import path from django.urls import path
from .views import ( from .views import (
DeleteAccountView,
GuestSessionView, GuestSessionView,
LoginView, LoginView,
LogoutView, LogoutView,
@@ -18,4 +19,5 @@ urlpatterns = [
path('auth/logout', LogoutView.as_view(), name='auth-logout'), path('auth/logout', LogoutView.as_view(), name='auth-logout'),
path('user/grade', SetUserGradeView.as_view(), name='user-grade'), path('user/grade', SetUserGradeView.as_view(), name='user-grade'),
path('user/profile', UserProfileView.as_view(), name='user-profile'), path('user/profile', UserProfileView.as_view(), name='user-profile'),
path('user/delete', DeleteAccountView.as_view(), name='user-delete'),
] ]

View File

@@ -19,6 +19,8 @@ from .authentication import invalidate_user_cache, issue_access_token
from .models import OTPCode, Profile, Session, User from .models import OTPCode, Profile, Session, User
from .services import OTPDeliveryError, send_otp_sms from .services import OTPDeliveryError, send_otp_sms
from .serializers import ( from .serializers import (
DeleteAccountRequestSerializer,
DeleteAccountResponseSerializer,
GuestSessionRequestSerializer, GuestSessionRequestSerializer,
GuestSessionResponseSerializer, GuestSessionResponseSerializer,
LoginRequestSerializer, LoginRequestSerializer,
@@ -279,6 +281,57 @@ class RequestOTPView(APIView):
return Response(RequestOTPResponseSerializer(data).data) return Response(RequestOTPResponseSerializer(data).data)
def verify_phone_otp(phone: str, otp: str) -> Response | None:
"""
کدِ پیامکیِ این شماره را می‌سنجد و مصرف می‌کند؛ None یعنی درست بود، وگرنه پاسخِ خطا.
مشترکِ ورود (LoginView) و حذفِ حساب (DeleteAccountView): هر دو باید همان سقفِ تلاشِ
شماره، همان قفلِ ردیف و همان باطل شدنِ کد پس از چند حدسِ غلط را داشته باشند. پیش از
۲۰۲۶-۱۰-۰۲ این منطق فقط داخلِ LoginView بود.
"""
# محدودیت تلاش بر اساس شماره تلفن (مستقل از throttle IP-محورِ هر view).
phone_attempts_key = f'otp_login_phone_attempts:{phone}'
if cache.add(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS):
attempt_count = 1
else:
try:
attempt_count = cache.incr(phone_attempts_key)
except ValueError:
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
cache.set(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS)
attempt_count = 1
if attempt_count > settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS:
# هیچ سکرتی (OTP، token) لاگ نمی‌شه؛ از شماره فقط ۴ رقمِ آخر.
logger.warning(
'OTP login phone attempt limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
phone[-4:], attempt_count, settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS,
)
return Response(
{'detail': 'تعداد تلاش‌های ورود برای این شماره بیش از حد مجاز است.'},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
# قفل ردیف OTP جلوی مصرف دوباره‌ی یک کد یک‌بارمصرف را در درخواست‌های هم‌زمان می‌گیرد.
with transaction.atomic():
test_code = settings.TEST_LOGIN_PHONES.get(phone, '')
if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP:
return None
if test_code and secrets.compare_digest(test_code, otp):
# شماره‌ی تستِ تیم. compare_digest تا نتیجه از روی زمانِ پاسخ قابلِ استنتاج نباشه.
logger.info('Login via configured test phone: phone_suffix=%s', phone[-4:])
return None
otp_obj = OTPCode.objects.select_for_update().filter(
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()
).order_by('-id').first()
if otp_obj:
otp_obj.is_used = True
otp_obj.save(update_fields=['is_used'])
return None
_count_failed_attempt(phone)
return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST)
class LoginView(APIView): class LoginView(APIView):
"""POST /api/auth/login — ورود با OTP""" """POST /api/auth/login — ورود با OTP"""
@@ -296,57 +349,11 @@ class LoginView(APIView):
phone = serializer.validated_data['phone'] phone = serializer.validated_data['phone']
otp = serializer.validated_data['otp'] otp = serializer.validated_data['otp']
# محدودیت تلاش ورود بر اساس شماره تلفن (مستقل از throttle IP-محور بالا). error = verify_phone_otp(phone, otp)
phone_attempts_key = f'otp_login_phone_attempts:{phone}' if error is not None:
if cache.add(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS): return error
attempt_count = 1
else:
try:
attempt_count = cache.incr(phone_attempts_key)
except ValueError:
# بینِ add و incr دقیقاً منقضی شده؛ پنجره رو از نو شروع کن.
cache.set(phone_attempts_key, 1, timeout=settings.OTP_LOGIN_PHONE_WINDOW_SECONDS)
attempt_count = 1
if attempt_count > settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS:
# ⚠️ افزوده شده: لاگ هشدارِ امنیتی برای رصد/آلارمِ brute-force روی
# این endpoint. هیچ سکرتی (OTP، access/refresh token) لاگ نمی‌شه؛
# شماره‌ی تلفن هم کامل نوشته نمی‌شه، فقط ۴ رقم آخرش (برای
# correlation در تحلیل/پشتیبانی، بدون افشای کامل PII).
logger.warning(
'OTP login phone attempt limit exceeded: phone_suffix=%s attempt_count=%s max_attempts=%s',
phone[-4:], attempt_count, settings.OTP_LOGIN_PHONE_MAX_ATTEMPTS,
)
return Response(
{'detail': 'تعداد تلاش‌های ورود برای این شماره بیش از حد مجاز است.'},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
# قفل ردیف OTP جلوی مصرف دوباره‌ی یک کد یک‌بارمصرف را در درخواست‌های هم‌زمان می‌گیرد.
with transaction.atomic(): with transaction.atomic():
valid = False
test_code = settings.TEST_LOGIN_PHONES.get(phone, '')
if settings.DEV_MASTER_OTP is not None and otp == settings.DEV_MASTER_OTP:
valid = True
elif test_code and secrets.compare_digest(test_code, otp):
# شماره‌ی تستِ تیم. compare_digest تا نتیجه‌ی مقایسه از روی زمانِ
# پاسخ قابلِ استنتاج نباشه. سقفِ تلاشِ همین شماره بالاتر چک شده،
# پس حدسِ کد با brute-force بسته‌ست.
logger.info('Login via configured test phone: phone_suffix=%s', phone[-4:])
valid = True
else:
otp_obj = OTPCode.objects.select_for_update().filter(
phone=phone, code=otp, is_used=False, expires_at__gte=timezone.now()
).order_by('-id').first()
if otp_obj:
otp_obj.is_used = True
otp_obj.save(update_fields=['is_used'])
valid = True
if not valid:
_count_failed_attempt(phone)
return Response({'detail': 'کد OTP نامعتبر یا منقضی‌شده است.'}, status=status.HTTP_400_BAD_REQUEST)
user = User.objects.filter(phone=phone).first() user = User.objects.filter(phone=phone).first()
created = False created = False
guest = _upgradable_guest(request, phone) if user is None else None guest = _upgradable_guest(request, phone) if user is None else None
@@ -415,6 +422,60 @@ def _upgradable_guest(request, phone: str):
return User.objects.filter(pk=user.pk, is_guest=True, phone__isnull=True).first() return User.objects.filter(pk=user.pk, is_guest=True, phone__isnull=True).first()
DELETE_CONFIRM_WORD = 'حذف'
# سوابقِ پرداخت سندِ مالی‌اند و با حذفِ حساب نمی‌روند (تراکنش‌ها با سفارش می‌مانند).
KEPT_ON_ACCOUNT_DELETE = ('payment.Order',)
class DeleteAccountView(APIView):
"""
POST /api/user/delete — حذفِ همیشگیِ حسابِ خودِ کاربر و همه‌ی داده‌اش.
چرا این‌طور (۲۰۲۶-۱۰-۰۲):
- کاربرِ شماره‌دار باید کدِ پیامکیِ تازه بدهد (همان verify_phone_otpِ ورود، با همان سقفِ حدس).
توکنِ access به‌تنهایی کافی نیست: گوشیِ بازمانده دستِ یک بچه‌ی دیگر یا توکنِ دزدیده‌شده نباید
بتواند حساب را برای همیشه پاک کند.
- مهمان شماره ندارد؛ باید کلمه‌ی «حذف» را تایپ کرده باشد. حسابش فقط به همین دستگاه وصل است.
- داده در همه‌ی اپ‌ها پاک می‌شود (delete_users)، جز سفارش‌ها و تراکنش‌هایشان که سندِ مالی‌اند.
- حساب‌های دمو (TEST_LOGIN_PHONES) حذف نمی‌شوند؛ مشترک‌اند و برای ارائه پُرشده‌اند.
- بعد از حذف، توکن‌های همین کاربر کار نمی‌کنند چون کاربری با آن شناسه نمانده.
"""
permission_classes = [IsAuthenticated]
throttle_scope = 'account_delete'
throttle_classes = [ScopedRateThrottle]
@extend_schema(
request=DeleteAccountRequestSerializer,
responses=DeleteAccountResponseSerializer,
tags=['Identity & Profile (accounts)'],
)
def post(self, request):
from .management.commands.delete_users import delete_users
serializer = DeleteAccountRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
v = serializer.validated_data
user = request.user
if user.phone and user.phone in settings.TEST_LOGIN_PHONES:
return Response({'detail': 'حسابِ نمایشی را نمی‌شود حذف کرد.'}, status=status.HTTP_403_FORBIDDEN)
if user.phone:
if not v.get('otp'):
return Response({'detail': 'برای حذفِ حساب، کدی را که به شماره‌ات پیامک شد وارد کن.'},
status=status.HTTP_400_BAD_REQUEST)
error = verify_phone_otp(user.phone, v['otp'])
if error is not None:
return error
elif (v.get('confirm') or '').strip() != DELETE_CONFIRM_WORD:
return Response({'detail': f'برای حذفِ حساب کلمه‌ی «{DELETE_CONFIRM_WORD}» را بنویس.'},
status=status.HTTP_400_BAD_REQUEST)
logger.info('Account deleted by its owner: user_id=%s guest=%s', user.id, user.is_guest)
delete_users([user.id], keep=KEPT_ON_ACCOUNT_DELETE)
return Response(DeleteAccountResponseSerializer({'success': True}).data)
class LogoutView(APIView): class LogoutView(APIView):
""" """
POST /api/auth/logout — خروج از حساب. POST /api/auth/logout — خروج از حساب.
@@ -530,10 +591,11 @@ class SetUserGradeView(APIView):
def _profile_payload(user, profile) -> dict: def _profile_payload(user, profile) -> dict:
from . import jalali from . import jalali, public_id
return { return {
'userId': str(user.id), 'userId': str(user.id),
'publicId': public_id.encode(user.id),
'phone': user.phone, 'phone': user.phone,
'name': user.name, 'name': user.name,
'avatar': user.avatar, 'avatar': user.avatar,

View File

@@ -28,6 +28,22 @@ from .serializers import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _learner_age(user) -> int | None:
"""سنِ کاربر از پروفایلش (accounts)؛ لحنِ دستیار برای ۱۸+ بزرگسالانه است."""
from accounts.jalali import age_of
from accounts.models import Profile
profile = Profile.objects.filter(user_id=user.id).only('birth_date', 'birth_year').first()
return age_of(profile.birth_date, profile.birth_year) if profile else None
def _cache_grade(grade_title: str, age: int | None) -> str:
"""پایه در کلیدِ کشِ جواب، با لحن: جوابِ بزرگسالانه به کودک سرو نشود و برعکس."""
from rag.services.generation import tone_for
return f'{grade_title}|adult' if tone_for(grade_title, age) == 'adult' else grade_title
def daily_quota(user_id: int) -> int: def daily_quota(user_id: int) -> int:
"""سقفِ سوالِ روزانه‌ی این کاربر؛ رایگان یا از پلنِ اشتراکش (subscription.services).""" """سقفِ سوالِ روزانه‌ی این کاربر؛ رایگان یا از پلنِ اشتراکش (subscription.services)."""
from subscription.services import ai_daily_quota from subscription.services import ai_daily_quota
@@ -368,6 +384,7 @@ class AskAIView(APIView):
from rag.services import guardrails from rag.services import guardrails
screened = guardrails.screen(v['userQuestion']) screened = guardrails.screen(v['userQuestion'])
age = _learner_age(user)
# --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندی‌شده. # --- مسیرِ استریم (SSE) --- فقط وقتی stream=true و RAG پیکربندی‌شده.
# قراردادِ JSONِ غیراستریم دست‌نخورده می‌ماند؛ Swagger که stream # قراردادِ JSONِ غیراستریم دست‌نخورده می‌ماند؛ Swagger که stream
@@ -381,7 +398,7 @@ class AskAIView(APIView):
user_message=user_message, conversation_is_new=conversation_is_new, user_message=user_message, conversation_is_new=conversation_is_new,
idempotency_key=idempotency_key, used_today=used_today, idempotency_key=idempotency_key, used_today=used_today,
subject=subject, grade_id=grade_id, grade_title=grade_title, subject=subject, grade_id=grade_id, grade_title=grade_title,
lesson_number=lesson_number, screened=screened, quota=quota, lesson_number=lesson_number, screened=screened, quota=quota, age=age,
) )
used_chunks: list[dict] = [] used_chunks: list[dict] = []
@@ -404,7 +421,7 @@ class AskAIView(APIView):
focus = _question_focus(v, user) focus = _question_focus(v, user)
cacheable = answer_cache.cacheable(history) and not focus cacheable = answer_cache.cacheable(history) and not focus
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number) _col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title) cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], _cache_grade(grade_title, age))
cached = answer_cache.get(cache_key) if cacheable else None cached = answer_cache.get(cache_key) if cacheable else None
if cached is not None: if cached is not None:
answer_text = cached['answer'] answer_text = cached['answer']
@@ -426,7 +443,7 @@ class AskAIView(APIView):
answer_text, follow_ups, generation_meta = generate_answer( answer_text, follow_ups, generation_meta = generate_answer(
v['userQuestion'], used_chunks or None, v['userQuestion'], used_chunks or None,
retrieval_meta.get('gradeTitle') or grade_title or '', retrieval_meta.get('gradeTitle') or grade_title or '',
history=history, focus=focus, history=history, focus=focus, age=age,
) )
provider_status.record_success() provider_status.record_success()
# ردِ «غیرِ ریاضی» نه منبعِ کتاب نشان می‌دهد نه کش می‌شود. # ردِ «غیرِ ریاضی» نه منبعِ کتاب نشان می‌دهد نه کش می‌شود.
@@ -508,7 +525,8 @@ class AskAIView(APIView):
def _streaming_response(self, *, request, v, user, conversation, user_message, def _streaming_response(self, *, request, v, user, conversation, user_message,
conversation_is_new, idempotency_key, used_today, conversation_is_new, idempotency_key, used_today,
subject, grade_id, grade_title, lesson_number, screened=None, quota=None): subject, grade_id, grade_title, lesson_number, screened=None, quota=None,
age=None):
""" """
نسخه‌ی SSE از post(): همون منطقِ retrieval + کوتا + idempotency، ولی نسخه‌ی SSE از post(): همون منطقِ retrieval + کوتا + idempotency، ولی
پاسخ توکن‌به‌توکن استریم می‌شه. رویدادها: پاسخ توکن‌به‌توکن استریم می‌شه. رویدادها:
@@ -531,7 +549,7 @@ class AskAIView(APIView):
generation_meta: dict = {} generation_meta: dict = {}
answer_from_cache = False answer_from_cache = False
_col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number) _col, _fk, _fv = resolve_target(subject, grade_title, grade_id, lesson_number)
cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], grade_title) cache_key = answer_cache.make_key(_col, _fv, v['userQuestion'], _cache_grade(grade_title, age))
try: try:
history = _conversation_history(conversation, user_message) history = _conversation_history(conversation, user_message)
focus = _question_focus(v, user) focus = _question_focus(v, user)
@@ -566,7 +584,7 @@ class AskAIView(APIView):
effective_grade = retrieval_meta.get('gradeTitle') or grade_title or '' effective_grade = retrieval_meta.get('gradeTitle') or grade_title or ''
for delta in stream_answer( for delta in stream_answer(
v['userQuestion'], used_chunks or None, effective_grade, v['userQuestion'], used_chunks or None, effective_grade,
history=history, focus=focus, result=stream_result, history=history, focus=focus, result=stream_result, age=age,
): ):
parts.append(delta) parts.append(delta)
yield _sse('chunk', {'delta': delta}) yield _sse('chunk', {'delta': delta})

View File

@@ -475,6 +475,8 @@ REST_FRAMEWORK = {
'discount_validate': '20/hour', 'discount_validate': '20/hour',
'discount_redeem': '10/hour', 'discount_redeem': '10/hour',
'user_profile_write': '60/hour', 'user_profile_write': '60/hour',
# حذفِ حساب با کدِ پیامکی؛ سقفِ حدسِ کد جدا در verify_phone_otp است.
'account_delete': '5/hour',
'affiliate_track': '10/hour', 'affiliate_track': '10/hour',
'payment_webhook': '60/hour', 'payment_webhook': '60/hour',
'payment_pay': '30/hour', 'payment_pay': '30/hour',

View File

@@ -39,7 +39,7 @@ _RAW_MARKUP = re.compile(r'\\[A-Za-z(\[]|\$|\*\*|^\s*#{1,6}\s|^\s*\|\s*:?-{3,}',
# بشود گفت «کدام نسخه‌ی پرامپت جوابِ بهتری می‌داد» — بدونِ این، همه‌ی فیدبک‌ها # بشود گفت «کدام نسخه‌ی پرامپت جوابِ بهتری می‌داد» — بدونِ این، همه‌ی فیدبک‌ها
# در یک کاسه می‌ریزند و مقایسه‌ی نسخه‌ها ممکن نیست. کشِ پاسخ هم با همین نسخه # در یک کاسه می‌ریزند و مقایسه‌ی نسخه‌ها ممکن نیست. کشِ پاسخ هم با همین نسخه
# کلید می‌خورد، پس جوابِ نسخه‌ی قبلی بعد از دیپلوی دوباره سرو نمی‌شود. # کلید می‌خورد، پس جوابِ نسخه‌ی قبلی بعد از دیپلوی دوباره سرو نمی‌شود.
PROMPT_VERSION = 'tutor-v11-math-only' PROMPT_VERSION = 'tutor-v12-age-tone'
_PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'} _PRIMARY = {'اول', 'دوم', 'سوم', 'چهارم', 'پنجم', 'ششم'}
_MIDDLE = {'هفتم', 'هشتم', 'نهم'} _MIDDLE = {'هفتم', 'هشتم', 'نهم'}
@@ -65,9 +65,24 @@ def grade_tier(grade_title: str) -> str:
return '' return ''
def _persona(grade_title: str) -> tuple[str, str]: # از این سن به بعد لحن بزرگسالانه است، هر پایه‌ای که انتخاب شده باشد (سینیور، ۲۰۲۶-۱۰-۰۲:
"""(توصیفِ معلم، توصیفِ دانش‌آموز) متناسب با پایه.""" # «ممکن است یک بی‌سواد بخواهد کلاسِ ابتدایی بردارد»). پایه فقط محتوا و نمادگذاری را تعیین می‌کند.
ADULT_AGE = 18
def tone_for(grade_title: str, age: int | None) -> str:
"""کلیدِ _TONE: 'adult' برای بزرگسال، وگرنه سطحِ پایه."""
if age is not None and age >= ADULT_AGE:
return 'adult'
return grade_tier(grade_title)
def _persona(grade_title: str, age: int | None = None) -> tuple[str, str]:
"""(توصیفِ معلم، توصیفِ یادگیرنده) متناسب با پایه و سن."""
grade = (grade_title or '').strip() grade = (grade_title or '').strip()
if tone_for(grade, age) == 'adult':
level = f'ریاضیِ پایه‌ی {grade}' if grade else 'ریاضی'
return 'معلمِ ریاضیِ بزرگسالان', f'بزرگسالی که {level} را یاد می‌گیرد'
tier = grade_tier(grade) tier = grade_tier(grade)
if tier in ('middle', 'upper'): if tier in ('middle', 'upper'):
return f'معلمِ ریاضیِ پایه‌ی {grade}', f'دانش‌آموزِ پایه‌ی {grade}' return f'معلمِ ریاضیِ پایه‌ی {grade}', f'دانش‌آموزِ پایه‌ی {grade}'
@@ -98,6 +113,15 @@ _TONE = {
'- شرط‌ها را صریح چک کن: دامنه، مخرجِ مخالفِ صفر، زیرِ رادیکالِ نامنفی، ' '- شرط‌ها را صریح چک کن: دامنه، مخرجِ مخالفِ صفر، زیرِ رادیکالِ نامنفی، '
'علامتِ عبارت، واحدِ زاویه.' 'علامتِ عبارت، واحدِ زاویه.'
), ),
'adult': (
'با یک بزرگسال حرف می‌زنی؛ شاید سال‌ها از درس دور بوده، شاید تازه سواد آموخته، شاید '
'فقط می‌خواهد یک مبحث را یاد بگیرد. محترمانه، صبور و بی‌قضاوت.\n'
'- هرگز لحنِ کودکانه نداشته باش: نه «عزیزم» و «آفرین کوچولو»، نه شکلک، نه مثالِ '
'شکلات و اسباب‌بازی.\n'
'- مثال‌ها از زندگیِ بزرگسال: خرید و تخفیف، قبض، اجاره، دستمزد، آشپزی، مسافت و زمان.\n'
'- هیچ پیش‌دانسته‌ای فرض نکن: هر اصطلاح را اولین بار با زبانِ ساده معنی کن و هر مرحله '
'فقط یک کار باشد، حتی اگر مسئله ساده است.'
),
'': 'دوستانه، روشن و دقیق حرف بزن.', '': 'دوستانه، روشن و دقیق حرف بزن.',
} }
@@ -149,18 +173,19 @@ _METHOD = (
) )
def system_prompt(grade_title: str = '') -> str: def system_prompt(grade_title: str = '', age: int | None = None) -> str:
""" """
قبلاً «معلمِ دبستانی» ثابت بود؛ بعد فقط نامِ پایه در آن آمد. حالا لحن، قبلاً «معلمِ دبستانی» ثابت بود؛ بعد فقط نامِ پایه در آن آمد. حالا لحن،
روش (حلِ کامل + وارسی) و قالب (متنِ ساده) هر سه صریح‌اند. روش (حلِ کامل + وارسی) و قالب (متنِ ساده) هر سه صریح‌اند.
""" """
teacher, student = _persona(grade_title) teacher, student = _persona(grade_title, age)
# نمادگذاریِ عدد از کتابِ همان پایه است، نه سنِ خواننده.
numbers = _NUMBERS['primary' if grade_tier(grade_title) == 'primary' else 'default'] numbers = _NUMBERS['primary' if grade_tier(grade_title) == 'primary' else 'default']
return ( return (
f'تو «پی» هستی، دستیارِ ریاضیِ اپِ آموزشیِ تدریسینو، و مثلِ یک {teacher} ' f'تو «پی» هستی، دستیارِ ریاضیِ اپِ آموزشیِ تدریسینو، و مثلِ یک {teacher} '
f'با یک {student} حرف می‌زنی. همیشه فارسی جواب بده.\n\n' f'با یک {student} حرف می‌زنی. همیشه فارسی جواب بده.\n\n'
f'{guardrails.SCOPE_RULE}\n\n' f'{guardrails.SCOPE_RULE}\n\n'
f'{_TONE[grade_tier(grade_title)]}\n\n{_METHOD}\n\n{_FORMAT.format(numbers=numbers)}' f'{_TONE[tone_for(grade_title, age)]}\n\n{_METHOD}\n\n{_FORMAT.format(numbers=numbers)}'
f'\n\n{diagram_mod.DIAGRAM_RULE}' f'\n\n{diagram_mod.DIAGRAM_RULE}'
) )
@@ -245,7 +270,7 @@ def completion_kwargs(model: str) -> dict:
def build_messages(question: str, context_chunks: list[dict] | None, def build_messages(question: str, context_chunks: list[dict] | None,
grade_title: str = '', history: list[dict] | None = None, grade_title: str = '', history: list[dict] | None = None,
focus: str = '') -> list[dict]: focus: str = '', age: int | None = None) -> list[dict]:
""" """
history: [{'role': 'user'|'assistant', 'text': ...}, ...] به ترتیبِ زمان. history: [{'role': 'user'|'assistant', 'text': ...}, ...] به ترتیبِ زمان.
@@ -253,7 +278,7 @@ def build_messages(question: str, context_chunks: list[dict] | None,
دکمه‌ی پیشنهادی نشان می‌دهد) برای مدل هیچ معنایی نداشت. دکمه‌ی پیشنهادی نشان می‌دهد) برای مدل هیچ معنایی نداشت.
""" """
contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')] contexts = [c['summary'] for c in (context_chunks or []) if c.get('summary')]
messages = [{'role': 'system', 'content': system_prompt(grade_title)}] messages = [{'role': 'system', 'content': system_prompt(grade_title, age)}]
for turn in (history or [])[-HISTORY_TURNS:]: for turn in (history or [])[-HISTORY_TURNS:]:
role = 'assistant' if turn.get('role') == 'assistant' else 'user' role = 'assistant' if turn.get('role') == 'assistant' else 'user'
text = (turn.get('text') or '').strip() text = (turn.get('text') or '').strip()
@@ -273,7 +298,8 @@ def _previous_question(history) -> str:
def generate_answer(question: str, context_chunks: list[dict] | None, def generate_answer(question: str, context_chunks: list[dict] | None,
grade_title: str = '', history: list[dict] | None = None, grade_title: str = '', history: list[dict] | None = None,
model: str | None = None, focus: str = '') -> tuple[str, list[str], dict]: model: str | None = None, focus: str = '',
age: int | None = None) -> tuple[str, list[str], dict]:
""" """
context_chunks: خروجیِ retrieve_context ([{title, summary, score}, ...]) یا None/[]. context_chunks: خروجیِ retrieve_context ([{title, summary, score}, ...]) یا None/[].
خروجی: (answer, follow_ups, meta) خروجی: (answer, follow_ups, meta)
@@ -284,7 +310,7 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
`model` فقط برای مقایسه‌ی مدل‌ها (`rag_model_benchmark`) است. `model` فقط برای مقایسه‌ی مدل‌ها (`rag_model_benchmark`) است.
""" """
model = model or chat_model_for(grade_title) model = model or chat_model_for(grade_title)
messages = build_messages(question, context_chunks, grade_title, history, focus) messages = build_messages(question, context_chunks, grade_title, history, focus, age)
client = clients.get_openai_client() client = clients.get_openai_client()
started = time.monotonic() started = time.monotonic()
@@ -332,7 +358,7 @@ def generate_answer(question: str, context_chunks: list[dict] | None,
def stream_answer(question: str, context_chunks: list[dict] | None, def stream_answer(question: str, context_chunks: list[dict] | None,
grade_title: str = '', history: list[dict] | None = None, grade_title: str = '', history: list[dict] | None = None,
focus: str = '', result: dict | None = None) -> Iterator[str]: focus: str = '', result: dict | None = None, age: int | None = None) -> Iterator[str]:
""" """
`result` (اختیاری) بعد از پایانِ استریم کلیدِ 'diagram' را می‌گیرد: شکلِ `result` (اختیاری) بعد از پایانِ استریم کلیدِ 'diagram' را می‌گیرد: شکلِ
اعتبارسنجی‌شده یا None. بلوکِ شکل هیچ‌وقت به‌صورتِ متن yield نمی‌شود. اعتبارسنجی‌شده یا None. بلوکِ شکل هیچ‌وقت به‌صورتِ متن yield نمی‌شود.
@@ -347,7 +373,7 @@ def stream_answer(question: str, context_chunks: list[dict] | None,
client = clients.get_openai_client() client = clients.get_openai_client()
stream = client.chat.completions.create( stream = client.chat.completions.create(
model=model, model=model,
messages=build_messages(question, context_chunks, grade_title, history, focus), messages=build_messages(question, context_chunks, grade_title, history, focus, age),
stream=True, stream=True,
# آخرین تکه‌ی استریم usage را می‌آورد؛ بدونِ این، هزینه‌ی جواب‌های اپ ثبت نمی‌شد. # آخرین تکه‌ی استریم usage را می‌آورد؛ بدونِ این، هزینه‌ی جواب‌های اپ ثبت نمی‌شد.
stream_options={'include_usage': True}, stream_options={'include_usage': True},

View File

@@ -138,3 +138,23 @@ class GuardrailThroughTheApiTests(TestCase):
self.assertEqual(response.json()['answer'], guardrails.REFUSAL) self.assertEqual(response.json()['answer'], guardrails.REFUSAL)
self.assertEqual(response.json()['usedChunks'], []) self.assertEqual(response.json()['usedChunks'], [])
self.assertEqual(Message.objects.get(role='assistant').text, guardrails.REFUSAL) self.assertEqual(Message.objects.get(role='assistant').text, guardrails.REFUSAL)
class AgeToneTests(SimpleTestCase):
"""لحن از سن، محتوا از پایه: بزرگسالی که سومِ دبستان برداشته لحنِ کودکانه نمی‌گیرد."""
def test_adult_in_a_primary_grade_gets_the_adult_tone_but_the_books_notation(self):
from .services.generation import _NUMBERS, _TONE
prompt = system_prompt('سوم', age=40)
self.assertIn(_TONE['adult'], prompt)
self.assertNotIn(_TONE['primary'], prompt)
self.assertIn(_NUMBERS['primary'], prompt)
self.assertIn('بزرگسال', prompt.split('\n')[0])
def test_child_or_unknown_age_keeps_the_grade_tone(self):
from .services.generation import _TONE
self.assertIn(_TONE['primary'], system_prompt('سوم', age=8))
self.assertIn(_TONE['primary'], system_prompt('سوم'))
self.assertIn(_TONE['upper'], system_prompt('دوازدهم', age=17))

View File

@@ -0,0 +1,66 @@
"""
قیمتِ موقتِ پلن‌ها بر اساسِ هزینه‌ی دستیار (مهرِ ۱۴۰۵).
هر سوال از دستیار حدودِ ۶۰۰ تومان هزینه‌ی API دارد (docs/ai-cost-and-guardrails.md).
اشتراکِ سالانه‌ی ۲۹۹ هزار تومانی (۰۰۰۵) فقط ~۴۰ سوال در ماه را پوشش می‌داد، در حالی که
مشترک تا ۴۵۰ سوال در ماه می‌توانست بپرسد. این قیمت‌ها را صاحبِ پروژه «موقت و معقول»
خواست، تا تصمیمِ نهاییِ مدیرعامل:
- ماهانه ۹۹٬۰۰۰ تومان، سالانه ۹۹۰٬۰۰۰ تومان (قیمتِ ۱۰ ماه، دو ماه هدیه)، هر دو ۵ سوال در روز.
- کاربرِ معمولی (یک‌سومِ سقف، ~۵۰ سوال در ماه) ~۳۰ هزار تومان هزینه دارد: ~۳۶٪ِ درآمدِ خالصِ
ماهانه پس از سهمِ ۱۵٪یِ بازار، و ~۴۳٪ در سالانه. کاربری که هر روز سقف را پر کند (۱۵۰ سوال،
~۹۰ هزار تومان) در پلنِ ماهانه از درآمدش بیشتر خرج می‌کند؛ این سقف با درآمدِ واقعی بازبینی شود.
ویژگی‌ها فقط چیزی است که کد واقعاً به مشترک می‌دهد. درس‌ها، آزمون‌ها و گزارشِ والدین برای همه
رایگان‌اند و این‌جا نیامده‌اند. سقفِ سوال را خودِ اپ از ai_daily_quota می‌سازد و در features نیست.
قاعده: پلنِ سالانه فقط وقتی عوض می‌شود که هنوز همان قیمتِ پیش‌فرضِ ۲۹۹ هزار تومانِ ۰۰۰۵ را
دارد، یعنی کسی در ادمین ویرایشش نکرده. پلنِ ماهانه فقط وقتی ساخته می‌شود که هیچ پلنِ ۳۰ روزه‌ای
وجود ندارد.
"""
from django.db import migrations
YEARLY_TITLE = 'اشتراک یک‌ساله'
YEARLY = {
'price': 990_000,
'discount_percent': 17,
'popular': True,
'badge': 'دو ماه هدیه',
'ai_daily_quota': 5,
'features': ['قلبِ نامحدود؛ هیچ‌وقت وسطِ تمرین متوقف نمی‌شی', 'دوازده ماه، به قیمتِ ده ماه'],
}
MONTHLY = {
'title': 'اشتراک ماهانه',
'price': 99_000,
'currency': 'IRT',
'discount_percent': 0,
'popular': False,
'duration_days': 30,
'badge': '',
'ai_daily_quota': 5,
'features': ['قلبِ نامحدود؛ هیچ‌وقت وسطِ تمرین متوقف نمی‌شی', 'هر وقت خواستی تمدید کن'],
}
def apply_prices(apps, schema_editor):
Plan = apps.get_model('subscription', 'Plan')
Plan.objects.filter(title=YEARLY_TITLE, price=299_000).update(**YEARLY)
if not Plan.objects.filter(duration_days=30).exists():
Plan.objects.create(**MONTHLY)
def undo(apps, schema_editor):
Plan = apps.get_model('subscription', 'Plan')
Plan.objects.filter(title=MONTHLY['title'], subscriptions__isnull=True).delete()
Plan.objects.filter(title=YEARLY_TITLE, price=YEARLY['price']).update(price=299_000, ai_daily_quota=15)
class Migration(migrations.Migration):
dependencies = [
('subscription', '0006_plan_ai_daily_quota'),
]
operations = [
migrations.RunPython(apply_prices, undo),
]

View File

@@ -11,10 +11,13 @@ class PlanSerializer(serializers.Serializer):
durationDays = serializers.IntegerField(required=False) durationDays = serializers.IntegerField(required=False)
features = serializers.ListField(child=serializers.CharField()) features = serializers.ListField(child=serializers.CharField())
badge = serializers.CharField(allow_blank=True) badge = serializers.CharField(allow_blank=True)
aiDailyQuota = serializers.IntegerField(help_text='سوال در روز از دستیار برای مشترکِ این پلن.')
class PlansListResponseSerializer(serializers.Serializer): class PlansListResponseSerializer(serializers.Serializer):
plans = PlanSerializer(many=True) plans = PlanSerializer(many=True)
# تا کارتِ پلن بتواند بگوید «۵ سوال در روز (رایگان: ۳)» بی‌آن‌که عدد در فرانت هاردکد شود.
freeAiDailyQuota = serializers.IntegerField()
class PurchaseRequestSerializer(serializers.Serializer): class PurchaseRequestSerializer(serializers.Serializer):

View File

@@ -547,15 +547,36 @@ class PlanCardContractTests(TestCase):
class YearlyPlanSeedTests(TestCase): class YearlyPlanSeedTests(TestCase):
"""migrationِ 0005: سرورِ بی‌پلن یک پلنِ یک‌ساله می‌گیرد.""" """migrationهای 0005 و 0007: سرورِ بی‌پلن پلنِ سالانه و ماهانه با قیمتِ ۱۴۰۵-۰۷ می‌گیرد."""
def test_the_yearly_plan_exists_after_migrations(self): def test_the_yearly_plan_exists_after_migrations(self):
plan = Plan.objects.get(title='اشتراک یک‌ساله') plan = Plan.objects.get(title='اشتراک یک‌ساله')
self.assertEqual(plan.duration_days, 365) self.assertEqual(plan.duration_days, 365)
self.assertEqual(plan.price, 299_000) self.assertEqual((plan.price, plan.ai_daily_quota), (990_000, 5))
self.assertEqual(plan.currency, 'IRT') self.assertEqual(plan.currency, 'IRT')
self.assertTrue(plan.features) self.assertTrue(plan.features)
def test_a_monthly_plan_is_created_next_to_it(self):
plan = Plan.objects.get(duration_days=30)
self.assertEqual((plan.price, plan.ai_daily_quota), (99_000, 5))
def test_repricing_leaves_an_admin_edited_yearly_plan_alone(self):
import importlib
from django.apps import apps as global_apps
reprice = importlib.import_module('subscription.migrations.0007_plans_priced_by_ai_cost')
Plan.objects.filter(title='اشتراک یک‌ساله').update(price=450_000, ai_daily_quota=9)
reprice.apply_prices(global_apps, None)
plan = Plan.objects.get(title='اشتراک یک‌ساله')
self.assertEqual((plan.price, plan.ai_daily_quota), (450_000, 9))
self.assertEqual(Plan.objects.filter(duration_days=30).count(), 1)
def test_plans_api_reports_each_quota_and_the_free_one(self):
from django.core.cache import cache
cache.clear()
data = self.client.get('/api/subscription/plans').json()
self.assertEqual(data['freeAiDailyQuota'], 3)
self.assertEqual({p['aiDailyQuota'] for p in data['plans']}, {5})
def test_it_is_served_to_the_app(self): def test_it_is_served_to_the_app(self):
from django.core.cache import cache from django.core.cache import cache
cache.clear() cache.clear()

View File

@@ -1,3 +1,4 @@
from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.db import transaction from django.db import transaction
from django.db.models import F from django.db.models import F
@@ -42,7 +43,7 @@ class GuestAwarePurchaseThrottle(ScopedRateThrottle):
# پلن‌ها تقریباً هیچ‌وقت عوض نمی‌شوند و روی صفحه‌ی اشتراک هر بار خوانده می‌شوند. # پلن‌ها تقریباً هیچ‌وقت عوض نمی‌شوند و روی صفحه‌ی اشتراک هر بار خوانده می‌شوند.
# با هر save/delete روی Plan کش باطل می‌شود (subscription/receivers.py). # با هر save/delete روی Plan کش باطل می‌شود (subscription/receivers.py).
PLANS_CACHE_KEY = 'subscription:plans:v1' PLANS_CACHE_KEY = 'subscription:plans:v2' # v2: aiDailyQuota و freeAiDailyQuota
PLANS_CACHE_TTL = 3600 PLANS_CACHE_TTL = 3600
@@ -64,9 +65,11 @@ class PlansListView(APIView):
'durationDays': p.duration_days, 'durationDays': p.duration_days,
'features': p.features or [], 'features': p.features or [],
'badge': p.badge, 'badge': p.badge,
'aiDailyQuota': p.ai_daily_quota,
} }
for p in Plan.objects.all() for p in Plan.objects.order_by('duration_days', 'price')
] ],
'freeAiDailyQuota': settings.AI_DAILY_QUOTA_FREE,
} }
data = cache.get_or_set(PLANS_CACHE_KEY, build, PLANS_CACHE_TTL) data = cache.get_or_set(PLANS_CACHE_KEY, build, PLANS_CACHE_TTL)