469 lines
24 KiB
Python
469 lines
24 KiB
Python
from django.db import IntegrityError, transaction
|
||
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.exceptions import PermissionDenied, ValidationError
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.throttling import ScopedRateThrottle
|
||
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 GuestAwareContentThrottle(ScopedRateThrottle):
|
||
"""
|
||
⚠️ اضافه شده: همون مشکلِ GuestAwareAIThrottle (ai_tutor/views.py)،
|
||
GuestAwareFamilyThrottle (family/views.py) و GuestAwareDiscountThrottle/
|
||
GuestAwarePurchaseThrottle (discount/views.py، subscription/views.py)
|
||
اینجا هم هست. ScopedRateThrottle استاندارد برای درخواستِ احرازهویتشده
|
||
از request.user.pk بهعنوانِ کلیدِ throttle استفاده میکنه؛ چون
|
||
GuestSessionView با هر deviceId جدید یک کاربرِ مهمانِ تازه (pk جدید)
|
||
میسازه، یک مهاجم میتونست با ساختنِ پیدرپیِ حسابهای مهمان، برای هر
|
||
حساب یک bucket تازه از throttle_scope 'question_answer'/'lesson_complete'
|
||
بگیره و محدودیتِ واقعی رو دور بزنه — درحالیکه هر دو endpoint اثرِ
|
||
جانبیِ ماندگار دارن (UserAnswer + کسرِ هارت، یا UserLessonCompletion +
|
||
xp/coins/progress).
|
||
|
||
دقیقاً طبقِ همون الگو: فقط برای کاربرهای مهمان (is_guest=True) کلیدِ
|
||
cache از pk کاربر به IP کلاینت (get_ident) تغییر میکنه، پس همهی
|
||
حسابهای مهمانِ ساختهشده از یک IP یک bucket مشترک دارن و چرخشِ حساب
|
||
دیگه bucket تازه نمیده. رفتارِ throttle برای کاربرهای غیرمهمان
|
||
(ثبتنامشده) دقیقاً مثلِ قبل (کلید بر اساسِ pk) باقی میمونه؛ منطقِ
|
||
answer/heart/progress/completion/idempotency دستنخوردهست.
|
||
"""
|
||
|
||
def get_cache_key(self, request, view):
|
||
user = request.user
|
||
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
||
ident = f'guest-ip:{self.get_ident(request)}'
|
||
elif user and user.is_authenticated:
|
||
ident = user.pk
|
||
else:
|
||
ident = self.get_ident(request)
|
||
|
||
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
||
|
||
|
||
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?grade — نقشهی مهارتها + آمار (ادغامشده)"""
|
||
|
||
# userId همیشه از request.user.id (توکن معتبر) گرفته میشود، نه ورودی کلاینت (IDOR).
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(
|
||
parameters=[
|
||
OpenApiParameter('grade', OpenApiTypes.INT, required=False),
|
||
],
|
||
responses=HomeResponseSerializer,
|
||
tags=['Curriculum (content)'],
|
||
)
|
||
def get(self, request):
|
||
grade_id = request.query_params.get('grade')
|
||
user_id = request.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 همیشه از request.user.id گرفته میشود، نه ورودی کلاینت.
|
||
permission_classes = [IsAuthenticated]
|
||
# Idempotency-Key فقط جلوی retry با همون کلید رو میگیره، نه حجمِ بالای
|
||
# درخواستهای *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک
|
||
# UserAnswer جدید میسازن و منطقِ هارت/NodeProgress رو اجرا میکنن —
|
||
# حتی وقتی هارتِ کاربر صفر شده. ScopedRateThrottle وقتی request.user
|
||
# احرازهویتشده باشه با pk کاربر کلید میخوره (نه فقط IP)، پس تغییرِ
|
||
# IP این محدودیت رو دور نمیزنه.
|
||
# ⚠️ اصلاح شده: چون کاربرهای مهمان (GuestSessionView) با هر deviceId
|
||
# جدید یک pk تازه میگیرن، ScopedRateThrottleِ ساده (کلید بر اساسِ
|
||
# user.pk) توسطِ چرخشِ حساب مهمان قابلدورزدن بود. حالا از
|
||
# GuestAwareContentThrottleِ مشترک استفاده میشه که برای کاربرهای
|
||
# مهمان کلید رو بر اساسِ IP میسازه و برای کاربرهای غیرمهمان رفتارِ
|
||
# per-user قبلی رو دستنخورده نگه میداره.
|
||
throttle_scope = 'question_answer'
|
||
throttle_classes = [GuestAwareContentThrottle]
|
||
|
||
@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
|
||
|
||
# درخواست تکراری با همین کلید: نتیجهی قبلی بدون کسر دوبارهی هارت برگردانده میشود.
|
||
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)
|
||
|
||
# اتمیک: ثبت پاسخ + کسر هارت باید یک واحد باشند. UserHearts زیر قفل
|
||
# خوانده/کم میشود تا lost update در پاسخهای همزمان رخ ندهد. قید یکتاییِ
|
||
# سطح DB (uniq_user_answer_idempotency_key) جلوی ثبت دوباره با همان کلید را میگیرد.
|
||
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 هم اینجا رعایت میشود تا این خواندنِ 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]
|
||
# Idempotency-Key فقط جلوی retry با همون کلید رو میگیره، نه حجمِ بالای
|
||
# درخواستهای *متفاوت* (بدون کلید یا با کلیدهای متفاوت) که هرکدوم یک
|
||
# UserLessonCompletion جدید میسازن و سیگنالِ post_save (NodeProgress) +
|
||
# بهروزرسانیِ UserStats رو اجرا میکنن. ScopedRateThrottle با pk
|
||
# کاربرِ احرازهویتشده کلید میخوره، نه IP.
|
||
# ⚠️ اصلاح شده: همون مشکلِ چرخشِ حسابِ مهمان (نگاه کنید به
|
||
# GuestAwareContentThrottle بالا) اینجا هم بود؛ حالا از همون کلاسِ
|
||
# مشترک استفاده میشه. نرخ هم از ۶۰/min به ۱۰/hour تغییر کرد چون این
|
||
# endpoint رکوردِ completion ماندگار میسازه و xp/coins/progress رو
|
||
# واقعاً تغییر میده (برخلافِ question_answer که فرکانسِ طبیعیاش بالاتره).
|
||
throttle_scope = 'lesson_complete'
|
||
throttle_classes = [GuestAwareContentThrottle]
|
||
|
||
@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']
|
||
# totalQuestions در بدنهی درخواست پذیرفته میشود (سازگاری با schema) اما در محاسبه استفاده نمیشود.
|
||
|
||
# correctCount نباید از تعداد واقعی سوالهای این درس بیشتر باشد، تا xp/coins متورم نگیرد.
|
||
actual_question_count = lesson.questions.count()
|
||
if correct_count > actual_question_count:
|
||
raise ValidationError({
|
||
'correctCount': (
|
||
'correctCount نمیتواند از تعداد واقعی سوالهای این درس '
|
||
f'({actual_question_count}) بیشتر باشد.'
|
||
)
|
||
})
|
||
|
||
# ratio همیشه بر اساس actual_question_count محاسبه میشود، نه totalQuestions ارسالی کلاینت.
|
||
ratio = correct_count / (actual_question_count or 1)
|
||
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
|
||
|
||
# درخواست تکراری با همین کلید: نتیجهی قبلی idempotent برگردانده میشود.
|
||
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:
|
||
# قید یکتاییِ سطح DB جلوی درخواستهای همزمان با همان کلید را میگیرد؛
|
||
# نتیجهی 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 قابل قبول است (والد میتواند پیشرفت فرزندش را ببیند)،
|
||
اما فقط اگر برابر خودِ request.user باشد یا درخواستدهنده والدِ تأییدشدهی
|
||
همان کاربر باشد (FamilyLink با status='accepted')؛ در غیر این صورت 403.
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(
|
||
parameters=[
|
||
OpenApiParameter('userId', OpenApiTypes.INT, required=False),
|
||
OpenApiParameter('unit', OpenApiTypes.INT, required=False),
|
||
],
|
||
responses=NodeProgressListResponseSerializer,
|
||
tags=['Curriculum (content)'],
|
||
)
|
||
def get(self, request):
|
||
requested_user_id = request.query_params.get('userId')
|
||
user_id = int(requested_user_id) if requested_user_id else request.user.id
|
||
|
||
if user_id != request.user.id:
|
||
from family.models import FamilyLink
|
||
|
||
is_verified_parent = FamilyLink.objects.filter(
|
||
parent_user_id=request.user.id, child_user_id=user_id, status='accepted',
|
||
).exists()
|
||
if not is_verified_parent:
|
||
raise PermissionDenied('اجازهی مشاهدهی پیشرفتِ این کاربر را ندارید.')
|
||
|
||
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)
|