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
23 changes: 20 additions & 3 deletions django_email_learning/jobs/send_reminders_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
from django_email_learning.models import ContentDelivery, DeliverySchedule
from django_email_learning.jobs.job_metrics import track_job_execution

from django_email_learning.services.command_models.send_assignment_reminder_command import (
SendAssignmentReminderCommand,
)
from django_email_learning.services.metrics_service import MetricsService
from django_email_learning.models import JobExecution, JobName, JobStatus
from django_email_learning.services.command_models.send_quiz_reminder_command import (
Expand Down Expand Up @@ -71,9 +74,23 @@ def get_reminder_queue(self) -> DeliveryQueueProtocol:

def process_reminder(self, delivery_schedule: DeliverySchedule) -> None:
try:
command = SendQuizReminderCommand(
delivery_schedule=delivery_schedule,
)
if delivery_schedule.delivery.course_content.quiz:
command = SendQuizReminderCommand(
delivery_schedule=delivery_schedule,
)
elif delivery_schedule.delivery.course_content.assignment:
command = SendAssignmentReminderCommand( # type: ignore[assignment]
delivery_schedule=delivery_schedule,
)
else:
logger.error(
f"Delivery with ID {delivery_schedule.delivery.id} has no associated quiz or assignment. Marking reminder as not applicable."
)
delivery_schedule.delivery.reminder_state = (
ContentDelivery.ReminderStatus.NOT_APPLICABLE
)
delivery_schedule.delivery.save()
return
command.execute()
delivery_schedule.delivery.reminder_state = (
ContentDelivery.ReminderStatus.SENT
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 6.0.4 on 2026-05-05 07:37

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("django_email_learning", "0025_alter_assignmentsubmission_status"),
]

operations = [
migrations.AlterField(
model_name="assignmentsubmission",
name="delivery",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="assignment_submission",
to="django_email_learning.contentdelivery",
),
),
]
2 changes: 1 addition & 1 deletion django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1110,7 +1110,7 @@ class SubmissionStatus(models.TextChoices):
delivery = models.OneToOneField(
ContentDelivery,
on_delete=models.CASCADE,
related_name="assignment_submissions",
related_name="assignment_submission",
unique=True,
)
text_submission = models.TextField(null=True, blank=True)
Expand Down
4 changes: 4 additions & 0 deletions django_email_learning/personalised/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
"text_submission": text_submission if text_submission else None,
},
)
delivery.reminder_state = ContentDelivery.ReminderStatus.NOT_APPLICABLE
delivery.valid_until = None
delivery.remind_at = None
delivery.save()
if not created:
submission.status = AssignmentSubmission.SubmissionStatus.PENDING_REVIEW
submission.save()
Expand Down
58 changes: 57 additions & 1 deletion django_email_learning/platform/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from django.urls import reverse
from django_email_learning.models import (
ApiKey,
AssignmentSubmission,
ContentDelivery,
CourseInstructor,
DeliveryStatus,
Expand Down Expand Up @@ -792,11 +793,19 @@ class EventType(enum.StrEnum):
VERIFIED = "verified"
DEACTIVATED = "deactivated"
QUIZ_SUBMITED = "quiz_submitted"
ASSIGNMENT_SUBMITTED = "assignment_submitted"
ASSIGNMENT_REVIEWED = "assignment_reviewed"
CONTENT_SENT = "content_sent"
REMINDER_SENT = "reminder_sent"
COURSE_COMPLETED = "course_completed"


class ReviewResult(enum.StrEnum):
APPROVED = "approved"
REJECTED = "rejected"
REQUESTING_CHANGES = "requesting_changes"


class DeactivatedEvent(BaseModel):
type: Literal[EventType.DEACTIVATED] = Field(
default=EventType.DEACTIVATED, exclude=True
Expand All @@ -816,6 +825,24 @@ class QuizSubmitedEvent(BaseModel):
is_practice: bool


class AssignmentSubmitedEvent(BaseModel):
type: Literal[EventType.ASSIGNMENT_SUBMITTED] = Field(
default=EventType.ASSIGNMENT_SUBMITTED, exclude=True
)
assignment_id: int
assignment_title: str


class AssignmentReviewdEvent(BaseModel):
type: Literal[EventType.ASSIGNMENT_REVIEWED] = Field(
default=EventType.ASSIGNMENT_REVIEWED, exclude=True
)
assignment_id: int
assignment_title: str
review_result: ReviewResult
reviewed_by: str


class ReminderSentEvent(BaseModel):
type: Literal[EventType.REMINDER_SENT] = Field(
default=EventType.REMINDER_SENT, exclude=True
Expand All @@ -836,7 +863,7 @@ class ContentSentEvent(BaseModel):
class Event(BaseModel):
type: EventType
timestamp: datetime
event_data: DeactivatedEvent | QuizSubmitedEvent | ContentSentEvent | ReminderSentEvent | None = Field(
event_data: DeactivatedEvent | QuizSubmitedEvent | ContentSentEvent | AssignmentSubmitedEvent | AssignmentReviewdEvent | ReminderSentEvent | None = Field(
discriminator="type"
) # REGISTERED, VERIFIED, COURSE_COMPLETED have no additional data

Expand Down Expand Up @@ -897,6 +924,35 @@ def from_django_model(enrollment: Enrollment) -> "EnrollmentResponse":
),
)
)
submission = delivery.assignment_submission # type: ignore[attr-defined]
if submission:
events.append(
Event(
type=EventType.ASSIGNMENT_SUBMITTED,
timestamp=submission.submitted_at, # type: ignore[arg-type]
event_data=AssignmentSubmitedEvent(
assignment_id=delivery.course_content.assignment.id, # type: ignore[union-attr]
assignment_title=delivery.course_content.assignment.title, # type: ignore[union-attr]
),
)
)
if (
submission.reviewed_at
and submission.status
!= AssignmentSubmission.SubmissionStatus.PENDING_REVIEW
):
events.append(
Event(
type=EventType.ASSIGNMENT_REVIEWED,
timestamp=submission.reviewed_at, # type: ignore[arg-type]
event_data=AssignmentReviewdEvent(
assignment_id=delivery.course_content.assignment.id, # type: ignore[union-attr]
assignment_title=delivery.course_content.assignment.title, # type: ignore[union-attr]
review_result=ReviewResult(submission.status), # type: ignore[union-attr]
reviewed_by=submission.reviewer.display_name, # type: ignore[union-attr, arg-type]
),
)
)
# TODO:events for reminders and submissions for assignments

if delivery.course_content.type == "quiz":
Expand Down
8 changes: 8 additions & 0 deletions django_email_learning/platform/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,15 @@ def get_locale_messages(self) -> Dict[str, str]:
"learner_verified": _("Learner Verified Email"),
"lesson_sent": _("Lesson Sent"),
"quiz_sent": _("Quiz Sent"),
"assignment_sent": _("Assignment Sent"),
"quiz_submitted": _("Quiz Submitted"),
"assignment_submitted": _("Assignment Submitted"),
"assignment_reviewed": _("Assignment Reviewed"),
"reviewed_by": _("Reviewed By"),
"assignment_title": _("Assignment Title"),
"requesting_changes": _("Requesting Changes"),
"approved": _("Approved"),
"rejected": _("Rejected"),
"course_completed": _("Course Completed"),
"learner_deactivated": _("Learner Deactivated"),
"score": _("Score"),
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 @@ -19,6 +19,11 @@ def quiz_reminder_sent(
) -> None:
...

def assignment_reminder_sent(
self, course_slug: str, organization_id: int, assignment_id: int
) -> None:
...

def lesson_sent(
self, course_slug: str, organization_id: int, lesson_id: int
) -> None:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from django_email_learning.services.command_models.abstract_command import (
AbstractCommand,
)
from django_email_learning.models import ContentDelivery, DeliverySchedule
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 pydantic import ConfigDict
from django.utils.translation import gettext as _
from django.utils import timezone
from django_email_learning.services.utils import mask_email


class AssignmentNotFoundError(Exception):
pass


class SendAssignmentReminderCommand(AbstractCommand):
command_name: Literal["send_assignment_reminder"] = "send_assignment_reminder"
delivery_schedule: DeliverySchedule

model_config = ConfigDict(arbitrary_types_allowed=True)

def execute(self) -> None:
metric_service = MetricsService()
content = self.delivery_schedule.delivery.course_content
if not content.assignment:
raise AssignmentNotFoundError(
f"CourseContent with ID {content.id} has no associated assignment"
)
email = self.delivery_schedule.delivery.enrollment.learner.email
self.logger.info(
f"Sending reminder for assignment with ID {content.assignment.id} to email {mask_email(email)}"
)

assignment = content.assignment

subject = _("Reminder: Assignment '{assignment_title}' is due soon").format(
assignment_title=assignment.title
)
context = {
"assignment": assignment,
"link": self.delivery_schedule.link,
"unsubscribe_link": content.course.generate_unsubscribe_link(email),
"deadline_time": self.delivery_schedule.delivery.valid_until,
}
payload = render_to_string("emails/assignment_reminder.txt", context)

email_service = EmailSenderService()
email_message = EmailMultiAlternatives(
subject=subject,
body=payload,
from_email=email_service.from_email,
to=[email],
)
email_message.attach_alternative(
render_to_string("emails/assignment_reminder.html", context), "text/html"
)

try:
email_service.send(email_message)
self.delivery_schedule.delivery.remind_at = timezone.now()
self.delivery_schedule.delivery.reminder_state = (
ContentDelivery.ReminderStatus.SENT
)
self.delivery_schedule.delivery.save()
metric_service.assignment_reminder_sent(
course_slug=content.course.slug,
organization_id=content.course.organization.id,
assignment_id=assignment.id,
)
except Exception as e:
self.logger.error(
f"Failed to send assignment reminder for assignment with ID {assignment.id} to email {mask_email(email)}: {str(e)}"
)
raise e
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ def quiz_reminder_sent(
},
)

