Skip to content

Commit 0955221

Browse files
authored
Merge pull request #408 from AvaCodeSolutions/feat/396/assignment-reminder
Feat/396/assignment reminder
2 parents a618256 + 321b3d8 commit 0955221

16 files changed

Lines changed: 395 additions & 6 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
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 6.0.4 on 2026-05-05 07:37
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("django_email_learning", "0025_alter_assignmentsubmission_status"),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name="assignmentsubmission",
15+
name="delivery",
16+
field=models.OneToOneField(
17+
on_delete=django.db.models.deletion.CASCADE,
18+
related_name="assignment_submission",
19+
to="django_email_learning.contentdelivery",
20+
),
21+
),
22+
]

django_email_learning/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1110,7 +1110,7 @@ class SubmissionStatus(models.TextChoices):
11101110
delivery = models.OneToOneField(
11111111
ContentDelivery,
11121112
on_delete=models.CASCADE,
1113-
related_name="assignment_submissions",
1113+
related_name="assignment_submission",
11141114
unique=True,
11151115
)
11161116
text_submission = models.TextField(null=True, blank=True)

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/platform/api/serializers.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from django.urls import reverse
1313
from django_email_learning.models import (
1414
ApiKey,
15+
AssignmentSubmission,
1516
ContentDelivery,
1617
CourseInstructor,
1718
DeliveryStatus,
@@ -792,11 +793,19 @@ class EventType(enum.StrEnum):
792793
VERIFIED = "verified"
793794
DEACTIVATED = "deactivated"
794795
QUIZ_SUBMITED = "quiz_submitted"
796+
ASSIGNMENT_SUBMITTED = "assignment_submitted"
797+
ASSIGNMENT_REVIEWED = "assignment_reviewed"
795798
CONTENT_SENT = "content_sent"
796799
REMINDER_SENT = "reminder_sent"
797800
COURSE_COMPLETED = "course_completed"
798801

799802

803+
class ReviewResult(enum.StrEnum):
804+
APPROVED = "approved"
805+
REJECTED = "rejected"
806+
REQUESTING_CHANGES = "requesting_changes"
807+
808+
800809
class DeactivatedEvent(BaseModel):
801810
type: Literal[EventType.DEACTIVATED] = Field(
802811
default=EventType.DEACTIVATED, exclude=True
@@ -816,6 +825,24 @@ class QuizSubmitedEvent(BaseModel):
816825
is_practice: bool
817826

818827

828+
class AssignmentSubmitedEvent(BaseModel):
829+
type: Literal[EventType.ASSIGNMENT_SUBMITTED] = Field(
830+
default=EventType.ASSIGNMENT_SUBMITTED, exclude=True
831+
)
832+
assignment_id: int
833+
assignment_title: str
834+
835+
836+
class AssignmentReviewdEvent(BaseModel):
837+
type: Literal[EventType.ASSIGNMENT_REVIEWED] = Field(
838+
default=EventType.ASSIGNMENT_REVIEWED, exclude=True
839+
)
840+
assignment_id: int
841+
assignment_title: str
842+
review_result: ReviewResult
843+
reviewed_by: str
844+
845+
819846
class ReminderSentEvent(BaseModel):
820847
type: Literal[EventType.REMINDER_SENT] = Field(
821848
default=EventType.REMINDER_SENT, exclude=True
@@ -836,7 +863,7 @@ class ContentSentEvent(BaseModel):
836863
class Event(BaseModel):
837864
type: EventType
838865
timestamp: datetime
839-
event_data: DeactivatedEvent | QuizSubmitedEvent | ContentSentEvent | ReminderSentEvent | None = Field(
866+
event_data: DeactivatedEvent | QuizSubmitedEvent | ContentSentEvent | AssignmentSubmitedEvent | AssignmentReviewdEvent | ReminderSentEvent | None = Field(
840867
discriminator="type"
841868
) # REGISTERED, VERIFIED, COURSE_COMPLETED have no additional data
842869

@@ -897,6 +924,35 @@ def from_django_model(enrollment: Enrollment) -> "EnrollmentResponse":
897924
),
898925
)
899926
)
927+
submission = delivery.assignment_submission # type: ignore[attr-defined]
928+
if submission:
929+
events.append(
930+
Event(
931+
type=EventType.ASSIGNMENT_SUBMITTED,
932+
timestamp=submission.submitted_at, # type: ignore[arg-type]
933+
event_data=AssignmentSubmitedEvent(
934+
assignment_id=delivery.course_content.assignment.id, # type: ignore[union-attr]
935+
assignment_title=delivery.course_content.assignment.title, # type: ignore[union-attr]
936+
),
937+
)
938+
)
939+
if (
940+
submission.reviewed_at
941+
and submission.status
942+
!= AssignmentSubmission.SubmissionStatus.PENDING_REVIEW
943+
):
944+
events.append(
945+
Event(
946+
type=EventType.ASSIGNMENT_REVIEWED,
947+
timestamp=submission.reviewed_at, # type: ignore[arg-type]
948+
event_data=AssignmentReviewdEvent(
949+
assignment_id=delivery.course_content.assignment.id, # type: ignore[union-attr]
950+
assignment_title=delivery.course_content.assignment.title, # type: ignore[union-attr]
951+
review_result=ReviewResult(submission.status), # type: ignore[union-attr]
952+
reviewed_by=submission.reviewer.display_name, # type: ignore[union-attr, arg-type]
953+
),
954+
)
955+
)
900956
# TODO:events for reminders and submissions for assignments
901957

902958
if delivery.course_content.type == "quiz":

django_email_learning/platform/views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,15 @@ def get_locale_messages(self) -> Dict[str, str]:
580580
"learner_verified": _("Learner Verified Email"),
581581
"lesson_sent": _("Lesson Sent"),
582582
"quiz_sent": _("Quiz Sent"),
583+
"assignment_sent": _("Assignment Sent"),
583584
"quiz_submitted": _("Quiz Submitted"),
585+
"assignment_submitted": _("Assignment Submitted"),
586+
"assignment_reviewed": _("Assignment Reviewed"),
587+
"reviewed_by": _("Reviewed By"),
588+
"assignment_title": _("Assignment Title"),
589+
"requesting_changes": _("Requesting Changes"),
590+
"approved": _("Approved"),
591+
"rejected": _("Rejected"),
584592
"course_completed": _("Course Completed"),
585593
"learner_deactivated": _("Learner Deactivated"),
586594
"score": _("Score"),

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:

0 commit comments

Comments
 (0)