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
5 changes: 1 addition & 4 deletions .github/workflows/pr-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,8 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.11, 3.12, 3.13, 3.14]
python-version: [3.12, 3.13, 3.14]
django-versions: [5.0, 6.0]
exclude:
- python-version: '3.11'
django-versions: 6.0
steps:
- name: Checkout code
uses: actions/checkout@v6
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Generated by Django 6.0.2 on 2026-02-13 07:33

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


class Migration(migrations.Migration):
dependencies = [
(
"django_email_learning",
"0007_alter_apikey_salt_alter_imapconnection_salt_and_more",
),
]

operations = [
migrations.AlterField(
model_name="apikey",
name="salt",
field=models.CharField(
default="24cbbf453c064d1c99017f78d01115a1",
editable=False,
max_length=32,
),
),
migrations.AlterField(
model_name="imapconnection",
name="salt",
field=models.CharField(
default="24cbbf453c064d1c99017f78d01115a1",
editable=False,
max_length=32,
),
),
migrations.CreateModel(
name="Certificate",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("issued_at", models.DateTimeField(auto_now_add=True)),
("name_on_certificate", models.CharField(max_length=200)),
("random_suffix", models.IntegerField()),
(
"enrollment",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="certificate",
to="django_email_learning.enrollment",
),
),
],
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Generated by Django 6.0.2 on 2026-02-13 08:07

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
(
"django_email_learning",
"0008_alter_apikey_salt_alter_imapconnection_salt_and_more",
),
]

operations = [
migrations.AddField(
model_name="deliveryschedule",
name="delivered_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name="apikey",
name="salt",
field=models.CharField(
default="ff58446e143a4d69b7773604efeb581c",
editable=False,
max_length=32,
),
),
migrations.AlterField(
model_name="imapconnection",
name="salt",
field=models.CharField(
default="ff58446e143a4d69b7773604efeb581c",
editable=False,
max_length=32,
),
),
]
95 changes: 87 additions & 8 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,23 @@
MinValueValidator,
MinLengthValidator,
)
from django_email_learning.services.email_sender_service import EmailSenderService
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.core.exceptions import ImproperlyConfigured
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from django.forms import ValidationError
from django.contrib.auth.models import User
from django.utils import timezone
from django.utils.translation import gettext as _
from datetime import timedelta
from django_email_learning.services import jwt_service

from PIL import Image
from typing import Optional
from datetime import datetime


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -522,16 +528,58 @@ class Meta:
]

