From b2ac99af65f7623099c1b4981a8b36fc1f99b007 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Wed, 8 Jul 2026 12:56:07 +0330 Subject: [PATCH] live classes notifications logic implemented --- .../notifications/test_live_notifications.py | 122 ++++++++++++++++++ apps/course/signals.py | 98 +++++++++++++- 2 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 apps/account/tests/notifications/test_live_notifications.py diff --git a/apps/account/tests/notifications/test_live_notifications.py b/apps/account/tests/notifications/test_live_notifications.py new file mode 100644 index 0000000..30c4da6 --- /dev/null +++ b/apps/account/tests/notifications/test_live_notifications.py @@ -0,0 +1,122 @@ +import datetime +from django.utils import timezone +from django.test import TestCase +from unittest.mock import patch +from django.core.files.uploadedfile import SimpleUploadedFile + +from dj_language.models import Language +from apps.account.models import StudentUser, Notification +from apps.course.models.course import Course, CourseCategory +from apps.course.models.participant import Participant +from apps.course.models.live_session import CourseLiveSession, LiveSessionRecording + +class LiveNotificationsFlowTests(TestCase): + def setUp(self): + # Create languages + self.lang_fa, _ = Language.objects.update_or_create( + code='fa', + defaults={'name': 'Persian', 'status': True, 'countries': []} + ) + self.lang_en, _ = Language.objects.update_or_create( + code='en', + defaults={'name': 'English', 'status': True, 'countries': []} + ) + + # Create category + self.category = CourseCategory.objects.create( + name=[{"title": "Category", "language_code": "en"}], + slug=[{"title": "category", "language_code": "en"}] + ) + + # Create student user + self.student = StudentUser.objects.create( + email='student_live@example.com', + username='student_live', + fullname='Live Student', + language=self.lang_fa, + fcm='token_live_student' + ) + + # Create Course + thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg') + self.course = Course.objects.create( + title=[ + {"title": "Course 1", "language_code": "en"}, + {"title": "دوره ۱", "language_code": "fa"} + ], + slug=[{"title": "course-1", "language_code": "en"}], + category=self.category, + thumbnail=thumbnail, + video_type=Course.VedioTypeChoices.YOUTUBE_LINK, + video_link='https://example.com/video', + is_online=False, + level=Course.LevelChoices.BEGINNER, + duration=10, + lessons_count=0, + description='Description', + short_description='Short description', + status=[{"title": "ongoing", "language_code": "en"}], + is_free=True, + ) + + # Enroll student in the course + self.participant = Participant.objects.create( + student=self.student, + course=self.course, + is_active=True + ) + + # Create Live Session + self.session = CourseLiveSession.objects.create( + course=self.course, + subject='Lesson One Live', + started_at=timezone.now() + datetime.timedelta(days=1), + is_cancelled=False, + reminder_sent=False + ) + + @patch('apps.account.notification_service.send_notification') + def test_live_class_cancelled_notification(self, mock_send): + # Event 1: LIVE Class Cancelled + self.session.is_cancelled = True + self.session.save() + + # Check DB notification for Persian student + notif = Notification.objects.filter(user=self.student, title="کلاس زنده لغو شد").first() + self.assertIsNotNone(notif) + self.assertIn("Lesson One Live", notif.message) + self.assertIn("دوره ۱", notif.message) + self.assertTrue(mock_send.called) + + @patch('apps.account.notification_service.send_notification') + def test_live_class_rescheduled_notification(self, mock_send): + # Event 2: LIVE Class Rescheduled + old_time = self.session.started_at + new_time = old_time + datetime.timedelta(hours=2) + + self.session.started_at = new_time + self.session.save() + + # Check DB notification + notif = Notification.objects.filter(user=self.student, title="کلاس زنده تغییر زمان یافت").first() + self.assertIsNotNone(notif) + self.assertIn("Lesson One Live", notif.message) + self.assertIn("دوره ۱", notif.message) + self.assertTrue(mock_send.called) + + @patch('apps.account.notification_service.send_notification') + def test_live_recording_available_notification(self, mock_send): + # Event 3: LIVE Recording Available + # Create a LiveSessionRecording with a dummy file + recording_file = SimpleUploadedFile('rec.mp4', b'video_data', content_type='video/mp4') + recording = LiveSessionRecording.objects.create( + session=self.session, + file=recording_file + ) + + # Check DB notification + notif = Notification.objects.filter(user=self.student, title="ضبط کلاس زنده در دسترس است").first() + self.assertIsNotNone(notif) + self.assertIn("Lesson One Live", notif.message) + self.assertIn("دوره ۱", notif.message) + self.assertTrue(mock_send.called) diff --git a/apps/course/signals.py b/apps/course/signals.py index 3730f52..771cc08 100644 --- a/apps/course/signals.py +++ b/apps/course/signals.py @@ -378,13 +378,107 @@ def check_course_completion(sender, instance, created, **kwargs): @receiver(pre_save, sender=CourseLiveSession) -def handle_live_session_reschedule(sender, instance, **kwargs): +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: - pass + 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} + ) +