Skip to content
Merged
Show file tree
Hide file tree
Changes from 37 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
ee9f971
section enrollment model
brandonistfan Feb 2, 2025
95460b9
basic async updating enrollment
brandonistfan Feb 2, 2025
f8459bf
enrollment func and filtering w/o reloading
brandonistfan Feb 2, 2025
3a64eb6
refactoring
brandonistfan Feb 2, 2025
6a5db3b
refactoring
brandonistfan Feb 2, 2025
a9bac3c
lint fixes
brandonistfan Feb 2, 2025
ace06b5
no testing
brandonistfan Feb 9, 2025
4dc9be7
adding test cases!
brandonistfan Feb 9, 2025
50c91e2
trigger ci
brandonistfan Feb 9, 2025
605da6d
Merge branch 'dev' into lous-list-open-seats-async
brandonistfan Feb 9, 2025
c8a211f
lint fixes
brandonistfan Feb 9, 2025
b219e2f
adding the refresh enrollment func
brandonistfan Feb 9, 2025
ba8cc92
linter issues
brandonistfan Feb 9, 2025
05217a2
lint fix
brandonistfan Feb 9, 2025
eefbe68
reducing update check count
brandonistfan Feb 9, 2025
07bae05
enrollment progress bars
Jay-Lalwani Feb 15, 2025
0eb7973
design cleanup
Jay-Lalwani Feb 16, 2025
d7da76c
top align progress bars
Jay-Lalwani Feb 16, 2025
d83a048
show first 5 sections; click section to see sis course
Jay-Lalwani Feb 17, 2025
2577a8b
added padding to top
Jay-Lalwani Feb 17, 2025
1d54d07
python command for enrollment
brandonistfan Feb 17, 2025
91da4e6
first pylint fix
brandonistfan Feb 17, 2025
75ce37e
switching to already defined setup function for testing
brandonistfan Feb 17, 2025
efd7560
course enrollment model
brandonistfan Feb 18, 2025
d8b3041
two hour updating limit
brandonistfan Feb 18, 2025
e9ea738
fixing merge conflicts
brandonistfan Feb 18, 2025
84963d2
minor enrollment update fix
brandonistfan Feb 18, 2025
2c75acc
adding open sections back to searchbar
brandonistfan Feb 18, 2025
2969cf7
lint fixes
brandonistfan Feb 23, 2025
4cb8468
Merge branch 'dev' into lous-list-open-seats-async
brandonistfan Feb 23, 2025
1aa2274
defaulting open sections val to false
brandonistfan Feb 23, 2025
dbe1a8d
context
brandonistfan Feb 23, 2025
3e74714
condensing migrations
brandonistfan Feb 23, 2025
f051ef3
Removed None from section_times and section_nums
ajnye Mar 4, 2025
a8c9a1e
quick fix to fetch data
Jay-Lalwani Mar 15, 2025
934b9bb
merge dev
Jay-Lalwani Mar 15, 2025
83129a4
Merge branch 'dev' into lous-list-open-seats-async
brandonistfan Mar 23, 2025
511bacb
comments
brandonistfan Mar 23, 2025
c9ff2b4
Removed unnecessary comment space
ajnye Mar 23, 2025
a78913d
Merge branch 'dev' into lous-list-open-seats-async
ajnye Mar 23, 2025
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
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
Django~=4.2.8
asgiref~=3.6.0
backoff~=2.2.1
black~=24.1.1
boto3~=1.37.4
Expand Down
1 change: 1 addition & 0 deletions tcf_core/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,6 @@ def searchbar_context(request):
"selected_weekdays": saved_filters.get("weekdays", []),
"from_time": saved_filters.get("from_time", ""),
"to_time": saved_filters.get("to_time", ""),
"open_sections": saved_filters.get("open_sections", False),
}
return context
37 changes: 37 additions & 0 deletions tcf_website/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,26 @@ def get_queryset(self, request):
return qs.select_related("section__course__subdepartment")


class SectionEnrollmentAdmin(admin.ModelAdmin):
list_display = [
"section",
"enrollment_taken",
"enrollment_limit",
"waitlist_taken",
"waitlist_limit",
]
search_fields = [
"section__course__subdepartment__mnemonic",
"section__course__number",
"section__course__title",
]
list_filter = ["section__semester"]

def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related("section__course__subdepartment", "section__semester")


class CourseGradeAdmin(admin.ModelAdmin):
ordering = ["course__subdepartment", "course__number", "course__title"]
search_fields = ["course__subdepartment", "course__number"]
Expand All @@ -105,6 +125,21 @@ class CourseInstructorGradeAdmin(admin.ModelAdmin):
search_fields = ["instructor__first_name", "instructor__last_name"]


class CourseEnrollmentAdmin(admin.ModelAdmin):
list_display = ["course", "last_update"]
search_fields = [
"course__subdepartment__mnemonic",
"course__number",
"course__title",
]
list_filter = ["last_update"]
readonly_fields = ["last_update"]

def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.select_related("course__subdepartment")


admin.site.register(Section, SectionAdmin)
admin.site.register(Instructor, InstructorAdmin)
admin.site.register(Discipline, DisciplineAdmin)
Expand All @@ -116,3 +151,5 @@ class CourseInstructorGradeAdmin(admin.ModelAdmin):
admin.site.register(CourseGrade, CourseGradeAdmin)
admin.site.register(CourseInstructorGrade, CourseInstructorGradeAdmin)
admin.site.register(SectionTime, SectionTimeAdmin)
admin.site.register(SectionEnrollment, SectionEnrollmentAdmin)
admin.site.register(CourseEnrollment, CourseEnrollmentAdmin)
110 changes: 110 additions & 0 deletions tcf_website/api/enrollment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""
Module for fetching and updating section enrollment data asynchronously.
"""

import asyncio
import time
import requests
from asgiref.sync import sync_to_async
from django.utils import timezone
from django.http import HttpResponseNotFound
from tcf_website.models import Section, SectionEnrollment, Semester, Course
from tcf_website.utils.enrollment import build_sis_api_url, format_enrollment_update_message

TIMEOUT = 10
MAX_WORKERS = 5


def fetch_section_data(section):
"""Fetch enrollment data for a given section from the UVA SIS API."""
url = build_sis_api_url(section)

try:
response = requests.get(url, timeout=TIMEOUT)
response.raise_for_status()
data = response.json()

if data and "classes" in data and data["classes"]:
class_data = data["classes"][0]
return {
"enrollment_taken": class_data.get("enrollment_total", 0),
"enrollment_limit": class_data.get("class_capacity", 0),
"waitlist_taken": class_data.get("wait_tot", 0),
"waitlist_limit": class_data.get("wait_cap", 0),
}
except requests.exceptions.RequestException as e:
print(f"Network error while fetching section {section.sis_section_number}: {e}")
except ValueError as e:
print(f"JSON decoding error for section {section.sis_section_number}: {e}")

return {}


async def update_enrollment_data(course_id):
"""Asynchronous function to update enrollment data."""
start_time = time.monotonic()

course_exists = await sync_to_async(Course.objects.filter(id=course_id).exists)()
if not course_exists:
return HttpResponseNotFound("Course not found.")

course = await sync_to_async(Course.objects.get)(id=course_id)
latest_semester = await sync_to_async(lambda: Semester.objects.order_by("-year").first())()

sections_queryset = Section.objects.filter(course=course, semester=latest_semester)
sections = await sync_to_async(list)(sections_queryset)

if not sections:
print(f"No sections found for course {course.code()} in semester {latest_semester}.")
return

print(f"Starting async enrollment update for {len(sections)} sections...")

changed_sections = 0

async def process_section(section):
"""Fetch and update enrollment data asynchronously."""
nonlocal changed_sections
loop = asyncio.get_running_loop()
data = await loop.run_in_executor(None, fetch_section_data, section)

if data:
was_changed = await sync_to_async(update_section_enrollment)(section, data)
if was_changed:
changed_sections += 1
print(f"Updated enrollment for section {section.sis_section_number}")

await asyncio.gather(*(process_section(section) for section in sections))

elapsed_time = time.monotonic() - start_time
print(
f"Enrollment update completed at {timezone.now()} "
f"(Total time: {elapsed_time:.2f} seconds, "
f"{changed_sections} sections changed)"
)


def update_section_enrollment(section, data):
"""Update SectionEnrollment only if the data has changed."""
section_enrollment, _ = SectionEnrollment.objects.get_or_create(section=section)

has_changes = any(
[
section_enrollment.enrollment_taken != data.get("enrollment_taken", 0),
section_enrollment.enrollment_limit != data.get("enrollment_limit", 0),
section_enrollment.waitlist_taken != data.get("waitlist_taken", 0),
section_enrollment.waitlist_limit != data.get("waitlist_limit", 0),
]
)

if has_changes:
section_enrollment.enrollment_taken = data.get("enrollment_taken", 0)
section_enrollment.enrollment_limit = data.get("enrollment_limit", 0)
section_enrollment.waitlist_taken = data.get("waitlist_taken", 0)
section_enrollment.waitlist_limit = data.get("waitlist_limit", 0)
section_enrollment.save()
print(format_enrollment_update_message(section, section_enrollment))
else:
print(f"No changes in enrollment data for section {section.sis_section_number}")

return has_changes
2 changes: 1 addition & 1 deletion tcf_website/api/filters.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
""""Custom DRF pagination classes"""
""" "Custom DRF pagination classes"""

