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:
@@ -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('<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):
|
||||
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/<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()
|
||||
|
||||
@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)
|
||||
|
||||
111
content/admin_browser.py
Normal file
111
content/admin_browser.py
Normal 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}',
|
||||
})
|
||||
31
content/migrations/0005_questionreport.py
Normal file
31
content/migrations/0005_questionreport.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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()})'
|
||||
|
||||
@@ -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()
|
||||
|
||||
144
content/templates/admin/content/browser.html
Normal file
144
content/templates/admin/content/browser.html
Normal 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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 %}
|
||||
@@ -1,6 +1,9 @@
|
||||
{% extends "admin/change_list.html" %}
|
||||
{# دکمهی «وارد کردنِ سوالها» کنارِ دکمهی «افزودن» #}
|
||||
{# ابزارهای محتوا کنارِ دکمهی «افزودن» #}
|
||||
{% block object-tools-items %}
|
||||
<li>
|
||||
<a href="{% url 'admin:content_browse' %}" class="addlink">مرورِ محتوا (پایه → فصل → درس → سوال)</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'admin:content_question_import' %}" class="addlink">وارد کردنِ سوالها از فایل</a>
|
||||
</li>
|
||||
|
||||
116
content/tests_admin_browser.py
Normal file
116
content/tests_admin_browser.py
Normal 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)
|
||||
@@ -8,6 +8,7 @@ from .views import (
|
||||
LessonQuestionsView,
|
||||
NodeProgressListView,
|
||||
QuestionAnswerView,
|
||||
QuestionReportView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -17,5 +18,6 @@ urlpatterns = [
|
||||
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('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'),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user