Suche in der Kopfzeile, Admin-Workspaces je Organisation aufklappbar

Lupe neben dem Anmeldenamen führt zu /suche: Funktionen (fester Index in
routers/suche.py), Geräte, Merkmale und Apps, beim Admin auch
Organisationen und Workspaces. Workspace-Creator › Workspaces gruppiert
nach Organisation (tuxflotte offen) mit Filter nach Name, Organisation,
Distro und Vorlagen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-29 07:38:01 +01:00
1 parent 9d6a650bd1
commit c160233d08
7 files changed
+350 -18

No files matched your search

+2
View File
@@ -21,6 +21,7 @@ from routers import (
organisation,
personalisierung,
struktur,
suche,
webapps,
workspace,
)
@@ -84,3 +85,4 @@ app.include_router(admin_flows.router)
app.include_router(admin_kunden.router)
app.include_router(admin_accounts.router)
app.include_router(admin_dashboard.router)
app.include_router(suche.router)
+21 -1
View File
@@ -103,10 +103,30 @@ def workspaces_liste(request: Request, user: dict = Depends(require_admin)):
workspaces = result.get("workspaces", []) if result.get("success") else []
return templates.TemplateResponse(
request, "admin/technik_workspaces.html", {"user": user, "workspaces": workspaces},
request, "admin/technik_workspaces.html",
{
"user": user,
"organisationen": workspaces_nach_organisation(workspaces),
"distros": sorted({f"{w['backend']['name']} {w['backend']['version']}" for w in workspaces if w.get("backend")}),
},
)
def workspaces_nach_organisation(workspaces: list[dict]) -> list[dict]:
"""
Nutzer-Wunsch 29.10.2026: Die Liste mit den Workspaces aller
Organisationen wurde unübersichtlich - je Organisation aufklappbar,
tuxflotte (ohne Organisation) zuerst, dann alphabetisch.
"""
gruppen: dict[str, dict] = {}
for w in workspaces:
name = w.get("organization_name") or "tuxflotte"
gruppe = gruppen.setdefault(name, {"name": name, "ist_tuxflotte": not w.get("organization_name"), "workspaces": []})
gruppe["workspaces"].append(w)
return sorted(gruppen.values(), key=lambda g: (not g["ist_tuxflotte"], g["name"].lower()))
def gruppiere_merkmale_nach_kategorie(alle_merkmale: list[dict], komponierte_ids: set[str]) -> dict[str, list[dict]]:
"""
Für den Workspace-Creator (10.09.2026, Schritt 4 der Design-Runde,
+185
View File
@@ -0,0 +1,185 @@
import unicodedata
from fastapi import APIRouter, Depends, Request
from anode_client import anode_request
from auth import get_current_user
from templating import org_ressourcen_freigeschaltet, templates
router = APIRouter()
# Nutzer-Wunsch 29.10.2026: Lupe in der Kopfzeile, Suche nach Begriffen und
# Funktionen. Drei Quellen: dieser Index der Funktionen (Seite, Weg dorthin,
# Stichworte, die Anwender vermutlich tippen), dazu Geräte und Merkmale und
# beim root-Admin Organisationen und Workspaces aus anode. Neue Seiten oder
# Abschnitte hier mit eintragen, sonst findet die Suche sie nicht.
#
# Eintrag: (Titel, Weg in der Oberfläche, URL, Stichworte)
FUNKTIONEN_KUNDE = [
("Dashboard", "Dashboard", "/dashboard",
"übersicht start erste schritte flotten-gesundheit ampel status"),
("Letzte Aktivitäten", "Dashboard › Letzte Aktivitäten", "/dashboard#aktivitaeten",
"aktivität protokoll verlauf agent-lauf fehler installationen ereignisse ältere"),
("Installationsmedium erstellen", "Installationsmedium › Erstellen", "/installationsmedium/erstellen",
"usb stick iso download herunterladen installieren installation neues gerät medium wlan bereitstellungsvorlage"),
("Ausgegebene Medien", "Installationsmedium › Medien", "/installationsmedium#ausgegebene-medien",
"medium deaktivieren widerrufen löschen verlängern aufladen abgelaufen aktivierung"),
("Kontingent und Gültigkeit", "Installationsmedium › Medien › Kontingent und Gültigkeit", "/installationsmedium#kontingent",
"kontingent gültigkeit ablauf 90 tage anzahl geräte"),
("Deine Geräte (Flotte)", "Flotte", "/geraete",
"geräte gerät rechner computer notebook laptop pc hostname liste flotte"),
("Gerät verwalten", "Flotte › Gerät › Aktionen", "/geraete",
"aktionen gruppe zuweisen standort verschieben debug-modus protokoll log hardware"),
("Gerät deprovisionieren oder löschen", "Flotte › Gerät › Aktionen", "/geraete",
"deprovisionieren löschen entfernen ausmustern zurückgeben neu installieren reprovisionieren"),
("Persönliches Konto auf einem Gerät (Personalisierung)", "Flotte › Gerät › Personalisierung", "/geraete",
"personalisierung persönliches konto eigenes konto leihgerät einladung passwort vergessen zurücksetzen "
"sperren verschlüsselt verschlüsselung home wiederherstellungsschlüssel qr-code benutzer lokaler benutzer"),
("Lehrergerät festlegen", "Flotte › Gerät › Unterrichtssteuerung", "/geraete",
"lehrergerät lehrer master veyon unterricht"),
("Unterrichtssteuerung (Veyon)", "Organisation › Standorte/Zwecke › Merkmale › Unterrichtssteuerung", "/struktur",
"veyon unterrichtssteuerung klassenraum lehrer schüler bildschirm sperren beobachten schlüssel erneuern"),
("Standorte/Zwecke", "Organisation › Standorte/Zwecke", "/struktur",
"standort zweck oe organisationseinheit raum klassenraum gebäude struktur workspace zuweisen merkmale"),
("Gruppen", "Organisation › Gruppen", "/gruppen",
"gruppe gerätegruppe gruppen merkmale mehrere geräte"),
("Stammdaten der Organisation", "Organisation › Stammdaten", "/organisation#stammdaten",
"name adresse schule organisation stammdaten"),
("WLAN der Organisation", "Organisation › WLAN der Organisation", "/organisation#wlan",
"wlan wifi ssid funknetz passwort schul-wlan netzwerk internet"),
("E-Mail-Domänen für die Personalisierung", "Organisation › E-Mail-Domänen", "/organisation#email-domaenen",
"e-mail mail domäne adresse personalisierung einladung"),
("Mein Konto", "Organisation › Mein Konto", "/organisation#konto",
"mein konto passwort ändern e-mail anmeldung profil"),
("Konten der Verwaltung", "Organisation › Konten", "/organisation/konten",
"konten kolleg kollegin verwaltung admin zugang anlegen benutzer portal passwort zurücksetzen"),
("Workspace", "Workspace", "/workspace",
"workspace apps programme software zusammenstellung vorlage standard merkmale"),
]
# Nur sichtbar, wenn der Server die Ressourcen für die Organisation freigibt
# (wie der Reiter in base.html).
FUNKTIONEN_RESSOURCEN = [
("Ressourcen der Organisation", "Organisation › Ressourcen", "/organisation/ressourcen",
"ressourcen lesezeichen nextcloud gemeinsame ablage netzlaufwerk smb freigabe dienste"),
]
FUNKTIONEN_ADMIN = [
("Dashboard", "Dashboard", "/admin/dashboard",
"übersicht ampel änderungen an der plattform deploy organisationen"),
("Organisationen", "Organisationen", "/admin/kunden",
"organisation kunde schule anlegen kundengruppe testschule"),
("Bereitstellungsvorlagen und Aktivierungscodes", "Organisationen › Organisation › Bereitstellungsvorlagen", "/admin/kunden",
"bereitstellungsvorlage vorlage aktivierungscode code installation"),
("Kundenkonten", "Organisationen › Organisation › Kundenkonten", "/admin/kunden",
"konten kunde admin org-admin passwort zurücksetzen"),
("Struktur & Workspaces einer Organisation", "Organisationen › Organisation › Struktur & Workspaces", "/admin/kunden",
"struktur standort zweck oe workspace zuweisen merkmale"),
("Diagnosezugang", "Organisationen › Organisation › Diagnosezugang", "/admin/kunden",
"diagnose diagnosezugang ssh support passwort"),
("Aktivitäten einer Organisation", "Organisationen › Organisation › Aktivitäten", "/admin/kunden",
"aktivitäten protokoll agent-lauf fehler installationen flows enrollment sessions ältere"),
("Katalog", "Workspace-Creator › Katalog", "/admin/workspaces/katalog",
"katalog merkmale apps kategorien webapp-vorlagen distros backends blueprints icons"),
("Workspaces", "Workspace-Creator › Workspaces", "/admin/workspaces/liste",
"workspaces liste vorlage version entwurf veröffentlichen"),
("Workspace erstellen", "Workspace-Creator › Workspace erstellen", "/admin/workspaces/neu",
"neu erstellen assistent workspace anlegen"),
("Flotte (alle Geräte)", "Flotte", "/admin/geraete",
"geräte gerät flotte hostname debug-modus protokoll"),
("Admin-Accounts", "Admin-Accounts", "/admin/accounts",
"admin root accounts konten passwort"),
]
MAX_TREFFER = 25
def _normal(text: str) -> str:
"""Kleinschreibung, Umlaute und ß wie ae/oe/ue/ss, damit "Gerat",
"Geraet" und "Gerät" dasselbe finden."""
text = (text or "").lower().replace("ß", "ss")
for umlaut, ersatz in (("ä", "ae"), ("ö", "oe"), ("ü", "ue")):
text = text.replace(umlaut, ersatz)
return unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
def _passt(woerter: list[str], *felder: str) -> bool:
heuhaufen = _normal(" ".join(f or "" for f in felder))
return all(w in heuhaufen for w in woerter)
def _funktionen(woerter: list[str], eintraege: list[tuple]) -> list[dict]:
treffer = []
for titel, weg, url, stichworte in eintraege:
if _passt(woerter, titel, weg, stichworte):
# Treffer im Titel zuerst
treffer.append((not _passt(woerter, titel), {"titel": titel, "weg": weg, "url": url}))
return [t for _, t in sorted(treffer, key=lambda t: t[0])]
def _liste(result: dict, schluessel: str) -> list[dict]:
return result.get(schluessel, []) if result.get("success") else []
def suchen(user, q: str) -> dict:
woerter = [_normal(w) for w in q.split() if w.strip()]
if not woerter:
return {}
if user.is_superuser:
funktionen = _funktionen(woerter, FUNKTIONEN_ADMIN)
geraete_basis = "/admin/geraete"
geraete = _liste(anode_request("GET", "/api/v1/devices"), "devices")
organisationen = [
{"titel": o["name"], "url": f"/admin/kunden/{o['id']}"}
for o in _liste(anode_request("GET", "/api/v1/organizations"), "organizations")
if _passt(woerter, o.get("name"))
]
workspaces = [
{"titel": w["name"], "weg": w.get("organization_name") or "tuxflotte", "url": f"/admin/workspaces/liste/{w['id']}"}
for w in _liste(anode_request("GET", "/api/v1/workspaces"), "workspaces")
if _passt(woerter, w.get("name"), w.get("key"), w.get("description"))
]
merkmal_url, merkmal_weg = "/admin/workspaces/katalog", "Workspace-Creator › Katalog"
else:
eintraege = FUNKTIONEN_KUNDE
if user.organization_id and org_ressourcen_freigeschaltet(user.organization_id):
eintraege = eintraege + FUNKTIONEN_RESSOURCEN
funktionen = _funktionen(woerter, eintraege)
geraete_basis = "/geraete"
geraete = _liste(anode_request("GET", f"/api/v1/organizations/{user.organization_id}/devices"), "devices")
organisationen, workspaces = [], []
merkmal_url, merkmal_weg = "/struktur", "zuweisen unter Organisation › Standorte/Zwecke › Merkmale oder Flotte › Gerät"
geraete_treffer = [
{
"titel": d.get("hostname") or "unbenannt",
"weg": " · ".join(x for x in (d.get("organization_name") if user.is_superuser else None, d.get("oe_name"), d.get("workspace_name")) if x),
"url": f"{geraete_basis}/{d['id']}",
}
for d in geraete
if _passt(woerter, d.get("hostname"), d.get("oe_name"), d.get("workspace_name"))
]
merkmale = [
{"titel": m["name"], "weg": f"{m.get('kategorie_name') or 'Merkmal'} – {merkmal_weg}", "url": merkmal_url, "text": m.get("description")}
for m in _liste(anode_request("GET", "/api/v1/merkmale"), "merkmale")
if _passt(woerter, m.get("name"), m.get("key"), m.get("description"), m.get("kategorie_name"))
]
return {
"Funktionen": funktionen[:MAX_TREFFER],
"Organisationen": organisationen[:MAX_TREFFER],
"Geräte": geraete_treffer[:MAX_TREFFER],
"Workspaces": workspaces[:MAX_TREFFER],
"Merkmale und Apps": merkmale[:MAX_TREFFER],
}
@router.get("/suche")
def suche(request: Request, q: str = "", user=Depends(get_current_user)):
q = q.strip()[:100]
ergebnisse = {k: v for k, v in suchen(user, q).items() if v} if q else {}
return templates.TemplateResponse(
request, "suche.html", {"user": user, "q": q, "ergebnisse": ergebnisse, "max_treffer": MAX_TREFFER},
)
+52
View File
@@ -0,0 +1,52 @@
// Admin › Workspace-Creator › Workspaces (Nutzer-Wunsch 29.10.2026): Filter
// nach Name/Schlüssel, Organisation, Distro und "nur Vorlagen". Blendet
// Zeilen und leere Organisationen aus; bei aktivem Filter sind alle
// passenden Organisationen aufgeklappt, ohne Filter gilt wieder der
// vorherige Zustand.
(function () {
const suche = document.getElementById("ws-suche");
if (!suche) return;
const org = document.getElementById("ws-org");
const distro = document.getElementById("ws-distro");
const vorlagen = document.getElementById("ws-vorlagen");
const alle = document.getElementById("ws-alle");
const gruppen = document.querySelectorAll(".workspace-orgs > details");
let vorher = Array.from(gruppen, d => d.open);
function filterAktiv() {
return suche.value.trim() !== "" || org.value !== "" || distro.value !== "" || vorlagen.checked;
}
function anwenden() {
const text = suche.value.trim().toLowerCase();
const aktiv = filterAktiv();
let gesamt = 0;
gruppen.forEach((gruppe, i) => {
let sichtbar = 0;
const orgPasst = !org.value || gruppe.dataset.org === org.value;
gruppe.querySelectorAll("tbody tr").forEach(tr => {
const passt = orgPasst
&& (!text || tr.dataset.text.includes(text))
&& (!distro.value || tr.dataset.distro === distro.value)
&& (!vorlagen.checked || tr.dataset.vorlage === "ja");
tr.hidden = !passt;
if (passt) sichtbar++;
});
gruppe.hidden = sichtbar === 0;
gruppe.querySelector(".ws-anzahl").textContent = sichtbar;
gruppe.open = aktiv ? sichtbar > 0 : vorher[i];
gesamt += sichtbar;
});
document.getElementById("ws-leer").hidden = gesamt > 0;
}
suche.addEventListener("input", anwenden);
[org, distro, vorlagen].forEach(el => el.addEventListener("change", anwenden));
alle.addEventListener("click", () => {
const aufklappen = Array.from(gruppen).some(d => !d.hidden && !d.open);
gruppen.forEach(d => { if (!d.hidden) d.open = aufklappen; });
if (!filterAktiv()) vorher = Array.from(gruppen, d => d.open);
alle.textContent = aufklappen ? "Alle zuklappen" : "Alle aufklappen";
});
})();
+48 -17
View File
@@ -6,22 +6,53 @@
{% if request.query_params.get("hinweis") %}<p><ins>{{ request.query_params.get("hinweis") }}</ins></p>{% endif %}
<p><a href="/admin/workspaces/neu" role="button">+ Workspace erstellen</a></p>
<figure>
<table>
<thead><tr><th>Name</th><th>Distro</th><th>Version</th><th>Eigentümer</th><th>Vorlage</th><th></th></tr></thead>
<tbody>
{% for w in workspaces %}
<tr>
<td>{{ w.name }}<br><small><code>{{ w.key }}</code></small></td>
<td>{% if w.backend %}{{ w.backend.name }} {{ w.backend.version }}{% else %}<small>noch offen</small>{% endif %}</td>
<td>{% if w.version %}v{{ w.version }}{% endif %}{% if w.hat_entwurf %} <small>+ Entwurf</small>{% endif %}</td>
<td>{{ w.organization_name or "tuxflotte" }}</td>
<td>{% if w.ist_vorlage %}✅ <small>für {% if w.sichtbarkeit == "global" %}alle{% elif w.sichtbarkeit == "kundengruppe" %}{{ w.freigabe_kundengruppen | map("replace", "Schule", "Schulen") | join(", ") }}{% else %}{{ w.freigabe_organisationen | join(", ") }}{% endif %}</small>{% else %}—{% endif %}</td>
<td><a href="/admin/workspaces/liste/{{ w.id }}" role="button" class="outline">Öffnen</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{# Nutzer-Wunsch 29.10.2026: je Organisation aufklappbar (tuxflotte offen),
Filter nach Name, Organisation, Distro und Vorlagen; static/workspace-liste.js #}
<style>
.workspace-filter { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; }
.workspace-filter input[type=search], .workspace-filter select { flex: 1 1 12rem; max-width: 18rem; margin-bottom: 0; }
.workspace-filter label { white-space: nowrap; margin-bottom: 0; }
.workspace-orgs > details { margin-bottom: 0.5rem; }
</style>
<div class="workspace-filter">
<input type="search" id="ws-suche" placeholder="Name suchen" aria-label="Nach Name oder Schlüssel filtern">
<select id="ws-org" aria-label="Nach Organisation filtern">
<option value="">Alle Organisationen</option>
{% for o in organisationen %}<option value="{{ o.name }}">{{ o.name }}</option>{% endfor %}
</select>
<select id="ws-distro" aria-label="Nach Distro filtern">
<option value="">Alle Distros</option>
{% for d in distros %}<option value="{{ d }}">{{ d }}</option>{% endfor %}
<option value="-">noch offen</option>
</select>
<label><input type="checkbox" id="ws-vorlagen"> nur Vorlagen</label>
<button type="button" id="ws-alle" class="secondary outline klein">Alle aufklappen</button>
</div>
<div class="workspace-orgs">
{% for o in organisationen %}
<details data-org="{{ o.name }}" {% if o.ist_tuxflotte %}open{% endif %}>
<summary><strong>{{ o.name }}</strong> <small>(<span class="ws-anzahl">{{ o.workspaces | length }}</span> Workspace{% if o.workspaces | length != 1 %}s{% endif %})</small></summary>
<figure>
<table class="kompakt">
<thead><tr><th>Name</th><th>Distro</th><th>Version</th><th>Vorlage</th><th></th></tr></thead>
<tbody>
{% for w in o.workspaces %}
<tr data-text="{{ (w.name ~ ' ' ~ w.key) | lower }}" data-distro="{{ (w.backend.name ~ ' ' ~ w.backend.version) if w.backend else '-' }}" data-vorlage="{{ 'ja' if w.ist_vorlage else '' }}">
<td>{{ w.name }}<br><small><code>{{ w.key }}</code></small></td>
<td>{% if w.backend %}{{ w.backend.name }} {{ w.backend.version }}{% else %}<small>noch offen</small>{% endif %}</td>
<td>{% if w.version %}v{{ w.version }}{% endif %}{% if w.hat_entwurf %} <small>+ Entwurf</small>{% endif %}</td>
<td>{% if w.ist_vorlage %}✅ <small>für {% if w.sichtbarkeit == "global" %}alle{% elif w.sichtbarkeit == "kundengruppe" %}{{ w.freigabe_kundengruppen | map("replace", "Schule", "Schulen") | join(", ") }}{% else %}{{ w.freigabe_organisationen | join(", ") }}{% endif %}</small>{% else %}—{% endif %}</td>
<td><a href="/admin/workspaces/liste/{{ w.id }}" role="button" class="outline klein">Öffnen</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</figure>
</details>
{% endfor %}
</div>
<p id="ws-leer" hidden><small>Kein Workspace passt zu diesem Filter.</small></p>
<script src="/static/workspace-liste.js?v={{ static_version }}" defer></script>
{% endblock %}
+2
View File
@@ -296,6 +296,8 @@
<ul>
<li><button id="theme-toggle" type="button" class="secondary outline" aria-label="Design umschalten">🌓</button></li>
{% if user %}
{# Nutzer-Wunsch 29.10.2026: Suche nach Begriffen und Funktionen #}
<li><a href="/suche" role="button" class="secondary outline" aria-label="Suche" title="Suche">🔍</a></li>
<li class="kopf-email">{{ user.email }}</li>
<li><a href="/logout" role="button" class="secondary outline">Abmelden</a></li>
{% endif %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Suche — Tuxflotte{% endblock %}
{% block content %}
{# Nutzer-Wunsch 29.10.2026, siehe routers/suche.py #}
<h2>Suche</h2>
<form method="get" action="/suche" role="search">
<fieldset role="group">
<input type="search" name="q" value="{{ q }}" placeholder="Begriff oder Funktion, z. B. WLAN, Passwort, Veyon" aria-label="Suchbegriff" autofocus>
<button type="submit">Suchen</button>
</fieldset>
</form>
{% if q %}
{% if ergebnisse %}
{% for bereich, treffer in ergebnisse.items() %}
<section>
<h3>{{ bereich }} <small>({{ treffer | length }}{% if treffer | length >= max_treffer %}+{% endif %})</small></h3>
<ul class="suchtreffer">
{% for t in treffer %}
<li>
<a href="{{ t.url }}">{{ t.titel }}</a>
{% if t.weg %}<br><small>{{ t.weg }}</small>{% endif %}
{% if t.text %}<br><small class="suchtreffer-text">{{ t.text | truncate(160) }}</small>{% endif %}
</li>
{% endfor %}
</ul>
</section>
{% endfor %}
{% else %}
<p>Nichts gefunden zu „{{ q }}“. Versuch es mit einem anderen oder kürzeren Begriff.</p>
{% endif %}
{% else %}
<p><small>Durchsucht Funktionen und Seiten des Flotten-Managements, deine Geräte und die Merkmale und Apps.{% if user.is_superuser %} Als Admin auch Organisationen und Workspaces.{% endif %}</small></p>
{% endif %}
<style>
ul.suchtreffer { list-style: none; padding-left: 0; }
ul.suchtreffer li { list-style: none; margin-bottom: 0.6rem; }
ul.suchtreffer small { color: var(--pico-muted-color); }
</style>
{% endblock %}