432 lines
22 KiB
Python
432 lines
22 KiB
Python
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)
|