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:
parent
ea92b3c54c
commit
ca25ce030e
@ -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 fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
from anode_client import anode_request
|
from anode_client import anode_request
|
||||||
from auth import require_admin
|
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
|
from templating import templates
|
||||||
|
|
||||||
|
|
||||||
@ -26,11 +26,31 @@ def index(request: Request, user: dict = Depends(require_admin)):
|
|||||||
result = anode_request("GET", "/api/v1/devices")
|
result = anode_request("GET", "/api/v1/devices")
|
||||||
devices = result.get("devices", []) if result.get("success") else []
|
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(
|
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}")
|
@router.get("/{device_id}")
|
||||||
def auftragskatalog_ansicht(request: Request, device_id: str, user: dict = Depends(require_admin)):
|
def auftragskatalog_ansicht(request: Request, device_id: str, user: dict = Depends(require_admin)):
|
||||||
device = find_device(device_id)
|
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 []
|
katalog = result.get("auftragskatalog", []) if result.get("success") else []
|
||||||
fehler = None if result.get("success") else result.get("message")
|
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(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"auftragskatalog.html",
|
"auftragskatalog.html",
|
||||||
|
|||||||
@ -228,3 +228,48 @@ async def konto_anlegen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return RedirectResponse(f"/admin/kunden/{organization_id}", status_code=303)
|
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)
|
||||||
|
|||||||
@ -48,6 +48,28 @@ def gruppiere_nach_kategorie(katalog: list[dict]) -> dict[str, list[dict]]:
|
|||||||
return kategorien
|
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("/")
|
@router.get("/")
|
||||||
def index(user: dict = Depends(get_current_user)):
|
def index(user: dict = Depends(get_current_user)):
|
||||||
return RedirectResponse("/geraete", status_code=303)
|
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 []
|
katalog = result.get("auftragskatalog", []) if result.get("success") else []
|
||||||
fehler = None if result.get("success") else result.get("message")
|
fehler = None if result.get("success") else result.get("message")
|
||||||
|
|
||||||
|
katalog = anreichere_mit_quelle_url(katalog, "/struktur", "/gruppen")
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"auftragskatalog.html",
|
"auftragskatalog.html",
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
<figure>
|
<figure>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for device in devices %}
|
{% for device in devices %}
|
||||||
@ -16,8 +16,19 @@
|
|||||||
<td>{{ device.organization_name }}</td>
|
<td>{{ device.organization_name }}</td>
|
||||||
<td>{{ device.hostname or "—" }}</td>
|
<td>{{ device.hostname or "—" }}</td>
|
||||||
<td><code>{{ device.device_fingerprint }}</code></td>
|
<td><code>{{ device.device_fingerprint }}</code></td>
|
||||||
|
<td>{{ device.oe_name or "—" }}</td>
|
||||||
<td>{{ device.agent_last_checkin or "noch nie" }}</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><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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
55
templates/admin/oe_merkmale.html
Normal file
55
templates/admin/oe_merkmale.html
Normal 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 %}
|
||||||
@ -22,16 +22,22 @@
|
|||||||
<td class="state-{{ eintrag.state }}">
|
<td class="state-{{ eintrag.state }}">
|
||||||
{{ "ausgewählt" if eintrag.state == "present" else "nicht ausgewählt" }}
|
{{ "ausgewählt" if eintrag.state == "present" else "nicht ausgewählt" }}
|
||||||
{% if eintrag.quelle and eintrag.quelle != "workspace" %}
|
{% 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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if eintrag.state == "present" %}
|
{% 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>
|
<button type="submit" class="secondary outline">Abwählen</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% 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) %}
|
{% if optionen_felder.get(eintrag.merkmal) %}
|
||||||
<div class="optionen">
|
<div class="optionen">
|
||||||
{% for feld in optionen_felder[eintrag.merkmal] %}
|
{% for feld in optionen_felder[eintrag.merkmal] %}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user