Skip to content
Merged
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
71 changes: 71 additions & 0 deletions backend/courses/management/commands/cache_course_reviews.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import django.utils.timezone as timezone
from django.core.management.base import BaseCommand
from django.db import transaction
from tqdm import tqdm

from courses.models import Course, Section, course_reviews, sections_with_reviews


REVIEW_FIELDS = [
"course_quality",
"difficulty",
"instructor_quality",
"work_required",
]

PREFIX = "precompute_"

fields_to_update = [f"{PREFIX}{field}" for field in REVIEW_FIELDS] + ["annotation_expiration"]


class Command(BaseCommand):
help = "Calculates and persists review averages for Course and Section."

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

self.stdout.write("Calculating Section review averages...")
self.update_sections()
self.stdout.write(self.style.SUCCESS("Finished calculating Section review averages."))

def update_courses(self):
queryset = course_reviews(
Course.objects.all(),
)

courses_to_update = []

for course in tqdm(queryset, desc="Updating Courses", file=self.stdout):
for field in REVIEW_FIELDS:
setattr(course, f"{PREFIX}{field}", getattr(course, field))
course.annotation_expiration = timezone.now() + timezone.timedelta(days=30)
courses_to_update.append(course)

with transaction.atomic():
Course.objects.bulk_update(
courses_to_update,
fields_to_update,
batch_size=500,
)

def update_sections(self):
queryset = sections_with_reviews(
Section.objects.all(),
)

sections_to_update = []

for section in tqdm(queryset, desc="Updating Sections", file=self.stdout):
for field in REVIEW_FIELDS:
setattr(section, f"{PREFIX}{field}", getattr(section, field))
section.annotation_expiration = timezone.now() + timezone.timedelta(days=30)
sections_to_update.append(section)

with transaction.atomic():
Section.objects.bulk_update(
sections_to_update,
fields_to_update,
batch_size=500,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Generated by Django 5.0.2 on 2026-02-13 21:53

from django.db import migrations, models


class Migration(migrations.Migration):

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

operations = [
migrations.AddField(
model_name="course",
name="course_quality",
field=models.DecimalField(
blank=True,
decimal_places=3,
help_text="\nThe average course quality rating for this course, on a scale from 0 to 5 (precomputed for efficiency).\n",
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),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Generated by Django 5.0.2 on 2026-02-26 21:29

import django.utils.timezone
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("courses", "0069_course_course_quality_course_difficulty_and_more"),
]

operations = [
migrations.RenameField(
model_name="course",
old_name="difficulty",
new_name="precompute_difficulty",
),
migrations.RenameField(
model_name="course",
old_name="instructor_quality",
new_name="precompute_instructor_quality",
),
migrations.RenameField(
model_name="course",
old_name="work_required",
new_name="precompute_work_required",
),
migrations.RenameField(
model_name="section",
old_name="course_quality",
new_name="precompute_course_quality",
),
migrations.RenameField(
model_name="section",
old_name="difficulty",
new_name="precompute_difficulty",
),
migrations.RenameField(
model_name="section",
old_name="instructor_quality",
new_name="precompute_instructor_quality",
),
migrations.RenameField(
model_name="section",
old_name="work_required",
new_name="precompute_work_required",
),
migrations.RemoveField(
model_name="course",
name="course_quality",
),
migrations.AddField(
model_name="course",
name="annotation_expiration",
field=models.DateTimeField(
default=django.utils.timezone.now,
help_text="\nThe expiration time for the annotations of this course, these fields should be refreshed\nevery month\n",
),
),
migrations.AddField(
model_name="course",
name="precompute_course_quality",
field=models.DecimalField(blank=True, decimal_places=3, max_digits=4, null=True),
),
migrations.AddField(
model_name="section",
name="annotation_expiration",
field=models.DateTimeField(
default=django.utils.timezone.now,
help_text="\nThe expiration time for the annotations of this section, these fields should\nbe refreshed every month\n",
),
),
]
95 changes: 90 additions & 5 deletions backend/courses/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction
from django.db.models import OuterRef, Q, Subquery, UniqueConstraint
from django.db.models import F, OuterRef, Q, Subquery, UniqueConstraint
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
Expand Down Expand Up @@ -89,7 +89,7 @@ def __str__(self):
return self.code


def sections_with_reviews(queryset):
def sections_with_reviews(queryset, prefix=""):
from review.views import section_filters_pcr

# ^ imported here to avoid circular imports
Expand All @@ -109,11 +109,12 @@ def sections_with_reviews(queryset):
& Q(course__topic=OuterRef("course__topic"))
& Q(instructors__in=instructors_subquery)
),
prefix=prefix,
extra_metrics=False,
).order_by("code")


