Files
MathApp/content/question_io.py
Zareian 3dacf3a50f 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>
2026-09-13 21:42:36 +03:30

145 lines
5.8 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.
"""
وارد/خارج کردنِ سوال‌ها به‌صورتِ فایل، از داخلِ پنلِ ادمین.
چرا: تا پیش از این تنها راهِ گذاشتنِ سوال روی یک سرور، زدنِ دستور در ترمینالِ
همان سرور بود. یعنی هر تغییرِ محتوایی به کسی که SSH دارد وابسته می‌شد، تاریخچه
نداشت، و برگشت‌پذیر نبود. محتوا داده است نه کد؛ باید با رابطِ کاربری ویرایش شود.
قالبِ فایل عمداً ساده است تا یک معلم هم بتواند در ویرایشگرِ متن درستش کند:
[
{
"lessonId": 42,
"prompt": "حاصل ۲+۳ چند است؟",
"type": "multiple_choice",
"choices": ["۴", "۵", "۶", "۷"],
"correct": 1
}
]
`correct` شماره‌ی گزینه‌ی درست است (از صفر). خروجیِ export دقیقاً همین قالب را
می‌دهد، پس چرخه‌ی «بگیر → ویرایش کن → برگردان» بسته است.
"""
import json
from django.db import transaction
from .models import Choice, Lesson, Question
VALID_TYPES = {value for value, _ in Question.TYPE_CHOICES}
class ImportError_(Exception):
"""خطای قابلِ نمایش به کاربر (نه باگ)."""
def _validate_row(row, index: int) -> dict:
where = f'ردیف {index + 1}'
if not isinstance(row, dict):
raise ImportError_(f'{where}: باید یک شیء JSON باشد.')
lesson_id = row.get('lessonId')
if not isinstance(lesson_id, int):
raise ImportError_(f'{where}: lessonId باید عدد باشد.')
prompt = (row.get('prompt') or '').strip()
if not prompt:
raise ImportError_(f'{where}: prompt خالی است.')
qtype = row.get('type') or 'multiple_choice'
if qtype not in VALID_TYPES:
raise ImportError_(f'{where}: نوعِ «{qtype}» معتبر نیست. مجاز: {"، ".join(sorted(VALID_TYPES))}')
choices = row.get('choices')
if not isinstance(choices, list) or len(choices) < 2:
raise ImportError_(f'{where}: حداقل دو گزینه لازم است.')
choices = [str(c).strip() for c in choices]
if any(not c for c in choices):
raise ImportError_(f'{where}: گزینه‌ی خالی دارد.')
if len(set(choices)) != len(choices):
raise ImportError_(f'{where}: گزینه‌های تکراری دارد.')
correct = row.get('correct')
if not isinstance(correct, int) or not (0 <= correct < len(choices)):
raise ImportError_(f'{where}: correct باید شماره‌ی یکی از گزینه‌ها باشد (از صفر).')
return {
'lesson_id': lesson_id,
'prompt': prompt,
'type': qtype,
'choices': choices,
'correct': correct,
}
def parse(raw: bytes) -> list[dict]:
try:
data = json.loads(raw.decode('utf-8'))
except UnicodeDecodeError:
raise ImportError_('فایل باید UTF-8 باشد.')
except json.JSONDecodeError as exc:
raise ImportError_(f'JSON نامعتبر است: {exc.msg} (خط {exc.lineno})')
if isinstance(data, dict):
data = data.get('questions', data)
if not isinstance(data, list):
raise ImportError_('محتوای فایل باید یک آرایه از سوال‌ها باشد.')
if not data:
raise ImportError_('فایل خالی است.')
return [_validate_row(row, i) for i, row in enumerate(data)]
@transaction.atomic
def import_questions(rows: list[dict], *, replace: bool = False) -> dict:
"""
همه‌ی ردیف‌ها یا با هم نوشته می‌شوند یا هیچ‌کدام — نیمه‌کاره نمی‌ماند.
replace=True یعنی سوال‌های قبلیِ همان درس‌ها اول پاک شوند؛ بدونِ آن
سوال‌ها اضافه می‌شوند.
"""
lesson_ids = {row['lesson_id'] for row in rows}
existing = set(Lesson.objects.filter(pk__in=lesson_ids).values_list('pk', flat=True))
missing = sorted(lesson_ids - existing)
if missing:
raise ImportError_(
'این درس‌ها وجود ندارند: ' + '، '.join(str(m) for m in missing[:10])
+ ('…' if len(missing) > 10 else '')
)
removed = 0
if replace:
# 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:
question = Question.objects.create(
lesson_id=row['lesson_id'], type=row['type'], prompt=row['prompt'],
)
Choice.objects.bulk_create([
Choice(question=question, value=value, is_correct=(i == row['correct']))
for i, value in enumerate(row['choices'])
])
created += 1
return {'created': created, 'removed': removed, 'lessons': len(lesson_ids)}
def export_questions(queryset) -> list[dict]:
"""خروجی دقیقاً همان قالبی‌ست که import می‌خواند."""
out = []
for question in queryset.prefetch_related('choices').select_related('lesson'):
choices = list(question.choices.all())
correct = next((i for i, c in enumerate(choices) if c.is_correct), 0)
out.append({
'lessonId': question.lesson_id,
'prompt': question.prompt,
'type': question.type,
'choices': [c.value for c in choices],
'correct': correct,
})
return out