Files
MathApp/content/admin.py

311 lines
14 KiB
Python
Raw Permalink 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.
import json
from django.contrib import admin, messages
from django.core.exceptions import PermissionDenied
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, ContentVote, Grade, Lesson, LessonReport, Node, NodeProgress, Question, QuestionReport, Subject, Unit,
UserAnswer, UserLessonCompletion,
)
class ChoiceInline(admin.TabularInline):
model = Choice
extra = 1
@admin.register(Grade)
class GradeAdmin(admin.ModelAdmin):
"""
برنامه‌ی درسی (پایه → فصل → درس → نوعِ مسئله) از همین‌جا فله‌ای وارد و خارج
می‌شود. تا پیش از این تنها راهِ رساندنش به سرور، اجرای چند دستور روی همان
سرور بود که به Qdrant و به دسترسیِ SSH وابسته بودند.
"""
list_display = ('number', 'title', 'id', 'icon', 'color')
search_fields = ('title',)
actions = ['export_curriculum_action']
change_list_template = 'admin/content/grade/change_list.html'
def get_urls(self):
return [
path(
'import-curriculum/',
self.admin_site.admin_view(self.import_curriculum_view),
name='content_grade_import_curriculum',
),
] + super().get_urls()
@admin.action(description='دریافتِ فایلِ برنامه‌ی درسیِ پایه‌های انتخاب‌شده')
def export_curriculum_action(self, request, queryset):
payload = json.dumps(export_curriculum(queryset), ensure_ascii=False, indent=2)
response = HttpResponse(payload, content_type='application/json; charset=utf-8')
response['Content-Disposition'] = 'attachment; filename="curriculum.json"'
return response
def import_curriculum_view(self, request):
# admin_view فقط is_staff را می‌سنجد؛ این صفحه پایه/فصل/درس می‌سازد و متنِ درس را عوض می‌کند.
if not (self.has_add_permission(request) and self.has_change_permission(request)):
raise PermissionDenied
if request.method == 'POST':
uploaded = request.FILES.get('file')
if uploaded is None:
self.message_user(request, 'فایلی انتخاب نشده بود.', messages.ERROR)
return redirect(request.path)
try:
counts = import_curriculum(parse_curriculum(uploaded.read()))
except CurriculumError as exc:
self.message_user(request, str(exc), messages.ERROR)
return redirect(request.path)
self.message_user(request, (
f"{counts['grades']} پایه، {counts['units']} فصل، {counts['nodes']} درس و "
f"{counts['lessons']} نوعِ مسئله اضافه شد"
+ (f"؛ {counts['updated']} متنِ درس به‌روز شد" if counts['updated'] else '')
+ '.'
), messages.SUCCESS)
return redirect(reverse('admin:content_grade_changelist'))
return render(request, 'admin/content/grade/import.html', {
**self.admin_site.each_context(request),
'title': 'وارد کردنِ برنامه‌ی درسی',
'opts': self.model._meta,
})
@admin.register(Subject)
class SubjectAdmin(admin.ModelAdmin):
"""کتاب‌های ریاضی (ریاضی، حسابان، هندسه، …) که نوارِ جابه‌جاییِ اپ از آن‌ها ساخته می‌شود."""
list_display = ('order', 'title', 'key', 'icon', 'color')
ordering = ('order',)
@admin.register(Unit)
class UnitAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'grade', 'subject', 'order')
list_filter = ('grade', 'subject')
# فیلترِ «فصل» در صفحه‌ی سوال‌ها هم از همین ترتیب می‌آید: پایه‌به‌پایه، نه
# همه‌ی «فصل ۱»ها پشتِ هم.
ordering = ('grade__number', 'grade_id', 'order')
@admin.register(Node)
class NodeAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'unit', 'order')
list_filter = ('unit',)
search_fields = ('title', 'unit__title')
def _vote_counts(kind):
"""Subquery: (likes, dislikes) برای هر هدف؛ ContentVote ارجاعِ سبک است، پس annotate مستقیم ممکن نیست."""
from django.db.models import IntegerField, OuterRef, Subquery
from django.db.models.functions import Coalesce
def sub(value):
return Coalesce(Subquery(
ContentVote.objects.filter(kind=kind, target_id=OuterRef('pk'), value=value)
.order_by().values('target_id').annotate(n=Count('id')).values('n'),
output_field=IntegerField()), 0)
return sub(1), sub(-1)
class VoteColumnsMixin:
vote_kind = ''
def get_queryset(self, request):
likes, dislikes = _vote_counts(self.vote_kind)
return super().get_queryset(request).annotate(_likes=likes, _dislikes=dislikes)
@admin.display(description='👍', ordering='_likes')
def likes(self, obj):
return obj._likes or '—'
@admin.display(description='👎', ordering='_dislikes')
def dislikes(self, obj):
if not obj._dislikes:
return '—'
return format_html('<span style="color:#b91c1c;font-weight:600">{}</span>', obj._dislikes)
@admin.register(LessonReport)
class LessonReportAdmin(admin.ModelAdmin):
"""گزارشِ دانش‌آموزان روی متنِ درس‌ها."""
list_display = ('id', 'lesson', 'reason', 'status', 'note', 'created_at')
list_filter = ('status', 'reason', 'lesson__node__unit__grade')
search_fields = ('lesson__title', 'note')
autocomplete_fields = ['lesson']
actions = ['mark_resolved']
@admin.action(description='علامت زدن به‌عنوانِ بررسی‌شده', permissions=['change'])
def mark_resolved(self, request, queryset):
updated = queryset.update(status='resolved', resolved_at=timezone.now())
self.message_user(request, f'{updated} گزارش بررسی‌شده علامت خورد.', messages.SUCCESS)
@admin.register(Lesson)
class LessonAdmin(VoteColumnsMixin, admin.ModelAdmin):
vote_kind = 'lesson'
list_display = ('id', 'title', 'node', 'likes', 'dislikes')
list_filter = ('node',)
# بدونِ این، انتخابِ درس در فرمِ سوال یک دراپ‌داونِ بیش از هزارتایی است
# که عملاً غیرقابلِ استفاده است.
search_fields = ('title', 'node__title', 'node__unit__title', 'node__unit__grade__title')
@admin.register(Question)
class QuestionAdmin(VoteColumnsMixin, admin.ModelAdmin):
vote_kind = 'question'
"""
سوال‌ها علاوه بر ویرایشِ تکی، از همین‌جا فله‌ای وارد و خارج می‌شوند.
چرا اینجا و نه در ترمینال: محتوا باید بدونِ دسترسیِ SSH و بدونِ برنامه‌نویس
قابلِ تغییر باشد. خروجیِ «دریافتِ فایل» دقیقاً همان قالبی‌ست که «وارد کردن»
می‌پذیرد، پس چرخه‌ی ویرایش بسته است.
"""
list_display = ('id', 'prompt', 'lesson', 'type', 'open_reports', 'likes', 'dislikes', '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'
# جستجوی درس هنگامِ افزودنِ تکیِ سوال، به‌جای دراپ‌داونِ کامل.
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(
'import/',
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='دریافتِ فایلِ سوال‌های انتخاب‌شده')
def export_selected(self, request, queryset):
payload = json.dumps(export_questions(queryset), ensure_ascii=False, indent=2)
response = HttpResponse(payload, content_type='application/json; charset=utf-8')
response['Content-Disposition'] = 'attachment; filename="questions.json"'
return response
def import_view(self, request):
# admin_view فقط is_staff را می‌سنجد؛ «جایگزینی» سوال‌ها و جواب‌های دانش‌آموزان به آن‌ها را پاک می‌کند.
if not self.has_add_permission(request):
raise PermissionDenied
if request.method == 'POST':
uploaded = request.FILES.get('file')
if uploaded is None:
self.message_user(request, 'فایلی انتخاب نشده بود.', messages.ERROR)
return redirect(request.path)
replace = request.POST.get('replace') == 'on'
if replace and not self.has_delete_permission(request):
raise PermissionDenied
try:
rows = parse(uploaded.read())
result = import_questions(rows, replace=replace)
except ImportError_ as exc:
# خطای قابلِ فهم برای کاربر — هیچ چیزی نوشته نشده.
self.message_user(request, str(exc), messages.ERROR)
return redirect(request.path)
note = f"{result['created']} سوال در {result['lessons']} درس اضافه شد"
if result['removed']:
note += f" و {result['removed']} ردیفِ قبلی پاک شد"
self.message_user(request, note + '.', messages.SUCCESS)
return redirect(reverse('admin:content_question_changelist'))
return render(request, 'admin/content/question/import.html', {
**self.admin_site.each_context(request),
'title': 'وارد کردنِ سوال‌ها',
'opts': self.model._meta,
})
@admin.register(UserLessonCompletion)
class UserLessonCompletionAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'lesson', 'correct_count', 'stars', 'xp_earned', 'coins_earned')
list_filter = ('lesson',)
@admin.register(UserAnswer)
class UserAnswerAdmin(admin.ModelAdmin):
list_display = ('id', 'user_id', 'question', 'choice', 'correct', 'xp_earned')
list_filter = ('correct',)
@admin.register(NodeProgress)
class NodeProgressAdmin(admin.ModelAdmin):
list_display = (
'id', 'user_id', 'node', 'status', 'lessons_completed_count',
'started_at', 'last_activity_at', 'completed_at',
)
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='علامت زدن به‌عنوانِ بررسی‌شده', permissions=['change'])
def mark_resolved(self, request, queryset):
updated = queryset.update(status='resolved', resolved_at=timezone.now())
self.message_user(request, f'{updated} گزارش بررسی‌شده علامت خورد.', messages.SUCCESS)