Skip to content

Commit 08ea3c9

Browse files
committed
feat: #40 Add unsubscribe and verify commands
1 parent e46b07b commit 08ea3c9

14 files changed

Lines changed: 352 additions & 13 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Generated by Django 6.0 on 2026-01-07 10:39
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
(
9+
"django_email_learning",
10+
"0004_remove_lesson_is_published_remove_quiz_is_published_and_more",
11+
),
12+
]
13+
14+
operations = [
15+
migrations.AlterField(
16+
model_name="enrollment",
17+
name="activation_code",
18+
field=models.CharField(blank=True, max_length=6, null=True),
19+
),
20+
]

django_email_learning/models.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,13 @@ class EnrollmentStatus(StrEnum):
293293
DEACTIVATED = "deactivated"
294294

295295

296+
class DeactivationReason(StrEnum):
297+
CANCELED = "canceled"
298+
BLOCKED = "blocked"
299+
FAILED = "failed"
300+
INACTIVE = "inactive"
301+
302+
296303
class Enrollment(models.Model):
297304
state_transitions = {
298305
EnrollmentStatus.UNVERIFIED: [
@@ -323,14 +330,14 @@ class Enrollment(models.Model):
323330
null=True,
324331
blank=True,
325332
choices=[
326-
("canceled", "Canceled"),
327-
("blocked", "Blocked"),
328-
("failed", "Failed"),
329-
("inactive", "Inactive"),
333+
(DeactivationReason.CANCELED, "Canceled"),
334+
(DeactivationReason.BLOCKED, "Blocked"),
335+
(DeactivationReason.FAILED, "Failed"),
336+
(DeactivationReason.INACTIVE, "Inactive"),
330337
],
331338
max_length=50,
332339
)
333-
activation_code = models.CharField(max_length=100, null=True, blank=True)
340+
activation_code = models.CharField(max_length=6, null=True, blank=True)
334341

335342
def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
336343
if self.pk:

django_email_learning/services/command_models/enroll_command.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from django_email_learning.services.command_models.abstract_command import (
22
AbstractCommand,
33
)
4+
from django_email_learning.services.command_models.exceptions.invalid_course_slug_error import (
5+
InvalidCourseSlugError,
6+
)
47
from django_email_learning.models import (
58
BlockedEmail,
69
Learner,
@@ -18,10 +21,6 @@
1821
from typing import Literal
1922

2023

21-
class InvalidCourseSlugError(Exception):
22-
pass
23-
24-
2524
class EnrollCommand(AbstractCommand):
2625
command_name: Literal["enroll"]
2726
email: str
@@ -109,7 +108,6 @@ def execute(self) -> None:
109108
template_context,
110109
)
111110

112-
from_email = settings.DJANGO_EMAIL_LEARNING["FROM_EMAIL"]
113111
to_emails = [self.email]
114112

115113
html_content = render_to_string(
@@ -120,7 +118,10 @@ def execute(self) -> None:
120118
# TODO: Add AMP content/type to activate directly in email clients that support it
121119

122120
email = EmailMultiAlternatives(
123-
subject=subject, body=body, from_email=from_email, to=to_emails
121+
subject=subject,
122+
body=body,
123+
from_email=email_service.from_email,
124+
to=to_emails,
124125
)
125126
email.attach_alternative(html_content, "text/html")
126127
email_service.send(email)

django_email_learning/services/command_models/exceptions/__init__.py

Whitespace-only changes.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class InvalidCourseSlugError(Exception):
2+
"""Exception raised when a course slug is invalid."""
3+
4+
pass
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class InvalidEnrollmentError(Exception):
2+
"""Exception raised when an enrollment is invalid."""
3+
4+
pass
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class InvalidVerificationCodeError(Exception):
2+
"""Exception raised when a verification code is invalid."""
3+
4+
pass
Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
from typing import Literal
2+
from django_email_learning.models import (
3+
Course,
4+
Enrollment,
5+
Learner,
6+
EnrollmentStatus,
7+
DeactivationReason,
8+
)
29
from django_email_learning.services.command_models.abstract_command import (
310
AbstractCommand,
411
)
12+
from django_email_learning.services.command_models.exceptions.invalid_course_slug_error import (
13+
InvalidCourseSlugError,
14+
)
515

616

717
class UnsubscribeCommand(AbstractCommand):
@@ -11,6 +21,36 @@ class UnsubscribeCommand(AbstractCommand):
1121
organization_id: int
1222

1323
def execute(self) -> None:
14-
print(
15-
f"Unsubscribing {self.email} from course {self.course_slug} for organization {self.organization_id}"
24+
try:
25+
course = Course.objects.get(
26+
slug=self.course_slug, organization_id=self.organization_id
27+
)
28+
except Course.DoesNotExist:
29+
self.logger.error(
30+
f"Unsubscribe Failed: Invalid course slug '{self.course_slug}' for organization ID {self.organization_id}"
31+
)
32+
raise InvalidCourseSlugError(
33+
f"Course with slug '{self.course_slug}' does not exist for organization ID {self.organization_id}"
34+
)
35+
36+
try:
37+
learner = Learner.objects.get(email=self.email)
38+
except Learner.DoesNotExist:
39+
self.logger.warning(
40+
f"Unsubscribe Skipped: No learner found with email {self.email}"
41+
)
42+
return
43+
44+
enrollments = Enrollment.objects.filter(learner=learner, course=course).exclude(
45+
status=EnrollmentStatus.DEACTIVATED
46+
)
47+
if not enrollments.exists():
48+
self.logger.warning(
49+
f"Unsubscribe Skipped: No active enrollment found for learner {learner.id} in course {course.slug}"
50+
)
51+
return
52+
53+
enrollments.update(
54+
status=EnrollmentStatus.DEACTIVATED,
55+
deactivation_reason=DeactivationReason.CANCELED,
1656
)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from django_email_learning.services.command_models.abstract_command import (
2+
AbstractCommand,
3+
)
4+
from django_email_learning.models import Enrollment, EnrollmentStatus
5+
from pydantic import Field
6+
7+
from django_email_learning.services.command_models.exceptions.invalid_enrollment_error import (
8+
InvalidEnrollmentError,
9+
)
10+
from django_email_learning.services.command_models.exceptions.invalid_verification_code_error import (
11+
InvalidVerificationCodeError,
12+
)
13+
from django_email_learning.services.email_sender_service import EmailSenderService
14+
from django.core.mail import EmailMultiAlternatives
15+
from django.template.loader import render_to_string
16+
17+
18+
class VerifyEnrollmentCommand(AbstractCommand):
19+
command_name: str = "verify_enrollment"
20+
enrollment_id: int = Field(..., gt=0)
21+
verification_code: int = Field(..., ge=100000, le=999999)
22+
23+
def execute(self) -> None:
24+
try:
25+
enrollment = Enrollment.objects.get(
26+
id=self.enrollment_id, status=EnrollmentStatus.UNVERIFIED
27+
)
28+
except Enrollment.DoesNotExist:
29+
self.logger.error(
30+
f"Verification Failed: No unverified enrollment found with ID {self.enrollment_id}"
31+
)
32+
raise InvalidEnrollmentError(
33+
f"No unverified enrollment found with ID {self.enrollment_id}"
34+
)
35+
36+
if str(enrollment.activation_code) != str(self.verification_code):
37+
self.logger.error(
38+
f"Verification Failed: Invalid verification code for Enrollment ID {self.enrollment_id}"
39+
)
40+
raise InvalidVerificationCodeError(
41+
f"Invalid verification code for Enrollment ID {self.enrollment_id}"
42+
)
43+
44+
enrollment.status = EnrollmentStatus.ACTIVE
45+
enrollment.activation_code = None
46+
enrollment.save()
47+
self.logger.info(
48+
f"Enrollment Verified: Enrollment ID {self.enrollment_id} has been activated"
49+
)
50+
51+
enrollment.schedule_first_content_delivery()
52+
self.logger.info(
53+
f"Content Delivery Scheduled: First content delivery scheduled for Enrollment ID {self.enrollment_id}"
54+
)
55+
56+
# Send confirmation email
57+
email_service = EmailSenderService()
58+
subject = "Enrollment Verified"
59+
body = render_to_string(
60+
"emails/enrollment_verified.txt",
61+
{
62+
"course_title": enrollment.course.title,
63+
"organization_name": enrollment.course.organization.name,
64+
},
65+
)
66+
67+
email = EmailMultiAlternatives(
68+
subject=subject,
69+
body=body,
70+
from_email=email_service.from_email,
71+
to=[enrollment.learner.email],
72+
)
73+
html_content = render_to_string(
74+
"emails/enrollment_verified.html",
75+
{
76+
"course_title": enrollment.course.title,
77+
"organization_name": enrollment.course.organization.name,
78+
},
79+
)
80+
email.attach_alternative(html_content, "text/html")
81+
82+
email_service.send(email=email)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{% extends "emails/base.html" %}
2+
3+
{% block content %}
4+
<P>Hello there,<br /></P>
5+
6+
<p><strong>Congratulations!</strong> Your enrollment for "<strong style="color: {{ brand_color }};">{{ course_title }}</strong>" has been successfully verified and confirmed.</p>
7+
8+
<p>We're excited to have you join our learning community. Here's what you can expect:</p>
9+
10+
<ul>
11+
<li>Course materials will be delivered directly to this email address</li>
12+
<li>You'll receive structured lessons and assignments on a regular schedule</li>
13+
<li>Interactive content and assessments will help track your progress</li>
14+
</ul>
15+
16+
<p>Your learning adventure begins soon! Keep an eye on your inbox for the first lesson and welcome materials.</p>
17+
18+
<h3>Welcome aboard!</h3>
19+
20+
<p>Best regards,<br>
21+
The <strong>{{ organization_name }}</strong> Team</p>
22+
{% endblock %}

0 commit comments

Comments
 (0)