diff --git a/django_email_learning/migrations/0001_initial.py b/django_email_learning/migrations/0001_initial.py
index 9a91ecd..8600ee5 100644
--- a/django_email_learning/migrations/0001_initial.py
+++ b/django_email_learning/migrations/0001_initial.py
@@ -1,4 +1,4 @@
-# Generated by Django 6.0 on 2026-01-03 12:20
+# Generated by Django 6.0.1 on 2026-01-08 17:24
import django.core.validators
import django.db.models.deletion
@@ -54,27 +54,6 @@ class Migration(migrations.Migration):
("enabled", models.BooleanField(default=False)),
],
),
- migrations.CreateModel(
- name="DeliverySchedule",
- fields=[
- (
- "id",
- models.BigAutoField(
- auto_created=True,
- primary_key=True,
- serialize=False,
- verbose_name="ID",
- ),
- ),
- (
- "time",
- models.DateTimeField(
- db_index=True, default=django.utils.timezone.now
- ),
- ),
- ("is_delivered", models.BooleanField(db_index=True, default=False)),
- ],
- ),
migrations.CreateModel(
name="ImapConnection",
fields=[
@@ -129,7 +108,6 @@ class Migration(migrations.Migration):
),
("title", models.CharField(max_length=200)),
("content", models.TextField()),
- ("is_published", models.BooleanField(default=False)),
],
),
migrations.CreateModel(
@@ -189,7 +167,26 @@ class Migration(migrations.Migration):
validators=[django.core.validators.MaxValueValidator(100)]
),
),
- ("is_published", models.BooleanField(default=False)),
+ (
+ "selection_strategy",
+ models.CharField(
+ choices=[
+ ("all", "All Questions"),
+ ("random", "Random Questions"),
+ ],
+ max_length=50,
+ ),
+ ),
+ (
+ "deadline_days",
+ models.IntegerField(
+ help_text="Time limit to complete the quiz in days. Minimum is 1 day and maximum is 30 days.",
+ validators=[
+ django.core.validators.MinValueValidator(1),
+ django.core.validators.MaxValueValidator(30),
+ ],
+ ),
+ ),
],
options={
"verbose_name_plural": "Quizzes",
@@ -220,6 +217,7 @@ class Migration(migrations.Migration):
help_text="Waiting period in seconds after previous content is sent or submited."
),
),
+ ("is_published", models.BooleanField(default=False)),
(
"course",
models.ForeignKey(
@@ -247,6 +245,58 @@ 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)),
+ ("valid_until", models.DateTimeField(blank=True, null=True)),
+ (
+ "course_content",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="django_email_learning.coursecontent",
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="DeliverySchedule",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "time",
+ models.DateTimeField(
+ db_index=True, default=django.utils.timezone.now
+ ),
+ ),
+ ("is_delivered", models.BooleanField(db_index=True, default=False)),
+ (
+ "delivery",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="delivery_schedules",
+ to="django_email_learning.contentdelivery",
+ ),
+ ),
+ ],
+ ),
migrations.CreateModel(
name="Enrollment",
fields=[
@@ -260,17 +310,36 @@ class Migration(migrations.Migration):
),
),
("enrolled_at", models.DateTimeField(auto_now_add=True)),
- ("next_send_timestamp", models.DateTimeField(blank=True, null=True)),
(
"status",
models.CharField(
choices=[
- ("unverified", "Unverified"),
- ("active", "Active"),
- ("completed", "Completed"),
- ("deactivated", "Deactivated"),
+ (
+ django_email_learning.models.EnrollmentStatus[
+ "UNVERIFIED"
+ ],
+ "Unverified",
+ ),
+ (
+ django_email_learning.models.EnrollmentStatus["ACTIVE"],
+ "Active",
+ ),
+ (
+ django_email_learning.models.EnrollmentStatus[
+ "COMPLETED"
+ ],
+ "Completed",
+ ),
+ (
+ django_email_learning.models.EnrollmentStatus[
+ "DEACTIVATED"
+ ],
+ "Deactivated",
+ ),
+ ],
+ default=django_email_learning.models.EnrollmentStatus[
+ "UNVERIFIED"
],
- default="unverified",
max_length=50,
),
),
@@ -279,10 +348,30 @@ class Migration(migrations.Migration):
models.CharField(
blank=True,
choices=[
- ("canceled", "Canceled"),
- ("blocked", "Blocked"),
- ("failed", "Failed"),
- ("inactive", "Inactive"),
+ (
+ django_email_learning.models.DeactivationReason[
+ "CANCELED"
+ ],
+ "Canceled",
+ ),
+ (
+ django_email_learning.models.DeactivationReason[
+ "BLOCKED"
+ ],
+ "Blocked",
+ ),
+ (
+ django_email_learning.models.DeactivationReason[
+ "FAILED"
+ ],
+ "Failed",
+ ),
+ (
+ django_email_learning.models.DeactivationReason[
+ "INACTIVE"
+ ],
+ "Inactive",
+ ),
],
max_length=50,
null=True,
@@ -290,7 +379,7 @@ class Migration(migrations.Migration):
),
(
"activation_code",
- models.CharField(blank=True, max_length=100, null=True),
+ models.CharField(blank=True, max_length=6, null=True),
),
(
"course",
@@ -308,41 +397,13 @@ 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="contentdelivery",
+ name="enrollment",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="django_email_learning.enrollment",
+ ),
),
migrations.AddField(
model_name="course",
@@ -468,6 +529,10 @@ class Migration(migrations.Migration):
),
],
),
+ migrations.AlterUniqueTogether(
+ name="contentdelivery",
+ unique_together={("enrollment", "course_content")},
+ ),
migrations.AddConstraint(
model_name="enrollment",
constraint=models.UniqueConstraint(
diff --git a/django_email_learning/migrations/0002_remove_enrollment_next_send_timestamp.py b/django_email_learning/migrations/0002_remove_enrollment_next_send_timestamp.py
deleted file mode 100644
index a3d8879..0000000
--- a/django_email_learning/migrations/0002_remove_enrollment_next_send_timestamp.py
+++ /dev/null
@@ -1,16 +0,0 @@
-# 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",
- ),
- ]
diff --git a/django_email_learning/migrations/0003_remove_contentdelivery_delivery_schedules_and_more.py b/django_email_learning/migrations/0003_remove_contentdelivery_delivery_schedules_and_more.py
deleted file mode 100644
index 34dbdd6..0000000
--- a/django_email_learning/migrations/0003_remove_contentdelivery_delivery_schedules_and_more.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# 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,
- ),
- ]
diff --git a/django_email_learning/migrations/0004_remove_lesson_is_published_remove_quiz_is_published_and_more.py b/django_email_learning/migrations/0004_remove_lesson_is_published_remove_quiz_is_published_and_more.py
deleted file mode 100644
index 0d560bf..0000000
--- a/django_email_learning/migrations/0004_remove_lesson_is_published_remove_quiz_is_published_and_more.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# 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),
- ),
- ]
diff --git a/django_email_learning/migrations/0005_alter_enrollment_activation_code.py b/django_email_learning/migrations/0005_alter_enrollment_activation_code.py
deleted file mode 100644
index 59e7c7c..0000000
--- a/django_email_learning/migrations/0005_alter_enrollment_activation_code.py
+++ /dev/null
@@ -1,20 +0,0 @@
-# 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),
- ),
- ]
diff --git a/django_email_learning/models.py b/django_email_learning/models.py
index c47ec68..f9a7188 100644
--- a/django_email_learning/models.py
+++ b/django_email_learning/models.py
@@ -7,7 +7,7 @@
from typing import Any
from django.conf import settings
from django.db import models, transaction
-from django.core.validators import MaxValueValidator
+from django.core.validators import MaxValueValidator, MinValueValidator
from django.core.exceptions import ImproperlyConfigured
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
@@ -139,9 +139,25 @@ def __str__(self) -> str:
return self.title
+class QuizSelectionStrategy(StrEnum):
+ ALL_QUESTIONS = "all"
+ RANDOM_QUESTIONS = "random"
+
+
class Quiz(models.Model):
title = models.CharField(max_length=500)
required_score = models.IntegerField(validators=[MaxValueValidator(100)])
+ selection_strategy = models.CharField(
+ max_length=50,
+ choices=[
+ (QuizSelectionStrategy.ALL_QUESTIONS.value, "All Questions"),
+ (QuizSelectionStrategy.RANDOM_QUESTIONS.value, "Random Questions"),
+ ],
+ )
+ deadline_days = models.IntegerField(
+ help_text="Time limit to complete the quiz in days. Minimum is 1 day and maximum is 30 days.",
+ validators=[MinValueValidator(1), MaxValueValidator(30)],
+ )
class Meta:
verbose_name_plural = "Quizzes"
@@ -398,6 +414,7 @@ class ContentDelivery(models.Model):
enrollment = models.ForeignKey(Enrollment, on_delete=models.CASCADE)
course_content = models.ForeignKey(CourseContent, on_delete=models.CASCADE)
hash_value = models.CharField(max_length=64, null=True, blank=True)
+ valid_until = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = [["enrollment", "course_content"]]
diff --git a/django_email_learning/platform/api/serializers.py b/django_email_learning/platform/api/serializers.py
index 345a343..27f6377 100644
--- a/django_email_learning/platform/api/serializers.py
+++ b/django_email_learning/platform/api/serializers.py
@@ -16,6 +16,7 @@
Answer,
CourseContent,
Course,
+ QuizSelectionStrategy,
)
import enum
@@ -177,7 +178,7 @@ def populate_from_session(cls, session): # type: ignore[no-untyped-def]
class LessonCreate(BaseModel):
title: str
content: str
- type: Literal["lesson"]
+ type: Literal["lesson"] = "lesson"
class LessonUpdate(BaseModel):
@@ -239,10 +240,18 @@ def serialize_answers(self, answers: Any) -> list[dict]:
model_config = ConfigDict(from_attributes=True)
+MIN_QUIZ_DEADLINE = 1
+MAX_QUIZ_DEADLINE = 30
+
+
class UpdateQuiz(BaseModel):
questions: Optional[list[QuestionCreate]] = Field(min_length=1)
title: Optional[str] = None
required_score: Optional[int] = Field(ge=0, examples=[80], default=None)
+ selection_strategy: QuizSelectionStrategy
+ deadline_days: int = Field(
+ ge=MIN_QUIZ_DEADLINE, le=MAX_QUIZ_DEADLINE, examples=[14]
+ )
model_config = ConfigDict(extra="forbid")
@@ -250,14 +259,20 @@ class UpdateQuiz(BaseModel):
class QuizCreate(BaseModel):
title: str
required_score: int = Field(ge=0, examples=[80])
+ selection_strategy: QuizSelectionStrategy
+ deadline_days: int = Field(
+ ge=MIN_QUIZ_DEADLINE, le=MAX_QUIZ_DEADLINE, examples=[14]
+ )
questions: list[QuestionCreate] = Field(min_length=1)
- type: Literal["quiz"]
+ type: Literal["quiz"] = "quiz"
class QuizResponse(BaseModel):
id: int
title: str
required_score: int
+ selection_strategy: str
+ deadline_days: int = Field(ge=MIN_QUIZ_DEADLINE, le=MAX_QUIZ_DEADLINE)
questions: Any # Will be converted to list in field_serializer
@field_serializer("questions")
@@ -325,6 +340,8 @@ def to_django_model(self, course: Course) -> CourseContent:
quiz = Quiz(
title=self.content.title,
required_score=self.content.required_score,
+ selection_strategy=self.content.selection_strategy.value, # type: ignore[misc]
+ deadline_days=self.content.deadline_days, # type: ignore[misc]
)
quiz.save()
for question_data in self.content.questions:
diff --git a/django_email_learning/platform/api/views.py b/django_email_learning/platform/api/views.py
index c292b59..8fae82f 100644
--- a/django_email_learning/platform/api/views.py
+++ b/django_email_learning/platform/api/views.py
@@ -238,6 +238,10 @@ def _update_course_content_atomic(
quiz.title = quiz_serializer.title
if quiz_serializer.required_score is not None:
quiz.required_score = quiz_serializer.required_score
+ if quiz_serializer.selection_strategy is not None:
+ quiz.selection_strategy = quiz_serializer.selection_strategy.value
+ if quiz_serializer.deadline_days is not None:
+ quiz.deadline_days = quiz_serializer.deadline_days
if quiz_serializer.questions is not None:
# Clear existing questions and answers
quiz.questions.all().delete()
diff --git a/frontend/platform/course/Course.jsx b/frontend/platform/course/Course.jsx
index 8e79ce9..67be86a 100644
--- a/frontend/platform/course/Course.jsx
+++ b/frontend/platform/course/Course.jsx
@@ -102,6 +102,8 @@ function Course() {
initialRequiredScore={content.quiz.required_score}
initialQuestions={translateQuestions(content.quiz.questions)}
initialWaitingPeriod={content.waiting_period}
+ initialStrategy={content.quiz.selection_strategy}
+ initialDeadlineDays={content.quiz.deadline_days}
/>);
}
}
diff --git a/frontend/platform/course/components/QuizForm.jsx b/frontend/platform/course/components/QuizForm.jsx
index 611dcde..1c312a0 100644
--- a/frontend/platform/course/components/QuizForm.jsx
+++ b/frontend/platform/course/components/QuizForm.jsx
@@ -1,11 +1,11 @@
import { useRef, useState, useEffect } from 'react';
-import { Alert,Box, Button, Grid, MenuItem, Select, Tooltip, Typography } from '@mui/material';
+import { Alert,Box, Button, Grid, InputLabel, MenuItem, Select, Tooltip, Typography } from '@mui/material';
import QuizIcon from '@mui/icons-material/Quiz';
import RequiredTextField from '../../../src/components/RequiredTextField';
import QuestionForm from './QuestionForm';
import { getCookie } from '../../../src/utils';
-const QuizForm = ({cancelCallback, successCallback, courseId, quizId, contentId, initialRequiredScore, initialTitle, initialQuestions, initialWaitingPeriod }) => {
+const QuizForm = ({cancelCallback, successCallback, courseId, quizId, contentId, initialRequiredScore, initialTitle, initialQuestions, initialWaitingPeriod, initialStrategy, initialDeadlineDays }) => {
const [showQuestionField, setShowQuestionField] = useState(false);
const [newQuestion, setNewQuestion] = useState("");
@@ -13,6 +13,8 @@ const QuizForm = ({cancelCallback, successCallback, courseId, quizId, contentId,
const [errorMessage, setErrorMessage] = useState("");
const [title, setTitle] = useState(initialTitle || "");
const [requiredScore , setRequiredScore] = useState(initialRequiredScore || 70);
+ const [selectionStrategy, setSelectionStrategy] = useState(initialStrategy || "random");
+ const [deadlineDays, setDeadlineDays] = useState(initialDeadlineDays || 14);
const [waitingPeriod, setWaitingPeriod] = useState(initialWaitingPeriod ? initialWaitingPeriod.period : 1);
const [waitingPeriodUnit, setWaitingPeriodUnit] = useState(initialWaitingPeriod ? initialWaitingPeriod.type : "days");
const questionInputRef = useRef(null);
@@ -37,6 +39,8 @@ const QuizForm = ({cancelCallback, successCallback, courseId, quizId, contentId,
type: 'quiz',
title: title,
required_score: requiredScore,
+ selection_strategy: selectionStrategy,
+ deadline_days: deadlineDays,
questions: questionsPayload(),
},
waiting_period: {
@@ -136,6 +140,8 @@ const QuizForm = ({cancelCallback, successCallback, courseId, quizId, contentId,
quiz: {
title: title,
required_score: requiredScore,
+ selection_strategy: selectionStrategy,
+ deadline_days: deadlineDays,
questions: questionsPayload(),
},
waiting_period: {
@@ -232,36 +238,114 @@ const QuizForm = ({cancelCallback, successCallback, courseId, quizId, contentId,
)) }
-
- setRequiredScore(e.target.value)}
- sx={{ width: '200px', mr: 2 }}
- inputProps={{ min: 0, max: 100 }}
- disabled={userRole === 'viewer'}
- >
-
+ {/* Quiz Settings Section */}
+
+
+ Quiz Settings
+
+
+
+ {/* Row 1: Required Score and Waiting Period */}
+
+
+ Required Score to Pass
+
+
+ setRequiredScore(e.target.value)}
+ sx={{ width: '100%' }}
+ inputProps={{ min: 0, max: 100 }}
+ disabled={userRole === 'viewer'}
+ />
+
+
+
+
+
+
+
+ Waiting Period
+
+
+ setWaitingPeriod(e.target.value)}
+ sx={{ flex: 1 }}
+ inputProps={{ min: 1 }}
+ disabled={userRole === 'viewer'}
+ />
+
+
+
+
+
+
+ {/* Row 2: Deadline and Selection Strategy */}
+
+
+ Deadline to Complete Quiz
+
+
+ setDeadlineDays(e.target.value)}
+ sx={{ width: '100%' }}
+ inputProps={{ min: 1 }}
+ disabled={userRole === 'viewer'}
+ />
+
+
+
+
+
+
+
+ Selection Strategy
+
+
+
+
+
+
-
- setWaitingPeriod(e.target.value)}
- sx={{ width: '200px', mr: 2 }}
- inputProps={{ min: 1 }}
- disabled={userRole === 'viewer'}
- />
-
-
+