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

@@ -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())