Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/master' into aag/ui-fixes-2
Browse files Browse the repository at this point in the history
  • Loading branch information
AaDalal committed Apr 11, 2024
2 parents 1810373 + 98f39b8 commit 38f2f5c
Show file tree
Hide file tree
Showing 26 changed files with 309 additions and 94 deletions.
6 changes: 4 additions & 2 deletions backend/courses/management/commands/recompute_soft_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ def recompute_enrollment():
)


# course credits = sum(section credis for all activities)
# course credits = sum(section credis for all activities for sections below 500)
# the < 500 heuristic comes from here:
# https://provider.www.upenn.edu/computing/da/dw/student/enrollment_section_type.e.html
COURSE_CREDITS_RAW_SQL = dedent(
"""
WITH CourseCredits AS (
Expand All @@ -108,6 +110,7 @@ def recompute_enrollment():
INNER JOIN (
SELECT MAX(U1."credits") AS "activity_cus", U1."course_id"
FROM "courses_section" U1
WHERE U1."code" < '500' AND (U1."status" <> 'X' OR U1."status" <> '')
GROUP BY U1."course_id", U1."activity"
) AS U2
ON U0."id" = U2."course_id"
Expand All @@ -125,7 +128,6 @@ def recompute_enrollment():
def recompute_course_credits(
model=Course, # so this function can be used in migrations (see django.db.migrations.RunPython)
):

with connection.cursor() as cursor:
cursor.execute(COURSE_CREDITS_RAW_SQL)

Expand Down
28 changes: 26 additions & 2 deletions backend/courses/management/commands/recompute_topics.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Count, OuterRef, Subquery
from tqdm import tqdm

from courses.models import Course, Topic
from courses.util import all_semesters
from courses.util import all_semesters, historical_semester_probability


def garbage_collect_topics():
Expand Down Expand Up @@ -151,5 +152,28 @@ def handle(self, *args, **kwargs):
assert (
min_semester in all_semesters()
), f"--min-semester={min_semester} is not a valid semester."

semesters = sorted(
[sem for sem in all_semesters() if not min_semester or sem >= min_semester]
)
recompute_topics(min_semester, verbose=True, allow_null_parent_topic=bool(min_semester))
recompute_historical_semester_probabilities(current_semester=semesters[-1], verbose=True)


def recompute_historical_semester_probabilities(current_semester, verbose=False):
"""
Recomputes the historical probabilities for all topics.
"""
if verbose:
print("Recomputing historical probabilities for all topics...")
topics = Topic.objects.all()
# Iterate over each Topic
for i, topic in tqdm(enumerate(topics), disable=not verbose, total=topics.count()):
# Calculate historical_year_probability for the current topic
ordered_courses = topic.courses.all().order_by("semester")
ordered_semester = [course.semester for course in ordered_courses]
historical_prob = historical_semester_probability(current_semester, ordered_semester)
# Update the historical_probabilities field for the current topic
topic.historical_probabilities_spring = historical_prob[0]
topic.historical_probabilities_summer = historical_prob[1]
topic.historical_probabilities_fall = historical_prob[2]
topic.save()
66 changes: 66 additions & 0 deletions backend/courses/migrations/0064_auto_20240225_1331.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Generated by Django 3.2.23 on 2024-02-25 18:31

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("courses", "0063_auto_20231212_1750"),
]

operations = [
migrations.AddField(
model_name="topic",
name="historical_probabilities_fall",
field=models.FloatField(
default=0,
help_text="\nThe historical probability of a student taking a course in this topic in the fall\nsemester, based on historical data. This field is recomputed nightly from the\n`parent_course` graph (in the recompute_soft_state cron job).\n",
),
),
migrations.AddField(
model_name="topic",
name="historical_probabilities_spring",
field=models.FloatField(
default=0,
help_text="\nThe historical probability of a student taking a course in this topic in the spring\nsemester, based on historical data. This field is recomputed nightly from the\n`parent_course` graph (in the recompute_soft_state cron job).\n",
),
),
migrations.AddField(
model_name="topic",
name="historical_probabilities_summer",
field=models.FloatField(
default=0,
help_text="\nThe historical probability of a student taking a course in this topic in the summer\nsemester, based on historical data. This field is recomputed nightly from the\n`parent_course` graph (in the recompute_soft_state cron job).\n",
),
),
migrations.AlterField(
model_name="section",
name="activity",
field=models.CharField(
choices=[
("", "Undefined"),
("CLN", "Clinic"),
("CRT", "Clinical Rotation"),
("DAB", "Dissertation Abroad"),
("DIS", "Dissertation"),
("DPC", "Doctoral Program Exchange"),
("FLD", "Field Work"),
("HYB", "Hybrid"),
("IND", "Independent Study"),
("LAB", "Lab"),
("LEC", "Lecture"),
("MST", "Masters Thesis"),
("ONL", "Online"),
("PRC", "Practicum"),
("REC", "Recitation"),
("SEM", "Seminar"),
("SRT", "Senior Thesis"),
("STU", "Studio"),
],
db_index=True,
help_text='The section activity, e.g. `LEC` for CIS-120-001 (2020A). Options and meanings: <table width=100%><tr><td>""</td><td>"Undefined"</td></tr><tr><td>"CLN"</td><td>"Clinic"</td></tr><tr><td>"CRT"</td><td>"Clinical Rotation"</td></tr><tr><td>"DAB"</td><td>"Dissertation Abroad"</td></tr><tr><td>"DIS"</td><td>"Dissertation"</td></tr><tr><td>"DPC"</td><td>"Doctoral Program Exchange"</td></tr><tr><td>"FLD"</td><td>"Field Work"</td></tr><tr><td>"HYB"</td><td>"Hybrid"</td></tr><tr><td>"IND"</td><td>"Independent Study"</td></tr><tr><td>"LAB"</td><td>"Lab"</td></tr><tr><td>"LEC"</td><td>"Lecture"</td></tr><tr><td>"MST"</td><td>"Masters Thesis"</td></tr><tr><td>"ONL"</td><td>"Online"</td></tr><tr><td>"PRC"</td><td>"Practicum"</td></tr><tr><td>"REC"</td><td>"Recitation"</td></tr><tr><td>"SEM"</td><td>"Seminar"</td></tr><tr><td>"SRT"</td><td>"Senior Thesis"</td></tr><tr><td>"STU"</td><td>"Studio"</td></tr></table>',
max_length=50,
),
),
]
30 changes: 30 additions & 0 deletions backend/courses/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,36 @@ class Topic(models.Model):
),
)

historical_probabilities_spring = models.FloatField(
default=0,
help_text=dedent(
"""
The historical probability of a student taking a course in this topic in the spring
semester, based on historical data. This field is recomputed nightly from the
`parent_course` graph (in the recompute_soft_state cron job).
"""
),
)
historical_probabilities_summer = models.FloatField(
default=0,
help_text=dedent(
"""
The historical probability of a student taking a course in this topic in the summer
semester, based on historical data. This field is recomputed nightly from the
`parent_course` graph (in the recompute_soft_state cron job).
"""
),
)
historical_probabilities_fall = models.FloatField(
default=0,
help_text=dedent(
"""
The historical probability of a student taking a course in this topic in the fall
semester, based on historical data. This field is recomputed nightly from the
`parent_course` graph (in the recompute_soft_state cron job).
"""
),
)
branched_from = models.ForeignKey(
"Topic",
related_name="branched_to",
Expand Down
50 changes: 50 additions & 0 deletions backend/courses/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -721,3 +721,53 @@ def get_semesters(semesters: str = None) -> list[str]:
if s not in possible_semesters:
raise ValueError(f"Provided semester {s} was not found in the db.")
return sorted(semesters)


def historical_semester_probability(current_semester: str, semesters: list[str]):
"""
:param current: The current semester represented in the 20XX(A|B|C) format.
:type current: str
:param courses: A list of Course objects sorted by date in ascending order.
:type courses: list
:returns: A list of 3 probabilities representing the likelihood of
taking a course in each semester.
:rtype: list
"""
PROB_DISTRIBUTION = [0.4, 0.3, 0.15, 0.1, 0.05]

def normalize_and_round(prob, i):
"""Modifies the probability distribution to account for the
fact that the last course was taken i years ago."""
truncate = PROB_DISTRIBUTION[:i]
total = sum(truncate)
return list(map(lambda x: round(x / total, 3), truncate))

semester_probabilities = {"A": 0.0, "B": 0.0, "C": 0.0}
current_year = int(current_semester[:-1])
semesters = [
semester
for semester in semesters
if semester < str(current_year) and semester > str(current_year - 5)
]
if not semesters:
return [0, 0, 0]
if current_year - int(semesters[0][:-1]) < 5:
# If the class hasn't been offered in the last 5 years,
# we make sure the resulting probabilities sum to 1
modified_prob_distribution = normalize_and_round(
PROB_DISTRIBUTION, current_year - int(semesters[0][:-1])
)
else:
modified_prob_distribution = PROB_DISTRIBUTION
for historical_semester in semesters:
historical_year = int(historical_semester[:-1])
sem_char = historical_semester[-1].upper() # A, B, C
semester_probabilities[sem_char] += modified_prob_distribution[
current_year - historical_year - 1
]
return list(
map(
lambda x: min(round(x, 2), 1.00),
[semester_probabilities["A"], semester_probabilities["B"], semester_probabilities["C"]],
)
)
8 changes: 6 additions & 2 deletions backend/courses/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,12 @@ def filter_by_semester(self, queryset):
semester = self.get_semester()
if semester != "all":
queryset = queryset.filter(**{self.get_semester_field(): semester})
else:
queryset = queryset.order_by("full_code", "-semester").distinct("full_code")
else: # Only used for Penn Degree Plan (as of 4/10/2024)
queryset = (
queryset.exclude(credits=None) # heuristic: if the credits are empty, then ignore
.order_by("full_code", "-semester")
.distinct("full_code")
)
return queryset

def get_queryset(self):
Expand Down
20 changes: 12 additions & 8 deletions backend/degree/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from textwrap import dedent

from django.db.models import Q
from django.db.models import Q, Subquery
from rest_framework import serializers

from courses.models import Course
Expand Down Expand Up @@ -146,13 +146,17 @@ def validate(self, data):
for rule in rules:
# NOTE: we don't do any validation if the course doesn't exist in DB. In future,
# it may be better to prompt user for manual override
if (
Course.objects.filter(full_code=full_code).exists()
and not Course.objects.filter(rule.get_q_object(), full_code=full_code).exists()
):
raise serializers.ValidationError(
f"Course {full_code} does not satisfy rule {rule.id}"
)
if Course.objects.filter(full_code=full_code).exists():
satisfying_courses = Course.objects.filter(rule.get_q_object())
if not (
Course.objects.filter(
full_code=full_code,
topic_id__in=Subquery(satisfying_courses.values("topic_id")),
).exists()
):
raise serializers.ValidationError(
f"Course {full_code} does not satisfy rule {rule.id}"
)

# Check for double count restrictions
double_count_restrictions = DoubleCountRestriction.objects.filter(
Expand Down
13 changes: 13 additions & 0 deletions backend/tests/courses/test_recompute_soft_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,10 @@ def setUp(self):
"CIS-1210-001", TEST_SEMESTER
)

# Implictly testing that we exclude sections with code > 500
_, self.section5, _, _ = get_or_create_course_and_section("CIS-1210-500", TEST_SEMESTER)
self.section5.credits = 10.0

def test_null_section_credits(self):
self.assertIsNone(self.course3.credits)
self.assertIsNone(self.section4.credits)
Expand Down Expand Up @@ -267,3 +271,12 @@ def test_same_activity_null_credits(self):
recompute_course_credits()
self.course.refresh_from_db()
self.assertEqual(self.course.credits, 2.00)

def test_excludes_sections_with_status_besides_closed_and_open(self):
_, cancelled_section, _, _ = get_or_create_course_and_section("CIS-160-102", TEST_SEMESTER)
cancelled_section.credits = 10.0
cancelled_section.status = "X"
recompute_course_credits()

self.course2.refresh_from_db()
self.assertEqual(self.course2.credits, 1.50)
2 changes: 2 additions & 0 deletions backend/tests/degree/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
SatisfactionStatus,
)
from degree.serializers import SimpleCourseSerializer
from tests.courses.util import fill_course_soft_state


TEST_SEMESTER = "2023C"
Expand Down Expand Up @@ -98,6 +99,7 @@ def setUp(self):
self.cis_1930, self.cis_1930_001, _, _ = get_or_create_course_and_section(
"CIS-1920-001", TEST_SEMESTER, course_defaults={"credits": 1}
)
fill_course_soft_state()

self.degree = Degree.objects.create(program="EU_BSE", degree="BSE", major="CIS", year=2023)
self.parent_rule = Rule.objects.create()
Expand Down
4 changes: 2 additions & 2 deletions frontend/degree-plan/components/Course/Course.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ReviewPanelTrigger } from "../Infobox/ReviewPanel";
import { Draggable } from "../common/DnD";
import Skeleton from "react-loading-skeleton"
import 'react-loading-skeleton/dist/skeleton.css'
import { TRANSFER_CREDIT_SEMESTER_KEY } from "@/constants";

const COURSE_BORDER_RADIUS = "9px";

Expand Down Expand Up @@ -103,10 +104,9 @@ const IconBadge = styled.div`
`


const SemesterIcon = ({semester}:{semester: string | null}) => {
if (!semester) return <div></div>;
const year = semester.substring(2,4);
const year = semester === TRANSFER_CREDIT_SEMESTER_KEY ? "AP" : semester.substring(2,4);
const sem = semester.substring(4);

return (
Expand Down
14 changes: 9 additions & 5 deletions frontend/degree-plan/components/Dock/Dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@ const DockContainer = styled.div<{$isDroppable:boolean, $isOver: boolean}>`
const SearchIconContainer = styled.div`
padding: .25rem 2rem;
padding-left: 0;
border-color: var(--primary-color-extra-dark);
border-color: var(--primary-color-xx-dark);
color: var(--primary-color-extra-dark);
border-width: 0;
border-right-width: 2px;
border-style: solid;
flex-shrink: 0;
display: flex;
gap: 1rem;
`

const DockedCoursesWrapper = styled.div`
Expand Down Expand Up @@ -106,7 +109,7 @@ const Dock = ({ user, login, logout, activeDegreeplanId }: DockProps) => {
// const [courseAdded, setCourseAdded] = React.useState(false);
const { searchPanelOpen, setSearchPanelOpen, setSearchRuleQuery, setSearchRuleId } = useContext(SearchPanelContext)
const { createOrUpdate } = useSWRCrud<DockedCourse>(`/api/degree/docked`, { idKey: 'full_code' });
const {data: dockedCourses = [], isLoading} = useSWR<DockedCourse[]>(user ? `/api/degree/docked` : null);
const { data: dockedCourses = [], isLoading } = useSWR<DockedCourse[]>(user ? `/api/degree/docked` : null);

// Returns a boolean that indiates whether this is the first render
const useIsMount = () => {
Expand All @@ -117,8 +120,6 @@ const Dock = ({ user, login, logout, activeDegreeplanId }: DockProps) => {
return isMountRef.current;
};

const isMount = useIsMount();

const [{ isOver, canDrop }, drop] = useDrop(() => ({
accept: [ItemTypes.COURSE_IN_PLAN, ItemTypes.COURSE_IN_REQ],
drop: (course: DnDCourse) => {
Expand Down Expand Up @@ -160,8 +161,11 @@ const Dock = ({ user, login, logout, activeDegreeplanId }: DockProps) => {
setSearchPanelOpen(!searchPanelOpen);
}}>
<DarkBlueIcon>
<i className="fas fa-search fa-lg"/>
<i className="fas fa-plus fa-lg"/>
</DarkBlueIcon>
<div>
Add Course
</div>
</SearchIconContainer>
<DockedCoursesWrapper>
{isLoading ?
Expand Down
8 changes: 3 additions & 5 deletions frontend/degree-plan/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,10 @@ const Footer = () => (
Penn Labs
</Link>
.
Have feedback about Penn Degree Plan? Let us know: {" "}
<Link href="mailto:[email protected]">[email protected]</Link>
{
// TODO: uncomment once out of beta
// <Link href="https://airtable.com/appFRa4NQvNMEbWsA/shrzXeuiEFF8OD89P">here!</Link>
Have feedback about Penn Degree Plan? Let us know {" "}
{// <Link href="mailto:[email protected]">[email protected]</Link>
}
<Link href="https://airtable.com/appFRa4NQvNMEbWsA/shr120VUScuNJywyv">here!</Link>
</Wrapper>
);

Expand Down
Loading

0 comments on commit 38f2f5c

Please sign in to comment.