Files
MathApp/config/health.py
Zareian 5aa10eb7b7 give every developer a way into the API that does not depend on one person
With DEBUG=0 and Kavenegar unconfigured, POST /api/auth/otp/request returns a
hard 503, so the only way to sign in was for someone with admin access to hand-
create an OTPCode row. That made every frontend developer's login depend on one
person being available.

TEST_LOGIN_PHONES is the pattern real apps use for exactly this (Firebase Auth's
fictional numbers, Apple's App Store demo account): a short allowlist of
phone:code pairs that skip SMS delivery entirely. Unlike DEV_MASTER_OTP it is
not "any phone with one code" and it is not tied to DEBUG -- each number carries
its own code, and everything else takes the normal OTP path untouched.

The code never appears in an API response, so knowing the number is not enough
to log in, and the existing per-phone login attempt limit still applies, so the
code cannot be brute-forced. Malformed configuration raises ImproperlyConfigured
at startup rather than being skipped silently, and /api/health reports how many
test numbers are open (count only) so they cannot be forgotten on a server that
has real users.

The code is capped at six digits because LoginRequestSerializer caps otp at
max_length=6; a longer code would have been rejected before reaching the check
and looked like the feature was broken.

The guest path the frontend uses today is untouched, and leaving the variable
empty reproduces the previous behaviour exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:52:57 +03:30

185 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
GET /api/health — «آیا این سرور واقعاً سالم است؟»
چرا لازم بود: تا امروز تنها راهِ فهمیدنِ اینکه دیپلوی درست نشسته یا نه، این
بود که کسی از بیرون اپ را دستی امتحان کند و اگر چیزی غلط بود به آدمی که به
سرور دسترسی دارد پیام بدهد. یعنی خرابی را همیشه آدم کشف می‌کرد، نه ابزار.
این اندپوینت همان بررسی‌ها را از بیرون و بدونِ دسترسیِ SSH ممکن می‌کند.
مهم‌ترین بررسی «مهاجرت‌های اعمال‌نشده» است: شایع‌ترین حالتِ خرابِ دیپلوی این
است که کد pull شده ولی migrate اجرا نشده — اپ بالا می‌آید و بعد زیرِ بارِ
واقعی با خطای ستونِ ناموجود می‌شکند.
عمداً هیچ سکرتی برنمی‌گرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا.
"""
from datetime import timedelta
from django.conf import settings
from django.core.cache import cache
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView
def _check_database() -> dict:
try:
with connection.cursor() as cursor:
cursor.execute('SELECT 1')
cursor.fetchone()
return {'ok': True, 'engine': connection.vendor}
except Exception as exc: # noqa: BLE001
return {'ok': False, 'error': type(exc).__name__}
def _check_migrations() -> dict:
"""مهاجرتِ اعمال‌نشده یعنی دیپلوی نیمه‌کاره است."""
try:
executor = MigrationExecutor(connection)
targets = executor.loader.graph.leaf_nodes()
pending = executor.migration_plan(targets)
# migration_plan یک فهرست از (migration, backwards) می‌دهد؛ خودِ
# Migration فیلدهای app_label و name دارد (نه key).
return {
'ok': not pending,
'pending': [f'{migration.app_label}.{migration.name}' for migration, _ in pending],
}
except Exception as exc: # noqa: BLE001
return {'ok': False, 'error': type(exc).__name__}
def _check_cache() -> dict:
"""با DEBUG=False کشِ مشترک اجباری است؛ throttle بدونش بی‌اثر می‌شود."""
try:
cache.set('health:probe', 'ok', 10)
return {'ok': cache.get('health:probe') == 'ok', 'backend': settings.CACHES['default']['BACKEND'].rsplit('.', 1)[-1]}
except Exception as exc: # noqa: BLE001
return {'ok': False, 'error': type(exc).__name__}
def _check_content() -> dict:
"""
محتوا روی این سرور هست یا نه — بدونِ این، اپ سالم به نظر می‌رسد ولی
کاربر صفحه‌ی خالی می‌بیند.
"""
try:
from content.models import Grade, Lesson, Question
grades = Grade.objects.count()
lessons = Lesson.objects.count()
questions = Question.objects.count()
return {
'ok': grades > 0 and lessons > 0,
'grades': grades,
'lessons': lessons,
'questions': questions,
}
except Exception as exc: # noqa: BLE001
return {'ok': False, 'error': type(exc).__name__}
def _traffic() -> dict:
"""
آیا کسی واقعاً به این سرور وصل می‌شود؟
تا امروز راهی نبود که از بیرون بفهمیم فرانت به سرور رسیده یا نه — باید
منتظر می‌ماندیم کسی بگوید. این سه عدد همان سیگنال‌اند. فقط شمارش و زمان،
بدونِ هیچ اطلاعاتِ شخصی.
"""
try:
from django.utils import timezone
from accounts.models import User
since = timezone.now() - timedelta(hours=24)
last = User.objects.order_by('-last_activity_at').values_list(
'last_activity_at', flat=True,
).first()
return {
'users': User.objects.count(),
'activeLast24h': User.objects.filter(last_activity_at__gte=since).count(),
'lastActivityAt': last.isoformat() if last else None,
}
except Exception as exc: # noqa: BLE001
return {'error': type(exc).__name__}
def _check_integrations() -> dict:
"""فقط «تنظیم شده یا نه» — هیچ کلیدی برنمی‌گردد."""
return {
'qdrant': bool(getattr(settings, 'QDRANT_API_KEY', '')),
'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')),
'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')),
}
def _warnings() -> list[str]:
"""
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناک‌اند.
اینجا هستند چون تنها راهِ دیدنشان تا امروز این بود که کسی تنظیماتِ سرور را
از نزدیک بخواند — و ما به سرور دسترسی نداریم.
"""
out = []
if settings.DEBUG:
out.append(
'DEBUG روشن است: صفحه‌های خطا کلِ تنظیمات و traceback را نشان می‌دهند، '
'و کدِ ورودِ آزمایشیِ 0000 برای هر شماره‌ای پذیرفته می‌شود. '
'روی سرورِ عمومی باید خاموش باشد.'
)
if getattr(settings, 'DEV_MASTER_OTP', None) is not None:
out.append('کدِ یک‌سانِ ورود (DEV_MASTER_OTP) فعال است — هر کسی می‌تواند به‌جای هر کاربری وارد شود.')
test_phones = getattr(settings, 'TEST_LOGIN_PHONES', {})
if test_phones:
# نه خودِ شماره و نه کد برنمی‌گردد؛ فقط تعداد، تا کسی که تنظیماتِ سرور
# را ندیده هم بداند چند حسابِ تست با کدِ ثابت باز است و بتواند ببنددشان.
out.append(
f'{len(test_phones)} شماره‌ی تستِ ورود با کدِ ثابت تعریف شده (TEST_LOGIN_PHONES). '
'برای توسعه لازم است؛ پیش از تحویلِ نهایی به کاربرانِ واقعی حذفشان کنید.'
)
backend = settings.CACHES['default']['BACKEND']
if not settings.DEBUG and 'LocMem' in backend:
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')
if not getattr(settings, 'CORS_ALLOWED_ORIGINS', None):
out.append('هیچ origin ای برای CORS مجاز نیست — مرورگر همه‌ی درخواست‌های فرانت را بلاک می‌کند.')
return out
class HealthView(APIView):
"""
GET /api/health — وضعیتِ این نسخه‌ی مستقرشده.
۲۰۰ یعنی سالم، ۵۰۳ یعنی چیزی واقعاً خراب است (دیتابیس، کش، یا مهاجرتِ
اعمال‌نشده). نبودِ محتوا خرابی حساب نمی‌شود ولی گزارش می‌شود.
"""
permission_classes = [AllowAny]
authentication_classes = []
throttle_classes = [ScopedRateThrottle]
throttle_scope = 'health'
@extend_schema(tags=['System'], responses={200: dict, 503: dict})
def get(self, request):
checks = {
'database': _check_database(),
'migrations': _check_migrations(),
'cache': _check_cache(),
'content': _check_content(),
}
critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache'))
warnings = _warnings()
payload = {
'status': 'ok' if critical_ok else 'error',
'debug': bool(settings.DEBUG),
'warnings': warnings,
'checks': checks,
'integrations': _check_integrations(),
'traffic': _traffic(),
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
}
return Response(payload, status=200 if critical_ok else 503)