diff --git a/django_email_learning/jobs/send_reminders_job.py b/django_email_learning/jobs/send_reminders_job.py index c3d8ac7..67e40f8 100644 --- a/django_email_learning/jobs/send_reminders_job.py +++ b/django_email_learning/jobs/send_reminders_job.py @@ -2,6 +2,9 @@ from django_email_learning.models import ContentDelivery, DeliverySchedule from django_email_learning.jobs.job_metrics import track_job_execution +from django_email_learning.services.command_models.send_assignment_reminder_command import ( + SendAssignmentReminderCommand, +) from django_email_learning.services.metrics_service import MetricsService from django_email_learning.models import JobExecution, JobName, JobStatus from django_email_learning.services.command_models.send_quiz_reminder_command import ( @@ -71,9 +74,23 @@ def get_reminder_queue(self) -> DeliveryQueueProtocol: def process_reminder(self, delivery_schedule: DeliverySchedule) -> None: try: - command = SendQuizReminderCommand( - delivery_schedule=delivery_schedule, - ) + if delivery_schedule.delivery.course_content.quiz: + command = SendQuizReminderCommand( + delivery_schedule=delivery_schedule, + ) + elif delivery_schedule.delivery.course_content.assignment: + command = SendAssignmentReminderCommand( # type: ignore[assignment] + delivery_schedule=delivery_schedule, + ) + else: + logger.error( + f"Delivery with ID {delivery_schedule.delivery.id} has no associated quiz or assignment. Marking reminder as not applicable." + ) + delivery_schedule.delivery.reminder_state = ( + ContentDelivery.ReminderStatus.NOT_APPLICABLE + ) + delivery_schedule.delivery.save() + return command.execute() delivery_schedule.delivery.reminder_state = ( ContentDelivery.ReminderStatus.SENT diff --git a/django_email_learning/migrations/0026_alter_assignmentsubmission_delivery.py b/django_email_learning/migrations/0026_alter_assignmentsubmission_delivery.py new file mode 100644 index 0000000..6918b26 --- /dev/null +++ b/django_email_learning/migrations/0026_alter_assignmentsubmission_delivery.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.4 on 2026-05-05 07:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("django_email_learning", "0025_alter_assignmentsubmission_status"), + ] + + operations = [ + migrations.AlterField( + model_name="assignmentsubmission", + name="delivery", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="assignment_submission", + to="django_email_learning.contentdelivery", + ), + ), + ] diff --git a/django_email_learning/models.py b/django_email_learning/models.py index 451d70a..4ec8e38 100644 --- a/django_email_learning/models.py +++ b/django_email_learning/models.py @@ -1110,7 +1110,7 @@ class SubmissionStatus(models.TextChoices): delivery = models.OneToOneField( ContentDelivery, on_delete=models.CASCADE, - related_name="assignment_submissions", + related_name="assignment_submission", unique=True, ) text_submission = models.TextField(null=True, blank=True) diff --git a/django_email_learning/personalised/api/views.py b/django_email_learning/personalised/api/views.py index 22b0b34..2a1b8b9 100644 --- a/django_email_learning/personalised/api/views.py +++ b/django_email_learning/personalised/api/views.py @@ -171,6 +171,10 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def] "text_submission": text_submission if text_submission else None, }, ) + delivery.reminder_state = ContentDelivery.ReminderStatus.NOT_APPLICABLE + delivery.valid_until = None + delivery.remind_at = None + delivery.save() if not created: submission.status = AssignmentSubmission.SubmissionStatus.PENDING_REVIEW submission.save() diff --git a/django_email_learning/platform/api/serializers.py b/django_email_learning/platform/api/serializers.py index 3341337..0468c03 100644 --- a/django_email_learning/platform/api/serializers.py +++ b/django_email_learning/platform/api/serializers.py @@ -12,6 +12,7 @@ from django.urls import reverse from django_email_learning.models import ( ApiKey, + AssignmentSubmission, ContentDelivery, CourseInstructor, DeliveryStatus, @@ -792,11 +793,19 @@ class EventType(enum.StrEnum): VERIFIED = "verified" DEACTIVATED = "deactivated" QUIZ_SUBMITED = "quiz_submitted" + ASSIGNMENT_SUBMITTED = "assignment_submitted" + ASSIGNMENT_REVIEWED = "assignment_reviewed" CONTENT_SENT = "content_sent" REMINDER_SENT = "reminder_sent" COURSE_COMPLETED = "course_completed" +class ReviewResult(enum.StrEnum): + APPROVED = "approved" + REJECTED = "rejected" + REQUESTING_CHANGES = "requesting_changes" + + class DeactivatedEvent(BaseModel): type: Literal[EventType.DEACTIVATED] = Field( default=EventType.DEACTIVATED, exclude=True @@ -816,6 +825,24 @@ class QuizSubmitedEvent(BaseModel): is_practice: bool +class AssignmentSubmitedEvent(BaseModel): + type: Literal[EventType.ASSIGNMENT_SUBMITTED] = Field( + default=EventType.ASSIGNMENT_SUBMITTED, exclude=True + ) + assignment_id: int + assignment_title: str + + +class AssignmentReviewdEvent(BaseModel): + type: Literal[EventType.ASSIGNMENT_REVIEWED] = Field( + default=EventType.ASSIGNMENT_REVIEWED, exclude=True + ) + assignment_id: int + assignment_title: str + review_result: ReviewResult + reviewed_by: str + + class ReminderSentEvent(BaseModel): type: Literal[EventType.REMINDER_SENT] = Field( default=EventType.REMINDER_SENT, exclude=True @@ -836,7 +863,7 @@ class ContentSentEvent(BaseModel): class Event(BaseModel): type: EventType timestamp: datetime - event_data: DeactivatedEvent | QuizSubmitedEvent | ContentSentEvent | ReminderSentEvent | None = Field( + event_data: DeactivatedEvent | QuizSubmitedEvent | ContentSentEvent | AssignmentSubmitedEvent | AssignmentReviewdEvent | ReminderSentEvent | None = Field( discriminator="type" ) # REGISTERED, VERIFIED, COURSE_COMPLETED have no additional data @@ -897,6 +924,35 @@ def from_django_model(enrollment: Enrollment) -> "EnrollmentResponse": ), ) ) + submission = delivery.assignment_submission # type: ignore[attr-defined] + if submission: + events.append( + Event( + type=EventType.ASSIGNMENT_SUBMITTED, + timestamp=submission.submitted_at, # type: ignore[arg-type] + event_data=AssignmentSubmitedEvent( + assignment_id=delivery.course_content.assignment.id, # type: ignore[union-attr] + assignment_title=delivery.course_content.assignment.title, # type: ignore[union-attr] + ), + ) + ) + if ( + submission.reviewed_at + and submission.status + != AssignmentSubmission.SubmissionStatus.PENDING_REVIEW + ): + events.append( + Event( + type=EventType.ASSIGNMENT_REVIEWED, + timestamp=submission.reviewed_at, # type: ignore[arg-type] + event_data=AssignmentReviewdEvent( + assignment_id=delivery.course_content.assignment.id, # type: ignore[union-attr] + assignment_title=delivery.course_content.assignment.title, # type: ignore[union-attr] + review_result=ReviewResult(submission.status), # type: ignore[union-attr] + reviewed_by=submission.reviewer.display_name, # type: ignore[union-attr, arg-type] + ), + ) + ) # TODO:events for reminders and submissions for assignments if delivery.course_content.type == "quiz": diff --git a/django_email_learning/platform/views.py b/django_email_learning/platform/views.py index 85b7235..ddfbccb 100644 --- a/django_email_learning/platform/views.py +++ b/django_email_learning/platform/views.py @@ -580,7 +580,15 @@ def get_locale_messages(self) -> Dict[str, str]: "learner_verified": _("Learner Verified Email"), "lesson_sent": _("Lesson Sent"), "quiz_sent": _("Quiz Sent"), + "assignment_sent": _("Assignment Sent"), "quiz_submitted": _("Quiz Submitted"), + "assignment_submitted": _("Assignment Submitted"), + "assignment_reviewed": _("Assignment Reviewed"), + "reviewed_by": _("Reviewed By"), + "assignment_title": _("Assignment Title"), + "requesting_changes": _("Requesting Changes"), + "approved": _("Approved"), + "rejected": _("Rejected"), "course_completed": _("Course Completed"), "learner_deactivated": _("Learner Deactivated"), "score": _("Score"), diff --git a/django_email_learning/ports/metric_recorder_protocol.py b/django_email_learning/ports/metric_recorder_protocol.py index abc15d9..60453e6 100644 --- a/django_email_learning/ports/metric_recorder_protocol.py +++ b/django_email_learning/ports/metric_recorder_protocol.py @@ -19,6 +19,11 @@ def quiz_reminder_sent( ) -> None: ... + def assignment_reminder_sent( + self, course_slug: str, organization_id: int, assignment_id: int + ) -> None: + ... + def lesson_sent( self, course_slug: str, organization_id: int, lesson_id: int ) -> None: diff --git a/django_email_learning/services/command_models/send_assignment_reminder_command.py b/django_email_learning/services/command_models/send_assignment_reminder_command.py new file mode 100644 index 0000000..2c17739 --- /dev/null +++ b/django_email_learning/services/command_models/send_assignment_reminder_command.py @@ -0,0 +1,78 @@ +from django_email_learning.services.command_models.abstract_command import ( + AbstractCommand, +) +from django_email_learning.models import ContentDelivery, DeliverySchedule +from django_email_learning.services.email_sender_service import EmailSenderService +from django_email_learning.services.metrics_service import MetricsService +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from typing import Literal +from pydantic import ConfigDict +from django.utils.translation import gettext as _ +from django.utils import timezone +from django_email_learning.services.utils import mask_email + + +class AssignmentNotFoundError(Exception): + pass + + +class SendAssignmentReminderCommand(AbstractCommand): + command_name: Literal["send_assignment_reminder"] = "send_assignment_reminder" + delivery_schedule: DeliverySchedule + + model_config = ConfigDict(arbitrary_types_allowed=True) + + def execute(self) -> None: + metric_service = MetricsService() + content = self.delivery_schedule.delivery.course_content + if not content.assignment: + raise AssignmentNotFoundError( + f"CourseContent with ID {content.id} has no associated assignment" + ) + email = self.delivery_schedule.delivery.enrollment.learner.email + self.logger.info( + f"Sending reminder for assignment with ID {content.assignment.id} to email {mask_email(email)}" + ) + + assignment = content.assignment + + subject = _("Reminder: Assignment '{assignment_title}' is due soon").format( + assignment_title=assignment.title + ) + context = { + "assignment": assignment, + "link": self.delivery_schedule.link, + "unsubscribe_link": content.course.generate_unsubscribe_link(email), + "deadline_time": self.delivery_schedule.delivery.valid_until, + } + payload = render_to_string("emails/assignment_reminder.txt", context) + + email_service = EmailSenderService() + email_message = EmailMultiAlternatives( + subject=subject, + body=payload, + from_email=email_service.from_email, + to=[email], + ) + email_message.attach_alternative( + render_to_string("emails/assignment_reminder.html", context), "text/html" + ) + + try: + email_service.send(email_message) + self.delivery_schedule.delivery.remind_at = timezone.now() + self.delivery_schedule.delivery.reminder_state = ( + ContentDelivery.ReminderStatus.SENT + ) + self.delivery_schedule.delivery.save() + metric_service.assignment_reminder_sent( + course_slug=content.course.slug, + organization_id=content.course.organization.id, + assignment_id=assignment.id, + ) + except Exception as e: + self.logger.error( + f"Failed to send assignment reminder for assignment with ID {assignment.id} to email {mask_email(email)}: {str(e)}" + ) + raise e diff --git a/django_email_learning/services/defaults/log_based_metric_recorder.py b/django_email_learning/services/defaults/log_based_metric_recorder.py index 2970d17..7a07a9e 100644 --- a/django_email_learning/services/defaults/log_based_metric_recorder.py +++ b/django_email_learning/services/defaults/log_based_metric_recorder.py @@ -53,6 +53,19 @@ def quiz_reminder_sent( }, ) + def assignment_reminder_sent( + self, course_slug: str, organization_id: int, assignment_id: int + ) -> None: + logger.info( + "Assignment reminder sent", + extra={ + "metric": "assignment_reminder_sent", + "course_slug": course_slug, + "organization_id": organization_id, + "assignment_id": assignment_id, + }, + ) + def lesson_sent( self, course_slug: str, organization_id: int, lesson_id: int ) -> None: diff --git a/django_email_learning/services/metrics_service.py b/django_email_learning/services/metrics_service.py index 502dd6c..ea799ae 100644 --- a/django_email_learning/services/metrics_service.py +++ b/django_email_learning/services/metrics_service.py @@ -41,6 +41,13 @@ def quiz_reminder_sent( ) -> None: self.metric_recorder.quiz_reminder_sent(course_slug, organization_id, quiz_id) + def assignment_reminder_sent( + self, course_slug: str, organization_id: int, assignment_id: int + ) -> None: + self.metric_recorder.assignment_reminder_sent( + course_slug, organization_id, assignment_id + ) + def lesson_sent( self, course_slug: str, organization_id: int, lesson_id: int ) -> None: diff --git a/django_email_learning/templates/emails/assignment_reminder.html b/django_email_learning/templates/emails/assignment_reminder.html new file mode 100644 index 0000000..c66398d --- /dev/null +++ b/django_email_learning/templates/emails/assignment_reminder.html @@ -0,0 +1,33 @@ +{% extends "emails/base.html" %} +{% load i18n %} +{% load tz %} + +{% block content %} +

{% blocktranslate with assignment_title=assignment.title %}Reminder For Assignment: {{assignment_title}}{% endblocktranslate %}

+ +

{% translate "Hello there," %}

+ +
+

{% translate "This is a friendly reminder to complete your assignment." %}

+ + {% if deadline_time %} +

+ {% blocktranslate %}The assignment link is valid until:{% endblocktranslate %} +
+ {{ deadline_time|timezone:"UTC"|date:"Y-m-d H:i e" }} +

+ {% endif %} +
+ +

{% translate "Please click the link below to access and submit your assignment:" %}

+ +
+ + {% translate "Submit Your Assignment" %} + +
+ +

+ {% blocktranslate %}If you wish to unsubscribe from this course, please click here{% endblocktranslate %}. +

+{% endblock %} diff --git a/django_email_learning/templates/emails/assignment_reminder.txt b/django_email_learning/templates/emails/assignment_reminder.txt new file mode 100644 index 0000000..fae09a7 --- /dev/null +++ b/django_email_learning/templates/emails/assignment_reminder.txt @@ -0,0 +1,13 @@ +{% load i18n %} +{% load tz %} +{{ assignment.title }} + +{% translate "Hello there," %} + +{% translate "This is a reminder to complete your assignment." %}{% if deadline_time %}{% blocktranslate with deadline=deadline_time|timezone:"UTC"|date:"Y-m-d H:i e" %}The assignment link is valid until {{ deadline }}.{% endblocktranslate %}{% endif %} + +{% translate "Please click the link below to access and submit your assignment:" %} +{{ link }} + + +{% translate "Unsubscribe link:" %} {{ unsubscribe_link }} diff --git a/django_service/views.py b/django_service/views.py index 0d23365..7eae63e 100644 --- a/django_service/views.py +++ b/django_service/views.py @@ -31,6 +31,7 @@ def get_template_names(self) -> list[str]: "password_reset", "quiz", "assignment", + "assignment_reminder", "quiz_reminder", "deactivation_deadline_passed", ]: diff --git a/frontend/platform/learners/Learners.jsx b/frontend/platform/learners/Learners.jsx index 575269f..db1b8b7 100644 --- a/frontend/platform/learners/Learners.jsx +++ b/frontend/platform/learners/Learners.jsx @@ -6,6 +6,8 @@ import AppRegistrationIcon from '@mui/icons-material/AppRegistration'; import HowToRegIcon from '@mui/icons-material/HowToReg'; import LibraryBooksIcon from '@mui/icons-material/LibraryBooks'; import BallotIcon from '@mui/icons-material/Ballot'; +import AssignmentIcon from '@mui/icons-material/Assignment'; +import AssignmentIndIcon from '@mui/icons-material/AssignmentInd'; import AssignmentReturnedIcon from '@mui/icons-material/AssignmentReturned'; import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; @@ -39,10 +41,13 @@ function Learners(initialQs="") { 'verified': {icon: , color: "#66bb6a", title: localeMessages["learner_verified"]}, 'content_sent_lesson': {icon: , color: "#00acc1", title: localeMessages["lesson_sent"]}, 'content_sent_quiz': {icon: , color: "#26a69a", title: localeMessages["quiz_sent"]}, + 'content_sent_assignment': {icon: , color: "#336eb7", title: localeMessages["assignment_sent"]}, 'quiz_submitted': {icon: , color: "#26a69a", title: localeMessages["quiz_submitted"]}, 'course_completed': {icon: , color: "#0097a7", title: localeMessages["course_completed"]}, 'deactivated': {icon: , color: "#b71c1c", title: localeMessages["learner_deactivated"]}, - "reminder_sent": {icon: , color: "#ae4ad6", title: localeMessages["reminder_sent"]}, + 'reminder_sent': {icon: , color: "#ae4ad6", title: localeMessages["reminder_sent"]}, + 'assignment_submitted': {icon: , color: "#23bca8", title: localeMessages["assignment_submitted"]}, + 'assignment_reviewed': {icon: , color: "#336eb7", title: localeMessages["assignment_reviewed"]}, }; @@ -97,6 +102,13 @@ function Learners(initialQs="") { {localeMessages["score"]}: {event.event_data.score} { event.event_data.is_practice ? : <>{localeMessages["result"]}: {event.event_data.is_passed ? <>{localeMessages["passed"]} : <> {localeMessages["failed"]}}} } + { event.type === "assignment_reviewed" && <> + {localeMessages["result"]}: {event.event_data.review_result === "approved" ? <>{localeMessages["approved"]} : event.event_data.review_result === "rejected" ? <>{localeMessages["rejected"]} : {localeMessages["requesting_changes"]}} + {localeMessages["reviewed_by"]}: {event.event_data.reviewed_by} + } + { event.type === "assignment_submitted" && <> + {localeMessages["assignment_title"]}: {event.event_data.assignment_title} + } { event.type === "content_sent" && <> {event.event_data.course_content_title} } diff --git a/tests/jobs/test_send_reminders_job.py b/tests/jobs/test_send_reminders_job.py index a42ee98..d2ad141 100644 --- a/tests/jobs/test_send_reminders_job.py +++ b/tests/jobs/test_send_reminders_job.py @@ -7,6 +7,9 @@ SendQuizReminderCommand, QuizNotFoundError, ) +from django_email_learning.services.command_models.send_assignment_reminder_command import ( + SendAssignmentReminderCommand, +) from django_email_learning.models import ( ContentDelivery, DeliverySchedule, @@ -162,3 +165,59 @@ def test_send_reminders_job_does_not_emit_start_or_finish_metrics_when_already_r metric_started_spy.assert_not_called() metric_finished_spy.assert_not_called() + + +def test_send_reminders_job_uses_quiz_reminder_command_for_quiz_content( + db, reminder_queue_mock, enrollment, course_quiz_content +): + enrollment.status = EnrollmentStatus.ACTIVE + enrollment.save() + + delivery = ContentDelivery.objects.create( + enrollment=enrollment, + course_content=course_quiz_content, + reminder_state=ContentDelivery.ReminderStatus.PENDING, + ) + delivery_schedule = DeliverySchedule.objects.create(delivery=delivery) + + reminder_queue_mock.add_task(delivery_schedule) + + with patch.object( + SendQuizReminderCommand, "execute", return_value=None + ) as quiz_execute, patch.object( + SendAssignmentReminderCommand, "execute", return_value=None + ) as assignment_execute: + job = SendRemindersJob() + job.run() + + quiz_execute.assert_called_once() + assignment_execute.assert_not_called() + + +def test_send_reminders_job_uses_assignment_reminder_command_for_assignment_content( + db, reminder_queue_mock, enrollment, course_assignment_content +): + enrollment.status = EnrollmentStatus.ACTIVE + enrollment.save() + + delivery = ContentDelivery.objects.create( + enrollment=enrollment, + course_content=course_assignment_content, + reminder_state=ContentDelivery.ReminderStatus.PENDING, + ) + delivery_schedule = DeliverySchedule.objects.create(delivery=delivery) + + reminder_queue_mock.add_task(delivery_schedule) + + with patch.object( + SendAssignmentReminderCommand, "execute", return_value=None + ) as assignment_execute, patch.object( + SendQuizReminderCommand, "execute", return_value=None + ) as quiz_execute: + job = SendRemindersJob() + job.run() + + assignment_execute.assert_called_once() + quiz_execute.assert_not_called() + delivery.refresh_from_db() + assert delivery.reminder_state == ContentDelivery.ReminderStatus.SENT diff --git a/tests/services/command_models/test_send_assignment_reminder_command.py b/tests/services/command_models/test_send_assignment_reminder_command.py new file mode 100644 index 0000000..fcbdfa4 --- /dev/null +++ b/tests/services/command_models/test_send_assignment_reminder_command.py @@ -0,0 +1,61 @@ +from django_email_learning.services.command_models.send_assignment_reminder_command import ( + SendAssignmentReminderCommand, +) +from django_email_learning.models import ( + ContentDelivery, + DeliverySchedule, + DeliveryStatus, +) +from django.core import mail +from django.utils import timezone +import datetime +import pytest + + +@pytest.fixture +def assignment_content_delivery(db, active_enrollment, course_assignment_content): + course_assignment_content.is_published = True + course_assignment_content.save() + + delivery = ContentDelivery.objects.create( + enrollment=active_enrollment, + course_content_id=course_assignment_content.id, + hash_value="testhash", + ) + delivery.delivery_schedules.add( + DeliverySchedule.objects.create( + status=DeliveryStatus.DELIVERED, delivery=delivery + ) + ) + return delivery + + +def test_send_assignment_reminder_command(db, assignment_content_delivery): + assignment_link = "https://example.com/assignment/token-123" + test_time = timezone.now() - datetime.timedelta(days=1) + assignment_content_delivery.remind_at = test_time + delivery_schedule = DeliverySchedule.objects.create( + delivery=assignment_content_delivery, + link=assignment_link, + ) + command = SendAssignmentReminderCommand( + delivery_schedule=delivery_schedule, + ) + + command.execute() + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert ( + email.subject + == f"Reminder: Assignment '{delivery_schedule.delivery.course_content.assignment.title}' is due soon" + ) + assert assignment_content_delivery.enrollment.learner.email in email.to + assert assignment_link in email.body + assert len(email.alternatives) == 1 + delivery_schedule.delivery.refresh_from_db() + assert ( + delivery_schedule.delivery.reminder_state == ContentDelivery.ReminderStatus.SENT + ) + assert delivery_schedule.delivery.remind_at is not None + assert delivery_schedule.delivery.remind_at > test_time