641 lines
22 KiB
Python
641 lines
22 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
WebUntis Absence Viewer
|
||
-----------------------
|
||
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 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
|
||
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 Frame, 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
|
||
self.display_name: str = "" # real name from getUserData2017
|
||
|
||
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", {})
|
||
|
||
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.")
|
||
# 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:
|
||
self._rpc("logout")
|
||
except Exception:
|
||
pass
|
||
|
||
def get_absences(self, start: date, end: date) -> list[dict]:
|
||
"""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"
|
||
)
|
||
resp = self.session.get(url, timeout=15)
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
absences = (
|
||
data.get("data", {}).get("absences")
|
||
or data.get("absences")
|
||
or data.get("data")
|
||
or []
|
||
)
|
||
if isinstance(absences, list):
|
||
return absences
|
||
|
||
# Fallback
|
||
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,
|
||
},
|
||
}
|
||
})
|
||
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
|
||
|
||
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 ""
|
||
|
||
|
||
# ─── 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 _resolve_excused(ab: dict) -> bool | None:
|
||
"""
|
||
Return True if excused, False if unexcused, None if unknown.
|
||
isExcused is a direct bool field on the absence object.
|
||
"""
|
||
# Top-level isExcused is the authoritative field
|
||
if "isExcused" in ab:
|
||
val = ab["isExcused"]
|
||
if isinstance(val, bool):
|
||
return val
|
||
# Fall back to excuseStatus text
|
||
status_text = (ab.get("excuseStatus") or "").lower()
|
||
if "nicht" in status_text:
|
||
return False
|
||
if status_text:
|
||
return True
|
||
return None
|
||
|
||
|
||
def _is_unexcused(ab: dict) -> bool:
|
||
result = _resolve_excused(ab)
|
||
return result is False or result is None # unknown treated as unexcused
|
||
|
||
|
||
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)
|
||
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(ab: dict) -> str:
|
||
result = _resolve_excused(ab)
|
||
if result is True:
|
||
return "✓ Excused"
|
||
if result is False:
|
||
return "✗ Unexcused"
|
||
return "? Unknown"
|
||
|
||
|
||
def format_absence(ab: dict, idx: int) -> str:
|
||
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_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"
|
||
|
||
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 / 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
|
||
|
||
|
||
# ─── PDF filling ───────────────────────────────────────────────────────────────
|
||
|
||
|
||
# ─── PDF export dialog ─────────────────────────────────────────────────────────
|
||
|
||
def _absence_date_str(ab: dict) -> str:
|
||
"""Return the absence date as DD.MM.YYYY string."""
|
||
dv = ab.get("date") or ab.get("startDate", "")
|
||
try:
|
||
d = str(dv)
|
||
return f"{d[6:8]}.{d[4:6]}.{d[:4]}"
|
||
except Exception:
|
||
return str(dv)
|
||
|
||
|
||
def _absence_date_suffix(ab: dict) -> str:
|
||
"""Return DDMMYYYY for use in the filename."""
|
||
dv = ab.get("date") or ab.get("startDate", "")
|
||
try:
|
||
d = str(dv)
|
||
return f"{d[6:8]}{d[4:6]}{d[:4]}"
|
||
except Exception:
|
||
return datetime.now().strftime("%d%m%Y")
|
||
|
||
|
||
def run_pdf_export_dialog(
|
||
absences: list[dict],
|
||
template_path: str,
|
||
student_name: str,
|
||
student_class: str,
|
||
cfg: dict | None = None,
|
||
) -> None:
|
||
"""
|
||
For each selected absence, generate one individual PDF named
|
||
EntschuldigungDDMMYYYY.pdf where the date is the absence date.
|
||
"""
|
||
print("\n─── Export Entschuldigung PDF ─────────────────────────────")
|
||
|
||
# Show unexcused absences grouped by date (newest first)
|
||
unexcused = filter_absences(absences, unexcused_only=True)
|
||
if not unexcused:
|
||
print("No unexcused absences found.")
|
||
return
|
||
|
||
# Group by date so the user can see days clearly
|
||
from collections import defaultdict
|
||
by_date: dict[str, list[dict]] = defaultdict(list)
|
||
for ab in unexcused:
|
||
by_date[_absence_date_str(ab)].append(ab)
|
||
|
||
dates_in_order = list(dict.fromkeys(_absence_date_str(ab) for ab in unexcused))
|
||
|
||
print("\nUnexcused absences (one PDF will be created per entry):")
|
||
idx = 0
|
||
entry_map: list[dict] = [] # flat list matching printed numbers
|
||
for ds in dates_in_order:
|
||
print(f" {ds}")
|
||
for ab in by_date[ds]:
|
||
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 "—"
|
||
minutes = ab.get("absentTime") or ab.get("minutesAbsent") or "?"
|
||
t_start = str(start).zfill(4)
|
||
t_end = str(end).zfill(4) if end else ""
|
||
time_str = f"{t_start[:2]}:{t_start[2:]}"
|
||
if t_end:
|
||
time_str += f"–{t_end[:2]}:{t_end[2:]}"
|
||
print(f" [{idx+1}] {time_str} {subject} ({minutes} min)")
|
||
entry_map.append(ab)
|
||
idx += 1
|
||
|
||
sel = input("\nEnter number(s) to export (comma-separated, or 'all'): ").strip()
|
||
if sel.lower() == "all":
|
||
selected = entry_map
|
||
else:
|
||
indices = [int(x.strip()) - 1 for x in sel.split(",") if x.strip().isdigit()]
|
||
selected = [entry_map[i] for i in indices if 0 <= i < len(entry_map)]
|
||
|
||
if not selected:
|
||
print("No valid selection.")
|
||
return
|
||
|
||
# ── Shared fields (same for every PDF) ──────────────────────────────
|
||
cfg = cfg or {}
|
||
saved_name = student_name or cfg.get("student_name", "")
|
||
saved_class = student_class or cfg.get("student_class", "")
|
||
|
||
print()
|
||
|
||
# Yolo mode — accept all defaults, only stop for truly unknown values
|
||
yolo = input(" Yolo mode — accept all defaults? [y/N]: ").strip().lower() == "y"
|
||
print()
|
||
|
||
# Name — always prompt if unknown (even in yolo)
|
||
if not saved_name:
|
||
saved_name = input(" Student name (required): ").strip()
|
||
elif not yolo:
|
||
v = input(f" Student name [{saved_name}]: ").strip()
|
||
if v:
|
||
saved_name = v
|
||
final_name = saved_name
|
||
|
||
# Class — always prompt if unknown
|
||
if not saved_class:
|
||
saved_class = input(" Class (required): ").strip()
|
||
elif not yolo:
|
||
v = input(f" Class [{saved_class}]: ").strip()
|
||
if v:
|
||
saved_class = v
|
||
final_class = saved_class
|
||
|
||
save_config({"student_name": final_name, "student_class": final_class})
|
||
|
||
sign_date_default = datetime.now().strftime("%d.%m.%Y")
|
||
saved_output_dir = cfg.get("output_dir", os.getcwd())
|
||
|
||
if yolo:
|
||
sign_date = sign_date_default
|
||
output_dir = saved_output_dir
|
||
else:
|
||
v = input(f" Signature date [{sign_date_default}]: ").strip()
|
||
sign_date = v if v else sign_date_default
|
||
v = input(f" Save to directory [{saved_output_dir}]: ").strip()
|
||
output_dir = v if v else saved_output_dir
|
||
save_config({"output_dir": output_dir})
|
||
|
||
# ── Per-entry prompts + PDF generation ──────────────────────────────
|
||
from pypdf import PdfReader, PdfWriter
|
||
|
||
print()
|
||
saved_files, failed = [], []
|
||
for ab in selected:
|
||
absence_date = _absence_date_str(ab)
|
||
start_raw = ab.get("startTime") or ab.get("lessonStartTime", "")
|
||
t = str(start_raw).zfill(4)
|
||
label = f"{absence_date} {t[:2]}:{t[2:]}" # e.g. "16.06.2026 11:40"
|
||
|
||
minutes_raw = ab.get("absentTime") or ab.get("minutesAbsent") or 50
|
||
default_hours = max(1, round(int(minutes_raw) / 50))
|
||
default_reason = ab.get("reason") or ab.get("text") or "Krankheit"
|
||
default_workshop = 0
|
||
|
||
if yolo:
|
||
final_reason = default_reason
|
||
final_hours = default_hours
|
||
final_workshop = default_workshop
|
||
else:
|
||
r = input(f" Reason {label} [{default_reason}]: ").strip()
|
||
final_reason = r if r else default_reason
|
||
|
||
h = input(f" Hours {label} [{default_hours}]: ").strip()
|
||
final_hours = int(h) if h.isdigit() else default_hours
|
||
|
||
w = input(f" Workshop {label} [{default_workshop}]: ").strip()
|
||
final_workshop = int(w) if w.isdigit() else default_workshop
|
||
|
||
filename = f"Entschuldigung{_absence_date_suffix(ab)}.pdf"
|
||
out_path = os.path.join(output_dir, filename)
|
||
try:
|
||
reader = PdfReader(template_path)
|
||
writer = PdfWriter()
|
||
writer.append(reader)
|
||
writer.update_page_form_field_values(writer.pages[0], {
|
||
"Textfeld 1": final_name,
|
||
"Textfeld 2": final_class,
|
||
"Textfeld 4": absence_date,
|
||
"Textfeld 3": final_reason,
|
||
"Textfeld 5": str(final_hours),
|
||
"Textfeld 6": str(final_workshop),
|
||
"Textfeld 7": sign_date,
|
||
})
|
||
with open(out_path, "wb") as f:
|
||
writer.write(f)
|
||
print(f" ✅ {filename}")
|
||
saved_files.append(out_path)
|
||
except Exception as e:
|
||
print(f" ❌ {filename}: {e}")
|
||
failed.append(filename)
|
||
|
||
print(f"\n Done: {len(saved_files)} saved, {len(failed)} failed.\n")
|
||
|
||
|
||
# ─── TUI ──────────────────────────────────────────────────────────────────────
|
||
|
||
STYLE = Style.from_dict({
|
||
"frame.border": "ansicyan",
|
||
"frame.title": "bold ansicyan",
|
||
"status": "reverse ansicyan",
|
||
"text-area": "ansiwhite",
|
||
})
|
||
|
||
|
||
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,
|
||
cfg: dict | None = None,
|
||
) -> 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")
|
||
@kb.add("c-c")
|
||
@kb.add("c-q")
|
||
def _quit(event):
|
||
event.app.exit()
|
||
|
||
@kb.add("f")
|
||
def _toggle_filter(event):
|
||
state["filter"] = not state["filter"]
|
||
_refresh()
|
||
|
||
@kb.add("e")
|
||
def _export(event):
|
||
if template_path:
|
||
event.app.exit(result="export")
|
||
# if no template, key does nothing
|
||
|
||
pdf_hint = " <b>[E]</b> Export PDF" if template_path else ""
|
||
status_bar = Window(
|
||
content=FormattedTextControl(
|
||
HTML(
|
||
" <b>[Q]</b> Quit"
|
||
" <b>[F]</b> Toggle unexcused filter"
|
||
f"{pdf_hint}"
|
||
" <b>[↑/↓]</b> Scroll"
|
||
f" <b>Fetched:</b> {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,
|
||
)
|
||
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,
|
||
cfg=cfg or {},
|
||
)
|
||
|
||
|
||
# ─── Config file ──────────────────────────────────────────────────────────────
|
||
|
||
CONFIG_PATH = Path.home() / ".webuntis_absence_viewer.json"
|
||
|
||
def load_config() -> dict:
|
||
try:
|
||
return json.loads(CONFIG_PATH.read_text())
|
||
except Exception:
|
||
return {}
|
||
|
||
def save_config(data: dict) -> None:
|
||
try:
|
||
existing = load_config()
|
||
existing.update(data)
|
||
CONFIG_PATH.write_text(json.dumps(existing, indent=2))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# ─── CLI ──────────────────────────────────────────────────────────────────────
|
||
|
||
def prompt_missing(args, cfg: dict) -> None:
|
||
if not args.server:
|
||
default = cfg.get("server", "")
|
||
args.server = input(f"WebUntis server [{default}]: ").strip() or default
|
||
if not args.school:
|
||
default = cfg.get("school", "")
|
||
args.school = input(f"School name [{default}]: ").strip() or default
|
||
if not args.username:
|
||
default = cfg.get("username", "")
|
||
args.username = input(f"Username [{default}]: ").strip() or default
|
||
if not args.password:
|
||
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)")
|
||
parser.add_argument("--template", help="Path to Entschuldigung PDF template",
|
||
default=None)
|
||
args = parser.parse_args()
|
||
|
||
cfg = load_config()
|
||
prompt_missing(args, cfg)
|
||
|
||
# Persist server/school/username for next run
|
||
save_config({"server": args.server, "school": args.school, "username": args.username})
|
||
|
||
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)
|
||
|
||
# Use API result; fall back to saved values from config
|
||
student_name = client.display_name or cfg.get("student_name", "")
|
||
student_class = client.get_student_class() or cfg.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 …")
|
||
except Exception as exc:
|
||
print(f"\n❌ Error: {exc}", file=sys.stderr)
|
||
sys.exit(1)
|
||
finally:
|
||
client.logout()
|
||
|
||
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,
|
||
cfg=cfg,
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|