Harden auth, AI tutor, payments, and subscriptions

This commit is contained in:
2026-08-22 04:42:36 +03:30
parent f945f960c9
commit cb18c93a3e
41 changed files with 2202 additions and 595 deletions

View File

@@ -29,29 +29,9 @@ class Command(BaseCommand):
)
count = 0
# ⚛️ atomic (به‌ازای هر رکورد، نه کل حلقه): این یک job دوره‌ای رو
# روی هزاران رکورد می‌تونه اجرا کنه. اگه کل حلقه یک atomic واحد
# باشه، یک خطا روی رکورد هزارم، ابطالِ ۹۹۹ رکورد درست‌شده‌ی قبلی رو
# هم rollback می‌کنه (و کل job باید از اول اجرا بشه). به‌جاش هر
# رکورد (save + signal که StatusChangeLog می‌سازه) جدا اتمیکه: یک
# خطای مقطعی فقط همون رکورد رو نصفه‌کاره نگه نمی‌داره، رکوردهای
# قبلی/بعدی مستقل می‌مونن.
#
# ⚛️🔒 اصلاح race condition (lost update): قبلاً همین آبجکتِ
# progressِ خونده‌شده از stale_qs (بدونِ قفل) مستقیماً save می‌شد.
# اگه هم‌زمان یک درخواستِ کاربرِ واقعی (touch_node_progress_on_answer
# یا update_node_progress_on_lesson_completion در content/services.py،
# هر دو با select_for_update) دقیقاً همون ردیف رو به‌روزرسانی و
# last_activity_at رو تازه می‌کرد، این کامند می‌تونست بدونِ اطلاع از
# اون تغییرِ تازه، status رو با مقدارِ قدیمیِ در-حافظه به 'abandoned'
# برگردونه — یعنی فعالیتِ تازه‌ی کاربر توسط این جاب rollback/overwrite
# می‌شد. حالا برای هر رکورد، فقط id از stale_qs گرفته می‌شه؛ ردیفِ
# واقعی داخل transaction.atomic با select_for_update دوباره خونده و
# قفل می‌شه (دقیقاً همون الگویی که مسیرِ کاربر استفاده می‌کنه)، و
# شرطِ نصفه‌رها بودن (status='in_progress' و last_activity_at قدیمی‌تر
# از threshold) بعد از گرفتنِ قفل دوباره چک می‌شه؛ اگه بین queryset
# اولیه و گرفتنِ قفل، یک فعالیتِ کاربر همین ردیف رو تازه کرده باشه،
# دیگه شرط برقرار نیست و این ردیف نادیده گرفته می‌شه (نه abandoned).
# هر رکورد جدا اتمیک است (نه کل حلقه) تا خطای یک رکورد بقیه را rollback نکند.
# id ها جدا گرفته می‌شوند و هر ردیف زیر select_for_update دوباره خوانده و
# شرط abandoned بعد از قفل دوباره چک می‌شود، تا فعالیت هم‌زمان کاربر overwrite نشود.
stale_ids = list(stale_qs.values_list('id', flat=True))
for progress_id in stale_ids:
with transaction.atomic():

View File

