112 lines
5.4 KiB
Python
112 lines
5.4 KiB
Python
"""
|
||
منطق تغییر status روی NodeProgress. دو نقطهی ورودی، هر دو از
|
||
content/receivers.py صدا زده میشن (نه مستقیم از views.py) تا اگه جای
|
||
دیگهای هم (مثلاً یک import/bulk-completion از ai_tutor) رکورد UserAnswer
|
||
یا UserLessonCompletion ساخته بشه، وضعیت Node هم خودکار بهروز بمونه —
|
||
دقیقاً همون الگوی signal-based که برای discount/subscription استفاده شده.
|
||
"""
|
||
from django.db import transaction
|
||
from django.utils import timezone
|
||
|
||
from statuslog.signals import status_changed
|
||
|
||
from .models import Node, 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 صدا بزنه (هر دو نقطهی
|
||
فراخوانی در همین فایل همینطورن).
|
||
"""
|
||
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):
|
||
if progress.status != old_status:
|
||
status_changed.send(
|
||
sender='content.NodeProgress', object_id=progress.id,
|
||
old_status=old_status, new_status=progress.status,
|
||
event=event, user_id=user_id,
|
||
)
|
||
|
||
|
||
def touch_node_progress_on_answer(user_id, question):
|
||
"""
|
||
وقتی کاربر به یک سوال پاسخ میده (چه درست چه غلط)، یعنی روی Node
|
||
مربوطه فعالیت داشته. اگه Node هنوز شروع نشده یا رها شده بوده،
|
||
به in_progress برمیگرده.
|
||
"""
|
||
node = question.lesson.node
|
||
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'])
|
||
|
||
_emit_if_changed(progress, old_status, event='answer_activity', user_id=user_id)
|
||
return progress
|
||
|
||
|
||
def update_node_progress_on_lesson_completion(user_id, lesson):
|
||
"""
|
||
وقتی یک Lesson تکمیل میشه، تعداد Lessonهای تکمیلشدهی Node رو
|
||
بهروز میکنه؛ اگه همهی Lessonهای Node تکمیل شده باشن، status
|
||
میره روی completed، وگرنه (یا اگه از abandoned برگشته) in_progress.
|
||
"""
|
||
node = lesson.node
|
||
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()
|
||
|
||
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'
|
||
|
||
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)
|
||
return progress
|
||
|
||
|
||
def node_progress_summary(node):
|
||
"""برای سریالایزر: تعداد کل Lessonهای یک Node (لازم برای lessonsTotal)."""
|
||
return node.lessons.count()
|