schizo v4 working export
This commit is contained in:
parent
a423d0a645
commit
64d022d016
3 changed files with 193 additions and 142 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
*.pdf
|
||||||
|
|
||||||
|
!Entschuldigung_template.pdf
|
||||||
Binary file not shown.
332
main.py
332
main.py
|
|
@ -260,166 +260,184 @@ def build_display(absences: list[dict], title: str) -> str:
|
||||||
|
|
||||||
# ─── PDF filling ───────────────────────────────────────────────────────────────
|
# ─── 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 ─────────────────────────────────────────────────────────
|
# ─── PDF export dialog ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _absence_date_str(ab: dict) -> str:
|
||||||
|
"""Return the absence date as DD.MM.YYYY string."""
|
||||||
|
dv = ab.get("date") or ab.get("startDate", "")
|
||||||
|
try:
|
||||||
|
d = str(dv)
|
||||||
|
return f"{d[6:8]}.{d[4:6]}.{d[:4]}"
|
||||||
|
except Exception:
|
||||||
|
return str(dv)
|
||||||
|
|
||||||
|
|
||||||
|
def _absence_date_suffix(ab: dict) -> str:
|
||||||
|
"""Return DDMMYYYY for use in the filename."""
|
||||||
|
dv = ab.get("date") or ab.get("startDate", "")
|
||||||
|
try:
|
||||||
|
d = str(dv)
|
||||||
|
return f"{d[6:8]}{d[4:6]}{d[:4]}"
|
||||||
|
except Exception:
|
||||||
|
return datetime.now().strftime("%d%m%Y")
|
||||||
|
|
||||||
|
|
||||||
def run_pdf_export_dialog(
|
def run_pdf_export_dialog(
|
||||||
absences: list[dict],
|
absences: list[dict],
|
||||||
template_path: str,
|
template_path: str,
|
||||||
student_name: str,
|
student_name: str,
|
||||||
student_class: str,
|
student_class: str,
|
||||||
|
cfg: dict | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Simple terminal prompts to collect data and fill the PDF."""
|
"""
|
||||||
|
For each selected absence, generate one individual PDF named
|
||||||
|
EntschuldigungDDMMYYYY.pdf where the date is the absence date.
|
||||||
|
"""
|
||||||
print("\n─── Export Entschuldigung PDF ─────────────────────────────")
|
print("\n─── Export Entschuldigung PDF ─────────────────────────────")
|
||||||
|
|
||||||
# Pick absence(s) to excuse
|
# Show unexcused absences grouped by date (newest first)
|
||||||
unexcused = [a for a in absences if _is_unexcused(a)]
|
unexcused = filter_absences(absences, unexcused_only=True)
|
||||||
if not unexcused:
|
if not unexcused:
|
||||||
print("No unexcused absences found.")
|
print("No unexcused absences found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
print("\nUnexcused absences:")
|
# Group by date so the user can see days clearly
|
||||||
for i, ab in enumerate(unexcused[:20]):
|
from collections import defaultdict
|
||||||
date_val = ab.get("date") or ab.get("startDate", "")
|
by_date: dict[str, list[dict]] = defaultdict(list)
|
||||||
start = ab.get("startTime") or ab.get("lessonStartTime", "")
|
for ab in unexcused:
|
||||||
print(f" [{i+1}] {_parse_dt(date_val, start)}")
|
by_date[_absence_date_str(ab)].append(ab)
|
||||||
|
|
||||||
sel = input("\nEnter number(s) to excuse (comma-separated, or 'all'): ").strip()
|
dates_in_order = list(dict.fromkeys(_absence_date_str(ab) for ab in unexcused))
|
||||||
|
|
||||||
|
print("\nUnexcused absences (one PDF will be created per entry):")
|
||||||
|
idx = 0
|
||||||
|
entry_map: list[dict] = [] # flat list matching printed numbers
|
||||||
|
for ds in dates_in_order:
|
||||||
|
print(f" {ds}")
|
||||||
|
for ab in by_date[ds]:
|
||||||
|
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 "—"
|
||||||
|
minutes = ab.get("absentTime") or ab.get("minutesAbsent") or "?"
|
||||||
|
t_start = str(start).zfill(4)
|
||||||
|
t_end = str(end).zfill(4) if end else ""
|
||||||
|
time_str = f"{t_start[:2]}:{t_start[2:]}"
|
||||||
|
if t_end:
|
||||||
|
time_str += f"–{t_end[:2]}:{t_end[2:]}"
|
||||||
|
print(f" [{idx+1}] {time_str} {subject} ({minutes} min)")
|
||||||
|
entry_map.append(ab)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
sel = input("\nEnter number(s) to export (comma-separated, or 'all'): ").strip()
|
||||||
if sel.lower() == "all":
|
if sel.lower() == "all":
|
||||||
selected = unexcused[:20]
|
selected = entry_map
|
||||||
else:
|
else:
|
||||||
indices = [int(x.strip()) - 1 for x in sel.split(",") if x.strip().isdigit()]
|
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)]
|
selected = [entry_map[i] for i in indices if 0 <= i < len(entry_map)]
|
||||||
|
|
||||||
if not selected:
|
if not selected:
|
||||||
print("No valid selection.")
|
print("No valid selection.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Build date string
|
# ── Shared fields (same for every PDF) ──────────────────────────────
|
||||||
dates_sorted = sorted(selected, key=_absence_sort_key)
|
cfg = cfg or {}
|
||||||
date_strs = []
|
saved_name = student_name or cfg.get("student_name", "")
|
||||||
for ab in dates_sorted:
|
saved_class = student_class or cfg.get("student_class", "")
|
||||||
dv = ab.get("date") or ab.get("startDate", "")
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Yolo mode — accept all defaults, only stop for truly unknown values
|
||||||
|
yolo = input(" Yolo mode — accept all defaults? [y/N]: ").strip().lower() == "y"
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Name — always prompt if unknown (even in yolo)
|
||||||
|
if not saved_name:
|
||||||
|
saved_name = input(" Student name (required): ").strip()
|
||||||
|
elif not yolo:
|
||||||
|
v = input(f" Student name [{saved_name}]: ").strip()
|
||||||
|
if v:
|
||||||
|
saved_name = v
|
||||||
|
final_name = saved_name
|
||||||
|
|
||||||
|
# Class — always prompt if unknown
|
||||||
|
if not saved_class:
|
||||||
|
saved_class = input(" Class (required): ").strip()
|
||||||
|
elif not yolo:
|
||||||
|
v = input(f" Class [{saved_class}]: ").strip()
|
||||||
|
if v:
|
||||||
|
saved_class = v
|
||||||
|
final_class = saved_class
|
||||||
|
|
||||||
|
save_config({"student_name": final_name, "student_class": final_class})
|
||||||
|
|
||||||
|
sign_date_default = datetime.now().strftime("%d.%m.%Y")
|
||||||
|
saved_output_dir = cfg.get("output_dir", os.getcwd())
|
||||||
|
|
||||||
|
if yolo:
|
||||||
|
sign_date = sign_date_default
|
||||||
|
output_dir = saved_output_dir
|
||||||
|
else:
|
||||||
|
v = input(f" Signature date [{sign_date_default}]: ").strip()
|
||||||
|
sign_date = v if v else sign_date_default
|
||||||
|
v = input(f" Save to directory [{saved_output_dir}]: ").strip()
|
||||||
|
output_dir = v if v else saved_output_dir
|
||||||
|
save_config({"output_dir": output_dir})
|
||||||
|
|
||||||
|
# ── Per-entry prompts + PDF generation ──────────────────────────────
|
||||||
|
from pypdf import PdfReader, PdfWriter
|
||||||
|
|
||||||
|
print()
|
||||||
|
saved_files, failed = [], []
|
||||||
|
for ab in selected:
|
||||||
|
absence_date = _absence_date_str(ab)
|
||||||
|
start_raw = ab.get("startTime") or ab.get("lessonStartTime", "")
|
||||||
|
t = str(start_raw).zfill(4)
|
||||||
|
label = f"{absence_date} {t[:2]}:{t[2:]}" # e.g. "16.06.2026 11:40"
|
||||||
|
|
||||||
|
minutes_raw = ab.get("absentTime") or ab.get("minutesAbsent") or 50
|
||||||
|
default_hours = max(1, round(int(minutes_raw) / 50))
|
||||||
|
default_reason = ab.get("reason") or ab.get("text") or "Krankheit"
|
||||||
|
default_workshop = 0
|
||||||
|
|
||||||
|
if yolo:
|
||||||
|
final_reason = default_reason
|
||||||
|
final_hours = default_hours
|
||||||
|
final_workshop = default_workshop
|
||||||
|
else:
|
||||||
|
r = input(f" Reason {label} [{default_reason}]: ").strip()
|
||||||
|
final_reason = r if r else default_reason
|
||||||
|
|
||||||
|
h = input(f" Hours {label} [{default_hours}]: ").strip()
|
||||||
|
final_hours = int(h) if h.isdigit() else default_hours
|
||||||
|
|
||||||
|
w = input(f" Workshop {label} [{default_workshop}]: ").strip()
|
||||||
|
final_workshop = int(w) if w.isdigit() else default_workshop
|
||||||
|
|
||||||
|
filename = f"Entschuldigung{_absence_date_suffix(ab)}.pdf"
|
||||||
|
out_path = os.path.join(output_dir, filename)
|
||||||
try:
|
try:
|
||||||
d = str(dv)
|
reader = PdfReader(template_path)
|
||||||
date_strs.append(f"{d[6:8]}.{d[4:6]}.{d[:4]}")
|
writer = PdfWriter()
|
||||||
except Exception:
|
writer.append(reader)
|
||||||
date_strs.append(str(dv))
|
writer.update_page_form_field_values(writer.pages[0], {
|
||||||
unique_dates = list(dict.fromkeys(date_strs))
|
"Textfeld 1": final_name,
|
||||||
absence_dates = ", ".join(unique_dates)
|
"Textfeld 2": final_class,
|
||||||
|
"Textfeld 4": absence_date,
|
||||||
|
"Textfeld 3": final_reason,
|
||||||
|
"Textfeld 5": str(final_hours),
|
||||||
|
"Textfeld 6": str(final_workshop),
|
||||||
|
"Textfeld 7": sign_date,
|
||||||
|
})
|
||||||
|
with open(out_path, "wb") as f:
|
||||||
|
writer.write(f)
|
||||||
|
print(f" ✅ {filename}")
|
||||||
|
saved_files.append(out_path)
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ {filename}: {e}")
|
||||||
|
failed.append(filename)
|
||||||
|
|
||||||
# Count hours (each absence period ≈ 1 lesson; use absentTime if available)
|
print(f"\n Done: {len(saved_files)} saved, {len(failed)} failed.\n")
|
||||||
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 ──────────────────────────────────────────────────────────────────────
|
# ─── TUI ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -441,6 +459,7 @@ def run_tui(
|
||||||
template_path: str | None,
|
template_path: str | None,
|
||||||
student_name: str,
|
student_name: str,
|
||||||
student_class: str,
|
student_class: str,
|
||||||
|
cfg: dict | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
state = {
|
state = {
|
||||||
"filter": False, # True = show only unexcused
|
"filter": False, # True = show only unexcused
|
||||||
|
|
@ -523,18 +542,41 @@ def run_tui(
|
||||||
template_path=template_path,
|
template_path=template_path,
|
||||||
student_name=student_name,
|
student_name=student_name,
|
||||||
student_class=student_class,
|
student_class=student_class,
|
||||||
|
cfg=cfg or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Config file ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CONFIG_PATH = Path.home() / ".webuntis_absence_viewer.json"
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(CONFIG_PATH.read_text())
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def save_config(data: dict) -> None:
|
||||||
|
try:
|
||||||
|
existing = load_config()
|
||||||
|
existing.update(data)
|
||||||
|
CONFIG_PATH.write_text(json.dumps(existing, indent=2))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ─── CLI ──────────────────────────────────────────────────────────────────────
|
# ─── CLI ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def prompt_missing(args):
|
def prompt_missing(args, cfg: dict) -> None:
|
||||||
if not args.server:
|
if not args.server:
|
||||||
args.server = input("WebUntis server (e.g. mese.webuntis.com): ").strip()
|
default = cfg.get("server", "")
|
||||||
|
args.server = input(f"WebUntis server [{default}]: ").strip() or default
|
||||||
if not args.school:
|
if not args.school:
|
||||||
args.school = input("School name (from the WebUntis URL): ").strip()
|
default = cfg.get("school", "")
|
||||||
|
args.school = input(f"School name [{default}]: ").strip() or default
|
||||||
if not args.username:
|
if not args.username:
|
||||||
args.username = input("Username: ").strip()
|
default = cfg.get("username", "")
|
||||||
|
args.username = input(f"Username [{default}]: ").strip() or default
|
||||||
if not args.password:
|
if not args.password:
|
||||||
args.password = getpass.getpass("Password: ")
|
args.password = getpass.getpass("Password: ")
|
||||||
|
|
||||||
|
|
@ -551,7 +593,11 @@ def main():
|
||||||
default=None)
|
default=None)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
prompt_missing(args)
|
cfg = load_config()
|
||||||
|
prompt_missing(args, cfg)
|
||||||
|
|
||||||
|
# Persist server/school/username for next run
|
||||||
|
save_config({"server": args.server, "school": args.school, "username": args.username})
|
||||||
|
|
||||||
end_date = date.today()
|
end_date = date.today()
|
||||||
start_date = end_date - timedelta(days=args.days)
|
start_date = end_date - timedelta(days=args.days)
|
||||||
|
|
@ -563,8 +609,9 @@ def main():
|
||||||
print("Logging in …")
|
print("Logging in …")
|
||||||
client.login(args.username, args.password)
|
client.login(args.username, args.password)
|
||||||
|
|
||||||
student_name = client.display_name
|
# Use API result; fall back to saved values from config
|
||||||
student_class = client.get_student_class()
|
student_name = client.display_name or cfg.get("student_name", "")
|
||||||
|
student_class = client.get_student_class() or cfg.get("student_class", "")
|
||||||
|
|
||||||
print(f"Logged in as: {args.username}"
|
print(f"Logged in as: {args.username}"
|
||||||
+ (f" (name: {student_name})" if student_name else ""))
|
+ (f" (name: {student_name})" if student_name else ""))
|
||||||
|
|
@ -586,6 +633,7 @@ def main():
|
||||||
template_path=args.template,
|
template_path=args.template,
|
||||||
student_name=student_name,
|
student_name=student_name,
|
||||||
student_class=student_class,
|
student_class=student_class,
|
||||||
|
cfg=cfg,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue