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>
This commit is contained in:
parent
232d621245
commit
ea92b3c54c
2
app.py
2
app.py
@ -8,6 +8,7 @@ from routers import (
|
|||||||
admin_kunden,
|
admin_kunden,
|
||||||
admin_technik,
|
admin_technik,
|
||||||
geraete,
|
geraete,
|
||||||
|
gruppen,
|
||||||
installationsmedium,
|
installationsmedium,
|
||||||
organisation,
|
organisation,
|
||||||
struktur,
|
struktur,
|
||||||
@ -21,6 +22,7 @@ app.include_router(auth.router)
|
|||||||
app.include_router(geraete.router)
|
app.include_router(geraete.router)
|
||||||
app.include_router(organisation.router)
|
app.include_router(organisation.router)
|
||||||
app.include_router(struktur.router)
|
app.include_router(struktur.router)
|
||||||
|
app.include_router(gruppen.router)
|
||||||
app.include_router(installationsmedium.router)
|
app.include_router(installationsmedium.router)
|
||||||
app.include_router(admin_technik.router)
|
app.include_router(admin_technik.router)
|
||||||
app.include_router(admin_geraete.router)
|
app.include_router(admin_geraete.router)
|
||||||
|
|||||||
180
routers/gruppen.py
Normal file
180
routers/gruppen.py
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
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)
|
||||||
@ -20,6 +20,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><a href="/geraete">Geräte</a></li>
|
<li><a href="/geraete">Geräte</a></li>
|
||||||
<li><a href="/struktur">Struktur</a></li>
|
<li><a href="/struktur">Struktur</a></li>
|
||||||
|
<li><a href="/gruppen">Gruppen</a></li>
|
||||||
<li><a href="/installationsmedium">Installationsmedium</a></li>
|
<li><a href="/installationsmedium">Installationsmedium</a></li>
|
||||||
<li><a href="/organisation">Organisation</a></li>
|
<li><a href="/organisation">Organisation</a></li>
|
||||||
{% if user.is_superuser %}<li><a href="/admin/technik">Admin</a></li>{% endif %}
|
{% if user.is_superuser %}<li><a href="/admin/technik">Admin</a></li>{% endif %}
|
||||||
|
|||||||
91
templates/gruppe_detail.html
Normal file
91
templates/gruppe_detail.html
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Gruppe {{ gruppe.name }} — Tuxflotte{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="/gruppen">← Gruppen</a></p>
|
||||||
|
<h2>Gruppe: {{ gruppe.name }}</h2>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Merkmale</h3>
|
||||||
|
<p><small>"Nicht festgelegt" überlässt die Entscheidung der OE-Kette bzw. dem Workspace-Standard des Geräts. Gehört ein Gerät mehreren Gruppen mit widersprüchlicher Einstellung an, gewinnt "aktiv".</small></p>
|
||||||
|
{% if not kategorien %}
|
||||||
|
<p>Keine katalogfähigen Merkmale vorhanden.</p>
|
||||||
|
{% else %}
|
||||||
|
{% for kategorie_name, eintraege in kategorien.items() %}
|
||||||
|
<h4>{{ kategorie_name }}</h4>
|
||||||
|
<figure>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{% for eintrag in eintraege %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ eintrag.name }}</strong>
|
||||||
|
{% if eintrag.description %}<br><small>{{ eintrag.description }}</small>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if eintrag.aktiv is none %}
|
||||||
|
<span class="state-absent">nicht festgelegt</span>
|
||||||
|
{% elif eintrag.aktiv %}
|
||||||
|
<span class="state-present">aktiv</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="state-absent">inaktiv</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if eintrag.aktiv != true %}
|
||||||
|
<form method="post" action="/gruppen/{{ gruppe.id }}/merkmale/{{ eintrag.merkmal }}/select">
|
||||||
|
<button type="submit">Aktiv setzen</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if eintrag.aktiv != false %}
|
||||||
|
<form method="post" action="/gruppen/{{ gruppe.id }}/merkmale/{{ eintrag.merkmal }}/deselect">
|
||||||
|
<button type="submit" class="secondary">Inaktiv setzen</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if eintrag.aktiv is not none %}
|
||||||
|
<form method="post" action="/gruppen/{{ gruppe.id }}/merkmale/{{ eintrag.merkmal }}/unset">
|
||||||
|
<button type="submit" class="outline">Zurücksetzen</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</figure>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>Geräte in dieser Gruppe</h3>
|
||||||
|
{% if not devices %}
|
||||||
|
<p>Keine Geräte vorhanden.</p>
|
||||||
|
{% else %}
|
||||||
|
<figure>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Hostname</th><th>Kennung</th><th>Mitglied</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for device in devices %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ device.hostname or "—" }}</td>
|
||||||
|
<td><code>{{ device.device_fingerprint }}</code></td>
|
||||||
|
<td>{{ "ja" if device.id in mitglied_ids else "nein" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if device.id in mitglied_ids %}
|
||||||
|
<form method="post" action="/gruppen/{{ gruppe.id }}/geraete/{{ device.id }}/entfernen">
|
||||||
|
<button type="submit" class="outline secondary">Entfernen</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="/gruppen/{{ gruppe.id }}/geraete/{{ device.id }}/hinzufuegen">
|
||||||
|
<button type="submit" class="outline">Hinzufügen</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</figure>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
13
templates/gruppe_loeschen.html
Normal file
13
templates/gruppe_loeschen.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Gruppe löschen — Tuxflotte{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="/gruppen">← Gruppen</a></p>
|
||||||
|
<h2>Gruppe löschen</h2>
|
||||||
|
|
||||||
|
<p>Soll die Gruppe "{{ gruppe.name }}" wirklich gelöscht werden? Betroffene Geräte verlieren die Merkmal-Einstellungen dieser Gruppe.</p>
|
||||||
|
|
||||||
|
<form method="post" action="/gruppen/{{ gruppe.id }}/loeschen">
|
||||||
|
<button type="submit">Ja, Gruppe löschen</button>
|
||||||
|
<a href="/gruppen" role="button" class="secondary outline">Abbrechen</a>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
12
templates/gruppe_umbenennen.html
Normal file
12
templates/gruppe_umbenennen.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Gruppe umbenennen — Tuxflotte{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<p><a href="/gruppen">← Gruppen</a></p>
|
||||||
|
<h2>Gruppe umbenennen</h2>
|
||||||
|
|
||||||
|
<form method="post" action="/gruppen/{{ gruppe.id }}/umbenennen">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input type="text" id="name" name="name" value="{{ gruppe.name }}" required>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
37
templates/gruppen_liste.html
Normal file
37
templates/gruppen_liste.html
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Gruppen — Tuxflotte{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h2>Gruppen</h2>
|
||||||
|
<p>Querschnitts-Gruppen (z.B. Lehrer/Schulleitung) — ein Gerät kann in mehreren gleichzeitig sein. Anders als Organisationseinheiten sind Gruppen nicht verschachtelt.</p>
|
||||||
|
|
||||||
|
{% if not gruppen %}
|
||||||
|
<p>Keine Gruppen angelegt.</p>
|
||||||
|
{% else %}
|
||||||
|
<figure>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for gruppe in gruppen %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ gruppe.name }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/gruppen/{{ gruppe.id }}" role="button" class="outline">Verwalten</a>
|
||||||
|
<a href="/gruppen/{{ gruppe.id }}/umbenennen" role="button" class="secondary outline">Umbenennen</a>
|
||||||
|
<a href="/gruppen/{{ gruppe.id }}/loeschen" role="button" class="secondary outline">Löschen</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</figure>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary role="button" class="secondary outline">Neue Gruppe anlegen</summary>
|
||||||
|
<form method="post" action="/gruppen">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input type="text" id="name" name="name" required>
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endblock %}
|
||||||
Loading…
x
Reference in New Issue
Block a user