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 %} +
+"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() %} +| {{ 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 %} + | +
Keine Geräte in dieser Organisation.
+ {% else %} +| Hostname | Kennung | Mitglied | |
|---|---|---|---|
| {{ device.hostname or "—" }} | +{{ device.device_fingerprint }} |
+ {{ "ja" if device.id in mitglied_ids else "nein" }} | ++ {% if device.id in mitglied_ids %} + + {% else %} + + {% endif %} + | +
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 %} +| Name | |
|---|---|
| {{ gruppe.name }} | ++ Verwalten + + | +
| Hostname | Kennung | Letzter Check-in | Status | ||||
|---|---|---|---|---|---|---|---|
| Hostname | Kennung | OE | Letzter Check-in | Status | |||
| {{ device.hostname or "—" }} | {{ device.device_fingerprint }} |
+ {{ device.oe_name or "—" }} | {{ device.agent_last_checkin or "noch nie" }} |
{% if device.deprovisioned_at %}
@@ -41,6 +42,16 @@
{% endif %}
+ Struktur+Organisationseinheiten (OEs) zum Verorten Ihrer Geräte — z.B. Gebäude oder Klassen. Ein Gerät gehört immer zu genau einer OE, neu aktivierte Geräte landen automatisch in "Neue Geräte". + +{% macro knoten(oe, oes) %} +
+
+ {% set kinder = oes | selectattr("parent_id", "equalto", oe.id) | list %}
+ {% if kinder %}
+ Unter-OE anlegen+ +
Keine OEs gefunden. +{% else %} +
+
+{% endblock %}
diff --git a/templates/struktur_loeschen.html b/templates/struktur_loeschen.html
new file mode 100644
index 0000000..d946e7e
--- /dev/null
+++ b/templates/struktur_loeschen.html
@@ -0,0 +1,17 @@
+{% extends "base.html" %}
+{% block title %}OE löschen — Tuxflotte{% endblock %}
+{% block content %}
+
+Neue OE auf oberster Ebene anlegen+ +OE löschen+ +{% if fehler %} +{{ fehler }} +{% endif %} + +Soll die OE "{{ oe.name }}" wirklich gelöscht werden? + + +{% endblock %} diff --git a/templates/struktur_merkmale.html b/templates/struktur_merkmale.html new file mode 100644 index 0000000..39c3eec --- /dev/null +++ b/templates/struktur_merkmale.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}Merkmale {{ oe.name }} — Tuxflotte{% endblock %} +{% block content %} + +Merkmale: {{ oe.name }}+Legt fest, was Geräte in dieser OE (und ihren Unter-OEs, sofern dort nichts Spezifischeres gilt) standardmäßig bekommen. "Nicht festgelegt" überlässt die Entscheidung der nächsten Schicht (Gruppen, dann Workspace-Standard). + +{% if not kategorien %} +Keine katalogfähigen Merkmale vorhanden. +{% else %} + {% for kategorie_name, eintraege in kategorien.items() %} +{{ kategorie_name }}+
+
+
OE umbenennen+ + +{% endblock %} |