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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 13:34:38 +02:00
1 parent 857461cc44
commit 421e3edc45
1 file changed
+670 -4
+670 -4
View File
@@ -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: