Zareian 66364e0e92 fix the campaigns concurrency test by fixing what it was catching
ExecuteCampaignRealConcurrencyTests had been written off as a known flake
for weeks — about 45% red on a full-suite run. It was not timing noise.
It was reporting a real defect, and the defect is in production code.

execute_campaign looped with .iterator(), which keeps the read cursor on
the user table open for the whole loop — a loop that writes a
CampaignDelivery per user and, on the sms branch, makes a network call to
Kavenegar in between. On SQLite that open cursor locks the table against
any concurrent writer, and the lock is not released until the loop ends.
So _retry_on_sqlite_lock could never recover from it no matter how long
it waited: widening its window from 0.5s to 2s (also done here, since the
linear backoff was too tight regardless) moved the failure rate not at
all on its own.

Both branches now materialise the ids before the first write. After the
change: 20 consecutive runs of the test itself and 3 full-suite runs, all
green.

This is not only a test fix. While we are on SQLite the same pattern
produces the same lock in production, where it surfaces as "database is
locked" on unrelated requests that happen to land during a campaign run.

Also in this pass
-----------------
- CLAUDE.md carried three sections each claiming to be the current state,
  with three different question counts (1,441 / 1,783 / reality 2,463).
  There is now one current-state section and the older two are labelled
  as history, so whoever picks this up next is not reading stale numbers
  as fact. Three other figures that had drifted are corrected against the
  database: the verdict split, the figure-gap share (881/~42% is now
  508/~14% after the five books were re-read), and the node coverage.
- Dropped four genuinely unused imports. The remaining pyflakes hits are
  the signal registrations in apps.py, which are imported for effect.
- .env.example suggested gpt-4o for vision; gpt-4.1-mini measured both
  cheaper and more accurate and is what the server actually runs.

Audited all 2,463 questions across every grade: no question with the
wrong number of options, none with zero or two correct answers, no empty
or duplicated option, no orphan lesson or node, no duplicate question
inside a lesson.

458 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 04:43:44 +03:30

Tadrisino API

بک‌اند اپلیکیشن آموزشی Tadrisino — Django + Django REST Framework.

احراز هویت با OTP، محتوای درسی و پیشرفت کاربر، دستیار هوش مصنوعی، اشتراک و پرداخت، تخفیف، اعلان‌ها، همکاری در فروش، پشتیبانی و حساب خانواده.

اپ مسئولیت
accounts احراز هویت، session، پروفایل کاربر
content پایه‌ها، مباحث، درس‌ها، سوال‌ها، پیشرفت
progress آمار، لیدربرد، streak
ai_tutor مکالمات دستیار هوش مصنوعی
rag لایه‌ی RAG: بازیابی از Qdrant + تولید با GPT، پشتِ POST /api/ai/ask
subscription / payment / discount پلن، سفارش، تراکنش، کد تخفیف
notifications, affiliate, support, family امکانات جانبی
statuslog لاگ عمومی تغییر status، مستقل از بقیه‌ی اپ‌ها

۱. راه‌اندازی محیط توسعه

پیش‌نیازها

  • پایتون ۳.۱۲
  • pip

تنظیم محیط

python3 -m venv venv
source venv/bin/activate        # ویندوز: venv\Scripts\activate
pip install -r requirements.txt
cp .env.example .env

.env.example (تمام متغیرهایی که واقعاً توسط اپ خونده می‌شن):

DJANGO_DEBUG=1
DJANGO_SECRET_KEY=
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
CACHE_URL=
KAVENEGAR_API_KEY=
GRAFANA_ADMIN_PASSWORD=admin

مقادیر .env رو به شل خودتون export کنید (یا از direnv/python-dotenv استفاده کنید):

export DJANGO_DEBUG=1            # ویندوز (PowerShell): $env:DJANGO_DEBUG=1

DEBUG دیفالتش False است. برای فعال‌کردنِ کمک‌کننده‌های dev-only در Swagger (DEV_MASTER_OTP و فیلدِ devOtp توی پاسخ‌ها)، محلی DJANGO_DEBUG=1 رو ست کنید.

سریع‌ترین راه اجرای پروژه

python manage.py migrate
python manage.py runserver

اپ روی http://127.0.0.1:8000 بالا میاد.

مایگریشن‌ها

python manage.py migrate

دیتای تستی (Seed)

یک دیتاست کوچک و idempotent می‌سازه که همه‌ی اپ‌ها رو پوشش می‌ده (کاربر، محتوا، پیشرفت، اشتراک، پرداخت، تخفیف، دستیار هوش مصنوعی، اعلان، افیلیت، پشتیبانی، خانواده). اجرای مکرر امن است — با get_or_create هیچ رکورد تکراری‌ای نمی‌سازه. پیش‌فرض فقط وقتی DEBUG=True اجرا می‌شه، مگر با --force.

python manage.py seed_test_data              # لوکال (DEBUG=True)
python manage.py seed_test_data --force      # staging (DEBUG=False)
python manage.py seed_test_data --reset      # پاک‌کردنِ دیتای قبلیِ seed و ساختِ دوباره

مستندات API

Swagger UI http://127.0.0.1:8000/api/docs/
Redoc http://127.0.0.1:8000/api/redoc/
OpenAPI schema http://127.0.0.1:8000/api/schema/
پنل ادمین http://127.0.0.1:8000/admin/

شروع سریع در Swagger:

  1. POST /api/auth/session با {"type":"guest","deviceId":"d_test1"} → یک token بگیرید.
  2. روی دکمه‌ی Authorize بزنید و فقط خودِ token رو (بدون کلمه‌ی Bearer) وارد کنید.
  3. برای تست لاگین با شماره: POST /api/auth/otp/request بزنید، یا در محیط توسعه کد 0000 رو با هر شماره‌ای در POST /api/auth/login امتحان کنید (dev-only، نیاز به DEBUG=True).

دستورهای پرکاربرد

python manage.py createsuperuser   # اختیاری، برای /admin
python manage.py test              # اجرای کل تست‌سوییت

دستیار هوش مصنوعی (RAG)

POST /api/ai/ask پاسخ را از روی محتوای واقعیِ کتاب می‌سازد: embedding سوال → جستجوی Qdrant محدود به همان درس/فصل → تولید با GPT. تیکه‌های استفاده‌شده در usedChunks برمی‌گردند.

  • نیازمندِ OPENAI_API_KEY و QDRANT_API_KEY در .env (اپ rag؛ تنظیماتِ OPENAI_* / RAG_* در config/settings.py). بدونِ کلید، همان پاسخِ stub قبلی.
  • تست در Swagger: POST /api/ai/ask با {"userQuestion":"...","subject":"ریاضی","grade":"ششم","lessonNumber":2}.
  • "stream": true پاسخ را به‌صورت SSE توکن‌به‌توکن می‌فرستد (قراردادِ JSON بدونِ این فلگ دست‌نخورده).
  • python manage.py rag_catalog فهرستِ زنده‌ی collectionهای Qdrant را نشان می‌دهد (فقط‌خواندنی).
  • افزودنِ محتوای خودمان: POST /api/rag/ingest/text و /api/rag/ingest/pdf (داخلی، هدرِ X-Internal-Key).

۲. راهنمای دیپلوی پروداکشن

بالا آوردنِ نسخه‌ی جدید — یک دستور

./deploy.sh

همین. اسکریپت به ترتیب کدِ تازه را می‌گیرد، وابستگی‌ها را نصب می‌کند، مهاجرت‌ها را اعمال می‌کند، فایل‌های استاتیک را جمع می‌کند، سرویس را ری‌استارت می‌کند، و در پایان خودش بررسی می‌کند که واقعاً بالا آمده یا نه. اگر بالا نیامد، علت‌های رایج را چاپ می‌کند.

