schizo working v1
This commit is contained in:
commit
0e8f866256
3 changed files with 373 additions and 0 deletions
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1780952837,
|
||||||
|
"narHash": "sha256-Fwd1+spDtQ0hDyBwme6ufG3n4mY0UrjjFdYHv+G/Hds=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "e820eb4a444b46a19b2e03e8dfd2359439ff30fe",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-25.11",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
23
flake.nix
Normal file
23
flake.nix
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
description = "Python env for HaFaS-Terminal-App";
|
||||||
|
|
||||||
|
inputs.nixpkgs.url = github:nixos/nixpkgs/nixos-25.11;
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, ... }: let
|
||||||
|
system = "x86_64-linux";
|
||||||
|
pkgs = import nixpkgs { inherit system; };
|
||||||
|
nixPackages = with pkgs; with python313Packages; [
|
||||||
|
requests
|
||||||
|
prompt-toolkit
|
||||||
|
];
|
||||||
|
in {
|
||||||
|
devShells."${system}".default = pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
python313
|
||||||
|
] ++ nixPackages;
|
||||||
|
shellHook = ''
|
||||||
|
exec zsh || exec bash || true
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
323
main.py
Normal file
323
main.py
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue