Skip to content

Commit 18d5f06

Browse files
committed
Add overrides
1 parent 207ff05 commit 18d5f06

8 files changed

Lines changed: 453 additions & 19 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Generated by Django 5.0.2 on 2026-04-08 06:00
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("degree", "0003_alter_degree_program_alter_fulfillment_legal_and_more"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="fulfillment",
15+
name="overrides",
16+
field=models.ManyToManyField(
17+
blank=True,
18+
help_text="\nRules this course is manually overridden to count for, bypassing the normal Q filter\ncheck. Use this to handle edge cases where a course should fulfill a requirement\neven though it doesn't technically satisfy the rule's conditions.\n",
19+
related_name="overridden_fulfillments",
20+
to="degree.rule",
21+
),
22+
),
23+
migrations.AlterField(
24+
model_name="degree",
25+
name="program",
26+
field=models.CharField(
27+
choices=[
28+
("EU_BSE", "Engineering BSE"),
29+
("EU_BAS", "Engineering BAS"),
30+
("AU_BA", "College BA"),
31+
("WU_BS", "Wharton BS"),
32+
("NU_BSN", "Nursing BSN"),
33+
],
34+
help_text="\nThe program code for this degree, e.g., EU_BSE\n",
35+
max_length=16,
36+
),
37+
),
38+
]

backend/degree/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,18 @@ class Fulfillment(models.Model):
464464
"""
465465
),
466466
)
467+
overrides = models.ManyToManyField(
468+
Rule,
469+
related_name="overridden_fulfillments",
470+
blank=True,
471+
help_text=dedent(
472+
"""
473+
Rules this course is manually overridden to count for, bypassing the normal Q filter
474+
check. Use this to handle edge cases where a course should fulfill a requirement
475+
even though it doesn't technically satisfy the rule's conditions.
476+
"""
477+
),
478+
)
467479

468480
class Meta:
469481
unique_together = ("degree_plan", "full_code")

backend/degree/serializers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ def get_course(self, obj):
120120
rules = serializers.PrimaryKeyRelatedField(
121121
many=True, queryset=Rule.objects.all(), required=False
122122
)
123+
overrides = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
123124

124125
def to_internal_value(self, data):
125126
"""
@@ -140,6 +141,7 @@ class Meta:
140141
"rules",
141142
"unselected_rules",
142143
"legal",
144+
"overrides",
143145
]
144146

145147
def validate(self, data):
@@ -168,8 +170,12 @@ def validate(self, data):
168170

169171
data["rules"] = rules
170172

173+
overridden_rules = set(self.instance.overrides.all()) if self.instance else set()
174+
171175
# TODO: check that rules belong to this degree plan
172176
for rule in rules:
177+
if rule in overridden_rules:
178+
continue
173179
# NOTE: we don't do any validation if the course doesn't exist in DB. In future,
174180
# it may be better to prompt user for manual override
175181
if (

backend/degree/views.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ def switch_rule(self, request, *args, **kwargs):
264264
if target_rule not in rule_to_degree:
265265
raise ValidationError({"rule_id": "Rule does not belong to this degree plan."})
266266

267-
if not target_rule.check_belongs(full_code):
267+
if not target_rule.check_belongs(full_code) and target_rule not in fulfillment.overrides.all():
268268
raise ValidationError(
269269
{"rule_id": f"Course {full_code} does not satisfy rule {target_rule.id}"}
270270
)
@@ -329,6 +329,56 @@ def switch_rule(self, request, *args, **kwargs):
329329
data["displaced"] = displaced
330330
return Response(data, status=status.HTTP_200_OK)
331331

332+
@action(detail=True, methods=["post"], url_path="override")
333+
def add_override(self, request, *args, **kwargs):
334+
"""
335+
Add a manual override that allows a course to count for a rule regardless of whether
336+
it satisfies the rule's Q filter. Also adds the rule to the fulfillment's selected
337+
rules if not already present.
338+
339+
POST with `{"rule_id": <id>}`.
340+
"""
341+
rule_id = request.data.get("rule_id")
342+
if rule_id is None:
343+
raise ValidationError({"rule_id": "This field is required."})
344+
345+
try:
346+
rule = Rule.objects.get(id=int(rule_id))
347+
except (ValueError, TypeError, Rule.DoesNotExist):
348+
raise ValidationError({"rule_id": "Invalid rule_id."})
349+
350+
fulfillment = self.get_object()
351+
352+
_, rule_to_degree = map_rules_and_degrees(fulfillment.degree_plan)
353+
if rule not in rule_to_degree:
354+
raise ValidationError({"rule_id": "Rule does not belong to this degree plan."})
355+
356+
fulfillment.overrides.add(rule)
357+
if rule not in fulfillment.rules.all():
358+
fulfillment.rules.add(rule)
359+
360+
return Response(self.get_serializer(fulfillment).data, status=status.HTTP_200_OK)
361+
362+
@action(detail=True, methods=["delete"], url_path="override/(?P<rule_id>[^/.]+)")
363+
def remove_override(self, request, rule_id=None, *args, **kwargs):
364+
"""
365+
Remove a manual override, and remove the rule from the fulfillment's selected
366+
and unselected rules.
367+
368+
DELETE to `override/<rule_id>/`.
369+
"""
370+
try:
371+
rule = Rule.objects.get(id=int(rule_id))
372+
except (ValueError, TypeError, Rule.DoesNotExist):
373+
raise ValidationError({"rule_id": "Invalid rule_id."})
374+
375+
fulfillment = self.get_object()
376+
fulfillment.overrides.remove(rule)
377+
fulfillment.rules.remove(rule)
378+
fulfillment.unselected_rules.remove(rule)
379+
380+
return Response(self.get_serializer(fulfillment).data, status=status.HTTP_200_OK)
381+
332382

333383
class DockedCourseViewset(viewsets.ModelViewSet):
334384
"""

frontend/degree-plan/components/Course/Course.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ interface DraggableComponentProps {
104104
isDragging: boolean;
105105
fulfillment?: Fulfillment;
106106
isUnselectedRule?: boolean;
107+
isOverride?: boolean;
107108
ruleId?: number;
108109
activeDegreePlanId?: number;
109110
className?: string;
@@ -209,6 +210,15 @@ const RuleList = styled.ul`
209210
}
210211
`;
211212

213+
const OverrideBadge = styled.span`
214+
display: inline-flex;
215+
align-items: center;
216+
gap: 0.2rem;
217+
font-size: 0.7rem;
218+
color: #b8860b;
219+
font-weight: 600;
220+
`;
221+
212222
const SwitchRuleButton = styled.button`
213223
border: 1px solid #d7cc86;
214224
border-radius: 6px;
@@ -309,6 +319,7 @@ const CourseComponent = ({
309319
course,
310320
fulfillment,
311321
isUnselectedRule = false,
322+
isOverride = false,
312323
ruleId,
313324
activeDegreePlanId,
314325
removeCourse,
@@ -342,6 +353,10 @@ const CourseComponent = ({
342353
.map(id => ruleMap.get(id))
343354
.filter((t): t is { title: string; degreeName: string } => !!t);
344355
}, [fulfillment?.unselected_rules, degreePlanDetail]);
356+
const overrideSet = useMemo(
357+
() => new Set(fulfillment?.overrides ?? []),
358+
[fulfillment?.overrides]
359+
);
345360
const selectedRuleNames = useMemo(() => {
346361
if (!fulfillment?.rules?.length || !degreePlanDetail?.degrees) return [];
347362
const ruleMap = new Map<number, { title: string; degreeName: string }>();
@@ -352,9 +367,13 @@ const CourseComponent = ({
352367
);
353368
}
354369
return fulfillment.rules
355-
.map(id => ruleMap.get(id))
356-
.filter((t): t is { title: string; degreeName: string } => !!t);
357-
}, [fulfillment?.rules, degreePlanDetail]);
370+
.map(id => {
371+
const info = ruleMap.get(id);
372+
if (!info) return null;
373+
return { ...info, isOverride: overrideSet.has(id) };
374+
})
375+
.filter((t): t is { title: string; degreeName: string; isOverride: boolean } => !!t);
376+
}, [fulfillment?.rules, degreePlanDetail, overrideSet]);
358377

359378
const [infoOpen, setInfoOpen] = useState(false);
360379
const [switchingRule, setSwitchingRule] = useState(false);
@@ -503,6 +522,21 @@ const CourseComponent = ({
503522
)}
504523
<CourseBadge>
505524
{course.full_code.replace("-", " ")}
525+
{isOverride && (
526+
<OverrideBadge
527+
data-tooltip-id={`override-${fulfillment.full_code}-${ruleId}`}
528+
data-tooltip-content="Manual override"
529+
>
530+
<i className="fas fa-star" />
531+
</OverrideBadge>
532+
)}
533+
{isOverride && (
534+
<Tooltip
535+
id={`override-${fulfillment.full_code}-${ruleId}`}
536+
place="top"
537+
style={{ zIndex: 9999 }}
538+
/>
539+
)}
506540
{showSemesterOnCard && (
507541
<SemesterIcon semester={displaySemester} />
508542
)}
@@ -526,6 +560,11 @@ const CourseComponent = ({
526560
{selectedRuleNames.map((ruleInfo, i) => (
527561
<li key={i}>
528562
[{ruleInfo.degreeName}] {ruleInfo.title}
563+
{ruleInfo.isOverride && (
564+
<OverrideBadge style={{ marginLeft: "0.35rem" }}>
565+
<i className="fas fa-star" /> override
566+
</OverrideBadge>
567+
)}
529568
</li>
530569
))}
531570
</RuleList>

frontend/degree-plan/components/Requirements/CourseInReq.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,18 @@ const CourseInReq = ({ course, isUsed, isUnselectedRule = false, isDisabled, rul
6969
})
7070
}), [course])
7171

72+
const isOverride = !!fulfillment?.overrides?.includes(ruleId);
73+
7274
return (
73-
<CourseComponent
74-
courseType={ItemTypes.COURSE_IN_REQ}
75-
removeCourse={handleRemoveCourse}
76-
dragRef={drag}
77-
isDragging={isDragging}
75+
<CourseComponent
76+
courseType={ItemTypes.COURSE_IN_REQ}
77+
removeCourse={handleRemoveCourse}
78+
dragRef={drag}
79+
isDragging={isDragging}
7880
isDisabled={isDisabled}
7981
isUsed={isUsed}
8082
isUnselectedRule={isUnselectedRule}
83+
isOverride={isOverride}
8184
ruleId={ruleId}
8285
activeDegreePlanId={activeDegreePlanId}
8386
course={course}

0 commit comments

Comments
 (0)