Skip to content

Commit ea9195a

Browse files
authored
Merge pull request #400 from AvaCodeSolutions/feat/393/assignment-course-content
feat: #393 Add assignment course content
2 parents 3b4dbff + c598a94 commit ea9195a

11 files changed

Lines changed: 1044 additions & 26 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Generated by Django 6.0.4 on 2026-04-30 05:39
2+
3+
import django.core.validators
4+
import django.db.models.deletion
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
("django_email_learning", "0017_alter_organizationuser_role"),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name="Assignment",
16+
fields=[
17+
(
18+
"id",
19+
models.BigAutoField(
20+
auto_created=True,
21+
primary_key=True,
22+
serialize=False,
23+
verbose_name="ID",
24+
),
25+
),
26+
("title", models.CharField(max_length=200)),
27+
("description", models.TextField()),
28+
(
29+
"is_blocking",
30+
models.BooleanField(
31+
default=True,
32+
help_text="Whether the learner is required to submit the assignment to proceed to the next content.",
33+
),
34+
),
35+
(
36+
"deadline_days",
37+
models.IntegerField(
38+
help_text="Time limit to complete the assignment in days. 0 indicates no deadline.",
39+
validators=[django.core.validators.MinValueValidator(0)],
40+
),
41+
),
42+
(
43+
"requires_text_submission",
44+
models.BooleanField(
45+
default=True,
46+
help_text="Whether the assignment requires text submission.",
47+
),
48+
),
49+
(
50+
"requires_file_submission",
51+
models.BooleanField(
52+
default=False,
53+
help_text="Whether the assignment requires file submission.",
54+
),
55+
),
56+
],
57+
),
58+
migrations.AlterField(
59+
model_name="coursecontent",
60+
name="type",
61+
field=models.CharField(
62+
choices=[
63+
("lesson", "Lesson"),
64+
("quiz", "Quiz"),
65+
("assignment", "Assignment"),
66+
],
67+
max_length=50,
68+
),
69+
),
70+
migrations.AddField(
71+
model_name="coursecontent",
72+
name="assignment",
73+
field=models.ForeignKey(
74+
blank=True,
75+
null=True,
76+
on_delete=django.db.models.deletion.CASCADE,
77+
to="django_email_learning.assignment",
78+
),
79+
),
80+
migrations.AddConstraint(
81+
model_name="coursecontent",
82+
constraint=models.UniqueConstraint(
83+
condition=models.Q(("assignment__isnull", False)),
84+
fields=("course", "assignment"),
85+
name="unique_assignment_per_course",
86+
),
87+
),
88+
]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Generated by Django 6.0.4 on 2026-04-30 05:45
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_email_learning", "0018_assignment_alter_coursecontent_type_and_more"),
9+
]
10+
11+
operations = [
12+
migrations.AlterField(
13+
model_name="assignment",
14+
name="requires_file_submission",
15+
field=models.BooleanField(
16+
help_text="Whether the assignment requires file submission."
17+
),
18+
),
19+
migrations.AlterField(
20+
model_name="assignment",
21+
name="requires_text_submission",
22+
field=models.BooleanField(
23+
help_text="Whether the assignment requires text submission."
24+
),
25+
),
26+
]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Generated by Django 6.0.4 on 2026-04-30 08:01
2+
3+
import django.core.validators
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
(
10+
"django_email_learning",
11+
"0019_alter_assignment_requires_file_submission_and_more",
12+
),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name="assignment",
18+
name="reminder_interval_days",
19+
field=models.IntegerField(
20+
blank=True,
21+
help_text="For assignments without a deadline (deadline_days = 0), send reminder emails every N days.",
22+
null=True,
23+
validators=[django.core.validators.MinValueValidator(0)],
24+
),
25+
),
26+
]

django_email_learning/models.py

Lines changed: 78 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,34 @@ def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: # type: ignore
414414
return super().delete(*args, **kwargs)
415415

416416

417+
class Assignment(models.Model):
418+
title = models.CharField(max_length=200)
419+
description = models.TextField()
420+
is_blocking = models.BooleanField(
421+
default=True,
422+
help_text="Whether the learner is required to submit the assignment to proceed to the next content.",
423+
)
424+
deadline_days = models.IntegerField(
425+
help_text="Time limit to complete the assignment in days. 0 indicates no deadline.",
426+
validators=[MinValueValidator(0)],
427+
)
428+
requires_text_submission = models.BooleanField(
429+
help_text="Whether the assignment requires text submission."
430+
)
431+
requires_file_submission = models.BooleanField(
432+
help_text="Whether the assignment requires file submission."
433+
)
434+
reminder_interval_days = models.IntegerField(
435+
help_text="For assignments without a deadline (deadline_days = 0), send reminder emails every N days.",
436+
validators=[MinValueValidator(0)],
437+
null=True,
438+
blank=True,
439+
)
440+
441+
def __str__(self) -> str:
442+
return self.title
443+
444+
417445
class CourseContent(models.Model):
418446
course = models.ForeignKey(Course, on_delete=models.CASCADE)
419447
priority = models.IntegerField()
@@ -422,10 +450,14 @@ class CourseContent(models.Model):
422450
choices=[
423451
("lesson", "Lesson"),
424452
("quiz", "Quiz"),
453+
("assignment", "Assignment"),
425454
],
426455
)
427456
lesson = models.ForeignKey(Lesson, null=True, blank=True, on_delete=models.CASCADE)
428457
quiz = models.ForeignKey(Quiz, null=True, blank=True, on_delete=models.CASCADE)
458+
assignment = models.ForeignKey(
459+
Assignment, null=True, blank=True, on_delete=models.CASCADE
460+
)
429461
waiting_period = models.IntegerField(
430462
help_text="Waiting period in seconds after previous content is sent or submited."
431463
)
@@ -436,14 +468,34 @@ def __str__(self) -> str:
436468
return f"{self.priority} - Lesson: {self.lesson.title}"
437469
elif self.type == "quiz" and self.quiz:
438470
return f"{self.priority} - Quiz: {self.quiz.title}"
471+
elif self.type == "assignment" and self.assignment:
472+
return f"{self.priority} - Assignment: {self.assignment.title}"
439473
return f"{self.course.title} content #{self.priority}"
440474

