Skip to content

Commit 321b3d8

Browse files
committed
feat: #396 send reminder for assignments
1 parent 7ce34c3 commit 321b3d8

11 files changed

Lines changed: 294 additions & 3 deletions

File tree

django_email_learning/jobs/send_reminders_job.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
from django_email_learning.models import ContentDelivery, DeliverySchedule
33
from django_email_learning.jobs.job_metrics import track_job_execution
44

5+
from django_email_learning.services.command_models.send_assignment_reminder_command import (
6+
SendAssignmentReminderCommand,
7+
)
58
from django_email_learning.services.metrics_service import MetricsService
69
from django_email_learning.models import JobExecution, JobName, JobStatus
710
from django_email_learning.services.command_models.send_quiz_reminder_command import (
@@ -71,9 +74,23 @@ def get_reminder_queue(self) -> DeliveryQueueProtocol:
7174

7275
def process_reminder(self, delivery_schedule: DeliverySchedule) -> None:
7376
try:
74-
command = SendQuizReminderCommand(
75-
delivery_schedule=delivery_schedule,
76-
)
77+
if delivery_schedule.delivery.course_content.quiz:
78+
command = SendQuizReminderCommand(
79+
delivery_schedule=delivery_schedule,
80+
)
81+
elif delivery_schedule.delivery.course_content.assignment:
82+
command = SendAssignmentReminderCommand( # type: ignore[assignment]
83+
delivery_schedule=delivery_schedule,
84+
)
85+
else:
86+
logger.error(
87+
f"Delivery with ID {delivery_schedule.delivery.id} has no associated quiz or assignment. Marking reminder as not applicable."
88+
)
89+
delivery_schedule.delivery.reminder_state = (
90+
ContentDelivery.ReminderStatus.NOT_APPLICABLE
91+
)
92+
delivery_schedule.delivery.save()
93+
return
7794
command.execute()
7895
delivery_schedule.delivery.reminder_state = (
7996
ContentDelivery.ReminderStatus.SENT

django_email_learning/personalised/api/views.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,10 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
171171
"text_submission": text_submission if text_submission else None,
172172
},
173173
)
174+
delivery.reminder_state = ContentDelivery.ReminderStatus.NOT_APPLICABLE
175+
delivery.valid_until = None
176+
delivery.remind_at = None
177+
delivery.save()
174178
if not created:
175179
submission.status = AssignmentSubmission.SubmissionStatus.PENDING_REVIEW
176180
submission.save()

django_email_learning/ports/metric_recorder_protocol.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ def quiz_reminder_sent(
1919
) -> None:
2020
...
2121

