# Copyright (C) 2017 Semester.ly Technologies, LLC
#
# Semester.ly is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Semester.ly is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
import itertools
from collections import namedtuple
from courses.utils import get_sections_by_section_type
from timetable.models import Section, Semester
from timetable.school_mappers import SCHOOLS_MAP
from timetable.scoring import get_tt_cost, get_num_days, get_avg_day_length, get_num_friends, \
get_avg_rating
from student.models import PersonalTimetable
MAX_RETURN = 60 # Max number of timetables we want to consider
Slot = namedtuple('Slot', 'course section offerings is_optional is_locked')
Timetable = namedtuple('Timetable', 'courses sections has_conflict')
def courses_to_timetables(courses, locked_sections, semester, sort_metrics, school, custom_events, with_conflicts, optional_course_ids):
all_offerings = courses_to_slots(courses, locked_sections, semester, optional_course_ids)
timetable_gen = slots_to_timetables(all_offerings, school, custom_events, with_conflicts)
timetables = itertools.islice(timetable_gen, MAX_RETURN)
return sorted(timetables, key=lambda tt: get_tt_cost(tt, sort_metrics))
[docs]def courses_to_slots(courses, locked_sections, semester, optional_course_ids):
"""
Return a list of lists of Slots. Each Slot sublist represents the list of possibilities
for a given course and section type, i.e. a valid timetable consists of any one slot from each
sublist.
"""
slots = []
optional_course_ids = set(optional_course_ids)
for course in courses:
is_optional = course.id in optional_course_ids
grouped = get_sections_by_section_type(course, semester)
for section_type, sections in grouped.iteritems():
locked_section_code = locked_sections.get(str(course.id), {}).get(section_type)
section_codes = [section.meeting_section for section in sections]
if locked_section_code in section_codes:
locked_section = next(s for s in sections
if s.meeting_section == locked_section_code)
locked_slot = Slot(course, locked_section, locked_section.offering_set.all(),
is_optional=is_optional, is_locked=True)
slots.append([locked_slot])
else:
possibilities = [Slot(course, section, section.offering_set.all(),
is_optional=is_optional, is_locked=False)
for section in sections]
slots.append(possibilities)
return slots
[docs]def update_locked_sections(locked_sections, cid, locked_section, semester):
"""
Take cid of new course, and locked section for that course
and toggle its locked status (ie if was locked, unlock and vice versa.
"""
section_type = Section.objects.filter(semester=semester,
course=cid, meeting_section=locked_section)[0].section_type
if locked_sections[cid].get(section_type, '') == locked_section: # already locked
locked_sections[cid][section_type] = '' # unlock that section_type
else: # add as locked section for that section_type
locked_sections[cid][section_type] = locked_section
[docs]def get_xproduct_indicies(lists):
"""
Takes a list of lists and returns two lists of indicies needed to iterate
through the cross product of the input.
"""
num_offerings = []
num_permutations_remaining = [1]
for i in xrange(len(lists) - 1, -1, -1):
length = len(lists[i])
num_offerings.insert(0, length)
num_permutations_remaining.insert(0, length * num_permutations_remaining[0])
return num_offerings, num_permutations_remaining
[docs]def add_meeting_and_check_conflict(day_to_usage, new_meeting, school):
"""
Takes a @day_to_usage dictionary and a @new_meeting section and
returns a tuple of the updated day_to_usage dict and a boolean
which is True if conflict, False otherwise.
"""
course_offerings = new_meeting[2]
new_conflicts = 0
for offering in course_offerings:
day = offering.day
if day != 'U':
for slot in find_slots_to_fill(offering.time_start, offering.time_end, school):
previous_len = max(1, len(day_to_usage[day][slot]))
day_to_usage[day][slot].add(offering)
new_conflicts += len(day_to_usage[day][slot]) - previous_len
return new_conflicts
[docs]def find_slots_to_fill(start, end, school):
"""
Take a @start and @end time in the format found in the coursefinder (e.g. 9:00, 16:30),
and return the indices of the slots in thet array which represents times from 8:00am
to 10pm that would be filled by the given @start and @end. For example, for uoft
input: '10:30', '13:00'
output: [5, 6, 7, 8, 9]
"""
start_hour, start_minute = get_hours_minutes(start)
end_hour, end_minute = get_hours_minutes(end)
return range(get_time_index(start_hour, start_minute, school),
get_time_index(end_hour, end_minute, school))
[docs]def get_time_index(hours, minutes, school):
"""Take number of hours and minutes, and return the corresponding time slot index"""
# earliest possible hour is 8, so we get the number of hours past 8am
return (hours - 8) * (60 /
SCHOOLS_MAP[school].granularity) + minutes / SCHOOLS_MAP[school].granularity
[docs]def get_hours_minutes(time_string):
"""
Return tuple of two integers representing the hour and the time
given a string representation of time.
e.g. '14:20' -> (14, 20)
"""
return (get_hour_from_string_time(time_string),
get_minute_from_string_time(time_string))
[docs]def get_hour_from_string_time(time_string):
"""Get hour as an int from time as a string."""
return int(time_string[:time_string.index(':')]) if ':' in time_string else int(time_string)
[docs]def get_minute_from_string_time(time_string):
"""Get minute as an int from time as a string."""
return int(time_string[time_string.index(':') + 1:] if ':' in time_string else 0)
def get_tt_stats(timetable, day_to_usage):
return {
'days_with_class': get_num_days(day_to_usage),
'time_on_campus': get_avg_day_length(day_to_usage),
'num_friends': get_num_friends(timetable),
'avg_rating': get_avg_rating(timetable)
}
[docs]def get_day_to_usage(custom_events, school):
"""Initialize day_to_usage dictionary, which has custom events blocked out."""
day_to_usage = {
day: [set() for _ in range(14 * 60 / SCHOOLS_MAP[school].granularity)]
for day in ['M', 'T', 'W', 'R', 'F']
}
for event in custom_events:
for slot in find_slots_to_fill(event['time_start'], event['time_end'], school):
day_to_usage[event['day']][slot].add('custom_slot')
return day_to_usage
[docs]def get_current_semesters(school):
"""List of semesters ordered by academic temporality.
For a given school, get the possible semesters ordered by the most recent
year for each semester that has course data, and return a list of
(semester name, year) pairs.
"""
semesters = []
for year, terms in reversed(SCHOOLS_MAP[school].active_semesters.items()):
for term in terms:
# Ensure DB has all semesters.
Semester.objects.update_or_create(name=term, year=year)
semesters.append({
'name': term,
'year': str(year)
})
return semesters
# def get_old_semesters(school):
# semesters = old_school_to_semesters[school]
# # Ensure DB has all semesters.
# for semester in semesters:
# Semester.objects.update_or_create(**semester)
# return semesters