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:
1 parent
4b229a992b
commit
d7f14c9feb
2 files changed
+259
No files matched your search
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user