Thomas Stallinger ea92b3c54c feat: Gruppen-Selfservice im Kundenportal
Kund:innen können jetzt eigene Gruppen selbst verwalten (/gruppen) statt
nur intern über /admin/kunden - reiner Kundenplattform-Zusatz, keine
Backend-Änderung nötig (anode-API war bereits organisationsgebunden und
generisch aufgebaut, siehe ADR-0012). Admin-Zugang bleibt zusätzlich
bestehen (Support/Onboarding), beide teilen sich dieselbe anode-API.

Neuer Router routers/gruppen.py (eng an struktur.py angelehnt, gleiches
Besitz-Validierungsmuster), Templates gruppen_liste/gruppe_detail
(Merkmale-Tristate-Editor + Geräte-Mitgliedschaft, wie
admin/gruppe_detail.html)/gruppe_umbenennen/gruppe_loeschen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 20:57:48 +02:00

181 lines
7.6 KiB
Python

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_gruppen(organization_id: str) -> list[dict]:
result = anode_request("GET", f"/api/v1/organizations/{organization_id}/gruppen")
return result.get("gruppen", []) if result.get("success") else []
def find_gruppe_in_organization(gruppe_id: str, organization_id: str) -> dict | None:
"""
Besitz-Validierung analog zu find_oe_in_organization() in
routers/struktur.py (siehe ADR-0011): eine gruppe_id aus der URL wird
nur akzeptiert, wenn sie tatsächlich in der Gruppenliste der eigenen
Organisation auftaucht.
"""
for gruppe in fetch_gruppen(organization_id):
if gruppe["id"] == gruppe_id:
return gruppe
return None
@router.get("/gruppen")
def gruppen_liste(request: Request, user=Depends(get_current_user)):
gruppen = fetch_gruppen(user.organization_id)
return templates.TemplateResponse(request, "gruppen_liste.html", {"user": user, "gruppen": gruppen})
@router.post("/gruppen")
def gruppe_anlegen(name: str = Form(...), user=Depends(get_current_user)):
anode_request("POST", f"/api/v1/organizations/{user.organization_id}/gruppen", json={"name": name})
return RedirectResponse("/gruppen", status_code=303)
@router.get("/gruppen/{gruppe_id}/umbenennen")
def gruppe_umbenennen_form(request: Request, gruppe_id: str, user=Depends(get_current_user)):
gruppe = find_gruppe_in_organization(gruppe_id, user.organization_id)
if gruppe is None:
raise HTTPException(status_code=404, detail="Gruppe wurde nicht gefunden.")
return templates.TemplateResponse(request, "gruppe_umbenennen.html", {"user": user, "gruppe": gruppe})
@router.post("/gruppen/{gruppe_id}/umbenennen")
def gruppe_umbenennen(gruppe_id: str, name: str = Form(...), user=Depends(get_current_user)):
if find_gruppe_in_organization(gruppe_id, user.organization_id) is None:
raise HTTPException(status_code=404, detail="Gruppe wurde nicht gefunden.")
anode_request("PATCH", f"/api/v1/gruppen/{gruppe_id}", json={"name": name})
return RedirectResponse("/gruppen", status_code=303)
@router.get("/gruppen/{gruppe_id}/loeschen")
def gruppe_loeschen_form(request: Request, gruppe_id: str, user=Depends(get_current_user)):
gruppe = find_gruppe_in_organization(gruppe_id, user.organization_id)
if gruppe is None:
raise HTTPException(status_code=404, detail="Gruppe wurde nicht gefunden.")
return templates.TemplateResponse(request, "gruppe_loeschen.html", {"user": user, "gruppe": gruppe})
@router.post("/gruppen/{gruppe_id}/loeschen")
def gruppe_loeschen(gruppe_id: str, user=Depends(get_current_user)):
if find_gruppe_in_organization(gruppe_id, user.organization_id) is None:
raise HTTPException(status_code=404, detail="Gruppe wurde nicht gefunden.")
anode_request("DELETE", f"/api/v1/gruppen/{gruppe_id}")
return RedirectResponse("/gruppen", status_code=303)
@router.get("/gruppen/{gruppe_id}")
def gruppe_detail(request: Request, gruppe_id: str, user=Depends(get_current_user)):
gruppe = find_gruppe_in_organization(gruppe_id, user.organization_id)
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/{user.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
# (gleiches Muster wie admin_kunden.py:gruppe_detail()).
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,
"gruppe_detail.html",
{
"user": user,
"gruppe": gruppe,
"kategorien": kategorien,
"devices": devices,
"mitglied_ids": mitglied_ids,
},
)
@router.post("/gruppen/{gruppe_id}/merkmale/{merkmal_key}/select")
def gruppe_merkmal_select(gruppe_id: str, merkmal_key: str, user=Depends(get_current_user)):
if find_gruppe_in_organization(gruppe_id, user.organization_id) is None:
raise HTTPException(status_code=404, detail="Gruppe wurde nicht gefunden.")
anode_request("POST", f"/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/select")
return RedirectResponse(f"/gruppen/{gruppe_id}", status_code=303)
@router.post("/gruppen/{gruppe_id}/merkmale/{merkmal_key}/deselect")
def gruppe_merkmal_deselect(gruppe_id: str, merkmal_key: str, user=Depends(get_current_user)):
if find_gruppe_in_organization(gruppe_id, user.organization_id) is None:
raise HTTPException(status_code=404, detail="Gruppe wurde nicht gefunden.")
anode_request("POST", f"/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/deselect")
return RedirectResponse(f"/gruppen/{gruppe_id}", status_code=303)
@router.post("/gruppen/{gruppe_id}/merkmale/{merkmal_key}/unset")
def gruppe_merkmal_unset(gruppe_id: str, merkmal_key: str, user=Depends(get_current_user)):
if find_gruppe_in_organization(gruppe_id, user.organization_id) is None:
raise HTTPException(status_code=404, detail="Gruppe wurde nicht gefunden.")
anode_request("POST", f"/api/v1/gruppen/{gruppe_id}/merkmale/{merkmal_key}/unset")
return RedirectResponse(f"/gruppen/{gruppe_id}", status_code=303)
@router.post("/gruppen/{gruppe_id}/geraete/{device_id}/hinzufuegen")
def gruppe_geraet_hinzufuegen(gruppe_id: str, device_id: str, user=Depends(get_current_user)):
if find_gruppe_in_organization(gruppe_id, user.organization_id) is None:
raise HTTPException(status_code=404, detail="Gruppe wurde nicht gefunden.")
# Besitz-Validierung fürs Gerät reicht hier über den bestehenden
# find_device_in_organization()-Umweg nicht direkt, aber anode prüft
# bei add_device_to_gruppe() bereits serverseitig, dass Gerät und
# Gruppe zur selben Organisation gehören (siehe app.py) - ein Zugriff
# auf ein fremdes device_id würde dort mit invalid_membership abgelehnt.
anode_request("POST", f"/api/v1/devices/{device_id}/gruppen/{gruppe_id}")
return RedirectResponse(f"/gruppen/{gruppe_id}", status_code=303)
@router.post("/gruppen/{gruppe_id}/geraete/{device_id}/entfernen")
def gruppe_geraet_entfernen(gruppe_id: str, device_id: str, user=Depends(get_current_user)):
if find_gruppe_in_organization(gruppe_id, user.organization_id) is None:
raise HTTPException(status_code=404, detail="Gruppe wurde nicht gefunden.")
anode_request("DELETE", f"/api/v1/devices/{device_id}/gruppen/{gruppe_id}")
return RedirectResponse(f"/gruppen/{gruppe_id}", status_code=303)