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 %} +
{% 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" }}
+
{% translate "Please click the link below to access and 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: