diff --git a/app.py b/app.py index c888897..44329c8 100644 --- a/app.py +++ b/app.py @@ -213,12 +213,16 @@ class CreateWebappRequest(BaseModel): name: str url: str sort_order: int = 0 + kategorie_id: str | None = None + kiosk_modus: bool = False class UpdateWebappRequest(BaseModel): name: str url: str sort_order: int = 0 + kategorie_id: str | None = None + kiosk_modus: bool = False class CreateWebappTemplateRequest(BaseModel): @@ -804,6 +808,60 @@ def fetch_favicon(page_url: str) -> tuple[bytes, str] | None: return _fetch_url_bytes(fallback_url, FAVICON_MAX_BYTES) +# 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- +# Kategorien, diese Abbildung uebersetzt dazwischen. Mehrfachwerte (durch +# Semikolon getrennt, mit abschliessendem Semikolon - Pflicht im +# .desktop-Format) helfen Menues, die eine Kategorie nicht kennen, noch +# eine plausible Alternative zu finden. +KATEGORIE_ZU_XDG_CATEGORIES = { + "bildung-mathe-naturwissenschaft": "Education;Science;", + "bildung-sprache-schreiben": "Education;", + "kreativ-grafik": "Graphics;", + "musik-audio": "AudioVideo;Audio;", + "buero-produktivitaet": "Office;", + "programmieren-making": "Development;", + "internet-kommunikation": "Network;", + "unterrichtswerkzeuge": "Education;", + "individuelle-systemkonfiguration": "Settings;", + "konfig-identitaet-netzwerk": "Settings;", + "konfig-system": "Settings;", + "haertung": "Settings;", + "app-store": "Network;", +} +# Unveraenderter alter Default (keine Kategorie gewaehlt) - landet wie +# bisher meist im "Internet"-Menueordner. +WEBAPP_XDG_CATEGORIES_DEFAULT = "Network;WebBrowser;" + +# Kiosk-Modus (Phase 4): Chrome bevorzugt, Chromium Fallback - beide nur +# nutzbar, wenn das jeweilige Browser-Merkmal im Workspace aktiv ist +# (Nutzer-Entscheidung aus der Plan-Klaerungsrunde: "vermutlich Chrome, +# weil Chromium etwas abgespeckter ist"). Ohne einen der beiden faellt +# die .desktop-Vorlage auf den normalen xdg-open-Start zurueck. +KIOSK_BROWSER_PRIORITAET = ["google-chrome", "chromium"] + + +def _resolve_kiosk_browser(workspace_id: str) -> str | None: + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT m.key + FROM workspace_merkmale wm + JOIN merkmale m ON m.id = wm.merkmal_id + WHERE wm.workspace_id = %s AND m.key = ANY(%s) + """, + (workspace_id, KIOSK_BROWSER_PRIORITAET), + ) + vorhandene = {row[0] for row in cur.fetchall()} + + for browser in KIOSK_BROWSER_PRIORITAET: + if browser in vorhandene: + return browser + return None + + def fetch_workspace_webapps(device_id: str): with get_database_connection() as conn: with conn.cursor() as cur: @@ -825,27 +883,38 @@ def fetch_workspace_webapps(device_id: str): cur.execute( """ - SELECT id, name, url, icon_filename - FROM workspace_webapps - WHERE workspace_id = %s - ORDER BY sort_order, name + SELECT w.id, w.name, w.url, w.icon_filename, w.kiosk_modus, k.key + FROM workspace_webapps w + LEFT JOIN kategorien k ON k.id = w.kategorie_id + WHERE w.workspace_id = %s + ORDER BY w.sort_order, w.name """, (workspace_id,), ) - return [ - { - "id": str(webapp_id), - "name": name, - "url": url, - # Relativer Pfad, kein voller URL - der Server kennt - # seine eigene oeffentliche Adresse nirgends (siehe - # ANODE_URL in tuxflotte-installer/provisioning-agent, - # bewusst nur client-seitig bekannt, gleiches Muster wie - # bei GOLDEN_IMAGE_URL). Der Agent haengt ANODE_URL davor. - "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None, - } - for webapp_id, name, url, icon_filename in cur.fetchall() - ] + zeilen = cur.fetchall() + + kiosk_browser = _resolve_kiosk_browser(workspace_id) if any(row[4] for row in zeilen) else None + + return [ + { + "id": str(webapp_id), + "name": name, + "url": url, + # Relativer Pfad, kein voller URL - der Server kennt + # seine eigene oeffentliche Adresse nirgends (siehe + # ANODE_URL in tuxflotte-installer/provisioning-agent, + # bewusst nur client-seitig bekannt, gleiches Muster wie + # bei GOLDEN_IMAGE_URL). Der Agent haengt ANODE_URL davor. + "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None, + "categories": KATEGORIE_ZU_XDG_CATEGORIES.get(kategorie_key, WEBAPP_XDG_CATEGORIES_DEFAULT), + # None, wenn kein Kiosk-Modus gewuenscht ODER kein Kiosk- + # faehiger Browser im Workspace verfuegbar ist - die + # .desktop-Vorlage faellt in beiden Faellen auf den normalen + # xdg-open-Start zurueck (siehe webapp-shortcuts-Rolle). + "kiosk_browser": kiosk_browser if kiosk_modus else None, + } + for webapp_id, name, url, icon_filename, kiosk_modus, kategorie_key in zeilen + ] def fetch_assigned_blueprints(device_id: str): @@ -2719,10 +2788,12 @@ def fetch_webapps_for_workspace(workspace_id: str): with conn.cursor() as cur: cur.execute( """ - SELECT id, name, url, icon_filename, sort_order - FROM workspace_webapps - WHERE workspace_id = %s - ORDER BY sort_order, name + SELECT w.id, w.name, w.url, w.icon_filename, w.sort_order, + w.kategorie_id, k.name, w.kiosk_modus + FROM workspace_webapps w + LEFT JOIN kategorien k ON k.id = w.kategorie_id + WHERE w.workspace_id = %s + ORDER BY w.sort_order, w.name """, (workspace_id,), ) @@ -2733,12 +2804,18 @@ def fetch_webapps_for_workspace(workspace_id: str): "url": url, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None, "sort_order": sort_order, + "kategorie_id": str(kategorie_id) if kategorie_id else None, + "kategorie_name": kategorie_name, + "kiosk_modus": kiosk_modus, } - for webapp_id, name, url, icon_filename, sort_order in cur.fetchall() + for webapp_id, name, url, icon_filename, sort_order, kategorie_id, kategorie_name, kiosk_modus in cur.fetchall() ] -def create_webapp(workspace_id: str, name: str, url: str, sort_order: int): +def create_webapp( + workspace_id: str, name: str, url: str, sort_order: int, + kategorie_id: str | None = None, kiosk_modus: bool = False, +): webapp_id = uuid4() favicon = fetch_favicon(url) icon_filename = _write_webapp_icon(webapp_id, favicon[0], favicon[1]) if favicon else None @@ -2748,16 +2825,20 @@ def create_webapp(workspace_id: str, name: str, url: str, sort_order: int): with conn.cursor() as cur: cur.execute( """ - INSERT INTO workspace_webapps (id, workspace_id, name, url, icon_filename, icon_content_type, sort_order) - VALUES (%s, %s, %s, %s, %s, %s, %s) + INSERT INTO workspace_webapps + (id, workspace_id, name, url, icon_filename, icon_content_type, sort_order, kategorie_id, kiosk_modus) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) """, - (webapp_id, workspace_id, name, url, icon_filename, icon_content_type, sort_order), + (webapp_id, workspace_id, name, url, icon_filename, icon_content_type, sort_order, kategorie_id, kiosk_modus), ) return {"id": str(webapp_id), "name": name, "url": url, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None} -def update_webapp(webapp_id: str, name: str, url: str, sort_order: int): +def update_webapp( + webapp_id: str, name: str, url: str, sort_order: int, + kategorie_id: str | None = None, kiosk_modus: bool = False, +): # Favicon bei jeder Bearbeitung neu holen (einfacher als zu erkennen, # ob sich nur die URL geaendert hat - Fetch ist billig, siehe # fetch_favicon()) - alte Icon-Datei wird durch die neue ID-basierte @@ -2776,10 +2857,10 @@ def update_webapp(webapp_id: str, name: str, url: str, sort_order: int): """ UPDATE workspace_webapps SET name = %s, url = %s, sort_order = %s, icon_filename = %s, - icon_content_type = %s, updated_at = now() + icon_content_type = %s, kategorie_id = %s, kiosk_modus = %s, updated_at = now() WHERE id = %s """, - (name, url, sort_order, icon_filename, icon_content_type, webapp_id), + (name, url, sort_order, icon_filename, icon_content_type, kategorie_id, kiosk_modus, webapp_id), ) return {"id": webapp_id, "name": name, "url": url, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None} @@ -4287,7 +4368,13 @@ def create_workspace_webapp(workspace_id: str, payload: CreateWebappRequest, aut if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} - return {"success": True, "webapp": create_webapp(workspace_id, payload.name, payload.url, payload.sort_order)} + return { + "success": True, + "webapp": create_webapp( + workspace_id, payload.name, payload.url, payload.sort_order, + payload.kategorie_id, payload.kiosk_modus, + ), + } @app.put("/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}") @@ -4295,7 +4382,13 @@ def update_workspace_webapp(workspace_id: str, webapp_id: str, payload: UpdateWe if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} - return {"success": True, "webapp": update_webapp(webapp_id, payload.name, payload.url, payload.sort_order)} + return { + "success": True, + "webapp": update_webapp( + webapp_id, payload.name, payload.url, payload.sort_order, + payload.kategorie_id, payload.kiosk_modus, + ), + } @app.delete("/api/v1/workspaces/{workspace_id}/webapps/{webapp_id}") diff --git a/migrations/0043_webapp_kategorie_kiosk.sql b/migrations/0043_webapp_kategorie_kiosk.sql new file mode 100644 index 0000000..921016e --- /dev/null +++ b/migrations/0043_webapp_kategorie_kiosk.sql @@ -0,0 +1,24 @@ +BEGIN; + +-- Phase 4 des GUI-Ausbaus (11.09.2026, siehe +-- project_kundenplattform_gui_umbau_2026-09-11_dashboard_workspace-Memory): +-- Webapps bekommen zwei neue, optionale Eigenschaften. +-- +-- kategorie_id: statt eines eigenen "Webapps"-Menüpunkts (bewusst +-- verworfen, siehe Nutzer-Feedback in der Plan-Runde) ordnet sich eine +-- Webapp optional einer der BESTEHENDEN App-Kategorien zu - die +-- .desktop-Datei bekommt dadurch die passende XDG-Categories=-Zeile +-- (Mapping in app.py) statt immer "Network;WebBrowser;" und landet im +-- Menü in derselben Kategorie-Schublade wie reguläre Apps. NULL = alter, +-- unveränderter Default (Internet/Netzwerk). +-- +-- kiosk_modus: bei true startet die Webapp im Chrome-/Chromium-Kiosk- +-- Fenster (--app=, keine Adressleiste/Tabs) statt im normalen +-- Standardbrowser - welcher der beiden Browser verwendet wird (Chrome +-- bevorzugt, Chromium Fallback, sonst normaler Tab), wird serverseitig +-- anhand der Workspace-Merkmal-Zuordnung aufgelöst (siehe +-- fetch_workspace_webapps() in app.py), nicht zur Laufzeit auf dem Gerät. +ALTER TABLE workspace_webapps ADD COLUMN kategorie_id UUID REFERENCES kategorien(id) ON DELETE SET NULL; +ALTER TABLE workspace_webapps ADD COLUMN kiosk_modus BOOLEAN NOT NULL DEFAULT FALSE; + +COMMIT; diff --git a/migrations/0044_chromium_edge_merkmale.sql b/migrations/0044_chromium_edge_merkmale.sql new file mode 100644 index 0000000..d8f8582 --- /dev/null +++ b/migrations/0044_chromium_edge_merkmale.sql @@ -0,0 +1,33 @@ +BEGIN; + +-- Phase 4 des GUI-Ausbaus (11.09.2026): zwei neue Browser-Merkmale, wie +-- vom Nutzer in der Plan-Klärungsrunde gewünscht ("wollte sowieso noch +-- Chrome, Chromium und Edge ergänzen") - Google Chrome existiert bereits +-- aus Migration 0031. +-- +-- Chromium bewusst NUR auf debian-image/mint-image blueprint-verknüpft, +-- NICHT auf ubuntu-image - siehe ausführlicher Kommentar in +-- ansible-content/roles/chromium/tasks/main.yml (Ubuntus "chromium"-Paket +-- ist seit 19.10 nur noch ein Snap-Wrapper, kein echtes .deb). +-- Microsoft Edge läuft (wie Google Chrome) über ein eigenes, distributions- +-- unabhängiges Microsoft-Repo - auf allen drei Backends verfügbar. +INSERT INTO merkmale (id, key, name, description, kategorie_id, im_auftragskatalog) VALUES + (gen_random_uuid(), 'chromium', 'Chromium', + 'Freie Basis von Google Chrome. Auf Ubuntu bewusst nicht angeboten (dortiges Paket zieht Snap nach) - dort steht Chrome zur Verfügung.', + (SELECT id FROM kategorien WHERE key = 'internet-kommunikation'), TRUE), + (gen_random_uuid(), 'microsoft-edge', 'Microsoft Edge', NULL, + (SELECT id FROM kategorien WHERE key = 'internet-kommunikation'), TRUE); + +INSERT INTO blueprints (id, merkmal_id, backend_id, ansible_role) +SELECT gen_random_uuid(), m.id, b.id, 'chromium' +FROM merkmale m +JOIN backends b ON b.key IN ('mint-image', 'debian-image') +WHERE m.key = 'chromium'; + +INSERT INTO blueprints (id, merkmal_id, backend_id, ansible_role) +SELECT gen_random_uuid(), m.id, b.id, 'microsoft-edge' +FROM merkmale m +JOIN backends b ON b.key IN ('mint-image', 'debian-image', 'ubuntu-image') +WHERE m.key = 'microsoft-edge'; + +COMMIT;