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
17 changes: 17 additions & 0 deletions django_email_learning/migrations/0002_deliveryschedule_link.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
48 changes: 48 additions & 0 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.")
Expand All @@ -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:
Expand All @@ -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}"
Expand Down
12 changes: 5 additions & 7 deletions django_email_learning/templates/personalised/quiz_public.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@
{% load django_vite %}
{% block head_script %}
<script>
let quiz = {{ quiz|default:"null"|safe }};
let token = "{{ token }}";
let csrfToken = "{{ csrf_token }}";
let apiEndpoint = "{{ api_endpoint }}";
let error_message = "{{ error_message }}";
let ref = "{{ ref }}";
const quiz = {{ quiz|default:"null"|safe }};
const token = "{{ token }}";
const csrfToken = "{{ csrf_token }}";
const apiEndpoint = "{{ api_endpoint }}";
</script>
{% vite_asset 'my/quiz/Quiz.jsx' %}
{% vite_asset 'personalised/quiz_public/Quiz.jsx' %}
{% endblock %}
Original file line number Diff line number Diff line change
@@ -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 %}
1 change: 0 additions & 1 deletion django_service/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.sites",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
Expand Down
4 changes: 3 additions & 1 deletion frontend/platform/course/components/QuizForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,9 @@ const QuizForm = ({cancelCallback, successCallback, courseId, quizId, contentId,

<Grid size={{ xs: 12, md: 6 }}>
<Tooltip
title="Choose how questions are selected for each quiz attempt"
title="Choose how questions are selected for each quiz attempt,
if total questions is less than 6, all questions will be used even
if 'Random Questions' is selected"
placement="top-start"
>
<Box>
Expand Down
6 changes: 6 additions & 0 deletions tests/test_models/test_content_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 32 additions & 0 deletions tests/test_models/test_quiz.py
Original file line number Diff line number Diff line change
@@ -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)