def course_reviews(queryset):
def course_reviews(queryset, prefix=""):
from review.views import section_filters_pcr

# ^ imported here to avoid circular imports
Expand All @@ -122,13 +123,32 @@ def course_reviews(queryset):
queryset,
reviewbit_subfilters=(Q(review__section__course__topic=OuterRef("topic"))),
section_subfilters=(section_filters_pcr & Q(course__topic=OuterRef("topic"))),
prefix=prefix,
extra_metrics=False,
)


def cache_or_compute_course_reviews(queryset):
"""
A helper function which takes a Course queryset and annotates it with review averages, either by
using cached precompute fields if the annotations have not expired or by computing the averages
from the reviews if they have expired or are missing.
"""
if queryset.filter(annotation_expiration__lt=timezone.now()).exists():
return course_reviews(queryset).order_by("full_code", "semester")

return queryset.annotate(
course_quality=F("precompute_course_quality"),
instructor_quality=F("precompute_instructor_quality"),
difficulty=F("precompute_difficulty"),
work_required=F("precompute_work_required"),
).order_by("full_code", "semester")


class CourseManager(models.Manager):
def get_queryset(self):
return course_reviews(super().get_queryset())
queryset = super().get_queryset()
return cache_or_compute_course_reviews(queryset)


class Course(models.Model):
Expand Down Expand Up @@ -281,6 +301,31 @@ class Course(models.Model):
),
)

annotation_expiration = models.DateTimeField(
default=timezone.now,
help_text=dedent(
"""
The expiration time for the annotations of this course, these fields should be refreshed
every month
"""
),
)
precompute_course_quality = models.DecimalField(
max_digits=4,
decimal_places=3,
null=True,
blank=True,
)
precompute_instructor_quality = models.DecimalField(
max_digits=4, decimal_places=3, null=True, blank=True
)
precompute_difficulty = models.DecimalField(
max_digits=4, decimal_places=3, null=True, blank=True
)
precompute_work_required = models.DecimalField(
max_digits=4, decimal_places=3, null=True, blank=True
)

class Meta:
unique_together = (
("department", "code", "semester"),
Expand Down Expand Up @@ -554,9 +599,27 @@ def __str__(self):
return f"{self.code} - {self.restriction_type} - {self.description}"


def cache_or_compute_section_reviews(queryset):
"""
A helper function which takes a Section queryset and annotates it with review averages, either
by using cached precompute fields if the annotations have not expired or by computing the
averages if they have expired or are missing.
"""
if queryset.filter(annotation_expiration__lt=timezone.now()).exists():
return sections_with_reviews(queryset).order_by("full_code")

return queryset.annotate(
course_quality=F("precompute_course_quality"),
instructor_quality=F("precompute_instructor_quality"),
difficulty=F("precompute_difficulty"),
work_required=F("precompute_work_required"),
).order_by("full_code")


class SectionManager(models.Manager):
def get_queryset(self):
return sections_with_reviews(super().get_queryset()).distinct()
queryset = super().get_queryset()
return cache_or_compute_section_reviews(queryset)


class PreNGSSRestriction(models.Model):
Expand Down Expand Up @@ -827,6 +890,28 @@ class Meta:
help_text="The number of active PCA registrations watching this section.",
) # For the set of PCA registrations for this section, use the related field `registrations`.

annotation_expiration = models.DateTimeField(
default=timezone.now,
help_text=dedent(
"""
The expiration time for the annotations of this section, these fields should
be refreshed every month
"""
),
)
precompute_course_quality = models.DecimalField(
max_digits=4, decimal_places=3, null=True, blank=True
)
precompute_instructor_quality = models.DecimalField(
max_digits=4, decimal_places=3, null=True, blank=True
)
precompute_difficulty = models.DecimalField(
max_digits=4, decimal_places=3, null=True, blank=True
)
precompute_work_required = models.DecimalField(
max_digits=4, decimal_places=3, null=True, blank=True
)

def __str__(self):
return "%s %s" % (self.full_code, self.course.semester)

Expand Down
Loading
Loading