""" منطق تغییر 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.db.models import Count 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 playable_lesson_ids(lessons_with_counts) -> list[int]: """ [(lesson_id, تعدادِ سوال), ...] → idهای درس‌های سوال‌دار، به همان ترتیب. ۵۸۵ درس از ۲٬۶۲۴ (۲۰۲۶-۰۹-۲۴) هنوز سوال ندارند و اپ برایشان فقط «به‌زودی» نشان می‌دهد، پس هیچ‌وقت تمام نمی‌شوند. اگر شرطِ تمام شدنِ جزیره «همه‌ی درس‌ها» باشد، ۱۸۱ جزیره از ۲۶۹ هرگز تمام نمی‌شد. جزیره‌ای که هیچ درسِ سوال‌داری ندارد، همه‌ی درس‌هایش را نگه می‌دارد (رفتارِ قبلی). """ pairs = list(lessons_with_counts) playable = [lid for lid, count in pairs if count] return playable or [lid for lid, _count in pairs] def node_playable_lesson_ids(node) -> list[int]: return playable_lesson_ids( node.lessons.annotate(question_total=Count('questions')) .values_list('id', 'question_total') ) def verified_correct_count(user_id, *, lesson_id=None, node_id=None) -> int: """ تعدادِ سوال‌های متمایزی از این درس/مبحث که کاربر درست جواب داده و ثبت شده. «درست» یعنی *هر* جوابِ درست، حتی بعد از جوابِ غلط روی همان سوال. چون جوابِ غلط `correctOptionId` را برمی‌گرداند و تکرارِ جواب آزاد است، این عدد جلوی «جواب نداده امتیاز گرفتن» را می‌گیرد، نه «غلط زدن و بعد درست زدن». """ from .models import UserAnswer qs = UserAnswer.objects.filter(user_id=user_id, correct=True) if lesson_id is not None: qs = qs.filter(question__lesson_id=lesson_id) if node_id is not None: qs = qs.filter(question__lesson__node_id=node_id) return qs.values('question_id').distinct().count() 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 = node_playable_lesson_ids(node) 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(playable_lesson_ids( Lesson.objects.filter(node__unit__grade_id=grade_id) .annotate(question_total=Count('questions')).values_list('id', 'question_total') )) 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)) done &= lesson_ids 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()