diff --git a/django_email_learning/admin.py b/django_email_learning/admin.py index 88f9c07..c547f35 100644 --- a/django_email_learning/admin.py +++ b/django_email_learning/admin.py @@ -1,17 +1,16 @@ from django.contrib import admin from django import forms -from django.http import HttpRequest from .models import ( Course, ImapConnection, - Quiz, - Lesson, - Question, - Answer, - CourseContent, Organization, OrganizationUser, BlockedEmail, + Enrollment, + ContentDelivery, + Learner, + DeliverySchedule, + QuizSubmission, ) @@ -45,51 +44,13 @@ def get_object(self, *args, **kwargs) -> ImapConnection | None: # type: ignore[ return obj -class QuizAdmin(admin.ModelAdmin): - list_display = ("title", "required_score", "is_published") - search_fields = ("title",) - list_filter = ("is_published",) - - def get_fields( - self, request: HttpRequest, obj: Quiz | None = None - ) -> tuple[str, ...]: - if obj is None: - return ("title", "required_score") - return ("title", "required_score", "is_published") - - -class AnswerInline(admin.TabularInline): - model = Answer - extra = 1 - - -class QuestionAdmin(admin.ModelAdmin): - inlines = [AnswerInline] - list_display = ("text", "quiz") - search_fields = ("text",) - list_filter = ("quiz",) - - -class CourseContentAdmin(admin.ModelAdmin): - list_filter = ("course", "type") - list_display = ("course", "priority", "type", "get_content_title") - ordering = ("course", "priority") - - def get_content_title(self, obj: CourseContent) -> str | None: - if obj.type == "lesson" and obj.lesson: - return obj.lesson.title - elif obj.type == "quiz" and obj.quiz: - return obj.quiz.title - return None - - admin.site.register(Course, CourseAdmin) admin.site.register(ImapConnection, ImapConnectionAdmin) -admin.site.register(Lesson) -admin.site.register(Quiz, QuizAdmin) -admin.site.register(CourseContent, CourseContentAdmin) -admin.site.register(Question, QuestionAdmin) -admin.site.register(Answer) admin.site.register(Organization) admin.site.register(BlockedEmail) admin.site.register(OrganizationUser) +admin.site.register(Enrollment) +admin.site.register(ContentDelivery) +admin.site.register(Learner) +admin.site.register(DeliverySchedule) +admin.site.register(QuizSubmission) diff --git a/django_email_learning/migrations/0001_initial.py b/django_email_learning/migrations/0001_initial.py index 9a0b3d3..9a91ecd 100644 --- a/django_email_learning/migrations/0001_initial.py +++ b/django_email_learning/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.8 on 2025-11-29 19:01 +# Generated by Django 6.0 on 2026-01-03 12:20 import django.core.validators import django.db.models.deletion @@ -32,7 +32,30 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name="EventTimestamp", + name="Course", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=200)), + ( + "slug", + models.SlugField( + help_text="A short label for the course, used in URLs or email interactive actions. You can not edit it later." + ), + ), + ("description", models.TextField(blank=True, null=True)), + ("enabled", models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name="DeliverySchedule", fields=[ ( "id", @@ -49,6 +72,7 @@ class Migration(migrations.Migration): db_index=True, default=django.utils.timezone.now ), ), + ("is_delivered", models.BooleanField(db_index=True, default=False)), ], ), migrations.CreateModel( @@ -172,7 +196,7 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name="Course", + name="CourseContent", fields=[ ( "id", @@ -183,29 +207,42 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("title", models.CharField(max_length=200)), + ("priority", models.IntegerField()), ( - "slug", - models.SlugField( - help_text="A short label for the course, used in URLs or email interactive actions. You can not edit it later." + "type", + models.CharField( + choices=[("lesson", "Lesson"), ("quiz", "Quiz")], max_length=50 + ), + ), + ( + "waiting_period", + models.IntegerField( + help_text="Waiting period in seconds after previous content is sent or submited." + ), + ), + ( + "course", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="django_email_learning.course", ), ), - ("description", models.TextField(blank=True, null=True)), - ("enabled", models.BooleanField(default=False)), ( - "imap_connection", + "lesson", models.ForeignKey( blank=True, null=True, - on_delete=django.db.models.deletion.SET_NULL, - to="django_email_learning.imapconnection", + on_delete=django.db.models.deletion.CASCADE, + to="django_email_learning.lesson", ), ), ( - "organization", + "quiz", models.ForeignKey( + blank=True, + null=True, on_delete=django.db.models.deletion.CASCADE, - to="django_email_learning.organization", + to="django_email_learning.quiz", ), ), ], @@ -271,6 +308,52 @@ class Migration(migrations.Migration): ), ], ), + migrations.CreateModel( + name="ContentDelivery", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("hash_value", models.CharField(blank=True, max_length=64, null=True)), + ( + "course_content", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="django_email_learning.coursecontent", + ), + ), + ( + "delivery_schedules", + models.ManyToManyField(to="django_email_learning.deliveryschedule"), + ), + ( + "enrollment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="django_email_learning.enrollment", + ), + ), + ], + options={ + "unique_together": {("enrollment", "course_content")}, + }, + ), + migrations.AddField( + model_name="course", + name="imap_connection", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="django_email_learning.imapconnection", + ), + ), migrations.AddField( model_name="imapconnection", name="organization", @@ -279,6 +362,14 @@ class Migration(migrations.Migration): to="django_email_learning.organization", ), ), + migrations.AddField( + model_name="course", + name="organization", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="django_email_learning.organization", + ), + ), migrations.CreateModel( name="OrganizationUser", fields=[ @@ -353,91 +444,6 @@ class Migration(migrations.Migration): to="django_email_learning.quiz", ), ), - migrations.CreateModel( - name="CourseContent", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("priority", models.IntegerField()), - ( - "type", - models.CharField( - choices=[("lesson", "Lesson"), ("quiz", "Quiz")], max_length=50 - ), - ), - ( - "waiting_period", - models.IntegerField( - help_text="Waiting period in seconds after previous content is sent or submited." - ), - ), - ( - "course", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="django_email_learning.course", - ), - ), - ( - "lesson", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="django_email_learning.lesson", - ), - ), - ( - "quiz", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - to="django_email_learning.quiz", - ), - ), - ], - ), - migrations.CreateModel( - name="SentItem", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("times_sent", models.IntegerField(default=1)), - ( - "course_content", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="django_email_learning.coursecontent", - ), - ), - ( - "enrollment", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="django_email_learning.enrollment", - ), - ), - ( - "send_events", - models.ManyToManyField(to="django_email_learning.eventtimestamp"), - ), - ], - ), migrations.CreateModel( name="QuizSubmission", fields=[ @@ -454,10 +460,10 @@ class Migration(migrations.Migration): ("is_passed", models.BooleanField()), ("submitted_at", models.DateTimeField(auto_now_add=True)), ( - "sent_item", + "delivery", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - to="django_email_learning.sentitem", + to="django_email_learning.contentdelivery", ), ), ], @@ -476,8 +482,26 @@ class Migration(migrations.Migration): name="course", unique_together={("slug", "organization"), ("title", "organization")}, ), - migrations.AlterUniqueTogether( - name="sentitem", - unique_together={("enrollment", "course_content")}, + migrations.AddConstraint( + model_name="coursecontent", + constraint=models.UniqueConstraint( + condition=models.Q(("quiz__isnull", False)), + fields=("course", "quiz"), + name="unique_quiz_per_course", + ), + ), + migrations.AddConstraint( + model_name="coursecontent", + constraint=models.UniqueConstraint( + condition=models.Q(("lesson__isnull", False)), + fields=("course", "lesson"), + name="unique_lesson_per_course", + ), + ), + migrations.AddConstraint( + model_name="coursecontent", + constraint=models.UniqueConstraint( + fields=("course", "priority"), name="unique_priority_per_course" + ), ), ] diff --git a/django_email_learning/models.py b/django_email_learning/models.py index 024fdb9..4dcc76a 100644 --- a/django_email_learning/models.py +++ b/django_email_learning/models.py @@ -1,6 +1,8 @@ import base64 import ipaddress import re +import uuid +from enum import StrEnum from typing import Any from django.conf import settings from django.db import models @@ -309,12 +311,25 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] super().save(*args, **kwargs) +class EnrollmentStatus(StrEnum): + UNVERIFIED = "unverified" + ACTIVE = "active" + COMPLETED = "completed" + DEACTIVATED = "deactivated" + + class Enrollment(models.Model): state_transitions = { - "unverified": ["active", "deactivated"], - "active": ["completed", "deactivated"], - "completed": [], - "deactivated": [], + EnrollmentStatus.UNVERIFIED: [ + EnrollmentStatus.ACTIVE, + EnrollmentStatus.DEACTIVATED, + ], + EnrollmentStatus.ACTIVE: [ + EnrollmentStatus.COMPLETED, + EnrollmentStatus.DEACTIVATED, + ], + EnrollmentStatus.COMPLETED: [], + EnrollmentStatus.DEACTIVATED: [], } learner = models.ForeignKey(Learner, on_delete=models.CASCADE) course = models.ForeignKey(Course, on_delete=models.CASCADE) @@ -323,12 +338,12 @@ class Enrollment(models.Model): status = models.CharField( max_length=50, choices=[ - ("unverified", "Unverified"), - ("active", "Active"), - ("completed", "Completed"), - ("deactivated", "Deactivated"), + (EnrollmentStatus.UNVERIFIED, "Unverified"), + (EnrollmentStatus.ACTIVE, "Active"), + (EnrollmentStatus.COMPLETED, "Completed"), + (EnrollmentStatus.DEACTIVATED, "Deactivated"), ], - default="unverified", + default=EnrollmentStatus.UNVERIFIED, ) deactivation_reason = models.CharField( null=True, @@ -346,6 +361,7 @@ class Enrollment(models.Model): def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] if self.pk: old_status = Enrollment.objects.get(pk=self.pk).status + old_status = EnrollmentStatus(old_status) if old_status != self.status: allowed_transitions = self.state_transitions.get(old_status, []) if self.status not in allowed_transitions: @@ -363,6 +379,9 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] self.full_clean() super().save(*args, **kwargs) + def __str__(self) -> str: + return f"{self.learner.email} - {self.course.title} ({self.status})" + class Meta: constraints = [ models.UniqueConstraint( @@ -373,35 +392,60 @@ class Meta: ] -class EventTimestamp(models.Model): +class DeliverySchedule(models.Model): time = models.DateTimeField(default=timezone.now, db_index=True) + is_delivered = models.BooleanField(default=False, db_index=True) + + def __str__(self) -> str: + return f"Delivery at {self.time} - Delivered: {self.is_delivered}" -class SentItem(models.Model): +class ContentDelivery(models.Model): enrollment = models.ForeignKey(Enrollment, on_delete=models.CASCADE) course_content = models.ForeignKey(CourseContent, on_delete=models.CASCADE) - send_events = models.ManyToManyField(EventTimestamp) - times_sent = models.IntegerField(default=1) + delivery_schedules = models.ManyToManyField(DeliverySchedule) + hash_value = models.CharField(max_length=64, null=True, blank=True) class Meta: unique_together = [["enrollment", "course_content"]] + @property + def times_delivered(self) -> int: + return self.delivery_schedules.filter(is_delivered=True).count() + + def update_hash(self) -> None: + self.hash_value = ( + base64.urlsafe_b64encode(uuid.uuid4().bytes).decode().rstrip("=") + ) + self.save() + 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("=") + ) super().save(*args, **kwargs) - if not self.send_events.exists(): - timestamp = EventTimestamp.objects.create() - self.send_events.add(timestamp) + + def __str__(self) -> str: + return f"Delivery of {self.course_content.title} to {self.enrollment.learner.email}" class QuizSubmission(models.Model): - sent_item = models.ForeignKey(SentItem, on_delete=models.CASCADE) + delivery = models.ForeignKey(ContentDelivery, on_delete=models.CASCADE) score = models.IntegerField() is_passed = models.BooleanField() submitted_at = models.DateTimeField(auto_now_add=True) def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] - if self.sent_item.course_content.type != "quiz": + if self.delivery.course_content.type != "quiz": raise ValidationError("Sent item must be associated with a quiz content.") + already_submitted = QuizSubmission.objects.filter( + delivery=self.delivery + ).count() + if already_submitted >= self.delivery.times_delivered: + raise ValidationError( + "Quiz submission count exceeds the number of times the quiz was sent." + ) self.full_clean() super().save(*args, **kwargs) diff --git a/django_email_learning/api/__init__.py b/django_email_learning/personalised/api/__init__.py similarity index 100% rename from django_email_learning/api/__init__.py rename to django_email_learning/personalised/api/__init__.py diff --git a/django_email_learning/personalised/api/serializers.py b/django_email_learning/personalised/api/serializers.py new file mode 100644 index 0000000..825d20d --- /dev/null +++ b/django_email_learning/personalised/api/serializers.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class QuestionResponse(BaseModel): + id: int + answers: list[int] + + +class QuizSubmissionRequest(BaseModel): + answers: list[QuestionResponse] + token: str diff --git a/django_email_learning/personalised/api/urls.py b/django_email_learning/personalised/api/urls.py new file mode 100644 index 0000000..e46ab8c --- /dev/null +++ b/django_email_learning/personalised/api/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from django_email_learning.personalised.api.views import QuizSubmissionView + +app_name = "email_learning" + +urlpatterns = [ + path("quiz/", QuizSubmissionView.as_view(), name="quiz_submission"), +] diff --git a/django_email_learning/personalised/api/views.py b/django_email_learning/personalised/api/views.py new file mode 100644 index 0000000..6a195b0 --- /dev/null +++ b/django_email_learning/personalised/api/views.py @@ -0,0 +1,150 @@ +from django.http import JsonResponse +from django.views import View +from django_email_learning.personalised.api.serializers import ( + QuizSubmissionRequest, + QuestionResponse, +) +from django_email_learning.services import jwt_service +from django_email_learning.models import ( + ContentDelivery, + QuizSubmission, + Quiz, + EnrollmentStatus, +) +from pydantic import ValidationError +import json +import logging + + +class QuizSubmissionView(View): + def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def] + payload = json.loads(request.body) + try: + serializer = QuizSubmissionRequest.model_validate(payload) + except ValidationError as ve: + return JsonResponse({"error": ve.errors()}, status=400) + + token = serializer.token + answers = serializer.answers + + 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) + + delivery_id = decoded["delivery_id"] + + try: + delivery = ContentDelivery.objects.get( + id=delivery_id, hash_value=decoded["delivery_hash"] + ) + except ContentDelivery.DoesNotExist: + return JsonResponse( + { + "error": "The content delivery associated with this token does not exist." + }, + status=500, + ) + + enrolment = delivery.enrollment + if enrolment.status != EnrollmentStatus.ACTIVE: + return JsonResponse({"error": "Quiz is not valid anymore"}, status=400) + + quiz = delivery.course_content.quiz + if not quiz: + return JsonResponse( + {"error": "No quiz associated with this link"}, status=500 + ) + + try: + score, passed = self.calculate_score_and_passed( + quiz, answers, decoded.get("question_ids") + ) + except ValueError as ve: + return JsonResponse({"error": str(ve)}, status=500) + + QuizSubmission.objects.create( + delivery=delivery, + score=score, + is_passed=passed, + ) + # Updating the ContentDelivery hash value to invalidate the quiz link + delivery.update_hash() + + if passed: + # TODO: Update the next content_delivery if the quiz is passed + pass + else: + # TODO: Schedule the second quiz attempt if the quiz is failed + pass + + return JsonResponse( + { + "score": score, + "passed": passed, + "required_score": quiz.required_score, + }, + status=200, + ) + + @staticmethod + def calculate_score_and_passed( + quiz: Quiz, answers: list[QuestionResponse], question_ids: list | None + ) -> tuple[int, bool]: + # Optimize: Prefetch related answers to avoid N+1 queries + questions = quiz.questions.prefetch_related("answers").all() + + if question_ids is None: + question_ids = list(questions.values_list("id", flat=True)) + + # Create lookup dictionaries for O(1) access + questions_dict = {q.id: q for q in questions} + answers_dict = {} + correct_answers_count = {} + + # Pre-populate answer lookup and count correct answers per question + for question_obj in questions: + answers_dict[question_obj.id] = { + a.id: a for a in question_obj.answers.all() + } + correct_answers_count[question_obj.id] = question_obj.answers.filter( + is_correct=True + ).count() + + base_score = 0.0 + + for response in answers: + if response.id not in question_ids: + raise ValueError( + f"Question ID {response.id} is not valid for this quiz." + ) + + question = questions_dict.get(response.id) + if not question: + raise ValueError(f"Question ID {response.id} not found.") + + for answer_id in response.answers: + # Check if answer exists for this question + if answer_id not in answers_dict[response.id]: + raise ValueError( + f"Answer ID {answer_id} is not valid for Question ID {response.id}." + ) + + answer = answers_dict[response.id][answer_id] + correct_count = correct_answers_count[response.id] + + if answer.is_correct: + base_score += 1 / correct_count # Full point for correct answer + else: + base_score -= 0.5 / correct_count # Penalty for incorrect answer + + logging.info( + f"User submitted quiz with id {quiz.id} with base score {base_score}" + ) + + score = round(base_score / len(question_ids) * 100) # Score as percentage + score = max(0, score) + passed = score >= quiz.required_score + return score, passed diff --git a/django_email_learning/personalised/serializers.py b/django_email_learning/personalised/serializers.py new file mode 100644 index 0000000..246ebac --- /dev/null +++ b/django_email_learning/personalised/serializers.py @@ -0,0 +1,43 @@ +from pydantic import ( + BaseModel, + ConfigDict, + field_serializer, +) +from typing import Any + + +class PublicAnswerSerializer(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + text: str + + +class PublicQuestionSerializer(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + text: str + answers: Any + + @field_serializer("answers") + def serialize_answers(self, answers: Any) -> list[dict]: + return [ + PublicAnswerSerializer.model_validate(answer).model_dump() + for answer in answers.all() + ] + + +class PublicQuizSerializer(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + title: str + questions: Any + + @field_serializer("questions") + def serialize_questions(self, questions: Any) -> list[dict]: + return [ + PublicQuestionSerializer.model_validate(question).model_dump() + for question in questions.all() + ] diff --git a/django_email_learning/personalised/urls.py b/django_email_learning/personalised/urls.py new file mode 100644 index 0000000..93df5f4 --- /dev/null +++ b/django_email_learning/personalised/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from django_email_learning.personalised.views import QuizPublicView + +app_name = "email_learning" + +urlpatterns = [ + path("quiz/", QuizPublicView.as_view(), name="quiz_public_view"), +] diff --git a/django_email_learning/personalised/views.py b/django_email_learning/personalised/views.py new file mode 100644 index 0000000..d1761c8 --- /dev/null +++ b/django_email_learning/personalised/views.py @@ -0,0 +1,86 @@ +from django.views import View +from django.views.generic.base import TemplateResponseMixin +from django.http import HttpResponse +from django.urls import reverse +from django_email_learning.models import ContentDelivery, EnrollmentStatus +from django_email_learning.services import jwt_service +from django_email_learning.personalised.serializers import PublicQuizSerializer +import uuid +import logging + + +class QuizPublicView(View, TemplateResponseMixin): + template_name = "personalised/quiz_public.html" + + def get(self, request, *args, **kwargs) -> HttpResponse: # type: ignore[no-untyped-def] + try: + token = request.GET["token"] + decoded = jwt_service.decode_jwt(token=token) + question_ids = decoded.get("question_ids", []) + delivery = ContentDelivery.objects.get( + id=decoded["delivery_id"], hash_value=decoded["delivery_hash"] + ) + enrolment = delivery.enrollment + if enrolment.status != EnrollmentStatus.ACTIVE: + return self.errr_response( + message="Quiz is not valid anymore", + exception=ValueError("Enrolment is not active"), + ) + quiz = delivery.course_content.quiz + if not quiz: + return self.errr_response( + message="No quiz associated with this link", exception=None + ) + if not quiz.is_published: + return self.errr_response( + message="No valid quiz associated with this link", + exception=ValueError("Quiz is not published"), + ) + quiz_data = PublicQuizSerializer.model_validate(quiz).model_dump() + if question_ids: + quiz_data["questions"] = [ + q for q in quiz_data["questions"] if q["id"] in question_ids + ] + return self.render_to_response( + context={ + "quiz": quiz_data, + "token": token, + "csrf_token": request.META.get("CSRF_COOKIE", ""), + "api_endpoint": reverse( + "django_email_learning:api_personalised:quiz_submission" + ), + } + ) + + except ContentDelivery.DoesNotExist as e: + return self.errr_response( + message="An error occurred while retrieving the quiz", exception=e + ) + except KeyError as e: + return self.errr_response( + message="The link is not valid", exception=e, status_code=400 + ) + except jwt_service.InvalidTokenException as e: + return self.errr_response( + message="The link is not valid", exception=e, status_code=400 + ) + except jwt_service.ExpiredTokenException as e: + return self.errr_response( + message="The link has expired", exception=e, status_code=410 + ) + + def errr_response( + self, message: str, exception: Exception | None, status_code: int = 500 + ) -> HttpResponse: + error_ref = uuid.uuid4().hex + if exception: + logging.exception( + f"{message} - Ref: {error_ref}", extra={"error_ref": error_ref} + ) + else: + logging.error( + f"{message} - Ref: {error_ref}", extra={"error_ref": error_ref} + ) + return self.render_to_response( + context={"ref": error_ref, "error_message": message}, status=status_code + ) diff --git a/tests/api/__init__.py b/django_email_learning/platform/api/__init__.py similarity index 100% rename from tests/api/__init__.py rename to django_email_learning/platform/api/__init__.py diff --git a/django_email_learning/api/serializers.py b/django_email_learning/platform/api/serializers.py similarity index 100% rename from django_email_learning/api/serializers.py rename to django_email_learning/platform/api/serializers.py diff --git a/django_email_learning/api/urls.py b/django_email_learning/platform/api/urls.py similarity index 96% rename from django_email_learning/api/urls.py rename to django_email_learning/platform/api/urls.py index 32ed7df..7de1e23 100644 --- a/django_email_learning/api/urls.py +++ b/django_email_learning/platform/api/urls.py @@ -1,6 +1,6 @@ from django.urls import path from django.views.defaults import page_not_found -from django_email_learning.api.views import ( +from django_email_learning.platform.api.views import ( CourseView, ImapConnectionView, OrganizationsView, diff --git a/django_email_learning/api/views.py b/django_email_learning/platform/api/views.py similarity index 99% rename from django_email_learning/api/views.py rename to django_email_learning/platform/api/views.py index 1625c84..1720f06 100644 --- a/django_email_learning/api/views.py +++ b/django_email_learning/platform/api/views.py @@ -8,7 +8,7 @@ from pydantic import ValidationError -from django_email_learning.api import serializers +from django_email_learning.platform.api import serializers from django_email_learning.models import ( Course, CourseContent, diff --git a/django_email_learning/platform/urls.py b/django_email_learning/platform/urls.py index 552eb7d..29fe623 100644 --- a/django_email_learning/platform/urls.py +++ b/django_email_learning/platform/urls.py @@ -10,7 +10,9 @@ path("organizations/", Organizations.as_view(), name="organizations_view"), path( "", - RedirectView.as_view(pattern_name="email_learning:platform:courses_view"), + RedirectView.as_view( + pattern_name="django_email_learning:platform:courses_view" + ), name="root", ), ] diff --git a/django_email_learning/platform/views.py b/django_email_learning/platform/views.py index e265736..7d2c05d 100644 --- a/django_email_learning/platform/views.py +++ b/django_email_learning/platform/views.py @@ -31,7 +31,7 @@ def get_shared_context(self) -> Dict[str, Any]: organization_id=active_organization_id, ).role return { - "api_base_url": reverse("django_email_learning:api:root")[:-1], + "api_base_url": reverse("django_email_learning:api_platform:root")[:-1], "platform_base_url": reverse("django_email_learning:platform:root")[:-1], "active_organization_id": active_organization_id, "user_role": role, diff --git a/django_email_learning/services/jwt_service.py b/django_email_learning/services/jwt_service.py index 22091d4..4471a6d 100644 --- a/django_email_learning/services/jwt_service.py +++ b/django_email_learning/services/jwt_service.py @@ -1,18 +1,33 @@ from django.conf import settings -from datetime import datetime, timedelta +import datetime import jwt SECRET = settings.SECRET_KEY ALGORITHM = "HS256" +class InvalidTokenException(Exception): + pass + + +class ExpiredTokenException(Exception): + pass + + def generate_jwt(payload: dict, expiration_seconds: int = 3600) -> str: payload_copy = payload.copy() - payload_copy["exp"] = datetime.utcnow() + timedelta(seconds=expiration_seconds) + payload_copy["exp"] = datetime.datetime.now(datetime.UTC) + datetime.timedelta( + seconds=expiration_seconds + ) token = jwt.encode(payload_copy, SECRET, algorithm=ALGORITHM) return token def decode_jwt(token: str) -> dict: - decoded = jwt.decode(token, SECRET, algorithms=[ALGORITHM]) - return decoded + try: + decoded = jwt.decode(token, SECRET, algorithms=[ALGORITHM]) + return decoded + except (jwt.InvalidSignatureError, jwt.DecodeError, jwt.InvalidAlgorithmError): + raise InvalidTokenException("The signature is invalid") + except jwt.ExpiredSignatureError: + raise ExpiredTokenException("The token is not valid anymore") diff --git a/django_email_learning/templates/personalised/quiz_public.html b/django_email_learning/templates/personalised/quiz_public.html new file mode 100644 index 0000000..915ff6a --- /dev/null +++ b/django_email_learning/templates/personalised/quiz_public.html @@ -0,0 +1,26 @@ +{% load django_vite %} +{% load static %} + + + + {% vite_hmr_client %} + {% vite_react_refresh %} + + {% vite_asset 'my/quiz/Quiz.jsx' %} + + + + {% block title %}{{ page_title }}{% endblock %} + + +
+
+ + diff --git a/django_email_learning/urls.py b/django_email_learning/urls.py index 07d1e85..9bcc1e9 100644 --- a/django_email_learning/urls.py +++ b/django_email_learning/urls.py @@ -1,10 +1,17 @@ from django.urls import path, include -from django_email_learning.api import urls as api_urls +from django_email_learning.platform.api import urls as api_urls from django_email_learning.platform import urls as platform_urls +from django_email_learning.personalised.api import urls as personalised_api_urls +from django_email_learning.personalised import urls as personalised_urls app_name = "django_email_learning" urlpatterns = [ - path("api/", include(api_urls, namespace="api")), + path("api/platform/", include(api_urls, namespace="api_platform")), + path( + "api/personalised/", + include(personalised_api_urls, namespace="api_personalised"), + ), path("platform/", include(platform_urls, namespace="platform")), + path("my/", include(personalised_urls, namespace="personalised")), ] diff --git a/django_service/settings.py b/django_service/settings.py index 817f8a4..685ea22 100644 --- a/django_service/settings.py +++ b/django_service/settings.py @@ -105,9 +105,16 @@ LOGGING = { "version": 1, "disable_existing_loggers": False, + "formatters": { + "json": { + "()": "pythonjsonlogger.json.JsonFormatter", + "format": "%(asctime)s %(levelname)s %(name)s %(message)s", + }, + }, "handlers": { "console": { "class": "logging.StreamHandler", + "formatter": "json", }, }, "root": { diff --git a/frontend/my/quiz/Quiz.jsx b/frontend/my/quiz/Quiz.jsx new file mode 100644 index 0000000..6e097b5 --- /dev/null +++ b/frontend/my/quiz/Quiz.jsx @@ -0,0 +1,114 @@ +import render from '../../src/render.jsx'; +import { useState } from 'react'; +import { Alert, Box, Button, Checkbox, FormControlLabel, GlobalStyles, Typography, Dialog } from '@mui/material'; +import CelebrationIcon from '@mui/icons-material/Celebration'; +import SentimentVeryDissatisfiedIcon from '@mui/icons-material/SentimentVeryDissatisfied'; + + +const Quiz = () => { + + const [dialogOpen, setDialogOpen] = useState(false); + const [selectedAnswers, setSelectedAnswers] = useState(quiz? quiz.questions.map(q => ({"id": q.id, "answers": []})) : []); + const [warning, setWarning] = useState(""); + const [showQuestions, setShowQuestions] = useState(true); + const [isPassed, setIsPassed] = useState(null); + const [score, setScore] = useState(null); + + if (error_message) { + console.log("Error:", error_message, ref); + } else { + console.log("No error"); + } + + const showSubmitDialog = () => { + setDialogOpen(true); + let answerCounter = 0; + for (let i = 0; i < selectedAnswers.length; i++) { + answerCounter += selectedAnswers[i].answers.length; + } + if (answerCounter === 0) { + setWarning("You have not selected any answers. Are you sure you want to submit an empty quiz?"); + } else { + setWarning(""); + } + } + + const submitQuiz = () => { + fetch(`${apiEndpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrfToken + }, + body: JSON.stringify({ token: token, answers: selectedAnswers }), + }) + .then((response) => response.json()) + .then((data) => { + setDialogOpen(false); + setShowQuestions(false); + setIsPassed(data.passed); + setScore(data.score); + }) + .catch(() => { + console.error("Error submitting quiz"); + }); + } + + + return <> + ({ body: { margin: 0, padding: 0, backgroundColor: theme.palette.background.dark, color: theme.palette.text.primary } })} /> + + + { !error_message ? + + {quiz.title} + + {showQuestions ? + + Please select all correct answers for each question. Note that some questions may have multiple correct answers. + This quiz uses negative marking for incorrect choices; if you are unsure, it is better to leave the question unanswered. + + + + {quiz.questions.map((question, index) => ( + + {question.text} + {question.answers.map((answer, cIndex) => ( + { if (!e.target.checked) { + const newSelectedAnswers = [...selectedAnswers]; + const questionAnswers = newSelectedAnswers.find(qa => qa.id === question.id); + questionAnswers.answers = questionAnswers.answers.filter(aid => aid !== answer.id); + setSelectedAnswers(newSelectedAnswers); + } else { + const newSelectedAnswers = [...selectedAnswers]; + const questionAnswers = newSelectedAnswers.find(qa => qa.id === question.id); + questionAnswers.answers.push(answer.id); + setSelectedAnswers(newSelectedAnswers); + } }} />} label={answer.text} key={cIndex} sx={{display: 'block', fontSize: 'small'}}/> + ))} + + ))} + + + + : + {isPassed !== null && (isPassed ? Congratulations! You have passed the quiz. Your score is {score}%. : + Unfortunately, you did not pass the quiz. Your score is {score}%.)} + } + : Error loading quiz: {error_message} {ref && `(Ref: ${ref})`}} + setDialogOpen(false)} fullWidth maxWidth="sm"> + + Ready to submit? + {warning ? {warning} : + Please keep in mind that this quiz uses negative marking for incorrect answers. If you are unsure of an answer, it may be better to leave it blank.} + + + + + + + + +} + +render({children: }); diff --git a/frontend/my/quiz/index.html b/frontend/my/quiz/index.html new file mode 100644 index 0000000..14fed87 --- /dev/null +++ b/frontend/my/quiz/index.html @@ -0,0 +1,12 @@ + + + + + + Quiz + + +
+ + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 65f6ef5..6b014f0 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -28,7 +28,8 @@ export default defineConfig({ courses: resolve(__dirname, 'courses/index.html'), course: resolve(__dirname, 'course/index.html'), organizations: resolve(__dirname, 'organizations/index.html'), - users: resolve(__dirname, 'users/index.html') + users: resolve(__dirname, 'users/index.html'), + quiz: resolve(__dirname, "my/index.html") } }, manifest: 'manifest.json', diff --git a/poetry.lock b/poetry.lock index 68f10b7..bf7ea5e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1241,6 +1241,21 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "python-json-logger" +version = "4.0.0" +description = "JSON Log Formatter for the Python Logging Package" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2"}, + {file = "python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f"}, +] + +[package.extras] +dev = ["backports.zoneinfo ; python_version < \"3.9\"", "black", "build", "freezegun", "mdx_truly_sane_lists", "mike", "mkdocs", "mkdocs-awesome-pages-plugin", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-material (>=8.5)", "mkdocstrings[python]", "msgspec ; implementation_name != \"pypy\"", "mypy", "orjson ; implementation_name != \"pypy\"", "pylint", "pytest", "tzdata", "validate-pyproject[all]"] + [[package]] name = "pyyaml" version = "6.0.3" diff --git a/pyproject.toml b/pyproject.toml index 92f2913..e006b93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,8 @@ dev = [ "pre-commit (>=4.4.0,<5.0.0)", "bandit (>=1.8.6,<2.0.0)", "pytest-cov (>=7.0.0,<8.0.0)", - "freezegun (>=1.5.5,<2.0.0)" + "freezegun (>=1.5.5,<2.0.0)", + "python-json-logger (>=4.0.0,<5.0.0)" ] [build-system] diff --git a/tests/conftest.py b/tests/conftest.py index 077959d..b9205ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,11 @@ Learner, Enrollment, CourseContent, + Question, + Answer, + EnrollmentStatus, + ContentDelivery, + DeliverySchedule, ) import pytest @@ -176,3 +181,45 @@ def course_quiz_content(db, course, quiz) -> CourseContent: course=course, priority=2, type="quiz", quiz=quiz, waiting_period=3600 ) return content + + +@pytest.fixture +def quiz_with_questions(db, quiz) -> Quiz: + questions = [] + question = Question.objects.create( + quiz=quiz, + text="Question?", + priority=1, + ) + for i in range(4): + Answer.objects.create( + question=question, + text=f"Answer {i+1}", + is_correct=(i == 0), # First answer is correct + ) + questions.append(question) + quiz.is_published = True + quiz.save() + return quiz + + +@pytest.fixture +def active_enrollment(db, learner, course): + enrollment = Enrollment.objects.create( + learner=learner, course=course, status=EnrollmentStatus.ACTIVE + ) + return enrollment + + +@pytest.fixture +def content_delivery(db, active_enrollment, course_quiz_content, quiz_with_questions): + course_quiz_content.quiz = quiz_with_questions + course_quiz_content.save() + + delivery = ContentDelivery.objects.create( + enrollment=active_enrollment, + course_content_id=course_quiz_content.id, + hash_value="testhash", + ) + delivery.delivery_schedules.add(DeliverySchedule.objects.create(is_delivered=True)) + return delivery diff --git a/tests/personalised/api/test_views/test_quiz_submission.py b/tests/personalised/api/test_views/test_quiz_submission.py new file mode 100644 index 0000000..5ccd353 --- /dev/null +++ b/tests/personalised/api/test_views/test_quiz_submission.py @@ -0,0 +1,72 @@ +from django_email_learning.services import jwt_service +from django_email_learning.personalised.api.views import QuizSubmissionView +from django_email_learning.personalised.api.serializers import QuestionResponse +from django.urls import reverse + + +URL = reverse("django_email_learning:api_personalised:quiz_submission") + + +def test_quiz_submission_api_valid_token(content_delivery, anonymous_client): + token = jwt_service.generate_jwt( + { + "delivery_id": content_delivery.id, + "delivery_hash": content_delivery.hash_value, + } + ) + + response = anonymous_client.post( + URL, + data={ + "token": token, + "answers": [ + {"id": q.id, "answers": []} + for q in content_delivery.course_content.quiz.questions.all() + ], + }, + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json()["score"] == 0 + assert response.json()["passed"] is False + assert ( + response.json()["required_score"] + == content_delivery.course_content.quiz.required_score + ) + + # verify that the hash value has been updated + content_delivery.refresh_from_db() + assert content_delivery.hash_value != jwt_service.decode_jwt(token)["delivery_hash"] + + +def test_quiz_public_view_invalid_token(content_delivery, anonymous_client): + response = anonymous_client.post( + URL, + data={ + "token": "Invalid", + "answers": [ + {"id": q.id, "answers": []} + for q in content_delivery.course_content.quiz.questions.all() + ], + }, + content_type="application/json", + ) + assert response.status_code == 400 + assert "The signature is invalid" in response.json()["error"] + + +def test_calculate_score_and_passed_static_method(quiz_with_questions): + answers = [] + for question in quiz_with_questions.questions.all(): + correct_answer_ids = list( + question.answers.filter(is_correct=True).values_list("id", flat=True) + ) + answers.append(QuestionResponse(id=question.id, answers=correct_answer_ids)) + + score, passed = QuizSubmissionView.calculate_score_and_passed( + quiz_with_questions, answers, question_ids=None + ) + + assert score == 100 + assert passed is True diff --git a/tests/personalised/test_views/test_quiz_public_view.py b/tests/personalised/test_views/test_quiz_public_view.py new file mode 100644 index 0000000..8a2b709 --- /dev/null +++ b/tests/personalised/test_views/test_quiz_public_view.py @@ -0,0 +1,28 @@ +from django_email_learning.services import jwt_service +from django.urls import reverse + + +URL = reverse("django_email_learning:personalised:quiz_public_view") + + +def test_quiz_public_view_valid_token(content_delivery, anonymous_client): + token = jwt_service.generate_jwt( + { + "delivery_id": content_delivery.id, + "delivery_hash": content_delivery.hash_value, + } + ) + + response = anonymous_client.get(f"{URL}?token={token}") + assert response.status_code == 200 + assert "quiz" in response.context + assert response.context["quiz"]["id"] == content_delivery.course_content.quiz.id + + +def test_quiz_public_view_invalid_token(anonymous_client): + response = anonymous_client.get(f"{URL}?token=invalidtoken") + assert response.status_code == 400 + assert "The link is not valid" in response.content.decode() + + +# TODO: Add more tests for various scenarios diff --git a/tests/api/test_views/__init__.py b/tests/platform/api/__init__.py similarity index 100% rename from tests/api/test_views/__init__.py rename to tests/platform/api/__init__.py diff --git a/tests/platform/api/test_views/__init__.py b/tests/platform/api/test_views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/test_views/test_course_content_view.py b/tests/platform/api/test_views/test_course_content_view.py similarity index 98% rename from tests/api/test_views/test_course_content_view.py rename to tests/platform/api/test_views/test_course_content_view.py index c2cd504..150d65d 100644 --- a/tests/api/test_views/test_course_content_view.py +++ b/tests/platform/api/test_views/test_course_content_view.py @@ -10,7 +10,7 @@ def get_url() -> str: return reverse( - "django_email_learning:api:course_content_view", + "django_email_learning:api_platform:course_content_view", kwargs={"organization_id": 1, "course_id": 1}, ) @@ -19,7 +19,7 @@ def single_content_url( course_content_id: int, course_id: int, organization_id: int = 1 ) -> str: return reverse( - "django_email_learning:api:single_course_content_view", + "django_email_learning:api_platform:single_course_content_view", kwargs={ "organization_id": organization_id, "course_id": course_id, @@ -44,7 +44,7 @@ def valid_create_course_payload( @pytest.fixture() def create_course(superadmin_client): url = reverse( - "django_email_learning:api:course_view", + "django_email_learning:api_platform:course_view", kwargs={"organization_id": 1}, ) payload = valid_create_course_payload() @@ -381,7 +381,7 @@ def test_delete_course_content(superadmin_client, create_course): # Delete the created course content delete_url = reverse( - "django_email_learning:api:single_course_content_view", + "django_email_learning:api_platform:single_course_content_view", kwargs={ "organization_id": 1, "course_id": 1, diff --git a/tests/api/test_views/test_course_view.py b/tests/platform/api/test_views/test_course_view.py similarity index 96% rename from tests/api/test_views/test_course_view.py rename to tests/platform/api/test_views/test_course_view.py index b9dc6f8..fbc4a29 100644 --- a/tests/api/test_views/test_course_view.py +++ b/tests/platform/api/test_views/test_course_view.py @@ -8,7 +8,7 @@ def get_url(organization_id: int) -> str: return reverse( - "django_email_learning:api:course_view", + "django_email_learning:api_platform:course_view", kwargs={"organization_id": organization_id}, ) @@ -216,7 +216,7 @@ def test_update_course_success(superadmin_client): # Now, update the created course update_payload = valid_update_course_payload() update_url = reverse( - "django_email_learning:api:single_course_view", + "django_email_learning:api_platform:single_course_view", kwargs={"organization_id": 1, "course_id": course_id}, ) update_response = superadmin_client.post( @@ -244,7 +244,7 @@ def test_slug_change_not_allowed(superadmin_client): ) update_payload["slug"] = "new-slug" # Attempt to change slug update_url = reverse( - "django_email_learning:api:single_course_view", + "django_email_learning:api_platform:single_course_view", kwargs={"organization_id": 1, "course_id": course_id}, ) update_response = superadmin_client.post( @@ -257,7 +257,7 @@ def test_slug_change_not_allowed(superadmin_client): def test_update_course_not_found(superadmin_client): update_payload = valid_update_course_payload() update_url = reverse( - "django_email_learning:api:single_course_view", + "django_email_learning:api_platform:single_course_view", kwargs={"organization_id": 1, "course_id": 9999}, ) update_response = superadmin_client.post( @@ -283,7 +283,7 @@ def test_update_course_reset_imap_connection_conflict(sample_course, superadmin_ imap_connection_id=1, reset_imap_connection=True ) update_url = reverse( - "django_email_learning:api:single_course_view", + "django_email_learning:api_platform:single_course_view", kwargs={"organization_id": 1, "course_id": course_id}, ) update_response = superadmin_client.post( @@ -299,7 +299,7 @@ def test_update_course_reset_imap_connection_conflict(sample_course, superadmin_ def test_viewer_not_allowed_to_delete_course(sample_course, viewer_client): url = reverse( - "django_email_learning:api:single_course_view", + "django_email_learning:api_platform:single_course_view", kwargs={"organization_id": 1, "course_id": sample_course["id"]}, ) delete_response = viewer_client.delete(url) @@ -312,7 +312,7 @@ def test_editor_can_delete_course(sample_course, editor_client): assert len(courses.json().get("courses")) == 1 url = reverse( - "django_email_learning:api:single_course_view", + "django_email_learning:api_platform:single_course_view", kwargs={"organization_id": 1, "course_id": sample_course["id"]}, ) delete_response = editor_client.delete(url) diff --git a/tests/api/test_views/test_imap_connection_view.py b/tests/platform/api/test_views/test_imap_connection_view.py similarity index 97% rename from tests/api/test_views/test_imap_connection_view.py rename to tests/platform/api/test_views/test_imap_connection_view.py index 818791d..2a5a104 100644 --- a/tests/api/test_views/test_imap_connection_view.py +++ b/tests/platform/api/test_views/test_imap_connection_view.py @@ -4,7 +4,7 @@ def get_url(organization_id: int) -> str: return reverse( - "django_email_learning:api:imap_connection_view", + "django_email_learning:api_platform:imap_connection_view", kwargs={"organization_id": organization_id}, ) diff --git a/tests/api/test_views/test_organizations_view.py b/tests/platform/api/test_views/test_organizations_view.py similarity index 96% rename from tests/api/test_views/test_organizations_view.py rename to tests/platform/api/test_views/test_organizations_view.py index dd3a997..d7e25de 100644 --- a/tests/api/test_views/test_organizations_view.py +++ b/tests/platform/api/test_views/test_organizations_view.py @@ -4,7 +4,7 @@ def get_url() -> str: - return reverse("django_email_learning:api:organizations_view") + return reverse("django_email_learning:api_platform:organizations_view") @pytest.fixture(autouse=True) diff --git a/tests/api/test_views/test_reorder_course_content_view.py b/tests/platform/api/test_views/test_reorder_course_content_view.py similarity index 88% rename from tests/api/test_views/test_reorder_course_content_view.py rename to tests/platform/api/test_views/test_reorder_course_content_view.py index a1317b6..b5083f6 100644 --- a/tests/api/test_views/test_reorder_course_content_view.py +++ b/tests/platform/api/test_views/test_reorder_course_content_view.py @@ -10,7 +10,7 @@ def test_reorder_course_contents_view_success( course_quiz_content, ): url = reverse( - "django_email_learning:api:reorder_course_contents_view", + "django_email_learning:api_platform:reorder_course_contents_view", kwargs={"organization_id": course.organization_id, "course_id": course.id}, ) @@ -43,7 +43,7 @@ def test_reorder_course_contents_view_invalid_request( course_quiz_content, ): url = reverse( - "django_email_learning:api:reorder_course_contents_view", + "django_email_learning:api_platform:reorder_course_contents_view", kwargs={"organization_id": course.organization_id, "course_id": course.id}, ) @@ -65,7 +65,7 @@ def test_viewer_cannot_reorder_course_contents( course_quiz_content, ): url = reverse( - "django_email_learning:api:reorder_course_contents_view", + "django_email_learning:api_platform:reorder_course_contents_view", kwargs={"organization_id": course.organization_id, "course_id": course.id}, ) @@ -84,7 +84,7 @@ def test_reorder_course_contents_view_nonexistent_course( superadmin_client, ): url = reverse( - "django_email_learning:api:reorder_course_contents_view", + "django_email_learning:api_platform:reorder_course_contents_view", kwargs={"organization_id": 1, "course_id": 9999}, ) @@ -106,7 +106,7 @@ def test_anonymous_user_cannot_reorder_course_contents( course_quiz_content, ): url = reverse( - "django_email_learning:api:reorder_course_contents_view", + "django_email_learning:api_platform:reorder_course_contents_view", kwargs={"organization_id": course.organization_id, "course_id": course.id}, ) diff --git a/tests/api/test_views/test_session_view.py b/tests/platform/api/test_views/test_session_view.py similarity index 87% rename from tests/api/test_views/test_session_view.py rename to tests/platform/api/test_views/test_session_view.py index b4d5e89..c46067d 100644 --- a/tests/api/test_views/test_session_view.py +++ b/tests/platform/api/test_views/test_session_view.py @@ -11,7 +11,7 @@ def second_organization(db): def test_update_session_view_as_viewer(viewer_client): - url = reverse("django_email_learning:api:update_session_view") + url = reverse("django_email_learning:api_platform:update_session_view") response = viewer_client.post( url, {"active_organization_id": 1}, content_type="application/json" ) diff --git a/tests/services/test_jwt_service.py b/tests/services/test_jwt_service.py index d174300..cfc6b0c 100644 --- a/tests/services/test_jwt_service.py +++ b/tests/services/test_jwt_service.py @@ -1,6 +1,6 @@ from django_email_learning.services import jwt_service from freezegun import freeze_time -from datetime import timedelta, datetime +import datetime import jwt import pytest @@ -21,21 +21,23 @@ def test_jwt_service_token_expiration(): token = jwt_service.generate_jwt(payload, expiration_seconds=3600) # Fast forward time by 4000 seconds - frozen_time.tick(delta=timedelta(seconds=4000)) + frozen_time.tick(delta=datetime.timedelta(seconds=4000)) # Token should now be expired - with pytest.raises(jwt.ExpiredSignatureError): + with pytest.raises(jwt_service.ExpiredTokenException): jwt_service.decode_jwt(token) def test_jwt_service_invalid_token(): payload = {"user_id": 789} payload_copy = payload.copy() - payload_copy["exp"] = datetime.utcnow() + timedelta(seconds=3600) + payload_copy["exp"] = datetime.datetime.now(datetime.UTC) + datetime.timedelta( + seconds=3600 + ) # Create an invalid token by altering the signature invalid_token = jwt.encode( payload_copy, "INVALID_SECRET", algorithm=jwt_service.ALGORITHM ) - with pytest.raises(jwt.InvalidSignatureError): + with pytest.raises(jwt_service.InvalidTokenException): jwt_service.decode_jwt(invalid_token) diff --git a/tests/test_models/test_content_delivery.py b/tests/test_models/test_content_delivery.py new file mode 100644 index 0000000..e1f2e96 --- /dev/null +++ b/tests/test_models/test_content_delivery.py @@ -0,0 +1,26 @@ +from django_email_learning.models import ContentDelivery +import pytest + + +def test_content_delivery_create(db, course_lesson_content, enrollment): + delivery = ContentDelivery.objects.create( + enrollment=enrollment, + course_content=course_lesson_content, + ) + assert delivery.id is not None + + +def test_content_delivery_unique_constraint(db, course_lesson_content, enrollment): + ContentDelivery.objects.create( + enrollment=enrollment, + course_content=course_lesson_content, + ) + with pytest.raises(Exception) as exc_info: + ContentDelivery.objects.create( + enrollment=enrollment, + course_content=course_lesson_content, + ) + assert ( + "content delivery with this enrollment and course content already exists" + in str(exc_info.value).lower() + ) diff --git a/tests/test_models/test_quiz_submission.py b/tests/test_models/test_quiz_submission.py index 373fed1..ec8b16d 100644 --- a/tests/test_models/test_quiz_submission.py +++ b/tests/test_models/test_quiz_submission.py @@ -1,15 +1,20 @@ -from django_email_learning.models import QuizSubmission, SentItem +from django_email_learning.models import ( + QuizSubmission, + ContentDelivery, + DeliverySchedule, +) from django.core.exceptions import ValidationError import pytest def test_quiz_submission_creation(db, course_quiz_content, enrollment): - sent_item = SentItem.objects.create( + delivery = ContentDelivery.objects.create( enrollment=enrollment, course_content=course_quiz_content, ) + delivery.delivery_schedules.add(DeliverySchedule.objects.create(is_delivered=True)) submission = QuizSubmission.objects.create( - sent_item=sent_item, + delivery=delivery, score=85, is_passed=False, ) @@ -20,14 +25,14 @@ def test_quiz_submission_creation(db, course_quiz_content, enrollment): def test_quiz_submission_for_lesson_content(db, course_lesson_content, enrollment): - sent_item = SentItem.objects.create( + delivery = ContentDelivery.objects.create( enrollment=enrollment, course_content=course_lesson_content, ) - + delivery.delivery_schedules.add(DeliverySchedule.objects.create(is_delivered=True)) with pytest.raises(Exception) as exc_info: QuizSubmission.objects.create( - sent_item=sent_item, + delivery=delivery, score=90, is_passed=True, ) @@ -41,17 +46,38 @@ def test_quiz_submission_for_lesson_content(db, course_lesson_content, enrollmen (50, None), ], ) -def test_sent_item_invalid_quiz_submission_fields( +def test_invalid_quiz_submission_fields( db, score, is_passed, course_quiz_content, enrollment ): - sent_item = SentItem.objects.create( + delivery = ContentDelivery.objects.create( enrollment=enrollment, course_content=course_quiz_content, ) + delivery.delivery_schedules.add(DeliverySchedule.objects.create(is_delivered=True)) with pytest.raises(ValidationError): QuizSubmission.objects.create( - sent_item=sent_item, + delivery=delivery, score=score, is_passed=is_passed, ) + + +def test_quiz_submission_check_sent_item_quiz_association( + db, course_quiz_content, enrollment +): + delivery = ContentDelivery.objects.create( + enrollment=enrollment, + course_content=course_quiz_content, + ) + + with pytest.raises(Exception) as exc_info: + QuizSubmission.objects.create( + delivery=delivery, + score=75, + is_passed=True, + ) + assert ( + "Quiz submission count exceeds the number of times the quiz was sent." + in str(exc_info.value) + ) diff --git a/tests/test_models/test_sent_item.py b/tests/test_models/test_sent_item.py deleted file mode 100644 index e544cde..0000000 --- a/tests/test_models/test_sent_item.py +++ /dev/null @@ -1,27 +0,0 @@ -from django_email_learning.models import SentItem -import pytest - - -def test_sent_item_create(db, course_lesson_content, enrollment): - sent_item = SentItem.objects.create( - enrollment=enrollment, - course_content=course_lesson_content, - ) - assert sent_item.id is not None - assert sent_item.send_events.count() == 1 - - -def test_sent_item_unique_constraint(db, course_lesson_content, enrollment): - SentItem.objects.create( - enrollment=enrollment, - course_content=course_lesson_content, - ) - with pytest.raises(Exception) as exc_info: - SentItem.objects.create( - enrollment=enrollment, - course_content=course_lesson_content, - ) - assert ( - "sent item with this enrollment and course content already exists" - in str(exc_info.value).lower() - )