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']

View File

@@ -0,0 +1,46 @@
# Generated by Django 5.2.17 on 2026-10-12 09:21
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0022_plain_math_messages_again'),
]
operations = [
migrations.CreateModel(
name='ContentVote',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kind', models.CharField(choices=[('question', 'سوال'), ('lesson', 'درس')], max_length=10)),
('target_id', models.IntegerField()),
('user_id', models.IntegerField()),
('value', models.SmallIntegerField()),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'indexes': [models.Index(fields=['kind', 'target_id'], name='content_con_kind_87301f_idx')],
'constraints': [models.UniqueConstraint(fields=('kind', 'target_id', 'user_id'), name='one_vote_per_user_target')],
},
),
migrations.CreateModel(
name='LessonReport',
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_content', 'مطلبِ درس اشتباه است'), ('unclear', 'توضیح گنگ است'), ('typo', 'غلطِ املایی/نگارشی'), ('bad_figure', 'شکل اشکال دارد'), ('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)),
('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='content.lesson')),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['lesson', 'status'], name='content_les_lesson__a6a0ce_idx')],
},
),
]

View File

@@ -398,3 +398,50 @@ class SeedRecord(models.Model):
def __str__(self):
return f'seed-{self.kind}-{self.key[:8]}'
class LessonReport(models.Model):
"""گزارشِ ایراد روی متنِ یک درس (توضیح، مثال، قدم‌ها)؛ همتای `QuestionReport`."""
REASON_CHOICES = [
('wrong_content', 'مطلبِ درس اشتباه است'),
('unclear', 'توضیح گنگ است'),
('typo', 'غلطِ املایی/نگارشی'),
('bad_figure', 'شکل اشکال دارد'),
('other', 'موردِ دیگر'),
]
STATUS_CHOICES = [('open', 'باز'), ('resolved', 'بررسی‌شده'), ('rejected', 'رد شد')]
lesson = models.ForeignKey(Lesson, 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=['lesson', 'status'])]
def __str__(self):
return f'report on lesson {self.lesson_id} ({self.get_reason_display()})'
class ContentVote(models.Model):
"""
لایک/دیسلایکِ دانش‌آموز روی یک سوال یا یک درس. هر کاربر روی هر هدف یک رأی دارد (تغییرپذیر).
ارجاعِ سبک (kind + target_id) تا یک جدول هر دو را بپوشاند؛ شمارش در ادمین با Subquery است.
"""
KIND_CHOICES = [('question', 'سوال'), ('lesson', 'درس')]
kind = models.CharField(max_length=10, choices=KIND_CHOICES)
target_id = models.IntegerField()
user_id = models.IntegerField()
value = models.SmallIntegerField() # ۱ = لایک، −۱ = دیسلایک
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [models.UniqueConstraint(fields=['kind', 'target_id', 'user_id'], name='one_vote_per_user_target')]
indexes = [models.Index(fields=['kind', 'target_id'])]

File diff suppressed because it is too large Load Diff

View File

