Skip to content

Commit 1fe4c8e

Browse files
committed
feat: #395 Send Assignment Email
1 parent 2553c8b commit 1fe4c8e

11 files changed

Lines changed: 219 additions & 12 deletions

File tree

django_email_learning/jobs/deliver_contents_job.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
SendQuizCommand,
99
QuizNotFoundError,
1010
)
11+
from django_email_learning.services.command_models.send_assignment_command import (
12+
SendAssignmentCommand,
13+
AssignmentNotFoundError,
14+
)
1115
from django_email_learning.jobs.job_metrics import track_job_execution
1216
from django_email_learning.services.metrics_service import MetricsService
1317
from django_email_learning.models import JobExecution, JobName, JobStatus
@@ -121,6 +125,24 @@ def process_delivery(self, delivery_schedule: DeliverySchedule) -> None:
121125
logger.info(
122126
f"Quiz content delivered for DeliverySchedule ID {delivery_schedule.id}. Next content scheduling is deferred until quiz completion."
123127
)
128+
elif (
129+
course_content.type == "assignment"
130+
and course_content.assignment is not None
131+
):
132+
is_delivered = self.send_assignment_content(delivery_schedule)
133+
134+
# For assignment we don't schedule next content automatically, because the scheduling should be done after assignment completion.
135+
if is_delivered:
136+
if not course_content.assignment.is_blocking:
137+
# reschedule next content immediately for non-blocking assignments, For blocking assignments, the next content will be scheduled after the submission approval.
138+
logger.info(
139+
f"Non-blocking assignment content delivered for DeliverySchedule ID {delivery_schedule.id}. Scheduling next content."
140+
)
141+
next_delivery = delivery_schedule.delivery.schedule_next_delivery()
142+
if next_delivery:
143+
logger.info(
144+
f"Scheduled next delivery {next_delivery.id} for enrollment {delivery_schedule.delivery.enrollment.id}"
145+
)
124146

125147
def send_lesson_content(self, delivery_schedule: DeliverySchedule) -> bool:
126148
if not delivery_schedule.delivery.course_content.lesson:
@@ -192,6 +214,44 @@ def send_quiz_content(self, delivery_schedule: DeliverySchedule) -> bool:
192214
self.handle_failed_delivery(delivery_schedule)
193215
return False
194216

217+
def send_assignment_content(self, delivery_schedule: DeliverySchedule) -> bool:
218+
if not delivery_schedule.delivery.course_content.assignment:
219+
delivery_schedule.status = DeliveryStatus.CANCELED
220+
delivery_schedule.save()
221+
logger.error(
222+
f"DeliverySchedule ID {delivery_schedule.id} has no associated assignment. Canceling the delivery."
223+
)
224+
return False
225+
226+
try:
227+
if not delivery_schedule.link:
228+
link = delivery_schedule.generate_link()
229+
delivery_schedule.link = link
230+
delivery_schedule.save()
231+
232+
command = SendAssignmentCommand(
233+
content_id=delivery_schedule.delivery.course_content.id,
234+
email=delivery_schedule.delivery.enrollment.learner.email,
235+
link=delivery_schedule.link,
236+
)
237+
command.execute()
238+
delivery_schedule.status = DeliveryStatus.DELIVERED
239+
delivery_schedule.save()
240+
241+
return True
242+
except AssignmentNotFoundError:
243+
logger.error(
244+
f"Assignment with ID {delivery_schedule.delivery.course_content.assignment.id} not found. Canceling the delivery."
245+
)
246+
delivery_schedule.status = DeliveryStatus.CANCELED
247+
delivery_schedule.save()
248+
except Exception as e:
249+
logger.exception(
250+
f"Failed to send assignment content for DeliverySchedule ID {delivery_schedule.id}: {str(e)}"
251+
)
252+
self.handle_failed_delivery(delivery_schedule)
253+
return False
254+
195255
def handle_failed_delivery(self, delivery_schedule: DeliverySchedule) -> None:
196256
# TODO: Implement custome metric logging for blocked deliveries and failed attempts.
197257
"""Handle a failed delivery by rescheduling or blocking it."""

django_email_learning/ports/metric_recorder_protocol.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ def user_enrolled_in_course(self, course_slug: str, organization_id: int) -> Non
99
def quiz_sent(self, course_slug: str, organization_id: int, quiz_id: int) -> None:
1010
...
1111

