Files
MathApp/docs/architecture.md
Zareian 0c6b08c9ff make every grade's id its class number and sort grades by it everywhere
Grade ids were the order the books were imported in (ninth=1, eighth=5), so
gradeId: 5 meant eighth grade and the admin, the question filter and the API
all listed grades out of order. Grade.number (1-12) is now read from the title,
new grades take it as their id, and migration 0008 moves existing ids plus every
reference to them: Unit, the loose grade_id in accounts/ai_tutor/rag, and the
grade target inside campaign JSON. Unnumbered demo grades move above 100 and the
id sequence is raised past 12 so they never collide with a real grade.

The frontend needs no change: it always reads gradeId from the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:47:02 +03:30

7.1 KiB
Raw Blame History

معماری

تصمیم‌هایی که از خواندنِ کد واضح نیستند و دلیلشان.

احرازِ هویت، سیستمِ خودمان است

accounts.User عمداً AUTH_USER_MODEL نیست — یک مدلِ بیزنسیِ مستقل است. ورود با توکنِ bearerِ امضاشده و stateless انجام می‌شود (django.core.signing، بدونِ کتابخانه‌ی JWT)، که در accounts/authentication.py صادر و تأیید می‌شود و پشتوانه‌اش مدلِ accounts.Session است.

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

DJANGO_SECRET_KEY همین توکن‌ها را امضا می‌کند. عوض شدنش یعنی باطل شدنِ همه‌ی نشست‌های فعال.

اپ‌ها با قرارداد جدا شده‌اند، نه با ForeignKey

هر اپ یک مسئولیت دارد و مدل‌های خودش را نگه می‌دارد. ارجاعِ بینِ اپ‌ها با user_id یا object_id + label انجام می‌شود، نه با FK واقعی.

دلیلش این است که هر اپ بعداً قابلِ جدا شدن بماند. FK بینِ اپ‌ها اضافه نکنید — الگوی ارجاعِ شل را دنبال کنید.

محدودیتِ نرخ در یک جا تعریف می‌شود

هر اندپوینتِ حساس یک throttle_scope دارد و نرخش در REST_FRAMEWORK['DEFAULT_THROTTLE_RATES'] داخلِ config/settings.py است. اندپوینتِ جدیدِ حساس یعنی هر دو: یک throttle_scope روی ویو و یک ردیفِ متناظر در تنظیمات.

کاربرانِ مهمان با IP محدود می‌شوند. NUM_PROXIES تعیین می‌کند چند hop از X-Forwarded-For قابلِ اعتماد است و باید با تعدادِ واقعیِ پروکسی‌ها یکی باشد، وگرنه جعلِ IP و دور زدنِ محدودیت ممکن می‌شود.

پرداخت: هرگز مستقیم به درگاه

create_order هیچ‌وقت زرین‌پال را صدا نمی‌زند. paymentUrl به اندپوینتِ خودمان اشاره می‌کند (GET /api/payment/orders/{id}/pay)، که در اولین بازدید PaymentRequest را می‌زند، Order.gateway_authority را ذخیره می‌کند و به StartPay می‌فرستد. این کار ساختِ سفارش را به در دسترس بودنِ زرین‌پال وابسته نمی‌کند.

بازگشتِ کاربر به GET /api/payment/zarinpal/callback می‌رسد، که همیشه سرور-به-سرور با payment/zarinpal.py::verify_payment دوباره تأیید می‌کند. یک Status=OK جعلی در query string به‌تنهایی هیچ کاری نمی‌کند.

هم payment/zarinpal.py و هم payment/bazaar.py وقتی اعتبارنامه ندارند fail-closed می‌شوند (503 یا نامعتبر)، نه موفقیتِ ساختگی. همین قاعده برای پیامک هم برقرار است.

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

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

  • نیازمندِ OPENAI_API_KEY و QDRANT_API_KEY. بدونِ کلید، همان پاسخِ stubِ قبلی برمی‌گردد (سازگاریِ عقب‌رو).
  • "stream": true پاسخ را به‌صورتِ SSE توکن‌به‌توکن می‌فرستد؛ قراردادِ JSON بدونِ این فلگ دست‌نخورده است.
  • python manage.py rag_catalog فهرستِ زنده‌ی collectionهای Qdrant را نشان می‌دهد (فقط‌خواندنی).
  • افزودنِ محتوای خودمان: POST /api/rag/ingest/text و /api/rag/ingest/pdf (داخلی، با هدرِ X-Internal-Key).

کیفیتِ محتوایی که این لایه می‌خواند در content-authoring.md توضیح داده شده.

ساختِ آزمون

اپ در سطحِ نود بازی می‌شود (جزیره روی نقشه)، ولی سوال‌ها به درس وصلند — و درس اینجا ریزترین دانه است: یک نوعِ مسئله از کتاب، نه یک جلسه‌ی کلاسی.

GET /api/nodes/{id}/quiz آزمون را از درس‌های یک نود می‌سازد، حداکثر یک سوال از هر درس. ترتیب با nodeId:userId seed می‌شود، پس رفرش همان آزمون را برمی‌گرداند. questionCount جدا از requested گزارش می‌شود تا یک نودِ کم‌سوال شبیهِ خطا به نظر نرسد.

دیتابیس

هنوز SQLite است (OPTIONS.timeout=20 تا قفل‌های هم‌زمانیِ نوشتن را تحمل کند). مهاجرت به Postgres انجام نشده.

idِ هر پایه همان شماره‌ی پایه است (سوم=۳، هشتم=۸، دوازدهم=۱۲) و در فیلدِ Grade.number هم هست. پایه‌ی تازه id را از شماره‌اش می‌گیرد، نه از شمارنده‌ی دیتابیس؛ شماره اگر خالی بماند از عنوان («پایه چهارم») خوانده می‌شود. پایه‌ی بی‌شماره ([DEMO]/[SEED]) idِ بالای ۱۰۰ می‌گیرد. همه‌جا — API، ادمین، فیلترِ سوال‌ها — پایه‌ها به‌ترتیبِ شماره‌اند. تا migration‌ِ content.0008 id ترتیبِ واردشدنِ کتاب‌ها بود (نهم=۱، هشتم=۵).

⚠️ .iterator() را دورِ حلقه‌ای که می‌نویسد یا I/O بیرونی می‌زند نگذارید. کرسرِ خواندن در تمامِ طولِ حلقه باز می‌ماند و روی SQLite جدول را برای هر نویسنده‌ی هم‌زمان قفل می‌کند — قفلی که تا پایانِ حلقه رها نمی‌شود و هیچ retryای نجاتش نمی‌دهد. این یک بارْ ماه‌ها به‌شکلِ یک تستِ «flaky» ظاهر شد؛ باگِ واقعی بود، نه تصادفِ زمان‌بندی. به‌جایش نتیجه را با list(...) مادی کنید.

لاگِ ساختاریافته

config.middleware.APIRequestLoggingMiddleware (آخرین middleware، نزدیک‌ترین به ویو) هر درخواستِ /api/ را به‌صورتِ یک شیءِ JSON در هر خط لاگ می‌کند: متد، مسیر، وضعیت، مدت، کاربر — بدونِ بدنه، هدر یا سکرت (config/log_formatters.py).