diff --git a/README.md b/README.md index c31e3c0..e9d078a 100644 --- a/README.md +++ b/README.md @@ -154,14 +154,30 @@ python deploy.py --gui python deploy.py ``` -In der GUI: -- **+ Zeile** fügt eine neue Eingabezeile hinzu (Endlosfelder). -- **✕ löschen** entfernt eine Zeile (rote Schrift). -- **CSV laden** füllt die Felder aus einer bestehenden CSV. -- **Ausgabeordner** wählen. -- **Ausführen ▶** legt die Konten an + erzeugt PDFs + Admin-Report. -- **📄 Nur PDF** schreibt nur die PDFs ohne API-Aufrufe. -- Log unten zeigt Fortschritt. +Das Fenster startet maximiert. Jeder Account ist eine **Karte** mit zwei +Zeilen: oben die Person (Vorname, Name, Email) + Lösch-Button, unten drei +Service-Gruppen (Plesk / Kerio / Nextcloud) mit Host + Passwort. Passwort- +Felder sind maskiert. Die Karten sind mit `#1, #2, …` durchnummeriert, +Fehlermeldungen referenzieren diese Nummern. + +Buttons in der Toolbar: + +| Button | Wirkung | +| ------------------ | ------------------------------------------------------------------ | +| `+ Zeile` | Neue leere Karte am Ende anhängen | +| `CSV laden …` | Felder aus einer bestehenden CSV befüllen | +| `CSV speichern …` | Aktuell ausgefüllte Karten als CSV exportieren (Round-Trip-fähig) | +| `Ausgabeordner …` | Zielverzeichnis für PDFs + Admin-Report wählen | +| `📄 Nur PDF` | Nur PDFs schreiben, keine API-Aufrufe | +| `Ausführen ▶` | Konten anlegen + PDFs + Admin-Report | +| `✕ löschen` (rot) | Karte entfernen (auch die letzte – es bleibt eine leere) | + +> Die per `CSV speichern …` exportierte Datei enthält **Klartext-Passwörter** – +> sicher ablegen / verschlüsselt versenden, und die `*.csv` ist via +> `.gitignore` (Ausnahme `example.csv`) standardmäßig vom Repo ausgeschlossen. + +Log unten im Fenster zeigt Fortschritt; nach dem Lauf öffnet eine Hinweis-Box +mit dem Pfad zur Sammel-PDF. --- diff --git a/gui.py b/gui.py index ae206a3..329b35c 100644 --- a/gui.py +++ b/gui.py @@ -1,5 +1,6 @@ -"""Tkinter-GUI mit Endlosfeldern (eine Zeile = ein Account).""" +"""Tkinter-GUI mit Endlosfeldern (eine Karte = ein Account).""" from __future__ import annotations +import csv import threading from pathlib import Path import tkinter as tk @@ -10,33 +11,84 @@ from deploy import parse_csv, pdf_only, run_deploy from models import Account -FIELDS = [ - ("vorname", "Vorname", 14), - ("name", "Name", 14), - ("emailadresse", "Emailadresse", 22), - ("pleskhost", "Plesk-Host", 18), - ("keriohost", "Kerio-Host", 18), - ("nextcloudhost", "Nextcloud-Host", 18), - ("pleskemailkennwort", "Plesk Mail-PW", 14), - ("kerioemailkennwort", "Kerio PW", 14), - ("nextcloudkennwort", "Nextcloud PW", 14), - ("nextcloudgruppe", "NC Gruppe", 14), - ("nextcloudspeicher", "NC GB (leer=∞)", 12), -] +# 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): - super().__init__(master) + 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] = {} - for col, (key, _label, width) in enumerate(FIELDS): - e = ttk.Entry(self, width=width) - e.grid(row=0, column=col, padx=2, pady=2, sticky="ew") - self.entries[key] = e - ttk.Button(self, text="✕ löschen", width=10, - style="Remove.TButton", - command=lambda: on_remove(self)).grid( - row=0, column=len(FIELDS), padx=4) + + # ---------- 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()} @@ -52,24 +104,49 @@ class App(tk.Tk): def __init__(self): super().__init__() self.title("Email / Cloud Account Deployment") - self.geometry("1500x800") 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) @@ -78,13 +155,6 @@ class App(tk.Tk): self.pdf_btn = ttk.Button(toolbar, text="📄 Nur PDF", command=self.run_pdf_only) self.pdf_btn.pack(side="right", padx=2) - # Header über die Eingabezeilen - header = ttk.Frame(self) - header.pack(fill="x", padx=8) - for col, (_k, label, width) in enumerate(FIELDS): - ttk.Label(header, text=label, font=("Helvetica", 9, "bold"), - width=width, anchor="w").grid(row=0, column=col, padx=2, sticky="w") - # Scrollbares Eingabefeld wrap = ttk.Frame(self) wrap.pack(fill="both", expand=True, padx=8, pady=4) @@ -94,11 +164,17 @@ class App(tk.Tk): self.canvas.pack(side="left", fill="both", expand=True) sb.pack(side="right", fill="y") self.rows_frame = ttk.Frame(self.canvas) - self.canvas.create_window((0, 0), window=self.rows_frame, anchor="nw") + 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")) @@ -113,18 +189,30 @@ class App(tk.Tk): 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): - row = AccountRow(self.rows_frame, on_remove=self.remove_row) - row.pack(fill="x", pady=1) + 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.rows.append(row) + self._renumber() def remove_row(self, row): - self.rows.remove(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() # immer mindestens eine leere Zeile + self.add_row() + else: + self._renumber() # ----- Buttons ----- def load_csv(self): @@ -140,6 +228,7 @@ class App(tk.Tk): for r in self.rows: r.destroy() self.rows = [] + self.row_index_vars = [] for acc in accounts: self.add_row({ "vorname": acc.vorname, @@ -159,6 +248,49 @@ class App(tk.Tk): 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: @@ -178,7 +310,7 @@ class App(tk.Tk): "nextcloudkennwort"] missing = [k for k in required if not d.get(k)] if missing: - raise ValueError(f"Zeile {idx}: Pflichtfelder leer: {', '.join(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"],