Browse Source

motivation notifications added

master
Mohsen Taba 2 weeks ago
parent
commit
ae59096315
  1. 57
      apps/account/tasks.py
  2. 151
      apps/account/tests/notifications/test_retention_notifications.py
  3. 47
      apps/course/signals.py
  4. 1
      apps/quiz/models/quiz.py
  5. 4
      config/settings/base.py
  6. 4
      config/settings/production.py

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

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

47
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):

1
apps/quiz/models/quiz.py

@ -99,3 +99,4 @@ class QuizRankUser(User):
verbose_name = _('Rank Quiz')
verbose_name_plural = _('Rank Quizzes')

4
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

4
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 = [

Loading…
Cancel
Save