From 128809792742137084e8c8239a6f44db5704b488 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Wed, 7 Oct 2026 14:12:33 +0200 Subject: [PATCH] Workspace an der OE, Distro-Konfliktregel, Ziel-OE fuer Installationsmedien - PUT /api/v1/organisationseinheiten/{id}/workspace: Workspace (sichtbar fuer die Organisation, mit Distro) an eine OE haengen, automatisch oder auf eine Version gepinnt. - distro_konflikte(): simuliert OE-Workspace, OE umhaengen und Geraet verschieben und blockiert, wenn ein aktives Geraet danach einen Workspace mit anderer Distro bekaeme (Meldung nennt Geraete und OE). - Sicherheitsnetz in geraete_basis_herkunft(): passt der OE-Workspace nicht zur Geraete-Distro, gilt der Workspace der Installation. - Enrollment-Sessions mit Ziel-OE (geprueft gegen die Profil-Distro); aktivierte Geraete landen dort, resolve_template liefert die Blueprints der wirksamen Grundausstattung. - Profil anlegen prueft, dass Workspace und Profil dieselbe Distro haben. - OE-Liste mit eigenem und wirksamem (geerbtem) Workspace, Geraeteliste mit wirksamem Workspace und Distro. Workspace-Creator neu, Schritt F. Co-Authored-By: Claude Opus 5.5 --- app.py | 415 ++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 355 insertions(+), 60 deletions(-) diff --git a/app.py b/app.py index 9632a2f..5547ee4 100644 --- a/app.py +++ b/app.py @@ -320,6 +320,13 @@ class CreateEnrollmentSessionRequest(BaseModel): max_devices: int expires_at: str neuinstallation_ohne_rueckfrage: bool = False + ziel_oe_id: str | None = None + + +class SetOeWorkspaceRequest(BaseModel): + workspace_id: str | None = None + version_id: str | None = None + automatisch: bool = True class RechargeEnrollmentSessionRequest(BaseModel): @@ -653,7 +660,7 @@ def find_enrollment_session(code: str): """ SELECT es.id, es.organization_id, o.name, es.bereitstellungsvorlage_id, es.max_devices, es.devices_used, es.expires_at, es.revoked_at, - es.neuinstallation_ohne_rueckfrage + es.neuinstallation_ohne_rueckfrage, es.ziel_oe_id FROM enrollment_sessions es JOIN organizations o ON o.id = es.organization_id WHERE es.id = %s @@ -667,7 +674,7 @@ def find_enrollment_session(code: str): ( session_id, organization_id, organization_name, bereitstellungsvorlage_id, - max_devices, devices_used, expires_at, revoked_at, neuinstallation_ohne_rueckfrage, + max_devices, devices_used, expires_at, revoked_at, neuinstallation_ohne_rueckfrage, ziel_oe_id, ) = row if revoked_at is not None: @@ -683,8 +690,25 @@ def find_enrollment_session(code: str): "organization_name": organization_name, "bereitstellungsvorlage_id": str(bereitstellungsvorlage_id), "neuinstallation_ohne_rueckfrage": bool(neuinstallation_ohne_rueckfrage), + "ziel_oe_id": str(ziel_oe_id) if ziel_oe_id else None, } +def geraet_in_ziel_oe(device_id: str, ziel_oe_id: str) -> None: + """Gerät aus einem Installationsmedium mit Ziel-OE dorthin setzen + (Schritt F) - auch bei einer Neuinstallation eines bekannten Geräts: + wer mit diesem Stick installiert, will es dort haben. Die Distro- + Verträglichkeit wurde beim Erstellen des Mediums geprüft.""" + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE devices SET oe_id = %s + WHERE id = %s AND organization_id = (SELECT organization_id FROM organisationseinheiten WHERE id = %s) + """, + (ziel_oe_id, device_id, ziel_oe_id), + ) + def consume_enrollment_session(session_id: str) -> None: with get_database_connection() as conn: with conn.cursor() as cur: @@ -1355,6 +1379,19 @@ def geraete_basis_herkunft(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: + # Sicherheitsnetz (Schritt F): passt die Distro des + # OE-Workspace nicht zur Distro des Geräts (z. B. nach einer + # Neuinstallation mit anderer Distro), gilt der Workspace aus + # der Installation - statt still fehlender Apps. Zuweisungen + # und Verschiebungen verhindern diesen Fall sonst vorab + # (distro_konflikte()). + cur.execute("SELECT backend_id FROM workspaces WHERE id = %s", (workspace_id,)) + ws_backend = cur.fetchone()[0] + if ws_backend is not None and ws_backend != backend_id: + return ( + veroeffentlichte_version_id(cur, installations_workspace_id), backend_id, + {"art": "installation", "oe_konflikt": aktuell}, + ) automatisch = bool(automatisch or version_id is None) if automatisch: version_id = veroeffentlichte_version_id(cur, workspace_id) @@ -1645,22 +1682,46 @@ def fetch_organisationseinheiten(organization_id: str): with conn.cursor() as cur: cur.execute( """ - SELECT id, parent_id, name, ist_standard - FROM organisationseinheiten - WHERE organization_id = %s - ORDER BY name + SELECT oe.id, oe.parent_id, oe.name, oe.ist_standard, + oe.workspace_id, w.name, b.name, b.version, oe.automatisch_aktualisieren, v.nummer + FROM organisationseinheiten oe + LEFT JOIN workspaces w ON w.id = oe.workspace_id + LEFT JOIN backends b ON b.id = w.backend_id + LEFT JOIN workspace_versionen v ON v.id = oe.workspace_version_id + WHERE oe.organization_id = %s + ORDER BY oe.name """, (organization_id,), ) - return [ - { - "id": str(oe_id), - "parent_id": str(parent_id) if parent_id is not None else None, - "name": name, - "ist_standard": ist_standard, - } - for oe_id, parent_id, name, ist_standard in cur.fetchall() - ] + zeilen = cur.fetchall() + + oes = [ + { + "id": str(oe_id), + "parent_id": str(parent_id) if parent_id is not None else None, + "name": name, + "ist_standard": ist_standard, + # Workspace-Creator neu, Schritt F: eigener Workspace der OE + # (oder None) und der wirksame, ggf. geerbte. + "workspace": ( + {"id": str(ws_id), "name": ws_name, "distro": f"{b_name} {b_version}", + "automatisch": automatisch or nummer is None, "version": nummer} + if ws_id else None + ), + } + for oe_id, parent_id, name, ist_standard, ws_id, ws_name, b_name, b_version, automatisch, nummer in zeilen + ] + nach_id = {oe["id"]: oe for oe in oes} + for oe in oes: + aktuell, gesehen = oe, set() + while aktuell is not None and aktuell["workspace"] is None and aktuell["id"] not in gesehen: + gesehen.add(aktuell["id"]) + aktuell = nach_id.get(aktuell["parent_id"]) + oe["wirksamer_workspace"] = ( + {**aktuell["workspace"], "geerbt_von": None if aktuell is oe else aktuell["name"]} + if aktuell is not None and aktuell["workspace"] else None + ) + return oes def create_organisationseinheit(organization_id: str, name: str, parent_id: str | None): """ @@ -1734,6 +1795,10 @@ def update_organisationseinheit(oe_id: str, name: str, parent_id: str | None): return None current = parent_by_id.get(current) + konflikte = distro_konflikte(cur, str(organization_id), oe_parent={oe_id: parent_id}) + if konflikte: + return {"konflikt": konflikt_meldung(konflikte)} + cur.execute( """ UPDATE organisationseinheiten @@ -1897,15 +1962,139 @@ def move_device_to_oe(device_id: str, oe_id: str): with conn.cursor() as cur: cur.execute( """ - UPDATE devices - SET oe_id = %s - WHERE id = %s - AND organization_id = (SELECT organization_id FROM organisationseinheiten WHERE id = %s) - RETURNING id + SELECT d.organization_id FROM devices d JOIN organisationseinheiten oe ON oe.id = %s + WHERE d.id = %s AND d.organization_id = oe.organization_id """, - (oe_id, device_id, oe_id), + (oe_id, device_id), ) - return cur.fetchone() is not None + row = cur.fetchone() + if row is None: + return False + konflikte = distro_konflikte(cur, str(row[0]), geraet_oe={device_id: oe_id}) + if konflikte: + return konflikt_meldung(konflikte) + cur.execute("UPDATE devices SET oe_id = %s WHERE id = %s", (oe_id, device_id)) + return True + +# --- Workspace an der OE und Distro-Konfliktregel (Schritt F, 07.10.2026) --- + +def distro_konflikte(cur, organization_id, oe_workspace=None, oe_parent=None, geraet_oe=None) -> list[dict]: + """ + Simuliert eine Änderung und meldet jedes aktive Gerät der Organisation, + das danach einen Workspace mit anderer Distro bekäme als seine eigene. + oe_workspace: {oe_id: workspace_id oder None}, oe_parent: {oe_id: + parent_id}, geraet_oe: {device_id: oe_id}. Grundlage der Regel + "blockieren mit klarer Meldung" (ADR-0038) - eine andere Distro hieße + Neuinstallation. + """ + + oe_workspace = {str(k): v for k, v in (oe_workspace or {}).items()} + oe_parent = {str(k): v for k, v in (oe_parent or {}).items()} + geraet_oe = {str(k): v for k, v in (geraet_oe or {}).items()} + + cur.execute( + "SELECT id::text, parent_id::text, workspace_id::text, name FROM organisationseinheiten WHERE organization_id = %s", + (organization_id,), + ) + oes = {} + for oe_id, parent_id, workspace_id, name in cur.fetchall(): + oes[oe_id] = { + "parent": oe_parent.get(oe_id, parent_id), + "workspace": oe_workspace.get(oe_id, workspace_id) if oe_id in oe_workspace else workspace_id, + "name": name, + } + + cur.execute("SELECT w.id::text, w.name, w.backend_id, b.name FROM workspaces w LEFT JOIN backends b ON b.id = w.backend_id") + workspaces = {wid: (name, backend_id, bname) for wid, name, backend_id, bname in cur.fetchall()} + + cur.execute( + """ + SELECT d.id::text, COALESCE(d.hostname, d.id::text), d.oe_id::text, bv.backend_id, b.name + FROM devices d + JOIN assignments a ON a.device_id = d.id + JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id + JOIN backends b ON b.id = bv.backend_id + WHERE d.organization_id = %s AND d.archived_at IS NULL AND d.deprovisioned_at IS NULL + """, + (organization_id,), + ) + konflikte = [] + for device_id, hostname, oe_id, backend_id, distro in cur.fetchall(): + aktuell, gesehen = geraet_oe.get(device_id, oe_id), set() + while aktuell is not None and aktuell in oes and aktuell not in gesehen: + gesehen.add(aktuell) + ws = oes[aktuell]["workspace"] + if ws is not None: + ws_name, ws_backend, ws_distro = workspaces.get(str(ws), ("?", None, None)) + if ws_backend is not None and ws_backend != backend_id: + konflikte.append({ + "geraet": hostname, "geraet_distro": distro, + "oe": oes[aktuell]["name"], "workspace": ws_name, "workspace_distro": ws_distro, + }) + break + aktuell = oes[aktuell]["parent"] + return konflikte + + +def konflikt_meldung(konflikte: list[dict]) -> str: + beispiele = "; ".join( + f"{k['geraet']} ({k['geraet_distro']}) bekäme „{k['workspace']}“ ({k['workspace_distro']}) über OE {k['oe']}" + for k in konflikte[:5] + ) + rest = f" und {len(konflikte) - 5} weitere" if len(konflikte) > 5 else "" + return ( + f"Nicht möglich – {len(konflikte)} Gerät(e) hätten danach einen Workspace mit anderer Distro, " + f"das ginge nur per Neuinstallation: {beispiele}{rest}." + ) + + +def set_oe_workspace(oe_id: str, workspace_id: str | None, version_id: str | None, automatisch: bool): + """Workspace (oder keinen) an eine OE hängen. Rückgabe (True, None) oder + (None, (fehlercode, nachricht)).""" + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT organization_id FROM organisationseinheiten WHERE id = %s", (oe_id,)) + row = cur.fetchone() + if row is None: + return None, ("not_found", "OE wurde nicht gefunden.") + organization_id = str(row[0]) + + if workspace_id is not None: + sichtbar = {w["id"] for w in fetch_available_workspaces_for_organization(organization_id)} + if workspace_id not in sichtbar: + return None, ("workspace_nicht_verfuegbar", "Dieser Workspace steht der Organisation nicht zur Verfügung.") + cur.execute("SELECT backend_id FROM workspaces WHERE id = %s", (workspace_id,)) + if cur.fetchone()[0] is None: + return None, ("ohne_distro", "Der Workspace hat noch keine Distro und kann nicht zugewiesen werden.") + if not automatisch: + if not version_id: + return None, ("version_fehlt", "Bitte eine Version wählen oder „automatisch aktualisieren“ setzen.") + cur.execute( + "SELECT 1 FROM workspace_versionen WHERE id = %s AND workspace_id = %s AND status = 'veroeffentlicht'", + (version_id, workspace_id), + ) + if cur.fetchone() is None: + return None, ("version_ungueltig", "Die gewählte Version gehört nicht zu diesem Workspace.") + konflikte = distro_konflikte(cur, organization_id, oe_workspace={oe_id: workspace_id}) + if konflikte: + return None, ("distro_konflikt", konflikt_meldung(konflikte)) + + cur.execute( + """ + UPDATE organisationseinheiten + SET workspace_id = %s, workspace_version_id = %s, automatisch_aktualisieren = %s + WHERE id = %s + """, + ( + workspace_id, + None if (workspace_id is None or automatisch) else version_id, + True if workspace_id is None else automatisch, + oe_id, + ), + ) + return True, None + def fetch_gruppen(organization_id: str): with get_database_connection() as conn: @@ -2143,44 +2332,56 @@ def fetch_devices_for_organization(organization_id: str): cur.execute( f""" SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin, - d.deprovisioned_at, d.oe_id, oe.name, w.name, {GERAETE_HEALTH_SPALTEN_SQL} + d.deprovisioned_at, d.oe_id, oe.name, w.name, {GERAETE_HEALTH_SPALTEN_SQL}, be.name FROM devices d LEFT JOIN organisationseinheiten oe ON oe.id = d.oe_id LEFT JOIN assignments a ON a.device_id = d.id LEFT JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id LEFT JOIN workspaces w ON w.id = bv.workspace_id + LEFT JOIN backends be ON be.id = bv.backend_id WHERE d.organization_id = %s AND d.archived_at IS NULL ORDER BY d.hostname NULLS LAST, d.created_at """, (organization_id,), ) - return [ - { - "id": str(device_id), - "hostname": hostname, - "device_fingerprint": fingerprint, - "agent_last_checkin": ( - last_checkin.isoformat() if last_checkin is not None else None - ), - "deprovisioned_at": ( - deprovisioned_at.isoformat() if deprovisioned_at is not None else None - ), - "oe_id": str(oe_id) if oe_id is not None else None, - "oe_name": oe_name, - # Install-time zugewiesener Workspace (GUI-Umbau - # 10.09.2026, Geräteliste-Spalte) - bei einer nachträglichen - # Gerätegruppen-Workspace-Übersteuerung (ADR-0028) weicht - # der tatsächlich wirksame Katalog davon ab, für den - # genauen Stand siehe der Auftragskatalog-Seite des Geräts. - "workspace_name": workspace_name, - **_health_felder_aus_row(*health_spalten), - } - for ( - device_id, hostname, fingerprint, last_checkin, deprovisioned_at, oe_id, oe_name, - workspace_name, - *health_spalten, - ) in cur.fetchall() - ] + zeilen = cur.fetchall() + + # Wirksamer Workspace (Schritt F): über OE oder Installation, + # wie ihn der Agent anwendet - nicht mehr nur der der Installation. + wirksam = {} + for zeile in zeilen: + basis = geraete_basis(cur, str(zeile[0])) + if basis is not None and basis[0] is not None: + cur.execute( + "SELECT w.name, v.nummer FROM workspace_versionen v JOIN workspaces w ON w.id = v.workspace_id WHERE v.id = %s", + (basis[0],), + ) + wirksam[zeile[0]] = cur.fetchone() + + return [ + { + "id": str(device_id), + "hostname": hostname, + "device_fingerprint": fingerprint, + "agent_last_checkin": ( + last_checkin.isoformat() if last_checkin is not None else None + ), + "deprovisioned_at": ( + deprovisioned_at.isoformat() if deprovisioned_at is not None else None + ), + "oe_id": str(oe_id) if oe_id is not None else None, + "oe_name": oe_name, + "workspace_name": wirksam[device_id][0] if device_id in wirksam else workspace_name, + "workspace_version": wirksam[device_id][1] if device_id in wirksam else None, + "distro": rest[-1], + **_health_felder_aus_row(*rest[:-1]), + } + for ( + device_id, hostname, fingerprint, last_checkin, deprovisioned_at, oe_id, oe_name, + workspace_name, + *rest, + ) in zeilen + ] def fetch_all_devices(): """ @@ -4160,26 +4361,71 @@ def fetch_enrollment_sessions(organization_id: str): ) in cur.fetchall() ] +def ziel_oe_pruefen(cur, organization_id: str, ziel_oe_id: str, bereitstellungsvorlage_id: str) -> str | None: + """Passt die Ziel-OE zum Installationsprofil? Fehlermeldung oder None. + Gilt in der OE (auch geerbt) ein Workspace, muss das Profil dieselbe + Distro installieren.""" + + cur.execute( + "SELECT 1 FROM organisationseinheiten WHERE id = %s AND organization_id = %s", + (ziel_oe_id, organization_id), + ) + if cur.fetchone() is None: + return "Die Ziel-OE gehört nicht zu dieser Organisation." + cur.execute( + "SELECT bv.backend_id, b.name FROM bereitstellungsvorlagen bv JOIN backends b ON b.id = bv.backend_id WHERE bv.id = %s", + (bereitstellungsvorlage_id,), + ) + profil = cur.fetchone() + if profil is None: + return "Das Installationsprofil wurde nicht gefunden." + cur.execute("SELECT id, parent_id, workspace_id FROM organisationseinheiten WHERE organization_id = %s", (organization_id,)) + oes = {oe: (parent, ws) for oe, parent, ws in cur.fetchall()} + aktuell = UUID(str(ziel_oe_id)) + while aktuell is not None and aktuell in oes: + parent, ws = oes[aktuell] + if ws is not None: + cur.execute( + "SELECT w.name, w.backend_id, b.name FROM workspaces w LEFT JOIN backends b ON b.id = w.backend_id WHERE w.id = %s", + (ws,), + ) + ws_name, ws_backend, ws_distro = cur.fetchone() + if ws_backend is not None and ws_backend != profil[0]: + return ( + f"Die Ziel-OE nutzt „{ws_name}“ ({ws_distro}), das Installationsprofil installiert aber " + f"{profil[1]}. Bitte ein {ws_distro}-Profil oder eine andere OE wählen." + ) + return None + aktuell = parent + return None + + def create_enrollment_session( organization_id: str, bereitstellungsvorlage_id: str, max_devices: int, expires_at: str, - neuinstallation_ohne_rueckfrage: bool = False, + neuinstallation_ohne_rueckfrage: bool = False, ziel_oe_id: str | None = None, ): + """Rückgabe: ({"id": ...}, None) oder (None, fehlermeldung).""" + session_id = uuid4() with get_database_connection() as conn: with conn.cursor() as cur: + if ziel_oe_id: + fehler = ziel_oe_pruefen(cur, organization_id, ziel_oe_id, bereitstellungsvorlage_id) + if fehler: + return None, fehler cur.execute( """ INSERT INTO enrollment_sessions (id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at, - neuinstallation_ohne_rueckfrage) - VALUES (%s, %s, %s, %s, %s, %s) + neuinstallation_ohne_rueckfrage, ziel_oe_id) + VALUES (%s, %s, %s, %s, %s, %s, %s) """, (session_id, organization_id, bereitstellungsvorlage_id, max_devices, expires_at, - neuinstallation_ohne_rueckfrage), + neuinstallation_ohne_rueckfrage, ziel_oe_id or None), ) - return {"id": str(session_id)} + return {"id": str(session_id)}, None def revoke_enrollment_session(session_id: str): with get_database_connection() as conn: @@ -4638,6 +4884,8 @@ def activate(payload: ActivationRequest, request: Request): if enrollment_session is not None: consume_enrollment_session(enrollment_session["session_id"]) + if enrollment_session.get("ziel_oe_id"): + geraet_in_ziel_oe(device["id"], enrollment_session["ziel_oe_id"]) write_log({ "timestamp": datetime.now(timezone.utc).isoformat(), @@ -4747,8 +4995,6 @@ def resolve_template(template_id: str, payload: ResolveRequest): disk_encryption, partitioning, secure_boot_required, ) = template_row - blueprints = version_blueprints(cur, veroeffentlichte_version_id(cur, workspace_id), backend_id) - cur.execute( """ INSERT INTO assignments (id, device_id, bereitstellungsvorlage_id) @@ -4759,6 +5005,24 @@ def resolve_template(template_id: str, payload: ResolveRequest): (uuid4(), payload.device_id, template_id), ) + # Schritt F: dieselbe Grundausstattung, die der Agent später + # anwendet - liegt das Gerät (z. B. über die Ziel-OE des Mediums) + # in einer OE mit Workspace, gilt deren Workspace, sonst wie + # bisher der Workspace des Profils. + basis = geraete_basis(cur, payload.device_id) + if basis is not None: + version_id, _backend = basis + blueprints = version_blueprints(cur, version_id, backend_id) + cur.execute( + "SELECT w.key FROM workspace_versionen v JOIN workspaces w ON w.id = v.workspace_id WHERE v.id = %s", + (version_id,), + ) + key_row = cur.fetchone() + if key_row: + workspace_key = key_row[0] + else: + blueprints = version_blueprints(cur, veroeffentlichte_version_id(cur, workspace_id), backend_id) + return { "success": True, "runtime_blueprint": { @@ -5906,6 +6170,18 @@ def create_bereitstellungsvorlage_endpoint( elif payload.extra_partitions: return {"success": False, "error": "invalid_request", "message": "extra_partitions ist nur bei scheme='custom' erlaubt."} + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT backend_id FROM workspaces WHERE id = %s", (payload.workspace_id,)) + ws_row = cur.fetchone() + if ws_row is None: + return {"success": False, "error": "workspace_not_found", "message": "Der Workspace wurde nicht gefunden."} + if ws_row[0] is not None and str(ws_row[0]) != payload.backend_id: + return { + "success": False, "error": "distro_konflikt", + "message": "Der Workspace gehört zu einer anderen Distro als das Profil (ein Workspace hat genau eine Distro).", + } + vorlage = create_bereitstellungsvorlage( organization_id, payload.workspace_id, @@ -5948,10 +6224,12 @@ def create_enrollment_session_endpoint( if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} - session = create_enrollment_session( + session, fehler = create_enrollment_session( organization_id, payload.bereitstellungsvorlage_id, payload.max_devices, payload.expires_at, - payload.neuinstallation_ohne_rueckfrage, + payload.neuinstallation_ohne_rueckfrage, payload.ziel_oe_id, ) + if fehler: + return {"success": False, "error": "ziel_oe_konflikt", "message": fehler} return {"success": True, "enrollment_session": session} @@ -6131,6 +6409,17 @@ def create_organisationseinheit_endpoint( return {"success": True, "organisationseinheit": oe} +@app.put("/api/v1/organisationseinheiten/{oe_id}/workspace") +def set_oe_workspace_endpoint(oe_id: str, payload: SetOeWorkspaceRequest, 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 = set_oe_workspace(oe_id, payload.workspace_id, payload.version_id, payload.automatisch) + if fehler: + return {"success": False, "error": fehler[0], "message": fehler[1]} + return {"success": True} + + @app.patch("/api/v1/organisationseinheiten/{oe_id}") def update_organisationseinheit_endpoint( oe_id: str, @@ -6142,6 +6431,9 @@ def update_organisationseinheit_endpoint( oe = update_organisationseinheit(oe_id, payload.name, payload.parent_id) + if oe is not None and "konflikt" in oe: + return {"success": False, "error": "distro_konflikt", "message": oe["konflikt"]} + if oe is None: return { "success": False, @@ -6219,7 +6511,10 @@ def move_device_to_oe_endpoint(device_id: str, payload: MoveDeviceOeRequest, aut if not require_service_token(authorization): return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} - if not move_device_to_oe(device_id, payload.oe_id): + ergebnis = move_device_to_oe(device_id, payload.oe_id) + if isinstance(ergebnis, str): + return {"success": False, "error": "distro_konflikt", "message": ergebnis} + if not ergebnis: return { "success": False, "error": "invalid_move",