feat: Struktur (OE-Selfservice) + Gruppen-Verwaltung (Admin)

Kundenportal:
- Neue Seite /struktur - OE-Baum (rekursives Jinja-Makro), Anlegen/
  Umbenennen/Löschen (Standard-OE 'Neue Geräte' geschützt), Merkmale-
  Editor pro OE mit Tristate aktiv/inaktiv/nicht festgelegt.
- geraete_liste.html: neue Spalte 'OE', Aktionen-Dropdown um 'In OE
  verschieben' erweitert.
- auftragskatalog.html: zeigt jetzt die Herkunft einer Einstellung an
  (OE/Gruppe/manuell), sofern sie nicht vom Workspace-Default kommt.

Admin (/admin/kunden/{id}):
- Neue Sektion 'Gruppen' - Anlegen/Löschen, verlinkt auf neue
  Detailseite (admin/gruppe_detail.html) mit Merkmale-Editor (wie OE)
  und Geräte-Mitgliedschaft-Checkliste. Bewusst nur intern verwaltbar
  in v1 (siehe Konfigurationsgruppen-Diskussion).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-12 16:41:04 +02:00
parent af6e93067f
commit 232d621245
13 changed files with 552 additions and 4 deletions

2
app.py
View File

@ -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)

View File

@ -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,

View File

@ -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)

156
routers/struktur.py Normal file
View File

@ -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)

View File

