Update project

This commit is contained in:
2026-08-19 17:15:55 +03:30
parent 02d40e1238
commit f945f960c9
25 changed files with 1092 additions and 287 deletions

View File

@@ -10,6 +10,7 @@ commandه، نه receiver. باید periodic اجرا بشه (cron / celery beat
"""
from django.conf import settings
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils import timezone
from content.models import NodeProgress
@@ -28,15 +29,46 @@ class Command(BaseCommand):
)
count = 0
for progress in stale_qs:
old_status = progress.status
progress.status = 'abandoned'
progress.save(update_fields=['status'])
status_changed.send(
sender='content.NodeProgress', object_id=progress.id,
old_status=old_status, new_status='abandoned',
event='inactivity_timeout', user_id=progress.user_id,
)
# ⚛️ 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).
stale_ids = list(stale_qs.values_list('id', flat=True))
for progress_id in stale_ids:
with transaction.atomic():
progress = NodeProgress.objects.select_for_update().get(pk=progress_id)
if progress.status != 'in_progress' or progress.last_activity_at is None \
or progress.last_activity_at >= threshold:
continue
old_status = progress.status
progress.status = 'abandoned'
progress.save(update_fields=['status'])
status_changed.send(
sender='content.NodeProgress', object_id=progress.id,
old_status=old_status, new_status='abandoned',
event='inactivity_timeout', user_id=progress.user_id,
)
count += 1
self.stdout.write(self.style.SUCCESS(

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.17 on 2026-08-19 10:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0002_nodeprogress'),
]
operations = [
migrations.AddField(
model_name='userlessoncompletion',
name='idempotency_key',
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AddConstraint(
model_name='userlessoncompletion',
constraint=models.UniqueConstraint(condition=models.Q(('idempotency_key__isnull', False), models.Q(('idempotency_key', ''), _negated=True)), fields=('user_id', 'lesson', 'idempotency_key'), name='uniq_lesson_completion_idempotency_key'),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.17 on 2026-08-19 11:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('content', '0003_userlessoncompletion_idempotency_key_and_more'),
]
operations = [
migrations.AddField(
model_name='useranswer',
name='idempotency_key',
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AddConstraint(
model_name='useranswer',
constraint=models.UniqueConstraint(condition=models.Q(('idempotency_key__isnull', False), models.Q(('idempotency_key', ''), _negated=True)), fields=('user_id', 'question', 'idempotency_key'), name='uniq_user_answer_idempotency_key'),
),
]

View File

@@ -82,8 +82,27 @@ 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 می‌شن.
idempotency_key = models.CharField(max_length=100, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=['user_id', 'lesson', 'idempotency_key'],
condition=models.Q(idempotency_key__isnull=False) & ~models.Q(idempotency_key=''),
name='uniq_lesson_completion_idempotency_key',
),
]
def __str__(self):
return f"completion-user{self.user_id}-lesson{self.lesson_id}"
@@ -158,7 +177,32 @@ 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_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 واقعاً پر شده اعمال بشه، نه وقتی خالی/نال‌ه
# (وگرنه پاسخ‌های بدونِ کلید هم به اشتباه یکتا می‌شدن).
models.UniqueConstraint(
fields=['user_id', 'question', 'idempotency_key'],
condition=models.Q(idempotency_key__isnull=False) & ~models.Q(idempotency_key=''),
name='uniq_user_answer_idempotency_key',
),
]
def __str__(self):
return f"answer-user{self.user_id}-q{self.question_id}"

View File

@@ -5,6 +5,7 @@ content/receivers.py صدا زده می‌شن (نه مستقیم از views.py)
یا UserLessonCompletion ساخته بشه، وضعیت Node هم خودکار به‌روز بمونه —
دقیقاً همون الگوی signal-based که برای discount/subscription استفاده شده.
"""
from django.db import transaction
from django.utils import timezone
from statuslog.signals import status_changed
@@ -13,7 +14,28 @@ from .models import Node, NodeProgress, UserLessonCompletion
def _get_or_create_progress(user_id, node):
return NodeProgress.objects.get_or_create(user_id=user_id, node=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 صدا بزنه (هر دو نقطه‌ی
فراخوانی در همین فایل همین‌طورن).
"""
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
def _emit_if_changed(progress, old_status, event, user_id):
@@ -32,18 +54,19 @@ def touch_node_progress_on_answer(user_id, question):
به in_progress برمی‌گرده.
"""
node = question.lesson.node
progress, _created = _get_or_create_progress(user_id, node)
old_status = progress.status
now = timezone.now()
with transaction.atomic():
progress, _created = _get_or_create_progress(user_id, node)
old_status = progress.status
now = timezone.now()
if progress.status in ('not_started', 'abandoned'):
progress.status = 'in_progress'
if progress.started_at is None:
progress.started_at = now
progress.last_activity_at = now
progress.save(update_fields=['status', 'started_at', 'last_activity_at'])
if progress.status in ('not_started', 'abandoned'):
progress.status = 'in_progress'
if progress.started_at is None:
progress.started_at = now
progress.last_activity_at = now
progress.save(update_fields=['status', 'started_at', 'last_activity_at'])
_emit_if_changed(progress, old_status, event='answer_activity', user_id=user_id)
_emit_if_changed(progress, old_status, event='answer_activity', user_id=user_id)
return progress
@@ -54,31 +77,32 @@ def update_node_progress_on_lesson_completion(user_id, lesson):
میره روی completed، وگرنه (یا اگه از abandoned برگشته) in_progress.
"""
node = lesson.node
progress, _created = _get_or_create_progress(user_id, node)
old_status = progress.status
now = timezone.now()
with transaction.atomic():
progress, _created = _get_or_create_progress(user_id, node)
old_status = progress.status
now = timezone.now()
lesson_ids = list(node.lessons.values_list('id', flat=True))
completed_count = UserLessonCompletion.objects.filter(
user_id=user_id, lesson_id__in=lesson_ids
).values('lesson_id').distinct().count()
lesson_ids = list(node.lessons.values_list('id', flat=True))
completed_count = UserLessonCompletion.objects.filter(
user_id=user_id, lesson_id__in=lesson_ids
).values('lesson_id').distinct().count()
progress.lessons_completed_count = completed_count
if progress.started_at is None:
progress.started_at = now
progress.last_activity_at = now
progress.lessons_completed_count = completed_count
if progress.started_at is None:
progress.started_at = now
progress.last_activity_at = now
if lesson_ids and completed_count >= len(lesson_ids):
progress.status = 'completed'
progress.completed_at = now
else:
progress.status = 'in_progress'
if lesson_ids and completed_count >= len(lesson_ids):
progress.status = 'completed'
progress.completed_at = now
else:
progress.status = 'in_progress'
progress.save(update_fields=[
'lessons_completed_count', 'status', 'started_at', 'completed_at', 'last_activity_at',
])
progress.save(update_fields=[
'lessons_completed_count', 'status', 'started_at', 'completed_at', 'last_activity_at',
])
_emit_if_changed(progress, old_status, event='lesson_completed', user_id=user_id)
_emit_if_changed(progress, old_status, event='lesson_completed', user_id=user_id)
return progress

View File

@@ -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 = {