#!/usr/bin/env python3 """ WebUntis Absence Viewer ----------------------- Fetches your absences from WebUntis, displays them in an interactive prompt_toolkit TUI (newest first, filterable), and can fill & export the school's Entschuldigung PDF template. Requirements: pip install requests prompt_toolkit pypdf Usage: python webuntis_absences.py python webuntis_absences.py --server mese.webuntis.com --school MySchool python webuntis_absences.py --template /path/to/Entschuldigung_template.pdf """ import argparse import getpass import json import os import sys from datetime import date, datetime, timedelta from pathlib import Path import requests from prompt_toolkit import Application from prompt_toolkit.formatted_text import HTML from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.layout import HSplit, Layout from prompt_toolkit.layout.containers import Window from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.styles import Style from prompt_toolkit.widgets import Frame, TextArea # ─── WebUntis API ───────────────────────────────────────────────────────────── class WebUntisClient: """Minimal WebUntis client using JSON-RPC + REST endpoints.""" def __init__(self, server: str, school: str): self.base_url = f"https://{server}/WebUntis" self.school = school self.session = requests.Session() self.session.headers.update({ "Content-Type": "application/json", "User-Agent": "WebUntisAbsenceViewer/1.0", "X-Requested-With": "XMLHttpRequest", }) self._rpc_id = 0 self.person_id: int | None = None self.person_type: int | None = None self.display_name: str = "" # real name from getUserData2017 def _rpc(self, method: str, params: dict | None = None) -> dict: self._rpc_id += 1 payload = { "jsonrpc": "2.0", "method": method, "params": params or {}, "id": str(self._rpc_id), } resp = self.session.post( f"{self.base_url}/jsonrpc.do?school={self.school}", json=payload, timeout=15, ) resp.raise_for_status() data = resp.json() if "error" in data: raise RuntimeError(f"WebUntis RPC error: {data['error']}") return data.get("result", {}) def login(self, username: str, password: str) -> None: result = self._rpc("authenticate", { "user": username, "password": password, "client": "WebUntisAbsenceViewer", }) self.person_id = result.get("personId") self.person_type = result.get("personType") if not self.person_id: raise RuntimeError("Login succeeded but no personId returned.") # Fetch the real display name (login name ≠ student name) try: ud = self._rpc("getUserData2017") self.display_name = ( ud.get("userData", {}).get("displayName") or ud.get("displayName") or "" ) except Exception: pass # non-critical def logout(self) -> None: try: self._rpc("logout") except Exception: pass def get_absences(self, start: date, end: date) -> list[dict]: """REST endpoint first, JSON-RPC fallback.""" url = ( f"{self.base_url}/api/classreg/absences/students" f"?startDate={start.strftime('%Y%m%d')}" f"&endDate={end.strftime('%Y%m%d')}" f"&studentId={self.person_id}" f"&excuseStatusId=-1" ) resp = self.session.get(url, timeout=15) if resp.status_code == 200: data = resp.json() absences = ( data.get("data", {}).get("absences") or data.get("absences") or data.get("data") or [] ) if isinstance(absences, list): return absences # Fallback result = self._rpc("getTimetableWithAbsences", { "options": { "startDate": int(start.strftime("%Y%m%d")), "endDate": int(end.strftime("%Y%m%d")), "element": { "id": self.person_id, "type": self.person_type or 5, }, } }) absences = [] for period in result if isinstance(result, list) else result.get("periods", []): for ab in period.get("absences", []): ab["date"] = period.get("date", "") ab["startTime"] = period.get("startTime", "") ab["endTime"] = period.get("endTime", "") absences.append(ab) return absences def get_student_class(self) -> str: """Try to get the student's current class/Klasse.""" try: ud = self._rpc("getUserData2017") # look for class in userData klassen = ( ud.get("userData", {}).get("klassenName") or ud.get("userData", {}).get("klasse") or "" ) return klassen except Exception: return "" # ─── Sorting & filtering ─────────────────────────────────────────────────────── def _absence_sort_key(ab: dict): """Return a comparable key for sorting (newest first).""" date_val = ab.get("date") or ab.get("startDate") or 0 time_val = ab.get("startTime") or ab.get("lessonStartTime") or 0 try: return (int(date_val), int(time_val)) except (ValueError, TypeError): return (0, 0) def _is_unexcused(ab: dict) -> bool: excused = ab.get("isExcused") or ab.get("excused") or ab.get("excuse") print(f"Debug: Absence {ab.get('id', '?')} excuse status: {excused}") return excused def filter_absences(absences: list[dict], unexcused_only: bool) -> list[dict]: if unexcused_only: absences = [a for a in absences if _is_unexcused(a)] return sorted(absences, key=_absence_sort_key, reverse=True) # ─── Formatting ──────────────────────────────────────────────────────────────── def _parse_dt(date_int, time_int) -> str: try: d = str(date_int) t = str(time_int).zfill(4) dt = datetime(int(d[:4]), int(d[4:6]), int(d[6:8]), int(t[:2]), int(t[2:])) return dt.strftime("%d.%m.%Y %H:%M") except Exception: return f"{date_int} {time_int}" def _excuse_label(ab: dict) -> str: excused = ab.get("isExcused") or ab.get("excused") or ab.get("excuse") if excused is True or excused == 1: return "✓ Excused" # if excused is False or excused == 0: else: return "✗ Unexcused" return "? Unknown" def format_absence(ab: dict, idx: int) -> str: date_val = ab.get("date") or ab.get("startDate", "") start = ab.get("startTime") or ab.get("lessonStartTime", "") end = ab.get("endTime") or ab.get("lessonEndTime", "") subject = ab.get("subject") or ab.get("subjectLong") or ab.get("subjectName") or "—" teacher = ab.get("teacher") or ab.get("teacherName") or "—" reason = ab.get("reason") or ab.get("text") or "" excuse = _excuse_label(ab) minutes = ab.get("absentTime") or ab.get("minutesAbsent") or "" if date_val and start: when = _parse_dt(date_val, start) when_end = _parse_dt(date_val, end) if end else "" time_str = when + (f" → {when_end.split(' ')[1]}" if when_end else "") else: time_str = str(date_val) or "unknown" lines = [ f" #{idx+1:<3} {time_str}", f" Subject : {subject}", f" Teacher : {teacher}", f" Status : {excuse}", ] if minutes: lines.append(f" Minutes : {minutes}") if reason: lines.append(f" Reason : {reason}") lines.append("") return "\n".join(lines) def build_display(absences: list[dict], title: str) -> str: if not absences: return " No absences found for the selected period / filter.\n" header = f" {title}\n {'─' * 60}\n\n" body = "".join(format_absence(ab, i) for i, ab in enumerate(absences)) footer = f"\n Total: {len(absences)} absence(s)\n" return header + body + footer # ─── PDF filling ─────────────────────────────────────────────────────────────── def fill_entschuldigung( template_path: str, output_dir: str, student_name: str, student_class: str, absence_dates: str, # e.g. "12.06.2025 – 13.06.2025" reason: str, total_hours: int, workshop_hours: int, sign_date: str, # e.g. "16.06.2025" ) -> str: """Fill the Entschuldigung PDF form and return the output path.""" try: from pypdf import PdfReader, PdfWriter except ImportError: raise RuntimeError("pypdf is required: pip install pypdf") reader = PdfReader(template_path) writer = PdfWriter() writer.append(reader) # Field mapping (confirmed from extract_form_field_info): # Textfeld 1 → Name des Schülers # Textfeld 2 → Klasse # Textfeld 4 → Versäumte(r) Unterrichtstag(e) / Datum # Textfeld 3 → Begründung für das Fernbleiben # Textfeld 5 → Zahl der versäumten Unterrichtsstunden # Textfeld 6 → davon versäumte Werkstättenstunden # Textfeld 7 → Datum (signature date, bottom left) fields = { "Textfeld 1": student_name, "Textfeld 2": student_class, "Textfeld 4": absence_dates, "Textfeld 3": reason, "Textfeld 5": str(total_hours), "Textfeld 6": str(workshop_hours), "Textfeld 7": sign_date, } writer.update_page_form_field_values(writer.pages[0], fields) # Derive date for filename from absence_dates (take first date portion) try: first_date = absence_dates.split("–")[0].strip().split(" ")[0] dt = datetime.strptime(first_date, "%d.%m.%Y") date_suffix = dt.strftime("%d%m%Y") except Exception: date_suffix = datetime.now().strftime("%d%m%Y") output_filename = f"Entschuldigung{date_suffix}.pdf" output_path = os.path.join(output_dir, output_filename) with open(output_path, "wb") as f: writer.write(f) return output_path # ─── PDF export dialog ───────────────────────────────────────────────────────── def run_pdf_export_dialog( absences: list[dict], template_path: str, student_name: str, student_class: str, ) -> None: """Simple terminal prompts to collect data and fill the PDF.""" print("\n─── Export Entschuldigung PDF ─────────────────────────────") # Pick absence(s) to excuse unexcused = [a for a in absences if _is_unexcused(a)] if not unexcused: print("No unexcused absences found.") return print("\nUnexcused absences:") for i, ab in enumerate(unexcused[:20]): date_val = ab.get("date") or ab.get("startDate", "") start = ab.get("startTime") or ab.get("lessonStartTime", "") print(f" [{i+1}] {_parse_dt(date_val, start)}") sel = input("\nEnter number(s) to excuse (comma-separated, or 'all'): ").strip() if sel.lower() == "all": selected = unexcused[:20] else: indices = [int(x.strip()) - 1 for x in sel.split(",") if x.strip().isdigit()] selected = [unexcused[i] for i in indices if 0 <= i < len(unexcused)] if not selected: print("No valid selection.") return # Build date string dates_sorted = sorted(selected, key=_absence_sort_key) date_strs = [] for ab in dates_sorted: dv = ab.get("date") or ab.get("startDate", "") try: d = str(dv) date_strs.append(f"{d[6:8]}.{d[4:6]}.{d[:4]}") except Exception: date_strs.append(str(dv)) unique_dates = list(dict.fromkeys(date_strs)) absence_dates = ", ".join(unique_dates) # Count hours (each absence period ≈ 1 lesson; use absentTime if available) total_minutes = sum( int(ab.get("absentTime") or ab.get("minutesAbsent") or 50) for ab in selected ) total_hours = max(1, round(total_minutes / 50)) # Prompt user for details print(f"\n Student name : {student_name or '(unknown)'}") name_input = input(f" Override name? [Enter to keep]: ").strip() final_name = name_input if name_input else student_name print(f" Class : {student_class or '(unknown)'}") class_input = input(f" Override class? [Enter to keep]: ").strip() final_class = class_input if class_input else student_class print(f" Absence date(s): {absence_dates}") date_override = input(f" Override date(s)? [Enter to keep]: ").strip() final_dates = date_override if date_override else absence_dates default_reason = (selected[0].get("reason") or selected[0].get("text") or "Krankheit") reason_input = input(f" Reason [{default_reason}]: ").strip() final_reason = reason_input if reason_input else default_reason print(f" Total lessons missed: {total_hours}") hours_input = input(f" Override? [Enter to keep]: ").strip() final_hours = int(hours_input) if hours_input.isdigit() else total_hours workshop_input = input(f" Workshop hours missed [0]: ").strip() final_workshop = int(workshop_input) if workshop_input.isdigit() else 0 sign_date = input( f" Signature date [{datetime.now().strftime('%d.%m.%Y')}]: " ).strip() or datetime.now().strftime("%d.%m.%Y") output_dir = input( f" Save to directory [{os.getcwd()}]: " ).strip() or os.getcwd() try: out = fill_entschuldigung( template_path=template_path, output_dir=output_dir, student_name=final_name, student_class=final_class, absence_dates=final_dates, reason=final_reason, total_hours=final_hours, workshop_hours=final_workshop, sign_date=sign_date, ) print(f"\n✅ Saved: {out}\n") except Exception as e: print(f"\n❌ Failed to fill PDF: {e}\n") # ─── TUI ────────────────────────────────────────────────────────────────────── STYLE = Style.from_dict({ "frame.border": "ansicyan", "frame.title": "bold ansicyan", "status": "reverse ansicyan", "text-area": "ansiwhite", }) def run_tui( all_absences: list[dict], server: str, school: str, start: date, end: date, template_path: str | None, student_name: str, student_class: str, ) -> None: state = { "filter": False, # True = show only unexcused } def _render() -> str: filtered = filter_absences(all_absences, state["filter"]) mode = "Unexcused only" if state["filter"] else "All absences" title = ( f"{school} | {start.strftime('%d.%m.%Y')} – {end.strftime('%d.%m.%Y')} | {mode}" ) return build_display(filtered, title) content = TextArea( text=_render(), read_only=True, scrollbar=True, style="class:text-area", wrap_lines=False, ) def _refresh(): content.text = _render() kb = KeyBindings() @kb.add("q") @kb.add("c-c") @kb.add("c-q") def _quit(event): event.app.exit() @kb.add("f") def _toggle_filter(event): state["filter"] = not state["filter"] _refresh() @kb.add("e") def _export(event): if template_path: event.app.exit(result="export") # if no template, key does nothing pdf_hint = " [E] Export PDF" if template_path else "" status_bar = Window( content=FormattedTextControl( HTML( " [Q] Quit" " [F] Toggle unexcused filter" f"{pdf_hint}" " [↑/↓] Scroll" f" Fetched: {datetime.now().strftime('%H:%M:%S')}" ) ), height=1, style="class:status", ) root = HSplit([ Frame( body=content, title=" 📅 WebUntis Absence Viewer ", style="class:frame", ), status_bar, ]) app = Application( layout=Layout(root), key_bindings=kb, style=STYLE, full_screen=True, mouse_support=True, ) result = app.run() if result == "export" and template_path: run_pdf_export_dialog( absences=all_absences, template_path=template_path, student_name=student_name, student_class=student_class, ) # ─── CLI ────────────────────────────────────────────────────────────────────── def prompt_missing(args): if not args.server: args.server = input("WebUntis server (e.g. mese.webuntis.com): ").strip() if not args.school: args.school = input("School name (from the WebUntis URL): ").strip() if not args.username: args.username = input("Username: ").strip() if not args.password: args.password = getpass.getpass("Password: ") def main(): parser = argparse.ArgumentParser(description="WebUntis Absence Viewer") parser.add_argument("--server", help="WebUntis hostname, e.g. mese.webuntis.com") parser.add_argument("--school", help="School name (from the WebUntis URL)") parser.add_argument("--username", help="Your WebUntis username") parser.add_argument("--password", help="Your WebUntis password (omit to be prompted)") parser.add_argument("--days", type=int, default=365, help="How many past days to include (default: 365)") parser.add_argument("--template", help="Path to Entschuldigung PDF template", default=None) args = parser.parse_args() prompt_missing(args) end_date = date.today() start_date = end_date - timedelta(days=args.days) print(f"Connecting to {args.server} …") client = WebUntisClient(server=args.server, school=args.school) try: print("Logging in …") client.login(args.username, args.password) student_name = client.display_name student_class = client.get_student_class() print(f"Logged in as: {args.username}" + (f" (name: {student_name})" if student_name else "")) print(f"Fetching absences from {start_date} to {end_date} …") absences = client.get_absences(start_date, end_date) print(f"Got {len(absences)} absence(s). Launching viewer …") except Exception as exc: print(f"\n❌ Error: {exc}", file=sys.stderr) sys.exit(1) finally: client.logout() run_tui( all_absences=absences, server=args.server, school=args.school, start=start_date, end=end_date, template_path=args.template, student_name=student_name, student_class=student_class, ) if __name__ == "__main__": main()