diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0a56834 --- /dev/null +++ b/CLAUDE.md @@ -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 `), 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. diff --git a/accounts/admin.py b/accounts/admin.py index b118a9c..eb23ec3 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -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() diff --git a/accounts/management/commands/seed_test_data.py b/accounts/management/commands/seed_test_data.py new file mode 100644 index 0000000..bc31482 --- /dev/null +++ b/accounts/management/commands/seed_test_data.py @@ -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 اتصالِ خانواده (پذیرفته‌شده) با تنظیمات و گزارشِ هفتگی آماده شد.')) diff --git a/accounts/tests.py b/accounts/tests.py index 0558ab7..603b0ac 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -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): diff --git a/campaigns/services.py b/campaigns/services.py index 29702da..9e8eed2 100644 --- a/campaigns/services.py +++ b/campaigns/services.py @@ -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 بالاست)، هر دو حالت اینجا diff --git a/config/settings.py b/config/settings.py index dd9ce08..321820b 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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, + }, } } diff --git a/notifications/services.py b/notifications/services.py index d9ebf32..c736c63 100644 --- a/notifications/services.py +++ b/notifications/services.py @@ -82,3 +82,4 @@ def notify( body=body, related_id=related_id, ) +