Browse Source

Learning process notifications logic updated and added

master
Mohsen Taba 2 weeks ago
parent
commit
22b457a2e8
  1. 56
      Mobile App Push Notifications.md
  2. 43
      apps/account/notification_service.py
  3. 121
      apps/account/tasks.py
  4. 1
      apps/account/tests/notifications/__init__.py
  5. 207
      apps/account/tests/notifications/test_notifications.py
  6. 3
      apps/certificate/apps.py
  7. 43
      apps/certificate/signals.py
  8. 28
      apps/course/migrations/0018_course_notified_new_registration_and_more.py
  9. 5
      apps/course/models/course.py
  10. 10
      apps/course/models/live_session.py
  11. 89
      apps/course/signals.py
  12. 13
      config/settings/base.py
  13. 8
      config/settings/production.py

56
Mobile App Push Notifications.md

@ -0,0 +1,56 @@
## 1. Learning Process
|Event|Trigger Condition|Recipient|
|---|---|---|
|**Course Access Granted**|When access to the course is activated|Student|
|**LIVE Class Reminder**|2 hours and 15 minutes before the start (only if not cancelled/rescheduled)|Students enrolled in the course|
|**Course Completed**|When all lessons and mandatory conditions are met|Student|
|**Certificate Issued**|Immediately after the certificate is generated (auto or manual)|Student|
|**New Course Registration**|**Weekly check (Fridays)** for courses with "Registration" status published in the last 7 days:<br>**1 course:** "Registration is open for: [Name]"<br>**2-3 courses:** "Great news! Enrollment is open for: [Name 1], [Name 2], and others"<br>**3+ courses:** "New directions are open at Imam al-Jawad School. Choose the right one for you!"<br>_Note: Do not notify about the same course more than once._|All registered students|
---
## 2. Quizzes and Progress
|Event|Trigger Condition|Recipient|
|---|---|---|
|**Low Quiz Result**|2, 7, and 21 days after a score below the threshold, suggesting a retake|Student|
---
## 3. LIVE Classes
|Event|Trigger Condition|Recipient|
|---|---|---|
|**LIVE Class Cancelled**|Immediately after the teacher or admin cancels the session|All enrolled students|
|**LIVE Class Rescheduled**|Immediately after date or time change|All enrolled students|
|**LIVE Recording Available**|When the session recording is uploaded and available in the archive|All enrolled students|
---
## 4. Communication
|Event|Trigger Condition|Recipient|
|---|---|---|
|**Teacher Replied**|When a teacher replies in a private or course chat|Student (the recipient)|
|**Support Replied**|When a support agent replies to a ticket/chat|Student (the sender)|
---
## 5. Transactions and Finance
|Event|Trigger Condition|Recipient|
|---|---|---|
|**Payment Successful**|Immediately after payment confirmation|Student|
|**Payment Failed**|Immediately after a failed transaction attempt|Student|
|**Refund Completed**|After the refund is processed and confirmed|Student|
---
## 6. Retention and Motivation
|Event|Trigger Condition|Recipient|
|---|---|---|
|**Inactivity (Self-paced)**|If the student hasn't opened the app or taken lessons for 7+ days|Student|
|**Missed LIVE Sessions**|If the student missed 2 consecutive LIVE broadcasts|Student|
|**Unfinished Quiz**|If a quiz was started but not completed within 24 hours|Student|

43
apps/account/notification_service.py

@ -0,0 +1,43 @@
import asyncio
import logging
from apps.account.models import Notification
from apps.account.tasks import send_notification
logger = logging.getLogger(__name__)
def create_and_send_notification(user, title_en, body_en, title_fa, body_fa, service='imam-javad', data=None):
"""
Creates a Notification record in the database and sends a push notification to FCM.
Resolves localized title and body based on user.language.
"""
lang = getattr(user, 'language', None)
lang_code = lang.code if lang and hasattr(lang, 'code') else 'fa'
title = title_fa if lang_code == 'fa' else title_en
message = body_fa if lang_code == 'fa' else body_en
# Save to database
notif = Notification.objects.create(
user=user,
title=title,
message=message,
service=service
)
fcm_token = getattr(user, 'fcm', None)
if fcm_token:
try:
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if loop.is_running():
loop.create_task(send_notification([fcm_token], title, message, data))
else:
loop.run_until_complete(send_notification([fcm_token], title, message, data))
except Exception as e:
logger.error(f"Failed to send push notification via FCM: {e}")
return notif

