From a6d9a46d8fa26379e3df930230b386fa13fc5c42 Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Thu, 17 Sep 2026 17:17:37 +0200 Subject: [PATCH] Version 2.8.48: belegter Code sichtbar, Geraetewerte zaehlen mit --- apps/tesm/pyproject.toml | 2 +- apps/tesm/src/tesm/blueprints/dhcp.py | 69 +++++++++++++++++++-- apps/tesm/src/tesm/services/kea.py | 29 +++++---- apps/tesm/src/tesm/templates/tesm/dhcp.html | 41 +++++++++--- 4 files changed, 113 insertions(+), 28 deletions(-) diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index 675ef07..1b00276 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.8.47" +version = "2.8.48" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/dhcp.py b/apps/tesm/src/tesm/blueprints/dhcp.py index 6ce743d..077ff38 100644 --- a/apps/tesm/src/tesm/blueprints/dhcp.py +++ b/apps/tesm/src/tesm/blueprints/dhcp.py @@ -81,15 +81,61 @@ def overview(): for zeile in Database.all(conn, "SELECT mac FROM devices WHERE deleted_at IS NULL") } + options = Database.all( + conn, + "SELECT o.*, v.value, v.id AS value_id FROM dhcp_options o " + "LEFT JOIN dhcp_option_values v ON v.option_id = o.id AND v.device_id IS NULL " + "ORDER BY o.code", + ) + # Ein Wert an einem einzelnen Geraet ist ein gesendeter Wert. Die Uebersicht + # zaehlte nur die globalen -- und meldete "Nichts gesetzt", waehrend die + # Vorschau daneben die Option in der Reservierung auswies. + je_geraet: dict[int, list[dict[str, Any]]] = {} + for zeile in Database.all( + conn, + "SELECT o.code, d.name AS geraet, v.value FROM dhcp_option_values v " + "JOIN dhcp_options o ON o.id = v.option_id " + "JOIN devices d ON d.id = v.device_id " + "WHERE v.device_id IS NOT NULL AND d.deleted_at IS NULL " + "AND TRIM(COALESCE(v.value, '')) <> '' ORDER BY o.code, d.name", + ): + je_geraet.setdefault(int(zeile["code"]), []).append(dict(zeile)) + + gesendet = [] + for option in options: + geraete = je_geraet.get(int(option["code"]), []) + if str(option["value"] or "").strip() or geraete: + eintrag = dict(option) + eintrag["geraete"] = geraete + gesendet.append(eintrag) + + # Der Server belegt einen Code selbst. Ihn aus dem Katalog herauszuhalten + # hiess: er steht in der erzeugten Datei, aber nirgends auf der Seite -- + # und wer 224 vergeben will, erfaehrt es erst hinterher. + eigenes_verzeichnis = kea.verzeichnis(conn) + katalog = list(options) + if not any(int(zeile["code"]) == kea.KATALOG_CODE for zeile in katalog): + katalog.append( + { + "id": None, + "code": kea.KATALOG_CODE, + "name": kea.KATALOG_NAME, + "type": "string", + "description": "Vom Server belegt | nennt den Clients Name und Typ der " + "eigenen Optionen", + "value": eigenes_verzeichnis, + "is_standard": 1, + "reserviert": True, + } + ) + katalog.sort(key=lambda zeile: int(zeile["code"])) + return render_template( "tesm/dhcp.html", subnets=Database.all(conn, "SELECT * FROM dhcp_subnets ORDER BY sort_order, id"), - options=Database.all( - conn, - "SELECT o.*, v.value, v.id AS value_id FROM dhcp_options o " - "LEFT JOIN dhcp_option_values v ON v.option_id = o.id AND v.device_id IS NULL " - "ORDER BY o.code", - ), + options=options, + katalog=katalog, + gesendet=gesendet, option_types=kea.OPTION_TYPES, device_option_values=_device_option_values(conn), reservations=reservations, @@ -375,6 +421,17 @@ def add_custom_option(): if not 1 <= code <= 254: flash("Der Optionscode muss zwischen 1 und 254 liegen.", "danger") return redirect(url_for("dhcp.overview")) + if code == kea.KATALOG_CODE: + # Hier abweisen statt spaeter stillschweigend das Verzeichnis + # wegzulassen: sonst funktionierte die Benennung auf den Clients + # nicht mehr, und niemand wuesste warum. + flash( + f"Code {kea.KATALOG_CODE} ist belegt: darunter sendet der Server seinen Clients " + "die Namen und Typen der eigenen Optionen. Ohne dieses Verzeichnis heißt eine " + "neue Option auf dem Client nur noch nach ihrer Nummer.", + "danger", + ) + return redirect(url_for("dhcp.overview")) if not name or option_type not in kea.OPTION_TYPES: flash("Name und Typ sind erforderlich.", "danger") return redirect(url_for("dhcp.overview")) diff --git a/apps/tesm/src/tesm/services/kea.py b/apps/tesm/src/tesm/services/kea.py index 2b7746e..4bd7b55 100644 --- a/apps/tesm/src/tesm/services/kea.py +++ b/apps/tesm/src/tesm/services/kea.py @@ -549,20 +549,13 @@ def render_config( # Server tatsaechlich sendet. Es wird nicht gespeichert, sondern aus dem # Katalog abgeleitet -- gespeichert waere es eine zweite Wahrheit. belegte_codes = {int(row["code"]) for row in option_rows} - gesendete_ids = set(global_values) | { - option_id for werte in device_values.values() for option_id in werte - } - verzeichnis = " ".join( - f"{int(row['code'])}:{row['name']}:{row['type']}" - for row in option_rows - if not int(row["is_standard"] or 0) and int(row["id"]) in gesendete_ids - ) - if verzeichnis and KATALOG_CODE not in belegte_codes: + eintrag = verzeichnis(conn) + if eintrag and KATALOG_CODE not in belegte_codes: custom_defs.append( {"name": KATALOG_NAME, "code": KATALOG_CODE, "type": "string", "space": "dhcp4"} ) - global_option_data.append({"name": KATALOG_NAME, "data": verzeichnis}) - elif verzeichnis: + global_option_data.append({"name": KATALOG_NAME, "data": eintrag}) + elif eintrag: warnings.append( f"Code {KATALOG_CODE} ist von einer eigenen Option belegt | das Verzeichnis für " "die Clients wird nicht mitgesendet. Neue Optionen sind dort dann nur unter " @@ -993,6 +986,20 @@ echo "ergaenzt: $ziel" """ +def verzeichnis(conn: sqlite3.Connection) -> str: + """Das Verzeichnis, das der Server seinen Clients mitsendet. + + Eine Quelle fuer beides -- die Konfiguration und die Anzeige. Zweimal + berechnet waeren es zwei Wahrheiten, und die Seite wuerde irgendwann etwas + anderes zeigen als der Server sendet. + """ + return " ".join( + f"{int(zeile['code'])}:{zeile['name']}:{zeile['type']}" + for zeile in gesendete_optionen(conn) + if not int(zeile["is_standard"] or 0) + ) + + def client_konfiguration(conn: sqlite3.Connection, stil: str) -> dict[str, Any]: """Was ein Linux-Client braucht, damit die Optionen bei ihm ankommen. diff --git a/apps/tesm/src/tesm/templates/tesm/dhcp.html b/apps/tesm/src/tesm/templates/tesm/dhcp.html index 7f6894c..73b5502 100644 --- a/apps/tesm/src/tesm/templates/tesm/dhcp.html +++ b/apps/tesm/src/tesm/templates/tesm/dhcp.html @@ -230,15 +230,18 @@ {% endif %} {% endmacro %} -{% set gesetzte = options | selectattr('value') | list %} +{# "gesetzt" heisst nicht nur "global gesetzt": ein Wert an einem einzelnen + Geraet wird genauso gesendet. Die Liste kommt deshalb fertig vom Server -- + in Jinja gerechnet zaehlte sie nur die globalen und meldete "Nichts + gesetzt", waehrend die Vorschau daneben die Option auswies. #}
{{ icon('puzzle', size=17) }} Gesendete Optionen
- {% if gesetzte %} - {{ gesetzte | length }} von {{ options | length }} Optionen tragen + {% if gesendet %} + {{ gesendet | length }} von {{ options | length }} Optionen tragen einen Wert. Was leer ist, wird nicht gesendet. {% else %} Derzeit trägt keine Option einen Wert – es wird also nichts über die @@ -248,7 +251,7 @@
- {% if gesetzte %} + {% if gesendet %}
@@ -256,7 +259,7 @@ - {% for option in gesetzte %} + {% for option in gesendet %} - + {% endfor %} @@ -284,7 +299,7 @@
{{ icon('list', size=17) }} Optionskatalog
- Alle {{ options | length }} bekannten Optionen. Standardoptionen sind nicht löschbar, + Alle {{ katalog | length }} bekannten Optionen. Standardoptionen sind nicht löschbar, ihr Wert lässt sich leeren.
@@ -297,7 +312,7 @@ - {% for option in options %} + {% for option in katalog %} - - + {% if option.reserviert %} + {# Kein Eingabefeld: den Wert bestimmt der Katalog selbst. #} + + + {% else %} + + + {% endif %} {% endfor %}
Wert
{{ option.code }} @@ -264,7 +267,19 @@
{{ option.description }}
{{ option.type }}{{ optionswert(option) }} + {{ optionswert(option) }} + {% if option.geraete %} +
+ {% for eintrag in option.geraete %} +
+ nur für {{ eintrag.geraet }}: + {{ eintrag.value }} +
+ {% endfor %} +
+ {% endif %} +
{{ optionsloeschen(option) }}
Wert
{{ option.code }} @@ -305,8 +320,14 @@
{{ option.description }}
{{ option.type }}{{ optionswert(option) }}{{ optionsloeschen(option) }}{{ option.value or '(noch keine eigene Option)' }}{{ pill('belegt', '') }}{{ optionswert(option) }}{{ optionsloeschen(option) }}