From 0e8f866256f2c3558a7ff7af7ec88bf9b4ee2b18 Mon Sep 17 00:00:00 2001 From: mia Date: Tue, 16 Jun 2026 09:09:09 +0200 Subject: [PATCH] schizo working v1 --- flake.lock | 27 +++++ flake.nix | 23 ++++ main.py | 323 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 373 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 main.py diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0f6d111 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1780952837, + "narHash": "sha256-Fwd1+spDtQ0hDyBwme6ufG3n4mY0UrjjFdYHv+G/Hds=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "e820eb4a444b46a19b2e03e8dfd2359439ff30fe", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..68ab227 --- /dev/null +++ b/flake.nix @@ -0,0 +1,23 @@ +{ + description = "Python env for HaFaS-Terminal-App"; + + inputs.nixpkgs.url = github:nixos/nixpkgs/nixos-25.11; + + outputs = { self, nixpkgs, ... }: let + system = "x86_64-linux"; + pkgs = import nixpkgs { inherit system; }; + nixPackages = with pkgs; with python313Packages; [ + requests + prompt-toolkit + ]; + in { + devShells."${system}".default = pkgs.mkShell { + buildInputs = with pkgs; [ + python313 + ] ++ nixPackages; + shellHook = '' + exec zsh || exec bash || true + ''; + }; + }; +} diff --git a/main.py b/main.py new file mode 100644 index 0000000..f0da6e0 --- /dev/null +++ b/main.py @@ -0,0 +1,323 @@ +#!/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()