121
apps/account/tasks.py

@ -158,4 +158,125 @@ def send_otp_code_whatsapp(phone_number, code):
time.sleep(2)
@shared_task
def check_live_class_reminders_task():
"""
Checks for any non-cancelled live classes starting in the next 2 hours and 15 minutes
and sends a reminder to all enrolled students.
"""
from django.utils import timezone
from datetime import timedelta
from apps.course.models import CourseLiveSession, Participant
from apps.course.models.course import get_localized_field
from apps.account.notification_service import create_and_send_notification
now = timezone.now()
sessions = CourseLiveSession.objects.filter(
started_at__gt=now,
started_at__lte=now + timedelta(hours=2, minutes=15),
reminder_sent=False,
is_cancelled=False
)
logger.info(f"[Live Session Reminder Check] Found {sessions.count()} sessions starting within 2h 15m.")
for session in sessions:
participants = Participant.objects.filter(course=session.course, is_active=True).select_related('student')
for p in participants:
student_user = p.student
course_title_en = get_localized_field('en', session.course.title)
course_title_fa = get_localized_field('fa', session.course.title)
create_and_send_notification(
user=student_user,
title_en="LIVE Class Reminder",
body_en=f"The live class for '{course_title_en}' starts in 2 hours and 15 minutes.",
title_fa="یادآوری کلاس زنده",
body_fa=f"کلاس زنده دوره «{course_title_fa}» تا ۲ ساعت و ۱۵ دقیقه دیگر شروع می‌شود.",
service='imam-javad',
data={'type': 'live_class_reminder', 'session_id': session.id}
)
session.reminder_sent = True
session.save(update_fields=['reminder_sent'])
@shared_task
def check_new_course_registration_task():
"""
Weekly check (Fridays) for courses with 'registering' status published in the last 7 days.
Notifies all registered students about new courses.
"""
from django.utils import timezone
from datetime import timedelta
from django.contrib.auth import get_user_model
from apps.course.models import Course
from apps.course.models.course import get_localized_field, normalize_status
from apps.account.notification_service import create_and_send_notification
User = get_user_model()
now = timezone.now()
candidate_courses = Course.objects.filter(
created_at__gte=now - timedelta(days=7),
notified_new_registration=False
)
new_courses = []
for course in candidate_courses:
if normalize_status(course.status) == 'registering':
new_courses.append(course)
if not new_courses:
logger.info("[New Course Registration Check] No new registering courses found in the last 7 days.")
return
logger.info(f"[New Course Registration Check] Found {len(new_courses)} new registering courses.")
if len(new_courses) == 1:
course = new_courses[0]
name_en = get_localized_field('en', course.title) or "New Course"
name_fa = get_localized_field('fa', course.title) or "دوره جدید"
title_en = "New Course Registration"
body_en = f"Registration is open for: {name_en}"
title_fa = "ثبت‌نام دوره جدید"
body_fa = f"ثبت‌نام برای دوره «{name_fa}» آغاز شد."
elif len(new_courses) <= 3:
names_en = ", ".join(get_localized_field('en', c.title) for c in new_courses[:-1]) + f", and {get_localized_field('en', new_courses[-1].title)}"
names_fa = "، ".join(get_localized_field('fa', c.title) for c in new_courses[:-1]) + f" و {get_localized_field('fa', new_courses[-1].title)}"
title_en = "New Courses Registration"
body_en = f"Great news! Enrollment is open for: {names_en}, and others"
title_fa = "ثبت‌نام دوره‌های جدید"
body_fa = f"خبر خوب! ثبت‌نام برای دوره‌های «{names_fa}» و دیگر دوره‌ها آغاز شد."
else:
title_en = "New Courses at Imam al-Jawad School"
body_en = "New directions are open at Imam al-Jawad School. Choose the right one for you!"
title_fa = "دوره‌های جدید در مدرسه امام جواد"
body_fa = "دوره‌های جدید در مدرسه امام جواد (ع) آغاز شده است. مسیر مناسب خود را انتخاب کنید!"
students = User.objects.filter(user_type='student', is_active=True)
logger.info(f"[New Course Registration Check] Notifying {students.count()} students.")
for student in students:
create_and_send_notification(
user=student,
title_en=title_en,
body_en=body_en,
title_fa=title_fa,
body_fa=body_fa,
service='imam-javad',
data={'type': 'new_course_registration'}
)
for course in new_courses:
course.notified_new_registration = True
course.save(update_fields=['notified_new_registration'])

