""" منطق تغییر 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 Lesson, Node, NodeProgress, UserLessonCompletion, UserNodeCompletion def _get_or_create_progress(user_id, node): """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 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 elif UserNodeCompletion.objects.filter(user_id=user_id, node=node).exists(): # آزمونِ مبحث قبلاً تمام شده؛ درسِ تازه نباید مبحث را به # in_progress برگرداند. progress.status = 'completed' progress.completed_at = progress.completed_at or 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 mark_node_completed_by_quiz(user_id, node): """پایانِ آزمونِ مبحث → NodeProgress همان مبحث completed می‌شود.""" with transaction.atomic(): progress, _created = _get_or_create_progress(user_id, node) old_status = progress.status now = timezone.now() progress.status = 'completed' progress.started_at = progress.started_at or now progress.completed_at = progress.completed_at or now progress.last_activity_at = now progress.save(update_fields=['status', 'started_at', 'completed_at', 'last_activity_at']) _emit_if_changed(progress, old_status, event='node_quiz_completed', user_id=user_id) return progress def quiz_completed_node_ids(user_id, node_ids): """از میانِ node_ids، آن‌هایی که کاربر آزمونشان را تمام کرده.""" if not node_ids: return set() return set( UserNodeCompletion.objects.filter(user_id=user_id, node_id__in=node_ids) .values_list('node_id', flat=True) ) def grade_progress_percent(user_id, grade_id): """ درصدِ پیشرفت در پایه‌ی خودِ کاربر: درس‌های تمام‌شده، به‌اضافه‌ی همه‌ی درس‌های مبحث‌هایی که آزمونشان تمام شده، تقسیم بر کلِ درس‌های پایه. قبلاً مخرج کلِ درس‌های همه‌ی پایه‌ها بود (۲٬۶۱۷ تا)، پس دانش‌آموزی که پایه‌ی خودش را کامل تمام می‌کرد هم حدودِ ۱۰٪ می‌دید. """ if not grade_id: return 0 lesson_ids = set( Lesson.objects.filter(node__unit__grade_id=grade_id).values_list('id', flat=True) ) if not lesson_ids: return 0 done = set( UserLessonCompletion.objects.filter(user_id=user_id, lesson_id__in=lesson_ids) .values_list('lesson_id', flat=True) ) quiz_nodes = UserNodeCompletion.objects.filter( user_id=user_id, node__unit__grade_id=grade_id, ).values_list('node_id', flat=True) done |= set(Lesson.objects.filter(node_id__in=quiz_nodes).values_list('id', flat=True)) return round(min(len(done) / len(lesson_ids), 1.0) * 100, 2) def next_node_after(node): """مبحثِ بعدی روی نقشه: بعدی در همان فصل، وگرنه اولینِ فصلِ بعد، وگرنه None.""" following = ( Node.objects.filter(unit=node.unit, order__gt=node.order).order_by('order', 'id').first() or Node.objects.filter(unit=node.unit, order=node.order, id__gt=node.id).order_by('id').first() ) if following: return following unit = node.unit next_unit_node = ( Node.objects.filter(unit__grade_id=unit.grade_id, unit__order__gt=unit.order) .order_by('unit__order', 'unit_id', 'order', 'id').first() ) return next_unit_node def node_progress_summary(node): """برای سریالایزر: تعداد کل Lessonهای یک Node (لازم برای lessonsTotal).""" return node.lessons.count()