""" 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', [])), # IPای که throttle برای همین درخواست می‌بیند. باید IPِ عمومیِ خودِ # صدازننده باشد؛ اگر IPِ ArvanCloud یا nginx بود، NUM_PROXIES غلط # است و همه‌ی مهمان‌ها یک سهمِ مشترک دارند. فقط IPِ خودِ صدازننده # را به خودش برمی‌گرداند، پس چیزی لو نمی‌دهد. 'clientIp': ScopedRateThrottle().get_ident(request), } return Response(payload, status=200 if critical_ok else 503)