Files
deploy-email-plesk-kerio-ne…/gui.py
T
duffyduck 4711c55d89 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>
2026-05-12 13:13:41 +02:00

377 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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(
"<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"))
self.canvas.bind_all("<Button-4>",
lambda e: self.canvas.yview_scroll(-1, "units"))
self.canvas.bind_all("<Button-5>",
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()