schizo working v1

This commit is contained in:
mia 2026-06-16 09:09:09 +02:00
commit 0e8f866256
3 changed files with 373 additions and 0 deletions

323
main.py Normal file
View file

@ -0,0 +1,323 @@
#!/usr/bin/env python3
"""
WebUntis Absence Viewer
-----------------------
Fetches your absences from WebUntis and displays them
in an interactive prompt_toolkit TUI.
Requirements:
pip install requests prompt_toolkit
Usage:
python webuntis_absences.py
python webuntis_absences.py --server mese.webuntis.com --school MySchool
"""
import argparse
import json
import sys
from datetime import date, datetime, timedelta
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.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
# ─── 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
# ── JSON-RPC helpers ──────────────────────────────────────────────────
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", {})
# ── Auth ──────────────────────────────────────────────────────────────
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.")
def logout(self) -> None:
try:
self._rpc("logout")
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.
"""
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
)
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")
or data.get("data")
or []
)
if isinstance(absences, list):
return absences
# Fallback: JSON-RPC timetableWithAbsences
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,
},
}
})
# Extract absences from timetable periods
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
# ─── Formatting helpers ───────────────────────────────────────────────────────
def _parse_untis_datetime(date_int, time_int) -> str:
"""Convert Untis integer date/time to readable string."""
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(absence: dict) -> str:
excused = absence.get("isExcused") or absence.get("excused") or absence.get("excuse")
if excused is True or excused == 1:
return "✓ Excused"
if excused is False or excused == 0:
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 ""
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 "")
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.\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 ─────────────────────────────────────────────────────────────────────
STYLE = Style.from_dict({
"frame.border": "ansicyan",
"frame.title": "bold ansicyan",
"status": "reverse ansicyan",
"key": "bold ansiwhite",
"text-area": "ansiwhite",
"label": "ansiyellow",
})
def run_tui(absences: list[dict], server: str, school: str,
start: date, end: date) -> None:
kb = KeyBindings()
@kb.add("q")
@kb.add("c-c")
@kb.add("c-q")
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)"
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,
)
status_bar = Window(
content=FormattedTextControl(
HTML(
" <b>[Q]</b> Quit "
" <b>[↑/↓]</b> Scroll "
f" <b>Server:</b> {server} "
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,
)
app.run()
# ─── 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()
if not args.school:
args.school = input("School name (as shown in the URL): ").strip()
if not args.username:
args.username = input("Username: ").strip()
if not args.password:
import getpass
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)")
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)
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(absences, args.server, args.school, start_date, end_date)
if __name__ == "__main__":
main()