admin & data: correct admin action redirect, add SQLite retry, seed_test_data command
This commit is contained in:
67
CLAUDE.md
Normal file
67
CLAUDE.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
Django + DRF backend for Tadrisino, an educational app (Persian/Farsi). Codebase and API responses are in Persian; comments in source are Persian too.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
python manage.py runserver
|
||||
python manage.py migrate
|
||||
python manage.py test # all tests
|
||||
python manage.py test accounts # single app
|
||||
python manage.py test accounts.tests.SomeTestCase.test_something # single test
|
||||
```
|
||||
|
||||
Local dev requires `DJANGO_DEBUG=1` (enables Swagger auth via `DEV_MASTER_OTP`/`devOtp` in responses). `DEBUG` defaults to `False`. Production also requires `DJANGO_SECRET_KEY`, `DJANGO_ALLOWED_HOSTS` (comma-separated), and `CACHE_URL` (Redis — required whenever `DEBUG=False` since rate limiting needs a shared cache; raises `ImproperlyConfigured` otherwise).
|
||||
|
||||
No pytest; uses Django's built-in test runner. No linter/formatter configured.
|
||||
|
||||
Docker (`docker compose up --build`) adds a local Loki+Grafana Alloy+Grafana log stack; not a production setup (still `runserver`, not gunicorn). See comments in `docker-compose.yml`, `docker/alloy/config.alloy`, and `LOG_DIR` in `config/settings.py`.
|
||||
|
||||
API docs: Swagger `/api/docs/`, Redoc `/api/redoc/`, admin `/admin/`.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Apps** (each owns its own models, one responsibility):
|
||||
|
||||
| app | responsibility | key models |
|
||||
|---|---|---|
|
||||
| `accounts` | identity, sessions, OTP auth | `User`, `Profile`, `Session`, `OTPCode` |
|
||||
| `content` | lessons/curriculum | `Grade`, `Unit`, `Node`, `Lesson`, `Question`, `Choice`, `NodeProgress` |
|
||||
| `progress` | stats, leaderboard, streaks | `UserStats`, `LeaderboardEntry`, `StreakRecord`, `UserHearts` |
|
||||
| `ai_tutor` | AI assistant chat | `Conversation`, `Message`, `MessageFeedback` |
|
||||
| `subscription` | plans | `Plan`, `Subscription` |
|
||||
| `payment` | orders/transactions (incl. Cafe Bazaar IAP) | `Order`, `Transaction` |
|
||||
| `discount` | discount codes/offers | `DiscountCode`, `Redemption`, `Offer`, `UserOffer` |
|
||||
| `notifications` | notifications | `Notification` |
|
||||
| `affiliate` | referral program | `AffiliateAccount`, `Conversion` |
|
||||
| `support` | tickets/bug reports | `BugReport`, `Ticket`, `TicketMessage` |
|
||||
| `family` | parental control | `FamilyLink`, `ParentalSettings`, `WeeklyReportLog` |
|
||||
| `statuslog` | generic cross-app status-change log | `StatusChangeLog` |
|
||||
| `campaigns` | messaging campaign storage only, no send logic yet | `MessageCampaign` |
|
||||
| `config` | project settings, root urls, middleware | — |
|
||||
|
||||
**Auth is custom, not Django's.** `accounts.User` is deliberately *not* `AUTH_USER_MODEL` — it's an independent business model. Auth is a stateless signed bearer token (`django.core.signing`, no JWT library) issued/verified in `accounts/authentication.py` (`TadrisinoTokenAuthentication`, header `Authorization: Bearer <token>`), backed by `accounts.Session`. Missing/invalid header → anonymous request, not a hard failure; each view's permission class decides if auth is required.
|
||||
|
||||
**Apps are decoupled by convention**: instead of real FKs across app boundaries, code uses loose refs (`user_id`, `object_id` + `label`) so any app can be split out later. Don't add cross-app FKs — follow the existing loose-reference pattern.
|
||||
|
||||
**Throttling**: DRF `ScopedRateThrottle` with per-endpoint `throttle_scope` strings, rates centralized in `REST_FRAMEWORK['DEFAULT_THROTTLE_RATES']` in `config/settings.py`. Guest users are throttled by IP (`NUM_PROXIES` setting controls how many XFF hops are trusted — must match real proxy count in prod or IP spoofing/throttle-bypass is possible). Adding a new sensitive endpoint means adding both a `throttle_scope` on the view and a matching rate entry in settings.
|
||||
|
||||
**`POST /api/ai/ask`** currently returns a stub; real model integration point is `generate_tutor_answer` in `ai_tutor/views.py`.
|
||||
|
||||
**Structured logging**: `config.middleware.APIRequestLoggingMiddleware` (last in `MIDDLEWARE`, closest to the view) logs every `/api/` request as JSON (method/path/status/duration/user, no body/headers/secrets) via `config/log_formatters.py`.
|
||||
|
||||
**SQLite** is current DB (`OPTIONS.timeout=20` to tolerate concurrent-write locks, e.g. campaigns tests); README notes Postgres migration is still pending.
|
||||
|
||||
**SMS** goes through Kavenegar (`KAVENEGAR_API_KEY`/`KAVENEGAR_SENDER`); failures return explicit 503, not a fake success.
|
||||
|
||||
## Known gaps (per README pre-deploy checklist)
|
||||
|
||||
- Still on SQLite, not Postgres.
|
||||
- Throttle rates need periodic review against real traffic.
|
||||
- `DEV_MASTER_OTP` (`0000`) must stay gated behind `DEBUG` — never enabled in prod.
|
||||
@@ -28,6 +28,7 @@ class SendSMSForm(forms.Form):
|
||||
choices=TARGET_CHOICES,
|
||||
widget=forms.RadioSelect,
|
||||
initial=TARGET_SELECTED,
|
||||
required=False,
|
||||
)
|
||||
inactive_since = forms.DateField(
|
||||
label='غیرفعال از این تاریخ به بعد',
|
||||
@@ -157,8 +158,12 @@ class UserAdmin(admin.ModelAdmin):
|
||||
f'{skipped_no_phone} کاربر بدونِ شمارهتلفن رد شدن (پیامک ارسال نشد).',
|
||||
level=messages.WARNING,
|
||||
)
|
||||
# None یعنی صفحهی changelist دوباره نمایش داده بشه.
|
||||
return None
|
||||
# توجه: قبلاً اینجا `return None` بود؛ حذف شد چون در
|
||||
# جنگو، None برگردوندن از یک اکشنِ ادمین باعثِ یک
|
||||
# ریدایرکتِ ۳۰۲ واقعی میشه (نه رندرِ مستقیمِ ۲۰۰)،
|
||||
# درست برخلافِ چیزی که این کامنت قبلاً فرض کرده بود.
|
||||
# حالا همالگو با شاخهی cap-exceeded، سقوط میکنیم به
|
||||
# رندرِ مشترکِ پایینِ تابع.
|
||||
else:
|
||||
form = SendSMSForm()
|
||||
|
||||
|
||||
472
accounts/management/commands/seed_test_data.py
Normal file
472
accounts/management/commands/seed_test_data.py
Normal 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 اتصالِ خانواده (پذیرفتهشده) با تنظیمات و گزارشِ هفتگی آماده شد.'))
|
||||
@@ -956,9 +956,20 @@ class SendSmsAdminActionTests(TestCase):
|
||||
username='sms-admin', email='sms-admin@example.com', password='pass12345',
|
||||
)
|
||||
self.client.force_login(self.staff_user)
|
||||
self.user1 = User.objects.create(name='u1', is_guest=False, phone='0910000001')
|
||||
self.user2 = User.objects.create(name='u2', is_guest=False, phone='0910000002')
|
||||
self.guest_no_phone = User.objects.create(name='guest', is_guest=True, phone=None)
|
||||
# last_activity_at=now تا این کاربرهای عمومیِ setUp با فیلترِ
|
||||
# get_inactive_users (که last_activity_at is null رو هم inactive
|
||||
# حساب میکنه) قاطی نشن؛ تستهای target='inactive' کاربرِ
|
||||
# inactive/never-active موردنظرِ خودشون رو صریحاً میسازن.
|
||||
now = timezone.now()
|
||||
self.user1 = User.objects.create(
|
||||
name='u1', is_guest=False, phone='0910000001', last_activity_at=now,
|
||||
)
|
||||
self.user2 = User.objects.create(
|
||||
name='u2', is_guest=False, phone='0910000002', last_activity_at=now,
|
||||
)
|
||||
self.guest_no_phone = User.objects.create(
|
||||
name='guest', is_guest=True, phone=None, last_activity_at=now,
|
||||
)
|
||||
self.changelist_url = reverse('admin:accounts_user_changelist')
|
||||
|
||||
def test_action_registered_on_user_admin(self):
|
||||
|
||||
@@ -9,7 +9,9 @@ retry یا پیگیریِ تحویلی انجام نمیده — طبق Messa
|
||||
فراخوانی همیشه sync و بلاکینگه (برای هر کاربر یک send_sms/notify)؛
|
||||
مناسبِ لیستهای کوچیک یا اجرای دستی/تست، نه کمپینهای بزرگ در پروداکشن.
|
||||
"""
|
||||
from django.db import IntegrityError, transaction
|
||||
import time
|
||||
|
||||
from django.db import IntegrityError, OperationalError, transaction
|
||||
|
||||
from accounts import services as account_services
|
||||
from accounts.services import SMSDeliveryError
|
||||
@@ -17,6 +19,26 @@ from notifications.services import notify
|
||||
|
||||
from .models import CampaignDelivery, MessageCampaign
|
||||
|
||||
|
||||
def _retry_on_sqlite_lock(fn, *, retries=5, base_delay=0.05):
|
||||
"""
|
||||
روی SQLite (مخصوصاً دیتابیسِ حافظهایِ shared-cache که تستها ازش
|
||||
استفاده میکنن)، وقتی دو اتصالِ همزمان میخوان همون جدول رو بنویسن،
|
||||
ممکنه OperationalError('database table is locked') بگیریم. این
|
||||
SQLITE_LOCKEDه نه SQLITE_BUSY، پس busy_timeoutِ معمولیِ اتصال کمکی
|
||||
نمیکنه و بلافاصله raise میشه؛ یک خطای واقعیِ داده نیست، فقط
|
||||
تناقضِ لحظهایِ لاکه. این تابع فقط همین خطای گذرا رو با چند بار
|
||||
تلاشِ سریع (backoffِ کوچیکِ فزاینده) دوباره امتحان میکنه؛ هر
|
||||
OperationalError دیگه (یا شکستِ تلاشِ آخر) دستنخورده بالا میره.
|
||||
"""
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
return fn()
|
||||
except OperationalError as exc:
|
||||
if 'locked' not in str(exc).lower() or attempt == retries - 1:
|
||||
raise
|
||||
time.sleep(base_delay * (attempt + 1))
|
||||
|
||||
# نوعِ نوتیفیکیشنِ ثبتشده برای اعلانهای ناشی از کمپین. عمداً در
|
||||
# notifications.models.Notification.TYPE_CHOICES اضافه نشده چون آن
|
||||
# choices فقط رویدادهاییان که notifications بهعنوانِ event-listener
|
||||
@@ -70,12 +92,12 @@ def _mark_delivery_result(delivery: CampaignDelivery, *, error: Exception | None
|
||||
"""رکوردِ pendingای که قبل از تلاشِ ارسال ساخته شده رو به sent/failed آپدیت میکنه."""
|
||||
if error is None:
|
||||
delivery.status = CampaignDelivery.STATUS_SENT
|
||||
delivery.save(update_fields=['status'])
|
||||
_retry_on_sqlite_lock(lambda: delivery.save(update_fields=['status']))
|
||||
return
|
||||
|
||||
delivery.status = CampaignDelivery.STATUS_FAILED
|
||||
delivery.error_message = str(error)[:300]
|
||||
delivery.save(update_fields=['status', 'error_message'])
|
||||
_retry_on_sqlite_lock(lambda: delivery.save(update_fields=['status', 'error_message']))
|
||||
|
||||
|
||||
def _claim_delivery(campaign: MessageCampaign, user_id: int) -> CampaignDelivery | None:
|
||||
@@ -96,11 +118,14 @@ def _claim_delivery(campaign: MessageCampaign, user_id: int) -> CampaignDelivery
|
||||
همزمانِ دیگر)، create() با IntegrityError رد میشه و این تابع
|
||||
None برمیگردونه — یعنی کاربر بهجای duplicate delivery، skip میشه.
|
||||
"""
|
||||
try:
|
||||
def _create():
|
||||
with transaction.atomic():
|
||||
return CampaignDelivery.objects.create(
|
||||
campaign=campaign, user_id=user_id, channel=campaign.channel,
|
||||
)
|
||||
|
||||
try:
|
||||
return _retry_on_sqlite_lock(_create)
|
||||
except IntegrityError:
|
||||
return None
|
||||
|
||||
@@ -203,7 +228,7 @@ def execute_campaign(campaign: MessageCampaign) -> None:
|
||||
delivery = _claim_delivery(campaign, user.id)
|
||||
if delivery is None:
|
||||
continue
|
||||
try:
|
||||
def _notify_and_mark_sent():
|
||||
with transaction.atomic():
|
||||
notify(
|
||||
user_id=user.id,
|
||||
@@ -213,6 +238,9 @@ def execute_campaign(campaign: MessageCampaign) -> None:
|
||||
related_id=campaign.id,
|
||||
)
|
||||
_mark_delivery_result(delivery, error=None)
|
||||
|
||||
try:
|
||||
_retry_on_sqlite_lock(_notify_and_mark_sent)
|
||||
except Exception as exc:
|
||||
# چه notify() خودش شکست بخوره چه فقط نوشتنِ نهاییِ sent
|
||||
# (که تویِ همون atomic block بالاست)، هر دو حالت اینجا
|
||||
|
||||
@@ -127,6 +127,13 @@ DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
# timeout بالاتر از پیشفرض: وقتی دو کانکشنِ همزمان (مثلِ تستِ
|
||||
# concurrency واقعیِ campaigns) به قفلِ نوشتنِ SQLite برخورد
|
||||
# میکنن، این باعث میشه کانکشنِ منتظر تا ۲۰ ثانیه صبر کنه بهجای
|
||||
# اینکه فوراً OperationalError('database table is locked') بده.
|
||||
'OPTIONS': {
|
||||
'timeout': 20,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,3 +82,4 @@ def notify(
|
||||
body=body,
|
||||
related_id=related_id,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user