absencesFetcher/main.py
2026-06-16 12:05:47 +02:00

576 lines
20 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 _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)
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:
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:
else:
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 ───────────────────────────────────────────────────────────────
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",
"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,
) -> 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,
)
# ─── CLI ──────────────────────────────────────────────────────────────────────
def prompt_missing(args):
if not args.server:
args.server = input("WebUntis server (e.g. mese.webuntis.com): ").strip()
if not args.school:
args.school = input("School name (from the WebUntis URL): ").strip()
if not args.username:
args.username = input("Username: ").strip()
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()
prompt_missing(args)
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)
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 …")
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,
)
if __name__ == "__main__":
main()