API: Workspace aus dem Assistenten in einem Rutsch anlegen

POST /api/v1/workspaces/assistent legt Workspace mit Distro, Version 1,
Merkmalen und Webapps aus Vorlagen in einer Transaktion an und lehnt
Merkmale ab, die auf der gewaehlten Distro nicht umgesetzt sind
(Fehlermeldung nennt sie). Key wird aus dem Namen erzeugt und je
Eigentuemer eindeutig gemacht. Workspace-Creator neu, Schritt D.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 12:12:12 +02:00
1 parent b361579cc6
commit 857461cc44
1 file changed
+164
+164
View File
@@ -214,6 +214,16 @@ class CreateKategorieRequest(BaseModel):
art: Literal["app", "richtlinie"] = "app"
class CreateWorkspaceAssistentRequest(BaseModel):
"""Workspace-Creator neu, Schritt D: alles, was der Assistent sammelt."""
name: str
description: str | None = None
organization_id: str | None = None
backend_id: str
merkmal_ids: list[str] = []
webapp_template_ids: list[str] = []
class CreateWorkspaceRequest(BaseModel):
key: str
name: str
@@ -2962,6 +2972,143 @@ def create_workspace(
return {"id": str(workspace_id), "key": key, "name": name}
def workspace_key_aus_name(cur, name: str, organization_id: str | None) -> str:
"""Key (eindeutig je Eigentümer) aus dem Anzeigenamen, Umlaute auf ihr
ASCII-Grundzeichen reduziert; bei Kollision mit -2, -3 … ergänzt."""
ascii_only = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode("ascii")
basis = re.sub(r"[^a-z0-9]+", "-", ascii_only.lower()).strip("-")[:48] or "workspace"
cur.execute(
"SELECT key FROM workspaces WHERE organization_id IS NOT DISTINCT FROM %s",
(organization_id,),
)
vergeben = {row[0] for row in cur.fetchall()}
key, zaehler = basis, 2
while key in vergeben:
key = f"{basis}-{zaehler}"
zaehler += 1
return key
def create_workspace_mit_inhalt(
name: str, description: str | None, organization_id: str | None, backend_id: str,
merkmal_ids: list[str], webapp_template_ids: list[str],
):
"""
Legt einen Workspace aus dem Assistenten in einem Rutsch an (eine
Transaktion): Workspace mit Distro, veröffentlichte Version 1, Merkmale,
Webapps aus Vorlagen. Lehnt ab, wenn ein Merkmal auf der gewählten Distro
nicht umgesetzt ist - ein Workspace soll nichts enthalten, was auf seinen
Geräten still fehlt (ADR-0038).
Rückgabe: (workspace_dict, None) oder (None, (fehlercode, nachricht)).
"""
name = name.strip()
if not name:
return None, ("invalid_request", "Bitte einen Namen angeben.")
merkmal_ids = list(dict.fromkeys(merkmal_ids))
webapp_template_ids = list(dict.fromkeys(webapp_template_ids))
workspace_id, version_id = uuid4(), uuid4()
icon_kopien = []
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT name FROM backends WHERE id = %s", (backend_id,))
if cur.fetchone() is None:
return None, ("backend_not_found", "Die gewählte Distro gibt es nicht.")
if organization_id is not None:
cur.execute("SELECT 1 FROM organizations WHERE id = %s", (organization_id,))
if cur.fetchone() is None:
return None, ("organization_not_found", "Die gewählte Organisation gibt es nicht.")
if merkmal_ids:
cur.execute(
"""
SELECT m.id::text, m.name, bp.id IS NOT NULL
FROM merkmale m
LEFT JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
WHERE m.id::text = ANY(%s)
""",
(backend_id, merkmal_ids),
)
gefunden = {mid: (mname, umgesetzt) for mid, mname, umgesetzt in cur.fetchall()}
if len(gefunden) != len(merkmal_ids):
return None, ("merkmal_not_found", "Mindestens ein gewähltes Merkmal gibt es nicht.")
fehlend = sorted(mname for mname, umgesetzt in gefunden.values() if not umgesetzt)
if fehlend:
return None, (
"distro_konflikt",
"Auf der gewählten Distro nicht umgesetzt: " + ", ".join(fehlend),
)
vorlagen = []
if webapp_template_ids:
cur.execute(
"""
SELECT id, name, url, icon_filename, icon_content_type, kategorie_id
FROM webapp_templates WHERE id::text = ANY(%s)
""",
(webapp_template_ids,),
)
vorlagen = cur.fetchall()
if len(vorlagen) != len(webapp_template_ids):
return None, ("template_not_found", "Mindestens eine gewählte Webapp-Vorlage gibt es nicht.")
key = workspace_key_aus_name(cur, name, organization_id)
cur.execute(
"""
INSERT INTO workspaces (id, organization_id, key, name, description, backend_id, sichtbarkeit, ist_vorlage)
VALUES (%s, %s, %s, %s, %s, %s, %s, FALSE)
""",
(
workspace_id, organization_id, key, name, description, backend_id,
"global" if organization_id is None else "organisation",
),
)
cur.execute(
"""
INSERT INTO workspace_versionen (id, workspace_id, nummer, status, notiz, veroeffentlicht_am)
VALUES (%s, %s, 1, 'veroeffentlicht', 'Mit dem Assistenten erstellt', now())
""",
(version_id, workspace_id),
)
for merkmal_id in merkmal_ids:
cur.execute(
"INSERT INTO workspace_version_merkmale (workspace_version_id, merkmal_id) VALUES (%s, %s)",
(version_id, merkmal_id),
)
for sort_order, (template_id, tname, url, icon_filename, icon_content_type, kategorie_id) in enumerate(vorlagen):
webapp_id = uuid4()
kopie = f"{webapp_id}{Path(icon_filename).suffix}" if icon_filename else None
if kopie:
icon_kopien.append((icon_filename, kopie))
cur.execute(
"""
INSERT INTO workspace_webapps
(id, workspace_id, workspace_version_id, name, url, icon_filename, icon_content_type,
sort_order, template_id, kategorie_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
webapp_id, workspace_id, version_id, tname, url, kopie, icon_content_type,
sort_order, template_id, kategorie_id,
),
)
# Eigene Icon-Kopie je Webapp wie bei activate_webapp_template(), damit
# Änderungen an der Vorlage die Kopie nicht mitreißen.
for quelle, ziel in icon_kopien:
quelle_pfad = WEBAPP_ICONS_DIR / quelle
if quelle_pfad.is_file():
WEBAPP_ICONS_DIR.mkdir(parents=True, exist_ok=True)
(WEBAPP_ICONS_DIR / ziel).write_bytes(quelle_pfad.read_bytes())
return {"id": str(workspace_id), "key": key, "name": name}, None
def clone_workspace(source_workspace_id: str, organization_id: str):
"""
"Diesen Standard übernehmen" (GUI-Umbau Phase 3, Nachtrag 11.09.2026):
@@ -4856,6 +5003,23 @@ def list_workspaces(authorization: str | None = Header(default=None)):
return {"success": True, "workspaces": fetch_workspaces()}
@app.post("/api/v1/workspaces/assistent")
def create_workspace_assistent_endpoint(
payload: CreateWorkspaceAssistentRequest, authorization: str | None = Header(default=None)
):
if not require_service_token(authorization):
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
workspace, fehler = create_workspace_mit_inhalt(
payload.name, payload.description, payload.organization_id, payload.backend_id,
payload.merkmal_ids, payload.webapp_template_ids,
)
if fehler:
return {"success": False, "error": fehler[0], "message": fehler[1]}
return {"success": True, "workspace": workspace}
@app.post("/api/v1/workspaces")
def create_workspace_endpoint(payload: CreateWorkspaceRequest, authorization: str | None = Header(default=None)):
if not require_service_token(authorization):