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 %} +
یک فایلِ JSON با این قالب بدهید. برای گرفتنِ نمونه، از فهرستِ سوالها چند + مورد را انتخاب کنید و اکشنِ «دریافتِ فایلِ سوالهای انتخابشده» را بزنید — + خروجی دقیقاً همین قالب است.
+ +[
+ {
+ "lessonId": 42,
+ "prompt": "حاصل ۲+۳ چند است؟",
+ "type": "multiple_choice",
+ "choices": ["۴", "۵", "۶", "۷"],
+ "correct": 1
+ }
+]
+
+ correct شمارهی گزینهی درست است و از صفر شمرده میشود.