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:
+404
-6
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user