#!/usr/bin/env python3 """Print your future lessons in a specific class""" import argparse from dataclasses import dataclass import datetime import fnmatch from getpass import getpass import pathlib import os import sys import typing as t import icalevents from icalevents.icalevents import events import toml import webuntis def die(msg: str, status=1) -> t.NoReturn: """print a message and exit""" print(msg, file=sys.stderr) sys.exit(status) @dataclass class Period: """Data representing a period where a lesson could take place""" start: datetime.datetime code = None @dataclass class WebuntisSessionCredentials: """Data required to create a new webuntis session""" school: str server: str username: str password: str def __init__(self, config: dict): self.school = config.get('school') or input('Webuntis School: ') self.server = config.get('server') or input('Webuntis Server: ') self.username = config.get('user') or input('Webuntis User: ') self.password = config.get('password') or getpass( prompt='Webuntis Password: ') def find_config() -> t.Optional[pathlib.Path]: """Search for a config file""" path = pathlib.Path('config') if path.is_file(): return path config_dir = pathlib.Path( os.environ.get('XDG_CONFIG_HOME', os.environ['HOME'] + '/.config')) path = config_dir / 'avail-lessons' / 'config' if path.is_file(): return path return None def load_config(path="") -> dict: """Load a toml config file""" path = path or find_config() if not path: return {} with open(path, 'r', encoding='utf-8') as config_file: config = toml.load(config_file) return config def load_events(config: dict) -> list[icalevents.icalparser.Event]: """Retrieve all events from the specified ics calendars""" evs = [] for uri in config['icalendars']: if uri.startswith("http"): evs += events(url=uri, start=config['start'], end=config['end']) elif pathlib.Path(uri).is_file(): evs += events(file=uri, start=config['start'], end=config['end']) return evs def filter_special_events( evs: list[icalevents.icalparser.Event], special_events_definitions: dict[str, dict[str, str]] ) -> list[tuple[icalevents.icalparser.Event, str]]: """Filter all events for special events and return a sorted list""" special_events = [] for special_event in special_events_definitions: pattern, fmt = special_events_definitions[special_event].values() for event in evs: if not fnmatch.fnmatch(event.summary, pattern): continue special_events.append((event, eval('f' + f'"{fmt}"'))) # pylint: disable=eval-used # Sort the special events by their start datetime special_events.sort(key=lambda t: t[0].start) return special_events def format_period(period: webuntis.objects.PeriodObject, evs: list[icalevents.icalparser.Event], config: dict) -> str: """Format a given period""" # Provide some useful shortcuts for the output format date = period.start.date() week = date.isocalendar().week # pylint: disable=unused-variable period_events = "" period_evs = [ ev for ev in evs if ev.start.timestamp() <= period.start.timestamp() <= ev.end.timestamp() # Detect early end of classes or (ev.summary == 'Unterrichtsschluss' and ev.start.date() == date and ev.start.timestamp() <= period.start.timestamp()) ] if period_evs: for event in period_evs: period_events += f' {event.summary} |' # strip the leading ' ' and trailing ' |' period_events = period_events[1:-2] fmt = config.get( 'fmt') or '{week} {date.strftime(\'%d.%m\')} {period_events}' # Using eval here instead of string.format() is potentially dangerous. # However, I do not consider this an actual thread because this is an # obscure script run with only trusted inputs (cli arguments, manual written # config), therefore potential code execution is not a real concern. line = eval('f' + f'"{fmt}"') # pylint: disable=eval-used # Apply event_line_formats event_line_formats = config.get('event_line_formats', []) for event in period_evs: if event.summary in event_line_formats: # Same eval reasoning as above. line = eval('f' + f'"{event_line_formats[event.summary]}"') # pylint: disable=eval-used return line def filter_periods(timetable: webuntis.objects.PeriodList, config: dict) -> list[webuntis.objects.PeriodObject]: """Filter the timetable for our periods""" return [ p for p in timetable if p.subjects.filter( # type: ignore # pylint: disable=no-member name=config['subject']) and p.teachers.filter( # pylint: disable=no-member name=config['teacher']) ] DAYS_MAP = {'Mo': 1, 'Di': 2, 'Mi': 3, 'Do': 4, 'Fr': 5} TIME_SLOTS = { '1': datetime.timedelta(hours=8), '2': datetime.timedelta(hours=8, minutes=45), '3': datetime.timedelta(hours=9, minutes=45), '4': datetime.timedelta(hours=10, minutes=30), '5': datetime.timedelta(hours=11, minutes=30), '6': datetime.timedelta(hours=12, minutes=15), '7': datetime.timedelta(hours=13), '8': datetime.timedelta(hours=13, minutes=45), '9': datetime.timedelta(hours=14, minutes=30), '10': datetime.timedelta(hours=15, minutes=20), '11': datetime.timedelta(hours=16), '12': datetime.timedelta(hours=16, minutes=45), } def build_periods(config: str, start: datetime.datetime, end: datetime.datetime) -> list[Period]: """Build a list of periods from a configuration string""" periods = [] week = start.isocalendar().week while True: year = start.year if week >= start.isocalendar().week else end.year for period in config.split(';'): weekday, slot = period.split(':') period_day = datetime.datetime.fromisocalendar( year=year, week=week, day=DAYS_MAP[weekday]) period_start = period_day + TIME_SLOTS[slot] periods.append(Period(period_start)) week = week % 52 + 1 if week > end.isocalendar().week and year == end.isocalendar().year: break return periods def main(): """Programm entry point loading data from webuntis and printing the remaining lessons""" parser = argparse.ArgumentParser() parser.add_argument('--school', help='The shool identifier used by webuntis') parser.add_argument('--server', help='The webuntis server') parser.add_argument('--user', help='The webuntis login username') parser.add_argument('--password', help='The webuntis login password') parser.add_argument('--config', help='Path to a configuration file') parser.add_argument('--teacher', help='The teacher abbreviation used in webuntis') parser.add_argument('--subject', help='The subject abbreviation used in webuntis') parser.add_argument('--class', help='The class name') parser.add_argument('--periods', help=('The periods when you teach the class.' 'Format: :[;]*'), type=str) parser.add_argument('--show-events', action='store_true', help='Show ICS calendar events during the lessons') args = parser.parse_args() config = {'start': datetime.datetime.now()} config.update(load_config(args.config)) # command line arguments override the values in the config file config.update({k: v for k, v in args.__dict__.items() if v}) if 'class' not in config or 'subject' not in config: die('Class and subject must be provided') periods = None if 'webuntis' in config: # use the webuntis username as teacher fallback if 'teacher' not in config: config['teacher'] = config['webuntis']['username'] session_info = WebuntisSessionCredentials(config['webuntis']) with webuntis.Session(useragent="muhq's avail-lessons script", **session_info.__dict__).login() as session: # Unfortunately pylint does not understand the type (_itemclass) # of the webuntis ListResults, therfore we supress the errors when # accessing members of the _itemclass. year = [ y for y in session.schoolyears() if y.start <= config['start'] <= y.end # pylint: disable=no-member ] if not year: die('No school year is active') year = year[0] if 'end' not in config: config['end'] = year.end klasse = session.klassen().filter(name=config['class'])[0] # pylint: disable=no-member timetable = session.timetable_extended(klasse=klasse, start=config['start'], end=config['end']) periods = filter_periods(timetable, config) elif args.periods: start = config.setdefault( 'start', datetime.datetime.fromisocalendar(2023, 37, 2)) end = config.setdefault('end', datetime.datetime.fromisocalendar(2024, 30, 5)) periods = build_periods(args.periods, start, end) if not periods: die('Webuntis credentials or manual periods must be provided') # load ICS events evs = load_events(config) # filter special events special_events = filter_special_events(evs, config.get('special_events', {})) lines = [] for period in periods: # Insert formatted prior special event if special_events and special_events[0][0].start.timestamp( ) <= period.start.timestamp(): lines.append(special_events[0][1]) special_events = special_events[1:] if period.code: print(f'WARNING: period {period.start} is {period.code}', file=sys.stderr) lines.append(format_period(period, evs, config)) if 'output_header' in config: print(config['output_header']) for line in lines: print(line) if 'output_footer' in config: print(config['output_footer']) if __name__ == '__main__': main()