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,
|
choices=TARGET_CHOICES,
|
||||||
widget=forms.RadioSelect,
|
widget=forms.RadioSelect,
|
||||||
initial=TARGET_SELECTED,
|
initial=TARGET_SELECTED,
|
||||||
|
required=False,
|
||||||
)
|
)
|
||||||
inactive_since = forms.DateField(
|
inactive_since = forms.DateField(
|
||||||
label='غیرفعال از این تاریخ به بعد',
|
label='غیرفعال از این تاریخ به بعد',
|
||||||
@@ -157,8 +158,12 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
f'{skipped_no_phone} کاربر بدونِ شمارهتلفن رد شدن (پیامک ارسال نشد).',
|
f'{skipped_no_phone} کاربر بدونِ شمارهتلفن رد شدن (پیامک ارسال نشد).',
|
||||||
level=messages.WARNING,
|
level=messages.WARNING,
|
||||||
)
|
)
|
||||||
# None یعنی صفحهی changelist دوباره نمایش داده بشه.
|
# توجه: قبلاً اینجا `return None` بود؛ حذف شد چون در
|
||||||
return None
|
# جنگو، None برگردوندن از یک اکشنِ ادمین باعثِ یک
|
||||||
|
# ریدایرکتِ ۳۰۲ واقعی میشه (نه رندرِ مستقیمِ ۲۰۰)،
|
||||||
|
# درست برخلافِ چیزی که این کامنت قبلاً فرض کرده بود.
|
||||||
|
# حالا همالگو با شاخهی cap-exceeded، سقوط میکنیم به
|
||||||
|
# رندرِ مشترکِ پایینِ تابع.
|
||||||
else:
|
else:
|
||||||
form = SendSMSForm()
|
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',
|
username='sms-admin', email='sms-admin@example.com', password='pass12345',
|
||||||
)
|
)
|
||||||
self.client.force_login(self.staff_user)
|
self.client.force_login(self.staff_user)
|
||||||
self.user1 = User.objects.create(name='u1', is_guest=False, phone='0910000001')
|
# last_activity_at=now تا این کاربرهای عمومیِ setUp با فیلترِ
|
||||||
self.user2 = User.objects.create(name='u2', is_guest=False, phone='0910000002')
|
# get_inactive_users (که last_activity_at is null رو هم inactive
|
||||||
self.guest_no_phone = User.objects.create(name='guest', is_guest=True, phone=None)
|
# حساب میکنه) قاطی نشن؛ تستهای 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')
|
self.changelist_url = reverse('admin:accounts_user_changelist')
|
||||||
|
|
||||||
def test_action_registered_on_user_admin(self):
|
def test_action_registered_on_user_admin(self):
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ retry یا پیگیریِ تحویلی انجام نمیده — طبق Messa
|
|||||||
فراخوانی همیشه sync و بلاکینگه (برای هر کاربر یک send_sms/notify)؛
|
فراخوانی همیشه 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 import services as account_services
|
||||||
from accounts.services import SMSDeliveryError
|
from accounts.services import SMSDeliveryError
|
||||||
@@ -17,6 +19,26 @@ from notifications.services import notify
|
|||||||
|
|
||||||
from .models import CampaignDelivery, MessageCampaign
|
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 اضافه نشده چون آن
|
# notifications.models.Notification.TYPE_CHOICES اضافه نشده چون آن
|
||||||
# choices فقط رویدادهاییان که notifications بهعنوانِ event-listener
|
# choices فقط رویدادهاییان که notifications بهعنوانِ event-listener
|
||||||
@@ -70,12 +92,12 @@ def _mark_delivery_result(delivery: CampaignDelivery, *, error: Exception | None
|
|||||||
"""رکوردِ pendingای که قبل از تلاشِ ارسال ساخته شده رو به sent/failed آپدیت میکنه."""
|
"""رکوردِ pendingای که قبل از تلاشِ ارسال ساخته شده رو به sent/failed آپدیت میکنه."""
|
||||||
if error is None:
|
if error is None:
|
||||||
delivery.status = CampaignDelivery.STATUS_SENT
|
delivery.status = CampaignDelivery.STATUS_SENT
|
||||||
delivery.save(update_fields=['status'])
|
_retry_on_sqlite_lock(lambda: delivery.save(update_fields=['status']))
|
||||||
return
|
return
|
||||||
|
|
||||||
delivery.status = CampaignDelivery.STATUS_FAILED
|
delivery.status = CampaignDelivery.STATUS_FAILED
|
||||||
delivery.error_message = str(error)[:300]
|
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:
|
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 رد میشه و این تابع
|
همزمانِ دیگر)، create() با IntegrityError رد میشه و این تابع
|
||||||
None برمیگردونه — یعنی کاربر بهجای duplicate delivery، skip میشه.
|
None برمیگردونه — یعنی کاربر بهجای duplicate delivery، skip میشه.
|
||||||
"""
|
"""
|
||||||
try:
|
def _create():
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
return CampaignDelivery.objects.create(
|
return CampaignDelivery.objects.create(
|
||||||
campaign=campaign, user_id=user_id, channel=campaign.channel,
|
campaign=campaign, user_id=user_id, channel=campaign.channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _retry_on_sqlite_lock(_create)
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -203,7 +228,7 @@ def execute_campaign(campaign: MessageCampaign) -> None:
|
|||||||
delivery = _claim_delivery(campaign, user.id)
|
delivery = _claim_delivery(campaign, user.id)
|
||||||
if delivery is None:
|
if delivery is None:
|
||||||
continue
|
continue
|
||||||
try:
|
def _notify_and_mark_sent():
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
notify(
|
notify(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -213,6 +238,9 @@ def execute_campaign(campaign: MessageCampaign) -> None:
|
|||||||
related_id=campaign.id,
|
related_id=campaign.id,
|
||||||
)
|
)
|
||||||
_mark_delivery_result(delivery, error=None)
|
_mark_delivery_result(delivery, error=None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_retry_on_sqlite_lock(_notify_and_mark_sent)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# چه notify() خودش شکست بخوره چه فقط نوشتنِ نهاییِ sent
|
# چه notify() خودش شکست بخوره چه فقط نوشتنِ نهاییِ sent
|
||||||
# (که تویِ همون atomic block بالاست)، هر دو حالت اینجا
|
# (که تویِ همون atomic block بالاست)، هر دو حالت اینجا
|
||||||
|
|||||||
@@ -127,6 +127,13 @@ DATABASES = {
|
|||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
'NAME': BASE_DIR / 'db.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,
|
body=body,
|
||||||
related_id=related_id,
|
related_id=related_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user