You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

484 lines
20 KiB

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}
)