چرا این مهم است: قبلاً این‌ها چند کارِ دستی به ترتیبِ درست بودند و فراموش شدنِ هر کدام اپ را می‌خواباند، با خطایی که گویا نبود. مثلاً اضافه شدنِ یک پکیجِ جدید به requirements.txt بدونِ pip install باعثِ ModuleNotFoundError موقعِ بالا آمدن می‌شود و کاربر فقط 502 Bad Gateway می‌بیند. اسکریپت همیشه pip install را اجرا می‌کند تا این حالت اصلاً پیش نیاید.

بارِ اول، نامِ سرویس را بدهید تا دفعه‌های بعد خودکار ری‌استارت شود:

TADRISINO_SERVICE=<نامِ سرویس> ./deploy.sh

متغیرهای اختیاری: SKIP_PULL=1 (بدونِ git pull)، TADRISINO_HEALTH_URL، PIP_INDEX_URL (اگر pypi.org تایم‌اوت می‌دهد).

بررسیِ سلامت از بیرون

curl https://<host>/api/health

بدونِ احرازِ هویت و بدونِ SSH می‌گوید دیتابیس سالم است یا نه، مهاجرتِ اعمال‌نشده مانده یا نه، کش کار می‌کند یا نه، و چند پایه/درس/سوال روی آن سرور هست. 200 یعنی سالم، 503 یعنی چیزی واقعاً خراب است. هیچ سکرتی برنمی‌گرداند.

این همان چیزی‌ست که جلوی «کد رفته ولی مهاجرت اجرا نشده» را می‌گیرد — حالتی که اپ ظاهراً بالا می‌آید و بعد زیرِ بارِ واقعی می‌شکند.

مدیریتِ محتوا بدونِ ترمینال

هیچ محتوایی لازم نیست با دستور روی سرور گذاشته شود.

کار کجا
افزودن/ویرایشِ یک سوال /admin/content/question/ → «افزودن» (انتخابِ درس با جستجو)
وارد کردنِ فله‌ای سوال همان صفحه → «وارد کردنِ سوال‌ها از فایل»
گرفتنِ خروجیِ سوال‌ها چند سوال را انتخاب کنید → اکشنِ «دریافتِ فایل»
وارد کردنِ برنامه‌ی درسی (پایه/فصل/درس) /admin/content/grade/ → «وارد کردنِ برنامه‌ی درسی از فایل»
گرفتنِ خروجیِ برنامه‌ی درسی پایه‌ها را انتخاب کنید → اکشنِ «دریافتِ فایلِ برنامه‌ی درسی»

خروجیِ هر کدام دقیقاً همان قالبی‌ست که ورودی‌اش می‌پذیرد، پس چرخه‌ی «بگیر → ویرایش کن → برگردان» بسته است و برای گرفتنِ نمونه لازم نیست کسی مستندات بخواند.

دو تضمین:

  • سوال‌ها: فایلِ نامعتبر هیچ چیزی نمی‌نویسد — یا همه وارد می‌شوند یا هیچ‌کدام.
  • برنامه‌ی درسی: چیزی پاک نمی‌شود. هر چه در فایل هست ساخته/به‌روز می‌شود و هر چه نیست دست‌نخورده می‌ماند، پس یک فایلِ ناقص محتوای سرور را از بین نمی‌برد.

ساختِ انبوهِ سوال از کتاب

وقتی کتابی تازه روی Qdrant آمده و باید برایش سوال ساخته شود، مسیرِ رایگان این است:

# اسکلتِ برنامه‌ی درسی از فهرستِ خودِ کتاب (بدونِ هزینه)
python manage.py seed_curriculum_from_textbook --subject ریاضی --grade نهم --dry-run
python manage.py seed_curriculum_from_textbook --subject ریاضی --grade نهم

# جفت‌های (درس، مسئله‌ی تأییدشده) را برای سوال‌نویسی بیرون بده (بدونِ هزینه)
python manage.py generate_questions_from_chunks --subject ریاضی --grade نهم --export /tmp/batches

