#!/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 @dataclass class SessionCredentials: """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']) ] 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('--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: print('Class and subject must be provided') sys.exit(1) # use the webuntis username as teacher fallback if 'teacher' not in config: config['teacher'] = config['username'] session_info = SessionCredentials(config) 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 ][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) # 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()