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__":