diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e48794e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pdf + +!Entschuldigung_template.pdf diff --git a/Entschuldigung16062026.pdf b/Entschuldigung16062026.pdf deleted file mode 100644 index 6df1b82..0000000 Binary files a/Entschuldigung16062026.pdf and /dev/null differ diff --git a/main.py b/main.py index f793e88..c4d1c41 100644 --- a/main.py +++ b/main.py @@ -260,166 +260,184 @@ def build_display(absences: list[dict], title: str) -> str: # ─── 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 _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( absences: list[dict], template_path: str, student_name: str, student_class: str, + cfg: dict | 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 ─────────────────────────────") - # Pick absence(s) to excuse - unexcused = [a for a in absences if _is_unexcused(a)] + # Show unexcused absences grouped by date (newest first) + unexcused = filter_absences(absences, unexcused_only=True) 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)}") + # Group by date so the user can see days clearly + from collections import defaultdict + by_date: dict[str, list[dict]] = defaultdict(list) + for ab in unexcused: + 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": - selected = unexcused[:20] + selected = entry_map 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)] + indices = [int(x.strip()) - 1 for x in sel.split(",") if x.strip().isdigit()] + selected = [entry_map[i] for i in indices if 0 <= i < len(entry_map)] 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", "") + # ── Shared fields (same for every PDF) ────────────────────────────── + cfg = cfg or {} + saved_name = student_name or cfg.get("student_name", "") + saved_class = student_class or cfg.get("student_class", "") + + 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: - 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) + reader = PdfReader(template_path) + writer = PdfWriter() + writer.append(reader) + writer.update_page_form_field_values(writer.pages[0], { + "Textfeld 1": final_name, + "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) - 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") + print(f"\n Done: {len(saved_files)} saved, {len(failed)} failed.\n") # ─── TUI ────────────────────────────────────────────────────────────────────── @@ -441,6 +459,7 @@ def run_tui( template_path: str | None, student_name: str, student_class: str, + cfg: dict | None = None, ) -> None: state = { "filter": False, # True = show only unexcused @@ -523,18 +542,41 @@ def run_tui( template_path=template_path, student_name=student_name, 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 ────────────────────────────────────────────────────────────────────── -def prompt_missing(args): +def prompt_missing(args, cfg: dict) -> None: 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: - 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: - args.username = input("Username: ").strip() + default = cfg.get("username", "") + args.username = input(f"Username [{default}]: ").strip() or default if not args.password: args.password = getpass.getpass("Password: ") @@ -551,7 +593,11 @@ def main(): default=None) 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() start_date = end_date - timedelta(days=args.days) @@ -563,8 +609,9 @@ def main(): print("Logging in …") client.login(args.username, args.password) - student_name = client.display_name - student_class = client.get_student_class() + # Use API result; fall back to saved values from config + 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}" + (f" (name: {student_name})" if student_name else "")) @@ -586,6 +633,7 @@ def main(): template_path=args.template, student_name=student_name, student_class=student_class, + cfg=cfg, )