feat: Konfigurationsgruppen im Admin-Auftragskatalog-UI

- Bugfix: auftragskatalog.html hatte Select/Deselect-Formular-Ziele fest
  auf /geraete/... verdrahtet statt zurueck_url zu nutzen - Admin-Ansicht
  eines fremden-Org-Geraets scheiterte dadurch mit 404. Jetzt dynamisch.
- Herkunfts-Anzeige ('via OE'/'via Gruppe') ist jetzt anklickbar, sofern
  ein Sprungziel existiert (neue anreichere_mit_quelle_url()-Hilfsfunktion
  in routers/geraete.py, von geraete.py und admin_geraete.py genutzt).
- Admin-Geräteliste (/admin/geraete) bekommt OE-Spalte + 'In OE
  verschieben', analog zur Kundenportal-Liste.
- Neue, bewusst schlanke Admin-OE-Ansicht
  (/admin/kunden/{org}/organisationseinheiten/{oe}/merkmale) - nur der
  Merkmale-Editor als Sprungziel, kein OE-CRUD (bleibt laut ADR-0012
  Kunden-Self-Service unter /struktur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-12 21:28:10 +02:00
parent ea92b3c54c
commit ca25ce030e
6 changed files with 174 additions and 7 deletions

View File

@ -1,9 +1,9 @@
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
from auth import require_admin
from routers.geraete import OPTIONEN_FELDER, gruppiere_nach_kategorie
from routers.geraete import OPTIONEN_FELDER, anreichere_mit_quelle_url, gruppiere_nach_kategorie
from templating import templates
@ -26,11 +26,31 @@ def index(request: Request, user: dict = Depends(require_admin)):
result = anode_request("GET", "/api/v1/devices")
devices = result.get("devices", []) if result.get("success") else []
# Admin-Liste ist organisationsübergreifend - jede Zeile braucht die
# OE-Auswahl ihrer eigenen Organisation für "In OE verschieben", daher
# je vorkommender Organisation einmal (nicht pro Gerät) abfragen.
oe_by_org: dict[str, list[dict]] = {}
for organization_id in {device["organization_id"] for device in devices}:
oe_result = anode_request("GET", f"/api/v1/organizations/{organization_id}/organisationseinheiten")
oe_by_org[organization_id] = oe_result.get("organisationseinheiten", []) if oe_result.get("success") else []
return templates.TemplateResponse(
request, "admin/geraete_liste.html", {"user": user, "devices": devices}
request,
"admin/geraete_liste.html",
{"user": user, "devices": devices, "oe_by_org": oe_by_org},
)
@router.post("/{device_id}/oe")
def oe_verschieben(device_id: str, oe_id: str = Form(...), user: dict = Depends(require_admin)):
if find_device(device_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("/admin/geraete", status_code=303)
@router.get("/{device_id}")
def auftragskatalog_ansicht(request: Request, device_id: str, user: dict = Depends(require_admin)):
device = find_device(device_id)
@ -42,6 +62,12 @@ def auftragskatalog_ansicht(request: Request, device_id: str, user: dict = Depen
katalog = result.get("auftragskatalog", []) if result.get("success") else []
fehler = None if result.get("success") else result.get("message")
katalog = anreichere_mit_quelle_url(
katalog,
f"/admin/kunden/{device['organization_id']}/organisationseinheiten",
f"/admin/kunden/{device['organization_id']}/gruppen",
)
return templates.TemplateResponse(
request,
"auftragskatalog.html",

View File

@ -228,3 +228,48 @@ async def konto_anlegen(
)
return RedirectResponse(f"/admin/kunden/{organization_id}", status_code=303)
@router.get("/{organization_id}/organisationseinheiten/{oe_id}/merkmale")
def oe_merkmale_ansicht(request: Request, organization_id: str, oe_id: str, user: dict = Depends(require_admin)):
"""
Minimale Admin-Ansicht einer OE - anders als bei Gruppen kein volles
CRUD hier (OE-Struktur bleibt laut ADR-0012 Kunden-Self-Service unter
/struktur), nur der Merkmale-Editor als Sprungziel von der
Auftragskatalog-Herkunftsanzeige aus.
"""
merkmale_result = anode_request("GET", f"/api/v1/organisationseinheiten/{oe_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)
return templates.TemplateResponse(
request,
"admin/oe_merkmale.html",
{"user": user, "organization_id": organization_id, "oe_id": oe_id, "kategorien": kategorien},
)
@router.post("/{organization_id}/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/select")
def admin_oe_merkmal_select(organization_id: str, oe_id: str, merkmal_key: str, user: dict = Depends(require_admin)):
anode_request("POST", f"/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/select")
return RedirectResponse(f"/admin/kunden/{organization_id}/organisationseinheiten/{oe_id}/merkmale", status_code=303)
@router.post("/{organization_id}/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/deselect")
def admin_oe_merkmal_deselect(organization_id: str, oe_id: str, merkmal_key: str, user: dict = Depends(require_admin)):
anode_request("POST", f"/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/deselect")
return RedirectResponse(f"/admin/kunden/{organization_id}/organisationseinheiten/{oe_id}/merkmale", status_code=303)
@router.post("/{organization_id}/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/unset")
def admin_oe_merkmal_unset(organization_id: str, oe_id: str, merkmal_key: str, user: dict = Depends(require_admin)):
anode_request("POST", f"/api/v1/organisationseinheiten/{oe_id}/merkmale/{merkmal_key}/unset")
return RedirectResponse(f"/admin/kunden/{organization_id}/organisationseinheiten/{oe_id}/merkmale", status_code=303)

View File

@ -48,6 +48,28 @@ def gruppiere_nach_kategorie(katalog: list[dict]) -> dict[str, list[dict]]:
return kategorien
def anreichere_mit_quelle_url(katalog: list[dict], struktur_praefix: str, gruppen_praefix: str) -> list[dict]:
"""
Ergänzt jeden Auftragskatalog-Eintrag um quelle_url - ein Sprungziel zur
verantwortlichen OE/Gruppe (siehe ADR-0012), passend zum jeweiligen
Kontext (Kundenportal-Self-Service vs. Admin-Bereich, siehe Aufrufer).
None für die Fälle "workspace"/"geraet", die kein sinnvolles Linkziel
haben.
"""
for eintrag in katalog:
quelle, quelle_id = eintrag.get("quelle"), eintrag.get("quelle_id")
if quelle == "oe" and quelle_id:
eintrag["quelle_url"] = f"{struktur_praefix}/{quelle_id}/merkmale"
elif quelle == "gruppe" and quelle_id:
eintrag["quelle_url"] = f"{gruppen_praefix}/{quelle_id}"
else:
eintrag["quelle_url"] = None
return katalog
@router.get("/")
def index(user: dict = Depends(get_current_user)):
return RedirectResponse("/geraete", status_code=303)
@ -79,6 +101,8 @@ def auftragskatalog_ansicht(request: Request, device_id: str, user: dict = Depen
katalog = result.get("auftragskatalog", []) if result.get("success") else []
fehler = None if result.get("success") else result.get("message")
katalog = anreichere_mit_quelle_url(katalog, "/struktur", "/gruppen")
return templates.TemplateResponse(
request,
"auftragskatalog.html",

View File

@ -8,7 +8,7 @@
<figure>
<table>
<thead>
<tr><th>Organisation</th><th>Hostname</th><th>Kennung</th><th>Letzter Check-in</th><th></th></tr>
<tr><th>Organisation</th><th>Hostname</th><th>Kennung</th><th>OE</th><th>Letzter Check-in</th><th></th><th></th></tr>
</thead>
<tbody>
{% for device in devices %}
@ -16,8 +16,19 @@
<td>{{ device.organization_name }}</td>
<td>{{ device.hostname or "—" }}</td>
<td><code>{{ device.device_fingerprint }}</code></td>
<td>{{ device.oe_name or "—" }}</td>
<td>{{ device.agent_last_checkin or "noch nie" }}</td>
<td><a href="/admin/geraete/{{ device.id }}" role="button" class="outline">Auftragskatalog →</a></td>
<td>
<form method="post" action="/admin/geraete/{{ device.id }}/oe">
<select name="oe_id" aria-label="OE auswählen">
{% for oe in oe_by_org.get(device.organization_id, []) %}
<option value="{{ oe.id }}" {% if oe.id == device.oe_id %}selected{% endif %}>{{ oe.name }}</option>
{% endfor %}
</select>
<button type="submit" class="outline">In OE verschieben</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>

View File

@ -0,0 +1,55 @@
{% extends "admin/layout.html" %}
{% block title %}OE-Merkmale — Tuxflotte Admin{% endblock %}
{% block admin_content %}
<p><a href="/admin/kunden/{{ organization_id }}">← Organisation</a></p>
<h2>Merkmale dieser OE</h2>
<p><small>"Nicht festgelegt" überlässt die Entscheidung einer übergeordneten OE bzw. dem Workspace-Standard des Geräts. Die OE-Struktur selbst wird im Kundenportal verwaltet (Self-Service), diese Seite dient nur der Merkmal-Konfiguration.</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="/admin/kunden/{{ organization_id }}/organisationseinheiten/{{ oe_id }}/merkmale/{{ eintrag.merkmal }}/select" style="display:inline">
<button type="submit">Aktiv</button>
</form>
{% endif %}
{% if eintrag.aktiv != false %}
<form method="post" action="/admin/kunden/{{ organization_id }}/organisationseinheiten/{{ oe_id }}/merkmale/{{ eintrag.merkmal }}/deselect" style="display:inline">
<button type="submit" class="secondary">Inaktiv</button>
</form>
{% endif %}
{% if eintrag.aktiv is not none %}
<form method="post" action="/admin/kunden/{{ organization_id }}/organisationseinheiten/{{ oe_id }}/merkmale/{{ eintrag.merkmal }}/unset" style="display:inline">
<button type="submit" class="outline">Zurücksetzen</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{% endfor %}
{% endif %}
{% endblock %}

View File

@ -22,16 +22,22 @@
<td class="state-{{ eintrag.state }}">
{{ "ausgewählt" if eintrag.state == "present" else "nicht ausgewählt" }}
{% if eintrag.quelle and eintrag.quelle != "workspace" %}
<br><small>via {{ {"oe": "OE", "gruppe": "Gruppe", "geraet": "manuell"}[eintrag.quelle] }}</small>
<br><small>
{% if eintrag.quelle_url %}
via <a href="{{ eintrag.quelle_url }}">{{ {"oe": "OE", "gruppe": "Gruppe"}[eintrag.quelle] }}</a>
{% else %}
via {{ {"oe": "OE", "gruppe": "Gruppe", "geraet": "manuell"}[eintrag.quelle] }}
{% endif %}
</small>
{% endif %}
</td>
<td>
{% if eintrag.state == "present" %}
<form method="post" action="/geraete/{{ device.id }}/auftragskatalog/{{ eintrag.merkmal }}/deselect">
<form method="post" action="{{ zurueck_url }}/{{ device.id }}/auftragskatalog/{{ eintrag.merkmal }}/deselect">
<button type="submit" class="secondary outline">Abwählen</button>
</form>
{% else %}
<form method="post" action="/geraete/{{ device.id }}/auftragskatalog/{{ eintrag.merkmal }}/select">
<form method="post" action="{{ zurueck_url }}/{{ device.id }}/auftragskatalog/{{ eintrag.merkmal }}/select">
{% if optionen_felder.get(eintrag.merkmal) %}
<div class="optionen">
{% for feld in optionen_felder[eintrag.merkmal] %}