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"]} + {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 */} + setConfirmCloseDialogOpen(false)}> + {localeMessages['cancel']} + + {localeMessages['unsaved_changes_warning']} + + + + + + + + ); +}; + +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 ):