diff --git a/app.py b/app.py index 32388e6..7c254ca 100644 --- a/app.py +++ b/app.py @@ -24,6 +24,9 @@ ANSIBLE_CONTENT_REPO = os.environ.get("TUXFLOTTE_ANSIBLE_CONTENT_REPO", "") ADMIN_TOKEN = os.environ.get("TUXFLOTTE_ADMIN_TOKEN", "") KUNDENPLATTFORM_TOKEN = os.environ.get("TUXFLOTTE_KUNDENPLATTFORM_TOKEN", "") AGENT_REPORTABLE_EVENT_TYPES = {"applied", "apply_failed", "removed", "remove_failed"} +# Name der pro Organisation automatisch angelegten Standard-OE (siehe +# Konfigurationsgruppen-Plan) - Landeplatz fuer neu aktivierte Geraete. +STANDARD_OE_NAME = "Neue Geräte" # Phase 4 des Self-Service-ISO-Plans (siehe tuxflotte-installer/scripts/ # build_customer_iso.sh). TUXFLOTTE_INSTALLER_DIR ist ein separat auf anode @@ -143,6 +146,28 @@ class CreateIsoBuildRequest(BaseModel): wifi_psk: str | None = None +class CreateOrganisationseinheitRequest(BaseModel): + name: str + parent_id: str | None = None + + +class UpdateOrganisationseinheitRequest(BaseModel): + name: str + parent_id: str | None = None + + +class MoveDeviceOeRequest(BaseModel): + oe_id: str + + +class CreateGruppeRequest(BaseModel): + name: str + + +class UpdateGruppeRequest(BaseModel): + name: 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") @@ -212,15 +237,23 @@ def load_or_create_device( else: device_id = uuid4() + cur.execute( + "SELECT id FROM organisationseinheiten WHERE organization_id = %s AND ist_standard", + (organization_id,), + ) + standard_oe_row = cur.fetchone() + standard_oe_id = standard_oe_row[0] if standard_oe_row is not None else None + cur.execute( """ INSERT INTO devices ( id, organization_id, device_fingerprint, - hostname + hostname, + oe_id ) - VALUES (%s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s) RETURNING id, organization_id, @@ -234,6 +267,7 @@ def load_or_create_device( organization_id, device_fingerprint, hostname, + standard_oe_id, ), ) @@ -599,19 +633,102 @@ def fetch_assigned_blueprints(device_id: str): for key, ansible_role in cur.fetchall() ] +def resolve_katalog_overrides(device_id: str) -> dict: + """ + Ermittelt für ein Gerät die OE-/Gruppen-Zwischenschicht des + Auftragskatalogs (siehe Konfigurationsgruppen-Plan): pro Merkmal-ID ein + (aktiv, quelle)-Tupel, falls die OE-Kette oder eine Gruppe eine explizite + Meinung dazu hat - fehlt der Merkmal-Key im Ergebnis, hat weder OE-Kette + noch eine Gruppe eine Meinung, der Aufrufer fällt dann auf den + Workspace-Default zurück. + + Reihenfolge: innerhalb der OE-Kette gewinnt die spezifischste OE + (klassische Vererbung, von der eigenen OE aus nach oben zur Wurzel + laufend - erste explizite Zeile pro Merkmal gewinnt). Das + OE-Kettenergebnis zählt danach als eine weitere Stimme neben den + Gruppen: sobald irgendeine zutreffende Stimme (OE-Kette oder eine + Gruppe) "aktiv" sagt, gewinnt aktiv ("mehr gewinnt"). + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT organization_id, oe_id FROM devices WHERE id = %s", + (device_id,), + ) + device_row = cur.fetchone() + + if device_row is None: + return {} + + organization_id, oe_id = device_row + + # OE-Kette von der eigenen OE aus zur Wurzel laufen (parent_id + # in Python verfolgen statt rekursivem SQL - ein Baum von + # wenigen Dutzend OEs pro Schule passt komplett in den Speicher, + # siehe Plan). + oe_chain = [] + if oe_id is not None: + cur.execute( + "SELECT id, parent_id FROM organisationseinheiten WHERE organization_id = %s", + (organization_id,), + ) + parent_by_id = {row[0]: row[1] for row in cur.fetchall()} + + current = oe_id + while current is not None: + oe_chain.append(current) + current = parent_by_id.get(current) + + oe_aktiv_by_merkmal = {} + if oe_chain: + cur.execute( + "SELECT oe_id, merkmal_id, aktiv FROM oe_merkmale WHERE oe_id = ANY(%s)", + (oe_chain,), + ) + rows_by_oe = {} + for row_oe_id, merkmal_id, aktiv in cur.fetchall(): + rows_by_oe.setdefault(row_oe_id, {})[merkmal_id] = aktiv + + for oe_id_in_chain in oe_chain: + for merkmal_id, aktiv in rows_by_oe.get(oe_id_in_chain, {}).items(): + oe_aktiv_by_merkmal.setdefault(merkmal_id, aktiv) + + cur.execute( + """ + SELECT gm.merkmal_id, gm.aktiv + FROM device_gruppen dg + JOIN gruppen_merkmale gm ON gm.gruppe_id = dg.gruppe_id + WHERE dg.device_id = %s + """, + (device_id,), + ) + gruppen_aktiv_by_merkmal = {} + for merkmal_id, aktiv in cur.fetchall(): + gruppen_aktiv_by_merkmal.setdefault(merkmal_id, []).append(aktiv) + + ergebnis = {} + for merkmal_id in set(oe_aktiv_by_merkmal) | set(gruppen_aktiv_by_merkmal): + oe_aktiv = oe_aktiv_by_merkmal.get(merkmal_id) + gruppen_stimmen = gruppen_aktiv_by_merkmal.get(merkmal_id, []) + + if oe_aktiv is True or True in gruppen_stimmen: + ergebnis[merkmal_id] = (True, "oe" if oe_aktiv is True else "gruppe") + else: + ergebnis[merkmal_id] = (False, "oe" if oe_aktiv is False else "gruppe") + + return ergebnis + def fetch_auftragskatalog_state(device_id: str): """ Voller Soll-Zustand (present/absent) aller katalogfähigen Merkmale für - das Backend des Device (siehe ADR-0010). Zustandslos aus der aktuellen - Auswahl abgeleitet, keine Historie nötig. + das Backend des Device (siehe ADR-0010, erweitert um OEs/Gruppen siehe + Konfigurationsgruppen-Plan). Zustandslos aus der aktuellen Auswahl + abgeleitet, keine Historie nötig. - Default ohne explizite Auftragszuweisung ist die Workspace-Zugehörigkeit: - der Auftragskatalog besteht aus denselben Merkmalen, aus denen auch - Workspaces zusammengesetzt werden, und ein Workspace ist fachlich nichts - anderes als eine Vorauswahl aus dem Katalog. Eine vorhandene - device_merkmale-Zeile überschreibt diesen Default in beide Richtungen - (auch ein workspace-komponiertes Merkmal lässt sich damit geräteweise - abwählen). + 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. """ with get_database_connection() as conn: @@ -634,8 +751,9 @@ def fetch_auftragskatalog_state(device_id: str): cur.execute( """ - SELECT m.key, bp.ansible_role, - COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL), + SELECT m.id, m.key, bp.ansible_role, + dm.aktiv, + (wm.merkmal_id IS NOT NULL) AS workspace_default, COALESCE(dm.optionen, '{}'::jsonb) FROM merkmale m JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s @@ -647,21 +765,33 @@ def fetch_auftragskatalog_state(device_id: str): """, (backend_id, device_id, workspace_id), ) - return [ - { - "merkmal": key, - "ansible_role": ansible_role, - "state": "present" if aktiv else "absent", - "optionen": optionen, - } - for key, ansible_role, aktiv, optionen in cur.fetchall() - ] + rows = cur.fetchall() + + overrides = resolve_katalog_overrides(device_id) + + ergebnis = [] + for merkmal_id, key, ansible_role, device_override, workspace_default, optionen in rows: + if device_override is not None: + aktiv = device_override + elif merkmal_id in overrides: + aktiv, _quelle = overrides[merkmal_id] + else: + aktiv = workspace_default + + ergebnis.append({ + "merkmal": key, + "ansible_role": ansible_role, + "state": "present" if aktiv else "absent", + "optionen": optionen, + }) + + return ergebnis def fetch_auftragskatalog_listing(device_id: str): """ Wie fetch_auftragskatalog_state, aber für die Auswahl-API angereichert um - Anzeige-relevante Felder (Name, Beschreibung, Kategorie) statt nur die - für den Agenten nötigen (Rollenname, State). + Anzeige-relevante Felder (Name, Beschreibung, Kategorie, Herkunft der + Einstellung) statt nur die für den Agenten nötigen (Rollenname, State). """ with get_database_connection() as conn: @@ -684,9 +814,10 @@ def fetch_auftragskatalog_listing(device_id: str): cur.execute( """ - SELECT m.key, m.name, m.description, + SELECT m.id, m.key, m.name, m.description, k.key, k.name, k.sort_order, - COALESCE(dm.aktiv, wm.merkmal_id IS NOT NULL), + dm.aktiv, + (wm.merkmal_id IS NOT NULL) AS workspace_default, COALESCE(dm.optionen, '{}'::jsonb) FROM merkmale m JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s @@ -700,24 +831,37 @@ def fetch_auftragskatalog_listing(device_id: str): """, (backend_id, device_id, workspace_id), ) - return [ - { - "merkmal": key, - "name": name, - "description": description, - "kategorie": ( - {"key": kat_key, "name": kat_name, "sort_order": sort_order} - if kat_key is not None else None - ), - "state": "present" if aktiv else "absent", - "optionen": optionen, - } - for ( - key, name, description, - kat_key, kat_name, sort_order, - aktiv, optionen, - ) in cur.fetchall() - ] + rows = cur.fetchall() + + overrides = resolve_katalog_overrides(device_id) + + ergebnis = [] + for ( + merkmal_id, key, name, description, + kat_key, kat_name, sort_order, + device_override, workspace_default, optionen, + ) in rows: + if device_override is not None: + aktiv, quelle = device_override, "geraet" + elif merkmal_id in overrides: + aktiv, quelle = overrides[merkmal_id] + else: + aktiv, quelle = workspace_default, "workspace" + + ergebnis.append({ + "merkmal": key, + "name": name, + "description": description, + "kategorie": ( + {"key": kat_key, "name": kat_name, "sort_order": sort_order} + if kat_key is not None else None + ), + "state": "present" if aktiv else "absent", + "quelle": quelle, + "optionen": optionen, + }) + + return ergebnis def set_auftrag_selection(device_id: str, merkmal_key: str, aktiv: bool, optionen: dict): """ @@ -762,6 +906,432 @@ def set_auftrag_selection(device_id: str, merkmal_key: str, aktiv: bool, optione return True +def fetch_organisationseinheiten(organization_id: str): + """ + Flache Liste aller OEs einer Organisation (inkl. parent_id) - der Baum + wird im Kundenportal-Template aus parent_id aufgebaut, nicht hier. + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT id, parent_id, name, ist_standard + FROM organisationseinheiten + WHERE organization_id = %s + ORDER BY 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() + ] + +def create_organisationseinheit(organization_id: str, name: str, parent_id: str | None): + """ + Legt eine neue OE an. Gibt None zurück, wenn parent_id gesetzt ist, aber + nicht zur selben Organisation gehört (verhindert Cross-Org- + Verschachtelung). + """ + + oe_id = uuid4() + + with get_database_connection() as conn: + with conn.cursor() as cur: + if parent_id is not None: + cur.execute( + "SELECT id FROM organisationseinheiten WHERE id = %s AND organization_id = %s", + (parent_id, organization_id), + ) + if cur.fetchone() is None: + return None + + cur.execute( + """ + INSERT INTO organisationseinheiten (id, organization_id, parent_id, name) + VALUES (%s, %s, %s, %s) + """, + (oe_id, organization_id, parent_id, name), + ) + + return {"id": str(oe_id), "parent_id": parent_id, "name": name, "ist_standard": False} + +def update_organisationseinheit(oe_id: str, name: str, parent_id: str | None): + """ + Benennt um und/oder verschiebt eine OE. Gibt None zurück, wenn die OE + nicht existiert, parent_id nicht zur selben Organisation gehört, oder + die Verschiebung einen Zyklus im Baum erzeugen würde (OE würde zu ihrem + eigenen Nachfahren). + """ + + 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 + + organization_id = row[0] + + if parent_id is not None: + if parent_id == oe_id: + return None + + cur.execute( + "SELECT id, parent_id FROM organisationseinheiten WHERE organization_id = %s", + (organization_id,), + ) + parent_by_id = { + str(r[0]): (str(r[1]) if r[1] is not None else None) + for r in cur.fetchall() + } + + if parent_id not in parent_by_id: + return None + + current = parent_id + while current is not None: + if current == oe_id: + return None + current = parent_by_id.get(current) + + cur.execute( + """ + UPDATE organisationseinheiten + SET name = %s, parent_id = %s + WHERE id = %s + RETURNING id, parent_id, name, ist_standard + """, + (name, parent_id, oe_id), + ) + updated = cur.fetchone() + + return { + "id": str(updated[0]), + "parent_id": str(updated[1]) if updated[1] is not None else None, + "name": updated[2], + "ist_standard": updated[3], + } + +def delete_organisationseinheit(oe_id: str): + """ + Löscht eine OE. Gibt (True, None) bei Erfolg zurück, sonst + (False, Fehlermeldung) - die Standard-OE, OEs mit Kind-OEs und OEs mit + zugeordneten Geräten sind geschützt (freundliche Fehlermeldung statt dem + rohen DB-RESTRICT-Fehler). + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT ist_standard FROM organisationseinheiten WHERE id = %s", + (oe_id,), + ) + row = cur.fetchone() + + if row is None: + return False, "Die OE wurde nicht gefunden." + + if row[0]: + return False, "Die Standard-OE 'Neue Geräte' kann nicht gelöscht werden." + + cur.execute("SELECT count(*) FROM organisationseinheiten WHERE parent_id = %s", (oe_id,)) + if cur.fetchone()[0] > 0: + return False, "Die OE hat noch untergeordnete OEs - diese zuerst verschieben oder löschen." + + cur.execute("SELECT count(*) FROM devices WHERE oe_id = %s", (oe_id,)) + if cur.fetchone()[0] > 0: + return False, "Der OE sind noch Geräte zugeordnet - diese zuerst verschieben." + + cur.execute("DELETE FROM organisationseinheiten WHERE id = %s", (oe_id,)) + + return True, None + +def set_oe_auswahl(oe_id: str, merkmal_key: str, aktiv: bool, optionen: dict): + """Setzt die OE-weite Auswahl eines katalogfähigen Merkmals (Upsert in oe_merkmale), analog zu set_auftrag_selection.""" + + 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 + + cur.execute( + """ + INSERT INTO oe_merkmale (oe_id, merkmal_id, aktiv, optionen) + VALUES (%s, %s, %s, %s) + ON CONFLICT (oe_id, merkmal_id) DO UPDATE + SET aktiv = EXCLUDED.aktiv, optionen = EXCLUDED.optionen + """, + (oe_id, merkmal_row[0], aktiv, Jsonb(optionen)), + ) + + return True + +def clear_oe_auswahl(oe_id: str, merkmal_key: str): + """Entfernt eine explizite OE-Einstellung wieder komplett (fällt danach auf die nächste Schicht zurück, statt nur auf true/false zu wechseln).""" + + 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 + + cur.execute( + "DELETE FROM oe_merkmale WHERE oe_id = %s AND merkmal_id = %s", + (oe_id, merkmal_row[0]), + ) + + return True + +def fetch_oe_merkmale_listing(oe_id: str): + """ + Für den OE-Merkmale-Editor im Kundenportal: alle katalogfähigen + Merkmale mit dem aktuellen, expliziten Stand dieser OE - aktiv (True), + inaktiv (False) oder nicht festgelegt (None, keine oe_merkmale-Zeile). + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT m.key, m.name, m.description, + k.key, k.name, k.sort_order, + om.aktiv + FROM merkmale m + LEFT JOIN kategorien k ON k.id = m.kategorie_id + LEFT JOIN oe_merkmale om ON om.merkmal_id = m.id AND om.oe_id = %s + WHERE m.im_auftragskatalog + ORDER BY k.sort_order NULLS LAST, m.name + """, + (oe_id,), + ) + return [ + { + "merkmal": key, + "name": name, + "description": description, + "kategorie": ( + {"key": kat_key, "name": kat_name, "sort_order": sort_order} + if kat_key is not None else None + ), + "aktiv": aktiv, + } + for key, name, description, kat_key, kat_name, sort_order, aktiv in cur.fetchall() + ] + +def move_device_to_oe(device_id: str, oe_id: str): + """ + Verschiebt ein Gerät in eine andere OE derselben Organisation. Gibt + False zurück, wenn Gerät/OE nicht existieren oder zu unterschiedlichen + Organisationen gehören. + """ + + 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) + RETURNING id + """, + (oe_id, device_id, oe_id), + ) + return cur.fetchone() is not None + +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", + (organization_id,), + ) + 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() + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "INSERT INTO gruppen (id, organization_id, name) VALUES (%s, %s, %s)", + (gruppe_id, organization_id, name), + ) + + return {"id": str(gruppe_id), "name": name} + +def update_gruppe(gruppe_id: str, name: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "UPDATE gruppen SET name = %s WHERE id = %s RETURNING id, name", + (name, gruppe_id), + ) + row = cur.fetchone() + + if row is None: + return None + + return {"id": str(row[0]), "name": row[1]} + +def delete_gruppe(gruppe_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM gruppen 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: + 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 + + cur.execute( + """ + INSERT INTO gruppen_merkmale (gruppe_id, merkmal_id, aktiv, optionen) + VALUES (%s, %s, %s, %s) + ON CONFLICT (gruppe_id, merkmal_id) DO UPDATE + SET aktiv = EXCLUDED.aktiv, optionen = EXCLUDED.optionen + """, + (gruppe_id, merkmal_row[0], aktiv, Jsonb(optionen)), + ) + + return True + +def clear_gruppe_auswahl(gruppe_id: str, merkmal_key: str): + 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 + + cur.execute( + "DELETE FROM gruppen_merkmale WHERE gruppe_id = %s AND merkmal_id = %s", + (gruppe_id, merkmal_row[0]), + ) + + return True + +def fetch_gruppe_merkmale_listing(gruppe_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT m.key, m.name, m.description, + k.key, k.name, k.sort_order, + gm.aktiv + FROM merkmale m + LEFT JOIN kategorien k ON k.id = m.kategorie_id + LEFT JOIN gruppen_merkmale gm ON gm.merkmal_id = m.id AND gm.gruppe_id = %s + WHERE m.im_auftragskatalog + ORDER BY k.sort_order NULLS LAST, m.name + """, + (gruppe_id,), + ) + return [ + { + "merkmal": key, + "name": name, + "description": description, + "kategorie": ( + {"key": kat_key, "name": kat_name, "sort_order": sort_order} + if kat_key is not None else None + ), + "aktiv": aktiv, + } + for key, name, description, kat_key, kat_name, sort_order, aktiv in cur.fetchall() + ] + +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. + """ + + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT d.organization_id, g.organization_id + FROM devices d, gruppen g + WHERE d.id = %s AND g.id = %s + """, + (device_id, gruppe_id), + ) + row = cur.fetchone() + + if row is None or row[0] != row[1]: + return False + + cur.execute( + """ + INSERT INTO device_gruppen (device_id, gruppe_id) + VALUES (%s, %s) + ON CONFLICT DO NOTHING + """, + (device_id, gruppe_id), + ) + + return True + +def remove_device_from_gruppe(device_id: str, gruppe_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM device_gruppen WHERE device_id = %s AND gruppe_id = %s", + (device_id, gruppe_id), + ) + + return True + +def fetch_device_gruppen(device_id: str): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT g.id, g.name + FROM device_gruppen dg + JOIN gruppen g ON g.id = dg.gruppe_id + WHERE dg.device_id = %s + ORDER BY g.name + """, + (device_id,), + ) + return [{"id": str(gruppe_id), "name": name} for gruppe_id, name in cur.fetchall()] + def fetch_devices_for_organization(organization_id: str): """ Für Kundenplattforms Geräteliste + Besitz-Validierung (siehe ADR-0011): @@ -773,10 +1343,12 @@ def fetch_devices_for_organization(organization_id: str): with conn.cursor() as cur: cur.execute( """ - SELECT id, hostname, device_fingerprint, agent_last_checkin, deprovisioned_at - FROM devices - WHERE organization_id = %s AND archived_at IS NULL - ORDER BY hostname NULLS LAST, created_at + SELECT d.id, d.hostname, d.device_fingerprint, d.agent_last_checkin, + d.deprovisioned_at, d.oe_id, oe.name + FROM devices d + LEFT JOIN organisationseinheiten oe ON oe.id = d.oe_id + WHERE d.organization_id = %s AND d.archived_at IS NULL + ORDER BY d.hostname NULLS LAST, d.created_at """, (organization_id,), ) @@ -791,8 +1363,10 @@ def fetch_devices_for_organization(organization_id: str): "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, } - for device_id, hostname, fingerprint, last_checkin, deprovisioned_at in cur.fetchall() + for device_id, hostname, fingerprint, last_checkin, deprovisioned_at, oe_id, oe_name in cur.fetchall() ] def fetch_all_devices(): @@ -1050,6 +1624,17 @@ def create_organization(name: str): (organization_id, name), ) + # Jede Organisation bekommt von Geburt an eine Standard-OE (siehe + # Konfigurationsgruppen-Plan) - Landeplatz fuer neu aktivierte + # Geraete, geschuetzt vor Loeschen (siehe delete_organisationseinheit). + cur.execute( + """ + INSERT INTO organisationseinheiten (id, organization_id, parent_id, name, ist_standard) + VALUES (%s, %s, NULL, %s, TRUE) + """, + (uuid4(), organization_id, STANDARD_OE_NAME), + ) + return {"id": str(organization_id), "name": name} def update_organization(organization_id: str, name: str): @@ -2493,3 +3078,250 @@ def deselect_auftrag( } return {"success": True} + + +# --- Organisationseinheiten (OEs) --- + +@app.get("/api/v1/organizations/{organization_id}/organisationseinheiten") +def list_organisationseinheiten(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, "organisationseinheiten": fetch_organisationseinheiten(organization_id)} + + +@app.post("/api/v1/organizations/{organization_id}/organisationseinheiten") +def create_organisationseinheit_endpoint( + organization_id: str, + payload: CreateOrganisationseinheitRequest, + authorization: str | None = Header(default=None), +): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + oe = create_organisationseinheit(organization_id, payload.name, payload.parent_id) + + if oe is None: + return { + "success": False, + "error": "invalid_parent", + "message": "Die übergeordnete OE wurde nicht gefunden oder gehört zu einer anderen Organisation.", + } + + return {"success": True, "organisationseinheit": oe} + + +@app.patch("/api/v1/organisationseinheiten/{oe_id}") +def update_organisationseinheit_endpoint( + oe_id: str, + payload: UpdateOrganisationseinheitRequest, + authorization: str | None = Header(default=None), +): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + oe = update_organisationseinheit(oe_id, payload.name, payload.parent_id) + + if oe is None: + return { + "success": False, + "error": "invalid_move", + "message": "OE nicht gefunden, übergeordnete OE gehört zu einer anderen Organisation, oder das würde einen Zyklus erzeugen.", + } + + return {"success": True, "organisationseinheit": oe} + + +@app.delete("/api/v1/organisationseinheiten/{oe_id}") +def delete_organisationseinheit_endpoint(oe_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."} + + deleted, fehler = delete_organisationseinheit(oe_id) + + if not deleted: + return {"success": False, "error": "delete_blocked", "message": fehler} + + return {"success": True} + + +@app.post("/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/select") +def select_oe_merkmal( + oe_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 Service-Token."} + + if not set_oe_auswahl(oe_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/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/deselect") +def deselect_oe_merkmal(oe_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 Service-Token."} + + if not set_oe_auswahl(oe_id, merkmal_key, False, {}): + return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"} + + return {"success": True} + + +@app.post("/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/unset") +def unset_oe_merkmal(oe_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 Service-Token."} + + if not clear_oe_auswahl(oe_id, merkmal_key): + return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"} + + return {"success": True} + + +@app.get("/api/v1/organisationseinheiten/{oe_id}/merkmale") +def get_oe_merkmale(oe_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, "merkmale": fetch_oe_merkmale_listing(oe_id)} + + +@app.post("/api/v1/devices/{device_id}/oe") +def move_device_to_oe_endpoint(device_id: str, payload: MoveDeviceOeRequest, 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 move_device_to_oe(device_id, payload.oe_id): + return { + "success": False, + "error": "invalid_move", + "message": "Gerät oder OE nicht gefunden, oder sie gehören zu unterschiedlichen Organisationen.", + } + + return {"success": True} + + +# --- Gruppen --- + +@app.get("/api/v1/organizations/{organization_id}/gruppen") +def list_gruppen(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, "gruppen": fetch_gruppen(organization_id)} + + +@app.post("/api/v1/organizations/{organization_id}/gruppen") +def create_gruppe_endpoint(organization_id: str, payload: CreateGruppeRequest, 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, "gruppe": create_gruppe(organization_id, payload.name)} + + +@app.patch("/api/v1/gruppen/{gruppe_id}") +def update_gruppe_endpoint(gruppe_id: str, payload: UpdateGruppeRequest, authorization: str | None = Header(default=None)): + if not require_service_token(authorization): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + gruppe = update_gruppe(gruppe_id, payload.name) + + if gruppe is None: + return {"success": False, "error": "gruppe_not_found", "message": "Die Gruppe wurde nicht gefunden."} + + return {"success": True, "gruppe": gruppe} + + +@app.delete("/api/v1/gruppen/{gruppe_id}") +def delete_gruppe_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 delete_gruppe(gruppe_id): + return {"success": False, "error": "gruppe_not_found", "message": "Die Gruppe wurde nicht gefunden."} + + return {"success": True} + + +@app.post("/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/select") +def select_gruppe_merkmal( + gruppe_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 Service-Token."} + + if not set_gruppe_auswahl(gruppe_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/gruppen/{gruppe_id}/merkmale/{merkmal_key}/deselect") +def deselect_gruppe_merkmal(gruppe_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 Service-Token."} + + if not set_gruppe_auswahl(gruppe_id, merkmal_key, False, {}): + return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"} + + return {"success": True} + + +@app.post("/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/unset") +def unset_gruppe_merkmal(gruppe_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 Service-Token."} + + if not clear_gruppe_auswahl(gruppe_id, merkmal_key): + return {"success": False, "error": "unknown_merkmal", "message": f"Kein katalogfähiges Merkmal mit Key: {merkmal_key}"} + + 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): + return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."} + + return {"success": True, "merkmale": fetch_gruppe_merkmale_listing(gruppe_id)} + + +@app.get("/api/v1/devices/{device_id}/gruppen") +def get_device_gruppen(device_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, "gruppen": fetch_device_gruppen(device_id)} + + +@app.post("/api/v1/devices/{device_id}/gruppen/{gruppe_id}") +def add_device_gruppe_endpoint(device_id: str, 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 add_device_to_gruppe(device_id, gruppe_id): + return { + "success": False, + "error": "invalid_membership", + "message": "Gerät oder Gruppe nicht gefunden, oder sie gehören zu unterschiedlichen Organisationen.", + } + + return {"success": True} + + +@app.delete("/api/v1/devices/{device_id}/gruppen/{gruppe_id}") +def remove_device_gruppe_endpoint(device_id: str, 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."} + + remove_device_from_gruppe(device_id, gruppe_id) + + return {"success": True} diff --git a/migrations/0018_organisationseinheiten_gruppen.sql b/migrations/0018_organisationseinheiten_gruppen.sql new file mode 100644 index 0000000..6a65a7c --- /dev/null +++ b/migrations/0018_organisationseinheiten_gruppen.sql @@ -0,0 +1,83 @@ +BEGIN; + +-- Organisationseinheiten (OEs): Baum pro Organisation, ein Geraet gehoert zu +-- genau einer OE. "Neue Geraete" ist pro Organisation die einzige Zeile mit +-- ist_standard=true - Landeplatz fuer neu aktivierte Geraete, geschuetzt vor +-- Loeschen (siehe app.py). parent_id = NULL heisst "oberste Ebene direkt +-- unter der Organisation" - kein eigener Wurzel-Datensatz noetig, die +-- Organisation selbst ist implizit die Wurzel. +CREATE TABLE organisationseinheiten ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL + REFERENCES organizations(id) + ON DELETE CASCADE, + parent_id UUID + REFERENCES organisationseinheiten(id) + ON DELETE RESTRICT, + name TEXT NOT NULL, + ist_standard BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (organization_id, parent_id, name) +); + +CREATE UNIQUE INDEX organisationseinheiten_one_standard_per_org + ON organisationseinheiten (organization_id) + WHERE ist_standard; + +-- Nullable hier: bestehende Geraete brauchen erst einen Backfill +-- (scripts/backfill_organisationseinheiten.py), bevor NOT NULL erzwungen +-- werden kann (siehe Migration 0019). +ALTER TABLE devices ADD COLUMN oe_id UUID + REFERENCES organisationseinheiten(id) + ON DELETE RESTRICT; + +-- Gruppen: flach (keine Verschachtelung), ein Geraet kann in mehreren +-- gleichzeitig sein (device_gruppen als M:N). +CREATE TABLE gruppen ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL + REFERENCES organizations(id) + ON DELETE CASCADE, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (organization_id, name) +); + +CREATE TABLE device_gruppen ( + device_id UUID NOT NULL + REFERENCES devices(id) + ON DELETE CASCADE, + gruppe_id UUID NOT NULL + REFERENCES gruppen(id) + ON DELETE CASCADE, + PRIMARY KEY (device_id, gruppe_id) +); + +-- Sparsame Merkmal-Overrides je OE/Gruppe, gleiches Muster wie +-- device_merkmale (0007_auftragskatalog.sql): nur eine Zeile, wenn diese +-- OE/Gruppe tatsaechlich eine explizite Meinung zu diesem Merkmal hat. +CREATE TABLE oe_merkmale ( + oe_id UUID NOT NULL + REFERENCES organisationseinheiten(id) + ON DELETE CASCADE, + merkmal_id UUID NOT NULL + REFERENCES merkmale(id) + ON DELETE RESTRICT, + aktiv BOOLEAN NOT NULL, + optionen JSONB NOT NULL DEFAULT '{}'::jsonb, + PRIMARY KEY (oe_id, merkmal_id) +); + +CREATE TABLE gruppen_merkmale ( + gruppe_id UUID NOT NULL + REFERENCES gruppen(id) + ON DELETE CASCADE, + merkmal_id UUID NOT NULL + REFERENCES merkmale(id) + ON DELETE RESTRICT, + aktiv BOOLEAN NOT NULL, + optionen JSONB NOT NULL DEFAULT '{}'::jsonb, + PRIMARY KEY (gruppe_id, merkmal_id) +); + +COMMIT; diff --git a/migrations/0019_devices_oe_id_not_null.sql b/migrations/0019_devices_oe_id_not_null.sql new file mode 100644 index 0000000..41bfb15 --- /dev/null +++ b/migrations/0019_devices_oe_id_not_null.sql @@ -0,0 +1,8 @@ +BEGIN; + +-- Erst nach scripts/backfill_organisationseinheiten.py einspielen - das +-- Script legt fuer jede bestehende Organisation die "Neue Geraete"-OE an +-- und setzt oe_id auf allen bis dahin unzugeordneten Geraeten. +ALTER TABLE devices ALTER COLUMN oe_id SET NOT NULL; + +COMMIT; diff --git a/scripts/backfill_organisationseinheiten.py b/scripts/backfill_organisationseinheiten.py new file mode 100644 index 0000000..e284d73 --- /dev/null +++ b/scripts/backfill_organisationseinheiten.py @@ -0,0 +1,65 @@ +""" +Einmaliger Backfill für Migration 0018 (siehe Konfigurationsgruppen-Plan): +legt für jede bestehende Organisation ohne Standard-OE eine "Neue Geräte"-OE +an und ordnet alle bislang unzugeordneten Geräte (oe_id IS NULL) dieser OE +ihrer Organisation zu. Danach kann Migration 0019 (oe_id NOT NULL) sicher +eingespielt werden. + +Ab Migration 0018 legt create_organization() die Standard-OE für neue +Organisationen bereits selbst an - dieses Script ist nur für den +Bestandsdaten-Übergang nötig, kann aber gefahrlos mehrfach laufen +(idempotent: überspringt Organisationen, die schon eine Standard-OE haben). + +Aufruf: + TUXFLOTTE_DATABASE_URL=... .venv/bin/python scripts/backfill_organisationseinheiten.py +""" + +import os +import sys +from uuid import uuid4 + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app import STANDARD_OE_NAME, get_database_connection # noqa: E402 + + +def backfill(): + with get_database_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT id, name FROM organizations ORDER BY name") + organizations = cur.fetchall() + + for organization_id, organization_name in organizations: + cur.execute( + "SELECT id FROM organisationseinheiten WHERE organization_id = %s AND ist_standard", + (organization_id,), + ) + row = cur.fetchone() + + if row is None: + standard_oe_id = uuid4() + cur.execute( + """ + INSERT INTO organisationseinheiten (id, organization_id, parent_id, name, ist_standard) + VALUES (%s, %s, NULL, %s, TRUE) + """, + (standard_oe_id, organization_id, STANDARD_OE_NAME), + ) + print(f"[{organization_name}] Standard-OE '{STANDARD_OE_NAME}' angelegt ({standard_oe_id})") + else: + standard_oe_id = row[0] + print(f"[{organization_name}] Standard-OE existiert bereits ({standard_oe_id})") + + cur.execute( + "UPDATE devices SET oe_id = %s WHERE organization_id = %s AND oe_id IS NULL RETURNING id", + (standard_oe_id, organization_id), + ) + updated = cur.fetchall() + print(f"[{organization_name}] {len(updated)} Gerät(e) der Standard-OE zugeordnet") + + +if __name__ == "__main__": + if not os.environ.get("TUXFLOTTE_DATABASE_URL"): + sys.exit("TUXFLOTTE_DATABASE_URL ist nicht gesetzt.") + + backfill()