- 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>
121 lines
7.0 KiB
Python
121 lines
7.0 KiB
Python
"""
|
||
پاک کردنِ کاملِ چند کاربر و همهی دادهشان در همهی اپها.
|
||
|
||
چرا لازم شد (۲۰۲۶-۱۰-۰۲): پیش از رسیدنِ کاربرِ واقعی، حسابهایی که تیم با شمارهی واقعیِ خودش
|
||
ساخته بود باید پاک شوند تا همان شمارهها دوباره مسیرِ «کاربرِ تازه» را ببینند. پاک کردنِ ردیفِ
|
||
`accounts.User` کافی نیست: اپها به کاربر فقط ارجاعِ سبک (`user_id`) دارند، نه FK، پس امتیاز،
|
||
گفتگو، سفارش و اعلانِ او یتیم میماند و کاربرِ تازهای که همان شناسه را بگیرد آنها را میدید
|
||
(روی SQLite شناسه دوباره داده نمیشود، ولی روی Postgres پس از بازنشانیِ sequence ممکن است).
|
||
|
||
مدلها خودکار پیدا میشوند: هر مدلی که ستونِ `user_id` یا `referred_user_id` دارد. پس اپِ تازه
|
||
بیویرایشِ این فایل پوشش داده میشود. کدهای OTPِ همان شمارهها هم پاک میشوند تا سقفِ روزانهی
|
||
پیامکِ آن شماره از صفر شروع شود.
|
||
|
||
پیشفرض فقط گزارش است و چیزی پاک نمیکند:
|
||
|
||
python manage.py delete_users --phones 09121234567 09351234567
|
||
python manage.py delete_users --ids 17 22 --yes
|
||
python manage.py delete_users --real-phones # هر کاربرِ شمارهدار جز شمارههای دمو
|
||
|
||
`--real-phones` یعنی هر کاربری که شماره دارد و شمارهاش در TEST_LOGIN_PHONES (حسابهای دمو با کدِ
|
||
ثابت) نیست. شمارهها از سرور بیرون نمیآیند؛ گزارش فقط چهار رقمِ اول و سه رقمِ آخر را نشان میدهد.
|
||
مهمانها (بیشماره) با این گزینه انتخاب نمیشوند.
|
||
|
||
سفارشِ پرداختشده سندِ مالی است؛ کاربری که سفارشِ «paid» دارد بدونِ --include-paid پاک نمیشود.
|
||
"""
|
||
from django.apps import apps
|
||
from django.core.management.base import BaseCommand, CommandError
|
||
from django.db import transaction
|
||
|
||
from accounts.models import OTPCode, User
|
||
from accounts.phone import normalize_phone
|
||
|
||
USER_COLUMNS = ('user_id', 'referred_user_id')
|
||
|
||
|
||
def user_scoped_models():
|
||
"""[(model, column)] برای هر مدلی که به کاربر ارجاع میدهد، جز خودِ User."""
|
||
found = []
|
||
for model in apps.get_models():
|
||
if model is User:
|
||
continue
|
||
names = {f.attname for f in model._meta.concrete_fields}
|
||
found += [(model, column) for column in USER_COLUMNS if column in names]
|
||
return found
|
||
|
||
|
||
def plan(user_ids):
|
||
"""{'app.Model.column': count} — آنچه پاک میشود، پیش از پاک شدن."""
|
||
counts = {}
|
||
for model, column in user_scoped_models():
|
||
n = model.objects.filter(**{f'{column}__in': user_ids}).count()
|
||
if n:
|
||
counts[f'{model._meta.label}.{column}'] = n
|
||
return counts
|
||
|
||
|
||
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()
|
||
|
||
|
||
class Command(BaseCommand):
|
||
help = 'کاربر و همهی دادهاش را در همهی اپها پاک میکند (پیشفرض: فقط گزارش).'
|
||
|
||
def add_arguments(self, parser):
|
||
parser.add_argument('--phones', nargs='*', default=[])
|
||
parser.add_argument('--ids', nargs='*', type=int, default=[])
|
||
parser.add_argument('--real-phones', action='store_true',
|
||
help='همهی کاربرهای شمارهدار جز شمارههای TEST_LOGIN_PHONES.')
|
||
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']]
|
||
users = list(User.objects.filter(phone__in=phones)) + list(User.objects.filter(pk__in=options['ids']))
|
||
if options['real_phones']:
|
||
from django.conf import settings
|
||
|
||
users += list(User.objects.exclude(phone=None).exclude(phone='')
|
||
.exclude(phone__in=list(settings.TEST_LOGIN_PHONES)))
|
||
users = list({u.pk: u for u in users}.values())
|
||
missing = set(phones) - {u.phone for u in users}
|
||
if missing:
|
||
self.stdout.write(f'این شمارهها کاربری ندارند: {len(missing)}')
|
||
if not users:
|
||
raise CommandError('کاربری برای پاک کردن پیدا نشد.')
|
||
|
||
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 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]
|
||
for u in users:
|
||
phone = f'{u.phone[:4]}***{u.phone[-3:]}' if u.phone else 'مهمان'
|
||
self.stdout.write(f' کاربرِ {u.pk} {phone} ساختهشده {u.created_at:%Y-%m-%d}')
|
||
for label, n in sorted(plan(ids).items()):
|
||
self.stdout.write(f' {label}: {n}')
|
||
|
||
if not options['yes']:
|
||
self.stdout.write(self.style.WARNING('فقط گزارش بود؛ برای پاک کردن --yes بدهید.'))
|
||
return
|
||
delete_users(ids)
|
||
self.stdout.write(self.style.SUCCESS(f'{len(ids)} کاربر و همهی دادهشان پاک شدند.'))
|