Files
MathApp/accounts/management/commands/seed_test_data.py
Zareian 663252a260 Remove the guest system entirely: no is_guest/device_id, no guest throttles, phone login is the only way in
accounts.0008 drops the columns and refuses to run while guest rows exist.
Docs describe the current state; tests that only proved guest behaviour are gone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 18:20:10 +03:30

435 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
یک دیتاست کوچک و واقعی برای تست دستی/API لوکال یا staging می‌سازه، طوری که
اجرای مکرر (repeat) هیچ رکورد تکراری‌ای نسازه — همه‌چیز با get_or_create روی
یک کلید طبیعی/یکتا (phone، code، idempotency_key و ...) انجام
می‌شه، نه با create ساده.
هدف: پوشش کمینه‌ی جریان‌های اصلی API در هر ۱۰ اپ (accounts، content،
progress، ai_tutor، subscription، payment، discount، notifications،
affiliate، support) با کمترین حجم داده‌ی لازم برای این‌که هر
endpoint اصلی بدون نیاز به ساختن دستی پیش‌نیازهاش قابل تست باشه.
⚠️ این دستور فقط برای محیط لوکال/staging است، نه پروداکشن:
- هیچ سکرت واقعی (کلید API واقعی کاوه‌نگار، gateway واقعی و ...) نمی‌سازه؛
Order/Transaction با provider/gateway_ref_id ساختگی و مقدارِ ثابت ساخته
می‌شن (شبیه‌سازیِ یک پرداختِ موفق، نه فراخوانیِ واقعیِ درگاه).
- برای جلوگیری از اجرای تصادفی روی یک دیتابیسِ پروداکشن، پیش‌فرض فقط وقتی
DEBUG=True اجرا می‌شه؛ برای اجرای صریح در حالت DEBUG=False (مثلاً یک
محیط staging که عمداً DEBUG خاموش نگه داشته می‌شه) باید `--force` بدید.
اجرا:
python manage.py seed_test_data
python manage.py seed_test_data --force # وقتی DEBUG=False (staging)
python manage.py seed_test_data --reset # پاک‌کردن دیتای قبلیِ همین seed و ساختِ دوباره
idempotent است: اجرای دوباره (بدون --reset) نه رکورد تکراری می‌سازه و نه
خطا می‌ده؛ فقط همون رکوردهای قبلی رو پیدا می‌کنه (get_or_create) و دوباره
گزارششون می‌ده. تمام رکوردهای ساخته‌شده با پیشوند «[SEED]» در فیلدهای
متنیِ قابل‌مشاهده (عنوان/نام) مشخص شدن تا با دیتای واقعی اشتباه گرفته
نشن و پاک‌سازیِ دستی هم راحت باشه.
"""
from datetime import timedelta
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from accounts.authentication import issue_access_token
from accounts.models import OTPCode, Profile, Session, User, hash_refresh_token
# --- شناسه‌های ثابتِ داده‌ی seed --------------------------------------------
# شماره‌های 0912000000X عمداً خارج از بازه‌ی واقعیِ اپراتورهای ایران نیستن؛
# چون این فقط دیتای لوکال/staging است نه پروداکشن، همین کافیه. با پیشوند
# یکسان و بازه‌ی اختصاصی، شناساییِ رکوردهای seed در دیتابیس هم ساده می‌مونه.
MAIN_PHONE = '09120000001'
REFERRED_PHONE = '09120000004'
SEED_TAG = '[SEED]'
class Command(BaseCommand):
help = (
'یک دیتاست کوچک و idempotent برای تستِ دستی/API روی محیطِ لوکال یا '
'staging می‌سازه (کاربر، محتوای درسی، پیشرفت، اشتراک/پرداخت، تخفیف، '
'دستیار هوش مصنوعی، اعلان، افیلیت، پشتیبانی، خانواده). اجرای مکرر '
'رکورد تکراری نمی‌سازه.'
)
def add_arguments(self, parser):
parser.add_argument(
'--force',
action='store_true',
help='اجرای دستور حتی وقتی DEBUG=False است (مثلاً staging عمدی).',
)
parser.add_argument(
'--reset',
action='store_true',
help='قبل از ساخت، فقط همون رکوردهای seed (بر اساس شماره/شناسه‌های ثابتِ بالا) حذف می‌شه.',
)
def handle(self, *args, **options):
if not settings.DEBUG and not options['force']:
raise CommandError(
'DEBUG=False است — این دستور برای دیتای پروداکشن نیست. اگه '
'واقعاً می‌خواید روی این محیط (مثلاً staging) اجرا بشه، '
'--force بدید.'
)
with transaction.atomic():
if options['reset']:
self._reset()
summary = self._seed()
self.stdout.write(self.style.SUCCESS('\nseed_test_data با موفقیت تمام شد.'))
self.stdout.write('\nکاربرهای تستی (برای Authorize در Swagger، فقط خودِ token را بدون "Bearer" وارد کنید):')
for label, user, token in summary['tokens']:
self.stdout.write(f' - {label}: user_id={user.pk} phone={user.phone or "-"} token={token}')
self.stdout.write(
'\nنکته: این توکن‌ها با ACCESS_TOKEN_TTL_SECONDS فعلی امضا شدن و بعد از انقضا '
'باید دوباره seed یا از /api/auth/login گرفته بشن.'
)
# -- reset ----------------------------------------------------------
def _reset(self):
"""فقط دقیقاً همون رکوردهایی که این دستور می‌سازه رو (بر اساس کلیدهای ثابتِ بالا) حذف می‌کنه."""
from ai_tutor.models import Conversation
from affiliate.models import AffiliateAccount, Conversion
from content.models import Grade, NodeProgress, UserAnswer, UserLessonCompletion
from discount.models import DiscountCode, Offer, Redemption, UserOffer
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, REFERRED_PHONE]
user_ids = list(User.objects.filter(phone__in=phones).values_list('pk', flat=True))
if user_ids:
UserAnswer.objects.filter(user_id__in=user_ids).delete()
UserLessonCompletion.objects.filter(user_id__in=user_ids).delete()
NodeProgress.objects.filter(user_id__in=user_ids).delete()
UserStats.objects.filter(user_id__in=user_ids).delete()
LeaderboardEntry.objects.filter(user_id__in=user_ids).delete()
StreakRecord.objects.filter(user_id__in=user_ids).delete()
UserHearts.objects.filter(user_id__in=user_ids).delete()
Conversation.objects.filter(user_id__in=user_ids).delete()
Subscription.objects.filter(user_id__in=user_ids).delete()
Order.objects.filter(user_id__in=user_ids).delete()
UserOffer.objects.filter(user_id__in=user_ids).delete()
Redemption.objects.filter(user_id__in=user_ids).delete()
Notification.objects.filter(user_id__in=user_ids).delete()
AffiliateAccount.objects.filter(user_id__in=user_ids).delete()
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()
from progress.models import Character
Character.objects.filter(slug__startswith='seed-').delete()
Plan.objects.filter(title__startswith=SEED_TAG).delete()
DiscountCode.objects.filter(code='SEEDTEST10').delete()
Offer.objects.filter(code='SEEDTRIAL7').delete()
Grade.objects.filter(title__startswith=SEED_TAG).delete() # cascades units/nodes/lessons/questions/choices
OTPCode.objects.filter(phone__in=phones).delete()
Session.objects.filter(user__phone__in=phones).delete()
User.objects.filter(phone__in=phones).delete()
self.stdout.write('رکوردهای قبلیِ seed حذف شدن؛ در حال ساختِ دوباره...')
# -- seed -------------------------------------------------------------
def _seed(self):
main_user, referred_user = self._seed_accounts()
self._seed_content_and_progress(main_user)
self._seed_characters()
self._seed_ai_tutor(main_user)
plan_monthly, plan_yearly = self._seed_subscription_and_payment(main_user)
self._seed_discount(main_user)
self._seed_notifications(main_user)
self._seed_affiliate(main_user, referred_user)
self._seed_support(main_user)
tokens = [
('کاربر اصلی', main_user, issue_access_token(main_user.pk)),
]
return {'tokens': tokens}
# -- accounts -----------------------------------------------------------
def _seed_accounts(self):
main_user, _ = User.objects.get_or_create(
phone=MAIN_PHONE,
defaults=dict(name=f'{SEED_TAG} کاربر اصلی', grade_id=None),
)
Profile.objects.get_or_create(
user=main_user, defaults=dict(birth_year=1394, bio=f'{SEED_TAG} پروفایل تستی')
)
Session.objects.get_or_create(
user=main_user,
refresh_token=hash_refresh_token(f'seed-refresh-{MAIN_PHONE}'),
defaults=dict(
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
revoked=False,
),
)
referred_user, _ = User.objects.get_or_create(
phone=REFERRED_PHONE, defaults=dict(name=f'{SEED_TAG} کاربر معرفی‌شده')
)
self.stdout.write(self.style.SUCCESS('accounts: ۲ کاربر (اصلی/معرفی‌شده) آماده شد.'))
return main_user, referred_user
# -- content + progress --------------------------------------------------
def _seed_content_and_progress(self, main_user):
from content.models import Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion
from progress.models import ActiveDay, LeaderboardEntry, StreakRecord, UserHearts, UserStats
grade, _ = Grade.objects.get_or_create(
title=f'{SEED_TAG} پایه سوم', defaults=dict(icon='grade-3', color='#4CAF50')
)
unit, _ = Unit.objects.get_or_create(grade=grade, title=f'{SEED_TAG} جمع و تفریق', defaults=dict(order=1))
node1, _ = Node.objects.get_or_create(unit=unit, title=f'{SEED_TAG} جمع اعداد یک‌رقمی', defaults=dict(order=1))
node2, _ = Node.objects.get_or_create(unit=unit, title=f'{SEED_TAG} تفریق اعداد یک‌رقمی', defaults=dict(order=2))
lesson1, _ = Lesson.objects.get_or_create(
node=node1,
title=f'{SEED_TAG} درس اول جمع',
defaults=dict(intro_text='دو عدد را با هم جمع کن.', mascot_animation='explain'),
)
lesson2, _ = Lesson.objects.get_or_create(
node=node2,
title=f'{SEED_TAG} درس اول تفریق',
defaults=dict(intro_text='یک عدد را از عدد دیگر کم کن.', mascot_animation='explain'),
)
question1, _ = Question.objects.get_or_create(
lesson=lesson1, prompt=f'{SEED_TAG} 2 + 3 = ?', defaults=dict(type='multiple_choice', asset_group='')
)
choices1 = [('4', False), ('5', True), ('6', False), ('7', False)]
correct_choice1 = None
for value, is_correct in choices1:
choice, _ = Choice.objects.get_or_create(question=question1, value=value, defaults=dict(is_correct=is_correct))
if is_correct:
correct_choice1 = choice
question2, _ = Question.objects.get_or_create(
lesson=lesson2, prompt=f'{SEED_TAG} 9 - 4 = ?', defaults=dict(type='multiple_choice', asset_group='')
)
for value, is_correct in [('4', False), ('5', True), ('6', False)]:
Choice.objects.get_or_create(question=question2, value=value, defaults=dict(is_correct=is_correct))
# پیشرفتِ کاربرِ اصلی: درسِ اول تمام‌شده، Nodeِ اول in_progress
UserAnswer.objects.get_or_create(
user_id=main_user.pk,
question=question1,
idempotency_key='seed-answer-1',
defaults=dict(choice=correct_choice1, correct=True, xp_earned=10),
)
UserLessonCompletion.objects.get_or_create(
user_id=main_user.pk,
lesson=lesson1,
idempotency_key='seed-completion-1',
defaults=dict(correct_count=1, stars=3, xp_earned=10, coins_earned=5),
)
NodeProgress.objects.get_or_create(
user_id=main_user.pk,
node=node1,
defaults=dict(
status='in_progress',
lessons_completed_count=1,
started_at=timezone.now() - timedelta(days=1),
last_activity_at=timezone.now(),
),
)
NodeProgress.objects.get_or_create(
user_id=main_user.pk,
node=node2,
defaults=dict(status='not_started', lessons_completed_count=0),
)
stats, _ = UserStats.objects.get_or_create(
user_id=main_user.pk, defaults=dict(xp=10, coins=5, progress_percent=12.5)
)
streak, _ = StreakRecord.objects.get_or_create(
user_id=main_user.pk, defaults=dict(current_streak=1, longest_streak=1)
)
ActiveDay.objects.get_or_create(streak=streak, date=timezone.now().date())
UserHearts.objects.get_or_create(user_id=main_user.pk, defaults=dict(current_hearts=5, max_hearts=5))
LeaderboardEntry.objects.get_or_create(
user_id=main_user.pk, period='weekly', defaults=dict(xp_in_period=10, rank=1)
)
LeaderboardEntry.objects.get_or_create(
user_id=main_user.pk, period='all_time', defaults=dict(xp_in_period=10, rank=1)
)
self.stdout.write(self.style.SUCCESS(
'content/progress: 1 پایه، 1 مبحث، 2 Node، 2 درس، 2 سوال و پیشرفتِ کاربرِ اصلی آماده شد.'
))
# -- گیمیفیکیشن: کاراکترها -------------------------------------------------
def _seed_characters(self):
from progress.models import Character
# unlock_xp پلکانی، هماهنگ با xp=10ی کاربرِ اصلی که بالاتر ساخته شد:
# فقط اولین کاراکتر (unlock_xp=0) براش باز است، بقیه هنوز قفلن —
# برای این‌که هم مسیرِ «باز» و هم مسیرِ «قفل» در تست دستی قابل‌دیدن باشه.
characters = [
('seed-owl', f'{SEED_TAG} جغد کنجکاو', 'owl', 0, 1),
('seed-fox', f'{SEED_TAG} روباه باهوش', 'fox', 100, 2),
('seed-turtle', f'{SEED_TAG} لاک‌پشت صبور', 'turtle', 300, 3),
('seed-lion', f'{SEED_TAG} شیر شجاع', 'lion', 600, 4),
]
for slug, name, icon, unlock_xp, order in characters:
Character.objects.get_or_create(
slug=slug, defaults=dict(name=name, icon=icon, unlock_xp=unlock_xp, order=order),
)
self.stdout.write(self.style.SUCCESS(f'progress: {len(characters)} کاراکترِ گیمیفیکیشن آماده شد.'))
# -- ai_tutor -------------------------------------------------------------
def _seed_ai_tutor(self, main_user):
from ai_tutor.models import Conversation, Message, MessageFeedback
conversation, _ = Conversation.objects.get_or_create(
user_id=main_user.pk, lesson_id=None, chapter_id=None, grade_id=None
)
user_msg, _ = Message.objects.get_or_create(
conversation=conversation,
role='user',
text=f'{SEED_TAG} چرا حاصل 2+3 برابر 5 است؟',
)
assistant_msg, created = Message.objects.get_or_create(
conversation=conversation,
role='assistant',
text=f'{SEED_TAG} چون وقتی 2 واحد و 3 واحد را کنار هم بگذاریم، 5 واحد می‌شود.',
)
MessageFeedback.objects.get_or_create(message=assistant_msg, defaults=dict(is_helpful=True))
self.stdout.write(self.style.SUCCESS('ai_tutor: 1 مکالمه با یک پیامِ کاربر و یک پاسخِ دستیار آماده شد.'))
# -- subscription + payment -----------------------------------------------
def _seed_subscription_and_payment(self, main_user):
from payment.models import Order, Transaction
from subscription.models import Plan, Subscription
plan_monthly, _ = Plan.objects.get_or_create(
title=f'{SEED_TAG} ماهانه',
defaults=dict(price=99000, currency='IRT', discount_percent=0, popular=False, duration_days=30),
)
plan_yearly, _ = Plan.objects.get_or_create(
title=f'{SEED_TAG} سالانه',
defaults=dict(price=799000, currency='IRT', discount_percent=20, popular=True, duration_days=365),
)
order, _ = Order.objects.get_or_create(
idempotency_key='seed-order-1',
defaults=dict(
user_id=main_user.pk,
amount=plan_monthly.price,
currency='IRT',
status='paid',
provider='gateway',
),
)
Transaction.objects.get_or_create(
order=order,
gateway='zarinpal',
gateway_ref_id='SEED-REF-0001',
defaults=dict(status='paid', paid_at=timezone.now()),
)
subscription, _ = Subscription.objects.get_or_create(
user_id=main_user.pk,
defaults=dict(
plan=plan_monthly,
status='active',
expires_at=timezone.now() + timedelta(days=plan_monthly.duration_days),
order_id=order.pk,
),
)
if order.subscription_id != subscription.pk:
order.subscription_id = subscription.pk
order.save(update_fields=['subscription_id'])
self.stdout.write(self.style.SUCCESS(
'subscription/payment: 2 پلن، 1 سفارشِ پرداخت‌شده و 1 اشتراکِ فعال آماده شد.'
))
return plan_monthly, plan_yearly
# -- discount ---------------------------------------------------------------
def _seed_discount(self, main_user):
from discount.models import DiscountCode, Offer
DiscountCode.objects.get_or_create(
code='SEEDTEST10',
defaults=dict(type='percent', value=10, max_uses=100, expires_at=timezone.now() + timedelta(days=90)),
)
Offer.objects.get_or_create(
code='SEEDTRIAL7',
defaults=dict(
name=f'{SEED_TAG} هفت روز رایگان',
kind='trial',
duration_days=7,
auto_assign_on_signup=False,
is_active=True,
),
)
self.stdout.write(self.style.SUCCESS('discount: 1 کدتخفیفِ فعال و 1 آفرِ trial آماده شد.'))
# -- notifications ------------------------------------------------------------
def _seed_notifications(self, main_user):
from notifications.models import Notification
Notification.objects.get_or_create(
user_id=main_user.pk,
type='xp_earned',
title=f'{SEED_TAG} امتیاز گرفتی',
defaults=dict(body='10 امتیاز از درس اول جمع گرفتی!', related_id=None, is_read=False),
)
Notification.objects.get_or_create(
user_id=main_user.pk,
type='order_paid',
title=f'{SEED_TAG} پرداخت موفق',
defaults=dict(body='اشتراک ماهانه‌ت فعال شد.', related_id=None, is_read=True),
)
self.stdout.write(self.style.SUCCESS('notifications: 2 اعلان (یکی خوانده‌نشده، یکی خوانده‌شده) آماده شد.'))
# -- affiliate ------------------------------------------------------------------
def _seed_affiliate(self, main_user, referred_user):
from affiliate.models import AffiliateAccount, Conversion
account, _ = AffiliateAccount.objects.get_or_create(
user_id=main_user.pk, defaults=dict(referral_code='SEEDREF01', commission_rate=0.1)
)
Conversion.objects.get_or_create(
referred_user_id=referred_user.pk,
defaults=dict(affiliate_account=account, status='pending', commission_amount=0),
)
self.stdout.write(self.style.SUCCESS('affiliate: 1 حساب افیلیت و 1 کانورژنِ در انتظار آماده شد.'))
# -- support ----------------------------------------------------------------------
def _seed_support(self, main_user):
from support.models import BugReport, Ticket, TicketMessage
ticket, _ = Ticket.objects.get_or_create(
user_id=main_user.pk,
subject=f'{SEED_TAG} مشکل در نمایش درس',
defaults=dict(category='content', status='open', priority='normal'),
)
TicketMessage.objects.get_or_create(
ticket=ticket, sender='user', text=f'{SEED_TAG} درسِ اول باز نمی‌شه.'
)
BugReport.objects.get_or_create(
user_id=main_user.pk,
title=f'{SEED_TAG} کرش هنگام باز کردن درس',
defaults=dict(description='بعد از زدن دکمه‌ی شروع، اپ بسته می‌شه.', status='open'),
)
self.stdout.write(self.style.SUCCESS('support: 1 تیکت با یک پیام، و 1 گزارش باگ آماده شد.'))