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,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" %}
{# دکمه‌ی «وارد کردنِ سوال‌ها» کنارِ دکمه‌ی «افزودن» #}
{# ابزارهای محتوا کنارِ دکمه‌ی «افزودن» #}
{% 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>