From 4a7ae27dc7d6d1913f8803ab1369dc75b41e03cc Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Wed, 7 Oct 2026 11:54:35 +0200 Subject: [PATCH] Server auf Workspace-Versionen und OE-Workspace umgestellt geraete_basis() ermittelt die Grundausstattung eines Geraets: Distro aus der Installation, Workspace von der naechsten OE mit Workspace (gepinnte Version oder neueste veroeffentlichte), sonst wie bisher aus der Installation. Blueprints, Auftragskatalog, Webapps und Profil-Aufloesung lesen die Version; Gruppen-Webapps ergaenzen. Gruppen tragen keinen Workspace mehr, Exklusivregel und Endpunkte entfallen. Admin-Bearbeitung wirkt bis Schritt E auf die Arbeitsversion. Workspace-Liste/-Detail liefern Distro, Version, Vorlage, Sichtbarkeit; Sichtbarkeit fuer Kunden nach Eigentum, global, Kundengruppe oder Organisation. Lokal gegen eine Kopie der Produktionsdaten geprueft: Soll-Zustand aller 21 Geraete und 12 Profile identisch zum alten Code, 19 Szenarien gruen. Co-Authored-By: Claude Opus 5.5 --- app.py | 621 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 301 insertions(+), 320 deletions(-) diff --git a/app.py b/app.py index 96f92b4..999af51 100644 --- a/app.py +++ b/app.py @@ -219,6 +219,7 @@ class CreateWorkspaceRequest(BaseModel): name: str description: str | None = None organization_id: str | None = None + backend_id: str | None = None class CloneWorkspaceRequest(BaseModel): @@ -327,10 +328,6 @@ class UpdateGruppeRequest(BaseModel): name: str -class SetGruppeWorkspaceRequest(BaseModel): - workspace_id: str - - class SetHostnameKonventionRequest(BaseModel): praefix: str | None = None stellen: int = 4 @@ -936,17 +933,17 @@ WEBAPP_XDG_CATEGORIES_DEFAULT = "Network;WebBrowser;" KIOSK_BROWSER_PRIORITAET = ["google-chrome", "chromium"] -def _resolve_kiosk_browser(workspace_id: str) -> str | None: +def _resolve_kiosk_browser(version_id) -> str | None: with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ SELECT m.key - FROM workspace_merkmale wm - JOIN merkmale m ON m.id = wm.merkmal_id - WHERE wm.workspace_id = %s AND m.key = ANY(%s) + FROM workspace_version_merkmale vm + JOIN merkmale m ON m.id = vm.merkmal_id + WHERE vm.workspace_version_id = %s AND m.key = ANY(%s) """, - (workspace_id, KIOSK_BROWSER_PRIORITAET), + (version_id, KIOSK_BROWSER_PRIORITAET), ) vorhandene = {row[0] for row in cur.fetchall()} @@ -957,37 +954,37 @@ def _resolve_kiosk_browser(workspace_id: str) -> str | None: def fetch_workspace_webapps(device_id: str): + """Webapps eines Geräts: die der Workspace-Version (geraete_basis()) + plus die Ergänzungen aller Gruppen, in denen das Gerät ist.""" + with get_database_connection() as conn: with conn.cursor() as cur: - cur.execute( - """ - SELECT bv.workspace_id - FROM assignments a - JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id - WHERE a.device_id = %s - """, - (device_id,), - ) - assignment_row = cur.fetchone() + basis = geraete_basis(cur, device_id) - if assignment_row is None: + if basis is None: return [] - (workspace_id,) = assignment_row + version_id, _backend_id = basis cur.execute( """ - SELECT w.id, w.name, w.url, w.icon_filename, w.kiosk_modus, k.key + SELECT w.id, w.name, w.url, w.icon_filename, w.kiosk_modus, k.key, w.sort_order FROM workspace_webapps w LEFT JOIN kategorien k ON k.id = w.kategorie_id - WHERE w.workspace_id = %s - ORDER BY w.sort_order, w.name + WHERE w.workspace_version_id = %s + UNION ALL + SELECT gw.id, gw.name, gw.url, gw.icon_filename, gw.kiosk_modus, k.key, gw.sort_order + FROM gruppen_webapps gw + JOIN device_gruppen dg ON dg.gruppe_id = gw.gruppe_id + LEFT JOIN kategorien k ON k.id = gw.kategorie_id + WHERE dg.device_id = %s + ORDER BY 7, 2 """, - (workspace_id,), + (version_id, device_id), ) - zeilen = cur.fetchall() + zeilen = [row[:6] for row in cur.fetchall()] - kiosk_browser = _resolve_kiosk_browser(workspace_id) if any(row[4] for row in zeilen) else None + kiosk_browser = _resolve_kiosk_browser(version_id) if any(row[4] for row in zeilen) else None return [ { @@ -1011,39 +1008,52 @@ def fetch_workspace_webapps(device_id: str): ] -def fetch_assigned_blueprints(device_id: str): +def version_blueprints(cur, version_id, backend_id) -> list[dict]: + """Merkmale einer Workspace-Version, die auf dieser Distro umgesetzt + sind, mit ihrer Ansible-Rolle.""" + + cur.execute( + """ + SELECT m.key, bp.ansible_role + FROM workspace_version_merkmale vm + JOIN merkmale m ON m.id = vm.merkmal_id + JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s + WHERE vm.workspace_version_id = %s + """, + (backend_id, version_id), + ) + return [{"merkmal": key, "ansible_role": ansible_role} for key, ansible_role in cur.fetchall()] + + +def profil_blueprints(template_id: str) -> list[dict] | None: + """Blueprints, mit denen ein Gerät über diese Bereitstellungsvorlage + (Installationsprofil) installiert wird: neueste veröffentlichte Version + des Profil-Workspace auf der Profil-Distro.""" + with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( - """ - SELECT bv.workspace_id, bv.backend_id - FROM assignments a - JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id - WHERE a.device_id = %s - """, - (device_id,), + "SELECT workspace_id, backend_id FROM bereitstellungsvorlagen WHERE id = %s", + (template_id,), ) - assignment_row = cur.fetchone() + row = cur.fetchone() + if row is None: + return None + workspace_id, backend_id = row + return version_blueprints(cur, veroeffentlichte_version_id(cur, workspace_id), backend_id) - if assignment_row is None: + +def fetch_assigned_blueprints(device_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + basis = geraete_basis(cur, device_id) + + if basis is None: return [] - workspace_id, backend_id = assignment_row + version_id, backend_id = basis + return version_blueprints(cur, version_id, backend_id) - cur.execute( - """ - SELECT m.key, bp.ansible_role - FROM workspace_merkmale wm - JOIN merkmale m ON m.id = wm.merkmal_id - JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s - WHERE wm.workspace_id = %s - """, - (backend_id, workspace_id), - ) - return [ - {"merkmal": key, "ansible_role": ansible_role} - for key, ansible_role in cur.fetchall() - ] def _fernet() -> Fernet: if not ENCRYPTION_KEY: @@ -1220,31 +1230,96 @@ def resolve_katalog_overrides(device_id: str) -> dict: return ergebnis -def resolve_effective_workspace_id(cur, device_id: str, vorlage_workspace_id: str) -> str: +def veroeffentlichte_version_id(cur, workspace_id): + """Neueste veröffentlichte Version eines Workspace (None, falls es noch + keine gibt) - das bekommen Geräte, deren OE "künftige Versionen + automatisch übernehmen" hat, und alle Bestandsgeräte ohne OE-Workspace.""" + + cur.execute( + """ + SELECT id FROM workspace_versionen + WHERE workspace_id = %s AND status = 'veroeffentlicht' + ORDER BY nummer DESC LIMIT 1 + """, + (workspace_id,), + ) + row = cur.fetchone() + return row[0] if row else None + + +def arbeitsversion_id(cur, workspace_id): """ - Ermittelt den effektiv geltenden Workspace eines Geräts: normalerweise - der bei der Installation zugewiesene (über die Bereitstellungsvorlage), - außer eine Gerätegruppe des Geräts trägt einen eigenen Workspace - dann - gewinnt dieser (siehe set_gruppe_workspace(), Design-Runde 10.09.2026). - Das Backend/die Distro bleiben davon unberührt, die ändert sich nie - nachträglich - nur welcher Merkmal-Grundbestand gilt. Die - Exklusivitätsregel beim Zuweisen sorgt dafür, dass hier höchstens eine - Zeile zurückkommt. + Die Version, die die Admin-Bearbeitung eines Workspace verändert: der + offene Entwurf, sonst die neueste veröffentlichte. Übergangsregel bis + Schritt E (Entwurf/Veröffentlichen mit Wirkungsvorschau, siehe + platform-docs/roadmap/workspace-creator-neu.md): solange es keinen + Entwurf gibt, wirken Änderungen wie bisher sofort. """ cur.execute( """ - SELECT g.workspace_id - FROM device_gruppen dg - JOIN gruppen g ON g.id = dg.gruppe_id - WHERE dg.device_id = %s AND g.workspace_id IS NOT NULL - LIMIT 1 + SELECT id FROM workspace_versionen + WHERE workspace_id = %s + ORDER BY (status = 'entwurf') DESC, nummer DESC LIMIT 1 + """, + (workspace_id,), + ) + row = cur.fetchone() + return row[0] if row else None + + +def geraete_basis(cur, device_id: str): + """ + Grundausstattung eines Geräts (Workspace-Creator neu, Schritt C): + (workspace_version_id, backend_id) oder None ohne Zuweisung. + + Die Distro (backend_id) kommt immer aus der Installation und ändert sich + nie. Der Workspace kommt von der nächstgelegenen OE mit Workspace (eigene + OE, dann die Eltern bis zur Wurzel) - mit deren gepinnter Version, oder + der neuesten veröffentlichten, wenn dort "automatisch aktualisieren" + gesetzt ist. Trägt keine OE der Kette einen Workspace, gilt wie bisher + der Workspace aus der Installation (neueste veröffentlichte Version). + Gruppen ersetzen die Grundausstattung nicht mehr, sie ergänzen nur + (resolve_katalog_overrides(), gruppen_webapps). + """ + + cur.execute( + """ + SELECT bv.workspace_id, bv.backend_id, d.organization_id, d.oe_id + FROM assignments a + JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id + JOIN devices d ON d.id = a.device_id + WHERE a.device_id = %s """, (device_id,), ) - gruppen_row = cur.fetchone() + row = cur.fetchone() - return gruppen_row[0] if gruppen_row is not None else vorlage_workspace_id + if row is None: + return None + + installations_workspace_id, backend_id, organization_id, oe_id = row + + if oe_id is not None: + cur.execute( + """ + SELECT id, parent_id, workspace_id, workspace_version_id, automatisch_aktualisieren + FROM organisationseinheiten WHERE organization_id = %s + """, + (organization_id,), + ) + oes = {r[0]: r[1:] for r in cur.fetchall()} + + aktuell = oe_id + 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: + version_id = veroeffentlichte_version_id(cur, workspace_id) + return version_id, backend_id + aktuell = parent_id + + return veroeffentlichte_version_id(cur, installations_workspace_id), backend_id def fetch_auftragskatalog_state(device_id: str): """ @@ -1253,9 +1328,8 @@ def fetch_auftragskatalog_state(device_id: str): Konfigurationsgruppen-Plan). Zustandslos aus der aktuellen Auswahl abgeleitet, keine Historie nötig. - Auflösungsreihenfolge: Workspace-Default (ggf. durch eine - workspace-tragende Gerätegruppe ersetzt, siehe - resolve_effective_workspace_id()) → OE-/Gruppen-Merkmal-Zwischenschicht + Auflösungsreihenfolge: Grundausstattung (Workspace-Version, siehe + geraete_basis()) → OE-/Gruppen-Merkmal-Zwischenschicht (resolve_katalog_overrides). Die frühere device_merkmale-Override-Ebene (gewann immer) wurde am 10.09.2026 abgelöst - Einzelgerät-Ausnahmen laufen jetzt über eine Ein-Geräte-Gruppe, also über genau dieselbe @@ -1264,34 +1338,24 @@ def fetch_auftragskatalog_state(device_id: str): with get_database_connection() as conn: with conn.cursor() as cur: - cur.execute( - """ - SELECT bv.workspace_id, bv.backend_id - FROM assignments a - JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id - WHERE a.device_id = %s - """, - (device_id,), - ) - assignment_row = cur.fetchone() + basis = geraete_basis(cur, device_id) - if assignment_row is None: + if basis is None: return [] - vorlage_workspace_id, backend_id = assignment_row - workspace_id = resolve_effective_workspace_id(cur, device_id, vorlage_workspace_id) + version_id, backend_id = basis cur.execute( """ SELECT m.id, m.key, bp.ansible_role, - (wm.merkmal_id IS NOT NULL) AS workspace_default + (vm.merkmal_id IS NOT NULL) AS workspace_default FROM merkmale m JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s - LEFT JOIN workspace_merkmale wm - ON wm.merkmal_id = m.id AND wm.workspace_id = %s + LEFT JOIN workspace_version_merkmale vm + ON vm.merkmal_id = m.id AND vm.workspace_version_id = %s WHERE m.im_auftragskatalog """, - (backend_id, workspace_id), + (backend_id, version_id), ) rows = cur.fetchall() @@ -1323,37 +1387,27 @@ def fetch_auftragskatalog_listing(device_id: str): with get_database_connection() as conn: with conn.cursor() as cur: - cur.execute( - """ - SELECT bv.workspace_id, bv.backend_id - FROM assignments a - JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id - WHERE a.device_id = %s - """, - (device_id,), - ) - assignment_row = cur.fetchone() + basis = geraete_basis(cur, device_id) - if assignment_row is None: + if basis is None: return None - vorlage_workspace_id, backend_id = assignment_row - workspace_id = resolve_effective_workspace_id(cur, device_id, vorlage_workspace_id) + version_id, backend_id = basis cur.execute( """ SELECT m.id, m.key, m.name, m.description, k.key, k.name, k.sort_order, - (wm.merkmal_id IS NOT NULL) AS workspace_default + (vm.merkmal_id IS NOT NULL) AS workspace_default FROM merkmale m JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s LEFT JOIN kategorien k ON k.id = m.kategorie_id - LEFT JOIN workspace_merkmale wm - ON wm.merkmal_id = m.id AND wm.workspace_id = %s + LEFT JOIN workspace_version_merkmale vm + ON vm.merkmal_id = m.id AND vm.workspace_version_id = %s WHERE m.im_auftragskatalog ORDER BY k.sort_order NULLS LAST, m.name """, - (backend_id, workspace_id), + (backend_id, version_id), ) rows = cur.fetchall() @@ -1813,23 +1867,14 @@ def fetch_gruppen(organization_id: str): with conn.cursor() as cur: cur.execute( """ - SELECT g.id, g.name, g.workspace_id, w.name + SELECT g.id, g.name FROM gruppen g - LEFT JOIN workspaces w ON w.id = g.workspace_id WHERE g.organization_id = %s ORDER BY g.name """, (organization_id,), ) - return [ - { - "id": str(gruppe_id), - "name": name, - "workspace_id": str(workspace_id) if workspace_id else None, - "workspace_name": workspace_name, - } - for gruppe_id, name, workspace_id, workspace_name in cur.fetchall() - ] + return [{"id": str(gruppe_id), "name": name} for gruppe_id, name in cur.fetchall()] def create_gruppe(organization_id: str, name: str): gruppe_id = uuid4() @@ -1863,87 +1908,6 @@ def delete_gruppe(gruppe_id: str): cur.execute("DELETE FROM gruppen WHERE id = %s RETURNING id", (gruppe_id,)) return cur.fetchone() is not None -def set_gruppe_workspace(gruppe_id: str, workspace_id: str): - """ - Weist einer Gerätegruppe einen alternativen Workspace zu (Design-Runde - 10.09.2026, siehe project_phase2_app_store_merkmale-Memory) - ersetzt - für Mitgliedsgeräte die komplette Workspace-Baseline (nicht nur - einzelne Merkmale, dafür bleibt gruppen_merkmale zuständig). Ein - Gerät kann gleichzeitig nur in EINER workspace-tragenden Gerätegruppe - sein (bewusste Nutzer-Entscheidung, verhindert widersprüchliche - Workspace-Wahl statt einer Prioritätsregel) - deshalb hier zusätzlich - zur Organisationszugehörigkeit geprüft: kein Mitgliedsgerät der - Gruppe darf schon in einer ANDEREN workspace-tragenden Gruppe sein. - - Rückgabe: (True, None) bei Erfolg, sonst (False, Fehlercode). - """ - - with get_database_connection() as conn: - with conn.cursor() as cur: - cur.execute( - "SELECT organization_id FROM gruppen WHERE id = %s", - (gruppe_id,), - ) - gruppe_row = cur.fetchone() - - if gruppe_row is None: - return False, "gruppe_not_found" - - gruppe_org_id = gruppe_row[0] - - cur.execute( - "SELECT organization_id FROM workspaces WHERE id = %s", - (workspace_id,), - ) - workspace_row = cur.fetchone() - - if workspace_row is None: - return False, "workspace_not_found" - - # NULL organization_id = globaler Standard-Workspace (siehe - # workspaces-Schema), den jede Organisation nutzen darf. Ist er - # gesetzt, muss er zur eigenen Organisation gehören - ein - # fremder, privater Workspace einer anderen Organisation darf - # nicht ausgewählt werden können. - workspace_org_id = workspace_row[0] - if workspace_org_id is not None and workspace_org_id != gruppe_org_id: - return False, "workspace_wrong_organization" - - cur.execute( - """ - SELECT DISTINCT d.id, d.hostname, g2.name - FROM device_gruppen dg - JOIN devices d ON d.id = dg.device_id - JOIN gruppen g2 ON g2.id = dg.gruppe_id - WHERE dg.device_id IN ( - SELECT device_id FROM device_gruppen WHERE gruppe_id = %s - ) - AND g2.id != %s - AND g2.workspace_id IS NOT NULL - """, - (gruppe_id, gruppe_id), - ) - konflikt_row = cur.fetchone() - - if konflikt_row is not None: - return False, "workspace_conflict" - - cur.execute( - "UPDATE gruppen SET workspace_id = %s WHERE id = %s", - (workspace_id, gruppe_id), - ) - - return True, None - -def clear_gruppe_workspace(gruppe_id: str): - with get_database_connection() as conn: - with conn.cursor() as cur: - cur.execute( - "UPDATE gruppen SET workspace_id = NULL WHERE id = %s RETURNING id", - (gruppe_id,), - ) - return cur.fetchone() is not None - def set_gruppe_auswahl(gruppe_id: str, merkmal_key: str, aktiv: bool, optionen: dict): with get_database_connection() as conn: with conn.cursor() as cur: @@ -2034,17 +1998,16 @@ def fetch_gruppe_merkmale_listing(gruppe_id: str): def add_device_to_gruppe(device_id: str, gruppe_id: str): """ Fügt ein Gerät einer Gruppe hinzu (idempotent). Gibt False zurück, wenn - Gerät und Gruppe nicht zur selben Organisation gehören, oder "conflict", - wenn die Zielgruppe einen Workspace trägt und das Gerät bereits in einer - ANDEREN workspace-tragenden Gruppe ist (siehe set_gruppe_workspace() für - die Begründung der Exklusivitätsregel). + Gerät und Gruppe nicht zur selben Organisation gehören. Seit 07.10.2026 + tragen Gruppen keinen eigenen Workspace mehr (nur noch Ergänzungen), + die frühere Exklusivitätsregel entfällt damit. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ - SELECT d.organization_id, g.organization_id, g.workspace_id + SELECT d.organization_id, g.organization_id FROM devices d, gruppen g WHERE d.id = %s AND g.id = %s """, @@ -2055,21 +2018,6 @@ def add_device_to_gruppe(device_id: str, gruppe_id: str): if row is None or row[0] != row[1]: return False - ziel_workspace_id = row[2] - - if ziel_workspace_id is not None: - cur.execute( - """ - SELECT 1 - FROM device_gruppen dg - JOIN gruppen g ON g.id = dg.gruppe_id - WHERE dg.device_id = %s AND g.workspace_id IS NOT NULL AND g.id != %s - """, - (device_id, gruppe_id), - ) - if cur.fetchone() is not None: - return "conflict" - cur.execute( """ INSERT INTO device_gruppen (device_id, gruppe_id) @@ -2900,9 +2848,15 @@ def fetch_workspaces(): with conn.cursor() as cur: cur.execute( """ - SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name + SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name, + w.backend_id, b.name, b.version, w.ist_vorlage, w.sichtbarkeit, + (SELECT max(nummer) FROM workspace_versionen v + WHERE v.workspace_id = w.id AND v.status = 'veroeffentlicht'), + EXISTS (SELECT 1 FROM workspace_versionen v + WHERE v.workspace_id = w.id AND v.status = 'entwurf') FROM workspaces w LEFT JOIN organizations o ON o.id = w.organization_id + LEFT JOIN backends b ON b.id = w.backend_id ORDER BY o.name NULLS FIRST, w.name """ ) @@ -2914,30 +2868,49 @@ def fetch_workspaces(): "description": description, "organization_id": str(organization_id) if organization_id else None, "organization_name": organization_name, + "backend": ( + {"id": str(backend_id), "name": backend_name, "version": backend_version} + if backend_id else None + ), + "ist_vorlage": ist_vorlage, + "sichtbarkeit": sichtbarkeit, + "version": version, + "hat_entwurf": hat_entwurf, } for ( wid, key, name, description, organization_id, organization_name, + backend_id, backend_name, backend_version, ist_vorlage, sichtbarkeit, + version, hat_entwurf, ) in cur.fetchall() ] def fetch_available_workspaces_for_organization(organization_id: str): """ - Workspaces, die eine Organisation für ihre Gerätegruppen wählen darf: - globale Standard-Workspaces (organization_id IS NULL, von uns - gepflegt) plus die eigenen. Bewusst NICHT fetch_workspaces() - die - liefert workspaces aller Organisationen (nur für die Admin-Techniker- - Ansicht gedacht), das würde im Kundenportal fremde, private Workspace- - Namen preisgeben. + Workspaces, die eine Organisation sehen und verwenden darf: die eigenen + plus die für sie freigegebenen Vorlagen - global, für ihre Kundengruppe + oder ausdrücklich für sie (Workspace-Creator neu, 07.10.2026). Bewusst + NICHT fetch_workspaces() - die liefert Workspaces aller Organisationen + (nur für die Admin-Ansicht), das würde im Kundenportal fremde, private + Workspace-Namen preisgeben. """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ - SELECT id, key, name, description, organization_id - FROM workspaces - WHERE organization_id IS NULL OR organization_id = %s - ORDER BY (organization_id IS NULL) DESC, name + SELECT w.id, w.key, w.name, w.description, w.organization_id + FROM workspaces w + JOIN organizations o ON o.id = %s + WHERE w.organization_id = o.id + OR (w.ist_vorlage AND ( + w.sichtbarkeit = 'global' + OR (w.sichtbarkeit = 'kundengruppe' AND EXISTS ( + SELECT 1 FROM workspace_freigabe_kundengruppen f + WHERE f.workspace_id = w.id AND f.kundengruppe_id = o.kundengruppe_id)) + OR (w.sichtbarkeit = 'organisation' AND EXISTS ( + SELECT 1 FROM workspace_freigabe_organisationen f + WHERE f.workspace_id = w.id AND f.organization_id = o.id)))) + ORDER BY (w.organization_id IS NULL) DESC, w.name """, (organization_id,), ) @@ -2956,17 +2929,35 @@ def fetch_available_workspaces_for_organization(organization_id: str): for wid, key, name, description, ws_org_id in cur.fetchall() ] -def create_workspace(key: str, name: str, description: str | None, organization_id: str | None): +def create_workspace( + key: str, name: str, description: str | None, organization_id: str | None, + backend_id: str | None = None, +): + """Legt einen Workspace samt Version 1 an. Bis Schritt E (Entwurf/ + Veröffentlichen) ist Version 1 sofort veröffentlicht und wird direkt + bearbeitet (siehe arbeitsversion_id()).""" + workspace_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ - INSERT INTO workspaces (id, organization_id, key, name, description) - VALUES (%s, %s, %s, %s, %s) + INSERT INTO workspaces (id, organization_id, key, name, description, backend_id, sichtbarkeit, ist_vorlage) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """, - (workspace_id, organization_id, key, name, description), + ( + workspace_id, organization_id, key, name, description, backend_id, + "global" if organization_id is None else "organisation", + organization_id is None, + ), + ) + cur.execute( + """ + INSERT INTO workspace_versionen (id, workspace_id, nummer, status, veroeffentlicht_am) + VALUES (%s, %s, 1, 'veroeffentlicht', now()) + """, + (uuid4(), workspace_id), ) return {"id": str(workspace_id), "key": key, "name": name} @@ -3008,7 +2999,8 @@ def clone_workspace(source_workspace_id: str, organization_id: str): zaehler += 1 neuer_workspace = create_workspace( - neuer_key, f"{quelle['name']} (eigene Kopie)", quelle["description"], organization_id + neuer_key, f"{quelle['name']} (eigene Kopie)", quelle["description"], organization_id, + quelle["backend"]["id"] if quelle["backend"] else None, ) for merkmal in quelle["merkmale"]: @@ -3021,9 +3013,11 @@ def fetch_workspace_detail(workspace_id: str): with conn.cursor() as cur: cur.execute( """ - SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name + SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name, + w.backend_id, b.name, b.version, w.ist_vorlage, w.sichtbarkeit FROM workspaces w LEFT JOIN organizations o ON o.id = w.organization_id + LEFT JOIN backends b ON b.id = w.backend_id WHERE w.id = %s """, (workspace_id,), @@ -3033,15 +3027,22 @@ def fetch_workspace_detail(workspace_id: str): if row is None: return None + version_id = arbeitsversion_id(cur, workspace_id) + cur.execute( + "SELECT nummer, status FROM workspace_versionen WHERE id = %s", + (version_id,), + ) + version_row = cur.fetchone() + cur.execute( """ SELECT m.id, m.key, m.name - FROM workspace_merkmale wm - JOIN merkmale m ON m.id = wm.merkmal_id - WHERE wm.workspace_id = %s + FROM workspace_version_merkmale vm + JOIN merkmale m ON m.id = vm.merkmal_id + WHERE vm.workspace_version_id = %s ORDER BY m.key """, - (workspace_id,), + (version_id,), ) merkmale = [ {"id": str(mid), "key": key, "name": name} @@ -3055,6 +3056,13 @@ def fetch_workspace_detail(workspace_id: str): "description": row[3], "organization_id": str(row[4]) if row[4] else None, "organization_name": row[5], + "backend": {"id": str(row[6]), "name": row[7], "version": row[8]} if row[6] else None, + "ist_vorlage": row[9], + "sichtbarkeit": row[10], + "version": ( + {"id": str(version_id), "nummer": version_row[0], "status": version_row[1]} + if version_row else None + ), "merkmale": merkmale, } @@ -3063,11 +3071,11 @@ def add_merkmal_to_workspace(workspace_id: str, merkmal_id: str): with conn.cursor() as cur: cur.execute( """ - INSERT INTO workspace_merkmale (workspace_id, merkmal_id) + INSERT INTO workspace_version_merkmale (workspace_version_id, merkmal_id) VALUES (%s, %s) ON CONFLICT DO NOTHING """, - (workspace_id, merkmal_id), + (arbeitsversion_id(cur, workspace_id), merkmal_id), ) def _write_webapp_icon(webapp_id, icon_bytes: bytes, content_type: str) -> str: @@ -3147,10 +3155,10 @@ def fetch_webapps_for_workspace(workspace_id: str): w.kategorie_id, k.name, w.kiosk_modus FROM workspace_webapps w LEFT JOIN kategorien k ON k.id = w.kategorie_id - WHERE w.workspace_id = %s + WHERE w.workspace_version_id = %s ORDER BY w.sort_order, w.name """, - (workspace_id,), + (arbeitsversion_id(cur, workspace_id),), ) return [ { @@ -3181,10 +3189,14 @@ def create_webapp( cur.execute( """ INSERT INTO workspace_webapps - (id, workspace_id, name, url, icon_filename, icon_content_type, sort_order, kategorie_id, kiosk_modus) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + (id, workspace_id, workspace_version_id, name, url, icon_filename, icon_content_type, + sort_order, kategorie_id, kiosk_modus) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, - (webapp_id, workspace_id, name, url, icon_filename, icon_content_type, sort_order, kategorie_id, kiosk_modus), + ( + webapp_id, workspace_id, arbeitsversion_id(cur, workspace_id), name, url, + icon_filename, icon_content_type, sort_order, kategorie_id, kiosk_modus, + ), ) return {"id": str(webapp_id), "name": name, "url": url, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None} @@ -3231,7 +3243,23 @@ def delete_webapp(webapp_id: str): row = cur.fetchone() cur.execute("DELETE FROM workspace_webapps WHERE id = %s", (webapp_id,)) - if row and row[0]: + # Seit den Workspace-Versionen (07.10.2026) teilen sich Kopien + # derselben Webapp in mehreren Versionen eine Icon-Datei - nur + # löschen, wenn sie niemand mehr verwendet. + icon_noch_verwendet = False + if row and row[0]: + cur.execute( + """ + SELECT 1 FROM workspace_webapps WHERE icon_filename = %s + UNION ALL + SELECT 1 FROM gruppen_webapps WHERE icon_filename = %s + LIMIT 1 + """, + (row[0], row[0]), + ) + icon_noch_verwendet = cur.fetchone() is not None + + if row and row[0] and not icon_noch_verwendet: icon_path = WEBAPP_ICONS_DIR / row[0] if icon_path.is_file(): icon_path.unlink() @@ -3280,11 +3308,11 @@ def fetch_available_templates_for_workspace(workspace_id: str): FROM webapp_templates t WHERE NOT EXISTS ( SELECT 1 FROM workspace_webapps w - WHERE w.workspace_id = %s AND w.template_id = t.id + WHERE w.workspace_version_id = %s AND w.template_id = t.id ) ORDER BY t.name """, - (workspace_id,), + (arbeitsversion_id(cur, workspace_id),), ) return [ { @@ -3371,10 +3399,11 @@ def activate_webapp_template(workspace_id: str, template_id: str): return None name, url, icon_filename, icon_content_type, kategorie_id = template + version_id = arbeitsversion_id(cur, workspace_id) cur.execute( - "SELECT 1 FROM workspace_webapps WHERE workspace_id = %s AND template_id = %s", - (workspace_id, template_id), + "SELECT 1 FROM workspace_webapps WHERE workspace_version_id = %s AND template_id = %s", + (version_id, template_id), ) if cur.fetchone() is not None: return None @@ -3398,10 +3427,15 @@ def activate_webapp_template(workspace_id: str, template_id: str): cur.execute( """ - INSERT INTO workspace_webapps (id, workspace_id, name, url, icon_filename, icon_content_type, template_id, kategorie_id) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + INSERT INTO workspace_webapps + (id, workspace_id, workspace_version_id, name, url, icon_filename, icon_content_type, + template_id, kategorie_id) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) """, - (webapp_id, workspace_id, name, url, copied_icon_filename, icon_content_type, template_id, kategorie_id), + ( + webapp_id, workspace_id, version_id, name, url, copied_icon_filename, + icon_content_type, template_id, kategorie_id, + ), ) return {"id": str(webapp_id), "name": name, "url": url, "icon_path": f"/webapp-icons/{copied_icon_filename}" if copied_icon_filename else None} @@ -3411,8 +3445,8 @@ def remove_merkmal_from_workspace(workspace_id: str, merkmal_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( - "DELETE FROM workspace_merkmale WHERE workspace_id = %s AND merkmal_id = %s", - (workspace_id, merkmal_id), + "DELETE FROM workspace_version_merkmale WHERE workspace_version_id = %s AND merkmal_id = %s", + (arbeitsversion_id(cur, workspace_id), merkmal_id), ) def fetch_backends(): @@ -4092,20 +4126,7 @@ def resolve_template(template_id: str, payload: ResolveRequest): disk_encryption, partitioning, secure_boot_required, ) = template_row - cur.execute( - """ - SELECT m.key, bp.ansible_role - FROM workspace_merkmale wm - JOIN merkmale m ON m.id = wm.merkmal_id - JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s - WHERE wm.workspace_id = %s - """, - (backend_id, workspace_id), - ) - blueprints = [ - {"merkmal": key, "ansible_role": ansible_role} - for key, ansible_role in cur.fetchall() - ] + blueprints = version_blueprints(cur, veroeffentlichte_version_id(cur, workspace_id), backend_id) cur.execute( """ @@ -4171,8 +4192,13 @@ def get_webapp_icon(filename: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( - "SELECT icon_content_type FROM workspace_webapps WHERE icon_filename = %s LIMIT 1", - (safe_name,), + """ + SELECT icon_content_type FROM workspace_webapps WHERE icon_filename = %s + UNION ALL + SELECT icon_content_type FROM gruppen_webapps WHERE icon_filename = %s + LIMIT 1 + """, + (safe_name, safe_name), ) row = cur.fetchone() media_type = row[0] if row and row[0] else "application/octet-stream" @@ -4837,7 +4863,9 @@ def create_workspace_endpoint(payload: CreateWorkspaceRequest, authorization: st return { "success": True, - "workspace": create_workspace(payload.key, payload.name, payload.description, payload.organization_id), + "workspace": create_workspace( + payload.key, payload.name, payload.description, payload.organization_id, payload.backend_id + ), } @@ -5238,7 +5266,7 @@ def get_device_auftragskatalog( # Direkte Geräte-Einzel-Auswahl (select/deselect) wurde am 10.09.2026 # abgelöst - device_merkmale ist weg, Einzelgerät-Ausnahmen laufen jetzt -# über eine Ein-Geräte-Gruppe (siehe set_gruppe_workspace() und +# über eine Ein-Geräte-Gruppe (siehe # gruppen_merkmale-Endpunkte oben). get_device_auftragskatalog() bleibt als # reine Ist-Zustand-Anzeige bestehen. @@ -5459,45 +5487,6 @@ def list_available_workspaces(organization_id: str, authorization: str | None = return {"success": True, "workspaces": fetch_available_workspaces_for_organization(organization_id)} -@app.post("/api/v1/gruppen/{gruppe_id}/workspace") -def set_gruppe_workspace_endpoint( - gruppe_id: str, - payload: SetGruppeWorkspaceRequest, - authorization: str | None = Header(default=None), -): - if not require_service_token(authorization): - return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} - - erfolg, fehlercode = set_gruppe_workspace(gruppe_id, payload.workspace_id) - - if not erfolg: - nachrichten = { - "gruppe_not_found": "Die Gruppe wurde nicht gefunden.", - "workspace_not_found": "Der Workspace wurde nicht gefunden.", - "workspace_wrong_organization": "Dieser Workspace gehört einer anderen Organisation.", - "workspace_conflict": "Mindestens ein Mitgliedsgerät ist bereits in einer anderen " - "workspace-tragenden Gerätegruppe.", - } - return { - "success": False, - "error": fehlercode, - "message": nachrichten.get(fehlercode, "Workspace konnte nicht zugewiesen werden."), - } - - return {"success": True} - - -@app.delete("/api/v1/gruppen/{gruppe_id}/workspace") -def clear_gruppe_workspace_endpoint(gruppe_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 clear_gruppe_workspace(gruppe_id): - return {"success": False, "error": "gruppe_not_found", "message": "Die Gruppe wurde nicht gefunden."} - - return {"success": True} - - @app.get("/api/v1/gruppen/{gruppe_id}/merkmale") def get_gruppe_merkmale(gruppe_id: str, authorization: str | None = Header(default=None)): if not require_service_token(authorization): @@ -5521,14 +5510,6 @@ def add_device_gruppe_endpoint(device_id: str, gruppe_id: str, authorization: st ergebnis = add_device_to_gruppe(device_id, gruppe_id) - if ergebnis == "conflict": - return { - "success": False, - "error": "workspace_conflict", - "message": "Das Gerät ist bereits in einer anderen Gerätegruppe mit eigenem Workspace. " - "Ein Gerät kann nur in einer workspace-tragenden Gerätegruppe gleichzeitig sein.", - } - if not ergebnis: return { "success": False,