Compare commits

...

2 Commits

Author SHA1 Message Date
37d66a4a4c 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>
2026-09-13 21:08:00 +03:30
67361f6711 boot with sane defaults instead of refusing to start
سه بار پشتِ سر هم همین الگو تکرار شد: یک متغیرِ محیطی ست نبود، جنگو موقعِ
بالا آمدن مرد، و از بیرون فقط «502 Bad Gateway» دیده شد. کسی که سرور را بالا
می‌آورد هیچ سرنخی نداشت، و تشخیص هر بار یک رفت‌وبرگشتِ یک‌روزه بود.

ALLOWED_HOSTS و CSRF_TRUSTED_ORIGINS حالا دامنه‌های شناخته‌شده‌ی خودمان را
پیش‌فرض دارند. CSRF مخصوصاً مهم است: بدونش ورود به پنلِ ادمین از پشتِ پراکسیِ
HTTPS «CSRF verification failed» می‌دهد و علتش از پیام معلوم نیست.

SECRET_KEY اگر ست نباشد، در پروداکشن یک کلیدِ تصادفیِ واقعی ساخته و در
.django_secret_key نگه داشته می‌شود (۰۶۰۰، خارج از گیت). همان‌قدر
غیرقابلِ حدس، بدونِ یک قدمِ دستیِ دیگر؛ و چون روی دیسک می‌ماند، ری‌استارت
نشست‌های کاربران را باطل نمی‌کند.

CACHE_URL نبودن دیگر مانعِ بالا آمدن نیست. مشترک نبودنِ کش بینِ workerها یک
ضعفِ واقعی‌ست ولی دلیلِ خوبی برای خوابیدنِ کلِ سرویس نیست؛ حالا بالا می‌آید و
/api/health صریحاً هشدار می‌دهد. تستِ مربوطه هم از «باید بالا نیاید» به
«باید بالا بیاید ولی بی‌صدا نماند» تغییر کرد.

متغیرِ محیطی همچنان بر همه‌ی این پیش‌فرض‌ها اولویت دارد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:08:00 +03:30
13 changed files with 687 additions and 42 deletions

3
.gitignore vendored
View File

