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)
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')
@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',)
actions = ['send_sms_to_selected']

View File

@@ -92,3 +92,15 @@ def parse(value: str) -> date:
def to_string(d: date) -> str:
jy, jm, jd = from_gregorian(d)
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
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))
with transaction.atomic():
for model, column in user_scoped_models():
if model._meta.label in keep:
continue
model.objects.filter(**{f'{column}__in': user_ids}).delete()
OTPCode.objects.filter(phone__in=phones).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('--include-paid', action='store_true',
help='کاربرِ دارای سفارشِ پرداخت‌شده را هم پاک کن.')
parser.add_argument('--skip-paid', action='store_true',
help='کاربرِ دارای سفارشِ پرداخت‌شده را کنار بگذار و بقیه را پاک کن.')
def handle(self, *args, **options):
phones = [normalize_phone(p) or p for p in options['phones']]
@@ -93,7 +98,12 @@ class Command(BaseCommand):
from payment.models import Order
paid = set(Order.objects.filter(user_id__in=[u.pk for u in users], status='paid')
.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 پاک می‌شوند.')
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):
"""پاسخ GET /api/user/profile — فقط داده‌ی لازم برای تب Profile اپ موبایل."""
# شناسه‌ی کاربر همان شماره‌ی ثبتِ اوست (کاربرِ چندم)؛ اپ آن را زیرِ نام نشان می‌دهد
# تا در پشتیبانی بشود حساب را بی‌شماره‌ی تلفن پیدا کرد.
userId = serializers.CharField()
# شناسه‌ی نمایشی («User482917»، accounts/public_id.py)؛ اپ آن را زیرِ نام نشان می‌دهد
# و پشتیبانی با همین در پنلِ ادمین حساب را پیدا می‌کند.
publicId = serializers.CharField()
phone = serializers.CharField(allow_null=True)
name = serializers.CharField()
avatar = serializers.CharField()
@@ -172,6 +173,17 @@ class ProfileResponseSerializer(serializers.Serializer):
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):
refreshToken = serializers.CharField(required=False, allow_blank=True)

View File

@@ -88,3 +88,17 @@ class ProfileBirthDateApiTests(TestCase):
def test_parent_phone_is_gone(self):
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.assertTrue(User.objects.filter(pk=self.kept.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 .views import (
DeleteAccountView,
GuestSessionView,
LoginView,
LogoutView,
@@ -18,4 +19,5 @@ urlpatterns = [
path('auth/logout', LogoutView.as_view(), name='auth-logout'),
path('user/grade', SetUserGradeView.as_view(), name='user-grade'),
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 .services import OTPDeliveryError, send_otp_sms
from .serializers import (
DeleteAccountRequestSerializer,
DeleteAccountResponseSerializer,
GuestSessionRequestSerializer,
GuestSessionResponseSerializer,
LoginRequestSerializer,
@@ -279,6 +281,57 @@ class RequestOTPView(APIView):
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):
"""POST /api/auth/login — ورود با OTP"""
@@ -296,57 +349,11 @@ class LoginView(APIView):
phone = serializer.validated_data['phone']
otp = serializer.validated_data['otp']
# محدودیت تلاش ورود بر اساس شماره تلفن (مستقل از throttle IP-محور بالا).
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
error = verify_phone_otp(phone, otp)
if error is not None:
return error
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():
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()
created = False
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()
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):
"""
POST /api/auth/logout — خروج از حساب.
@@ -530,10 +591,11 @@ class SetUserGradeView(APIView):
def _profile_payload(user, profile) -> dict:
from . import jalali
from . import jalali, public_id
return {
'userId': str(user.id),
'publicId': public_id.encode(user.id),
'phone': user.phone,
'name': user.name,
'avatar': user.avatar,