admin & data: correct admin action redirect, add SQLite retry, seed_test_data command

This commit is contained in:
2026-08-26 03:22:20 +03:30
parent f322d27e4f
commit 05ab6ffc36
7 changed files with 601 additions and 10 deletions

View File

@@ -0,0 +1,472 @@
"""
یک دیتاست کوچک و واقعی برای تست دستی/API لوکال یا staging می‌سازه، طوری که
اجرای مکرر (repeat) هیچ رکورد تکراری‌ای نسازه — همه‌چیز با get_or_create روی
یک کلید طبیعی/یکتا (phone، device_id، code، idempotency_key و ...) انجام
می‌شه، نه با create ساده.
هدف: پوشش کمینه‌ی جریان‌های اصلی API در هر ۱۱ اپ (accounts، content،
progress، ai_tutor، subscription، payment، discount، notifications،
affiliate، support، family) با کمترین حجم داده‌ی لازم برای این‌که هر
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
# --- شناسه‌های ثابتِ داده‌ی seed --------------------------------------------
# شماره‌های 0912000000X عمداً خارج از بازه‌ی واقعیِ اپراتورهای ایران نیستن؛
# چون این فقط دیتای لوکال/staging است نه پروداکشن، همین کافیه. با پیشوند
# یکسان و بازه‌ی اختصاصی، شناساییِ رکوردهای seed در دیتابیس هم ساده می‌مونه.
MAIN_PHONE = '09120000001'
PARENT_PHONE = '09120000002'
CHILD_PHONE = '09120000003'
REFERRED_PHONE = '09120000004'
GUEST_DEVICE_ID = 'seed-guest-device-0001'
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/session یا /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 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]
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:
user_ids.append(guest.pk)
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()
FamilyLink.objects.filter(parent_user_id__in=user_ids).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()
if guest:
Session.objects.filter(user=guest).delete()
User.objects.filter(phone__in=phones).delete()
if guest:
guest.delete()
self.stdout.write('رکوردهای قبلیِ seed حذف شدن؛ در حال ساختِ دوباره...')
# -- seed -------------------------------------------------------------
def _seed(self):
main_user, guest_user, parent_user, child_user, referred_user = self._seed_accounts()
self._seed_content_and_progress(main_user)
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)
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}
# -- accounts -----------------------------------------------------------
def _seed_accounts(self):
main_user, _ = User.objects.get_or_create(
phone=MAIN_PHONE,
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} پروفایل تستی')
)
Session.objects.get_or_create(
user=main_user,
refresh_token=f'seed-refresh-{MAIN_PHONE}',
defaults=dict(
device_id='seed-device-main',
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
revoked=False,
),
)
guest_user, _ = User.objects.get_or_create(
device_id=GUEST_DEVICE_ID,
is_guest=True,
defaults=dict(name=f'{SEED_TAG} کاربر مهمان'),
)
Session.objects.get_or_create(
user=guest_user,
refresh_token=f'seed-refresh-{GUEST_DEVICE_ID}',
defaults=dict(
device_id=GUEST_DEVICE_ID,
expires_at=timezone.now() + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS),
revoked=False,
),
)
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
# -- 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 سوال و پیشرفتِ کاربرِ اصلی آماده شد.'
))
# -- 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 گزارش باگ آماده شد.'))
# -- 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 اتصالِ خانواده (پذیرفته‌شده) با تنظیمات و گزارشِ هفتگی آماده شد.'))