Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Parser updates and fixes #10

Merged
merged 6 commits into from
Sep 24, 2023
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ postgres
*-test.json
celerybeat-schedule
pcr-backup*
degreeworks_env.json
156 changes: 93 additions & 63 deletions backend/degree/scripts/parse_degreeworks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.db.models import Q
from request_degreeworks import audit
from structs import DegreePlan, Requirement, Rule
from pprint import pprint

# TODO: these should not be hardcoded, but rather added to the database
E_DEPTS = ["BE", "CIS", "CMPE", "EAS", "ESE", "MEAM", "MSE", "NETS", "ROBO"] # SEAS
Expand Down Expand Up @@ -88,36 +89,42 @@
] # Wharton


def parse_coursearray(courseArray):
def parse_qualifiers(qualifiers):
return [qualifier.get("label") or qualifier.get("name") for qualifier in qualifiers]


def parse_coursearray(courseArray) -> Q:
"""
Parses a Course rule's courseArray and returns a Q filter to find valid courses.
"""
q = Q()
for course in courseArray:
course_q = Q()
match course["discipline"], course["number"], course.get("numberEnd"):
case "@", "@", end: # any course
# an @ is a placeholder meaning any
case ("@", "@", end) | ("PSEUDO@", "@", end):
assert end is None
pass
case "PSEUDO@", "@", end:
case discipline, "@", end:
assert end is None
pass
case _, "@", end:
assert end is None
course_q &= Q(department__code=course["discipline"])
case _, _, None:
course_q &= Q(department__code=discipline)
case discipline, number, None:
if number.isdigit():
course_q &= Q(full_code=f"{discipline}-{number}")
elif number[:-1].isdigit() and number[-1] == "@":
course_q &= Q(full_code__startswith=f"{discipline}-{number[:-1]}")
else:
print(f"WARNING: non-integer course number: {number}")
case discipline, number, end:
try:
int(course["number"])
except ValueError:
print(f"WARNING: non-integer course number: {course['number']}")
course_q &= Q(department__code=course["discipline"], code=course["number"])
case _, _, _:
try:
int(course["number"])
int(course["numberEnd"])
int(number)
int(end)
except ValueError:
print("WARNING: non-integer course number or numberEnd")
course_q &= Q(
department__code=course["discipline"],
code__gte=int(course["number"]),
code__lte=course["numberEnd"],
department__code=discipline,
code__gte=int(number),
code__lte=end,
)

