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

@@ -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 — یک آزمونِ آماده برای کلِ مبحث.