1
apps/account/tests/notifications/__init__.py

@ -0,0 +1 @@
# Notifications tests package

207
apps/account/tests/notifications/test_notifications.py

@ -0,0 +1,207 @@
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, User, 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
from apps.certificate.models import Certificate
from apps.account.tasks import check_live_class_reminders_task, check_new_course_registration_task
class NotificationFlowTests(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_test@example.com',
username='student_test',
fullname='Test Student',
language=self.lang_fa, # Persian template check
fcm='token_student'
)
# Create English student user for template check
self.student_en = StudentUser.objects.create(
email='student_en@example.com',
username='student_en',
fullname='Test Student EN',
language=self.lang_en,
fcm='token_student_en'
)
def _create_course(self, slug, title_en, title_fa, status='ongoing'):
thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
return Course.objects.create(
title=[
{"title": title_en, "language_code": "en"},
{"title": title_fa, "language_code": "fa"}
],
slug=[{"title": slug, "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": status, "language_code": "en"},
{"title": status, "language_code": "ru"}
],
is_free=True,
)
@patch('apps.account.notification_service.send_notification')
def test_course_access_granted_notification(self, mock_send):
# Event 1: Course Access Granted
course = self._create_course('course-access', 'Course 1', 'دوره ۱')
# Create participant (active=True by default)
Participant.objects.create(student=self.student, course=course)
# Verify notification created in DB (in Persian)
notif_fa = Notification.objects.filter(user=self.student).first()
self.assertIsNotNone(notif_fa)
self.assertEqual(notif_fa.title, "دسترسی به دوره فعال شد")
self.assertIn("دوره ۱", notif_fa.message)
# Create participant for English student
Participant.objects.create(student=self.student_en, course=course)
notif_en = Notification.objects.filter(user=self.student_en).first()
self.assertIsNotNone(notif_en)
self.assertEqual(notif_en.title, "Course Access Granted")
self.assertIn("Course 1", notif_en.message)
# Verify send_notification was called
self.assertTrue(mock_send.called)
@patch('apps.account.notification_service.send_notification')
def test_live_class_reminder_notification(self, mock_send):
# Event 2: LIVE Class Reminder
course = self._create_course('course-live', 'Course 2', 'دوره ۲')
Participant.objects.create(student=self.student, course=course)
# Create a live session starting in 2 hours
session = CourseLiveSession.objects.create(
course=course,
subject='Live Lesson 1',
started_at=timezone.now() + datetime.timedelta(hours=2),
is_cancelled=False,
reminder_sent=False
)
# Run periodic task
check_live_class_reminders_task()
# Verify reminder sent
session.refresh_from_db()
self.assertTrue(session.reminder_sent)
notif = Notification.objects.filter(user=self.student, title="یادآوری کلاس زنده").first()
self.assertIsNotNone(notif)
self.assertIn("دوره ۲", notif.message)
# Test rescheduling resets reminder_sent
session.started_at = timezone.now() + datetime.timedelta(hours=5)
session.save()
session.refresh_from_db()
self.assertFalse(session.reminder_sent)
@patch('apps.account.notification_service.send_notification')
def test_course_completed_notification(self, mock_send):
# Event 3: Course Completed
course = self._create_course('course-complete', 'Course 3', 'دوره ۳')
Participant.objects.create(student=self.student, course=course)
chapter = CourseChapter.objects.create(
course=course,
title='Chapter 1',
priority=1,
is_active=True
)
# Add 2 lessons
l1 = Lesson.objects.create(title='Lesson 1', content_type='video_file', duration=5)
cl1 = CourseLesson.objects.create(course=course, chapter=chapter, lesson=l1, priority=1, is_active=True)
l2 = Lesson.objects.create(title='Lesson 2', content_type='video_file', duration=5)
cl2 = CourseLesson.objects.create(course=course, chapter=chapter, lesson=l2, priority=2, is_active=True)
# Complete first lesson
LessonCompletion.objects.create(student=self.student, course_lesson=cl1)
self.assertFalse(Notification.objects.filter(user=self.student, title="دوره به پایان رسید").exists())
# Complete second (last) lesson
LessonCompletion.objects.create(student=self.student, course_lesson=cl2)
# Verify notification triggered
self.assertTrue(Notification.objects.filter(user=self.student, title="دوره به پایان رسید").exists())
@patch('apps.account.notification_service.send_notification')
def test_certificate_issued_notification(self, mock_send):
# Event 4: Certificate Issued
course = self._create_course('course-cert', 'Course 4', 'دوره ۴')
cert = Certificate.objects.create(
student=self.student,
course=course,
status='pending'
)
# Verify no notification yet
self.assertFalse(Notification.objects.filter(user=self.student, title="گواهی صادر شد").exists())
# Approve certificate
cert.status = 'approved'
cert.save()
# Verify notification triggered
self.assertTrue(Notification.objects.filter(user=self.student, title="گواهی صادر شد").exists())
@patch('apps.account.notification_service.send_notification')
def test_new_course_registration_notification(self, mock_send):
# Event 5: New Course Registration
student_recipient = User.objects.create(
email='student_recipient@example.com',
username='student_recipient',
fullname='Recipient Student',
user_type='student',
is_active=True,
language=self.lang_fa,
fcm='token_recipient'
)
course = self._create_course('course-new', 'New Course A', 'دوره جدید الف', status='registering')
check_new_course_registration_task()
course.refresh_from_db()
self.assertTrue(course.notified_new_registration)
notif = Notification.objects.filter(user=student_recipient, title="ثبت‌نام دوره جدید").first()
self.assertIsNotNone(notif)
self.assertIn("دوره جدید الف", notif.message)

3
apps/certificate/apps.py

@ -4,3 +4,6 @@ from django.apps import AppConfig
class CertificateConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.certificate'
def ready(self):
import apps.certificate.signals

43
apps/certificate/signals.py

@ -0,0 +1,43 @@
import logging
from django.db.models.signals import pre_save, post_save
from django.dispatch import receiver
from apps.certificate.models import Certificate
from apps.account.notification_service import create_and_send_notification
from apps.course.models.course import extract_text_from_json, get_localized_field
logger = logging.getLogger(__name__)
@receiver(pre_save, sender=Certificate)
def store_certificate_previous_status(sender, instance, **kwargs):
if instance.pk:
try:
old = Certificate.objects.get(pk=instance.pk)
instance._previous_status = old.status
except Certificate.DoesNotExist:
instance._previous_status = None
else:
instance._previous_status = None
@receiver(post_save, sender=Certificate)
def notify_certificate_issued(sender, instance, created, **kwargs):
# Trigger when status becomes approved
if instance.status == 'approved':
was_approved = False
if created:
was_approved = True
elif hasattr(instance, '_previous_status') and instance._previous_status != 'approved':
was_approved = True
if was_approved:
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="Certificate Issued",
body_en=f"Your certificate for '{course_title_en}' has been issued.",
title_fa="گواهی صادر شد",
body_fa=f"گواهی پایان دوره شما برای «{course_title_fa}» صادر شد.",
service='imam-javad',
data={'type': 'certificate_issued', 'certificate_id': instance.id}
)

