Server auf Workspace-Versionen und OE-Workspace umgestellt

geraete_basis() ermittelt die Grundausstattung eines Geraets: Distro aus
der Installation, Workspace von der naechsten OE mit Workspace (gepinnte
Version oder neueste veroeffentlichte), sonst wie bisher aus der
Installation. Blueprints, Auftragskatalog, Webapps und Profil-Aufloesung
lesen die Version; Gruppen-Webapps ergaenzen. Gruppen tragen keinen
Workspace mehr, Exklusivregel und Endpunkte entfallen. Admin-Bearbeitung
wirkt bis Schritt E auf die Arbeitsversion. Workspace-Liste/-Detail
liefern Distro, Version, Vorlage, Sichtbarkeit; Sichtbarkeit fuer Kunden
nach Eigentum, global, Kundengruppe oder Organisation.

Lokal gegen eine Kopie der Produktionsdaten geprueft: Soll-Zustand aller
21 Geraete und 12 Profile identisch zum alten Code, 19 Szenarien gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 11:54:35 +02:00
1 parent 3ac7485179
commit 4a7ae27dc7
1 file changed
+301 -320
+301 -320
View File
@@ -219,6 +219,7 @@ class CreateWorkspaceRequest(BaseModel):
name: str
description: str | None = None
organization_id: str | None = None
backend_id: str | None = None
class CloneWorkspaceRequest(BaseModel):
@@ -327,10 +328,6 @@ class UpdateGruppeRequest(BaseModel):
name: str
class SetGruppeWorkspaceRequest(BaseModel):
workspace_id: str
class SetHostnameKonventionRequest(BaseModel):
praefix: str | None = None
stellen: int = 4
@@ -936,17 +933,17 @@ WEBAPP_XDG_CATEGORIES_DEFAULT = "Network;WebBrowser;"
KIOSK_BROWSER_PRIORITAET = ["google-chrome", "chromium"]
def _resolve_kiosk_browser(workspace_id: str) -> str | None:
def _resolve_kiosk_browser(version_id) -> str | None:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT m.key
FROM workspace_merkmale wm
JOIN merkmale m ON m.id = wm.merkmal_id
WHERE wm.workspace_id = %s AND m.key = ANY(%s)
FROM workspace_version_merkmale vm
JOIN merkmale m ON m.id = vm.merkmal_id
WHERE vm.workspace_version_id = %s AND m.key = ANY(%s)
""",
(workspace_id, KIOSK_BROWSER_PRIORITAET),
(version_id, KIOSK_BROWSER_PRIORITAET),
)
vorhandene = {row[0] for row in cur.fetchall()}
@@ -957,37 +954,37 @@ def _resolve_kiosk_browser(workspace_id: str) -> str | None:
def fetch_workspace_webapps(device_id: str):
"""Webapps eines Geräts: die der Workspace-Version (geraete_basis())
plus die Ergänzungen aller Gruppen, in denen das Gerät ist."""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.workspace_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
WHERE a.device_id = %s
""",
(device_id,),
)
assignment_row = cur.fetchone()
basis = geraete_basis(cur, device_id)
if assignment_row is None:
if basis is None:
return []
(workspace_id,) = assignment_row
version_id, _backend_id = basis
cur.execute(
"""
SELECT w.id, w.name, w.url, w.icon_filename, w.kiosk_modus, k.key
SELECT w.id, w.name, w.url, w.icon_filename, w.kiosk_modus, k.key, w.sort_order
FROM workspace_webapps w
LEFT JOIN kategorien k ON k.id = w.kategorie_id
WHERE w.workspace_id = %s
ORDER BY w.sort_order, w.name
WHERE w.workspace_version_id = %s
UNION ALL
SELECT gw.id, gw.name, gw.url, gw.icon_filename, gw.kiosk_modus, k.key, gw.sort_order
FROM gruppen_webapps gw
JOIN device_gruppen dg ON dg.gruppe_id = gw.gruppe_id
LEFT JOIN kategorien k ON k.id = gw.kategorie_id
WHERE dg.device_id = %s
ORDER BY 7, 2
""",
(workspace_id,),
(version_id, device_id),
)
zeilen = cur.fetchall()
zeilen = [row[:6] for row in cur.fetchall()]
kiosk_browser = _resolve_kiosk_browser(workspace_id) if any(row[4] for row in zeilen) else None
kiosk_browser = _resolve_kiosk_browser(version_id) if any(row[4] for row in zeilen) else None
return [
{
@@ -1011,39 +1008,52 @@ def fetch_workspace_webapps(device_id: str):
]
def fetch_assigned_blueprints(device_id: str):
def version_blueprints(cur, version_id, backend_id) -> list[dict]:
"""Merkmale einer Workspace-Version, die auf dieser Distro umgesetzt
sind, mit ihrer Ansible-Rolle."""
cur.execute(
"""
SELECT m.key, bp.ansible_role
FROM workspace_version_merkmale vm
JOIN merkmale m ON m.id = vm.merkmal_id
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
WHERE vm.workspace_version_id = %s
""",
(backend_id, version_id),
)
return [{"merkmal": key, "ansible_role": ansible_role} for key, ansible_role in cur.fetchall()]
def profil_blueprints(template_id: str) -> list[dict] | None:
"""Blueprints, mit denen ein Gerät über diese Bereitstellungsvorlage
(Installationsprofil) installiert wird: neueste veröffentlichte Version
des Profil-Workspace auf der Profil-Distro."""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.workspace_id, bv.backend_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
WHERE a.device_id = %s
""",
(device_id,),
"SELECT workspace_id, backend_id FROM bereitstellungsvorlagen WHERE id = %s",
(template_id,),
)
assignment_row = cur.fetchone()
row = cur.fetchone()
if row is None:
return None
workspace_id, backend_id = row
return version_blueprints(cur, veroeffentlichte_version_id(cur, workspace_id), backend_id)
if assignment_row is None:
def fetch_assigned_blueprints(device_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
basis = geraete_basis(cur, device_id)
if basis is None:
return []
workspace_id, backend_id = assignment_row
version_id, backend_id = basis
return version_blueprints(cur, version_id, backend_id)
cur.execute(
"""
SELECT m.key, bp.ansible_role
FROM workspace_merkmale wm
JOIN merkmale m ON m.id = wm.merkmal_id
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
WHERE wm.workspace_id = %s
""",
(backend_id, workspace_id),
)
return [
{"merkmal": key, "ansible_role": ansible_role}
for key, ansible_role in cur.fetchall()
]
def _fernet() -> Fernet:
if not ENCRYPTION_KEY:
@@ -1220,31 +1230,96 @@ def resolve_katalog_overrides(device_id: str) -> dict:
return ergebnis
def resolve_effective_workspace_id(cur, device_id: str, vorlage_workspace_id: str) -> str:
def veroeffentlichte_version_id(cur, workspace_id):
"""Neueste veröffentlichte Version eines Workspace (None, falls es noch
keine gibt) - das bekommen Geräte, deren OE "künftige Versionen
automatisch übernehmen" hat, und alle Bestandsgeräte ohne OE-Workspace."""
cur.execute(
"""
SELECT id FROM workspace_versionen
WHERE workspace_id = %s AND status = 'veroeffentlicht'
ORDER BY nummer DESC LIMIT 1
""",
(workspace_id,),
)
row = cur.fetchone()
return row[0] if row else None
def arbeitsversion_id(cur, workspace_id):
"""
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.
Die Version, die die Admin-Bearbeitung eines Workspace verändert: der
offene Entwurf, sonst die neueste veröffentlichte. Übergangsregel bis
Schritt E (Entwurf/Veröffentlichen mit Wirkungsvorschau, siehe
platform-docs/roadmap/workspace-creator-neu.md): solange es keinen
Entwurf gibt, wirken Änderungen wie bisher sofort.
"""
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
SELECT id FROM workspace_versionen
WHERE workspace_id = %s
ORDER BY (status = 'entwurf') DESC, nummer DESC LIMIT 1
""",
(workspace_id,),
)
row = cur.fetchone()
return row[0] if row else None
def geraete_basis(cur, device_id: str):
"""
Grundausstattung eines Geräts (Workspace-Creator neu, Schritt C):
(workspace_version_id, backend_id) oder None ohne Zuweisung.
Die Distro (backend_id) kommt immer aus der Installation und ändert sich
nie. Der Workspace kommt von der nächstgelegenen OE mit Workspace (eigene
OE, dann die Eltern bis zur Wurzel) - mit deren gepinnter Version, oder
der neuesten veröffentlichten, wenn dort "automatisch aktualisieren"
gesetzt ist. Trägt keine OE der Kette einen Workspace, gilt wie bisher
der Workspace aus der Installation (neueste veröffentlichte Version).
Gruppen ersetzen die Grundausstattung nicht mehr, sie ergänzen nur
(resolve_katalog_overrides(), gruppen_webapps).
"""
cur.execute(
"""
SELECT bv.workspace_id, bv.backend_id, d.organization_id, d.oe_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
JOIN devices d ON d.id = a.device_id
WHERE a.device_id = %s
""",
(device_id,),
)
gruppen_row = cur.fetchone()
row = cur.fetchone()
return gruppen_row[0] if gruppen_row is not None else vorlage_workspace_id
if row is None:
return None
installations_workspace_id, backend_id, organization_id, oe_id = row
if oe_id is not None:
cur.execute(
"""
SELECT id, parent_id, workspace_id, workspace_version_id, automatisch_aktualisieren
FROM organisationseinheiten WHERE organization_id = %s
""",
(organization_id,),
)
oes = {r[0]: r[1:] for r in cur.fetchall()}
aktuell = oe_id
while aktuell is not None and aktuell in oes:
parent_id, workspace_id, version_id, automatisch = oes[aktuell]
if workspace_id is not None:
if automatisch or version_id is None:
version_id = veroeffentlichte_version_id(cur, workspace_id)
return version_id, backend_id
aktuell = parent_id
return veroeffentlichte_version_id(cur, installations_workspace_id), backend_id
def fetch_auftragskatalog_state(device_id: str):
"""
@@ -1253,9 +1328,8 @@ def fetch_auftragskatalog_state(device_id: str):
Konfigurationsgruppen-Plan). Zustandslos aus der aktuellen Auswahl
abgeleitet, keine Historie nötig.
Auflösungsreihenfolge: Workspace-Default (ggf. durch eine
workspace-tragende Gerätegruppe ersetzt, siehe
resolve_effective_workspace_id()) → OE-/Gruppen-Merkmal-Zwischenschicht
Auflösungsreihenfolge: Grundausstattung (Workspace-Version, siehe
geraete_basis()) → 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
@@ -1264,34 +1338,24 @@ def fetch_auftragskatalog_state(device_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.workspace_id, bv.backend_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
WHERE a.device_id = %s
""",
(device_id,),
)
assignment_row = cur.fetchone()
basis = geraete_basis(cur, device_id)
if assignment_row is None:
if basis is None:
return []
vorlage_workspace_id, backend_id = assignment_row
workspace_id = resolve_effective_workspace_id(cur, device_id, vorlage_workspace_id)
version_id, backend_id = basis
cur.execute(
"""
SELECT m.id, m.key, bp.ansible_role,
(wm.merkmal_id IS NOT NULL) AS workspace_default
(vm.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 workspace_merkmale wm
ON wm.merkmal_id = m.id AND wm.workspace_id = %s
LEFT JOIN workspace_version_merkmale vm
ON vm.merkmal_id = m.id AND vm.workspace_version_id = %s
WHERE m.im_auftragskatalog
""",
(backend_id, workspace_id),
(backend_id, version_id),
)
rows = cur.fetchall()
@@ -1323,37 +1387,27 @@ def fetch_auftragskatalog_listing(device_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT bv.workspace_id, bv.backend_id
FROM assignments a
JOIN bereitstellungsvorlagen bv ON bv.id = a.bereitstellungsvorlage_id
WHERE a.device_id = %s
""",
(device_id,),
)
assignment_row = cur.fetchone()
basis = geraete_basis(cur, device_id)
if assignment_row is None:
if basis is None:
return None
vorlage_workspace_id, backend_id = assignment_row
workspace_id = resolve_effective_workspace_id(cur, device_id, vorlage_workspace_id)
version_id, backend_id = basis
cur.execute(
"""
SELECT m.id, m.key, m.name, m.description,
k.key, k.name, k.sort_order,
(wm.merkmal_id IS NOT NULL) AS workspace_default
(vm.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 workspace_merkmale wm
ON wm.merkmal_id = m.id AND wm.workspace_id = %s
LEFT JOIN workspace_version_merkmale vm
ON vm.merkmal_id = m.id AND vm.workspace_version_id = %s
WHERE m.im_auftragskatalog
ORDER BY k.sort_order NULLS LAST, m.name
""",
(backend_id, workspace_id),
(backend_id, version_id),
)
rows = cur.fetchall()
@@ -1813,23 +1867,14 @@ def fetch_gruppen(organization_id: str):
with conn.cursor() as cur:
cur.execute(
"""
SELECT g.id, g.name, g.workspace_id, w.name
SELECT g.id, g.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,
"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()
]
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()
@@ -1863,87 +1908,6 @@ 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:
@@ -2034,17 +1998,16 @@ 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, 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).
Gerät und Gruppe nicht zur selben Organisation gehören. Seit 07.10.2026
tragen Gruppen keinen eigenen Workspace mehr (nur noch Ergänzungen),
die frühere Exklusivitätsregel entfällt damit.
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT d.organization_id, g.organization_id, g.workspace_id
SELECT d.organization_id, g.organization_id
FROM devices d, gruppen g
WHERE d.id = %s AND g.id = %s
""",
@@ -2055,21 +2018,6 @@ 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)
@@ -2900,9 +2848,15 @@ def fetch_workspaces():
with conn.cursor() as cur:
cur.execute(
"""
SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name
SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name,
w.backend_id, b.name, b.version, w.ist_vorlage, w.sichtbarkeit,
(SELECT max(nummer) FROM workspace_versionen v
WHERE v.workspace_id = w.id AND v.status = 'veroeffentlicht'),
EXISTS (SELECT 1 FROM workspace_versionen v
WHERE v.workspace_id = w.id AND v.status = 'entwurf')
FROM workspaces w
LEFT JOIN organizations o ON o.id = w.organization_id
LEFT JOIN backends b ON b.id = w.backend_id
ORDER BY o.name NULLS FIRST, w.name
"""
)
@@ -2914,30 +2868,49 @@ def fetch_workspaces():
"description": description,
"organization_id": str(organization_id) if organization_id else None,
"organization_name": organization_name,
"backend": (
{"id": str(backend_id), "name": backend_name, "version": backend_version}
if backend_id else None
),
"ist_vorlage": ist_vorlage,
"sichtbarkeit": sichtbarkeit,
"version": version,
"hat_entwurf": hat_entwurf,
}
for (
wid, key, name, description, organization_id, organization_name,
backend_id, backend_name, backend_version, ist_vorlage, sichtbarkeit,
version, hat_entwurf,
) 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.
Workspaces, die eine Organisation sehen und verwenden darf: die eigenen
plus die für sie freigegebenen Vorlagen - global, für ihre Kundengruppe
oder ausdrücklich für sie (Workspace-Creator neu, 07.10.2026). Bewusst
NICHT fetch_workspaces() - die liefert Workspaces aller Organisationen
(nur für die Admin-Ansicht), 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, organization_id
FROM workspaces
WHERE organization_id IS NULL OR organization_id = %s
ORDER BY (organization_id IS NULL) DESC, name
SELECT w.id, w.key, w.name, w.description, w.organization_id
FROM workspaces w
JOIN organizations o ON o.id = %s
WHERE w.organization_id = o.id
OR (w.ist_vorlage AND (
w.sichtbarkeit = 'global'
OR (w.sichtbarkeit = 'kundengruppe' AND EXISTS (
SELECT 1 FROM workspace_freigabe_kundengruppen f
WHERE f.workspace_id = w.id AND f.kundengruppe_id = o.kundengruppe_id))
OR (w.sichtbarkeit = 'organisation' AND EXISTS (
SELECT 1 FROM workspace_freigabe_organisationen f
WHERE f.workspace_id = w.id AND f.organization_id = o.id))))
ORDER BY (w.organization_id IS NULL) DESC, w.name
""",
(organization_id,),
)
@@ -2956,17 +2929,35 @@ def fetch_available_workspaces_for_organization(organization_id: str):
for wid, key, name, description, ws_org_id in cur.fetchall()
]
def create_workspace(key: str, name: str, description: str | None, organization_id: str | None):
def create_workspace(
key: str, name: str, description: str | None, organization_id: str | None,
backend_id: str | None = None,
):
"""Legt einen Workspace samt Version 1 an. Bis Schritt E (Entwurf/
Veröffentlichen) ist Version 1 sofort veröffentlicht und wird direkt
bearbeitet (siehe arbeitsversion_id())."""
workspace_id = uuid4()
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO workspaces (id, organization_id, key, name, description)
VALUES (%s, %s, %s, %s, %s)
INSERT INTO workspaces (id, organization_id, key, name, description, backend_id, sichtbarkeit, ist_vorlage)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""",
(workspace_id, organization_id, key, name, description),
(
workspace_id, organization_id, key, name, description, backend_id,
"global" if organization_id is None else "organisation",
organization_id is None,
),
)
cur.execute(
"""
INSERT INTO workspace_versionen (id, workspace_id, nummer, status, veroeffentlicht_am)
VALUES (%s, %s, 1, 'veroeffentlicht', now())
""",
(uuid4(), workspace_id),
)
return {"id": str(workspace_id), "key": key, "name": name}
@@ -3008,7 +2999,8 @@ def clone_workspace(source_workspace_id: str, organization_id: str):
zaehler += 1
neuer_workspace = create_workspace(
neuer_key, f"{quelle['name']} (eigene Kopie)", quelle["description"], organization_id
neuer_key, f"{quelle['name']} (eigene Kopie)", quelle["description"], organization_id,
quelle["backend"]["id"] if quelle["backend"] else None,
)
for merkmal in quelle["merkmale"]:
@@ -3021,9 +3013,11 @@ def fetch_workspace_detail(workspace_id: str):
with conn.cursor() as cur:
cur.execute(
"""
SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name
SELECT w.id, w.key, w.name, w.description, w.organization_id, o.name,
w.backend_id, b.name, b.version, w.ist_vorlage, w.sichtbarkeit
FROM workspaces w
LEFT JOIN organizations o ON o.id = w.organization_id
LEFT JOIN backends b ON b.id = w.backend_id
WHERE w.id = %s
""",
(workspace_id,),
@@ -3033,15 +3027,22 @@ def fetch_workspace_detail(workspace_id: str):
if row is None:
return None
version_id = arbeitsversion_id(cur, workspace_id)
cur.execute(
"SELECT nummer, status FROM workspace_versionen WHERE id = %s",
(version_id,),
)
version_row = cur.fetchone()
cur.execute(
"""
SELECT m.id, m.key, m.name
FROM workspace_merkmale wm
JOIN merkmale m ON m.id = wm.merkmal_id
WHERE wm.workspace_id = %s
FROM workspace_version_merkmale vm
JOIN merkmale m ON m.id = vm.merkmal_id
WHERE vm.workspace_version_id = %s
ORDER BY m.key
""",
(workspace_id,),
(version_id,),
)
merkmale = [
{"id": str(mid), "key": key, "name": name}
@@ -3055,6 +3056,13 @@ def fetch_workspace_detail(workspace_id: str):
"description": row[3],
"organization_id": str(row[4]) if row[4] else None,
"organization_name": row[5],
"backend": {"id": str(row[6]), "name": row[7], "version": row[8]} if row[6] else None,
"ist_vorlage": row[9],
"sichtbarkeit": row[10],
"version": (
{"id": str(version_id), "nummer": version_row[0], "status": version_row[1]}
if version_row else None
),
"merkmale": merkmale,
}
@@ -3063,11 +3071,11 @@ def add_merkmal_to_workspace(workspace_id: str, merkmal_id: str):
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO workspace_merkmale (workspace_id, merkmal_id)
INSERT INTO workspace_version_merkmale (workspace_version_id, merkmal_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(workspace_id, merkmal_id),
(arbeitsversion_id(cur, workspace_id), merkmal_id),
)
def _write_webapp_icon(webapp_id, icon_bytes: bytes, content_type: str) -> str:
@@ -3147,10 +3155,10 @@ def fetch_webapps_for_workspace(workspace_id: str):
w.kategorie_id, k.name, w.kiosk_modus
FROM workspace_webapps w
LEFT JOIN kategorien k ON k.id = w.kategorie_id
WHERE w.workspace_id = %s
WHERE w.workspace_version_id = %s
ORDER BY w.sort_order, w.name
""",
(workspace_id,),
(arbeitsversion_id(cur, workspace_id),),
)
return [
{
@@ -3181,10 +3189,14 @@ def create_webapp(
cur.execute(
"""
INSERT INTO workspace_webapps
(id, workspace_id, name, url, icon_filename, icon_content_type, sort_order, kategorie_id, kiosk_modus)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
(id, workspace_id, workspace_version_id, name, url, icon_filename, icon_content_type,
sort_order, kategorie_id, kiosk_modus)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(webapp_id, workspace_id, name, url, icon_filename, icon_content_type, sort_order, kategorie_id, kiosk_modus),
(
webapp_id, workspace_id, arbeitsversion_id(cur, workspace_id), name, url,
icon_filename, icon_content_type, sort_order, kategorie_id, kiosk_modus,
),
)
return {"id": str(webapp_id), "name": name, "url": url, "icon_path": f"/webapp-icons/{icon_filename}" if icon_filename else None}
@@ -3231,7 +3243,23 @@ def delete_webapp(webapp_id: str):
row = cur.fetchone()
cur.execute("DELETE FROM workspace_webapps WHERE id = %s", (webapp_id,))
if row and row[0]:
# Seit den Workspace-Versionen (07.10.2026) teilen sich Kopien
# derselben Webapp in mehreren Versionen eine Icon-Datei - nur
# löschen, wenn sie niemand mehr verwendet.
icon_noch_verwendet = False
if row and row[0]:
cur.execute(
"""
SELECT 1 FROM workspace_webapps WHERE icon_filename = %s
UNION ALL
SELECT 1 FROM gruppen_webapps WHERE icon_filename = %s
LIMIT 1
""",
(row[0], row[0]),
)
icon_noch_verwendet = cur.fetchone() is not None
if row and row[0] and not icon_noch_verwendet:
icon_path = WEBAPP_ICONS_DIR / row[0]
if icon_path.is_file():
icon_path.unlink()
@@ -3280,11 +3308,11 @@ def fetch_available_templates_for_workspace(workspace_id: str):
FROM webapp_templates t
WHERE NOT EXISTS (
SELECT 1 FROM workspace_webapps w
WHERE w.workspace_id = %s AND w.template_id = t.id
WHERE w.workspace_version_id = %s AND w.template_id = t.id
)
ORDER BY t.name
""",
(workspace_id,),
(arbeitsversion_id(cur, workspace_id),),
)
return [
{
@@ -3371,10 +3399,11 @@ def activate_webapp_template(workspace_id: str, template_id: str):
return None
name, url, icon_filename, icon_content_type, kategorie_id = template
version_id = arbeitsversion_id(cur, workspace_id)
cur.execute(
"SELECT 1 FROM workspace_webapps WHERE workspace_id = %s AND template_id = %s",
(workspace_id, template_id),
"SELECT 1 FROM workspace_webapps WHERE workspace_version_id = %s AND template_id = %s",
(version_id, template_id),
)
if cur.fetchone() is not None:
return None
@@ -3398,10 +3427,15 @@ def activate_webapp_template(workspace_id: str, template_id: str):
cur.execute(
"""
INSERT INTO workspace_webapps (id, workspace_id, name, url, icon_filename, icon_content_type, template_id, kategorie_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
INSERT INTO workspace_webapps
(id, workspace_id, workspace_version_id, name, url, icon_filename, icon_content_type,
template_id, kategorie_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(webapp_id, workspace_id, name, url, copied_icon_filename, icon_content_type, template_id, kategorie_id),
(
webapp_id, workspace_id, version_id, name, url, copied_icon_filename,
icon_content_type, template_id, kategorie_id,
),
)
return {"id": str(webapp_id), "name": name, "url": url, "icon_path": f"/webapp-icons/{copied_icon_filename}" if copied_icon_filename else None}
@@ -3411,8 +3445,8 @@ def remove_merkmal_from_workspace(workspace_id: str, merkmal_id: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM workspace_merkmale WHERE workspace_id = %s AND merkmal_id = %s",
(workspace_id, merkmal_id),
"DELETE FROM workspace_version_merkmale WHERE workspace_version_id = %s AND merkmal_id = %s",
(arbeitsversion_id(cur, workspace_id), merkmal_id),
)
def fetch_backends():
@@ -4092,20 +4126,7 @@ def resolve_template(template_id: str, payload: ResolveRequest):
disk_encryption, partitioning, secure_boot_required,
) = template_row
cur.execute(
"""
SELECT m.key, bp.ansible_role
FROM workspace_merkmale wm
JOIN merkmale m ON m.id = wm.merkmal_id
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
WHERE wm.workspace_id = %s
""",
(backend_id, workspace_id),
)
blueprints = [
{"merkmal": key, "ansible_role": ansible_role}
for key, ansible_role in cur.fetchall()
]
blueprints = version_blueprints(cur, veroeffentlichte_version_id(cur, workspace_id), backend_id)
cur.execute(
"""
@@ -4171,8 +4192,13 @@ def get_webapp_icon(filename: str):
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT icon_content_type FROM workspace_webapps WHERE icon_filename = %s LIMIT 1",
(safe_name,),
"""
SELECT icon_content_type FROM workspace_webapps WHERE icon_filename = %s
UNION ALL
SELECT icon_content_type FROM gruppen_webapps WHERE icon_filename = %s
LIMIT 1
""",
(safe_name, safe_name),
)
row = cur.fetchone()
media_type = row[0] if row and row[0] else "application/octet-stream"
@@ -4837,7 +4863,9 @@ def create_workspace_endpoint(payload: CreateWorkspaceRequest, authorization: st
return {
"success": True,
"workspace": create_workspace(payload.key, payload.name, payload.description, payload.organization_id),
"workspace": create_workspace(
payload.key, payload.name, payload.description, payload.organization_id, payload.backend_id
),
}
@@ -5238,7 +5266,7 @@ def get_device_auftragskatalog(
# 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
# über eine Ein-Geräte-Gruppe (siehe
# gruppen_merkmale-Endpunkte oben). get_device_auftragskatalog() bleibt als
# reine Ist-Zustand-Anzeige bestehen.
@@ -5459,45 +5487,6 @@ def list_available_workspaces(organization_id: str, authorization: str | None =
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):
@@ -5521,14 +5510,6 @@ def add_device_gruppe_endpoint(device_id: str, gruppe_id: str, authorization: st
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,