def graduate(self) -> None:
if self.status != EnrollmentStatus.ACTIVE:
raise ValidationError("Only active enrollments can be marked as completed.")
self.status = EnrollmentStatus.COMPLETED
self.final_state_at = timezone.now()
logger.info(
f"Learner ID {self.learner.id} has completed the course {self.course.title}."
with transaction.atomic():
if self.status != EnrollmentStatus.ACTIVE:
raise ValidationError(
"Only active enrollments can be marked as completed."
)
self.status = EnrollmentStatus.COMPLETED
self.final_state_at = timezone.now()
logger.info(
f"Learner ID {self.learner.id} has completed the course {self.course.title}."
)
self.save()
self.send_certificate_form()

def send_certificate_form(self) -> None:
if self.status != EnrollmentStatus.COMPLETED:
raise ValidationError(
"Certificate form can only be sent for completed enrollments."
)
token_payload = {
"enrollment_id": self.id,
}
logging.info(
f"Executing SendCertificateFormCommand for enrollment ID {self.id}"
)
token = jwt_service.generate_jwt(token_payload, exp=datetime.max)
certificate_path = reverse(
"django_email_learning:personalised:certificate_form"
)
link = f"{settings.DJANGO_EMAIL_LEARNING['SITE_BASE_URL']}{certificate_path}?token={token}"

# TODO: send certificate email here
self.save()
subject = _("Finalize your Certificate")

context = {
"course_title": self.course.title,
"organization_name": self.course.organization.name,
"link": link,
}
payload = render_to_string("emails/certificate_form.txt", context)

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

email_service.send(email_message)
logging.info(f"Certificate form email sent for enrollment ID {self.id}")

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


class Certificate(models.Model):
enrollment = models.OneToOneField(
Enrollment, on_delete=models.CASCADE, related_name="certificate"
)
issued_at = models.DateTimeField(auto_now_add=True)
name_on_certificate = models.CharField(max_length=200)
random_suffix = models.IntegerField()

@property
def certificate_number(self) -> str:
return f"{self.enrollment.course.id}-{self.enrollment.id}-{self.id}-{self.random_suffix}"

def save( # type: ignore[no-untyped-def]
self, *, force_insert=False, force_update=False, using=None, update_fields=None
):
if not self.random_suffix:
self.random_suffix = random.randint(100000, 999999)
return super().save(
force_insert=force_insert,
force_update=force_update,
using=using,
update_fields=update_fields,
)


class ContentDelivery(models.Model):
enrollment = models.ForeignKey(
Enrollment, on_delete=models.CASCADE, related_name="content_deliveries"
Expand Down Expand Up @@ -664,6 +737,7 @@ class DeliverySchedule(models.Model):
db_index=True,
)
failed_attempts = models.IntegerField(default=0)
delivered_at = models.DateTimeField(null=True, blank=True)

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

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


class QuizSubmission(models.Model):
delivery = models.ForeignKey(
Expand Down
10 changes: 9 additions & 1 deletion django_email_learning/personalised/api/urls.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
from django.urls import path
from django_email_learning.personalised.api.views import QuizSubmissionView
from django_email_learning.personalised.api.views import (
QuizSubmissionView,
SubmitCertificateFormView,
)

app_name = "django_email_learning"

urlpatterns = [
path("quiz/", QuizSubmissionView.as_view(), name="quiz_submission"),
path(
"certificate-form/",
SubmitCertificateFormView.as_view(),
name="submit_certificate_form",
),
]
44 changes: 44 additions & 0 deletions django_email_learning/personalised/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from django.utils.translation import gettext as _
from django_email_learning.models import (
ContentDelivery,
Enrollment,
Certificate,
QuizSubmission,
Quiz,
EnrollmentStatus,
Expand Down Expand Up @@ -175,3 +177,45 @@ def calculate_score_and_passed(
score = max(0, score)
passed = score >= quiz.required_score
return score, passed


class SubmitCertificateFormView(View):
def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
payload = json.loads(request.body)
token = payload.get("token")
name = payload.get("name")

if not name:
return JsonResponse({"error": _("Name is required")}, status=400)

try:
decoded = jwt_service.decode_jwt(token=token)
except jwt_service.InvalidTokenException as jde:
return JsonResponse({"error": str(jde)}, status=400)
except jwt_service.ExpiredTokenException as ete:
return JsonResponse({"error": str(ete)}, status=410)

enrollment_id = decoded["enrollment_id"]

try:
enrollment = Enrollment.objects.get(id=enrollment_id)
except Enrollment.DoesNotExist:
return JsonResponse(
{"error": "The enrollment associated with this token does not exist."},
status=500,
)

if enrollment.status != EnrollmentStatus.COMPLETED:
return JsonResponse(
{
"error": "The enrollment is not completed. Certificate cannot be issued."
},
status=400,
)
Certificate.objects.update_or_create(
enrollment=enrollment, defaults={"name_on_certificate": name}
)

return JsonResponse(
{"message": _("Certificate name submitted successfully.")}, status=200
)
8 changes: 8 additions & 0 deletions django_email_learning/personalised/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from django_email_learning.personalised.views import (
QuizPublicView,
VerifyEnrollmentView,
CertificateFormView,
CertificateView,
UnsubscribeView,
)

Expand All @@ -12,5 +14,11 @@
path(
"verify-enrollment/", VerifyEnrollmentView.as_view(), name="verify_enrollment"
),
path("certificate-form/", CertificateFormView.as_view(), name="certificate_form"),
path(
"certificate/<str:certificate_number>/",
CertificateView.as_view(),
name="certificate",
),
path("unsubscribe/", UnsubscribeView.as_view(), name="unsubscribe"),
]
Loading