diff --git a/django_email_learning/jobs/deliver_contents_job.py b/django_email_learning/jobs/deliver_contents_job.py index 3676000..239cdbf 100644 --- a/django_email_learning/jobs/deliver_contents_job.py +++ b/django_email_learning/jobs/deliver_contents_job.py @@ -8,6 +8,10 @@ SendQuizCommand, QuizNotFoundError, ) +from django_email_learning.services.command_models.send_assignment_command import ( + SendAssignmentCommand, + AssignmentNotFoundError, +) from django_email_learning.jobs.job_metrics import track_job_execution from django_email_learning.services.metrics_service import MetricsService from django_email_learning.models import JobExecution, JobName, JobStatus @@ -121,6 +125,24 @@ def process_delivery(self, delivery_schedule: DeliverySchedule) -> None: logger.info( f"Quiz content delivered for DeliverySchedule ID {delivery_schedule.id}. Next content scheduling is deferred until quiz completion." ) + elif ( + course_content.type == "assignment" + and course_content.assignment is not None + ): + is_delivered = self.send_assignment_content(delivery_schedule) + + # For assignment we don't schedule next content automatically, because the scheduling should be done after assignment completion. + if is_delivered: + if not course_content.assignment.is_blocking: + # reschedule next content immediately for non-blocking assignments, For blocking assignments, the next content will be scheduled after the submission approval. + logger.info( + f"Non-blocking assignment content delivered for DeliverySchedule ID {delivery_schedule.id}. Scheduling next content." + ) + next_delivery = delivery_schedule.delivery.schedule_next_delivery() + if next_delivery: + logger.info( + f"Scheduled next delivery {next_delivery.id} for enrollment {delivery_schedule.delivery.enrollment.id}" + ) def send_lesson_content(self, delivery_schedule: DeliverySchedule) -> bool: if not delivery_schedule.delivery.course_content.lesson: @@ -192,6 +214,44 @@ def send_quiz_content(self, delivery_schedule: DeliverySchedule) -> bool: self.handle_failed_delivery(delivery_schedule) return False + def send_assignment_content(self, delivery_schedule: DeliverySchedule) -> bool: + if not delivery_schedule.delivery.course_content.assignment: + delivery_schedule.status = DeliveryStatus.CANCELED + delivery_schedule.save() + logger.error( + f"DeliverySchedule ID {delivery_schedule.id} has no associated assignment. Canceling the delivery." + ) + return False + + try: + if not delivery_schedule.link: + link = delivery_schedule.generate_link() + delivery_schedule.link = link + delivery_schedule.save() + + command = SendAssignmentCommand( + content_id=delivery_schedule.delivery.course_content.id, + email=delivery_schedule.delivery.enrollment.learner.email, + link=delivery_schedule.link, + ) + command.execute() + delivery_schedule.status = DeliveryStatus.DELIVERED + delivery_schedule.save() + + return True + except AssignmentNotFoundError: + logger.error( + f"Assignment with ID {delivery_schedule.delivery.course_content.assignment.id} not found. Canceling the delivery." + ) + delivery_schedule.status = DeliveryStatus.CANCELED + delivery_schedule.save() + except Exception as e: + logger.exception( + f"Failed to send assignment content for DeliverySchedule ID {delivery_schedule.id}: {str(e)}" + ) + self.handle_failed_delivery(delivery_schedule) + return False + def handle_failed_delivery(self, delivery_schedule: DeliverySchedule) -> None: # TODO: Implement custome metric logging for blocked deliveries and failed attempts. """Handle a failed delivery by rescheduling or blocking it.""" diff --git a/django_email_learning/ports/metric_recorder_protocol.py b/django_email_learning/ports/metric_recorder_protocol.py index 3cad634..abc15d9 100644 --- a/django_email_learning/ports/metric_recorder_protocol.py +++ b/django_email_learning/ports/metric_recorder_protocol.py @@ -9,6 +9,11 @@ def user_enrolled_in_course(self, course_slug: str, organization_id: int) -> Non def quiz_sent(self, course_slug: str, organization_id: int, quiz_id: int) -> None: ... + def assignment_sent( + self, course_slug: str, organization_id: int, assignment_id: int + ) -> None: + ... + def quiz_reminder_sent( self, course_slug: str, organization_id: int, quiz_id: int ) -> None: diff --git a/django_email_learning/services/command_models/send_assignment_command.py b/django_email_learning/services/command_models/send_assignment_command.py new file mode 100644 index 0000000..7a98546 --- /dev/null +++ b/django_email_learning/services/command_models/send_assignment_command.py @@ -0,0 +1,60 @@ +from django_email_learning.services.command_models.abstract_command import ( + AbstractCommand, +) +from django_email_learning.models import CourseContent +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 django_email_learning.services.utils import mask_email + + +class AssignmentNotFoundError(Exception): + pass + + +class SendAssignmentCommand(AbstractCommand): + command_name: Literal["send_assignment"] = "send_assignment" + link: str + email: str + content_id: int + + def execute(self) -> None: + metric_service = MetricsService() + content = CourseContent.objects.get(id=self.content_id) + if not content.assignment: + raise AssignmentNotFoundError( + f"CourseContent with ID {self.content_id} has no associated assignment" + ) + self.logger.info( + f"Sending assignment with ID {content.assignment.id} to email {mask_email(self.email)}" + ) + + assignment = content.assignment + subject = assignment.title + context = { + "assignment": assignment, + "link": self.link, + "unsubscribe_link": content.course.generate_unsubscribe_link(self.email), + } + payload = render_to_string("emails/assignment.txt", context) + + email_service = EmailSenderService() + email_message = EmailMultiAlternatives( + subject=subject, + body=payload, + from_email=email_service.from_email, + to=[self.email], + ) + email_message.attach_alternative( + render_to_string("emails/assignment.html", context), "text/html" + ) + + email_service.send(email_message) + metric_service.assignment_sent( + course_slug=content.course.slug, + organization_id=content.course.organization.id, + assignment_id=assignment.id, + ) 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 7f9aa3d..2970d17 100644 --- a/django_email_learning/services/defaults/log_based_metric_recorder.py +++ b/django_email_learning/services/defaults/log_based_metric_recorder.py @@ -27,6 +27,19 @@ def quiz_sent(self, course_slug: str, organization_id: int, quiz_id: int) -> Non }, ) + def assignment_sent( + self, course_slug: str, organization_id: int, assignment_id: int + ) -> None: + logger.info( + "Assignment sent", + extra={ + "metric": "assignment_sent", + "course_slug": course_slug, + "organization_id": organization_id, + "assignment_id": assignment_id, + }, + ) + def quiz_reminder_sent( self, course_slug: str, organization_id: int, quiz_id: int ) -> None: diff --git a/django_email_learning/services/metrics_service.py b/django_email_learning/services/metrics_service.py index 4c7954b..502dd6c 100644 --- a/django_email_learning/services/metrics_service.py +++ b/django_email_learning/services/metrics_service.py @@ -29,6 +29,13 @@ def user_enrolled_in_course(self, course_slug: str, organization_id: int) -> Non def quiz_sent(self, course_slug: str, organization_id: int, quiz_id: int) -> None: self.metric_recorder.quiz_sent(course_slug, organization_id, quiz_id) + def assignment_sent( + self, course_slug: str, organization_id: int, assignment_id: int + ) -> None: + self.metric_recorder.assignment_sent( + course_slug, organization_id, assignment_id + ) + def quiz_reminder_sent( self, course_slug: str, organization_id: int, quiz_id: int ) -> None: diff --git a/django_email_learning/templates/emails/assignment.html b/django_email_learning/templates/emails/assignment.html new file mode 100644 index 0000000..8656d3a --- /dev/null +++ b/django_email_learning/templates/emails/assignment.html @@ -0,0 +1,30 @@ +{% extends "emails/base.html" %} +{% load i18n %} + +{% block content %} +
{{ assignment.description }}
+ + {% if assignment.deadline_days > 0 %} +
+ {% blocktranslate %}Please submit your responses within:{% endblocktranslate %}
+
+ {% blocktranslate with deadline_days=assignment.deadline_days %}{{ deadline_days }} days{% endblocktranslate %}
+
{% 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.txt b/django_email_learning/templates/emails/assignment.txt new file mode 100644 index 0000000..9d8009f --- /dev/null +++ b/django_email_learning/templates/emails/assignment.txt @@ -0,0 +1,12 @@ +{% load i18n %} +{{ assignment.title }} + +{{ assignment.description }} + +{% if deadline_days > 0 %}{% blocktranslate with deadline_days=assignment.deadline_days %} You have {{ deadline_days }} days from today to submit your assignment.{% endblocktranslate %}{% endif %} + + +{% translate "Please click the link below to access and submit your assignment:" %} +{{ link }} + +{% translate "Unsubscribe link:" %} {{ unsubscribe_link }} diff --git a/django_email_learning/templates/personalised/assignment_public.html b/django_email_learning/templates/personalised/assignment_public.html index 62b6eaa..6063f43 100644 --- a/django_email_learning/templates/personalised/assignment_public.html +++ b/django_email_learning/templates/personalised/assignment_public.html @@ -1,13 +1,5 @@ {% extends "personalised/base.html" %} - +{% load django_vite %} {% block head_script %} - - - - - - - - - + {% vite_asset 'personalised/assignment_public/Assignment.jsx' %} {% endblock %} diff --git a/django_service/views.py b/django_service/views.py index e9eda9b..0d23365 100644 --- a/django_service/views.py +++ b/django_service/views.py @@ -1,5 +1,5 @@ from django.views.generic import TemplateView -from django_email_learning.models import Lesson, Quiz, CourseContent +from django_email_learning.models import Assignment, Lesson, Quiz, CourseContent from django_email_learning.platform.views import CourseView from django_email_learning.platform.serializers import WebComponent from django.utils import timezone @@ -30,12 +30,13 @@ def get_template_names(self) -> list[str]: "lesson", "password_reset", "quiz", + "assignment", "quiz_reminder", "deactivation_deadline_passed", ]: raise ValueError( "Invalid template name. Allowed values are: 'certificate_form', 'enrollment_verified', " - "'enrollment_verification', 'lesson', 'password_reset', 'quiz', 'quiz_reminder', 'deactivation_deadline_passed'." + "'enrollment_verification', 'lesson', 'password_reset', 'quiz', 'assignment', 'quiz_reminder', 'deactivation_deadline_passed'." ) return [f"emails/{template_name}.html"] @@ -43,6 +44,7 @@ def get_template_names(self) -> list[str]: def get_context_data(self, **kwargs): # type: ignore[no-untyped-def] lesson = Lesson.objects.first() quiz = Quiz.objects.first() + assignment = Assignment.objects.first() content = ( CourseContent.objects.filter(lesson=lesson).first() if lesson else None ) @@ -64,5 +66,6 @@ def get_context_data(self, **kwargs): # type: ignore[no-untyped-def] "token": "sampletoken", "progress": 40, "deadline_time": timezone.now(), + "assignment": assignment, "next_content": content.get_next() if content else None, } diff --git a/tests/services/command_models/test_send_assignment_command.py b/tests/services/command_models/test_send_assignment_command.py new file mode 100644 index 0000000..b885e1c --- /dev/null +++ b/tests/services/command_models/test_send_assignment_command.py @@ -0,0 +1,23 @@ +from django_email_learning.services.command_models.send_assignment_command import ( + SendAssignmentCommand, +) +from django.core import mail + + +def test_send_assignment_command(db, course_assignment_content): + assignment_link = "https://example.com/assignment/token-123" + command = SendAssignmentCommand( + command_name="send_assignment", + content_id=course_assignment_content.id, + email="test@example.com", + link=assignment_link, + ) + + command.execute() + + assert len(mail.outbox) == 1 + email = mail.outbox[0] + assert email.subject == course_assignment_content.assignment.title + assert "test@example.com" in email.to + assert assignment_link in email.body + assert len(email.alternatives) == 1 diff --git a/tests/services/test_metrics_service.py b/tests/services/test_metrics_service.py index 384984a..d3ac359 100644 --- a/tests/services/test_metrics_service.py +++ b/tests/services/test_metrics_service.py @@ -89,6 +89,8 @@ def test_metrics_service_uses_configured_recorder_object_as_is(settings): ("user_completed_course", ("course-1", 11)), ("quiz_submitted", ("course-1", 11, 7, True, False)), ("method_executed", ("deliver_contents", 123)), + ("assignment_sent", ("course-1", 11, 5)), + ("assignment_submitted", ("course-1", 11, 5)), ], ) def test_metrics_service_delegates_all_calls(method_name, args):