Version 2.7.5: jede Aenderung nennt alt und neu
This commit is contained in:
1 parent
e68f22fd88
commit
1d67c36ed2
6 files changed
+118
-18
No files matched your search
@@ -28,7 +28,7 @@ import sqlite3
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Collection, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
from .db import Database
|
from .db import Database
|
||||||
|
|
||||||
@@ -116,6 +116,49 @@ def log(
|
|||||||
return int(cursor.lastrowid or 0)
|
return int(cursor.lastrowid or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def diff(
|
||||||
|
vorher: Mapping[str, Any] | None,
|
||||||
|
nachher: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
felder: Sequence[tuple[str, str]] | None = None,
|
||||||
|
geheim: Collection[str] = (),
|
||||||
|
) -> str:
|
||||||
|
"""Was sich geaendert hat: ``Feld: alt -> neu``, mit Komma getrennt.
|
||||||
|
|
||||||
|
Damit steht im Protokoll nicht nur *dass* etwas geaendert wurde, sondern
|
||||||
|
auch *von was auf was* -- der Weg zurueck steht damit in der Zeile selbst.
|
||||||
|
|
||||||
|
``felder`` gibt Auswahl und Beschriftung vor (``[("ssh_port", "SSH-Port")]``);
|
||||||
|
ohne Angabe werden alle gemeinsamen Felder verglichen. Namen in ``geheim``
|
||||||
|
werden nie im Klartext geschrieben -- dort steht nur, dass sie sich
|
||||||
|
geaendert haben.
|
||||||
|
"""
|
||||||
|
if vorher is None or nachher is None:
|
||||||
|
return ""
|
||||||
|
paare = felder or [(name, name) for name in sorted(set(vorher) & set(nachher))]
|
||||||
|
teile: list[str] = []
|
||||||
|
for name, beschriftung in paare:
|
||||||
|
if name not in vorher or name not in nachher:
|
||||||
|
continue
|
||||||
|
alt, neu = vorher[name], nachher[name]
|
||||||
|
if alt == neu:
|
||||||
|
continue
|
||||||
|
if name in geheim:
|
||||||
|
teile.append(f"{beschriftung}: geaendert")
|
||||||
|
continue
|
||||||
|
teile.append(f"{beschriftung}: {_wert(alt)} -> {_wert(neu)}")
|
||||||
|
return ", ".join(teile)
|
||||||
|
|
||||||
|
|
||||||
|
def _wert(wert: Any) -> str:
|
||||||
|
if wert is None or wert == "":
|
||||||
|
return "(leer)"
|
||||||
|
if isinstance(wert, bool):
|
||||||
|
return "ein" if wert else "aus"
|
||||||
|
text = " ".join(str(wert).split())
|
||||||
|
return text if len(text) <= 80 else text[:79] + "…"
|
||||||
|
|
||||||
|
|
||||||
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
|
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
|
||||||
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
||||||
sql = "SELECT * FROM audit_log ORDER BY id"
|
sql = "SELECT * FROM audit_log ORDER BY id"
|
||||||
|
|||||||
@@ -359,15 +359,11 @@ def logout(conn: sqlite3.Connection) -> None:
|
|||||||
current_app.logger.exception(
|
current_app.logger.exception(
|
||||||
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
||||||
)
|
)
|
||||||
with core().database.transaction(conn):
|
# Kein Protokolleintrag fuer das Abmelden: eine Abmeldung aendert
|
||||||
audit.log(
|
# nichts. Sie stand als "Info" in jeder zweiten Zeile und verdraengte
|
||||||
conn,
|
# die Eintraege, um die es geht. Wer wissen will, wer wann da war,
|
||||||
actor=user.username,
|
# liest die Sitzungsuebersicht; fehlgeschlagene Anmeldungen und
|
||||||
actor_id=user.id,
|
# Sperren stehen weiterhin jedes Mal im Protokoll.
|
||||||
action="auth.logout",
|
|
||||||
target=user.username,
|
|
||||||
ip=client_ip(),
|
|
||||||
)
|
|
||||||
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
||||||
g.user = ANONYMOUS
|
g.user = ANONYMOUS
|
||||||
|
|
||||||
|
|||||||
@@ -556,14 +556,20 @@ def update_user(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fields: dict[str, Any] = {}
|
fields: dict[str, Any] = {}
|
||||||
|
# Mit Werten, nicht nur "geaendert": aus dem Protokoll soll man den
|
||||||
|
# vorherigen Stand ablesen koennen, ohne eine Sicherung auszupacken.
|
||||||
|
beschriftungen = {"first_name": "Vorname", "last_name": "Nachname", "email": "E-Mail"}
|
||||||
for column, value in (
|
for column, value in (
|
||||||
("first_name", first_name),
|
("first_name", first_name),
|
||||||
("last_name", last_name),
|
("last_name", last_name),
|
||||||
("email", email),
|
("email", email),
|
||||||
):
|
):
|
||||||
if value is not None and value.strip() != str(target.get(column) or ""):
|
alt = str(target.get(column) or "")
|
||||||
|
if value is not None and value.strip() != alt:
|
||||||
fields[column] = value.strip()
|
fields[column] = value.strip()
|
||||||
changes.append(f"{column} geändert")
|
changes.append(
|
||||||
|
f"{beschriftungen[column]}: {alt or '(leer)'} -> {value.strip() or '(leer)'}"
|
||||||
|
)
|
||||||
|
|
||||||
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
|
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
|
||||||
if not getattr(actor, "is_admin", False):
|
if not getattr(actor, "is_admin", False):
|
||||||
|
|||||||
@@ -67,6 +67,45 @@ class SettingSpec:
|
|||||||
return "" if value is None else str(value)
|
return "" if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
#: So lang darf ein Wert im Protokoll werden -- danach wird gekuerzt. Ein
|
||||||
|
#: JSON-Feld mit der halben Navigationsreihenfolge macht die Zeile unlesbar.
|
||||||
|
WERT_GRENZE = 80
|
||||||
|
|
||||||
|
|
||||||
|
class Aenderung(str):
|
||||||
|
"""Eine geaenderte Einstellung, lesbar als ``schluessel: alt -> neu``.
|
||||||
|
|
||||||
|
Eine Zeichenkette und kein eigener Typ, weil die Aufrufer die Liste
|
||||||
|
weiterhin mit ``", ".join(...)`` in einen Protokolleintrag schreiben --
|
||||||
|
so gibt es keine Stelle, die man beim Umbau vergessen kann.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
alt: str
|
||||||
|
neu: str
|
||||||
|
|
||||||
|
def __new__(cls, key: str, alt: Any, neu: Any, *, geheim: bool = False) -> "Aenderung":
|
||||||
|
alt_text = "(gesetzt)" if geheim and alt else _kurz(alt)
|
||||||
|
neu_text = "(geaendert)" if geheim else _kurz(neu)
|
||||||
|
selbst = super().__new__(cls, f"{key}: {alt_text} -> {neu_text}")
|
||||||
|
selbst.key = key
|
||||||
|
selbst.alt = alt_text
|
||||||
|
selbst.neu = neu_text
|
||||||
|
return selbst
|
||||||
|
|
||||||
|
|
||||||
|
def _kurz(wert: Any) -> str:
|
||||||
|
"""Ein Wert als kurzer Text -- leer wird sichtbar, lang wird gekuerzt."""
|
||||||
|
if wert is None or wert == "":
|
||||||
|
return "(leer)"
|
||||||
|
if isinstance(wert, bool):
|
||||||
|
return "ein" if wert else "aus"
|
||||||
|
text = " ".join(str(wert).split())
|
||||||
|
if len(text) > WERT_GRENZE:
|
||||||
|
return text[: WERT_GRENZE - 1] + "…"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
class SettingsStore:
|
class SettingsStore:
|
||||||
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
|
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
|
||||||
|
|
||||||
@@ -140,16 +179,24 @@ class SettingsStore:
|
|||||||
|
|
||||||
def set_many(
|
def set_many(
|
||||||
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
|
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
|
||||||
) -> list[str]:
|
) -> list["Aenderung"]:
|
||||||
"""Setzt mehrere Werte, gibt die tatsaechlich geaenderten Schluessel zurueck."""
|
"""Setzt mehrere Werte und gibt die Aenderungen zurueck.
|
||||||
changed: list[str] = []
|
|
||||||
|
Jede Aenderung ist eine Zeichenkette der Form ``schluessel: alt -> neu``
|
||||||
|
-- und traegt die Einzelteile als Attribute. Damit steht im
|
||||||
|
Aenderungsprotokoll, was sich geaendert hat, ohne dass jede
|
||||||
|
Einstellungsseite es einzeln zusammenbauen muss. Vorher stand dort nur
|
||||||
|
der Schluessel, und die Frage "was war vorher eingestellt?" liess sich
|
||||||
|
aus dem Protokoll nicht beantworten.
|
||||||
|
"""
|
||||||
|
changed: list[Aenderung] = []
|
||||||
current = self.get_many(conn, list(values))
|
current = self.get_many(conn, list(values))
|
||||||
for key, value in values.items():
|
for key, value in values.items():
|
||||||
spec = self.spec(key)
|
spec = self.spec(key)
|
||||||
if spec.serialize(current.get(key)) == spec.serialize(value):
|
if spec.serialize(current.get(key)) == spec.serialize(value):
|
||||||
continue
|
continue
|
||||||
self.set(conn, key, value, actor=actor)
|
self.set(conn, key, value, actor=actor)
|
||||||
changed.append(key)
|
changed.append(Aenderung(key, current.get(key), value, geheim=spec.kind == "secret"))
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
def delete(self, conn: sqlite3.Connection, key: str) -> None:
|
def delete(self, conn: sqlite3.Connection, key: str) -> None:
|
||||||
|
|||||||
@@ -69,8 +69,10 @@
|
|||||||
<td class="fs-sm fw-600" data-spalte="Benutzer">{{ row.actor }}</td>
|
<td class="fs-sm fw-600" data-spalte="Benutzer">{{ row.actor }}</td>
|
||||||
<td class="mono fs-xs" data-spalte="Aktion">{{ row.action }}</td>
|
<td class="mono fs-xs" data-spalte="Aktion">{{ row.action }}</td>
|
||||||
<td class="fs-sm" data-spalte="Ziel">{{ row.target }}</td>
|
<td class="fs-sm" data-spalte="Ziel">{{ row.target }}</td>
|
||||||
|
{# Ohne die Minuszeichen stuende bei "white-space: pre-line" die
|
||||||
|
Einrueckung der Vorlage als leere erste Zeile in der Zelle. #}
|
||||||
<td class="fs-sm muted break-all td--detail" data-spalte="Detail">
|
<td class="fs-sm muted break-all td--detail" data-spalte="Detail">
|
||||||
{{ row.detail | audit_detail }}
|
{{- row.detail | audit_detail -}}
|
||||||
</td>
|
</td>
|
||||||
<td data-spalte="Stufe">
|
<td data-spalte="Stufe">
|
||||||
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
|
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
|
||||||
|
|||||||
@@ -427,9 +427,15 @@ def nav_order():
|
|||||||
if submitted:
|
if submitted:
|
||||||
child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted)
|
child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted)
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
|
vorher = extension.settings.get(conn, "nav_order") or extension.nav.default_order()
|
||||||
extension.settings.set(conn, "nav_order", order, actor=current_user.username)
|
extension.settings.set(conn, "nav_order", order, actor=current_user.username)
|
||||||
extension.settings.set(conn, "nav_child_order", child_order, actor=current_user.username)
|
extension.settings.set(conn, "nav_child_order", child_order, actor=current_user.username)
|
||||||
_audit(conn, "system.nav_order_changed", severity="info")
|
_audit(
|
||||||
|
conn,
|
||||||
|
"system.nav_order_changed",
|
||||||
|
detail=f"{', '.join(vorher)} -> {', '.join(order)}",
|
||||||
|
severity="info",
|
||||||
|
)
|
||||||
flash("Reihenfolge der Navigation gespeichert.", "success")
|
flash("Reihenfolge der Navigation gespeichert.", "success")
|
||||||
return redirect(request.referrer or url_for("index"))
|
return redirect(request.referrer or url_for("index"))
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user