Gerätegruppen mit alternativem Workspace, device_merkmale abgelöst

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VLk93dhDMrkPXefVdknMV9
This commit is contained in:
adminandClaude Sonnet 5 committed 2026-09-10 10:43:34 +02:00
1 parent b270103eab
commit 10d31f8cf3
3 files changed
+312 -137

No files matched your search

+255 -137
View File
@@ -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",