fix two checks that only broke in the case they exist for
بازبینیِ کارِ امروز، سه مورد پیدا کرد. /api/health وقتی مهاجرتِ اعمالنشده وجود داشت میشکست: کدِ فهرستکردنِ مهاجرتها به migration.key تکیه میکرد که اصلاً وجود ندارد (فیلدها app_label و name هستند). یعنی دقیقاً در همان حالتی که این بررسی برایش ساخته شده، بهجای نامِ مهاجرتها فقط AttributeError برمیگرداند. حالا تستی دارد که مهاجرتِ معلق را شبیهسازی میکند — چیزی که نبودش باعث شد اولین بار غلط بماند. وارد کردنِ سوال با replace عددِ غلط گزارش میکرد: delete() در جنگو مجموعِ همهی ردیفهای آبشاری را میدهد، پس «۵ سوال پاک شد» در واقع گزینهها و پاسخها را هم میشمرد. deploy.sh بررسیِ متغیرهای محیطیاش با تنظیماتِ جدید ناسازگار شده بود: متغیرهایی را اجباری میدانست که حالا پیشفرض دارند، و فقط شل را میخواند نه .env — یعنی روی سرورِ درست هم دیپلوی را رد میکرد. حالا بهجای تکرارِ منطقِ تنظیمات، از خودِ جنگو میپرسد چه چیزی مؤثر است و گزارش میدهد. کوچکتر: بررسیِ سلامت یک درخواست میزند نه دو تا، و شکستِ ریاستارت دیگر اسکریپت را بیصدا تمام نمیکند. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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__}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, 'باید ۱ باشد نه ۵')
|
||||
|
||||
79
deploy.sh
79
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"
|
||||
|
||||
Reference in New Issue
Block a user