make the API docs page answer a consumer's first questions, and fix a missing endpoint

The Swagger header was a list of features. That is what the project does, not
what a caller needs. It now opens with the three things anyone integrating
against this API asks first: how do I get a token, what does an error look like,
and when do I get rate limited -- plus the field-naming and date conventions,
and a note that the schema is the contract and clients should be generated from
it rather than written from memory. Swagger UI and Redoc both render Markdown,
so it is structured rather than one long paragraph, which also fixes how badly
the old mixed Persian/Latin line wrapped right-to-left.

Two defects surfaced while checking the rendered schema:

- /api/health was absent from the schema entirely. The trailing-slash alias was
  excluded with extend_schema(exclude=True)(HealthView), but that decorator
  mutates the class, and both routes pointed at the same class, so the canonical
  route disappeared with it. A subclass now carries the exclusion, and the
  endpoint is back, exactly once. Endpoint count is 56, not 55.
- One endpoint was tagged 'محتوا (content)' while every other content endpoint
  used 'Curriculum (content)', which split the group into two sections.

Tags are now declared with descriptions and ordered by the path a real user
takes, rather than alphabetically.

Comment pass over the codebase, keeping the substance and dropping the noise:
the "⚠️ اضافه شده" prefixes dated comments against a moment no current reader
shares; "فاز ۲ / بخش ۴" referenced a plan that no longer exists and only
repeated the app name on the next line; "فاز اول - ۴ از ۱۱ اپ" had drifted (there
are fourteen). INSTALLED_APPS is now grouped by responsibility. The
TEST_LOGIN_PHONES block in settings shrank from 29 lines to 8 now that the same
explanation lives in docs/operations.md and .env.example -- a comment beside the
code should carry what a code reader needs, not a copy of the runbook.

Checked mechanically that no comment references an identifier that no longer
exists: zero. The dated measurements in rag/services/ingest.py and
generate_questions_from_chunks.py were deliberately left alone -- a measurement
carrying the date it was taken is the useful kind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 01:28:32 +03:30
parent 5492788bf8
commit ae0375c26c
10 changed files with 143 additions and 76 deletions

View File

