"""Tkinter-GUI mit Endlosfeldern (eine Karte = ein Account).""" from __future__ import annotations import csv import threading from pathlib import Path import tkinter as tk from tkinter import filedialog, messagebox, scrolledtext, ttk from config import Config from deploy import parse_csv, pdf_only, run_deploy from models import Account # Felder gruppiert: (key, label, breite, gruppe) # gruppe = None → kommt in die obere "Person"-Zeile GROUPS = { "person": [ ("vorname", "Vorname", 14), ("name", "Name", 14), ("emailadresse", "Emailadresse", 34), ], "Plesk": [ ("pleskhost", "Host", 20), ("pleskemailkennwort", "Passwort", 16), ], "Kerio": [ ("keriohost", "Host", 20), ("kerioemailkennwort", "Passwort", 16), ], "Nextcloud": [ ("nextcloudhost", "Host", 20), ("nextcloudkennwort", "Passwort", 16), ("nextcloudgruppe", "Gruppe", 12), ("nextcloudspeicher", "GB (∞=leer)", 10), ], } ALL_FIELD_KEYS = [k for grp in GROUPS.values() for (k, _l, _w) in grp] class AccountRow(ttk.Frame): def __init__(self, master, on_remove, index_label_var: tk.StringVar): super().__init__(master, padding=(8, 6, 8, 8), borderwidth=1, relief="solid") self.entries: dict[str, ttk.Entry] = {} # ---------- obere Zeile: Index-Chip + Person + löschen ---------- top = ttk.Frame(self) top.pack(fill="x", pady=(0, 6)) ttk.Label(top, textvariable=index_label_var, font=("Helvetica", 10, "bold"), foreground="#666").pack(side="left", padx=(0, 12)) for key, label, width in GROUPS["person"]: self._make_field(top, key, label, width, side="left") ttk.Button(top, text="✕ löschen", style="Remove.TButton", command=lambda: on_remove(self)).pack(side="right") # ---------- untere Zeile: drei Service-Gruppen ---------- bot = ttk.Frame(self) bot.pack(fill="x") for i, group_name in enumerate(("Plesk", "Kerio", "Nextcloud")): if i > 0: ttk.Separator(bot, orient="vertical").pack( side="left", fill="y", padx=10, pady=2) grp = ttk.Frame(bot) grp.pack(side="left", anchor="n") ttk.Label(grp, text=group_name, font=("Helvetica", 9, "bold"), foreground="#3060a0").grid(row=0, column=0, columnspan=4, sticky="w") for col, (key, label, width) in enumerate(GROUPS[group_name]): cell = ttk.Frame(grp) cell.grid(row=1, column=col, padx=2, sticky="w") ttk.Label(cell, text=label, font=("Helvetica", 8)).pack(anchor="w") show = "•" if "kennwort" in key else None e = ttk.Entry(cell, width=width, show=show) e.pack() self.entries[key] = e def _make_field(self, parent, key, label, width, side="left"): cell = ttk.Frame(parent) cell.pack(side=side, padx=4) ttk.Label(cell, text=label, font=("Helvetica", 8)).pack(anchor="w") e = ttk.Entry(cell, width=width) e.pack() self.entries[key] = e def to_dict(self) -> dict: return {k: e.get().strip() for k, e in self.entries.items()} def from_dict(self, d: dict) -> None: for k, e in self.entries.items(): e.delete(0, "end") v = d.get(k, "") e.insert(0, "" if v is None else str(v)) class App(tk.Tk): def __init__(self): super().__init__() self.title("Email / Cloud Account Deployment") self.output_dir = Path("./output").resolve() self.rows: list[AccountRow] = [] self.row_index_vars: list[tk.StringVar] = [] # Roter Lösch-Button-Style style = ttk.Style(self) try: style.configure("Remove.TButton", foreground="#b00020") except tk.TclError: pass self._maximize() self._build() self.add_row() # ----- Window-Modi ----- def _maximize(self): """Fenster auf Vollbild bzw. maximiert öffnen, plattform-unabhängig.""" # Linux X11 try: self.attributes("-zoomed", True) return except tk.TclError: pass # Windows try: self.state("zoomed") return except tk.TclError: pass # Fallback: Bildschirmgröße self.update_idletasks() sw = self.winfo_screenwidth() sh = self.winfo_screenheight() self.geometry(f"{sw}x{sh}+0+0") # ----- Layout ----- def _build(self): toolbar = ttk.Frame(self) toolbar.pack(fill="x", padx=8, pady=6) ttk.Button(toolbar, text="+ Zeile", command=self.add_row).pack(side="left", padx=2) ttk.Button(toolbar, text="CSV laden …", command=self.load_csv).pack(side="left", padx=2) ttk.Button(toolbar, text="CSV speichern …", command=self.save_csv).pack(side="left", padx=2) ttk.Button(toolbar, text="Ausgabeordner …", command=self.choose_output).pack(side="left", padx=2) self.output_lbl = ttk.Label(toolbar, text=f"Output: {self.output_dir}") self.output_lbl.pack(side="left", padx=8) self.run_btn = ttk.Button(toolbar, text="Ausführen ▶", command=self.run) self.run_btn.pack(side="right", padx=2) self.pdf_btn = ttk.Button(toolbar, text="📄 Nur PDF", command=self.run_pdf_only) self.pdf_btn.pack(side="right", padx=2) # Scrollbares Eingabefeld wrap = ttk.Frame(self) wrap.pack(fill="both", expand=True, padx=8, pady=4) self.canvas = tk.Canvas(wrap, highlightthickness=0) sb = ttk.Scrollbar(wrap, orient="vertical", command=self.canvas.yview) self.canvas.configure(yscrollcommand=sb.set) self.canvas.pack(side="left", fill="both", expand=True) sb.pack(side="right", fill="y") self.rows_frame = ttk.Frame(self.canvas) self.rows_window = self.canvas.create_window( (0, 0), window=self.rows_frame, anchor="nw") self.rows_frame.bind( "", lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")), ) # Karten füllen die volle Breite des Canvas self.canvas.bind( "", lambda e: self.canvas.itemconfigure(self.rows_window, width=e.width), ) # Mousewheel-Scrollen self.canvas.bind_all("", lambda e: self.canvas.yview_scroll(int(-e.delta / 120), "units")) self.canvas.bind_all("", lambda e: self.canvas.yview_scroll(-1, "units")) self.canvas.bind_all("", lambda e: self.canvas.yview_scroll(1, "units")) ttk.Label(self, text="Log:").pack(anchor="w", padx=8) self.log = scrolledtext.ScrolledText(self, height=14, wrap="word", font=("Monospace", 9)) self.log.pack(fill="x", padx=8, pady=(0, 8)) # ----- Reihen-Handling ----- def _renumber(self): for i, var in enumerate(self.row_index_vars, 1): var.set(f"#{i}") def add_row(self, data: dict | None = None): idx_var = tk.StringVar(value="") row = AccountRow(self.rows_frame, on_remove=self.remove_row, index_label_var=idx_var) row.pack(fill="x", pady=4, padx=2) self.row_index_vars.append(idx_var) self.rows.append(row) if data: row.from_dict(data) self._renumber() def remove_row(self, row): idx = self.rows.index(row) self.rows.pop(idx) self.row_index_vars.pop(idx) row.destroy() if not self.rows: self.add_row() else: self._renumber() # ----- Buttons ----- def load_csv(self): path = filedialog.askopenfilename( filetypes=[("CSV", "*.csv"), ("Alle Dateien", "*.*")]) if not path: return try: accounts = parse_csv(path) except Exception as e: messagebox.showerror("CSV-Fehler", str(e)) return for r in self.rows: r.destroy() self.rows = [] self.row_index_vars = [] for acc in accounts: self.add_row({ "vorname": acc.vorname, "name": acc.name, "emailadresse": acc.emailadresse, "pleskhost": acc.pleskhost, "keriohost": acc.keriohost, "nextcloudhost": acc.nextcloudhost, "pleskemailkennwort": acc.pleskemailkennwort, "kerioemailkennwort": acc.kerioemailkennwort, "nextcloudkennwort": acc.nextcloudkennwort, "nextcloudgruppe": acc.nextcloudgruppe, "nextcloudspeicher": "" if acc.nextcloudspeicher is None else str(acc.nextcloudspeicher), }) if not self.rows: self.add_row() self._log(f"CSV geladen: {len(accounts)} Account(s)") # Spalten-Reihenfolge wie in example.csv – das, was parse_csv erwartet. CSV_COLUMNS = [ ("Vorname", "vorname"), ("Name", "name"), ("emailadresse", "emailadresse"), ("pleskhost", "pleskhost"), ("keriohost", "keriohost"), ("nextcloudhost", "nextcloudhost"), ("kerioemailkennwort", "kerioemailkennwort"), ("pleskemailkennwort", "pleskemailkennwort"), ("nextcloudgruppe", "nextcloudgruppe"), ("nextcloudspeicher", "nextcloudspeicher"), ("nextcloudkennwort", "nextcloudkennwort"), ] def save_csv(self): rows_with_data = [r for r in self.rows if r.to_dict().get("emailadresse")] if not rows_with_data: messagebox.showinfo("Hinweis", "Keine ausgefüllten Karten zum Speichern (Emailadresse leer).") return path = filedialog.asksaveasfilename( defaultextension=".csv", initialfile="accounts.csv", filetypes=[("CSV (Semikolon)", "*.csv")]) if not path: return try: with open(path, "w", encoding="utf-8", newline="") as f: w = csv.writer(f, delimiter=";") w.writerow([h for h, _k in self.CSV_COLUMNS]) for row in rows_with_data: d = row.to_dict() w.writerow([d.get(k, "") for _h, k in self.CSV_COLUMNS]) except OSError as e: messagebox.showerror("Speicher-Fehler", str(e)) return self._log(f"CSV gespeichert: {path} ({len(rows_with_data)} Zeile(n))") messagebox.showinfo("Gespeichert", f"{len(rows_with_data)} Zeile(n) in\n{path}\ngespeichert.\n\n" "⚠ Datei enthält Klartext-Passwörter – sicher ablegen / verschlüsselt versenden.") def choose_output(self): path = filedialog.askdirectory(initialdir=str(self.output_dir)) if path: self.output_dir = Path(path) self.output_lbl.config(text=f"Output: {self.output_dir}") # ----- Ausführen ----- def _collect_accounts(self) -> list[Account]: accounts = [] for idx, r in enumerate(self.rows, 1): d = r.to_dict() if not d.get("emailadresse"): continue required = ["name", "vorname", "emailadresse", "pleskhost", "keriohost", "nextcloudhost", "pleskemailkennwort", "kerioemailkennwort", "nextcloudkennwort"] missing = [k for k in required if not d.get(k)] if missing: raise ValueError(f"Karte #{idx}: Pflichtfelder leer: {', '.join(missing)}") quota = int(d["nextcloudspeicher"]) if d.get("nextcloudspeicher") else None accounts.append(Account( name=d["name"], vorname=d["vorname"], emailadresse=d["emailadresse"], pleskhost=d["pleskhost"], keriohost=d["keriohost"], nextcloudhost=d["nextcloudhost"], kerioemailkennwort=d["kerioemailkennwort"], pleskemailkennwort=d["pleskemailkennwort"], nextcloudgruppe=d.get("nextcloudgruppe", ""), nextcloudspeicher=quota, nextcloudkennwort=d["nextcloudkennwort"], )) return accounts def _log(self, msg: str): self.log.insert("end", msg + "\n") self.log.see("end") def _start_worker(self, fn, success_title: str): try: accounts = self._collect_accounts() except ValueError as e: messagebox.showerror("Eingabe ungültig", str(e)) return if not accounts: messagebox.showinfo("Hinweis", "Keine Accounts eingetragen.") return cfg = Config() self.log.delete("1.0", "end") self.run_btn.config(state="disabled") self.pdf_btn.config(state="disabled") def worker(): try: combined = fn(accounts, cfg, self.output_dir, log=lambda m: self.after(0, self._log, m)) # run_deploy gibt (results, combined) zurück, pdf_only nur combined if isinstance(combined, tuple): combined = combined[1] self.after(0, lambda: messagebox.showinfo( success_title, f"Fertig.\n\nGesamt-PDF:\n{combined}")) except Exception as e: self.after(0, lambda: self._log(f"FEHLER: {e}")) self.after(0, lambda: messagebox.showerror("Fehler", str(e))) finally: self.after(0, lambda: self.run_btn.config(state="normal")) self.after(0, lambda: self.pdf_btn.config(state="normal")) threading.Thread(target=worker, daemon=True).start() def run(self): self._start_worker(run_deploy, "Verarbeitung abgeschlossen") def run_pdf_only(self): self._start_worker(pdf_only, "Nur PDFs erzeugt") def launch(): App().mainloop() if __name__ == "__main__": launch()