Audit-Log, Import/Export, eigenes Profil & anpassbare Navigation

- Änderungslog: audit_log-Tabelle + last_modified_by/at an Devices und
  Switches; log_action()/touch_record() in allen Verwaltungsrouten
  (Devices, Switches, Zugangsdaten, Benutzer, Gruppen, Settings) verdrahtet.
  Neue Admin-Seite "Änderungslog" unter /logs/aenderungen. PoE-Neustarts
  werden bewusst nicht geloggt.
- Verschlüsseltes Import/Export für Devices/Switches/Zugangsdaten unter
  Settings, passphrasenbasiert (PBKDF2 + Fernet), für Umzug auf neue
  Umgebungen. Referenziert Zugangsdaten/Switche über Name/Hostname statt
  interner ID für stabilen Re-Import.
- Eigenes Profil: Klick auf den Namen in der Sidebar öffnet ein Modal zum
  Ändern von Vor-/Nachname, eigenem Passwort (mit Prüfung des aktuellen
  Passworts) und Profilbild-Upload (Anzeige in Sidebar + Änderungslog).
- Anpassbare Navigation: Reihenfolge der Sidebar-Punkte ist unter Settings
  per Auf-/Ab-Buttons konfigurierbar (settings.nav_order); "Devices"/
  "Users"/"Settings" umbenannt zu "Geräte"/"Benutzer"/"Einstellungen";
  "Live-Log" und "Änderungslog" zu aufklappbarer "Logs"-Gruppe
  zusammengefasst. Jeder Benutzer sieht weiterhin nur, wofür er berechtigt
  ist.
- create_db.py mit allen Schema-Erweiterungen synchronisiert (audit_log,
  last_modified_by/at, avatar_filename) für Frischinstallationen.
- venv-Umgebung im WSL neu aufgesetzt (war fälschlich unter dem
  Erstellungspfad venv-linux verankert und daher nicht aktivierbar).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 14:27:46 +02:00
co-authored by Claude Sonnet 5
parent fa6bb2935f
commit a7a23b7c12
13 changed files with 828 additions and 63 deletions
+404 -6
View File
@@ -8,13 +8,16 @@ Switch-/User-Verwaltung, Live-Log, Settings, manueller PoE-Neustart),
lediglich mit modernisiertem Frontend und aufgeräumten/konfigurierbaren
Pfaden im Backend.
"""
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_from_directory
from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user
from flask_bcrypt import Bcrypt
from flask_sock import Sock
from werkzeug.utils import secure_filename
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from datetime import datetime
import sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
import base64, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
import paramiko
@@ -73,6 +76,9 @@ LOG_GLOB = os.environ.get("POE_LOG_GLOB", "/var/log/rpi-*.log")
FERNET_KEY_PATH = os.environ.get("POE_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key"))
SSH_KNOWN_HOSTS_PATH = os.environ.get("POE_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts"))
SECRET_KEY_PATH = os.environ.get("POE_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key"))
AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars")
ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"}
os.makedirs(AVATAR_DIR, exist_ok=True)
app = Flask(__name__)
@@ -143,14 +149,66 @@ ALL_PERMISSION_KEYS = [key for cat in PERMISSIONS.values() for key in cat["items
DEFAULT_GROUP_NAME = "Benutzer"
DEFAULT_GROUP_PERMISSIONS = ["devices.view", "switches.view"]
# ---------------------------------------------------------------------------
# Navbar — Reihenfolge ist admin-konfigurierbar (siehe settings(), gespeichert
# als JSON-Liste von Keys unter settings.nav_order); standardmäßig wird pro
# Benutzer nur angezeigt, wofür er auch tatsächlich eine Berechtigung hat.
# "logs" ist eine Gruppe mit Untermenü (Live-Log + Änderungslog).
# ---------------------------------------------------------------------------
NAV_ITEMS = [
{"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"},
{"key": "devices", "label": "Geräte", "icon": "cpu", "endpoint": "devices"},
{"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"},
{"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"},
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
{"key": "logs", "label": "Logs", "icon": "terminal", "children": [
{"key": "logs_live", "label": "Live-Log", "icon": "terminal", "endpoint": "logs"},
{"key": "logs_activity", "label": "Änderungslog", "icon": "history", "endpoint": "activity_log"},
]},
{"key": "settings", "label": "Einstellungen", "icon": "sliders", "endpoint": "settings"},
]
DEFAULT_NAV_ORDER = [item["key"] for item in NAV_ITEMS]
NAV_ITEMS_BY_KEY = {item["key"]: item for item in NAV_ITEMS}
def _nav_item_visible(key, user):
if key == "index":
return True
if key == "devices":
return user.can_view_devices
if key in ("switches", "credentials"):
return user.can_manage_switches
if key in ("users", "groups", "logs", "settings"):
return user.is_admin
return False
@app.context_processor
def inject_nav():
if not current_user.is_authenticated:
return {}
try:
stored_order = json.loads(get_setting("nav_order", "") or "[]")
except (ValueError, TypeError):
stored_order = []
ordered_keys = [k for k in stored_order if k in NAV_ITEMS_BY_KEY]
ordered_keys += [k for k in DEFAULT_NAV_ORDER if k not in ordered_keys]
nav_items_ordered = [NAV_ITEMS_BY_KEY[k] for k in ordered_keys if _nav_item_visible(k, current_user)]
return {"nav_items_ordered": nav_items_ordered}
class User(UserMixin):
def __init__(self, id_, username, is_admin, permissions=None, group_names=None):
def __init__(self, id_, username, is_admin, permissions=None, group_names=None,
first_name=None, last_name=None, avatar_filename=None):
self.id = id_
self.username = username
self.is_admin = bool(is_admin)
self.permissions = permissions or set()
self.group_names = group_names or ""
self.first_name = first_name
self.last_name = last_name
self.avatar_filename = avatar_filename
def has_permission(self, key):
return self.is_admin or key in self.permissions
@@ -165,6 +223,21 @@ class User(UserMixin):
"switches.view", "switches.create", "switches.edit", "switches.delete",
})
@property
def full_name(self):
parts = [p for p in (self.first_name, self.last_name) if p]
return " ".join(parts)
@property
def display_name(self):
return self.full_name or self.username
@property
def avatar_url(self):
if self.avatar_filename:
return url_for("static", filename=f"uploads/avatars/{self.avatar_filename}")
return None
def get_db_connection():
conn = sqlite3.connect(DB_PATH)
@@ -203,12 +276,34 @@ def _ensure_schema():
if "is_default" not in existing_cols:
conn.execute("ALTER TABLE groups ADD COLUMN is_default INTEGER DEFAULT 0")
# Migration: Vor-/Nachname für Benutzer nachrüsten.
# Migration: Vor-/Nachname + Profilbild für Benutzer nachrüsten.
user_cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)").fetchall()}
if "first_name" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN first_name TEXT")
if "last_name" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN last_name TEXT")
if "avatar_filename" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN avatar_filename TEXT")
# Änderungsverlauf (Audit-Log): wer hat wann was geändert. Bewusst ohne
# PoE-Neustarts (die stehen bereits im Live-Log von poe.sh).
conn.execute("""
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
username TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT,
details TEXT
)
""")
# "Wer hat zuletzt geändert" direkt am Objekt, zusätzlich zum Audit-Log.
device_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()}
if "last_modified_by" not in device_cols:
conn.execute("ALTER TABLE devices ADD COLUMN last_modified_by TEXT")
if "last_modified_at" not in device_cols:
conn.execute("ALTER TABLE devices ADD COLUMN last_modified_at TEXT")
# Zugangsdaten (Credentials): eigenständige, wiederverwendbare
# SSH-Logins für Switche, statt Username/Passwort direkt am Switch.
@@ -223,6 +318,10 @@ def _ensure_schema():
switch_cols = {row["name"] for row in conn.execute("PRAGMA table_info(switches)").fetchall()}
if "credential_id" not in switch_cols:
conn.execute("ALTER TABLE switches ADD COLUMN credential_id INTEGER")
if "last_modified_by" not in switch_cols:
conn.execute("ALTER TABLE switches ADD COLUMN last_modified_by TEXT")
if "last_modified_at" not in switch_cols:
conn.execute("ALTER TABLE switches ADD COLUMN last_modified_at TEXT")
# Migration: bestehende, direkt am Switch hinterlegte Zugangsdaten
# (ältere DB-Version) in eigene Credentials-Datensätze überführen.
@@ -305,7 +404,44 @@ def _build_user(row):
conn.close()
group_names = ", ".join(g["name"] for g in group_rows)
return User(row["id"], row["username"], row["is_admin"], permissions, group_names)
return User(
row["id"], row["username"], row["is_admin"], permissions, group_names,
first_name=row["first_name"] if "first_name" in row.keys() else None,
last_name=row["last_name"] if "last_name" in row.keys() else None,
avatar_filename=row["avatar_filename"] if "avatar_filename" in row.keys() else None,
)
def log_action(action, target=None, details=None):
"""Schreibt einen Eintrag ins Änderungsverlauf-Log. Absichtlich NICHT für
PoE-Neustarts genutzt (die stehen bereits im Live-Log von poe.sh)."""
who = current_user.username if current_user.is_authenticated else "system"
conn = get_db_connection()
conn.execute(
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), who, action, target, details),
)
conn.commit()
conn.close()
def touch_record(conn, table, key_col, key_val):
"""Trägt 'zuletzt geändert von/am' direkt am Datensatz ein (Devices/Switches)."""
who = current_user.username if current_user.is_authenticated else "system"
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
f"UPDATE {table} SET last_modified_by=?, last_modified_at=? WHERE {key_col}=?",
(who, now, key_val),
)
def _derive_export_fernet(passphrase: str, salt: bytes) -> Fernet:
"""Leitet aus einer Passphrase + Salt einen Fernet-Schlüssel ab (PBKDF2) —
für portable, umgebungsunabhängige Ver-/Entschlüsselung beim Import/Export
(im Gegensatz zum eigentlichen fernet.key, der pro Installation neu ist)."""
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=390000)
key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
return Fernet(key)
def encrypt_password(password: str) -> str:
@@ -370,6 +506,71 @@ def logout():
return redirect(url_for("login"))
# ---------------------------------------------------------------------------
# Eigenes Profil — jeder eingeloggte Benutzer darf Vor-/Nachname, eigenes
# Passwort und Profilbild selbst ändern (Klick auf den eigenen Namen in der
# Sidebar). Bewusst getrennt vom Admin-"Users"-Bereich, der andere Benutzer
# verwaltet.
# ---------------------------------------------------------------------------
@app.route("/profile", methods=["POST"])
@login_required
def profile():
conn = get_db_connection()
if "update_profile" in request.form:
first_name = request.form.get("first_name", "").strip() or None
last_name = request.form.get("last_name", "").strip() or None
conn.execute(
"UPDATE users SET first_name=?, last_name=? WHERE id=?",
(first_name, last_name, current_user.id),
)
conn.commit()
log_action("profile.update", current_user.username)
flash("Profil aktualisiert.", "success")
elif "change_password" in request.form:
current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
row = conn.execute("SELECT password FROM users WHERE id=?", (current_user.id,)).fetchone()
if not row or not bcrypt.check_password_hash(row["password"], current_password):
flash("Aktuelles Passwort ist falsch.", "danger")
elif not new_password or new_password != confirm_password:
flash("Neues Passwort und Bestätigung stimmen nicht überein.", "danger")
else:
pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
conn.execute("UPDATE users SET password=? WHERE id=?", (pw_hash, current_user.id))
conn.commit()
log_action("profile.password", current_user.username)
flash("Passwort geändert.", "success")
elif "upload_avatar" in request.form:
file = request.files.get("avatar")
if not file or not file.filename:
flash("Bitte ein Bild auswählen.", "danger")
else:
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
if ext not in ALLOWED_AVATAR_EXT:
flash("Nur PNG, JPG, GIF oder WEBP erlaubt.", "danger")
else:
old_row = conn.execute(
"SELECT avatar_filename FROM users WHERE id=?", (current_user.id,)
).fetchone()
filename = secure_filename(f"user_{current_user.id}_{secrets.token_hex(4)}.{ext}")
file.save(os.path.join(AVATAR_DIR, filename))
conn.execute("UPDATE users SET avatar_filename=? WHERE id=?", (filename, current_user.id))
conn.commit()
if old_row and old_row["avatar_filename"]:
old_path = os.path.join(AVATAR_DIR, old_row["avatar_filename"])
if os.path.isfile(old_path):
os.remove(old_path)
flash("Profilbild aktualisiert.", "success")
conn.close()
return redirect(request.referrer or url_for("index"))
# ---------------------------------------------------------------------------
# Log-Auswertung (Erreichbarkeits-Status je Gerät)
# ---------------------------------------------------------------------------
@@ -544,6 +745,7 @@ def settings():
new_interval = int(request.form["interval"])
set_setting("interval", new_interval)
set_setting("check_interval", new_interval * 60)
log_action("settings.update", "Prüfintervall", f"{new_interval} Minuten")
try:
subprocess.run(["systemctl", "restart", "rpi-check.service"], check=False)
@@ -556,6 +758,149 @@ def settings():
return render_template("settings.html", interval=interval)
@app.route("/settings/nav-order", methods=["POST"])
@login_required
def save_nav_order():
if not current_user.is_admin:
flash("Nur Admins dürfen die Navbar-Reihenfolge ändern!", "danger")
return redirect(url_for("index"))
order = [k for k in request.form.getlist("nav_order") if k in NAV_ITEMS_BY_KEY]
order += [k for k in DEFAULT_NAV_ORDER if k not in order]
set_setting("nav_order", json.dumps(order))
log_action("settings.update", "Navbar-Reihenfolge")
flash("Navbar-Reihenfolge gespeichert.", "success")
return redirect(url_for("settings"))
# ---------------------------------------------------------------------------
# Import/Export — für Umzug auf eine neue Umgebung. Der komplette Datensatz
# (Devices, Switche, Zugangsdaten inkl. Passwörter) wird als ein einziges,
# mit einer selbstgewählten Passphrase verschlüsseltes JSON-Bundle exportiert
# — unabhängig vom fernet.key dieser Installation, damit die Datei auf jedem
# anderen Server wieder eingelesen werden kann, der dieselbe Passphrase kennt.
# ---------------------------------------------------------------------------
@app.route("/settings/export", methods=["POST"])
@login_required
def export_data():
if not current_user.is_admin:
flash("Nur Admins dürfen Daten exportieren!", "danger")
return redirect(url_for("index"))
passphrase = request.form.get("export_passphrase", "")
if not passphrase:
flash("Bitte eine Passphrase für den Export angeben.", "danger")
return redirect(url_for("settings"))
conn = get_db_connection()
credentials_rows = conn.execute("SELECT name, username, password FROM credentials").fetchall()
switches_rows = conn.execute("""
SELECT switches.hostname, switches.ip, credentials.name AS credential_name
FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
""").fetchall()
devices_rows = conn.execute(
"SELECT mac, rpi_ip, port, name, switch_hostname, is_active FROM devices"
).fetchall()
conn.close()
payload = {
"credentials": [
{"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"])}
for r in credentials_rows
],
"switches": [
{"hostname": r["hostname"], "ip": r["ip"], "credential_name": r["credential_name"]}
for r in switches_rows
],
"devices": [dict(r) for r in devices_rows],
}
salt = secrets.token_bytes(16)
export_fernet = _derive_export_fernet(passphrase, salt)
encrypted_payload = export_fernet.encrypt(json.dumps(payload).encode("utf-8"))
envelope = {
"app": "poe-manager",
"version": 1,
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
"payload": encrypted_payload.decode("utf-8"),
}
log_action(
"data.export", "Devices/Switches/Zugangsdaten",
f"{len(payload['devices'])} Geräte, {len(payload['switches'])} Switche, {len(payload['credentials'])} Zugangsdaten",
)
filename = f"poe_manager_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
response = jsonify(envelope)
response.headers["Content-Disposition"] = f"attachment; filename={filename}"
return response
@app.route("/settings/import", methods=["POST"])
@login_required
def import_data():
if not current_user.is_admin:
flash("Nur Admins dürfen Daten importieren!", "danger")
return redirect(url_for("index"))
passphrase = request.form.get("import_passphrase", "")
upload = request.files.get("import_file")
if not passphrase or not upload or not upload.filename:
flash("Bitte Passphrase und Export-Datei angeben.", "danger")
return redirect(url_for("settings"))
try:
envelope = json.loads(upload.read().decode("utf-8"))
salt = base64.urlsafe_b64decode(envelope["salt"])
export_fernet = _derive_export_fernet(passphrase, salt)
payload = json.loads(export_fernet.decrypt(envelope["payload"].encode("utf-8")).decode("utf-8"))
except Exception:
flash("Import fehlgeschlagen: Datei ungültig oder Passphrase falsch.", "danger")
return redirect(url_for("settings"))
conn = get_db_connection()
n_cred = n_switch = n_dev = 0
for c in payload.get("credentials", []):
conn.execute(
"""INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password""",
(c["name"], c["username"], encrypt_password(c["password"])),
)
n_cred += 1
for s in payload.get("switches", []):
cred_id = None
if s.get("credential_name"):
row = conn.execute("SELECT id FROM credentials WHERE name=?", (s["credential_name"],)).fetchone()
cred_id = row["id"] if row else None
conn.execute(
"""INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?)
ON CONFLICT(hostname) DO UPDATE SET ip=excluded.ip, credential_id=excluded.credential_id""",
(s["hostname"], s["ip"], cred_id),
)
n_switch += 1
for d in payload.get("devices", []):
conn.execute(
"""INSERT INTO devices (mac, rpi_ip, port, name, switch_hostname, is_active) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(mac) DO UPDATE SET rpi_ip=excluded.rpi_ip, port=excluded.port, name=excluded.name,
switch_hostname=excluded.switch_hostname, is_active=excluded.is_active""",
(d["mac"], d["rpi_ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1)),
)
n_dev += 1
conn.commit()
conn.close()
log_action("data.import", "Devices/Switches/Zugangsdaten", f"{n_dev} Geräte, {n_switch} Switche, {n_cred} Zugangsdaten")
flash(f"Import abgeschlossen: {n_cred} Zugangsdaten, {n_switch} Switche, {n_dev} Geräte.", "success")
return redirect(url_for("settings"))
# ---------------------------------------------------------------------------
# Devices
# ---------------------------------------------------------------------------
@@ -601,7 +946,9 @@ def devices():
"VALUES (?, ?, ?, ?, ?, ?)",
(mac, rpi_ip, port, name, switch_hostname, is_active),
)
touch_record(conn, "devices", "mac", mac)
conn.commit()
log_action("device.create", name, f"MAC {mac}, IP {rpi_ip}")
flash(f"Gerät {name} hinzugefügt.", "success")
except sqlite3.IntegrityError:
flash("Fehler beim Hinzufügen des Geräts!", "danger")
@@ -632,7 +979,9 @@ def devices():
conn.execute(
"UPDATE devices SET switch_hostname=? WHERE mac=?", (new_switch, old_mac)
)
touch_record(conn, "devices", "mac", old_mac)
conn.commit()
log_action("device.edit", device["name"], f"Switch: {old_switch}{new_switch or 'Kein Switch'}")
flash(
f"Switch von {device['name']} geändert: {old_switch}{new_switch or 'Kein Switch'}",
"success",
@@ -661,7 +1010,9 @@ def devices():
"UPDATE devices SET mac=?, rpi_ip=?, port=?, name=? WHERE mac=?",
(mac, rpi_ip, port, name, old_mac),
)
touch_record(conn, "devices", "mac", mac)
conn.commit()
log_action("device.edit", name, f"MAC {old_mac}{mac}, IP {rpi_ip}")
flash(f"Gerät {name} aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Fehler beim Aktualisieren des Geräts!", "danger")
@@ -677,6 +1028,7 @@ def devices():
name = device["name"] if device else del_mac
conn.execute("DELETE FROM devices WHERE mac=?", (del_mac,))
conn.commit()
log_action("device.delete", name, f"MAC {del_mac}")
flash(f"Gerät {name} gelöscht.", "success")
else:
flash("Keine MAC-Adresse übermittelt!", "danger")
@@ -709,10 +1061,12 @@ def toggle_device(mac):
new_status = 0 if device["is_active"] else 1
conn.execute("UPDATE devices SET is_active=? WHERE mac=?", (new_status, mac))
touch_record(conn, "devices", "mac", mac)
conn.commit()
conn.close()
status_text = "deaktiviert" if new_status == 0 else "aktiviert"
log_action("device.deactivate" if new_status == 0 else "device.activate", device["name"])
return {"success": True, "msg": f"Gerät {device['name']} wurde {status_text}.", "new_status": new_status}
@@ -772,7 +1126,9 @@ def switches():
"INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?)",
(hostname, ip, credential_id),
)
touch_record(conn, "switches", "hostname", hostname)
conn.commit()
log_action("switch.create", hostname, f"IP {ip}")
flash(f"Switch {hostname} hinzugefügt.", "success")
except sqlite3.IntegrityError:
flash("Hostname existiert bereits oder Eingabefehler!", "danger")
@@ -798,7 +1154,9 @@ def switches():
"UPDATE devices SET switch_hostname=? WHERE switch_hostname=?",
(hostname, old_hostname),
)
touch_record(conn, "switches", "hostname", hostname)
conn.commit()
log_action("switch.edit", hostname, f"IP {ip}")
flash(f"Switch {hostname} aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Hostname existiert bereits oder Eingabefehler!", "danger")
@@ -833,6 +1191,7 @@ def delete_switch(hostname):
conn.execute("DELETE FROM switches WHERE hostname=?", (hostname,))
conn.commit()
conn.close()
log_action("switch.delete", hostname)
flash(f"Switch '{hostname}' gelöscht.", "success")
return redirect(url_for("switches"))
@@ -866,6 +1225,7 @@ def credentials():
(name, username, encrypt_password(password)),
)
conn.commit()
log_action("credential.create", name)
flash(f"Zugangsdaten '{name}' angelegt.", "success")
except sqlite3.IntegrityError:
flash("Es existieren bereits Zugangsdaten mit diesem Namen!", "danger")
@@ -893,6 +1253,7 @@ def credentials():
(name, username, cred_id),
)
conn.commit()
log_action("credential.edit", name)
flash(f"Zugangsdaten '{name}' aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Es existieren bereits Zugangsdaten mit diesem Namen!", "danger")
@@ -909,8 +1270,10 @@ def credentials():
names = ", ".join(s["hostname"] for s in used_by)
flash(f"Diese Zugangsdaten werden noch von folgenden Switchen verwendet: {names}", "danger")
else:
cred_row = conn.execute("SELECT name FROM credentials WHERE id=?", (cred_id,)).fetchone()
conn.execute("DELETE FROM credentials WHERE id=?", (cred_id,))
conn.commit()
log_action("credential.delete", cred_row["name"] if cred_row else cred_id)
flash("Zugangsdaten gelöscht.", "success")
credential_rows = conn.execute("""
@@ -1184,6 +1547,27 @@ def logs():
)
@app.route("/logs/aenderungen")
@login_required
def activity_log():
"""Änderungslog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen,
Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts — die stehen im Live-Log."""
if not current_user.is_admin:
flash("Nur Admins dürfen das Änderungslog einsehen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
entries = conn.execute(
"SELECT id, ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT 500"
).fetchall()
avatars = {
row["username"]: row["avatar_filename"]
for row in conn.execute("SELECT username, avatar_filename FROM users").fetchall()
}
conn.close()
return render_template("activity_log.html", entries=entries, avatars=avatars)
# ---------------------------------------------------------------------------
# Manueller PoE-Neustart
# ---------------------------------------------------------------------------
@@ -1255,6 +1639,7 @@ def users():
(cur.lastrowid, group_choice),
)
conn.commit()
log_action("user.create", username)
flash(f"Benutzer '{username}' erfolgreich angelegt!", "success")
except sqlite3.IntegrityError:
flash("Benutzername existiert bereits!", "danger")
@@ -1282,6 +1667,7 @@ def users():
(username, first_name, last_name, user_id),
)
conn.commit()
log_action("user.edit", username)
flash("Benutzer aktualisiert!", "success")
else:
flash("Username darf nicht leer sein!", "danger")
@@ -1291,7 +1677,7 @@ def users():
choice = request.form.get("group_id") or ""
# Mindestens ein Admin muss bestehen bleiben.
target = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
target = conn.execute("SELECT username, is_admin FROM users WHERE id=?", (user_id,)).fetchone()
if target and target["is_admin"] and choice != "admin":
admin_count = conn.execute("SELECT COUNT(*) AS n FROM users WHERE is_admin=1").fetchone()["n"]
if admin_count <= 1:
@@ -1310,13 +1696,16 @@ def users():
(user_id, choice),
)
conn.commit()
log_action("user.assign_group", target["username"] if target else user_id, choice or "keine Gruppe")
flash("Gruppe zugewiesen!", "success")
elif "delete_user" in request.form:
user_id = request.form["delete_user"]
target = conn.execute("SELECT username FROM users WHERE id=?", (user_id,)).fetchone()
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
conn.commit()
log_action("user.delete", target["username"] if target else user_id)
flash("Benutzer gelöscht!", "success")
users_list = conn.execute("""
@@ -1355,6 +1744,7 @@ def groups():
try:
conn.execute("INSERT INTO groups (name) VALUES (?)", (name,))
conn.commit()
log_action("group.create", name)
flash(f"Gruppe '{name}' angelegt.", "success")
except sqlite3.IntegrityError:
flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger")
@@ -1396,6 +1786,12 @@ def groups():
)
conn.commit()
detail_bits = []
if "permissions_submitted" in request.form:
detail_bits.append("Rechte geändert")
if "members_submitted" in request.form:
detail_bits.append("Mitglieder geändert")
log_action("group.edit", name, ", ".join(detail_bits) or None)
flash(f"Gruppe '{name}' aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger")
@@ -1418,6 +1814,7 @@ def groups():
[(uid,) for uid in selected_admins],
)
conn.commit()
log_action("group.assign_admins", "Admin", f"{len(selected_admins)} Mitglieder")
flash("Admin-Zuweisung aktualisiert.", "success")
elif "delete_group" in request.form:
@@ -1430,6 +1827,7 @@ def groups():
conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,))
conn.execute("DELETE FROM groups WHERE id=?", (group_id,))
conn.commit()
log_action("group.delete", target["name"] if target else group_id)
flash("Gruppe gelöscht.", "success")
conn.close()