Org-Ressourcen: Tabellen, API und Auftrag org-ressourcen im Check-in (Migration 0062)
Ressourcen (Mail, Nextcloud, ownCloud, WebDAV, SMB, Web-Proxy) gehoeren zur Organisation und wirken fuer die ganze Org, OEs samt Unter-OEs oder Geraetegruppen. Keine Zugangsdaten: unbekannte Felder werden verworfen. Der Check-in schickt die fuer das Geraet geltenden Ressourcen (spezifisch zuerst) als Auftrag org-ressourcen, vorerst nur fuer Organisationen mit Ausrollstufe vorab (ORG_RESSOURCEN_NUR_VORAB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
413d9a4795
commit
ed145be3d2
2 files changed
+486
-3
No files matched your search
@@ -394,6 +394,15 @@ class CreateGruppeRequest(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class OrgRessourceRequest(BaseModel):
|
||||||
|
typ: str
|
||||||
|
name: str
|
||||||
|
daten: dict = {}
|
||||||
|
ganze_organisation: bool = True
|
||||||
|
oe_ids: list[str] = []
|
||||||
|
gruppe_ids: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class UpdateGruppeRequest(BaseModel):
|
class UpdateGruppeRequest(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
@@ -1576,6 +1585,8 @@ def fetch_auftragskatalog_state(device_id: str):
|
|||||||
standardbrowser_row = cur.fetchone()
|
standardbrowser_row = cur.fetchone()
|
||||||
standardbrowser = standardbrowser_row[0] if standardbrowser_row else None
|
standardbrowser = standardbrowser_row[0] if standardbrowser_row else None
|
||||||
|
|
||||||
|
ressourcen = fetch_geraete_ressourcen(cur, device_id)
|
||||||
|
|
||||||
overrides = resolve_katalog_overrides(device_id)
|
overrides = resolve_katalog_overrides(device_id)
|
||||||
|
|
||||||
ergebnis = []
|
ergebnis = []
|
||||||
@@ -1603,8 +1614,367 @@ def fetch_auftragskatalog_state(device_id: str):
|
|||||||
"optionen": {"browser": standardbrowser} if standardbrowser else {},
|
"optionen": {"browser": standardbrowser} if standardbrowser else {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Org-Ressourcen (siehe ORG_RESSOURCEN_AUFTRAG): wie beim Standardbrowser
|
||||||
|
# immer mitschicken, auch als "absent", damit die Rolle nach dem Löschen
|
||||||
|
# der letzten Ressource aufräumt. Nicht freigeschaltet: gar nicht.
|
||||||
|
if ressourcen is not None:
|
||||||
|
ergebnis.append({
|
||||||
|
"merkmal": ORG_RESSOURCEN_AUFTRAG,
|
||||||
|
"ansible_role": ORG_RESSOURCEN_AUFTRAG,
|
||||||
|
"state": "present" if ressourcen else "absent",
|
||||||
|
"optionen": {"ressourcen": ressourcen} if ressourcen else {},
|
||||||
|
})
|
||||||
|
|
||||||
return ergebnis
|
return ergebnis
|
||||||
|
|
||||||
|
# Org-Ressourcen (15.10.2026, platform-docs roadmap/org-ressourcen-konzept.md,
|
||||||
|
# Migration 0062): lokale Dienste einer Organisation, die App-Rollen zum
|
||||||
|
# Vorbefüllen nutzen. Der Check-in schickt die für ein Gerät geltenden
|
||||||
|
# Ressourcen als eigenen Auftrag "org-ressourcen" mit (wie den
|
||||||
|
# Standardbrowser: kein Katalog-Merkmal, keine Ereigniszeile, der Agent
|
||||||
|
# reicht ihn ungesehen an Ansible weiter). App-Rollen lesen sie im selben
|
||||||
|
# Lauf aus tuxflotte_auftrag_optionen['org-ressourcen'].
|
||||||
|
ORG_RESSOURCEN_AUFTRAG = "org-ressourcen"
|
||||||
|
|
||||||
|
# Freischaltung: solange noch keine App-Rolle die Ressourcen nutzt, gelten
|
||||||
|
# Auftrag und Kunden-Maske nur für Organisationen mit Ausrollstufe "vorab"
|
||||||
|
# (Testorganisationen). Sonst bekäme jedes Kundengerät einen zusätzlichen
|
||||||
|
# Ansible-Lauf für eine Rolle, die sein stabiler Stand noch nicht kennt, und
|
||||||
|
# Kunden eine Maske ohne Wirkung. Auf False stellen, sobald die erste
|
||||||
|
# nutzende Rolle in ansible-content-stabil freigegeben ist.
|
||||||
|
ORG_RESSOURCEN_NUR_VORAB = True
|
||||||
|
|
||||||
|
# Feldtypen: "text", "url", "port", "auswahl" (mit "werte"), "liste"
|
||||||
|
# (kommagetrennt). Pflichtfelder mit "pflicht". Felder für Zugangsdaten gibt
|
||||||
|
# es absichtlich nicht - die Ressourcen landen unverschlüsselt in
|
||||||
|
# /etc/tuxflotte/org-ressourcen.json und sind für jeden Benutzer lesbar.
|
||||||
|
ORG_RESSOURCEN_TYPEN = {
|
||||||
|
"mail": {
|
||||||
|
"name": "E-Mail (IMAP/SMTP)",
|
||||||
|
"felder": [
|
||||||
|
{"key": "mail_domain", "label": "Maildomäne (Teil nach dem @, z. B. schule.de)", "typ": "text", "pflicht": True},
|
||||||
|
{"key": "imap_server", "label": "IMAP-Server", "typ": "text", "pflicht": True},
|
||||||
|
{"key": "imap_port", "label": "IMAP-Port", "typ": "port", "standard": 993},
|
||||||
|
{"key": "imap_verschluesselung", "label": "IMAP-Verschlüsselung", "typ": "auswahl", "werte": ["ssl", "starttls"], "standard": "ssl"},
|
||||||
|
{"key": "smtp_server", "label": "SMTP-Server", "typ": "text", "pflicht": True},
|
||||||
|
{"key": "smtp_port", "label": "SMTP-Port", "typ": "port", "standard": 465},
|
||||||
|
{"key": "smtp_verschluesselung", "label": "SMTP-Verschlüsselung", "typ": "auswahl", "werte": ["ssl", "starttls"], "standard": "ssl"},
|
||||||
|
{"key": "benutzername", "label": "Anmeldename", "typ": "auswahl", "werte": ["mailadresse", "lokalteil"], "standard": "mailadresse"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"nextcloud": {
|
||||||
|
"name": "Nextcloud",
|
||||||
|
"felder": [
|
||||||
|
{"key": "url", "label": "Adresse (z. B. https://cloud.schule.de)", "typ": "url", "pflicht": True},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"owncloud": {
|
||||||
|
"name": "ownCloud",
|
||||||
|
"felder": [
|
||||||
|
{"key": "url", "label": "Adresse (z. B. https://cloud.schule.de)", "typ": "url", "pflicht": True},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"webdav": {
|
||||||
|
"name": "WebDAV-Freigabe",
|
||||||
|
"felder": [
|
||||||
|
{"key": "url", "label": "Adresse (z. B. https://server/remote.php/dav/files/)", "typ": "url", "pflicht": True},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"smb": {
|
||||||
|
"name": "Windows-Freigabe (SMB/CIFS)",
|
||||||
|
"felder": [
|
||||||
|
{"key": "server", "label": "Server (Name oder IP)", "typ": "text", "pflicht": True},
|
||||||
|
{"key": "freigabe", "label": "Freigabename", "typ": "text", "pflicht": True},
|
||||||
|
{"key": "domaene", "label": "Domäne/Arbeitsgruppe (optional)", "typ": "text"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"name": "Web-Proxy",
|
||||||
|
"felder": [
|
||||||
|
{"key": "server", "label": "Proxy-Server (Name oder IP)", "typ": "text", "pflicht": True},
|
||||||
|
{"key": "port", "label": "Port", "typ": "port", "standard": 3128},
|
||||||
|
{"key": "ausnahmen", "label": "Ohne Proxy (kommagetrennt, z. B. localhost,.schule.local)", "typ": "liste"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Name/Host: Buchstaben, Ziffern, Punkt, Bindestrich, Unterstrich (IPv6 in
|
||||||
|
# eckigen Klammern). Freigabename zusätzlich Leerzeichen und $.
|
||||||
|
_RESSOURCE_HOST_RE = re.compile(r"^(\[[0-9a-fA-F:]+\]|[A-Za-z0-9._-]+)$")
|
||||||
|
_RESSOURCE_FREIGABE_RE = re.compile(r"^[A-Za-z0-9 ._$-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_ressource(typ: str, daten: dict) -> tuple[dict | None, str | None]:
|
||||||
|
"""Prüft die Felder einer Ressource gegen ORG_RESSOURCEN_TYPEN. Liefert
|
||||||
|
(bereinigte Daten, None) oder (None, Fehlermeldung). Unbekannte Felder
|
||||||
|
werden verworfen, damit auch keine Zugangsdaten hineinrutschen."""
|
||||||
|
|
||||||
|
definition = ORG_RESSOURCEN_TYPEN.get(typ)
|
||||||
|
if definition is None:
|
||||||
|
return None, f"Unbekannter Ressourcentyp: {typ}"
|
||||||
|
|
||||||
|
ergebnis = {}
|
||||||
|
for feld in definition["felder"]:
|
||||||
|
roh = daten.get(feld["key"])
|
||||||
|
wert = roh.strip() if isinstance(roh, str) else roh
|
||||||
|
if wert in (None, ""):
|
||||||
|
if feld.get("pflicht"):
|
||||||
|
return None, f"Bitte „{feld['label']}“ ausfüllen."
|
||||||
|
if "standard" in feld:
|
||||||
|
ergebnis[feld["key"]] = feld["standard"]
|
||||||
|
continue
|
||||||
|
|
||||||
|
if feld["typ"] == "port":
|
||||||
|
try:
|
||||||
|
wert = int(wert)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, f"„{feld['label']}“ muss eine Zahl sein."
|
||||||
|
if not 1 <= wert <= 65535:
|
||||||
|
return None, f"„{feld['label']}“ muss zwischen 1 und 65535 liegen."
|
||||||
|
elif feld["typ"] == "auswahl":
|
||||||
|
if wert not in feld["werte"]:
|
||||||
|
return None, f"Ungültiger Wert für „{feld['label']}“."
|
||||||
|
elif feld["typ"] == "url":
|
||||||
|
teile = urlparse(str(wert))
|
||||||
|
if teile.scheme not in ("http", "https") or not teile.hostname:
|
||||||
|
return None, f"„{feld['label']}“ muss mit http:// oder https:// beginnen."
|
||||||
|
if teile.username or teile.password:
|
||||||
|
return None, "Zugangsdaten gehören nicht in die Adresse."
|
||||||
|
wert = str(wert).rstrip("/")
|
||||||
|
elif feld["typ"] == "liste":
|
||||||
|
eintraege = [e.strip() for e in str(wert).split(",") if e.strip()]
|
||||||
|
if any(not _RESSOURCE_HOST_RE.match(e.lstrip("*")) for e in eintraege):
|
||||||
|
return None, f"„{feld['label']}“ enthält ungültige Einträge."
|
||||||
|
wert = eintraege
|
||||||
|
else:
|
||||||
|
wert = str(wert)
|
||||||
|
muster = _RESSOURCE_FREIGABE_RE if feld["key"] == "freigabe" else _RESSOURCE_HOST_RE
|
||||||
|
if feld["key"] in ("mail_domain", "imap_server", "smtp_server", "server", "freigabe", "domaene") and not muster.match(wert):
|
||||||
|
return None, f"„{feld['label']}“ enthält ungültige Zeichen."
|
||||||
|
|
||||||
|
ergebnis[feld["key"]] = wert
|
||||||
|
|
||||||
|
return ergebnis, None
|
||||||
|
|
||||||
|
|
||||||
|
def _ist_uuid(wert) -> bool:
|
||||||
|
try:
|
||||||
|
UUID(str(wert))
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def org_ressourcen_freigeschaltet(cur, organization_id) -> bool:
|
||||||
|
if not ORG_RESSOURCEN_NUR_VORAB:
|
||||||
|
return True
|
||||||
|
cur.execute("SELECT ausrollstufe FROM organizations WHERE id = %s", (organization_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
return bool(row) and row[0] == "vorab"
|
||||||
|
|
||||||
|
|
||||||
|
def _ressourcen_ziele_pruefen(cur, organization_id, oe_ids, gruppe_ids) -> str | None:
|
||||||
|
if oe_ids:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT count(*) FROM organisationseinheiten WHERE organization_id = %s AND id::text = ANY(%s)",
|
||||||
|
(organization_id, oe_ids),
|
||||||
|
)
|
||||||
|
if cur.fetchone()[0] != len(set(oe_ids)):
|
||||||
|
return "Unbekannte Organisationseinheit."
|
||||||
|
if gruppe_ids:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT count(*) FROM gruppen WHERE organization_id = %s AND id::text = ANY(%s)",
|
||||||
|
(organization_id, gruppe_ids),
|
||||||
|
)
|
||||||
|
if cur.fetchone()[0] != len(set(gruppe_ids)):
|
||||||
|
return "Unbekannte Gruppe."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_org_ressourcen(organization_id: str) -> dict:
|
||||||
|
if not _ist_uuid(organization_id):
|
||||||
|
return {"freigeschaltet": False, "ressourcen": []}
|
||||||
|
with get_database_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
freigeschaltet = org_ressourcen_freigeschaltet(cur, organization_id)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, typ, name, daten, ganze_organisation
|
||||||
|
FROM org_ressourcen WHERE organization_id = %s
|
||||||
|
ORDER BY typ, name
|
||||||
|
""",
|
||||||
|
(organization_id,),
|
||||||
|
)
|
||||||
|
zeilen = cur.fetchall()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT z.ressource_id, z.oe_id, oe.name, z.gruppe_id, g.name
|
||||||
|
FROM org_ressourcen_ziele z
|
||||||
|
JOIN org_ressourcen r ON r.id = z.ressource_id
|
||||||
|
LEFT JOIN organisationseinheiten oe ON oe.id = z.oe_id
|
||||||
|
LEFT JOIN gruppen g ON g.id = z.gruppe_id
|
||||||
|
WHERE r.organization_id = %s
|
||||||
|
ORDER BY oe.name, g.name
|
||||||
|
""",
|
||||||
|
(organization_id,),
|
||||||
|
)
|
||||||
|
ziele = {}
|
||||||
|
for ressource_id, oe_id, oe_name, gruppe_id, gruppe_name in cur.fetchall():
|
||||||
|
eintrag = ziele.setdefault(ressource_id, {"oes": [], "gruppen": []})
|
||||||
|
if oe_id is not None:
|
||||||
|
eintrag["oes"].append({"id": str(oe_id), "name": oe_name})
|
||||||
|
else:
|
||||||
|
eintrag["gruppen"].append({"id": str(gruppe_id), "name": gruppe_name})
|
||||||
|
|
||||||
|
ressourcen = []
|
||||||
|
for ressource_id, typ, name, daten, ganze_organisation in zeilen:
|
||||||
|
z = ziele.get(ressource_id, {"oes": [], "gruppen": []})
|
||||||
|
ressourcen.append({
|
||||||
|
"id": str(ressource_id),
|
||||||
|
"typ": typ,
|
||||||
|
"typ_name": ORG_RESSOURCEN_TYPEN.get(typ, {}).get("name", typ),
|
||||||
|
"name": name,
|
||||||
|
"daten": daten,
|
||||||
|
"ganze_organisation": ganze_organisation,
|
||||||
|
"oes": z["oes"],
|
||||||
|
"gruppen": z["gruppen"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"freigeschaltet": freigeschaltet, "ressourcen": ressourcen}
|
||||||
|
|
||||||
|
|
||||||
|
def speichere_org_ressource(
|
||||||
|
organization_id: str, ressource_id: str | None, typ: str, name: str, daten: dict,
|
||||||
|
ganze_organisation: bool, oe_ids: list[str], gruppe_ids: list[str],
|
||||||
|
) -> tuple[dict | None, str | None]:
|
||||||
|
"""Legt eine Ressource an (ressource_id None) oder ersetzt sie komplett
|
||||||
|
(Felder und Ziele). Liefert ({"id": …}, None) oder (None, Fehler)."""
|
||||||
|
|
||||||
|
if not all(_ist_uuid(x) for x in [organization_id, *([ressource_id] if ressource_id else []), *(oe_ids or []), *(gruppe_ids or [])]):
|
||||||
|
return None, "Ungültige Kennung."
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return None, "Bitte einen Namen angeben."
|
||||||
|
bereinigt, fehler = pruefe_ressource(typ, daten or {})
|
||||||
|
if fehler:
|
||||||
|
return None, fehler
|
||||||
|
oe_ids = [] if ganze_organisation else list(dict.fromkeys(oe_ids or []))
|
||||||
|
gruppe_ids = [] if ganze_organisation else list(dict.fromkeys(gruppe_ids or []))
|
||||||
|
|
||||||
|
with get_database_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
if fehler := _ressourcen_ziele_pruefen(cur, organization_id, oe_ids, gruppe_ids):
|
||||||
|
return None, fehler
|
||||||
|
try:
|
||||||
|
if ressource_id is None:
|
||||||
|
ressource_id = str(uuid4())
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO org_ressourcen (id, organization_id, typ, name, daten, ganze_organisation)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
(ressource_id, organization_id, typ, name, Jsonb(bereinigt), ganze_organisation),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE org_ressourcen
|
||||||
|
SET typ = %s, name = %s, daten = %s, ganze_organisation = %s, updated_at = now()
|
||||||
|
WHERE id = %s AND organization_id = %s
|
||||||
|
""",
|
||||||
|
(typ, name, Jsonb(bereinigt), ganze_organisation, ressource_id, organization_id),
|
||||||
|
)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
return None, "Die Ressource wurde nicht gefunden."
|
||||||
|
cur.execute("DELETE FROM org_ressourcen_ziele WHERE ressource_id = %s", (ressource_id,))
|
||||||
|
except psycopg.errors.UniqueViolation:
|
||||||
|
conn.rollback()
|
||||||
|
return None, "Eine Ressource mit diesem Namen gibt es schon."
|
||||||
|
|
||||||
|
for oe_id in oe_ids:
|
||||||
|
cur.execute("INSERT INTO org_ressourcen_ziele (ressource_id, oe_id) VALUES (%s, %s)", (ressource_id, oe_id))
|
||||||
|
for gruppe_id in gruppe_ids:
|
||||||
|
cur.execute("INSERT INTO org_ressourcen_ziele (ressource_id, gruppe_id) VALUES (%s, %s)", (ressource_id, gruppe_id))
|
||||||
|
|
||||||
|
return {"id": ressource_id}, None
|
||||||
|
|
||||||
|
|
||||||
|
def loesche_org_ressource(organization_id: str, ressource_id: str) -> bool:
|
||||||
|
if not (_ist_uuid(organization_id) and _ist_uuid(ressource_id)):
|
||||||
|
return False
|
||||||
|
with get_database_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"DELETE FROM org_ressourcen WHERE id = %s AND organization_id = %s",
|
||||||
|
(ressource_id, organization_id),
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_geraete_ressourcen(cur, device_id) -> list[dict] | None:
|
||||||
|
"""Ressourcen, die für ein Gerät gelten - None, wenn sie für seine
|
||||||
|
Organisation (noch) nicht freigeschaltet sind. Eine Ressource gilt, wenn
|
||||||
|
sie für die ganze Organisation wirkt, eine ihrer OEs die OE des Geräts
|
||||||
|
oder eine darüberliegende ist, oder das Gerät in einer ihrer Gruppen ist.
|
||||||
|
|
||||||
|
Sortiert vom Spezifischsten zum Allgemeinsten (Gruppe, OE nahe am Gerät,
|
||||||
|
OE weiter oben, ganze Organisation), innerhalb gleicher Stufe nach Name:
|
||||||
|
Rollen, die nur eine Ressource eines Typs brauchen (Web-Proxy), nehmen
|
||||||
|
die erste."""
|
||||||
|
|
||||||
|
cur.execute("SELECT organization_id, oe_id FROM devices WHERE id = %s", (device_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
organization_id, oe_id = row
|
||||||
|
if not org_ressourcen_freigeschaltet(cur, organization_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Abstand jeder OE der Kette zur Geräte-OE (0 = eigene OE), wie in
|
||||||
|
# resolve_katalog_overrides() in Python verfolgt.
|
||||||
|
abstand_by_oe = {}
|
||||||
|
if oe_id is not None:
|
||||||
|
cur.execute("SELECT id, parent_id FROM organisationseinheiten WHERE organization_id = %s", (organization_id,))
|
||||||
|
parent_by_id = {r[0]: r[1] for r in cur.fetchall()}
|
||||||
|
current, abstand = oe_id, 0
|
||||||
|
while current is not None and current not in abstand_by_oe:
|
||||||
|
abstand_by_oe[current] = abstand
|
||||||
|
current, abstand = parent_by_id.get(current), abstand + 1
|
||||||
|
|
||||||
|
cur.execute("SELECT gruppe_id FROM device_gruppen WHERE device_id = %s", (device_id,))
|
||||||
|
gruppen = {r[0] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT r.id, r.typ, r.name, r.daten, r.ganze_organisation, z.oe_id, z.gruppe_id
|
||||||
|
FROM org_ressourcen r
|
||||||
|
LEFT JOIN org_ressourcen_ziele z ON z.ressource_id = r.id
|
||||||
|
WHERE r.organization_id = %s
|
||||||
|
""",
|
||||||
|
(organization_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rang je Ressource: kleiner = spezifischer. Gruppe 0, OE 1 + Abstand,
|
||||||
|
# ganze Organisation 1000.
|
||||||
|
beste = {}
|
||||||
|
for ressource_id, typ, name, daten, ganze_organisation, z_oe, z_gruppe in cur.fetchall():
|
||||||
|
kandidaten = []
|
||||||
|
if ganze_organisation:
|
||||||
|
kandidaten.append((1000, "organisation"))
|
||||||
|
if z_gruppe is not None and z_gruppe in gruppen:
|
||||||
|
kandidaten.append((0, "gruppe"))
|
||||||
|
if z_oe is not None and z_oe in abstand_by_oe:
|
||||||
|
kandidaten.append((1 + abstand_by_oe[z_oe], "oe"))
|
||||||
|
for rang, quelle in kandidaten:
|
||||||
|
if ressource_id not in beste or rang < beste[ressource_id][0]:
|
||||||
|
beste[ressource_id] = (rang, quelle, typ, name, daten)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"id": str(rid), "typ": typ, "name": name, "quelle": quelle, **daten}
|
||||||
|
for rid, (rang, quelle, typ, name, daten) in sorted(beste.items(), key=lambda e: (e[1][0], e[1][3]))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def fetch_auftragskatalog_listing(device_id: str):
|
def fetch_auftragskatalog_listing(device_id: str):
|
||||||
"""
|
"""
|
||||||
Wie fetch_auftragskatalog_state, aber für die Auswahl-API angereichert um
|
Wie fetch_auftragskatalog_state, aber für die Auswahl-API angereichert um
|
||||||
@@ -5770,9 +6140,13 @@ def agent_report(
|
|||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
merkmal_ids = {}
|
merkmal_ids = {}
|
||||||
|
|
||||||
# Der Standardbrowser-Auftrag ist kein Merkmal und hat keine
|
# Standardbrowser- und Org-Ressourcen-Auftrag sind keine Merkmale
|
||||||
# Ereigniszeile (siehe STANDARDBROWSER_AUFTRAG).
|
# und haben keine Ereigniszeile (siehe STANDARDBROWSER_AUFTRAG,
|
||||||
payload.results = [e for e in payload.results if e.merkmal != STANDARDBROWSER_AUFTRAG]
|
# ORG_RESSOURCEN_AUFTRAG).
|
||||||
|
payload.results = [
|
||||||
|
e for e in payload.results
|
||||||
|
if e.merkmal not in (STANDARDBROWSER_AUFTRAG, ORG_RESSOURCEN_AUFTRAG)
|
||||||
|
]
|
||||||
|
|
||||||
for entry in payload.results:
|
for entry in payload.results:
|
||||||
if entry.merkmal in merkmal_ids:
|
if entry.merkmal in merkmal_ids:
|
||||||
@@ -7245,6 +7619,66 @@ def list_gruppen(organization_id: str, authorization: str | None = Header(defaul
|
|||||||
return {"success": True, "gruppen": fetch_gruppen(organization_id)}
|
return {"success": True, "gruppen": fetch_gruppen(organization_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/org-ressourcen/typen")
|
||||||
|
def list_org_ressourcen_typen(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, "typen": ORG_RESSOURCEN_TYPEN}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/organizations/{organization_id}/ressourcen")
|
||||||
|
def list_org_ressourcen(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, **fetch_org_ressourcen(organization_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/organizations/{organization_id}/ressourcen")
|
||||||
|
def create_org_ressource(organization_id: str, payload: OrgRessourceRequest, authorization: str | None = Header(default=None)):
|
||||||
|
if not require_service_token(authorization):
|
||||||
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
||||||
|
|
||||||
|
ergebnis, fehler = speichere_org_ressource(
|
||||||
|
organization_id, None, payload.typ, payload.name, payload.daten,
|
||||||
|
payload.ganze_organisation, payload.oe_ids, payload.gruppe_ids,
|
||||||
|
)
|
||||||
|
if fehler:
|
||||||
|
return {"success": False, "error": "invalid", "message": fehler}
|
||||||
|
|
||||||
|
return {"success": True, "ressource": ergebnis}
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/v1/organizations/{organization_id}/ressourcen/{ressource_id}")
|
||||||
|
def update_org_ressource(
|
||||||
|
organization_id: str, ressource_id: str, payload: OrgRessourceRequest,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
):
|
||||||
|
if not require_service_token(authorization):
|
||||||
|
return {"success": False, "error": "unauthorized", "message": "Fehlendes oder ungültiges Service-Token."}
|
||||||
|
|
||||||
|
ergebnis, fehler = speichere_org_ressource(
|
||||||
|
organization_id, ressource_id, payload.typ, payload.name, payload.daten,
|
||||||
|
payload.ganze_organisation, payload.oe_ids, payload.gruppe_ids,
|
||||||
|
)
|
||||||
|
if fehler:
|
||||||
|
return {"success": False, "error": "invalid", "message": fehler}
|
||||||
|
|
||||||
|
return {"success": True, "ressource": ergebnis}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/v1/organizations/{organization_id}/ressourcen/{ressource_id}")
|
||||||
|
def delete_org_ressource(organization_id: str, ressource_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 loesche_org_ressource(organization_id, ressource_id):
|
||||||
|
return {"success": False, "error": "not_found", "message": "Die Ressource wurde nicht gefunden."}
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/v1/organizations/{organization_id}/gruppen")
|
@app.post("/api/v1/organizations/{organization_id}/gruppen")
|
||||||
def create_gruppe_endpoint(organization_id: str, payload: CreateGruppeRequest, authorization: str | None = Header(default=None)):
|
def create_gruppe_endpoint(organization_id: str, payload: CreateGruppeRequest, authorization: str | None = Header(default=None)):
|
||||||
if not require_service_token(authorization):
|
if not require_service_token(authorization):
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Org-Ressourcen (15.10.2026, platform-docs roadmap/org-ressourcen-konzept.md,
|
||||||
|
-- Schritt 1): lokale Dienste einer Organisation (Mail, Nextcloud, ownCloud,
|
||||||
|
-- WebDAV, SMB, Web-Proxy), einmal zentral vom Kunden-Admin gepflegt. App-
|
||||||
|
-- Rollen auf dem Gerät nutzen sie zum Vorbefüllen (Server, Ports), der
|
||||||
|
-- Benutzer gibt nur noch Name und Passwort ein.
|
||||||
|
--
|
||||||
|
-- Bewusst KEINE Zugangsdaten: der Server nimmt in daten keine Passwort-
|
||||||
|
-- oder Benutzer-Felder an (Typ-Definitionen in app.py, ORG_RESSOURCEN_TYPEN).
|
||||||
|
-- Die Ressource gehört zur Organisation, nicht zum Workspace, weil
|
||||||
|
-- Workspaces über Kundengruppen von mehreren Organisationen genutzt werden.
|
||||||
|
|
||||||
|
CREATE TABLE org_ressourcen (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
organization_id UUID NOT NULL
|
||||||
|
REFERENCES organizations(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
typ TEXT NOT NULL
|
||||||
|
CHECK (typ IN ('mail', 'nextcloud', 'owncloud', 'webdav', 'smb', 'proxy')),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
daten JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
-- Wirkung: ganze Organisation, sonst nur die Ziele aus
|
||||||
|
-- org_ressourcen_ziele (OE samt Unter-OEs, Gerätegruppe). Ohne Ziel und
|
||||||
|
-- ohne ganze Organisation wirkt die Ressource nirgends (gepflegt, aber
|
||||||
|
-- nicht verteilt).
|
||||||
|
ganze_organisation BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (organization_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE org_ressourcen_ziele (
|
||||||
|
ressource_id UUID NOT NULL
|
||||||
|
REFERENCES org_ressourcen(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
oe_id UUID
|
||||||
|
REFERENCES organisationseinheiten(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
gruppe_id UUID
|
||||||
|
REFERENCES gruppen(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CHECK ((oe_id IS NULL) <> (gruppe_id IS NULL))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX org_ressourcen_ziele_oe ON org_ressourcen_ziele (ressource_id, oe_id) WHERE oe_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX org_ressourcen_ziele_gruppe ON org_ressourcen_ziele (ressource_id, gruppe_id) WHERE gruppe_id IS NOT NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Reference in new issue
Block a user