Skip to content

Commit 3fd1dfb

Browse files
authored
Merge pull request #222 from AvaCodeSolutions/feat/164/course-certificate
feat: #164 certificate of course completion
2 parents cc85636 + 6428a37 commit 3fd1dfb

26 files changed

Lines changed: 857 additions & 162 deletions

.github/workflows/pr-check.yml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,8 @@ jobs:
3939
runs-on: ubuntu-latest
4040
strategy:
4141
matrix:
42-
python-version: [3.11, 3.12, 3.13, 3.14]
42+
python-version: [3.12, 3.13, 3.14]
4343
django-versions: [5.0, 6.0]
44-
exclude:
45-
- python-version: '3.11'
46-
django-versions: 6.0
4744
steps:
4845
- name: Checkout code
4946
uses: actions/checkout@v6
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Generated by Django 6.0.2 on 2026-02-13 07:33
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
(
10+
"django_email_learning",
11+
"0007_alter_apikey_salt_alter_imapconnection_salt_and_more",
12+
),
13+
]
14+
15+
operations = [
16+
migrations.AlterField(
17+
model_name="apikey",
18+
name="salt",
19+
field=models.CharField(
20+
default="24cbbf453c064d1c99017f78d01115a1",
21+
editable=False,
22+
max_length=32,
23+
),
24+
),
25+
migrations.AlterField(
26+
model_name="imapconnection",
27+
name="salt",
28+
field=models.CharField(
29+
default="24cbbf453c064d1c99017f78d01115a1",
30+
editable=False,
31+
max_length=32,
32+
),
33+
),
34+
migrations.CreateModel(
35+
name="Certificate",
36+
fields=[
37+
(
38+
"id",
39+
models.BigAutoField(
40+
auto_created=True,
41+
primary_key=True,
42+
serialize=False,
43+
verbose_name="ID",
44+
),
45+
),
46+
("issued_at", models.DateTimeField(auto_now_add=True)),
47+
("name_on_certificate", models.CharField(max_length=200)),
48+
("random_suffix", models.IntegerField()),
49+
(
50+
"enrollment",
51+
models.OneToOneField(
52+
on_delete=django.db.models.deletion.CASCADE,
53+
related_name="certificate",
54+
to="django_email_learning.enrollment",
55+
),
56+
),
57+
],
58+
),
59+
]
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Generated by Django 6.0.2 on 2026-02-13 08:07
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
(
9+
"django_email_learning",
10+
"0008_alter_apikey_salt_alter_imapconnection_salt_and_more",
11+
),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name="deliveryschedule",
17+
name="delivered_at",
18+
field=models.DateTimeField(blank=True, null=True),
19+
),
20+
migrations.AlterField(
21+
model_name="apikey",
22+
name="salt",
23+
field=models.CharField(
24+
default="ff58446e143a4d69b7773604efeb581c",
25+
editable=False,
26+
max_length=32,
27+
),
28+
),
29+
migrations.AlterField(
30+
model_name="imapconnection",
31+
name="salt",
32+
field=models.CharField(
33+
default="ff58446e143a4d69b7773604efeb581c",
34+
editable=False,
35+
max_length=32,
36+
),
37+
),
38+
]

django_email_learning/models.py

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,23 @@
1515
MinValueValidator,
1616
MinLengthValidator,
1717
)
18+
from django_email_learning.services.email_sender_service import EmailSenderService
19+
from django.core.mail import EmailMultiAlternatives
20+
from django.template.loader import render_to_string
1821
from django.core.exceptions import ImproperlyConfigured
1922
from cryptography.fernet import Fernet, InvalidToken
2023
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
2124
from cryptography.hazmat.primitives import hashes
2225
from django.forms import ValidationError
2326
from django.contrib.auth.models import User
2427
from django.utils import timezone
28+
from django.utils.translation import gettext as _
2529
from datetime import timedelta
2630
from django_email_learning.services import jwt_service
31+
2732
from PIL import Image
2833
from typing import Optional
34+
from datetime import datetime
2935

3036

