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)
|
||||
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']
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
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):
|
||||
"""پاسخ 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)
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
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.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())
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user