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

67
CLAUDE.md Normal file
View 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.

View File

@@ -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()

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 اتصالِ خانواده (پذیرفته‌شده) با تنظیمات و گزارشِ هفتگی آماده شد.'))

View File

@@ -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):

View File

@@ -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 بالاست)، هر دو حالت اینجا

View File

@@ -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,
},
} }
} }

View File

@@ -82,3 +82,4 @@ def notify(
body=body, body=body,
related_id=related_id, related_id=related_id,
) )