Update project
This commit is contained in:
237
content/views.py
237
content/views.py
@@ -1,3 +1,4 @@
|
||||
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
|
||||
@@ -138,6 +139,17 @@ class QuestionAnswerView(APIView):
|
||||
@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):
|
||||
@@ -151,22 +163,90 @@ class QuestionAnswerView(APIView):
|
||||
|
||||
user_id = request.user.id
|
||||
xp_earned = 10 if choice.is_correct else 0
|
||||
idempotency_key = request.headers.get('Idempotency-Key') or None
|
||||
|
||||
UserAnswer.objects.create(
|
||||
user_id=user_id,
|
||||
question=question,
|
||||
choice=choice,
|
||||
correct=choice.is_correct,
|
||||
xp_earned=xp_earned,
|
||||
)
|
||||
if not choice.is_correct:
|
||||
hearts, _ = UserHearts.objects.get_or_create(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()
|
||||
# ⚛️🔒 اصلاح 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 = {
|
||||
@@ -189,6 +269,16 @@ class LessonCompleteView(APIView):
|
||||
@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):
|
||||
@@ -204,39 +294,100 @@ class LessonCompleteView(APIView):
|
||||
potential_coins = stars * 5
|
||||
|
||||
user_id = request.user.id
|
||||
idempotency_key = request.headers.get('Idempotency-Key') or None
|
||||
|
||||
# ⚠️ اصلاح: تکمیل هر درس یکبارمصرفه — دفعات بعدیِ تکمیل همون درس
|
||||
# نباید دوباره xp/coins به آمار کاربر اضافه کنه (قبلاً هر بار
|
||||
# تکمیل، xp_earned/coins_earned رو بدون قید به stats اضافه میکرد).
|
||||
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
|
||||
# ⚛️🔒 اصلاح 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)
|
||||
|
||||
UserLessonCompletion.objects.create(
|
||||
user_id=user_id,
|
||||
lesson=lesson,
|
||||
correct_count=correct_count,
|
||||
stars=stars,
|
||||
xp_earned=xp_earned,
|
||||
coins_earned=coins_earned,
|
||||
)
|
||||
stats, _ = UserStats.objects.get_or_create(user_id=user_id)
|
||||
stats.xp += xp_earned
|
||||
stats.coins += coins_earned
|
||||
# ⚛️ 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)
|
||||
|
||||
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'])
|
||||
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,
|
||||
'xp': stats.xp,
|
||||
'coins': stats.coins,
|
||||
'progressPercent': stats.progress_percent if stats else 0,
|
||||
'xp': stats.xp if stats else 0,
|
||||
'coins': stats.coins if stats else 0,
|
||||
}
|
||||
|
||||
data = {
|
||||
|
||||
Reference in New Issue
Block a user