Skip to content

Commit 3f4375a

Browse files
Allow program as requirement or elective on another program (#2772)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 6740c0b commit 3f4375a

File tree

11 files changed

+508
-40
lines changed

11 files changed

+508
-40
lines changed

courses/forms.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ def program_requirements_schema():
1818
# such as read data from database and populate choices
1919

2020
courses = Course.objects.live().order_by("title")
21+
programs = Program.objects.live().order_by("title")
2122

2223
return {
2324
"title": "Requirements",
@@ -105,11 +106,13 @@ def program_requirements_schema():
105106
"enum": [
106107
ProgramRequirementNodeType.COURSE.value,
107108
ProgramRequirementNodeType.OPERATOR.value,
109+
ProgramRequirementNodeType.PROGRAM.value,
108110
],
109111
"options": {
110112
"enum_titles": [
111113
ProgramRequirementNodeType.COURSE.label,
112114
ProgramRequirementNodeType.OPERATOR.label,
115+
ProgramRequirementNodeType.PROGRAM.label,
113116
],
114117
},
115118
},
@@ -170,6 +173,21 @@ def program_requirements_schema():
170173
],
171174
},
172175
},
176+
# program fields
177+
"required_program": {
178+
"type": "number",
179+
"title": "Required Program",
180+
"enum": [program.id for program in programs],
181+
"options": {
182+
"dependencies": {
183+
"node_type": ProgramRequirementNodeType.PROGRAM.value
184+
},
185+
"enum_titles": [
186+
f"{program.readable_id} | {program.title}"
187+
for program in programs
188+
],
189+
},
190+
},
173191
},
174192
},
175193
},
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Generated by Django for MITx Online
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
dependencies = [
9+
("courses", "0062_update_courserun_name_unique"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="programrequirement",
15+
name="required_program",
16+
field=models.ForeignKey(
17+
blank=True,
18+
help_text="Program that is required to be completed",
19+
null=True,
20+
on_delete=django.db.models.deletion.CASCADE,
21+
related_name="required_by",
22+
to="courses.program",
23+
),
24+
),
25+
migrations.AlterField(
26+
model_name="programrequirement",
27+
name="node_type",
28+
field=models.CharField(
29+
choices=[
30+
("program_root", "Program Root"),
31+
("operator", "Operator"),
32+
("course", "Course"),
33+
("program", "Program"),
34+
],
35+
max_length=12,
36+
null=True,
37+
),
38+
),
39+
migrations.RemoveConstraint(
40+
model_name="programrequirement",
41+
name="courses_programrequirement_node_check",
42+
),
43+
migrations.AddConstraint(
44+
model_name="programrequirement",
45+
constraint=models.CheckConstraint(
46+
check=models.Q(
47+
models.Q(
48+
("course__isnull", True),
49+
("depth", 1),
50+
("node_type", "program_root"),
51+
("operator__isnull", True),
52+
("operator_value__isnull", True),
53+
("required_program__isnull", True),
54+
),
55+
models.Q(
56+
("course__isnull", True),
57+
("depth__gt", 1),
58+
("node_type", "operator"),
59+
("operator__isnull", False),
60+
("required_program__isnull", True),
61+
),
62+
models.Q(
63+
("course__isnull", False),
64+
("depth__gt", 1),
65+
("node_type", "course"),
66+
("operator__isnull", True),
67+
("operator_value__isnull", True),
68+
("required_program__isnull", True),
69+
),
70+
models.Q(
71+
("course__isnull", True),
72+
("depth__gt", 1),
73+
("node_type", "program"),
74+
("operator__isnull", True),
75+
("operator_value__isnull", True),
76+
("required_program__isnull", False),
77+
),
78+
_connector="OR",
79+
),
80+
name="courses_programrequirement_node_check",
81+
),
82+
),
83+
migrations.AlterIndexTogether(
84+
name="programrequirement",
85+
index_together={
86+
("program", "course"),
87+
("course", "program"),
88+
("program", "required_program"),
89+
("required_program", "program"),
90+
},
91+
),
92+
]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Generated by Django 4.2.22 on 2025-07-02 18:33
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("courses", "0063_add_program_requirement_support"),
9+
]
10+
11+
operations = [
12+
migrations.RenameIndex(
13+
model_name="programrequirement",
14+
new_name="courses_pro_program_18e9ef_idx",
15+
old_fields=("program", "required_program"),
16+
),
17+
migrations.RenameIndex(
18+
model_name="programrequirement",
19+
new_name="courses_pro_require_99b956_idx",
20+
old_fields=("required_program", "program"),
21+
),
22+
migrations.AlterIndexTogether(
23+
name="programrequirement",
24+
index_together=set(),
25+
),
26+
]

courses/models.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,34 @@ def add_elective(self, course):
319319

320320
return new_req # noqa: RET504
321321

322+
def add_program_requirement(self, required_program):
323+
"""Makes the specified program a required program"""
324+
self.get_requirements_root().get_children().filter(
325+
required_program=required_program
326+
).delete()
327+
328+
new_req = self._add_course_node(ProgramRequirement.Operator.ALL_OF).add_child(
329+
required_program=required_program,
330+
node_type=ProgramRequirementNodeType.PROGRAM,
331+
)
332+
333+
return new_req # noqa: RET504
334+
335+
def add_program_elective(self, required_program):
336+
"""Makes the specified program an elective program"""
337+
self.get_requirements_root().get_children().filter(
338+
required_program=required_program
339+
).delete()
340+
341+
new_req = self._add_course_node(
342+
ProgramRequirement.Operator.MIN_NUMBER_OF
343+
).add_child(
344+
required_program=required_program,
345+
node_type=ProgramRequirementNodeType.PROGRAM,
346+
)
347+
348+
return new_req # noqa: RET504
349+
322350
def save(self, *args, **kwargs):
323351
super().save(*args, **kwargs)
324352

@@ -413,6 +441,46 @@ def elective_courses(self) -> list:
413441
"""
414442
return [course for (course, type) in self.courses if type == "Elective Courses"] # noqa: A001
415443

444+
@property
445+
def required_programs(self):
446+
"""
447+
Returns the programs that are required by this program.
448+
449+
Returns:
450+
- list of Program: programs that are requirements
451+
"""
452+
return [
453+
req.required_program
454+
for req in ProgramRequirement.objects.filter(
455+
program=self,
456+
node_type=ProgramRequirementNodeType.PROGRAM,
457+
required_program__isnull=False,
458+
)
459+
.select_related("required_program")
460+
.all()
461+
if not req.get_parent().elective_flag
462+
]
463+
464+
@property
465+
def elective_programs(self):
466+
"""
467+
Returns the programs that are electives for this program.
468+
469+
Returns:
470+
- list of Program: programs that are electives
471+
"""
472+
return [
473+
req.required_program
474+
for req in ProgramRequirement.objects.filter(
475+
program=self,
476+
node_type=ProgramRequirementNodeType.PROGRAM,
477+
required_program__isnull=False,
478+
)
479+
.select_related("required_program")
480+
.all()
481+
if req.get_parent().elective_flag
482+
]
483+
416484
def __str__(self):
417485
title = f"{self.readable_id} | {self.title}"
418486
return title if len(title) <= 100 else title[:97] + "..." # noqa: PLR2004
@@ -1479,6 +1547,7 @@ class ProgramRequirementNodeType(models.TextChoices):
14791547
PROGRAM_ROOT = "program_root", "Program Root"
14801548
OPERATOR = "operator", "Operator"
14811549
COURSE = "course", "Course"
1550+
PROGRAM = "program", "Program"
14821551

14831552

14841553
class ProgramRequirement(MP_Node):
@@ -1548,6 +1617,14 @@ class Operator(models.TextChoices):
15481617
blank=True,
15491618
related_name="in_programs",
15501619
)
1620+
required_program = models.ForeignKey(
1621+
"courses.Program",
1622+
on_delete=models.CASCADE,
1623+
null=True,
1624+
blank=True,
1625+
related_name="required_by",
1626+
help_text="Program that is required to be completed",
1627+
)
15511628

15521629
title = models.TextField(null=True, blank=True, default="") # noqa: DJ001
15531630
elective_flag = models.BooleanField(null=True, blank=True, default=False)
@@ -1572,6 +1649,11 @@ def is_course(self):
15721649
"""True if the node references a course"""
15731650
return self.node_type == ProgramRequirementNodeType.COURSE
15741651

1652+
@property
1653+
def is_program(self):
1654+
"""True if the node references a program"""
1655+
return self.node_type == ProgramRequirementNodeType.PROGRAM
1656+
15751657
@property
15761658
def is_root(self):
15771659
"""True if the node is the root"""
@@ -1594,6 +1676,8 @@ def __str__(self):
15941676
attrs["operator_value"] = self.operator_value
15951677
elif self.is_course:
15961678
attrs["course"] = self.course
1679+
elif self.is_program:
1680+
attrs["required_program"] = self.required_program
15971681

15981682
return " ".join(f"{key}={value}" for key, value in attrs.items())
15991683

@@ -1609,13 +1693,15 @@ class Meta:
16091693
operator__isnull=True,
16101694
operator_value__isnull=True,
16111695
course__isnull=True,
1696+
required_program__isnull=True,
16121697
depth=1,
16131698
)
16141699
# operator nodes
16151700
| Q(
16161701
node_type=ProgramRequirementNodeType.OPERATOR.value,
16171702
operator__isnull=False,
16181703
course__isnull=True,
1704+
required_program__isnull=True,
16191705
depth__gt=1,
16201706
)
16211707
# course nodes
@@ -1624,6 +1710,16 @@ class Meta:
16241710
operator__isnull=True,
16251711
operator_value__isnull=True,
16261712
course__isnull=False,
1713+
required_program__isnull=True,
1714+
depth__gt=1,
1715+
)
1716+
# program nodes
1717+
| Q(
1718+
node_type=ProgramRequirementNodeType.PROGRAM.value,
1719+
operator__isnull=True,
1720+
operator_value__isnull=True,
1721+
course__isnull=True,
1722+
required_program__isnull=False,
16271723
depth__gt=1,
16281724
)
16291725
),
@@ -1638,6 +1734,8 @@ class Meta:
16381734
indexes = [
16391735
models.Index(fields=("program", "course")),
16401736
models.Index(fields=("course", "program")),
1737+
models.Index(fields=("program", "required_program")),
1738+
models.Index(fields=("required_program", "program")),
16411739
]
16421740

16431741

0 commit comments

Comments
 (0)