Files
deploy-email-plesk-kerio-ne…/gui.py
T
duffyduck 06d7e00e49 fix(kerio): korrekte Admin-API gemäß Delivery.idl + Pop3Account-Doku
- Methoden: Delivery.getPop3AccountList / addPop3AccountList /
  setPop3Account (vorher geraten als Pop3Accounts.set/.create →
  Method not found).
- Pop3Account-Felder mit den richtigen Namen: isActive (statt enabled),
  mode (statt sslMode), authentication (statt authType), und
  leaveOnServer.removeAfterPeriod als OptionalLong-Wrapper.
  Falsche Namen wurden von Kerio still ignoriert → Sammler war inaktiv.
- User-Struct: allowPasswordChange=false (statt mayChangePassword,
  das es nicht gibt). emailAddresses weggelassen, Kerio leitet die
  primäre Adresse aus loginName+domain ab.
- Kerio-Step in 2 Sub-Steps aufgeteilt: User (skip wenn vorhanden) +
  POP3 (upsert). Damit wird bei einem zweiten Lauf der Sammler nicht
  übersprungen, nur weil der User schon existiert.
- POP3-Sammler ist jetzt UPSERT: existierende werden via setPop3Account
  überschrieben → Selbstreparatur kaputter Einträge + Passwort-
  Änderungen aus der CSV ziehen sich von selbst nach.

GUI: 👁/🙈-Toggle pro Passwort-Feld (Klartext temporär einsehbar).

Filenames der Sammel-PDFs + Admin-Report ohne Zeitstempel –
erneuter Lauf überschreibt statt anzuhäufen.

README: Ablauf-Sektion + Idempotenz-Tabelle aktualisiert; Kerio-
Caveat ersetzt durch konkrete Methoden-/Feld-Liste mit Doku-Link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:09:06 +02:00

399 lines
15 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")
if "kennwort" in key:
self._make_password_entry(cell, key, width)
else:
e = ttk.Entry(cell, width=width)
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 _make_password_entry(self, parent, key, width):
"""Passwort-Entry + Auge-Toggle nebenan."""
row = ttk.Frame(parent)
row.pack()
e = ttk.Entry(row, width=width, show="")
e.pack(side="left")
btn = ttk.Button(row, text="👁", width=2)
btn.config(command=lambda: self._toggle_password(e, btn))
btn.pack(side="left", padx=(2, 0))
self.entries[key] = e
@staticmethod
def _toggle_password(entry, btn):
if entry.cget("show"):
entry.config(show="")
btn.config(text="🙈")
else:
entry.config(show="")
btn.config(text="👁")
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()