diff --git a/Entschuldigung16062026.pdf b/Entschuldigung16062026.pdf new file mode 100644 index 0000000..6df1b82 Binary files /dev/null and b/Entschuldigung16062026.pdf differ diff --git a/Entschuldigung_template.pdf b/Entschuldigung_template.pdf new file mode 100644 index 0000000..4218046 Binary files /dev/null and b/Entschuldigung_template.pdf differ diff --git a/flake.nix b/flake.nix index 68ab227..e3791b4 100644 --- a/flake.nix +++ b/flake.nix @@ -9,6 +9,7 @@ nixPackages = with pkgs; with python313Packages; [ requests prompt-toolkit + pypdf ]; in { devShells."${system}".default = pkgs.mkShell { diff --git a/main.py b/main.py index f0da6e0..5d69332 100644 --- a/main.py +++ b/main.py @@ -2,51 +2,55 @@ """ WebUntis Absence Viewer ----------------------- -Fetches your absences from WebUntis and displays them -in an interactive prompt_toolkit TUI. +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 + 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, VSplit +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 Box, Frame, Label, TextArea +from prompt_toolkit.widgets import Frame, TextArea -# ─── WebUntis API ──────────────────────────────────────────────────────────── +# ─── 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.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", + "Content-Type": "application/json", + "User-Agent": "WebUntisAbsenceViewer/1.0", "X-Requested-With": "XMLHttpRequest", }) - self._rpc_id = 0 - self.person_id: int | None = None + self._rpc_id = 0 + self.person_id: int | None = None self.person_type: int | None = None - - # ── JSON-RPC helpers ────────────────────────────────────────────────── + self.display_name: str = "" # real name from getUserData2017 def _rpc(self, method: str, params: dict | None = None) -> dict: self._rpc_id += 1 @@ -67,18 +71,26 @@ class WebUntisClient: 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, + "user": username, "password": password, - "client": "WebUntisAbsenceViewer", + "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: @@ -86,24 +98,18 @@ class WebUntisClient: 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. - """ + """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" # -1 = all statuses + f"&excuseStatusId=-1" ) 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") @@ -113,7 +119,7 @@ class WebUntisClient: if isinstance(absences, list): return absences - # Fallback: JSON-RPC timetableWithAbsences + # Fallback result = self._rpc("getTimetableWithAbsences", { "options": { "startDate": int(start.strftime("%Y%m%d")), @@ -124,7 +130,6 @@ class WebUntisClient: }, } }) - # Extract absences from timetable periods absences = [] for period in result if isinstance(result, list) else result.get("periods", []): for ab in period.get("absences", []): @@ -134,11 +139,48 @@ class WebUntisClient: 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 "" -# ─── Formatting helpers ─────────────────────────────────────────────────────── -def _parse_untis_datetime(date_int, time_int) -> str: - """Convert Untis integer date/time to readable string.""" +# ─── 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) @@ -149,30 +191,30 @@ def _parse_untis_datetime(date_int, time_int) -> str: return f"{date_int} {time_int}" -def _excuse_label(absence: dict) -> str: - excused = absence.get("isExcused") or absence.get("excused") or absence.get("excuse") +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: + # if excused is False or excused == 0: + else: 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 "" + 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 "") + 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" @@ -192,27 +234,220 @@ def format_absence(ab: dict, idx: int) -> str: def build_display(absences: list[dict], title: str) -> str: if not absences: - return " No absences found for the selected period.\n" + 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 -# ─── TUI ───────────────────────────────────────────────────────────────────── +# ─── 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", - "key": "bold ansiwhite", - "text-area": "ansiwhite", - "label": "ansiyellow", + "frame.border": "ansicyan", + "frame.title": "bold ansicyan", + "status": "reverse ansicyan", + "text-area": "ansiwhite", }) -def run_tui(absences: list[dict], server: str, school: str, - start: date, end: date) -> None: +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") @@ -221,31 +456,26 @@ def run_tui(absences: list[dict], server: str, school: str, 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)" + @kb.add("f") + def _toggle_filter(event): + state["filter"] = not state["filter"] + _refresh() - 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, - ) + @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 " - " [↑/↓] Scroll " - f" Server: {server} " - f" Fetched: {datetime.now().strftime('%H:%M:%S')}" + " [Q] Quit" + " [F] Toggle unexcused filter" + f"{pdf_hint}" + " [↑/↓] Scroll" + f" Fetched: {datetime.now().strftime('%H:%M:%S')}" ) ), height=1, @@ -268,21 +498,27 @@ def run_tui(absences: list[dict], server: str, school: str, full_screen=True, mouse_support=True, ) - app.run() + 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): - """Interactively prompt for any missing arguments.""" if not args.server: - args.server = input("WebUntis server (e.g. mese.webuntis.com): ").strip() + 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() + args.school = input("School name (from the WebUntis URL): ").strip() if not args.username: args.username = input("Username: ").strip() if not args.password: - import getpass args.password = getpass.getpass("Password: ") @@ -294,6 +530,8 @@ def main(): 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) @@ -307,6 +545,12 @@ def main(): 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 …") @@ -316,7 +560,16 @@ def main(): finally: client.logout() - run_tui(absences, args.server, args.school, start_date, end_date) + 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__":