Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions django_email_learning/jobs/deliver_contents_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
5 changes: 5 additions & 0 deletions django_email_learning/ports/metric_recorder_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions django_email_learning/services/metrics_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions django_email_learning/templates/emails/assignment.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{% extends "emails/base.html" %}
{% load i18n %}

{% block content %}
<h2 class="email-title">{{ assignment.title }}</h2>

<div class="email-panel reminder-panel">
<p class="email-paragraph" style="margin-bottom: 10px;">{{ assignment.description }}</p>

{% if assignment.deadline_days > 0 %}
<p class="email-paragraph" style="margin-bottom: 0;">
{% blocktranslate %}Please submit your responses within:{% endblocktranslate %}
<br>
<span class="deadline-chip">{% blocktranslate with deadline_days=assignment.deadline_days %}{{ deadline_days }} days{% endblocktranslate %}</span>
</p>
{% endif %}
</div>

<p class="email-paragraph">{% translate "Please click the link below to access and submit your assignment:" %}</p>

<div class="email-cta-wrap assignment-cta-wrap">
<a href="{{ link }}" class="email-cta assignment-cta bg-brand">
{% translate "Submit Your Assignment" %}
</a>
</div>

<p class="email-muted-note">
{% blocktranslate %}If you wish to unsubscribe from this course, please <a href="{{ unsubscribe_link }}" class="color-brand" style="text-decoration: none;">click here</a>{% endblocktranslate %}.
</p>
{% endblock %}
12 changes: 12 additions & 0 deletions django_email_learning/templates/emails/assignment.txt
Original file line number Diff line number Diff line change
@@ -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 }}
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
{% extends "personalised/base.html" %}

{% load django_vite %}
{% block head_script %}
<link rel="modulepreload" crossorigin href="/static/assets/render-Hdkk0lMj.js">
<link rel="modulepreload" crossorigin href="/static/assets/modulepreload-polyfill-C2klXQaA.js">
<link rel="modulepreload" crossorigin href="/static/assets/CloudUpload-BcCKN_9D.js">
<link rel="modulepreload" crossorigin href="/static/assets/Alert-DubzxodY.js">
<link rel="modulepreload" crossorigin href="/static/assets/Box-B0Gps-B6.js">
<link rel="modulepreload" crossorigin href="/static/assets/Button-DeO-gBXX.js">
<link rel="modulepreload" crossorigin href="/static/assets/TextField-DzRDLqD_.js">
<link rel="modulepreload" crossorigin href="/static/assets/Layout-CQiSh1XG.js">
<script type="module" crossorigin src="/static/assets/assignment_public-BLhlfYWp.js"></script>
{% vite_asset 'personalised/assignment_public/Assignment.jsx' %}
{% endblock %}
7 changes: 5 additions & 2 deletions django_service/views.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -30,19 +30,21 @@ 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"]

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
)
Expand All @@ -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,
}
23 changes: 23 additions & 0 deletions tests/services/command_models/test_send_assignment_command.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions tests/services/test_metrics_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down