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;