From 421e3edc45d74bfa9dc4f37b395ef514c94f3c46 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Wed, 7 Oct 2026 13:34:38 +0200 Subject: [PATCH] Workspace-Verwaltung: Entwurf/Veroeffentlichen, Wirkung, Vorlage, Kopie, Loeschen Inhalte aendern sich nur noch im Entwurf (Endpunkte lehnen sonst mit kein_entwurf ab, Webapps muessen zum Entwurf gehoeren, neue Merkmale zur Distro passen). Veroeffentlichen prueft die Distro erneut. Neue Endpunkte: Entwurf anlegen/verwerfen, veroeffentlichen, Versionen, Nutzung (Profile, OEs, Geraete automatisch vs. gepinnt), Wirkung (Diff plus Nutzung), Name/Zweck, Vorlage mit Sichtbarkeit, Kopie-Pruefung und Kopie auf andere Distro (fehlende Merkmale fallen weg und werden gemeldet), Loeschen nur wenn ungenutzt (raeumt Icons auf), Kundengruppen. geraete_basis_herkunft() liefert die Herkunft der Grundausstattung. Workspace-Creator neu, Schritt E. Co-Authored-By: Claude Opus 5.5 --- app.py | 674 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 670 insertions(+), 4 deletions(-) diff --git a/app.py b/app.py index d14093b..9632a2f 100644 --- a/app.py +++ b/app.py @@ -214,6 +214,28 @@ class CreateKategorieRequest(BaseModel): art: Literal["app", "richtlinie"] = "app" +class WorkspaceAktualisierenRequest(BaseModel): + name: str + description: str | None = None + + +class WorkspaceVeroeffentlichenRequest(BaseModel): + notiz: str | None = None + + +class WorkspaceVorlageRequest(BaseModel): + ist_vorlage: bool + sichtbarkeit: Literal["global", "kundengruppe", "organisation"] = "global" + kundengruppe_ids: list[str] = [] + organization_ids: list[str] = [] + + +class WorkspaceKopieRequest(BaseModel): + name: str + backend_id: str + organization_id: str | None = None + + class CreateWorkspaceAssistentRequest(BaseModel): """Workspace-Creator neu, Schritt D: alles, was der Assistent sammelt.""" name: str @@ -1279,9 +1301,18 @@ def arbeitsversion_id(cur, workspace_id): def geraete_basis(cur, device_id: str): + """Wie geraete_basis_herkunft(), ohne die Herkunft.""" + + ergebnis = geraete_basis_herkunft(cur, device_id) + return ergebnis[:2] if ergebnis else None + + +def geraete_basis_herkunft(cur, device_id: str): """ Grundausstattung eines Geräts (Workspace-Creator neu, Schritt C): - (workspace_version_id, backend_id) oder None ohne Zuweisung. + (workspace_version_id, backend_id, herkunft) oder None ohne Zuweisung. + herkunft: {"art": "oe", "oe_id", "automatisch"} oder {"art": "installation"} + - für die Wirkungsvorschau (wer steigt bei einer neuen Version mit um). Die Distro (backend_id) kommt immer aus der Installation und ändert sich nie. Der Workspace kommt von der nächstgelegenen OE mit Workspace (eigene @@ -1324,12 +1355,16 @@ def geraete_basis(cur, device_id: str): while aktuell is not None and aktuell in oes: parent_id, workspace_id, version_id, automatisch = oes[aktuell] if workspace_id is not None: - if automatisch or version_id is None: + automatisch = bool(automatisch or version_id is None) + if automatisch: version_id = veroeffentlichte_version_id(cur, workspace_id) - return version_id, backend_id + return version_id, backend_id, {"art": "oe", "oe_id": aktuell, "automatisch": automatisch} aktuell = parent_id - return veroeffentlichte_version_id(cur, installations_workspace_id), backend_id + return ( + veroeffentlichte_version_id(cur, installations_workspace_id), backend_id, + {"art": "installation"}, + ) def fetch_auftragskatalog_state(device_id: str): """ @@ -3109,6 +3144,445 @@ def create_workspace_mit_inhalt( return {"id": str(workspace_id), "key": key, "name": name}, None +# --- Workspace-Verwaltung (Workspace-Creator neu, Schritt E, 07.10.2026) --- +# +# Inhalte (Merkmale, Webapps) ändern sich nur noch in einem Entwurf. Die +# veröffentlichte Version ist eingefroren; Veröffentlichen macht den Entwurf +# zur neuen Version, auf die alle Geräte mit "automatisch aktualisieren" +# (bzw. ohne OE-Workspace) umsteigen. Gepinnte OEs bleiben auf ihrer Version. + +def entwurf_id(cur, workspace_id): + cur.execute( + "SELECT id FROM workspace_versionen WHERE workspace_id = %s AND status = 'entwurf'", + (workspace_id,), + ) + row = cur.fetchone() + return row[0] if row else None + + +def _version_kopieren(cur, quelle_version_id, ziel_version_id, ziel_workspace_id, merkmal_filter=None): + """Merkmale und Webapps einer Version in eine andere kopieren (Webapps + mit neuer ID, Icon-Datei geteilt). merkmal_filter: optionale Menge + erlaubter Merkmal-IDs.""" + + cur.execute( + "SELECT merkmal_id FROM workspace_version_merkmale WHERE workspace_version_id = %s", + (quelle_version_id,), + ) + for (merkmal_id,) in cur.fetchall(): + if merkmal_filter is None or merkmal_id in merkmal_filter: + cur.execute( + "INSERT INTO workspace_version_merkmale (workspace_version_id, merkmal_id) VALUES (%s, %s)", + (ziel_version_id, merkmal_id), + ) + 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, kiosk_modus) + SELECT gen_random_uuid(), %s, %s, name, url, icon_filename, icon_content_type, sort_order, + template_id, kategorie_id, kiosk_modus + FROM workspace_webapps WHERE workspace_version_id = %s + """, + (ziel_workspace_id, ziel_version_id, quelle_version_id), + ) + + +def workspace_entwurf_anlegen(workspace_id: str): + """Neuer Entwurf als Kopie der neuesten veröffentlichten Version; gibt es + schon einen, wird er zurückgegeben. None, wenn der Workspace fehlt.""" + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM workspaces WHERE id = %s FOR UPDATE", (workspace_id,)) + if cur.fetchone() is None: + return None + vorhanden = entwurf_id(cur, workspace_id) + if vorhanden: + return str(vorhanden) + cur.execute("SELECT COALESCE(max(nummer), 0) + 1 FROM workspace_versionen WHERE workspace_id = %s", (workspace_id,)) + (nummer,) = cur.fetchone() + neu = uuid4() + cur.execute( + "INSERT INTO workspace_versionen (id, workspace_id, nummer, status) VALUES (%s, %s, %s, 'entwurf')", + (neu, workspace_id, nummer), + ) + quelle = veroeffentlichte_version_id(cur, workspace_id) + if quelle: + _version_kopieren(cur, quelle, neu, workspace_id) + return str(neu) + + +def _icons_aufraeumen(cur, icon_dateien): + """Webapp-Icons löschen, die keine Zeile mehr verwendet (nach dem Löschen + von Webapp-Zeilen).""" + + for datei in {d for d in icon_dateien if d}: + cur.execute( + """ + SELECT 1 FROM workspace_webapps WHERE icon_filename = %s + UNION ALL SELECT 1 FROM gruppen_webapps WHERE icon_filename = %s + UNION ALL SELECT 1 FROM webapp_templates WHERE icon_filename = %s + LIMIT 1 + """, + (datei, datei, datei), + ) + if cur.fetchone() is None: + pfad = WEBAPP_ICONS_DIR / datei + if pfad.is_file(): + pfad.unlink() + + +def workspace_entwurf_verwerfen(workspace_id: str) -> bool: + with get_database_connection() as conn: + with conn.cursor() as cur: + vid = entwurf_id(cur, workspace_id) + if vid is None: + return False + cur.execute("SELECT icon_filename FROM workspace_webapps WHERE workspace_version_id = %s", (vid,)) + icons = [r[0] for r in cur.fetchall()] + cur.execute("DELETE FROM workspace_versionen WHERE id = %s", (vid,)) + _icons_aufraeumen(cur, icons) + return True + + +def workspace_veroeffentlichen(workspace_id: str, notiz: str | None): + """Rückgabe: (nummer, None) oder (None, (fehlercode, nachricht)).""" + + with get_database_connection() as conn: + with conn.cursor() as cur: + vid = entwurf_id(cur, workspace_id) + if vid is None: + return None, ("kein_entwurf", "Es gibt keinen Entwurf zum Veröffentlichen.") + # Gleiche Distro-Regel wie beim Anlegen: nichts veröffentlichen, + # was auf der Distro des Workspace still fehlen würde. + cur.execute( + """ + SELECT m.name FROM workspace_version_merkmale vm + JOIN merkmale m ON m.id = vm.merkmal_id + JOIN workspaces w ON w.id = %s + LEFT JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = w.backend_id + WHERE vm.workspace_version_id = %s AND w.backend_id IS NOT NULL AND bp.id IS NULL + ORDER BY m.name + """, + (workspace_id, vid), + ) + fehlend = [r[0] for r in cur.fetchall()] + if fehlend: + return None, ("distro_konflikt", "Auf der Distro des Workspace nicht umgesetzt: " + ", ".join(fehlend)) + cur.execute( + """ + UPDATE workspace_versionen SET status = 'veroeffentlicht', veroeffentlicht_am = now(), notiz = %s + WHERE id = %s RETURNING nummer + """, + (notiz or None, vid), + ) + (nummer,) = cur.fetchone() + cur.execute("UPDATE workspaces SET updated_at = now() WHERE id = %s", (workspace_id,)) + return nummer, None + + +def workspace_versionen(workspace_id: str) -> list[dict]: + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT v.id, v.nummer, v.status, v.notiz, v.erstellt_am, v.veroeffentlicht_am, + (SELECT count(*) FROM workspace_version_merkmale vm WHERE vm.workspace_version_id = v.id), + (SELECT count(*) FROM workspace_webapps ww WHERE ww.workspace_version_id = v.id) + FROM workspace_versionen v WHERE v.workspace_id = %s ORDER BY v.nummer DESC + """, + (workspace_id,), + ) + return [ + { + "id": str(vid), "nummer": nummer, "status": status, "notiz": notiz, + "erstellt_am": erstellt.isoformat() if erstellt else None, + "veroeffentlicht_am": veroeff.isoformat() if veroeff else None, + "anzahl_merkmale": anz_m, "anzahl_webapps": anz_w, + } + for vid, nummer, status, notiz, erstellt, veroeff, anz_m, anz_w in cur.fetchall() + ] + + +def workspace_nutzung(workspace_id: str) -> dict: + """ + Wer nutzt diesen Workspace: Installationsprofile (künftige + Installationen), OEs (mit Version/Automatik) und Geräte (aktive, nicht + archivierte) - letztere aufgeteilt in "steigen bei neuer Version + automatisch um" und "auf Version N gepinnt". + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT bv.id, bv.label, o.name FROM bereitstellungsvorlagen bv + JOIN organizations o ON o.id = bv.organization_id + WHERE bv.workspace_id = %s ORDER BY o.name, bv.label + """, + (workspace_id,), + ) + profile = [{"id": str(i), "label": l, "organisation": o} for i, l, o in cur.fetchall()] + + cur.execute( + """ + SELECT oe.id, oe.name, o.name, oe.automatisch_aktualisieren, v.nummer + FROM organisationseinheiten oe + JOIN organizations o ON o.id = oe.organization_id + LEFT JOIN workspace_versionen v ON v.id = oe.workspace_version_id + WHERE oe.workspace_id = %s ORDER BY o.name, oe.name + """, + (workspace_id,), + ) + oes = [ + {"id": str(i), "name": n, "organisation": o, "automatisch": a or nr is None, "version": nr} + for i, n, o, a, nr in cur.fetchall() + ] + + cur.execute( + """ + SELECT d.id, o.name FROM devices d + JOIN organizations o ON o.id = d.organization_id + JOIN assignments a ON a.device_id = d.id + WHERE d.archived_at IS NULL AND d.deprovisioned_at IS NULL + """ + ) + geraete = cur.fetchall() + cur.execute("SELECT id, nummer FROM workspace_versionen WHERE workspace_id = %s", (workspace_id,)) + eigene_versionen = {vid: nr for vid, nr in cur.fetchall()} + + automatisch, gepinnt, organisationen = 0, {}, set() + for device_id, org_name in geraete: + basis = geraete_basis_herkunft(cur, str(device_id)) + if basis is None or basis[0] not in eigene_versionen: + continue + organisationen.add(org_name) + if basis[2]["art"] == "installation" or basis[2]["automatisch"]: + automatisch += 1 + else: + nr = eigene_versionen[basis[0]] + gepinnt[nr] = gepinnt.get(nr, 0) + 1 + + return { + "profile": profile, + "oes": oes, + "geraete_automatisch": automatisch, + "geraete_gepinnt": [{"version": nr, "anzahl": n} for nr, n in sorted(gepinnt.items())], + "organisationen": sorted(organisationen | {p["organisation"] for p in profile} | {o["organisation"] for o in oes}), + "in_benutzung": bool(profile or oes or automatisch or gepinnt), + } + + +def workspace_wirkung(workspace_id: str) -> dict | None: + """Wirkungsvorschau für das Veröffentlichen des Entwurfs: Unterschiede + zur neuesten veröffentlichten Version plus Nutzung.""" + + with get_database_connection() as conn: + with conn.cursor() as cur: + neu = entwurf_id(cur, workspace_id) + if neu is None: + return None + alt = veroeffentlichte_version_id(cur, workspace_id) + + def merkmale(vid): + if vid is None: + return {} + cur.execute( + """ + SELECT m.id, m.name, k.art FROM workspace_version_merkmale vm + JOIN merkmale m ON m.id = vm.merkmal_id + LEFT JOIN kategorien k ON k.id = m.kategorie_id + WHERE vm.workspace_version_id = %s + """, + (vid,), + ) + return {mid: (name, art or "app") for mid, name, art in cur.fetchall()} + + def webapps(vid): + if vid is None: + return set() + cur.execute("SELECT name, url FROM workspace_webapps WHERE workspace_version_id = %s", (vid,)) + return set(cur.fetchall()) + + m_alt, m_neu = merkmale(alt), merkmale(neu) + w_alt, w_neu = webapps(alt), webapps(neu) + + def namen(ids, quelle, art): + return sorted(quelle[i][0] for i in ids if quelle[i][1] == art) + + hinzu, weg = set(m_neu) - set(m_alt), set(m_alt) - set(m_neu) + return { + "apps_neu": namen(hinzu, m_neu, "app"), + "apps_weg": namen(weg, m_alt, "app"), + "richtlinien_neu": namen(hinzu, m_neu, "richtlinie"), + "richtlinien_weg": namen(weg, m_alt, "richtlinie"), + "webapps_neu": sorted(n for n, _u in w_neu - w_alt), + "webapps_weg": sorted(n for n, _u in w_alt - w_neu), + "unveraendert": not (hinzu or weg or w_neu != w_alt), + "nutzung": workspace_nutzung(workspace_id), + } + + +def workspace_aktualisieren(workspace_id: str, name: str, description: str | None) -> bool: + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE workspaces SET name = %s, description = %s, updated_at = now() WHERE id = %s RETURNING id", + (name.strip(), description or None, workspace_id), + ) + return cur.fetchone() is not None + + +def workspace_vorlage_setzen( + workspace_id: str, ist_vorlage: bool, sichtbarkeit: str, + kundengruppe_ids: list[str], organization_ids: list[str], +): + """Vorlagen-Kennzeichen und für wen sie sichtbar ist. Rückgabe wie + workspace_veroeffentlichen().""" + + if sichtbarkeit not in ("global", "kundengruppe", "organisation"): + return None, ("invalid_request", "Unbekannte Sichtbarkeit.") + if ist_vorlage and sichtbarkeit == "kundengruppe" and not kundengruppe_ids: + return None, ("invalid_request", "Bitte mindestens eine Kundengruppe wählen.") + if ist_vorlage and sichtbarkeit == "organisation" and not organization_ids: + return None, ("invalid_request", "Bitte mindestens eine Organisation wählen.") + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE workspaces SET ist_vorlage = %s, sichtbarkeit = %s, updated_at = now() WHERE id = %s RETURNING id", + (ist_vorlage, sichtbarkeit, workspace_id), + ) + if cur.fetchone() is None: + return None, ("not_found", "Workspace wurde nicht gefunden.") + cur.execute("DELETE FROM workspace_freigabe_kundengruppen WHERE workspace_id = %s", (workspace_id,)) + cur.execute("DELETE FROM workspace_freigabe_organisationen WHERE workspace_id = %s", (workspace_id,)) + if sichtbarkeit == "kundengruppe": + for kg in dict.fromkeys(kundengruppe_ids): + cur.execute( + "INSERT INTO workspace_freigabe_kundengruppen (workspace_id, kundengruppe_id) VALUES (%s, %s)", + (workspace_id, kg), + ) + if sichtbarkeit == "organisation": + for org in dict.fromkeys(organization_ids): + cur.execute( + "INSERT INTO workspace_freigabe_organisationen (workspace_id, organization_id) VALUES (%s, %s)", + (workspace_id, org), + ) + return True, None + + +def workspace_freigaben(workspace_id: str) -> dict: + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT kundengruppe_id FROM workspace_freigabe_kundengruppen WHERE workspace_id = %s", (workspace_id,)) + kgs = [str(r[0]) for r in cur.fetchall()] + cur.execute("SELECT organization_id FROM workspace_freigabe_organisationen WHERE workspace_id = %s", (workspace_id,)) + orgs = [str(r[0]) for r in cur.fetchall()] + return {"kundengruppe_ids": kgs, "organization_ids": orgs} + + +def fetch_kundengruppen() -> list[dict]: + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT id, key, name FROM kundengruppen ORDER BY sort_order, name") + return [{"id": str(i), "key": k, "name": n} for i, k, n in cur.fetchall()] + + +def workspace_kopie_pruefen(workspace_id: str, backend_id: str) -> list[str] | None: + """Merkmale der neuesten veröffentlichten Version, die auf der Ziel-Distro + nicht umgesetzt sind und bei einer Kopie wegfallen würden.""" + + with get_database_connection() as conn: + with conn.cursor() as cur: + vid = veroeffentlichte_version_id(cur, workspace_id) + if vid is None: + return None + cur.execute( + """ + SELECT m.name FROM workspace_version_merkmale vm + JOIN merkmale m ON m.id = vm.merkmal_id + LEFT JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s + WHERE vm.workspace_version_id = %s AND bp.id IS NULL ORDER BY m.name + """, + (backend_id, vid), + ) + return [r[0] for r in cur.fetchall()] + + +def workspace_kopieren(workspace_id: str, name: str, backend_id: str, organization_id: str | None): + """Kopie der neuesten veröffentlichten Version als neuer Workspace + (Version 1) - auch auf eine andere Distro; Merkmale, die dort fehlen, + fallen weg und werden zurückgemeldet.""" + + name = name.strip() + if not name: + return None, ("invalid_request", "Bitte einen Namen angeben.") + + with get_database_connection() as conn: + with conn.cursor() as cur: + quelle = veroeffentlichte_version_id(cur, workspace_id) + if quelle is None: + return None, ("not_found", "Workspace oder veröffentlichte Version nicht gefunden.") + cur.execute("SELECT description FROM workspaces WHERE id = %s", (workspace_id,)) + (description,) = cur.fetchone() + cur.execute("SELECT 1 FROM backends WHERE id = %s", (backend_id,)) + if cur.fetchone() is None: + return None, ("backend_not_found", "Die gewählte Distro gibt es nicht.") + cur.execute( + """ + SELECT vm.merkmal_id, m.name, bp.id IS NOT NULL FROM workspace_version_merkmale vm + JOIN merkmale m ON m.id = vm.merkmal_id + LEFT JOIN blueprints bp ON bp.merkmal_id = vm.merkmal_id AND bp.backend_id = %s + WHERE vm.workspace_version_id = %s + """, + (backend_id, quelle), + ) + zeilen = cur.fetchall() + erlaubt = {mid for mid, _n, ok in zeilen if ok} + weggefallen = sorted(n for _mid, n, ok in zeilen if not ok) + + neu_ws, neu_v = uuid4(), uuid4() + 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) + """, + (neu_ws, 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', %s, now()) + """, + (neu_v, neu_ws, "Kopie von Workspace " + str(workspace_id)), + ) + _version_kopieren(cur, quelle, neu_v, neu_ws, merkmal_filter=erlaubt) + + return {"id": str(neu_ws), "key": key, "name": name, "weggefallen": weggefallen}, None + + +def workspace_loeschen(workspace_id: str): + """Löscht einen Workspace samt Versionen - nur wenn ihn nichts mehr nutzt + (kein Profil, keine OE, kein Gerät).""" + + nutzung = workspace_nutzung(workspace_id) + if nutzung["in_benutzung"]: + return None, ("in_benutzung", "Der Workspace wird noch verwendet und kann nicht gelöscht werden.") + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT icon_filename FROM workspace_webapps WHERE workspace_id = %s", (workspace_id,)) + icons = [r[0] for r in cur.fetchall()] + cur.execute("DELETE FROM workspaces WHERE id = %s RETURNING id", (workspace_id,)) + if cur.fetchone() is None: + return None, ("not_found", "Workspace wurde nicht gefunden.") + _icons_aufraeumen(cur, icons) + return True, None + + def clone_workspace(source_workspace_id: str, organization_id: str): """ "Diesen Standard übernehmen" (GUI-Umbau Phase 3, Nachtrag 11.09.2026): @@ -5048,6 +5522,139 @@ def clone_workspace_endpoint( return {"success": True, "workspace": neuer_workspace} +@app.get("/api/v1/kundengruppen") +def list_kundengruppen(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, "kundengruppen": fetch_kundengruppen()} + + +@app.put("/api/v1/workspaces/{workspace_id}") +def update_workspace_endpoint( + workspace_id: str, payload: WorkspaceAktualisierenRequest, authorization: str | None = Header(default=None) +): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + if not payload.name.strip(): + return {"success": False, "error": "invalid_request", "message": "Bitte einen Namen angeben."} + if not workspace_aktualisieren(workspace_id, payload.name, payload.description): + return {"success": False, "error": "not_found", "message": "Workspace wurde nicht gefunden."} + return {"success": True} + + +@app.delete("/api/v1/workspaces/{workspace_id}") +def delete_workspace_endpoint(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."} + + _ok, fehler = workspace_loeschen(workspace_id) + if fehler: + return {"success": False, "error": fehler[0], "message": fehler[1]} + return {"success": True} + + +@app.get("/api/v1/workspaces/{workspace_id}/versionen") +def list_workspace_versionen(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, "versionen": workspace_versionen(workspace_id)} + + +@app.post("/api/v1/workspaces/{workspace_id}/entwurf") +def create_workspace_entwurf(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."} + + vid = workspace_entwurf_anlegen(workspace_id) + if vid is None: + return {"success": False, "error": "not_found", "message": "Workspace wurde nicht gefunden."} + return {"success": True, "version_id": vid} + + +@app.delete("/api/v1/workspaces/{workspace_id}/entwurf") +def delete_workspace_entwurf(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."} + + if not workspace_entwurf_verwerfen(workspace_id): + return {"success": False, "error": "kein_entwurf", "message": "Es gibt keinen Entwurf."} + return {"success": True} + + +@app.post("/api/v1/workspaces/{workspace_id}/veroeffentlichen") +def publish_workspace_entwurf( + workspace_id: str, payload: WorkspaceVeroeffentlichenRequest, authorization: str | None = Header(default=None) +): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + nummer, fehler = workspace_veroeffentlichen(workspace_id, payload.notiz) + if fehler: + return {"success": False, "error": fehler[0], "message": fehler[1]} + return {"success": True, "nummer": nummer} + + +@app.get("/api/v1/workspaces/{workspace_id}/nutzung") +def get_workspace_nutzung(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, "nutzung": workspace_nutzung(workspace_id)} + + +@app.get("/api/v1/workspaces/{workspace_id}/wirkung") +def get_workspace_wirkung(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."} + + wirkung = workspace_wirkung(workspace_id) + if wirkung is None: + return {"success": False, "error": "kein_entwurf", "message": "Es gibt keinen Entwurf."} + return {"success": True, "wirkung": wirkung} + + +@app.put("/api/v1/workspaces/{workspace_id}/vorlage") +def set_workspace_vorlage( + workspace_id: str, payload: WorkspaceVorlageRequest, authorization: str | None = Header(default=None) +): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + _ok, fehler = workspace_vorlage_setzen( + workspace_id, payload.ist_vorlage, payload.sichtbarkeit, payload.kundengruppe_ids, payload.organization_ids + ) + if fehler: + return {"success": False, "error": fehler[0], "message": fehler[1]} + return {"success": True} + + +@app.get("/api/v1/workspaces/{workspace_id}/kopie-pruefung") +def check_workspace_kopie(workspace_id: str, backend_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."} + + fehlend = workspace_kopie_pruefen(workspace_id, backend_id) + if fehlend is None: + return {"success": False, "error": "not_found", "message": "Workspace wurde nicht gefunden."} + return {"success": True, "weggefallen": fehlend} + + +@app.post("/api/v1/workspaces/{workspace_id}/kopie") +def copy_workspace_endpoint( + workspace_id: str, payload: WorkspaceKopieRequest, authorization: str | None = Header(default=None) +): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + kopie, fehler = workspace_kopieren(workspace_id, payload.name, payload.backend_id, payload.organization_id) + if fehler: + return {"success": False, "error": fehler[0], "message": fehler[1]} + return {"success": True, "workspace": kopie} + + @app.get("/api/v1/workspaces/{workspace_id}") def get_workspace(workspace_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): @@ -5058,14 +5665,58 @@ def get_workspace(workspace_id: str, authorization: str | None = Header(default= if workspace is None: return {"success": False, "error": "not_found", "message": "Workspace wurde nicht gefunden."} + workspace.update(workspace_freigaben(workspace_id)) return {"success": True, "workspace": workspace} +def _nur_im_entwurf(workspace_id: str, webapp_id: str | None = None, merkmal_id: str | None = None): + """ + Inhaltliche Änderungen gehen seit Schritt E (07.10.2026) nur in einen + Entwurf - die veröffentlichte Version ist eingefroren. Prüft zusätzlich, + dass eine Webapp zu diesem Entwurf gehört und ein neues Merkmal auf der + Distro des Workspace umgesetzt ist. Gibt eine Fehlerantwort oder None. + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + vid = entwurf_id(cur, workspace_id) + if vid is None: + return {"success": False, "error": "kein_entwurf", + "message": "Änderungen gehen nur in einem Entwurf - bitte zuerst einen Entwurf anlegen."} + if webapp_id is not None: + cur.execute( + "SELECT 1 FROM workspace_webapps WHERE id = %s AND workspace_version_id = %s", + (webapp_id, vid), + ) + if cur.fetchone() is None: + return {"success": False, "error": "not_found", "message": "Webapp gehört nicht zum Entwurf."} + if merkmal_id is not None: + cur.execute( + """ + SELECT m.name, w.backend_id IS NULL OR bp.id IS NOT NULL + FROM merkmale m JOIN workspaces w ON w.id = %s + LEFT JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = w.backend_id + WHERE m.id = %s + """, + (workspace_id, merkmal_id), + ) + row = cur.fetchone() + if row is None: + return {"success": False, "error": "not_found", "message": "Merkmal wurde nicht gefunden."} + if not row[1]: + return {"success": False, "error": "distro_konflikt", + "message": f"{row[0]} ist auf der Distro dieses Workspace nicht umgesetzt."} + return None + + @app.post("/api/v1/workspaces/{workspace_id}/merkmale/{merkmal_id}") def add_workspace_merkmal(workspace_id: str, merkmal_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."} + if fehler := _nur_im_entwurf(workspace_id, merkmal_id=merkmal_id): + return fehler + add_merkmal_to_workspace(workspace_id, merkmal_id) return {"success": True} @@ -5076,6 +5727,9 @@ def remove_workspace_merkmal(workspace_id: str, merkmal_id: str, authorization: if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + if fehler := _nur_im_entwurf(workspace_id): + return fehler + remove_merkmal_from_workspace(workspace_id, merkmal_id) return {"success": True} @@ -5094,6 +5748,9 @@ def create_workspace_webapp(workspace_id: str, payload: CreateWebappRequest, aut if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + if fehler := _nur_im_entwurf(workspace_id): + return fehler + return { "success": True, "webapp": create_webapp( @@ -5108,6 +5765,9 @@ def update_workspace_webapp(workspace_id: str, webapp_id: str, payload: UpdateWe if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + if fehler := _nur_im_entwurf(workspace_id, webapp_id=webapp_id): + return fehler + return { "success": True, "webapp": update_webapp( @@ -5122,6 +5782,9 @@ def delete_workspace_webapp(workspace_id: str, webapp_id: str, authorization: st if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + if fehler := _nur_im_entwurf(workspace_id, webapp_id=webapp_id): + return fehler + delete_webapp(webapp_id) return {"success": True} @@ -5174,6 +5837,9 @@ def activate_webapp_template_endpoint(workspace_id: str, template_id: str, autho if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + if fehler := _nur_im_entwurf(workspace_id): + return fehler + webapp = activate_webapp_template(workspace_id, template_id) if webapp is None: