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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLk93dhDMrkPXefVdknMV9
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-09 17:42:30 +02:00
1 parent 4b229a992b
commit d7f14c9feb
2 files changed
+259

No files matched your search

+230
View File
@@ -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):