12+
def assignment_sent(
13+
self, course_slug: str, organization_id: int, assignment_id: int
14+
) -> None:
15+
...
16+
1217
def quiz_reminder_sent(
1318
self, course_slug: str, organization_id: int, quiz_id: int
1419
) -> None:
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from django_email_learning.services.command_models.abstract_command import (
2+
AbstractCommand,
3+
)
4+
from django_email_learning.models import CourseContent
5+
from django_email_learning.services.email_sender_service import EmailSenderService
6+
from django_email_learning.services.metrics_service import MetricsService
7+
from django.core.mail import EmailMultiAlternatives
8+
from django.template.loader import render_to_string
9+
from typing import Literal
10+
11+
from django_email_learning.services.utils import mask_email
12+
13+
14+
class AssignmentNotFoundError(Exception):
15+
pass
16+
17+
18+
class SendAssignmentCommand(AbstractCommand):
19+
command_name: Literal["send_assignment"] = "send_assignment"
20+
link: str
21+
email: str
22+
content_id: int
23+
24+
def execute(self) -> None:
25+
metric_service = MetricsService()
26+
content = CourseContent.objects.get(id=self.content_id)
27+
if not content.assignment:
28+
raise AssignmentNotFoundError(
29+
f"CourseContent with ID {self.content_id} has no associated assignment"
30+
)
31+
self.logger.info(
32+
f"Sending assignment with ID {content.assignment.id} to email {mask_email(self.email)}"
33+
)
34+
35+
assignment = content.assignment
36+
subject = assignment.title
37+
context = {
38+
"assignment": assignment,
39+
"link": self.link,
40+
"unsubscribe_link": content.course.generate_unsubscribe_link(self.email),
41+
}
42+
payload = render_to_string("emails/assignment.txt", context)
43+
44+
email_service = EmailSenderService()
45+
email_message = EmailMultiAlternatives(
46+
subject=subject,
47+
body=payload,
48+
from_email=email_service.from_email,
49+
to=[self.email],
50+
)
51+
email_message.attach_alternative(
52+
render_to_string("emails/assignment.html", context), "text/html"
53+
)
54+
55+
email_service.send(email_message)
56+
metric_service.assignment_sent(
57+
course_slug=content.course.slug,
58+
organization_id=content.course.organization.id,
59+
assignment_id=assignment.id,
60+
)

django_email_learning/services/defaults/log_based_metric_recorder.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,19 @@ def quiz_sent(self, course_slug: str, organization_id: int, quiz_id: int) -> Non
2727
},
2828
)
2929

30+
def assignment_sent(
31+
self, course_slug: str, organization_id: int, assignment_id: int
32+
) -> None:
33+
logger.info(
34+
"Assignment sent",
35+
extra={
36+
"metric": "assignment_sent",
37+
"course_slug": course_slug,
38+
"organization_id": organization_id,
39+
"assignment_id": assignment_id,
40+
},
41+
)
42+
3043
def quiz_reminder_sent(
3144
self, course_slug: str, organization_id: int, quiz_id: int
3245
) -> None:

django_email_learning/services/metrics_service.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ def user_enrolled_in_course(self, course_slug: str, organization_id: int) -> Non
2929
def quiz_sent(self, course_slug: str, organization_id: int, quiz_id: int) -> None:
3030
self.metric_recorder.quiz_sent(course_slug, organization_id, quiz_id)
3131

