dda5c746ce
- PLESK_BACKEND={manual,api,ssh}: manual als Default für Shared Hosts,
api unverändert, ssh ruft `plesk bin mail` per paramiko auf.
- POP3_PORT default 995, POP3_SSL mit Auto-Erkennung anhand Port.
- Kerio: User wird mit mayChangePassword=False angelegt.
- Zusätzliche Minimal-PDF (nur Email + Cloud) pro Konto + Sammel-Variante,
IMAP-Port konfigurierbar.
- CLI-Flag --pdf-only und entsprechender GUI-Button "📄 Nur PDF".
- GUI: Lösch-Button "✕ löschen" sichtbarer, letzte Zeile löschbar.
- PDFs sind kunden-tauglich (kein Status-Block, kein ACHTUNG-Hinweis);
Anlage-Status separat in _admin_report_<ts>.txt.
- README dokumentiert die Skip-Logik pro Dienst und ihre Caveats.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
245 lines
9.7 KiB
Python
245 lines
9.7 KiB
Python
"""Tkinter-GUI mit Endlosfeldern (eine Zeile = ein Account)."""
|
|
from __future__ import annotations
|
|
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
|
|
|
|
|
|
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),
|
|
]
|
|
|
|
|
|
class AccountRow(ttk.Frame):
|
|
def __init__(self, master, on_remove):
|
|
super().__init__(master)
|
|
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)
|
|
|
|
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.geometry("1500x800")
|
|
self.output_dir = Path("./output").resolve()
|
|
self.rows: list[AccountRow] = []
|
|
# Roter Lösch-Button-Style
|
|
style = ttk.Style(self)
|
|
try:
|
|
style.configure("Remove.TButton", foreground="#b00020")
|
|
except tk.TclError:
|
|
pass
|
|
self._build()
|
|
self.add_row()
|
|
|
|
# ----- 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="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)
|
|
|
|
# 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)
|
|
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.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")),
|
|
)
|
|
# 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 add_row(self, data: dict | None = None):
|
|
row = AccountRow(self.rows_frame, on_remove=self.remove_row)
|
|
row.pack(fill="x", pady=1)
|
|
if data:
|
|
row.from_dict(data)
|
|
self.rows.append(row)
|
|
|
|
def remove_row(self, row):
|
|
self.rows.remove(row)
|
|
row.destroy()
|
|
if not self.rows:
|
|
self.add_row() # immer mindestens eine leere Zeile
|
|
|
|
# ----- 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 = []
|
|
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)")
|
|
|
|
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"Zeile {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()
|