schizo v2

This commit is contained in:
mia 2026-06-16 12:05:47 +02:00
parent 0e8f866256
commit 5b4aee13e7
4 changed files with 337 additions and 83 deletions

BIN
Entschuldigung16062026.pdf Normal file

Binary file not shown.

BIN
Entschuldigung_template.pdf Normal file

Binary file not shown.

View file

@ -9,6 +9,7 @@
nixPackages = with pkgs; with python313Packages; [ nixPackages = with pkgs; with python313Packages; [
requests requests
prompt-toolkit prompt-toolkit
pypdf
]; ];
in { in {
devShells."${system}".default = pkgs.mkShell { devShells."${system}".default = pkgs.mkShell {

373
main.py
View file

@ -2,33 +2,38 @@
""" """
WebUntis Absence Viewer WebUntis Absence Viewer
----------------------- -----------------------
Fetches your absences from WebUntis and displays them Fetches your absences from WebUntis, displays them in an interactive
in an interactive prompt_toolkit TUI. prompt_toolkit TUI (newest first, filterable), and can fill & export
the school's Entschuldigung PDF template.
Requirements: Requirements:
pip install requests prompt_toolkit pip install requests prompt_toolkit pypdf
Usage: Usage:
python webuntis_absences.py python webuntis_absences.py
python webuntis_absences.py --server mese.webuntis.com --school MySchool python webuntis_absences.py --server mese.webuntis.com --school MySchool
python webuntis_absences.py --template /path/to/Entschuldigung_template.pdf
""" """
import argparse import argparse
import getpass
import json import json
import os
import sys import sys
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from pathlib import Path
import requests import requests
from prompt_toolkit import Application from prompt_toolkit import Application
from prompt_toolkit.formatted_text import HTML from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.key_binding import KeyBindings 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.containers import Window
from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.layout.controls import FormattedTextControl
from prompt_toolkit.styles import Style 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: class WebUntisClient:
"""Minimal WebUntis client using JSON-RPC + REST endpoints.""" """Minimal WebUntis client using JSON-RPC + REST endpoints."""
@ -45,8 +50,7 @@ class WebUntisClient:
self._rpc_id = 0 self._rpc_id = 0
self.person_id: int | None = None self.person_id: int | None = None
self.person_type: int | None = None self.person_type: int | None = None
self.display_name: str = "" # real name from getUserData2017
# ── JSON-RPC helpers ──────────────────────────────────────────────────
def _rpc(self, method: str, params: dict | None = None) -> dict: def _rpc(self, method: str, params: dict | None = None) -> dict:
self._rpc_id += 1 self._rpc_id += 1
@ -67,8 +71,6 @@ class WebUntisClient:
raise RuntimeError(f"WebUntis RPC error: {data['error']}") raise RuntimeError(f"WebUntis RPC error: {data['error']}")
return data.get("result", {}) return data.get("result", {})
# ── Auth ──────────────────────────────────────────────────────────────
def login(self, username: str, password: str) -> None: def login(self, username: str, password: str) -> None:
result = self._rpc("authenticate", { result = self._rpc("authenticate", {
"user": username, "user": username,
@ -79,6 +81,16 @@ class WebUntisClient:
self.person_type = result.get("personType") self.person_type = result.get("personType")
if not self.person_id: if not self.person_id:
raise RuntimeError("Login succeeded but no personId returned.") 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: def logout(self) -> None:
try: try:
@ -86,24 +98,18 @@ class WebUntisClient:
except Exception: except Exception:
pass pass
# ── Absences ──────────────────────────────────────────────────────────
def get_absences(self, start: date, end: date) -> list[dict]: def get_absences(self, start: date, end: date) -> list[dict]:
""" """REST endpoint first, JSON-RPC fallback."""
Fetch absences via the REST endpoint.
Falls back to timetable_with_absences RPC if REST fails.
"""
url = ( url = (
f"{self.base_url}/api/classreg/absences/students" f"{self.base_url}/api/classreg/absences/students"
f"?startDate={start.strftime('%Y%m%d')}" f"?startDate={start.strftime('%Y%m%d')}"
f"&endDate={end.strftime('%Y%m%d')}" f"&endDate={end.strftime('%Y%m%d')}"
f"&studentId={self.person_id}" f"&studentId={self.person_id}"
f"&excuseStatusId=-1" # -1 = all statuses f"&excuseStatusId=-1"
) )
resp = self.session.get(url, timeout=15) resp = self.session.get(url, timeout=15)
if resp.status_code == 200: if resp.status_code == 200:
data = resp.json() data = resp.json()
# REST endpoint wraps in data.absences or data directly
absences = ( absences = (
data.get("data", {}).get("absences") data.get("data", {}).get("absences")
or data.get("absences") or data.get("absences")
@ -113,7 +119,7 @@ class WebUntisClient:
if isinstance(absences, list): if isinstance(absences, list):
return absences return absences
# Fallback: JSON-RPC timetableWithAbsences # Fallback
result = self._rpc("getTimetableWithAbsences", { result = self._rpc("getTimetableWithAbsences", {
"options": { "options": {
"startDate": int(start.strftime("%Y%m%d")), "startDate": int(start.strftime("%Y%m%d")),
@ -124,7 +130,6 @@ class WebUntisClient:
}, },
} }
}) })
# Extract absences from timetable periods
absences = [] absences = []
for period in result if isinstance(result, list) else result.get("periods", []): for period in result if isinstance(result, list) else result.get("periods", []):
for ab in period.get("absences", []): for ab in period.get("absences", []):
@ -134,11 +139,48 @@ class WebUntisClient:
absences.append(ab) absences.append(ab)
return absences 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: # ─── Sorting & filtering ───────────────────────────────────────────────────────
"""Convert Untis integer date/time to readable string."""
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: try:
d = str(date_int) d = str(date_int)
t = str(time_int).zfill(4) t = str(time_int).zfill(4)
@ -149,17 +191,17 @@ def _parse_untis_datetime(date_int, time_int) -> str:
return f"{date_int} {time_int}" return f"{date_int} {time_int}"
def _excuse_label(absence: dict) -> str: def _excuse_label(ab: dict) -> str:
excused = absence.get("isExcused") or absence.get("excused") or absence.get("excuse") excused = ab.get("isExcused") or ab.get("excused") or ab.get("excuse")
if excused is True or excused == 1: if excused is True or excused == 1:
return "✓ Excused" return "✓ Excused"
if excused is False or excused == 0: # if excused is False or excused == 0:
else:
return "✗ Unexcused" return "✗ Unexcused"
return "? Unknown" return "? Unknown"
def format_absence(ab: dict, idx: int) -> str: 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", "") date_val = ab.get("date") or ab.get("startDate", "")
start = ab.get("startTime") or ab.get("lessonStartTime", "") start = ab.get("startTime") or ab.get("lessonStartTime", "")
end = ab.get("endTime") or ab.get("lessonEndTime", "") end = ab.get("endTime") or ab.get("lessonEndTime", "")
@ -170,9 +212,9 @@ def format_absence(ab: dict, idx: int) -> str:
minutes = ab.get("absentTime") or ab.get("minutesAbsent") or "" minutes = ab.get("absentTime") or ab.get("minutesAbsent") or ""
if date_val and start: if date_val and start:
when = _parse_untis_datetime(date_val, start) when = _parse_dt(date_val, start)
when_end = _parse_untis_datetime(date_val, end) if end else "" when_end = _parse_dt(date_val, end) if end else ""
time_str = f"{when}" + (f"{when_end.split(' ')[1]}" if when_end else "") time_str = when + (f"{when_end.split(' ')[1]}" if when_end else "")
else: else:
time_str = str(date_val) or "unknown" 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: def build_display(absences: list[dict], title: str) -> str:
if not absences: 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" header = f" {title}\n {'' * 60}\n\n"
body = "".join(format_absence(ab, i) for i, ab in enumerate(absences)) body = "".join(format_absence(ab, i) for i, ab in enumerate(absences))
footer = f"\n Total: {len(absences)} absence(s)\n" footer = f"\n Total: {len(absences)} absence(s)\n"
return header + body + footer 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({ STYLE = Style.from_dict({
"frame.border": "ansicyan", "frame.border": "ansicyan",
"frame.title": "bold ansicyan", "frame.title": "bold ansicyan",
"status": "reverse ansicyan", "status": "reverse ansicyan",
"key": "bold ansiwhite",
"text-area": "ansiwhite", "text-area": "ansiwhite",
"label": "ansiyellow",
}) })
def run_tui(absences: list[dict], server: str, school: str, def run_tui(
start: date, end: date) -> None: 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 = KeyBindings()
@kb.add("q") @kb.add("q")
@ -221,30 +456,25 @@ def run_tui(absences: list[dict], server: str, school: str,
def _quit(event): def _quit(event):
event.app.exit() event.app.exit()
@kb.add("r") @kb.add("f")
def _refresh(event): def _toggle_filter(event):
content.text = " Press 'r' again after data reloads…\n (restart the script to fetch fresh data)" state["filter"] = not state["filter"]
_refresh()
period_title = ( @kb.add("e")
f"Absences for {school}" def _export(event):
f"{start.strftime('%d.%m.%Y')} to {end.strftime('%d.%m.%Y')}" if template_path:
) event.app.exit(result="export")
display_text = build_display(absences, period_title) # if no template, key does nothing
content = TextArea(
text=display_text,
read_only=True,
scrollbar=True,
style="class:text-area",
wrap_lines=False,
)
pdf_hint = " <b>[E]</b> Export PDF" if template_path else ""
status_bar = Window( status_bar = Window(
content=FormattedTextControl( content=FormattedTextControl(
HTML( HTML(
" <b>[Q]</b> Quit " " <b>[Q]</b> Quit"
" <b>[↑/↓]</b> Scroll " " <b>[F]</b> Toggle unexcused filter"
f" <b>Server:</b> {server} " f"{pdf_hint}"
" <b>[↑/↓]</b> Scroll"
f" <b>Fetched:</b> {datetime.now().strftime('%H:%M:%S')}" f" <b>Fetched:</b> {datetime.now().strftime('%H:%M:%S')}"
) )
), ),
@ -268,21 +498,27 @@ def run_tui(absences: list[dict], server: str, school: str,
full_screen=True, full_screen=True,
mouse_support=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 ────────────────────────────────────────────────────────────────────── # ─── CLI ──────────────────────────────────────────────────────────────────────
def prompt_missing(args): def prompt_missing(args):
"""Interactively prompt for any missing arguments."""
if not args.server: 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: 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: if not args.username:
args.username = input("Username: ").strip() args.username = input("Username: ").strip()
if not args.password: if not args.password:
import getpass
args.password = getpass.getpass("Password: ") 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("--password", help="Your WebUntis password (omit to be prompted)")
parser.add_argument("--days", type=int, default=365, parser.add_argument("--days", type=int, default=365,
help="How many past days to include (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() args = parser.parse_args()
prompt_missing(args) prompt_missing(args)
@ -307,6 +545,12 @@ def main():
try: try:
print("Logging in …") print("Logging in …")
client.login(args.username, args.password) 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}") print(f"Fetching absences from {start_date} to {end_date}")
absences = client.get_absences(start_date, end_date) absences = client.get_absences(start_date, end_date)
print(f"Got {len(absences)} absence(s). Launching viewer …") print(f"Got {len(absences)} absence(s). Launching viewer …")
@ -316,7 +560,16 @@ def main():
finally: finally:
client.logout() 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__": if __name__ == "__main__":