475+
@property
476+
def deadline_days(self) -> Optional[int]:
477+
if self.type == "quiz" and self.quiz:
478+
return self.quiz.deadline_days
479+
elif self.type == "assignment" and self.assignment:
480+
return self.assignment.deadline_days
481+
return None
482+
483+
@property
484+
def reminder_interval_days(self) -> Optional[int]:
485+
if self.type == "quiz" and self.quiz:
486+
return self.quiz.reminder_interval_days
487+
elif self.type == "assignment" and self.assignment:
488+
return self.assignment.reminder_interval_days
489+
return None
490+
441491
@property
442492
def title(self) -> str:
443493
if self.type == "lesson" and self.lesson:
444494
return self.lesson.title
445495
elif self.type == "quiz" and self.quiz:
446496
return self.quiz.title
497+
elif self.type == "assignment" and self.assignment:
498+
return self.assignment.title
447499
return "Untitled Content"
448500

449501
@property
@@ -456,6 +508,8 @@ def limited_attempts(self) -> Optional[bool]:
456508
def is_blocking(self) -> Optional[bool]:
457509
if self.type == "quiz" and self.quiz:
458510
return self.quiz.is_blocking
511+
elif self.type == "assignment" and self.assignment:
512+
return self.assignment.is_blocking
459513
return None
460514

461515
def human_readable_waiting_period(self) -> str:
@@ -482,10 +536,14 @@ def _validate_content(self) -> None:
482536
raise ValidationError("Lesson must be provided for lesson content.")
483537
if self.type == "quiz" and not self.quiz:
484538
raise ValidationError("Quiz must be provided for quiz content.")
539+
if self.type == "assignment" and not self.assignment:
540+
raise ValidationError("Assignment must be provided for assignment content.")
485541
if self.type == "lesson" and self.lesson:
486542
self.lesson.full_clean()
487543
elif self.type == "quiz" and self.quiz:
488544
self.quiz.full_clean()
545+
elif self.type == "assignment" and self.assignment:
546+
self.assignment.full_clean()
489547

490548
def full_clean(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
491549
self._validate_content()
@@ -517,6 +575,11 @@ class Meta:
517575
condition=models.Q(lesson__isnull=False),
518576
name="unique_lesson_per_course",
519577
),
578+
models.UniqueConstraint(
579+
fields=["course", "assignment"],
580+
condition=models.Q(assignment__isnull=False),
581+
name="unique_assignment_per_course",
582+
),
520583
models.UniqueConstraint(
521584
fields=["course", "priority"],
522585
name="unique_priority_per_course",
@@ -861,28 +924,29 @@ def repeat_delivery_in_days(self, days: int) -> bool:
861924
return True
862925

863926
def calculate_remind_at(self) -> Optional[datetime]:
864-
if self.course_content.quiz:
865-
if self.course_content.quiz.deadline_days > 0:
866-
if self.course_content.quiz.deadline_days > 1:
927+
if self.course_content.quiz or self.course_content.assignment:
928+
if (
929+
self.course_content.deadline_days
930+
and self.course_content.deadline_days > 0
931+
):
932+
if self.course_content.deadline_days > 1:
867933
return timezone.now() + timedelta(
868-
days=self.course_content.quiz.deadline_days - 1
934+
days=self.course_content.deadline_days - 1
869935
)
870936
else:
871937
return timezone.now() + timedelta(
872-
hours=(self.course_content.quiz.deadline_days * 24) - 10
938+
hours=(self.course_content.deadline_days * 24) - 10
873939
)
874940
else:
875-
if self.course_content.quiz.reminder_interval_days:
941+
if self.course_content.reminder_interval_days:
876942
return timezone.now() + timedelta(
877-
days=self.course_content.quiz.reminder_interval_days
943+
days=self.course_content.reminder_interval_days
878944
)
879945
return None
880946

881947
def calculate_valid_until(self) -> Optional[datetime]:
882-
if self.course_content.quiz and self.course_content.quiz.deadline_days > 0:
883-
return timezone.now() + timedelta(
884-
days=self.course_content.quiz.deadline_days
885-
)
948+
if self.course_content.deadline_days and self.course_content.deadline_days > 0:
949+
return timezone.now() + timedelta(days=self.course_content.deadline_days)
886950
return None
887951

888952
def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
@@ -947,6 +1011,9 @@ def generate_link(self) -> str:
9471011
self.link = link
9481012
self.save()
9491013
return link
1014+
elif self.delivery.course_content.assignment:
1015+
# TODO: Implement assignment link generation
1016+
return ""
9501017
else:
9511018
# TODO: Implement lesson link generation
9521019
return ""

0 commit comments

Comments
 (0)