From 10d31f8cf32d67b3f2e77803df1c50bb25ee1bc5 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Thu, 10 Sep 2026 10:43:34 +0200 Subject: [PATCH] =?UTF-8?q?Ger=C3=A4tegruppen=20mit=20alternativem=20Works?= =?UTF-8?q?pace,=20device=5Fmerkmale=20abgel=C3=B6st?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 0038: gruppen.workspace_id (nullable FK), device_merkmale Tabelle entfernt (keine Datenzeilen auf anode, geprüft vor Drop). - set_gruppe_workspace()/clear_gruppe_workspace(): weist einer Gerätegruppe einen alternativen Workspace zu, ersetzt die komplette Baseline für Mitgliedsgeräte statt nur einzelner Merkmale. Prüft Organisationszugehörigkeit des Workspace (global oder eigene Org) und die Exklusivitätsregel (ein Gerät nur in einer workspace-tragenden Gruppe gleichzeitig) - sowohl beim Zuweisen als auch beim Hinzufügen eines Geräts zu einer bereits workspace-tragenden Gruppe. - resolve_effective_workspace_id(): neue Auflösungsstufe zwischen Bereitstellungsvorlage und OE-/Gruppen-Merkmal-Overrides. - fetch_auftragskatalog_state()/_listing(): device_merkmale-Override-Ebene entfernt (gewann bisher immer), select_auftrag/deselect_auftrag- Endpunkte + set_auftrag_selection() entfernt. get_device_auftragskatalog bleibt als reine Ist-Zustand-Anzeige. - Neuer Endpunkt GET /api/v1/organizations/{id}/workspaces (global + eigene, org-gefiltert - fetch_workspaces() bleibt admin-only, würde sonst fremde private Workspaces preisgeben). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VLk93dhDMrkPXefVdknMV9 --- app.py | 392 ++++++++++++------ .../0037_terminal_netzwerkscanner_sperre.sql | 22 + migrations/0038_geraetegruppen_workspace.sql | 35 ++ 3 files changed, 312 insertions(+), 137 deletions(-) create mode 100644 migrations/0037_terminal_netzwerkscanner_sperre.sql create mode 100644 migrations/0038_geraetegruppen_workspace.sql diff --git a/app.py b/app.py index 73f4411..8d8c8d4 100644 --- a/app.py +++ b/app.py @@ -283,6 +283,10 @@ class UpdateGruppeRequest(BaseModel): name: str +class SetGruppeWorkspaceRequest(BaseModel): + workspace_id: str + + def write_log(entry: dict) -> None: with LOG_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") @@ -1043,6 +1047,32 @@ def resolve_katalog_overrides(device_id: str) -> dict: return ergebnis +def resolve_effective_workspace_id(cur, device_id: str, vorlage_workspace_id: str) -> str: + """ + 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. + """ + + 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 + """, + (device_id,), + ) + gruppen_row = cur.fetchone() + + return gruppen_row[0] if gruppen_row is not None else vorlage_workspace_id + def fetch_auftragskatalog_state(device_id: str): """ Voller Soll-Zustand (present/absent) aller katalogfähigen Merkmale für @@ -1050,9 +1080,13 @@ def fetch_auftragskatalog_state(device_id: str): Konfigurationsgruppen-Plan). Zustandslos aus der aktuellen Auswahl abgeleitet, keine Historie nötig. - Auflösungsreihenfolge: Workspace-Default → OE-/Gruppen-Zwischenschicht - (resolve_katalog_overrides) → device_merkmale-Override, der gewinnt - immer, unabhängig davon was OE-Kette oder Gruppen sagen. + Auflösungsreihenfolge: Workspace-Default (ggf. durch eine + workspace-tragende Gerätegruppe ersetzt, siehe + resolve_effective_workspace_id()) → 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 + OE-/Gruppen-Zwischenschicht wie jede andere Gruppe auch. """ with get_database_connection() as conn: @@ -1071,38 +1105,28 @@ def fetch_auftragskatalog_state(device_id: str): if assignment_row is None: return [] - workspace_id, backend_id = assignment_row + vorlage_workspace_id, backend_id = assignment_row + workspace_id = resolve_effective_workspace_id(cur, device_id, vorlage_workspace_id) cur.execute( """ SELECT m.id, m.key, bp.ansible_role, - dm.aktiv, - (wm.merkmal_id IS NOT NULL) AS workspace_default, - COALESCE(dm.optionen, '{}'::jsonb) + (wm.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 device_merkmale dm - ON dm.merkmal_id = m.id AND dm.device_id = %s LEFT JOIN workspace_merkmale wm ON wm.merkmal_id = m.id AND wm.workspace_id = %s WHERE m.im_auftragskatalog """, - (backend_id, device_id, workspace_id), + (backend_id, workspace_id), ) rows = cur.fetchall() overrides = resolve_katalog_overrides(device_id) ergebnis = [] - for merkmal_id, key, ansible_role, device_override, workspace_default, device_optionen in rows: - # optionen folgen derselben Rangfolge wie aktiv - device_merkmale - # gewinnt, sonst die OE-/Gruppen-Quelle, die die aktiv-Entscheidung - # getroffen hat, sonst leer (kein Optionen-Konzept auf reiner - # Workspace-Default-Ebene, siehe workspace_merkmale-Schema). - if device_override is not None: - aktiv = device_override - optionen = device_optionen - elif merkmal_id in overrides: + for merkmal_id, key, ansible_role, workspace_default in rows: + if merkmal_id in overrides: aktiv, _quelle, _quelle_id, optionen = overrides[merkmal_id] else: aktiv = workspace_default @@ -1140,26 +1164,23 @@ def fetch_auftragskatalog_listing(device_id: str): if assignment_row is None: return None - workspace_id, backend_id = assignment_row + vorlage_workspace_id, backend_id = assignment_row + workspace_id = resolve_effective_workspace_id(cur, device_id, vorlage_workspace_id) cur.execute( """ SELECT m.id, m.key, m.name, m.description, k.key, k.name, k.sort_order, - dm.aktiv, - (wm.merkmal_id IS NOT NULL) AS workspace_default, - COALESCE(dm.optionen, '{}'::jsonb) + (wm.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 device_merkmale dm - ON dm.merkmal_id = m.id AND dm.device_id = %s LEFT JOIN workspace_merkmale wm ON wm.merkmal_id = m.id AND wm.workspace_id = %s WHERE m.im_auftragskatalog ORDER BY k.sort_order NULLS LAST, m.name """, - (backend_id, device_id, workspace_id), + (backend_id, workspace_id), ) rows = cur.fetchall() @@ -1169,11 +1190,9 @@ def fetch_auftragskatalog_listing(device_id: str): for ( merkmal_id, key, name, description, kat_key, kat_name, sort_order, - device_override, workspace_default, device_optionen, + workspace_default, ) in rows: - if device_override is not None: - aktiv, quelle, quelle_id, optionen = device_override, "geraet", None, device_optionen - elif merkmal_id in overrides: + if merkmal_id in overrides: aktiv, quelle, quelle_id, optionen = overrides[merkmal_id] else: aktiv, quelle, quelle_id, optionen = workspace_default, "workspace", None, {} @@ -1200,62 +1219,6 @@ def fetch_auftragskatalog_listing(device_id: str): return ergebnis -def set_auftrag_selection(device_id: str, merkmal_key: str, aktiv: bool, optionen: dict): - """ - Setzt die geräteweise Auswahl eines katalogfähigen Merkmals (Upsert in - device_merkmale) und protokolliert die Änderung als selected/deselected- - Event. Gibt False zurück, wenn kein katalogfähiges Merkmal mit diesem Key - existiert (unbekannter Key oder im_auftragskatalog = false). - """ - - with get_database_connection() as conn: - with conn.cursor() as cur: - cur.execute( - "SELECT id FROM merkmale WHERE key = %s AND im_auftragskatalog", - (merkmal_key,), - ) - merkmal_row = cur.fetchone() - - if merkmal_row is None: - return False - - merkmal_id = merkmal_row[0] - - # Bestehende optionen holen und mit den neu eingegebenen - # zusammenführen (siehe merge_optionen()) - sonst würde ein leer - # gelassenes Passwort-Feld im Formular ein zuvor gesetztes - # Passwort löschen, statt es unverändert zu lassen. - cur.execute( - "SELECT optionen FROM device_merkmale WHERE device_id = %s AND merkmal_id = %s", - (device_id, merkmal_id), - ) - bestehend_row = cur.fetchone() - optionen = encrypt_sensitive_optionen( - merge_optionen(bestehend_row[0] if bestehend_row else {}, optionen) - ) - - cur.execute( - """ - INSERT INTO device_merkmale (id, device_id, merkmal_id, aktiv, optionen) - VALUES (%s, %s, %s, %s, %s) - ON CONFLICT (device_id, merkmal_id) DO UPDATE - SET aktiv = EXCLUDED.aktiv, - optionen = EXCLUDED.optionen, - updated_at = CURRENT_TIMESTAMP - """, - (uuid4(), device_id, merkmal_id, aktiv, Jsonb(optionen)), - ) - - cur.execute( - """ - INSERT INTO device_merkmal_events (id, device_id, merkmal_id, event_type) - VALUES (%s, %s, %s, %s) - """, - (uuid4(), device_id, merkmal_id, "selected" if aktiv else "deselected"), - ) - - return True - def fetch_organisationseinheiten(organization_id: str): """ Flache Liste aller OEs einer Organisation (inkl. parent_id) - der Baum @@ -1532,10 +1495,24 @@ def fetch_gruppen(organization_id: str): with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( - "SELECT id, name FROM gruppen WHERE organization_id = %s ORDER BY name", + """ + SELECT g.id, g.name, g.workspace_id, w.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} for gruppe_id, name in cur.fetchall()] + 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() + ] def create_gruppe(organization_id: str, name: str): gruppe_id = uuid4() @@ -1569,6 +1546,87 @@ 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: @@ -1659,14 +1717,17 @@ 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. + 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). """ with get_database_connection() as conn: with conn.cursor() as cur: cur.execute( """ - SELECT d.organization_id, g.organization_id + SELECT d.organization_id, g.organization_id, g.workspace_id FROM devices d, gruppen g WHERE d.id = %s AND g.id = %s """, @@ -1677,6 +1738,21 @@ 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) @@ -2322,6 +2398,32 @@ def fetch_workspaces(): ) 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. + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, key, name, description + FROM workspaces + WHERE organization_id IS NULL OR organization_id = %s + ORDER BY (organization_id IS NULL) DESC, name + """, + (organization_id,), + ) + return [ + {"id": str(wid), "key": key, "name": name, "description": description} + for wid, key, name, description in cur.fetchall() + ] + def create_workspace(key: str, name: str, description: str | None, organization_id: str | None): workspace_id = uuid4() @@ -4199,52 +4301,11 @@ def get_device_auftragskatalog( return {"success": True, "auftragskatalog": katalog} -@app.post("/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/select") -def select_auftrag( - device_id: str, - merkmal_key: str, - payload: AuftragSelectRequest, - authorization: str | None = Header(default=None), -): - if not require_service_token(authorization): - return { - "success": False, - "error": "unauthorized", - "message": "Fehlendes oder ungültiges Admin-Token.", - } - - if not set_auftrag_selection(device_id, merkmal_key, True, payload.optionen): - return { - "success": False, - "error": "unknown_merkmal", - "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}", - } - - return {"success": True} - - -@app.post("/api/v1/devices/{device_id}/auftragskatalog/{merkmal_key}/deselect") -def deselect_auftrag( - device_id: str, - merkmal_key: str, - authorization: str | None = Header(default=None), -): - if not require_service_token(authorization): - return { - "success": False, - "error": "unauthorized", - "message": "Fehlendes oder ungültiges Admin-Token.", - } - - if not set_auftrag_selection(device_id, merkmal_key, False, {}): - return { - "success": False, - "error": "unknown_merkmal", - "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}", - } - - return {"success": True} - +# 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 +# gruppen_merkmale-Endpunkte oben). get_device_auftragskatalog() bleibt als +# reine Ist-Zustand-Anzeige bestehen. # --- Organisationseinheiten (OEs) --- @@ -4455,6 +4516,53 @@ def unset_gruppe_merkmal(gruppe_id: str, merkmal_key: str, authorization: str | return {"success": True} +@app.get("/api/v1/organizations/{organization_id}/workspaces") +def list_available_workspaces(organization_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, "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): @@ -4476,7 +4584,17 @@ def add_device_gruppe_endpoint(device_id: str, gruppe_id: str, authorization: st if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} - if not add_device_to_gruppe(device_id, gruppe_id): + 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, "error": "invalid_membership", diff --git a/migrations/0037_terminal_netzwerkscanner_sperre.sql b/migrations/0037_terminal_netzwerkscanner_sperre.sql new file mode 100644 index 0000000..b10cd5e --- /dev/null +++ b/migrations/0037_terminal_netzwerkscanner_sperre.sql @@ -0,0 +1,22 @@ +BEGIN; + +-- Letzte zwei offene Punkte aus der ursprünglichen Härtung-Liste +-- (Roadmap-Update 10.09.2026): "Terminal nur bei Bedarf" (weiche +-- Variante - nur Menüeintrag ausblenden) und "keine Netzwerkscanner" +-- (schlankes Cleanup, da ADR-0026 Selbstinstallation ohnehin verhindert). + +INSERT INTO merkmale (id, key, name, description, kategorie_id, im_auftragskatalog) VALUES + (gen_random_uuid(), 'terminal-sperre', 'Terminal ausblenden', + 'Blendet Terminal-Emulatoren aus dem Anwendungsmenü aus (Menüeintrag, keine Ausführungssperre).', + (SELECT id FROM kategorien WHERE key = 'haertung'), TRUE), + (gen_random_uuid(), 'netzwerkscanner-sperre', 'Keine Netzwerkscanner', + 'Entfernt vorinstallierte Netzwerkscanner-Werkzeuge (z.B. nmap, Wireshark) aus dem Image.', + (SELECT id FROM kategorien WHERE key = 'haertung'), TRUE); + +INSERT INTO blueprints (id, merkmal_id, backend_id, ansible_role) +SELECT gen_random_uuid(), m.id, b.id, m.key +FROM merkmale m +JOIN backends b ON b.key IN ('mint-image', 'debian-image', 'ubuntu-image') +WHERE m.key IN ('terminal-sperre', 'netzwerkscanner-sperre'); + +COMMIT; diff --git a/migrations/0038_geraetegruppen_workspace.sql b/migrations/0038_geraetegruppen_workspace.sql new file mode 100644 index 0000000..55a69d1 --- /dev/null +++ b/migrations/0038_geraetegruppen_workspace.sql @@ -0,0 +1,35 @@ +BEGIN; + +-- Design-Runde 10.09.2026 (Workspace-Creator, siehe +-- project_phase2_app_store_merkmale-Memory / roadmap-Update): Gerätegruppen +-- können ab jetzt optional einen alternativen Workspace tragen - ersetzt +-- für Mitgliedsgeräte die komplette Workspace-Baseline (Grundbestand an +-- Merkmalen), nicht nur einzelne Merkmal-Overrides (dafür bleibt +-- gruppen_merkmale weiterhin zuständig). Gleiches Distro-Backend wie am +-- Installationstag bleibt Voraussetzung (kein Reinstall-Mechanismus) - +-- wird nicht hart erzwungen, sondern läuft über die bestehende +-- Blueprint-Zuordnung natürlich leer: ein Merkmal ohne Blueprint für das +-- Geräte-Backend fällt einfach aus dem Katalog raus (INNER JOIN in +-- fetch_auftragskatalog_state/listing), kein Hard-Error - gleiches +-- Verhalten wie bei jeder anderen Backend-Lücke im Katalog. +-- +-- Exklusivität (ein Gerät kann nur in einer workspace-tragenden +-- Gerätegruppe gleichzeitig sein) wird NICHT per DB-Constraint erzwungen +-- (M:N über device_gruppen lässt sich das nicht sauber als CHECK +-- ausdrücken), sondern application-seitig in app.py +-- (set_gruppe_workspace()/add_device_to_gruppe()). +ALTER TABLE gruppen ADD COLUMN workspace_id UUID + REFERENCES workspaces(id) + ON DELETE SET NULL; + +-- device_merkmale-Ablösung (Entscheidung 09./10.09.2026, siehe Plan-Datei +-- zur Architektur-Diskussion): Einzelgerät-Overrides sind ab jetzt +-- Ein-Geräte-Gruppen - dieselbe OE-/Gruppen-Zwischenschicht wie jede +-- andere Gruppe, statt einer eigenen, immer gewinnenden Override-Ebene. +-- Vor dieser Migration auf anode geprüft: device_merkmale hatte keine +-- Datenzeilen, kein Migrationsschritt für Bestandsdaten nötig. +-- device_merkmal_events bleibt unberührt (allgemeines Audit-Log, referenziert +-- device_id/merkmal_id direkt, nicht device_merkmale). +DROP TABLE device_merkmale; + +COMMIT;