import logging from django.utils import timezone from apps.course.models import Course, Participant, LessonCompletion, CourseLiveSession from apps.account.notification_service import create_and_send_notification from apps.chat.models import RoomMessage from apps.course.models.course import Course, extract_text_from_json, get_localized_field from apps.course.models.lesson import CourseLesson from apps.course.models.live_session import LiveSessionRecording from django.db.models import Q logger = logging.getLogger(__name__) from django.db.models.signals import post_save, post_delete, pre_save, m2m_changed from django.dispatch import receiver from django.core.cache import cache from django.contrib.auth import get_user_model UserModel = get_user_model() def sync_course_group_room(instance): title_str = extract_text_from_json(instance.title) first_professor = instance.professors.first() if first_professor: # Try to get an existing group room; if multiple exist, pick the first. try: room, created = RoomMessage.objects.get_or_create( course=instance, room_type=RoomMessage.RoomTypeChoices.GROUP, defaults={ "name": f"{title_str} - Group", "description": f"Group chat for course: {title_str}", "initiator": first_professor, }, ) except RoomMessage.MultipleObjectsReturned: # Fallback: get the earliest room and update it room = RoomMessage.objects.filter( course=instance, room_type=RoomMessage.RoomTypeChoices.GROUP, ).order_by("id").first() created = False if not created: # Update the existing room with latest info RoomMessage.objects.filter(id=room.id).update( name=f"{title_str} - Group", description=f"Group chat for course: {title_str}", initiator=first_professor, ) else: # No professor yet – just ensure the group room exists without initiator RoomMessage.objects.filter( course=instance, room_type=RoomMessage.RoomTypeChoices.GROUP, ).update( name=f"{title_str} - Group", description=f"Group chat for course: {title_str}", ) @receiver(post_save, sender=Course) def handle_room_message_for_course(sender, instance, created, **kwargs): title_str = extract_text_from_json(instance.title) first_professor = instance.professors.first() if created and not first_professor: return if created: # فقط برای موارد جدید اجرا شود RoomMessage.objects.create( name=f"{title_str} - Group", description=f"Group chat for course: {title_str}", initiator=first_professor, # استاد اول به‌عنوان سازنده اتاق course=instance, room_type=RoomMessage.RoomTypeChoices.GROUP ) else: # این بخش در زمان آپدیت دوره اجرا می‌شود # Find the existing group room for this course and update its details update_kwargs = { 'name': f"{title_str} - Group", 'description': f"Group chat for course: {title_str}", } if first_professor: update_kwargs['initiator'] = first_professor RoomMessage.objects.filter( course=instance, room_type=RoomMessage.RoomTypeChoices.GROUP ).update(**update_kwargs) @receiver(m2m_changed, sender=Course.professors.through) def handle_course_professors_changed(sender, instance, action, pk_set, **kwargs): if action not in {"post_add", "post_remove", "post_clear"}: return sync_course_group_room(instance) for professor in instance.professors.all(): professor.ensure_professor_profile() detail_cache_key = f"professor_detail_{professor.slug}" cache.delete(detail_cache_key) @receiver(post_save, sender=Course) def ensure_professor_role(sender, instance, **kwargs): for professor in instance.professors.all(): professor.ensure_professor_profile() @receiver([post_save, post_delete], sender=Course) def invalidate_professor_course_cache(sender, instance, **kwargs): """ Clears the cached professor detail page AND their course list whenever a course assigned to them is created, updated, or deleted. """ for professor in instance.professors.all(): detail_cache_key = f"professor_detail_{professor.slug}" cache.delete(detail_cache_key) # Optional: If you update a Professor's profile in the admin directly @receiver([post_save, post_delete], sender=UserModel) def invalidate_professor_profile_cache(sender, instance, **kwargs): if instance.user_type == UserModel.UserType.PROFESSOR: cache_key = f"professor_detail_{instance.slug}" cache.delete(cache_key) @receiver(post_save, sender=Course) def sync_course_chat_locks(sender, instance, **kwargs): """ Automatically locks/unlocks the related chat rooms when the admin toggles the chat locks on the Course page. """ # 1. Update the Group Chat RoomMessage.objects.filter( course=instance, room_type=RoomMessage.RoomTypeChoices.GROUP ).update(is_locked=instance.is_group_chat_locked) # 2. Update the Private Chats between the Professors and Students of this course # Get all student IDs enrolled in this course student_ids = instance.participants.values_list('student_id', flat=True) professor_ids = list(instance.professors.values_list('id', flat=True)) if student_ids and professor_ids: RoomMessage.objects.filter( room_type=RoomMessage.RoomTypeChoices.PRIVATE ).filter( # Find rooms where initiator is any professor and recipient is student, OR vice versa Q(initiator_id__in=professor_ids, recipient_id__in=student_ids) | Q(initiator_id__in=student_ids, recipient_id__in=professor_ids) ).update(is_locked=instance.is_professor_chat_locked) def _recalculate_course_lessons_count(course_id): Course.recalculate_lessons_count_for_course(course_id) def _sync_quiz_lesson_numbers(course_id): if not course_id: return from apps.quiz.models import Quiz from apps.course.models import CourseLesson active_lessons_count = CourseLesson.objects.filter( course_id=course_id, is_active=True ).filter( Q(chapter__isnull=True) | Q(chapter__is_active=True) ).count() # Update quizzes where lesson_number > active_lessons_count to 0 Quiz.objects.filter( course_id=course_id, lesson_number__gt=active_lessons_count ).update(lesson_number=0) @receiver(pre_save, sender=CourseLesson) def capture_previous_course_lesson_state(sender, instance, **kwargs): if not instance.pk: instance._previous_course_id = None instance._previous_is_active = None return previous = sender.objects.filter(pk=instance.pk).values('course_id', 'is_active').first() if previous: instance._previous_course_id = previous['course_id'] instance._previous_is_active = previous['is_active'] else: instance._previous_course_id = None instance._previous_is_active = None @receiver(post_save, sender=CourseLesson) def sync_lessons_count_on_course_lesson_save(sender, instance, **kwargs): affected_course_ids = {instance.course_id} previous_course_id = getattr(instance, '_previous_course_id', None) previous_is_active = getattr(instance, '_previous_is_active', None) if previous_course_id and previous_course_id != instance.course_id: affected_course_ids.add(previous_course_id) if previous_is_active is not None and previous_is_active != instance.is_active and previous_course_id: affected_course_ids.add(previous_course_id) for course_id in affected_course_ids: _recalculate_course_lessons_count(course_id) _sync_quiz_lesson_numbers(course_id) @receiver(post_delete, sender=CourseLesson) def sync_lessons_count_on_course_lesson_delete(sender, instance, **kwargs): _recalculate_course_lessons_count(instance.course_id) _sync_quiz_lesson_numbers(instance.course_id) @receiver(post_save, sender=LiveSessionRecording) def create_course_lesson_from_recording(sender, instance, **kwargs): """ Automatically creates a CourseChapter (if not exists) and CourseLesson whenever a LiveSessionRecording is saved with a valid file. """ if not instance.file: return # To avoid duplicate creation if save is called multiple times from apps.course.models.lesson import Lesson, CourseChapter, CourseLesson if Lesson.objects.filter(content_file=instance.file.name).exists(): return session = instance.session course = session.course # 1. Determine chapter title as recording date (YYYY-MM-DD) date_val = instance.created_at or timezone.now() recording_date_str = date_val.strftime('%Y-%m-%d') # 2. Get or create CourseChapter for this date from apps.course.models.course import extract_text_from_json chapter = None for ch in CourseChapter.objects.filter(course=course): if extract_text_from_json(ch.title) == recording_date_str: chapter = ch break if not chapter: chapter = CourseChapter.objects.create( course=course, title=recording_date_str, is_active=True ) # 3. Determine lesson title: use custom title or subject (with parts if multiple recordings exist) base_title = session.recording_title or session.subject # Get all recordings for this session, ordered by creation recordings = list(LiveSessionRecording.objects.filter(session=session).order_by('created_at', 'id')) recordings_count = len(recordings) if recordings_count <= 1: lesson_title = base_title else: try: current_index = recordings.index(instance) + 1 except ValueError: current_index = recordings_count lesson_title = f"{base_title} part {current_index}" # Retroactively rename the first lesson if this is a subsequent recording if current_index > 1: first_recording = recordings[0] if first_recording.file: first_lesson = Lesson.objects.filter(content_file=first_recording.file.name).first() if first_lesson: first_lesson_title = f"{base_title} part 1" first_lesson.title = first_lesson_title first_lesson.save(update_fields=['title']) # Also update corresponding CourseLesson CourseLesson.objects.filter(lesson=first_lesson).update(title=first_lesson_title) # 4. Calculate duration in minutes duration_minutes = 0 if instance.file_time: duration_minutes = int(instance.file_time.total_seconds() / 60) if duration_minutes == 0 and instance.file_time.total_seconds() > 0: duration_minutes = 1 # 5. Create the Lesson object lesson = Lesson.objects.create( title=lesson_title, content_type="video_file", content_file=instance.file, duration=duration_minutes ) # 6. Create the CourseLesson object linking it to the chapter course_lesson = CourseLesson.objects.create( course=course, chapter=chapter, lesson=lesson, title=lesson_title, is_active=True ) logger.info(f"✨ [Signal] Automatically created CourseLesson {course_lesson.id} ({lesson_title}) for course {course.id} from recording {instance.id}") @receiver(pre_save, sender=Participant) def store_participant_previous_active(sender, instance, **kwargs): if instance.pk: try: old = Participant.objects.get(pk=instance.pk) instance._previous_is_active = old.is_active except Participant.DoesNotExist: instance._previous_is_active = None else: instance._previous_is_active = None @receiver(post_save, sender=Participant) def notify_course_access_granted(sender, instance, created, **kwargs): if instance.is_active: was_activated = False if created: was_activated = True elif hasattr(instance, '_previous_is_active') and instance._previous_is_active is False: was_activated = True if was_activated: course_title_en = get_localized_field('en', instance.course.title) course_title_fa = get_localized_field('fa', instance.course.title) create_and_send_notification( user=instance.student, title_en="Course Access Granted", body_en=f"Your access to the course '{course_title_en}' has been activated.", title_fa="دسترسی به دوره فعال شد", body_fa=f"دسترسی شما به دوره «{course_title_fa}» فعال شد.", service='imam-javad', data={'type': 'course_access_granted', 'course_id': instance.course.id} ) @receiver(post_save, sender=LessonCompletion) def check_course_completion(sender, instance, created, **kwargs): if created: student = instance.student course_lesson = instance.course_lesson course = course_lesson.course if not course: return total_lessons_count = CourseLesson.objects.filter(course=course, is_active=True).count() if total_lessons_count > 0: completed_lessons_count = LessonCompletion.objects.filter( student=student, course_lesson__course=course, course_lesson__is_active=True ).count() if completed_lessons_count >= total_lessons_count: cache_key = f"notified_course_completed_{student.id}_{course.id}" if not cache.get(cache_key): cache.set(cache_key, True, timeout=86400 * 30) # 30 days course_title_en = get_localized_field('en', course.title) course_title_fa = get_localized_field('fa', course.title) create_and_send_notification( user=student, title_en="Course Completed", body_en=f"Congratulations! You have completed the course '{course_title_en}'.", title_fa="دوره به پایان رسید", body_fa=f"تبریک! شما دوره «{course_title_fa}» را با موفقیت به پایان رساندید.", service='imam-javad', data={'type': 'course_completed', 'course_id': course.id} ) @receiver(pre_save, sender=CourseLiveSession) def handle_live_session_pre_save(sender, instance, **kwargs): if instance.pk: try: old = CourseLiveSession.objects.get(pk=instance.pk) instance._previous_is_cancelled = old.is_cancelled instance._previous_started_at = old.started_at # Reset reminder_sent if rescheduled to a future time if old.started_at != instance.started_at: if instance.started_at > timezone.now(): instance.reminder_sent = False except CourseLiveSession.DoesNotExist: instance._previous_is_cancelled = None instance._previous_started_at = None else: instance._previous_is_cancelled = None instance._previous_started_at = None @receiver(post_save, sender=CourseLiveSession) def notify_live_session_changes(sender, instance, created, **kwargs): # Event 1: LIVE Class Cancelled if instance.is_cancelled: was_cancelled = False if created: was_cancelled = True elif hasattr(instance, '_previous_is_cancelled') and instance._previous_is_cancelled is False: was_cancelled = True if was_cancelled: participants = Participant.objects.filter(course=instance.course, is_active=True).select_related('student') course_title_en = get_localized_field('en', instance.course.title) course_title_fa = get_localized_field('fa', instance.course.title) for p in participants: create_and_send_notification( user=p.student, title_en="LIVE Class Cancelled", body_en=f"The live class '{instance.subject}' for '{course_title_en}' has been cancelled.", title_fa="کلاس زنده لغو شد", body_fa=f"کلاس زنده «{instance.subject}» برای دوره «{course_title_fa}» لغو شده است.", service='imam-javad', data={'type': 'live_class_cancelled', 'session_id': instance.id} ) return # Event 2: LIVE Class Rescheduled if not created and hasattr(instance, '_previous_started_at') and instance._previous_started_at is not None: if instance._previous_started_at != instance.started_at: if not instance.is_cancelled: participants = Participant.objects.filter(course=instance.course, is_active=True).select_related('student') course_title_en = get_localized_field('en', instance.course.title) course_title_fa = get_localized_field('fa', instance.course.title) for p in participants: create_and_send_notification( user=p.student, title_en="LIVE Class Rescheduled", body_en=f"The live class '{instance.subject}' for '{course_title_en}' has been rescheduled to {instance.started_at.strftime('%Y-%m-%d %H:%M')}.", title_fa="کلاس زنده تغییر زمان یافت", body_fa=f"زمان کلاس زنده «{instance.subject}» برای دوره «{course_title_fa}» به {instance.started_at.strftime('%Y-%m-%d %H:%M')} تغییر یافته است.", service='imam-javad', data={'type': 'live_class_rescheduled', 'session_id': instance.id} ) @receiver(pre_save, sender=LiveSessionRecording) def store_recording_previous_file(sender, instance, **kwargs): if instance.pk: try: old = LiveSessionRecording.objects.get(pk=instance.pk) instance._previous_file = old.file except LiveSessionRecording.DoesNotExist: instance._previous_file = None else: instance._previous_file = None @receiver(post_save, sender=LiveSessionRecording) def notify_recording_available(sender, instance, created, **kwargs): if instance.file: was_uploaded = False if created: was_uploaded = True elif hasattr(instance, '_previous_file') and not instance._previous_file: was_uploaded = True if was_uploaded: session = instance.session if not session: return participants = Participant.objects.filter(course=session.course, is_active=True).select_related('student') course_title_en = get_localized_field('en', session.course.title) course_title_fa = get_localized_field('fa', session.course.title) for p in participants: create_and_send_notification( user=p.student, title_en="LIVE Recording Available", body_en=f"The recording for '{session.subject}' in '{course_title_en}' is now available.", title_fa="ضبط کلاس زنده در دسترس است", body_fa=f"ضبط کلاس زنده «{session.subject}» برای دوره «{course_title_fa}» اکنون در آرشیو در دسترس است.", service='imam-javad', data={'type': 'live_recording_available', 'recording_id': instance.id} )