├── timetabler ├── __init__.py ├── ssc │ ├── __init__.py │ ├── course.py │ └── ssc_conn.py ├── sort.py ├── util.py ├── schedule.py └── scheduler.py ├── example.jpg ├── example2.jpg ├── requirements.txt ├── .gitignore ├── LICENSE ├── README.md ├── css └── table.css └── example.py /timetabler/__init__.py: -------------------------------------------------------------------------------- 1 | from .scheduler import Scheduler 2 | -------------------------------------------------------------------------------- /timetabler/ssc/__init__.py: -------------------------------------------------------------------------------- 1 | from .ssc_conn import SSCConnection 2 | -------------------------------------------------------------------------------- /example.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hfaran/ubc-timetabler/HEAD/example.jpg -------------------------------------------------------------------------------- /example2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hfaran/ubc-timetabler/HEAD/example2.jpg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | beautifulsoup4 3 | prettytable 4 | filecache 5 | 6 | # If using OS X 7 | # gnureadline 8 | 9 | # If using Windows 10 | # pyreadline 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | bin/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Mr Developer 41 | .mr.developer.cfg 42 | .project 43 | .pydevproject 44 | .idea 45 | 46 | # Rope 47 | .ropeproject 48 | 49 | # Django stuff: 50 | *.log 51 | *.pot 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | timetabler/ssc/__cache__/ 57 | timetabler/ssc/*.cache 58 | credentials.json 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Hamza Faran 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ubc-timetabler 2 | 3 | Course scheduling is hard. This helps. 4 | 5 | Enter desired courses and get generated schedules. 6 | 7 | ![Example](example.jpg?raw=true "Example") 8 | 9 | ## Setup 10 | 11 | `pip install -r requirements.txt` 12 | 13 | ## Usage 14 | 15 | See `example.py` for example usage. 16 | 17 | ### Setting Constants 18 | 19 | Set the constants to your liking: 20 | 21 | * `COMMUTE_HOURS` 22 | * How long it takes you to commute **one-way** to campus 23 | * `SESSION` 24 | * The session you would like to create schedules for 25 | * `TERMS` 26 | * Tuple of terms you want to generate schedules for 27 | * `NO_CACHE` 28 | * `ALLOW_SAME_SLOT_SECTIONS` 29 | 30 | ### Setting Required and Optional Courses in `get_schedules` 31 | 32 | * Enter all mandatory courses for the year in `required` 33 | * For optional courses/electives, enter the electives you would potentially 34 | like to take in `opt`, and the in `num_required_from_opt`, specify how many 35 | electives you want to take. 36 | * If you want more fine-grained control, feel free to define custom 37 | combinations of your own and modify the code accordingly. 38 | 39 | ### Adding Constraints for Schedules 40 | 41 | You'll find this line in the code: 42 | 43 | ```python 44 | # I don't want any classes that start before 9:00AM 45 | s.add_constraint(lambda sched: earliest_start(sched.activities) >= 9) 46 | ``` 47 | 48 | You can modify this, or add more like to define further constraints for 49 | scheduling. 50 | 51 | ### Adding Constraints for Courses 52 | 53 | Further, you'll find this: 54 | 55 | ```python 56 | # Add GEOG122 constraints if we need to 57 | if "GEOG 122" in courses: 58 | # STTs are for Vantage College students 59 | s.courses["GEOG 122"].add_constraint( 60 | lambda acts: all(a.status not in [u"STT"] for a in acts) 61 | ) 62 | # Default sections contained a Tutorial but that is for Vantage 63 | # students, so removing that and only setting Lecture and Discussion 64 | s.courses["GEOG 122"].num_section_constraints = [ 65 | (Lecture, 1), (Discussion, 1) 66 | ] 67 | ``` 68 | 69 | Use this is a template for adding constraints for courses if necessary. 70 | 71 | ### Unregisterable Courses 72 | 73 | ```python 74 | # Add statuses for courses that shouldn't be considered 75 | bad_statuses = ( 76 | "Full", 77 | # "Blocked", 78 | ) 79 | ``` 80 | 81 | Modify the above as required (but "Full" and "Blocked" are good defaults). 82 | 83 | ### Sorting Schedules 84 | 85 | ```python 86 | # Sort 87 | # Statements in order from top-to-bottom from least-to-most important 88 | # i.e., put the most important at the bottom 89 | scheds = sort.free_days(scheds) 90 | scheds = sort.least_time_at_school(scheds, commute_hrs=COMMUTE_HOURS) 91 | scheds = sort.sum_latest_daily_morning(scheds) 92 | scheds = sort.even_time_per_day(scheds, commute_hrs=COMMUTE_HOURS) 93 | scheds = sort.even_courses_per_term(scheds) 94 | ``` 95 | 96 | Modify the above to your liking. 97 | 98 | ### Looking at the Results 99 | 100 | Use the REPL in `example.py` to browse, and create worklists for schedules 101 | you like. 102 | 103 | ``` 104 | python example.py 105 | > help 106 | ``` 107 | 108 | ![Example](example2.jpg?raw=true "Example") 109 | -------------------------------------------------------------------------------- /css/table.css: -------------------------------------------------------------------------------- 1 | .CSSTableGenerator { 2 | margin:0px;padding:0px; 3 | width:100%; 4 | box-shadow: 10px 10px 5px #888888; 5 | border:1px solid #3f7f00; 6 | 7 | -moz-border-radius-bottomleft:7px; 8 | -webkit-border-bottom-left-radius:7px; 9 | border-bottom-left-radius:7px; 10 | 11 | -moz-border-radius-bottomright:7px; 12 | -webkit-border-bottom-right-radius:7px; 13 | border-bottom-right-radius:7px; 14 | 15 | -moz-border-radius-topright:7px; 16 | -webkit-border-top-right-radius:7px; 17 | border-top-right-radius:7px; 18 | 19 | -moz-border-radius-topleft:7px; 20 | -webkit-border-top-left-radius:7px; 21 | border-top-left-radius:7px; 22 | }.CSSTableGenerator table{ 23 | border-collapse: collapse; 24 | border-spacing: 0; 25 | width:100%; 26 | height:100%; 27 | margin:0px;padding:0px; 28 | }.CSSTableGenerator tr:last-child td:last-child { 29 | -moz-border-radius-bottomright:7px; 30 | -webkit-border-bottom-right-radius:7px; 31 | border-bottom-right-radius:7px; 32 | } 33 | .CSSTableGenerator table tr:first-child td:first-child { 34 | -moz-border-radius-topleft:7px; 35 | -webkit-border-top-left-radius:7px; 36 | border-top-left-radius:7px; 37 | } 38 | .CSSTableGenerator table tr:first-child td:last-child { 39 | -moz-border-radius-topright:7px; 40 | -webkit-border-top-right-radius:7px; 41 | border-top-right-radius:7px; 42 | }.CSSTableGenerator tr:last-child td:first-child{ 43 | -moz-border-radius-bottomleft:7px; 44 | -webkit-border-bottom-left-radius:7px; 45 | border-bottom-left-radius:7px; 46 | }.CSSTableGenerator tr:hover td{ 47 | 48 | } 49 | .CSSTableGenerator tr:nth-child(odd){ background-color:#d4ffaa; } 50 | .CSSTableGenerator tr:nth-child(even) { background-color:#ffffff; }.CSSTableGenerator td{ 51 | vertical-align:middle; 52 | 53 | 54 | border:1px solid #3f7f00; 55 | border-width:0px 1px 1px 0px; 56 | text-align:center; 57 | padding:6px; 58 | font-size:16px; 59 | font-family:Arial; 60 | font-weight:bold; 61 | color:#000000; 62 | }.CSSTableGenerator tr:last-child td{ 63 | border-width:0px 1px 0px 0px; 64 | }.CSSTableGenerator tr td:last-child{ 65 | border-width:0px 0px 1px 0px; 66 | }.CSSTableGenerator tr:last-child td:last-child{ 67 | border-width:0px 0px 0px 0px; 68 | } 69 | .CSSTableGenerator tr:first-child td{ 70 | background:-o-linear-gradient(bottom, #5fbf00 5%, #3f7f00 100%); background:-webkit-gradient( linear, left top, left bottom, color-stop(0.05, #5fbf00), color-stop(1, #3f7f00) ); 71 | background:-moz-linear-gradient( center top, #5fbf00 5%, #3f7f00 100% ); 72 | filter:progid:DXImageTransform.Microsoft.gradient(startColorstr="#5fbf00", endColorstr="#3f7f00"); background: -o-linear-gradient(top,#5fbf00,3f7f00); 73 | 74 | background-color:#5fbf00; 75 | border:0px solid #3f7f00; 76 | text-align:center; 77 | border-width:0px 0px 1px 1px; 78 | font-size:20px; 79 | font-family:Arial; 80 | font-weight:bold; 81 | color:#ffffff; 82 | } 83 | .CSSTableGenerator tr:first-child:hover td{ 84 | background:-o-linear-gradient(bottom, #5fbf00 5%, #3f7f00 100%); background:-webkit-gradient( linear, left top, left bottom, color-stop(0.05, #5fbf00), color-stop(1, #3f7f00) ); 85 | background:-moz-linear-gradient( center top, #5fbf00 5%, #3f7f00 100% ); 86 | filter:progid:DXImageTransform.Microsoft.gradient(startColorstr="#5fbf00", endColorstr="#3f7f00"); background: -o-linear-gradient(top,#5fbf00,3f7f00); 87 | 88 | background-color:#5fbf00; 89 | } 90 | .CSSTableGenerator tr:first-child td:first-child{ 91 | border-width:0px 0px 1px 0px; 92 | } 93 | .CSSTableGenerator tr:first-child td:last-child{ 94 | border-width:0px 0px 1px 1px; 95 | } 96 | -------------------------------------------------------------------------------- /timetabler/sort.py: -------------------------------------------------------------------------------- 1 | """This module contains common sorting functions useful for sorting schedules""" 2 | from __future__ import division 3 | from collections import defaultdict 4 | 5 | from timetabler.util import DAY_LIST, strtime2num, stddev 6 | 7 | 8 | def sum_latest_daily_morning(schedules): 9 | """Sort for the latest daily morning""" 10 | 11 | def key(s): 12 | total = 0 13 | num_days = 0 14 | for day in DAY_LIST: 15 | acts = s.activities_for_day(day) 16 | if not acts: 17 | continue # It's a free day! 18 | earliest_start_time = earliest_start(acts) 19 | total += earliest_start_time 20 | num_days += 1 21 | return total/num_days 22 | 23 | return sorted(schedules, key=key, reverse=True) 24 | 25 | 26 | def least_time_at_school(schedules, commute_hrs=0): 27 | """Sorts schedules for least time spend at school (including daily commute) 28 | 29 | :type commute_hrs: int or float 30 | :param commute_hrs: Time in hours for ONE-WAY commute 31 | """ 32 | def key(s): 33 | total = 0 34 | for day in DAY_LIST: 35 | acts = s.activities_for_day(day) 36 | if not acts: 37 | continue # It's a free day! 38 | earliest_start_time = earliest_start(acts) 39 | latest_end_time = latest_end(acts) 40 | time_at_school = (latest_end_time - earliest_start_time) + \ 41 | (2 * commute_hrs) 42 | total += time_at_school 43 | return total 44 | 45 | return sorted(schedules, key=key) 46 | 47 | 48 | def even_time_per_day(schedules, commute_hrs=0): 49 | """Sorts by standard deviation of time at school per day 50 | 51 | As a result, sorting by this gives the most "even" schedule 52 | in regards to time spent at school every day of the week 53 | 54 | :type commute_hrs: int or float 55 | :param commute_hrs: Time in hours for ONE-WAY commute 56 | """ 57 | 58 | def key(s): 59 | time_at_school_week = [] 60 | for day in DAY_LIST: 61 | acts = s.activities_for_day(day) 62 | if not acts: 63 | continue # It's a free day! 64 | earliest_start_time = earliest_start(acts) 65 | latest_end_time = latest_end(acts) 66 | time_at_school = (latest_end_time - earliest_start_time) + \ 67 | (2 * commute_hrs) 68 | time_at_school_week.append(time_at_school) 69 | return stddev(time_at_school_week) 70 | 71 | return sorted(schedules, key=key) 72 | 73 | 74 | def even_courses_per_term(schedules): 75 | """Sort for 'even' number of courses per term 76 | 77 | This is done by creating a dict which has sets of courses per term; then 78 | we take the standard deviation of the number of courses per term. The 79 | smaller the deviation, the better in this case. 80 | """ 81 | 82 | def key(s): 83 | d = defaultdict(set) 84 | for act in s.activities: 85 | d[act.term].add(tuple(act.section.split()[:2])) 86 | return stddev(map(len, d.values())) 87 | 88 | return sorted(schedules, key=key) 89 | 90 | 91 | def free_days(schedules): 92 | """Optimizes for days off (i.e., no classes on that day)""" 93 | return sorted(schedules, key=lambda s: sum(1 for day in DAY_LIST if s.activities_for_day(day))) 94 | 95 | 96 | ########### 97 | # Helpers # 98 | ########### 99 | 100 | def earliest_start(activities): 101 | return strtime2num(min(a.start_time for a in activities)) 102 | 103 | 104 | def latest_end(activities): 105 | return strtime2num(max(a.end_time for a in activities)) 106 | -------------------------------------------------------------------------------- /timetabler/util.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | from math import sqrt 4 | 5 | 6 | ############# 7 | # Constants # 8 | ############# 9 | 10 | DAY_LIST = ["Mon", "Tue", "Wed", "Thu", "Fri"] 11 | 12 | ########### 13 | # Helpers # 14 | ########### 15 | 16 | # General 17 | 18 | def chunks(l, n): 19 | """Yields successive ``n``-sized chunks from ``l`` 20 | 21 | http://stackoverflow.com/a/312464/1798683 22 | """ 23 | for i in xrange(0, len(l), n): 24 | yield l[i:i + n] 25 | 26 | 27 | def check_equal(iterable): 28 | """Check equivalency or all items in ``iterable`` 29 | 30 | >>> check_equal(xrange(5)) 31 | False 32 | >>> check_equal([1, 1, 1]) 33 | True 34 | >>> check_equal([1, 2, 1]) 35 | False 36 | """ 37 | iterable = iter(iterable) 38 | first = next(iterable) 39 | return all(first == i for i in iterable) 40 | 41 | 42 | def check_diff(iterable): 43 | """Returns true if any items in ``iterable`` differ 44 | 45 | >>> check_diff([1, 1]) 46 | False 47 | >>> check_diff([1, 2]) 48 | True 49 | >>> check_diff(xrange(5)) 50 | True 51 | """ 52 | iterable = iter(iterable) 53 | first = next(iterable) 54 | return any(first != i for i in iterable) 55 | 56 | 57 | def all_unique(x): 58 | """Check if all items in ``x`` are unique 59 | 60 | http://stackoverflow.com/a/5281641/1798683 61 | """ 62 | seen = set() 63 | return not any(i in seen or seen.add(i) for i in x) 64 | 65 | 66 | def stddev(lst): 67 | """Calculate **population** (not sample) standard deviation of ``lst`` 68 | 69 | :type lst: list 70 | :param lst: List of numbers 71 | :returns: standard deviation of ``lst`` 72 | :rtype: float 73 | 74 | >>> act = stddev([13,25,46,255,55]) 75 | >>> exp = 89.34517334 76 | >>> abs(act - exp) < 1E-6 77 | True 78 | """ 79 | points = len(lst) 80 | mean = sum(lst)/points 81 | variance = sum((i - mean)**2 for i in lst)/points 82 | return sqrt(variance) 83 | 84 | 85 | def setup_root_logger(log_level='INFO'): 86 | import logging 87 | import sys 88 | 89 | root = logging.getLogger() 90 | root.setLevel(getattr(logging, log_level)) 91 | 92 | ch = logging.StreamHandler(sys.stdout) 93 | ch.setLevel(logging.INFO) 94 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 95 | ch.setFormatter(formatter) 96 | root.addHandler(ch) 97 | 98 | 99 | # timetabler-specific helpers 100 | 101 | def strtime2num(s): 102 | """Turns ``s`` like "09:00" to 9.5""" 103 | t = s.split(":") 104 | t = map(int, t) 105 | if t[1] == 30: 106 | return t[0] + 0.5 107 | else: 108 | return t[0] 109 | 110 | 111 | def iter_time(start, end): 112 | """Returns an iterator that gives a range of half-hourly time 113 | from ``start`` (inclusive) to ``end`` (exclusive) 114 | 115 | >>> list(iter_time("09:00", "12:30")) 116 | ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '12:00'] 117 | """ 118 | 119 | def time2tuple(t): 120 | return tuple(map(int, t.split(":"))) 121 | 122 | def tuple2time(t): 123 | return ":".join([str(i).zfill(2) for i in t]) 124 | 125 | current = start 126 | while current < end: 127 | # Put yield at the time because we do inclusive start, exclusive stop 128 | yield current 129 | _current = time2tuple(current) 130 | if _current[1] == 30: 131 | _current = (_current[0] + 1, 0) 132 | else: 133 | _current = (_current[0], 30) 134 | current = tuple2time(_current) 135 | 136 | 137 | 138 | if __name__ == '__main__': 139 | import doctest 140 | doctest.testmod() 141 | -------------------------------------------------------------------------------- /timetabler/schedule.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | import os 3 | from uuid import uuid4 4 | 5 | from prettytable import PrettyTable 6 | 7 | from timetabler.util import iter_time, DAY_LIST 8 | 9 | 10 | class Schedule(object): 11 | def __init__(self, sched): 12 | """Schedule 13 | 14 | e.g. for ``sched``: 15 | ((Lab, 16 | Lecture), 17 | (Lab, 18 | Lecture), 19 | (Lecture, 20 | Tutorial)) 21 | """ 22 | self._sched = sched 23 | self.activities = [act for crs in sched for act in crs] 24 | 25 | def activities_for_day(self, day): 26 | return [a for a in self.activities if day in a.days] 27 | 28 | def activity_at_time(self, time="09:00", day="Mon", term=1): 29 | res = [a for a in self.activities if all([ 30 | a.start_time <= time, 31 | a.end_time > time, 32 | day in a.days, 33 | term == a.term 34 | ])] 35 | assert len(res) in [0, 1], ("More than one activity found at specified time. " 36 | "This likely means the code is wrong.") 37 | if res: 38 | return res[0] 39 | else: 40 | return None 41 | 42 | def _draw(self, term=1): 43 | t = PrettyTable(["Time"] + DAY_LIST) 44 | earliest_start_time = min(a.start_time for a in self.activities) 45 | latest_end_time = max(a.end_time for a in self.activities) 46 | time_iter = iter_time(earliest_start_time, latest_end_time) 47 | for time in time_iter: 48 | t.add_row([time] + [getattr(self.activity_at_time(time, day, term), 'section', "") for day in DAY_LIST]) 49 | return t 50 | 51 | def _create_table_div(self, table): 52 | return "\n{}\n{}\n{}\n".format( 53 | '
', 54 | table.get_html_string(), 55 | '
' 56 | ) 57 | 58 | def draw(self, terms=(1,), draw_location="browser", title_format="code"): 59 | """Draw schedule 60 | 61 | :type terms: tuple or list 62 | :param term: Terms for which you would like to draw the schedule 63 | :param draw_location: "browser"|"terminal" 64 | :param title_format: "title"|"code" 65 | :returns: List of tables 66 | :rtype: list 67 | """ 68 | assert draw_location in ["browser", "terminal"] 69 | tables = {term: self._draw(term) for term in terms} 70 | if draw_location=="browser": 71 | tempdir = tempfile.gettempdir() 72 | tempfile_loc = os.path.join(tempdir, "ubc-timetabler_{}.html".format(uuid4().hex)) 73 | with open(tempfile_loc, 'w+') as f: 74 | html = "{}{}{}".format( 75 | """ 76 | 77 | 78 | 79 | 82 | 83 | 84 | 85 | """, 86 | "


