diff --git a/.gitignore b/.gitignore index 56ac861..791d0ff 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ frontend/static/README.md .pytest_cache/ tests/test_db/data.py !test_database.json + +# Generated test files +test_resources/data_model_test_dir* diff --git a/Pipfile b/Pipfile index 4977ced..805030b 100644 --- a/Pipfile +++ b/Pipfile @@ -16,6 +16,8 @@ maya = "*" pytest = "*" flask = "*" pylint = "*" +flask-inputs = "*" +jsonschema = "*" [dev-packages] diff --git a/owl/__init__.py b/owl/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/owl/access.py b/owl/access.py new file mode 100644 index 0000000..6606ccd --- /dev/null +++ b/owl/access.py @@ -0,0 +1,231 @@ +""" +Intermediary module facilitating access of model. + +Methods contained are distinct from those in owl.model because they +are intended primarily for the convenience of the user facing module +(server.py) and contain methods and classes that may result in user- +feedback (eg: AccessException's user_msg and similar). + +Methods and classes are separate from server.py because they deal +primarily with access and retrieval of data, and not the form of the +response to the api user's request. +""" +import typing as ty + +import owl.model +import owl.filter + + +# Access Argument keywords +ANY = 'any' +ALL = 'all' +LATEST = 'latest' + + +class AccessException(ValueError): + """ + Exception raised when requested data cannot be accessed, but not as + result of an internal error or bug, or badly a badly formed + request. This usually is because the requested data does not exist. + """ + def __init__(self, *args, user_msg='Bad Request'): + super().__init__(*args) + self.user_msg = user_msg + + +class ModelAccessor: + """ + Handles access of data from a specific data model. + """ + def __init__(self, model: owl.model.DataModel) -> None: + self.model = model + + # This method may be removed in the future if the get_one + # method path is removed. + def get_one( + self, + school: str, + department: str, + course: str = ALL, + quarter: str = LATEST, + section_filter: owl.filter.SectionFilter = None + ) -> ty.Union[owl.model.COURSE_DATA_T, owl.model.DEPT_DATA_T]: + """ + Retrieves data for a department or course, as identified by + the api user. + If no course is passed, all of the specified department's data + is returned. + :param school: str (ex: 'fh') + :param department: str (ex: 'CS') + :param course: str (ex: 1A) (optional) + :param quarter: str (ex: '201812') + :param section_filter: filter kwargs + :return: COURSE_DATA_T or DEPT_DATA_T + """ + if course == ALL: + return self.get_department_data( + school, department, quarter, section_filter) + return self.get_course_data( + school, department, course, quarter, section_filter) + + def get_department_data( + self, + school: str, + department: str, + quarter: str = LATEST, + section_filter: owl.filter.SectionFilter = None + ) -> owl.model.DEPT_DATA_T: + """ + Gets data for a specific department in a single quarter. + :param school: str (ex: 'fh') + :param department: str (ex: 'CS') + :param quarter: str (ex: '201812') + :param section_filter: filter kwargs + :return: DEPT_DATA_T + """ + # Find department view + department_view = self.get_department(school, department, quarter) + + # get data from department view + return { + course_view.name: { + section_view.crn: section_view.data for + section_view in course_view.sections if + not section_filter or section_filter.check(section_view) + } + for course_view in department_view.courses + } + + def get_course_data( + self, + school: str, + department: str, + course: str, + quarter: str = LATEST, + section_filter: owl.filter.SectionFilter = None + ) -> owl.model.COURSE_DATA_T: + """ + Gets data for a specific course in a specific quarter. + :param school: str (ex: 'fh') + :param department: str (ex: 'CS') + :param course: str (ex: '1A') + :param quarter: str (ex: '201812') + :param section_filter: filter kwargs + :return: COURSE_DATA_T + """ + # Find department view + course_view = self.get_course(school, department, course, quarter) + return { + section_view.crn: section_view.data for + section_view in course_view.sections if + not section_filter or section_filter.check(section_view) + } + + def get_quarter( + self, + school: str, + quarter: str = LATEST, + ) -> 'owl.model.QuarterView': + """ + Gets QuarterView, throwing readable access errors in the event + that a passed value is not found. + :param school: str (ex: 'fh') + :param quarter: str (ex: '201812') + :return: QuarterView + """ + try: + school_view: owl.model.SchoolView = \ + self.model.schools[school.upper()] + except KeyError as e: + raise AccessException( + f'No school found with identifier: {school}.') from e + try: + if quarter == LATEST: + quarter_view = school_view.latest_quarter + else: + quarter_view = school_view.quarters[quarter] + except KeyError as e: + raise AccessException( + f'No quarter in {school} with name: {quarter}.') from e + return quarter_view + + def get_department( + self, + school: str, + department: str, + quarter: str = LATEST, + ) -> owl.model.DepartmentQuarterView: + """ + Gets department view of department data for a specific quarter. + :param school: str (ex: 'fh') + :param department: str (ex: 'CS') + :param quarter: str (ex: '201812') + :return: DepartmentQuarterView + """ + quarter_view = self.get_quarter(school, quarter) + try: + department_view = quarter_view.departments[department] + except KeyError as e: + raise AccessException( + f'No department in {quarter} with name: {department}.') from e + return department_view + + def get_course( + self, + school: str, + department: str, + course: str, + quarter: str = LATEST, + ) -> owl.model.CourseQuarterView: + """ + Gets view of data for a specific course in a specific quarter. + :param school: str (ex: 'fh') + :param department: str (ex: 'CS') + :param course: str (ex: '1A') + :param quarter: str (ex: '201812') + :return: CourseQuarterView + """ + department_view = self.get_department(school, department, quarter) + try: + course_view = department_view.courses[course] + except KeyError as e: + raise AccessException( + f'No course in {department} with name: {course}.') from e + return course_view + + def get_section( + self, + school: str, + department: str, + course: str, + section: str, + quarter: str = LATEST, + ) -> owl.model.SectionQuarterView: + """ + Gets view of data for a specific section in a specific quarter. + :param school: str (ex: 'fh') + :param department: str (ex: 'CS') + :param course: str (ex: '1A') + :param quarter: str (ex: '201812') + :param section: str (ex: '12345') + :return: SectionQuarterView + """ + course_view = self.get_course(school, department, course, quarter) + try: + section_view = course_view.sections[section] + except KeyError as e: + raise AccessException( + f'No section in {course} with name: {section}.') from e + return section_view + + def get_urls( + self, school: str, quarter: str = LATEST + ) -> ty.Dict[str, ty.Dict[str, str]]: + """ + Helps list all courses, their names and departments. + :param school: str (ex: 'fh') + :param quarter: str (ex: '201812') + :return: Dict[str, Dict[str, str]] + """ + quarter_view = self.get_quarter(school, quarter) + return quarter_view.urls diff --git a/owl/filter.py b/owl/filter.py new file mode 100644 index 0000000..400324f --- /dev/null +++ b/owl/filter.py @@ -0,0 +1,109 @@ +""" +Contains classes and methods for filtering data. +""" + +import typing as ty +import maya + +import owl.model + +# Keys +MONDAY_KEY = 'M' +TUESDAY_KEY = 'T' +WEDNESDAY_KEY = 'W' +THURSDAY_KEY = 'Th' +FRIDAY_KEY = 'F' +SATURDAY_KEY = 'S' +SUNDAY_KEY = 'U' + +# Filter Argument keywords +ANY = 'any' +ALL = 'all' + + +class SectionFilter: + """ + Filters courses based on passed parameters. + """ + def __init__( + self, + status: ty.Dict[str, int] = None, + types: ty.Dict[str, int] = None, + days: ty.Dict[str, int] = None, + time: ty.Dict[str, str] = None, + instructors: ty.Dict[str, int] = None, + conflict_sections: ty.Set[owl.model.SectionQuarterView] = None + ): + self.status = status + self.types = types + self.days = days + self.time = time + self.instructors = instructors + self.conflict_sections = conflict_sections + + def check(self, section: owl.model.SectionQuarterView) -> bool: + """ + Determines whether passed course passes filter + :param section: owl.model.SectionQuarterView + :return: bool (True if section passes filters) + """ + + # Nested functions filter courses by taking a course key and + # returning a boolean indicating whether they should be included + # or excluded. True if they are to be included, False if excluded. + + def status_filter() -> bool: + # {'open':0, 'waitlist':0, 'full':0} + if not self.status: + return True + # Create 'mask' of course statuses that are to be included. + status_mask = {k for (k, v) in self.status.items() if v} + # Return True only if course status is in mask. + return section.status in status_mask + + def type_filter() -> bool: + # {'standard':1, 'online':1, 'hybrid':0} + if not self.types: + return True + # Get course section + mask = {k for (k, v) in self.types.items() if v} + return section.section_type in mask + + def day_filter() -> bool: + # {'M':1, 'T':0, 'W':1, 'Th':0, 'F':0, 'S':0, 'U':0} + if not self.days: + return True + # create set of days that are allowed by passed filters + mask = {k for (k, v) in self.days.items() if v} + return section.days <= mask + + def time_filter() -> bool: + # {'start':'8:30 AM', 'end':'9:40 PM'} + if not self.time: + return True + filter_range = maya.MayaInterval( + start=maya.when(self.time['start']), + end=maya.when(self.time['end'])) + for duration in section.durations: + if not filter_range.contains(duration.interval): + return False + return True + + def instructor_filter() -> bool: + if not self.instructors: + return True + return section.instructor_name in \ + {k for k, v in self.instructors.items() if v} + + def conflict_filter() -> bool: + if not self.conflict_sections: + return True + for conflict_section in self.conflict_sections: + if section.conflicts(conflict_section): + return False + return True + + return all(( + status_filter(), type_filter(), day_filter(), time_filter(), + instructor_filter(), conflict_filter() + )) diff --git a/owl/input.py b/owl/input.py new file mode 100644 index 0000000..afab045 --- /dev/null +++ b/owl/input.py @@ -0,0 +1,59 @@ +""" +This module contains input classes, which validate data passed by api +users in requests. + +This module also allows more readable results to api users, that list +any potential issues with their requests, before they are acted on, +and without requiring error code lookups. +""" +from itertools import chain + +from flask_inputs.validators import JsonSchema +import flask_inputs + +from owl.schema import get_definition + + +class Inputs(flask_inputs.Inputs): + """ + Extends flask_inputs.Input in order to fix issues with validate method. + """ + + def validate(self): + """ + Validate incoming request data. Returns True if all data + is valid. + Adds each of the validator's error messages to Inputs.errors if + not valid. + + :returns: Boolean + """ + success = True + + for attribute, form in self._forms.items(): # Fixed iterator here. + if '_input' in form._fields: + form.process(self._get_values(attribute, coerse=False)) + else: + form.process(self._get_values(attribute)) + + if not form.validate(): + success = False + self.errors += chain(*form.errors.values()) + + return success + + +class GetOneInput(Inputs): + json = [JsonSchema(schema=get_definition('get_one'))] + + +class GetManyInput(Inputs): + json = [JsonSchema(schema=get_definition('get_many'))] + + +class GetListInput(Inputs): + json = [JsonSchema(schema=get_definition('get_list'))] + + +class GetUrlsInput(Inputs): + json = [JsonSchema(schema=get_definition('get_urls'))] diff --git a/owl/model.py b/owl/model.py new file mode 100644 index 0000000..810b220 --- /dev/null +++ b/owl/model.py @@ -0,0 +1,1001 @@ +""" +This module contains the data model used by server.py. +""" +import os +import typing as ty +import string +import weakref +import re +import itertools as itr +import json + +import tinydb +import maya + +import owl.serial + +DB_EXT = '.json' +DB_SUFFIX = '_database' +CACHE_TABLE_NAME = 'cache_' +FH = 'FH' +DA = 'DA' +SCHOOL_NAMES_BY_CODE = { + '1': FH, + '2': DA +} +QUARTER_NAMES_BY_CODE = { + 1: 'summer', + 2: 'fall', + 3: 'winter', + 4: 'spring' +} +DAYS_PATTERN = f"^{'(M|T|W|Th|F|S|U)?'*7}$" + +STANDARD_TYPE = 'standard' +ONLINE_TYPE = 'online' +HYBRID_TYPE = 'hybrid' + +SCHOOL_TYPE_CODES = { + FH: {'W': ONLINE_TYPE, 'Y': HYBRID_TYPE}, + DA: {'Z': ONLINE_TYPE, 'Y': HYBRID_TYPE} +} + +# Section fields +COURSE_ID_KEY = 'course' +CRN_KEY = 'CRN' +DESCRIPTION_KEY = 'desc' +STATUS_KEY = 'status' +DAYS_KEY = 'days' +TIME_KEY = 'time' +START_KEY = 'start' +END_KEY = 'end' +ROOM_KEY = 'room' +CAMPUS_KEY = 'campus' +UNITS_KEY = 'units' +INSTRUCTOR_KEY = 'instructor' +SEATS_KEY = 'seats' +WAIT_SEATS_KEY = 'wait_seats' +WAIT_CAP_KEY = 'wait_cap' + +# section keywords +ONLINE = 'ONLINE' +OPEN = 'Open' +WAITLIST = 'Waitlist' +FULL = 'Full' +TBA = 'TBA' + +# Type aliases + +COURSE_FIELD_T = str +COURSE_VALUE_T = str +CRN_T = str +COURSE_ID_T = str +DEPT_ID_T = str + +SECTION_ENTRY_T = ty.Dict[COURSE_FIELD_T, COURSE_VALUE_T] +SECTION_DATA_T = ty.List[SECTION_ENTRY_T] +COURSE_DATA_T = ty.Dict[CRN_T, SECTION_DATA_T] +DEPT_DATA_T = ty.Dict[COURSE_ID_T, COURSE_DATA_T] + + +class DataError(Exception): + """ + Raised when accessed data cannot be handled. + Distinct from ValueError because ValueError indicates invalid data + has been passed, while DataError indicates data that has been + retrieved from model contains errors. + """ + + +def quarter_cache(f): + def wrapper(self: 'QuarterView', *args, **kwargs): + cache_data = self.db.table(CACHE_TABLE_NAME).get(doc_id=1) or {} + arg_hash = str(_hash_args(args, kwargs)) + # Get method cache dictionary + try: + method_cache = cache_data[f.__name__] + except KeyError: + method_cache = cache_data[f.__name__] = dict() + # Get result cache dictionary + try: + result = json.loads(method_cache[arg_hash], + object_hook=owl.serial.hook) + except KeyError: + result = f(self, *args, **kwargs) + method_cache[arg_hash] = json.dumps(result, cls=owl.serial.Encoder) + try: + self.db.table(CACHE_TABLE_NAME).update(cache_data, doc_ids=[1]) + except KeyError: + self.db.table(CACHE_TABLE_NAME).insert(cache_data) + # Update stored table + return result + + wrapper.__name__ = f.__name__ + '_cache_wrapper' + return wrapper + + +class DataModel: + def __init__(self, db_dir: str) -> None: + """ + Initializer for DataModel that uses the passed db_dir path + to find database tables and store cached values. + :param db_dir: str path to directory. + """ + if not os.path.isdir(db_dir): + raise ValueError(f'Passed path is not a directory: {db_dir}') + self.db_dir: str = db_dir + self.quarter_instances = weakref.WeakValueDictionary() + self.schools = {} # Populated by generate_data() + self.quarters = {} # Populated by generate_data() + + self.generate_data() + + def generate_data(self): + """ + Generates basic data for DataModel that is kept in memory for + duration of program or until generate_data is called again to + update stored data. + :return: None + """ + + def find_quarters() -> ty.Dict[str, 'QuarterView']: + """ + Returns dictionary of quarters by name. + :return: Dict[str code, QuarterView] + """ + quarters: ty.Dict[str, 'QuarterView'] = {} + for file_name in os.listdir(self.db_dir): + long_name, ext = os.path.splitext(file_name) + if ext != DB_EXT or not long_name.endswith(DB_SUFFIX): + continue # Ignore non-database files + name = long_name[:6] + if all(c in string.digits for c in name): + quarters[name] = QuarterView.get_quarter(self, name) + return quarters + + self.clear_cache() # Clears caches, since data may now be changed. + self.quarters = find_quarters() + self.schools = {quarter.school_name: quarter.school + for quarter in self.quarters.values()} + + def register_quarter(self, quarter: 'QuarterView'): + """ + Stores weak reference to passed quarter so that in the future, + the program can avoid creating duplicates of the same quarter. + :param quarter: QuarterView + :return: None + """ + if quarter.model is not self: + raise ValueError( + f'Passed quarter {quarter} has model {quarter.model}, ' + f'cannot register it with {self}') + if quarter.name in self.quarter_instances: + raise ValueError(f'Passed quarter {quarter} is a duplicate of ' + f'an existing quarter in {self}.') + self.quarter_instances[quarter.name] = quarter + + def clear_cache(self): + for quarter in self.quarters.values(): + quarter.clear_cache() + + def __repr__(self) -> str: + return f'DataModel[{self.db_dir}]' + + +# The reason that the classes below are considered views is that they +# do not contain their own data, and multiple instances may be able to +# be created that all refer to the same data in the model. + + +class SchoolView: + """ + Represents a view onto data for a specific school. + """ + def __init__(self, model: DataModel, name: str): + if name not in SCHOOL_NAMES_BY_CODE.values(): + raise ValueError(f'Unexpected name received: {name} expected ' + f'name in {SCHOOL_NAMES_BY_CODE.values()}') + self.model = model + self.name = name + + @property + def quarters(self) -> ty.Dict[str, 'QuarterView']: + """ + Iterates over and yields views for each quarter that is + associated with school. + :return: QuarterView + """ + return {name: quarter for name, quarter in self.model.quarters.items() + if quarter.school_name == self.name} + + @property + def latest_quarter(self) -> 'QuarterView': + """ + Gets view of most recent quarter. + :return: QuarterView + """ + latest = None + for quarter in self.quarters.values(): + if latest is None or int(quarter.name[:5]) > int(latest.name[:5]): + latest = quarter + return latest + + @property + def type_codes(self) -> ty.Dict[str, str]: + """ + Gets dictionary of course section type codes that are specific + to individual schools. + :return: Dict[ + str (course section id suffix), + str (course section type) + ] + """ + return SCHOOL_TYPE_CODES[self.name] + + def __repr__(self) -> str: + return f'SchoolView[{self.name}]' + + +class QuarterView: + """ + Represents a view of a specific quarter, for a specific school. + """ + # In order to avoid loading into memory many duplicates of the same + # database file, QuarterViews will throw an exception if a + # duplicate QuarterView is created using the same model + name. + # + # To get a QuarterView of a specific quarter, in a specific model, + # get_quarter() factory method is intended to be used, rather than + # the constructor. + # + # The get_quarter() method will check if the model has a previously + # stored weak reference to a QuarterView with the same name, and + # return it if it exists. Otherwise, it creates a new QuarterView, + # which during its __init__ method, registers itself with + # the model. + + def __init__(self, model: DataModel, name: str) -> None: + """ + Instantiates a new QuarterView. + When instantiated, QuarterView will attempt to register + itself with its associated model. If a duplicate QuarterView + exists (with the same model and name) a ValueError is raised. + :param model: DataModel + :param name: str + :raises ValueError if QuarterView is a duplicate. + """ + self.model: DataModel = model + self.name: str = name + self._db: tinydb.TinyDB or None = None # Loaded lazily (use .db prop) + + # sanity check + if self.quarter_number not in range(1, 5): # 1, 2, 3, or 4 + raise ValueError(f'Invalid Quarter number: {self.quarter_number}' + f'for {self}') + + # Register QuarterView with model. If QuarterView is a + # duplicate, then something has gone wrong, and an exception + # is raised by the method. + model.register_quarter(self) + + # This factory method might equally have been implemented as an + # overwritten __new__ method, for more concise instantiation, but + # the potential for confusion and unintuitive behavior is + # considered to outweigh the benefit at the time of writing. + # This balance may change in the future. + @classmethod + def get_quarter(cls, model: DataModel, name: str) -> 'QuarterView': + """ + Returns the quarter of the passed name in the passed model. + If a QuarterView exists that has already been instantiated + with the passed name, the pre-existing QuarterView will be + returned. + Otherwise, a newly created QuarterView will be returned. + :param model: DataModel + :param name: str + :return: QuarterView + """ + try: + return model.quarter_instances[name] + except KeyError: + return cls(model, name) + + def clear_cache(self): + self.db.purge_table(CACHE_TABLE_NAME) + + @property + @quarter_cache + def urls(self): + """ + Gets urls dictionary for quarter. + Returns in format: + { + CS: { + 1A: { + 'course': '1A' + 'dept': 'CS' + } + } + } + :return: Dict[str, Dict[str, Dict[str, str]]] + """ + return { + department_view.name: { + course_view.name: { + 'course': course_view.name, + 'dept': course_view.department.name + } for course_view in department_view.courses + } for department_view in self.departments + } + + @property + def db(self) -> tinydb.TinyDB: + """ + Database getter; + Database will only be created when this property is first + accessed, and will then stored for future uses. + :return: TinyDB + """ + if not self._db: + if not os.path.exists(self.path): + raise ValueError( + f'Path does not exist for {self} in {self.model}') + self._db = tinydb.TinyDB(self.path) + return self._db + + @property + def year(self) -> int: + """ + Returns school year that quarter occurs in. + The school year starts with summer and ends with the end of + spring, and the number represents the calendar year in which + spring occurs. + :return: str + """ + return int(self.name[:4]) + + @property + def quarter_number(self) -> int: + """ + Returns integer indicating which quarter of the year the + quarter is. + 1: Summer + 2: Fall + 3: Winter + 4: Spring + :return: int in range(1, 4) inclusive. + """ + return int(self.name[4]) + + @property + def quarter_name(self) -> str: + """ + Returns name of the quarter of the year in which the + quarter occurs. + 1: Summer + 2: Fall + 3: Winter + 4: Spring + :return: str + """ + return QUARTER_NAMES_BY_CODE[self.quarter_number] + + @property + def school_name(self) -> str: + """ + Gets name of school as str. + :return: str 'FH' or 'DA' + """ + return SCHOOL_NAMES_BY_CODE[self.name[5]] + + @property + def school(self) -> SchoolView: + return SchoolView(self.model, self.school_name) + + # Since the vast majority of classes are of the primary duration, + # it is not necessary to check all sections + # Assuming 75% of classes are of primary duration, sampling + # 4*2*3 (24) sections has only a (1-0.75)^24 (3.5e-15) chance of + # returning an incorrect value, assuming random sampling. + # + # Reducing the samples taken reduces response time by ~80 times, + # And keeps response times comfortably under 1s. + DEPARTMENTS_SAMPLED = 4 + COURSE_SAMPLES = 2 + SECTION_SAMPLES = 3 + + @property + @quarter_cache + def primary_duration(self): + """ + Gets duration of quarter for full-length classes. + :return: CalendarDuration + """ + greatest_calendar_duration = None + greatest_delta: float = 0 + for i, department in enumerate(self.departments): + for j, course in enumerate(department.courses): + for k, section in enumerate(course.sections): + duration: 'CalendarDuration' = section.calendar_duration + if duration.interval.duration > greatest_delta: + greatest_delta = duration.interval.duration + greatest_calendar_duration = duration + if k == self.SECTION_SAMPLES: + break + if j == self.COURSE_SAMPLES: + break + if i == self.DEPARTMENTS_SAMPLED: + break + return greatest_calendar_duration + + @property + def path(self) -> str: + """ + Gets path to database file which contains quarter data. + :return: str path + """ + return os.path.join(self.model.db_dir, self.name) + DB_SUFFIX + DB_EXT + + @property + def departments(self) -> 'Departments': + """ + Gets helper class that provides methods for accessing + departments and associated information. + :return: QuarterView.Departments + """ + return self.Departments(self) + + def __repr__(self) -> str: + return f'QuarterView[{self.name}]' + + class Departments: + """ + Helper class that handles access of department data. + """ + def __init__(self, quarter: 'QuarterView'): + self.quarter = quarter + + def __getitem__(self, dept_name: str) -> 'DepartmentQuarterView': + # screen department names that might otherwise access + # internal tables, defaults, etc. + if not self._valid_name(dept_name): + raise ValueError( + f'Invalid department name passed: {dept_name}') + if dept_name not in self.db.tables(): + raise ValueError( + f'Passed department name: {dept_name} does not' + f'exist in {self}.') + + dept_data: DEPT_DATA_T = self.db.table(dept_name).all()[0] + return DepartmentQuarterView(self.quarter, dept_name, dept_data) + + def __iter__(self) -> ty.Iterable['DepartmentQuarterView']: + for dept_name in self.db.tables(): + if self._valid_name(dept_name): + data: DEPT_DATA_T = self.db.table(dept_name).all()[0] + yield DepartmentQuarterView(self.quarter, dept_name, data) + + @staticmethod + def _valid_name(dept_name: str) -> bool: + return all(char in string.ascii_letters for char in dept_name) + + @property + def db(self) -> tinydb.TinyDB: + return self.quarter.db + + def __repr__(self) -> str: + return f'{self.quarter}.Departments' + + +class DepartmentQuarterView: + """ + View onto data of a department's data for a specific quarter. + """ + + def __init__(self, quarter: 'QuarterView', name: str, data: DEPT_DATA_T): + self.quarter = quarter + self.name = name + self.data = data + + # Sanity Check + if not 2 <= len(name) <= 4: + raise Warning(f'Odd department name received: {name}') + + @property + def model(self): + return self.quarter.model + + @property + def courses(self): + """ + Gets helper class instance for accessing courses + within department. + :return: DepartmentQuarterView.Courses + """ + return self.Courses(self) + + def __repr__(self) -> str: + return f'DepartmentQuarterView[dept: {self.name}, ' \ + f'qtr: {self.quarter.name}]' + + class Courses: + """ + Helper class for accessing courses within department. + """ + def __init__(self, department: 'DepartmentQuarterView'): + self.department = department + + def __getitem__(self, course_name: str): + try: + return CourseQuarterView( + self.department, + course_name, + self.department.data[course_name] + ) + except KeyError as e: + raise KeyError(f'No course found named: {course_name} in ' + f'{self}') from e + + def __iter__(self): + for course_name, course_data in self.department.data.items(): + yield CourseQuarterView( + self.department, course_name, course_data) + + def __repr__(self) -> str: + return f'{self.department}.Courses' + + +class CourseQuarterView: + """ + View onto course data for a single quarter. + """ + def __init__( + self, + department: 'DepartmentQuarterView', + name: str, + data: COURSE_DATA_T + ): + self.department = department + self.name = name + self.data = data + + @property + def sections(self): + """ + Gets helper class instance for accessing sections + within course. + :return: CourseQuarterView.Sections + """ + return self.Sections(self) + + def __repr__(self) -> str: + return f'CourseQuarterView[dept: {self.department.name}, ' \ + f'course: {self.name}]' + + class Sections: + """ + Helper class for accessing sections within Course + """ + def __init__(self, course: 'CourseQuarterView'): + self.course = course + + def __getitem__(self, section_name: str) -> 'SectionQuarterView': + # If section cannot be found, raise a more readable + # key error. + try: + data = self.course.data[section_name] + except KeyError as e: + raise KeyError(f'No Section exists in {self} with id: ' + f'{repr(section_name)}') from e + + # Get Section from data + section = SectionQuarterView(self.course, data) + assert section.crn == section_name, \ + f'section crn: {section.crn} does not match that requested: ' \ + + section_name + return section + + def __iter__(self): + for section_data in self.course.data.values(): + yield SectionQuarterView(self.course, section_data) + + def __repr__(self): + return f'{self.course}.Sections' + + +class SectionQuarterView: + """ + View onto course section data in a specific quarter. + """ + # All these fields should be equal if multiple entries exist. + EQUAL_FIELDS = ( + COURSE_ID_KEY, CRN_KEY, DESCRIPTION_KEY, STATUS_KEY, UNITS_KEY, + SEATS_KEY, WAIT_SEATS_KEY, WAIT_CAP_KEY + ) + + def __init__( + self, + course: CourseQuarterView, + data: SECTION_DATA_T + ): + self.course = course + self.data = data + + # Sanity check; Ensure assumptions made about data are true + for field_name in self.EQUAL_FIELDS: + if not all(entry[field_name] == self.data[0][field_name] for + entry in self.data): + raise ValueError( + f'{self}: {field_name} fields do not match in data: ' + f'{[entry[field_name] for entry in self.data]}' + ) + + @property + def course_id(self) -> str: + """ + Returns full identity of section, as listed in database under + the key 'course' (a somewhat misleading field name). + 'Course' field usually contains department 2-4 letter code, + Followed by a number indicating Campus, Course id, + section index, and section type. + + ex: "ACTG F067.03W" + ^^^^ ^^^^ ^^^ + 1111 2333 445 + + 1: Department code + 2: School + 3: Course id + 4: Section index + 5: Section type ('standard', 'hybrid', or 'online) + + :return: str + """ + return self.data[0][COURSE_ID_KEY] + + @property + def crn(self) -> str: + """ + Gets course registration number for course. + :return: str of digits of length 5. + """ + return self.data[0][CRN_KEY] + + @property + def description(self) -> str: + """ + Gets description of course. + This usually takes the form of an all-capital course title, + ex: "ADVANCED TAX ACCOUNTING I" + Database spelling may vary. + :return: str description. + """ + return self.data[0][DESCRIPTION_KEY] + + @property + def section_type(self) -> str: + """ + Returns type of course section. + :return: str 'standard', 'hybrid' or 'online' + """ + return self.school.type_codes.get(self.course_id[-1], STANDARD_TYPE) + + @property + def days(self) -> ty.Set[str]: + """ + Gets set of days during which class/section meets. + example result: {'T', 'Th', 'F'} + :raises DataError if non-online entry has 'TBA' or other value + in 'days' field. + :return: Set[str] + """ + return {day for entry in self.data + for day in self._unpack_entry_days(entry)} + + def _unpack_entry_days(self, entry: SECTION_ENTRY_T) -> ty.Set[str]: + """ + Gets set of days that are listed in passed entry. + example result: {'T', 'Th', 'F'} + :param entry: SECTION_ENTRY_T (Dict[str, str]) + :raises DataError if non-online entry has 'TBA' or other value + in 'days' field. + :return: Set[str] + """ + if entry[ROOM_KEY] == ONLINE: + # If entry is online, entry will not have a valid room. + # (usually 'TBA') + # Return empty set, since no in-persons meetings are in + # this entry. + return set() + s = entry[DAYS_KEY] + if s == TBA: + # If days are 'TBA' and class is not online, raise an + # exception rather than trying to handle odd edge case. + raise DataError(f'{self} Entry days have not yet been entered') + matches = re.match(DAYS_PATTERN, entry[DAYS_KEY]) + if not matches: + raise DataError(f'Could not parse days string: {entry[DAYS_KEY]}') + days = set(matches.groups()) - {None} + return days + + def conflicts(self, other: 'SectionQuarterView') -> bool: + """ + Checks whether this section has any overlapping class meetings + with another passed section. + :param other: SectionQuarterView + :return: bool + """ + # There may be a more computationally efficient way to do this; + # if this becomes a highly used function, optimization should + # be looked into. + for own_duration, other_duration in \ + itr.product(self.durations, other.durations): + if own_duration.intersects(other_duration): + return False + return True + + @property + def start_date(self) -> maya.MayaDT: + """ + Gets start date of class. + :return: MayaDT + """ + return maya.when(self.data[0][START_KEY]) + + @property + def end_date(self) -> maya.MayaDT: + """ + Gets end date of class. + :return: MayaDT + """ + return maya.when(self.data[0][END_KEY]) + + @property + def durations(self) -> ty.List['ClassDuration']: + """ + Gets list of meeting durations, for times over the week that + a section meets in person. + :return: List[ClassDuration] + :raises DataError if unexpected values, such as 'TBA' are found + in data entries that are not online. + """ + durations = [] + for entry in self.data: + room = entry[ROOM_KEY] + if room == ONLINE: + continue + start_s, end_s = entry[TIME_KEY].split('-') + start, end = maya.when(start_s), maya.when(end_s) + for day in self._unpack_entry_days(entry): + durations.append(ClassDuration(day, room, start, end)) + return durations + + @property + def calendar_duration(self) -> 'CalendarDuration': + """ + Gets calendar duration of section. + :return: CalendarDuration + """ + return CalendarDuration(self.start_date, self.end_date) + + @property + def rooms(self) -> ty.Set['str']: + """ + Gets set of rooms (or occasionally other locations) in which + Set will be empty if course is online. + :return: Set[str] + :raises DataError if unexpected values are found in data. + """ + return {duration.room for duration in self.durations} + + @property + def status(self) -> str: + """ + Gets status of course section. + :return: str 'OPEN', 'WAITLIST', 'FULL' + """ + return self.data[0][STATUS_KEY] + + @property + def campus(self) -> str: + """ + Returns campus code for campus. + (Not the same thing as 'school'). + :return: str (ex: 'FH') + """ + return self.data[0][CAMPUS_KEY] + + @property + def units(self) -> float: + """ + Gets number of units of course section. + :return: float + """ + return float(self.data[0][UNITS_KEY]) + + @property + def instructor_names(self) -> ty.Set[str]: + """ + Gets names of instructors for this section. + This will usually be a set containing a single name, but + some classes will have different class sessions taught by + different instructors. + :return: Set[str] + """ + return {entry[INSTRUCTOR_KEY] for entry in self.data} + + @property + def instructors(self) -> ty.Set['InstructorView']: + """ + Gets data view of instructor for this course section. + :return: InstanceView + """ + return {InstructorView(self.model, name) for + name in self.instructor_names} + + @property + def open_seats_available(self) -> int: + """ + Gets number of open seats in course section. + :return: int + """ + return int(self.data[0][SEATS_KEY]) + + @property + def waitlist_seats_available(self) -> int: + """ + Gets number of open waitlist seats in course section. + :return: int + """ + return int(self.data[0][WAIT_SEATS_KEY]) + + @property + def waitlist_capacity(self) -> int: + """ + Gets overall capacity of waitlist, both open and filled. + :return: int + """ + return int(self.data[0][WAIT_CAP_KEY]) + + @property + def school_name(self) -> str: + """ + Gets name of school + :return: str, (ex: 'DA') + """ + return self.quarter.school_name + + @property + def school(self) -> SchoolView: + """ + Gets course section's school data view. + :return: SchoolView + """ + return self.quarter.school + + @property + def department(self) -> DepartmentQuarterView: + """ + Gets view of data for department that course section is under. + :return: DepartmentQuarterView + """ + return self.course.department + + @property + def quarter(self) -> QuarterView: + """ + Gets view of data for quarter that course section + occurs within. + :return: QuarterView + """ + return self.department.quarter + + @property + def model(self) -> DataModel: + """ + Gets data model. + :return: DataModel + """ + return self.quarter.model + + def __repr__(self) -> str: + return f'SectionQuarterView[dept: {self.department.name}, cid: ' \ + f'{self.course_id}, crn: {self.crn}]' + + +@owl.serial.serializable +class CalendarDuration: + """ + Class storing data about a class calendar duration. + """ + def __init__(self, start: maya.MayaDT, end: maya.MayaDT): + self.interval = maya.MayaInterval(start, end) + + @classmethod + def from_serializable(cls, d: ty.Dict[str, str]) -> 'CalendarDuration': + return cls(start=maya.when(d['start']), end=maya.when(d['end'])) + + @property + def as_serializable(self) -> ty.Dict[str, str]: + return {'start': self.start.iso8601(), 'end': self.end.iso8601()} + + @property + def start(self) -> maya.MayaDT: + """ + Gets start date. + :return: MayaDT + """ + return self.interval.start + + @property + def end(self) -> maya.MayaDT: + """ + Gets end date. + :return: MayaDT + """ + return self.interval.end + + +class ClassDuration: + """ + Class storing data about a specific meeting time for a class, on a + specific day. + """ + def __init__( + self, + day: str, + room: str, + start: maya.MayaDT, + end: maya.MayaDT + ): + self.day = day + self.room = room + self.interval = maya.MayaInterval(start, end) + + def intersects(self, other: 'ClassDuration') -> bool: + """ + Checks whether the ClassDuration overlaps with another passed + ClassDuration. + :param other: ClassDuration + :return: bool + """ + return self.day == other.day and \ + self.interval.intersects(other.interval) + + @property + def start(self) -> maya.MayaDT: + """ + Gets start time of class meeting. + :return: MayaDT + """ + return self.interval.start + + @property + def end(self) -> maya.MayaDT: + """ + Gets end time of class meeting. + :return: MayaDT + """ + return self.interval.end + + +class InstructorView: + """ + Class handling access of instructor data. + """ + def __init__(self, model: DataModel, name: str) -> None: + self.model: DataModel = model + self.name: str = name + + +def _hash_args(args: ty.Tuple, kwargs: ty.Dict[str, ty.Any]) -> int: + """ + Produces a hash int from passed tuple of args and dictionary + of kwargs. + :param args: Tuple[Any] + :param kwargs: Dict[str, Any] + :return: int + """ + arg_hash: int = hash(args) + kwargs_hash: int = hash(repr(sorted(kwargs.items()))) + return hash((arg_hash, kwargs_hash)) diff --git a/owl/schema.py b/owl/schema.py new file mode 100644 index 0000000..f940bda --- /dev/null +++ b/owl/schema.py @@ -0,0 +1,130 @@ + +definitions = { + 'status_filter': { + 'type': 'object', + 'properties': { + 'open': {'type': 'number'}, + 'waitlist': {'type': 'number'}, + 'full': {'type': 'number'}, + }, + "additionalProperties": False + }, + 'type_filter': { + 'type': 'object', + 'properties': { + 'standard': {'type': 'number'}, + 'online': {'type': 'number'}, + 'hybrid': {'type': 'number'} + }, + "additionalProperties": False + }, + 'day_filter': { + 'type': 'object', + 'properties': { + 'M': {'type': 'number'}, + 'T': {'type': 'number'}, + 'W': {'type': 'number'}, + 'Th': {'type': 'number'}, + 'F': {'type': 'number'}, + 'S': {'type': 'number'}, + 'U': {'type': 'number'}, + }, + "additionalProperties": False + }, + 'time_filter': { + 'type': 'object', + 'properties': { + 'start': {'type': 'string'}, + 'end': {'type': 'string'}, + }, + "additionalProperties": False + }, + 'instructor_filter': { + 'type': 'object', + 'properties': {}, + "additionalProperties": { + "type": 'number' + } + }, + 'conflict_filter': { + 'type': 'array', + 'items': { + '$ref': '#/definitions/get_section' + } + }, + 'filter': { + 'type': 'object', + 'properties': { + 'status': {'$ref': '#/definitions/status_filter'}, + 'type': {'$ref': '#/definitions/type_filter'}, + 'days': {'$ref': '#/definitions/day_filter'}, + 'time': {'$ref': '#/definitions/time_filter'}, + 'instructor': {'$ref': '#/definitions/instructor_filter'}, + 'conflict_sections': {'$ref': '#/definitions/conflict_filter'} + }, + "additionalProperties": False + }, + 'get_section': { + 'type': 'object', + 'properties': { + 'quarter': {'type': 'string'}, + 'department': {'type': 'string'}, + 'course': {'type': 'string'}, + 'section': {'type': 'string'}, + }, + 'required': ['department', 'course', 'section'] + }, + 'get_one': { + 'type': 'object', + 'properties': { + 'quarter': {'type': 'string'}, + 'department': {'type': 'string'}, + 'course': {'type': 'string'}, + 'filter': {'$ref': '#/definitions/filter'}, + }, + 'required': ['department'], + "additionalProperties": False + }, + 'get_many': { + 'type': 'object', + 'properties': { + 'courses': { + 'type': 'array', + 'items': { + '$ref': '#/definitions/get_one' + } + }, + 'filter': {'$ref': '#/definitions/filter'}, + }, + 'required': ['courses'], + "additionalProperties": False + }, + 'get_list': { + 'type': 'object', + 'properties': { + 'quarter': {'type': 'string'}, + 'department': {'type': 'string'}, + 'course': {'type': 'string'}, + }, + 'required': ['department'], + "additionalProperties": False + }, + 'get_urls': { + 'type': 'object', + 'properties': { + 'quarter': {'type': 'string'} + }, + 'additionalProperties': False + } +} + + +def get_definition(definition: str): + """ + Gets schema definition with the passed name. + :param definition: str name of definition + :return: dict representation of json schema. + """ + d = definitions[definition].copy() + d['definitions'] = definitions + return d diff --git a/owl/serial.py b/owl/serial.py new file mode 100644 index 0000000..d04aebd --- /dev/null +++ b/owl/serial.py @@ -0,0 +1,77 @@ +""" +Contains functions and classes for serializing and de-serializing +data. +""" +import typing as ty + +from json import JSONEncoder + +AS_JSON_PROPERTY = 'as_json' +FROM_JSON_PROPERTY = 'from_json' +TYPE_KEY = '_type_key' + +serializable_types = {} + + +class Encoder(JSONEncoder): + """ + Encodes object as json + """ + def default(self, o: ty.Any) -> ty.Any: + """ + Handles the default serialization case, wherein an object is + not by default serializable. + If the object is recognized as a custom serializable object, + it is converted into a serializable object. + Otherwise the superclass JSONEncoder default method is used. + :param o: Any + :return: Any serializable + """ + k = _get_type_key(type(o)) + if k in serializable_types: + d = o.as_serializable + d[TYPE_KEY] = k + return d + return super().default(o) + + +def hook(o: ty.Any) -> ty.Any: + """ + Hook taking the default-deserialized object (usually a dictionary) + and, if the object is recognized as an encoded serializable of + another type, re-creates the type instance. + :param o: Any type deserializable by default. + :return: Any + """ + try: + k = o.pop(TYPE_KEY) + except (KeyError, AttributeError): + return o + else: + object_type = serializable_types[k] + return object_type.from_serializable(o) + + +def serializable(clz): + """ + Decorator for a class marking it as serializable. + :param clz: Type + :return: Type + """ + k = _get_type_key(clz) + if k in serializable_types: + raise ValueError(f'Passed type: {clz} name conflicts with previously' + f'registered type: {serializable_types[k]}') + serializable_types[k] = clz + return clz + + +def _get_type_key(clz: ty.Type) -> ty.Any: + """ + Gets key used for storing passed type in dictionary of + serializable types. Should return a unique value for each passed + type. + :param clz: Type + :return: ty.Any + """ + return clz.__name__ diff --git a/server.py b/server.py index 517ffff..8800bac 100644 --- a/server.py +++ b/server.py @@ -1,33 +1,50 @@ -from os.path import join -from collections import defaultdict -from re import match - -import itertools as itr -import typing as ty +""" +This module contains functions for receiving user input and formatting +returned data. +Retrieval logic of data is done in access.py. +""" # 3rd party from flask import Flask, jsonify, request, render_template -from tinydb import TinyDB -from maya import when, MayaInterval + +# Owl modules +# The reason for the relatively verbose imports is to avoid names +# like 'model', 'request' or 'filter' being in the module namespace, +# which may easily be accidentally overridden or mistaken for variables +# of other types. +import settings +import owl.model # Bring in all objects from model without an ambiguous name. +import owl.input +import owl.access +import owl.filter + # Quart config def add_cors_headers(response): response.headers['Access-Control-Allow-Origin'] = '*' return response -application = Flask(__name__, - template_folder="../frontend/templates", static_folder='../frontend/static') -application.after_request(add_cors_headers) -DB_ROOT = 'db/' +application = Flask( + __name__, + template_folder="../frontend/templates", + static_folder='../frontend/static' +) +application.after_request(add_cors_headers) -CAMPUS_LIST = {'fh':'201911', 'da':'201912', 'test':'test'} +CAMPUS_LIST = {'fh', 'da'} -COURSE_PATTERN = r'[FD]0*(\d*\w?)\.?\d*([YWZH])?' -DAYS_PATTERN = f"^{'(M|T|W|Th|F|S|U)?'*7}$" +# fields +SCHOOL_KEY = 'school' +DEPARTMENT_KEY = 'dept' +COURSE_KEY = 'course' +QUARTER_KEY = 'quarter' +SECTION_KEY = 'section' +FILTER_KEY = 'filters' +FILTER_CONFLICTS_KEY = 'conflict_sections' -FH_TYPE_ALIAS = {'standard': None, 'online': 'W', 'hybrid': 'Y'} -DA_TYPE_ALIAS = {'standard': None, 'online': 'Z', 'hybrid': 'Y'} +data_model = owl.model.DataModel(settings.DB_DIR) +accessor = owl.access.ModelAccessor(data_model) @application.route('/') @@ -35,16 +52,41 @@ def idx(): return render_template('index.html') +def basic_checks(input_type=None): + """ + Wrapper for an api call that handles common exception cases. + :param input_type: + :return: Callable + """ + def decorator(f): + def api_method_wrapper(*args, **kwargs): + if input_type: + inputs = input_type(request) + if not inputs.validate(): + return jsonify(success=False, errors=inputs.errors) + try: + response = f(*args, **kwargs) + except owl.access.AccessException as e: + return e.user_msg, 404 # Data not found + else: + return response + + api_method_wrapper.__name__ = f.__name__ + '_wrapper' + return api_method_wrapper + + return decorator + + @application.route('//single', methods=['GET']) -def api_one(campus): +@basic_checks(input_type=owl.input.GetOneInput) +def api_one(campus: str): """ `/single` with [GET] handles a single request to get a whole department or a whole course listing from the database It expects a mandatory query parameter `dept` and an optional `course`. - Example: - {'dept': 'CS', 'course': '2C'} + Example: {'dept': 'CS', 'course': '2C'} If only `dept` is requested, it checked for its existence in the database and then returns it. @@ -55,22 +97,24 @@ def api_one(campus): :return: 200 - Found entry and returned data successfully to the user. - :return: 404 - Could not find entry + :return: 400 - Badly formatted request. + :return: 404 - Could not find entry. """ - if campus not in CAMPUS_LIST: - return 'Error! Could not find campus in database', 404 - - raw = request.args - qp = {k: v.upper() for k, v in raw.items()} - - db = TinyDB(join(DB_ROOT, f'{CAMPUS_LIST[campus]}_database.json')) - data = get_one(db, qp, filters=dict()) - json = jsonify(data) - return (json, 200) if data else ( - 'Error! Could not find given selectors in database', 404) + # 'campus' refers here to the school, not the physical location, + # and is not the same thing as the 'campus' field in section data. + # This is something that should probably be refactored if possible. + data = accessor.get_one( + school=campus.upper(), + quarter=request.args.get(QUARTER_KEY, owl.access.LATEST), + department=request.args[DEPARTMENT_KEY], # No default; required field. + course=request.args.get(COURSE_KEY, owl.access.ALL), + section_filter=_get_section_filter(request.args), # May return None. + ) + return jsonify(data), 200 @application.route('//batch', methods=['POST']) +@basic_checks(input_type=owl.input.GetManyInput) def api_many(campus): """ `/batch` with [POST] handles a batch request to get many @@ -104,188 +148,33 @@ def api_many(campus): to the user. :return: 404 - Could not find one or more entries. """ - if campus not in CAMPUS_LIST: - return 'Error! Could not find campus in database', 404 - - db = TinyDB(join(DB_ROOT, f'{CAMPUS_LIST[campus]}_database.json')) - raw = request.get_json() - - data = raw['courses'] - filters = raw['filters'] if ('filters' in raw) else dict() - - courses = get_many(db=db, data=data, filters=filters) - if not courses: # null case from get_one (invalid param or filter) - return 'Error! Could not find one or more course selectors in database', 404 - - json = jsonify({'courses': courses}) - return json, 200 - - -def get_one(db: TinyDB, data: dict, filters: dict): - """ - This is a helper used by the `/get` route to extract course data. - It works for both [GET] and [POST] and fetches data from the database - - :param db: (TinyDB) Database to retrieve data from - :param data: (dict) The query param or the POST body dict - :param filters: (dict) A optional dictionary of filters to be - passed to filter_courses() - - :return: course: (dict) A singular course listing from the database - (if it passes filters) - """ - - course = dict() - data_dept = data['dept'] - if data_dept in db.tables(): - table = db.table(f'{data_dept}') - entries = table.all() - - if 'course' not in data: - return entries - - data_course = data['course'] + course_filter = _get_section_filter(request.args) + def get_sub_request_data(args): try: - course = next((e[f'{data_course}'] for e in entries - if f'{data_course}' in e)) - if filters: - filter_courses(filters, course) - - except StopIteration: - return dict() - - return course - - -def get_many(db: TinyDB, data: dict(), filters: dict()): - ret = [] - - for course in data: - d = get_one(db, course, filters=filters) - if not d: # null case from get_one (invalid param or filter) - continue - ret.append(d) - - return ret - - -def filter_courses(filters: ty.Dict[str, ty.Any], course): - """ - This is a helper called by get_one() that filters a set of classes - based on some filter conditionals - - This is a helper called by get_one() that filters a set of classes - based on some filter conditionals - - Be careful with these as they can be limiting on the data, often - returning as 404 when one of the courses does not pass the filter. - Additionally, filters like status and types can be extremely - limiting on the data. Some courses won't even offer non-online - classes. Below is an example input with these filters. - - Example filters: { - 'status': {'open':1, 'waitlist':0, 'full':0}, - 'types': {'standard':1, 'online':1, 'hybrid':0}, - 'days': {'M':1, 'T':0, 'W':1, 'Th':0, 'F':0, 'S':0, 'U':0}, - 'time': {'start':'8:30 AM', 'end':'9:40 PM'} - } - - :param filters: `status` - filter by the availability of a course - (Open, Waitlist, Full) - `types` - filter by the format of the course - (In Person, Online, Hybrid) - `days` - filter by the days the course should be - limited to (M, T, W, Th, F, S, U) - `time` - filter by a specified time interval - (8:30 AM - 9:40 PM) - :param course: (dict) the mutable course listing - - :return: None - """ - # Nested functions filter courses by taking a course key and - # returning a boolean indicating whether they should be included - # or excluded. True if they are to be included, False if excluded. - - def status_filter(course_key) -> bool: - # {'open':0, 'waitlist':0, 'full':0} - if 'status' not in filters: - return True - # Create 'mask' of course statuses that are to be included. - status_mask = {k for (k, v) in filters['status'].items() if v} - # Return True only if course status is in mask. - return course[course_key][0]['status'].lower() in status_mask - - def type_filter(course_key) -> bool: - # {'standard':1, 'online':1, 'hybrid':0} - if 'types' not in filters: - return True - # Get course section - section = get_key(course[course_key][0]['course']) - mask = set() - for k, v in filters['types'].items(): - if not v: - continue - mask.add(FH_TYPE_ALIAS[k]) - mask.add(DA_TYPE_ALIAS[k]) - return section[1] in mask - - def day_filter(course_key) -> bool: - # {'M':1, 'T':0, 'W':1, 'Th':0, 'F':0, 'S':0, 'U':0} - if 'days' in filters: - # create set of days that are allowed by passed filters - mask = {k for (k, v) in filters['days'].items() if v} - for class_ in course[course_key]: - days_match = match(DAYS_PATTERN, class_['days']) - course_days = {x for x in days_match.groups() if x} if \ - days_match else {} - # Return False if course day is not in mask. - if not course_days <= mask: - return False - return True - - def time_filter(course_key) -> bool: - # {'start':'8:30 AM', 'end':'9:40 PM'} - if 'time' in filters: - f_range = MayaInterval( - start=when(filters['time']['start']), - end=when(filters['time']['end'])) - for class_ in course[course_key]: - if '-' in class_['time']: - data = class_['time'].split('-') - class_range = MayaInterval( - start=when(data[0]), end=when(data[1])) - if not f_range.contains(class_range): - return False - return True - - def filter_all(k) -> bool: - return all((status_filter(k), type_filter(k), - day_filter(k), time_filter(k))) - - # remove each key that is evaluated false by filter_all - for key in itr.filterfalse(filter_all, set(course.keys())): - del course[key] - - -def get_key(key): - """ - This is the key parser for the course names + return accessor.get_one( + school=campus, + department=args[DEPARTMENT_KEY], + course=args.get(COURSE_KEY, owl.access.ALL), + quarter=args.get(QUARTER_KEY, owl.access.LATEST), + section_filter=_get_section_filter(args) or course_filter + ) + except owl.access.AccessException: + return {} - :param campus: (str) The campus to retrieve data from - :param key: (str) The unparsed string containing the course name + data = list(map(get_sub_request_data, request.args['courses'])) - :return match_obj.groups(): (list) the string for the regex match - """ - k = key.split(' ') - i = 1 if len(k) < 3 else 2 - section = k[i] + if any(not sub_data for sub_data in data): + response_code = 404 + else: + response_code = 200 - match_obj = match(COURSE_PATTERN, section) - return match_obj.groups() + json = jsonify({'courses': data}) + return json, response_code @application.route('//list', methods=['GET']) +@basic_checks(input_type=owl.input.GetListInput) def api_list(campus): """ `/list` with [GET] handles a single request to list department or @@ -304,28 +193,23 @@ def api_list(campus): if campus not in CAMPUS_LIST: return 'Error! Could not find campus in database', 404 - db = TinyDB(join(DB_ROOT, f'{CAMPUS_LIST[campus]}_database.json')) + data = accessor.get_one( + school=campus.upper(), + quarter=request.args.get(QUARTER_KEY, owl.access.LATEST), + department=request.args[DEPARTMENT_KEY], # No default; required field. + course=request.args.get(COURSE_KEY, owl.access.ALL), + section_filter=_get_section_filter(request.args), # May return None. + ).keys() - raw = request.args - qp = {k: v.upper() for k, v in raw.items()} - - if 'dept' not in qp: - return jsonify(', '.join(db.tables())), 200 - - qp_dept = qp['dept'] - if qp_dept in db.tables(): - table = db.table(f'{qp_dept}') - keys = set().union(*(d.keys() for d in table.all())) - return jsonify(', '.join(keys)), 200 - - return 'Error! Could not list', 404 + return ('Error! Could not list', 404) if not data else (jsonify(data), 200) @application.route('//urls', methods=['GET']) +@basic_checks(input_type=owl.input.GetUrlsInput) def api_list_url(campus): """ - `/urls` with [GET] returns a tree of all departments, their - courses, and the courses' endpoints to hit. + `/urls` with [GET] returns a tree of all departments in a quarter, + their courses, and the courses' endpoints to hit. :param campus: (str) The campus to retrieve data from @@ -334,28 +218,40 @@ def api_list_url(campus): if campus not in CAMPUS_LIST: return 'Error! Could not find campus in database', 404 - db = TinyDB(join(DB_ROOT, f'{CAMPUS_LIST[campus]}_database.json')) - - data = defaultdict(list) - - for dept in db.tables(): - table = db.table(dept) - keys = set().union(*(d.keys() for d in table.all())) - data[f'{dept}'].append({k: generate_url(dept, k) for k in keys}) + data = accessor.get_urls( + school=campus, + quarter=request.args.get(QUARTER_KEY, owl.access.LATEST) + ) return jsonify(data), 200 -def generate_url(dept: str, course: str) -> ty.Dict[str, str]: +def _get_section_filter(args): """ - This is a helper function that generates a url string from a passed - department and course for the /urls route. - :param dept: str identifier for department - :param course: str - - :return: dict[str, str] + Produces a SectionFilter from passed arguments + :param args: request args + :return: SectionFilter or None """ - return {"dept": f"{dept}", "course": f"{course}"} + if not args[FILTER_KEY]: + return None + filter_kwargs = args.copy() + try: + conflict_sections = filter_kwargs[FILTER_CONFLICTS_KEY] + except KeyError: + pass + else: + # replace filter conflicts identifiers with set of + # section views. + filter_kwargs[FILTER_CONFLICTS_KEY] = { + accessor.get_section( + school=section_identifiers[SCHOOL_KEY], + quarter=section_identifiers[QUARTER_KEY], + department=section_identifiers[DEPARTMENT_KEY], + course=section_identifiers[COURSE_KEY], + section=section_identifiers[SECTION_KEY] + ) for section_identifiers in conflict_sections + } + return owl.filter.SectionFilter(**filter_kwargs) if __name__ == '__main__': diff --git a/settings.py b/settings.py index df60686..5c25641 100644 --- a/settings.py +++ b/settings.py @@ -4,4 +4,4 @@ API_DIR = os.path.join(ROOT_DIR, 'owlapi') DB_DIR = os.path.join(ROOT_DIR, 'db') TEST_DIR = os.path.join(ROOT_DIR, 'tests') -TEST_DB_DIR = os.path.join(TEST_DIR, 'test_db') +TEST_RESOURCES_DIR = os.path.join(ROOT_DIR, 'test_resources') diff --git a/tests/test_db/test_database.json b/test_resources/test_database.json similarity index 100% rename from tests/test_db/test_database.json rename to test_resources/test_database.json diff --git a/tests/generate_test_data.py b/tests/generate_test_data.py index c09ced0..97ce2b8 100644 --- a/tests/generate_test_data.py +++ b/tests/generate_test_data.py @@ -1,37 +1,68 @@ import os +import shutil + from tinydb import TinyDB -TEST_DB_DIR = os.path.join(os.path.dirname(__file__), 'test_db') -test_database = TinyDB(os.path.join(TEST_DB_DIR, 'test_database.json')) +# No guarantees can be made about .path when this script is used, so +# use of settings.py is somewhat difficult. +TESTS_DIR = os.path.join(os.path.dirname(__file__)) +TEST_DATA_DIR = os.path.join(TESTS_DIR, 'test_db') +TEST_RESOURCES_DIR = os.path.abspath( + os.path.join(TESTS_DIR, '..', 'test_resources')) +TEST_DATABASE_PATH = os.path.join(TEST_RESOURCES_DIR, 'test_database.json') +test_database = TinyDB(TEST_DATABASE_PATH) -with open(os.path.join(TEST_DB_DIR, 'data.py'), 'w') as file: - # test_sample_url_can_be_generated - data = {'dept': 'test_dept', 'course': 'test_course'} - file.write(f"test_sample_url_can_be_generated_data = {data}\n") +def generate_data_py(): + with open(os.path.join(TEST_DATA_DIR, 'data.py'), 'w') as file: + # test_sample_url_can_be_generated + data = {'dept': 'test_dept', 'course': 'test_course'} + file.write(f"test_sample_url_can_be_generated_data = {data}\n") - # test_get_one_dept - data = {'dept': 'CS'} + # test_get_one_dept + data = {'dept': 'CS'} + dept = test_database.table(f"{data['dept']}").all() + file.write(f"test_get_one_dept_data = {dept}\n") - dept = test_database.table(f"{data['dept']}").all() + # test_get_one_dept_and_course + data = {'dept': 'CS', 'course': '2A'} + dept = test_database.table(f"{data['dept']}").all() + course = next((e[f"{data['course']}"] for e in dept if + f"{data['course']}" in e)) + file.write(f"test_get_one_dept_and_course_data = {course}\n") - file.write(f"test_get_one_dept_data = {dept}\n") + # test_get_two_dept + data = {'courses': [{'dept': 'CS'}, {'dept': 'MATH'}]} + depts = [] + for i in data['courses']: + depts.append(test_database.table(i['dept']).all()) + file.write(f"test_get_two_dept_data = {depts}\n") - # test_get_one_dept_and_course - data = {'dept': 'CS', 'course': '2A'} - dept = test_database.table(f"{data['dept']}").all() +def generate_data_model_test_files(): + copies = { + 'model_test_dir_a': ( + '000011_database', + '000012_database', + '000021_database', + ) + } + # create directories + for dir_ in copies: + abs_dir = os.path.join(TEST_RESOURCES_DIR, dir_) + os.makedirs(abs_dir, exist_ok=True) # Create path if needed. - course = next((e[f"{data['course']}"] for e in dept if f"{data['course']}" in e)) + # create test data copies + for copy_name in copies[dir_]: + destination = os.path.join(TEST_RESOURCES_DIR, dir_, copy_name) + destination += '.json' + shutil.copyfile(TEST_DATABASE_PATH, destination) - file.write(f"test_get_one_dept_and_course_data = {course}\n") - # test_get_two_dept - data = {'courses': [{'dept': 'CS'}, {'dept': 'MATH'}]} - filters = dict() +def generate_all(): + generate_data_py() + generate_data_model_test_files() - depts = [] - for i in data['courses']: - depts.append(test_database.table(i['dept']).all()) - file.write(f"test_get_two_dept_data = {depts}\n") +if __name__ == '__main__': + generate_all() diff --git a/tests/model/__init__.py b/tests/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/model/test_access.py b/tests/model/test_access.py new file mode 100644 index 0000000..2b1c870 --- /dev/null +++ b/tests/model/test_access.py @@ -0,0 +1,53 @@ +from unittest import TestCase + +from .test_model import get_test_data_dir + +from owl.model import DataModel +from owl.access import ModelAccessor + +try: + from tests.test_db import data as test_data +except ImportError as e: + raise ImportError('Test Data could not be imported. If the data.py file ' + 'does not exist, it can be generated using the ' + 'generate_test_data.py script') from e + + +class TestAccessor(TestCase): + def test_get_one_dept(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + accessor = get_accessor(data_dir) + result = accessor.get_one('fh', department='CS') + self.assertEqual( + test_data.test_get_one_dept_data[0], + result + ) + + def test_get_one_dept_returns_n_courses(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + accessor = get_accessor(data_dir) + result = accessor.get_one(school='fh', department='CS') + self.assertEqual( + len(test_data.test_get_one_dept_data[0].keys()), + len(result.keys()) + ) + + def test_get_one_dept_and_course(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + accessor = get_accessor(data_dir) + result = accessor.get_one( + school='fh', department='CS', course='2A') + self.assertEqual( + test_data.test_get_one_dept_and_course_data, + result + ) + + def test_get_urls_gets_all_departments(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + accessor = get_accessor(data_dir) + result = accessor.get_urls(school='fh', quarter='000011') + self.assertEqual(74, len(result)) + + +def get_accessor(dir_name: str): + return ModelAccessor(DataModel(dir_name)) diff --git a/tests/model/test_input.py b/tests/model/test_input.py new file mode 100644 index 0000000..19d29f9 --- /dev/null +++ b/tests/model/test_input.py @@ -0,0 +1,219 @@ +from unittest import TestCase + +import owl.input + + +class TestInput(TestCase): + def test_get_one_allows_proper_input(self): + raw = PseudoRequest({ + 'quarter': '000011', + 'department': 'CS', + 'course': '1A', + }) + inputs = owl.input.GetOneInput(raw) + inputs.validate() + self.assertFalse(inputs.errors) + + def test_get_one_raises_issues_when_extra_arguments_are_received(self): + raw = PseudoRequest({ + 'quarter': '000011', + 'department': 'CS', + 'course': '1A', + 'blah': 5, + }) + inputs = owl.input.GetOneInput(raw) + inputs.validate() + self.assertTrue(inputs.errors) + + def test_get_one_disallows_input_without_department(self): + raw = PseudoRequest({ + 'quarter': '000011', + 'course': '1A', + }) + inputs = owl.input.GetOneInput(raw) + inputs.validate() + self.assertEqual(1, len(inputs.errors)) + + def test_filter_can_accept_status_argument(self): + raw = PseudoRequest({ + 'quarter': '000011', + 'department': 'CS', + 'course': '1A', + 'filter': { + 'status': {'open': 1, 'waitlist': 1}, + } + }) + inputs = owl.input.GetOneInput(raw) + inputs.validate() + self.assertFalse(inputs.errors) + + def test_filter_can_accept_type_argument(self): + raw = PseudoRequest({ + 'quarter': '000011', + 'department': 'CS', + 'course': '1A', + 'filter': { + 'type': {'hybrid': 1, 'online': 1}, + } + }) + inputs = owl.input.GetOneInput(raw) + inputs.validate() + self.assertFalse(inputs.errors) + + def test_filter_can_accept_days_argument(self): + raw = PseudoRequest({ + 'quarter': '000011', + 'department': 'CS', + 'course': '1A', + 'filter': { + 'days': {'M': 1, 'T': 1, 'Th': 1}, + } + }) + inputs = owl.input.GetOneInput(raw) + inputs.validate() + self.assertFalse(inputs.errors) + + def test_filter_can_accept_instructor_argument(self): + raw = PseudoRequest({ + 'quarter': '000011', + 'department': 'CS', + 'course': '1A', + 'filter': { + 'instructor': {'PlaceholderName': 1}, + } + }) + inputs = owl.input.GetOneInput(raw) + inputs.validate() + self.assertFalse(inputs.errors) + + def test_filter_can_accept_all_arguments(self): + raw = PseudoRequest({ + 'quarter': '000011', + 'department': 'CS', + 'course': '1A', + 'filter': { + 'status': {'open': 1, 'waitlist': 1}, + 'type': {'hybrid': 1, 'online': 1}, + 'days': {'M': 1, 'T': 1, 'Th': 1}, + 'time': {'start': '8:30 AM', 'end': '4:30 PM'}, + 'instructor': {'PlaceholderName': 1}, + 'conflict_sections': [ + { + 'quarter': '000011', + 'department': 'CS', + 'course': '1A', + 'section': '123456', + }, + { + 'quarter': '000011', + 'department': 'CS', + 'course': '2A', + 'section': '654321', + } + ] + } + }) + inputs = owl.input.GetOneInput(raw) + inputs.validate() + self.assertFalse(inputs.errors) + + def test_filter_does_not_accept_extra_arguments(self): + raw = PseudoRequest({ + 'quarter': '000011', + 'department': 'CS', + 'course': '1A', + 'filter': { + 'foo': {'Placeholder': 1}, + } + }) + inputs = owl.input.GetOneInput(raw) + inputs.validate() + self.assertTrue(inputs.errors) + + def test_get_many_allows_proper_input(self): + request = PseudoRequest({ + 'courses': [ + { + 'quarter': '000011', + 'department': 'CS', + 'course': '1A', + }, + { + 'quarter': '000012', + 'department': 'CS', + }, + { + 'quarter': '000011', + 'department': 'MATH', + } + ] + }) + inputs = owl.input.GetManyInput(request) + inputs.validate() + self.assertFalse(inputs.errors) + + def test_get_many_raises_issues_when_extra_arguments_are_received(self): + request = PseudoRequest({ + 'courses': [ + { + 'quarter': '000011', + 'department': 'CS', + 'course': '1A', + }, + { + 'quarter': '000012', + 'department': 'CS', + }, + { + 'quarter': '000011', + 'department': 'MATH', + } + ], + 'unneeded_arg': 2 + }) + inputs = owl.input.GetManyInput(request) + inputs.validate() + self.assertTrue(inputs.errors) + + def test_get_list_accepts_expected_arguments(self): + raw = PseudoRequest({ + 'quarter': '000011', + 'department': 'MATH', + 'course': '1A', + }) + inputs = owl.input.GetOneInput(raw) + inputs.validate() + self.assertFalse(inputs.errors) + + def test_get_list_does_not_accept_unexpected_arguments(self): + raw = PseudoRequest({ + 'quarter': '000011', + 'department': 'MATH', + 'course': '1A', + 'unexpected': 4 + }) + inputs = owl.input.GetOneInput(raw) + inputs.validate() + self.assertTrue(inputs.errors) + + def test_get_urls_accepts_valid_input(self): + raw = PseudoRequest({ + 'quarter': '000011', + }) + inputs = owl.input.GetUrlsInput(raw) + inputs.validate() + self.assertFalse(inputs.errors) + + def test_get_urls_does_not_accept_extra_arguments(self): + raw = PseudoRequest({ + 'quarter': '000011', + 'Unneeded': 'CS', + }) + inputs = owl.input.GetUrlsInput(raw) + inputs.validate() + self.assertTrue(inputs.errors) + + +class PseudoRequest: + def __init__(self, args): + self.json = args diff --git a/tests/model/test_model.py b/tests/model/test_model.py new file mode 100644 index 0000000..cf01704 --- /dev/null +++ b/tests/model/test_model.py @@ -0,0 +1,387 @@ +import os +import tempfile +import time + +from distutils.dir_util import copy_tree +from unittest import TestCase + +import maya + +from owl.model import DataModel, DepartmentQuarterView, CourseQuarterView, \ + SectionQuarterView, ClassDuration, \ + OPEN, STANDARD_TYPE, ONLINE_TYPE, HYBRID_TYPE +from tests.generate_test_data import TEST_RESOURCES_DIR + + +class TestDataModel(TestCase): + def test_model_finds_all_tables(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + self.assertEqual(3, len([quarter for quarter in data.quarters])) + + def test_model_contains_correct_schools(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + self.assertEqual(2, len(data.schools)) + self.assertIn('FH', data.schools) + self.assertIn('DA', data.schools) + + def test_department_view_can_be_retrieved_from_quarter(self): + # This test doesn't help much, but at least confirms that no + # errors are thrown. + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + self.assertEqual('ACTG', dept.name) + self.assertIsInstance(dept, DepartmentQuarterView) + + def test_courses_are_accessible_from_department(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1A'] + self.assertIsInstance(course, CourseQuarterView) + + def test_sections_are_accessible_from_course(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1A'] + section = course.sections['40065'] + self.assertIsInstance(section, SectionQuarterView) + + def test_section_course_id_returns_correctly(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1A'] + section = course.sections['40065'] + self.assertEqual('ACTG F001A02Y', section.course_id) + + def test_section_crn_returns_correctly(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1A'] + section = course.sections['40065'] + self.assertEqual('40065', section.crn) + + def test_section_description_returns_correctly(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1A'] + section = course.sections['40065'] + self.assertEqual('FINANCIAL ACCOUNTING I', section.description) + + def test_section_status_returns_correctly(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1B'] + section = course.sections['41130'] + self.assertEqual(OPEN, section.status) + + def test_section_days_returns_correctly_in_semi_online_class(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1A'] + section = course.sections['40065'] + self.assertEqual({'T', 'Th'}, section.days) + + def test_section_days_property_raises_exception_in_online_class(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1A'] + section = course.sections['40018'] + self.assertEqual(set(), section.days) + + def test_section_days_returns_correctly_in_offline_class(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ENGL'] + course = dept.courses['1A'] + section = course.sections['40140'] + self.assertEqual({'M', 'W'}, section.days) + + def test_section_days_returns_correctly_in_single_day_class(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['76'] + section = course.sections['41440'] + self.assertEqual({'W'}, section.days) + + def test_section_days_returns_correctly_in_class_with_labs_a(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['PHYS'] + course = dept.courses['2B'] + section = course.sections['40582'] + self.assertEqual({'T', 'Th'}, section.days) + + def test_section_days_returns_correctly_in_class_with_labs_b(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['PHYS'] + course = dept.courses['4D'] + section = course.sections['40208'] + self.assertEqual({'T', 'Th', 'F'}, section.days) + + def test_section_durations_return_correct_maya_date_times(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['PHYS'] + course = dept.courses['4D'] + section = course.sections['40208'] + durations = section.durations + self.assertEqual(4, len(durations)) + self.assertIsInstance(durations[0], ClassDuration) + self.assertIsInstance(durations[1], ClassDuration) + self.assertIsInstance(durations[2], ClassDuration) + self.assertIsInstance(durations[3], ClassDuration) + + # Check that each duration has expected times + for duration in durations: + if duration.day == 'Th': + self.assertEqual(maya.when('10:00AM'), duration.start) + self.assertEqual(maya.when('11:50AM'), duration.end) + elif duration.day == 'F': + self.assertEqual(maya.when('11:00 AM'), duration.start) + self.assertEqual(maya.when('11:50AM'), duration.end) + + def test_section_rooms_are_found_correctly(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['PHYS'] + course = dept.courses['4D'] + section = course.sections['40208'] + durations = section.durations + self.assertIsInstance(durations[0], ClassDuration) + self.assertIsInstance(durations[1], ClassDuration) + self.assertIsInstance(durations[2], ClassDuration) + self.assertIsInstance(durations[3], ClassDuration) + + # Check that each duration has expected times + for duration in durations: + if duration.day == 'Th': + self.assertEqual('4501', duration.room) + elif duration.day == 'F': + self.assertEqual('4501', duration.room) + elif duration.day == 'T' and \ + duration.start == maya.when('12:00PM'): + self.assertEqual('4718', duration.room) + + def test_section_rooms_are_found_correctly_for_multi_room_class(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['PHYS'] + course = dept.courses['4D'] + section = course.sections['40208'] + self.assertEqual({'4718', '4501'}, section.rooms) + + def test_section_rooms_are_found_correctly_for_single_room_class(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1B'] + section = course.sections['40067'] + self.assertEqual({'3201'}, section.rooms) + + def test_section_rooms_are_found_correctly_for_online_class(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1C'] + section = course.sections['40022'] + self.assertEqual(set(), section.rooms) + + def test_section_end_returns_correct_maya_date(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ENGL'] + course = dept.courses['1A'] + section = course.sections['40140'] + end_date = section.end_date + self.assertIsInstance(end_date, maya.MayaDT) + + def test_section_type_is_determined_correctly_for_offline_class(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ENGL'] + course = dept.courses['1A'] + section = course.sections['40140'] + self.assertEqual(STANDARD_TYPE, section.section_type) + + def test_section_type_is_determined_correctly_for_online_class(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1A'] + section = course.sections['40018'] + self.assertEqual(ONLINE_TYPE, section.section_type) + + def test_section_type_is_determined_correctly_for_hybrid_class(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1A'] + section = course.sections['40065'] + self.assertEqual(HYBRID_TYPE, section.section_type) + + def test_section_campus_is_found_correctly(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1B'] + section = course.sections['40067'] + self.assertEqual('FH', section.campus) + + def test_section_units_are_found_correctly(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1B'] + section = course.sections['40067'] + self.assertEqual(5, section.units) + + def test_section_instructor_names_return_correctly(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1B'] + section = course.sections['40067'] + self.assertIn('Drake', section.instructor_names) + + def test_section_seats_return_correctly_when_on_waitlist_status(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1B'] + section = course.sections['40067'] + self.assertEqual(0, section.open_seats_available) + + def test_section_wait_seats_return_correctly_when_on_waitlist_status(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1B'] + section = course.sections['40067'] + self.assertEqual(7, section.waitlist_seats_available) + + def test_section_waitlist_capacity_returns_correctly(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + dept = quarter.departments['ACTG'] + course = dept.courses['1B'] + section = course.sections['40067'] + self.assertEqual(15, section.waitlist_capacity) + + def test_quarter_cache_increases_url_access_speed_after_first_access(self): + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + + def time_urls(): + t0 = time.time() + urls = quarter.urls + tf = time.time() + return urls, tf - t0 + + urls1, elapsed1 = time_urls() + urls2, elapsed2 = time_urls() + urls3, elapsed3 = time_urls() + self.assertLess(elapsed2, elapsed1 / 4) + self.assertLess(elapsed3, elapsed1 / 4) + self.assertEqual(urls1, urls2) + self.assertEqual(urls1, urls3) + + def test_quarter_primary_start_and_end_dates_are_accurate(self): + # start: "04/09/2018", "end": "06/29/2018" + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + + duration = quarter.primary_duration + self.assertEqual(maya.when('04/09/2018'), duration.start) + self.assertEqual(maya.when('06/29/2018'), duration.end) + + def test_caching_improves_quarter_primary_start_and_end_date_access(self): + # start: "04/09/2018", "end": "06/29/2018" + with get_test_data_dir('model_test_dir_a') as data_dir: + data = DataModel(data_dir) + quarter = data.quarters['000011'] + + def time_calendar_date(): + t0 = time.time() + duration = quarter.primary_duration + tf = time.time() + return duration, tf - t0 + + duration1, elapsed1 = time_calendar_date() + duration2, elapsed2 = time_calendar_date() + duration3, elapsed3 = time_calendar_date() + self.assertLess(elapsed2, elapsed1 / 4) + self.assertLess(elapsed3, elapsed1 / 4) + + +class TestCourseDuration(TestCase): + def test_durations_intersect_returns_true_with_intersection(self): + a = ClassDuration( + 'M', '1234', maya.when('8:30 AM'), maya.when('10:30 AM')) + b = ClassDuration( + 'M', '5678', maya.when('9:00 AM'), maya.when('11:00 AM')) + self.assertTrue(a.intersects(b)) + + def test_durations_intersect_returns_false_with_differing_days(self): + a = ClassDuration( + 'M', '1234', maya.when('8:30 AM'), maya.when('10:30 AM')) + b = ClassDuration( + 'T', '5678', maya.when('9:00 AM'), maya.when('11:00 AM')) + self.assertFalse(a.intersects(b)) + + def test_durations_intersect_returns_false_with_differing_times(self): + a = ClassDuration( + 'M', '1234', maya.when('8:30 AM'), maya.when('10:30 AM')) + b = ClassDuration( + 'M', '5678', maya.when('2:00 PM'), maya.when('4:00 PM')) + self.assertFalse(a.intersects(b)) + + +# todo: more rigorously test _hash_args + + +def get_test_data_dir(dir_name: str) -> tempfile.TemporaryDirectory: + temp_dir = tempfile.TemporaryDirectory() + copy_tree(os.path.join(TEST_RESOURCES_DIR, dir_name), temp_dir.name) + return temp_dir diff --git a/tests/test_server.py b/tests/test_server.py deleted file mode 100644 index 9c26353..0000000 --- a/tests/test_server.py +++ /dev/null @@ -1,141 +0,0 @@ -from os.path import join -from unittest import TestCase - -from tinydb import TinyDB - -import settings -from server import generate_url, get_one, get_many - -# Try to get generated data. -try: - from .test_db import data as test_data -except ImportError as e: - raise ImportError('Test Data could not be imported. If the data.py file ' - 'does not exist, it can be generated using the ' - 'generate_test_data.py script') from e - -test_database = TinyDB(join(settings.TEST_DB_DIR, 'test_database.json')) - - -class TestGenerateURL(TestCase): - def test_sample_url_can_be_generated(self): - self.assertEqual( - test_data.test_sample_url_can_be_generated_data, - generate_url('test_dept', 'test_course') - ) - - -class TestGetOne(TestCase): - def test_get_one_dept(self): - data = {'dept': 'CS'} # floof.li/single?dept=CS - - result = get_one(db=test_database, data=data, filters=dict()) - self.assertEqual( - test_data.test_get_one_dept_data, - result - ) - - def test_get_one_dept_returns_n_courses(self): - data = {'dept': 'CS'} - - result = get_one(db=test_database, data=data, filters=dict()) - self.assertEqual( - len(test_data.test_get_one_dept_data[0].keys()), - len(result[0].keys()) - ) - - def test_get_one_dept_and_course(self): - data = {'dept': 'CS', 'course': '2A'} - - result = get_one(db=test_database, data=data, filters=dict()) - self.assertEqual( - test_data.test_get_one_dept_and_course_data, - result - ) - - -class TestGetMany(TestCase): - def test_get_many_dept(self): - data = {'courses': [{'dept': 'CS'}, {'dept': 'MATH'}]} - - result = get_many(db=test_database, data=data['courses'], filters=dict()) - self.assertEqual( - test_data.test_get_two_dept_data, - result - ) - - def test_get_many_dept_returns_n_courses(self): - data = {'courses': [{'dept': 'CS'}, {'dept': 'MATH'}]} - - result = get_many(db=test_database, data=data['courses'], filters=dict()) - self.assertEqual( - len(test_data.test_get_two_dept_data[0]), - len(result[0]) - ) - -class TestFilters(TestCase): - def test_filters_status_returns_n_courses(self): - data = {'courses': [{'dept':'CS', 'course':'1A'}], - 'filters': {'status': {'open':0, 'waitlist':1, 'full':0}}} - - result = get_many(db=test_database, data=data['courses'], filters=data['filters']) - - self.assertEqual( - 1, - len(result[0].keys()) - ) - - def test_filters_type_returns_n_courses(self): - data = {'courses':[{'dept':'CS', 'course':'1A'}], - 'filters':{'types':{'standard':0, 'online':1, 'hybrid':0}}} - - result = get_many(db=test_database, data=data['courses'], filters=data['filters']) - - self.assertEqual( - 4, - len(result[0].keys()) - ) - - def test_filters_days_returns_n_courses(self): - data = {'courses':[{'dept':'DANC', 'course':'14'}], - 'filters':{'days':{'M':1, 'T':0, 'W':0, 'Th':0, 'F':0, 'S':0, 'U':0}}} - - result = get_many(db=test_database, data=data['courses'], filters=data['filters']) - - self.assertEqual( - 1, - len(result[0].keys()) - ) - - def test_filter_days_will_return_correct_n_courses_with_all_days_set(self): - data = { - 'courses': [{ - 'dept': 'DANC', - 'course': '14' - }], - 'filters': { - 'days': { - 'M': 1, - 'T': 1, - 'W': 1, - 'Th': 1, - 'F': 1, - 'S': 1, - 'U': 1 - } - } - } - - result = get_many(db=test_database, data=data['courses'], filters=data['filters']) - self.assertEqual(len(result[0].keys()), 3) - - def test_filters_time_returns_n_courses(self): - data = {'courses':[{'dept':'DANC', 'course':'14'}], - 'filters': {'time':{'start':'7:30 AM', 'end':'12:00 PM'}}} - - result = get_many(db=test_database, data=data['courses'], filters=data['filters']) - - self.assertEqual( - 1, - len(result[0].keys()) - )