Compare commits
2 Commits
9cee574fa9
...
37d66a4a4c
| Author | SHA1 | Date | |
|---|---|---|---|
| 37d66a4a4c | |||
| 67361f6711 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,3 +8,6 @@ db.sqlite3
|
||||
# کتابهای درسی: خودِ PDFها در گیت نمیآیند (حجیماند)
|
||||
data/textbooks/*
|
||||
!data/textbooks/README.md
|
||||
|
||||
# کلیدِ امضای تولیدشدهی خودکار (هرگز کامیت نشود)
|
||||
.django_secret_key
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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