From 37d66a4a4c0ec4c2959ea8070f2e5eb70281bf84 Mon Sep 17 00:00:00 2001 From: Zareian Date: Sun, 13 Sep 2026 21:08:00 +0330 Subject: [PATCH] give content editors a drill-down browser and per-question signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit پیدا کردنِ «سوال‌های فلان مبحث» در فهرستِ پیش‌فرضِ جنگو عملاً ممکن نبود: باید بینِ چند صفحه‌ی جدا با دراپ‌داون‌های هزارتایی بالا و پایین می‌رفتی. /admin/content/question/browse/ چهار ستونِ آبشاری دارد — پایه → فصل → درس → مبحث — و هر ستون فقط وقتی پر می‌شود که قبلی انتخاب شده باشد، پس هیچ‌وقت فهرستِ هزارتایی جلوی چشم نیست. ته‌اش سوال‌های همان مبحث با گزینه‌هایشان می‌آیند و از همان‌جا ویرایش یا اضافه می‌شوند. کنارِ هر سوال دو عددِ تصمیم‌ساز: گزارش‌های بازِ کاربران، و درصدِ پاسخِ غلط. درصد زیرِ ۵ پاسخ نشان داده نمی‌شود — یک نمونه‌ی کوچک سوالِ سالم را «مشکل‌دار» جلوه می‌دهد. همین دو ستون به فهرستِ سوال‌ها هم اضافه شدند. content.QuestionReport و POST /api/questions/{id}/report تازه‌اند: تنها بازخوردی که تا امروز جمع می‌شد مالِ چتِ دستیار بود، و روی سوال‌های مسیرِ آموزشی هیچ راهی نبود که کاربر بگوید گزینه‌ی درست غلط است. گزارشِ بازِ تکراریِ همان کاربر ردیفِ تازه نمی‌سازد تا دابل‌کلیک آمار را به‌هم نزند. وابستگیِ تازه‌ای اضافه نشد: چند اندپوینتِ کوچکِ JSON و JavaScript ساده. Co-Authored-By: Claude Opus 5 --- content/admin.py | 63 +++++++- content/admin_browser.py | 111 ++++++++++++++ content/migrations/0005_questionreport.py | 31 ++++ content/models.py | 38 +++++ content/serializers.py | 15 +- content/templates/admin/content/browser.html | 144 ++++++++++++++++++ .../admin/content/question/change_list.html | 5 +- content/tests_admin_browser.py | 116 ++++++++++++++ content/urls.py | 2 + content/views.py | 58 ++++++- 10 files changed, 576 insertions(+), 7 deletions(-) create mode 100644 content/admin_browser.py create mode 100644 content/migrations/0005_questionreport.py create mode 100644 content/templates/admin/content/browser.html create mode 100644 content/tests_admin_browser.py diff --git a/content/admin.py b/content/admin.py index 4db1946..b4457d7 100644 --- a/content/admin.py +++ b/content/admin.py @@ -1,17 +1,21 @@ import json from django.contrib import admin, messages +from django.db.models import Count, Q from django.http import HttpResponse from django.shortcuts import redirect, render from django.urls import path, reverse +from django.utils import timezone from django.utils.html import format_html +from . import admin_browser from .curriculum_io import CurriculumError, export_curriculum, import_curriculum from .curriculum_io import parse as parse_curriculum from .question_io import ImportError_, export_questions, import_questions, parse from .models import ( - Choice, Grade, Lesson, Node, NodeProgress, Question, Unit, UserAnswer, UserLessonCompletion, + Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Unit, UserAnswer, + UserLessonCompletion, ) @@ -108,8 +112,8 @@ class QuestionAdmin(admin.ModelAdmin): می‌پذیرد، پس چرخه‌ی ویرایش بسته است. """ - list_display = ('id', 'prompt', 'lesson', 'type') - list_filter = ('type', 'lesson') + list_display = ('id', 'prompt', 'lesson', 'type', 'open_reports', 'wrong_percent') + list_filter = ('type', 'lesson__node__unit__grade', 'lesson__node__unit') inlines = [ChoiceInline] actions = ['export_selected'] change_list_template = 'admin/content/question/change_list.html' @@ -117,6 +121,33 @@ class QuestionAdmin(admin.ModelAdmin): autocomplete_fields = ['lesson'] search_fields = ('prompt',) + def get_queryset(self, request): + return super().get_queryset(request).annotate( + _open_reports=Count('reports', filter=Q(reports__status='open'), distinct=True), + _total_answers=Count('answers', distinct=True), + _wrong_answers=Count('answers', filter=Q(answers__correct=False), distinct=True), + ) + + @admin.display(description='گزارشِ باز', ordering='_open_reports') + def open_reports(self, obj): + if not obj._open_reports: + return '—' + url = (reverse('admin:content_questionreport_changelist') + + f'?question__id__exact={obj.pk}&status__exact=open') + return format_html('{}', + url, obj._open_reports) + + @admin.display(description='٪ غلط', ordering='_wrong_answers') + def wrong_percent(self, obj): + # زیرِ ۵ پاسخ عددی نشان نمی‌دهیم: یک نمونه‌ی کوچک سوالِ سالم را + # «مشکل‌دار» جلوه می‌دهد. + if obj._total_answers < 5: + return '—' + percent = round(100 * obj._wrong_answers / obj._total_answers) + color = '#b91c1c' if percent >= 70 else '#475569' + return format_html('{}٪ از {}', + color, percent, obj._total_answers) + def get_urls(self): return [ path( @@ -124,6 +155,16 @@ class QuestionAdmin(admin.ModelAdmin): self.admin_site.admin_view(self.import_view), name='content_question_import', ), + # مرورگرِ آبشاریِ محتوا؛ زیرِ همین مدل ثبت می‌شود تا در پنل پیدا باشد. + path('browse/', admin_browser.browser_view, name='content_browse'), + path('browse/units//', admin_browser.units_json, + name='content_browse_units'), + path('browse/nodes//', admin_browser.nodes_json, + name='content_browse_nodes'), + path('browse/lessons//', admin_browser.lessons_json, + name='content_browse_lessons'), + path('browse/questions//', admin_browser.questions_json, + name='content_browse_questions'), ] + super().get_urls() @admin.action(description='دریافتِ فایلِ سوال‌های انتخاب‌شده') @@ -182,3 +223,19 @@ class NodeProgressAdmin(admin.ModelAdmin): ) list_filter = ('status', 'node__unit') search_fields = ('user_id',) + + +@admin.register(QuestionReport) +class QuestionReportAdmin(admin.ModelAdmin): + """گزارشِ کاربران روی سوال‌ها — همان‌جایی که تصمیمِ اصلاحِ محتوا گرفته می‌شود.""" + + list_display = ('id', 'question', 'reason', 'status', 'created_at') + list_filter = ('status', 'reason', 'question__lesson__node__unit__grade') + search_fields = ('question__prompt', 'note') + autocomplete_fields = ['question'] + actions = ['mark_resolved'] + + @admin.action(description='علامت زدن به‌عنوانِ بررسی‌شده') + def mark_resolved(self, request, queryset): + updated = queryset.update(status='resolved', resolved_at=timezone.now()) + self.message_user(request, f'{updated} گزارش بررسی‌شده علامت خورد.', messages.SUCCESS) diff --git a/content/admin_browser.py b/content/admin_browser.py new file mode 100644 index 0000000..fa329a9 --- /dev/null +++ b/content/admin_browser.py @@ -0,0 +1,111 @@ +""" +مرورگرِ محتوا در پنلِ ادمین: پایه → فصل → درس → نوعِ مسئله → سوال‌ها. + +چرا: فهرستِ پیش‌فرضِ جنگو برای پیدا کردنِ «سوال‌های فلان مبحث» مناسب نیست — +باید بینِ چند صفحه‌ی جدا با فیلترهای دراپ‌داونی بالا و پایین رفت، و وقتی +۱۳۰۷ درس و هزاران سوال داری عملاً کار نمی‌کند. + +اینجا هر ستون فقط وقتی پر می‌شود که ستونِ قبلی انتخاب شده باشد، پس هیچ‌وقت +فهرستِ هزارتایی جلوی چشم نیست. کنارِ هر سوال دو عددِ تصمیم‌ساز می‌آید: +گزارش‌های بازِ کاربران، و درصدِ پاسخِ غلط — یعنی همان سیگنالی که می‌گوید کدام +سوال ایراد دارد یا بیش از حد سخت است. + +وابستگیِ تازه‌ای اضافه نمی‌کند: چند اندپوینتِ کوچکِ JSON و JavaScript ساده. +""" +from django.contrib.admin.views.decorators import staff_member_required +from django.db.models import Count, Q +from django.http import JsonResponse +from django.shortcuts import render +from django.urls import reverse + +from .models import Grade, Lesson, Node, Question, Unit + + +@staff_member_required +def browser_view(request): + return render(request, 'admin/content/browser.html', { + 'title': 'مرورِ محتوا', + 'grades': Grade.objects.order_by('title').values('id', 'title'), + }) + + +@staff_member_required +def units_json(request, grade_id: int): + rows = ( + Unit.objects.filter(grade_id=grade_id) + .annotate(node_count=Count('nodes', distinct=True)) + .order_by('order', 'id') + .values('id', 'title', 'node_count') + ) + return JsonResponse({'results': list(rows)}) + + +@staff_member_required +def nodes_json(request, unit_id: int): + rows = ( + Node.objects.filter(unit_id=unit_id) + .annotate(lesson_count=Count('lessons', distinct=True)) + .order_by('order', 'id') + .values('id', 'title', 'lesson_count') + ) + return JsonResponse({'results': list(rows)}) + + +@staff_member_required +def lessons_json(request, node_id: int): + rows = ( + Lesson.objects.filter(node_id=node_id) + .annotate(question_count=Count('questions', distinct=True)) + .order_by('id') + .values('id', 'title', 'question_count') + ) + return JsonResponse({'results': list(rows)}) + + +@staff_member_required +def questions_json(request, lesson_id: int): + """ + هر سوال با دو سیگنال: گزارشِ بازِ کاربران، و درصدِ پاسخِ غلط. + + درصدِ غلط فقط وقتی معنا دارد که تعدادِ پاسخ کم نباشد؛ زیرِ ۵ پاسخ عمداً + None برمی‌گردد تا یک نمونه‌ی تصادفی سوالِ سالم را «مشکل‌دار» نشان ندهد. + """ + questions = ( + Question.objects.filter(lesson_id=lesson_id) + .annotate( + open_reports=Count('reports', filter=Q(reports__status='open'), distinct=True), + total_answers=Count('answers', distinct=True), + wrong_answers=Count('answers', filter=Q(answers__correct=False), distinct=True), + ) + .prefetch_related('choices') + .order_by('id') + ) + + results = [] + for question in questions: + wrong_percent = ( + round(100 * question.wrong_answers / question.total_answers) + if question.total_answers >= 5 else None + ) + results.append({ + 'id': question.pk, + 'prompt': question.prompt, + 'type': question.get_type_display(), + 'choices': [ + {'value': c.value, 'isCorrect': c.is_correct} + for c in question.choices.all() + ], + 'openReports': question.open_reports, + 'totalAnswers': question.total_answers, + 'wrongPercent': wrong_percent, + 'editUrl': reverse('admin:content_question_change', args=[question.pk]), + 'reportsUrl': ( + reverse('admin:content_questionreport_changelist') + + f'?question__id__exact={question.pk}' + ), + }) + + return JsonResponse({ + 'results': results, + 'addUrl': reverse('admin:content_question_add') + f'?lesson={lesson_id}', + }) diff --git a/content/migrations/0005_questionreport.py b/content/migrations/0005_questionreport.py new file mode 100644 index 0000000..4445f6c --- /dev/null +++ b/content/migrations/0005_questionreport.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.17 on 2026-09-13 17:30 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content', '0004_useranswer_idempotency_key_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='QuestionReport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(db_index=True)), + ('reason', models.CharField(choices=[('wrong_answer', 'گزینه\u200cی درست اشتباه است'), ('unclear', 'صورتِ سوال گنگ است'), ('typo', 'غلطِ املایی/نگارشی'), ('too_hard', 'خیلی سخت است'), ('other', 'موردِ دیگر')], default='other', max_length=20)), + ('note', models.TextField(blank=True)), + ('status', models.CharField(choices=[('open', 'باز'), ('resolved', 'بررسی\u200cشده'), ('rejected', 'رد شد')], db_index=True, default='open', max_length=15)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('resolved_at', models.DateTimeField(blank=True, null=True)), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='content.question')), + ], + options={ + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['question', 'status'], name='content_que_questio_57be34_idx')], + }, + ), + ] diff --git a/content/models.py b/content/models.py index d301d34..8a9552a 100644 --- a/content/models.py +++ b/content/models.py @@ -177,3 +177,41 @@ class UserAnswer(models.Model): def __str__(self): return f"answer-user{self.user_id}-q{self.question_id}" + + +class QuestionReport(models.Model): + """ + گزارشِ ایراد روی یک سوالِ مشخص. + + چرا جدا از support.BugReport: آن یکی گزارشِ کلیِ اپ است و به سوال وصل + نمی‌شود. تا وقتی گزارش به خودِ سوال نچسبد، کسی که محتوا را نگه می‌دارد + نمی‌فهمد کدام سوال ایراد دارد — باید متنِ گزارش‌ها را می‌خواند و دستی + پیدا می‌کرد. + + سیگنالِ ضمنی (چند درصد غلط جواب داده‌اند) از UserAnswer درمی‌آید و مدلِ + جدا لازم ندارد؛ این مدل فقط برای گزارشِ صریحِ کاربر است. + """ + + REASON_CHOICES = [ + ('wrong_answer', 'گزینه‌ی درست اشتباه است'), + ('unclear', 'صورتِ سوال گنگ است'), + ('typo', 'غلطِ املایی/نگارشی'), + ('too_hard', 'خیلی سخت است'), + ('other', 'موردِ دیگر'), + ] + STATUS_CHOICES = [('open', 'باز'), ('resolved', 'بررسی‌شده'), ('rejected', 'رد شد')] + + question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='reports') + user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User + reason = models.CharField(max_length=20, choices=REASON_CHOICES, default='other') + note = models.TextField(blank=True) + status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='open', db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + resolved_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['-created_at'] + indexes = [models.Index(fields=['question', 'status'])] + + def __str__(self): + return f'report-{self.pk} on q{self.question_id} ({self.get_reason_display()})' diff --git a/content/serializers.py b/content/serializers.py index ad15934..486ee5a 100644 --- a/content/serializers.py +++ b/content/serializers.py @@ -1,7 +1,7 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from .models import Grade +from .models import Grade, QuestionReport # ---- Grades ---- @@ -133,3 +133,16 @@ class NodeProgressSerializer(serializers.Serializer): class NodeProgressListResponseSerializer(serializers.Serializer): progress = NodeProgressSerializer(many=True) + + +class QuestionReportRequestSerializer(serializers.Serializer): + """دلیل اجباری‌ست تا گزارش‌ها قابلِ دسته‌بندی باشند؛ توضیح اختیاری.""" + + reason = serializers.ChoiceField(choices=QuestionReport.REASON_CHOICES) + note = serializers.CharField(required=False, allow_blank=True, max_length=1000) + + +class QuestionReportResponseSerializer(serializers.Serializer): + success = serializers.BooleanField() + reportId = serializers.IntegerField() + alreadyReported = serializers.BooleanField() diff --git a/content/templates/admin/content/browser.html b/content/templates/admin/content/browser.html new file mode 100644 index 0000000..b57c5c2 --- /dev/null +++ b/content/templates/admin/content/browser.html @@ -0,0 +1,144 @@ +{% extends "admin/base_site.html" %} +{% block content %} + + +
+

مرورِ محتوا

+

از راست به چپ انتخاب کنید تا به سوال‌ها برسید.

+ +
+
+

پایه

+
+ {% for grade in grades %} +
{{ grade.title }}
+ {% empty %} +
هیچ پایه‌ای ثبت نشده.
+ {% endfor %} +
+
+

فصل

اول پایه را انتخاب کنید.
+

درس

—
+

مبحث

—
+
+ +
+
+ + +{% endblock %} diff --git a/content/templates/admin/content/question/change_list.html b/content/templates/admin/content/question/change_list.html index 91c75eb..b30493e 100644 --- a/content/templates/admin/content/question/change_list.html +++ b/content/templates/admin/content/question/change_list.html @@ -1,6 +1,9 @@ {% extends "admin/change_list.html" %} -{# دکمه‌ی «وارد کردنِ سوال‌ها» کنارِ دکمه‌ی «افزودن» #} +{# ابزارهای محتوا کنارِ دکمه‌ی «افزودن» #} {% block object-tools-items %} +
  • + مرورِ محتوا (پایه → فصل → درس → سوال) +
  • وارد کردنِ سوال‌ها از فایل
  • diff --git a/content/tests_admin_browser.py b/content/tests_admin_browser.py new file mode 100644 index 0000000..8b1de73 --- /dev/null +++ b/content/tests_admin_browser.py @@ -0,0 +1,116 @@ +""" +تست‌های پنلِ ادمینِ محتوا. + +اینجا چیزی که بررسی می‌شود «کد اجرا می‌شود» نیست، بلکه «ادمین واقعاً می‌تواند +کارش را بکند» است: صفحه‌ها با یک کاربرِ لاگین‌شده باز شوند، آبشار درست پر شود، +و سوالِ مشکل‌دار در همان فهرست دیده شود. +""" +from django.contrib.auth.models import User as AuthUser +from django.test import TestCase +from django.urls import reverse + +from content.models import ( + Choice, Grade, Lesson, Node, Question, QuestionReport, Unit, UserAnswer, +) + + +class AdminContentBrowserTests(TestCase): + def setUp(self): + AuthUser.objects.create_superuser('admin', 'a@b.c', 'pw12345!') + self.client.login(username='admin', password='pw12345!') + + self.grade = Grade.objects.create(title='پایه نهم', icon='', color='') + self.unit = Unit.objects.create(grade=self.grade, title='فصل ۴: توان و ریشه', order=4) + self.node = Node.objects.create(unit=self.unit, title='توان صحیح', order=1) + self.lesson = Lesson.objects.create(node=self.node, title='ضرب توان‌ها') + self.question = Question.objects.create( + lesson=self.lesson, type='multiple_choice', prompt='حاصل ۲^۳ چند است؟', + ) + self.right = Choice.objects.create(question=self.question, value='۸', is_correct=True) + self.wrong = Choice.objects.create(question=self.question, value='۶', is_correct=False) + + def test_the_browser_page_opens(self): + response = self.client.get(reverse('admin:content_browse')) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'پایه نهم') + + def test_each_column_only_returns_children_of_the_one_before_it(self): + units = self.client.get(reverse('admin:content_browse_units', args=[self.grade.pk])).json() + self.assertEqual([u['title'] for u in units['results']], ['فصل ۴: توان و ریشه']) + self.assertEqual(units['results'][0]['node_count'], 1) + + nodes = self.client.get(reverse('admin:content_browse_nodes', args=[self.unit.pk])).json() + self.assertEqual(nodes['results'][0]['lesson_count'], 1) + + lessons = self.client.get(reverse('admin:content_browse_lessons', args=[self.node.pk])).json() + self.assertEqual(lessons['results'][0]['question_count'], 1) + + def test_questions_come_back_with_their_choices_and_an_edit_link(self): + data = self.client.get( + reverse('admin:content_browse_questions', args=[self.lesson.pk]) + ).json() + row = data['results'][0] + self.assertEqual(row['prompt'], 'حاصل ۲^۳ چند است؟') + self.assertEqual([c['isCorrect'] for c in row['choices']], [True, False]) + self.assertIn(str(self.question.pk), row['editUrl']) + self.assertIn(str(self.lesson.pk), data['addUrl']) + + def test_an_open_report_is_surfaced_on_the_question(self): + QuestionReport.objects.create(question=self.question, user_id=1, reason='wrong_answer') + row = self.client.get( + reverse('admin:content_browse_questions', args=[self.lesson.pk]) + ).json()['results'][0] + self.assertEqual(row['openReports'], 1) + + def test_wrong_rate_is_hidden_until_there_are_enough_answers(self): + for i in range(4): + UserAnswer.objects.create(user_id=i, question=self.question, choice=self.wrong, + correct=False) + row = self.client.get( + reverse('admin:content_browse_questions', args=[self.lesson.pk]) + ).json()['results'][0] + self.assertIsNone(row['wrongPercent'], 'نمونه‌ی کوچک نباید درصد نشان دهد') + + UserAnswer.objects.create(user_id=99, question=self.question, choice=self.wrong, + correct=False) + row = self.client.get( + reverse('admin:content_browse_questions', args=[self.lesson.pk]) + ).json()['results'][0] + self.assertEqual(row['wrongPercent'], 100) + + def test_the_browser_is_closed_to_anonymous_visitors(self): + self.client.logout() + response = self.client.get(reverse('admin:content_browse')) + self.assertIn(response.status_code, (302, 403)) + + def test_the_question_list_shows_the_report_and_difficulty_columns(self): + QuestionReport.objects.create(question=self.question, user_id=1, reason='unclear') + response = self.client.get(reverse('admin:content_question_changelist')) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'گزارشِ باز') + self.assertContains(response, '٪ غلط') + + def test_the_import_pages_open(self): + for name in ('admin:content_question_import', 'admin:content_grade_import_curriculum'): + self.assertEqual(self.client.get(reverse(name)).status_code, 200, name) + + +class QuestionReportEndpointTests(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) + lesson = Lesson.objects.create(node=node, title='نوع ۱') + self.question = Question.objects.create( + lesson=lesson, type='multiple_choice', prompt='؟', + ) + + def test_reporting_twice_does_not_create_two_open_rows(self): + QuestionReport.objects.create(question=self.question, user_id=7, reason='typo') + report, created = QuestionReport.objects.get_or_create( + question=self.question, user_id=7, status='open', defaults={'reason': 'typo'}, + ) + self.assertFalse(created) + self.assertEqual(QuestionReport.objects.filter(status='open').count(), 1) diff --git a/content/urls.py b/content/urls.py index 84086df..9ac21a9 100644 --- a/content/urls.py +++ b/content/urls.py @@ -8,6 +8,7 @@ from .views import ( LessonQuestionsView, NodeProgressListView, QuestionAnswerView, + QuestionReportView, ) urlpatterns = [ @@ -17,5 +18,6 @@ urlpatterns = [ path('lessons//questions', LessonQuestionsView.as_view(), name='lesson-questions'), path('lessons//complete', LessonCompleteView.as_view(), name='lesson-complete'), path('questions//answer', QuestionAnswerView.as_view(), name='question-answer'), + path('questions//report', QuestionReportView.as_view(), name='question-report'), path('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'), ] diff --git a/content/views.py b/content/views.py index 5ee7493..117b6a4 100644 --- a/content/views.py +++ b/content/views.py @@ -4,7 +4,8 @@ from django.http import Http404 from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema -from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework import status +from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle @@ -13,7 +14,10 @@ from rest_framework.views import APIView from progress.models import UserHearts, UserStats from subscription.services import has_unlimited_hearts -from .models import Choice, Grade, Lesson, NodeProgress, Question, UserAnswer, UserLessonCompletion +from .models import ( + Choice, Grade, Lesson, NodeProgress, Question, QuestionReport, UserAnswer, + UserLessonCompletion, +) from .serializers import ( AnswerRequestSerializer, AnswerResponseSerializer, @@ -24,6 +28,8 @@ from .serializers import ( LessonIntroResponseSerializer, LessonQuestionsResponseSerializer, NodeProgressListResponseSerializer, + QuestionReportRequestSerializer, + QuestionReportResponseSerializer, ) @@ -535,3 +541,51 @@ class NodeProgressListView(APIView): data = {'progress': list(qs.order_by('node__unit_id', 'node__order'))} return Response(NodeProgressListResponseSerializer(data).data) + + +class QuestionReportView(APIView): + """ + POST /api/questions/{id}/report — «این سوال ایراد دارد» + + چرا لازم بود: تنها بازخوردی که تا امروز جمع می‌شد مالِ چتِ دستیار بود. + روی خودِ سوال‌های مسیرِ آموزشی هیچ راهی نبود که کاربر بگوید گزینه‌ی درست + غلط است یا صورتِ سوال گنگ — و کسی که محتوا را نگه می‌دارد هیچ سیگنالی + نداشت جز حدس. + """ + + permission_classes = [IsAuthenticated] + throttle_scope = 'question_report' + throttle_classes = [ScopedRateThrottle] + + @extend_schema( + request=QuestionReportRequestSerializer, + responses=QuestionReportResponseSerializer, + tags=['محتوا (content)'], + ) + def post(self, request, question_id: int): + question = Question.objects.filter(pk=question_id).first() + if question is None: + raise NotFound('سوال یافت نشد.') + + serializer = QuestionReportRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # گزارشِ بازِ تکراریِ همان کاربر روی همان سوال، ردیفِ تازه نمی‌سازد: + # دابل‌کلیک نباید صف را شلوغ کند و آمار را به‌هم بزند. + report, created = QuestionReport.objects.get_or_create( + question=question, + user_id=request.user.id, + status='open', + defaults={ + 'reason': serializer.validated_data['reason'], + 'note': serializer.validated_data.get('note', ''), + }, + ) + return Response( + QuestionReportResponseSerializer({ + 'success': True, + 'reportId': report.pk, + 'alreadyReported': not created, + }).data, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + )