absencesFetcher/main.py
2026-06-17 09:04:35 +02:00

641 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()