Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions backend/courses/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,10 @@ def meeting_filter(queryset, meeting_query):
lab section, and thus the set of course activities available to us is incomplete).
"""
return queryset.filter(
id__in=course_ids_by_section_query(
Q(num_meetings=0) | Q(id__in=section_ids_by_meeting_query(meeting_query))
id__in=Subquery(
course_ids_by_section_query(
Q(num_meetings=0) | Q(id__in=Subquery(section_ids_by_meeting_query(meeting_query)))
)
)
)

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


def day_filter(days):
Expand Down Expand Up @@ -161,6 +163,22 @@ def pre_ngss_requirement_filter(queryset, req_ids):
return queryset.filter(query)


def requirements_filter(queryset, requirements):
"""
:param queryset: initial Course object queryset
:param requirements: the requirements query string; a comma separated list of requirement codes.
:return: filtered queryset
"""
if not requirements:
return queryset

query = Q()
for requirement in requirements.split(","):
query |= Q(requirements__code=requirement)

return queryset.filter(query)


# See the attribute_filter docstring for an explanation of this grammar
# https://lark-parser.readthedocs.io/en/latest/examples/calc.html
attribute_query_parser = Lark(
Expand Down Expand Up @@ -340,6 +358,7 @@ def filter_queryset(self, request, queryset, view):
filters = {
"attributes": attribute_filter,
"pre_ngss_requirements": pre_ngss_requirement_filter,
"requirements": requirements_filter,
"cu": choice_filter("sections__credits"),
"activity": choice_filter("sections__activity"),
"course_quality": bound_filter("course_quality"),
Expand Down Expand Up @@ -367,7 +386,7 @@ def filter_queryset(self, request, queryset, view):
if len(meeting_query) > 0:
queryset = meeting_filter(queryset, meeting_query)

return queryset.distinct("full_code") # TODO: THIS COULD BE A BREAKING CHANGE FOR PCX
return queryset

def get_schema_operation_parameters(self, view):
return [
Expand Down Expand Up @@ -409,6 +428,20 @@ def get_schema_operation_parameters(self, view):
"schema": {"type": "string"},
"example": "SS@SEAS,H@SEAS",
},
{
"name": "requirements",
"required": False,
"in": "query",
"description": (
"Filter by course requirements. Use the "
"[List Requirements](/api/documentation/#operation/penn_courses_api_v1_courses_requirements_list) "
"endpoint to get a list of valid requirement codes. "
"This parameter accepts a comma-separated list of requirement codes. "
"Courses that satisfy any of the requirements will be returned."
),
"schema": {"type": "string"},
"example": "MFR/MATH,MFR/PHYS",
},
{
"name": "attributes",
"required": False,
Expand Down
156 changes: 156 additions & 0 deletions backend/courses/management/commands/calculate_review_averages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
from tqdm import tqdm
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import OuterRef, Q, Subquery, Avg, F, Value, IntegerField

from courses.models import Course, Section, Instructor
from review.models import ReviewBit, Review
from review.views import section_filters_pcr


def calculate_course_review_averages(course_queryset, stdout):
"""
Calculates and updates review averages for a given Course queryset.
"""
# Use a simplified version of review_averages that only focuses on Course data
# We need to re-implement some parts as review_averages is too generic for direct use here
annotated_queryset = course_queryset.annotate(
avg_course_quality=Subquery(
ReviewBit.objects.filter(
review__section__course__topic=OuterRef('topic'),
field='course_quality',
review__responses__gt=0,
)
.values('field')
.order_by()
.annotate(avg=Avg('average'))
.values('avg')[:1]
),
avg_instructor_quality=Subquery(
ReviewBit.objects.filter(
review__section__course__topic=OuterRef('topic'),
field='instructor_quality',
review__responses__gt=0,
)
.values('field')
.order_by()
.annotate(avg=Avg('average'))
.values('avg')[:1]
),
avg_difficulty=Subquery(
ReviewBit.objects.filter(
review__section__course__topic=OuterRef('topic'),
field='difficulty',
review__responses__gt=0,
)
.values('field')
.order_by()
.annotate(avg=Avg('average'))
.values('avg')[:1]
),
avg_work_required=Subquery(
ReviewBit.objects.filter(
review__section__course__topic=OuterRef('topic'),
field='work_required',
review__responses__gt=0,
)
.values('field')
.order_by()
.annotate(avg=Avg('average'))
.values('avg')[:1]
),
)

with transaction.atomic():
for course in tqdm(annotated_queryset, desc="Updating Course Averages", file=stdout):
Course.objects.filter(pk=course.pk).update(
course_quality=course.avg_course_quality,
instructor_quality=course.avg_instructor_quality,
difficulty=course.avg_difficulty,
work_required=course.avg_work_required,
)

def calculate_section_review_averages(section_queryset, stdout):
"""
Calculates and updates review averages for a given Section queryset.
"""
# Similar to calculate_course_review_averages, we need to re-implement for sections
# as the original review_averages was too generic.

# Subquery for instructors related to the section
instructors_subquery = Subquery(
Instructor.objects.filter(section__id=OuterRef(OuterRef("id"))).values("id")
)

annotated_queryset = section_queryset.annotate(
avg_course_quality=Subquery(
ReviewBit.objects.filter(
Q(review__section__course__topic=OuterRef('course__topic')) & \
Q(review__instructor__in=instructors_subquery),
field='course_quality',
review__responses__gt=0,
)
.values('field')
.order_by()
.annotate(avg=Avg('average'))
.values('avg')[:1]
),
avg_instructor_quality=Subquery(
ReviewBit.objects.filter(
Q(review__section__course__topic=OuterRef('course__topic')) & \
Q(review__instructor__in=instructors_subquery),
field='instructor_quality',
review__responses__gt=0,
)
.values('field')
.order_by()
.annotate(avg=Avg('average'))
.values('avg')[:1]
),
avg_difficulty=Subquery(
ReviewBit.objects.filter(
Q(review__section__course__topic=OuterRef('course__topic')) & \
Q(review__instructor__in=instructors_subquery),
field='difficulty',
review__responses__gt=0,
)
.values('field')
.order_by()
.annotate(avg=Avg('average'))
.values('avg')[:1]
),
avg_work_required=Subquery(
ReviewBit.objects.filter(
Q(review__section__course__topic=OuterRef('course__topic')) & \
Q(review__instructor__in=instructors_subquery),
field='work_required',
review__responses__gt=0,
)
.values('field')
.order_by()
.annotate(avg=Avg('average'))
.values('avg')[:1]
),
)

with transaction.atomic():
for section in tqdm(annotated_queryset, desc="Updating Section Averages", file=stdout):
Section.objects.filter(pk=section.pk).update(
course_quality=section.avg_course_quality,
instructor_quality=section.avg_instructor_quality,
difficulty=section.avg_difficulty,
work_required=section.avg_work_required,
)


class Command(BaseCommand):
help = 'Calculates and saves review averages for Course and Section models.'

def handle(self, *args, **options):
self.stdout.write("Calculating Course review averages...")
calculate_course_review_averages(Course.objects.all(), self.stdout)
self.stdout.write(self.style.SUCCESS("Finished calculating Course review averages."))

self.stdout.write("Calculating Section review averages...")
calculate_section_review_averages(Section.objects.all(), self.stdout)
self.stdout.write(self.style.SUCCESS("Finished calculating Section review averages."))
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Generated by Django 5.0.2 on 2026-02-07 03:33

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("courses", "0068_merge_20250819_1758"),
]

operations = [
migrations.RemoveField(
model_name="course",
name="num_activities",
),
migrations.AddField(
model_name="course",
name="course_quality",
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
),
migrations.AddField(
model_name="course",
name="difficulty",
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
),
migrations.AddField(
model_name="course",
name="instructor_quality",
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
),
migrations.AddField(
model_name="course",
name="work_required",
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
),
migrations.AddField(
model_name="section",
name="course_quality",
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
),
migrations.AddField(
model_name="section",
name="difficulty",
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
),
migrations.AddField(
model_name="section",
name="instructor_quality",
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
),
migrations.AddField(
model_name="section",
name="work_required",
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
),
migrations.AlterField(
model_name="meeting",
name="day",
field=models.CharField(
db_index=True,
help_text="The single day on which the meeting takes place (one of M, T, W, R, or F).",
max_length=1,
),
),
migrations.AlterField(
model_name="meeting",
name="end",
field=models.DecimalField(
db_index=True,
decimal_places=2,
help_text="The end time of the meeting; hh:mm is formatted as hh.mm = h+mm/100.",
max_digits=4,
),
),
migrations.AlterField(
model_name="meeting",
name="start",
field=models.DecimalField(
db_index=True,
decimal_places=2,
help_text="The start time of the meeting; hh:mm is formatted as hh.mm = h+mm/100.",
max_digits=4,
),
),
]
Loading
Loading