Course Algorithms
Note to future teams: These algorithms will require a lot of tweaking to adapt to constantly changing requirements in the future. It is possible that some information is out of date so make sure to check the page history and constantly update this wiki.
Contents
Prior Reading
In order to understand this, you should ensure you have read:
Overview
The goal of the algorithms is to determine if a specific course has been unlocked for the user based on their taken program, specialisation, taken courses, uoc, wam, and much more. It has been implemented using 3 main classes - Condition, Category, User. We will go into detail on this later.
Have a look at the following example:
user = User(some_user_data)
comp2521_tokens = ["(", "COMP1511", "||", "DPST1091", "||", "COMP1917", "||", "COMP1921", ")"]
comp2521_cond = create_condition(comp2521_tokens, "COMP2521")
res = comp2521_cond.is_unlocked(user)
res["result"] # True/False if this course can be taken by the user
res["warnings"] # List of warnings (for now, it only applies to GRADE and WAM requirements)
Firstly, a User is created from some given user data (including things like what courses they have already taken). This is usually collected from the front-end as a JSON, as the front-end manages the user state (this will be changed when we get federated auth). The Condition object is created via create_condition()
which takes in the condition tokens and the course the condition applies to. We then pass in the User to is_unlocked()
to determine if the user can take this course or not.
Classes
Inside conditions.py and categories.py, you can find implementations for many different Condition and Category classes.
These are basically composite pattern thingos from 2511.
You can also think of these as AST nodes if you remember 3131.
The exact details are best learnt via reading through the code and playing around with it on a separate branch. Also, the tests written may be enlightening.
Conditions
There are many types of conditions in UNSW involving courses, co-requisites, UOC, WAM and more... Each condition has a validate()
method which checks if that condition is met. For example, "12UOC" would generate a UOCCondition with the uoc field set to 12. Calling validate()
will check if the given user has taken at least 12 units of courses.
For complex conditions, we have a CompositeCondition class. This class can contain multiple Conditions (including other CompositeConditions) and be of "AND" or "OR" logic. If "AND" logic, then it will check if all the Conditions validate() as true. Similarly, "OR" will check if any of the Conditions validate() as true. Notice that this is mutable - this is done for the sake of making parsing easier. It should not be mutated at any other stage.
All of these inherit Conditions and force a check at runtime for implementing some methods using the abc
library. If you develop any more functionality that expects to be implemented in every child, this should be enforeced using the abc
code.
Categories
Many courses have requirements with the "in" keyword. E.g. "12UOC in L2 MATH". For this reason, the Category class was developed. Some Conditions might contain a Category. Again, it is best to read the code but a brief overview of this logic for the above example is:
Create UOCCondition with 12UOC
Attach a LevelCourseCategory with L2 and MATH
The UOCCondition will use this Category to check the applicable UOC of the user when in order to validate() itself.
The user class will use the category if it is provided.
These categories are made to be pretty lean. Most functionality should reside in other areas.
User
There is a User class. The frontend will pass some user data to our API which then creates a User from that data. This User will be passed to our Condition object's is_unlocked()
method to determine if the User can take this course or not.
We also can manually mutate the user data - this is usually done for testing purposes, or for some simulation (eg to simulate unselecting a course, as below)
unselect_course() and problems loading condition objects
This is a tricky method in User which I thought I would elaborate on. When someone unselects a course on the front-end, we need to return a list of courses that could potentially be affected. E.g. unselecting COMP1511 would "affect" COMP2521 since COMP1511 is a prerequisite of COMP2521. It makes sense that the user should know that unselecting COMP1511 should unselect COMP2521.
NOTE: One method people often jump to is to think of the courses as some sort of dependency graph. Think about why this is difficult (hint, what if a course has a UOC condition such as 48UOC? Which courses does this depend on?).
Firstly, have a read of the unselect_course() method. You will notice that we need access to the Condition objects for the courses. But where do we get these Condition objects? There is a conditions.pkl file which stores all the Conditions (so we don't have to recreate them each time...) but due to problems with pickle and importing* this failed. A workaround I've done is to load all the condition tokens, then have unselect_course() create the necessary Conditions each time. Please read the code.
*Read up on this if you're interested, I think the solution requires separating the User from conditions.py or with absolute path imports or something but then you'd have to make sure this works with docker and etc my brain is too small and there is no time someone in the future pls fix this.
Creating a Condition from Tokens
Have a read through the create_condition
function. Tokens are read from left to right and then Conditions and Categories are created recursively. This is essentially a parser and AST generator rolled into one. We can attach an optional parameter that is the course this condition belongs to. (we may eventually consider using automated parsers and AST generators like YACC)
This is needed to check for exclusions since we must know the course in order to know what it excludes.