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

@@ -1,17 +1,21 @@
import json import json
from django.contrib import admin, messages from django.contrib import admin, messages
from django.db.models import Count, Q
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import path, reverse from django.urls import path, reverse
from django.utils import timezone
from django.utils.html import format_html from django.utils.html import format_html
from . import admin_browser
from .curriculum_io import CurriculumError, export_curriculum, import_curriculum from .curriculum_io import CurriculumError, export_curriculum, import_curriculum
from .curriculum_io import parse as parse_curriculum from .curriculum_io import parse as parse_curriculum
from .question_io import ImportError_, export_questions, import_questions, parse from .question_io import ImportError_, export_questions, import_questions, parse
from .models import ( 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_display = ('id', 'prompt', 'lesson', 'type', 'open_reports', 'wrong_percent')
list_filter = ('type', 'lesson') list_filter = ('type', 'lesson__node__unit__grade', 'lesson__node__unit')
inlines = [ChoiceInline] inlines = [ChoiceInline]
actions = ['export_selected'] actions = ['export_selected']
change_list_template = 'admin/content/question/change_list.html' change_list_template = 'admin/content/question/change_list.html'
@@ -117,6 +121,33 @@ class QuestionAdmin(admin.ModelAdmin):
autocomplete_fields = ['lesson'] autocomplete_fields = ['lesson']
search_fields = ('prompt',) 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('<a href="{}" style="color:#b91c1c;font-weight:600">{}</a>',
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('<span style="color:{}">{}٪ از {}</span>',
color, percent, obj._total_answers)
def get_urls(self): def get_urls(self):
return [ return [
path( path(
@@ -124,6 +155,16 @@ class QuestionAdmin(admin.ModelAdmin):
self.admin_site.admin_view(self.import_view), self.admin_site.admin_view(self.import_view),
name='content_question_import', name='content_question_import',
), ),
# مرورگرِ آبشاریِ محتوا؛ زیرِ همین مدل ثبت می‌شود تا در پنل پیدا باشد.
path('browse/', admin_browser.browser_view, name='content_browse'),
path('browse/units/<int:grade_id>/', admin_browser.units_json,
name='content_browse_units'),
path('browse/nodes/<int:unit_id>/', admin_browser.nodes_json,
name='content_browse_nodes'),
path('browse/lessons/<int:node_id>/', admin_browser.lessons_json,
name='content_browse_lessons'),
path('browse/questions/<int:lesson_id>/', admin_browser.questions_json,
name='content_browse_questions'),
] + super().get_urls() ] + super().get_urls()
@admin.action(description='دریافتِ فایلِ سوال‌های انتخاب‌شده') @admin.action(description='دریافتِ فایلِ سوال‌های انتخاب‌شده')
@@ -182,3 +223,19 @@ class NodeProgressAdmin(admin.ModelAdmin):
) )
list_filter = ('status', 'node__unit') list_filter = ('status', 'node__unit')
search_fields = ('user_id',) 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)

111
content/admin_browser.py Normal file
View File

@@ -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}',
})

View File

@@ -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')],
},
),
]

View File

@@ -177,3 +177,41 @@ class UserAnswer(models.Model):
def __str__(self): def __str__(self):
return f"answer-user{self.user_id}-q{self.question_id}" 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()})'

View File

@@ -1,7 +1,7 @@
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from .models import Grade from .models import Grade, QuestionReport
# ---- Grades ---- # ---- Grades ----
@@ -133,3 +133,16 @@ class NodeProgressSerializer(serializers.Serializer):
class NodeProgressListResponseSerializer(serializers.Serializer): class NodeProgressListResponseSerializer(serializers.Serializer):
progress = NodeProgressSerializer(many=True) 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()

View File

