give content editors a drill-down browser and per-question signals

پیدا کردنِ «سوال‌های فلان مبحث» در فهرستِ پیش‌فرضِ جنگو عملاً ممکن نبود: باید
بینِ چند صفحه‌ی جدا با دراپ‌داون‌های هزارتایی بالا و پایین می‌رفتی.

/admin/content/question/browse/ چهار ستونِ آبشاری دارد — پایه → فصل → درس →
مبحث — و هر ستون فقط وقتی پر می‌شود که قبلی انتخاب شده باشد، پس هیچ‌وقت
فهرستِ هزارتایی جلوی چشم نیست. ته‌اش سوال‌های همان مبحث با گزینه‌هایشان
می‌آیند و از همان‌جا ویرایش یا اضافه می‌شوند.

کنارِ هر سوال دو عددِ تصمیم‌ساز: گزارش‌های بازِ کاربران، و درصدِ پاسخِ غلط.
درصد زیرِ ۵ پاسخ نشان داده نمی‌شود — یک نمونه‌ی کوچک سوالِ سالم را
«مشکل‌دار» جلوه می‌دهد. همین دو ستون به فهرستِ سوال‌ها هم اضافه شدند.

content.QuestionReport و POST /api/questions/{id}/report تازه‌اند: تنها
بازخوردی که تا امروز جمع می‌شد مالِ چتِ دستیار بود، و روی سوال‌های مسیرِ
آموزشی هیچ راهی نبود که کاربر بگوید گزینه‌ی درست غلط است. گزارشِ بازِ تکراریِ
همان کاربر ردیفِ تازه نمی‌سازد تا دابل‌کلیک آمار را به‌هم نزند.

وابستگیِ تازه‌ای اضافه نشد: چند اندپوینتِ کوچکِ JSON و JavaScript ساده.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-13 21:08:00 +03:30
parent 67361f6711
commit 37d66a4a4c
10 changed files with 576 additions and 7 deletions

View File

@@ -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)