schizo v2
This commit is contained in:
parent
0e8f866256
commit
5b4aee13e7
4 changed files with 337 additions and 83 deletions
BIN
Entschuldigung16062026.pdf
Normal file
BIN
Entschuldigung16062026.pdf
Normal file
Binary file not shown.
BIN
Entschuldigung_template.pdf
Normal file
BIN
Entschuldigung_template.pdf
Normal file
Binary file not shown.
|
|
@ -9,6 +9,7 @@
|
||||||
nixPackages = with pkgs; with python313Packages; [
|
nixPackages = with pkgs; with python313Packages; [
|
||||||
requests
|
requests
|
||||||
prompt-toolkit
|
prompt-toolkit
|
||||||
|
pypdf
|
||||||
];
|
];
|
||||||
in {
|
in {
|
||||||
devShells."${system}".default = pkgs.mkShell {
|
devShells."${system}".default = pkgs.mkShell {
|
||||||
|
|
|
||||||
419
main.py
419
main.py
|
|
@ -2,51 +2,55 @@
|
||||||
"""
|
"""
|
||||||
WebUntis Absence Viewer
|
WebUntis Absence Viewer
|
||||||
-----------------------
|
-----------------------
|
||||||
Fetches your absences from WebUntis and displays them
|
Fetches your absences from WebUntis, displays them in an interactive
|
||||||
in an interactive prompt_toolkit TUI.
|
prompt_toolkit TUI (newest first, filterable), and can fill & export
|
||||||
|
the school's Entschuldigung PDF template.
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
pip install requests prompt_toolkit
|
pip install requests prompt_toolkit pypdf
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python webuntis_absences.py
|
python webuntis_absences.py
|
||||||
python webuntis_absences.py --server mese.webuntis.com --school MySchool
|
python webuntis_absences.py --server mese.webuntis.com --school MySchool
|
||||||
|
python webuntis_absences.py --template /path/to/Entschuldigung_template.pdf
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import getpass
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from prompt_toolkit import Application
|
from prompt_toolkit import Application
|
||||||
from prompt_toolkit.formatted_text import HTML
|
from prompt_toolkit.formatted_text import HTML
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
from prompt_toolkit.layout import HSplit, Layout, VSplit
|
from prompt_toolkit.layout import HSplit, Layout
|
||||||
from prompt_toolkit.layout.containers import Window
|
from prompt_toolkit.layout.containers import Window
|
||||||
from prompt_toolkit.layout.controls import FormattedTextControl
|
from prompt_toolkit.layout.controls import FormattedTextControl
|
||||||
from prompt_toolkit.styles import Style
|
from prompt_toolkit.styles import Style
|
||||||
from prompt_toolkit.widgets import Box, Frame, Label, TextArea
|
from prompt_toolkit.widgets import Frame, TextArea
|
||||||
|
|
||||||
# ─── WebUntis API ────────────────────────────────────────────────────────────
|
# ─── WebUntis API ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class WebUntisClient:
|
class WebUntisClient:
|
||||||
"""Minimal WebUntis client using JSON-RPC + REST endpoints."""
|
"""Minimal WebUntis client using JSON-RPC + REST endpoints."""
|
||||||
|
|
||||||
def __init__(self, server: str, school: str):
|
def __init__(self, server: str, school: str):
|
||||||
self.base_url = f"https://{server}/WebUntis"
|
self.base_url = f"https://{server}/WebUntis"
|
||||||
self.school = school
|
self.school = school
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
self.session.headers.update({
|
self.session.headers.update({
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"User-Agent": "WebUntisAbsenceViewer/1.0",
|
"User-Agent": "WebUntisAbsenceViewer/1.0",
|
||||||
"X-Requested-With": "XMLHttpRequest",
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
})
|
})
|
||||||
self._rpc_id = 0
|
self._rpc_id = 0
|
||||||
self.person_id: int | None = None
|
self.person_id: int | None = None
|
||||||
self.person_type: int | None = None
|
self.person_type: int | None = None
|
||||||
|
self.display_name: str = "" # real name from getUserData2017
|
||||||
# ── JSON-RPC helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _rpc(self, method: str, params: dict | None = None) -> dict:
|
def _rpc(self, method: str, params: dict | None = None) -> dict:
|
||||||
self._rpc_id += 1
|
self._rpc_id += 1
|
||||||
|
|
@ -67,18 +71,26 @@ class WebUntisClient:
|
||||||
raise RuntimeError(f"WebUntis RPC error: {data['error']}")
|
raise RuntimeError(f"WebUntis RPC error: {data['error']}")
|
||||||
return data.get("result", {})
|
return data.get("result", {})
|
||||||
|
|
||||||
# ── Auth ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def login(self, username: str, password: str) -> None:
|
def login(self, username: str, password: str) -> None:
|
||||||
result = self._rpc("authenticate", {
|
result = self._rpc("authenticate", {
|
||||||
"user": username,
|
"user": username,
|
||||||
"password": password,
|
"password": password,
|
||||||
"client": "WebUntisAbsenceViewer",
|
"client": "WebUntisAbsenceViewer",
|
||||||
})
|
})
|
||||||
self.person_id = result.get("personId")
|
self.person_id = result.get("personId")
|
||||||
self.person_type = result.get("personType")
|
self.person_type = result.get("personType")
|
||||||
if not self.person_id:
|
if not self.person_id:
|
||||||
raise RuntimeError("Login succeeded but no personId returned.")
|
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:
|
def logout(self) -> None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -86,24 +98,18 @@ class WebUntisClient:
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ── Absences ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def get_absences(self, start: date, end: date) -> list[dict]:
|
def get_absences(self, start: date, end: date) -> list[dict]:
|
||||||
"""
|
"""REST endpoint first, JSON-RPC fallback."""
|
||||||
Fetch absences via the REST endpoint.
|
|
||||||
Falls back to timetable_with_absences RPC if REST fails.
|
|
||||||
"""
|
|
||||||
url = (
|
url = (
|
||||||
f"{self.base_url}/api/classreg/absences/students"
|
f"{self.base_url}/api/classreg/absences/students"
|
||||||
f"?startDate={start.strftime('%Y%m%d')}"
|
f"?startDate={start.strftime('%Y%m%d')}"
|
||||||
f"&endDate={end.strftime('%Y%m%d')}"
|
f"&endDate={end.strftime('%Y%m%d')}"
|
||||||
f"&studentId={self.person_id}"
|
f"&studentId={self.person_id}"
|
||||||
f"&excuseStatusId=-1" # -1 = all statuses
|
f"&excuseStatusId=-1"
|
||||||
)
|
)
|
||||||
resp = self.session.get(url, timeout=15)
|
resp = self.session.get(url, timeout=15)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
# REST endpoint wraps in data.absences or data directly
|
|
||||||
absences = (
|
absences = (
|
||||||
data.get("data", {}).get("absences")
|
data.get("data", {}).get("absences")
|
||||||
or data.get("absences")
|
or data.get("absences")
|
||||||
|
|
@ -113,7 +119,7 @@ class WebUntisClient:
|
||||||
if isinstance(absences, list):
|
if isinstance(absences, list):
|
||||||
return absences
|
return absences
|
||||||
|
|
||||||
# Fallback: JSON-RPC timetableWithAbsences
|
# Fallback
|
||||||
result = self._rpc("getTimetableWithAbsences", {
|
result = self._rpc("getTimetableWithAbsences", {
|
||||||
"options": {
|
"options": {
|
||||||
"startDate": int(start.strftime("%Y%m%d")),
|
"startDate": int(start.strftime("%Y%m%d")),
|
||||||
|
|
@ -124,7 +130,6 @@ class WebUntisClient:
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
# Extract absences from timetable periods
|
|
||||||
absences = []
|
absences = []
|
||||||
for period in result if isinstance(result, list) else result.get("periods", []):
|
for period in result if isinstance(result, list) else result.get("periods", []):
|
||||||
for ab in period.get("absences", []):
|
for ab in period.get("absences", []):
|
||||||
|
|
@ -134,11 +139,48 @@ class WebUntisClient:
|
||||||
absences.append(ab)
|
absences.append(ab)
|
||||||
return absences
|
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 ""
|
||||||
|
|
||||||
# ─── Formatting helpers ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _parse_untis_datetime(date_int, time_int) -> str:
|
# ─── Sorting & filtering ───────────────────────────────────────────────────────
|
||||||
"""Convert Untis integer date/time to readable string."""
|
|
||||||
|
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:
|
try:
|
||||||
d = str(date_int)
|
d = str(date_int)
|
||||||
t = str(time_int).zfill(4)
|
t = str(time_int).zfill(4)
|
||||||
|
|
@ -149,30 +191,30 @@ def _parse_untis_datetime(date_int, time_int) -> str:
|
||||||
return f"{date_int} {time_int}"
|
return f"{date_int} {time_int}"
|
||||||
|
|
||||||
|
|
||||||
def _excuse_label(absence: dict) -> str:
|
def _excuse_label(ab: dict) -> str:
|
||||||
excused = absence.get("isExcused") or absence.get("excused") or absence.get("excuse")
|
excused = ab.get("isExcused") or ab.get("excused") or ab.get("excuse")
|
||||||
if excused is True or excused == 1:
|
if excused is True or excused == 1:
|
||||||
return "✓ Excused"
|
return "✓ Excused"
|
||||||
if excused is False or excused == 0:
|
# if excused is False or excused == 0:
|
||||||
|
else:
|
||||||
return "✗ Unexcused"
|
return "✗ Unexcused"
|
||||||
return "? Unknown"
|
return "? Unknown"
|
||||||
|
|
||||||
|
|
||||||
def format_absence(ab: dict, idx: int) -> str:
|
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", "")
|
||||||
date_val = ab.get("date") or ab.get("startDate", "")
|
start = ab.get("startTime") or ab.get("lessonStartTime", "")
|
||||||
start = ab.get("startTime") or ab.get("lessonStartTime", "")
|
end = ab.get("endTime") or ab.get("lessonEndTime", "")
|
||||||
end = ab.get("endTime") or ab.get("lessonEndTime", "")
|
subject = ab.get("subject") or ab.get("subjectLong") or ab.get("subjectName") or "—"
|
||||||
subject = ab.get("subject") or ab.get("subjectLong") or ab.get("subjectName") or "—"
|
teacher = ab.get("teacher") or ab.get("teacherName") or "—"
|
||||||
teacher = ab.get("teacher") or ab.get("teacherName") or "—"
|
reason = ab.get("reason") or ab.get("text") or ""
|
||||||
reason = ab.get("reason") or ab.get("text") or ""
|
excuse = _excuse_label(ab)
|
||||||
excuse = _excuse_label(ab)
|
minutes = ab.get("absentTime") or ab.get("minutesAbsent") or ""
|
||||||
minutes = ab.get("absentTime") or ab.get("minutesAbsent") or ""
|
|
||||||
|
|
||||||
if date_val and start:
|
if date_val and start:
|
||||||
when = _parse_untis_datetime(date_val, start)
|
when = _parse_dt(date_val, start)
|
||||||
when_end = _parse_untis_datetime(date_val, end) if end else ""
|
when_end = _parse_dt(date_val, end) if end else ""
|
||||||
time_str = f"{when}" + (f" → {when_end.split(' ')[1]}" if when_end else "")
|
time_str = when + (f" → {when_end.split(' ')[1]}" if when_end else "")
|
||||||
else:
|
else:
|
||||||
time_str = str(date_val) or "unknown"
|
time_str = str(date_val) or "unknown"
|
||||||
|
|
||||||
|
|
@ -192,27 +234,220 @@ def format_absence(ab: dict, idx: int) -> str:
|
||||||
|
|
||||||
def build_display(absences: list[dict], title: str) -> str:
|
def build_display(absences: list[dict], title: str) -> str:
|
||||||
if not absences:
|
if not absences:
|
||||||
return " No absences found for the selected period.\n"
|
return " No absences found for the selected period / filter.\n"
|
||||||
header = f" {title}\n {'─' * 60}\n\n"
|
header = f" {title}\n {'─' * 60}\n\n"
|
||||||
body = "".join(format_absence(ab, i) for i, ab in enumerate(absences))
|
body = "".join(format_absence(ab, i) for i, ab in enumerate(absences))
|
||||||
footer = f"\n Total: {len(absences)} absence(s)\n"
|
footer = f"\n Total: {len(absences)} absence(s)\n"
|
||||||
return header + body + footer
|
return header + body + footer
|
||||||
|
|
||||||
|
|
||||||
# ─── TUI ─────────────────────────────────────────────────────────────────────
|
# ─── 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({
|
STYLE = Style.from_dict({
|
||||||
"frame.border": "ansicyan",
|
"frame.border": "ansicyan",
|
||||||
"frame.title": "bold ansicyan",
|
"frame.title": "bold ansicyan",
|
||||||
"status": "reverse ansicyan",
|
"status": "reverse ansicyan",
|
||||||
"key": "bold ansiwhite",
|
"text-area": "ansiwhite",
|
||||||
"text-area": "ansiwhite",
|
|
||||||
"label": "ansiyellow",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def run_tui(absences: list[dict], server: str, school: str,
|
def run_tui(
|
||||||
start: date, end: date) -> None:
|
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 = KeyBindings()
|
||||||
|
|
||||||
@kb.add("q")
|
@kb.add("q")
|
||||||
|
|
@ -221,31 +456,26 @@ def run_tui(absences: list[dict], server: str, school: str,
|
||||||
def _quit(event):
|
def _quit(event):
|
||||||
event.app.exit()
|
event.app.exit()
|
||||||
|
|
||||||
@kb.add("r")
|
@kb.add("f")
|
||||||
def _refresh(event):
|
def _toggle_filter(event):
|
||||||
content.text = " Press 'r' again after data reloads…\n (restart the script to fetch fresh data)"
|
state["filter"] = not state["filter"]
|
||||||
|
_refresh()
|
||||||
|
|
||||||
period_title = (
|
@kb.add("e")
|
||||||
f"Absences for {school} — "
|
def _export(event):
|
||||||
f"{start.strftime('%d.%m.%Y')} to {end.strftime('%d.%m.%Y')}"
|
if template_path:
|
||||||
)
|
event.app.exit(result="export")
|
||||||
display_text = build_display(absences, period_title)
|
# if no template, key does nothing
|
||||||
|
|
||||||
content = TextArea(
|
|
||||||
text=display_text,
|
|
||||||
read_only=True,
|
|
||||||
scrollbar=True,
|
|
||||||
style="class:text-area",
|
|
||||||
wrap_lines=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
pdf_hint = " <b>[E]</b> Export PDF" if template_path else ""
|
||||||
status_bar = Window(
|
status_bar = Window(
|
||||||
content=FormattedTextControl(
|
content=FormattedTextControl(
|
||||||
HTML(
|
HTML(
|
||||||
" <b>[Q]</b> Quit "
|
" <b>[Q]</b> Quit"
|
||||||
" <b>[↑/↓]</b> Scroll "
|
" <b>[F]</b> Toggle unexcused filter"
|
||||||
f" <b>Server:</b> {server} "
|
f"{pdf_hint}"
|
||||||
f" <b>Fetched:</b> {datetime.now().strftime('%H:%M:%S')}"
|
" <b>[↑/↓]</b> Scroll"
|
||||||
|
f" <b>Fetched:</b> {datetime.now().strftime('%H:%M:%S')}"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
height=1,
|
height=1,
|
||||||
|
|
@ -268,21 +498,27 @@ def run_tui(absences: list[dict], server: str, school: str,
|
||||||
full_screen=True,
|
full_screen=True,
|
||||||
mouse_support=True,
|
mouse_support=True,
|
||||||
)
|
)
|
||||||
app.run()
|
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 ──────────────────────────────────────────────────────────────────────
|
# ─── CLI ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def prompt_missing(args):
|
def prompt_missing(args):
|
||||||
"""Interactively prompt for any missing arguments."""
|
|
||||||
if not args.server:
|
if not args.server:
|
||||||
args.server = input("WebUntis server (e.g. mese.webuntis.com): ").strip()
|
args.server = input("WebUntis server (e.g. mese.webuntis.com): ").strip()
|
||||||
if not args.school:
|
if not args.school:
|
||||||
args.school = input("School name (as shown in the URL): ").strip()
|
args.school = input("School name (from the WebUntis URL): ").strip()
|
||||||
if not args.username:
|
if not args.username:
|
||||||
args.username = input("Username: ").strip()
|
args.username = input("Username: ").strip()
|
||||||
if not args.password:
|
if not args.password:
|
||||||
import getpass
|
|
||||||
args.password = getpass.getpass("Password: ")
|
args.password = getpass.getpass("Password: ")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -294,6 +530,8 @@ def main():
|
||||||
parser.add_argument("--password", help="Your WebUntis password (omit to be prompted)")
|
parser.add_argument("--password", help="Your WebUntis password (omit to be prompted)")
|
||||||
parser.add_argument("--days", type=int, default=365,
|
parser.add_argument("--days", type=int, default=365,
|
||||||
help="How many past days to include (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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
prompt_missing(args)
|
prompt_missing(args)
|
||||||
|
|
@ -307,6 +545,12 @@ def main():
|
||||||
try:
|
try:
|
||||||
print("Logging in …")
|
print("Logging in …")
|
||||||
client.login(args.username, args.password)
|
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} …")
|
print(f"Fetching absences from {start_date} to {end_date} …")
|
||||||
absences = client.get_absences(start_date, end_date)
|
absences = client.get_absences(start_date, end_date)
|
||||||
print(f"Got {len(absences)} absence(s). Launching viewer …")
|
print(f"Got {len(absences)} absence(s). Launching viewer …")
|
||||||
|
|
@ -316,7 +560,16 @@ def main():
|
||||||
finally:
|
finally:
|
||||||
client.logout()
|
client.logout()
|
||||||
|
|
||||||
run_tui(absences, args.server, args.school, start_date, end_date)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue