From 3dacf3a50f49a76e9112b0a6aabc3772b1e97e1e Mon Sep 17 00:00:00 2001 From: Zareian Date: Sun, 13 Sep 2026 21:42:36 +0330 Subject: [PATCH] fix two checks that only broke in the case they exist for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit بازبینیِ کارِ امروز، سه مورد پیدا کرد. /api/health وقتی مهاجرتِ اعمال‌نشده وجود داشت می‌شکست: کدِ فهرست‌کردنِ مهاجرت‌ها به migration.key تکیه می‌کرد که اصلاً وجود ندارد (فیلدها app_label و name هستند). یعنی دقیقاً در همان حالتی که این بررسی برایش ساخته شده، به‌جای نامِ مهاجرت‌ها فقط AttributeError برمی‌گرداند. حالا تستی دارد که مهاجرتِ معلق را شبیه‌سازی می‌کند — چیزی که نبودش باعث شد اولین بار غلط بماند. وارد کردنِ سوال با replace عددِ غلط گزارش می‌کرد: delete() در جنگو مجموعِ همه‌ی ردیف‌های آبشاری را می‌دهد، پس «۵ سوال پاک شد» در واقع گزینه‌ها و پاسخ‌ها را هم می‌شمرد. deploy.sh بررسیِ متغیرهای محیطی‌اش با تنظیماتِ جدید ناسازگار شده بود: متغیرهایی را اجباری می‌دانست که حالا پیش‌فرض دارند، و فقط شل را می‌خواند نه .env — یعنی روی سرورِ درست هم دیپلوی را رد می‌کرد. حالا به‌جای تکرارِ منطقِ تنظیمات، از خودِ جنگو می‌پرسد چه چیزی مؤثر است و گزارش می‌دهد. کوچک‌تر: بررسیِ سلامت یک درخواست می‌زند نه دو تا، و شکستِ ری‌استارت دیگر اسکریپت را بی‌صدا تمام نمی‌کند. Co-Authored-By: Claude Opus 5 --- config/health.py | 5 ++- content/question_io.py | 5 ++- content/tests_question_io.py | 49 ++++++++++++++++++++++ deploy.sh | 79 ++++++++++++++++++++---------------- 4 files changed, 101 insertions(+), 37 deletions(-) diff --git a/config/health.py b/config/health.py index e047de8..4fab0cd 100644 --- a/config/health.py +++ b/config/health.py @@ -39,10 +39,11 @@ def _check_migrations() -> dict: 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'{app}.{name}' for (app, name), _ in - ((m.key, m) for m, _ in 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__} diff --git a/content/question_io.py b/content/question_io.py index 1b4e867..cc51a85 100644 --- a/content/question_io.py +++ b/content/question_io.py @@ -109,7 +109,10 @@ def import_questions(rows: list[dict], *, replace: bool = False) -> dict: removed = 0 if replace: - removed, _ = Question.objects.filter(lesson_id__in=lesson_ids).delete() + # delete() مجموعِ همه‌ی ردیف‌های آبشاری (گزینه‌ها، پاسخ‌ها، گزارش‌ها) را + # برمی‌گرداند؛ آنچه به کاربر گزارش می‌شود باید فقط تعدادِ سوال باشد. + _, per_model = Question.objects.filter(lesson_id__in=lesson_ids).delete() + removed = per_model.get('content.Question', 0) created = 0 for row in rows: diff --git a/content/tests_question_io.py b/content/tests_question_io.py index f8d0e1e..f565175 100644 --- a/content/tests_question_io.py +++ b/content/tests_question_io.py @@ -180,3 +180,52 @@ class HealthWarningTests(TestCase): def test_allowed_origins_include_the_deployed_frontend(self): body = self.client.get('/api/health').json() self.assertIn('https://math.tadrisino2.ir', body['allowedOrigins']) + + +class MigrationCheckTests(TestCase): + """ + این بررسی مهم‌ترین چیزی‌ست که /api/health می‌گوید، و تنها وقتی اهمیت دارد + که واقعاً مهاجرتِ اعمال‌نشده باشد — یعنی همان حالتی که تستش سخت است و + برای همین اولین بار غلط نوشته شد (Migration فیلدِ key ندارد). + """ + + def test_pending_migrations_are_listed_by_name(self): + from unittest.mock import patch + + from django.db.migrations import Migration + + from config import health + + fake = Migration('0009_something', 'content') + with patch.object(health.MigrationExecutor, 'migration_plan', return_value=[(fake, False)]): + result = health._check_migrations() + + self.assertFalse(result['ok']) + self.assertEqual(result['pending'], ['content.0009_something']) + self.assertNotIn('error', result, 'نباید به استثنا بیفتد') + + def test_a_clean_database_reports_nothing_pending(self): + from config import health + + result = health._check_migrations() + self.assertTrue(result['ok']) + self.assertEqual(result['pending'], []) + + +class ReplaceCountTests(TestCase): + """عددی که به کاربر گزارش می‌شود باید تعدادِ سوال باشد، نه مجموعِ ردیف‌های آبشاری.""" + + def test_replace_reports_the_number_of_questions_not_of_rows(self): + grade = Grade.objects.create(title='پایه نهم', icon='', color='') + unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1) + node = Node.objects.create(unit=unit, title='درس ۱', order=1) + lesson = Lesson.objects.create(node=node, title='نوع ۱') + + payload = json.dumps([{ + 'lessonId': lesson.pk, 'prompt': 'سوال یک', 'type': 'multiple_choice', + 'choices': ['۱', '۲', '۳', '۴'], 'correct': 0, + }], ensure_ascii=False).encode() + import_questions(parse(payload)) # ۱ سوال + ۴ گزینه = ۵ ردیف + + result = import_questions(parse(payload), replace=True) + self.assertEqual(result['removed'], 1, 'باید ۱ باشد نه ۵') diff --git a/deploy.sh b/deploy.sh index 81497fd..66b5ac1 100755 --- a/deploy.sh +++ b/deploy.sh @@ -60,39 +60,40 @@ python -m pip install "${PIP_ARGS[@]}" \ || fail "نصبِ وابستگی‌ها شکست خورد. اگر pypi.org تایم‌اوت می‌دهد، این را امتحان کنید: PIP_INDEX_URL=https://mirror-pypi.runflare.com/simple ./deploy.sh" -# ─── ۳.۵ بررسیِ متغیرهای محیطی، قبل از دست زدن به دیتابیس ────────────────── -# چرا اینجا: اگر SECRET_KEY یا CACHE_URL نباشد، جنگو موقعِ بالا آمدن می‌میرد و -# از بیرون فقط 502 دیده می‌شود. بهتر است همین‌جا و با پیامِ روشن متوقف شویم. -step "بررسیِ متغیرهای محیطی" -python - <<'PYCHECK' || fail "متغیرهای محیطیِ لازم ست نیستند؛ هیچ تغییری اعمال نشد." -import os, sys +# ─── ۳.۵ تنظیماتِ مؤثر ───────────────────────────────────────────────────── +# اینجا عمداً چیزی را اجباری نمی‌کنیم: تنظیماتِ پروژه خودش پیش‌فرضِ سالم دارد و +# با فایلِ خالی هم بالا می‌آید. تکرارِ آن منطق در این اسکریپت یعنی دو جا که +# باید هم‌زمان به‌روز بمانند — و همان باعث می‌شد دیپلویِ درست هم رد شود. +# پس فقط از خودِ جنگو می‌پرسیم چه چیزی واقعاً فعال است (شاملِ مقادیرِ .env). +step "تنظیماتِ مؤثر" +python - <<'PYCHECK' || fail "تنظیمات اصلاً بار نشد؛ هیچ تغییری اعمال نشد." +import os -debug = os.environ.get('DJANGO_DEBUG', '').strip().lower() in {'1', 'true', 'yes', 'on'} -missing = [] +import django -if not os.environ.get('DJANGO_ALLOWED_HOSTS', '').strip(): - missing.append(('DJANGO_ALLOWED_HOSTS', 'دامنه‌هایی که سرو می‌شوند، مثلاً api.math.tadrisino2.ir')) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +django.setup() +from django.conf import settings -if not debug: - if not os.environ.get('DJANGO_SECRET_KEY', '').strip(): - missing.append(('DJANGO_SECRET_KEY', 'کلیدِ امضا؛ با DEBUG خاموش اجباری است')) - if not os.environ.get('CACHE_URL', '').strip(): - missing.append(('CACHE_URL', 'آدرسِ Redis؛ محدودیتِ نرخ بدونش بینِ workerها مشترک نیست')) - -if missing: - print(' این متغیرها ست نیستند:') - for name, why in missing: - print(f' {name:24} {why}') - print() - print(' در فایلِ .env سرور اضافه‌شان کنید (نمونه: .env.example).') - sys.exit(1) - -if debug: - print(' ⚠ DJANGO_DEBUG روشن است.') - print(' روی سرورِ عمومی یعنی: صفحه‌های خطا کلِ تنظیمات را نشان می‌دهند،') - print(' و کدِ ورودِ 0000 برای هر شماره‌ای پذیرفته می‌شود.') +print(f' DEBUG : {settings.DEBUG}') +print(f' ALLOWED_HOSTS : {", ".join(settings.ALLOWED_HOSTS) or "—"}') +print(f' CORS مجاز : {", ".join(settings.CORS_ALLOWED_ORIGINS) or "—"}') +print(f' کش : {settings.CACHES["default"]["BACKEND"].rsplit(".", 1)[-1]}') +if os.environ.get('DJANGO_SECRET_KEY'): + key_source = 'از متغیرِ محیطی' +elif settings.DEBUG: + key_source = 'کلیدِ ثابتِ حالتِ توسعه (در پروداکشن استفاده نمی‌شود)' else: - print(' ✓ حالتِ پروداکشن') + key_source = 'تولیدشده و ذخیره‌شده در .django_secret_key' +print(f' کلیدِ امضا : {key_source}') + +if settings.DEBUG: + print() + print(' ⚠ DEBUG روشن است. روی سرورِ عمومی یعنی:') + print(' • صفحه‌های خطا کلِ تنظیمات و traceback را نشان می‌دهند') + print(' • کدِ ورودِ 0000 برای هر شماره‌ای پذیرفته می‌شود') +if 'LocMem' in settings.CACHES['default']['BACKEND'] and not settings.DEBUG: + print(' ⚠ بدونِ Redis محدودیتِ نرخ بینِ workerها مشترک نیست (CACHE_URL).') PYCHECK # ─── ۴. دیتابیس ──────────────────────────────────────────────────────────── @@ -124,16 +125,24 @@ python manage.py check --deploy 2>&1 | sed 's/^/ /' || true step "ری‌استارتِ سرویس" SERVICE="${TADRISINO_SERVICE:-}" if [ -z "$SERVICE" ] && command -v systemctl >/dev/null 2>&1; then + # `systemctl cat` تنها بررسیِ قابلِ اتکاست: list-unit-files حتی وقتی چیزی + # پیدا نکند هم کدِ ۰ برمی‌گرداند. for candidate in tadrisino tadrisino-api mathapp gunicorn; do - if systemctl list-unit-files "${candidate}.service" >/dev/null 2>&1 \ - && systemctl cat "${candidate}.service" >/dev/null 2>&1; then + if systemctl cat "${candidate}.service" >/dev/null 2>&1; then SERVICE="$candidate"; break fi done fi if [ -n "$SERVICE" ]; then - sudo systemctl restart "$SERVICE" && printf ' سرویسِ %s ری‌استارت شد\n' "$SERVICE" + # شکستِ ری‌استارت نباید اسکریپت را بی‌صدا تمام کند؛ بررسیِ سلامتِ پایین + # همچنان باید اجرا شود تا معلوم شود سرویسِ قبلی هنوز بالاست یا نه. + if sudo systemctl restart "$SERVICE"; then + printf ' سرویسِ %s ری‌استارت شد\n' "$SERVICE" + else + warn "ری‌استارتِ $SERVICE شکست خورد (شاید sudo رمز می‌خواهد). خودتان اجرا کنید: + sudo systemctl restart $SERVICE" + fi else warn "سرویسِ systemd پیدا نشد؛ خودتان ری‌استارت کنید، مثلاً: sudo systemctl restart <نامِ سرویس> @@ -145,8 +154,10 @@ fi step "بررسیِ سلامت" printf ' %s\n' "$HEALTH_URL" for attempt in 1 2 3 4 5 6 7 8 9 10; do - BODY="$(curl -s -m 10 "$HEALTH_URL" 2>/dev/null || true)" - CODE="$(curl -s -m 10 -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null || echo 000)" + # یک درخواست، نه دو تا: دو فراخوانیِ جدا می‌توانست دو وضعیتِ متفاوت ببیند. + RESPONSE="$(curl -s -m 10 -w '\n%{http_code}' "$HEALTH_URL" 2>/dev/null || printf '\n000')" + CODE="${RESPONSE##*$'\n'}" + BODY="${RESPONSE%$'\n'*}" if [ "$CODE" = "200" ]; then printf '%s✓ سرویس سالم بالا آمد.%s\n' "$C_OK" "$C_OFF" printf '%s\n' "$BODY" | python -m json.tool 2>/dev/null | sed 's/^/ /' || printf ' %s\n' "$BODY"