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>
185 lines
8.3 KiB
Python
185 lines
8.3 KiB
Python
"""
|
||
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)
|