Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
713ff12db2 | ||
|
|
2db7e03154 | ||
|
|
5dcb87d6ec | ||
|
|
dc94a8e666 | ||
|
|
c639f4f507 | ||
|
|
ad0195bab0 | ||
|
|
ce22239d78 | ||
|
|
92c8abb79b |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.7.2"
|
version = "2.7.8"
|
||||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ def update_subnet(subnet_id: int):
|
|||||||
flash(problem, "danger")
|
flash(problem, "danger")
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
|
# Vor der Aenderung lesen -- danach ist der alte Stand weg, und "Subnetz
|
||||||
|
# gespeichert" allein sagt nicht, was vorher eingestellt war.
|
||||||
|
vorher = Database.one(conn, "SELECT * FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||||
with transaction():
|
with transaction():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
|
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
|
||||||
@@ -189,7 +192,26 @@ def update_subnet(subnet_id: int):
|
|||||||
subnet_id,
|
subnet_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_audit("dhcp.subnet_updated", str(subnet_id), severity="notice")
|
nachher = Database.one(conn, "SELECT * FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||||
|
_audit(
|
||||||
|
"dhcp.subnet_updated",
|
||||||
|
f"{interface} {start}-{end}",
|
||||||
|
audit.diff(
|
||||||
|
vorher,
|
||||||
|
nachher,
|
||||||
|
felder=(
|
||||||
|
("interface", "Schnittstelle"),
|
||||||
|
("range_start", "Von"),
|
||||||
|
("range_end", "Bis"),
|
||||||
|
("gateway", "Gateway"),
|
||||||
|
("dns", "DNS"),
|
||||||
|
("comment", "Kommentar"),
|
||||||
|
("enabled", "Aktiv"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
or "keine inhaltliche Änderung",
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
flash("Subnetz gespeichert.", "success")
|
flash("Subnetz gespeichert.", "success")
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
@@ -395,6 +417,7 @@ def add_reservation():
|
|||||||
)
|
)
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
|
vorher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
|
||||||
with transaction():
|
with transaction():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
|
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
|
||||||
@@ -407,7 +430,18 @@ def add_reservation():
|
|||||||
(request.form.get("comment") or "").strip(),
|
(request.form.get("comment") or "").strip(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_audit("dhcp.reservation_saved", f"{mac} -> {ip}", severity="notice")
|
nachher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
|
||||||
|
_audit(
|
||||||
|
"dhcp.reservation_saved",
|
||||||
|
mac,
|
||||||
|
audit.diff(
|
||||||
|
vorher,
|
||||||
|
nachher,
|
||||||
|
felder=(("ip", "IP"), ("hostname", "Hostname"), ("comment", "Kommentar")),
|
||||||
|
)
|
||||||
|
or f"neu angelegt: {ip}",
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
flash("Reservierung gespeichert.", "success")
|
flash("Reservierung gespeichert.", "success")
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|||||||
@@ -272,6 +272,19 @@ def save_form(token: str):
|
|||||||
if upload_file is None or not upload_file.filename:
|
if upload_file is None or not upload_file.filename:
|
||||||
return {"error": "Es kam keine Datei an."}, 400
|
return {"error": "Es kam keine Datei an."}, 400
|
||||||
|
|
||||||
|
# Ein unterschriebenes Dokument ist abgeschlossen. Es als Vorlage fuer eine
|
||||||
|
# neue Fassung zu benutzen hiesse, eine Unterschrift unter einen anderen
|
||||||
|
# Text zu stellen -- die Oberflaeche blendet das aus, hier wird es
|
||||||
|
# abgewiesen.
|
||||||
|
if service.signaturstand(db(), mount, relative).get("status") in {
|
||||||
|
"unveraendert",
|
||||||
|
"veraendert",
|
||||||
|
}:
|
||||||
|
return {
|
||||||
|
"error": "Dieses Dokument ist unterschrieben und damit abgeschlossen. "
|
||||||
|
"Legen Sie eine neue Fassung aus der ursprünglichen Vorlage an."
|
||||||
|
}, 409
|
||||||
|
|
||||||
grenze = uploads.limit_bytes(db())
|
grenze = uploads.limit_bytes(db())
|
||||||
daten = upload_file.stream.read(grenze + 1)
|
daten = upload_file.stream.read(grenze + 1)
|
||||||
if len(daten) > grenze:
|
if len(daten) > grenze:
|
||||||
@@ -343,10 +356,13 @@ def save_form(token: str):
|
|||||||
# da: der Name **in** der Unterschrift ist eine Angabe des
|
# da: der Name **in** der Unterschrift ist eine Angabe des
|
||||||
# Unterschreibenden -- wie auf Papier. Wer die Datei abgelegt hat, steht
|
# Unterschreibenden -- wie auf Papier. Wer die Datei abgelegt hat, steht
|
||||||
# ohnehin als Handelnder in der Zeile und ist belegt.
|
# ohnehin als Handelnder in der Zeile und ist belegt.
|
||||||
beschreibung = f"{ziel_mount.label}:{wohin} sha256={pruefsumme}"
|
# Zwei Zeilen: oben, was ein Mensch liest; unten die Pruefsumme, die
|
||||||
|
# niemand liest, aber jeder vergleichen koennen muss. In einer einzigen
|
||||||
|
# Zeile drueckte der Hash den Rest aus der Spalte.
|
||||||
|
kopf = f"{'Signiert' if unterschriften else 'Gespeichert'}: {wohin}"
|
||||||
if unterschriften:
|
if unterschriften:
|
||||||
namen = ", ".join(unterschriften)
|
kopf += f" von: {', '.join(unterschriften)}"
|
||||||
beschreibung = f"{beschreibung} -- angegeben unterschrieben von: {namen}"
|
beschreibung = f"{kopf}{chr(10)}sha256={pruefsumme}"
|
||||||
_audit(
|
_audit(
|
||||||
"fileshare.form_signed" if unterschriften else "fileshare.form_saved",
|
"fileshare.form_signed" if unterschriften else "fileshare.form_saved",
|
||||||
f"{mount.label}:{relative}",
|
f"{mount.label}:{relative}",
|
||||||
|
|||||||
@@ -696,7 +696,10 @@ def signaturdetail_lesen(detail: str) -> dict[str, Any]:
|
|||||||
von: <Namen>``. Gelesen wird sie an zwei Stellen -- beim Uebernehmen
|
von: <Namen>``. Gelesen wird sie an zwei Stellen -- beim Uebernehmen
|
||||||
alter Eintraege und wenn das Verzeichnis nichts weiss.
|
alter Eintraege und wenn das Verzeichnis nichts weiss.
|
||||||
"""
|
"""
|
||||||
ort, _, rest = detail.partition(" sha256=")
|
# Der Trenner ist heute ein Zeilenumbruch, frueher ein Leerzeichen -- die
|
||||||
|
# Zerlegung muss beides vertragen, sonst sind alte Eintraege stumm.
|
||||||
|
ort, _, rest = detail.partition("sha256=")
|
||||||
|
ort = ort.strip()
|
||||||
label, _, pfad = ort.partition(":")
|
label, _, pfad = ort.partition(":")
|
||||||
summe = rest.split(" ", 1)[0].strip() if rest else ""
|
summe = rest.split(" ", 1)[0].strip() if rest else ""
|
||||||
return {
|
return {
|
||||||
@@ -892,6 +895,31 @@ def _namen_aus_detail(detail: str) -> list[str]:
|
|||||||
return [teil.strip() for teil in detail.split(marke, 1)[1].split(",") if teil.strip()]
|
return [teil.strip() for teil in detail.split(marke, 1)[1].split(",") if teil.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
#: Mehr als das zaehlt niemand mehr mit dem Auge -- und auf einer
|
||||||
|
#: Netzwerkfreigabe kostet jeder weitere Eintrag Zeit.
|
||||||
|
ZAEHLGRENZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _inhalt_zaehlen(verzeichnis: Path) -> int:
|
||||||
|
"""Wie viele Eintraege liegen im Ordner? Begrenzt gezaehlt.
|
||||||
|
|
||||||
|
Gezeigt wird die Zahl an der Stelle, an der bei einer Datei die Groesse
|
||||||
|
steht -- dort stand bisher ein Gedankenstrich. Bei mehr als
|
||||||
|
``ZAEHLGRENZE`` bricht das Zaehlen ab: die Oberflaeche schreibt dann
|
||||||
|
"500+", und niemand wartet auf eine Freigabe mit 40.000 Dateien.
|
||||||
|
"""
|
||||||
|
anzahl = 0
|
||||||
|
try:
|
||||||
|
with os.scandir(verzeichnis) as eintraege:
|
||||||
|
for _ in eintraege:
|
||||||
|
anzahl += 1
|
||||||
|
if anzahl >= ZAEHLGRENZE:
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
return -1
|
||||||
|
return anzahl
|
||||||
|
|
||||||
|
|
||||||
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
||||||
path = resolve_path(mount, relative)
|
path = resolve_path(mount, relative)
|
||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
@@ -913,6 +941,7 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
if entry.is_dir():
|
if entry.is_dir():
|
||||||
|
record["entries"] = _inhalt_zaehlen(entry)
|
||||||
directories.append(record)
|
directories.append(record)
|
||||||
else:
|
else:
|
||||||
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
||||||
|
|||||||
@@ -677,6 +677,12 @@ def update_device(
|
|||||||
("Port", str(row["port"] or ""), clean_port),
|
("Port", str(row["port"] or ""), clean_port),
|
||||||
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
|
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
|
||||||
("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))),
|
("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))),
|
||||||
|
# Diese vier fehlten: eine geaenderte Kategorie oder ein anderer
|
||||||
|
# SSH-Port stand nirgends, obwohl beides das Verhalten aendert.
|
||||||
|
("SSH-Port", str(row["ssh_port"] or ""), str(validate_ssh_port(ssh_port) or "")),
|
||||||
|
("Zugangsdaten", str(row["credential_id"] or ""), str(credential_id or "")),
|
||||||
|
("Kategorie", str(row["category"] or ""), (category or "").strip()),
|
||||||
|
("Standort", str(row["location"] or ""), (location or "").strip()),
|
||||||
):
|
):
|
||||||
if before != after:
|
if before != after:
|
||||||
changes.append(f"{label}: {before or '-'} -> {after or '-'}")
|
changes.append(f"{label}: {before or '-'} -> {after or '-'}")
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ CATEGORIES: tuple[Category, ...] = (
|
|||||||
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
||||||
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
|
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
|
||||||
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
|
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
|
||||||
|
Category(
|
||||||
|
"signatures",
|
||||||
|
"Unterschriften",
|
||||||
|
"Pruefsummen unterschriebener Dokumente. Ohne sie sagt die Vorschau nach einer "
|
||||||
|
"Wiederherstellung zu jeder alten Fassung \"unbekannt\" – auch zu einer "
|
||||||
|
"veraenderten.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
|
CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
|
||||||
@@ -186,6 +193,16 @@ def build_export(
|
|||||||
# Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben.
|
# Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben.
|
||||||
if not str(row["key"]).startswith("license_")
|
if not str(row["key"]).startswith("license_")
|
||||||
}
|
}
|
||||||
|
if "signatures" in wanted:
|
||||||
|
# Das Verzeichnis der Unterschriften. Der Beleg selbst bleibt das
|
||||||
|
# Aenderungsprotokoll (Hash-Kette) und das Journal neben dem Archiv --
|
||||||
|
# beides wandert nicht in die Sicherung. Ohne diese Zeilen waere nach
|
||||||
|
# einer Wiederherstellung jede unterschriebene Fassung "unbekannt".
|
||||||
|
payload["signatures"] = Database.all(
|
||||||
|
conn,
|
||||||
|
"SELECT sha256, share_label, path, signed_at, actor, names "
|
||||||
|
"FROM document_signatures ORDER BY id",
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -625,6 +642,38 @@ def apply_import(
|
|||||||
(user_id, int(group_id)),
|
(user_id, int(group_id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "signatures" in wanted:
|
||||||
|
for eintrag in payload.get("signatures") or []:
|
||||||
|
if not isinstance(eintrag, dict):
|
||||||
|
continue
|
||||||
|
summe = str(eintrag.get("sha256") or "").strip()
|
||||||
|
pfad = str(eintrag.get("path") or "").strip()
|
||||||
|
if not summe:
|
||||||
|
continue
|
||||||
|
# Zweimal einlesen darf nichts verdoppeln: verglichen wird ueber
|
||||||
|
# Pruefsumme und Ablageort, genau wie beim Journal.
|
||||||
|
vorhanden = Database.value(
|
||||||
|
conn,
|
||||||
|
"SELECT 1 FROM document_signatures WHERE sha256=? AND path=? LIMIT 1",
|
||||||
|
(summe, pfad),
|
||||||
|
)
|
||||||
|
if vorhanden:
|
||||||
|
bump("signatures", "skipped")
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, "
|
||||||
|
"actor, names, audit_id) VALUES (?,?,?,?,?,?,NULL)",
|
||||||
|
(
|
||||||
|
summe,
|
||||||
|
str(eintrag.get("share_label") or ""),
|
||||||
|
pfad,
|
||||||
|
str(eintrag.get("signed_at") or stamp),
|
||||||
|
str(eintrag.get("actor") or ""),
|
||||||
|
str(eintrag.get("names") or ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bump("signatures", "created")
|
||||||
|
|
||||||
if "settings" in wanted:
|
if "settings" in wanted:
|
||||||
for key, value in (payload.get("settings") or {}).items():
|
for key, value in (payload.get("settings") or {}).items():
|
||||||
if str(key).startswith("license_"):
|
if str(key).startswith("license_"):
|
||||||
|
|||||||
@@ -104,10 +104,11 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
True,
|
True,
|
||||||
"bool",
|
"bool",
|
||||||
"Unterschriften mit dem ablegenden Konto bezeugen",
|
"Unterschriften mit dem ablegenden Konto bezeugen",
|
||||||
hint="Unter jede Unterschrift kommt eine Zeile, wer das Dokument in TESM abgelegt "
|
hint="Eine Zeile unter der **ersten** Unterschrift nennt das Konto, das das "
|
||||||
"hat. Der Name in der Unterschrift ist eine Angabe des Unterschreibenden – wie "
|
"Dokument in TESM abgelegt hat. In den Dokumenteigenschaften und im "
|
||||||
"auf Papier; das Konto daneben ist belegt. Eine Passwortabfrage gibt es dafür "
|
"Änderungsprotokoll steht es ohnehin, unabhängig von diesem Schalter. Der Name in "
|
||||||
"nicht.",
|
"der Unterschrift ist eine Angabe des Unterschreibenden – wie auf Papier; das "
|
||||||
|
"Konto ist belegt. Eine Passwortabfrage gibt es dafür nicht.",
|
||||||
section="fileshare",
|
section="fileshare",
|
||||||
),
|
),
|
||||||
# -- Terminal ----------------------------------------------------------- #
|
# -- Terminal ----------------------------------------------------------- #
|
||||||
|
|||||||
@@ -81,11 +81,24 @@
|
|||||||
{% if current_user.has('fileshare.delete') %}
|
{% if current_user.has('fileshare.delete') %}
|
||||||
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td>
|
{# Symbol und Name in einer Zeile: der Zeilenumbruch dazwischen
|
||||||
|
liess den Ordner aussehen wie zwei halbe Zeilen. #}
|
||||||
|
<td class="file-cell">
|
||||||
{{ icon('folder', size=15) }}
|
{{ icon('folder', size=15) }}
|
||||||
<a href="{{ url_for('fileshare.browse', share=token, path=entry.relative) }}">{{ entry.name }}</a>
|
<a href="{{ url_for('fileshare.browse', share=token, path=entry.relative) }}">{{ entry.name }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="numeric faint">–</td>
|
{# An der Stelle der Dateigrösse: wie viel liegt darin. #}
|
||||||
|
<td class="numeric faint fs-sm">
|
||||||
|
{%- if entry.entries is not defined or entry.entries < 0 -%}
|
||||||
|
–
|
||||||
|
{%- elif entry.entries >= 500 -%}
|
||||||
|
500+ Einträge
|
||||||
|
{%- elif entry.entries == 1 -%}
|
||||||
|
1 Eintrag
|
||||||
|
{%- else -%}
|
||||||
|
{{ entry.entries }} Einträge
|
||||||
|
{%- endif -%}
|
||||||
|
</td>
|
||||||
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
||||||
<td class="actions"></td>
|
<td class="actions"></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -84,7 +84,9 @@
|
|||||||
<div class="card__title">{{ icon('pencil', size=17) }} Unterschriften</div>
|
<div class="card__title">{{ icon('pencil', size=17) }} Unterschriften</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Der Name in der Unterschrift ist eine Angabe des Unterschreibenden – wie auf
|
Der Name in der Unterschrift ist eine Angabe des Unterschreibenden – wie auf
|
||||||
Papier. Wer das Dokument in TESM abgelegt hat, ist dagegen belegt.
|
Papier. Wer das Dokument abgelegt hat, ist dagegen belegt: in den
|
||||||
|
Dokumenteigenschaften und im Änderungsprotokoll, auf Wunsch zusätzlich
|
||||||
|
sichtbar unter der ersten Unterschrift.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -542,8 +542,8 @@ if [[ "$APP" == "tesm" ]]; then
|
|||||||
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
|
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
|
||||||
fi
|
fi
|
||||||
# Aufraeumen lief bisher nur von Hand -- also praktisch nie. Auf einer
|
# Aufraeumen lief bisher nur von Hand -- also praktisch nie. Auf einer
|
||||||
# laufenden Installation standen deshalb hunderte abgelaufene Sitzungszeilen
|
# laufenden Installation lagen deshalb hunderte abgelaufene Sitzungszeilen in
|
||||||
# in der Datenbank, und das Aenderungsprotokoll waere nie archiviert worden.
|
# der Datenbank, und das Aenderungsprotokoll waere nie archiviert worden.
|
||||||
render_unit "$ROOT/deploy/systemd/$APP-cleanup.service" "/etc/systemd/system/$CLEANUP_UNIT"
|
render_unit "$ROOT/deploy/systemd/$APP-cleanup.service" "/etc/systemd/system/$CLEANUP_UNIT"
|
||||||
render_unit "$ROOT/deploy/systemd/$APP-cleanup.timer" "/etc/systemd/system/$CLEANUP_TIMER"
|
render_unit "$ROOT/deploy/systemd/$APP-cleanup.timer" "/etc/systemd/system/$CLEANUP_TIMER"
|
||||||
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ Description=TESM -- taegliches Aufraeumen
|
|||||||
# und die Archivierung des Aenderungsprotokolls.
|
# und die Archivierung des Aenderungsprotokolls.
|
||||||
#
|
#
|
||||||
# Vorher lief das nur, wenn jemand "tesm-admin housekeeping" aufrief oder in
|
# Vorher lief das nur, wenn jemand "tesm-admin housekeeping" aufrief oder in
|
||||||
# der Diagnose darauf klickte -- also praktisch nie. Gemessen: 444 offene
|
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
|
||||||
# Sitzungszeilen bei drei Benutzern.
|
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
|
||||||
OnCalendar=*-*-* 03:30:00
|
OnCalendar=*-*-* 03:30:00
|
||||||
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
|
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
|
||||||
RandomizedDelaySec=15m
|
RandomizedDelaySec=15m
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ Description=TESM-Lizenzserver -- taegliches Aufraeumen
|
|||||||
# und die Archivierung des Aenderungsprotokolls.
|
# und die Archivierung des Aenderungsprotokolls.
|
||||||
#
|
#
|
||||||
# Vorher lief das nur, wenn jemand "tesm-license-admin housekeeping" aufrief oder in
|
# Vorher lief das nur, wenn jemand "tesm-license-admin housekeeping" aufrief oder in
|
||||||
# der Diagnose darauf klickte -- also praktisch nie. Gemessen: 444 offene
|
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
|
||||||
# Sitzungszeilen bei drei Benutzern.
|
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
|
||||||
OnCalendar=*-*-* 03:30:00
|
OnCalendar=*-*-* 03:30:00
|
||||||
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
|
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
|
||||||
RandomizedDelaySec=15m
|
RandomizedDelaySec=15m
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.7.2"
|
version = "2.7.8"
|
||||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -148,7 +148,11 @@ def create_base_app(
|
|||||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||||
#: Anwendungen.
|
#: Anwendungen.
|
||||||
auth_tagline: str = "",
|
auth_tagline: str = "",
|
||||||
app_version: str = "2.7.2",
|
#: Beide Anwendungen geben ihre eigene Paketversion mit -- und die laeuft
|
||||||
|
#: absichtlich auseinander: was sich nicht geaendert hat, bekommt keine
|
||||||
|
#: neue Nummer. Eine feste Zahl an dieser Stelle wuerde irgendwann eine
|
||||||
|
#: falsche Version in die Fussnote schreiben.
|
||||||
|
app_version: str = "unbekannt",
|
||||||
built_in_trusted_keys: tuple[str, ...] = (),
|
built_in_trusted_keys: tuple[str, ...] = (),
|
||||||
enable_license: bool = True,
|
enable_license: bool = True,
|
||||||
template_folder: str | None = None,
|
template_folder: str | None = None,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1456,18 +1456,23 @@
|
|||||||
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
|
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
|
||||||
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
|
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
|
||||||
zieht statt die Seite zu scrollen. */
|
zieht statt die Seite zu scrollen. */
|
||||||
|
/* Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
|
||||||
|
Ankerpunkt -- dieselbe Kante, an der pdf-lib das Bild setzt. Der Text
|
||||||
|
haengt darunter und darf die Hoehe nicht mitbestimmen. */
|
||||||
.viewer__unterschrift {
|
.viewer__unterschrift {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
container-type: inline-size;
|
/* "outline" statt "border": ein Rahmen haette die Marke um zwei Pixel
|
||||||
border: 1px dashed transparent;
|
groesser gemacht und die Bildkante vom Ankerpunkt weggeschoben. */
|
||||||
|
outline: 1px dashed transparent;
|
||||||
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.viewer__unterschrift:hover,
|
.viewer__unterschrift:hover,
|
||||||
.viewer__unterschrift.is-fassend {
|
.viewer__unterschrift.is-fassend {
|
||||||
border-color: var(--accent);
|
outline-color: var(--accent);
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1483,11 +1488,23 @@
|
|||||||
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
|
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
|
||||||
anderes als die Datei. */
|
anderes als die Datei. */
|
||||||
.viewer__unterschrifttext {
|
.viewer__unterschrifttext {
|
||||||
font-size: 5.6cqw;
|
/* Unter der Zeichnung, nicht in ihr: "top: 100%" setzt die Oberkante des
|
||||||
|
Strichs auf die Bildkante -- genau dort zieht ihn auch pdf-lib. Abstand,
|
||||||
|
Strichstaerke und Innenabstand rechnet das Skript in Punkten. */
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
/* Helvetica: dieselbe Schrift, die das PDF benutzt (StandardFonts.Helvetica).
|
||||||
|
Mit der Hausschrift sah die Vorschau anders aus als das Ergebnis. Die
|
||||||
|
Groesse setzt das Skript je Unterschrift -- hier steht nur ein Rueckfall. */
|
||||||
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
|
/* Die tatsaechliche Groesse setzt das Skript in Pixeln (Punkte mal
|
||||||
|
Pixel-je-Punkt der Seite). Dieser Wert gilt nur, solange sie noch nicht
|
||||||
|
gerechnet wurde -- geerbt wuerde sonst die Schriftgroesse der Seite. */
|
||||||
|
font-size: 8px;
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
color: #111;
|
color: #111;
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.35);
|
border-top: 1px solid rgba(0, 0, 0, 0.85);
|
||||||
padding-top: 1px;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
@@ -1581,3 +1598,76 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
background: rgba(201, 42, 42, 0.12);
|
background: rgba(201, 42, 42, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Tabelle, die schmal zu Bloecken wird -------------------------------- */
|
||||||
|
/* Eine Protokollzeile besteht aus sieben Angaben, von denen eine lang ist:
|
||||||
|
das Detail. Auf einem breiten Schirm passt das nebeneinander. Wird es eng,
|
||||||
|
ist eine Spalte von zwei Zentimetern fuer eine Pruefsumme keine Loesung --
|
||||||
|
dann steht jede Angabe auf ihrer eigenen Zeile, beschriftet. */
|
||||||
|
|
||||||
|
.table--stapelbar .td--detail {
|
||||||
|
/* Zeilenumbrueche aus dem Eintrag bleiben stehen: die Pruefsumme steht
|
||||||
|
unter dem lesbaren Teil, nicht mitten darin. Und ein Hash ohne Trennstelle
|
||||||
|
darf notfalls hart umbrechen. */
|
||||||
|
white-space: pre-line;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
max-width: 52ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.table--stapelbar thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar,
|
||||||
|
.table--stapelbar tbody,
|
||||||
|
.table--stapelbar tr,
|
||||||
|
.table--stapelbar td {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar tr {
|
||||||
|
padding: var(--sp-3) 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar td {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 8.5rem 1fr;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
align-items: baseline;
|
||||||
|
border: 0;
|
||||||
|
padding: 2px var(--sp-3);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar td::before {
|
||||||
|
content: attr(data-spalte);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar .td--detail {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leere Zellen brauchen keine Zeile. */
|
||||||
|
.table--stapelbar td:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Dateiliste: Symbol und Name in einer Zeile -------------------------- */
|
||||||
|
/* Das Symbol ist ein Block-SVG; ohne diese Regel stand der Name darunter und
|
||||||
|
eine Ordnerzeile sah doppelt so hoch aus wie eine Dateizeile. */
|
||||||
|
|
||||||
|
.file-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-cell > svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
@@ -280,7 +280,13 @@ async function nachbearbeiten(
|
|||||||
const hoehe = (png.height / png.width) * breite;
|
const hoehe = (png.height / png.width) * breite;
|
||||||
seite.drawImage(png, { x: eintrag.x, y: eintrag.y, width: breite, height: hoehe });
|
seite.drawImage(png, { x: eintrag.x, y: eintrag.y, width: breite, height: hoehe });
|
||||||
const zeilen = [`${eintrag.name} | ${eintrag.zeit}`];
|
const zeilen = [`${eintrag.name} | ${eintrag.zeit}`];
|
||||||
if (zeuge) zeilen.push(`In TESM abgelegt durch ${zeuge}`);
|
// Der Zeugenvermerk gehoert zum Dokument, nicht zu jeder Unterschrift:
|
||||||
|
// unter drei Unterschriften stand dreimal derselbe Satz. Sichtbar
|
||||||
|
// erscheint er deshalb nur unter der ersten -- fest steht er ohnehin in
|
||||||
|
// den Dokumenteigenschaften und im Aenderungsprotokoll.
|
||||||
|
if (zeuge && eintrag === unterschriften[0]) {
|
||||||
|
zeilen.push(`In TESM abgelegt durch ${zeuge}`);
|
||||||
|
}
|
||||||
// Die Schrift waechst mit der Unterschrift: wer sie kleiner zieht, will
|
// Die Schrift waechst mit der Unterschrift: wer sie kleiner zieht, will
|
||||||
// den ganzen Block kleiner haben, nicht eine Zeichnung mit einer
|
// den ganzen Block kleiner haben, nicht eine Zeichnung mit einer
|
||||||
// Bildunterschrift in Originalgroesse daneben.
|
// Bildunterschrift in Originalgroesse daneben.
|
||||||
@@ -291,9 +297,9 @@ async function nachbearbeiten(
|
|||||||
const textbreite = Math.max(
|
const textbreite = Math.max(
|
||||||
...zeilen.map((zeile) => schrift.widthOfTextAtSize(zeile, groesse))
|
...zeilen.map((zeile) => schrift.widthOfTextAtSize(zeile, groesse))
|
||||||
);
|
);
|
||||||
let unten = eintrag.y - 2;
|
let unten = eintrag.y - UNTERSCHRIFT.textY;
|
||||||
for (const zeile of zeilen) {
|
for (const zeile of zeilen) {
|
||||||
unten -= groesse * 1.15;
|
unten -= groesse * UNTERSCHRIFT.zeile;
|
||||||
seite.drawText(zeile, {
|
seite.drawText(zeile, {
|
||||||
x: eintrag.x,
|
x: eintrag.x,
|
||||||
y: unten,
|
y: unten,
|
||||||
@@ -302,15 +308,32 @@ async function nachbearbeiten(
|
|||||||
color: PDFLib.rgb(0.1, 0.1, 0.1),
|
color: PDFLib.rgb(0.1, 0.1, 0.1),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Kraeftig genug, um neben den Linien eines Formulars als eigener Strich
|
||||||
|
// erkennbar zu bleiben: ein halber Punkt in hellem Grau ging zwischen
|
||||||
|
// den Tabellenlinien schlicht unter.
|
||||||
seite.drawLine({
|
seite.drawLine({
|
||||||
start: { x: eintrag.x, y: eintrag.y - 1 },
|
start: { x: eintrag.x, y: eintrag.y - UNTERSCHRIFT.strichY },
|
||||||
end: { x: eintrag.x + textbreite, y: eintrag.y - 1 },
|
end: { x: eintrag.x + textbreite, y: eintrag.y - UNTERSCHRIFT.strichY },
|
||||||
thickness: 0.5,
|
thickness: UNTERSCHRIFT.strich,
|
||||||
color: PDFLib.rgb(0.4, 0.4, 0.4),
|
color: PDFLib.rgb(0.15, 0.15, 0.15),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (unterschriften.length) {
|
||||||
|
// In den Dokumenteigenschaften -- dort sucht man so etwas, und dort steht
|
||||||
|
// es einmal statt unter jeder Unterschrift. Jeder Betrachter zeigt das
|
||||||
|
// an, auch ausserhalb von TESM.
|
||||||
|
const namen = unterschriften.map((eintrag) => eintrag.name);
|
||||||
|
const wann = unterschriften[unterschriften.length - 1].zeit;
|
||||||
|
dokument.setAuthor(namen.join(", "));
|
||||||
|
dokument.setSubject(
|
||||||
|
`Unterschrieben am ${wann}` + (zeuge ? ` | in TESM abgelegt durch ${zeuge}` : "")
|
||||||
|
);
|
||||||
|
dokument.setProducer("TESM");
|
||||||
|
dokument.setModificationDate(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
if (abschliessen) {
|
if (abschliessen) {
|
||||||
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
|
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
|
||||||
// gewollt: so kann niemand mehr etwas verstellen.
|
// gewollt: so kann niemand mehr etwas verstellen.
|
||||||
@@ -319,12 +342,77 @@ async function nachbearbeiten(
|
|||||||
return dokument.save();
|
return dokument.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Masse des Textblocks unter einer Unterschrift -- in PDF-Punkten.
|
||||||
|
*
|
||||||
|
* Die Vorschau rechnet dieselben Zahlen in Pixel um. Standen sie zweimal im
|
||||||
|
* Code, liefen sie auseinander: die Marke haengt mit ihrer **Unterkante** am
|
||||||
|
* Ankerpunkt, das PDF setzt dort die **Unterkante des Bildes** und schreibt
|
||||||
|
* den Text darunter. Beim Setzen sass der Block deshalb um die Texthoehe
|
||||||
|
* hoeher als in der gespeicherten Datei.
|
||||||
|
*/
|
||||||
|
const UNTERSCHRIFT = {
|
||||||
|
//: Mitte des Strichs unter der Bildkante.
|
||||||
|
strichY: 1.5,
|
||||||
|
//: Staerke des Strichs.
|
||||||
|
strich: 0.9,
|
||||||
|
//: Bildkante bis zur ersten Grundlinie, vor dem Zeilenabstand.
|
||||||
|
textY: 2,
|
||||||
|
//: Zeilenabstand als Vielfaches der Schriftgroesse.
|
||||||
|
zeile: 1.15,
|
||||||
|
//: Oberkante der Zeilenbox bis zur Grundlinie, in "em" -- nur ein
|
||||||
|
//: Rueckfall: gewoehnlich misst "grundlinie()" das am echten Text nach.
|
||||||
|
//: (Helvetica/Arial: Ascender 0,905 plus halber Durchschuss.)
|
||||||
|
grundlinie: 0.9215,
|
||||||
|
};
|
||||||
|
|
||||||
|
//: Gemessene Grundlinien, je dargestellter Schriftgroesse in Pixeln.
|
||||||
|
const grundlinien = new Map();
|
||||||
|
|
||||||
|
/** Abstand von der Oberkante der Zeilenbox zur Grundlinie, in "em".
|
||||||
|
*
|
||||||
|
* Gemessen statt geraten, und bei genau der Pixelgroesse, die auch auf dem
|
||||||
|
* Schirm steht. Zwei Gruende: welche Datei hinter "Helvetica" steckt,
|
||||||
|
* entscheidet das Geraet -- auf einem iPad eine andere als auf einem
|
||||||
|
* Windows-Rechner, mit anderen Oberlaengen. Und der Browser rundet die
|
||||||
|
* Schriftmasse auf ganze Pixel: bei acht Punkt Schrift verschiebt das die
|
||||||
|
* Zeile um einen halben Pixel gegenueber der Rechnung. pdf-lib rundet nicht.
|
||||||
|
*
|
||||||
|
* Eine leere Inline-Box sitzt auf der Grundlinie -- ihre Lage verraet also,
|
||||||
|
* wo der Text steht.
|
||||||
|
*/
|
||||||
|
function grundlinie(px) {
|
||||||
|
const schluessel = px.toFixed(1);
|
||||||
|
const gemerkt = grundlinien.get(schluessel);
|
||||||
|
if (gemerkt !== undefined) return gemerkt;
|
||||||
|
let anteil = UNTERSCHRIFT.grundlinie;
|
||||||
|
try {
|
||||||
|
const probe = document.createElement("div");
|
||||||
|
probe.className = "viewer__unterschrifttext";
|
||||||
|
probe.style.cssText =
|
||||||
|
"position:absolute;left:-9999px;top:0;visibility:hidden;border:0;padding:0;";
|
||||||
|
probe.style.fontSize = `${px}px`;
|
||||||
|
probe.style.lineHeight = String(UNTERSCHRIFT.zeile);
|
||||||
|
const lot = document.createElement("span");
|
||||||
|
lot.style.cssText = "display:inline-block;width:0;height:0;";
|
||||||
|
probe.append(document.createTextNode("Hg"), lot);
|
||||||
|
document.body.append(probe);
|
||||||
|
const oben = probe.getBoundingClientRect().top;
|
||||||
|
const auf = lot.getBoundingClientRect().top;
|
||||||
|
probe.remove();
|
||||||
|
if (auf > oben) anteil = (auf - oben) / px;
|
||||||
|
} catch (fehler) {
|
||||||
|
console.warn("Grundlinie nicht messbar -- Rueckfall auf Helvetica", fehler);
|
||||||
|
}
|
||||||
|
grundlinien.set(schluessel, anteil);
|
||||||
|
return anteil;
|
||||||
|
}
|
||||||
|
|
||||||
/** Schriftgroesse des Namenszugs unter einer Unterschrift dieser Breite.
|
/** Schriftgroesse des Namenszugs unter einer Unterschrift dieser Breite.
|
||||||
*
|
*
|
||||||
* Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht
|
* Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht
|
||||||
* Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe
|
* Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe
|
||||||
* Rechnung benutzt die Vorschau (als ``cqw``), damit auf dem Schirm steht,
|
* Rechnung benutzt die Vorschau -- in Pixeln umgerechnet --, damit auf dem
|
||||||
* was spaeter im PDF steht.
|
* Schirm steht, was spaeter im PDF steht.
|
||||||
*/
|
*/
|
||||||
function schriftgroesse(breite) {
|
function schriftgroesse(breite) {
|
||||||
return Math.max(5, Math.min(10, breite / 18));
|
return Math.max(5, Math.min(10, breite / 18));
|
||||||
@@ -614,6 +702,8 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
const MAX_BREITE = 150;
|
const MAX_BREITE = 150;
|
||||||
const MAX_HOEHE = 50;
|
const MAX_HOEHE = 50;
|
||||||
const unterschriften = [];
|
const unterschriften = [];
|
||||||
|
//: Je gesetzte Unterschrift eine Funktion, die ihre Marke neu vermisst.
|
||||||
|
const neuzeichnen = new Set();
|
||||||
let wartendeUnterschrift = null;
|
let wartendeUnterschrift = null;
|
||||||
let zeugenzeile = () => "";
|
let zeugenzeile = () => "";
|
||||||
|
|
||||||
@@ -661,10 +751,21 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
const bild = document.createElement("img");
|
const bild = document.createElement("img");
|
||||||
bild.src = eintrag.vorschau;
|
bild.src = eintrag.vorschau;
|
||||||
bild.alt = "";
|
bild.alt = "";
|
||||||
|
// Genau der Aufbau der spaeteren Datei: eine Zeile mit Namen und
|
||||||
|
// Zeitpunkt, darunter -- als eigene Zeile -- der Zeugenvermerk. In der
|
||||||
|
// Vorschau standen beide hintereinander, getrennt durch einen
|
||||||
|
// Schraegstrich; beim Positionieren sah der Block dadurch breiter und
|
||||||
|
// flacher aus als das Ergebnis.
|
||||||
const text = document.createElement("div");
|
const text = document.createElement("div");
|
||||||
text.className = "viewer__unterschrifttext";
|
text.className = "viewer__unterschrifttext";
|
||||||
text.textContent = `${eintrag.name} | ${eintrag.zeit}`;
|
const namenszeile = document.createElement("div");
|
||||||
if (zeuge) text.textContent += ` / In TESM abgelegt durch ${zeuge}`;
|
namenszeile.textContent = `${eintrag.name} | ${eintrag.zeit}`;
|
||||||
|
text.append(namenszeile);
|
||||||
|
if (zeuge && eintrag === unterschriften[0]) {
|
||||||
|
const zeugenzeile = document.createElement("div");
|
||||||
|
zeugenzeile.textContent = `In TESM abgelegt durch ${zeuge}`;
|
||||||
|
text.append(zeugenzeile);
|
||||||
|
}
|
||||||
const griff = document.createElement("button");
|
const griff = document.createElement("button");
|
||||||
griff.type = "button";
|
griff.type = "button";
|
||||||
griff.className = "viewer__unterschriftgriff";
|
griff.className = "viewer__unterschriftgriff";
|
||||||
@@ -680,16 +781,52 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
seitenEl.append(marke);
|
seitenEl.append(marke);
|
||||||
|
|
||||||
const zeichnen = () => {
|
const zeichnen = () => {
|
||||||
|
// Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
|
||||||
|
// Ankerpunkt, so wie im PDF. Der Text haengt darunter (siehe
|
||||||
|
// Gestaltung: absolut, "top: 100%") und zaehlt nicht zur Hoehe --
|
||||||
|
// sonst saesse der Block in der Vorschau um die Texthoehe zu hoch.
|
||||||
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
|
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
|
||||||
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
|
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
|
||||||
marke.style.width = `${(eintrag.breite / blick.width) * 100}%`;
|
marke.style.width = `${(eintrag.breite / blick.width) * 100}%`;
|
||||||
|
// Alles Weitere wird gerechnet, nicht vom Stylesheet abgeleitet:
|
||||||
|
// Punkte mal Pixel-je-Punkt der gerade dargestellten Seite. Ueber
|
||||||
|
// Container-Einheiten ("cqw") ging das auch -- aber nur dort, wo der
|
||||||
|
// Browser sie kennt; wo nicht, erbte der Block die Schriftgroesse der
|
||||||
|
// Seite, und der Text war um die Haelfte zu breit.
|
||||||
|
const proPunkt = seitenEl.getBoundingClientRect().width / blick.width;
|
||||||
|
const groesse = schriftgroesse(eintrag.breite);
|
||||||
|
const inPixeln = groesse * proPunkt;
|
||||||
|
const oben = UNTERSCHRIFT.strichY - UNTERSCHRIFT.strich / 2;
|
||||||
|
text.style.fontSize = `${inPixeln}px`;
|
||||||
|
text.style.lineHeight = String(UNTERSCHRIFT.zeile);
|
||||||
|
text.style.marginTop = `${oben * proPunkt}px`;
|
||||||
|
text.style.borderTopWidth = `${UNTERSCHRIFT.strich * proPunkt}px`;
|
||||||
|
// Rahmenbreiten rundet der Browser auf ganze Geraetepixel: aus 1,21
|
||||||
|
// wird 1. Mit dem gerechneten Wert sass die erste Zeile deshalb einen
|
||||||
|
// Fuenftel Punkt zu hoch -- also mit dem tatsaechlich benutzten rechnen.
|
||||||
|
const strich =
|
||||||
|
(parseFloat(getComputedStyle(text).borderTopWidth) ||
|
||||||
|
UNTERSCHRIFT.strich * proPunkt) / proPunkt;
|
||||||
|
// Oberkante des Strichs, die Strichstaerke, dann so viel Luft, dass die
|
||||||
|
// erste Grundlinie dort sitzt, wo pdf-lib sie setzt: textY plus einen
|
||||||
|
// Zeilenabstand unter der Bildkante.
|
||||||
|
const innen =
|
||||||
|
UNTERSCHRIFT.textY +
|
||||||
|
UNTERSCHRIFT.zeile * groesse -
|
||||||
|
oben -
|
||||||
|
strich -
|
||||||
|
grundlinie(inPixeln) * groesse;
|
||||||
|
text.style.paddingTop = `${Math.max(0, innen) * proPunkt}px`;
|
||||||
};
|
};
|
||||||
|
// Beim Zoomen aendert sich Pixel-je-Punkt -- dann neu rechnen.
|
||||||
|
neuzeichnen.add(zeichnen);
|
||||||
zeichnen();
|
zeichnen();
|
||||||
|
|
||||||
weg.addEventListener("click", (event) => {
|
weg.addEventListener("click", (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const stelle = unterschriften.indexOf(eintrag);
|
const stelle = unterschriften.indexOf(eintrag);
|
||||||
if (stelle >= 0) unterschriften.splice(stelle, 1);
|
if (stelle >= 0) unterschriften.splice(stelle, 1);
|
||||||
|
neuzeichnen.delete(zeichnen);
|
||||||
marke.remove();
|
marke.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -717,8 +854,8 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
} else {
|
} else {
|
||||||
eintrag.x = Math.max(2, start.x + (zug.clientX - startX) / proPunkt);
|
eintrag.x = Math.max(2, start.x + (zug.clientX - startX) / proPunkt);
|
||||||
eintrag.y = Math.max(
|
eintrag.y = Math.max(
|
||||||
// Platz fuer die Textzeilen unter der Zeichnung.
|
// Platz fuer die beiden Textzeilen unter der Zeichnung.
|
||||||
schriftgroesse(eintrag.breite) * 2.5,
|
UNTERSCHRIFT.textY + 2 * UNTERSCHRIFT.zeile * schriftgroesse(eintrag.breite),
|
||||||
start.y - (zug.clientY - startY) / proPunkt
|
start.y - (zug.clientY - startY) / proPunkt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -741,6 +878,18 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
|
|||||||
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
|
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Nach dem Zoomen stehen die Marken anteilig richtig, aber die Schrift
|
||||||
|
// haengt an der Pixelgroesse der Seite -- die ist jetzt eine andere.
|
||||||
|
eventBus.on("scalechanging", () => {
|
||||||
|
for (const zeichnen of neuzeichnen) {
|
||||||
|
try {
|
||||||
|
zeichnen();
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error("Unterschrift neu vermessen", fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hinein: () => {
|
hinein: () => {
|
||||||
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
||||||
@@ -834,6 +983,8 @@ export function initFilePreview(wurzel = document) {
|
|||||||
const padAbbrechen = stueck("signabbrechen");
|
const padAbbrechen = stueck("signabbrechen");
|
||||||
const padHinweis = stueck("signhinweis");
|
const padHinweis = stueck("signhinweis");
|
||||||
const basis = rumpf.dataset.viewerPdfjs || "";
|
const basis = rumpf.dataset.viewerPdfjs || "";
|
||||||
|
// Der Anmeldename, nicht der volle Name: er ist eindeutig, und genau das
|
||||||
|
// soll die Zeile belegen.
|
||||||
const konto = rumpf.dataset.viewerKonto || "";
|
const konto = rumpf.dataset.viewerKonto || "";
|
||||||
const bezeugen = rumpf.dataset.viewerZeuge === "1";
|
const bezeugen = rumpf.dataset.viewerZeuge === "1";
|
||||||
// Die Uhr des Servers, nicht die des Tablets: eine verstellte Uhr am Geraet
|
// Die Uhr des Servers, nicht die des Tablets: eine verstellte Uhr am Geraet
|
||||||
@@ -877,6 +1028,7 @@ export function initFilePreview(wurzel = document) {
|
|||||||
leiste.hidden = art === "text";
|
leiste.hidden = art === "text";
|
||||||
if (speichern) speichern.hidden = art !== "pdf";
|
if (speichern) speichern.hidden = art !== "pdf";
|
||||||
if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf";
|
if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf";
|
||||||
|
if (speichern) speichern.hidden = art !== "pdf";
|
||||||
if (unterschriftPanel) unterschriftPanel.hidden = true;
|
if (unterschriftPanel) unterschriftPanel.hidden = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -918,6 +1070,15 @@ export function initFilePreview(wurzel = document) {
|
|||||||
if (!adresse) return;
|
if (!adresse) return;
|
||||||
try {
|
try {
|
||||||
const stand = await window.tesm.api(adresse);
|
const stand = await window.tesm.api(adresse);
|
||||||
|
if (stand.status === "unveraendert" || stand.status === "veraendert") {
|
||||||
|
// Ein unterschriebenes Dokument ist abgeschlossen. Wer daraus eine
|
||||||
|
// neue Fassung machen koennte, koennte auch eine Unterschrift unter
|
||||||
|
// einen veraenderten Text setzen -- also gibt es hier nichts mehr zu
|
||||||
|
// bearbeiten. Der Server weist es zusaetzlich ab.
|
||||||
|
if (unterschreibenKnopf) unterschreibenKnopf.hidden = true;
|
||||||
|
if (speichern) speichern.hidden = true;
|
||||||
|
if (zeile) zeile.hidden = true;
|
||||||
|
}
|
||||||
if (stand.status === "unveraendert") {
|
if (stand.status === "unveraendert") {
|
||||||
pdfBehaelter.classList.add("viewer__pdf--unveraendert");
|
pdfBehaelter.classList.add("viewer__pdf--unveraendert");
|
||||||
if (pruefzeile) {
|
if (pruefzeile) {
|
||||||
|
|||||||
@@ -44,8 +44,11 @@
|
|||||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
|
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{# "table--stapelbar": schmal wird aus jeder Zeile ein kleiner Block. Vorher
|
||||||
|
quetschte sich das Detail mit Pruefsumme in eine Spalte von zwei
|
||||||
|
Zentimetern und lief ueber zehn Zeilen -- lesbar war das nicht. #}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact" data-behavior="sortable">
|
<table class="table table--compact table--stapelbar" data-behavior="sortable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="sortable">Zeit</th>
|
<th class="sortable">Zeit</th>
|
||||||
@@ -60,12 +63,18 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
|
<td class="fs-sm nowrap" data-spalte="Zeit" data-sort-value="{{ row.ts }}">
|
||||||
<td class="fs-sm fw-600">{{ row.actor }}</td>
|
{{ row.ts | dt }}
|
||||||
<td class="mono fs-xs">{{ row.action }}</td>
|
</td>
|
||||||
<td class="fs-sm">{{ row.target }}</td>
|
<td class="fs-sm fw-600" data-spalte="Benutzer">{{ row.actor }}</td>
|
||||||
<td class="fs-sm muted break-all">{{ row.detail | audit_detail }}</td>
|
<td class="mono fs-xs" data-spalte="Aktion">{{ row.action }}</td>
|
||||||
<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">
|
||||||
|
{{- row.detail | audit_detail -}}
|
||||||
|
</td>
|
||||||
|
<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
|
||||||
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
|
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
|
||||||
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
|
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
|
||||||
@@ -76,7 +85,7 @@
|
|||||||
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }}
|
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }}
|
||||||
{% else %}{{ pill('Info', 'online') }}{% endif %}
|
{% else %}{{ pill('Info', 'online') }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="mono fs-xs faint">{{ row.ip or '–' | safe }}</td>
|
<td class="mono fs-xs faint" data-spalte="Herkunft">{{ row.ip or '–' | safe }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.7.2"
|
version = "2.7.7"
|
||||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["cryptography>=42"]
|
dependencies = ["cryptography>=42"]
|
||||||
|
|||||||
Reference in new issue
Block a user