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:
@@ -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']
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
32
accounts/public_id.py
Normal 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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
66
accounts/tests_delete_account.py
Normal file
66
accounts/tests_delete_account.py
Normal 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())
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
66
subscription/migrations/0007_plans_priced_by_ai_cost.py
Normal file
66
subscription/migrations/0007_plans_priced_by_ai_cost.py
Normal 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),
|
||||||
|
]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user