تا الان تنها راهِ گذاشتنِ سوال روی یک سرور، زدنِ دستور در ترمینالِ همان سرور بود. یعنی هر تغییرِ محتوایی به کسی که SSH دارد وابسته میشد، تاریخچه نداشت و برگشتپذیر نبود. محتوا داده است نه کد؛ باید با رابطِ کاربری ویرایش شود. در /admin/content/question/ دکمهی «وارد کردنِ سوالها از فایل» اضافه شد، و اکشنِ «دریافتِ فایلِ سوالهای انتخابشده» خروجی میدهد. خروجی دقیقاً همان قالبی است که ورودی میپذیرد، پس چرخهی «بگیر → ویرایش کن → برگردان» بسته است و برای گرفتنِ نمونه هم لازم نیست کسی مستندات بخواند. قالب عمداً ساده است (lessonId، prompt، choices، correct) تا کسی که برنامهنویس نیست هم بتواند در ویرایشگرِ متن درستش کند. اعتبارسنجی قبل از هر نوشتنی کامل انجام میشود و کلِ فایل در یک تراکنش است: فایلِ نامعتبر هیچ چیزی نمینویسد. وارد کردنِ نیمهکاره بدترین حالت بود، چون کسی که فایل را داده فکر میکند کار شده ولی درس نصفه پر است. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
142 lines
5.5 KiB
Python
142 lines
5.5 KiB
Python
"""
|
||
وارد/خارج کردنِ سوالها بهصورتِ فایل، از داخلِ پنلِ ادمین.
|
||
|
||
چرا: تا پیش از این تنها راهِ گذاشتنِ سوال روی یک سرور، زدنِ دستور در ترمینالِ
|
||
همان سرور بود. یعنی هر تغییرِ محتوایی به کسی که 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
|