senior's post-demo changes: parent report in the student's own account, Jalali birth date, invoices, math-only tutor, priced quotas
- remove the family app from the root (separate parent accounts, invite codes, parental settings); progress.0006 drops its tables, permissions and migration history, and Profile.parent_phone, the two family notification types and ?userId= on /api/progress/nodes go with it - GET /api/user/weekly-report: last 7 days next to the 7 before (estimated minutes, questions, accuracy, islands, tutor questions) for the "parent report" page - profile birthDate in Jalali (accounts/jalali.py), ages 5-99, birthYear derived from it - GET /api/payment/invoices; orders now snapshot list price and plan title - tutor answers only math: server pre-filter, scope rule in the prompt (tutor-v11), refusal marker replaced with fixed text; self-harm gets a help line instead - streamed answers now record token usage (it was always 0) - daily tutor quota: AI_DAILY_QUOTA_FREE (default 3) vs Plan.ai_daily_quota - delete_users management command for wiping test accounts across every app - docs/ai-cost-and-guardrails.md: measured cost per question and guardrail results Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
98
accounts/management/commands/delete_users.py
Normal file
98
accounts/management/commands/delete_users.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
پاک کردنِ کاملِ چند کاربر و همهی دادهشان در همهی اپها.
|
||||
|
||||
چرا لازم شد (۲۰۲۶-۱۰-۰۲): پیش از رسیدنِ کاربرِ واقعی، حسابهایی که تیم با شمارهی واقعیِ خودش
|
||||
ساخته بود باید پاک شوند تا همان شمارهها دوباره مسیرِ «کاربرِ تازه» را ببینند. پاک کردنِ ردیفِ
|
||||
`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
|
||||
|
||||
سفارشِ پرداختشده سندِ مالی است؛ کاربری که سفارشِ «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):
|
||||
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():
|
||||
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('--yes', action='store_true', help='واقعاً پاک کن.')
|
||||
parser.add_argument('--include-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']))
|
||||
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 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)} کاربر و همهی دادهشان پاک شدند.'))
|
||||
@@ -4,9 +4,9 @@
|
||||
یک کلید طبیعی/یکتا (phone، device_id، code، idempotency_key و ...) انجام
|
||||
میشه، نه با create ساده.
|
||||
|
||||
هدف: پوشش کمینهی جریانهای اصلی API در هر ۱۱ اپ (accounts، content،
|
||||
هدف: پوشش کمینهی جریانهای اصلی API در هر ۱۰ اپ (accounts، content،
|
||||
progress، ai_tutor، subscription، payment، discount، notifications،
|
||||
affiliate، support، family) با کمترین حجم دادهی لازم برای اینکه هر
|
||||
affiliate، support) با کمترین حجم دادهی لازم برای اینکه هر
|
||||
endpoint اصلی بدون نیاز به ساختن دستی پیشنیازهاش قابل تست باشه.
|
||||
|
||||
⚠️ این دستور فقط برای محیط لوکال/staging است، نه پروداکشن:
|
||||
@@ -44,8 +44,6 @@ from accounts.models import OTPCode, Profile, Session, User
|
||||
# چون این فقط دیتای لوکال/staging است نه پروداکشن، همین کافیه. با پیشوند
|
||||
# یکسان و بازهی اختصاصی، شناساییِ رکوردهای seed در دیتابیس هم ساده میمونه.
|
||||
MAIN_PHONE = '09120000001'
|
||||
PARENT_PHONE = '09120000002'
|
||||
CHILD_PHONE = '09120000003'
|
||||
REFERRED_PHONE = '09120000004'
|
||||
GUEST_DEVICE_ID = 'seed-guest-device-0001'
|
||||
|
||||
@@ -101,14 +99,13 @@ class Command(BaseCommand):
|
||||
from affiliate.models import AffiliateAccount, Conversion
|
||||
from content.models import Grade, NodeProgress, UserAnswer, UserLessonCompletion
|
||||
from discount.models import DiscountCode, Offer, Redemption, UserOffer
|
||||
from family.models import FamilyLink
|
||||
from notifications.models import Notification
|
||||
from payment.models import Order
|
||||
from progress.models import LeaderboardEntry, StreakRecord, UserHearts, UserStats
|
||||
from subscription.models import Plan, Subscription
|
||||
from support.models import BugReport, Ticket
|
||||
|
||||
phones = [MAIN_PHONE, PARENT_PHONE, CHILD_PHONE, REFERRED_PHONE]
|
||||
phones = [MAIN_PHONE, REFERRED_PHONE]
|
||||
user_ids = list(User.objects.filter(phone__in=phones).values_list('pk', flat=True))
|
||||
guest = User.objects.filter(device_id=GUEST_DEVICE_ID, is_guest=True).first()
|
||||
if guest:
|
||||
@@ -132,7 +129,6 @@ class Command(BaseCommand):
|
||||
Conversion.objects.filter(referred_user_id__in=user_ids).delete()
|
||||
Ticket.objects.filter(user_id__in=user_ids).delete()
|
||||
BugReport.objects.filter(user_id__in=user_ids).delete()
|
||||
FamilyLink.objects.filter(parent_user_id__in=user_ids).delete()
|
||||
|
||||
from progress.models import Character
|
||||
Character.objects.filter(slug__startswith='seed-').delete()
|
||||
@@ -154,7 +150,7 @@ class Command(BaseCommand):
|
||||
|
||||
# -- seed -------------------------------------------------------------
|
||||
def _seed(self):
|
||||
main_user, guest_user, parent_user, child_user, referred_user = self._seed_accounts()
|
||||
main_user, guest_user, referred_user = self._seed_accounts()
|
||||
self._seed_content_and_progress(main_user)
|
||||
self._seed_characters()
|
||||
self._seed_ai_tutor(main_user)
|
||||
@@ -163,12 +159,10 @@ class Command(BaseCommand):
|
||||
self._seed_notifications(main_user)
|
||||
self._seed_affiliate(main_user, referred_user)
|
||||
self._seed_support(main_user)
|
||||
self._seed_family(parent_user, child_user)
|
||||
|
||||
tokens = [
|
||||
('کاربر اصلی (ثبتنامشده)', main_user, issue_access_token(main_user.pk)),
|
||||
('کاربر مهمان', guest_user, issue_access_token(guest_user.pk)),
|
||||
('والد', parent_user, issue_access_token(parent_user.pk)),
|
||||
]
|
||||
return {'tokens': tokens}
|
||||
|
||||
@@ -179,7 +173,7 @@ class Command(BaseCommand):
|
||||
defaults=dict(name=f'{SEED_TAG} کاربر اصلی', is_guest=False, grade_id=None),
|
||||
)
|
||||
Profile.objects.get_or_create(
|
||||
user=main_user, defaults=dict(birth_year=2015, parent_phone=PARENT_PHONE, bio=f'{SEED_TAG} پروفایل تستی')
|
||||
user=main_user, defaults=dict(birth_year=1394, bio=f'{SEED_TAG} پروفایل تستی')
|
||||
)
|
||||
Session.objects.get_or_create(
|
||||
user=main_user,
|
||||
@@ -206,18 +200,12 @@ class Command(BaseCommand):
|
||||
),
|
||||
)
|
||||
|
||||
parent_user, _ = User.objects.get_or_create(
|
||||
phone=PARENT_PHONE, defaults=dict(name=f'{SEED_TAG} والد', is_guest=False)
|
||||
)
|
||||
child_user, _ = User.objects.get_or_create(
|
||||
phone=CHILD_PHONE, defaults=dict(name=f'{SEED_TAG} فرزند', is_guest=False)
|
||||
)
|
||||
referred_user, _ = User.objects.get_or_create(
|
||||
phone=REFERRED_PHONE, defaults=dict(name=f'{SEED_TAG} کاربر معرفیشده', is_guest=False)
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('accounts: 5 کاربر (اصلی/مهمان/والد/فرزند/معرفیشده) آماده شد.'))
|
||||
return main_user, guest_user, parent_user, child_user, referred_user
|
||||
self.stdout.write(self.style.SUCCESS('accounts: 3 کاربر (اصلی/مهمان/معرفیشده) آماده شد.'))
|
||||
return main_user, guest_user, referred_user
|
||||
|
||||
# -- content + progress --------------------------------------------------
|
||||
def _seed_content_and_progress(self, main_user):
|
||||
@@ -469,28 +457,3 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('support: 1 تیکت با یک پیام، و 1 گزارش باگ آماده شد.'))
|
||||
|
||||
# -- family -----------------------------------------------------------------------
|
||||
def _seed_family(self, parent_user, child_user):
|
||||
from family.models import FamilyLink, ParentalSettings, WeeklyReportLog
|
||||
|
||||
link, _ = FamilyLink.objects.get_or_create(
|
||||
parent_user_id=parent_user.pk,
|
||||
child_phone=CHILD_PHONE,
|
||||
defaults=dict(
|
||||
child_user_id=child_user.pk,
|
||||
invite_code='SEEDFAM01',
|
||||
status='accepted',
|
||||
accepted_at=timezone.now(),
|
||||
),
|
||||
)
|
||||
ParentalSettings.objects.get_or_create(
|
||||
family_link=link, defaults=dict(daily_limit_minutes=60)
|
||||
)
|
||||
WeeklyReportLog.objects.get_or_create(
|
||||
family_link=link,
|
||||
week_start=(timezone.now() - timedelta(days=timezone.now().weekday())).date(),
|
||||
defaults=dict(sent_at=timezone.now()),
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('family: 1 اتصالِ خانواده (پذیرفتهشده) با تنظیمات و گزارشِ هفتگی آماده شد.'))
|
||||
|
||||
Reference in New Issue
Block a user