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' %} + + + +