Phase 6: App-Beschreibungen + Icon-Fetch-Mechanismus
Migration 0045: 47 von 56 Merkmalen hatten keine Beschreibung (Datenbank- Check 11.09.2026) - Kurzbeschreibungen ergänzt, gleicher Ton wie die 9 bestehenden. Neue Spalten homepage_url/icon_source_hint (nur real gegen die jeweilige URL bzw. https://cdn.simpleicons.org/<slug> geprüft gesetzt, sonst NULL statt geraten) sowie icon_filename/icon_content_type (Cache, analog workspace_webapps). Neuer Icon-Fetch: fetch_merkmal_icon() probiert zuerst den Simple-Icons- Slug, sonst fetch_favicon() der Homepage (bestehende Funktion aus dem Webapp-Mechanismus direkt wiederverwendet) - Kombi aus beidem war die Nutzer-Entscheidung in der Plan-Klärungsrunde. aktualisiere_merkmal_icon() ist ein eigener, wiederholbar aufrufbarer Schritt (POST /api/v1/merkmale/ {id}/icon/aktualisieren) statt Teil des Anlegens, weil Merkmale per Migration entstehen, nicht per API-Call mit sofortigem Netzwerk-Kontext. Auslieferung über GET /merkmal-icons/{filename}, gleiches Muster wie /webapp-icons. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VLk93dhDMrkPXefVdknMV9
This commit is contained in:
1 parent
2ebecd1426
commit
95946bd11c
2 files changed
+251
-2
No files matched your search
@@ -71,6 +71,13 @@ GOLDEN_IMAGES_DIR = Path(os.environ.get("TUXFLOTTE_GOLDEN_IMAGES_DIR", "data/gol
|
||||
# ansible-pull-Lauf, kennt aber (anders als Golden Images) keinen
|
||||
# Aktivierungs-Kontext dafuer.
|
||||
WEBAPP_ICONS_DIR = Path(os.environ.get("TUXFLOTTE_WEBAPP_ICONS_DIR", "data/webapp-icons")).resolve()
|
||||
# Merkmal-Icons (Phase 6, 11.09.2026) - eigenes Verzeichnis statt
|
||||
# WEBAPP_ICONS_DIR mitzunutzen, obwohl der Mechanismus fast identisch ist
|
||||
# (fetch_merkmal_icon()/_write_merkmal_icon() unten): Merkmale sind
|
||||
# Katalog-Stammdaten (per Migration angelegt), Webapps sind kundeneigene
|
||||
# Laufzeitdaten - getrennte Verzeichnisse halten das auseinander.
|
||||
MERKMAL_ICONS_DIR = Path(os.environ.get("TUXFLOTTE_MERKMAL_ICONS_DIR", "data/merkmal-icons")).resolve()
|
||||
SIMPLE_ICONS_CDN_URL = "https://cdn.simpleicons.org"
|
||||
# At-Rest-Verschluesselung fuer Merkmal-Optionen (10.09.2026, Vorbereitung
|
||||
# AD/OIDC/Fileserver-Merkmale mit echten Zugangsdaten) - gleiches Muster
|
||||
# wie kundenplattform/encryption.py fuer den WLAN-PSK, aber eigener
|
||||
@@ -808,6 +815,28 @@ def fetch_favicon(page_url: str) -> tuple[bytes, str] | None:
|
||||
return _fetch_url_bytes(fallback_url, FAVICON_MAX_BYTES)
|
||||
|
||||
|
||||
def fetch_merkmal_icon(icon_source_hint: str | None, homepage_url: str | None) -> tuple[bytes, str] | None:
|
||||
"""
|
||||
Icon-Fetch fuer ein Merkmal (Phase 6, 11.09.2026, Plan-Klaerungsrunde
|
||||
"Kombi aus Simple-Icons-CDN + Favicon-Fetch"): zuerst den Simple-Icons-
|
||||
Slug probieren (bekannte Marken-Apps, liefert ein sauberes einheitliches
|
||||
SVG), sonst fetch_favicon() der hinterlegten Homepage (Nischen-Apps,
|
||||
gleicher Mechanismus wie bei Webapps). None, wenn beides fehlschlaegt
|
||||
oder gar nichts hinterlegt ist - der Aufrufer setzt dann kein
|
||||
icon_filename, die Flottenmatrix faellt auf ihr bisheriges Haekchen/
|
||||
Fallback-Icon zurueck.
|
||||
"""
|
||||
if icon_source_hint:
|
||||
gefunden = _fetch_url_bytes(f"{SIMPLE_ICONS_CDN_URL}/{icon_source_hint}", FAVICON_MAX_BYTES)
|
||||
if gefunden is not None:
|
||||
return gefunden
|
||||
|
||||
if homepage_url:
|
||||
return fetch_favicon(homepage_url)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Phase 4 des GUI-Ausbaus (11.09.2026): Kategorie-Key -> echter Freedesktop-
|
||||
# Categories=-Wert fuers .desktop-Menue einer Webapp (siehe kategorien-
|
||||
# Tabelle). Unsere eigenen Kategorie-Namen sind keine gueltigen XDG-Menue-
|
||||
@@ -2693,7 +2722,7 @@ def fetch_merkmale():
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT m.id, m.key, m.name, m.description, m.im_auftragskatalog,
|
||||
m.kategorie_id, k.name
|
||||
m.kategorie_id, k.name, m.icon_filename
|
||||
FROM merkmale m
|
||||
LEFT JOIN kategorien k ON k.id = m.kategorie_id
|
||||
ORDER BY m.key
|
||||
@@ -2708,10 +2737,13 @@ def fetch_merkmale():
|
||||
"im_auftragskatalog": im_auftragskatalog,
|
||||
"kategorie_id": str(kategorie_id) if kategorie_id else None,
|
||||
"kategorie_name": kategorie_name,
|
||||
# Phase 6 (11.09.2026) - relativer Pfad wie bei den
|
||||
# Webapp-Icons, kein voller URL (siehe icon_path dort).
|
||||
"icon_path": f"/merkmal-icons/{icon_filename}" if icon_filename else None,
|
||||
}
|
||||
for (
|
||||
mid, key, name, description, im_auftragskatalog,
|
||||
kategorie_id, kategorie_name,
|
||||
kategorie_id, kategorie_name, icon_filename,
|
||||
) in cur.fetchall()
|
||||
]
|
||||
|
||||
@@ -2961,6 +2993,55 @@ def _write_webapp_icon(webapp_id, icon_bytes: bytes, content_type: str) -> str:
|
||||
return filename
|
||||
|
||||
|
||||
def _write_merkmal_icon(merkmal_id, icon_bytes: bytes, content_type: str) -> str:
|
||||
extension = mimetypes.guess_extension(content_type) or ".svg"
|
||||
if extension == ".jpe":
|
||||
extension = ".jpg"
|
||||
filename = f"{merkmal_id}{extension}"
|
||||
MERKMAL_ICONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(MERKMAL_ICONS_DIR / filename).write_bytes(icon_bytes)
|
||||
return filename
|
||||
|
||||
|
||||
def aktualisiere_merkmal_icon(merkmal_id: str) -> dict | None:
|
||||
"""
|
||||
Holt (falls möglich) ein Icon fuer ein Merkmal und schreibt icon_filename/
|
||||
icon_content_type - Gegenstueck zu create_webapp()s Favicon-Fetch, aber
|
||||
als eigener, wiederholbar aufrufbarer Schritt statt beim Anlegen
|
||||
(Merkmale entstehen per Migration, nicht per API-Aufruf mit sofortigem
|
||||
Netzwerk-Kontext). Liefert None, wenn das Merkmal nicht existiert;
|
||||
{"icon_path": None} (kein Fehler), wenn weder Simple-Icons-Slug noch
|
||||
Favicon-Fetch etwas geliefert haben - dann bleibt das Fallback-Icon in
|
||||
der Flottenmatrix bestehen.
|
||||
"""
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT icon_source_hint, homepage_url FROM merkmale WHERE id = %s",
|
||||
(merkmal_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
icon_source_hint, homepage_url = row
|
||||
gefunden = fetch_merkmal_icon(icon_source_hint, homepage_url)
|
||||
|
||||
icon_filename = None
|
||||
icon_content_type = None
|
||||
if gefunden is not None:
|
||||
icon_bytes, icon_content_type = gefunden
|
||||
icon_filename = _write_merkmal_icon(merkmal_id, icon_bytes, icon_content_type)
|
||||
|
||||
cur.execute(
|
||||
"UPDATE merkmale SET icon_filename = %s, icon_content_type = %s WHERE id = %s",
|
||||
(icon_filename, icon_content_type, merkmal_id),
|
||||
)
|
||||
|
||||
return {"icon_path": f"/merkmal-icons/{icon_filename}" if icon_filename else None}
|
||||
|
||||
|
||||
def fetch_webapps_for_workspace(workspace_id: str):
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
@@ -4463,6 +4544,44 @@ def update_merkmal_endpoint(
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@app.post("/api/v1/merkmale/{merkmal_id}/icon/aktualisieren")
|
||||
def merkmal_icon_aktualisieren_endpoint(merkmal_id: str, authorization: str | None = Header(default=None)):
|
||||
if not require_service_token(authorization):
|
||||
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
||||
|
||||
ergebnis = aktualisiere_merkmal_icon(merkmal_id)
|
||||
|
||||
if ergebnis is None:
|
||||
return {"success": False, "error": "not_found", "message": "Merkmal wurde nicht gefunden."}
|
||||
|
||||
return {"success": True, **ergebnis}
|
||||
|
||||
|
||||
@app.get("/merkmal-icons/{filename}")
|
||||
def get_merkmal_icon(filename: str):
|
||||
# Gleiches Muster wie get_webapp_icon() oben (Path-Traversal-Schutz,
|
||||
# unauthentifiziert - wird vom Kundenplattform-Proxy ohne Session-
|
||||
# Kontext abgerufen).
|
||||
safe_name = Path(filename).name
|
||||
if safe_name != filename:
|
||||
raise HTTPException(status_code=400, detail="invalid_filename")
|
||||
|
||||
file_path = MERKMAL_ICONS_DIR / safe_name
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="not_found")
|
||||
|
||||
with get_database_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT icon_content_type FROM merkmale WHERE icon_filename = %s LIMIT 1",
|
||||
(safe_name,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
media_type = row[0] if row and row[0] else "application/octet-stream"
|
||||
|
||||
return FileResponse(file_path, media_type=media_type)
|
||||
|
||||
|
||||
@app.get("/api/v1/kategorien")
|
||||
def list_kategorien(authorization: str | None = Header(default=None)):
|
||||
if not require_service_token(authorization):
|
||||
|
||||
Reference in new issue
Block a user