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:
2026-10-02 02:38:43 +03:30
parent d5dbd1b23d
commit 39247f6d53
67 changed files with 1619 additions and 1140 deletions

View 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)} کاربر و همه‌ی داده‌شان پاک شدند.'))

View File

@@ -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 اتصالِ خانواده (پذیرفته‌شده) با تنظیمات و گزارشِ هفتگی آماده شد.'))