Compare commits

..
5 Commits
17 changed files with 382 additions and 44 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.7.4" 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 = [
+36 -2
View File
@@ -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()
+2 -2
View File
@@ -359,9 +359,9 @@ def save_form(token: str):
# Zwei Zeilen: oben, was ein Mensch liest; unten die Pruefsumme, die # Zwei Zeilen: oben, was ein Mensch liest; unten die Pruefsumme, die
# niemand liest, aber jeder vergleichen koennen muss. In einer einzigen # niemand liest, aber jeder vergleichen koennen muss. In einer einzigen
# Zeile drueckte der Hash den Rest aus der Spalte. # Zeile drueckte der Hash den Rest aus der Spalte.
kopf = f"{'Signiert' if unterschriften else 'Gespeichert'}: {wohin} ({ziel_mount.label})" kopf = f"{'Signiert' if unterschriften else 'Gespeichert'}: {wohin}"
if unterschriften: if unterschriften:
kopf += f" – angegeben von: {', '.join(unterschriften)}" kopf += f" von: {', '.join(unterschriften)}"
beschreibung = f"{kopf}{chr(10)}sha256={pruefsumme}" 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",
+26
View File
@@ -895,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():
@@ -916,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
+6
View File
@@ -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 '-'}")
@@ -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">&ndash;</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 -%}
&ndash;
{%- 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>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.7.4" 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.4", #: 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,
+44 -1
View File
@@ -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.85); 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;
@@ -1640,3 +1657,17 @@
display: none; 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;
}
@@ -297,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,
@@ -312,9 +312,9 @@ async function nachbearbeiten(
// erkennbar zu bleiben: ein halber Punkt in hellem Grau ging zwischen // erkennbar zu bleiben: ein halber Punkt in hellem Grau ging zwischen
// den Tabellenlinien schlicht unter. // den Tabellenlinien schlicht unter.
seite.drawLine({ seite.drawLine({
start: { x: eintrag.x, y: eintrag.y - 1.5 }, start: { x: eintrag.x, y: eintrag.y - UNTERSCHRIFT.strichY },
end: { x: eintrag.x + textbreite, y: eintrag.y - 1.5 }, end: { x: eintrag.x + textbreite, y: eintrag.y - UNTERSCHRIFT.strichY },
thickness: 0.9, thickness: UNTERSCHRIFT.strich,
color: PDFLib.rgb(0.15, 0.15, 0.15), color: PDFLib.rgb(0.15, 0.15, 0.15),
}); });
} }
@@ -342,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));
@@ -637,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 = () => "";
@@ -684,11 +751,20 @@ 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");
namenszeile.textContent = `${eintrag.name} | ${eintrag.zeit}`;
text.append(namenszeile);
if (zeuge && eintrag === unterschriften[0]) { if (zeuge && eintrag === unterschriften[0]) {
text.textContent += ` / In TESM abgelegt durch ${zeuge}`; 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";
@@ -705,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();
}); });
@@ -742,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
); );
} }
@@ -766,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);
@@ -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"))
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing" name = "tesm-licensing"
version = "2.7.4" 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"]