manage questions from the admin panel, not the server terminal

تا الان تنها راهِ گذاشتنِ سوال روی یک سرور، زدنِ دستور در ترمینالِ همان سرور
بود. یعنی هر تغییرِ محتوایی به کسی که SSH دارد وابسته می‌شد، تاریخچه نداشت و
برگشت‌پذیر نبود. محتوا داده است نه کد؛ باید با رابطِ کاربری ویرایش شود.

در /admin/content/question/ دکمه‌ی «وارد کردنِ سوال‌ها از فایل» اضافه شد، و
اکشنِ «دریافتِ فایلِ سوال‌های انتخاب‌شده» خروجی می‌دهد. خروجی دقیقاً همان قالبی
است که ورودی می‌پذیرد، پس چرخه‌ی «بگیر → ویرایش کن → برگردان» بسته است و برای
گرفتنِ نمونه هم لازم نیست کسی مستندات بخواند.

قالب عمداً ساده است (lessonId، prompt، choices، correct) تا کسی که برنامه‌نویس
نیست هم بتواند در ویرایشگرِ متن درستش کند.

اعتبارسنجی قبل از هر نوشتنی کامل انجام می‌شود و کلِ فایل در یک تراکنش است:
فایلِ نامعتبر هیچ چیزی نمی‌نویسد. وارد کردنِ نیمه‌کاره بدترین حالت بود، چون
کسی که فایل را داده فکر می‌کند کار شده ولی درس نصفه پر است.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 20:29:24 +03:30
parent cbe9b63dc4
commit 190bff3d0f
5 changed files with 344 additions and 1 deletions

141
content/question_io.py Normal file
View File

@@ -0,0 +1,141 @@
"""
وارد/خارج کردنِ سوال‌ها به‌صورتِ فایل، از داخلِ پنلِ ادمین.
چرا: تا پیش از این تنها راهِ گذاشتنِ سوال روی یک سرور، زدنِ دستور در ترمینالِ
همان سرور بود. یعنی هر تغییرِ محتوایی به کسی که 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:
removed, _ = Question.objects.filter(lesson_id__in=lesson_ids).delete()
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