diff --git a/django_email_learning/migrations/0018_assignment_alter_coursecontent_type_and_more.py b/django_email_learning/migrations/0018_assignment_alter_coursecontent_type_and_more.py
new file mode 100644
index 0000000..8d50fa7
--- /dev/null
+++ b/django_email_learning/migrations/0018_assignment_alter_coursecontent_type_and_more.py
@@ -0,0 +1,88 @@
+# Generated by Django 6.0.4 on 2026-04-30 05:39
+
+import django.core.validators
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("django_email_learning", "0017_alter_organizationuser_role"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Assignment",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("title", models.CharField(max_length=200)),
+ ("description", models.TextField()),
+ (
+ "is_blocking",
+ models.BooleanField(
+ default=True,
+ help_text="Whether the learner is required to submit the assignment to proceed to the next content.",
+ ),
+ ),
+ (
+ "deadline_days",
+ models.IntegerField(
+ help_text="Time limit to complete the assignment in days. 0 indicates no deadline.",
+ validators=[django.core.validators.MinValueValidator(0)],
+ ),
+ ),
+ (
+ "requires_text_submission",
+ models.BooleanField(
+ default=True,
+ help_text="Whether the assignment requires text submission.",
+ ),
+ ),
+ (
+ "requires_file_submission",
+ models.BooleanField(
+ default=False,
+ help_text="Whether the assignment requires file submission.",
+ ),
+ ),
+ ],
+ ),
+ migrations.AlterField(
+ model_name="coursecontent",
+ name="type",
+ field=models.CharField(
+ choices=[
+ ("lesson", "Lesson"),
+ ("quiz", "Quiz"),
+ ("assignment", "Assignment"),
+ ],
+ max_length=50,
+ ),
+ ),
+ migrations.AddField(
+ model_name="coursecontent",
+ name="assignment",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="django_email_learning.assignment",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="coursecontent",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("assignment__isnull", False)),
+ fields=("course", "assignment"),
+ name="unique_assignment_per_course",
+ ),
+ ),
+ ]
diff --git a/django_email_learning/migrations/0019_alter_assignment_requires_file_submission_and_more.py b/django_email_learning/migrations/0019_alter_assignment_requires_file_submission_and_more.py
new file mode 100644
index 0000000..84a9150
--- /dev/null
+++ b/django_email_learning/migrations/0019_alter_assignment_requires_file_submission_and_more.py
@@ -0,0 +1,26 @@
+# Generated by Django 6.0.4 on 2026-04-30 05:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("django_email_learning", "0018_assignment_alter_coursecontent_type_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="assignment",
+ name="requires_file_submission",
+ field=models.BooleanField(
+ help_text="Whether the assignment requires file submission."
+ ),
+ ),
+ migrations.AlterField(
+ model_name="assignment",
+ name="requires_text_submission",
+ field=models.BooleanField(
+ help_text="Whether the assignment requires text submission."
+ ),
+ ),
+ ]
diff --git a/django_email_learning/migrations/0020_assignment_reminder_interval_days.py b/django_email_learning/migrations/0020_assignment_reminder_interval_days.py
new file mode 100644
index 0000000..3fb3a72
--- /dev/null
+++ b/django_email_learning/migrations/0020_assignment_reminder_interval_days.py
@@ -0,0 +1,26 @@
+# Generated by Django 6.0.4 on 2026-04-30 08:01
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ (
+ "django_email_learning",
+ "0019_alter_assignment_requires_file_submission_and_more",
+ ),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="assignment",
+ name="reminder_interval_days",
+ field=models.IntegerField(
+ blank=True,
+ help_text="For assignments without a deadline (deadline_days = 0), send reminder emails every N days.",
+ null=True,
+ validators=[django.core.validators.MinValueValidator(0)],
+ ),
+ ),
+ ]
diff --git a/django_email_learning/models.py b/django_email_learning/models.py
index 3d27f0a..1718278 100644
--- a/django_email_learning/models.py
+++ b/django_email_learning/models.py
@@ -414,6 +414,34 @@ def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: # type: ignore
return super().delete(*args, **kwargs)
+class Assignment(models.Model):
+ title = models.CharField(max_length=200)
+ description = models.TextField()
+ is_blocking = models.BooleanField(
+ default=True,
+ help_text="Whether the learner is required to submit the assignment to proceed to the next content.",
+ )
+ deadline_days = models.IntegerField(
+ help_text="Time limit to complete the assignment in days. 0 indicates no deadline.",
+ validators=[MinValueValidator(0)],
+ )
+ requires_text_submission = models.BooleanField(
+ help_text="Whether the assignment requires text submission."
+ )
+ requires_file_submission = models.BooleanField(
+ help_text="Whether the assignment requires file submission."
+ )
+ reminder_interval_days = models.IntegerField(
+ help_text="For assignments without a deadline (deadline_days = 0), send reminder emails every N days.",
+ validators=[MinValueValidator(0)],
+ null=True,
+ blank=True,
+ )
+
+ def __str__(self) -> str:
+ return self.title
+
+
class CourseContent(models.Model):
course = models.ForeignKey(Course, on_delete=models.CASCADE)
priority = models.IntegerField()
@@ -422,10 +450,14 @@ class CourseContent(models.Model):
choices=[
("lesson", "Lesson"),
("quiz", "Quiz"),
+ ("assignment", "Assignment"),
],
)
lesson = models.ForeignKey(Lesson, null=True, blank=True, on_delete=models.CASCADE)
quiz = models.ForeignKey(Quiz, null=True, blank=True, on_delete=models.CASCADE)
+ assignment = models.ForeignKey(
+ Assignment, null=True, blank=True, on_delete=models.CASCADE
+ )
waiting_period = models.IntegerField(
help_text="Waiting period in seconds after previous content is sent or submited."
)
@@ -436,14 +468,34 @@ def __str__(self) -> str:
return f"{self.priority} - Lesson: {self.lesson.title}"
elif self.type == "quiz" and self.quiz:
return f"{self.priority} - Quiz: {self.quiz.title}"
+ elif self.type == "assignment" and self.assignment:
+ return f"{self.priority} - Assignment: {self.assignment.title}"
return f"{self.course.title} content #{self.priority}"
+ @property
+ def deadline_days(self) -> Optional[int]:
+ if self.type == "quiz" and self.quiz:
+ return self.quiz.deadline_days
+ elif self.type == "assignment" and self.assignment:
+ return self.assignment.deadline_days
+ return None
+
+ @property
+ def reminder_interval_days(self) -> Optional[int]:
+ if self.type == "quiz" and self.quiz:
+ return self.quiz.reminder_interval_days
+ elif self.type == "assignment" and self.assignment:
+ return self.assignment.reminder_interval_days
+ return None
+
@property
def title(self) -> str:
if self.type == "lesson" and self.lesson:
return self.lesson.title
elif self.type == "quiz" and self.quiz:
return self.quiz.title
+ elif self.type == "assignment" and self.assignment:
+ return self.assignment.title
return "Untitled Content"
@property
@@ -456,6 +508,8 @@ def limited_attempts(self) -> Optional[bool]:
def is_blocking(self) -> Optional[bool]:
if self.type == "quiz" and self.quiz:
return self.quiz.is_blocking
+ elif self.type == "assignment" and self.assignment:
+ return self.assignment.is_blocking
return None
def human_readable_waiting_period(self) -> str:
@@ -482,10 +536,14 @@ def _validate_content(self) -> None:
raise ValidationError("Lesson must be provided for lesson content.")
if self.type == "quiz" and not self.quiz:
raise ValidationError("Quiz must be provided for quiz content.")
+ if self.type == "assignment" and not self.assignment:
+ raise ValidationError("Assignment must be provided for assignment content.")
if self.type == "lesson" and self.lesson:
self.lesson.full_clean()
elif self.type == "quiz" and self.quiz:
self.quiz.full_clean()
+ elif self.type == "assignment" and self.assignment:
+ self.assignment.full_clean()
def full_clean(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
self._validate_content()
@@ -517,6 +575,11 @@ class Meta:
condition=models.Q(lesson__isnull=False),
name="unique_lesson_per_course",
),
+ models.UniqueConstraint(
+ fields=["course", "assignment"],
+ condition=models.Q(assignment__isnull=False),
+ name="unique_assignment_per_course",
+ ),
models.UniqueConstraint(
fields=["course", "priority"],
name="unique_priority_per_course",
@@ -861,28 +924,29 @@ def repeat_delivery_in_days(self, days: int) -> bool:
return True
def calculate_remind_at(self) -> Optional[datetime]:
- if self.course_content.quiz:
- if self.course_content.quiz.deadline_days > 0:
- if self.course_content.quiz.deadline_days > 1:
+ if self.course_content.quiz or self.course_content.assignment:
+ if (
+ self.course_content.deadline_days
+ and self.course_content.deadline_days > 0
+ ):
+ if self.course_content.deadline_days > 1:
return timezone.now() + timedelta(
- days=self.course_content.quiz.deadline_days - 1
+ days=self.course_content.deadline_days - 1
)
else:
return timezone.now() + timedelta(
- hours=(self.course_content.quiz.deadline_days * 24) - 10
+ hours=(self.course_content.deadline_days * 24) - 10
)
else:
- if self.course_content.quiz.reminder_interval_days:
+ if self.course_content.reminder_interval_days:
return timezone.now() + timedelta(
- days=self.course_content.quiz.reminder_interval_days
+ days=self.course_content.reminder_interval_days
)
return None
def calculate_valid_until(self) -> Optional[datetime]:
- if self.course_content.quiz and self.course_content.quiz.deadline_days > 0:
- return timezone.now() + timedelta(
- days=self.course_content.quiz.deadline_days
- )
+ if self.course_content.deadline_days and self.course_content.deadline_days > 0:
+ return timezone.now() + timedelta(days=self.course_content.deadline_days)
return None
def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
@@ -947,6 +1011,9 @@ def generate_link(self) -> str:
self.link = link
self.save()
return link
+ elif self.delivery.course_content.assignment:
+ # TODO: Implement assignment link generation
+ return ""
else:
# TODO: Implement lesson link generation
return ""
diff --git a/django_email_learning/platform/api/serializers.py b/django_email_learning/platform/api/serializers.py
index d613ee4..0a74dcd 100644
--- a/django_email_learning/platform/api/serializers.py
+++ b/django_email_learning/platform/api/serializers.py
@@ -17,6 +17,7 @@
Organization,
ImapConnection,
InboxFolder,
+ Assignment,
Lesson,
Quiz,
Question,
@@ -477,6 +478,41 @@ def populate_from_session(cls, session): # type: ignore[no-untyped-def]
)
+class AssignmentCreate(BaseModel):
+ title: str
+ description: str
+ is_blocking: bool
+ deadline_days: int = Field(ge=0, examples=[14])
+ requires_text_submission: bool
+ requires_file_submission: bool
+ type: Literal["assignment"] = "assignment"
+ reminder_interval_days: Optional[int] = Field(default=None, examples=[3])
+
+
+class AssignmentUpdate(BaseModel):
+ title: Optional[str] = None
+ description: Optional[str] = None
+ is_blocking: Optional[bool] = None
+ deadline_days: Optional[int] = Field(ge=0, examples=[14], default=None)
+ requires_text_submission: Optional[bool] = None
+ requires_file_submission: Optional[bool] = None
+ reminder_interval_days: Optional[int] = Field(default=None, examples=[3])
+
+ model_config = ConfigDict(extra="forbid")
+
+
+class AssignmentResponse(BaseModel):
+ id: int
+ title: str
+ description: str
+ is_blocking: bool
+ deadline_days: int
+ requires_text_submission: bool
+ requires_file_submission: bool
+ reminder_interval_days: Optional[int] = None
+ model_config = ConfigDict(from_attributes=True)
+
+
class LessonCreate(BaseModel):
title: str
content: str
@@ -692,8 +728,8 @@ class ReminderSentEvent(BaseModel):
type: Literal[EventType.REMINDER_SENT] = Field(
default=EventType.REMINDER_SENT, exclude=True
)
- quiz_id: int
- quiz_title: str
+ content_id: int
+ content_title: str
class ContentSentEvent(BaseModel):
@@ -749,13 +785,28 @@ def from_django_model(enrollment: Enrollment) -> "EnrollmentResponse":
timestamp=schedule.delivered_at, # type: ignore[arg-type]
event_data=ContentSentEvent(
course_content_id=delivery.course_content.id,
- course_content_title=delivery.course_content.lesson.title
- if delivery.course_content.lesson
- else delivery.course_content.quiz.title, # type: ignore[union-attr]
+ course_content_title=delivery.course_content.title, # type: ignore[union-attr]
course_content_type=delivery.course_content.type,
),
)
)
+ if delivery.course_content.type == "assignment":
+ if (
+ delivery.reminder_state == ContentDelivery.ReminderStatus.SENT
+ and delivery.remind_at
+ ):
+ events.append(
+ Event(
+ type=EventType.REMINDER_SENT,
+ timestamp=delivery.remind_at, # type: ignore[arg-type]
+ event_data=ReminderSentEvent(
+ content_id=delivery.course_content.id, # type: ignore[union-attr]
+ content_title=delivery.course_content.title, # type: ignore[union-attr]
+ ),
+ )
+ )
+ # TODO:events for reminders and submissions for assignments
+
if delivery.course_content.type == "quiz":
if (
delivery.reminder_state == ContentDelivery.ReminderStatus.SENT
@@ -766,8 +817,8 @@ def from_django_model(enrollment: Enrollment) -> "EnrollmentResponse":
type=EventType.REMINDER_SENT,
timestamp=delivery.remind_at, # type: ignore[arg-type]
event_data=ReminderSentEvent(
- quiz_id=delivery.course_content.quiz.id, # type: ignore[union-attr]
- quiz_title=delivery.course_content.quiz.title, # type: ignore[union-attr]
+ content_id=delivery.course_content.id, # type: ignore[union-attr]
+ content_title=delivery.course_content.title, # type: ignore[union-attr]
),
)
)
@@ -858,7 +909,7 @@ class GroupEnrollmentRequest(BaseModel):
class CreateCourseContentRequest(BaseModel):
priority: int | None = Field(gt=0, examples=[1], default=None)
waiting_period: WaitingPeriod
- content: LessonCreate | QuizCreate = Field(discriminator="type")
+ content: LessonCreate | QuizCreate | AssignmentCreate = Field(discriminator="type")
@property
def required_priority(self) -> int:
@@ -870,6 +921,7 @@ def required_priority(self) -> int:
def to_django_model(self, course: Course) -> CourseContent:
lesson = None
quiz = None
+ assignment = None
if isinstance(self.content, LessonCreate):
lesson = Lesson(
title=self.content.title,
@@ -877,6 +929,20 @@ def to_django_model(self, course: Course) -> CourseContent:
)
lesson.save()
content_type = "lesson"
+
+ elif isinstance(self.content, AssignmentCreate):
+ assignment = Assignment(
+ title=self.content.title,
+ description=self.content.description,
+ is_blocking=self.content.is_blocking, # type: ignore[misc]
+ deadline_days=self.content.deadline_days, # type: ignore[misc]
+ requires_text_submission=self.content.requires_text_submission, # type: ignore[misc]
+ requires_file_submission=self.content.requires_file_submission, # type: ignore[misc]
+ reminder_interval_days=self.content.reminder_interval_days, # type: ignore[misc]
+ )
+ assignment.save()
+ content_type = "assignment"
+
elif isinstance(self.content, QuizCreate):
quiz = Quiz(
title=self.content.title,
@@ -903,10 +969,12 @@ def to_django_model(self, course: Course) -> CourseContent:
)
answer.save()
content_type = "quiz"
+
course_content = CourseContent.objects.create(
course=course,
priority=self.required_priority,
waiting_period=self.waiting_period.to_seconds(),
+ assignment=assignment,
lesson=lesson,
quiz=quiz,
type=content_type,
@@ -920,6 +988,7 @@ class UpdateCourseContentRequest(BaseModel):
waiting_period: Optional[WaitingPeriod] = None
lesson: Optional[LessonUpdate] = None
quiz: Optional[UpdateQuiz] = None
+ assignment: Optional[AssignmentUpdate] = None
is_published: Optional[bool] = None
model_config = ConfigDict(extra="forbid")
@@ -932,11 +1001,12 @@ def check_at_least_one(self) -> "UpdateCourseContentRequest":
self.waiting_period,
self.lesson,
self.quiz,
+ self.assignment,
self.is_published,
]
if not any(f is not None for f in fields):
raise ValueError(
- "At least one of 'priority', 'waiting_period', 'lesson', 'quiz', or 'is_published' must be provided."
+ "At least one of 'priority', 'waiting_period', 'lesson', 'quiz', 'assignment', or 'is_published' must be provided."
)
return self
@@ -948,6 +1018,7 @@ class CourseContentResponse(BaseModel):
type: str
lesson: Optional[LessonResponse] = None
quiz: Optional[QuizResponse] = None
+ assignment: Optional[AssignmentResponse] = None
is_published: bool
@field_serializer("waiting_period")
diff --git a/django_email_learning/platform/api/views.py b/django_email_learning/platform/api/views.py
index 699620a..047b4ae 100644
--- a/django_email_learning/platform/api/views.py
+++ b/django_email_learning/platform/api/views.py
@@ -291,6 +291,29 @@ def _update_course_content_atomic(
lesson.content = lesson_serializer.content
lesson.save()
+ if serializer.assignment is not None and course_content.assignment is not None:
+ assignment_serializer = serializer.assignment
+ assignment = course_content.assignment
+ if assignment_serializer.title is not None:
+ assignment.title = assignment_serializer.title
+ if assignment_serializer.description is not None:
+ assignment.description = assignment_serializer.description
+ if assignment_serializer.deadline_days is not None:
+ assignment.deadline_days = assignment_serializer.deadline_days
+ if assignment_serializer.requires_text_submission is not None:
+ assignment.requires_text_submission = (
+ assignment_serializer.requires_text_submission
+ )
+ if assignment_serializer.requires_file_submission is not None:
+ assignment.requires_file_submission = (
+ assignment_serializer.requires_file_submission
+ )
+ if assignment_serializer.reminder_interval_days is not None:
+ assignment.reminder_interval_days = (
+ assignment_serializer.reminder_interval_days
+ )
+ assignment.save()
+
if serializer.quiz is not None and course_content.quiz is not None:
quiz_serializer = serializer.quiz
quiz = course_content.quiz
diff --git a/django_email_learning/platform/views.py b/django_email_learning/platform/views.py
index 3f7dcc6..79a2d80 100644
--- a/django_email_learning/platform/views.py
+++ b/django_email_learning/platform/views.py
@@ -293,10 +293,12 @@ def get_locale_messages(self) -> Dict[str, str]:
"waiting_time": _("Waiting Time"),
"title": _("Title"),
"add_quiz": _("Add Quiz"),
+ "add_assignment": _("Add Assignment"),
"send_lesson_to_yourself": _("Send it to yourself"),
"add_lesson": _("Add Lesson"),
"lesson": _("Lesson"),
"quiz": _("Quiz"),
+ "assignment": _("Assignment"),
"add": _("Add"),
"new_lesson": _("New Lesson"),
"update_lesson": _("Update Lesson"),
@@ -306,10 +308,14 @@ def get_locale_messages(self) -> Dict[str, str]:
"edit_with_ai": _("Edit with AI"),
"lesson_title": _("Lesson Title"),
"blocking_quiz": _("Blocking Quiz"),
+ "blocking_assignment": _("Blocking Assignment"),
"blocking_quiz_tooltip": _(
"If enabled, learners must pass the quiz to continue receiving course content. For practice quizzes that don't gate content, "
"you can disable this option so learners can continue with the course regardless of their quiz performance."
),
+ "blocking_assignment_tooltip": _(
+ "If enabled, learners must submit the assignment, and the assignment must be approved by an instructor before they can continue receiving course content."
+ ),
"lesson_waiting_tooltip": _(
"Set the amount of time that we should wait after the previous lesson or quiz submission before sending this lesson"
),
@@ -337,6 +343,12 @@ def get_locale_messages(self) -> Dict[str, str]:
"lesson_unsaved_changes_hint": _("You have unsaved changes."),
"save_quiz": _("Save Quiz"),
"quiz_title": _("Quiz Title"),
+ "assignment_title": _("Assignment Title"),
+ "new_assignment": _("New Assignment"),
+ "update_assignment": _("Update Assignment"),
+ "assignment_submission_required": _(
+ "At least one type of submission is required for the assignment. Please enable text submission, file submission, or both."
+ ),
"add_question": _("Add Question"),
"quiz_settings": _("Quiz Settings"),
"waiting_period": _("Waiting Period"),
@@ -366,7 +378,10 @@ def get_locale_messages(self) -> Dict[str, str]:
),
"percentage": _("Percentage"),
"quiz_deadline": _("Deadline to Complete Quiz"),
- "deadline_tooltip": _("Maximum time allowed to complete the quiz"),
+ "assignment_deadline": _("Deadline to Complete Assignment"),
+ "deadline_tooltip": _(
+ "Maximum time allowed to complete the quiz or assignment"
+ ),
"question_selection_strategy": _("Selection Strategy"),
"question_selection_strategy_tooltip": _(
"Choose how questions are selected for each quiz attempt. If the total number of questions is fewer than 6, all questions will be used even if 'Random Questions' is selected."
@@ -414,6 +429,18 @@ def get_locale_messages(self) -> Dict[str, str]:
"When a quiz does not have a deadline, you can define a reminder interval to specify "
"how often learners should receive reminder emails to complete the quiz. Setting to 0 means no reminder emails will be sent."
),
+ "assignment_description": _("Assignment Description"),
+ "assignment_description_required": _("Assignment description is required."),
+ "assignment_title_required": _("Assignment title is required."),
+ "save_assignment": _("Save Assignment"),
+ "assignment_saved_success": _("Assignment saved successfully."),
+ "assignment_waiting_tooltip": _(
+ "Set the amount of time to wait after the previous content delivery before sending this assignment."
+ ),
+ "no_deadline": _("No Deadline"),
+ "requires_text_submission": _("Requires Text Submission"),
+ "requires_file_submission": _("Requires File Submission"),
+ "save_failed": _("Unable to save. Please try again."),
}
def get_app_context(self) -> Dict[str, Any]:
@@ -452,6 +479,8 @@ def get_locale_messages(self) -> Dict[str, str]:
"organization_is_public_helper_text": _(
"Public organizations are visible on your public pages. Turn this off to keep the organization private."
),
+ "requires_text_submission": _("Requires Text Submission"),
+ "requires_file_submission": _("Requires File Submission"),
"uploaded_image_alt": _("Organization Logo"),
"private": _("Private"),
"are_you_sure_delete_org": _(
diff --git a/frontend/platform/course/Course.jsx b/frontend/platform/course/Course.jsx
index 895fc3f..2b0e531 100644
--- a/frontend/platform/course/Course.jsx
+++ b/frontend/platform/course/Course.jsx
@@ -6,6 +6,7 @@ import Base from '../../src/components/Base.jsx'
import EnrollMenu from './components/EnrollMenu.jsx';
import DescriptionIcon from '@mui/icons-material/Description';
import BallotIcon from '@mui/icons-material/Ballot';
+import AssignmentIcon from '@mui/icons-material/Assignment';
import { useState, useEffect } from 'react';
import { Box, Grid, Button, Dialog, LinearProgress, Typography, Alert, Divider, Skeleton } from '@mui/material'
import { useTheme } from '@mui/material/styles';
@@ -17,6 +18,7 @@ import { lazy, Suspense } from "react";
const QuizForm = lazy(() => import("./components/QuizForm.jsx"));
const LessonForm = lazy(() => import("./components/LessonForm.jsx"));
+const AssignmentForm = lazy(() => import("./components/AssignmentForm.jsx"));
const DeleteContentForm = lazy(() => import("./components/DeleteContentForm.jsx"));
@@ -237,6 +239,25 @@ function Course() {
initialIsBlocking={content.quiz.is_blocking}
initialReminderIntervalDays={content.quiz.reminder_interval_days}
/>);
+ } else if (content.type == 'assignment') {
+ console.log("Opening assignment editor for content:", content);
+ setDialogOpen(true);
+ setDialogContent(}> setDialogOpen(false)}
+ successCallback={resetDialog}
+ courseId={courseId}
+ assignmentId={content.assignment.id}
+ contentId={content.id}
+ initialTitle={content.assignment.title}
+ initialDescription={content.assignment.description}
+ initialIsBlocking={content.assignment.is_blocking}
+ initialDeadlineDays={content.assignment.deadline_days}
+ initialRequiresTextSubmission={content.assignment.requires_text_submission}
+ initialRequiresFileSubmission={content.assignment.requires_file_submission}
+ initialReminderIntervalDays={content.assignment.reminder_interval_days}
+ initialWaitingPeriod={content.waiting_period}
+ />);
}
}
if (event.type === 'content_reordered') {
@@ -335,6 +356,13 @@ function Course() {
successCallback={resetDialog}
courseId={courseId} />);
setDialogOpen(true);}}>{localeMessages["add_quiz"]}
+ } sx={{ marginBottom: 2, marginLeft: 1, marginRight: 1 }} onClick={() => {
+ setDialogContent(}> setDialogOpen(false)}
+ successCallback={resetDialog}
+ courseId={courseId} />);
+ setDialogOpen(true);}}>{localeMessages["add_assignment"]}
{userRole === 'admin' && }
> }
{customComponent && }
@@ -343,9 +371,9 @@ function Course() {
-
-
-
+
+
+
{localeMessages["enrollments_distribution"]}
{(localeMessages["total_enrollments"]) + ': ' + totalEnrollments}
@@ -391,8 +419,8 @@ function Course() {
)}
-
-
+
+
{localeMessages["weekly_enrollments"]}
{isWeeklyStatsLoading ? (
diff --git a/frontend/platform/course/components/AssignmentForm.jsx b/frontend/platform/course/components/AssignmentForm.jsx
new file mode 100644
index 0000000..6163485
--- /dev/null
+++ b/frontend/platform/course/components/AssignmentForm.jsx
@@ -0,0 +1,525 @@
+import { useState, useEffect } from 'react';
+import {
+ Alert,
+ Box,
+ Button,
+ FormControlLabel,
+ Grid,
+ InputLabel,
+ MenuItem,
+ Select,
+ Switch,
+ Tooltip,
+ Typography,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogContentText,
+ DialogActions,
+} from '@mui/material';
+import RequiredTextField from '../../../src/components/RequiredTextField';
+import { getCookie } from '../../../src/utils';
+import { useAppContext } from '../../../src/render';
+
+const AssignmentForm = ({
+ cancelCallback,
+ successCallback,
+ courseId,
+ assignmentId,
+ contentId,
+ initialTitle,
+ initialDescription,
+ initialIsBlocking,
+ initialDeadlineDays,
+ initialRequiresTextSubmission,
+ initialRequiresFileSubmission,
+ initialReminderIntervalDays,
+ initialWaitingPeriod,
+ header,
+}) => {
+ const { localeMessages, apiBaseUrl } = useAppContext();
+ const organizationId = localStorage.getItem('activeOrganizationId');
+
+ const initialWaitingPeriodValue = initialWaitingPeriod ? initialWaitingPeriod.period : 1;
+ const initialWaitingPeriodUnit = initialWaitingPeriod ? initialWaitingPeriod.type : 'days';
+ const initialHasDeadline =
+ initialDeadlineDays !== undefined ? Number(initialDeadlineDays) > 0 : true;
+ const initialDeadlineDaysValue =
+ initialDeadlineDays !== undefined ? Number(initialDeadlineDays) : 7;
+
+ const [assignmentIdentifier, setAssignmentIdentifier] = useState(assignmentId);
+ const [contentIdentifier, setContentIdentifier] = useState(contentId);
+
+ const [title, setTitle] = useState(initialTitle || '');
+ const [description, setDescription] = useState(initialDescription || '');
+ const [isBlocking, setIsBlocking] = useState(initialIsBlocking ?? true);
+ const [hasDeadline, setHasDeadline] = useState(initialHasDeadline);
+ const [deadlineDays, setDeadlineDays] = useState(initialDeadlineDaysValue);
+ const [requiresTextSubmission, setRequiresTextSubmission] = useState(
+ initialRequiresTextSubmission ?? true
+ );
+ const [requiresFileSubmission, setRequiresFileSubmission] = useState(
+ initialRequiresFileSubmission ?? false
+ );
+ const initialReminderIntervalValue = !initialHasDeadline
+ ? Number(initialReminderIntervalDays || 0)
+ : 0;
+ const initialReminderEnabled = Number(initialReminderIntervalValue) > 0;
+ const [hasReminderInterval, setHasReminderInterval] = useState(initialReminderEnabled);
+ const [reminderIntervalDays, setReminderIntervalDays] = useState(initialReminderIntervalValue);
+ const [waitingPeriod, setWaitingPeriod] = useState(initialWaitingPeriodValue);
+ const [waitingPeriodUnit, setWaitingPeriodUnit] = useState(initialWaitingPeriodUnit);
+
+ const [titleHelperText, setTitleHelperText] = useState('');
+ const [descriptionHelperText, setDescriptionHelperText] = useState('');
+ const [errorMessage, setErrorMessage] = useState('');
+ const [successMessage, setSuccessMessage] = useState('');
+
+ const [confirmCloseDialogOpen, setConfirmCloseDialogOpen] = useState(false);
+
+ const savedSnapshot = {
+ title: initialTitle || '',
+ description: initialDescription || '',
+ isBlocking: initialIsBlocking ?? true,
+ hasDeadline: initialHasDeadline,
+ deadlineDays: initialDeadlineDaysValue,
+ hasReminderInterval: initialReminderEnabled,
+ reminderIntervalDays: initialReminderIntervalValue,
+ requiresTextSubmission: initialRequiresTextSubmission ?? true,
+ requiresFileSubmission: initialRequiresFileSubmission ?? false,
+ waitingPeriod: String(initialWaitingPeriodValue),
+ waitingPeriodUnit: initialWaitingPeriodUnit,
+ };
+
+ const hasUnsavedChanges =
+ title !== savedSnapshot.title ||
+ description !== savedSnapshot.description ||
+ isBlocking !== savedSnapshot.isBlocking ||
+ hasDeadline !== savedSnapshot.hasDeadline ||
+ deadlineDays !== savedSnapshot.deadlineDays ||
+ hasReminderInterval !== savedSnapshot.hasReminderInterval ||
+ Number(reminderIntervalDays) !== Number(savedSnapshot.reminderIntervalDays) ||
+ requiresTextSubmission !== savedSnapshot.requiresTextSubmission ||
+ requiresFileSubmission !== savedSnapshot.requiresFileSubmission ||
+ String(waitingPeriod) !== savedSnapshot.waitingPeriod ||
+ waitingPeriodUnit !== savedSnapshot.waitingPeriodUnit;
+
+ useEffect(() => {
+ if (!successMessage) return;
+ const id = window.setTimeout(() => setSuccessMessage(''), 4000);
+ return () => window.clearTimeout(id);
+ }, [successMessage]);
+
+ const validateForm = () => {
+ let valid = true;
+ if (!title) {
+ setTitleHelperText(localeMessages['assignment_title_required']);
+ valid = false;
+ } else {
+ setTitleHelperText('');
+ }
+ if (!description) {
+ setDescriptionHelperText(localeMessages['assignment_description_required']);
+ valid = false;
+ } else {
+ setDescriptionHelperText('');
+ }
+ if (!requiresFileSubmission && !requiresTextSubmission) {
+ setErrorMessage(localeMessages['assignment_submission_required']);
+ valid = false;
+ }
+ if (
+ !hasDeadline &&
+ hasReminderInterval &&
+ (Number(reminderIntervalDays) <= 0 || reminderIntervalDays === '')
+ ) {
+ setErrorMessage(
+ localeMessages['reminder_interval_days_required'] ||
+ 'Reminder interval days must be greater than 0 when reminders are enabled.'
+ );
+ valid = false;
+ }
+ if (valid) {
+ setErrorMessage('');
+ }
+ return valid;
+ };
+
+ const buildPayload = (forCreate) => {
+ const finalDeadlineDays = hasDeadline ? deadlineDays : 0;
+ const normalizedReminderIntervalDays = !hasDeadline && hasReminderInterval
+ ? Number(reminderIntervalDays)
+ : 0;
+ const assignmentPayload = {
+ title,
+ description,
+ is_blocking: isBlocking,
+ deadline_days: finalDeadlineDays,
+ requires_text_submission: requiresTextSubmission,
+ requires_file_submission: requiresFileSubmission,
+ reminder_interval_days: normalizedReminderIntervalDays,
+ ...(forCreate ? { type: 'assignment' } : {}),
+ };
+
+ if (forCreate) {
+ return {
+ content: assignmentPayload,
+ waiting_period: { period: waitingPeriod, type: waitingPeriodUnit },
+ };
+ }
+ return {
+ assignment: assignmentPayload,
+ waiting_period: { period: waitingPeriod, type: waitingPeriodUnit },
+ };
+ };
+
+ const createAssignment = () => {
+ if (!validateForm()) {
+ if (!errorMessage) {
+ setErrorMessage(localeMessages['fix_errors']);
+ }
+ return;
+ }
+ fetch(`${apiBaseUrl}/organizations/${organizationId}/courses/${courseId}/contents/`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': getCookie('csrftoken'),
+ },
+ body: JSON.stringify(buildPayload(true)),
+ })
+ .then((res) => {
+ if (!res.ok) throw new Error('Assignment create failed');
+ return res.json();
+ })
+ .then((data) => {
+ setErrorMessage('');
+ setAssignmentIdentifier(data.assignment.id);
+ setContentIdentifier(data.id);
+ setSuccessMessage(localeMessages['assignment_saved_success']);
+ successCallback?.();
+ })
+ .catch(() => {
+ setSuccessMessage('');
+ setErrorMessage(localeMessages['save_failed']);
+ });
+ };
+
+ const updateAssignment = () => {
+ if (!validateForm()) {
+ if (!errorMessage) {
+ setErrorMessage(localeMessages['fix_errors']);
+ }
+ return;
+ }
+ fetch(
+ `${apiBaseUrl}/organizations/${organizationId}/courses/${courseId}/contents/${contentIdentifier}/`,
+ {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': getCookie('csrftoken'),
+ },
+ body: JSON.stringify(buildPayload(false)),
+ }
+ )
+ .then((res) => {
+ if (!res.ok) throw new Error('Assignment update failed');
+ return res.json();
+ })
+ .then(() => {
+ setErrorMessage('');
+ setSuccessMessage(localeMessages['assignment_saved_success']);
+ successCallback?.();
+ })
+ .catch(() => {
+ setSuccessMessage('');
+ setErrorMessage(localeMessages['save_failed']);
+ });
+ };
+
+ const handleSave = () => {
+ if (assignmentIdentifier) {
+ updateAssignment();
+ } else {
+ createAssignment();
+ }
+ };
+
+ const handleCancel = () => {
+ if (hasUnsavedChanges) {
+ setConfirmCloseDialogOpen(true);
+ } else {
+ cancelCallback?.();
+ }
+ };
+
+ useEffect(() => {
+ if (hasDeadline) {
+ if (hasReminderInterval) {
+ setHasReminderInterval(false);
+ }
+ if (reminderIntervalDays !== 0) {
+ setReminderIntervalDays(0);
+ }
+ }
+ }, [hasDeadline, hasReminderInterval, reminderIntervalDays]);
+
+ return (
+
+
+
+ {header || localeMessages['new_assignment']}
+
+
+
+ {errorMessage && {errorMessage}}
+ {successMessage && {successMessage}}
+
+ setTitle(e.target.value)}
+ helperText={titleHelperText}
+ error={!!titleHelperText}
+ sx={{ mb: 2, width: '100%' }}
+ />
+
+ setDescription(e.target.value)}
+ helperText={descriptionHelperText}
+ error={!!descriptionHelperText}
+ multiline
+ minRows={5}
+ sx={{ mb: 3, width: '100%' }}
+ />
+
+ {/* Settings section — mirrors QuizForm grid layout */}
+
+
+ {localeMessages['quiz_settings']}
+
+
+
+
+ {/* Blocking */}
+
+
+
+ {localeMessages['blocking_assignment']}
+
+ setIsBlocking(e.target.checked)}
+ />
+ }
+ label={localeMessages['blocking_assignment']}
+ />
+
+ {localeMessages['blocking_assignment_tooltip']}
+
+
+
+
+ {/* Waiting period */}
+
+
+
+
+ {localeMessages['waiting_period']}
+
+
+ setWaitingPeriod(e.target.value)}
+ slotProps={{ htmlInput: { min: 1 } }}
+ />
+
+
+
+
+
+
+ {/* Deadline */}
+
+
+
+
+ {localeMessages['assignment_deadline']}
+
+ {
+ setHasDeadline(e.target.checked);
+ if (!e.target.checked) {
+ setDeadlineDays(0);
+ } else if (!deadlineDays || deadlineDays === 0) {
+ setDeadlineDays(7);
+ }
+ }}
+ size="small"
+ />
+ }
+ label=""
+ sx={{ m: 0 }}
+ />
+
+
+ { if (hasDeadline) setDeadlineDays(e.target.value); }}
+ sx={{ width: '100%' }}
+ slotProps={{ htmlInput: { min: hasDeadline ? 1 : 0 } }}
+ disabled={!hasDeadline}
+ />
+
+
+
+
+ {/* Requires text submission */}
+ {!hasDeadline && (
+
+
+
+
+
+ {localeMessages['reminder_interval_days']}
+
+ {
+ setHasReminderInterval(e.target.checked);
+ if (!e.target.checked) {
+ setReminderIntervalDays(0);
+ } else if (
+ reminderIntervalDays === 0 ||
+ reminderIntervalDays === '0' ||
+ reminderIntervalDays === ''
+ ) {
+ setReminderIntervalDays(1);
+ }
+ }}
+ size="small"
+ />
+ }
+ label=""
+ sx={{ m: 0 }}
+ />
+
+ {
+ if (hasReminderInterval) {
+ setReminderIntervalDays(e.target.value);
+ }
+ }}
+ sx={{ width: '100%' }}
+ slotProps={{ htmlInput: { min: hasReminderInterval ? 1 : 0 } }}
+ disabled={!hasReminderInterval}
+ />
+
+
+
+ )}
+
+
+
+
+ {localeMessages['requires_text_submission']}
+
+ setRequiresTextSubmission(e.target.checked)}
+ />
+ }
+ label={localeMessages['requires_text_submission']}
+ />
+
+
+
+ {/* Requires file submission */}
+
+
+
+ {localeMessages['requires_file_submission']}
+
+ setRequiresFileSubmission(e.target.checked)}
+ />
+ }
+ label={localeMessages['requires_file_submission']}
+ />
+
+
+
+
+
+
+ {/* Sticky action bar */}
+
+
+
+
+
+ {/* Unsaved-changes confirm dialog */}
+
+
+ );
+};
+
+export default AssignmentForm;
diff --git a/tests/conftest.py b/tests/conftest.py
index a50cf94..dd02495 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -6,6 +6,7 @@
ImapConnection,
Quiz,
Lesson,
+ Assignment,
Course,
BlockedEmail,
Learner,
@@ -198,6 +199,20 @@ def lesson(db) -> Lesson:
return lesson
+@pytest.fixture()
+def assignment(db) -> Assignment:
+ assignment = Assignment(
+ title="Sample Assignment",
+ description="Assignment description",
+ is_blocking=True,
+ deadline_days=7,
+ requires_text_submission=True,
+ requires_file_submission=False,
+ )
+ assignment.save()
+ return assignment
+
+
@pytest.fixture()
def course(db, imap_connection) -> Course:
course = Course(
@@ -256,6 +271,19 @@ def course_quiz_content(db, course, quiz) -> CourseContent:
return content
+@pytest.fixture
+def course_assignment_content(db, course, assignment) -> CourseContent:
+ content = CourseContent.objects.create(
+ course=course,
+ priority=3,
+ type="assignment",
+ assignment=assignment,
+ waiting_period=7200,
+ is_published=False,
+ )
+ return content
+
+
@pytest.fixture
def quiz_with_questions(db, quiz) -> Quiz:
questions = []
diff --git a/tests/platform/api/test_views/test_course_content_view.py b/tests/platform/api/test_views/test_course_content_view.py
index 09b48e5..997579e 100644
--- a/tests/platform/api/test_views/test_course_content_view.py
+++ b/tests/platform/api/test_views/test_course_content_view.py
@@ -214,6 +214,41 @@ def test_create_quiz_content(superadmin_client, create_course):
assert len(data["quiz"]["questions"][1]["answers"]) == 2
+def test_create_assignment_content(superadmin_client, create_course):
+ url = get_url()
+ payload = {
+ "content": {
+ "type": "assignment",
+ "title": "Assignment 1",
+ "description": "Submit your first project draft.",
+ "is_blocking": True,
+ "deadline_days": 10,
+ "requires_text_submission": True,
+ "requires_file_submission": True,
+ },
+ "priority": 3,
+ "waiting_period": {"period": 6, "type": "hours"},
+ }
+ response = superadmin_client.post(
+ url, json.dumps(payload), content_type="application/json"
+ )
+
+ assert response.status_code == 201
+ data = response.json()
+ assert data["id"] is not None
+ assert data["type"] == "assignment"
+ assert data["priority"] == 3
+ assert data["waiting_period"] == {"period": 6, "type": "hours"}
+ assert data["is_published"] is False
+ assert data["assignment"]["id"] is not None
+ assert data["assignment"]["title"] == "Assignment 1"
+ assert data["assignment"]["description"] == "Submit your first project draft."
+ assert data["assignment"]["is_blocking"] is True
+ assert data["assignment"]["deadline_days"] == 10
+ assert data["assignment"]["requires_text_submission"] is True
+ assert data["assignment"]["requires_file_submission"] is True
+
+
def test_invalid_quiz_content_missing_questions(superadmin_client, create_course):
url = get_url()
payload = {
@@ -455,6 +490,46 @@ def test_get_course_content(viewer_client, course_lesson_content):
assert get_data["lesson"]["content"] == course_lesson_content.lesson.content
+def test_get_course_content_assignment_response_check(
+ viewer_client, course_assignment_content
+):
+ url = single_content_url(
+ course_content_id=course_assignment_content.id,
+ course_id=course_assignment_content.course.id,
+ organization_id=course_assignment_content.course.organization.id,
+ )
+ response = viewer_client.get(url)
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == course_assignment_content.id
+ assert data["type"] == "assignment"
+ assert data["priority"] == course_assignment_content.priority
+ assert data["waiting_period"] == {"period": 2, "type": "hours"}
+ assert data["assignment"]["id"] == course_assignment_content.assignment.id
+ assert data["assignment"]["title"] == course_assignment_content.assignment.title
+ assert (
+ data["assignment"]["description"]
+ == course_assignment_content.assignment.description
+ )
+ assert (
+ data["assignment"]["is_blocking"]
+ == course_assignment_content.assignment.is_blocking
+ )
+ assert (
+ data["assignment"]["deadline_days"]
+ == course_assignment_content.assignment.deadline_days
+ )
+ assert (
+ data["assignment"]["requires_text_submission"]
+ == course_assignment_content.assignment.requires_text_submission
+ )
+ assert (
+ data["assignment"]["requires_file_submission"]
+ == course_assignment_content.assignment.requires_file_submission
+ )
+
+
def test_update_course_content_valid_quiz_data(superadmin_client, course_quiz_content):
url = single_content_url(
course_content_id=course_quiz_content.id,
@@ -680,6 +755,38 @@ def test_update_course_content_with_valid_lesson_data(
assert data["lesson"]["content"] == "Updated lesson content"
+def test_update_course_content_with_valid_assignment_data(
+ superadmin_client, course_assignment_content
+):
+ url = single_content_url(
+ course_content_id=course_assignment_content.id,
+ course_id=course_assignment_content.course.id,
+ organization_id=course_assignment_content.course.organization.id,
+ )
+ payload = {
+ "assignment": {
+ "title": "Updated Assignment Title",
+ "description": "Updated assignment details",
+ "deadline_days": 14,
+ "requires_text_submission": False,
+ "requires_file_submission": True,
+ }
+ }
+ response = superadmin_client.post(
+ url, json.dumps(payload), content_type="application/json"
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["id"] == course_assignment_content.id
+ assert data["type"] == "assignment"
+ assert data["assignment"]["title"] == "Updated Assignment Title"
+ assert data["assignment"]["description"] == "Updated assignment details"
+ assert data["assignment"]["deadline_days"] == 14
+ assert data["assignment"]["requires_text_submission"] is False
+ assert data["assignment"]["requires_file_submission"] is True
+
+
def test_update_course_content_with_invalid_lesson_data(
superadmin_client, course_lesson_content
):