13 changed files with 625 additions and 2 deletions
-
56Mobile App Push Notifications.md
-
43apps/account/notification_service.py
-
121apps/account/tasks.py
-
1apps/account/tests/notifications/__init__.py
-
207apps/account/tests/notifications/test_notifications.py
-
3apps/certificate/apps.py
-
43apps/certificate/signals.py
-
28apps/course/migrations/0018_course_notified_new_registration_and_more.py
-
5apps/course/models/course.py
-
10apps/course/models/live_session.py
-
89apps/course/signals.py
-
13config/settings/base.py
-
8config/settings/production.py
@ -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| |
||||
@ -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 |
||||
@ -0,0 +1 @@ |
|||||
|
# Notifications tests package |
||||
@ -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) |
||||
@ -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} |
||||
|
) |
||||
@ -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'), |
||||
|
), |
||||
|
] |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue