#!/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 _resolve_excused(ab: dict) -> bool | None: """ Return True if excused, False if unexcused, None if unknown. isExcused is a direct bool field on the absence object. """ # Top-level isExcused is the authoritative field if "isExcused" in ab: val = ab["isExcused"] if isinstance(val, bool): return val # Fall back to excuseStatus text status_text = (ab.get("excuseStatus") or "").lower() if "nicht" in status_text: return False if status_text: return True return None def _is_unexcused(ab: dict) -> bool: result = _resolve_excused(ab) return result is False or result is None # unknown treated as unexcused 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: result = _resolve_excused(ab) if result is True: return "✓ Excused" if result is False: 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 ─────────────────────────────────────────────────────────────── # ─── PDF export dialog ───────────────────────────────────────────────────────── def _absence_date_str(ab: dict) -> str: """Return the absence date as DD.MM.YYYY string.""" dv = ab.get("date") or ab.get("startDate", "") try: d = str(dv) return f"{d[6:8]}.{d[4:6]}.{d[:4]}" except Exception: return str(dv) def _absence_date_suffix(ab: dict) -> str: """Return DDMMYYYY for use in the filename.""" dv = ab.get("date") or ab.get("startDate", "") try: d = str(dv) return f"{d[6:8]}{d[4:6]}{d[:4]}" except Exception: return datetime.now().strftime("%d%m%Y") def run_pdf_export_dialog( absences: list[dict], template_path: str, student_name: str, student_class: str, cfg: dict | None = None, ) -> None: """ For each selected absence, generate one individual PDF named EntschuldigungDDMMYYYY.pdf where the date is the absence date. """ print("\n─── Export Entschuldigung PDF ─────────────────────────────") # Show unexcused absences grouped by date (newest first) unexcused = filter_absences(absences, unexcused_only=True) if not unexcused: print("No unexcused absences found.") return # Group by date so the user can see days clearly from collections import defaultdict by_date: dict[str, list[dict]] = defaultdict(list) for ab in unexcused: by_date[_absence_date_str(ab)].append(ab) dates_in_order = list(dict.fromkeys(_absence_date_str(ab) for ab in unexcused)) print("\nUnexcused absences (one PDF will be created per entry):") idx = 0 entry_map: list[dict] = [] # flat list matching printed numbers for ds in dates_in_order: print(f" {ds}") for ab in by_date[ds]: 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 "—" minutes = ab.get("absentTime") or ab.get("minutesAbsent") or "?" t_start = str(start).zfill(4) t_end = str(end).zfill(4) if end else "" time_str = f"{t_start[:2]}:{t_start[2:]}" if t_end: time_str += f"–{t_end[:2]}:{t_end[2:]}" print(f" [{idx+1}] {time_str} {subject} ({minutes} min)") entry_map.append(ab) idx += 1 sel = input("\nEnter number(s) to export (comma-separated, or 'all'): ").strip() if sel.lower() == "all": selected = entry_map else: indices = [int(x.strip()) - 1 for x in sel.split(",") if x.strip().isdigit()] selected = [entry_map[i] for i in indices if 0 <= i < len(entry_map)] if not selected: print("No valid selection.") return # ── Shared fields (same for every PDF) ────────────────────────────── cfg = cfg or {} saved_name = student_name or cfg.get("student_name", "") saved_class = student_class or cfg.get("student_class", "") print() # Yolo mode — accept all defaults, only stop for truly unknown values yolo = input(" Yolo mode — accept all defaults? [y/N]: ").strip().lower() == "y" print() # Name — always prompt if unknown (even in yolo) if not saved_name: saved_name = input(" Student name (required): ").strip() elif not yolo: v = input(f" Student name [{saved_name}]: ").strip() if v: saved_name = v final_name = saved_name # Class — always prompt if unknown if not saved_class: saved_class = input(" Class (required): ").strip() elif not yolo: v = input(f" Class [{saved_class}]: ").strip() if v: saved_class = v final_class = saved_class save_config({"student_name": final_name, "student_class": final_class}) sign_date_default = datetime.now().strftime("%d.%m.%Y") saved_output_dir = cfg.get("output_dir", os.getcwd()) if yolo: sign_date = sign_date_default output_dir = saved_output_dir else: v = input(f" Signature date [{sign_date_default}]: ").strip() sign_date = v if v else sign_date_default v = input(f" Save to directory [{saved_output_dir}]: ").strip() output_dir = v if v else saved_output_dir save_config({"output_dir": output_dir}) # ── Per-entry prompts + PDF generation ────────────────────────────── from pypdf import PdfReader, PdfWriter print() saved_files, failed = [], [] for ab in selected: absence_date = _absence_date_str(ab) start_raw = ab.get("startTime") or ab.get("lessonStartTime", "") t = str(start_raw).zfill(4) label = f"{absence_date} {t[:2]}:{t[2:]}" # e.g. "16.06.2026 11:40" minutes_raw = ab.get("absentTime") or ab.get("minutesAbsent") or 50 default_hours = max(1, round(int(minutes_raw) / 50)) default_reason = ab.get("reason") or ab.get("text") or "Krankheit" default_workshop = 0 if yolo: final_reason = default_reason final_hours = default_hours final_workshop = default_workshop else: r = input(f" Reason {label} [{default_reason}]: ").strip() final_reason = r if r else default_reason h = input(f" Hours {label} [{default_hours}]: ").strip() final_hours = int(h) if h.isdigit() else default_hours w = input(f" Workshop {label} [{default_workshop}]: ").strip() final_workshop = int(w) if w.isdigit() else default_workshop filename = f"Entschuldigung{_absence_date_suffix(ab)}.pdf" out_path = os.path.join(output_dir, filename) try: reader = PdfReader(template_path) writer = PdfWriter() writer.append(reader) writer.update_page_form_field_values(writer.pages[0], { "Textfeld 1": final_name, "Textfeld 2": final_class, "Textfeld 4": absence_date, "Textfeld 3": final_reason, "Textfeld 5": str(final_hours), "Textfeld 6": str(final_workshop), "Textfeld 7": sign_date, }) with open(out_path, "wb") as f: writer.write(f) print(f" ✅ {filename}") saved_files.append(out_path) except Exception as e: print(f" ❌ {filename}: {e}") failed.append(filename) print(f"\n Done: {len(saved_files)} saved, {len(failed)} failed.\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, cfg: dict | None = None, ) -> 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, cfg=cfg or {}, ) # ─── Config file ────────────────────────────────────────────────────────────── CONFIG_PATH = Path.home() / ".webuntis_absence_viewer.json" def load_config() -> dict: try: return json.loads(CONFIG_PATH.read_text()) except Exception: return {} def save_config(data: dict) -> None: try: existing = load_config() existing.update(data) CONFIG_PATH.write_text(json.dumps(existing, indent=2)) except Exception: pass # ─── CLI ────────────────────────────────────────────────────────────────────── def prompt_missing(args, cfg: dict) -> None: if not args.server: default = cfg.get("server", "") args.server = input(f"WebUntis server [{default}]: ").strip() or default if not args.school: default = cfg.get("school", "") args.school = input(f"School name [{default}]: ").strip() or default if not args.username: default = cfg.get("username", "") args.username = input(f"Username [{default}]: ").strip() or default 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() cfg = load_config() prompt_missing(args, cfg) # Persist server/school/username for next run save_config({"server": args.server, "school": args.school, "username": args.username}) 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) # Use API result; fall back to saved values from config student_name = client.display_name or cfg.get("student_name", "") student_class = client.get_student_class() or cfg.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, cfg=cfg, ) if __name__ == "__main__": main()