connector = "AND" # the connector to the next element; and by default
Expand Down Expand Up @@ -155,6 +162,9 @@ def parse_coursearray(courseArray):
case "DWRESIDENT":
print("WARNING: ignoring DWRESIDENT")
sub_q = Q()
case "DWGRADE":
print("WARNING: ignoring DWGRADE")
sub_q = Q()
case _:
raise LookupError(f"Unknown filter type in withArray: {filter['code']}")
match filter[
Expand Down Expand Up @@ -214,9 +224,9 @@ def evaluate_condition(condition, degree_plan) -> bool:
raise ValueError(f"Unknowable left type in ifStmt: {comparator['left']}")
match comparator["operator"]:
case "=":
return degree_plan.major == comparator["right"]
return attribute == comparator["right"]
case "<>":
return degree_plan.major != comparator["right"]
return attribute != comparator["right"]
case ">=" | "<=" | ">" | "<":
raise LookupError(f"Unsupported comparator in ifStmt: {comparator}")
case _:
Expand All @@ -228,54 +238,65 @@ def evaluate_condition(condition, degree_plan) -> bool:


def parse_rulearray(ruleArray, degree_plan) -> list[Rule]:
"""
Logic to parse a single degree ruleArray in a blockArray requirement.
A ruleArray consists of a list of rule objects that contain a requirement object.
"""
rules = []
for rule in ruleArray:
rule_req = rule["requirement"]
# pprint(rule)
assert (
rule["ruleType"] == "Group" or rule["ruleType"] == "Subset" or "ruleArray" not in rule
)
match rule["ruleType"]:
case "Course":
num = None
cus = None
max_cus = None
max_num = None

"""
A Course rule can either specify a number (or range) of classes required or a number (or range) of CUs
required, or both.
"""
# check the number of classes/credits
# TODO: clean this up
match rule_req.get("classesBegin"), rule_req.get("classesEnd"), rule_req.get(
"creditsBegin"
), rule_req.get("creditsEnd"):
case None, None, None, None:
raise ValueError("No classesBegin or creditsBegin in Course requirement")
case num, None, None, None:
rule_req["classCreditOperator"] == "OR"
num = int(num)
case None, None, cus, None:
rule_req["classCreditOperator"] == "OR"
cus = float(cus)
case num, None, cus, None:
cus = float(cus)
num = int(num)
num = (
int(rule_req.get("classesBegin"))
if rule_req.get("classesBegin") is not None
else None
)
max_num = (
int(rule_req.get("classesEnd"))
if rule_req.get("classesEnd") is not None
else None
)
cus = (
float(rule_req.get("creditsBegin"))
if rule_req.get("creditsBegin") is not None
else None
)
max_cus = (
float(rule_req.get("creditsEnd"))
if rule_req.get("creditsEnd") is not None
else None
)

if num is None and cus is None:
raise ValueError("No classesBegin or creditsBegin in Course requirement")

if (num is None and max_num) or (cus is None and max_cus):
raise ValueError(f"Course requirement specified end without begin: {rule_req}")

# TODO: What is the point of this?
if max_num is None and max_cus is None:
if not (num and cus):
assert rule_req["classCreditOperator"] == "OR"
else:
assert rule_req["classCreditOperator"] == "AND"
case None, None, cus, max_cus:
cus = float(cus)
max_cus = float(max_cus)
case num, max_num, None, None:
num = int(num)
max_num = int(max_num)
case (None, _, _, _) | (_, _, None, _):
raise ValueError(f"Specify end of range without start: {rule_req}")
case num, max_num, cus, max_cus:
num = int(num)
max_num = int(max_num)
cus = float(cus)
max_cus = float(max_cus)

rules.append(
Rule(
q=parse_coursearray(rule_req["courseArray"]),
num=num,
max_num=max_num,
cus=cus,
max_cus=max_cus,
max_num=max_num,
)
)
case "IfStmt":
Expand Down Expand Up @@ -314,10 +335,13 @@ def parse_rulearray(ruleArray, degree_plan) -> list[Rule]:
case "Noncourse":
continue # this is a presentation or something else that's required
case "Subset": # what does this mean
rules += parse_rulearray(rule["ruleArray"], degree_plan)
if "ruleArray" in rule:
rules += parse_rulearray(rule["ruleArray"], degree_plan)
else:
print("WARNING: subset has no ruleArray")
case "Group": # TODO: this is nested
q = Q()
[q := q | rule.q for rule in parse_rulearray(rule["ruleArray"], degree_plan)]
[q := q & rule.q for rule in parse_rulearray(rule["ruleArray"], degree_plan)]
rules.append(
Rule(
q=q,
Expand All @@ -334,6 +358,9 @@ def parse_rulearray(ruleArray, degree_plan) -> list[Rule]:


def parse_degreeworks(json, degree_plan) -> list[Requirement]:
"""
Entry point for parsing a DegreeWorks degree.
"""
blockArray = json.get("blockArray")
degree = []

Expand All @@ -349,10 +376,6 @@ def parse_degreeworks(json, degree_plan) -> list[Requirement]:
return degree


def parse_qualifiers(qualifiers):
return [qualifier.get("label") or qualifier.get("name") for qualifier in qualifiers]


if __name__ == "__main__":
W_DEGREE_PLANS = [
DegreePlan(program="WU_BS", degree="BS", major="ACCT", concentration=None, year=2023),
Expand Down Expand Up @@ -1386,6 +1409,13 @@ def parse_qualifiers(qualifiers):
DegreePlan(program="EU_BAS", degree="BAS", major="VLST", concentration="PAS", year=2023),
]

for i, degree_plan in enumerate(E_BSE_DEGREE_PLANS):
for i, degree_plan in enumerate(E_BAS_DEGREE_PLANS):
print(degree_plan)
print(parse_degreeworks(audit(degree_plan), degree_plan))
pprint(parse_degreeworks(audit(degree_plan), degree_plan))

# degree_plan = DegreePlan(
# program="EU_BSE", degree="BSE", major="VLST", concentration="ACS", year=2023
# )
# import json
# print(json.dumps(audit(degree_plan), indent=2))
# pprint(parse_degreeworks(audit(degree_plan), degree_plan))
7 changes: 4 additions & 3 deletions backend/degree/scripts/request_degreeworks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from structs import DegreePlan
import json

with open(".env") as f:
with open("degreeworks_env.json") as f:
env = json.load(f)

cookies = {
Expand All @@ -29,10 +29,11 @@ def audit(degree_plan: DegreePlan, timeout=30):
"isIncludePreregistered": True,
"isKeepCurriculum": False,
"school": "UG",
"degree": "BA",
"catalogYear": "2023",
"degree": degree_plan.degree,
"catalogYear": degree_plan.year,
"goals": [
{"code": "MAJOR", "value": degree_plan.major},
{"code": "CONC", "value": degree_plan.concentration},
{"code": "PROGRAM", "value": degree_plan.program},
{"code": "COLLEGE", "value": degree_plan.program.split("_")[0]},
],
Expand Down
27 changes: 0 additions & 27 deletions backend/degree/scripts/structs.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,33 +23,6 @@ def __str__(self) -> str:
)


class QualifierType(str, Enum):
GPA = "GPA"
CUS = "CUS"
NUM = "NUM" # number of classes


class Exactness(str, Enum):
EXACT = "EXACT"
MIN = "MIN"
MAX = "MAX"
RANGE = "RANGE"


class Among(str, Enum):
PASSFAIL = "PASSFAIL"


@dataclass # TODO: unused
class Qualifier: # ex: min 5 CUS among NSCI
label: str
code: str
min_or: Exactness
num: float | int | None
type: QualifierType
among: Q | Among | None


@dataclass
class DegreePlan:
# NOTE: in the future, this might require a year field
Expand Down
Loading