22+
def assignment_reminder_sent(
23+
self, course_slug: str, organization_id: int, assignment_id: int
24+
) -> None:
25+
...
26+
2227
def lesson_sent(
2328
self, course_slug: str, organization_id: int, lesson_id: int
2429
) -> None:
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from django_email_learning.services.command_models.abstract_command import (
2+
AbstractCommand,
3+
)
4+
from django_email_learning.models import ContentDelivery, DeliverySchedule
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+
from pydantic import ConfigDict
11+
from django.utils.translation import gettext as _
12+
from django.utils import timezone
13+
from django_email_learning.services.utils import mask_email
14+
15+
16+
class AssignmentNotFoundError(Exception):
17+
pass
18+
19+
20+
class SendAssignmentReminderCommand(AbstractCommand):
21+
command_name: Literal["send_assignment_reminder"] = "send_assignment_reminder"
22+
delivery_schedule: DeliverySchedule
23+
24+
model_config = ConfigDict(arbitrary_types_allowed=True)
25+
26+
def execute(self) -> None:
27+
metric_service = MetricsService()
28+
content = self.delivery_schedule.delivery.course_content
29+
if not content.assignment:
30+
raise AssignmentNotFoundError(
31+
f"CourseContent with ID {content.id} has no associated assignment"
32+
)
33+
email = self.delivery_schedule.delivery.enrollment.learner.email
34+
self.logger.info(
35+
f"Sending reminder for assignment with ID {content.assignment.id} to email {mask_email(email)}"
36+
)
37+
38+
assignment = content.assignment
39+
40+
subject = _("Reminder: Assignment '{assignment_title}' is due soon").format(
41+
assignment_title=assignment.title
42+
)
43+
context = {
44+
"assignment": assignment,
45+
"link": self.delivery_schedule.link,
46+
"unsubscribe_link": content.course.generate_unsubscribe_link(email),
47+
"deadline_time": self.delivery_schedule.delivery.valid_until,
48+
}
49+
payload = render_to_string("emails/assignment_reminder.txt", context)
50+
51+
email_service = EmailSenderService()
52+
email_message = EmailMultiAlternatives(
53+
subject=subject,
54+
body=payload,
55+
from_email=email_service.from_email,
56+
to=[email],
57+
)
58+
email_message.attach_alternative(
59+
render_to_string("emails/assignment_reminder.html", context), "text/html"
60+
)
61+
62+
try:
63+
email_service.send(email_message)
64+
self.delivery_schedule.delivery.remind_at = timezone.now()
65+
self.delivery_schedule.delivery.reminder_state = (
66+
ContentDelivery.ReminderStatus.SENT
67+
)
68+
self.delivery_schedule.delivery.save()
69+
metric_service.assignment_reminder_sent(
70+
course_slug=content.course.slug,
71+
organization_id=content.course.organization.id,
72+
assignment_id=assignment.id,
73+
)
74+
except Exception as e:
75+
self.logger.error(
76+
f"Failed to send assignment reminder for assignment with ID {assignment.id} to email {mask_email(email)}: {str(e)}"
77+
)
78+
raise e

django_email_learning/services/defaults/log_based_metric_recorder.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,19 @@ def quiz_reminder_sent(
5353
},
5454
)
5555

56+
def assignment_reminder_sent(
57+
self, course_slug: str, organization_id: int, assignment_id: int
58+
) -> None:
59+
logger.info(
60+
"Assignment reminder sent",
61+
extra={
62+
"metric": "assignment_reminder_sent",
63+
"course_slug": course_slug,
64+
"organization_id": organization_id,
65+
"assignment_id": assignment_id,
66+
},
67+
)
68+
5669
def lesson_sent(
5770
self, course_slug: str, organization_id: int, lesson_id: int
5871
) -> None:

django_email_learning/services/metrics_service.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ def quiz_reminder_sent(
4141
) -> None:
4242
self.metric_recorder.quiz_reminder_sent(course_slug, organization_id, quiz_id)
4343

44+
def assignment_reminder_sent(
45+
self, course_slug: str, organization_id: int, assignment_id: int
46+
) -> None:
47+
self.metric_recorder.assignment_reminder_sent(
48+
course_slug, organization_id, assignment_id
49+
)
50+
4451
def lesson_sent(
4552
self, course_slug: str, organization_id: int, lesson_id: int
4653
) -> None:
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{% extends "emails/base.html" %}
2+
{% load i18n %}
3+
{% load tz %}
4+
5+
{% block content %}
6+
<h2 class="email-title">{% blocktranslate with assignment_title=assignment.title %}Reminder For Assignment: {{assignment_title}}{% endblocktranslate %}</h2>
7+
8+
<p class="email-paragraph">{% translate "Hello there," %}</p>
9+
10+
<div class="email-panel reminder-panel">
11+
<p class="email-paragraph" style="margin-bottom: 10px;">{% translate "This is a friendly reminder to complete your assignment." %}</p>
12+
13+
{% if deadline_time %}
14+
<p class="email-paragraph" style="margin-bottom: 0;">
15+
{% blocktranslate %}The assignment link is valid until:{% endblocktranslate %}
16+
<br>
17+
<span class="deadline-chip">{{ deadline_time|timezone:"UTC"|date:"Y-m-d H:i e" }}</span>
18+
</p>
19+
{% endif %}
20+
</div>
21+
22+
<p class="email-paragraph">{% translate "Please click the link below to access and submit your assignment:" %}</p>
23+
24+
<div class="email-cta-wrap assignment-cta-wrap">
25+
<a href="{{ link }}" class="email-cta assignment-cta bg-brand">
26+
{% translate "Submit Your Assignment" %}
27+
</a>
28+
</div>
29+
30+
<p class="email-muted-note">
31+
{% 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 %}.
32+
</p>
33+
{% endblock %}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{% load i18n %}
2+
{% load tz %}
3+
{{ assignment.title }}
4+
5+
{% translate "Hello there," %}
6+
7+
{% 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 %}
8+
9+
{% translate "Please click the link below to access and submit your assignment:" %}
10+
{{ link }}
11+
12+
13+
{% translate "Unsubscribe link:" %} {{ unsubscribe_link }}

