diff --git a/app.py b/app.py index 999af51..d14093b 100644 --- a/app.py +++ b/app.py @@ -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):