- Admin: refund/cancel/mark-resolved/Bazaar-link actions need change permission; question and curriculum import need add/delete; content browser needs view_question; guest device ids are shown to superusers only. - Accounts: registering a guest's phone revokes the guest's earlier sessions; self-deletion keeps Redemption, UserOffer and the phone's OTP rows; the public demo guest cannot be deleted (DEMO_GUEST_DEVICE_IDS); display-name contact filter covers any separator and social handles. - Payment: callback without Authority is 400; a second Bazaar purchase on a paid order is 409; orders snapshot credits/hearts/days at creation (payment.0011). - Assistant: a broken diagram no longer makes a streamed answer free; a late off-topic marker refuses the answer (not stored, not cached); a disconnect saves the partial answer; only KNOWN_COLLECTIONS are searched. - Infra: secret-key file is created atomically (workers no longer split keys); no default INTERNAL_SERVICE_KEY; Kavenegar key and Bazaar token kept out of error logs; Swagger/ReDoc pinned; schema throttled; docker compose bound to 127.0.0.1; non-ASCII digit ids are 400; ticket text capped at 5000. - docs/security-before-production.md: remaining open items 24-31 and the guard tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
4.9 KiB
Python
120 lines
4.9 KiB
Python
"""
|
||
مرورگرِ محتوا در پنلِ ادمین: پایه → فصل → درس → نوعِ مسئله → سوالها.
|
||
|
||
چرا: فهرستِ پیشفرضِ جنگو برای پیدا کردنِ «سوالهای فلان مبحث» مناسب نیست —
|
||
باید بینِ چند صفحهی جدا با فیلترهای دراپداونی بالا و پایین رفت، و وقتی
|
||
۱۳۰۷ درس و هزاران سوال داری عملاً کار نمیکند.
|
||
|
||
اینجا هر ستون فقط وقتی پر میشود که ستونِ قبلی انتخاب شده باشد، پس هیچوقت
|
||
فهرستِ هزارتایی جلوی چشم نیست. کنارِ هر سوال دو عددِ تصمیمساز میآید:
|
||
گزارشهای بازِ کاربران، و درصدِ پاسخِ غلط — یعنی همان سیگنالی که میگوید کدام
|
||
سوال ایراد دارد یا بیش از حد سخت است.
|
||
|
||
فقط برای کارمندی که اجازهی دیدنِ سوال دارد، چون کلیدِ جوابها را نشان میدهد.
|
||
|
||
وابستگیِ تازهای اضافه نمیکند: چند اندپوینتِ کوچکِ JSON و JavaScript ساده.
|
||
"""
|
||
from django.contrib.admin.views.decorators import staff_member_required
|
||
from django.contrib.auth.decorators import permission_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
|
||
@permission_required('content.view_question', raise_exception=True)
|
||
def browser_view(request):
|
||
return render(request, 'admin/content/browser.html', {
|
||
'title': 'مرورِ محتوا',
|
||
'grades': Grade.objects.order_by('title').values('id', 'title'),
|
||
})
|
||
|
||
|
||
@staff_member_required
|
||
@permission_required('content.view_question', raise_exception=True)
|
||
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
|
||
@permission_required('content.view_question', raise_exception=True)
|
||
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
|
||
@permission_required('content.view_question', raise_exception=True)
|
||
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
|
||
@permission_required('content.view_question', raise_exception=True)
|
||
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}',
|
||
})
|