@@ -57,19 +57,9 @@ class Question(models.Model):
class Choice(models.Model):
"""
⚠️ اصلاح نسبت به سند اولیه: دیاگرام معماری فقط فیلد `value` رو برای
Choice مشخص کرده بود و فیلد تشخیص گزینه‌ی درست (is_correct) رو نداشت
(این نکته توی سند models هم صراحتاً به‌عنوان یک ایراد/نقص علامت‌گذاری
شده بود: "یا این فیلد سهواً جا افتاده یا تشخیص جای دیگه‌ای انجام می‌شه").
چون بدون این فیلد امکان پیاده‌سازی POST /api/questions/{id}/answer
(که باید correct را برگردونه) وجود نداره، این فیلد اینجا اضافه شده.
اگه منطق تشخیص پاسخ درست قراره جای دیگه‌ای (سرویس content-authoring)
انجام بشه، این فیلد رو می‌شه بعداً حذف/جابه‌جا کرد.
"""
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='choices')
value = models.CharField(max_length=100)
is_correct = models.BooleanField(default=False) # <-- اصلاح/افزوده شده
is_correct = models.BooleanField(default=False)
def __str__(self):
return self.value
@@ -82,15 +72,9 @@ class UserLessonCompletion(models.Model):
stars = models.PositiveSmallIntegerField()
xp_earned = models.PositiveIntegerField(default=0)
coins_earned = models.PositiveIntegerField(default=0)
# ⚠️ افزوده شده برای رفع race condition: طبق طراحیِ فعلی، کاربر مجاز به
# تکرارِ یک درس هست (فقط بارِ اول xp/coins واقعی می‌گیره، نگاه کنید به
# is_first_completion در LessonCompleteView)، پس یک UniqueConstraint
# سختِ (user_id, lesson) درست نیست — جلوی retryِ مشروع رو هم می‌گیره.
# مشکلِ واقعی، دابل‌کلیک/رتریِ شبکه‌ایِ کلاینت روی *همون* submitِ
# تکمیل درسه که می‌تونست دو ردیف برای یک اتفاق بسازه. راه‌حل، همون
# الگوی Idempotency-Key هست که پروژه از قبل در payment/services.py
# برای create_order داره: کلاینت (اختیاری) یک کلید یکتا برای هر submit
# واقعی می‌فرسته و رتری‌ها با همون کلید idempotent می‌شن.
# تکرار درس مجاز است (فقط بار اول xp/coins واقعی می‌گیرد)، پس قید یکتایی
# سخت روی (user_id, lesson) درست نیست. idempotency_key فقط جلوی رکورد
# تکراریِ ناشی از دابل‌کلیک/رتری روی همان submit را می‌گیرد.
idempotency_key = models.CharField(max_length=100, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
@@ -177,26 +161,13 @@ class UserAnswer(models.Model):
choice = models.ForeignKey(Choice, on_delete=models.CASCADE, related_name='answers')
correct = models.BooleanField()
xp_earned = models.PositiveIntegerField(default=0)
# ⚠️ افزوده شده برای رفع idempotency در POST /api/questions/{id}/answer:
# قبلاً هیچ مکانیزمی برای تشخیصِ رتریِ شبکه‌ای/کلاینتِ همون submit وجود
# نداشت — یک retry دقیقاً همون درخواست رو دوباره می‌فرستاد و یک ردیفِ
# UserAnswer تکراری می‌ساخت و (در حالت پاسخ غلط) هارت رو دوبار کم
# می‌کرد. دقیقاً همون الگویی که UserLessonCompletion.idempotency_key
# برای LessonCompleteView داره: کلاینت (اختیاری) یک کلید یکتا برای هر
# submit واقعی می‌فرسته و رتری‌ها با همون کلید idempotent می‌شن؛ بدونِ
# کلید (مقدار None)، پاسخ‌های مشروعِ تکراری (کاربر واقعاً چند بار به یک
# سوال پاسخ می‌ده) همچنان مجازن.
# کلید اختیاری برای idempotency: رتری روی همان submit رکورد تکراری/کسر دوباره‌ی هارت نمی‌سازد.
idempotency_key = models.CharField(max_length=100, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
# 🔒 قید یکتاییِ سطح DB: به‌اندازه‌ی کافی محدود (user_id +
# question + idempotency_key) تا کاربر و سوال رو از هم تفکیک
# کنه — یعنی همون کلید توسط کاربرِ دیگه یا برای سوالِ دیگه هیچ
# تداخلی نداره. condition تضمین می‌کنه این قید فقط وقتی
# idempotency_key واقعاً پر شده اعمال بشه، نه وقتی خالی/نال‌ه
# (وگرنه پاسخ‌های بدونِ کلید هم به اشتباه یکتا می‌شدن).
# قید فقط وقتی idempotency_key پر باشد اعمال می‌شود، تا پاسخ‌های بدون کلید یکتا نشوند.
models.UniqueConstraint(
fields=['user_id', 'question', 'idempotency_key'],
condition=models.Q(idempotency_key__isnull=False) & ~models.Q(idempotency_key=''),

View File

@@ -1,7 +1,7 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from .models import Choice, Grade, Lesson, Question
from .models import Grade
# ---- Grades ----
@@ -84,8 +84,13 @@ class AnswerResponseSerializer(serializers.Serializer):
# ---- Lesson completion ----
class LessonCompleteRequestSerializer(serializers.Serializer):
correctCount = serializers.IntegerField()
totalQuestions = serializers.IntegerField()
# 🔒 اصلاح امنیتی: قبلاً هیچ محدودیتی روی این دو فیلد نبود؛ کلاینت
# می‌تونست correctCount منفی یا totalQuestions صفر/منفی بفرسته.
# min_value ها فقط شرطِ ساده‌ی «غیرمنفی/مثبت» رو تضمین می‌کنن؛ چکِ
# اصلیِ «correctCount نباید از تعدادِ واقعیِ سوال‌های درس بیشتر باشه»
# در LessonCompleteView (جایی که به Lesson دسترسی داریم) انجام می‌شه.
correctCount = serializers.IntegerField(min_value=0)
totalQuestions = serializers.IntegerField(min_value=1)
class LessonCompleteResponseSerializer(serializers.Serializer):

View File

@@ -10,29 +10,12 @@ from django.utils import timezone
from statuslog.signals import status_changed
from .models import Node, NodeProgress, UserLessonCompletion
from .models import NodeProgress, UserLessonCompletion
def _get_or_create_progress(user_id, node):
"""
⚛️🔒 اصلاح race condition: قبلاً این تابع فقط get_or_create می‌کرد و
ردیفِ برگردانده‌شده (چه تازه‌ساخته‌شده، چه از قبل موجود) هیچ‌وقت قفل
نمی‌شد. دو درخواستِ هم‌زمان برای همون (user_id, node) — مثلاً دو پاسخِ
پشت‌سرهم یا یک پاسخ و یک تکمیلِ درس که تقریباً هم‌زمان می‌رسن —
می‌تونستن هر دو همون ردیفِ NodeProgress رو با status/started_at/
last_activity_at یکسان بخونن، هر دو مستقل مقدارهای جدید رو محاسبه
کنن و save کنن؛ نتیجه‌ی یکی از دو نویسش گم می‌شد (lost update) —
مثلاً یک started_at یا یک تغییرِ status می‌تونست توسط نویسش دیگری
که دیرتر commit می‌شه، بازنویسی/نادیده گرفته بشه.
حالا دقیقاً مثل الگوی grant_free_subscription در subscription/services.py:
اول get_or_create برای اطمینان از وجودِ ردیف (idempotent، خودش با
savepoint در برابر race روی خودِ create محافظت‌شده)، بعد همون ردیف با
select_for_update دوباره خونده و قفل می‌شه تا خواندن/تغییر/ذخیره‌ی
فیلدهاش توسط caller، در برابر concurrent update ایمن باشه. caller
باید این تابع رو همیشه داخل transaction.atomic صدا بزنه (هر دو نقطه‌ی
فراخوانی در همین فایل همین‌طورن).
"""
"""get_or_create ایجاد می‌کند، سپس ردیف با select_for_update دوباره خوانده و قفل می‌شود
تا خواندن/تغییر/ذخیره در برابر concurrent update ایمن باشد. باید داخل transaction.atomic صدا زده شود."""
NodeProgress.objects.get_or_create(user_id=user_id, node=node)
progress = NodeProgress.objects.select_for_update().get(user_id=user_id, node=node)
return progress, False

View File

@@ -1,3 +1,109 @@
from django.test import TestCase
from rest_framework.test import APIClient
# Create your tests here.
from accounts.authentication import issue_access_token
from accounts.models import User
from .models import Grade, Lesson, Node, Question, Unit
class LessonCompleteImpossibleCountTests(TestCase):
"""
🔒 تستِ رگرسیونِ فیکسِ امنیتی: LessonCompleteView قبلاً correctCount رو
بدونِ مقایسه با تعدادِ واقعیِ سوال‌های درس می‌پذیرفت، پس یک کلاینتِ
مخرب می‌تونست با correctCount بزرگ‌تر از تعدادِ واقعیِ سوال‌ها،
xp/coins متورم بگیره. این تست هم ردِ درخواست‌های غیرممکن رو، هم
رفتارِ صحیحِ تکمیل با مقادیرِ معتبر رو تضمین می‌کنه.
"""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-complete-1', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
grade = Grade.objects.create(title='پایه اول', icon='icon', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۱', order=1)
node = Node.objects.create(unit=unit, title='گره ۱', order=1)
self.lesson = Lesson.objects.create(node=node, title='درس ۱', intro_text='...')
for i in range(3):
Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
)
def _complete(self, correct_count, total_questions):
return self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': correct_count, 'totalQuestions': total_questions},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
def test_correct_count_exceeding_actual_questions_is_rejected(self):
# فقط ۳ سوال واقعی در این درس هست؛ کلاینت ادعای ۱۰۰ تای درست از
# ۱۰ تای کل رو می‌کنه.
response = self._complete(correct_count=100, total_questions=10)
self.assertEqual(response.status_code, 400)
self.assertIn('correctCount', response.data)
def test_negative_correct_count_is_rejected(self):
response = self._complete(correct_count=-1, total_questions=3)
self.assertEqual(response.status_code, 400)
def test_zero_total_questions_is_rejected(self):
response = self._complete(correct_count=1, total_questions=0)
self.assertEqual(response.status_code, 400)
def test_valid_completion_still_grants_expected_reward(self):
# ۳ از ۳ سوالِ واقعیِ همین درس، درست — باید ۳ ستاره و xp کامل بده.
response = self._complete(correct_count=3, total_questions=3)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['stars'], 3)
self.assertEqual(response.data['xpEarned'], 30)
self.assertEqual(response.data['coinsEarned'], 15)
class LessonCompleteRatioIgnoresClientTotalQuestionsTests(TestCase):
"""
🔒 تستِ رگرسیونِ فیکسِ دوم: قبلاً ratio از totalQuestions ارسالی‌ی
خودِ کلاینت محاسبه می‌شد، نه از تعدادِ واقعیِ سوال‌های درس. یعنی حتی
بعد از سقف‌زدنِ correctCount، کلاینت می‌تونست با totalQuestions=1
(به‌جای ۳ سوالِ واقعی)، ratio رو مصنوعاً ۱۰۰٪ کنه و ۳ ستاره/coins
کامل بگیره درحالی‌که فقط ۱ از ۳ سوال رو درست جواب داده. حالا ratio
همیشه بر اساسِ actual_question_count محاسبه می‌شه.
"""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create(device_id='device-complete-2', is_guest=True, name='')
self.token = issue_access_token(self.user.id)
grade = Grade.objects.create(title='پایه دوم', icon='icon', color='#fff')
unit = Unit.objects.create(grade=grade, title='فصل ۲', order=1)
node = Node.objects.create(unit=unit, title='گره ۲', order=1)
self.lesson = Lesson.objects.create(node=node, title='درس ۲', intro_text='...')
for i in range(3):
Question.objects.create(
lesson=self.lesson, type='multiple_choice', prompt=f'سوال {i}',
)
def _complete(self, correct_count, total_questions):
return self.client.post(
f'/api/lessons/{self.lesson.id}/complete',
{'correctCount': correct_count, 'totalQuestions': total_questions},
format='json',
HTTP_AUTHORIZATION=f'Bearer {self.token}',
)
def test_manipulated_total_questions_does_not_yield_full_stars(self):
# درسِ ۳-سوالی؛ کلاینت فقط ۱ درست داشته ولی totalQuestions=1
# می‌فرسته تا وانمود کنه ۱۰۰٪ جواب داده.
response = self._complete(correct_count=1, total_questions=1)
self.assertEqual(response.status_code, 200)
self.assertNotEqual(response.data['stars'], 3)
def test_ratio_is_based_on_actual_lesson_question_count(self):
# ۱ از ۳ سوالِ واقعی = ratio نزدیکِ ۰.۳۳ → طبقِ آستانه‌های موجود
# (>=0.9 → 3, >=0.6 → 2, >0 → 1)، باید دقیقاً ۱ ستاره بده.
response = self._complete(correct_count=1, total_questions=1)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['stars'], 1)

View File

@@ -1,9 +1,8 @@
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.exceptions import PermissionDenied, ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
@@ -51,22 +50,21 @@ def _node_status(node, user_id, is_first_unlocked_candidate):
class HomeView(APIView):
"""GET /api/home?userId&grade — نقشه‌ی مهارت‌ها + آمار (ادغام‌شده)"""
"""GET /api/home?grade — نقشه‌ی مهارت‌ها + آمار (ادغام‌شده)"""
# userId همیشه از request.user.id (توکن معتبر) گرفته می‌شود، نه ورودی کلاینت (IDOR).
permission_classes = [IsAuthenticated]
@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)
user_id = request.user.id
units_qs = Grade.objects.get(pk=grade_id).units.all() if grade_id else []
@@ -130,10 +128,7 @@ class LessonQuestionsView(APIView):
class QuestionAnswerView(APIView):
"""POST /api/questions/{id}/answer — ثبت پاسخ"""
# ⚠️ اصلاح امنیتی: قبلاً userId از query/body کلاینت خونده می‌شد که به
# کلاینت اجازه می‌داد به‌جای هر کاربر دیگری پاسخ ثبت کنه (heart/xp کاربر
# دلخواه رو تغییر بده). حالا auth اجباریه و همیشه از request.user.id
# (که از توکن معتبر استخراج شده) استفاده می‌شه.
# userId همیشه از request.user.id گرفته می‌شود، نه ورودی کلاینت.
permission_classes = [IsAuthenticated]
@extend_schema(
@@ -165,12 +160,7 @@ class QuestionAnswerView(APIView):
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,
@@ -185,26 +175,9 @@ class QuestionAnswerView(APIView):
}
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 برمی‌گردونیم.
# اتمیک: ثبت پاسخ + کسر هارت باید یک واحد باشند. UserHearts زیر قفل
# خوانده/کم می‌شود تا lost update در پاسخ‌های هم‌زمان رخ ندهد. قید یکتاییِ
# سطح DB (uniq_user_answer_idempotency_key) جلوی ثبت دوباره با همان کلید را می‌گیرد.
try:
with transaction.atomic():
UserAnswer.objects.create(
@@ -235,16 +208,7 @@ class QuestionAnswerView(APIView):
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 کنه.
# قفل ردیف 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
@@ -286,9 +250,20 @@ class LessonCompleteView(APIView):
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
# totalQuestions در بدنه‌ی درخواست پذیرفته می‌شود (سازگاری با schema) اما در محاسبه استفاده نمی‌شود.
ratio = correct_count / total_questions
# 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
@@ -296,11 +271,7 @@ class LessonCompleteView(APIView):
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* رو.
# درخواست تکراری با همین کلید: نتیجه‌ی قبلی idempotent برگردانده می‌شود.
if idempotency_key:
existing = UserLessonCompletion.objects.filter(
user_id=user_id, lesson=lesson, idempotency_key=idempotency_key,
@@ -366,12 +337,8 @@ class LessonCompleteView(APIView):
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 برمی‌گردونیم.
# قید یکتاییِ سطح DB جلوی درخواست‌های هم‌زمان با همان کلید را می‌گیرد؛
# نتیجه‌ی completion رقیب idempotent برگردانده می‌شود.
if not idempotency_key:
raise
existing = UserLessonCompletion.objects.filter(
@@ -401,26 +368,35 @@ class LessonCompleteView(APIView):
class NodeProgressListView(APIView):
"""
GET /api/progress/nodes?userId=&unit= — لیست وضعیتِ کاربر روی هر Node
(تکمیل‌شده/نصفه‌رها/در حال انجام). userId مثل HomeView به‌صورت query
param گرفته می‌شه چون این endpoint قراره از سمت والدین (family app،
گزارش هفتگی) و پشتیبانی هم برای کاربر دیگه (نه فقط خودِ request.user)
صدا زده بشه، نه فقط خودِ دانش‌آموز.
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=True),
OpenApiParameter('userId', OpenApiTypes.INT, required=False),
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)
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')