@@ -180,30 +180,28 @@ INSTALLED_APPS = [
'drf_spectacular',
'corsheaders',
# Tadrisino apps (فاز اول - ۴ از ۱۱ اپ)
# اپ‌های خودمان. هر کدام یک مسئولیت و مدل‌های خودش را دارد؛ ارجاعِ بینِ
# اپ‌ها با user_id/object_id انجام می‌شود نه ForeignKey، تا هر کدام بعداً
# قابلِ جدا شدن بماند (docs/architecture.md).
# مسیرِ اصلیِ کاربر: ورود، محتوا، پیشرفت، دستیار
'accounts',
'content',
'progress',
'ai_tutor',
# Tadrisino apps (فاز دوم - بخش ۱ از ۳: subscription, payment, discount)
# کسبِ درآمد
'subscription',
'payment',
'discount',
# Tadrisino apps (فاز دوم - بخش ۲: notifications)
# ارتباط با کاربر و پشتیبانی
'notifications',
# Tadrisino apps (فاز دوم - بخش ۳: affiliate)
'affiliate',
# Tadrisino apps (فاز دوم - بخش ۴: support)
'support',
# Tadrisino apps (فاز دوم - بخش ۵: family)
'family',
# Tadrisino apps (سیستم پرداخت بازار + آفر/تخفیف + لاگ عمومی تغییر Status)
# لاگِ عمومیِ تغییرِ وضعیت، مستقل از بقیه‌ی اپ‌ها
'statuslog',
# Tadrisino apps (مدلِ مینیمالِ ذخیره‌سازیِ کمپین‌های پیامی، بدونِ منطقِ ارسال)
@@ -429,12 +427,100 @@ REST_FRAMEWORK = {
SPECTACULAR_SETTINGS = {
'TITLE': 'Tadrisino API',
# این متن قراردادِ بینِ ما و تیمِ فرانت است و تنها جایی‌ست که هر دو طرف
# می‌بینند. پس به‌جای فهرستِ امکانات، به سه سوالِ اولِ هر مصرف‌کننده جواب
# می‌دهد: چطور توکن بگیرم، خطا چه شکلی برمی‌گردد، و کِی محدود می‌شوم.
# Swagger UI و Redoc هر دو Markdown را رندر می‌کنند.
'DESCRIPTION': (
'بک‌اند اپلیکیشن آموزشی Tadrisino — احراز هویت با OTP، محتوای درسی '
'و پیشرفت کاربر، دستیار هوش مصنوعی، اشتراک و پرداخت، تخفیف، '
'اعلان‌ها، همکاری در فروش، پشتیبانی و حساب خانواده.'
'بک‌اندِ اپلیکیشنِ آموزشیِ تدریسینو: برنامه‌ی درسی، آزمون، پیشرفتِ '
'کاربر، دستیارِ هوشِ مصنوعی، اشتراک و پرداخت.\n'
'\n'
'## احرازِ هویت\n'
'\n'
'همه‌ی درخواست‌های احرازشده با یک توکنِ Bearer انجام می‌شوند:\n'
'\n'
'```\n'
'Authorization: Bearer <token>\n'
'```\n'
'\n'
'دو راه برای گرفتنِ توکن هست:\n'
'\n'
'| مسیر | کاربرد |\n'
'|---|---|\n'
'| `POST /api/auth/session` | ورودِ مهمان با `deviceId` — بدونِ شماره، بلافاصله |\n'
'| `POST /api/auth/otp/request` + `POST /api/auth/login` | ورودِ کامل با شماره‌ی موبایل |\n'
'\n'
'کاربرِ مهمان می‌تواند بعداً شماره‌اش را وصل کند و همان حساب را نگه '
'دارد. توکنِ دسترسی یک ساعت اعتبار دارد؛ با `POST /api/auth/refresh` '
'تازه‌اش کنید.\n'
'\n'
'**در همین صفحه:** روی **Authorize** بزنید و *فقط خودِ توکن* را وارد '
'کنید — کلمه‌ی `Bearer` را Swagger خودش اضافه می‌کند.\n'
'\n'
'اندپوینتی که توکن نگیرد، درخواست را ناشناس می‌بیند؛ اینکه ورود لازم '
'باشد یا نه، به خودِ آن اندپوینت بستگی دارد.\n'
'\n'
'## خطاها\n'
'\n'
'هر خطا — حتی خطای پیش‌بینی‌نشده — یک پاسخِ JSON است، نه صفحه‌ی HTML. '
'پیامِ `detail` فارسی و قابلِ نمایش به کاربرِ نهایی است.\n'
'\n'
'```json\n'
'{"detail": "کد OTP نامعتبر یا منقضی‌شده است."}\n'
'```\n'
'\n'
'| کد | معنی |\n'
'|---|---|\n'
'| `400` | ورودی نامعتبر است |\n'
'| `401` | توکن نیست، نامعتبر است یا منقضی شده |\n'
'| `403` | توکن معتبر است ولی اجازه‌ی این کار را ندارد |\n'
'| `404` | چیزی با این شناسه وجود ندارد |\n'
'| `429` | از محدودیتِ نرخ گذشتید — بخشِ بعد |\n'
'| `503` | سرویسِ بیرونی (پیامک، پرداخت) در دسترس نیست |\n'
'\n'
'`503` عمدی‌ست: وقتی سرویسِ بیرونی تنظیم یا در دسترس نباشد، پاسخِ '
'صریح می‌گیرید، نه موفقیتِ ساختگی.\n'
'\n'
'## محدودیتِ نرخ\n'
'\n'
'اندپوینت‌های حساس (ورود، درخواستِ کد، پرسش از دستیار) سقفِ مستقل '
'دارند. عبور از سقف یعنی `429`، و هر جا که معنی داشته باشد هدرِ '
'`Retry-After` هم می‌آید. کاربرِ مهمان بر اساسِ IP شمرده می‌شود.\n'
'\n'
'مسیرِ ورود سقفِ جداگانه‌ای بر اساسِ *شماره* هم دارد، مستقل از IP، تا '
'پخش کردنِ تلاش بینِ چند IP فایده‌ای نداشته باشد.\n'
'\n'
'## قراردادها\n'
'\n'
'- نامِ فیلدها `camelCase` است (`deviceId`, `refreshToken`, `nodeId`).\n'
'- زمان‌ها ISO 8601 با منطقه‌ی زمانی‌اند.\n'
'- متنِ رو به کاربر فارسی است؛ شناسه‌ها و نامِ فیلدها انگلیسی.\n'
'- این اسکیما **مرجعِ قرارداد** است. کلاینت را از روی حافظه ننویسید — '
'از `/api/schema/` تولیدش کنید.\n'
),
'VERSION': '1.0.0',
'CONTACT': {'name': 'تیمِ بک‌اندِ تدریسینو'},
'EXTERNAL_DOCS': {
'description': 'راهنمای اتصالِ فرانت‌اند',
'url': 'https://gitea.tadrisino2.ir/Tadrisino/MathApp/src/branch/main/docs/frontend/README.md',
},
# ترتیبِ تگ‌ها در Swagger به همین ترتیب است، نه الفبایی — یعنی مسیرِ
# واقعیِ یک کاربر: اول وارد شو، بعد محتوا بگیر، بعد پیشرفت و بقیه.
'TAGS': [
{'name': 'Identity & Profile (accounts)', 'description': 'ورود، session، پروفایل'},
{'name': 'Curriculum (content)', 'description': 'پایه، فصل، نود، درس، سوال، آزمون'},
{'name': 'Progress & Gamification (progress)', 'description': 'آمار، streak، لیدربرد، شخصیت‌ها'},
{'name': 'AI Tutor (ai_tutor)', 'description': 'پرسش از دستیار و بازخورد روی پاسخ'},
{'name': 'RAG (rag)', 'description': 'بازیابی از محتوای کتاب — داخلی'},
{'name': 'Subscription (subscription)', 'description': 'پلن‌ها و اشتراکِ کاربر'},
{'name': 'Payment (payment)', 'description': 'سفارش و تراکنش (زرین‌پال و کافه‌بازار)'},
{'name': 'Discount (discount)', 'description': 'کدِ تخفیف و آفرها'},
{'name': 'Notifications (notifications)', 'description': 'اعلان‌های کاربر'},
{'name': 'Family (family)', 'description': 'کنترلِ والدین و گزارشِ هفتگی'},
{'name': 'Support (support)', 'description': 'تیکت و گزارشِ اشکال'},
{'name': 'Affiliate (affiliate)', 'description': 'برنامه‌ی معرفی و پورسانت'},
{'name': 'System', 'description': 'سلامتِ سرویس'},
],
'SERVE_INCLUDE_SCHEMA': False,
'COMPONENT_SPLIT_REQUEST': True,
'SWAGGER_UI_SETTINGS': {
@@ -483,35 +569,14 @@ OTP_REQUEST_PHONE_COOLDOWN_SECONDS = 120
# فقط وقتی DEBUG=True مقدار دارد؛ در پروداکشن None است.
DEV_MASTER_OTP = '0000' if DEBUG else None
# -----------------------------------------------------------------------
# شماره‌های تستِ ورود (TEST_LOGIN_PHONES)
# -----------------------------------------------------------------------
# مشکلی که این حل می‌کند: با DEBUG=0 و بدونِ کاوه‌نگار، هیچ توسعه‌دهنده‌ای
# نمی‌تواند وارد API شود مگر اینکه کسی با دسترسیِ ادمین دستی یک ردیفِ OTPCode
# بسازد. یعنی ورودِ هر عضوِ تیم به یک آدمِ خاص وابسته بود.
# شماره‌های تستِ ورود: «شماره:کد» با کامای جداکننده. برای این شماره‌ها پیامک
# ارسال نمی‌شود و همان کدِ ثابت معتبر است — تا ورودِ تیمِ توسعه به تنظیم بودنِ
# کاوه‌نگار یا به یک آدمِ خاص وابسته نباشد. برخلافِ DEV_MASTER_OTP «هر
# شماره‌ای» نیست و به DEBUG گره نخورده. روالِ استفاده در docs/operations.md.
#
# این همان الگویی است که اپ‌های واقعی برای همین کار دارند (Firebase Auth
# «fictional phone numbers»، Apple «demo account» برای بررسیِ App Store):
# چند شماره‌ی مشخص با کدِ ثابت، که پیامک برایشان ارسال نمی‌شود. برخلافِ
# DEV_MASTER_OTP این «هر شماره‌ای» نیست — فقط همین چند شماره، و هر کدام
# کدِ خودش را دارد.
#
# TEST_LOGIN_PHONES=09120000001:834512,09120000002:110947
#
# قواعدی که عمداً رعایت می‌شوند:
# • کد هیچ‌وقت در پاسخِ API برنمی‌گردد — هر کس شماره را بداند هم نمی‌تواند
# کد را از سرور بیرون بکشد.
# • سقفِ تلاشِ ورودِ هر شماره (OTP_LOGIN_PHONE_MAX_ATTEMPTS) روی این‌ها هم
# اعمال می‌شود، پس حدس‌زدنِ کد با brute-force بسته است.
# • ورودیِ خراب باعثِ بالا نیامدنِ سرویس می‌شود، نه نادیده گرفته شدنِ بی‌صدا.
# • /api/health وجودشان را هشدار می‌دهد تا روی سرور فراموش نشوند.
#
# ⚠️ شماره باید *دقیقاً* همان رشته‌ای باشد که کلاینت می‌فرستد. این کدبیس شماره
# ⚠️ شماره باید *دقیقاً* همان رشته‌ای باشد که کلاینت می‌فرستد: این کدبیس شماره
# را normalize نمی‌کند (accounts/serializers.py فقط CharField است)، پس
# «09121111111» و «+989121111111» دو کاربرِ جدا هستند و تطبیق رشته‌ای است.
#
# ⚠️ این حساب‌ها حسابِ تست‌اند: کدِ واقعی و تصادفی بگذارید (نه 1234)، و هیچ
# داده‌ی واقعیِ کاربری رویشان نگذارید.
# «09121111111» و «+989121111111» دو کاربرِ جدا هستند.
def _parse_test_login_phones(raw: str) -> dict[str, str]:
pairs = {}
for item in raw.split(','):

View File

@@ -19,6 +19,11 @@ from drf_spectacular.views import (
SpectacularSwaggerView,
)
@extend_schema(exclude=True)
class HealthViewSlashAlias(HealthView):
"""نسخه‌ی اسلش‌دارِ `/api/health`، فقط برای پایشِ بیرونی — بیرون از اسکیما."""
logger = logging.getLogger(__name__)
@@ -52,37 +57,32 @@ urlpatterns = [
# هر `git pull` بعدی روی همین فایل گیر می‌کرد. یک مسیرِ اضافه ارزانش
# است در برابرِ یک فایلِ دست‌کاری‌شده روی پروداکشن.
path('api/health', HealthView.as_view(), name='health'),
# همان ویو، فقط برای پایشی که با اسلش صدا می‌زند. از اسکیما بیرون است تا دو
# مسیرِ یکسان operationIdهای health_retrieve و health_retrieve_2 نسازند.
path('api/health/', extend_schema(exclude=True)(HealthView).as_view(),
name='health-slash'),
# همان ویو، فقط برای پایشی که با اسلش صدا می‌زند. از اسکیما بیرون است تا
# دو مسیرِ یکسان operationIdهای health_retrieve و health_retrieve_2 نسازند.
#
# ⚠️ زیرکلاس لازم است، نه exclude روی خودِ HealthView. extend_schema روی
# یک کلاس همان کلاس را تغییر می‌دهد، و چون هر دو مسیر به یک کلاس اشاره
# می‌کردند، `/api/health` هم با خودش از اسکیما بیرون می‌افتاد — یعنی
# اندپوینتِ سلامت اصلاً در Swagger دیده نمی‌شد.
path('api/health/', HealthViewSlashAlias.as_view(), name='health-slash'),
# OpenAPI schema + Swagger UI + Redoc
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
# اپ‌های فاز ۱
# همه زیرِ /api/ جمع می‌شوند؛ هر اپ مسیرهای خودش را نگه می‌دارد، پس
# ترتیبِ این فهرست اهمیتی ندارد و تداخلی بینشان نیست.
path('api/', include('accounts.urls')),
path('api/', include('content.urls')),
path('api/', include('progress.urls')),
path('api/', include('ai_tutor.urls')),
path('api/', include('rag.urls')),
# اپ‌های فاز ۲ / بخش ۱ (subscription, payment, discount)
path('api/', include('subscription.urls')),
path('api/', include('payment.urls')),
path('api/', include('discount.urls')),
# اپ‌های فاز ۲ / بخش ۲ (notifications)
path('api/', include('notifications.urls')),
# اپ‌های فاز ۲ / بخش ۳ (affiliate)
path('api/', include('affiliate.urls')),
# اپ‌های فاز ۲ / بخش ۴ (support)
path('api/', include('support.urls')),
# اپ‌های فاز ۲ / بخش ۵ (family)
path('api/', include('family.urls')),
]