Like/dislike + report under every question and lesson, lesson reports and vote columns in admin; practice islands refresh from file; audit fixes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 12:56:47 +03:30
parent b075b93f2a
commit 2e0753587c
12 changed files with 1331 additions and 951 deletions

View File

@@ -15,7 +15,7 @@ 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, QuestionReport, Subject, Unit,
Choice, ContentVote, Grade, Lesson, LessonReport, Node, NodeProgress, Question, QuestionReport, Subject, Unit,
UserAnswer, UserLessonCompletion,
)
@@ -108,9 +108,57 @@ class NodeAdmin(admin.ModelAdmin):
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(admin.ModelAdmin):
list_display = ('id', 'title', 'node')
class LessonAdmin(VoteColumnsMixin, admin.ModelAdmin):
vote_kind = 'lesson'
list_display = ('id', 'title', 'node', 'likes', 'dislikes')
list_filter = ('node',)
# بدونِ این، انتخابِ درس در فرمِ سوال یک دراپ‌داونِ بیش از هزارتایی است
# که عملاً غیرقابلِ استفاده است.
@@ -118,7 +166,8 @@ class LessonAdmin(admin.ModelAdmin):
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
class QuestionAdmin(VoteColumnsMixin, admin.ModelAdmin):
vote_kind = 'question'
"""
سوال‌ها علاوه بر ویرایشِ تکی، از همین‌جا فله‌ای وارد و خارج می‌شوند.
@@ -127,7 +176,7 @@ class QuestionAdmin(admin.ModelAdmin):
می‌پذیرد، پس چرخه‌ی ویرایش بسته است.
"""
list_display = ('id', 'prompt', 'lesson', 'type', 'open_reports', 'wrong_percent')
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']