schizo working v1
This commit is contained in:
commit
0e8f866256
3 changed files with 373 additions and 0 deletions
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