@@ -204,6 +204,8 @@ def _sync_questions(rows, ledger, counts):
to_create.append(({**{k: v for k, v in row.items() if k != 'path'}, 'lessonId': lesson_id}, qkey))
existing.add((lesson_id, prompt))
_refresh_practice(rows, lessons, ledger, counts)
if missing:
raise question_io.ImportError_(
'این درس‌ها در برنامه‌ی درسی پیدا نشدند (اول کوریکولوم را وارد کنید): '
@@ -216,3 +218,61 @@ def _sync_questions(rows, ledger, counts):
counts['created'] += result['created']
for _row, qkey in to_create:
ledger.record(qkey, 'question')
PRACTICE_PREFIX = 'تمرین: '
def _refresh_practice(rows, lessons, ledger, counts):
"""
جزیره‌های «تمرین: …» را فایل می‌سازد و ادمین سوال‌هایش را کم‌وبیش دست نمی‌زند؛ پس اصلاحِ فایل
(گزینه‌ی هم‌ارزِ جواب، جوابِ غلط) باید به سوالِ موجود برسد، وگرنه سید فقط «سوالِ تازه» می‌سازد و نسخه‌ی
معیوب می‌ماند:
- همان صورت، گزینه‌ها/پاسخِ درست فرق دارد → گزینه‌ها جا به جا (همان idها) به‌روز می‌شود تا پاسخ‌های
ثبت‌شده نشکنند؛
- سوالی که خودِ سید قبلاً تحویل داده (دفتر) و حالا در فایل نیست و کسی جوابش نداده → پاک می‌شود.
سوالِ دستیِ ادمین (در دفتر نیست) هرگز دست نمی‌خورد. آزموده: content/tests_seed_content.py.
"""
practice_lessons = {
lid for path, lid in lessons.items() if path[2].startswith(PRACTICE_PREFIX)
}
if not practice_lessons:
return
want = {}
file_keys = set()
for row in rows:
path = tuple(str(p).strip() for p in row.get('path', []))
if len(path) != 4 or not path[2].startswith(PRACTICE_PREFIX):
continue
lid = lessons.get(path)
prompt = (row.get('prompt') or '').strip()
if lid is None:
continue
want[(lid, prompt)] = row
file_keys.add(_key('question', *path, prompt))
by_id = {}
for lid, path in ((v, k) for k, v in lessons.items() if v in practice_lessons):
by_id[lid] = path
qs = Question.objects.filter(lesson_id__in=practice_lessons).prefetch_related('choices')
stale = []
for q in qs.iterator(chunk_size=500):
row = want.get((q.lesson_id, q.prompt))
if row is None:
if _key('question', *by_id[q.lesson_id], q.prompt) in ledger.known:
stale.append(q.pk)
continue
new_choices = [str(c).strip() for c in row['choices']]
old = sorted(q.choices.all(), key=lambda c: c.pk)
if len(old) != len(new_choices):
continue
for index, choice in enumerate(old):
value, correct = new_choices[index], index == row['correct']
if choice.value != value or choice.is_correct != correct:
choice.value, choice.is_correct = value, correct
choice.save(update_fields=['value', 'is_correct'])
counts['updated'] += 1
if stale:
removable = list(Question.objects.filter(pk__in=stale, answers__isnull=True).values_list('pk', flat=True))
if removable:
Question.objects.filter(pk__in=removable).delete()
counts['retired'] += len(removable)

View File

@@ -1,6 +1,6 @@
from rest_framework import serializers
from .models import Grade, QuestionReport
from .models import Grade, LessonReport, QuestionReport
# ---- Grades ----
@@ -258,3 +258,8 @@ class QuestionReportResponseSerializer(serializers.Serializer):
success = serializers.BooleanField()
reportId = serializers.IntegerField()
alreadyReported = serializers.BooleanField()
class LessonReportRequestSerializer(serializers.Serializer):
reason = serializers.ChoiceField(choices=LessonReport.REASON_CHOICES)
note = serializers.CharField(required=False, allow_blank=True, max_length=1000)

52
content/tests_feedback.py Normal file
View File

@@ -0,0 +1,52 @@
"""لایک/دیسلایک و گزارشِ درس/سوال: API و ستون‌های ادمین."""
from django.core.cache import cache
from django.test import TestCase
from accounts.authentication import issue_access_token
from accounts.models import User
from content.models import Choice, ContentVote, Grade, Lesson, LessonReport, Node, Question, Unit
class FeedbackTests(TestCase):
def setUp(self):
cache.clear()
g = Grade.objects.create(title='پایه نهم', icon='', color='')
u = Unit.objects.create(grade=g, title='فصل ۱', order=1)
n = Node.objects.create(unit=u, title='مبحث', order=1)
self.lesson = Lesson.objects.create(node=n, title='درس')
self.q = Question.objects.create(lesson=self.lesson, type='multiple_choice', prompt='؟')
Choice.objects.create(question=self.q, value='الف', is_correct=True)
Choice.objects.create(question=self.q, value='ب', is_correct=False)
self.user = User.objects.create(phone='09120000051')
self.auth = {'HTTP_AUTHORIZATION': f'Bearer {issue_access_token(self.user.id)}'}
def _post(self, url, body):
return self.client.post(url, body, content_type='application/json', **self.auth)
def test_vote_toggle_and_retract(self):
url = f'/api/questions/{self.q.pk}/vote'
self.assertEqual(self._post(url, {'value': 1}).status_code, 200)
self.assertEqual(self._post(url, {'value': -1}).status_code, 200)
self.assertEqual(ContentVote.objects.get().value, -1)
self._post(url, {'value': 0})
self.assertEqual(ContentVote.objects.count(), 0)
def test_vote_rejects_bad_value_and_anonymous(self):
url = f'/api/lessons/{self.lesson.pk}/vote'
self.assertEqual(self._post(url, {'value': 5}).status_code, 400)
self.assertEqual(self.client.post(url, {'value': 1}, content_type='application/json').status_code, 401)
def test_lesson_report_is_deduplicated(self):
url = f'/api/lessons/{self.lesson.pk}/report'
r1 = self._post(url, {'reason': 'typo', 'note': 'غلط'})
r2 = self._post(url, {'reason': 'typo'})
self.assertEqual((r1.status_code, r2.status_code), (201, 200))
self.assertEqual(LessonReport.objects.count(), 1)
def test_admin_lists_show_votes(self):
ContentVote.objects.create(kind='question', target_id=self.q.pk, user_id=self.user.id, value=-1)
from django.contrib.auth.models import User as DjUser
DjUser.objects.create_superuser('root', 'r@x.ir', 'pw')
self.client.login(username='root', password='pw')
for path in ('/admin/content/question/', '/admin/content/lesson/', '/admin/content/lessonreport/'):
self.assertEqual(self.client.get(path).status_code, 200, path)

View File

@@ -287,3 +287,35 @@ class SeedFilesInRepoTests(TestCase):
positions = {row['correct'] for row in rows}
self.assertGreater(len(positions), 2)
class PracticeRefreshTests(TestCase):
"""اصلاحِ فایلِ جزیره‌ی تمرین به سوالِ موجود می‌رسد؛ سوالِ دستیِ ادمین دست نمی‌خورد."""
def _rows(self, choices, correct, prompt='۲ + ۳ = ؟'):
return [{'path': ['پایه سوم', 'فصل ۱', 'تمرین: جمع', 'درس'], 'prompt': prompt, 'type': 'multiple_choice',
'choices': choices, 'correct': correct}]
def _grades(self):
return [{'title': 'پایه سوم', 'icon': '', 'color': '', 'units': [{
'title': 'فصل ۱', 'order': 1, 'subject': 'ریاضی', 'blurb': '', 'nodes': [{
'title': 'تمرین: جمع', 'order': 1, 'lessons': [{
'title': 'درس', 'intro_text': 'متن', 'example': '', 'steps': []}]}]}]}]
def test_changed_choices_update_in_place_and_stale_is_removed(self):
from content import seed_sync
from content.models import Choice, Question
seed_sync.sync(self._grades(), self._rows(['۴', '۵', '۶', '۷'], 1))
q = Question.objects.get()
ids = list(q.choices.order_by('pk').values_list('pk', flat=True))
seed_sync.sync(self._grades(), self._rows(['۴', '۵', '۸', '۷'], 3))
q.refresh_from_db()
got = list(q.choices.order_by('pk').values_list('pk', 'value', 'is_correct'))
self.assertEqual([g[0] for g in got], ids)
self.assertEqual(got[3][1:], ('۷', True))
# سوالی که از فایل رفته و جوابی ندارد پاک می‌شود؛ سوالِ دستی نه
manual = Question.objects.create(lesson=q.lesson, type='multiple_choice', prompt='دستی')
Choice.objects.create(question=manual, value='الف', is_correct=True)
seed_sync.sync(self._grades(), self._rows(['۱', '۲'], 0, prompt='سوالِ دیگر'))
self.assertFalse(Question.objects.filter(prompt='۲ + ۳ = ؟').exists())
self.assertTrue(Question.objects.filter(prompt='دستی').exists())

View File

@@ -12,6 +12,9 @@ from .views import (
NodeProgressListView,
QuestionAnswerView,
QuestionReportView,
LessonReportView,
LessonVoteView,
QuestionVoteView,
)
urlpatterns = [
@@ -22,6 +25,9 @@ urlpatterns = [
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('questions/<int:question_id>/vote', QuestionVoteView.as_view(), name='question-vote'),
path('lessons/<int:lesson_id>/vote', LessonVoteView.as_view(), name='lesson-vote'),
path('lessons/<int:lesson_id>/report', LessonReportView.as_view(), name='lesson-report'),
path('nodes/<int:node_id>/lessons', NodeLessonsView.as_view(), name='node-lessons'),
path('nodes/<int:node_id>/quiz', NodeQuizView.as_view(), name='node-quiz'),
path('nodes/<int:node_id>/quiz/complete', NodeQuizCompleteView.as_view(), name='node-quiz-complete'),

View File

@@ -26,7 +26,7 @@ from .services import (
playable_lesson_ids, quiz_completed_node_ids, verified_correct_count,
)
from .models import (
Choice, Grade, Lesson, Node, NodeProgress, Question, QuestionReport, Subject, UserAnswer,
Choice, ContentVote, Grade, Lesson, LessonReport, Node, NodeProgress, Question, QuestionReport, Subject, UserAnswer,
UserLessonCompletion, UserNodeCompletion,
)
from .serializers import (
@@ -37,6 +37,7 @@ from .serializers import (
LessonCompleteRequestSerializer,
LessonCompleteResponseSerializer,
LessonIntroResponseSerializer,
LessonReportRequestSerializer,
LessonQuestionsResponseSerializer,
NodeLessonsResponseSerializer,
NodeQuizCompleteRequestSerializer,
@@ -988,6 +989,79 @@ class QuestionReportView(APIView):
)
class _VoteBase(APIView):
"""POST …/vote {value: 1|-1|0} — لایک/دیسلایکِ دانش‌آموز؛ ۰ یعنی رأی را پس می‌گیرد."""
permission_classes = [IsAuthenticated]
throttle_scope = 'content_vote'
throttle_classes = [ScopedRateThrottle]
kind = ''
def _exists(self, target_id) -> bool:
raise NotImplementedError
def post(self, request, **kwargs):
target_id = next(iter(kwargs.values()))
if not self._exists(target_id):
raise NotFound('پیدا نشد.')
value = request.data.get('value')
if value not in (1, -1, 0) or isinstance(value, bool):
raise ValidationError({'value': 'باید ۱، −۱ یا ۰ باشد.'})
if value == 0:
ContentVote.objects.filter(kind=self.kind, target_id=target_id, user_id=request.user.id).delete()
else:
ContentVote.objects.update_or_create(
kind=self.kind, target_id=target_id, user_id=request.user.id, defaults={'value': value})
return Response({'value': value})
class QuestionVoteView(_VoteBase):
kind = 'question'
@extend_schema(request=None, responses=OpenApiTypes.OBJECT, tags=['Curriculum (content)'])
def post(self, request, question_id: int):
return super().post(request, question_id=question_id)
def _exists(self, target_id):
return Question.objects.filter(pk=target_id).exists()
class LessonVoteView(_VoteBase):
kind = 'lesson'
@extend_schema(request=None, responses=OpenApiTypes.OBJECT, tags=['Curriculum (content)'])
def post(self, request, lesson_id: int):
return super().post(request, lesson_id=lesson_id)
def _exists(self, target_id):
return Lesson.objects.filter(pk=target_id).exists()
class LessonReportView(APIView):
"""POST /api/lessons/{id}/report — «متنِ این درس ایراد دارد» (همتای گزارشِ سوال)."""
permission_classes = [IsAuthenticated]
throttle_scope = 'question_report'
throttle_classes = [ScopedRateThrottle]
@extend_schema(request=LessonReportRequestSerializer, responses=QuestionReportResponseSerializer,
tags=['Curriculum (content)'])
def post(self, request, lesson_id: int):
lesson = Lesson.objects.filter(pk=lesson_id).first()
if lesson is None:
raise NotFound('درس یافت نشد.')
serializer = LessonReportRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
report, created = LessonReport.objects.get_or_create(
lesson=lesson, 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,
)
class NodeQuizView(APIView):
"""
GET /api/nodes/{id}/quiz — یک آزمونِ آماده برای کلِ مبحث.