""" پاک کردنِ کامل‌ِ چند کاربر و همه‌ی داده‌شان در همه‌ی اپ‌ها. چرا لازم شد (۲۰۲۶-۱۰-۰۲): پیش از رسیدنِ کاربرِ واقعی، حساب‌هایی که تیم با شماره‌ی واقعیِ خودش ساخته بود باید پاک شوند تا همان شماره‌ها دوباره مسیرِ «کاربرِ تازه» را ببینند. پاک کردنِ ردیفِ `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. فقط اپ‌هایی که داخلِ پوشه‌ی پروژه‌اند. `admin.LogEntry` هم ستونِ user_id دارد ولی آن کارمندِ ادمینِ جنگوست، نه کاربرِ اپ؛ بارِ اول که این دستور روی سرور گزارش گرفت، همان را هم شمرده بود و اگر اجرا می‌شد تاریخچه‌ی ادمینِ شماره‌ی ۱ پاک می‌شد. """ from pathlib import Path from django.conf import settings base = Path(settings.BASE_DIR).resolve() found = [] for model in apps.get_models(): if model is User: continue app_path = Path(model._meta.app_config.path).resolve() if base not in app_path.parents or 'site-packages' in app_path.parts: 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=(), delete_otp_codes=True): """ keep: برچسبِ مدل‌هایی که نباید پاک شوند (مثلاً 'payment.Order' برای حذفِ حسابِ خودِ کاربر). delete_otp_codes: این دستور (کارِ ادمین) کدهای شماره را پاک می‌کند تا آن شماره از صفر شروع کند؛ حذفِ حساب به دستِ خودِ کاربر این کار را نمی‌کند تا سقفِ پیامکِ شماره دور زده نشود. """ 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() if delete_otp_codes: 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)} کاربر و همه‌ی داده‌شان پاک شدند.'))