diff --git a/django_email_learning/migrations/0002_deliveryschedule_link.py b/django_email_learning/migrations/0002_deliveryschedule_link.py
new file mode 100644
index 0000000..2d4dc59
--- /dev/null
+++ b/django_email_learning/migrations/0002_deliveryschedule_link.py
@@ -0,0 +1,17 @@
+# Generated by Django 6.0.1 on 2026-01-08 20:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("django_email_learning", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="deliveryschedule",
+ name="link",
+ field=models.URLField(blank=True, null=True),
+ ),
+ ]
diff --git a/django_email_learning/models.py b/django_email_learning/models.py
index f9a7188..15634e3 100644
--- a/django_email_learning/models.py
+++ b/django_email_learning/models.py
@@ -6,6 +6,7 @@
from enum import StrEnum
from typing import Any
from django.conf import settings
+from django.urls import reverse
from django.db import models, transaction
from django.core.validators import MaxValueValidator, MinValueValidator
from django.core.exceptions import ImproperlyConfigured
@@ -16,6 +17,7 @@
from django.contrib.auth.models import User
from django.utils import timezone
from datetime import timedelta
+from django_email_learning.services import jwt_service
FIXED_SALT = b"\x00" * 16
@@ -175,6 +177,16 @@ def validate_questions(self) -> None:
except ValidationError as e:
raise ValidationError(f"For question '{question.text}', {e.message}")
+ def random_question_ids(self) -> list[int]:
+ question_ids = list(self.questions.values_list("id", flat=True))
+ if self.selection_strategy == QuizSelectionStrategy.ALL_QUESTIONS.value:
+ return question_ids
+ if len(question_ids) <= 5:
+ return question_ids
+ number_of_questions = int(max(5, len(question_ids) // 1.5))
+ selected_ids = random.sample(question_ids, k=number_of_questions)
+ return selected_ids
+
class Question(models.Model):
quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name="questions")
@@ -402,9 +414,13 @@ def schedule_first_content_delivery(self) -> None:
enrollment=self,
course_content=first_content,
)
+ link = None
+ if first_content.quiz:
+ link = delivery.link()
DeliverySchedule.objects.create(
time=timezone.now() + timedelta(seconds=first_content.waiting_period),
delivery=delivery,
+ link=link, # type: ignore[misc]
)
else:
raise ValidationError("No published content available to schedule.")
@@ -429,12 +445,43 @@ def update_hash(self) -> None:
)
self.save()
+ def link(self) -> str:
+ payload = {
+ "delivery_id": self.id,
+ "delivery_hash": self.hash_value,
+ }
+
+ if self.course_content.quiz:
+ if (
+ self.course_content.quiz.selection_strategy
+ == QuizSelectionStrategy.RANDOM_QUESTIONS.value
+ ):
+ payload["question_ids"] = self.course_content.quiz.random_question_ids() # type: ignore[assignment]
+ token = jwt_service.generate_jwt(
+ payload=payload,
+ expiration_seconds=60
+ * 60
+ * 24
+ * self.course_content.quiz.deadline_days,
+ )
+ quiz_path = reverse("django_email_learning:personalised:quiz_public_view")
+ return f"{settings.DJANGO_EMAIL_LEARNING['SITE_BASE_URL']}{quiz_path}?token={token}"
+ else:
+ # TODO: Implement lesson link generation
+ raise NotImplementedError(
+ "Link generation is only implemented for quiz content."
+ )
+
def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
self.full_clean()
if not self.hash_value:
self.hash_value = (
base64.urlsafe_b64encode(uuid.uuid4().bytes).decode().rstrip("=")
)
+ if self.course_content.quiz and not self.valid_until:
+ self.valid_until = timezone.now() + timedelta(
+ days=self.course_content.quiz.deadline_days
+ )
super().save(*args, **kwargs)
def __str__(self) -> str:
@@ -447,6 +494,7 @@ class DeliverySchedule(models.Model):
)
time = models.DateTimeField(default=timezone.now, db_index=True)
is_delivered = models.BooleanField(default=False, db_index=True)
+ link = models.URLField(null=True, blank=True)
def __str__(self) -> str:
return f"Delivery for {self.delivery.course_content.title} to {self.delivery.enrollment.learner.email} at {self.time} - Delivered: {self.is_delivered}"
diff --git a/django_email_learning/templates/personalised/quiz_public.html b/django_email_learning/templates/personalised/quiz_public.html
index 459b406..763fdeb 100644
--- a/django_email_learning/templates/personalised/quiz_public.html
+++ b/django_email_learning/templates/personalised/quiz_public.html
@@ -2,12 +2,10 @@
{% load django_vite %}
{% block head_script %}
- {% vite_asset 'my/quiz/Quiz.jsx' %}
+ {% vite_asset 'personalised/quiz_public/Quiz.jsx' %}
{% endblock %}
diff --git a/django_email_learning/templates/personalised/verify_enrollment.html b/django_email_learning/templates/personalised/verify_enrollment.html
index 90567c6..12d09c1 100644
--- a/django_email_learning/templates/personalised/verify_enrollment.html
+++ b/django_email_learning/templates/personalised/verify_enrollment.html
@@ -1,5 +1,5 @@
{% extends "personalised/base.html" %}
{% load django_vite %}
{% block head_script %}
- {% vite_asset 'my/verification/Verification.jsx' %}
+ {% vite_asset 'personalised/verify_enrollment/Verification.jsx' %}
{% endblock %}
diff --git a/django_service/settings.py b/django_service/settings.py
index 0129f90..1664b37 100644
--- a/django_service/settings.py
+++ b/django_service/settings.py
@@ -35,7 +35,6 @@
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
- "django.contrib.sites",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
diff --git a/frontend/platform/course/components/QuizForm.jsx b/frontend/platform/course/components/QuizForm.jsx
index 1c312a0..aa59335 100644
--- a/frontend/platform/course/components/QuizForm.jsx
+++ b/frontend/platform/course/components/QuizForm.jsx
@@ -323,7 +323,9 @@ const QuizForm = ({cancelCallback, successCallback, courseId, quizId, contentId,
diff --git a/tests/test_models/test_content_delivery.py b/tests/test_models/test_content_delivery.py
index e1f2e96..f42ae26 100644
--- a/tests/test_models/test_content_delivery.py
+++ b/tests/test_models/test_content_delivery.py
@@ -24,3 +24,9 @@ def test_content_delivery_unique_constraint(db, course_lesson_content, enrollmen
"content delivery with this enrollment and course content already exists"
in str(exc_info.value).lower()
)
+
+
+def test_content_delivery_link_generation(db, content_delivery: ContentDelivery):
+ link = content_delivery.link()
+ assert link.startswith("http")
+ assert "token=" in link
diff --git a/tests/test_models/test_quiz.py b/tests/test_models/test_quiz.py
new file mode 100644
index 0000000..e2d61c9
--- /dev/null
+++ b/tests/test_models/test_quiz.py
@@ -0,0 +1,32 @@
+from django_email_learning.models import Quiz, Question, Answer
+import pytest
+
+
+def quiz_with_n_questions(n) -> Quiz:
+ quiz = Quiz.objects.create(
+ title="Sample Quiz",
+ required_score=70,
+ deadline_days=7,
+ selection_strategy="random",
+ )
+ for i in range(n):
+ Question.objects.create(
+ quiz=quiz,
+ text=f"Question {i+1}",
+ priority=i + 1,
+ )
+ for j in range(4):
+ Answer.objects.create(
+ question=quiz.questions.last(),
+ text=f"Answer {j+1} for Question {i+1}",
+ is_correct=(j == 1),
+ )
+ return quiz
+
+
+@pytest.mark.parametrize("params", [(3, 3), (20, 13), (6, 5)])
+def test_random_question_ids(params, db):
+ quiz = quiz_with_n_questions(params[0])
+ question_ids = quiz.random_question_ids()
+ assert len(question_ids) == params[1]
+ assert all(isinstance(q_id, int) for q_id in question_ids)