\n".join(map(self._create_table_div, tables.itervalues())), 87 | """ 88 | 89 | 90 | """ 91 | ) 92 | f.write(html) 93 | import webbrowser 94 | webbrowser.open('file://' + os.path.realpath(tempfile_loc)) 95 | elif draw_location=="terminal": 96 | for term, table in tables.iteritems(): 97 | title_formatters = { 98 | "title": lambda act: act.course.title, 99 | "code": lambda act: "{} {}".format(act.course.dept, act.course.number) 100 | } 101 | print("Courses for Term {}: {}".format( 102 | term, 103 | ", ".join({title_formatters[title_format](act) 104 | for act in self.activities if act.term == term}) 105 | )) 106 | print(table) 107 | 108 | return tables 109 | -------------------------------------------------------------------------------- /timetabler/ssc/course.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import logging 3 | import re 4 | 5 | 6 | class Course(object): 7 | def __init__(self, dept, number, title, 8 | lectures=None, labs=None, tutorials=None, discussions=None, 9 | duplicates=True): 10 | self.dept = dept 11 | self.number = number 12 | self.title = title 13 | self.lectures = lectures if lectures else [] 14 | assert all(isinstance(l, Lecture) for l in self.lectures) 15 | self.labs = self.skip_duplicates(labs, duplicates) if labs else [] 16 | assert all(isinstance(l, Lab) for l in self.labs) 17 | self.tutorials = self.skip_duplicates(tutorials, duplicates) if tutorials else [] 18 | assert all(isinstance(l, Tutorial) for l in self.tutorials) 19 | self.discussions = self.skip_duplicates(discussions, duplicates) if discussions else [] 20 | assert all(isinstance(l, Discussion) for l in self.discussions) 21 | 22 | collection_activities = [ 23 | self.labs, self.lectures, 24 | self.tutorials, self.discussions 25 | ] 26 | # Set course for each activity 27 | for activity in itertools.chain(*collection_activities): 28 | activity.course = self 29 | self._num_section_constraints = [ 30 | (l[0].__class__, (2 if l[0].is_multi_term else 1)) 31 | for l in collection_activities 32 | if l 33 | ] 34 | self._constraints = [] 35 | 36 | def skip_duplicates(self, activities, duplicates=True): 37 | """Skip variations of the same activities that occur at the same time 38 | 39 | :type activities: list 40 | :param activities: [Activity] 41 | :type duplicates: bool 42 | :param duplicates: If this is set, variant activities are not filtered 43 | :rtype: list 44 | :return: Activities with unique times 45 | """ 46 | if duplicates: 47 | return activities 48 | non_duplicate_activities = [] 49 | while activities: 50 | activity = activities.pop() 51 | pattern = activity.section[:-1] + '\w' 52 | if activities: 53 | for l in activities: 54 | if re.search(pattern, l.section) and \ 55 | l.term == activity.term and \ 56 | l.days == activity.days and \ 57 | l.start_time == activity.start_time and \ 58 | l.end_time == activity.end_time: 59 | logging.warning('\nDuplicates: ' + activity.section + ', ' + l.section) 60 | break 61 | else: 62 | non_duplicate_activities.append(activity) 63 | else: 64 | non_duplicate_activities.append(activity) 65 | return non_duplicate_activities 66 | 67 | @property 68 | def constraints(self): 69 | return self._constraints 70 | 71 | def add_constraint(self, constraint): 72 | """Add constraint ``constraint`` to list of constraints 73 | 74 | :type constraint: callable 75 | :param constraint: A callable that takes a (single) list of 76 | Activity subtype instances that belong to this Course that we want 77 | to return True for in order to consider that list of 78 | Activities valid. 79 | """ 80 | self._constraints.append(constraint) 81 | 82 | @property 83 | def num_section_constraints(self): 84 | """List of tuples, where each tuple is 85 | (Activity subtype, # of sections of that activity needed) 86 | """ 87 | return self._num_section_constraints 88 | 89 | @num_section_constraints.setter 90 | def num_section_constraints(self, value): 91 | self._num_section_constraints = value 92 | 93 | @property 94 | def activities(self): 95 | return self.lectures + self.tutorials + self.labs + self.discussions 96 | 97 | 98 | class Activity(object): 99 | def __init__(self, status, section, term, days, start_time, end_time, 100 | comments, is_multi_term): 101 | self.status = status # e.g, "Restricted" 102 | self.section = section # e.g., "EECE 310 L1A" 103 | self.term = int(term) # e.g., 2 104 | self.days = set(days.split()) # e.g., {"Mon", "Wed"} 105 | # zfilling is important so that > and < comparison operators work 106 | self.start_time = start_time.zfill(5) # e.g., "13:00" 107 | self.end_time = end_time.zfill(5) 108 | self.comments = comments 109 | self.is_multi_term = is_multi_term # boolean 110 | 111 | self._course = None # Reference to Course object that has this activity 112 | 113 | @property 114 | def course(self): 115 | return self._course 116 | 117 | @course.setter 118 | def course(self, value): 119 | self._course = value 120 | 121 | def __repr__(self): 122 | return "{}<{}>".format( 123 | self.__class__.__name__, 124 | ", ".join([ 125 | "{}='{}'".format(attr, getattr(self, attr)) for attr in 126 | [ 127 | 'status', 128 | 'section', 129 | 'term', 130 | 'days', 131 | 'start_time', 132 | 'end_time', 133 | # 'comments', 134 | ] 135 | ]) 136 | ) 137 | 138 | 139 | class Lecture(Activity): 140 | pass 141 | 142 | 143 | class Lab(Activity): 144 | pass 145 | 146 | 147 | class Tutorial(Activity): 148 | pass 149 | 150 | 151 | class Discussion(Activity): 152 | pass 153 | -------------------------------------------------------------------------------- /timetabler/scheduler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from itertools import combinations, ifilter, product 3 | 4 | from timetabler.ssc import SSCConnection 5 | from timetabler.util import check_equal, all_unique 6 | from timetabler.schedule import Schedule 7 | 8 | 9 | class NoActivitiesError(Exception): 10 | """No activities for given course""" 11 | def __init__(self, course_name): 12 | self.course_name = course_name 13 | 14 | def __str__(self): 15 | return self.course_name 16 | 17 | 18 | class Scheduler(object): 19 | def __init__(self, courses, session="2014W", terms=(1, 2), 20 | refresh=False, duplicates=True, ssc_conn=None): 21 | """Schedule 22 | 23 | :type courses: list|tuple 24 | :param courses: ["CPSC 304", ...] 25 | :type session: str|unicode 26 | :param session: Session you want to schedule for 27 | :type terms: tuple|list 28 | :param terms: List of terms you want to schedule courses in; 29 | i.e., [1] for only first term, [1, 2] for whole session etc. 30 | :param refresh: Invalidate all cached data for relevant courses 31 | :type ssc_conn: SSCConnection 32 | """ 33 | self.ssc_conn = SSCConnection() if ssc_conn is None else ssc_conn 34 | self.courses = {c: self.ssc_conn.get_course(c, session, refresh=refresh, 35 | duplicates=duplicates) 36 | for c in courses} 37 | self.terms = terms 38 | self.session = session 39 | self._constraints = [] 40 | 41 | ################## 42 | # Public Methods # 43 | ################## 44 | 45 | def generate_schedules(self, bad_statuses=("Full", "Blocked")): 46 | """Generate valid schedules""" 47 | schedules_by_course = {} 48 | for name, course in self.courses.items(): 49 | logging.info("Generating schedules for {} ...".format(name)) 50 | # Courses should have at least one activity 51 | if not course.activities: 52 | raise NoActivitiesError(name) 53 | acts = course.activities 54 | r = sum(c[1] for c in course.num_section_constraints) 55 | combs = combinations(acts, r) 56 | # Makes sure: 57 | # * num_section_constraints from Course are met 58 | # * all activities are in terms that we want (according to self.terms) 59 | # * all activities themselves are in the same term (UNLESS they're multiterm) 60 | # * no activities are included that are Full/Blocked 61 | # * all constraints from the course are satisfied 62 | filter_func = lambda combo: all([ 63 | all( 64 | sum(int(isinstance(act, constraint[0])) for act in combo) == constraint[1] 65 | for constraint in course.num_section_constraints 66 | ), 67 | all(act.term in self.terms for act in combo), 68 | (check_equal([act.term for act in combo]) or 69 | any(act.is_multi_term for act in combo)), 70 | all(a.status not in bad_statuses for a in combo), 71 | all(c(combo) for c in course.constraints) 72 | ]) 73 | filtered_combs = filter(filter_func, combs) 74 | schedules_by_course[name] = filtered_combs 75 | logging.info("Schedules for {} generated.".format(name)) 76 | 77 | # Get all combinations (so all possible schedules); but we still need to check for conflicts 78 | all_scheds = self._generate_combinations(schedules_by_course) 79 | # Makes sure: 80 | # * Schedules don't have recurring courses 81 | # * Don't have conflicts 82 | filter_func = lambda s: all([ 83 | # all_unique(a.section for t in s for a in t), # This seems to be unnecessary 84 | not self._check_schedule_conflicts(s) 85 | ]) 86 | # Now we filter away all schedules that have conflicts 87 | filtered_all_scheds = ifilter(filter_func, all_scheds) 88 | logging.info("Generating all valid schedules ...") 89 | schedules = [Schedule(sched) for sched in filtered_all_scheds] 90 | # Now we filter away all the schedules that don't obey constraints 91 | filter_func = lambda s: all(c(s) for c in self._constraints) 92 | schedules = filter(filter_func, schedules) 93 | logging.info("Found {} valid schedules.".format(len(schedules))) 94 | 95 | return schedules 96 | 97 | def add_constraint(self, constraint): 98 | """Add constraint ``constraint`` to list of constraints 99 | 100 | :type constraint: callable 101 | :param constraint: A callable that takes a Schedule 102 | and returns True or False depending on whether 103 | a constraint is met 104 | """ 105 | self._constraints.append(constraint) 106 | 107 | ################### 108 | # Private Methods # 109 | ################### 110 | 111 | def _generate_combinations(self, scheds_by_course): 112 | """Generate all possible schedules given ``scheds_by_course`` 113 | 114 | :type scheds_by_course: dict 115 | :param scheds_by_course: Dictionary of possible schedules by course 116 | :rtype: list 117 | :return: Combination of all schedules 118 | """ 119 | if not scheds_by_course: 120 | return [] 121 | 122 | schedules = list(product(*scheds_by_course.itervalues())) 123 | return schedules 124 | 125 | 126 | @classmethod 127 | def _check_conflict(cls, act1, act2): 128 | """Checks for a scheduling conflict between two Activity instances""" 129 | return all([ 130 | # Check time conflict 131 | act1.start_time < act2.end_time, 132 | act1.end_time > act2.start_time, 133 | # Check if they are on the same day(s) 134 | act1.days & act2.days, # set intersection 135 | # Check that they are in the same term 136 | act1.term == act2.term 137 | ]) 138 | 139 | @classmethod 140 | def _check_conflicts(cls, current_act, other_acts): 141 | """Check for scheduling conflicts between ``current_act`` and ``other_acts`` 142 | 143 | :type current_act: Activity 144 | :type other_acts: [Activity, ...] 145 | """ 146 | return any(cls._check_conflict(current_act, other_act) for other_act in other_acts) 147 | 148 | @classmethod 149 | def _check_schedule_conflicts(cls, schedule): 150 | """Check for conflicts in ``schedule``""" 151 | acts = [a for t in schedule for a in t] 152 | for current_act in acts: 153 | other_acts = (a for a in acts if a != current_act) 154 | if cls._check_conflicts(current_act, other_acts): 155 | return True 156 | else: 157 | return False 158 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | 3 | from time import time 4 | import sys 5 | import logging 6 | from itertools import combinations 7 | import json 8 | import shlex 9 | import os 10 | from getpass import getpass 11 | 12 | from timetabler.scheduler import Scheduler 13 | from timetabler.ssc.course import Lecture, Discussion, Lab 14 | from timetabler import sort, util 15 | from timetabler.sort import earliest_start # Helper function (should probably be in util) 16 | from timetabler.ssc.ssc_conn import SSCConnection 17 | 18 | 19 | COMMUTE_HOURS = 1.75 20 | SESSION = "2015W" 21 | TERMS = (1, 2) 22 | # If this is set, pages will be freshly fetched from the SSC every time 23 | # This is only recommended if it is close to course-registration time 24 | # and courses are being closed out (please be friendly to the SSC 25 | # and leave this set to False for the usual case) 26 | NO_CACHE = False 27 | # If this is set, sections, such as labs that are in the same slot 28 | # but are at different places, are only represented as the first section 29 | # in the set of many. This is useful to set to True closer to course 30 | # registration when you are actually building worklists. 31 | ALLOW_SAME_SLOT_SECTIONS = False 32 | 33 | 34 | def inline_write(s): 35 | sys.stdout.write(s) 36 | sys.stdout.flush() 37 | 38 | 39 | def get_schedules(ssc_conn): 40 | required = ( 41 | ("CPEN 321", "Software Engineering"), 42 | ("CPEN 421", "Software Project Management"), 43 | ("CPEN 422", "Software Testing and Analysis"), 44 | # ("APSC 486", "NVD"), 45 | ("CPEN 492", "Software Engineering Capstone"), 46 | ("CPEN 481", "Economic Analysis of Engineering Projects"), 47 | ("APSC 450", "Professional Engineering Practice"), 48 | ) 49 | opt = [ 50 | ## CPSC 51 | ("CPSC 312", "Functional programming"), 52 | ("CPSC 340", "Machine Learning and Data Mining"), 53 | ("CPSC 415", "Advanced Operating Systems"), 54 | ("CPSC 322", "Introduction to Artificial Intelligence"), 55 | ("CPSC 421", "Introduction to Theory of Computing"), 56 | ("CPSC 418", "Parallel Computation"), 57 | ("CPSC 344", "Introduction to Human Computer Methods"), 58 | ("CPSC 314", "Computer Graphics"), 59 | ("CPSC 404", "Advanced Relational Databases"), 60 | ("CPSC 425", "Computer Vision"), 61 | ## CPEN 62 | ("CPEN 400A", "Topics in Computer Engineering (Building Modern Web Applications)"), 63 | ("CPEN 442", "Introduction to Computer Security"), 64 | ("CPEN 431", "Design of Distributed Software Applications"), 65 | ("CPEN 412", "Microcomputer System Design"), 66 | ("CPEN 411", "Computer Architecture"), 67 | ] 68 | num_required_from_opt = 2 69 | combs = list(combinations(opt, r=num_required_from_opt)) 70 | 71 | schedules = [] 72 | num_combs = len(combs) 73 | inline_write("Processing {} combinations".format(num_combs)) 74 | for i, courses in enumerate([required + comb for comb in combs]): 75 | s = Scheduler(courses, session=SESSION, terms=TERMS, refresh=NO_CACHE, 76 | duplicates=ALLOW_SAME_SLOT_SECTIONS, ssc_conn=ssc_conn) 77 | # I don't want any classes that start before 9:00AM 78 | s.add_constraint(lambda sched: earliest_start(sched.activities) >= 9) 79 | # Add GEOG122 constraints if we need to 80 | if "GEOG 122" in courses: 81 | # STTs are for Vantage College students 82 | s.courses["GEOG 122"].add_constraint( 83 | lambda acts: all(a.status not in [u"STT"] for a in acts) 84 | ) 85 | # Default sections contained a Tutorial but that is for Vantage 86 | # students, so removing that and only setting Lecture and Discussion 87 | s.courses["GEOG 122"].num_section_constraints = [ 88 | (Lecture, 1), (Discussion, 1) 89 | ] 90 | 91 | # Add statuses for courses that shouldn't be considered 92 | bad_statuses = ( 93 | "Full", 94 | # "Blocked", 95 | ) 96 | schedules.extend(s.generate_schedules(bad_statuses=bad_statuses)) 97 | inline_write(".") 98 | sys.stdout.write("\n") 99 | return schedules 100 | 101 | 102 | def repl(schedules, ssc): 103 | # Set up readline goodness for us so the prompts on OS X/Windows are nice 104 | try: 105 | import gnureadline 106 | except ImportError: 107 | try: 108 | import pyreadline 109 | except ImportError: 110 | pass 111 | 112 | HELP = """ 113 | n - Next 114 | cw - Create Worklist with name 115 | as - Add Sections to worklist 116 | pw - Print Worklists for session 117 | dw - Delete worklist with name 118 | q - Quit 119 | help - Print help 120 | """ 121 | print(HELP) 122 | 123 | for i, sched in enumerate(schedules): 124 | sched.draw(terms=TERMS, draw_location="terminal", title_format="code") 125 | if i < len(schedules) - 1: 126 | while True: 127 | try: 128 | cmd = raw_input("> ") 129 | cmd = shlex.split(cmd) 130 | if not cmd: 131 | continue 132 | if cmd[0] == "n": 133 | break 134 | elif cmd[0] == "cw": 135 | name = cmd[1] 136 | ssc.create_worklist(name, session=SESSION) 137 | print("Created worklist '{}'".format(name)) 138 | elif cmd[0] == "as": 139 | worklist = cmd[1] 140 | worklists = ssc.cache_worklists(SESSION) 141 | assert worklist in worklists 142 | for act in sched.activities: 143 | print("Registering {}...".format(act.section)) 144 | ssc.add_course_to_worklist( 145 | act.section, 146 | SESSION, 147 | worklist 148 | ) 149 | elif cmd[0] == "pw": 150 | session = cmd[1] if len(cmd) > 1 else SESSION 151 | print(json.dumps(ssc.cache_worklists(session), 152 | indent=4)) 153 | elif cmd[0] == "dw": 154 | worklist = cmd[1] 155 | ssc.delete_worklist(name=worklist, session=SESSION) 156 | print("Deleted worklist '{}'".format(worklist)) 157 | elif cmd[0] == "q": 158 | sys.exit(0) 159 | elif cmd[0] == "help": 160 | print(HELP) 161 | else: 162 | print(HELP) 163 | except Exception as err: 164 | logging.exception(err) 165 | print(HELP) 166 | 167 | 168 | def main(): 169 | # Get SSC credentials 170 | CREDENTIALS_FILE = "credentials.json" 171 | if not os.path.exists(CREDENTIALS_FILE): 172 | print("Please enter your SSC username and password (these will be " 173 | "stored on your local filesystem as {})".format( 174 | os.path.abspath(CREDENTIALS_FILE) 175 | )) 176 | credentials = dict( 177 | username=raw_input("Username: "), 178 | password=getpass() 179 | ) 180 | with open(CREDENTIALS_FILE, 'w+') as f: 181 | json.dump(credentials, f) 182 | else: 183 | credentials = json.load(open("credentials.json")) 184 | 185 | # Create SSC connection and log in 186 | ssc = SSCConnection() 187 | ssc.authorize(**credentials) 188 | 189 | # Setup logging 190 | util.setup_root_logger('WARNING') 191 | 192 | # Get schedules (time operation) 193 | start_time = time() 194 | scheds = get_schedules(ssc) 195 | print("There were {} valid schedules found.".format(len(scheds))) 196 | print("This took {:.2f} seconds to calculate.".format( 197 | time() - start_time 198 | )) 199 | # Sort 200 | # Statements in order from top-to-bottom from least-to-most important 201 | # i.e., put the most important at the bottom 202 | scheds = sort.free_days(scheds) 203 | scheds = sort.least_time_at_school(scheds, commute_hrs=COMMUTE_HOURS) 204 | scheds = sort.sum_latest_daily_morning(scheds) 205 | scheds = sort.even_time_per_day(scheds, commute_hrs=COMMUTE_HOURS) 206 | scheds = sort.even_courses_per_term(scheds) 207 | 208 | repl(scheds, ssc) 209 | 210 | 211 | if __name__ == '__main__': 212 | main() 213 | -------------------------------------------------------------------------------- /timetabler/ssc/ssc_conn.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import logging 4 | import cookielib 5 | import urllib2 6 | import urllib 7 | import re 8 | from itertools import chain 9 | from getpass import getpass 10 | 11 | import requests 12 | from bs4 import BeautifulSoup 13 | from bs4.element import NavigableString 14 | import filecache 15 | 16 | from .course import Lecture, Lab, Tutorial, Course, Discussion 17 | from timetabler.util import chunks 18 | 19 | 20 | ## Misc SSC notes 21 | # 22 | # req=1 gives you subject code page (i.e., CPSC) 23 | # req=3 for viewing course page (i.e. CPSC 314) 24 | # req=5 for viewing INDIVIDUAL section of a course (i.e, CPSC 314 101) 25 | # 26 | # Use get_worklists() to get list of worklists; can then use them to add 27 | # courses to worklists by navigating to worklist first 28 | # and THEN doing submit=save for courses to add to worklist 29 | 30 | 31 | class SSCConnection(object): 32 | """Connection to UBC SSC 33 | 34 | :param cache_period: Life of cache before invalidation (number of seconds); 35 | if this is set to None, cache is never automatically invalidated 36 | """ 37 | 38 | def __init__(self, cache_period=3600): 39 | self.base_url = "https://courses.students.ubc.ca" 40 | self.main_url = "{}/cs/main".format(self.base_url) 41 | self.cache_period = cache_period 42 | self.cache_path = os.path.join( 43 | os.path.dirname(os.path.realpath(__file__)), 44 | "__cache__" 45 | ) 46 | if not os.path.exists(self.cache_path): 47 | os.mkdir(self.cache_path) 48 | self.cookies = None 49 | self.worklists = {} 50 | 51 | ################## 52 | # Public Methods # 53 | ################## 54 | 55 | def get_course(self, course=("CPSC 304", "Introduction to Databases"), 56 | session="2014W", refresh=False, duplicates=True): 57 | """Get course data for provided ``course`` 58 | 59 | :type course: tuple|str 60 | :type session: str 61 | :type refresh: bool 62 | :type duplicates: bool 63 | :rtype: Course 64 | """ 65 | if isinstance(course, tuple): 66 | course_name, course_title = course 67 | elif isinstance(course, str): 68 | course_name, course_title = course, course 69 | else: 70 | raise TypeError 71 | dept, course_num = course_name.split() 72 | sessyr, sesscd = session[:4], session[-1] 73 | page = self._get_course_page(dept, course_num, sessyr, sesscd, invalidate=refresh) 74 | activities = self._activities_from_page(page) 75 | 76 | lectures = [a for a in activities if isinstance(a, Lecture)] 77 | labs = [a for a in activities if isinstance(a, Lab)] 78 | tutorials = [a for a in activities if isinstance(a, Tutorial)] 79 | discussions = [a for a in activities if isinstance(a, Discussion)] 80 | 81 | course = Course( 82 | dept=dept, 83 | number=course_num, 84 | title=course_title, 85 | lectures=lectures, 86 | labs=labs, 87 | tutorials=tutorials, 88 | discussions=discussions, 89 | duplicates=duplicates 90 | ) 91 | return course 92 | 93 | def create_worklist(self, name, session="2015W"): 94 | """Creates a worklist with ``name`` for ``session`` 95 | 96 | :type name: str 97 | :type session: str 98 | """ 99 | worklists = self.cache_worklists(session) 100 | if name in worklists: 101 | raise KeyError("Worklist {} already exists!.".format(name)) 102 | 103 | # First we navigate to the session so the SSC knows which session to 104 | # create the worklist for 105 | self._navigate_to_session(session=session) 106 | # Finally, can make the post request to create the worklist 107 | params = { 108 | "attrWorklistName": name, 109 | "submit": "Create New Worklist", 110 | "pname": "wlist", 111 | "tname": "wlist", 112 | "attrSelectedWorklist": "-1" 113 | } 114 | self._post(url=self.main_url, params=params) 115 | self.cache_worklists(session=session, force=True) 116 | 117 | def delete_worklist(self, name, session="2015W"): 118 | worklists = self.cache_worklists(session) 119 | if name not in worklists: 120 | raise KeyError("No such worklist {} exists.".format(name)) 121 | worklist_id = worklists[name].split('=')[-1] 122 | 123 | self._navigate_to_worklist(session=session, worklist=name) 124 | params = { 125 | "submit": "Delete Worklist", 126 | "attrSelectedWorklist": worklist_id 127 | } 128 | self._post(self.main_url, params=params) 129 | self.cache_worklists(session=session, force=True) 130 | 131 | def cache_worklists(self, session="2015W", force=False): 132 | """Cache and return worklists for ``session`` 133 | 134 | :type session: str 135 | :type force: bool 136 | :param force: Will always fetch new results if ``force`` is set 137 | :return: Worklists for the given session 138 | :rtype: dict 139 | """ 140 | if session not in self.worklists or force: 141 | self.worklists[session] = self.get_worklists(session) 142 | return self.worklists[session] 143 | 144 | def get_worklists(self, session="2015W"): 145 | """Retrieve name:url map for worklists for the given session 146 | 147 | :type session: str 148 | :rtype: dict 149 | :returns: e.g., {"fooworklist": "https://courses.students.ubc.ca 150 | /cs/main?pname=wlist&tname=wlist&attrSelectedWorklist=1000656166"} 151 | """ 152 | # Grab page that has worklists list in it 153 | resp = self._navigate_to_session(session) 154 | text = resp.text 155 | soup = BeautifulSoup(text) 156 | # Navigate through tree to find list and a create a map of worklist 157 | # name to URL 158 | worklist_div = soup.find("div", {"class": "worklist-sidebar docs-sidebar"}) 159 | worklist_map = {} 160 | for item in worklist_div.contents[0].contents: 161 | if isinstance(item, NavigableString): 162 | continue 163 | else: 164 | item = item.contents[0] 165 | name, link = item["title"], item["href"] 166 | if name not in ["New Worklist"]: 167 | worklist_map[name] = "{}{}".format(self.base_url, link) 168 | return worklist_map 169 | 170 | def authorize(self, username=None, password=None): 171 | """Authorize this connection for personal SSC use 172 | 173 | If either password or username are not provided, a prompt 174 | is given for the missing value(s). 175 | 176 | :type username: str|None 177 | :type password: str|None 178 | """ 179 | if username is None: 180 | username = raw_input("Username: ") 181 | if password is None: 182 | password = getpass() 183 | self.cookies = self._auth(username, password) 184 | 185 | def add_course_to_worklist(self, section, session, worklist): 186 | """Add provided ``section`` of course to worklist for the given session 187 | 188 | :type session: str 189 | :type section: str 190 | :type worklist: str 191 | """ 192 | # First, we visit the worklist page to ensure we're on the correct 193 | # worklist 194 | self._navigate_to_worklist(session=session, worklist=worklist) 195 | # First navigate to section page itself 196 | self._navigate_to_section_page(section=section) 197 | # Then, navigate to the page with submit=save to save it in the 198 | # worklist 199 | self._navigate_to_section_page(section=section, submit="save") 200 | 201 | ################### 202 | # Private Methods # 203 | ################### 204 | 205 | def _get(self, *args, **kwargs): 206 | return self._authreq(requests.get, *args, **kwargs) 207 | 208 | def _post(self, *args, **kwargs): 209 | return self._authreq(requests.post, *args, **kwargs) 210 | 211 | def _authreq(self, func, *args, **kwargs): 212 | assert self.cookies, "Unauthorized" 213 | logging.info("Making request {}({}{}{})".format( 214 | func.__name__, 215 | ",".join(args), 216 | ", " if kwargs else "", 217 | ",".join("{}={}".format(k, v) for k, v in kwargs.items()) 218 | )) 219 | return func(*args, cookies=self.cookies, **kwargs) 220 | 221 | def _navigate_to_section_page(self, section, session=None, submit=None): 222 | """Perform a GET on section page with various params 223 | 224 | :param section: e.g, "CPEN 422 101" 225 | :param session: e.g., "2015W" 226 | WARNING: this *resets* the selected worklist; 227 | do not add this if you want to stay on the existing session, 228 | and existing worklist. You should navigate to the worklist 229 | first and then do not provide this. 230 | :param submit: Provide this as "save" if you wish to add to current 231 | worklist 232 | """ 233 | # Note: expand this list when supporting more options 234 | assert submit in [None, "save"] 235 | 236 | dept, course, section = section.split() 237 | params = dict( 238 | pname="subjarea", 239 | tname="subjareas", 240 | req="5", 241 | dept=dept, 242 | course=course, 243 | section=section 244 | ) 245 | if session: 246 | sessyr, sesscd = session[:-1], session[-1] 247 | params.update(dict( 248 | sessyr=sessyr, 249 | sesscd=sesscd, 250 | )) 251 | if submit is not None: 252 | params["submit"] = submit 253 | return self._get(self.main_url, params=params) 254 | 255 | def _navigate_to_worklist(self, session, worklist): 256 | self._navigate_to_session(session) 257 | if session not in self.worklists: 258 | self.worklists[session] = self.get_worklists(session) 259 | worklist_url = self.worklists[session][worklist] 260 | return self._get(worklist_url) 261 | 262 | def _navigate_to_session(self, session="2015W"): 263 | sessyr, sesscd = session[:-1], session[-1] 264 | ref_url = "https://courses.students.ubc.ca/cs/main?sessyr={sessyr}" \ 265 | "&sesscd={sesscd}".format( 266 | sesscd=sesscd, 267 | sessyr=sessyr 268 | ) 269 | return self._get(ref_url) 270 | 271 | def _auth(self, cwl_user, cwl_pass): 272 | """Performs SSC auth and returns CookieJar 273 | 274 | This is basically taken verbatim from 275 | https://github.com/cyrussassani/ubc-coursecheck/blob/master/ubcCourseChecker.py 276 | because SSC auth is a PTFO and it was impossible to get working with 277 | requests 278 | 279 | :type cwl_pass: str 280 | :type cwl_user: str 281 | :rtype: cookielib.CookieJar 282 | """ 283 | # Cookie / Opener holder 284 | cj = cookielib.CookieJar() 285 | opener = urllib2.build_opener(urllib2.HTTPCookieProcessor(cj)) 286 | 287 | # Login Header 288 | opener.addheaders = [('User-agent', 'UBC-Login')] 289 | 290 | # Install opener 291 | urllib2.install_opener(opener) 292 | 293 | # Form POST URL 294 | postURL = "https://cas.id.ubc.ca/ubc-cas/login/" 295 | 296 | # First request form data 297 | formData = { 298 | 'username': cwl_user, 299 | 'password': cwl_pass, 300 | 'execution': 'e1s1', 301 | '_eventId': 'submit', 302 | 'lt': 'xxxxxx', 303 | 'submit': 'Continue >' 304 | } 305 | 306 | # Encode form data 307 | data = urllib.urlencode(formData) 308 | 309 | # First request object 310 | req = urllib2.Request(postURL, data) 311 | 312 | # Submit request and read data 313 | resp = urllib2.urlopen(req) 314 | respRead = resp.read() 315 | 316 | # Find the ticket number 317 | ticket = "" 318 | t = re.search(ticket, respRead) 319 | 320 | # Extract jsession ID 321 | firstRequestInfo = str(resp.info()) 322 | jsession = "Set-Cookie: JSESSIONID=(.*?);" 323 | j = re.search(jsession, firstRequestInfo) 324 | 325 | # Second request form data with ticket 326 | formData2 = { 327 | 'username': cwl_user, 328 | 'password': cwl_pass, 329 | 'execution': 'e1s1', 330 | '_eventId': 'submit', 331 | 'lt': t.group(1), 332 | 'submit': 'Continue >' 333 | } 334 | 335 | # Form POST URL with JSESSION ID 336 | postURL2 = "https://cas.id.ubc.ca/ubc-cas/login;jsessionid=" + j.group( 337 | 1) 338 | 339 | # Encode form data 340 | data2 = urllib.urlencode(formData2) 341 | 342 | # Submit request 343 | req2 = urllib2.Request(postURL2, data2) 344 | urllib2.urlopen(req2) 345 | 346 | # Perform login 347 | loginURL = "https://courses.students.ubc.ca/cs/secure/login" 348 | urllib2.urlopen(loginURL) 349 | 350 | return cj 351 | 352 | @staticmethod 353 | @filecache.filecache(filecache.FOREVER) 354 | def _activities_from_page(page): 355 | """Get list of ``Activity`` subclasses from data in ``page`` 356 | 357 | :rtype: [Activity, ...] 358 | """ 359 | attrs = { 360 | u'Status': 0, 361 | u'Section': 4, 362 | u'Activity': 7, 363 | u'Term': 8, 364 | u'Interval': 9, # Either 9 or 10? 365 | u'Days': 11, 366 | u'Start Time': 12, 367 | u'End Time': 13, 368 | u'Comments': 14 369 | } 370 | 371 | def activities_from_data(data): 372 | """Return list of ``Activity`` subclasses generated from ``data``""" 373 | # Make copy 374 | data = data[:] 375 | # Fill in anything missing with empty string 376 | # (This is to handle the case of the last activity which may have had comments section 377 | # stripped from it) 378 | data_length = len(data) 379 | if data_length < 15: 380 | num_missing = 15 - data_length 381 | data += ['' for i in xrange(num_missing)] 382 | # Generate a mapping of the data using ``attrs`` defined below 383 | data_dict = {k: data[v].encode('utf-8').strip(u'\n \xa0'.encode('utf-8')) 384 | for k, v in attrs.iteritems()} 385 | # Find the appropriate Activity subclass (Lab/Lecture etc.) 386 | try: 387 | activity_cls = { 388 | 'Lecture': Lecture, 389 | 'Lecture-Laboratory': Lecture, 390 | 'Laboratory': Lab, 391 | 'Tutorial': Tutorial, 392 | 'Discussion': Discussion 393 | }[data_dict["Activity"]] 394 | except KeyError: 395 | logging.info("Invalid Activity type of {}; skipping." 396 | .format(data_dict["Activity"])) 397 | return [] 398 | # Create and return activity (or two if in both terms) 399 | terms = map(int, data_dict["Term"].split('-')) 400 | is_multi_term = len(terms) >= 2 401 | activities = [] 402 | for term in terms: 403 | activity = activity_cls( 404 | status=data_dict["Status"], 405 | section=data_dict["Section"], 406 | term=term, 407 | days=data_dict["Days"], 408 | start_time=data_dict["Start Time"], 409 | end_time=data_dict["End Time"], 410 | comments=data_dict["Comments"], 411 | is_multi_term=is_multi_term 412 | ) 413 | activities.append(activity) 414 | return activities 415 | 416 | soup = BeautifulSoup(page) 417 | t = soup.text 418 | # Get rid of the top of the page 419 | t = t.split("Status\nSection")[-1] 420 | # Get rid of the bottom of the page 421 | t = "".join( 422 | t.split("Browse Standard Timetables")[:-1] 423 | ) # The list should be length 1 but "".join to do it cleanly 424 | # Strip outer stuff (newlines, spaces, etc.) 425 | t = t.strip(u'\n \xa0') 426 | # Split by newlines to start and give an almost "cell-by-cell" list for the table 427 | t = t.split('\n') 428 | # Strip sections and spaces so that after this we are actually left with a list of all course stuff 429 | itert = iter(t) 430 | current = next(itert) 431 | while current in attrs.keys() + [u'']: 432 | current = next(itert) 433 | t = [current] + list(itert) 434 | logging.info(t) 435 | # Create and return list of activities 436 | return list(chain.from_iterable( 437 | activities_from_data(data_chunk) 438 | for data_chunk in chunks(t, 15) 439 | )) 440 | 441 | def _get_course_page(self, dept="CPSC", course_num="304", sessyr="2014", sesscd="W", invalidate=False): 442 | """Get course page from SSC 443 | 444 | Retrieved pages are cached for a ``self.cache_period``, and then invalidated. 445 | 446 | :type dept: str 447 | :type course: str|int 448 | :type sessyr: str|int 449 | :type sesscd: str 450 | :type invalidate: bool 451 | :param invalidate: If this is set, existing cache for the page will be invalidated 452 | :returns: Text of SSC course page for given course 453 | """ 454 | page_name = "_".join(map(lambda x: str(x).lower(), [dept, course_num, sessyr, sesscd])) 455 | # Attempt to retrieve already cached page 456 | page = self._retrieve_cached_page(page_name, invalidate=invalidate) 457 | # If not already cached, retrieve, cache, and return 458 | if page is None: 459 | logging.info("Page was not found in cache or was invalidated; retrieving from remote and caching...") 460 | r = requests.get(self.main_url, params=dict( 461 | pname="subjarea", 462 | tname="subjareas", 463 | req="3", 464 | dept=dept, 465 | course=course_num, 466 | sessyr=sessyr, 467 | sesscd=sesscd 468 | )) 469 | page_data = r.text 470 | self._cache_page(page_name, page_data) 471 | return page_data 472 | else: 473 | logging.info("Valid existing page was found in cache; retrieving from file...") 474 | return page 475 | 476 | def _cache_page(self, name, text): 477 | """Stores page with name ``name`` and contents ``text`` to the cache folder""" 478 | filename = os.path.join(self.cache_path, name) 479 | with open(filename, 'w+') as f: 480 | f.write(text) 481 | 482 | def _retrieve_cached_page(self, name, invalidate=False): 483 | """Retrieves page ``name`` from cache""" 484 | filename = os.path.join(self.cache_path, name) 485 | # First case, cache does not already exist 486 | if not os.path.exists(filename): 487 | return None 488 | # Check if existing cache is stale 489 | last_modified = os.path.getmtime(filename) 490 | period = time.time() - last_modified 491 | logging.info("Page was last fetched {:.0f} seconds ago.".format(period)) 492 | # If cache is stale, or an invalidation was requested, remove 493 | if any([(self.cache_period) and (period > self.cache_period), invalidate]): 494 | os.remove(filename) 495 | return None 496 | # Cache exists, and is valid, so return it 497 | with open(filename, 'r') as f: 498 | return f.read() 499 | --------------------------------------------------------------------------------