Skip to content

Commit b0c047f

Browse files
committed
fun-things
1 parent 1a161f5 commit b0c047f

12 files changed

Lines changed: 444 additions & 95 deletions

File tree

backend/courses/filters.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,10 @@ def meeting_filter(queryset, meeting_query):
6060
lab section, and thus the set of course activities available to us is incomplete).
6161
"""
6262
return queryset.filter(
63-
id__in=course_ids_by_section_query(
64-
Q(num_meetings=0) | Q(id__in=section_ids_by_meeting_query(meeting_query))
63+
id__in=Subquery(
64+
course_ids_by_section_query(
65+
Q(num_meetings=0) | Q(id__in=Subquery(section_ids_by_meeting_query(meeting_query)))
66+
)
6567
)
6668
)
6769

@@ -75,7 +77,7 @@ def is_open_filter(queryset, *args):
7577
Note that for compatibility, this function can take additional positional
7678
arguments, but these are ignored.
7779
"""
78-
return queryset.filter(id__in=course_ids_by_section_query(Q(status="O")))
80+
return queryset.filter(id__in=Subquery(course_ids_by_section_query(Q(status="O"))))
7981

8082

8183
def day_filter(days):
@@ -161,6 +163,22 @@ def pre_ngss_requirement_filter(queryset, req_ids):
161163
return queryset.filter(query)
162164

163165