@@ -8,3 +8,6 @@ db.sqlite3
# کتاب‌های درسی: خودِ PDFها در گیت نمی‌آیند (حجیم‌اند)
data/textbooks/*
!data/textbooks/README.md
# کلیدِ امضای تولیدشده‌ی خودکار (هرگز کامیت نشود)
.django_secret_key

View File

@@ -37,21 +37,52 @@ except ModuleNotFoundError:
# Defaults to False; must be explicitly enabled via env for local dev.
DEBUG = os.environ.get('DJANGO_DEBUG', '') == '1'
# SECURITY WARNING: keep the secret key used in production secret!
# No hardcoded fallback when DEBUG=False — fails closed at startup instead.
# کلیدِ امضا. ترتیبِ اولویت: متغیرِ محیطی، بعد فایلِ تولیدشده‌ی کنارِ پروژه.
#
# چرا فایل و نه خطای راه‌اندازی: قبلاً با DEBUG خاموش و بدونِ این متغیر، اپ
# اصلاً بالا نمی‌آمد و از بیرون فقط 502 دیده می‌شد. کسی که سرور را بالا می‌آورد
# باید حدس می‌زد چه چیزی کم است. حالا اولین اجرا خودش یک کلیدِ تصادفیِ واقعی
# می‌سازد و در .django_secret_key (خارج از گیت) نگه می‌دارد — همان‌قدر امن،
# بدونِ یک قدمِ دستیِ دیگر. متغیرِ محیطی همچنان بر آن اولویت دارد.
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
if not SECRET_KEY:
if DEBUG:
SECRET_KEY = 'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss'
else:
import secrets
_key_file = BASE_DIR / '.django_secret_key'
try:
SECRET_KEY = _key_file.read_text().strip()
except OSError:
SECRET_KEY = ''
if not SECRET_KEY:
SECRET_KEY = secrets.token_urlsafe(64)
try:
_key_file.write_text(SECRET_KEY)
_key_file.chmod(0o600)
except OSError as exc:
raise ImproperlyConfigured(
'DJANGO_SECRET_KEY environment variable is not set. '
'A predictable fallback secret key cannot be used when DEBUG=False.'
)
f'DJANGO_SECRET_KEY ست نیست و نوشتنِ {_key_file} هم ممکن نشد ({exc}). '
'یا متغیر را ست کنید یا اجازه‌ی نوشتن در پوشه‌ی پروژه بدهید.'
) from exc
ALLOWED_HOSTS = [h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',') if h]
# دامنه‌های شناخته‌شده‌ی خودمان پیش‌فرض‌اند تا یک متغیرِ فراموش‌شده باعثِ
# DisallowedHost نشود. متغیرِ محیطی، اگر ست باشد، کاملاً جایگزینشان می‌کند.
DEFAULT_ALLOWED_HOSTS = [
'api.math.tadrisino2.ir',
'math.tadrisino2.ir',
'localhost', '127.0.0.1',
]
ALLOWED_HOSTS = [
h for h in os.environ.get('DJANGO_ALLOWED_HOSTS', '').split(',') if h
] or DEFAULT_ALLOWED_HOSTS
CSRF_TRUSTED_ORIGINS = [o for o in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if o]
# لازم برای ورود به پنلِ ادمین از پشتِ پراکسیِ HTTPS؛ بدونش فرمِ ورود
# «CSRF verification failed» می‌دهد و کسی نمی‌فهمد چرا.
CSRF_TRUSTED_ORIGINS = [
o for o in os.environ.get('CSRF_TRUSTED_ORIGINS', '').split(',') if o
] or ['https://api.math.tadrisino2.ir', 'https://math.tadrisino2.ir']
# --- CORS -------------------------------------------------------------------
# فرانت‌اند (Vite/React) از یک origin دیگر صدا می‌زند، پس بدونِ این هدرها مرورگر
@@ -212,12 +243,15 @@ elif DEBUG:
}
}
else:
raise ImproperlyConfigured(
'CACHE_URL environment variable is not set. '
'A shared cache is required when DEBUG=False, since rate limits '
'(OTP throttles and the phone-based login limit) rely on a cache '
'shared across all workers/instances; LocMemCache cannot be used in production.'
)
# بدونِ Redis محدودیتِ نرخ بینِ workerها مشترک نیست — ضعف است، ولی دلیلِ
# خوبی برای بالا نیامدنِ کلِ سرویس نیست. قبلاً اینجا استثنا پرتاب می‌شد و
# نتیجه‌اش از بیرون فقط 502 بود، بدونِ هیچ سرنخی. حالا بالا می‌آید و
# /api/health صریحاً هشدار می‌دهد.
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
}
}
# Password validation
@@ -325,6 +359,7 @@ REST_FRAMEWORK = {
'purchase': '10/hour',
'token_refresh': '30/hour',
'question_answer': '60/min',
'question_report': '20/hour',
'lesson_complete': '10/hour',
'support_create': '30/hour',
'offer_redeem': '10/hour',

View File

@@ -15,11 +15,14 @@ from config.middleware import APIRequestLoggingMiddleware
class SecretKeyFailClosedTests(SimpleTestCase):
"""
تست‌های متمرکز روی این فیکس: SECRET_KEY دیگه در پروداکشن (DEBUG=False)
دیفالتِ هاردکد/قابل‌حدس‌زدن نداره. اگه DJANGO_SECRET_KEY ست نشده باشه،
settings.py باید fail-closed بشه (خطای صریح موقعِ import)، نه اینکه با
یک fallback شناخته‌شده بالا بیاد. برای DEBUG=True، fallbackِ محلیِ قبلی
باید دست‌نخورده بمونه.
SECRET_KEY در پروداکشن هرگز دیفالتِ قابل‌حدس‌زدن ندارد — این هنوز برقرار است.
آنچه عوض شد: قبلاً نبودنِ DJANGO_SECRET_KEY یعنی اپ اصلاً بالا نمی‌آمد، و
نتیجه‌اش از بیرون فقط 502 بود بدونِ هیچ سرنخی؛ کسی که سرور را بالا می‌آورد
باید حدس می‌زد چه چیزی کم است. حالا اولین اجرا یک کلیدِ تصادفیِ واقعی
می‌سازد و در فایلی کنارِ پروژه نگه می‌دارد — همان‌قدر غیرقابلِ حدس، بدونِ
یک قدمِ دستیِ دیگر. چیزی که باید همچنان تضمین شود این است که آن کلید
تصادفی باشد، نه رشته‌ی هاردکدِ حالتِ توسعه.
مثل SecretDefaultsFailClosedTests در payment/tests.py، چون SECRET_KEY
فقط یک‌بار موقعِ import محاسبه می‌شه (نه چیزی که override_settings
@@ -44,12 +47,35 @@ class SecretKeyFailClosedTests(SimpleTestCase):
from config import settings as settings_module
importlib.reload(settings_module)
def test_production_missing_secret_key_fails_closed(self):
with self.assertRaises(ImproperlyConfigured):
self._reload_settings_with_env(
def test_production_without_the_env_var_generates_a_real_random_key(self):
settings_module = self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': ''},
env_removed={'DJANGO_SECRET_KEY'},
)
key = settings_module.SECRET_KEY
self.addCleanup(
lambda: (settings_module.BASE_DIR / '.django_secret_key').unlink(missing_ok=True)
)
self.assertFalse(settings_module.DEBUG)
self.assertNotEqual(
key,
'django-insecure-jn!s*25lz^+bjm101tzmlz8jlj70i0qka++*h-(!0s^^vk-0ss',
'کلیدِ حالتِ توسعه هرگز نباید در پروداکشن استفاده شود',
)
self.assertGreaterEqual(len(key), 50)
def test_the_generated_key_survives_a_restart(self):
"""اگر هر بار کلیدِ تازه ساخته شود، همه‌ی نشست‌ها با هر ری‌استارت باطل می‌شوند."""
first = self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': ''}, env_removed={'DJANGO_SECRET_KEY'},
).SECRET_KEY
settings_module = self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': ''}, env_removed={'DJANGO_SECRET_KEY'},
)
self.addCleanup(
lambda: (settings_module.BASE_DIR / '.django_secret_key').unlink(missing_ok=True)
)
self.assertEqual(first, settings_module.SECRET_KEY)
def test_production_configured_secret_key_used(self):
# CACHE_URL هم باید ست بشه، وگرنه با DEBUG=False همون
@@ -79,10 +105,13 @@ class SecretKeyFailClosedTests(SimpleTestCase):
class CacheUrlFailClosedTests(SimpleTestCase):
"""
🔒 تستِ متمرکز روی فیکسِ CACHE_URL: در پروداکشن (DEBUG=False)، اگه
CACHE_URL ست نشده باشه، settings.py باید fail-closed بشه (خطای صریح
موقعِ import)، نه fallback خاموش به LocMemCache (که بینِ worker/instance
ها مشترک نیست و throttle/rate-limit ها رو بی‌اثر می‌کنه).
نبودنِ CACHE_URL در پروداکشن یک ضعفِ واقعی‌ست: LocMemCache بینِ workerها
مشترک نیست و throttleها را کم‌اثر می‌کند.
ولی دلیلِ خوبی برای بالا نیامدنِ کلِ سرویس نیست — قبلاً همین‌جا استثنا
پرتاب می‌شد و نتیجه‌اش از بیرون فقط 502 بود. حالا سرویس بالا می‌آید و
/api/health صریحاً هشدار می‌دهد. چیزی که باید تضمین شود این است که این
ضعف **بی‌صدا** نماند.
"""
allow_database_queries = True
@@ -100,12 +129,21 @@ class CacheUrlFailClosedTests(SimpleTestCase):
from config import settings as settings_module
importlib.reload(settings_module)
def test_production_missing_cache_url_fails_closed(self):
with self.assertRaises(ImproperlyConfigured):
self._reload_settings_with_env(
def test_production_without_cache_url_boots_but_is_not_silent(self):
settings_module = self._reload_settings_with_env(
env_overrides={'DJANGO_DEBUG': '', 'DJANGO_SECRET_KEY': 'prod-secret-xyz'},
env_removed={'CACHE_URL'},
)
self.assertIn('LocMem', settings_module.CACHES['default']['BACKEND'])
# همان ضعف باید در /api/health دیده شود، وگرنه بی‌صدا می‌ماند.
from config import health
with self.settings(DEBUG=False,
CACHES=settings_module.CACHES,
DEV_MASTER_OTP=None):
warnings = ' '.join(health._warnings())
self.assertIn('کش', warnings)
def test_debug_mode_missing_cache_url_uses_locmem_fallback(self):
settings_module = self._reload_settings_with_env(

View File

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

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>

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

View File

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