""" Generates an HTML schedule for CSSE 120 from the list of learning objectives and relevant information about the term dates. Requires (via pip): gfm bs4 htmllib Author: David Mutchler and his colleagues. """ import datetime import string import re import abc import gfm import bs4 # BeautifulSoup import enum COMMENT_BEGIN_INDICATOR = "!!! BEGIN COMMENT" COMMEND_END_INDICATOR = "!!! END COMMENT" COMMENT_INDICATOR = "!!!" SESSION_INDICATOR = r'^.*Session[ ]*[0-9]*:' EXAM_INDICATOR = r'Exam ' EVENING_EXAM_TEMPLATE = string.Template(""" <div class=evening_exam> <p> The regular in-class session on $REGULAR_DAY is an OPTIONAL review session. The test itself is <span class=exam_date_time> $EXAM_DAY evening from 7:30 p.m. to 9:30 p.m. (with an additional 30-minute grace period) </span> <span class=exam_rooms> in rooms TBA.</span> </p> </div> """) SHOW_TOPICS = False @enum.unique class SessionType(enum.Enum): REGULAR = 1 NO_CLASS = 2 EVENING_EXAM = 3 IN_CLASS_EXAM = 4 PROJECT = 5 OTHER = 6 # The following really are defined, but PyDev does not think so, apparently. RE_MULTILINE = re.MULTILINE # @UndefinedVariable RE_DOTALL = re.DOTALL # @UndefinedVariable class TermInfo(object): """ Everything the ScheduleMaker needs to know about the term, except for the learning objectives. """ def __init__(self, term): """ term is a string that identifies the term, e.g. 201720 """ # FIXME: Some of this does not belong in TERM information. # TODO: Get the following from a file(s) instead of like this: self.term = term self.start_date = datetime.date(2017, 8, 28) self.end_date = datetime.date(2017, 11, 9) self.days_of_week = [1, 3, 4] # isoweekday: Monday is 1, Sunday is 7 self.weekdays_of_week = ['Monday', 'Wednesday', 'Thursday'] self.dates_to_skip = [ScheduleDate(2017, 8, 28, "No class"), ScheduleDate(2017, 8, 30, "No class"), ScheduleDate(2017, 10, 12, "Fall break") ] exam1_msg = EVENING_EXAM_TEMPLATE.substitute(REGULAR_DAY='Thursday', EXAM_DAY='Thursday') exam2_msg = exam1_msg self.evening_exams = [ScheduleDate(2017, 9, 14, exam1_msg), ScheduleDate(2017, 10, 5, exam2_msg)] self.start_session_number = 1 csse120_folder = "/Users/david/classes/120/" term_folder = csse120_folder + 'Public/' + self.term + '/' le_folder = term_folder + 'LearningObjectives/' le_file = 'LearningObjectives.txt' self.learning_objectives_filename = le_folder + le_file st_folder = term_folder st_file = 'index.html' # EVENTUALLY RETURN to: schedule_table.html self.schedule_table_filename = st_folder + st_file self.number_of_sessions = 30 class ScheduleDate(object): def __init__(self, year, day, month, message=None): self.datetime_date = datetime.date(year, day, month) self.message = message def __repr__(self): return '{} {}'.format(self.datetime_date, self.message) def __eq__(self, date): # Date may be a ScheduleDate or it may be a datetime.date. try: return (self.datetime_date == date.datetime_date and self.message == date.message) except AttributeError: return self.datetime_date == date class ScheduleMaker(object): """ Generates an HTML schedule for CSSE 120 from the list of learning objectives and relevant information about the term dates. """ def __init__(self, term_info): """ Given a file of Learning Objectives and information about the current term, makes an HTML file for the Schedule Page. :type term_info: TermInfo """ self.term_info = term_info self.raw_data = None self.topics_by_session = None def make_schedule(self): # Get the data from the Learning Objectives file: self.get_raw_data() # Parse the Learning Objectives file to get a list of ClassSession # objects, one per session, with each ClassSession object having # a title and GFM topics: self.split_into_sessions() # Go through the list of sessions, adding dates, session numbers, # and (where needed) NoClassSession objects: self.add_dates_numbers_and_NoClassSessions() # Make the HTML for each session: for session in self.sessions: session.make_html() # Make and prettify the entire table of sessions. self.make_html_table() # Prettify here?? Or just per session??? self.write_html(self.html) def get_raw_data(self): with open(self.term_info.learning_objectives_filename, 'r') as file: self.raw_data = file.read() def split_into_sessions(self): """ Parses the raw data to make a list of ClassSession objects. The object will NOT yet have a correct Session Number or Date. """ # FIXME: The above comment is no longer completely accurate. # Strip comments from the raw data: data = self.strip_comments() # Split the data by sessions: self.topics_by_session = re.split(SESSION_INDICATOR, data, flags=RE_MULTILINE) self.topics_by_session = self.topics_by_session[1:] # Ignore 1st item # Confirm that the number of topics matches the number of sessions. try: assert(len(self.topics_by_session) == self.term_info.number_of_sessions) except AssertionError: raise AssertionError("Number of topics != number of sessions") # Make the ClassSession objects. No dates or session numbers yet. self.sessions = [] for k in range(len(self.topics_by_session)): lines = self.topics_by_session[k].split('\n') # Title is first line (which was on the same line as SessionXX. title = lines[0] # Rest are the topics for that session, in GFM. topics = '\n'.join(lines[1:]) self.topics_by_session[k] = topics session = ClassSession(title=title, topics=topics) self.sessions.append(session) def strip_comments(self): # Parse by lines. lines = self.raw_data.split('\n') # Remove lines between a COMMENT_BEGIN_INDICATOR # and the next COMMENT_END_INDICATOR, then any with COMMENT_INDICATOR. i_am_inside_a_comment = False lines_to_keep = [] for line in lines: if i_am_inside_a_comment: if COMMEND_END_INDICATOR in line: i_am_inside_a_comment = False else: if COMMENT_BEGIN_INDICATOR in line: i_am_inside_a_comment = True elif COMMENT_INDICATOR not in line: lines_to_keep.append(line) return '\n'.join(lines_to_keep) def add_dates_numbers_and_NoClassSessions(self): date = self.term_info.start_date session_number = self.term_info.start_session_number session_index = 0 while True: # Go through each date from the start of term to end of term. # Done when we get to the last date of the term. if date > self.term_info.end_date: break # Deal only with dates that are on class days-of-week. if date.isoweekday() in self.term_info.days_of_week: if date in self.term_info.dates_to_skip: # Make a NoClassSession index = self.term_info.dates_to_skip.index(date) schedule_date = self.term_info.dates_to_skip[index] session = NoClassSession(schedule_date) self.sessions.insert(session_index, session) else: # Add date and session number to this session. session = self.sessions[session_index] session.datetime_date = date session.session_number = session_number session_number = session_number + 1 if date in self.term_info.evening_exams: evening_exams = self.term_info.evening_exams session.session_type = SessionType.EVENING_EXAM index = evening_exams.index(date) session.message = evening_exams[index].message session_index = session_index + 1 date = date + datetime.timedelta(1) def make_html_table(self): """ Sets self.html to the HTML for the schedule page, based on the (previously computed) HTML for the self.sessions """ days_in_week = len(self.term_info.days_of_week) sessions_html = '' for k in range(len(self.sessions)): if k % days_in_week == 0: sessions_html += ScheduleTable.ROW_START sessions_html += (ScheduleTable.ITEM_START + self.sessions[k].html + ScheduleTable.ITEM_END) if k % days_in_week == days_in_week - 1: sessions_html += ScheduleTable.ROW_END self.html = (ScheduleHeader(self.term_info.weekdays_of_week).html + sessions_html + ScheduleTrailer().html) def write_html(self, html): with open(self.term_info.schedule_table_filename, 'w') as file: file.write(html) # def add_topics_and_html_to_sessions(self, topics_by_session, sessions): # k = 0 # for session in sessions: # if isinstance(session, NoClassSession): # continue # session.topics = topics_by_session[k] # session.topics_html = self.make_html_from_topics(session.topics) # k = k + 1 def make_html_from_topics(self, topics_for_session): # Make the session title become a top-level list item, # and increase the indentation of all subsequent lines. topics_for_session = topics_for_session.replace('\n', '\n ') topics_for_session = '+ ' + topics_for_session # print(topics_for_session) # Convert each topic to HTML using Github Flavored Markdown. html = gfm.markdown(topics_for_session) # print(html) # Add class info to the HTML for topics, tests, and sprints. # The following is brittle. ul_class = 'topics collapsibleList' li_class = 'topic' if re.match(r'.*Test [0-9]\..*', html, RE_DOTALL): ul_class += ' exam' li_class += ' exam' elif re.match(r'.*Sprint [0-9].*', html, RE_DOTALL): ul_class += ' sprint' li_class += ' sprint' # CONSIDER: the following adds the class to the FIRST <ul> # but to ALL <li>'s. Is that what we want for sub-lists? html = html.replace('<ul>', '<ul class="' + ul_class + '">', 1) html = html.replace('<li>', '<li class="' + li_class + '">') # Add details for Tests. for_tests = ExamTopic.EVENING_EXAM_TEMPLATE.substitute() html = re.sub(r'(Test [0-9]\.)', r'\1' + for_tests, html) # Parenthetical expressions are additional markup: DOUBLE_OPEN_PARENTHESES = '!!!START_CANNOT_OCCUR_I_HOPE!!!' DOUBLE_CLOSE_PARENTHESES = '!!!END_CANNOT_OCCUR_I_HOPE!!!' html = html.replace('((', DOUBLE_OPEN_PARENTHESES) html = html.replace('))', DOUBLE_CLOSE_PARENTHESES) html = html.replace('(', '<span class=parenthetical>(') html = html.replace(')', ')</span>') html = html.replace('</span>.', '.</span>') html = html.replace(DOUBLE_OPEN_PARENTHESES, '(') html = html.replace(DOUBLE_CLOSE_PARENTHESES, ')') pretty_html = bs4.BeautifulSoup(html, "html.parser").prettify() lines = pretty_html.split('\n') for k in range(len(lines)): lines[k] = re.sub(r'^( *)', r'\1\1', lines[k]) final_html = '\n'.join(lines) # TODO: Deal with punctuation after a tag end. return final_html def prettify(markup): soup = bs4.BeautifulSoup(markup, "html5lib") return soup.prettify() class Session(abc.ABC): """ Data associated with a single Session. """ def __init__(self, datetime_date=None, title=None, session_number=None, topics=None, session_type=None, message=None): self.datetime_date = datetime_date self.title = title self.session_number = session_number self.topics = topics self.session_type = session_type self.message = None @abc.abstractmethod def make_html(self): """ Returns the HTML for this Session. """ # Subclasses must implement this method. def __repr__(self): return '{} {}'.format(self.datetime_date, self.session_number) def date_as_string(self): return (self.datetime_date.strftime('%B') + ' ' + str(self.datetime_date.day)) class NoClassSession(Session): NO_CLASS_TEMPLATE = string.Template(""" <div class="no_class_title">$SessionTitle</div> <div class="session_date">$SessionDate</div>""") def __init__(self, schedule_date): # TODO maybe should set the session type here too. super().__init__(schedule_date.datetime_date, schedule_date.message, session_type=SessionType.NO_CLASS) def make_html(self): self.html = NoClassSession.NO_CLASS_TEMPLATE.substitute( SessionTitle=self.title, SessionDate=self.date_as_string()) class ClassSession(Session): SESSION_TEMPLATE = string.Template(""" <div class=session_identifier> <span class="session_preparation">$SessionPreparationLink</span> <span class="for_session">for session</span> <span class="session_number">$SessionNumber</span> <span class="session_date">($SessionDate)</span> </div> <div class="session_title"> $SessionTitle </div> """) SESSION_TOPICS_TEMPLATE = string.Template(""" <div class=session_topics>$SessionTopics </div>""") TOPICS_INDENT = " " LINK_TO_PREP = ('<a href="Sessions/Session{:02}/index.html">' + 'Preparation</a>') def __init__(self, title, topics): session_type = self.find_session_type(topics) super().__init__(title=title, topics=topics, session_type=session_type) def make_html(self): self.preparation_link = ClassSession.LINK_TO_PREP.format( self.session_number) title = gfm.markdown(self.title.replace('/', '<br>\n')) print('Title:' + title) self.html = ClassSession.SESSION_TEMPLATE.substitute( SessionPreparationLink=self.preparation_link, SessionNumber=self.session_number, SessionDate=self.date_as_string(), SessionTitle=title) if self.session_type == SessionType.EVENING_EXAM: self.add_exam_info() if SHOW_TOPICS and self.topics.strip(): self.make_topics_html() self.html += ClassSession.SESSION_TOPICS_TEMPLATE.substitute( SessionTopics=self.topics_html) def add_exam_info(self): self.html += self.message def make_topics_html(self): """ Generate the HTML for the topics for this Session from the topics as a string written in Git-Flavored Markdown, along with the previously-determined type of this Session: -- Exam, Sprint, or Regular """ # Start with the topics: self.topics_html = gfm.markdown(self.topics) # topics_for_GFM = self.topics # Add text (in HTML) for special sessions, e.g. Tests. # for_tests = ExamTopic.EVENING_EXAM_TEMPLATE.substitute( # RegularClassDay=self.date.strftime('%A'), # ExamDay=(self.date + datetime.timedelta(1)).strftime('%A')) # # topics_for_GFM = re.sub(r'(Test [0-9]\.)', r'\1' + for_tests, # topics_for_GFM) # Prepare for GFM -> HTML: # -- Isolate the Session Title. # -- Add markup for parenthetical expressions # -- TODO: Anything else here? # Then generate the HTML from GFM. # topics_for_GFM = topics_for_GFM.replace('\n', '\n\n', 1) # topics_for_GFM = re.sub(r'([^(]\([^(])', # '<span class=parenthetical>' + r'\1', # topics_for_GFM) # topics_for_GFM = re.sub(r'([^)]\)[^)])', # r'\1' + '</span>', # topics_for_GFM) # topics_for_GFM = topics_for_GFM.replace( # '((', '(').replace('))', ')') # DOUBLE_OPEN_PARENTHESES = '!!!START_CANNOT_OCCUR_I_HOPE!!!' # DOUBLE_CLOSE_PARENTHESES = '!!!END_CANNOT_OCCUR_I_HOPE!!!' # html = html.replace('((', DOUBLE_OPEN_PARENTHESES) # html = html.replace('))', DOUBLE_CLOSE_PARENTHESES) # html = html.replace('(', '<span class=parenthetical>(') # html = html.replace(')', ')</span>') # html = html.replace('</span>.', '.</span>') # html = html.replace(DOUBLE_OPEN_PARENTHESES, '(') # html = html.replace(DOUBLE_CLOSE_PARENTHESES, ')') # Format the html, then return it. # pretty_html = bs4.BeautifulSoup(html, "html.parser").prettify() # # lines = pretty_html.split('\n') # for k in range(len(lines)): # lines[k] = re.sub(r'^( *)', r'\1\1', lines[k]) # # final_html = '\n'.join(lines) # TODO: Deal with punctuation after a tag end. # Add class info to the HTML for topics, tests, and sprints. # The following is brittle. # ul_class = 'topics collapsibleList' # li_class = 'topic' # if re.match(r'.*Test [0-9]\..*', html, re.DOTALL): # ul_class += ' exam' # li_class += ' exam' # elif re.match(r'.*Sprint [0-9].*', html, re.DOTALL): # ul_class += ' sprint' # li_class += ' sprint' # CONSIDER: the following adds the class to the FIRST <ul> # but to ALL <li>'s. Is that what we want for sub-lists? # html = html.replace('<ul>', # '<ul class="' + ul_class + '">', # 1) # html = html.replace('<li>', # '<li class="' + li_class + '">') # Add details for Tests. def find_session_type(self, topics): pass class ScheduleHeader(object): """ Data associated with the Schedule as a whole """ HEADER_TEMPLATE = string.Template("""\ <!DOCTYPE HTML> <html> <head> <meta charset="UTF-8"> <link rel="stylesheet" type="text/css" \ href="http://fonts.googleapis.com/css?family=Open+Sans"> <link rel="stylesheet" type="text/css" href="styles/style.css"> <link rel="stylesheet" type="text/css" href="styles/navigation_bar.css"> <link rel="stylesheet" type="text/css" href="styles/home_page.css"> <link rel="stylesheet" type="text/css" href="styles/schedule_page.css"> <title> CSSE 120 Home Page </title> </head> <body> <nav> <img src="../Images/girls_who_code2_90x60.png" alt="Girls coding together"/> <div> <p> <span class="course_number"> CSSE 120</span> <br> <span class="course_title">Introduction to Software Development</span> <br> <span class="course_term">Fall term, 2017-18 (aka 201810)</span> </p> </div> <a href="Syllabus_CourseInformation/syllabus.html">SYLLABUS</a> <a href="Resources/CSSE120_Setup">SETUP</a> <a href="../Resources/Piazza">PIAZZA (Q&A)</a> <a href="../Resources/Python">PYTHON</a> <a href="../Resources/Graphics">GRAPHICS</a> <a href="../Resources/Robotics">ROBOTICS</a> </nav> <section class="course_schedule"> <table> <caption>What to do, When</caption> <thead> <tr>${THs_FOR_CLASSDAYS} </tr> </thead> <tbody>""") GOOGLE_FONT = "http://fonts.googleapis.com/css?family=Open+Sans" TH_FOR_DAY_OF_WEEK_TEMPLATE = string.Template(""" <th class=schedule_caption> $DAY </th>""") def __init__(self, weekdays_of_week): TH_template = ScheduleHeader.TH_FOR_DAY_OF_WEEK_TEMPLATE THs = '' for weekday in weekdays_of_week: THs += TH_template.substitute(DAY=weekday) t = ScheduleHeader.HEADER_TEMPLATE self.html = t.substitute(GOOGLE_FONT=ScheduleHeader.GOOGLE_FONT, THs_FOR_CLASSDAYS=THs) class ScheduleTable(object): ROW_START = """ <tr>""" ROW_END = """ </tr>""" ITEM_START = """ <td>""" ITEM_END = """ </td>""" class ScheduleTrailer(object): TRAILER = """ </table> </section> </body> </html>""" FINAL_EXAM = """ </tbody> <tfoot> <tr> <th colspan=3> PROJECT DEMOs and FINAL EXAM during exam week, times TBD. </th> </tr> </tfoot>""" def __init__(self): self.html = ScheduleTrailer.FINAL_EXAM + ScheduleTrailer.TRAILER class ExamTopic(object): EVENING_EXAM_TEMPLATE = string.Template(""" <ul class="topic evening_exam"> <li class="topic evening_exam"> The regular in-class session on $RegularClassDay is an OPTIONAL review session. </li> <li class="topic exam_day_time_rooms"> The test itself is <span class=exam_date_time> $ExamDay evening from 7:30 p.m. to 9:30 p.m. (with an additional 30-minute grace period) </span> <span class=exam_rooms> in rooms TBA. </span> <ul> <li> So NOT $RegularClassDay, NOT daytime. </li> </ul> </li> <li class="topic exam_requirement"> You MUST have completed this test's PRACTICE project <span class="parenthetical"> (including its paper-and-pencil exercise) </span> BEFORE you take this test. <ul> <li> It is your ADMISSION TICKET to the test. </li> <li> Talk to your instructor if that poses a problem for you. </li> </ul> </li> </ul> """) def main(): """ Make a schedule for the indicated term. """ # html = gfm.markdown(xx) maker = ScheduleMaker(TermInfo('201810')) html = maker.make_schedule() # print(html) # ---------------------------------------------------------------------- # If this module is running at the top level (as opposed to being # imported by another module), then call the 'main' function. # ---------------------------------------------------------------------- if __name__ == '__main__': main()