4711c55d89
- 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>
377 lines
14 KiB
Python
377 lines
14 KiB
Python
"""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()
|