Version 2.7.2: Umlaute in der Oberflaeche, Einbindungen an der Sitzung

This commit is contained in:
alientim committed 2026-09-11 23:24:29 +02:00
1 parent e5ae10814f
commit 35247c895b
72 files changed
+825 -470

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-license"
version = "2.7.1"
version = "2.7.2"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11"
dependencies = [
@@ -204,7 +204,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
def _startup_tasks(app: Flask) -> None:
extension = core(app)
if not extension.config.web_process:
logger.info("Startaufgaben uebersprungen -- kein Webprozess.")
logger.info("Startaufgaben übersprungen – kein Webprozess.")
return
from .services import activation
@@ -59,7 +59,7 @@ def _handle(action: str):
)
if request.content_length and request.content_length > MAX_BODY_BYTES:
return jsonify({"error": "too_large", "message": "Anfrage zu gross."}), 413
return jsonify({"error": "too_large", "message": "Anfrage zu groß."}), 413
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
@@ -63,12 +63,12 @@ def export():
allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
flash("Es wurde keine Kategorie ausgewählt.", "warning")
return redirect(url_for("backup.overview"))
passphrase = request.form.get("passphrase") or ""
if passphrase != (request.form.get("passphrase_repeat") or ""):
flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger")
flash("Die beiden Passphrasen stimmen nicht überein.", "danger")
return redirect(url_for("backup.overview"))
try:
@@ -101,11 +101,11 @@ def import_preview():
conn = db()
upload = request.files.get("backup_file")
if upload is None or not upload.filename:
flash("Keine Datei ausgewaehlt.", "danger")
flash("Keine Datei ausgewählt.", "danger")
return redirect(url_for("backup.overview"))
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
if len(raw) > MAX_IMPORT_BYTES:
flash("Die Datei ist zu gross.", "danger")
flash("Die Datei ist zu groß.", "danger")
return redirect(url_for("backup.overview"))
try:
@@ -124,8 +124,8 @@ def import_preview():
scope="sicherung",
)
flash(
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
"Uebernahme zu bestaetigen.",
f"Datei geöffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
"Übernahme zu bestätigen.",
"success",
)
return redirect(url_for("backup.overview"))
@@ -139,13 +139,13 @@ def import_apply():
conn = db()
token = session.get(PREVIEW_SESSION_KEY, "")
if not token:
flash("Es liegt keine geoeffnete Sicherung vor.", "danger")
flash("Es liegt keine geöffnete Sicherung vor.", "danger")
return redirect(url_for("backup.overview"))
allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
flash("Es wurde keine Kategorie ausgewählt.", "warning")
return redirect(url_for("backup.overview"))
try:
@@ -172,7 +172,7 @@ def import_apply():
action="backup.imported",
detail="; ".join(
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
f"{counts['skipped']} uebersprungen"
f"{counts['skipped']} übersprungen"
for key, counts in report.items()
),
ip=client_ip(),
@@ -182,11 +182,11 @@ def import_apply():
lines = [
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
f"{counts['updated']} aktualisiert"
+ (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "")
+ (f", {counts['skipped']} übersprungen" if counts["skipped"] else "")
for key, counts in report.items()
if key in transfer.CATEGORY_BY_KEY
]
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success")
flash("Übernahme abgeschlossen. " + " – ".join(lines), "success")
return redirect(url_for("backup.overview"))
@@ -96,7 +96,7 @@ def update(customer_id: int):
severity="notice",
scope="kunde",
)
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.",
flash("Änderungen gespeichert." if changes else "Keine Änderungen.",
"success" if changes else "info")
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
@@ -119,7 +119,7 @@ def delete(customer_id: int):
severity="warning",
scope="kunde",
)
flash("Kunde geloescht.", "success")
flash("Kunde gelöscht.", "success")
return redirect(url_for("customers.overview"))
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
@@ -124,7 +124,7 @@ def issue(ticket_id: str):
if result.already_existed:
flash(
"Zu diesem Ticket war bereits eine Lizenz offen -- sie wird angezeigt statt eine "
"Zu diesem Ticket war bereits eine Lizenz offen – sie wird angezeigt statt eine "
"zweite auszustellen.",
"info",
)
@@ -166,7 +166,7 @@ def download(license_id: str):
channel="download",
recipient=current_user.username,
actor=current_user.username,
detail="Vollstaendiges Bundle" if "client_key" in bundle else "Nur Lizenzdokument",
detail="Vollständiges Bundle" if "client_key" in bundle else "Nur Lizenzdokument",
)
log_event("license.downloaded", target=license_id[:12], severity="notice")
return Response(
@@ -202,7 +202,7 @@ def resign(license_id: str):
try:
days = int(request.form["extend_days"])
except ValueError:
flash("Die Verlaengerung muss eine Zahl sein.", "danger")
flash("Die Verlängerung muss eine Zahl sein.", "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
if days > 0:
document = json.loads(str(row["license_document"]))
@@ -212,7 +212,7 @@ def resign(license_id: str):
changes["expires_at"] = lic.iso(base + timedelta(days=days))
if not changes:
flash("Es wurde keine Aenderung angegeben.", "info")
flash("Es wurde keine Änderung angegeben.", "info")
return redirect(url_for("licenses.detail", license_id=license_id))
key = signing_key()
@@ -233,7 +233,7 @@ def resign(license_id: str):
)
reporting.report_soon(core())
flash(
f"Neue Revision {updated['seq']} signiert. Der Client erhaelt sie beim naechsten "
f"Neue Revision {updated['seq']} signiert. Der Client erhält sie beim nächsten "
"Heartbeat.",
"success",
)
@@ -258,7 +258,7 @@ def revoke(license_id: str):
)
reporting.report_soon(core())
flash(
"Lizenz widerrufen. Das Ticket bleibt offen -- eine neue Lizenz kann sofort "
"Lizenz widerrufen. Das Ticket bleibt offen – eine neue Lizenz kann sofort "
"ausgestellt werden.",
"success",
)
@@ -277,7 +277,7 @@ def delete(license_id: str):
return redirect(url_for("licenses.detail", license_id=license_id))
log_event("license.deleted", target=license_id[:12], severity="warning")
reporting.report_soon(core())
flash("Lizenz geloescht.", "success")
flash("Lizenz gelöscht.", "success")
return redirect(url_for("licenses.overview"))
@@ -295,7 +295,7 @@ def send_mail(license_id: str):
abort(404)
recipient = (request.form.get("recipient") or str(row["contact_email"] or "")).strip()
if not recipient:
flash("Es ist keine Empfaengeradresse hinterlegt.", "danger")
flash("Es ist keine Empfängeradresse hinterlegt.", "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
try:
@@ -315,7 +315,7 @@ def send_mail(license_id: str):
"",
f"Lizenz-ID: {license_id}",
f"Typ: {type_labels().get(str(row['type']), str(row['type']))}",
f"Gueltig bis: {row['expires_at']}"
f"Gültig bis: {row['expires_at']}"
+ (" (unbefristet)" if row["lifetime"] else ""),
]
if is_customer and row["ticket_id"]:
@@ -329,7 +329,7 @@ def send_mail(license_id: str):
"Ihre Stammdaten pflegen:",
customer_facing_url("portal.request_link"),
]
lines += ["", "Freundliche Gruesse", vendor.get("name") or "Ihr Lizenzgeber"]
lines += ["", "Freundliche Grüße", vendor.get("name") or "Ihr Lizenzgeber"]
if vendor.get("phone"):
lines.append(f"Telefon: {vendor['phone']}")
if vendor.get("email"):
@@ -403,7 +403,7 @@ def manual_code():
if outcome.ok:
response_code = lic.encode_code(outcome.response)
ok = True
message = "Antwortcode erzeugt. Bitte an den Kunden zuruecksenden."
message = "Antwortcode erzeugt. Bitte an den Kunden zurücksenden."
log_event(
f"license.offline_{payload.get('action')}",
target=str(payload.get("license_id") or "")[:12],
@@ -79,11 +79,11 @@ def upload():
upload_file = request.files.get("license_file")
if upload_file is None or not upload_file.filename:
flash("Keine Datei ausgewaehlt.", "danger")
flash("Keine Datei ausgewählt.", "danger")
return redirect(url_for("own_license.overview"))
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
if len(raw) > MAX_UPLOAD_BYTES:
flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger")
flash("Die Datei ist zu groß für eine Lizenzdatei.", "danger")
return redirect(url_for("own_license.overview"))
try:
@@ -45,7 +45,7 @@ RATE_WINDOW_MINUTES = 15
GENERIC_ANSWER = (
"Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit "
"einem {minutes} Minuten gueltigen Zugangslink verschickt."
"einem {minutes} Minuten gültigen Zugangslink verschickt."
)
EDITABLE_FIELDS = (
@@ -150,13 +150,13 @@ def request_link():
f"Guten Tag{(' ' + str(customer['contact_person'])) if customer['contact_person'] else ''},",
"",
"mit dem folgenden Link erreichen Sie Ihre Daten im Kundenportal.",
f"Der Link ist {minutes} Minuten gueltig und laesst sich nur einmal verwenden:",
f"Der Link ist {minutes} Minuten gültig und lässt sich nur einmal verwenden:",
"",
access_url,
"",
"Wenn Sie diesen Zugang nicht angefragt haben, ignorieren Sie die Nachricht.",
"",
"Freundliche Gruesse",
"Freundliche Grüße",
vendor.get("name") or "Ihr Lizenzgeber",
]
),
@@ -185,7 +185,7 @@ def access(token: str):
expires = _parse(row["expires_at"]) if row is not None else None
valid = row is not None and row["used_at"] is None and expires is not None and _now() < expires
if not valid:
flash("Dieser Link ist ungueltig oder abgelaufen.", "danger")
flash("Dieser Link ist ungültig oder abgelaufen.", "danger")
return redirect(url_for("portal.request_link"))
minutes = int(extension.settings.get(conn, "portal_session_minutes") or 60)
@@ -247,7 +247,7 @@ def me():
ip=client_ip(),
severity="notice",
)
flash("Ihre Angaben wurden gespeichert." if changes else "Keine Aenderungen.", "success")
flash("Ihre Angaben wurden gespeichert." if changes else "Keine Änderungen.", "success")
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("portal.me"))
@@ -62,7 +62,7 @@ def _rate_guard() -> None:
with transaction():
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
if verdict.blocked:
abort(429, description="Zu viele Anfragen. Bitte spaeter erneut versuchen.")
abort(429, description="Zu viele Anfragen. Bitte später erneut versuchen.")
def _needs_offline(conn, ticket_id: str): # type: ignore[no-untyped-def]
@@ -141,8 +141,8 @@ def issue(ticket_id: str):
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
if issuance.ticket_has_open_license(conn, ticket_id):
flash(
"Es existiert bereits eine gueltige Lizenz. Geben Sie diese zuerst frei, wenn Sie "
"auf ein anderes System wechseln moechten.",
"Es existiert bereits eine gültige Lizenz. Geben Sie diese zuerst frei, wenn Sie "
"auf ein anderes System wechseln möchten.",
"warning",
)
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
@@ -238,12 +238,12 @@ def offline(ticket_id: str):
_log(
"selfservice.offline_rejected",
ticket,
"Code gehoert nicht zu diesem Vorgang",
"Code gehört nicht zu diesem Vorgang",
severity="warning",
)
flash(
"Dieser Code gehoert nicht zu Ihrem Vorgang. Bitte pruefen Sie, ob Sie ihn "
"vollstaendig kopiert haben.",
"Dieser Code gehört nicht zu Ihrem Vorgang. Bitte prüfen Sie, ob Sie ihn "
"vollständig kopiert haben.",
"danger",
)
return redirect(url_for("selfservice.offline", ticket_id=ticket_id))
@@ -299,7 +299,7 @@ def revoke(ticket_id: str, license_id: str):
_log("license.revoked_self_service", ticket, license_id[:12], severity="warning")
reporting.report_soon(core())
flash(
"Die Lizenz wurde freigegeben. Sie koennen jetzt eine neue Lizenz fuer Ihr neues System "
"Die Lizenz wurde freigegeben. Sie können jetzt eine neue Lizenz für Ihr neues System "
"ausstellen.",
"success",
)
@@ -121,7 +121,7 @@ def system_save():
ip=client_ip(),
severity="notice",
)
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.",
flash("Einstellungen gespeichert." if changed else "Keine Änderungen.",
"success" if changed else "info")
return redirect(url_for("settings.system"))
@@ -133,10 +133,10 @@ def set_hostname():
sysops = extension.sysops
hostname = (request.form.get("hostname") or "").strip()
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
flash("Der Hostname ist ungueltig.", "danger")
flash("Der Hostname ist ungültig.", "danger")
return redirect(url_for("settings.system"))
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("settings.system"))
result = sysops.set_hostname(hostname)
if result.ok:
@@ -158,7 +158,7 @@ def set_timezone():
flash("Unbekannte Zeitzone.", "danger")
return redirect(url_for("settings.system"))
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("settings.system"))
result = sysops.set_timezone(timezone)
if result.ok:
@@ -227,7 +227,7 @@ def vendor_save():
if suffix not in LOGO_MIME:
logo_problem = "Erlaubt sind PNG, JPG und SVG."
elif len(data) > MAX_LOGO_BYTES:
logo_problem = f"Das Logo ist zu gross (maximal {MAX_LOGO_BYTES // 1024} KB)."
logo_problem = f"Das Logo ist zu groß (maximal {MAX_LOGO_BYTES // 1024} KB)."
elif not any(head.startswith(magic) for magic in LOGO_MAGIC):
logo_problem = "Die Datei ist kein Bild."
else:
@@ -245,7 +245,7 @@ def vendor_save():
log_event(
"settings.vendor_saved", detail=", ".join(changed), severity="notice", scope="anbieter"
)
flash("Anbieterdaten gespeichert." if changed else "Keine Aenderungen.",
flash("Anbieterdaten gespeichert." if changed else "Keine Änderungen.",
"success" if changed else "info")
return redirect(url_for("settings.vendor"))
@@ -297,7 +297,7 @@ def mail_save():
mail.store_secret(conn, request.form.get("client_secret"))
changed.append("client_secret")
log_event("settings.mail_saved", detail=", ".join(changed), severity="notice", scope="mail")
flash("E-Mail-Einstellungen gespeichert." if changed else "Keine Aenderungen.",
flash("E-Mail-Einstellungen gespeichert." if changed else "Keine Änderungen.",
"success" if changed else "info")
return redirect(url_for("settings.mail_settings"))
@@ -106,9 +106,9 @@ def _endpoint_missing() -> str:
if master_endpoint():
return ""
return (
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
"Solange er fehlt, koennen keine Lizenzen ausgestellt werden: der Endpunkt wird "
"in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
"Es ist kein öffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
"Solange er fehlt, können keine Lizenzen ausgestellt werden: der Endpunkt wird "
"in die Lizenz signiert und lässt sich später nicht nachtragen."
)
@@ -176,9 +176,9 @@ def update(ticket_id: str):
)
reporting.report_soon(core())
flash(
"Gespeichert. Die Aenderung wirkt auf die naechste Ausstellung."
"Gespeichert. Die Änderung wirkt auf die nächste Ausstellung."
if changes
else "Keine Aenderungen.",
else "Keine Änderungen.",
"success" if changes else "info",
)
except (issuance.IssuanceError, ValueError) as exc:
@@ -205,7 +205,7 @@ def toggle_block(ticket_id: str):
)
reporting.report_soon(core())
flash(
"Ticket gesperrt -- es lassen sich keine neuen Lizenzen mehr ausstellen."
"Ticket gesperrt – es lassen sich keine neuen Lizenzen mehr ausstellen."
if new_status == "blocked"
else "Ticket entsperrt.",
"success",
@@ -231,7 +231,7 @@ def delete(ticket_id: str):
scope="ticket",
)
reporting.report_soon(core())
flash("Ticket und seine Lizenzhistorie geloescht.", "success")
flash("Ticket und seine Lizenzhistorie gelöscht.", "success")
return redirect(url_for("tickets.overview"))
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
+36 -36
View File
@@ -42,22 +42,22 @@ def cmd_init_key(args: argparse.Namespace) -> int:
path = master_key_path(extension.config)
if path.exists() and not args.force:
key = master_key.load(path)
print(f"Signaturschluessel existiert bereits: {key.key_id} ({path})")
print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unpruefbar.")
print(f"Signaturschlüssel existiert bereits: {key.key_id} ({path})")
print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unprüfbar.")
return 0
if path.exists() and args.force:
print(
"ABBRUCH: --force auf einen bestehenden Signaturschluessel wird nicht unterstuetzt.\n"
"Ein neuer Schluessel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n"
"ABBRUCH: --force auf einen bestehenden Signaturschlüssel wird nicht unterstützt.\n"
"Ein neuer Schlüssel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n"
"von Hand beiseite legen, wenn das wirklich gewollt ist.",
file=sys.stderr,
)
return 2
key = master_key.ensure(path)
print(f"Signaturschluessel erzeugt: {key.key_id}")
print(f"Signaturschlüssel erzeugt: {key.key_id}")
print(f"Datei: {path}")
print("Bitte SOFORT sichern -- ohne sie ist keine ausgestellte Lizenz mehr pruefbar.")
print(f"Oeffentlicher Schluessel (zum Pinnen beim Kunden):\n{key.public_key}")
print("Bitte SOFORT sichern – ohne sie ist keine ausgestellte Lizenz mehr prüfbar.")
print(f"Öffentlicher Schlüssel (zum Pinnen beim Kunden):\n{key.public_key}")
return 0
@@ -65,11 +65,11 @@ def cmd_bootstrap(args: argparse.Namespace) -> int:
"""Stellt die eigene Lizenz dieser Wurzelinstanz aus -- nur in der Master-Ausgabe."""
if not is_master_edition():
for zeile in (
"Diese Ausgabe ist fuer Sub-Lizenzserver und kann keine Master-Lizenz erzeugen.",
"Diese Ausgabe ist für Sub-Lizenzserver und kann keine Master-Lizenz erzeugen.",
"",
"Ein Sub-Lizenzserver bekommt seine Lizenz vom Master-Lizenzserver und spielt",
"sie unter 'Eigene Lizenz' ein. Danach stellt er selbst Kundenlizenzen aus --",
"mit seinem eigenen Signaturschluessel, der beim Installieren entstanden ist.",
"mit seinem eigenen Signaturschlüssel, der beim Installieren entstanden ist.",
"",
"Die Master-Ausgabe liegt im Repository tesm-license-master.",
):
@@ -83,10 +83,10 @@ def cmd_bootstrap(args: argparse.Namespace) -> int:
app, extension, days=int(args.days), name=args.name
)
print(f"Master-Lizenz ausgestellt: {document['license_id']}")
print(f"Gueltig bis: {document['expires_at']}")
print(f"Gültig bis: {document['expires_at']}")
print(f"Datei: {extension.config.license_path}")
if state is not None:
print(f"Zustand: gueltig={state.valid}, aktiviert={state.activated}, Rolle={state.role}")
print(f"Zustand: gültig={state.valid}, aktiviert={state.activated}, Rolle={state.role}")
return 0
@@ -98,7 +98,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
return 2
password = args.password or getpass.getpass("Passwort: ")
if not args.password and password != getpass.getpass("Passwort wiederholen: "):
print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr)
print("Die Passwörter stimmen nicht überein.", file=sys.stderr)
return 2
problems = extension.password_policy.check(password, username=username)
if problems and not args.force:
@@ -180,7 +180,7 @@ def cmd_status(args: argparse.Namespace) -> int:
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei {chain['broken_at']}")
)
print(
"Signaturschluessel: "
"Signaturschlüssel: "
+ (str(key_health.get("key_id")) if key_health.get("present") else "FEHLT")
)
for problem in key_health.get("problems", []):
@@ -253,17 +253,17 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
except legacy_import.LegacyImportError as exc:
if args.adopt_key:
raise
print(f"Hinweis: alter Signaturschluessel nicht lesbar ({exc}).")
print(f"Hinweis: alter Signaturschlüssel nicht lesbar ({exc}).")
if key_path.is_file():
existing = master_key.load(key_path)
report.key_id = existing.key_id
if legacy_public and existing.public_key == legacy_public:
print(f"Signaturschluessel stimmt bereits ueberein ({existing.key_id}).")
print(f"Signaturschlüssel stimmt bereits überein ({existing.key_id}).")
elif not legacy_public:
issuer_changes = True
print(
f"Es wird mit dem vorhandenen Signaturschluessel {existing.key_id} "
f"Es wird mit dem vorhandenen Signaturschlüssel {existing.key_id} "
"gearbeitet."
)
else:
@@ -271,7 +271,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
# ausgestellte Lizenz waere danach unpruefbar.
issuer_changes = True
print(
f"Diese Installation hat einen eigenen Signaturschluessel "
f"Diese Installation hat einen eigenen Signaturschlüssel "
f"({existing.key_id}); der alte war {lic.key_id(legacy_public)}. "
"Es wird mit dem vorhandenen weitergearbeitet."
)
@@ -279,12 +279,12 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
legacy_private, legacy_public = legacy_import.read_legacy_key(root)
report.key_id = legacy_import.adopt_key(legacy_private, legacy_public, key_path)
report.key_adopted = True
print(f"Signaturschluessel uebernommen: {report.key_id}")
print(f"Signaturschlüssel übernommen: {report.key_id}")
else:
print(
"Diese Installation hat noch keinen Signaturschluessel. Mit --adopt-key "
"wird der alte uebernommen -- dann bleibt die Anbieteridentitaet erhalten "
"und Kunden muessen keinen Ausstellerwechsel bestaetigen.",
"Diese Installation hat noch keinen Signaturschlüssel. Mit --adopt-key "
"wird der alte übernommen – dann bleibt die Anbieteridentität erhalten "
"und Kunden müssen keinen Ausstellerwechsel bestätigen.",
file=sys.stderr,
)
return 2
@@ -312,7 +312,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
# Uebernahme.
for einstellung, wert in uebernommen.items():
gekuerzt = wert if len(wert) <= 60 else wert[:57] + "..."
print(f"Uebernommen: {einstellung} = {gekuerzt}")
print(f"Übernommen: {einstellung} = {gekuerzt}")
vendor = {
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
@@ -326,7 +326,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
print(
"Es ist kein Endpunkt hinterlegt, und die alte Installation nennt "
"auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne "
"ihn koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
"ihn können übernommene Lizenzen sich später nicht aktivieren.",
file=sys.stderr,
)
return 2
@@ -347,10 +347,10 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
result.key_id = report.key_id
if issuer_changes:
result.warnings.append(
"Die Lizenzen sind mit einem anderen Schluessel signiert als "
"frueher. Kunden, die den alten Aussteller gepinnt haben, muessen "
"beim Einspielen einmal 'Wechsel des Ausstellers ausdruecklich "
"zulassen' bestaetigen."
"Die Lizenzen sind mit einem anderen Schlüssel signiert als "
"früher. Kunden, die den alten Aussteller gepinnt haben, müssen "
"beim Einspielen einmal 'Wechsel des Ausstellers ausdrücklich "
"zulassen' bestätigen."
)
if args.dry_run:
# Nichts festschreiben -- der Lauf war nur zur Ansicht.
@@ -385,7 +385,7 @@ def build_parser() -> argparse.ArgumentParser:
)
sub = parser.add_subparsers(dest="command", required=True)
init_key = sub.add_parser("init-key", help="Signaturschluessel erzeugen (einmalig)")
init_key = sub.add_parser("init-key", help="Signaturschlüssel erzeugen (einmalig)")
init_key.add_argument("--force", action="store_true")
init_key.set_defaults(func=cmd_init_key)
@@ -401,7 +401,7 @@ def build_parser() -> argparse.ArgumentParser:
bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)")
bootstrap.set_defaults(func=cmd_bootstrap)
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen")
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zurücksetzen")
admin.add_argument("--username")
admin.add_argument("--password")
admin.add_argument("--force", action="store_true")
@@ -411,16 +411,16 @@ def build_parser() -> argparse.ArgumentParser:
status.add_argument("--json", action="store_true")
status.set_defaults(func=cmd_status)
house = sub.add_parser("housekeeping", help="Laufzeitdaten aufraeumen")
house = sub.add_parser("housekeeping", help="Laufzeitdaten aufräumen")
house.set_defaults(func=cmd_housekeeping)
migrate = sub.add_parser(
"migrate-legacy",
help="Lizenzserver der Vorgaengerfassung uebernehmen",
help="Lizenzserver der Vorgängerfassung übernehmen",
description=(
"Uebernimmt Signaturschluessel, Kunden, Tickets und Lizenzen aus einer alten "
"Übernimmt Signaturschlüssel, Kunden, Tickets und Lizenzen aus einer alten "
"Installation. Die alte Installation wird nur gelesen. Jede Lizenz wird im "
"neuen Format neu signiert -- die Kunden brauchen danach eine neue "
"neuen Format neu signiert – die Kunden brauchen danach eine neue "
"Lizenzdatei, weil die alte Signatur nicht mehr gilt."
),
)
@@ -430,13 +430,13 @@ def build_parser() -> argparse.ArgumentParser:
migrate.add_argument(
"--adopt-key",
action="store_true",
help="Den alten Signaturschluessel uebernehmen. Nur moeglich, solange diese "
"Installation noch keinen eigenen hat -- ein vorhandener wird nie ueberschrieben.",
help="Den alten Signaturschlüssel übernehmen. Nur möglich, solange diese "
"Installation noch keinen eigenen hat – ein vorhandener wird nie überschrieben.",
)
migrate.add_argument(
"--dry-run",
action="store_true",
help="Nur anzeigen, was passieren wuerde -- nichts schreiben",
help="Nur anzeigen, was passieren würde – nichts schreiben",
)
migrate.set_defaults(func=cmd_migrate_legacy)
@@ -30,18 +30,18 @@ CUSTOMERS = Resource(
label="Kunden",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="Stammdaten der Kundeninstanzen",
action_hints={A.DELETE: "Nur moeglich, solange kein Ticket am Kunden haengt."},
action_hints={A.DELETE: "Nur möglich, solange kein Ticket am Kunden hängt."},
)
TICKETS = Resource(
key="tickets",
label="Tickets",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="Dauerhafte Anspruechte auf eine Lizenz",
hint="Dauerhafte Ansprüche auf eine Lizenz",
action_hints={
A.EDIT: "Parameter aendern und Ticket sperren/entsperren. Wirkt erst auf die "
"naechste Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.",
A.DELETE: "Nur moeglich, wenn keine offene Lizenz mehr existiert.",
A.EDIT: "Parameter ändern und Ticket sperren/entsperren. Wirkt erst auf die "
"nächste Ausstellung – bereits ausgestellte Lizenzen bleiben unverändert.",
A.DELETE: "Nur möglich, wenn keine offene Lizenz mehr existiert.",
},
)
@@ -62,7 +62,7 @@ PORTAL = Resource(
key="portal",
label="Kundenportal",
actions=(A.VIEW, A.EXECUTE),
hint="Zugangslinks fuer Kunden",
hint="Zugangslinks für Kunden",
action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."},
)
@@ -79,7 +79,7 @@ MAIL = Resource(
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
hint="Microsoft Graph (App-only)",
action_hints={
A.EXECUTE: "Verbindungstest ausfuehren.",
A.EXECUTE: "Verbindungstest ausführen.",
A.SECRETS: "Client-Secret setzen.",
},
)
@@ -88,7 +88,7 @@ VENDOR = Resource(
key="settings_vendor",
label="Anbieterdaten",
actions=(A.VIEW, A.EDIT),
hint="Name, Kontakt, Logo und oeffentlicher Endpunkt -- reisen in jeder Lizenz mit",
hint="Name, Kontakt, Logo und öffentlicher Endpunkt – reisen in jeder Lizenz mit",
)
LICENSING_AREA = Area(
@@ -144,7 +144,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset(
key="auditor",
name="Revision",
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.",
description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.",
grants=(
("customers", (A.VIEW,)),
("tickets", (A.VIEW,)),
@@ -161,7 +161,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset(
key="administration",
name="Administration",
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.",
description="Benutzer, Gruppen und Einstellungen – ohne Admin-Kennzeichen.",
grants=(
("users", "*"),
("groups", "*"),
@@ -132,7 +132,7 @@ def _verify(
seen_nonce=seen,
)
except lic.SignatureInvalid:
return None, _error(400, "invalid_signature", "Signatur der Anfrage ist ungueltig.")
return None, _error(400, "invalid_signature", "Signatur der Anfrage ist ungültig.")
except lic.ReplayDetected as exc:
return None, _error(409, "replay", str(exc))
except lic.ProtocolViolation as exc:
@@ -150,7 +150,7 @@ def process_activate(
) -> Outcome:
row, error = _verify(conn, request, expected_action="activate")
if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
return error or _error(400, "bad_request", "Ungültige Anfrage.")
fingerprint = str(request["fingerprint"])
hostname = str(request.get("hostname") or "")
@@ -202,7 +202,7 @@ def process_activate(
target=str(row["license_id"]),
detail=(
f"Fingerprint {fingerprint[:16]}..., Host {hostname or 'unbekannt'}"
+ (f", verdraengt {len(superseded)} Lizenz(en)" if superseded else "")
+ (f", verdrängt {len(superseded)} Lizenz(en)" if superseded else "")
),
severity="notice",
)
@@ -211,7 +211,7 @@ def process_activate(
request,
master_private_key=master_private_key,
status="ok",
detail="Aktivierung bestaetigt.",
detail="Aktivierung bestätigt.",
)
@@ -220,7 +220,7 @@ def process_deactivate(
) -> Outcome:
row, error = _verify(conn, request, expected_action="deactivate")
if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
return error or _error(400, "bad_request", "Ungültige Anfrage.")
fingerprint = str(request["fingerprint"])
if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint:
@@ -249,7 +249,7 @@ def process_deactivate(
request,
master_private_key=master_private_key,
status="ok",
detail="Deaktivierung bestaetigt. Die Lizenz kann auf einer anderen Maschine aktiviert werden.",
detail="Deaktivierung bestätigt. Die Lizenz kann auf einer anderen Maschine aktiviert werden.",
)
@@ -258,7 +258,7 @@ def process_heartbeat(
) -> Outcome:
row, error = _verify(conn, request, expected_action="heartbeat")
if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
return error or _error(400, "bad_request", "Ungültige Anfrage.")
fingerprint = str(request["fingerprint"])
hostname = str(request.get("hostname") or "")
@@ -306,7 +306,7 @@ def process_heartbeat(
)
else:
logger.warning(
"Bericht von Lizenz %s verworfen -- Rolle ist %s, nicht license_server",
"Bericht von Lizenz %s verworfen | Rolle ist %s, nicht license_server",
row["license_id"],
row["role"],
)
@@ -322,7 +322,7 @@ def process_heartbeat(
request,
master_private_key=master_private_key,
status="ok",
detail="Verbindung bestaetigt.",
detail="Verbindung bestätigt.",
license_update=update,
)
@@ -48,7 +48,7 @@ def validate_email(value: str, *, required: bool) -> str:
# sollen weiter bearbeitbar bleiben, statt ausgesperrt zu werden.
return ""
if not EMAIL_RE.match(text):
raise IssuanceError(f"{text!r} ist keine gueltige E-Mail-Adresse.")
raise IssuanceError(f"{text!r} ist keine gültige E-Mail-Adresse.")
return text
@@ -137,7 +137,7 @@ def delete_customer(conn: sqlite3.Connection, customer_id: int) -> None:
)
if int(tickets or 0):
raise IssuanceError(
f"An diesem Kunden haengen noch {tickets} Ticket(s). Bitte zuerst entfernen."
f"An diesem Kunden hängen noch {tickets} Ticket(s). Bitte zuerst entfernen."
)
conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,))
@@ -224,7 +224,7 @@ def create_ticket(
raise IssuanceError("Unbekannter Lizenztyp.")
if role not in lic.LICENSE_ROLES or role == "master":
# "master" entsteht ausschliesslich lokal per Bootstrap-Skript.
raise IssuanceError("Unzulaessige Rolle.")
raise IssuanceError("Unzulässige Rolle.")
# Trial + unbefristet ist ein sinnloser Zustand: eine permanente Lizenz ohne
# Module. Diese Regel wird an drei Stellen geprueft (hier, beim Bearbeiten
@@ -290,7 +290,7 @@ def update_ticket(
if license_type != str(ticket["type"]):
changes.append(f"Typ: {ticket['type']} -> {license_type}")
if new_modules != str(ticket["modules"]):
changes.append("Module geaendert")
changes.append("Module geändert")
if int(valid_days) != int(ticket["valid_days"]):
changes.append(f"Laufzeit: {ticket['valid_days']} -> {valid_days} Tage")
if bool(lifetime) != bool(ticket["lifetime"]):
@@ -428,7 +428,7 @@ def issue_from_ticket(
role = str(ticket["role"])
if as_trial and role != "customer":
raise IssuanceError(
"Eine Testlizenz gibt es nur fuer Kundeninstanzen, nicht fuer Lizenzserver."
"Eine Testlizenz gibt es nur für Kundeninstanzen, nicht für Lizenzserver."
)
if as_trial:
@@ -452,9 +452,9 @@ def issue_from_ticket(
# hier, an der einzigen Stelle, durch die jede Ausstellung laeuft.
if not str(master_endpoint or "").strip():
raise IssuanceError(
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
"Ohne ihn koennte sich diese Lizenz nie online aktivieren -- der Endpunkt "
"wird in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
"Es ist kein öffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
"Ohne ihn könnte sich diese Lizenz nie online aktivieren – der Endpunkt "
"wird in die Lizenz signiert und lässt sich später nicht nachtragen."
)
customer = get_customer(conn, int(ticket["customer_id"]))
@@ -550,7 +550,7 @@ def delivery_bundle(conn: sqlite3.Connection, license_id: str, *, keystore: Any)
bundle["client_key"] = keystore.decrypt(encrypted, aad=client_key_aad(license_id))
except Exception as exc: # noqa: BLE001
raise IssuanceError(
"Der Client-Schluessel dieser Lizenz konnte nicht entschluesselt werden. "
"Der Client-Schlüssel dieser Lizenz konnte nicht entschlüsselt werden. "
"Bitte eine neue Lizenz ausstellen."
) from exc
return bundle
@@ -580,7 +580,7 @@ def delete_license(conn: sqlite3.Connection, license_id: str) -> None:
raise IssuanceError("Diese Lizenz existiert nicht.")
if str(row["status"]) not in {"revoked", "issued"}:
raise IssuanceError(
"Eine aktive Lizenz kann nicht geloescht werden. Bitte zuerst widerrufen."
"Eine aktive Lizenz kann nicht gelöscht werden. Bitte zuerst widerrufen."
)
conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,))
@@ -61,7 +61,7 @@ class Report:
def as_text(self) -> str:
lines = [
f"Signaturschluessel uebernommen: {'ja' if self.key_adopted else 'nein'}"
f"Signaturschlüssel übernommen: {'ja' if self.key_adopted else 'nein'}"
+ (f" ({self.key_id})" if self.key_id else ""),
f"Kunden : {self.customers}",
f"Tickets : {self.tickets}",
@@ -69,7 +69,7 @@ class Report:
]
if self.reissued:
lines.append("")
lines.append("Neu signiert -- diese Kunden brauchen eine neue Lizenzdatei:")
lines.append("Neu signiert – diese Kunden brauchen eine neue Lizenzdatei:")
lines.extend(f" * {entry}" for entry in self.reissued)
if self.skipped:
lines.append("")
@@ -95,7 +95,7 @@ def open_legacy(root: Path) -> sqlite3.Connection:
"""Oeffnet die alte Datenbank **nur lesend**."""
path = Path(root) / "sqlite.db"
if not path.is_file():
raise LegacyImportError(f"Unter {path} liegt keine Datenbank der Vorgaengerfassung.")
raise LegacyImportError(f"Unter {path} liegt keine Datenbank der Vorgängerfassung.")
try:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
except sqlite3.Error as exc:
@@ -107,7 +107,7 @@ def open_legacy(root: Path) -> sqlite3.Connection:
if missing:
conn.close()
raise LegacyImportError(
f"Das sieht nicht nach einem Lizenzserver aus -- es fehlen: {', '.join(sorted(missing))}"
f"Das sieht nicht nach einem Lizenzserver aus – es fehlen: {', '.join(sorted(missing))}"
)
return conn
@@ -123,7 +123,7 @@ def _standard_b64(wert: str, feld: str) -> str:
"""
roh = wert.strip()
if not roh:
raise LegacyImportError(f"Im alten Signaturschluessel fehlt {feld!r}.")
raise LegacyImportError(f"Im alten Signaturschlüssel fehlt {feld!r}.")
# Fehlende Polsterung ergaenzen -- der Vorgaenger schrieb sie mit, andere
# Werkzeuge lassen sie weg.
gepolstert = roh + "=" * (-len(roh) % 4)
@@ -138,29 +138,29 @@ def _standard_b64(wert: str, feld: str) -> str:
continue
if len(rohbytes) != 32:
raise LegacyImportError(
f"{feld} im alten Signaturschluessel ist {len(rohbytes)} Byte lang, "
f"{feld} im alten Signaturschlüssel ist {len(rohbytes)} Byte lang, "
"erwartet werden 32 (Ed25519)."
)
return base64.b64encode(rohbytes).decode("ascii")
raise LegacyImportError(f"{feld} im alten Signaturschluessel ist kein Base64.")
raise LegacyImportError(f"{feld} im alten Signaturschlüssel ist kein Base64.")
def read_legacy_key(root: Path) -> tuple[str, str]:
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
path = Path(root) / "master_signing_key.json"
if not path.is_file():
raise LegacyImportError(f"Der alte Signaturschluessel {path} fehlt.")
raise LegacyImportError(f"Der alte Signaturschlüssel {path} fehlt.")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc
raise LegacyImportError(f"Der alte Signaturschlüssel ist unlesbar: {exc}") from exc
# Die Vorgaengerfassung nannte die Felder "private"/"public" und kodierte
# sie URL-sicher.
private = str(data.get("private") or data.get("private_key") or "")
public = str(data.get("public") or data.get("public_key") or "")
if not private or not public:
raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.")
raise LegacyImportError("Der alte Signaturschlüssel ist unvollständig.")
private = _standard_b64(private, "private")
public = _standard_b64(public, "public")
# Alles, was aus der Protokollschicht kommt, wird hier zu einer Meldung im
@@ -170,11 +170,11 @@ def read_legacy_key(root: Path) -> tuple[str, str]:
try:
passt = licsrv.public_from_private(private) == public
except (ValueError, TypeError) as exc:
raise LegacyImportError(f"Der alte Signaturschluessel ist unbrauchbar: {exc}") from exc
raise LegacyImportError(f"Der alte Signaturschlüssel ist unbrauchbar: {exc}") from exc
if not passt:
raise LegacyImportError(
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
"zueinander. Die Datei ist beschaedigt."
"Im alten Signaturschlüssel passen privater und öffentlicher Teil nicht "
"zueinander. Die Datei ist beschädigt."
)
return private, public
@@ -190,16 +190,16 @@ def adopt_key(private: str, public: str, target: Path) -> str:
if Path(target).is_file():
raise LegacyImportError(
f"Unter {target} liegt bereits ein Signaturschluessel. Er wird nicht "
"ueberschrieben -- das wuerde jede damit ausgestellte Lizenz unbrauchbar "
"machen. Fuer eine Uebernahme bitte eine frische Installation verwenden."
f"Unter {target} liegt bereits ein Signaturschlüssel. Er wird nicht "
"überschrieben – das würde jede damit ausgestellte Lizenz unbrauchbar "
"machen. Für eine Übernahme bitte eine frische Installation verwenden."
)
payload = {
"private_key": private,
"public_key": public,
"key_id": lic.key_id(public),
"created_at": _now(),
"origin": "Uebernommen aus der Vorgaengerfassung.",
"origin": "Übernommen aus der Vorgängerfassung.",
}
write_private_file(Path(target), json.dumps(payload, indent=2, sort_keys=True) + "\n")
return lic.key_id(public)
@@ -309,7 +309,7 @@ def import_all(
)
if existing is not None:
customer_map[int(row["id"])] = int(existing["id"])
report.warnings.append(f"Kunde {name!r} war schon vorhanden -- nicht doppelt angelegt.")
report.warnings.append(f"Kunde {name!r} war schon vorhanden – nicht doppelt angelegt.")
continue
new_id = issuance.create_customer(
conn,
@@ -331,7 +331,7 @@ def import_all(
for row in legacy.execute("SELECT * FROM license_tickets ORDER BY created_at"):
customer_id = customer_map.get(int(row["customer_id"] or 0))
if customer_id is None:
report.skipped.append(f"Ticket {str(row['id'])[:12]} -- Kunde nicht uebernommen")
report.skipped.append(f"Ticket {str(row['id'])[:12]} -- Kunde nicht übernommen")
continue
if Database.one(conn, "SELECT id FROM license_tickets WHERE id=?", (row["id"],)):
continue
@@ -348,7 +348,7 @@ def import_all(
int(row["lifetime"] or 0),
str(row["status"] or "open"),
str(row["role"] or "customer"),
"Aus der Vorgaengerfassung uebernommen.",
"Aus der Vorgängerfassung übernommen.",
str(row["created_by"] or actor),
str(row["created_at"] or _now()),
_now(),
@@ -361,7 +361,7 @@ def import_all(
license_id = str(row["license_id"] or "")
customer_id = customer_map.get(int(row["customer_id"] or 0))
if not license_id or customer_id is None:
report.skipped.append(f"Lizenz {license_id[:12] or '?'} -- Kunde nicht uebernommen")
report.skipped.append(f"Lizenz {license_id[:12] or '?'} -- Kunde nicht übernommen")
continue
if Database.one(conn, "SELECT id FROM licenses WHERE license_id=?", (license_id,)):
continue
@@ -371,7 +371,7 @@ def import_all(
modules = _modules(row["modules"])
if not reissue:
report.skipped.append(f"{customer['name']} / {license_id[:12]} -- nur gezaehlt")
report.skipped.append(f"{customer['name']} / {license_id[:12]} -- nur gezählt")
report.licenses += 1
continue
@@ -383,7 +383,7 @@ def import_all(
if not lifetime and valid_days <= 0:
report.skipped.append(
f"{customer['name']} / {license_id[:12]} -- abgelaufen am "
f"{str(row['expires_at'])[:10]}, nicht uebernommen"
f"{str(row['expires_at'])[:10]}, nicht übernommen"
)
continue
@@ -444,7 +444,7 @@ def import_all(
if report.reissued:
report.warnings.append(
"Die neu signierten Lizenzen sind noch nicht aktiviert: die Bindung an eine "
"Maschine gilt nur fuer die alte Datei. Nach dem Einspielen aktiviert der "
"Maschine gilt nur für die alte Datei. Nach dem Einspielen aktiviert der "
"Kunde erneut."
)
return report
@@ -92,7 +92,7 @@ def _secret(conn: sqlite3.Connection) -> str:
return core().keystore.decrypt(str(value), aad=AAD_GRAPH_SECRET)
except Exception as exc: # noqa: BLE001
raise MailError(
"Das Client-Secret konnte nicht entschluesselt werden -- bitte neu eintragen."
"Das Client-Secret konnte nicht entschlüsselt werden – bitte neu eintragen."
) from exc
@@ -162,7 +162,7 @@ def fetch_token(conn: sqlite3.Connection) -> str:
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
"""Holt nur ein Token -- verschickt bewusst keine echte Nachricht."""
if not is_enabled(conn):
return {"ok": False, "message": "Der E-Mail-Versand ist nicht vollstaendig konfiguriert."}
return {"ok": False, "message": "Der E-Mail-Versand ist nicht vollständig konfiguriert."}
try:
fetch_token(conn)
except MailError as exc:
@@ -48,22 +48,22 @@ class MasterKey:
def load(path: Path) -> MasterKey:
if not path.is_file():
raise MasterKeyError(
f"Der Signaturschluessel {path} fehlt. Ohne ihn kann keine Lizenz ausgestellt "
f"Der Signaturschlüssel {path} fehlt. Ohne ihn kann keine Lizenz ausgestellt "
"werden. Erzeugen Sie ihn mit 'tesm-license-admin init-key'."
)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise MasterKeyError(f"Der Signaturschluessel ist unlesbar: {exc}") from exc
raise MasterKeyError(f"Der Signaturschlüssel ist unlesbar: {exc}") from exc
private = str(data.get("private_key") or "")
public = str(data.get("public_key") or "")
if not private or not public:
raise MasterKeyError("Der Signaturschluessel ist unvollstaendig.")
raise MasterKeyError("Der Signaturschlüssel ist unvollständig.")
if licsrv.public_from_private(private) != public:
raise MasterKeyError(
"Der oeffentliche Teil des Signaturschluessels passt nicht zum privaten. "
"Die Datei ist beschaedigt oder wurde manipuliert."
"Der öffentliche Teil des Signaturschlüssels passt nicht zum privaten. "
"Die Datei ist beschädigt oder wurde manipuliert."
)
return MasterKey(
private_key=private, public_key=public, created_at=str(data.get("created_at") or "")
@@ -87,7 +87,7 @@ def ensure(path: Path) -> MasterKey:
}
write_private_file(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
logger.warning(
"Neuer Signaturschluessel erzeugt (%s). Diese Datei ist unwiederbringlich -- "
"Neuer Signaturschlüssel erzeugt (%s). Diese Datei ist unwiederbringlich – "
"bitte sofort gesichert ablegen: %s",
lic.key_id(public),
path,
@@ -98,7 +98,7 @@ def ensure(path: Path) -> MasterKey:
def health(path: Path) -> dict[str, object]:
"""Zustand fuer die Diagnoseseite."""
if not path.is_file():
return {"present": False, "problems": ["Signaturschluessel fehlt"]}
return {"present": False, "problems": ["Signaturschlüssel fehlt"]}
problems = check_private_file(path)
try:
key = load(path)
@@ -41,11 +41,11 @@ APP_TAG = "tesm-license"
CATEGORIES: tuple[Category, ...] = (
Category("customers", "Kunden", "Stammdaten der Kundeninstanzen"),
Category("tickets", "Tickets", "Anspruechte inklusive Self-Service-Schluessel", sensitive=True),
Category("tickets", "Tickets", "Ansprüche inklusive Self-Service-Schlüssel", sensitive=True),
Category(
"licenses",
"Lizenzen",
"Ausgestellte Lizenzen mit signiertem Dokument und verschluesseltem Client-Schluessel",
"Ausgestellte Lizenzen mit signiertem Dokument und verschlüsseltem Client-Schlüssel",
sensitive=True,
),
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
@@ -25,8 +25,8 @@ VENDOR_SETTINGS: tuple[SettingSpec, ...] = (
"master_endpoint",
"",
"str",
"Oeffentlicher Endpunkt dieses Lizenzservers",
hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen -- z. B. "
"Öffentlicher Endpunkt dieses Lizenzservers",
hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen – z. B. "
"https://lizenz.firma.de. Wird in jede Lizenz eingebettet und fuer alle "
"kundengerichteten Links verwendet.",
section="vendor",
@@ -38,8 +38,8 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
"portal_link_minutes",
30,
"int",
"Gueltigkeit des Zugangslinks (Minuten)",
hint="Der Link ist zusaetzlich nur einmal verwendbar.",
"Gültigkeit des Zugangslinks (Minuten)",
hint="Der Link ist zusätzlich nur einmal verwendbar.",
section="portal",
minimum=5,
maximum=1440,
@@ -48,9 +48,9 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
"portal_session_minutes",
60,
"int",
"Gueltigkeit der Portalsitzung (Minuten)",
hint="Zweites, unabhaengiges Zeitfenster: eines fuer das Anklicken des Links, "
"eines fuer die daraus entstehende Sitzung.",
"Gültigkeit der Portalsitzung (Minuten)",
hint="Zweites, unabhängiges Zeitfenster: eines für das Anklicken des Links, "
"eines für die daraus entstehende Sitzung.",
section="portal",
minimum=5,
maximum=1440,
@@ -64,7 +64,7 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
"bool",
"Self-Service-Link je Ticket aktiv",
hint="Erlaubt dem Kunden, seine Lizenz selbst herunterzuladen, zu widerrufen und "
"neu auszustellen -- ohne Anmeldung, allein ueber den unratbaren Ticket-Link.",
"neu auszustellen – ohne Anmeldung, allein über den unratbaren Ticket-Link.",
section="portal",
),
)
@@ -83,8 +83,8 @@ DEFAULT_TICKET_SETTINGS: tuple[SettingSpec, ...] = (
"allow_customer_revoke",
True,
"bool",
"Kunden duerfen ihre Lizenz selbst widerrufen",
hint="Notwendig fuer einen Systemwechsel ohne Support-Anruf.",
"Kunden dürfen ihre Lizenz selbst widerrufen",
hint="Notwendig für einen Systemwechsel ohne Support-Anruf.",
section="licensing",
),
)
@@ -170,7 +170,7 @@
<summary class="card__header" style="cursor:pointer">
<div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Signiertes Dokument</div>
<div class="card__sub">Genau so, wie es signiert wurde -- jede Änderung bricht die Signatur</div>
<div class="card__sub">Genau so, wie es signiert wurde – jede Änderung bricht die Signatur</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
@@ -214,7 +214,7 @@
<button class="btn btn--sm btn--danger btn--block" type="button"
data-dialog-open="revoke-dialog">Widerrufen</button>
<p class="fs-xs faint">
Das Ticket bleibt offen -- eine neue Lizenz kann sofort ausgestellt werden. Genau so
Das Ticket bleibt offen – eine neue Lizenz kann sofort ausgestellt werden. Genau so
läuft ein Systemwechsel.
</p>
{% endif %}
@@ -22,7 +22,7 @@
</form>
<p class="auth-footnote">
Aus Datenschutzgründen bekommen Sie immer dieselbe Rückmeldung -- unabhängig davon, ob die
Aus Datenschutzgründen bekommen Sie immer dieselbe Rückmeldung – unabhängig davon, ob die
Adresse bei uns bekannt ist.
</p>
{% endblock %}
@@ -15,7 +15,7 @@
{% if response_code %}
<div class="field">
<span class="field__label">Antwortcode -- in TESM eintragen</span>
<span class="field__label">Antwortcode – in TESM eintragen</span>
<pre class="log-view" id="offline-answer" style="max-height:16dvh">{{ response_code }}</pre>
<div class="row row--end mt-2">
<button class="btn btn--sm" type="button" data-copy="#offline-answer">
@@ -23,7 +23,7 @@
</button>
</div>
<p class="fs-sm muted mt-2">
Tragen Sie ihn in TESM unter Verwaltung -- Lizenz bei
Tragen Sie ihn in TESM unter Verwaltung → Lizenz bei
&bdquo;Antwortcode des Lizenzservers&ldquo; ein.
{% if summary.hostname %}
Die Anfrage kam von <span class="mono">{{ summary.hostname }}</span>.
@@ -45,7 +45,7 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<input class="input" type="file" name="vendor_logo" accept="image/png,image/jpeg,image/svg+xml"
{% if not current_user.has('settings_vendor.edit') %}disabled{% endif %}>
<span class="field__hint">
Ein zu grosses Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
Ein zu großes Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
</span>
</div>
</fieldset>
@@ -75,7 +75,7 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<div class="divider"></div>
<p class="fs-sm muted">
Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine
Änderung hier wirkt auf neue Lizenzen -- bestehende behalten ihren Stand, bis eine neue
Änderung hier wirkt auf neue Lizenzen – bestehende behalten ihren Stand, bis eine neue
Revision signiert wird.
</p>
</div>
@@ -33,7 +33,7 @@
<div class="card__title">{{ icon('shield', size=17) }} Lizenz ausstellen</div>
<div class="card__sub">
{% if has_open_license %}
Es existiert bereits eine offene Lizenz -- widerrufen Sie sie zuerst.
Es existiert bereits eine offene Lizenz – widerrufen Sie sie zuerst.
{% else %}
Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets.
{% endif %}
@@ -128,7 +128,7 @@
</div>
<p class="fs-xs faint">
Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und
neu ausstellen. Der Link enthält 192 Bit Zufall und ist nicht ratbar -- behandeln Sie
neu ausstellen. Der Link enthält 192 Bit Zufall und ist nicht ratbar – behandeln Sie
ihn wie ein Passwort.
</p>
<div class="divider"></div>
@@ -32,7 +32,7 @@
{% endfor %}
</select>
<span class="field__hint">
Der Kunde ist nach dem Anlegen unveränderlich -- der Self-Service-Link ist an ihn
Der Kunde ist nach dem Anlegen unveränderlich – der Self-Service-Link ist an ihn
gebunden.
</span>
</label>
@@ -77,7 +77,7 @@
<div class="flex-1">
<div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div>
<div class="card__sub">
Erzeugt eine neue Vertrauensgrenze -- der Empfänger verwaltet danach eigene Kunden
Erzeugt eine neue Vertrauensgrenze – der Empfänger verwaltet danach eigene Kunden
</div>
</div>
</div>
@@ -103,7 +103,7 @@
<div class="card__body">
<ol class="stack stack--tight fs-sm muted" style="list-style:decimal;padding-left:1.2em">
<li>Ticket anlegen (dieser Schritt).</li>
<li>Auf der Ticketseite eine Lizenz ausstellen -- optional als 30-Tage-Testlizenz mit
<li>Auf der Ticketseite eine Lizenz ausstellen – optional als 30-Tage-Testlizenz mit
allen Modulen.</li>
<li>Lizenzdatei herunterladen oder per E-Mail versenden.</li>
<li>Der Kunde spielt sie ein und aktiviert sie; die Aktivierung bindet sie an seine
@@ -112,7 +112,7 @@
<div class="divider"></div>
<p class="fs-sm muted">
Der Self-Service-Link des Tickets erlaubt dem Kunden, seine Lizenz selbst
herunterzuladen und bei einem Systemwechsel freizugeben -- ohne Support-Anruf.
herunterzuladen und bei einem Systemwechsel freizugeben – ohne Support-Anruf.
</p>
</div>
<div class="card__footer">