Files
MathApp/content/views.py
2026-08-19 17:15:55 +03:30

432 lines
22 KiB
Python
Raw 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.
from django.db import IntegrityError, transaction
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, NodeProgress, Question, UserAnswer, UserLessonCompletion
from .serializers import (
AnswerRequestSerializer,
AnswerResponseSerializer,
GradesListResponseSerializer,
HomeResponseSerializer,
LessonCompleteRequestSerializer,
LessonCompleteResponseSerializer,
LessonIntroResponseSerializer,
LessonQuestionsResponseSerializer,
NodeProgressListResponseSerializer,
)
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,
parameters=[
OpenApiParameter(
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
description=(
'کلید اختیاری idempotency (مثل payment و lesson/complete). اگه ست '
'بشه، رتری/دابل‌کلیکِ همون submit با همون کلید، دوباره ردیفِ پاسخ/'
'کسرِ هارتِ تکراری نمی‌سازه و نتیجه‌ی همون پاسخِ قبلاً ثبت‌شده '
'برگردونده می‌شه.'
),
),
],
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
idempotency_key = request.headers.get('Idempotency-Key') or None
# ⚛️🔒 اصلاح idempotency (duplicate answer row + دوبار کسرِ هارت):
# اگه کلاینت کلید idempotency فرستاده و قبلاً همین submit با همین
# کلید ثبت شده، به‌جای ساختِ دوباره‌ی ردیف/کسرِ دوباره‌ی هارت، همون
# نتیجه‌ی قبلی (و وضعیتِ فعلیِ هارت، بدونِ تغییرِ دوباره‌اش) برگردونده
# می‌شه — دقیقاً همون الگویی که LessonCompleteView برای
# UserLessonCompletion.idempotency_key داره.
if idempotency_key:
existing = UserAnswer.objects.filter(
user_id=user_id, question=question, idempotency_key=idempotency_key,
).first()
if existing is not None:
hearts = UserHearts.objects.filter(user_id=user_id).first()
data = {
'correct': existing.correct,
'correctOptionId': str(correct_choice.id) if correct_choice else '',
'xpEarned': existing.xp_earned,
'heartsRemaining': hearts.current_hearts if hearts else 5,
}
return Response(AnswerResponseSerializer(data).data)
# ⚛️ atomic: UserAnswer.create پایین‌تر یک سیگنال post_save رو تریگر
# می‌کنه (content/receivers.py) که خودش NodeProgress رو آپدیت و
# احتمالاً یک StatusChangeLog می‌سازه؛ به‌علاوه‌ی get_or_create/save
# روی UserHearts، همه‌شون باید یک واحد اتمیک باشن — وگرنه مثلاً اگه
# save روی hearts fail کنه، پاسخ کاربر ثبت شده ولی هارتش کم نشده.
#
# ⚛️🔒 race condition: خواندنِ hearts.current_hearts و کم‌کردنش
# («lost update») بدون قفل بود — اگه کاربر دو تا پاسخِ غلط رو تقریباً
# همزمان بفرسته (یا کلاینت retry کنه)، دو درخواست می‌تونستن هر دو
# current_hearts=5 رو بخونن، هر دو 4 رو ذخیره کنن؛ یعنی به‌جای کم‌شدنِ
# ۲ تا هارت، فقط ۱ تا کم بشه. حالا ردیفِ UserHearts قفل می‌شه
# (select_for_update) قبل از خوندن/کم‌کردنش.
#
# ⚛️🔒 دفاع در عمق برای idempotency: اگه دو submit هم‌زمان دقیقاً با
# همون Idempotency-Key وارد بشن (رتریِ واقعیِ شبکه‌ایِ کلاینت)،
# UniqueConstraintِ سطح DB (uniq_user_answer_idempotency_key) جلوی
# ردیفِ دومِ UserAnswer رو می‌گیره و کلِ این atomic block (شاملِ
# کسرِ هارت) rollback می‌شه — پس هیچ اثرِ تکراری‌ای اعمال نشده. در
# این حالت، به‌جای خطا، نتیجه‌ی همون پاسخی که رقیب ثبت کرده رو
# idempotent برمی‌گردونیم.
try:
with transaction.atomic():
UserAnswer.objects.create(
user_id=user_id,
question=question,
choice=choice,
correct=choice.is_correct,
xp_earned=xp_earned,
idempotency_key=idempotency_key,
)
if not choice.is_correct:
UserHearts.objects.get_or_create(user_id=user_id)
hearts = UserHearts.objects.select_for_update().get(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
except IntegrityError:
if not idempotency_key:
raise
existing = UserAnswer.objects.filter(
user_id=user_id, question=question, idempotency_key=idempotency_key,
).first()
if existing is None:
raise
choice = existing.choice
xp_earned = existing.xp_earned
# 🔒 اصلاح: خواندنِ UserHearts اینجا قبلاً بدون قفل و بیرونِ هر
# transaction.atomic انجام می‌شد (چون بعد از rollback شدنِ
# atomic بالا اجرا می‌شه). یعنی اگه هم‌زمان یک تغییرِ دیگر روی
# همون ردیفِ UserHearts در حالِ commit شدن بود (مثلاً پاسخِ غلطِ
# دیگری از همین کاربر که select_for_update گرفته)، این خواندنِ
# بدون قفل می‌تونست با اون race کنه و یک مقدارِ ناسازگار/گذرا
# برگردونه. حالا دقیقاً همون الگوی قفلِ ردیف که مسیرِ معمولیِ
# این ویو استفاده می‌کنه (select_for_update داخلِ
# transaction.atomic) اینجا هم برای همین خواندن به‌کار رفته تا
# پاسخِ fallback نتونه با هیچ جهشِ دیگری روی هارت race کنه.
with transaction.atomic():
hearts = UserHearts.objects.select_for_update().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,
parameters=[
OpenApiParameter(
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
description=(
'کلید اختیاری idempotency (مثل payment). اگه ست بشه، رتری/'
'دابل‌کلیکِ همون submit با همون کلید، دوباره ردیف/جایزه‌ی '
'تکراری نمی‌سازه و نتیجه‌ی همون completion موجود برگردونده می‌شه.'
),
),
],
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
idempotency_key = request.headers.get('Idempotency-Key') or None
# ⚛️🔒 اصلاح race condition (duplicate completion row): اگه کلاینت
# کلید idempotency فرستاده و قبلاً همین submit با همین کلید ثبت
# شده، به‌جای ساختِ دوباره‌ی ردیف/جایزه، همون نتیجه‌ی قبلی برگردونده
# می‌شه (idempotent). این جدا از قفلِ UserStats پایینه که فقط جلوی
# duplicate *reward* رو می‌گرفت، نه duplicate *row* رو.
if idempotency_key:
existing = UserLessonCompletion.objects.filter(
user_id=user_id, lesson=lesson, idempotency_key=idempotency_key,
).first()
if existing is not None:
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 = {
'stars': existing.stars,
'xpEarned': existing.xp_earned,
'coinsEarned': existing.coins_earned,
'stats': stats_payload,
}
return Response(LessonCompleteResponseSerializer(data).data)
# ⚛️ atomic: UserLessonCompletion.create پایین‌تر یک سیگنال post_save
# رو تریگر می‌کنه (content/receivers.py → NodeProgress + احتمالاً
# StatusChangeLog)؛ به‌علاوه‌ی get_or_create/save روی UserStats.
# بدون atomic، اگه save روی stats fail کنه، تکمیلِ درس ثبت شده ولی
# xp/coins/progress_percent به‌روز نشده — یعنی چون تکمیل هر درس
# یک‌بارمصرفه، کاربر دیگه هیچ‌وقت اون xp رو نمی‌گیره.
#
# ⚛️🔒 race condition: چکِ «is_first_completion» قبلاً قبل از atomic
# و بدون قفل انجام می‌شد — دو درخواستِ همزمان (دابل‌کلیک/رتریِ
# کلاینت) برای تکمیلِ همون درس می‌تونستن هر دو ببینن که هنوز کامل
# نشده و هر دو xp/coins کامل بگیرن (دوبرابر شدنِ جایزه). حالا ردیفِ
# UserStats کاربر قفل می‌شه (select_for_update) و چکِ
# is_first_completion داخلِ همون قفل، بعد از لاک‌شدن، دوباره انجام
# می‌شه — یعنی درخواستِ دومِ همزمان، حالا که اولی commit شده، به‌درستی
# می‌بینه که دیگه first completion نیست.
try:
with transaction.atomic():
UserStats.objects.get_or_create(user_id=user_id)
stats = UserStats.objects.select_for_update().get(user_id=user_id)
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,
idempotency_key=idempotency_key,
)
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'])
except IntegrityError:
# ⚛️🔒 دفاع در عمق: اگه دو submit هم‌زمان دقیقاً با همون
# Idempotency-Key وارد بشن (رتریِ واقعیِ شبکه‌ایِ کلاینت)،
# UniqueConstraint سطح DB جلوی ردیفِ دوم رو می‌گیره و کل این
# atomic block (شاملِ آپدیتِ stats) rollback می‌شه — پس هیچ
# جایزه‌ی تکراری‌ای اعمال نشده. در این حالت، به‌جای خطا، نتیجه‌ی
# همون completion که رقیب ساخته رو idempotent برمی‌گردونیم.
if not idempotency_key:
raise
existing = UserLessonCompletion.objects.filter(
user_id=user_id, lesson=lesson, idempotency_key=idempotency_key,
).first()
if existing is None:
raise
stats = UserStats.objects.filter(user_id=user_id).first()
stars = existing.stars
xp_earned = existing.xp_earned
coins_earned = existing.coins_earned
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 = {
'stars': stars,
'xpEarned': xp_earned,
'coinsEarned': coins_earned,
'stats': stats_payload,
}
return Response(LessonCompleteResponseSerializer(data).data)
class NodeProgressListView(APIView):
"""
GET /api/progress/nodes?userId=&unit= — لیست وضعیتِ کاربر روی هر Node
(تکمیل‌شده/نصفه‌رها/در حال انجام). userId مثل HomeView به‌صورت query
param گرفته می‌شه چون این endpoint قراره از سمت والدین (family app،
گزارش هفتگی) و پشتیبانی هم برای کاربر دیگه (نه فقط خودِ request.user)
صدا زده بشه، نه فقط خودِ دانش‌آموز.
"""
@extend_schema(
parameters=[
OpenApiParameter('userId', OpenApiTypes.INT, required=True),
OpenApiParameter('unit', OpenApiTypes.INT, required=False),
],
responses=NodeProgressListResponseSerializer,
tags=['Curriculum (content)'],
)
def get(self, request):
user_id = request.query_params.get('userId')
if not user_id:
return Response({'detail': 'پارامتر userId الزامی است.'}, status=status.HTTP_400_BAD_REQUEST)
user_id = int(user_id)
qs = NodeProgress.objects.filter(user_id=user_id).select_related('node')
unit_id = request.query_params.get('unit')
if unit_id:
qs = qs.filter(node__unit_id=unit_id)
data = {'progress': list(qs.order_by('node__unit_id', 'node__order'))}
return Response(NodeProgressListResponseSerializer(data).data)