diff --git a/web/asgi.py b/web/asgi.py
index 6f193d760..e1ffa47f0 100644
--- a/web/asgi.py
+++ b/web/asgi.py
@@ -22,7 +22,7 @@
from django.core.asgi import get_asgi_application
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
-# Initialize Django settings
+# Initialize Django settings to ensure models and routing can be imported without issues
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "web.settings")
django.setup()
diff --git a/web/assignment_reminders.py b/web/assignment_reminders.py
new file mode 100644
index 000000000..d5531527f
--- /dev/null
+++ b/web/assignment_reminders.py
@@ -0,0 +1,193 @@
+"""
+Assignment deadline reminder system for Alpha One Labs.
+
+Handles automatic sending of reminder emails to students when assignments
+are due soon. Uses Django signals to automatically create and manage reminders.
+"""
+
+import logging
+from datetime import timedelta
+
+from django.conf import settings
+from django.core.mail import send_mail
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from django.template.loader import render_to_string
+from django.utils import timezone
+
+from .models import CourseMaterial, Enrollment
+
+logger = logging.getLogger(__name__)
+
+
+class ReminderScheduler:
+ """Manages scheduling and sending of assignment deadline reminders."""
+
+ @staticmethod
+ def should_send_early_reminder(course_material):
+ """
+ Check if early reminder (24 hours before) should be sent.
+
+ Returns True if:
+ - Material has a due_date
+ - Reminder hasn't been sent yet
+ - Deadline is within 24-48 hours from now
+ """
+ if not course_material.due_date or course_material.reminder_sent:
+ return False
+
+ now = timezone.now()
+ time_until_due = course_material.due_date - now
+
+
+ return timedelta(hours=24) <= time_until_due <= timedelta(hours=48)
+
+ @staticmethod
+ def should_send_final_reminder(course_material):
+ """
+ Check if final reminder (on deadline day) should be sent.
+
+ Returns True if:
+ - Material has a due_date
+ - Final reminder hasn't been sent yet
+ - Deadline is within next 24 hours
+ """
+ if not course_material.due_date or course_material.final_reminder_sent:
+ return False
+
+ now = timezone.now()
+ time_until_due = course_material.due_date - now
+
+ # Send final reminder if deadline is within 24 hours
+ return timedelta(0) <= time_until_due <= timedelta(hours=24)
+
+ @staticmethod
+ def send_early_reminder(course_material):
+ """Send 24-hour before deadline reminder to all enrolled students."""
+ try:
+ course = course_material.course
+ enrollments = Enrollment.objects.filter(
+ course=course,
+ status="approved"
+ ).select_related("student", "student__profile")
+
+ for enrollment in enrollments:
+ student = enrollment.student
+ context = {
+ "student_name": student.first_name or student.username,
+ "assignment_title": course_material.title,
+ "course_title": course.title,
+ "due_date": course_material.due_date,
+ "description": course_material.description,
+ "hours_remaining": 24,
+ }
+
+ subject = f"Reminder: '{course_material.title}' is due in 24 hours"
+ html_message = render_to_string(
+ "emails/assignment_reminder_24h.html",
+ context
+ )
+
+ send_mail(
+ subject=subject,
+ message=strip_tags(html_message),
+ from_email=settings.DEFAULT_FROM_EMAIL,
+ recipient_list=[student.email],
+ html_message=html_message,
+ fail_silently=False,
+ )
+
+ logger.info(
+ f"24-hour reminder sent to {student.username} for "
+ f"assignment '{course_material.title}'"
+ )
+
+ # Mark reminder as sent
+ course_material.reminder_sent = True
+ course_material.save(update_fields=["reminder_sent"])
+ logger.info(f"Early reminder sent for {course_material.title}")
+
+ except Exception as e:
+ logger.error(
+ f"Error sending early reminder for {course_material.title}: {str(e)}"
+ )
+
+ @staticmethod
+ def send_final_reminder(course_material):
+ """Send final reminder on deadline day to all enrolled students."""
+ try:
+ course = course_material.course
+ enrollments = Enrollment.objects.filter(
+ course=course,
+ status="approved"
+ ).select_related("student", "student__profile")
+
+ for enrollment in enrollments:
+ student = enrollment.student
+ context = {
+ "student_name": student.first_name or student.username,
+ "assignment_title": course_material.title,
+ "course_title": course.title,
+ "due_date": course_material.due_date,
+ "description": course_material.description,
+ "is_final": True,
+ }
+
+ subject = f"FINAL REMINDER: '{course_material.title}' is due today!"
+ html_message = render_to_string(
+ "emails/assignment_reminder_final.html",
+ context
+ )
+
+ send_mail(
+ subject=subject,
+ message=strip_tags(html_message),
+ from_email=settings.DEFAULT_FROM_EMAIL,
+ recipient_list=[student.email],
+ html_message=html_message,
+ fail_silently=False,
+ )
+
+ logger.info(
+ f"Final reminder sent to {student.username} for "
+ f"assignment '{course_material.title}'"
+ )
+
+ # Mark final reminder as sent
+ course_material.final_reminder_sent = True
+ course_material.save(update_fields=["final_reminder_sent"])
+ logger.info(f"Final reminder sent for {course_material.title}")
+
+ except Exception as e:
+ logger.error(
+ f"Error sending final reminder for {course_material.title}: {str(e)}"
+ )
+
+
+@receiver(post_save, sender=CourseMaterial)
+def check_and_send_assignment_reminders(sender, instance, created, **kwargs):
+ """
+ Signal handler to check and send assignment reminders when CourseMaterial is saved.
+
+ This runs after every save of a CourseMaterial object and:
+ 1. Sends 24-hour early reminder if deadline is 24-48 hours away
+ 2. Sends final reminder if deadline is within 24 hours
+ """
+ # Only process if material is an assignment with a due date
+ if instance.material_type != "assignment" or not instance.due_date:
+ return
+
+ # Check and send early reminder
+ if ReminderScheduler.should_send_early_reminder(instance):
+ ReminderScheduler.send_early_reminder(instance)
+
+ # Check and send final reminder
+ if ReminderScheduler.should_send_final_reminder(instance):
+ ReminderScheduler.send_final_reminder(instance)
+
+
+def strip_tags(html):
+ """Utility function to strip HTML tags for plain text emails."""
+ import re
+ clean = re.compile('<.*?>')
+ return re.sub(clean, '', html)
diff --git a/web/signals.py b/web/signals.py
index a24995d54..6849e29e3 100644
--- a/web/signals.py
+++ b/web/signals.py
@@ -67,3 +67,7 @@ def invalidate_session_cache(sender, instance, **kwargs):
enrollments = Enrollment.objects.filter(course=instance.course)
for enrollment in enrollments:
invalidate_progress_cache(enrollment.student)
+
+
+# Import assignment reminder signal handlers
+from .assignment_reminders import check_and_send_assignment_reminders # noqa
diff --git a/web/templates/emails/assignment_reminder_24h.html b/web/templates/emails/assignment_reminder_24h.html
new file mode 100644
index 000000000..6448bf1d5
--- /dev/null
+++ b/web/templates/emails/assignment_reminder_24h.html
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
Hi {{ student_name }},
+
+
This is a reminder that you have an assignment due soon in {{ course_title }}.
+
+
+
Assignment: {{ assignment_title }}
+
Due: {{ due_date|date:"F j, Y \a\t g:i A" }}
+
+
+ {% if description %}
+
Details:
+
{{ description }}
+ {% endif %}
+
+
Make sure to submit your work before the deadline to avoid any late submissions.
+
+
Need help? Visit the course materials or reach out to your instructor.
+
+
+ View Assignment
+
+
+
+
+
+
diff --git a/web/templates/emails/assignment_reminder_final.html b/web/templates/emails/assignment_reminder_final.html
new file mode 100644
index 000000000..0309929a6
--- /dev/null
+++ b/web/templates/emails/assignment_reminder_final.html
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
🚨 FINAL REMINDER - DUE TODAY!
+
+
+
+
Hi {{ student_name }},
+
+
This is your final reminder that your assignment for {{ course_title }} is due TODAY.
+
+
+
Assignment: {{ assignment_title }}
+
Due: {{ due_date|date:"F j, Y \a\t g:i A" }}
+
+
+ {% if description %}
+
Assignment Details:
+
{{ description }}
+ {% endif %}
+
+
⚠️ Important: Please submit your work before the deadline. Late submissions may not be accepted.
+
+
If you're having any issues or need an extension, contact your instructor immediately.
+
+
+ Submit Your Assignment Now
+
+
+
+
+
+
diff --git a/web/tests/test_assignment_reminders.py b/web/tests/test_assignment_reminders.py
new file mode 100644
index 000000000..aeff6818c
--- /dev/null
+++ b/web/tests/test_assignment_reminders.py
@@ -0,0 +1,368 @@
+"""
+Tests for assignment deadline reminder system.
+
+Tests the automatic sending of reminder emails when assignments are due soon,
+using Django signals and the ReminderScheduler class.
+"""
+
+from datetime import timedelta
+from unittest.mock import patch, MagicMock
+
+from django.contrib.auth import get_user_model
+from django.core import mail
+from django.test import TestCase
+from django.utils import timezone
+
+from web.models import Course, CourseMaterial, Enrollment, Subject
+from web.assignment_reminders import ReminderScheduler
+
+User = get_user_model()
+
+
+class ReminderSchedulerTests(TestCase):
+ """Test the ReminderScheduler class logic."""
+
+ def setUp(self):
+ """Set up test data."""
+ self.subject = Subject.objects.create(
+ name="Computer Science",
+ slug="computer-science"
+ )
+
+ self.teacher = User.objects.create_user(
+ username="teacher",
+ email="teacher@example.com",
+ password="testpass123"
+ )
+ self.teacher.profile.is_teacher = True
+ self.teacher.profile.save()
+
+ self.course = Course.objects.create(
+ title="Python 101",
+ slug="python-101",
+ teacher=self.teacher,
+ description="Learn Python basics",
+ learning_objectives="Know Python",
+ price=99.99,
+ max_students=30,
+ subject=self.subject
+ )
+
+ self.student = User.objects.create_user(
+ username="student",
+ email="student@example.com",
+ password="testpass123"
+ )
+
+ self.enrollment = Enrollment.objects.create(
+ student=self.student,
+ course=self.course,
+ status="approved"
+ )
+
+ def test_should_send_early_reminder_when_due_in_24_hours(self):
+ """Test early reminder should be sent when deadline is 24-48 hours away."""
+ due_date = timezone.now() + timedelta(hours=36)
+ material = CourseMaterial.objects.create(
+ course=self.course,
+ title="Assignment 1",
+ material_type="assignment",
+ due_date=due_date
+ )
+
+ self.assertTrue(ReminderScheduler.should_send_early_reminder(material))
+
+ def test_should_not_send_early_reminder_when_already_sent(self):
+ """Test early reminder not sent if already marked as sent."""
+ due_date = timezone.now() + timedelta(hours=36)
+ material = CourseMaterial.objects.create(
+ course=self.course,
+ title="Assignment 1",
+ material_type="assignment",
+ due_date=due_date,
+ reminder_sent=True # Already sent
+ )
+
+ self.assertFalse(ReminderScheduler.should_send_early_reminder(material))
+
+ def test_should_not_send_early_reminder_without_due_date(self):
+ """Test early reminder not sent if no due date set."""
+ material = CourseMaterial.objects.create(
+ course=self.course,
+ title="Assignment 1",
+ material_type="assignment"
+ # No due_date
+ )
+
+ self.assertFalse(ReminderScheduler.should_send_early_reminder(material))
+
+ def test_should_not_send_early_reminder_when_too_far_away(self):
+ """Test early reminder not sent when deadline is more than 48 hours away."""
+ due_date = timezone.now() + timedelta(hours=60)
+ material = CourseMaterial.objects.create(
+ course=self.course,
+ title="Assignment 1",
+ material_type="assignment",
+ due_date=due_date
+ )
+
+ self.assertFalse(ReminderScheduler.should_send_early_reminder(material))
+
+ def test_should_send_final_reminder_when_due_today(self):
+ """Test final reminder should be sent when deadline is within 24 hours."""
+ due_date = timezone.now() + timedelta(hours=12)
+ material = CourseMaterial.objects.create(
+ course=self.course,
+ title="Assignment 1",
+ material_type="assignment",
+ due_date=due_date
+ )
+
+ self.assertTrue(ReminderScheduler.should_send_final_reminder(material))
+
+ def test_should_not_send_final_reminder_when_already_sent(self):
+ """Test final reminder not sent if already marked as sent."""
+ due_date = timezone.now() + timedelta(hours=12)
+ material = CourseMaterial.objects.create(
+ course=self.course,
+ title="Assignment 1",
+ material_type="assignment",
+ due_date=due_date,
+ final_reminder_sent=True # Already sent
+ )
+
+ self.assertFalse(ReminderScheduler.should_send_final_reminder(material))
+
+ def test_should_not_send_final_reminder_when_far_away(self):
+ """Test final reminder not sent when deadline is more than 24 hours away."""
+ due_date = timezone.now() + timedelta(hours=48)
+ material = CourseMaterial.objects.create(
+ course=self.course,
+ title="Assignment 1",
+ material_type="assignment",
+ due_date=due_date
+ )
+
+ self.assertFalse(ReminderScheduler.should_send_final_reminder(material))
+
+
+class AssignmentReminderSignalTests(TestCase):
+ """Test the Django signal that triggers reminder sending."""
+
+ def setUp(self):
+ """Set up test data."""
+ self.subject = Subject.objects.create(
+ name="Mathematics",
+ slug="mathematics"
+ )
+
+ self.teacher = User.objects.create_user(
+ username="math_teacher",
+ email="teacher@example.com",
+ password="testpass123"
+ )
+ self.teacher.profile.is_teacher = True
+ self.teacher.profile.save()
+
+ self.course = Course.objects.create(
+ title="Calculus 101",
+ slug="calculus-101",
+ teacher=self.teacher,
+ description="Learn calculus",
+ learning_objectives="Master calculus",
+ price=149.99,
+ max_students=25,
+ subject=self.subject
+ )
+
+ # Create multiple enrolled students
+ self.students = []
+ for i in range(3):
+ student = User.objects.create_user(
+ username=f"student{i}",
+ email=f"student{i}@example.com",
+ password="testpass123"
+ )
+ enrollment = Enrollment.objects.create(
+ student=student,
+ course=self.course,
+ status="approved"
+ )
+ self.students.append(student)
+
+ @patch('web.assignment_reminders.send_mail')
+ def test_early_reminder_sent_on_assignment_creation(self, mock_send_mail):
+ """Test that early reminder is sent when assignment created 36 hours before deadline."""
+ due_date = timezone.now() + timedelta(hours=36)
+
+ material = CourseMaterial.objects.create(
+ course=self.course,
+ title="Homework 1",
+ material_type="assignment",
+ description="Chapter 1-3 exercises",
+ due_date=due_date
+ )
+
+ # Check that an email was sent for each enrolled student
+ self.assertEqual(mock_send_mail.call_count, len(self.students))
+
+ # Check material was marked as reminder sent
+ material.refresh_from_db()
+ self.assertTrue(material.reminder_sent)
+
+ @patch('web.assignment_reminders.send_mail')
+ def test_final_reminder_sent_on_deadline_day(self, mock_send_mail):
+ """Test final reminder sent when creating assignment due within 24 hours."""
+ due_date = timezone.now() + timedelta(hours=6)
+
+ material = CourseMaterial.objects.create(
+ course=self.course,
+ title="Quiz",
+ material_type="assignment",
+ due_date=due_date
+ )
+
+ # Check that emails were sent
+ self.assertGreater(mock_send_mail.call_count, 0)
+
+ # Check material was marked as final reminder sent
+ material.refresh_from_db()
+ self.assertTrue(material.final_reminder_sent)
+
+ @patch('web.assignment_reminders.send_mail')
+ def test_no_reminder_for_non_assignment_materials(self, mock_send_mail):
+ """Test that reminders are not sent for non-assignment materials."""
+ due_date = timezone.now() + timedelta(hours=36)
+
+ # Create a video material with a due date (unusual but possible)
+ material = CourseMaterial.objects.create(
+ course=self.course,
+ title="Lecture Video",
+ material_type="video",
+ due_date=due_date
+ )
+
+ # No emails should be sent
+ mock_send_mail.assert_not_called()
+
+ @patch('web.assignment_reminders.send_mail')
+ def test_no_reminder_for_unapproved_enrollments(self, mock_send_mail):
+ """Test that reminders are not sent to unapproved enrollments."""
+ # Create an unapproved enrollment
+ pending_student = User.objects.create_user(
+ username="pending",
+ email="pending@example.com",
+ password="testpass123"
+ )
+ Enrollment.objects.create(
+ student=pending_student,
+ course=self.course,
+ status="pending" # Not approved
+ )
+
+ due_date = timezone.now() + timedelta(hours=36)
+ material = CourseMaterial.objects.create(
+ course=self.course,
+ title="Assignment",
+ material_type="assignment",
+ due_date=due_date
+ )
+
+ # Only the 3 approved students should receive emails
+ self.assertEqual(mock_send_mail.call_count, 3)
+
+ @patch('web.assignment_reminders.send_mail')
+ def test_reminder_email_contains_required_fields(self, mock_send_mail):
+ """Test that reminder emails contain required information."""
+ due_date = timezone.now() + timedelta(hours=36)
+
+ CourseMaterial.objects.create(
+ course=self.course,
+ title="Test Assignment",
+ material_type="assignment",
+ description="Test this material",
+ due_date=due_date
+ )
+
+ # Get the first call to send_mail
+ call_args = mock_send_mail.call_args
+
+ # Check that email contains assignment title
+ email_body = call_args.kwargs.get('message') or call_args[0][1]
+ self.assertIn('Test Assignment', email_body)
+
+
+class CourseMaterialRemindeFlagTests(TestCase):
+ """Test the reminder_sent and final_reminder_sent flags."""
+
+ def setUp(self):
+ """Set up test data."""
+ self.subject = Subject.objects.create(
+ name="Physics",
+ slug="physics"
+ )
+
+ self.teacher = User.objects.create_user(
+ username="professor",
+ email="prof@example.com",
+ password="testpass123"
+ )
+ self.teacher.profile.is_teacher = True
+ self.teacher.profile.save()
+
+ self.course = Course.objects.create(
+ title="Physics 101",
+ slug="physics-101",
+ teacher=self.teacher,
+ description="Learn physics",
+ learning_objectives="Understand physics laws",
+ price=199.99,
+ max_students=40,
+ subject=self.subject
+ )
+
+ def test_reminder_flags_start_as_false(self):
+ """Test that reminder flags are False by default."""
+ due_date = timezone.now() + timedelta(days=7)
+
+ material = CourseMaterial.objects.create(
+ course=self.course,
+ title="Lab Report",
+ material_type="assignment",
+ due_date=due_date
+ )
+
+ self.assertFalse(material.reminder_sent)
+ self.assertFalse(material.final_reminder_sent)
+
+ def test_reminder_flags_can_be_set_manually(self):
+ """Test that reminder flags can be manually set."""
+ due_date = timezone.now() + timedelta(days=7)
+
+ material = CourseMaterial.objects.create(
+ course=self.course,
+ title="Lab Report",
+ material_type="assignment",
+ due_date=due_date,
+ reminder_sent=True,
+ final_reminder_sent=True
+ )
+
+ self.assertTrue(material.reminder_sent)
+ self.assertTrue(material.final_reminder_sent)
+
+ def test_reminder_flag_updates_after_sending(self):
+ """Test that reminder flag is updated after sending reminder."""
+ due_date = timezone.now() + timedelta(hours=36)
+
+ with patch('web.assignment_reminders.send_mail'):
+ material = CourseMaterial.objects.create(
+ course=self.course,
+ title="Assignment",
+ material_type="assignment",
+ due_date=due_date
+ )
+
+ # After creation, should be marked as sent
+ material.refresh_from_db()
+ self.assertTrue(material.reminder_sent)