Files
MathApp/content/views.py

469 lines
24 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.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)