32+
def assignment_sent(
33+
self, course_slug: str, organization_id: int, assignment_id: int
34+
) -> None:
35+
self.metric_recorder.assignment_sent(
36+
course_slug, organization_id, assignment_id
37+
)
38+
3239
def quiz_reminder_sent(
3340
self, course_slug: str, organization_id: int, quiz_id: int
3441
) -> None:
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{% extends "emails/base.html" %}
2+
{% load i18n %}
3+
4+
{% block content %}
5+
<h2 class="email-title">{{ assignment.title }}</h2>
6+
7+
<div class="email-panel reminder-panel">
8+
<p class="email-paragraph" style="margin-bottom: 10px;">{{ assignment.description }}</p>
9+
10+
{% if assignment.deadline_days > 0 %}
11+
<p class="email-paragraph" style="margin-bottom: 0;">
12+
{% blocktranslate %}Please submit your responses within:{% endblocktranslate %}
13+
<br>
14+
<span class="deadline-chip">{% blocktranslate with deadline_days=assignment.deadline_days %}{{ deadline_days }} days{% endblocktranslate %}</span>
15+
</p>
16+
{% endif %}
17+
</div>
18+
19+
<p class="email-paragraph">{% translate "Please click the link below to access and submit your assignment:" %}</p>
20+
21+
<div class="email-cta-wrap assignment-cta-wrap">
22+
<a href="{{ link }}" class="email-cta assignment-cta bg-brand">
23+
{% translate "Submit Your Assignment" %}
24+
</a>
25+
</div>
26+
27+
<p class="email-muted-note">
28+
{% 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 %}.
29+
</p>
30+
{% endblock %}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{% load i18n %}
2+
{{ assignment.title }}
3+
4+
{{ assignment.description }}
5+
6+
{% if deadline_days > 0 %}{% blocktranslate with deadline_days=assignment.deadline_days %} You have {{ deadline_days }} days from today to submit your assignment.{% endblocktranslate %}{% endif %}
7+
8+
9+
{% translate "Please click the link below to access and submit your assignment:" %}
10+
{{ link }}
11+
12+
{% translate "Unsubscribe link:" %} {{ unsubscribe_link }}
Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,5 @@
11
{% extends "personalised/base.html" %}
2-
2+
{% load django_vite %}
33
{% block head_script %}
4-
<link rel="modulepreload" crossorigin href="/static/assets/render-Hdkk0lMj.js">
5-
<link rel="modulepreload" crossorigin href="/static/assets/modulepreload-polyfill-C2klXQaA.js">
6-
<link rel="modulepreload" crossorigin href="/static/assets/CloudUpload-BcCKN_9D.js">
7-
<link rel="modulepreload" crossorigin href="/static/assets/Alert-DubzxodY.js">
8-
<link rel="modulepreload" crossorigin href="/static/assets/Box-B0Gps-B6.js">
9-
<link rel="modulepreload" crossorigin href="/static/assets/Button-DeO-gBXX.js">
10-
<link rel="modulepreload" crossorigin href="/static/assets/TextField-DzRDLqD_.js">
11-
<link rel="modulepreload" crossorigin href="/static/assets/Layout-CQiSh1XG.js">
12-
<script type="module" crossorigin src="/static/assets/assignment_public-BLhlfYWp.js"></script>
4+
{% vite_asset 'personalised/assignment_public/Assignment.jsx' %}
135
{% endblock %}

django_service/views.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from django.views.generic import TemplateView
2-
from django_email_learning.models import Lesson, Quiz, CourseContent
2+
from django_email_learning.models import Assignment, Lesson, Quiz, CourseContent
33
from django_email_learning.platform.views import CourseView
44
from django_email_learning.platform.serializers import WebComponent
55
from django.utils import timezone
@@ -30,19 +30,21 @@ def get_template_names(self) -> list[str]:
3030
"lesson",
3131
"password_reset",
3232
"quiz",
33+
"assignment",
3334
"quiz_reminder",
3435
"deactivation_deadline_passed",
3536
]:
3637
raise ValueError(
3738
"Invalid template name. Allowed values are: 'certificate_form', 'enrollment_verified', "
38-
"'enrollment_verification', 'lesson', 'password_reset', 'quiz', 'quiz_reminder', 'deactivation_deadline_passed'."
39+
"'enrollment_verification', 'lesson', 'password_reset', 'quiz', 'assignment', 'quiz_reminder', 'deactivation_deadline_passed'."
3940
)
4041

4142
return [f"emails/{template_name}.html"]
4243

4344
def get_context_data(self, **kwargs): # type: ignore[no-untyped-def]
4445
lesson = Lesson.objects.first()
4546
quiz = Quiz.objects.first()
47+
assignment = Assignment.objects.first()
4648
content = (
4749
CourseContent.objects.filter(lesson=lesson).first() if lesson else None
4850
)
@@ -64,5 +66,6 @@ def get_context_data(self, **kwargs): # type: ignore[no-untyped-def]
6466
"token": "sampletoken",
6567
"progress": 40,
6668
"deadline_time": timezone.now(),
69+
"assignment": assignment,
6770
"next_content": content.get_next() if content else None,
6871
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from django_email_learning.services.command_models.send_assignment_command import (
2+
SendAssignmentCommand,
3+
)
4+
from django.core import mail
5+
6+
7+
def test_send_assignment_command(db, course_assignment_content):
8+
assignment_link = "https://example.com/assignment/token-123"
9+
command = SendAssignmentCommand(
10+
command_name="send_assignment",
11+
content_id=course_assignment_content.id,
12+
email="test@example.com",
13+
link=assignment_link,
14+
)
15+
16+
command.execute()
17+
18+
assert len(mail.outbox) == 1
19+
email = mail.outbox[0]
20+
assert email.subject == course_assignment_content.assignment.title
21+
assert "test@example.com" in email.to
22+
assert assignment_link in email.body
23+
assert len(email.alternatives) == 1

0 commit comments

Comments
 (0)