@@ -0,0 +1,144 @@
{% extends "admin/base_site.html" %}
{% block content %}
<style>
.cb { direction: rtl; }
.cb-cols { display: flex; gap: 12px; align-items: stretch; flex-wrap: wrap; }
.cb-col { flex: 1 1 190px; min-width: 170px; border: 1px solid #ddd; border-radius: 8px;
background: #fff; display: flex; flex-direction: column; max-height: 420px; }
.cb-col h3 { margin: 0; padding: 8px 10px; font-size: 13px; background: #f6f6f6;
border-bottom: 1px solid #ddd; border-radius: 8px 8px 0 0; }
.cb-list { overflow-y: auto; flex: 1; }
.cb-item { padding: 7px 10px; cursor: pointer; border-bottom: 1px solid #f0f0f0;
font-size: 13px; display: flex; justify-content: space-between; gap: 8px; }
.cb-item:hover { background: #eef5ff; }
.cb-item.sel { background: #dbeafe; font-weight: 600; }
.cb-count { color: #888; font-size: 11px; }
.cb-empty { padding: 10px; color: #999; font-size: 12px; }
.cb-q { border: 1px solid #ddd; border-radius: 8px; padding: 12px; margin-top: 10px; background: #fff; }
.cb-q.flag { border-color: #f0a; border-right: 4px solid #e11d48; }
.cb-q-top { display: flex; justify-content: space-between; gap: 10px; align-items: flex-start; }
.cb-badges { display: flex; gap: 6px; flex-wrap: wrap; }
.cb-badge { font-size: 11px; padding: 2px 7px; border-radius: 10px; white-space: nowrap; }
.cb-badge.warn { background: #fee2e2; color: #b91c1c; }
.cb-badge.info { background: #f1f5f9; color: #475569; }
.cb-choices { margin: 8px 0 0; padding: 0; list-style: none; display: flex;
flex-wrap: wrap; gap: 6px; }
.cb-choices li { font-size: 12px; padding: 3px 9px; border-radius: 6px; background: #f1f5f9; }
.cb-choices li.ok { background: #dcfce7; color: #15803d; font-weight: 600; }
</style>
<div class="cb">
<h1>مرورِ محتوا</h1>
<p style="color:#666">از راست به چپ انتخاب کنید تا به سوال‌ها برسید.</p>
<div class="cb-cols">
<div class="cb-col">
<h3>پایه</h3>
<div class="cb-list" id="col-grade">
{% for grade in grades %}
<div class="cb-item" data-id="{{ grade.id }}">{{ grade.title }}</div>
{% empty %}
<div class="cb-empty">هیچ پایه‌ای ثبت نشده.</div>
{% endfor %}
</div>
</div>
<div class="cb-col"><h3>فصل</h3><div class="cb-list" id="col-unit"><div class="cb-empty">اول پایه را انتخاب کنید.</div></div></div>
<div class="cb-col"><h3>درس</h3><div class="cb-list" id="col-node"><div class="cb-empty">—</div></div></div>
<div class="cb-col"><h3>مبحث</h3><div class="cb-list" id="col-lesson"><div class="cb-empty">—</div></div></div>
</div>
<div id="questions" style="margin-top:18px"></div>
</div>
<script>
(function () {
const el = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
function setEmpty(col, text) { col.innerHTML = '<div class="cb-empty">' + esc(text) + '</div>'; }
async function load(url) {
const response = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
}
function fill(col, rows, countKey, onPick) {
if (!rows.length) { setEmpty(col, 'چیزی اینجا نیست.'); return; }
col.innerHTML = '';
rows.forEach((row) => {
const item = document.createElement('div');
item.className = 'cb-item';
item.innerHTML = '<span>' + esc(row.title) + '</span>'
+ '<span class="cb-count">' + (row[countKey] ?? 0) + '</span>';
item.onclick = () => {
col.querySelectorAll('.cb-item').forEach((n) => n.classList.remove('sel'));
item.classList.add('sel');
onPick(row.id);
};
col.appendChild(item);
});
}
function renderQuestions(data) {
const box = el('questions');
let html = '<a class="addlink" href="' + data.addUrl + '">افزودنِ سوالِ تازه به این مبحث</a>';
if (!data.results.length) {
box.innerHTML = html + '<p style="color:#999;margin-top:10px">هنوز سوالی برای این مبحث ساخته نشده.</p>';
return;
}
data.results.forEach((q) => {
const flagged = q.openReports > 0 || (q.wrongPercent !== null && q.wrongPercent >= 70);
let badges = '';
if (q.openReports > 0) {
badges += '<a class="cb-badge warn" href="' + q.reportsUrl + '">'
+ q.openReports + ' گزارشِ باز</a>';
}
if (q.wrongPercent !== null) {
badges += '<span class="cb-badge ' + (q.wrongPercent >= 70 ? 'warn' : 'info') + '">'
+ q.wrongPercent + '٪ غلط از ' + q.totalAnswers + ' پاسخ</span>';
} else if (q.totalAnswers) {
badges += '<span class="cb-badge info">' + q.totalAnswers + ' پاسخ</span>';
}
html += '<div class="cb-q' + (flagged ? ' flag' : '') + '">'
+ '<div class="cb-q-top"><div><b>' + esc(q.prompt) + '</b>'
+ '<div style="color:#888;font-size:11px;margin-top:2px">' + esc(q.type) + ' · #' + q.id + '</div></div>'
+ '<div class="cb-badges">' + badges
+ '<a class="cb-badge info" href="' + q.editUrl + '">ویرایش</a></div></div>'
+ '<ul class="cb-choices">'
+ q.choices.map((c) => '<li class="' + (c.isCorrect ? 'ok' : '') + '">' + esc(c.value) + '</li>').join('')
+ '</ul></div>';
});
box.innerHTML = html;
}
function reset(...cols) { cols.forEach((c) => setEmpty(el(c), '—')); el('questions').innerHTML = ''; }
el('col-grade').onclick = null;
el('col-grade').querySelectorAll('.cb-item').forEach((item) => {
item.onclick = async () => {
el('col-grade').querySelectorAll('.cb-item').forEach((n) => n.classList.remove('sel'));
item.classList.add('sel');
reset('col-node', 'col-lesson');
setEmpty(el('col-unit'), 'در حالِ بارگذاری…');
const data = await load('units/' + item.dataset.id + '/');
fill(el('col-unit'), data.results, 'node_count', async (unitId) => {
reset('col-lesson');
setEmpty(el('col-node'), 'در حالِ بارگذاری…');
const nodes = await load('nodes/' + unitId + '/');
fill(el('col-node'), nodes.results, 'lesson_count', async (nodeId) => {
setEmpty(el('col-lesson'), 'در حالِ بارگذاری…');
el('questions').innerHTML = '';
const lessons = await load('lessons/' + nodeId + '/');
fill(el('col-lesson'), lessons.results, 'question_count', async (lessonId) => {
el('questions').innerHTML = '<p style="color:#999">در حالِ بارگذاری…</p>';
renderQuestions(await load('questions/' + lessonId + '/'));
});
});
});
};
});
})();
</script>
{% endblock %}

View File

@@ -1,6 +1,9 @@
{% extends "admin/change_list.html" %} {% extends "admin/change_list.html" %}
{# دکمه‌ی «وارد کردنِ سوال‌ها» کنارِ دکمه‌ی «افزودن» #} {# ابزارهای محتوا کنارِ دکمه‌ی «افزودن» #}
{% block object-tools-items %} {% block object-tools-items %}
<li>
<a href="{% url 'admin:content_browse' %}" class="addlink">مرورِ محتوا (پایه → فصل → درس → سوال)</a>
</li>
<li> <li>
<a href="{% url 'admin:content_question_import' %}" class="addlink">وارد کردنِ سوال‌ها از فایل</a> <a href="{% url 'admin:content_question_import' %}" class="addlink">وارد کردنِ سوال‌ها از فایل</a>
</li> </li>

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)

View File

@@ -8,6 +8,7 @@ from .views import (
LessonQuestionsView, LessonQuestionsView,
NodeProgressListView, NodeProgressListView,
QuestionAnswerView, QuestionAnswerView,
QuestionReportView,
) )
urlpatterns = [ urlpatterns = [
@@ -17,5 +18,6 @@ urlpatterns = [
path('lessons/<int:lesson_id>/questions', LessonQuestionsView.as_view(), name='lesson-questions'), path('lessons/<int:lesson_id>/questions', LessonQuestionsView.as_view(), name='lesson-questions'),
path('lessons/<int:lesson_id>/complete', LessonCompleteView.as_view(), name='lesson-complete'), path('lessons/<int:lesson_id>/complete', LessonCompleteView.as_view(), name='lesson-complete'),
path('questions/<int:question_id>/answer', QuestionAnswerView.as_view(), name='question-answer'), path('questions/<int:question_id>/answer', QuestionAnswerView.as_view(), name='question-answer'),
path('questions/<int:question_id>/report', QuestionReportView.as_view(), name='question-report'),
path('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'), path('progress/nodes', NodeProgressListView.as_view(), name='node-progress-list'),
] ]

View File

@@ -4,7 +4,8 @@ from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema 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.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle from rest_framework.throttling import ScopedRateThrottle
@@ -13,7 +14,10 @@ from rest_framework.views import APIView
from progress.models import UserHearts, UserStats from progress.models import UserHearts, UserStats
from subscription.services import has_unlimited_hearts 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 ( from .serializers import (
AnswerRequestSerializer, AnswerRequestSerializer,
AnswerResponseSerializer, AnswerResponseSerializer,
@@ -24,6 +28,8 @@ from .serializers import (
LessonIntroResponseSerializer, LessonIntroResponseSerializer,
LessonQuestionsResponseSerializer, LessonQuestionsResponseSerializer,
NodeProgressListResponseSerializer, NodeProgressListResponseSerializer,
QuestionReportRequestSerializer,
QuestionReportResponseSerializer,
) )
@@ -535,3 +541,51 @@ class NodeProgressListView(APIView):
data = {'progress': list(qs.order_by('node__unit_id', 'node__order'))} data = {'progress': list(qs.order_by('node__unit_id', 'node__order'))}
return Response(NodeProgressListResponseSerializer(data).data) 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,
)