3137
logger = logging.getLogger(__name__)
@@ -522,16 +528,58 @@ class Meta:
522528
]
523529

524530
def graduate(self) -> None:
525-
if self.status != EnrollmentStatus.ACTIVE:
526-
raise ValidationError("Only active enrollments can be marked as completed.")
527-
self.status = EnrollmentStatus.COMPLETED
528-
self.final_state_at = timezone.now()
529-
logger.info(
530-
f"Learner ID {self.learner.id} has completed the course {self.course.title}."
531+
with transaction.atomic():
532+
if self.status != EnrollmentStatus.ACTIVE:
533+
raise ValidationError(
534+
"Only active enrollments can be marked as completed."
535+
)
536+
self.status = EnrollmentStatus.COMPLETED
537+
self.final_state_at = timezone.now()
538+
logger.info(
539+
f"Learner ID {self.learner.id} has completed the course {self.course.title}."
540+
)
541+
self.save()
542+
self.send_certificate_form()
543+
544+
def send_certificate_form(self) -> None:
545+
if self.status != EnrollmentStatus.COMPLETED:
546+
raise ValidationError(
547+
"Certificate form can only be sent for completed enrollments."
548+
)
549+
token_payload = {
550+
"enrollment_id": self.id,
551+
}
552+
logging.info(
553+
f"Executing SendCertificateFormCommand for enrollment ID {self.id}"
531554
)
555+
token = jwt_service.generate_jwt(token_payload, exp=datetime.max)
556+
certificate_path = reverse(
557+
"django_email_learning:personalised:certificate_form"
558+
)
559+
link = f"{settings.DJANGO_EMAIL_LEARNING['SITE_BASE_URL']}{certificate_path}?token={token}"
532560

533-
# TODO: send certificate email here
534-
self.save()
561+
subject = _("Finalize your Certificate")
562+
563+
context = {
564+
"course_title": self.course.title,
565+
"organization_name": self.course.organization.name,
566+
"link": link,
567+
}
568+
payload = render_to_string("emails/certificate_form.txt", context)
569+
570+
email_service = EmailSenderService()
571+
email_message = EmailMultiAlternatives(
572+
subject=subject,
573+
body=payload,
574+
from_email=email_service.from_email,
575+
to=[self.learner.email],
576+
)
577+
email_message.attach_alternative(
578+
render_to_string("emails/certificate_form.html", context), "text/html"
579+
)
580+
581+
email_service.send(email_message)
582+
logging.info(f"Certificate form email sent for enrollment ID {self.id}")
535583

536584
def fail(self) -> None:
537585
if self.status != EnrollmentStatus.ACTIVE:
@@ -565,6 +613,31 @@ def schedule_first_content_delivery(self) -> None:
565613
raise ValidationError("No published content available to schedule.")
566614

567615

616+
class Certificate(models.Model):
617+
enrollment = models.OneToOneField(
618+
Enrollment, on_delete=models.CASCADE, related_name="certificate"
619+
)
620+
issued_at = models.DateTimeField(auto_now_add=True)
621+
name_on_certificate = models.CharField(max_length=200)
622+
random_suffix = models.IntegerField()
623+
624+
@property
625+
def certificate_number(self) -> str:
626+
return f"{self.enrollment.course.id}-{self.enrollment.id}-{self.id}-{self.random_suffix}"
627+
628+
def save( # type: ignore[no-untyped-def]
629+
self, *, force_insert=False, force_update=False, using=None, update_fields=None
630+
):
631+
if not self.random_suffix:
632+
self.random_suffix = random.randint(100000, 999999)
633+
return super().save(
634+
force_insert=force_insert,
635+
force_update=force_update,
636+
using=using,
637+
update_fields=update_fields,
638+
)
639+
640+
568641
class ContentDelivery(models.Model):
569642
enrollment = models.ForeignKey(
570643
Enrollment, on_delete=models.CASCADE, related_name="content_deliveries"
@@ -664,6 +737,7 @@ class DeliverySchedule(models.Model):
664737
db_index=True,
665738
)
666739
failed_attempts = models.IntegerField(default=0)
740+
delivered_at = models.DateTimeField(null=True, blank=True)
667741

668742
def generate_link(self) -> str:
669743
payload = {
@@ -695,6 +769,11 @@ def generate_link(self) -> str:
695769
def __str__(self) -> str:
696770
return f"Delivery for {self.delivery.course_content.title} to {self.delivery.enrollment.learner.email} at {self.time} - Status: {self.status}"
697771

772+
def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
773+
if self.status == DeliveryStatus.DELIVERED and not self.delivered_at:
774+
self.delivered_at = timezone.now()
775+
super().save(*args, **kwargs)
776+
698777

699778
class QuizSubmission(models.Model):
700779
delivery = models.ForeignKey(
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
from django.urls import path
2-
from django_email_learning.personalised.api.views import QuizSubmissionView
2+
from django_email_learning.personalised.api.views import (
3+
QuizSubmissionView,
4+
SubmitCertificateFormView,
5+
)
36

47
app_name = "django_email_learning"
58

69
urlpatterns = [
710
path("quiz/", QuizSubmissionView.as_view(), name="quiz_submission"),
11+
path(
12+
"certificate-form/",
13+
SubmitCertificateFormView.as_view(),
14+
name="submit_certificate_form",
15+
),
816
]

django_email_learning/personalised/api/views.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from django.utils.translation import gettext as _
99
from django_email_learning.models import (
1010
ContentDelivery,
11+
Enrollment,
12+
Certificate,
1113
QuizSubmission,
1214
Quiz,
1315
EnrollmentStatus,
@@ -175,3 +177,45 @@ def calculate_score_and_passed(
175177
score = max(0, score)
176178
passed = score >= quiz.required_score
177179
return score, passed
180+
181+
182+
class SubmitCertificateFormView(View):
183+
def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
184+
payload = json.loads(request.body)
185+
token = payload.get("token")
186+
name = payload.get("name")
187+
188+
if not name:
189+
return JsonResponse({"error": _("Name is required")}, status=400)
190+
191+
try:
192+
decoded = jwt_service.decode_jwt(token=token)
193+
except jwt_service.InvalidTokenException as jde:
194+
return JsonResponse({"error": str(jde)}, status=400)
195+
except jwt_service.ExpiredTokenException as ete:
196+
return JsonResponse({"error": str(ete)}, status=410)
197+
198+
enrollment_id = decoded["enrollment_id"]
199+
200+
try:
201+
enrollment = Enrollment.objects.get(id=enrollment_id)
202+
except Enrollment.DoesNotExist:
203+
return JsonResponse(
204+
{"error": "The enrollment associated with this token does not exist."},
205+
status=500,
206+
)
207+
208+
if enrollment.status != EnrollmentStatus.COMPLETED:
209+
return JsonResponse(
210+
{
211+
"error": "The enrollment is not completed. Certificate cannot be issued."
212+
},
213+
status=400,
214+
)
215+
Certificate.objects.update_or_create(
216+
enrollment=enrollment, defaults={"name_on_certificate": name}
217+
)
218+
219+
return JsonResponse(
220+
{"message": _("Certificate name submitted successfully.")}, status=200
221+
)

django_email_learning/personalised/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from django_email_learning.personalised.views import (
33
QuizPublicView,
44
VerifyEnrollmentView,
5+
CertificateFormView,
6+
CertificateView,
57
UnsubscribeView,
68
)
79

@@ -12,5 +14,11 @@
1214
path(
1315
"verify-enrollment/", VerifyEnrollmentView.as_view(), name="verify_enrollment"
1416
),
17+
path("certificate-form/", CertificateFormView.as_view(), name="certificate_form"),
18+
path(
19+
"certificate/<str:certificate_number>/",
20+
CertificateView.as_view(),
21+
name="certificate",
22+
),
1523
path("unsubscribe/", UnsubscribeView.as_view(), name="unsubscribe"),
1624
]

0 commit comments

Comments
 (0)