from django.db.models import Sum from django.shortcuts import get_object_or_404 from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from progress.models import UserHearts, UserStats from .models import Choice, Grade, Lesson, Question, UserAnswer, UserLessonCompletion from .serializers import ( AnswerRequestSerializer, AnswerResponseSerializer, GradesListResponseSerializer, HomeResponseSerializer, LessonCompleteRequestSerializer, LessonCompleteResponseSerializer, LessonIntroResponseSerializer, LessonQuestionsResponseSerializer, ) class GradesListView(APIView): """GET /api/grades — لیست پایه‌ها""" @extend_schema(responses=GradesListResponseSerializer, tags=['Curriculum (content)']) def get(self, request): grades = Grade.objects.all().order_by('id') data = {'grades': [ {'id': g.id, 'title': g.title, 'icon': g.icon, 'color': g.color} for g in grades ]} return Response(GradesListResponseSerializer(data).data) def _node_status(node, user_id, is_first_unlocked_candidate): lesson_ids = list(node.lessons.values_list('id', flat=True)) if not lesson_ids: return 'locked' completed_count = UserLessonCompletion.objects.filter( user_id=user_id, lesson_id__in=lesson_ids ).values('lesson_id').distinct().count() if completed_count >= len(lesson_ids): return 'completed' if completed_count > 0 or is_first_unlocked_candidate: return 'unlocked' return 'locked' class HomeView(APIView): """GET /api/home?userId&grade — نقشه‌ی مهارت‌ها + آمار (ادغام‌شده)""" @extend_schema( parameters=[ OpenApiParameter('userId', OpenApiTypes.INT, required=True), OpenApiParameter('grade', OpenApiTypes.INT, required=False), ], responses=HomeResponseSerializer, tags=['Curriculum (content)'], ) def get(self, request): user_id = request.query_params.get('userId') grade_id = request.query_params.get('grade') if not user_id: return Response({'detail': 'پارامتر userId الزامی است.'}, status=status.HTTP_400_BAD_REQUEST) user_id = int(user_id) units_qs = Grade.objects.get(pk=grade_id).units.all() if grade_id else [] units_payload = [] for unit in units_qs: nodes = list(unit.nodes.all()) node_payload = [] unlock_next = True # اولین نودِ قفل‌نشده در هر واحد، باز می‌مونه for node in nodes: node_status = _node_status(node, user_id, unlock_next) if node_status != 'completed': unlock_next = False node_payload.append({'id': str(node.id), 'title': node.title, 'status': node_status}) units_payload.append({'id': str(unit.id), 'title': unit.title, 'nodes': node_payload}) stats = UserStats.objects.filter(user_id=user_id).first() stats_payload = { 'progressPercent': stats.progress_percent if stats else 0, 'xp': stats.xp if stats else 0, 'coins': stats.coins if stats else 0, } data = {'units': units_payload, 'stats': stats_payload} return Response(HomeResponseSerializer(data).data) class LessonIntroView(APIView): """GET /api/lessons/{id}/intro""" @extend_schema(responses=LessonIntroResponseSerializer, tags=['Curriculum (content)']) def get(self, request, lesson_id): lesson = get_object_or_404(Lesson, pk=lesson_id) data = { 'lessonId': str(lesson.id), 'title': lesson.title, 'introText': lesson.intro_text, 'mascotAnimation': lesson.mascot_animation, } return Response(LessonIntroResponseSerializer(data).data) class LessonQuestionsView(APIView): """GET /api/lessons/{id}/questions""" @extend_schema(responses=LessonQuestionsResponseSerializer, tags=['Curriculum (content)']) def get(self, request, lesson_id): lesson = get_object_or_404(Lesson, pk=lesson_id) questions = lesson.questions.prefetch_related('choices').all() data = {'questions': [ { 'id': str(q.id), 'type': q.type, 'prompt': q.prompt, 'options': [{'id': str(c.id), 'value': c.value} for c in q.choices.all()], } for q in questions ]} return Response(LessonQuestionsResponseSerializer(data).data) class QuestionAnswerView(APIView): """POST /api/questions/{id}/answer — ثبت پاسخ""" # ⚠️ اصلاح امنیتی: قبلاً userId از query/body کلاینت خونده می‌شد که به # کلاینت اجازه می‌داد به‌جای هر کاربر دیگری پاسخ ثبت کنه (heart/xp کاربر # دلخواه رو تغییر بده). حالا auth اجباریه و همیشه از request.user.id # (که از توکن معتبر استخراج شده) استفاده می‌شه. permission_classes = [IsAuthenticated] @extend_schema( request=AnswerRequestSerializer, responses=AnswerResponseSerializer, tags=['Curriculum (content)'], ) def post(self, request, question_id): question = get_object_or_404(Question, pk=question_id) serializer = AnswerRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) selected_option_id = serializer.validated_data['selectedOptionId'] choice = get_object_or_404(Choice, pk=selected_option_id, question=question) correct_choice = question.choices.filter(is_correct=True).first() user_id = request.user.id xp_earned = 10 if choice.is_correct else 0 UserAnswer.objects.create( user_id=user_id, question=question, choice=choice, correct=choice.is_correct, xp_earned=xp_earned, ) if not choice.is_correct: hearts, _ = UserHearts.objects.get_or_create(user_id=user_id) if hearts.current_hearts > 0: hearts.current_hearts -= 1 hearts.save(update_fields=['current_hearts']) hearts_remaining = hearts.current_hearts else: hearts = UserHearts.objects.filter(user_id=user_id).first() hearts_remaining = hearts.current_hearts if hearts else 5 data = { 'correct': choice.is_correct, 'correctOptionId': str(correct_choice.id) if correct_choice else '', 'xpEarned': xp_earned, 'heartsRemaining': hearts_remaining if hearts_remaining is not None else 5, } return Response(AnswerResponseSerializer(data).data) class LessonCompleteView(APIView): """POST /api/lessons/{id}/complete — اتمام درس + آمار جدید""" # ⚠️ اصلاح امنیتی: قبلاً userId از query/body کلاینت خونده می‌شد که به # کلاینت اجازه می‌داد به‌جای هر کاربر دیگری xp/coins ثبت کنه. حالا auth # اجباریه و همیشه از request.user.id استفاده می‌شه. permission_classes = [IsAuthenticated] @extend_schema( request=LessonCompleteRequestSerializer, responses=LessonCompleteResponseSerializer, tags=['Curriculum (content)'], ) def post(self, request, lesson_id): lesson = get_object_or_404(Lesson, pk=lesson_id) serializer = LessonCompleteRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) correct_count = serializer.validated_data['correctCount'] total_questions = serializer.validated_data['totalQuestions'] or 1 ratio = correct_count / total_questions stars = 3 if ratio >= 0.9 else 2 if ratio >= 0.6 else 1 if ratio > 0 else 0 potential_xp = correct_count * 10 potential_coins = stars * 5 user_id = request.user.id # ⚠️ اصلاح: تکمیل هر درس یک‌بارمصرفه — دفعات بعدیِ تکمیل همون درس # نباید دوباره xp/coins به آمار کاربر اضافه کنه (قبلاً هر بار # تکمیل، xp_earned/coins_earned رو بدون قید به stats اضافه می‌کرد). is_first_completion = not UserLessonCompletion.objects.filter( user_id=user_id, lesson=lesson ).exists() xp_earned = potential_xp if is_first_completion else 0 coins_earned = potential_coins if is_first_completion else 0 UserLessonCompletion.objects.create( user_id=user_id, lesson=lesson, correct_count=correct_count, stars=stars, xp_earned=xp_earned, coins_earned=coins_earned, ) stats, _ = UserStats.objects.get_or_create(user_id=user_id) stats.xp += xp_earned stats.coins += coins_earned total_lessons = Lesson.objects.count() or 1 completed_lessons = UserLessonCompletion.objects.filter( user_id=user_id ).values('lesson_id').distinct().count() stats.progress_percent = round(min(completed_lessons / total_lessons, 1.0) * 100, 2) stats.save(update_fields=['xp', 'coins', 'progress_percent']) stats_payload = { 'progressPercent': stats.progress_percent, 'xp': stats.xp, 'coins': stats.coins, } data = { 'stars': stars, 'xpEarned': xp_earned, 'coinsEarned': coins_earned, 'stats': stats_payload, } return Response(LessonCompleteResponseSerializer(data).data)