diff --git a/content/admin.py b/content/admin.py index 970edcb..15789a5 100644 --- a/content/admin.py +++ b/content/admin.py @@ -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) diff --git a/content/question_io.py b/content/question_io.py new file mode 100644 index 0000000..1b4e867 --- /dev/null +++ b/content/question_io.py @@ -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 diff --git a/content/templates/admin/content/question/change_list.html b/content/templates/admin/content/question/change_list.html new file mode 100644 index 0000000..91c75eb --- /dev/null +++ b/content/templates/admin/content/question/change_list.html @@ -0,0 +1,8 @@ +{% extends "admin/change_list.html" %} +{# دکمه‌ی «وارد کردنِ سوال‌ها» کنارِ دکمه‌ی «افزودن» #} +{% block object-tools-items %} +
  • + وارد کردنِ سوال‌ها از فایل +
  • + {{ block.super }} +{% endblock %} diff --git a/content/templates/admin/content/question/import.html b/content/templates/admin/content/question/import.html new file mode 100644 index 0000000..b6581ac --- /dev/null +++ b/content/templates/admin/content/question/import.html @@ -0,0 +1,36 @@ +{% extends "admin/base_site.html" %} +{% block content %} +
    +

    وارد کردنِ سوال‌ها از فایل

    + +

    یک فایلِ JSON با این قالب بدهید. برای گرفتنِ نمونه، از فهرستِ سوال‌ها چند + مورد را انتخاب کنید و اکشنِ «دریافتِ فایلِ سوال‌های انتخاب‌شده» را بزنید — + خروجی دقیقاً همین قالب است.

    + +
    [
    +  {
    +    "lessonId": 42,
    +    "prompt": "حاصل ۲+۳ چند است؟",
    +    "type": "multiple_choice",
    +    "choices": ["۴", "۵", "۶", "۷"],
    +    "correct": 1
    +  }
    +]
    + +

    correct شماره‌ی گزینه‌ی درست است و از صفر شمرده می‌شود.

    + +
    + {% csrf_token %} +

    +

    + +

    +

    اگر فایل ایرادی داشته باشد هیچ چیزی نوشته نمی‌شود — + یا همه‌ی سوال‌ها وارد می‌شوند یا هیچ‌کدام.

    +

    +
    +
    +{% endblock %} diff --git a/content/tests_question_io.py b/content/tests_question_io.py new file mode 100644 index 0000000..c3b39db --- /dev/null +++ b/content/tests_question_io.py @@ -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)