feat: Organisationseinheiten (OEs) + Gruppen für Konfigurationsgruppen
Neues Datenmodell (Migration 0018/0019 + Backfill-Script):
- organisationseinheiten: Baum pro Organisation, ein Gerät gehört zu
genau einer OE (devices.oe_id). Jede Organisation bekommt automatisch
eine geschützte Standard-OE 'Neue Geräte' als Landeplatz für neu
aktivierte Geräte (create_organization() legt sie mit an,
load_or_create_device() weist sie neuen Geräten zu).
- gruppen + device_gruppen: flach, M:N - ein Gerät kann in mehreren
Gruppen gleichzeitig sein.
- oe_merkmale/gruppen_merkmale: sparsame present/absent-Overrides,
gleiches Muster wie device_merkmale.
Resolution-Algorithmus (resolve_katalog_overrides): Workspace-Default →
OE-Kette (spezifischste OE gewinnt, klassische Vererbung) → Gruppen
('mehr gewinnt' bei Widerspruch, OE-Kettenergebnis zählt als weitere
Stimme) → Geräte-Override (gewinnt immer, wie bisher).
fetch_auftragskatalog_listing() liefert zusätzlich die Herkunft
('workspace'/'oe'/'gruppe'/'geraet') für die Kundenportal-UI.
Neue Endpunkte: CRUD für OEs/Gruppen, Merkmale-Select/Deselect/Unset je
OE/Gruppe, Geräte-OE-Verschiebung, Geräte-Gruppen-Mitgliedschaft.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
507bab7282
commit
25e1c5525b
83
migrations/0018_organisationseinheiten_gruppen.sql
Normal file
83
migrations/0018_organisationseinheiten_gruppen.sql
Normal file
@ -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;
|
||||||
8
migrations/0019_devices_oe_id_not_null.sql
Normal file
8
migrations/0019_devices_oe_id_not_null.sql
Normal file
@ -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;
|
||||||
65
scripts/backfill_organisationseinheiten.py
Normal file
65
scripts/backfill_organisationseinheiten.py
Normal file
@ -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()
|
||||||
Loading…
x
Reference in New Issue
Block a user