@ -0,0 +1,88 @@
{% extends "admin/layout.html" %}
{% block title %}Gruppe {{ gruppe.name }} — Tuxflotte Admin{% endblock %}
{% block admin_content %}
<p><a href="/admin/kunden/{{ organization_id }}">← Organisation</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.</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>{{ eintrag.name }}</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 }}/gruppen/{{ gruppe.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 }}/gruppen/{{ gruppe.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 }}/gruppen/{{ gruppe.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 %}
</section>
<section>
<h3>Geräte in dieser Gruppe</h3>
{% if not devices %}
<p>Keine Geräte in dieser Organisation.</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="/admin/kunden/{{ organization_id }}/gruppen/{{ gruppe.id }}/geraete/{{ device.id }}/entfernen">
<button type="submit" class="outline secondary">Entfernen</button>
</form>
{% else %}
<form method="post" action="/admin/kunden/{{ organization_id }}/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 %}

View File

@ -117,4 +117,39 @@
</form>
</details>
</section>
<section>
<h3>Gruppen</h3>
<p><small>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.</small></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="/admin/kunden/{{ organization_id }}/gruppen/{{ gruppe.id }}" role="button" class="outline">Verwalten</a>
<form method="post" action="/admin/kunden/{{ organization_id }}/gruppen/{{ gruppe.id }}/loeschen" style="display:inline">
<button type="submit" class="outline secondary">Löschen</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{% endif %}
<details>
<summary role="button" class="secondary outline">Neue Gruppe anlegen</summary>
<form method="post" action="/admin/kunden/{{ organization_id }}/gruppen">
<label for="gruppe_name">Name</label>
<input type="text" id="gruppe_name" name="name" required>
<button type="submit">Anlegen</button>
</form>
</details>
</section>
{% endblock %}

View File

@ -21,6 +21,9 @@
</td>
<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>
{% endif %}
</td>
<td>
{% if eintrag.state == "present" %}

View File

@ -19,6 +19,7 @@
{% if user %}
<ul>
<li><a href="/geraete">Geräte</a></li>
<li><a href="/struktur">Struktur</a></li>
<li><a href="/installationsmedium">Installationsmedium</a></li>
<li><a href="/organisation">Organisation</a></li>
{% if user.is_superuser %}<li><a href="/admin/technik">Admin</a></li>{% endif %}

View File

@ -8,13 +8,14 @@
<figure>
<table>
<thead>
<tr><th>Hostname</th><th>Kennung</th><th>Letzter Check-in</th><th>Status</th><th></th><th></th></tr>
<tr><th>Hostname</th><th>Kennung</th><th>OE</th><th>Letzter Check-in</th><th>Status</th><th></th><th></th></tr>
</thead>
<tbody>
{% for device in devices %}
<tr>
<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>
{% if device.deprovisioned_at %}
@ -41,6 +42,16 @@
</form>
{% endif %}
</li>
<li>
<form method="post" action="/geraete/{{ device.id }}/oe">
<select name="oe_id" aria-label="OE auswählen">
{% for oe in organisationseinheiten %}
<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>
</li>
<li><a href="/geraete/{{ device.id }}/loeschen">Gerät löschen</a></li>
</ul>
</details>

54
templates/struktur.html Normal file
View File

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Struktur — Tuxflotte{% endblock %}
{% block content %}
<h2>Struktur</h2>
<p>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".</p>
{% macro knoten(oe, oes) %}
<li>
<strong>{{ oe.name }}</strong>{% if oe.ist_standard %} <small>(Standard)</small>{% endif %}
<small>
· <a href="/struktur/{{ oe.id }}/merkmale">Merkmale bearbeiten</a>
{% if not oe.ist_standard %}
· <a href="/struktur/{{ oe.id }}/umbenennen">Umbenennen</a>
· <a href="/struktur/{{ oe.id }}/loeschen">Löschen</a>
{% endif %}
</small>
<details>
<summary role="button" class="secondary outline">Unter-OE anlegen</summary>
<form method="post" action="/struktur">
<input type="hidden" name="parent_id" value="{{ oe.id }}">
<input type="text" name="name" placeholder="Name" required>
<button type="submit">Anlegen</button>
</form>
</details>
{% set kinder = oes | selectattr("parent_id", "equalto", oe.id) | list %}
{% if kinder %}
<ul>
{% for kind in kinder %}
{{ knoten(kind, oes) }}
{% endfor %}
</ul>
{% endif %}
</li>
{% endmacro %}
{% set wurzeln = organisationseinheiten | selectattr("parent_id", "equalto", none) | list %}
{% if not wurzeln %}
<p>Keine OEs gefunden.</p>
{% else %}
<ul>
{% for wurzel in wurzeln %}
{{ knoten(wurzel, organisationseinheiten) }}
{% endfor %}
</ul>
{% endif %}
<details>
<summary role="button" class="secondary outline">Neue OE auf oberster Ebene anlegen</summary>
<form method="post" action="/struktur">
<input type="text" name="name" placeholder="Name" required>
<button type="submit">Anlegen</button>
</form>
</details>
{% endblock %}

View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}OE löschen — Tuxflotte{% endblock %}
{% block content %}
<p><a href="/struktur">← Struktur</a></p>
<h2>OE löschen</h2>
{% if fehler %}
<p><mark>{{ fehler }}</mark></p>
{% endif %}
<p>Soll die OE "{{ oe.name }}" wirklich gelöscht werden?</p>
<form method="post" action="/struktur/{{ oe.id }}/loeschen">
<button type="submit">Ja, OE löschen</button>
<a href="/struktur" role="button" class="secondary outline">Abbrechen</a>
</form>
{% endblock %}

View File

@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block title %}Merkmale {{ oe.name }} — Tuxflotte{% endblock %}
{% block content %}
<p><a href="/struktur">← Struktur</a></p>
<h2>Merkmale: {{ oe.name }}</h2>
<p>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).</p>
{% if not kategorien %}
<p>Keine katalogfähigen Merkmale vorhanden.</p>
{% else %}
{% for kategorie_name, eintraege in kategorien.items() %}
<section>
<h3>{{ kategorie_name }}</h3>
<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="/struktur/{{ oe.id }}/merkmale/{{ eintrag.merkmal }}/select">
<button type="submit">Aktiv setzen</button>
</form>
{% endif %}
{% if eintrag.aktiv != false %}
<form method="post" action="/struktur/{{ oe.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="/struktur/{{ oe.id }}/merkmale/{{ eintrag.merkmal }}/unset">
<button type="submit" class="outline">Zurücksetzen</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
</section>
{% endfor %}
{% endif %}
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}OE umbenennen — Tuxflotte{% endblock %}
{% block content %}
<p><a href="/struktur">← Struktur</a></p>
<h2>OE umbenennen</h2>
<form method="post" action="/struktur/{{ oe.id }}/umbenennen">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{ oe.name }}" required>
<button type="submit">Speichern</button>
</form>
{% endblock %}