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;