Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions django_email_learning/apps.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,23 @@
from django.apps import AppConfig
from django.core import checks


def check_site_base_url_config(app_configs, **kwargs): # type: ignore[no-untyped-def]
errors = []
from django.conf import settings

if (
not hasattr(settings, "DJANGO_EMAIL_LEARNING")
or "SITE_BASE_URL" not in settings.DJANGO_EMAIL_LEARNING
):
errors.append(
checks.Error(
"DJANGO_EMAIL_LEARNING['SITE_BASE_URL'] is not set in settings.",
hint="Please set DJANGO_EMAIL_LEARNING['SITE_BASE_URL'] to the base URL of your site.",
id="django_email_learning.E001",
)
)
return errors


class EmailLearningConfig(AppConfig):
Expand All @@ -8,3 +27,5 @@ class EmailLearningConfig(AppConfig):

def ready(self) -> None:
import django_email_learning.signals # noqa

checks.register(check_site_base_url_config)
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Generated by Django 6.0 on 2026-01-07 06:35

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("django_email_learning", "0001_initial"),
]

operations = [
migrations.RemoveField(
model_name="enrollment",
name="next_send_timestamp",
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 6.0 on 2026-01-07 06:43

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("django_email_learning", "0002_remove_enrollment_next_send_timestamp"),
]

operations = [
migrations.RemoveField(
model_name="contentdelivery",
name="delivery_schedules",
),
migrations.AddField(
model_name="deliveryschedule",
name="delivery",
field=models.ForeignKey(
default=1,
on_delete=django.db.models.deletion.CASCADE,
related_name="delivery_schedules",
to="django_email_learning.contentdelivery",
),
preserve_default=False,
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 6.0 on 2026-01-07 07:18

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
(
"django_email_learning",
"0003_remove_contentdelivery_delivery_schedules_and_more",
),
]

operations = [
migrations.RemoveField(
model_name="lesson",
name="is_published",
),
migrations.RemoveField(
model_name="quiz",
name="is_published",
),
migrations.AddField(
model_name="coursecontent",
name="is_published",
field=models.BooleanField(default=False),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 6.0 on 2026-01-07 10:39

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
(
"django_email_learning",
"0004_remove_lesson_is_published_remove_quiz_is_published_and_more",
),
]

operations = [
migrations.AlterField(
model_name="enrollment",
name="activation_code",
field=models.CharField(blank=True, max_length=6, null=True),
),
]
92 changes: 48 additions & 44 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import base64
import ipaddress
import re
import random
import uuid
from enum import StrEnum
from typing import Any
from django.conf import settings
from django.db import models
from django.db import models, transaction
from django.core.validators import MaxValueValidator
from django.core.exceptions import ImproperlyConfigured
from cryptography.fernet import Fernet
Expand All @@ -14,6 +15,7 @@
from django.forms import ValidationError
from django.contrib.auth.models import User
from django.utils import timezone
from datetime import timedelta


FIXED_SALT = b"\x00" * 16
Expand Down Expand Up @@ -132,7 +134,6 @@ def delete(
class Lesson(models.Model):
title = models.CharField(max_length=200)
content = models.TextField()
is_published = models.BooleanField(default=False)

def __str__(self) -> str:
return self.title
Expand All @@ -141,7 +142,6 @@ def __str__(self) -> str:
class Quiz(models.Model):
title = models.CharField(max_length=500)
required_score = models.IntegerField(validators=[MaxValueValidator(100)])
is_published = models.BooleanField(default=False)

class Meta:
verbose_name_plural = "Quizzes"
Expand All @@ -159,24 +159,6 @@ def validate_questions(self) -> None:
except ValidationError as e:
raise ValidationError(f"For question '{question.text}', {e.message}")

def full_clean(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
if self.is_published:
try:
self.validate_questions()
except ValueError as e:
if not self.pk:
raise ValidationError(
"Quiz can not be saved as published the first time. "
"please save unpublished and try to publish again."
)
raise e

super().full_clean(*args, **kwargs)

def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
self.full_clean()
super().save(*args, **kwargs)


class Question(models.Model):
quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE, related_name="questions")
Expand Down Expand Up @@ -208,7 +190,7 @@ def __str__(self) -> str:
return self.text

def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: # type: ignore[no-untyped-def]
if self.question.quiz.is_published:
if self.question.quiz.coursecontent_set.filter(is_published=True).exists():
raise ValidationError("Cannot delete answers from a published quiz.")
return super().delete(*args, **kwargs)

Expand All @@ -228,6 +210,7 @@ class CourseContent(models.Model):
waiting_period = models.IntegerField(
help_text="Waiting period in seconds after previous content is sent or submited."
)
is_published = models.BooleanField(default=False)

def __str__(self) -> str:
if self.type == "lesson" and self.lesson:
Expand All @@ -244,14 +227,6 @@ def title(self) -> str:
return self.quiz.title
return "Untitled Content"

@property
def is_published(self) -> bool:
if self.type == "lesson" and self.lesson:
return self.lesson.is_published
elif self.type == "quiz" and self.quiz:
return self.quiz.is_published
return False

def _validate_content(self) -> None:
if self.type == "lesson" and not self.lesson:
raise ValidationError("Lesson must be provided for lesson content.")
Expand Down Expand Up @@ -318,6 +293,13 @@ class EnrollmentStatus(StrEnum):
DEACTIVATED = "deactivated"


class DeactivationReason(StrEnum):
CANCELED = "canceled"
BLOCKED = "blocked"
FAILED = "failed"
INACTIVE = "inactive"


class Enrollment(models.Model):
state_transitions = {
EnrollmentStatus.UNVERIFIED: [
Expand All @@ -334,7 +316,6 @@ class Enrollment(models.Model):
learner = models.ForeignKey(Learner, on_delete=models.CASCADE)
course = models.ForeignKey(Course, on_delete=models.CASCADE)
enrolled_at = models.DateTimeField(auto_now_add=True)
next_send_timestamp = models.DateTimeField(null=True, blank=True)
status = models.CharField(
max_length=50,
choices=[
Expand All @@ -349,14 +330,14 @@ class Enrollment(models.Model):
null=True,
blank=True,
choices=[
("canceled", "Canceled"),
("blocked", "Blocked"),
("failed", "Failed"),
("inactive", "Inactive"),
(DeactivationReason.CANCELED, "Canceled"),
(DeactivationReason.BLOCKED, "Blocked"),
(DeactivationReason.FAILED, "Failed"),
(DeactivationReason.INACTIVE, "Inactive"),
],
max_length=50,
)
activation_code = models.CharField(max_length=100, null=True, blank=True)
activation_code = models.CharField(max_length=6, null=True, blank=True)

def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
if self.pk:
Expand All @@ -368,6 +349,8 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
raise ValidationError(
f"Invalid status transition from {old_status} to {self.status}."
)
else:
self.activation_code = "".join(random.choices("0123456789", k=6))
if self.status != "deactivated" and self.deactivation_reason is not None:
raise ValidationError(
"Deactivation reason must be null unless status is 'deactivated'."
Expand All @@ -391,19 +374,29 @@ class Meta:
)
]


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}"
@transaction.atomic()
def schedule_first_content_delivery(self) -> None:
first_content = (
CourseContent.objects.filter(course=self.course, is_published=True)
.order_by("priority")
.first()
)
if first_content:
delivery = ContentDelivery.objects.create(
enrollment=self,
course_content=first_content,
)
DeliverySchedule.objects.create(
time=timezone.now() + timedelta(seconds=first_content.waiting_period),
delivery=delivery,
)
else:
raise ValidationError("No published content available to schedule.")


class ContentDelivery(models.Model):
enrollment = models.ForeignKey(Enrollment, on_delete=models.CASCADE)
course_content = models.ForeignKey(CourseContent, on_delete=models.CASCADE)
delivery_schedules = models.ManyToManyField(DeliverySchedule)
hash_value = models.CharField(max_length=64, null=True, blank=True)

class Meta:
Expand Down Expand Up @@ -431,6 +424,17 @@ def __str__(self) -> str:
return f"Delivery of {self.course_content.title} to {self.enrollment.learner.email}"


class DeliverySchedule(models.Model):
delivery = models.ForeignKey(
ContentDelivery, on_delete=models.CASCADE, related_name="delivery_schedules"
)
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 for {self.delivery.course_content.title} to {self.delivery.enrollment.learner.email} at {self.time} - Delivered: {self.is_delivered}"


class QuizSubmission(models.Model):
delivery = models.ForeignKey(ContentDelivery, on_delete=models.CASCADE)
score = models.IntegerField()
Expand Down
3 changes: 3 additions & 0 deletions django_email_learning/personalised/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@

urlpatterns = [
path("quiz/", QuizPublicView.as_view(), name="quiz_public_view"),
path(
"verify-enrollment/", QuizPublicView.as_view(), name="verify_enrollment"
), # TODO: Replace with actual view
]
2 changes: 1 addition & 1 deletion django_email_learning/personalised/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def get(self, request, *args, **kwargs) -> HttpResponse: # type: ignore[no-unty
return self.errr_response(
message="No quiz associated with this link", exception=None
)
if not quiz.is_published:
if not delivery.course_content.is_published:
return self.errr_response(
message="No valid quiz associated with this link",
exception=ValueError("Quiz is not published"),
Expand Down
3 changes: 1 addition & 2 deletions django_email_learning/platform/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,6 @@ class LessonResponse(BaseModel):
id: int
title: str
content: str
is_published: bool

model_config = ConfigDict(from_attributes=True)

Expand Down Expand Up @@ -260,7 +259,6 @@ class QuizResponse(BaseModel):
title: str
required_score: int
questions: Any # Will be converted to list in field_serializer
is_published: bool

@field_serializer("questions")
def serialize_questions(self, questions: Any) -> list[dict]:
Expand Down Expand Up @@ -389,6 +387,7 @@ class CourseContentResponse(BaseModel):
type: str
lesson: Optional[LessonResponse] = None
quiz: Optional[QuizResponse] = None
is_published: bool

@field_serializer("waiting_period")
def serialize_waiting_period(self, waiting_period: int) -> dict:
Expand Down
10 changes: 2 additions & 8 deletions django_email_learning/platform/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,14 +219,8 @@ def _update_course_content_atomic(
course_content.waiting_period = serializer.waiting_period.to_seconds()

if serializer.is_published is not None:
if course_content.type == "lesson" and course_content.lesson is not None:
lesson = course_content.lesson
lesson.is_published = serializer.is_published
lesson.save()
elif course_content.type == "quiz" and course_content.quiz is not None:
quiz = course_content.quiz
quiz.is_published = serializer.is_published
quiz.save()
course_content.is_published = serializer.is_published
course_content.save()

if serializer.lesson is not None and course_content.lesson is not None:
lesson_serializer = serializer.lesson
Expand Down
Loading