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)