django_service/views.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def get_template_names(self) -> list[str]:
3131
"password_reset",
3232
"quiz",
3333
"assignment",
34+
"assignment_reminder",
3435
"quiz_reminder",
3536
"deactivation_deadline_passed",
3637
]:

tests/jobs/test_send_reminders_job.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
SendQuizReminderCommand,
88
QuizNotFoundError,
99
)
10+
from django_email_learning.services.command_models.send_assignment_reminder_command import (
11+
SendAssignmentReminderCommand,
12+
)
1013
from django_email_learning.models import (
1114
ContentDelivery,
1215
DeliverySchedule,
@@ -162,3 +165,59 @@ def test_send_reminders_job_does_not_emit_start_or_finish_metrics_when_already_r
162165

163166
metric_started_spy.assert_not_called()
164167
metric_finished_spy.assert_not_called()
168+
169+
170+
def test_send_reminders_job_uses_quiz_reminder_command_for_quiz_content(
171+
db, reminder_queue_mock, enrollment, course_quiz_content
172+
):
173+
enrollment.status = EnrollmentStatus.ACTIVE
174+
enrollment.save()
175+
176+
delivery = ContentDelivery.objects.create(
177+
enrollment=enrollment,
178+
course_content=course_quiz_content,
179+
reminder_state=ContentDelivery.ReminderStatus.PENDING,
180+
)
181+
delivery_schedule = DeliverySchedule.objects.create(delivery=delivery)
182+
183+
reminder_queue_mock.add_task(delivery_schedule)
184+
185+
with patch.object(
186+
SendQuizReminderCommand, "execute", return_value=None
187+
) as quiz_execute, patch.object(
188+
SendAssignmentReminderCommand, "execute", return_value=None
189+
) as assignment_execute:
190+
job = SendRemindersJob()
191+
job.run()
192+
193+
quiz_execute.assert_called_once()
194+
assignment_execute.assert_not_called()
195+
196+
197+
def test_send_reminders_job_uses_assignment_reminder_command_for_assignment_content(
198+
db, reminder_queue_mock, enrollment, course_assignment_content
199+
):
200+
enrollment.status = EnrollmentStatus.ACTIVE
201+
enrollment.save()
202+
203+
delivery = ContentDelivery.objects.create(
204+
enrollment=enrollment,
205+
course_content=course_assignment_content,
206+
reminder_state=ContentDelivery.ReminderStatus.PENDING,
207+
)
208+
delivery_schedule = DeliverySchedule.objects.create(delivery=delivery)
209+
210+
reminder_queue_mock.add_task(delivery_schedule)
211+
212+
with patch.object(
213+
SendAssignmentReminderCommand, "execute", return_value=None
214+
) as assignment_execute, patch.object(
215+
SendQuizReminderCommand, "execute", return_value=None
216+
) as quiz_execute:
217+
job = SendRemindersJob()
218+
job.run()
219+
220+
assignment_execute.assert_called_once()
221+
quiz_execute.assert_not_called()
222+
delivery.refresh_from_db()
223+
assert delivery.reminder_state == ContentDelivery.ReminderStatus.SENT

0 commit comments

Comments
 (0)