diff --git a/apps/account/tasks.py b/apps/account/tasks.py index e1057f7..34e1a4f 100644 --- a/apps/account/tasks.py +++ b/apps/account/tasks.py @@ -341,6 +341,63 @@ def check_low_quiz_results_task(): ) +@shared_task +def check_inactive_students_task(): + """ + Finds active student users who have been registered for at least 7 days, + but have no login or lesson completions in the last 7 days, and sends an inactivity alert. + """ + from django.utils import timezone + from datetime import timedelta + from django.contrib.auth import get_user_model + from apps.course.models.lesson import LessonCompletion + from apps.account.notification_service import create_and_send_notification + from django.core.cache import cache + + User = get_user_model() + now = timezone.now() + cutoff_date = now - timedelta(days=7) + + # Active students registered >= 7 days ago + students = User.objects.filter( + user_type='student', + is_active=True, + date_joined__lte=cutoff_date + ) + + logger.info(f"[Inactivity Check] Checking {students.count()} students for inactivity...") + + for student in students: + # Check login activity in last 7 days + if student.last_login and student.last_login > cutoff_date: + continue + + # Check lesson completions in last 7 days + recent_completion = LessonCompletion.objects.filter( + student=student, + completed_at__gt=cutoff_date + ).exists() + + if recent_completion: + continue + + # Check cache to prevent spamming (limit to once every 7 days) + cache_key = f"notified_student_inactivity_{student.id}" + if not cache.get(cache_key): + cache.set(cache_key, True, timeout=86400 * 7) # 7-day cooldown + + create_and_send_notification( + user=student, + title_en="We Miss You!", + body_en="You haven't taken any lessons in the last week. Come back and continue your learning journey!", + title_fa="دلمان برایتان تنگ شده!", + body_fa="در یک هفته گذشته هیچ درسی را مطالعه نکرده‌اید. منتظرتان هستیم تا مسیر یادگیری خود را ادامه دهید!", + service='imam-javad', + data={'type': 'student_inactivity'} + ) + + + diff --git a/apps/account/tests/notifications/test_retention_notifications.py b/apps/account/tests/notifications/test_retention_notifications.py new file mode 100644 index 0000000..f6ca037 --- /dev/null +++ b/apps/account/tests/notifications/test_retention_notifications.py @@ -0,0 +1,151 @@ +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.lesson import CourseChapter, CourseLesson, Lesson, LessonCompletion +from apps.course.models.participant import Participant +from apps.course.models.live_session import CourseLiveSession, LiveSessionUser +from apps.account.tasks import check_inactive_students_task + +class RetentionNotificationsFlowTests(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_ret@example.com', + username='student_ret', + fullname='Ret Student', + language=self.lang_fa, + fcm='token_ret_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 + ) + + @patch('apps.account.notification_service.send_notification') + def test_inactive_student_inactivity_notification(self, mock_send): + # Event 1: Inactivity (Self-paced) + # Setup student to be registered 8 days ago with last_login 8 days ago + self.student.date_joined = timezone.now() - datetime.timedelta(days=8) + self.student.last_login = timezone.now() - datetime.timedelta(days=8) + self.student.save() + + # Run task + check_inactive_students_task() + + # Check DB notification + notif = Notification.objects.filter(user=self.student, title="دلمان برایتان تنگ شده!").first() + self.assertIsNotNone(notif) + self.assertTrue(mock_send.called) + + @patch('apps.account.notification_service.send_notification') + def test_active_student_no_inactivity_notification(self, mock_send): + # Date joined 8 days ago, but login 3 days ago + self.student.date_joined = timezone.now() - datetime.timedelta(days=8) + self.student.last_login = timezone.now() - datetime.timedelta(days=3) + self.student.save() + + check_inactive_students_task() + + self.assertFalse(Notification.objects.filter(user=self.student, title="دلمان برایتان تنگ شده!").exists()) + self.assertFalse(mock_send.called) + + @patch('apps.account.notification_service.send_notification') + def test_student_with_recent_lesson_completion_no_inactivity_notification(self, mock_send): + # Date joined 8 days ago, last login 8 days ago, but completed a lesson 2 days ago + self.student.date_joined = timezone.now() - datetime.timedelta(days=8) + self.student.last_login = timezone.now() - datetime.timedelta(days=8) + self.student.save() + + chapter = CourseChapter.objects.create(course=self.course, title='Chapter 1', priority=1, is_active=True) + l1 = Lesson.objects.create(title='Lesson 1', content_type='video_file', duration=5) + cl1 = CourseLesson.objects.create(course=self.course, chapter=chapter, lesson=l1, priority=1, is_active=True) + + # Complete lesson 2 days ago + completion = LessonCompletion.objects.create(student=self.student, course_lesson=cl1) + # Manually force completed_at to be 2 days ago (since auto_now_add makes it now) + LessonCompletion.objects.filter(id=completion.id).update(completed_at=timezone.now() - datetime.timedelta(days=2)) + + check_inactive_students_task() + + self.assertFalse(Notification.objects.filter(user=self.student, title="دلمان برایتان تنگ شده!").exists()) + self.assertFalse(mock_send.called) + + @patch('apps.account.notification_service.send_notification') + def test_missed_live_sessions_notification(self, mock_send): + # Event 2: Missed LIVE Sessions + # Create session 1 (ended) + s1 = CourseLiveSession.objects.create( + course=self.course, + subject='Live Class One', + started_at=timezone.now() - datetime.timedelta(days=3), + ended_at=timezone.now() - datetime.timedelta(days=3, hours=2), + is_cancelled=False, + reminder_sent=True + ) + + # Create session 2 (ended just now) + s2 = CourseLiveSession.objects.create( + course=self.course, + subject='Live Class Two', + started_at=timezone.now() - datetime.timedelta(hours=2), + is_cancelled=False, + reminder_sent=True + ) + + # The student has not joined either s1 or s2 (no LiveSessionUser entries). + # Set ended_at to trigger the signal + s2.ended_at = timezone.now() + s2.save() + + # Check DB notification + notif = Notification.objects.filter(user=self.student, title="جای شما در کلاس زنده خالی بود!").first() + self.assertIsNotNone(notif) + self.assertIn("Course 1", notif.message) + self.assertTrue(mock_send.called) diff --git a/apps/course/signals.py b/apps/course/signals.py index 771cc08..daaae95 100644 --- a/apps/course/signals.py +++ b/apps/course/signals.py @@ -5,7 +5,7 @@ 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 apps.course.models.live_session import LiveSessionRecording, LiveSessionUser from django.db.models import Q logger = logging.getLogger(__name__) @@ -384,6 +384,7 @@ def handle_live_session_pre_save(sender, instance, **kwargs): old = CourseLiveSession.objects.get(pk=instance.pk) instance._previous_is_cancelled = old.is_cancelled instance._previous_started_at = old.started_at + instance._previous_ended_at = old.ended_at # Reset reminder_sent if rescheduled to a future time if old.started_at != instance.started_at: @@ -392,9 +393,11 @@ def handle_live_session_pre_save(sender, instance, **kwargs): except CourseLiveSession.DoesNotExist: instance._previous_is_cancelled = None instance._previous_started_at = None + instance._previous_ended_at = None else: instance._previous_is_cancelled = None instance._previous_started_at = None + instance._previous_ended_at = None @receiver(post_save, sender=CourseLiveSession) @@ -441,6 +444,48 @@ def notify_live_session_changes(sender, instance, created, **kwargs): data={'type': 'live_class_rescheduled', 'session_id': instance.id} ) + # Event 6.2: Missed LIVE Sessions + if instance.ended_at and not instance.is_cancelled: + was_ended = False + if created: + was_ended = True + elif hasattr(instance, '_previous_ended_at') and not instance._previous_ended_at: + was_ended = True + + if was_ended: + last_2_sessions = list(CourseLiveSession.objects.filter( + course=instance.course, + ended_at__isnull=False, + is_cancelled=False + ).order_by('-started_at')[:2]) + + if len(last_2_sessions) == 2: + # Find all active enrolled students of the course + 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: + student = p.student + + # Check if student joined either session + joined_latest = LiveSessionUser.objects.filter(session=last_2_sessions[0], user=student).exists() + joined_second_latest = LiveSessionUser.objects.filter(session=last_2_sessions[1], user=student).exists() + + if not joined_latest and not joined_second_latest: + # Prevent duplicate alert using cache + cache_key = f"notified_missed_consecutive_live_{student.id}_{last_2_sessions[0].id}_{last_2_sessions[1].id}" + if not cache.get(cache_key): + cache.set(cache_key, True, timeout=86400 * 30) # 30 days + create_and_send_notification( + user=student, + title_en="We Missed You at the LIVE Class!", + body_en=f"You missed the last 2 consecutive live broadcasts for '{course_title_en}'. You can check the recordings in the archive.", + title_fa="جای شما در کلاس زنده خالی بود!", + body_fa=f"شما ۲ کلاس زنده اخیر دوره «{course_title_fa}» را از دست داده‌اید. می‌توانید ویدئوی ضبط‌شده را در بخش آرشیو مشاهده کنید.", + service='imam-javad', + data={'type': 'missed_live_sessions', 'course_id': instance.course.id} + ) + @receiver(pre_save, sender=LiveSessionRecording) def store_recording_previous_file(sender, instance, **kwargs): diff --git a/apps/quiz/models/quiz.py b/apps/quiz/models/quiz.py index 4616ba3..5cc1587 100644 --- a/apps/quiz/models/quiz.py +++ b/apps/quiz/models/quiz.py @@ -98,4 +98,5 @@ class QuizRankUser(User): proxy = True verbose_name = _('Rank Quiz') verbose_name_plural = _('Rank Quizzes') + diff --git a/config/settings/base.py b/config/settings/base.py index 39db450..e97c08d 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -267,6 +267,10 @@ CELERY_BEAT_SCHEDULE = { 'task': 'apps.account.tasks.check_low_quiz_results_task', 'schedule': crontab(minute=0, hour=10), # Daily at 10:00 AM }, + 'check_inactive_students_daily': { + 'task': 'apps.account.tasks.check_inactive_students_task', + 'schedule': crontab(minute=0, hour=9), # Daily at 9:00 AM + }, } # Password validation diff --git a/config/settings/production.py b/config/settings/production.py index 772d478..79a9e78 100644 --- a/config/settings/production.py +++ b/config/settings/production.py @@ -39,6 +39,10 @@ CELERY_BEAT_SCHEDULE = { 'task': 'apps.account.tasks.check_low_quiz_results_task', 'schedule': crontab(minute=0, hour=10), # Daily at 10:00 AM }, + 'check_inactive_students_daily': { + 'task': 'apps.account.tasks.check_inactive_students_task', + 'schedule': crontab(minute=0, hour=9), # Daily at 9:00 AM + }, } # CORS_ALLOWED_ORIGINS = [