خروجی چند فایلِ JSON است به‌علاوه‌ی یک *-RULES.txt که قواعدِ ساختِ سوال را دارد. سوال‌ها را با آن قواعد بنویسید و از «وارد کردنِ فله‌ای سوال» در جدولِ بالا واردشان کنید. همان فرمان بدونِ --export کار را به مدلِ پولی می‌سپارد، ولی گران‌تر است و کیفیتِ بهتری نمی‌دهد. جزئیات و هزینه‌های اندازه‌گیری‌شده در docs/extraction-playbook.md.

--dry-run روی هر دو فرمان کار می‌کند و پیش از هر نوشتنی نشان می‌دهد چه ساخته خواهد شد.

داده‌ی تستی روی سرور

seed_test_data فقط برای محیطِ لوکال است و نباید روی سروری که کاربرِ واقعی دارد اجرا شود؛ ردیف‌هایی با پیشوندِ [SEED]/[DEMO] می‌سازد که در API دیده می‌شوند. محتوای واقعی از مسیرِ جدولِ بالا می‌رود، نه از این دستور.

متغیرهای محیطی موردنیاز

متغیر لازم در پروداکشن توضیح
DJANGO_DEBUG باید ست‌نشده یا 0 باشه دیفالت‌های امنیتیِ پروداکشن رو فعال می‌کنه
DJANGO_SECRET_KEY بله بدونش وقتی DEBUG=False است اپ اصلاً بالا نمیاد
DJANGO_ALLOWED_HOSTS بله لیستِ host ها، کاما-جدا
CACHE_URL بله آدرسِ Redis، مثلاً redis://host:6379/0 — برای rate limitِ مشترک وقتی DEBUG=False لازمه
KAVENEGAR_API_KEY بله ارسال واقعیِ OTP از طریق کاوه‌نگار
GRAFANA_ADMIN_PASSWORD فقط اگه از استکِ Grafana استفاده می‌کنید بخشِ Docker پایین رو ببینید

تنظیمات پروداکشن

  • DEBUG باید ست نشه (یا 0 باشه). با DEBUG=False:
    • SECRET_KEY اجباریه — هیچ fallback ناامنی وجود نداره.
    • CACHE_URL (Redis) اجباریه — LocMemCache فقط برای dev هست.
    • DEV_MASTER_OTP و فیلدِ devOtp توی پاسخ‌ها غیرفعال می‌شن.
  • برای ترافیک واقعی به‌جای SQLیت پیش‌فرض، از یک دیتابیسِ پروداکشن‌گرید استفاده کنید (پروژه با همون SQLite شیپ می‌شه؛ عوض‌کردنِ engine یک تصمیمِ زیرساختیه، خارج از این ریپو).
  • از طریق یک سرورِ WSGI/ASGI (مثلاً gunicorn) جلوی اپ سرو کنید — تنظیماتِ Docker موجود همچنان runserver اجرا می‌کنه و فقط برای dev/local مستند شده (پایین رو ببینید).

تنظیمات Docker/دیپلوی

docker-compose.yml موجود، اپ رو به‌همراه یک استکِ لاگِ متمرکز (Loki + Grafana Alloy + Grafana) اجرا می‌کنه. این یک تنظیمِ dev/local هست، نه دیپلویِ پروداکشن (سرویسِ django همچنان runserver اجرا می‌کنه، نه یک سرورِ پروداکشن).

cp .env.example .env      # اختیاری — بدونش compose از دیفالت‌های dev استفاده می‌کنه
docker compose up --build
سرویس آدرس توضیح
اپ Django http://localhost:8000
Grafana http://localhost:3000 دسترسیِ anonymous admin، فقط محیطِ محلی
Loki http://localhost:3100 ذخیره‌سازیِ لاگ، در معرضِ کاربر نیست
Alloy http://localhost:12345 رابطِ دیباگِ اختیاری

ترتیبِ لازمِ بالا آمدن

