From d7f14c9febf159f4c0f5a6b68b46b005a17c06d8 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Wed, 9 Sep 2026 17:42:30 +0200 Subject: [PATCH] Webapp-Vorlagen (Zwei-Ebenen-Modell, Nutzer-Entscheidung 09.09.2026) Ergaenzung zum bisherigen rein kundeneigenen workspace_webapps: Admin kann jetzt zusaetzlich global wiederverwendbare Vorlagen anlegen ('bereitstellen und zur Auswahl stellen'), Kunden aktivieren sie pro Workspace zusaetzlich zu weiterhin frei erstellbaren eigenen Eintraegen ('Kunden koennen selbst welche erstellen'). Neue Tabelle webapp_templates (Migration 0033, diesmal korrekt ueber TUXFLOTTE_DATABASE_URL statt sudo-postgres angewendet - siehe Lehre aus dem vorherigen Owner-Bug). workspace_webapps bekommt ein nullable template_id (ON DELETE SET NULL) zur Aktivierungs-Nachverfolgung. activate_webapp_template() kopiert Name/URL/Icon-Datei PHYSISCH unter einem neuen, vom eigenen Eintrag abgeleiteten Dateinamen - keine laufende Referenz auf die Vorlagen-Icon-Datei, sonst wuerde ein spaeteres Loeschen/Aktualisieren der Vorlage die schon aktivierte Kopie mit betreffen. Neue Endpunkte: /api/v1/webapp-templates (CRUD), /api/v1/workspaces/{id}/webapp-templates/available (noch nicht aktivierte Vorlagen fuer einen Workspace), .../activate. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VLk93dhDMrkPXefVdknMV9 --- app.py | 230 +++++++++++++++++++++++++++ migrations/0033_webapp_templates.sql | 29 ++++ 2 files changed, 259 insertions(+) create mode 100644 migrations/0033_webapp_templates.sql diff --git a/app.py b/app.py index e91f840..468b36f 100644 --- a/app.py +++ b/app.py @@ -202,6 +202,18 @@ class UpdateWebappRequest(BaseModel): sort_order: int = 0 +class CreateWebappTemplateRequest(BaseModel): + name: str + url: str + description: str | None = None + + +class UpdateWebappTemplateRequest(BaseModel): + name: str + url: str + description: str | None = None + + class CreateBereitstellungsvorlageRequest(BaseModel): workspace_id: str backend_id: str @@ -2329,6 +2341,169 @@ def delete_webapp(webapp_id: str): icon_path.unlink() +# --- Webapp-Vorlagen (Phase 2, Nutzer-Entscheidung 09.09.2026): Admin legt +# global wiederverwendbare Vorlagen an, Kunden aktivieren sie pro Workspace +# (kopiert Name/URL/Icon in workspace_webapps, siehe activate_webapp_template() +# unten) - zusaetzlich zu weiterhin frei erstellbaren eigenen Eintraegen +# (create_webapp() oben, unveraendert). + +def fetch_webapp_templates(): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT id, name, url, description, icon_filename FROM webapp_templates ORDER BY name" + ) + return [ + { + "id": str(template_id), + "name": name, + "url": url, + "description": description, + "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None, + } + for template_id, name, url, description, icon_filename in cur.fetchall() + ] + + +def fetch_available_templates_for_workspace(workspace_id: str): + """Vorlagen, die fuer diesen Workspace noch NICHT aktiviert sind (per + workspace_webapps.template_id) - Grundlage fuer die + Aktivieren-Auswahl im Kunden-Self-Service.""" + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT t.id, t.name, t.url, t.description, t.icon_filename + FROM webapp_templates t + WHERE NOT EXISTS ( + SELECT 1 FROM workspace_webapps w + WHERE w.workspace_id = %s AND w.template_id = t.id + ) + ORDER BY t.name + """, + (workspace_id,), + ) + return [ + { + "id": str(template_id), + "name": name, + "url": url, + "description": description, + "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None, + } + for template_id, name, url, description, icon_filename in cur.fetchall() + ] + + +def create_webapp_template(name: str, url: str, description: str | None): + template_id = uuid4() + favicon = fetch_favicon(url) + icon_filename = _write_webapp_icon(template_id, favicon[0], favicon[1]) if favicon else None + icon_content_type = favicon[1] if favicon else None + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO webapp_templates (id, name, url, description, icon_filename, icon_content_type) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (template_id, name, url, description, icon_filename, icon_content_type), + ) + + return {"id": str(template_id), "name": name, "url": url, "description": description, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None} + + +def update_webapp_template(template_id: str, name: str, url: str, description: str | None): + favicon = fetch_favicon(url) + icon_filename = _write_webapp_icon(template_id, favicon[0], favicon[1]) if favicon else None + icon_content_type = favicon[1] if favicon else None + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE webapp_templates + SET name = %s, url = %s, description = %s, icon_filename = %s, + icon_content_type = %s, updated_at = now() + WHERE id = %s + """, + (name, url, description, icon_filename, icon_content_type, template_id), + ) + + return {"id": template_id, "name": name, "url": url, "description": description, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None} + + +def delete_webapp_template(template_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT icon_filename FROM webapp_templates WHERE id = %s", + (template_id,), + ) + row = cur.fetchone() + cur.execute("DELETE FROM webapp_templates WHERE id = %s", (template_id,)) + + if row and row[0]: + icon_path = WEBAPP_ICONS_DIR / row[0] + if icon_path.is_file(): + icon_path.unlink() + + +def activate_webapp_template(workspace_id: str, template_id: str): + """Kopiert Name/URL/Icon der Vorlage in einen neuen workspace_webapps- + Eintrag (bewusst eine Kopie, keine laufende Referenz - eine spaetere + Vorlagenaenderung wirkt sich nicht rueckwirkend auf schon aktivierte + Eintraege aus). Liefert None, wenn die Vorlage nicht existiert oder fuer + diesen Workspace bereits aktiviert ist (Aufrufer meldet das als Fehler).""" + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT name, url, icon_filename, icon_content_type FROM webapp_templates WHERE id = %s", + (template_id,), + ) + template = cur.fetchone() + + if template is None: + return None + + name, url, icon_filename, icon_content_type = template + + cur.execute( + "SELECT 1 FROM workspace_webapps WHERE workspace_id = %s AND template_id = %s", + (workspace_id, template_id), + ) + if cur.fetchone() is not None: + return None + + webapp_id = uuid4() + + # Physische Kopie der Icon-Datei unter einem neuen, vom + # workspace_webapps-Eintrag selbst abgeleiteten Dateinamen - + # nicht denselben Dateinamen wie die Vorlage weiterverwenden. + # Sonst wuerde ein spaeteres Loeschen/Aktualisieren der Vorlage + # (delete_webapp_template()/update_webapp_template(), beide + # ersetzen bzw. entfernen die Icon-Datei unter dem + # Vorlagen-Dateinamen) die bereits aktivierte Kopie mit + # aufraeumen - widerspraeche "keine laufende Referenz" oben. + copied_icon_filename = icon_filename + if icon_filename: + extension = Path(icon_filename).suffix + copied_icon_filename = f"{webapp_id}{extension}" + source_bytes = (WEBAPP_ICONS_DIR / icon_filename).read_bytes() + (WEBAPP_ICONS_DIR / copied_icon_filename).write_bytes(source_bytes) + + cur.execute( + """ + INSERT INTO workspace_webapps (id, workspace_id, name, url, icon_filename, icon_content_type, template_id) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + (webapp_id, workspace_id, name, url, copied_icon_filename, icon_content_type, template_id), + ) + + return {"id": str(webapp_id), "name": name, "url": url, "icon_path": f"/webapp-icons/{copied_icon_filename}" if copied_icon_filename else None} + + def remove_merkmal_from_workspace(workspace_id: str, merkmal_id: str): with get_database_connection() as conn: with conn.cursor() as cur: @@ -3604,6 +3779,61 @@ def delete_workspace_webapp(workspace_id: str, webapp_id: str, authorization: st return {"success": True} +@app.get("/api/v1/webapp-templates") +def list_webapp_templates(authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + return {"success": True, "webapp_templates": fetch_webapp_templates()} + + +@app.post("/api/v1/webapp-templates") +def create_webapp_template_endpoint(payload: CreateWebappTemplateRequest, authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + return {"success": True, "webapp_template": create_webapp_template(payload.name, payload.url, payload.description)} + + +@app.put("/api/v1/webapp-templates/{template_id}") +def update_webapp_template_endpoint(template_id: str, payload: UpdateWebappTemplateRequest, authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + return {"success": True, "webapp_template": update_webapp_template(template_id, payload.name, payload.url, payload.description)} + + +@app.delete("/api/v1/webapp-templates/{template_id}") +def delete_webapp_template_endpoint(template_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."} + + delete_webapp_template(template_id) + + return {"success": True} + + +@app.get("/api/v1/workspaces/{workspace_id}/webapp-templates/available") +def list_available_webapp_templates(workspace_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."} + + return {"success": True, "webapp_templates": fetch_available_templates_for_workspace(workspace_id)} + + +@app.post("/api/v1/workspaces/{workspace_id}/webapp-templates/{template_id}/activate") +def activate_webapp_template_endpoint(workspace_id: str, template_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."} + + webapp = activate_webapp_template(workspace_id, template_id) + + if webapp is None: + return {"success": False, "error": "not_available", "message": "Vorlage wurde nicht gefunden oder ist für diesen Workspace bereits aktiviert."} + + return {"success": True, "webapp": webapp} + + @app.get("/api/v1/backends") def list_backends(authorization: str | None = Header(default=None)): if not require_service_token(authorization): diff --git a/migrations/0033_webapp_templates.sql b/migrations/0033_webapp_templates.sql new file mode 100644 index 0000000..2c793ac --- /dev/null +++ b/migrations/0033_webapp_templates.sql @@ -0,0 +1,29 @@ +BEGIN; + +-- Zwei-Ebenen-Webapp-Modell (Nutzer-Entscheidung 09.09.2026, Ergänzung zum +-- bisherigen rein kundeneigenen workspace_webapps aus Migration 0032): +-- Admin kann global wiederverwendbare Vorlagen anlegen ("bereitstellen und +-- zur Auswahl stellen"), die Kunden pro Workspace aktivieren können - +-- zusätzlich zu weiterhin frei erstellbaren eigenen Einträgen. +CREATE TABLE webapp_templates ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL, + description TEXT, + icon_filename TEXT, + icon_content_type TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Nullable, gesetzt wenn ein workspace_webapps-Eintrag durch Aktivieren +-- einer Vorlage entstanden ist (Name/URL/Icon werden beim Aktivieren +-- KOPIERT, nicht referenziert - eine spätere Vorlagenänderung wirkt sich +-- nicht rückwirkend auf schon aktivierte Einträge aus, analog zu +-- Bereitstellungsvorlagen-Snapshots an anderer Stelle im System). +-- ON DELETE SET NULL statt CASCADE: eine gelöschte Vorlage soll bereits +-- aktivierte Einträge nicht mitreißen, nur die Aktivierungs-Historie +-- kappen. +ALTER TABLE workspace_webapps ADD COLUMN template_id UUID REFERENCES webapp_templates(id) ON DELETE SET NULL; + +COMMIT;