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

View File

@@ -1,4 +1,12 @@
from django.contrib import admin
import json
from django.contrib import admin, messages
from django.http import HttpResponse
from django.shortcuts import redirect, render
from django.urls import path, reverse
from django.utils.html import format_html
from .question_io import ImportError_, export_questions, import_questions, parse
from .models import (
Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion,
@@ -35,9 +43,63 @@ class LessonAdmin(admin.ModelAdmin):
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
"""
سوال‌ها علاوه بر ویرایشِ تکی، از همین‌جا فله‌ای وارد و خارج می‌شوند.
چرا اینجا و نه در ترمینال: محتوا باید بدونِ دسترسیِ SSH و بدونِ برنامه‌نویس
قابلِ تغییر باشد. خروجیِ «دریافتِ فایل» دقیقاً همان قالبی‌ست که «وارد کردن»
می‌پذیرد، پس چرخه‌ی ویرایش بسته است.
"""
list_display = ('id', 'prompt', 'lesson', 'type')
list_filter = ('type', 'lesson')
inlines = [ChoiceInline]
actions = ['export_selected']
change_list_template = 'admin/content/question/change_list.html'
def get_urls(self):
return [
path(
'import/',
self.admin_site.admin_view(self.import_view),
name='content_question_import',
),
] + super().get_urls()
@admin.action(description='دریافتِ فایلِ سوال‌های انتخاب‌شده')
def export_selected(self, request, queryset):
payload = json.dumps(export_questions(queryset), ensure_ascii=False, indent=2)
response = HttpResponse(payload, content_type='application/json; charset=utf-8')
response['Content-Disposition'] = 'attachment; filename="questions.json"'
return response
def import_view(self, request):
if request.method == 'POST':
uploaded = request.FILES.get('file')
if uploaded is None:
self.message_user(request, 'فایلی انتخاب نشده بود.', messages.ERROR)
return redirect(request.path)
replace = request.POST.get('replace') == 'on'
try:
rows = parse(uploaded.read())
result = import_questions(rows, replace=replace)
except ImportError_ as exc:
# خطای قابلِ فهم برای کاربر — هیچ چیزی نوشته نشده.
self.message_user(request, str(exc), messages.ERROR)
return redirect(request.path)
note = f"{result['created']} سوال در {result['lessons']} درس اضافه شد"
if result['removed']:
note += f" و {result['removed']} ردیفِ قبلی پاک شد"
self.message_user(request, note + '.', messages.SUCCESS)
return redirect(reverse('admin:content_question_changelist'))
return render(request, 'admin/content/question/import.html', {
**self.admin_site.each_context(request),
'title': 'وارد کردنِ سوال‌ها',
'opts': self.model._meta,
})
@admin.register(UserLessonCompletion)

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

View File

@@ -0,0 +1,8 @@
{% extends "admin/change_list.html" %}
{# دکمه‌ی «وارد کردنِ سوال‌ها» کنارِ دکمه‌ی «افزودن» #}
{% block object-tools-items %}
<li>
<a href="{% url 'admin:content_question_import' %}" class="addlink">وارد کردنِ سوال‌ها از فایل</a>
</li>
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends "admin/base_site.html" %}
{% block content %}
<div dir="rtl" style="max-width:760px">
<h1>وارد کردنِ سوال‌ها از فایل</h1>
<p>یک فایلِ JSON با این قالب بدهید. برای گرفتنِ نمونه، از فهرستِ سوال‌ها چند
مورد را انتخاب کنید و اکشنِ «دریافتِ فایلِ سوال‌های انتخاب‌شده» را بزنید —
خروجی دقیقاً همین قالب است.</p>
<pre style="background:#f6f6f6;padding:12px;border-radius:6px;direction:ltr;text-align:left">[
{
"lessonId": 42,
"prompt": "حاصل ۲+۳ چند است؟",
"type": "multiple_choice",
"choices": ["۴", "۵", "۶", "۷"],
"correct": 1
}
]</pre>
<p><code>correct</code> شماره‌ی گزینه‌ی درست است و از صفر شمرده می‌شود.</p>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<p><input type="file" name="file" accept=".json,application/json" required></p>
<p>
<label>
<input type="checkbox" name="replace">
سوال‌های قبلیِ همان درس‌ها اول پاک شوند
</label>
</p>
<p style="color:#666">اگر فایل ایرادی داشته باشد هیچ چیزی نوشته نمی‌شود —
یا همه‌ی سوال‌ها وارد می‌شوند یا هیچ‌کدام.</p>
<p><input type="submit" value="وارد کن" class="default"></p>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,96 @@
"""
تست‌های وارد/خارج کردنِ سوال‌ها.
نکته‌ی محوری: فایلِ بد نباید چیزی بنویسد. وارد کردنِ نیمه‌کاره بدترین حالت است
چون کسی که فایل را داده فکر می‌کند کار شده، ولی درس نصفه پر است.
"""
import json
from django.test import TestCase
from content.models import Choice, Grade, Lesson, Node, Question, Unit
from content.question_io import ImportError_, export_questions, import_questions, parse
class QuestionImportTests(TestCase):
def setUp(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)
self.lesson = Lesson.objects.create(node=node, title='نوع ۱')
def _payload(self, **over):
row = {
'lessonId': self.lesson.pk,
'prompt': 'حاصل ۲+۳ چند است؟',
'type': 'multiple_choice',
'choices': ['۴', '۵', '۶', '۷'],
'correct': 1,
}
row.update(over)
return json.dumps([row], ensure_ascii=False).encode()
def test_a_valid_file_creates_the_question_and_marks_one_choice_correct(self):
result = import_questions(parse(self._payload()))
self.assertEqual(result['created'], 1)
question = Question.objects.get()
self.assertEqual(question.choices.count(), 4)
self.assertEqual(question.choices.filter(is_correct=True).get().value, '۵')
def test_an_unknown_lesson_is_rejected_before_anything_is_written(self):
with self.assertRaises(ImportError_):
import_questions(parse(self._payload(lessonId=999999)))
self.assertEqual(Question.objects.count(), 0)
def test_a_bad_row_in_the_middle_rolls_the_whole_file_back(self):
good = json.loads(self._payload())[0]
bad = dict(good, lessonId=999999)
raw = json.dumps([good, bad], ensure_ascii=False).encode()
with self.assertRaises(ImportError_):
import_questions(parse(raw))
self.assertEqual(Question.objects.count(), 0, 'نباید نیمه‌کاره بنویسد')
def test_correct_out_of_range_is_rejected(self):
with self.assertRaises(ImportError_):
parse(self._payload(correct=9))
def test_duplicate_choices_are_rejected(self):
with self.assertRaises(ImportError_):
parse(self._payload(choices=['۵', '۵', '۶', '۷']))
def test_broken_json_says_where_it_broke(self):
with self.assertRaises(ImportError_) as ctx:
parse(b'[{"lessonId": 1,,}]')
self.assertIn('JSON', str(ctx.exception))
def test_replace_clears_the_previous_questions_of_that_lesson(self):
import_questions(parse(self._payload()))
import_questions(parse(self._payload(prompt='سوال تازه')), replace=True)
self.assertEqual(Question.objects.count(), 1)
self.assertEqual(Question.objects.get().prompt, 'سوال تازه')
def test_export_round_trips_back_through_import(self):
import_questions(parse(self._payload()))
exported = export_questions(Question.objects.all())
Question.objects.all().delete()
import_questions(parse(json.dumps(exported, ensure_ascii=False).encode()))
self.assertEqual(Question.objects.get().choices.filter(is_correct=True).get().value, '۵')
class HealthEndpointTests(TestCase):
def test_reports_ok_and_counts_content(self):
response = self.client.get('/api/health')
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body['status'], 'ok')
self.assertTrue(body['checks']['database']['ok'])
self.assertTrue(body['checks']['migrations']['ok'])
self.assertIn('questions', body['checks']['content'])
def test_never_leaks_a_secret(self):
raw = self.client.get('/api/health').content.decode()
for needle in ('sk-', 'SECRET', 'API_KEY', 'password'):
self.assertNotIn(needle, raw)
def test_needs_no_authentication(self):
self.assertEqual(self.client.get('/api/health').status_code, 200)