Compose خودش این رو مدیریت می‌کنه:

  1. اول loki بالا میاد.
  2. alloy و grafana بعد از loki بالا میان (depends_on).
  3. django هیچ وابستگی‌ای به استکِ لاگ نداره — خودش migrate بعد runserver رو اجرا می‌کنه و منتظرِ Loki/Alloy/Grafana نمی‌مونه.

دستورهای manage.py داخلِ Docker

اگه فقط از طریقِ Docker اجرا می‌کنید (بدونِ venv محلی)، python manage.py ... رو مستقیم روی سیستم خودتون اجرا نکنید — باید داخلِ کانتینرِ django اجرا بشه:

docker compose exec django python manage.py createsuperuser
docker compose exec django python manage.py seed_test_data
docker compose exec django python manage.py test

(migrate نیازی به این نیست — طبقِ بالا، خودکار قبل از runserver اجرا می‌شه.)

مایگریشن‌ها

python manage.py migrate --noinput

در Docker، این به‌صورت خودکار به‌عنوان بخشی از دستورِ سرویسِ django، قبل از runserver اجرا می‌شه.

راهنمای seed برای staging/تست

  • هرگز seed_test_data رو روی یک دیتابیسِ پروداکشنِ واقعی اجرا نکنید.
  • روی staging (DEBUG=False)، صریحاً با --force اجرا کنید:
    python manage.py seed_test_data --force
    
  • برای پاک‌کردن و ساختِ دوباره‌ی دیتای seed بین چرخه‌های تست، از --reset استفاده کنید. همه‌ی رکوردهای seed‌شده در فیلدهای متنیِ قابل‌مشاهده با تگِ [SEED] مشخص شدن، برای شناساییِ راحت‌تر و پاک‌سازی.

چطور API دیپلوی‌شده رو verify کنیم

curl -i https://<host>/api/schema/          # باید 200 برگردونه (OpenAPI schema)
curl -i https://<host>/api/docs/            # باید 200 برگردونه (Swagger UI)
curl -i -X POST https://<host>/api/auth/session \
  -H "Content-Type: application/json" \
  -d '{"type":"guest","deviceId":"verify-1"}'   # باید یک token برگردونه

نکات مهمِ پروداکشن

  • DEV_MASTER_OTP (کد 0000) و فیلدِ devOtp توی پاسخ‌ها فقط وقتی DEBUG=True وجود دارن — قبل از رفتن روی محیطِ واقعی مطمئن بشید DEBUG خاموشه.
  • احراز هویت با Bearer token امضاشده و stateless (django.core.signing)، هماهنگ با مدل Session — بدون کتابخانه‌ی JWT خارجی.
  • accounts.User یک مدل بیزنسیِ مستقله، نه AUTH_USER_MODEL جنگو.
  • همه‌ی لاگ‌های ساختاریافته‌ی درخواست (method/path/status/duration/user، بدونِ بدنه/هدر/سکرت) روی stdout و — وقتی LOG_DIR ست باشه (مثلِ داخلِ Docker) — در /var/log/django/app.log نوشته می‌شن؛ قابلِ مشاهده در Grafana از طریقِ استکِ بالا.
  • POST /api/ai/ask با تنظیمِ OPENAI_API_KEY/QDRANT_API_KEY پاسخِ واقعیِ RAG می‌دهد (اپ rag)؛ بدونِ کلید همان stub. جزئیات: بخشِ «دستیار هوش مصنوعی (RAG)» بالاتر.
  • ارسالِ OTP از طریقِ کاوه‌نگار (KAVENEGAR_API_KEY) انجام می‌شه؛ شکستِ ارسال به‌جای موفقیتِ ساختگی، خطای صریحِ 503 برمی‌گردونه.
  • هر خطای پیش‌بینی‌نشده (نه فقط خطاهای شناخته‌شده‌ی API) همیشه پاسخِ JSON برمی‌گردونه، نه صفحه‌ی HTML پیش‌فرضِ جنگو، و کامل با traceback لاگ می‌شه (config/exceptions.py).
Description
No description provided
Readme 67 MiB
Languages
Python 96.8%
Shell 2.2%
HTML 0.9%