def assignment_reminder_sent(
self, course_slug: str, organization_id: int, assignment_id: int
) -> None:
logger.info(
"Assignment reminder sent",
extra={
"metric": "assignment_reminder_sent",
"course_slug": course_slug,
"organization_id": organization_id,
"assignment_id": assignment_id,
},
)

def lesson_sent(
self, course_slug: str, organization_id: int, lesson_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 @@ -41,6 +41,13 @@ def quiz_reminder_sent(
) -> None:
self.metric_recorder.quiz_reminder_sent(course_slug, organization_id, quiz_id)

def assignment_reminder_sent(
self, course_slug: str, organization_id: int, assignment_id: int
) -> None:
self.metric_recorder.assignment_reminder_sent(
course_slug, organization_id, assignment_id
)

def lesson_sent(
self, course_slug: str, organization_id: int, lesson_id: int
) -> None:
Expand Down
33 changes: 33 additions & 0 deletions django_email_learning/templates/emails/assignment_reminder.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{% extends "emails/base.html" %}
{% load i18n %}
{% load tz %}

{% block content %}
<h2 class="email-title">{% blocktranslate with assignment_title=assignment.title %}Reminder For Assignment: {{assignment_title}}{% endblocktranslate %}</h2>

<p class="email-paragraph">{% translate "Hello there," %}</p>

<div class="email-panel reminder-panel">
<p class="email-paragraph" style="margin-bottom: 10px;">{% translate "This is a friendly reminder to complete your assignment." %}</p>

{% if deadline_time %}
<p class="email-paragraph" style="margin-bottom: 0;">
{% blocktranslate %}The assignment link is valid until:{% endblocktranslate %}
<br>
<span class="deadline-chip">{{ deadline_time|timezone:"UTC"|date:"Y-m-d H:i e" }}</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 %}
13 changes: 13 additions & 0 deletions django_email_learning/templates/emails/assignment_reminder.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{% load i18n %}
{% load tz %}
{{ assignment.title }}

{% translate "Hello there," %}

{% 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 %}

{% translate "Please click the link below to access and submit your assignment:" %}
{{ link }}


{% translate "Unsubscribe link:" %} {{ unsubscribe_link }}
1 change: 1 addition & 0 deletions django_service/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def get_template_names(self) -> list[str]:
"password_reset",
"quiz",
"assignment",
"assignment_reminder",
"quiz_reminder",
"deactivation_deadline_passed",
]:
Expand Down
Loading