gui: Karten-Layout, Vollbild-Start, CSV-Speichern-Button

- Eine Karte pro Account mit zwei Zeilen: Person oben (Vorname/Name/Email
  + Lösch-Button), Service-Felder unten in drei Gruppen (Plesk/Kerio/
  Nextcloud) mit blauen Überschriften und vertikalen Trennlinien.
- Karten haben Rand, füllen die volle Fensterbreite und sind mit #1, #2, …
  durchnummeriert; Pflichtfeld-Fehlermeldungen referenzieren diese Nummern.
- Passwort-Felder maskiert (•••).
- Fenster startet maximiert: -zoomed (Linux/X11) → state(zoomed) (Windows)
  → Fallback Bildschirmgeometrie.
- Neuer Toolbar-Button "CSV speichern …" exportiert die ausgefüllten
  Karten im selben Format wie example.csv (Round-Trip-fähig).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 13:13:41 +02:00
parent b88c766dd6
commit 4711c55d89
2 changed files with 195 additions and 47 deletions
+24 -8
View File
@@ -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.
---
+171 -39
View File
@@ -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(
"<Configure>",
lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")),
)
# Karten füllen die volle Breite des Canvas
self.canvas.bind(
"<Configure>",
lambda e: self.canvas.itemconfigure(self.rows_window, width=e.width),
)
# Mousewheel-Scrollen
self.canvas.bind_all("<MouseWheel>",
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"],