diff --git a/Mobile App Push Notifications.md b/Mobile App Push Notifications.md
new file mode 100644
index 0000000..58ab79a
--- /dev/null
+++ b/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:
- **1 course:** "Registration is open for: [Name]"
- **2-3 courses:** "Great news! Enrollment is open for: [Name 1], [Name 2], and others"
- **3+ courses:** "New directions are open at Imam al-Jawad School. Choose the right one for you!"
_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|
\ No newline at end of file
diff --git a/apps/account/notification_service.py b/apps/account/notification_service.py
new file mode 100644
index 0000000..6a3c2e9
--- /dev/null
+++ b/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
diff --git a/apps/account/tasks.py b/apps/account/tasks.py
index 3ea0628..04869e8 100644
--- a/apps/account/tasks.py
+++ b/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'])
+
+
+
diff --git a/apps/account/tests/notifications/__init__.py b/apps/account/tests/notifications/__init__.py
new file mode 100644
index 0000000..62a4bef
--- /dev/null
+++ b/apps/account/tests/notifications/__init__.py
@@ -0,0 +1 @@
+# Notifications tests package
diff --git a/apps/account/tests/notifications/test_notifications.py b/apps/account/tests/notifications/test_notifications.py
new file mode 100644
index 0000000..64d94e9
--- /dev/null
+++ b/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)
diff --git a/apps/certificate/apps.py b/apps/certificate/apps.py
index 6a8b903..5d62170 100644
--- a/apps/certificate/apps.py
+++ b/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
diff --git a/apps/certificate/signals.py b/apps/certificate/signals.py
new file mode 100644
index 0000000..d4ea86f
--- /dev/null
+++ b/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}
+ )
diff --git a/apps/course/migrations/0018_course_notified_new_registration_and_more.py b/apps/course/migrations/0018_course_notified_new_registration_and_more.py
new file mode 100644
index 0000000..064c1c8
--- /dev/null
+++ b/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'),
+ ),
+ ]
diff --git a/apps/course/models/course.py b/apps/course/models/course.py
index f5499ce..a812aca 100644
--- a/apps/course/models/course.py
+++ b/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')
diff --git a/apps/course/models/live_session.py b/apps/course/models/live_session.py
index da859a2..2e34f7b 100644
--- a/apps/course/models/live_session.py
+++ b/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"))
diff --git a/apps/course/signals.py b/apps/course/signals.py
index 401abac..3730f52 100644
--- a/apps/course/signals.py
+++ b/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
+
diff --git a/config/settings/base.py b/config/settings/base.py
index 00f228c..0cd537b 100644
--- a/config/settings/base.py
+++ b/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
diff --git a/config/settings/production.py b/config/settings/production.py
index 698f447..e9e5d82 100644
--- a/config/settings/production.py
+++ b/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 = [