166+
def requirements_filter(queryset, requirements):
167+
"""
168+
:param queryset: initial Course object queryset
169+
:param requirements: the requirements query string; a comma separated list of requirement codes.
170+
:return: filtered queryset
171+
"""
172+
if not requirements:
173+
return queryset
174+
175+
query = Q()
176+
for requirement in requirements.split(","):
177+
query |= Q(requirements__code=requirement)
178+
179+
return queryset.filter(query)
180+
181+
164182
# See the attribute_filter docstring for an explanation of this grammar
165183
# https://lark-parser.readthedocs.io/en/latest/examples/calc.html
166184
attribute_query_parser = Lark(
@@ -340,6 +358,7 @@ def filter_queryset(self, request, queryset, view):
340358
filters = {
341359
"attributes": attribute_filter,
342360
"pre_ngss_requirements": pre_ngss_requirement_filter,
361+
"requirements": requirements_filter,
343362
"cu": choice_filter("sections__credits"),
344363
"activity": choice_filter("sections__activity"),
345364
"course_quality": bound_filter("course_quality"),
@@ -367,7 +386,7 @@ def filter_queryset(self, request, queryset, view):
367386
if len(meeting_query) > 0:
368387
queryset = meeting_filter(queryset, meeting_query)
369388

370-
return queryset.distinct("full_code") # TODO: THIS COULD BE A BREAKING CHANGE FOR PCX
389+
return queryset
371390

372391
def get_schema_operation_parameters(self, view):
373392
return [
@@ -409,6 +428,20 @@ def get_schema_operation_parameters(self, view):
409428
"schema": {"type": "string"},
410429
"example": "SS@SEAS,H@SEAS",
411430
},
431+
{
432+
"name": "requirements",
433+
"required": False,
434+
"in": "query",
435+
"description": (
436+
"Filter by course requirements. Use the "
437+
"[List Requirements](/api/documentation/#operation/penn_courses_api_v1_courses_requirements_list) "
438+
"endpoint to get a list of valid requirement codes. "
439+
"This parameter accepts a comma-separated list of requirement codes. "
440+
"Courses that satisfy any of the requirements will be returned."
441+
),
442+
"schema": {"type": "string"},
443+
"example": "MFR/MATH,MFR/PHYS",
444+
},
412445
{
413446
"name": "attributes",
414447
"required": False,
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
from tqdm import tqdm
2+
from django.core.management.base import BaseCommand
3+
from django.db import transaction
4+
from django.db.models import OuterRef, Q, Subquery, Avg, F, Value, IntegerField
5+
6+
from courses.models import Course, Section, Instructor
7+
from review.models import ReviewBit, Review
8+
from review.views import section_filters_pcr
9+
10+
11+
def calculate_course_review_averages(course_queryset, stdout):
12+
"""
13+
Calculates and updates review averages for a given Course queryset.
14+
"""
15+
# Use a simplified version of review_averages that only focuses on Course data
16+
# We need to re-implement some parts as review_averages is too generic for direct use here
17+
annotated_queryset = course_queryset.annotate(
18+
avg_course_quality=Subquery(
19+
ReviewBit.objects.filter(
20+
review__section__course__topic=OuterRef('topic'),
21+
field='course_quality',
22+
review__responses__gt=0,
23+
)
24+
.values('field')
25+
.order_by()
26+
.annotate(avg=Avg('average'))
27+
.values('avg')[:1]
28+
),
29+
avg_instructor_quality=Subquery(
30+
ReviewBit.objects.filter(
31+
review__section__course__topic=OuterRef('topic'),
32+
field='instructor_quality',
33+
review__responses__gt=0,
34+
)
35+
.values('field')
36+
.order_by()
37+
.annotate(avg=Avg('average'))
38+
.values('avg')[:1]
39+
),
40+
avg_difficulty=Subquery(
41+
ReviewBit.objects.filter(
42+
review__section__course__topic=OuterRef('topic'),
43+
field='difficulty',
44+
review__responses__gt=0,
45+
)
46+
.values('field')
47+
.order_by()
48+
.annotate(avg=Avg('average'))
49+
.values('avg')[:1]
50+
),
51+
avg_work_required=Subquery(
52+
ReviewBit.objects.filter(
53+
review__section__course__topic=OuterRef('topic'),
54+
field='work_required',
55+
review__responses__gt=0,
56+
)
57+
.values('field')
58+
.order_by()
59+
.annotate(avg=Avg('average'))
60+
.values('avg')[:1]
61+
),
62+
)
63+
64+
with transaction.atomic():
65+
for course in tqdm(annotated_queryset, desc="Updating Course Averages", file=stdout):
66+
Course.objects.filter(pk=course.pk).update(
67+
course_quality=course.avg_course_quality,
68+
instructor_quality=course.avg_instructor_quality,
69+
difficulty=course.avg_difficulty,
70+
work_required=course.avg_work_required,
71+
)
72+
73+
def calculate_section_review_averages(section_queryset, stdout):
74+
"""
75+
Calculates and updates review averages for a given Section queryset.
76+
"""
77+
# Similar to calculate_course_review_averages, we need to re-implement for sections
78+
# as the original review_averages was too generic.
79+
80+
# Subquery for instructors related to the section
81+
instructors_subquery = Subquery(
82+
Instructor.objects.filter(section__id=OuterRef(OuterRef("id"))).values("id")
83+
)
84+
85+
annotated_queryset = section_queryset.annotate(
86+
avg_course_quality=Subquery(
87+
ReviewBit.objects.filter(
88+
Q(review__section__course__topic=OuterRef('course__topic')) & \
89+
Q(review__instructor__in=instructors_subquery),
90+
field='course_quality',
91+
review__responses__gt=0,
92+
)
93+
.values('field')
94+
.order_by()
95+
.annotate(avg=Avg('average'))
96+
.values('avg')[:1]
97+
),
98+
avg_instructor_quality=Subquery(
99+
ReviewBit.objects.filter(
100+
Q(review__section__course__topic=OuterRef('course__topic')) & \
101+
Q(review__instructor__in=instructors_subquery),
102+
field='instructor_quality',
103+
review__responses__gt=0,
104+
)
105+
.values('field')
106+
.order_by()
107+
.annotate(avg=Avg('average'))
108+
.values('avg')[:1]
109+
),
110+
avg_difficulty=Subquery(
111+
ReviewBit.objects.filter(
112+
Q(review__section__course__topic=OuterRef('course__topic')) & \
113+
Q(review__instructor__in=instructors_subquery),
114+
field='difficulty',
115+
review__responses__gt=0,
116+
)
117+
.values('field')
118+
.order_by()
119+
.annotate(avg=Avg('average'))
120+
.values('avg')[:1]
121+
),
122+
avg_work_required=Subquery(
123+
ReviewBit.objects.filter(
124+
Q(review__section__course__topic=OuterRef('course__topic')) & \
125+
Q(review__instructor__in=instructors_subquery),
126+
field='work_required',
127+
review__responses__gt=0,
128+
)
129+
.values('field')
130+
.order_by()
131+
.annotate(avg=Avg('average'))
132+
.values('avg')[:1]
133+
),
134+
)
135+
136+
with transaction.atomic():
137+
for section in tqdm(annotated_queryset, desc="Updating Section Averages", file=stdout):
138+
Section.objects.filter(pk=section.pk).update(
139+
course_quality=section.avg_course_quality,
140+
instructor_quality=section.avg_instructor_quality,
141+
difficulty=section.avg_difficulty,
142+
work_required=section.avg_work_required,
143+
)
144+
145+
146+
class Command(BaseCommand):
147+
help = 'Calculates and saves review averages for Course and Section models.'
148+
149+
def handle(self, *args, **options):
150+
self.stdout.write("Calculating Course review averages...")
151+
calculate_course_review_averages(Course.objects.all(), self.stdout)
152+
self.stdout.write(self.style.SUCCESS("Finished calculating Course review averages."))
153+
154+
self.stdout.write("Calculating Section review averages...")
155+
calculate_section_review_averages(Section.objects.all(), self.stdout)
156+
self.stdout.write(self.style.SUCCESS("Finished calculating Section review averages."))
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Generated by Django 5.0.2 on 2026-02-07 03:33
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("courses", "0068_merge_20250819_1758"),
10+
]
11+
12+
operations = [
13+
migrations.RemoveField(
14+
model_name="course",
15+
name="num_activities",
16+
),
17+
migrations.AddField(
18+
model_name="course",
19+
name="course_quality",
20+
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
21+
),
22+
migrations.AddField(
23+
model_name="course",
24+
name="difficulty",
25+
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
26+
),
27+
migrations.AddField(
28+
model_name="course",
29+
name="instructor_quality",
30+
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
31+
),
32+
migrations.AddField(
33+
model_name="course",
34+
name="work_required",
35+
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
36+
),
37+
migrations.AddField(
38+
model_name="section",
39+
name="course_quality",
40+
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
41+
),
42+
migrations.AddField(
43+
model_name="section",
44+
name="difficulty",
45+
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
46+
),
47+
migrations.AddField(
48+
model_name="section",
49+
name="instructor_quality",
50+
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
51+
),
52+
migrations.AddField(
53+
model_name="section",
54+
name="work_required",
55+
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
56+
),
57+
migrations.AlterField(
58+
model_name="meeting",
59+
name="day",
60+
field=models.CharField(
61+
db_index=True,
62+
help_text="The single day on which the meeting takes place (one of M, T, W, R, or F).",
63+
max_length=1,
64+
),
65+
),
66+
migrations.AlterField(
67+
model_name="meeting",
68+
name="end",
69+
field=models.DecimalField(
70+
db_index=True,
71+
decimal_places=2,
72+
help_text="The end time of the meeting; hh:mm is formatted as hh.mm = h+mm/100.",
73+
max_digits=4,
74+
),
75+
),
76+
migrations.AlterField(
77+
model_name="meeting",
78+
name="start",
79+
field=models.DecimalField(
80+
db_index=True,
81+
decimal_places=2,
82+
help_text="The start time of the meeting; hh:mm is formatted as hh.mm = h+mm/100.",
83+
max_digits=4,
84+
),
85+
),
86+
]

0 commit comments

Comments
 (0)