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