Files
MathApp/content/admin_browser.py
Zareian a973c41db2 Security review fixes: admin permissions, guest sessions, payment edge cases, assistant leaks
- 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>
2026-10-05 04:52:25 +03:30

120 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
مرورگرِ محتوا در پنلِ ادمین: پایه → فصل → درس → نوعِ مسئله → سوال‌ها.
چرا: فهرستِ پیش‌فرضِ جنگو برای پیدا کردنِ «سوال‌های فلان مبحث» مناسب نیست —
باید بینِ چند صفحه‌ی جدا با فیلترهای دراپ‌داونی بالا و پایین رفت، و وقتی
۱۳۰۷ درس و هزاران سوال داری عملاً کار نمی‌کند.
اینجا هر ستون فقط وقتی پر می‌شود که ستونِ قبلی انتخاب شده باشد، پس هیچ‌وقت
فهرستِ هزارتایی جلوی چشم نیست. کنارِ هر سوال دو عددِ تصمیم‌ساز می‌آید:
گزارش‌های بازِ کاربران، و درصدِ پاسخِ غلط — یعنی همان سیگنالی که می‌گوید کدام
سوال ایراد دارد یا بیش از حد سخت است.
فقط برای کارمندی که اجازه‌ی دیدنِ سوال دارد، چون کلیدِ جواب‌ها را نشان می‌دهد.
وابستگیِ تازه‌ای اضافه نمی‌کند: چند اندپوینتِ کوچکِ 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}',
})