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 @@ + + + + + + +
+
⏰ Assignment Due in 24 Hours
+ +

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!
+ +
Last Chance to Submit Your Assignment
+ +

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)