""" یک دیتاست کوچک و واقعی برای تست دستی/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 گزارش باگ آماده شد.'))