from django_filters import FilterSet, NumberFilter

Expand Down
1 change: 1 addition & 0 deletions tcf_website/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
router.register(r"courses", views.CourseViewSet)
router.register(r"instructors", views.InstructorViewSet)
router.register(r"semesters", views.SemesterViewSet)
router.register(r"enrollment", views.SectionEnrollmentViewSet, basename="enrollment")

urlpatterns = [
path("", include(router.urls)),
Expand Down
34 changes: 33 additions & 1 deletion tcf_website/api/views.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
# pylint: disable=too-many-ancestors,fixme
"""DRF Viewsets"""
from django.db.models import Avg, Sum
from django.http import JsonResponse

from rest_framework import viewsets

from ..models import Course, Department, Instructor, School, Semester, Subdepartment
from ..models import (
Course,
Department,
Instructor,
School,
Section,
SectionEnrollment,
Semester,
Subdepartment,
)
from .filters import InstructorFilter
from .serializers import (
CourseAllStatsSerializer,
Expand Down Expand Up @@ -137,3 +148,24 @@ def get_queryset(self):
params["section__instructors"] = self.request.query_params["instructor"]
# Returns filtered, unique semesters in reverse chronological order
return super().get_queryset().filter(**params).distinct().order_by("-number")


class SectionEnrollmentViewSet(viewsets.ViewSet):
"""ViewSet for retrieving section enrollment data."""

def retrieve(self, request, pk=None):
"""Retrieves enrollment data for all sections of a given course."""
sections = Section.objects.filter(course_id=pk)
enrollment_data = {}

for section in sections:
section_enrollment = SectionEnrollment.objects.filter(section=section).first()
if section_enrollment:
enrollment_data[section.sis_section_number] = {
"enrollment_taken": section_enrollment.enrollment_taken,
"enrollment_limit": section_enrollment.enrollment_limit,
"waitlist_taken": section_enrollment.waitlist_taken,
"waitlist_limit": section_enrollment.waitlist_limit,
}

return JsonResponse({"enrollment_data": enrollment_data})
10 changes: 4 additions & 6 deletions tcf_website/management/commands/fetch_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,7 @@ def compile_course_data(course_number, sem_code):
if meetings.get(0)
else ""
),
"Days1": (meetings.get(0)["meets"] if meetings.get(0)["meets"] != "-" else "TBA")
.replace("AM", "am")
.replace("PM", "pm"),
"Days1": (meetings.get(0)["meets"] if meetings.get(0)["meets"] != "-" else "TBA").lower(),
"Room1": (meetings.get(0)["room"] if meetings.get(0)["room"] != "-" else "TBA"),
"MeetingDates1": (meetings.get(0)["date_range"] if meetings.get(0) else ""),
"Instructor2": (
Expand All @@ -177,7 +175,7 @@ def compile_course_data(course_number, sem_code):
if meetings.get(1)
else ""
),
"Days2": meetings.get(1)["meets"] if meetings.get(1) else "",
"Days2": (meetings.get(1)["meets"] if meetings.get(1) else "").lower(),
"Room2": meetings.get(1)["room"] if meetings.get(1) else "",
"MeetingDates2": (meetings.get(1)["date_range"] if meetings.get(1) else ""),
"Instructor3": (
Expand All @@ -189,7 +187,7 @@ def compile_course_data(course_number, sem_code):
if meetings.get(2)
else ""
),
"Days3": meetings.get(2)["meets"] if meetings.get(2) else "",
"Days3": (meetings.get(2)["meets"] if meetings.get(2) else "").lower(),
"Room3": meetings.get(2)["room"] if meetings.get(2) else "",
"MeetingDates3": (meetings.get(2)["date_range"] if meetings.get(2) else ""),
"Instructor4": (
Expand All @@ -201,7 +199,7 @@ def compile_course_data(course_number, sem_code):
if meetings.get(3)
else ""
),
"Days4": meetings.get(3)["meets"] if meetings.get(3) else "",
"Days4": (meetings.get(3)["meets"] if meetings.get(3) else "").lower(),
"Room4": meetings.get(3)["room"] if meetings.get(3) else "",
"MeetingDates4": (meetings.get(3)["date_range"] if meetings.get(3) else ""),
"Title": class_details["course_title"],
Expand Down
Loading
Loading