From 232d6212458bbfdff0f6bc3ccefb922957fb63c1 Mon Sep 17 00:00:00 2001 From: Thomas Stallinger Date: Wed, 12 Aug 2026 16:41:04 +0200 Subject: [PATCH] feat: Struktur (OE-Selfservice) + Gruppen-Verwaltung (Admin) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kundenportal: - Neue Seite /struktur - OE-Baum (rekursives Jinja-Makro), Anlegen/ Umbenennen/Löschen (Standard-OE 'Neue Geräte' geschützt), Merkmale- Editor pro OE mit Tristate aktiv/inaktiv/nicht festgelegt. - geraete_liste.html: neue Spalte 'OE', Aktionen-Dropdown um 'In OE verschieben' erweitert. - auftragskatalog.html: zeigt jetzt die Herkunft einer Einstellung an (OE/Gruppe/manuell), sofern sie nicht vom Workspace-Default kommt. Admin (/admin/kunden/{id}): - Neue Sektion 'Gruppen' - Anlegen/Löschen, verlinkt auf neue Detailseite (admin/gruppe_detail.html) mit Merkmale-Editor (wie OE) und Geräte-Mitgliedschaft-Checkliste. Bewusst nur intern verwaltbar in v1 (siehe Konfigurationsgruppen-Diskussion). Co-Authored-By: Claude Sonnet 5 --- app.py | 2 + routers/admin_kunden.py | 99 +++++++++++++++++- routers/geraete.py | 19 +++- routers/struktur.py | 156 +++++++++++++++++++++++++++++ templates/admin/gruppe_detail.html | 88 ++++++++++++++++ templates/admin/kunden_detail.html | 35 +++++++ templates/auftragskatalog.html | 3 + templates/base.html | 1 + templates/geraete_liste.html | 13 ++- templates/struktur.html | 54 ++++++++++ templates/struktur_loeschen.html | 17 ++++ templates/struktur_merkmale.html | 57 +++++++++++ templates/struktur_umbenennen.html | 12 +++ 13 files changed, 552 insertions(+), 4 deletions(-) create mode 100644 routers/struktur.py create mode 100644 templates/admin/gruppe_detail.html create mode 100644 templates/struktur.html create mode 100644 templates/struktur_loeschen.html create mode 100644 templates/struktur_merkmale.html create mode 100644 templates/struktur_umbenennen.html diff --git a/app.py b/app.py index 1b8a9dd..08ccb2d 100644 --- a/app.py +++ b/app.py @@ -10,6 +10,7 @@ from routers import ( geraete, installationsmedium, organisation, + struktur, ) @@ -19,6 +20,7 @@ app.mount("/static", StaticFiles(directory="static"), name="static") app.include_router(auth.router) app.include_router(geraete.router) app.include_router(organisation.router) +app.include_router(struktur.router) app.include_router(installationsmedium.router) app.include_router(admin_technik.router) app.include_router(admin_geraete.router) diff --git a/routers/admin_kunden.py b/routers/admin_kunden.py index c090389..7a38377 100644 --- a/routers/admin_kunden.py +++ b/routers/admin_kunden.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, Form, Request +from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import RedirectResponse from anode_client import anode_request @@ -60,6 +60,9 @@ def detail(request: Request, organization_id: str, user: dict = Depends(require_ backends_result = anode_request("GET", "/api/v1/backends") backends = backends_result.get("backends", []) if backends_result.get("success") else [] + gruppen_result = anode_request("GET", f"/api/v1/organizations/{organization_id}/gruppen") + gruppen = gruppen_result.get("gruppen", []) if gruppen_result.get("success") else [] + return templates.TemplateResponse( request, "admin/kunden_detail.html", @@ -72,10 +75,104 @@ def detail(request: Request, organization_id: str, user: dict = Depends(require_ "bereitstellungsvorlagen": bereitstellungsvorlagen, "workspaces": workspaces, "backends": backends, + "gruppen": gruppen, }, ) +@router.post("/{organization_id}/gruppen") +def gruppe_anlegen(organization_id: str, name: str = Form(...), user: dict = Depends(require_admin)): + anode_request("POST", f"/api/v1/organizations/{organization_id}/gruppen", json={"name": name}) + + return RedirectResponse(f"/admin/kunden/{organization_id}", status_code=303) + + +@router.post("/{organization_id}/gruppen/{gruppe_id}/loeschen") +def gruppe_loeschen(organization_id: str, gruppe_id: str, user: dict = Depends(require_admin)): + anode_request("DELETE", f"/api/v1/gruppen/{gruppe_id}") + + return RedirectResponse(f"/admin/kunden/{organization_id}", status_code=303) + + +@router.get("/{organization_id}/gruppen/{gruppe_id}") +def gruppe_detail(request: Request, organization_id: str, gruppe_id: str, user: dict = Depends(require_admin)): + gruppen_result = anode_request("GET", f"/api/v1/organizations/{organization_id}/gruppen") + gruppen = gruppen_result.get("gruppen", []) if gruppen_result.get("success") else [] + gruppe = next((g for g in gruppen if g["id"] == gruppe_id), None) + + if gruppe is None: + raise HTTPException(status_code=404, detail="Gruppe wurde nicht gefunden.") + + merkmale_result = anode_request("GET", f"/api/v1/gruppen/{gruppe_id}/merkmale") + merkmale = merkmale_result.get("merkmale", []) if merkmale_result.get("success") else [] + + kategorien: dict[str, list[dict]] = {} + for eintrag in merkmale: + kategorie_name = eintrag["kategorie"]["name"] if eintrag["kategorie"] else "Ohne Kategorie" + kategorien.setdefault(kategorie_name, []).append(eintrag) + + devices_result = anode_request("GET", f"/api/v1/organizations/{organization_id}/devices") + devices = devices_result.get("devices", []) if devices_result.get("success") else [] + + # anode bietet keinen direkten "Geräte einer Gruppe"-Endpunkt an, nur + # "Gruppen eines Geräts" (GET /devices/{id}/gruppen) - daher hier für + # jedes Gerät der Organisation dessen Gruppen abfragen und filtern. + mitglied_ids = set() + for device in devices: + dg_result = anode_request("GET", f"/api/v1/devices/{device['id']}/gruppen") + device_gruppen = dg_result.get("gruppen", []) if dg_result.get("success") else [] + if any(g["id"] == gruppe_id for g in device_gruppen): + mitglied_ids.add(device["id"]) + + return templates.TemplateResponse( + request, + "admin/gruppe_detail.html", + { + "user": user, + "organization_id": organization_id, + "gruppe": gruppe, + "kategorien": kategorien, + "devices": devices, + "mitglied_ids": mitglied_ids, + }, + ) + + +@router.post("/{organization_id}/gruppen/{gruppe_id}/merkmale/{merkmal_key}/select") +def gruppe_merkmal_select(organization_id: str, gruppe_id: str, merkmal_key: str, user: dict = Depends(require_admin)): + anode_request("POST", f"/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/select") + + return RedirectResponse(f"/admin/kunden/{organization_id}/gruppen/{gruppe_id}", status_code=303) + + +@router.post("/{organization_id}/gruppen/{gruppe_id}/merkmale/{merkmal_key}/deselect") +def gruppe_merkmal_deselect(organization_id: str, gruppe_id: str, merkmal_key: str, user: dict = Depends(require_admin)): + anode_request("POST", f"/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/deselect") + + return RedirectResponse(f"/admin/kunden/{organization_id}/gruppen/{gruppe_id}", status_code=303) + + +@router.post("/{organization_id}/gruppen/{gruppe_id}/merkmale/{merkmal_key}/unset") +def gruppe_merkmal_unset(organization_id: str, gruppe_id: str, merkmal_key: str, user: dict = Depends(require_admin)): + anode_request("POST", f"/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/unset") + + return RedirectResponse(f"/admin/kunden/{organization_id}/gruppen/{gruppe_id}", status_code=303) + + +@router.post("/{organization_id}/gruppen/{gruppe_id}/geraete/{device_id}/hinzufuegen") +def gruppe_geraet_hinzufuegen(organization_id: str, gruppe_id: str, device_id: str, user: dict = Depends(require_admin)): + anode_request("POST", f"/api/v1/devices/{device_id}/gruppen/{gruppe_id}") + + return RedirectResponse(f"/admin/kunden/{organization_id}/gruppen/{gruppe_id}", status_code=303) + + +@router.post("/{organization_id}/gruppen/{gruppe_id}/geraete/{device_id}/entfernen") +def gruppe_geraet_entfernen(organization_id: str, gruppe_id: str, device_id: str, user: dict = Depends(require_admin)): + anode_request("DELETE", f"/api/v1/devices/{device_id}/gruppen/{gruppe_id}") + + return RedirectResponse(f"/admin/kunden/{organization_id}/gruppen/{gruppe_id}", status_code=303) + + @router.post("/{organization_id}/bereitstellungsvorlagen") def bereitstellungsvorlage_anlegen( organization_id: str, diff --git a/routers/geraete.py b/routers/geraete.py index 356a724..798df7c 100644 --- a/routers/geraete.py +++ b/routers/geraete.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import RedirectResponse from anode_client import anode_request @@ -58,8 +58,13 @@ def geraete_liste(request: Request, user: dict = Depends(get_current_user)): result = anode_request("GET", f"/api/v1/organizations/{user.organization_id}/devices") devices = result.get("devices", []) if result.get("success") else [] + oe_result = anode_request("GET", f"/api/v1/organizations/{user.organization_id}/organisationseinheiten") + organisationseinheiten = oe_result.get("organisationseinheiten", []) if oe_result.get("success") else [] + return templates.TemplateResponse( - request, "geraete_liste.html", {"user": user, "devices": devices} + request, + "geraete_liste.html", + {"user": user, "devices": devices, "organisationseinheiten": organisationseinheiten}, ) @@ -195,3 +200,13 @@ def loeschen(device_id: str, user: dict = Depends(get_current_user)): anode_request("POST", f"/api/v1/devices/{device_id}/archive") return RedirectResponse("/geraete", status_code=303) + + +@router.post("/geraete/{device_id}/oe") +def oe_verschieben(device_id: str, oe_id: str = Form(...), user: dict = Depends(get_current_user)): + if find_device_in_organization(device_id, user.organization_id) is None: + raise HTTPException(status_code=404, detail="Gerät wurde nicht gefunden.") + + anode_request("POST", f"/api/v1/devices/{device_id}/oe", json={"oe_id": oe_id}) + + return RedirectResponse("/geraete", status_code=303) diff --git a/routers/struktur.py b/routers/struktur.py new file mode 100644 index 0000000..f6a50dd --- /dev/null +++ b/routers/struktur.py @@ -0,0 +1,156 @@ +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import RedirectResponse + +from anode_client import anode_request +from auth import get_current_user +from templating import templates + + +router = APIRouter() + + +def fetch_organisationseinheiten(organization_id: str) -> list[dict]: + result = anode_request("GET", f"/api/v1/organizations/{organization_id}/organisationseinheiten") + return result.get("organisationseinheiten", []) if result.get("success") else [] + + +def find_oe_in_organization(oe_id: str, organization_id: str) -> dict | None: + """ + Besitz-Validierung analog zu find_device_in_organization() in + routers/geraete.py (siehe ADR-0011): eine oe_id aus der URL wird nur + akzeptiert, wenn sie tatsächlich in der OE-Liste der eigenen + Organisation auftaucht. + """ + + for oe in fetch_organisationseinheiten(organization_id): + if oe["id"] == oe_id: + return oe + + return None + + +@router.get("/struktur") +def struktur_ansicht(request: Request, user=Depends(get_current_user)): + oes = fetch_organisationseinheiten(user.organization_id) + + return templates.TemplateResponse( + request, "struktur.html", {"user": user, "organisationseinheiten": oes} + ) + + +@router.post("/struktur") +def oe_anlegen( + name: str = Form(...), + parent_id: str = Form(default=""), + user=Depends(get_current_user), +): + anode_request( + "POST", + f"/api/v1/organizations/{user.organization_id}/organisationseinheiten", + json={"name": name, "parent_id": parent_id or None}, + ) + + return RedirectResponse("/struktur", status_code=303) + + +@router.get("/struktur/{oe_id}/umbenennen") +def oe_umbenennen_form(request: Request, oe_id: str, user=Depends(get_current_user)): + oe = find_oe_in_organization(oe_id, user.organization_id) + + if oe is None: + raise HTTPException(status_code=404, detail="OE wurde nicht gefunden.") + + return templates.TemplateResponse(request, "struktur_umbenennen.html", {"user": user, "oe": oe}) + + +@router.post("/struktur/{oe_id}/umbenennen") +def oe_umbenennen(oe_id: str, name: str = Form(...), user=Depends(get_current_user)): + oe = find_oe_in_organization(oe_id, user.organization_id) + + if oe is None: + raise HTTPException(status_code=404, detail="OE wurde nicht gefunden.") + + anode_request( + "PATCH", + f"/api/v1/organisationseinheiten/{oe_id}", + json={"name": name, "parent_id": oe["parent_id"]}, + ) + + return RedirectResponse("/struktur", status_code=303) + + +@router.get("/struktur/{oe_id}/loeschen") +def oe_loeschen_form(request: Request, oe_id: str, user=Depends(get_current_user)): + oe = find_oe_in_organization(oe_id, user.organization_id) + + if oe is None: + raise HTTPException(status_code=404, detail="OE wurde nicht gefunden.") + + return templates.TemplateResponse(request, "struktur_loeschen.html", {"user": user, "oe": oe, "fehler": None}) + + +@router.post("/struktur/{oe_id}/loeschen") +def oe_loeschen(request: Request, oe_id: str, user=Depends(get_current_user)): + oe = find_oe_in_organization(oe_id, user.organization_id) + + if oe is None: + raise HTTPException(status_code=404, detail="OE wurde nicht gefunden.") + + result = anode_request("DELETE", f"/api/v1/organisationseinheiten/{oe_id}") + + if not result.get("success"): + return templates.TemplateResponse( + request, "struktur_loeschen.html", {"user": user, "oe": oe, "fehler": result.get("message")} + ) + + return RedirectResponse("/struktur", status_code=303) + + +@router.get("/struktur/{oe_id}/merkmale") +def oe_merkmale_ansicht(request: Request, oe_id: str, user=Depends(get_current_user)): + oe = find_oe_in_organization(oe_id, user.organization_id) + + if oe is None: + raise HTTPException(status_code=404, detail="OE wurde nicht gefunden.") + + result = anode_request("GET", f"/api/v1/organisationseinheiten/{oe_id}/merkmale") + merkmale = result.get("merkmale", []) if result.get("success") else [] + + kategorien: dict[str, list[dict]] = {} + for eintrag in merkmale: + kategorie_name = eintrag["kategorie"]["name"] if eintrag["kategorie"] else "Ohne Kategorie" + kategorien.setdefault(kategorie_name, []).append(eintrag) + + return templates.TemplateResponse( + request, "struktur_merkmale.html", {"user": user, "oe": oe, "kategorien": kategorien} + ) + + +@router.post("/struktur/{oe_id}/merkmale/{merkmal_key}/select") +def oe_merkmal_select(oe_id: str, merkmal_key: str, user=Depends(get_current_user)): + if find_oe_in_organization(oe_id, user.organization_id) is None: + raise HTTPException(status_code=404, detail="OE wurde nicht gefunden.") + + anode_request("POST", f"/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/select") + + return RedirectResponse(f"/struktur/{oe_id}/merkmale", status_code=303) + + +@router.post("/struktur/{oe_id}/merkmale/{merkmal_key}/deselect") +def oe_merkmal_deselect(oe_id: str, merkmal_key: str, user=Depends(get_current_user)): + if find_oe_in_organization(oe_id, user.organization_id) is None: + raise HTTPException(status_code=404, detail="OE wurde nicht gefunden.") + + anode_request("POST", f"/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/deselect") + + return RedirectResponse(f"/struktur/{oe_id}/merkmale", status_code=303) + + +@router.post("/struktur/{oe_id}/merkmale/{merkmal_key}/unset") +def oe_merkmal_unset(oe_id: str, merkmal_key: str, user=Depends(get_current_user)): + if find_oe_in_organization(oe_id, user.organization_id) is None: + raise HTTPException(status_code=404, detail="OE wurde nicht gefunden.") + + anode_request("POST", f"/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/unset") + + return RedirectResponse(f"/struktur/{oe_id}/merkmale", status_code=303) diff --git a/templates/admin/gruppe_detail.html b/templates/admin/gruppe_detail.html new file mode 100644 index 0000000..13d34d8 --- /dev/null +++ b/templates/admin/gruppe_detail.html @@ -0,0 +1,88 @@ +{% extends "admin/layout.html" %} +{% block title %}Gruppe {{ gruppe.name }} — Tuxflotte Admin{% endblock %} +{% block admin_content %} +

← Organisation

+

Gruppe: {{ gruppe.name }}

+ +
+

Merkmale

+

"Nicht festgelegt" überlässt die Entscheidung der OE-Kette bzw. dem Workspace-Standard des Geräts.

+ {% if not kategorien %} +

Keine katalogfähigen Merkmale vorhanden.

+ {% else %} + {% for kategorie_name, eintraege in kategorien.items() %} +

{{ kategorie_name }}

+
+ + + {% for eintrag in eintraege %} + + + + + + {% endfor %} + +
{{ eintrag.name }} + {% if eintrag.aktiv is none %} + nicht festgelegt + {% elif eintrag.aktiv %} + aktiv + {% else %} + inaktiv + {% endif %} + + {% if eintrag.aktiv != true %} +
+ +
+ {% endif %} + {% if eintrag.aktiv != false %} +
+ +
+ {% endif %} + {% if eintrag.aktiv is not none %} +
+ +
+ {% endif %} +
+
+ {% endfor %} + {% endif %} +
+ +
+

Geräte in dieser Gruppe

+ {% if not devices %} +

Keine Geräte in dieser Organisation.

+ {% else %} +
+ + + + {% for device in devices %} + + + + + + + {% endfor %} + +
HostnameKennungMitglied
{{ device.hostname or "—" }}{{ device.device_fingerprint }}{{ "ja" if device.id in mitglied_ids else "nein" }} + {% if device.id in mitglied_ids %} +
+ +
+ {% else %} +
+ +
+ {% endif %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin/kunden_detail.html b/templates/admin/kunden_detail.html index f219f83..0975775 100644 --- a/templates/admin/kunden_detail.html +++ b/templates/admin/kunden_detail.html @@ -117,4 +117,39 @@ + +
+

Gruppen

+

Querschnitts-Gruppen (z.B. Lehrer/Schulleitung) — ein Gerät kann in mehreren gleichzeitig sein. Anders als OEs vorerst nur hier intern verwaltbar, siehe Konfigurationsgruppen-ADR.

+ {% if not gruppen %} +

Keine Gruppen angelegt.

+ {% else %} +
+ + + + {% for gruppe in gruppen %} + + + + + {% endfor %} + +
Name
{{ gruppe.name }} + Verwalten +
+ +
+
+
+ {% endif %} +
+ Neue Gruppe anlegen +
+ + + +
+
+
{% endblock %} diff --git a/templates/auftragskatalog.html b/templates/auftragskatalog.html index 49d8240..d2459d7 100644 --- a/templates/auftragskatalog.html +++ b/templates/auftragskatalog.html @@ -21,6 +21,9 @@ {{ "ausgewählt" if eintrag.state == "present" else "nicht ausgewählt" }} + {% if eintrag.quelle and eintrag.quelle != "workspace" %} +
via {{ {"oe": "OE", "gruppe": "Gruppe", "geraet": "manuell"}[eintrag.quelle] }} + {% endif %} {% if eintrag.state == "present" %} diff --git a/templates/base.html b/templates/base.html index 4406efa..42adf5d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -19,6 +19,7 @@ {% if user %}