#!/usr/bin/env python3 # Copyright 2023 Florian Fischer # This program 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. # # This program 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. # # You should have received a copy of the GNU General Public License along with # this program. If not, see . """simple script to generate a rough markdown map of a school year""" import argparse from datetime import datetime, timedelta from pathlib import Path import os import re import sys import urllib import typing as t from bs4 import BeautifulSoup import icalevents from icalevents.icalevents import events import requests BAYERN = 1 ICAL_BASE_URL = 'https://www.schulferien.eu/downloads/ical4.php' TEMPLATE_DIR = Path( os.environ['HOME'] ) / 'Documents/MyDocuments/Work/Schule/Unterricht/templates' TEMPLATE_FILE = 'map.tex' LPP_BASE_URL = 'https://www.lehrplanplus.bayern.de/fachlehrplan/gymnasium' WEEKLY_HOURS = { 'mathematik': { 5: 4, 6: 4, 7: 4, 8: 3, 9: 4, 10: 3, 11: 4 }, 'informatik': { 9: 2, 10: 2, 11: 2 }, 'nt_gym': { 6: 2, 7: 2 } } def die(msg: str, status=1): """Exit with status printing a msg to stderr""" print(msg, file=sys.stderr) sys.exit(status) def default_year() -> int: """return the default year""" now = datetime.now() return now.year if now.month < 8 else now.year + 1 def get_holidays(land: int, year: int) -> list[icalevents.icalparser.Event]: """Get list of all holidays in a school year""" start_year = datetime(year=year - 1, month=1, day=1) end_year = datetime(year=year + 1, month=12, day=31) params = {'land': land, 'year': year, 'type': 1} uri_first_year = ICAL_BASE_URL + '?' + urllib.parse.urlencode(params) first_holidays = events(url=uri_first_year, start=start_year, end=end_year) params['year'] = year + 1 uri_second_year = ICAL_BASE_URL + '?' + urllib.parse.urlencode(params) second_holidays = events(url=uri_second_year, start=start_year, end=end_year) first_summer = next( filter(lambda e: "Sommerferien" in e.summary, first_holidays)) second_summer = next( filter(lambda e: "Sommerferien" in e.summary, second_holidays)) return [ h for h in first_holidays + second_holidays if h.start >= first_summer.start and h.start <= second_summer.start ] def fmt_holiday(holiday: icalevents.icalparser.Event) -> str: """Return a formatted holiday description""" h_start = f'{holiday.start.date().strftime("%d.%m")}' h_end = f'{holiday.end.date().strftime("%d.%m")}' return f'{h_start}-{h_end} {holiday.summary}' def get_public_holidays(land: int, start: datetime, end: datetime) -> list[icalevents.icalparser.Event]: """Get list of all public holidays in the school year""" params = {'land': land, 'year': start.year, 'type': 0} uri_first_year = ICAL_BASE_URL + '?' + urllib.parse.urlencode(params) params['year'] = end.year uri_second_year = ICAL_BASE_URL + '?' + urllib.parse.urlencode(params) all_public_holidays = events(url=uri_first_year, start=start, end=end) + events( url=uri_second_year, start=start, end=end) return [ e for e in all_public_holidays if e.start >= start and e.start <= end ] class LehrplanSection: """Representation of a section of Lehrplanplus""" def __init__(self, num, summary, duration, subsections=None): self.num = num self.summary = summary self.duration = duration self.subsections = subsections or [] def __str__(self): return f'{self.num}: {self.summary} ({self.duration}h) {self.subsections}' def __repr__(self): return (f'LehrplanSection({self.num}, {self.summary}, ' f'{self.duration}h, {self.subsections})') Lehrplan = list[LehrplanSection] SUMMARY_RE = re.compile(r'(.*) \(ca\. ([1-9][0-9]*) Std\.\)') def parse_section_html(section, summary_class: str) -> t.Tuple[str, str, int]: """Parse the html for a section from the Lehrplanplus webpage""" num = list(section.find(**{ 'class': 'head-absatz-num' }).children)[2].strip() summary = section.find(**{'class': summary_class}).string.strip() summary = summary.replace('\xa0', ' ') match = SUMMARY_RE.match(summary) if not match is None: summary = match.group(1) duration = int(match.group(2)) else: die(f'summary: {summary} does not match our re') return num, summary, duration def get_lehrplan(subject: str, form: str, form_type: str) -> Lehrplan: """Retrieve the sections and expected duration from lehrplanplus""" lpp_url = '/'.join([LPP_BASE_URL, form, subject]) if form_type: lpp_url += f'/{form_type}' resp = requests.get(lpp_url, timeout=10) html = BeautifulSoup(resp.text) lehrplan: Lehrplan = [] top_level_sections = html.find_all(**{'class': 'headline_lvl1'}) for section in top_level_sections: num, summary, duration = parse_section_html(section, 'head-absatz-title-short') lehrplan.append(LehrplanSection(num, summary, duration)) second_level_sections = html.find_all(**{'class': 'headline_lvl2'}) for section in second_level_sections: num, summary, duration = parse_section_html(section, 'head-absatz-title-long') lehrplan[int(num.split('.')[0]) - 1].subsections.append( LehrplanSection(num, summary, duration)) return lehrplan def gen_section_iter(lehrplan, inter_section_buffer_duration: int): """Create an iterator returning the current section""" end_section_buffer = LehrplanSection('', 'Puffer', 0) inter_section_buffer = LehrplanSection('', 'Puffer', inter_section_buffer_duration) _sections = [] for s in lehrplan: if not s.subsections: _sections.append(s) else: _sections.extend(s.subsections) # Add buffer sections to the lehrplan sections = [] for s in _sections: sections.append(s) if inter_section_buffer_duration: sections.append(inter_section_buffer) sections.append(end_section_buffer) next_section_in = sections[0].duration cur = 0 def _section_iter(weekly_hours, total_hours) -> list[LehrplanSection]: nonlocal cur if cur == len(sections) - 1: return sections[-1] nonlocal next_section_in # advance section if total_hours > next_section_in + (weekly_hours - next_section_in % weekly_hours): cur += 1 next_section_in += sections[cur].duration return sections[cur] return _section_iter def gen_latex_table( weekly_hours: int, lehrplan: Lehrplan, school_holidays: list[icalevents.icalparser.Event], public_holidays: list[icalevents.icalparser.Event], # # pylint: disable=unused-argument inter_section_buffer: int) -> list[str]: """generate a latex table matching lehrplan section on calendar weeks""" rows: list[str] = [] lehrplan.append(LehrplanSection('', 'Puffer', 0)) start = school_holidays[0].end end = school_holidays[-1].start holiday_iter = iter(school_holidays[1:]) next_holiday = next(holiday_iter) section_iter = gen_section_iter(lehrplan, inter_section_buffer) total_hours = 0 week_iter = start + timedelta(days=1) while week_iter < end: if week_iter > next_holiday.start: rows.append( rf'\Holiday{{{week_iter.isocalendar().week}}}{{{fmt_holiday(next_holiday)}}} \\' ) week_iter = next_holiday.end + timedelta(days=1) next_holiday = next(holiday_iter) continue lp_section = section_iter(weekly_hours, total_hours) rows.append( f'{week_iter.isocalendar().week} & {lp_section.num} & {lp_section.summary} \\\\' ) week_iter += timedelta(days=7) total_hours += weekly_hours return rows def print_map(rows: list[str], template_path: Path, outfile): """Print the template including the generated table""" with open(template_path, 'r', encoding='utf-8') as template_file: template = template_file.readlines() template_prefix = template[:-8] template_suffix = template[-2:] print(''.join(template_prefix), file=outfile, end='') print('\n'.join(rows), file=outfile) print(''.join(template_suffix), file=outfile) def main(): """Create a rough map template for a certain school year""" parser = argparse.ArgumentParser() parser.add_argument('subject') parser.add_argument('form', type=int) parser.add_argument( '--form-type', help='type of the form used by LPP', ) parser.add_argument('--template-dir') parser.add_argument('--year', default=default_year()) parser.add_argument('--land', default=BAYERN) parser.add_argument('-o', '--output', default=sys.stdout) parser.add_argument('-b', '--inter-section-buffer', type=int) args = parser.parse_args() school_holidays = get_holidays(args.land, args.year) public_holidays = get_public_holidays(args.land, school_holidays[0].end, school_holidays[-1].start) weekly_hours = WEEKLY_HOURS[args.subject][args.form] lehrplan = get_lehrplan(args.subject, str(args.form), args.form_type) if not lehrplan: print('WARNING: empty lehrplan', file=sys.stderr) rows = gen_latex_table(weekly_hours, lehrplan, school_holidays, public_holidays, args.inter_section_buffer) if args.template_dir: template_path = Path(args.template_dir) / TEMPLATE_FILE else: template_path = TEMPLATE_DIR / TEMPLATE_FILE if args.output != sys.stdout: with open(args.output, 'w', encoding='utf-8') as outfile: print_map(rows, template_path, outfile) else: print_map(rows, template_path, args.output) if __name__ == '__main__': main()