28
apps/course/migrations/0018_course_notified_new_registration_and_more.py

@ -0,0 +1,28 @@
# Generated by Django 5.2.12 on 2026-07-08 11:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0017_alter_coursecategory_options'),
]
operations = [
migrations.AddField(
model_name='course',
name='notified_new_registration',
field=models.BooleanField(default=False, help_text="Designates whether students have been notified of this course's registration.", verbose_name='New Registration Notified'),
),
migrations.AddField(
model_name='courselivesession',
name='is_cancelled',
field=models.BooleanField(default=False, help_text='Designates whether this live session has been cancelled.', verbose_name='Is Cancelled'),
),
migrations.AddField(
model_name='courselivesession',
name='reminder_sent',
field=models.BooleanField(default=False, help_text='Designates whether a start reminder has been sent for this session.', verbose_name='Reminder Sent'),
),
]

5
apps/course/models/course.py

@ -375,6 +375,11 @@ class Course(models.Model):
help_text=_('This field is automatically calculated based on the discount percentage.')
)
notified_new_registration = models.BooleanField(
default=False,
verbose_name=_("New Registration Notified"),
help_text=_("Designates whether students have been notified of this course's registration.")
)
is_group_chat_locked = models.BooleanField(
default=False,
verbose_name=_('Lock Group Chat')

10
apps/course/models/live_session.py

@ -112,6 +112,16 @@ class CourseLiveSession(models.Model):
null=True,
blank=True,
)
is_cancelled = models.BooleanField(
default=False,
verbose_name=_("Is Cancelled"),
help_text=_("Designates whether this live session has been cancelled.")
)
reminder_sent = models.BooleanField(
default=False,
verbose_name=_("Reminder Sent"),
help_text=_("Designates whether a start reminder has been sent for this session.")
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))

89
apps/course/signals.py

@ -1,8 +1,9 @@
import logging
from django.utils import timezone
from apps.course.models import Course
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
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
@ -303,3 +304,87 @@ def create_course_lesson_from_recording(sender, instance, **kwargs):
)
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_reschedule(sender, instance, **kwargs):
if instance.pk:
try:
old = CourseLiveSession.objects.get(pk=instance.pk)
if old.started_at != instance.started_at:
if instance.started_at > timezone.now():
instance.reminder_sent = False
except CourseLiveSession.DoesNotExist:
pass

13
config/settings/base.py

@ -252,6 +252,19 @@ CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
'check_live_class_reminders_every_10_mins': {
'task': 'apps.account.tasks.check_live_class_reminders_task',
'schedule': crontab(minute='*/10'),
},
'check_new_course_registration_every_friday': {
'task': 'apps.account.tasks.check_new_course_registration_task',
'schedule': crontab(minute=0, hour=12, day_of_week='friday'),
},
}
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators

8
config/settings/production.py

@ -27,6 +27,14 @@ CELERY_BEAT_SCHEDULE = {
'task': 'apps.tasrif.tasks.crawler_website_bonbast_rate_usd',
'schedule': crontab(minute=0, hour='*/1'), # اجرای هر ساعت یک‌بار
},
'check_live_class_reminders_every_10_mins': {
'task': 'apps.account.tasks.check_live_class_reminders_task',
'schedule': crontab(minute='*/10'),
},
'check_new_course_registration_every_friday': {
'task': 'apps.account.tasks.check_new_course_registration_task',
'schedule': crontab(minute=0, hour=12, day_of_week='friday'),
},
}
# CORS_ALLOWED_ORIGINS = [

Loading…
Cancel
Save