#!/usr/bin/env python3 """ WebUntis Absence Viewer ----------------------- Fetches your absences from WebUntis and displays them in an interactive prompt_toolkit TUI. Requirements: pip install requests prompt_toolkit Usage: python webuntis_absences.py python webuntis_absences.py --server mese.webuntis.com --school MySchool """ import argparse import json import sys from datetime import date, datetime, timedelta 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, VSplit 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 Box, Frame, Label, 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 # ── JSON-RPC helpers ────────────────────────────────────────────────── 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", {}) # ── Auth ────────────────────────────────────────────────────────────── 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.") def logout(self) -> None: try: self._rpc("logout") except Exception: pass # ── Absences ────────────────────────────────────────────────────────── def get_absences(self, start: date, end: date) -> list[dict]: """ Fetch absences via the REST endpoint. Falls back to timetable_with_absences RPC if REST fails. """ 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" # -1 = all statuses ) resp = self.session.get(url, timeout=15) if resp.status_code == 200: data = resp.json() # REST endpoint wraps in data.absences or data directly absences = ( data.get("data", {}).get("absences") or data.get("absences") or data.get("data") or [] ) if isinstance(absences, list): return absences # Fallback: JSON-RPC timetableWithAbsences 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, }, } }) # Extract absences from timetable periods 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 # ─── Formatting helpers ─────────────────────────────────────────────────────── def _parse_untis_datetime(date_int, time_int) -> str: """Convert Untis integer date/time to readable string.""" 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(absence: dict) -> str: excused = absence.get("isExcused") or absence.get("excused") or absence.get("excuse") if excused is True or excused == 1: return "✓ Excused" if excused is False or excused == 0: return "✗ Unexcused" return "? Unknown" def format_absence(ab: dict, idx: int) -> str: """Turn one absence dict into a human-readable block.""" 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_untis_datetime(date_val, start) when_end = _parse_untis_datetime(date_val, end) if end else "" time_str = f"{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.\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 # ─── TUI ───────────────────────────────────────────────────────────────────── STYLE = Style.from_dict({ "frame.border": "ansicyan", "frame.title": "bold ansicyan", "status": "reverse ansicyan", "key": "bold ansiwhite", "text-area": "ansiwhite", "label": "ansiyellow", }) def run_tui(absences: list[dict], server: str, school: str, start: date, end: date) -> None: kb = KeyBindings() @kb.add("q") @kb.add("c-c") @kb.add("c-q") def _quit(event): event.app.exit() @kb.add("r") def _refresh(event): content.text = " Press 'r' again after data reloads…\n (restart the script to fetch fresh data)" period_title = ( f"Absences for {school} — " f"{start.strftime('%d.%m.%Y')} to {end.strftime('%d.%m.%Y')}" ) display_text = build_display(absences, period_title) content = TextArea( text=display_text, read_only=True, scrollbar=True, style="class:text-area", wrap_lines=False, ) status_bar = Window( content=FormattedTextControl( HTML( " [Q] Quit " " [↑/↓] Scroll " f" Server: {server} " 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, ) app.run() # ─── CLI ────────────────────────────────────────────────────────────────────── def prompt_missing(args): """Interactively prompt for any missing arguments.""" if not args.server: args.server = input("WebUntis server (e.g. mese.webuntis.com): ").strip() if not args.school: args.school = input("School name (as shown in the URL): ").strip() if not args.username: args.username = input("Username: ").strip() if not args.password: import getpass 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)") 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) 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(absences, args.server, args.school, start_date, end_date) if __name__ == "__main__": main()