Org-Ressourcen: Seite Organisation > Ressourcen und Admin-Reiter

Kunden-Admins pflegen Mail, Nextcloud, ownCloud, WebDAV, SMB und Web-Proxy
ihrer Organisation (Formulare aus den Typ-Definitionen des Servers, keine
Zugangsdaten), Wirkung fuer die ganze Org, OEs oder Gruppen. Unterseite
erscheint nur fuer freigeschaltete Organisationen; root pflegt immer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-15 13:02:51 +02:00
1 parent a104a026c0
commit ccf7b547a7
9 files changed
+341

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
# Org-Ressourcen (15.10.2026, platform-docs roadmap/org-ressourcen-konzept.md):
# gemeinsamer Teil von Kunden-Seite (/organisation/ressourcen) und Admin-
# Reiter (/admin/kunden/<org>/ressourcen). Die Typen und ihre Felder
# definiert allein der Server (ORG_RESSOURCEN_TYPEN in provisioning-server),
# das Formular wird daraus erzeugt.
from urllib.parse import urlencode
from anode_client import anode_request
def lade_ressourcen_kontext(organization_id: str) -> dict:
"""Alles, was templates/_ressourcen.html braucht."""
typen = anode_request("GET", "/api/v1/org-ressourcen/typen").get("typen", {})
liste = anode_request("GET", f"/api/v1/organizations/{organization_id}/ressourcen")
oes_result = anode_request("GET", f"/api/v1/organizations/{organization_id}/organisationseinheiten")
oes = oes_result.get("organisationseinheiten", []) if oes_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 []
# OE-Pfad statt nur Name ("Schule › Haus A"), sortiert wie der Baum.
by_id = {oe["id"]: oe for oe in oes}
def pfad(oe):
teile, current, gesehen = [], oe, set()
while current is not None and current["id"] not in gesehen:
gesehen.add(current["id"])
teile.append(current["name"])
current = by_id.get(current.get("parent_id"))
return " › ".join(reversed(teile))
oe_auswahl = sorted(({"id": oe["id"], "name": pfad(oe)} for oe in oes), key=lambda o: o["name"].lower())
return {
"ressourcen_typen": typen,
"ressourcen": liste.get("ressourcen", []) if liste.get("success") else [],
"ressourcen_freigeschaltet": bool(liste.get("freigeschaltet")),
"ressourcen_oes": oe_auswahl,
"ressourcen_gruppen": gruppen,
}
def ressourcen_freigeschaltet(organization_id: str) -> bool:
liste = anode_request("GET", f"/api/v1/organizations/{organization_id}/ressourcen")
return bool(liste.get("success") and liste.get("freigeschaltet"))
def payload_aus_formular(form, typen: dict) -> dict:
"""Formularfelder (typ, name, daten.<key>, ganze_organisation,
oe_ids, gruppe_ids) in die Server-Anfrage übersetzen. Nur Felder, die der
Typ kennt; prüfen tut der Server."""
typ = form.get("typ", "")
felder = typen.get(typ, {}).get("felder", [])
daten = {f["key"]: form.get(f"daten.{f['key']}", "") for f in felder}
ganze_organisation = form.get("ganze_organisation") == "on"
return {
"typ": typ,
"name": form.get("name", ""),
"daten": daten,
"ganze_organisation": ganze_organisation,
"oe_ids": [] if ganze_organisation else form.getlist("oe_ids"),
"gruppe_ids": [] if ganze_organisation else form.getlist("gruppe_ids"),
}
def speichern(organization_id: str, ressource_id: str | None, form, ziel: str) -> str:
"""Anlegen oder ändern, liefert die Weiterleitungs-URL (mit ?fehler=…
bei einer Ablehnung durch den Server)."""
typen = anode_request("GET", "/api/v1/org-ressourcen/typen").get("typen", {})
payload = payload_aus_formular(form, typen)
if ressource_id is None:
result = anode_request("POST", f"/api/v1/organizations/{organization_id}/ressourcen", json=payload)
else:
result = anode_request("PUT", f"/api/v1/organizations/{organization_id}/ressourcen/{ressource_id}", json=payload)
if not result.get("success"):
return ziel + "?" + urlencode({"fehler": result.get("message", "Die Ressource konnte nicht gespeichert werden.")})
return ziel
def loeschen(organization_id: str, ressource_id: str, ziel: str) -> str:
result = anode_request("DELETE", f"/api/v1/organizations/{organization_id}/ressourcen/{ressource_id}")
if not result.get("success"):
return ziel + "?" + urlencode({"fehler": result.get("message", "Die Ressource konnte nicht gelöscht werden.")})
return ziel
+29
View File
@@ -8,6 +8,7 @@ from pydantic import EmailStr, TypeAdapter, ValidationError
from anode_client import anode_request
from auth import UserCreate, UserManager, fetch_benutzer_fuer_organisation, get_user_manager, require_admin
from diagnosezugang import aktiviere_diagnosezugang, deaktiviere_diagnosezugang, fetch_diagnosezugang
import ressourcen_formular
from templating import templates
@@ -188,6 +189,34 @@ def detail_gruppen(request: Request, organization_id: str, user: dict = Depends(
return _render_detail_tab(request, organization_id, user, "gruppen", gruppen=gruppen)
@router.get("/{organization_id}/ressourcen")
def detail_ressourcen(request: Request, organization_id: str, fehler: str = "", user: dict = Depends(require_admin)):
return _render_detail_tab(
request, organization_id, user, "ressourcen", fehler=fehler,
**ressourcen_formular.lade_ressourcen_kontext(organization_id),
)
# root pflegt Ressourcen auch für noch nicht freigeschaltete Organisationen
# (Ersteinrichtung); verteilt werden sie erst nach der Freischaltung.
@router.post("/{organization_id}/ressourcen")
async def ressource_anlegen(request: Request, organization_id: str, user: dict = Depends(require_admin)):
ziel = ressourcen_formular.speichern(organization_id, None, await request.form(), f"/admin/kunden/{organization_id}/ressourcen")
return RedirectResponse(ziel, status_code=303)
@router.post("/{organization_id}/ressourcen/{ressource_id}")
async def ressource_aendern(request: Request, organization_id: str, ressource_id: str, user: dict = Depends(require_admin)):
ziel = ressourcen_formular.speichern(organization_id, ressource_id, await request.form(), f"/admin/kunden/{organization_id}/ressourcen")
return RedirectResponse(ziel, status_code=303)
@router.post("/{organization_id}/ressourcen/{ressource_id}/loeschen")
def ressource_loeschen(organization_id: str, ressource_id: str, user: dict = Depends(require_admin)):
ziel = ressourcen_formular.loeschen(organization_id, ressource_id, f"/admin/kunden/{organization_id}/ressourcen")
return RedirectResponse(ziel, status_code=303)
@router.get("/{organization_id}/struktur")
def detail_struktur(request: Request, organization_id: str, user: dict = Depends(require_admin)):
oes_result = anode_request("GET", f"/api/v1/organizations/{organization_id}/organisationseinheiten")
+37
View File
@@ -5,6 +5,7 @@ from fastapi.responses import RedirectResponse
from fastapi_users import exceptions
from anode_client import anode_request
import ressourcen_formular
from auth import (
UserCreate,
UserManager,
@@ -55,6 +56,42 @@ def konten_ansicht(request: Request, user=Depends(get_current_user)):
)
@router.get("/organisation/ressourcen")
def ressourcen_ansicht(request: Request, fehler: str = "", user=Depends(get_current_user)):
kontext = ressourcen_formular.lade_ressourcen_kontext(str(user.organization_id))
return templates.TemplateResponse(
request, "organisation_ressourcen.html", {"user": user, "fehler": fehler, **kontext}
)
def _ressourcen_freigeschaltet_oder_404(user):
# Solange die Org nicht freigeschaltet ist (ORG_RESSOURCEN_NUR_VORAB im
# Server), auch keine Änderungen per direktem POST.
if not ressourcen_formular.ressourcen_freigeschaltet(str(user.organization_id)):
raise HTTPException(status_code=404, detail="Nicht verfügbar.")
@router.post("/organisation/ressourcen")
async def ressource_anlegen(request: Request, user=Depends(require_org_admin)):
_ressourcen_freigeschaltet_oder_404(user)
ziel = ressourcen_formular.speichern(str(user.organization_id), None, await request.form(), "/organisation/ressourcen")
return RedirectResponse(ziel, status_code=303)
@router.post("/organisation/ressourcen/{ressource_id}")
async def ressource_aendern(ressource_id: str, request: Request, user=Depends(require_org_admin)):
_ressourcen_freigeschaltet_oder_404(user)
ziel = ressourcen_formular.speichern(str(user.organization_id), ressource_id, await request.form(), "/organisation/ressourcen")
return RedirectResponse(ziel, status_code=303)
@router.post("/organisation/ressourcen/{ressource_id}/loeschen")
def ressource_loeschen(ressource_id: str, user=Depends(require_org_admin)):
_ressourcen_freigeschaltet_oder_404(user)
ziel = ressourcen_formular.loeschen(str(user.organization_id), ressource_id, "/organisation/ressourcen")
return RedirectResponse(ziel, status_code=303)
@router.post("/organisation")
def organisation_speichern(
name: str = Form(...),
+127
View File
@@ -0,0 +1,127 @@
{# Org-Ressourcen (15.10.2026): gemeinsam für die Kunden-Seite
(organisation_ressourcen.html) und den Admin-Reiter (admin/kunden_detail.html).
Erwartet: ressourcen, ressourcen_typen, ressourcen_oes, ressourcen_gruppen,
basis_url, darf_bearbeiten, fehler. Formularfelder erzeugt aus den
Typ-Definitionen des Servers, ohne JavaScript: je Typ ein eigenes
"Neu anlegen"-Formular. #}
{% macro felder(typ_key, typ, ressource=None) %}
<input type="hidden" name="typ" value="{{ typ_key }}">
<label>Name (zur Unterscheidung, z. B. „Schulcloud“)
<input type="text" name="name" value="{{ ressource.name if ressource else '' }}" required>
</label>
{% for feld in typ.felder %}
{% set wert = ressource.daten.get(feld.key) if ressource else none %}
{% if wert is none %}{% set wert = feld.standard if feld.standard is defined else '' %}{% endif %}
<label>{{ feld.label }}{% if feld.pflicht %} *{% endif %}
{% if feld.typ == "auswahl" %}
<select name="daten.{{ feld.key }}">
{% for w in feld.werte %}
<option value="{{ w }}" {% if w == wert %}selected{% endif %}>{{ {"ssl": "SSL/TLS", "starttls": "STARTTLS", "mailadresse": "vollständige Mailadresse", "lokalteil": "nur der Teil vor dem @"}.get(w, w) }}</option>
{% endfor %}
</select>
{% elif feld.typ == "port" %}
<input type="number" min="1" max="65535" name="daten.{{ feld.key }}" value="{{ wert }}">
{% elif feld.typ == "liste" %}
<input type="text" name="daten.{{ feld.key }}" value="{{ wert | join(', ') if wert is iterable and wert is not string else wert }}">
{% else %}
<input type="{{ 'url' if feld.typ == 'url' else 'text' }}" name="daten.{{ feld.key }}" value="{{ wert }}" {% if feld.pflicht %}required{% endif %}>
{% endif %}
</label>
{% endfor %}
<fieldset>
<legend>Wirkt auf</legend>
<label>
<input type="checkbox" name="ganze_organisation" {% if not ressource or ressource.ganze_organisation %}checked{% endif %}>
alle Geräte der Organisation
</label>
{% if ressourcen_oes or ressourcen_gruppen %}
<small>Sonst nur auf die gewählten Organisationseinheiten (samt untergeordneten) und Gruppen:</small>
{% for oe in ressourcen_oes %}
<label>
<input type="checkbox" name="oe_ids" value="{{ oe.id }}" {% if ressource and oe.id in ressource.oes | map(attribute='id') | list %}checked{% endif %}>
OE: {{ oe.name }}
</label>
{% endfor %}
{% for gruppe in ressourcen_gruppen %}
<label>
<input type="checkbox" name="gruppe_ids" value="{{ gruppe.id }}" {% if ressource and gruppe.id in ressource.gruppen | map(attribute='id') | list %}checked{% endif %}>
Gruppe: {{ gruppe.name }}
</label>
{% endfor %}
{% endif %}
</fieldset>
{% endmacro %}
<p>
Dienste Ihrer Organisation wie Mailserver, Cloud, Dateifreigaben oder
Web-Proxy. Die Geräte tragen sie in die passenden Programme ein; Benutzer
melden sich dann nur noch mit ihrem eigenen Namen und Passwort an.
<strong>Hier werden keine Zugangsdaten gespeichert.</strong>
</p>
{% if fehler %}
<p role="alert"><mark>{{ fehler }}</mark></p>
{% endif %}
{% if not ressourcen %}
<p>Noch keine Ressourcen eingetragen.</p>
{% else %}
<figure>
<table>
<thead><tr><th>Name</th><th>Art</th><th>Angaben</th><th>Wirkt auf</th>{% if darf_bearbeiten %}<th></th>{% endif %}</tr></thead>
<tbody>
{% for r in ressourcen %}
<tr>
<td>{{ r.name }}</td>
<td>{{ r.typ_name }}</td>
<td><small>
{% for feld in ressourcen_typen.get(r.typ, {}).get("felder", []) %}
{% set w = r.daten.get(feld.key) %}
{% if w not in (none, "", []) %}{{ feld.label.split(" (")[0] }}: {{ w | join(", ") if w is iterable and w is not string else w }}<br>{% endif %}
{% endfor %}
</small></td>
<td><small>
{% if r.ganze_organisation %}ganze Organisation
{% elif not r.oes and not r.gruppen %}<em>nirgends (nicht verteilt)</em>
{% else %}
{% for oe in r.oes %}OE {{ oe.name }}<br>{% endfor %}
{% for g in r.gruppen %}Gruppe {{ g.name }}<br>{% endfor %}
{% endif %}
</small></td>
{% if darf_bearbeiten %}
<td>
<details>
<summary role="button" class="outline">Bearbeiten</summary>
{% if r.typ in ressourcen_typen %}
<form method="post" action="{{ basis_url }}/{{ r.id }}">
{{ felder(r.typ, ressourcen_typen[r.typ], r) }}
<button type="submit">Speichern</button>
</form>
{% endif %}
<form method="post" action="{{ basis_url }}/{{ r.id }}/loeschen">
<button type="submit" class="outline secondary">Löschen</button>
</form>
</details>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{% endif %}
{% if darf_bearbeiten %}
<h4>Neue Ressource</h4>
{% for typ_key, typ in ressourcen_typen.items() %}
<details>
<summary role="button" class="secondary outline">{{ typ.name }} anlegen</summary>
<form method="post" action="{{ basis_url }}">
{{ felder(typ_key, typ) }}
<button type="submit">Anlegen</button>
</form>
</details>
{% endfor %}
{% endif %}
+1
View File
@@ -10,6 +10,7 @@
<li><a href="/admin/kunden/{{ organization_id }}/konten">{% if aktiver_tab == "konten" %}<strong>Kundenkonten</strong>{% else %}Kundenkonten{% endif %}</a></li>
<li><a href="/admin/kunden/{{ organization_id }}/struktur">{% if aktiver_tab == "struktur" %}<strong>Struktur &amp; Workspaces</strong>{% else %}Struktur &amp; Workspaces{% endif %}</a></li>
<li><a href="/admin/kunden/{{ organization_id }}/gruppen">{% if aktiver_tab == "gruppen" %}<strong>Gruppen</strong>{% else %}Gruppen{% endif %}</a></li>
<li><a href="/admin/kunden/{{ organization_id }}/ressourcen">{% if aktiver_tab == "ressourcen" %}<strong>Ressourcen</strong>{% else %}Ressourcen{% endif %}</a></li>
<li><a href="/admin/kunden/{{ organization_id }}/diagnosezugang">{% if aktiver_tab == "diagnosezugang" %}<strong>Diagnosezugang</strong>{% else %}Diagnosezugang{% endif %}</a></li>
<li><a href="/admin/kunden/{{ organization_id }}/aktivitaeten">{% if aktiver_tab == "aktivitaeten" %}<strong>Aktivitäten</strong>{% else %}Aktivitäten{% endif %}</a></li>
<li><a href="/admin/flows/{{ organization_id }}">{% if aktiver_tab == "flows" %}<strong>Flows</strong>{% else %}Flows{% endif %}</a></li>
+10
View File
@@ -419,4 +419,14 @@
</details>
</section>
{% endif %}
{% if aktiver_tab == "ressourcen" %}
<section>
{% if not ressourcen_freigeschaltet %}
<p><mark>Für diese Organisation noch nicht freigeschaltet</mark> (Ausrollstufe „stabil“): die Ressourcen werden gespeichert, aber nicht an Geräte verteilt, und die Kunden-Seite ist ausgeblendet.</p>
{% endif %}
{% set basis_url = "/admin/kunden/" ~ organization_id ~ "/ressourcen" %}
{% set darf_bearbeiten = true %}
{% include "_ressourcen.html" %}
</section>
{% endif %}
{% endblock %}
+4
View File
@@ -209,6 +209,7 @@
else "" %}
{% set aktive_unterseite =
"konten" if pfad.startswith("/organisation/konten")
else "ressourcen" if pfad.startswith("/organisation/ressourcen")
else "organisation" if pfad.startswith("/organisation")
else "struktur" if pfad.startswith("/struktur")
else "gruppen" if pfad.startswith("/gruppen")
@@ -257,6 +258,9 @@
<ul>
<li><a href="/organisation" {% if aktive_unterseite == "organisation" %}class="active"{% endif %}>Organisation</a></li>
<li><a href="/organisation/konten" {% if aktive_unterseite == "konten" %}class="active"{% endif %}>Konten</a></li>
{% if user and user.organization_id and org_ressourcen_freigeschaltet(user.organization_id) %}
<li><a href="/organisation/ressourcen" {% if aktive_unterseite == "ressourcen" %}class="active"{% endif %}>Ressourcen</a></li>
{% endif %}
<li><a href="/struktur" {% if aktive_unterseite == "struktur" %}class="active"{% endif %}>Struktur</a></li>
<li><a href="/gruppen" {% if aktive_unterseite == "gruppen" %}class="active"{% endif %}>Gruppen</a></li>
</ul>
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Ressourcen — Tuxflotte{% endblock %}
{% block content %}
<h2>Ressourcen</h2>
{% if not ressourcen_freigeschaltet %}
<p>Diese Funktion wird gerade vorbereitet und steht Ihrer Organisation bald zur Verfügung.</p>
{% else %}
{% set basis_url = "/organisation/ressourcen" %}
{% set darf_bearbeiten = user.is_superuser or user.ist_organisationsadmin %}
{% include "_ressourcen.html" %}
{% if not darf_bearbeiten %}
<p><small>Nur Organisations-Admins können Ressourcen eintragen oder ändern.</small></p>
{% endif %}
{% endif %}
{% endblock %}
+27
View File
@@ -98,3 +98,30 @@ def ring_segmente(teile: list[dict], radius: float = 54, staerke: float = 16) ->
templates.env.globals["ring_segmente"] = ring_segmente
# Org-Ressourcen (15.10.2026): die Kunden-Unterseite "Ressourcen" erscheint
# nur, wenn der Server sie für die Organisation freigeschaltet hat
# (ORG_RESSOURCEN_NUR_VORAB). Kurz zwischengespeichert, damit nicht jede
# Seite der Organisation-Navigation eine zusätzliche Anfrage auslöst.
_ressourcen_freigabe_cache: dict = {}
def org_ressourcen_freigeschaltet(organization_id) -> bool:
import time
from ressourcen_formular import ressourcen_freigeschaltet
schluessel = str(organization_id)
eintrag = _ressourcen_freigabe_cache.get(schluessel)
if eintrag and time.monotonic() - eintrag[1] < 60:
return eintrag[0]
try:
wert = ressourcen_freigeschaltet(schluessel)
except Exception:
wert = False
_ressourcen_freigabe_cache[schluessel] = (wert, time.monotonic())
return wert
templates.env.globals["org_ressourcen_freigeschaltet"] = org_ressourcen_freigeschaltet