Anleitungen im Portal unter /hilfe und in der Suche
Das Portal zeigt die Anleitungen aus dem Repo manuals selbst an (Klon unter /opt/tuxflotte-manuals, KUNDENPLATTFORM_MANUALS_DIR) statt auf git.tuxflotte.de zu verlinken; geänderte Dateien werden ohne Neustart neu eingelesen. Verweise zwischen den Anleitungen zeigen auf /hilfe, Anker wie bei Gitea, kein Roh-HTML. Die Suche hat einen neuen Bereich "Anleitungen" (Abschnitte nach Überschriften, Neuigkeiten/Roadmap nachrangig), Funktionstreffer der Kunden verlinken die passende Anleitung. Kopfzeile: Hilfe-Knopf neben der Lupe; der Secure-Boot-Hinweis verlinkt jetzt /hilfe. Neue Abhängigkeit Markdown 3.11.2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
86a68e3cd7
commit
acd0406181
11 files changed
+377
-25
No files matched your search
@@ -31,6 +31,11 @@ Env-Variablen:
|
||||
- `KUNDENPLATTFORM_DATABASE_URL`
|
||||
- `KUNDENPLATTFORM_SESSION_SECRET` — beliebiger zufälliger String zum Signieren
|
||||
der Session-Cookie (z.B. `openssl rand -base64 32`)
|
||||
- `KUNDENPLATTFORM_MANUALS_DIR` (Default `/opt/tuxflotte-manuals`) — Klon des
|
||||
Repos manuals; das Portal zeigt die Anleitungen unter `/hilfe` und in der
|
||||
Suche und liest geänderte Dateien selbst neu ein. Auf anode aktuell halten
|
||||
mit `tuxflotte-deploy manuals` (infra/anode-ops). Fehlt der Klon, zeigt
|
||||
`/hilfe` einen Hinweis.
|
||||
|
||||
Start: `.venv/bin/uvicorn app:app --port 8081`
|
||||
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Anleitungen (Repo manuals) im Portal unter /hilfe und in der Suche.
|
||||
|
||||
Nutzer-Wunsch 30.10.2026: Die Suche soll auch die Anleitungen finden, und
|
||||
das Portal soll sie selbst zeigen statt auf git.tuxflotte.de zu verlinken
|
||||
(Gitea geht nach ADR-0046 Schritt 4 nach außen zu). Auf anode liegt ein
|
||||
Klon von manuals unter /opt/tuxflotte-manuals, aktualisiert per
|
||||
`tuxflotte-deploy manuals`. Gelesen wird bei Bedarf: ändert sich eine
|
||||
Datei (mtime), wird neu eingelesen, ein Neustart ist nicht nötig.
|
||||
"""
|
||||
|
||||
import html
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
import threading
|
||||
import xml.etree.ElementTree as etree
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import markdown
|
||||
from markdown.extensions.toc import slugify_unicode
|
||||
from markdown.treeprocessors import Treeprocessor
|
||||
|
||||
MANUALS_DIR = Path(os.environ.get("KUNDENPLATTFORM_MANUALS_DIR", "/opt/tuxflotte-manuals"))
|
||||
|
||||
# Welche Dateien das Portal zeigt; README.md ist die Übersicht unter /hilfe.
|
||||
ORDNER = ("kunden-admin", "nutzer")
|
||||
EINZELDATEIEN = ("README.md", "neuigkeiten.md", "roadmap.md")
|
||||
# Änderungsprotokoll und Planung: in der Suche hinter den eigentlichen
|
||||
# Anleitungen, sonst verdrängen sie bei fast jedem Begriff die Treffer.
|
||||
NACHRANGIG = ("neuigkeiten", "roadmap")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Abschnitt:
|
||||
titel: str
|
||||
anker: str
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Anleitung:
|
||||
pfad: str # ohne .md, z. B. "kunden-admin/veyon"; README → ""
|
||||
titel: str
|
||||
html: str
|
||||
abschnitte: list[Abschnitt] = field(default_factory=list)
|
||||
|
||||
|
||||
def _ziel(von: str, href: str) -> str | None:
|
||||
"""Relativen Verweis zwischen den Anleitungen auf /hilfe umschreiben.
|
||||
von: Pfad der Datei ohne .md. None = Verweis unverändert lassen."""
|
||||
|
||||
if re.match(r"^[a-z][a-z0-9+.-]*:", href, re.I) or href.startswith(("/", "#")):
|
||||
return None
|
||||
ziel, _, anker = href.partition("#")
|
||||
ziel = posixpath.normpath(posixpath.join(posixpath.dirname(von), ziel))
|
||||
if ziel.endswith(".md"):
|
||||
ziel = ziel[:-3]
|
||||
if ziel in (".", "README") or ziel.rstrip("/") in ORDNER:
|
||||
# Ordner-Verweise der README (kunden-admin/) führen auf die Übersicht.
|
||||
ziel = ""
|
||||
url = "/hilfe/" + ziel if ziel else "/hilfe"
|
||||
return url + ("#" + anker if anker else "")
|
||||
|
||||
|
||||
class _VerweiseUmschreiben(Treeprocessor):
|
||||
def __init__(self, md, von: str):
|
||||
super().__init__(md)
|
||||
self.von = von
|
||||
|
||||
def run(self, root: etree.Element):
|
||||
for a in root.iter("a"):
|
||||
href = a.get("href") or ""
|
||||
neu = _ziel(self.von, href)
|
||||
if neu is not None:
|
||||
a.set("href", neu)
|
||||
elif href.startswith(("http://", "https://")):
|
||||
a.set("target", "_blank")
|
||||
a.set("rel", "noopener")
|
||||
|
||||
|
||||
def _umwandeln(quelle: str, von: str) -> str:
|
||||
md = markdown.Markdown(
|
||||
extensions=["tables", "sane_lists", "toc"],
|
||||
# Wie Gitea: Kleinbuchstaben, Umlaute bleiben – die Anker in den
|
||||
# Anleitungen (#änderungen-schneller-…) sind so geschrieben.
|
||||
extension_configs={"toc": {"slugify": slugify_unicode}},
|
||||
)
|
||||
# Kein Roh-HTML durchreichen, die Anleitungen brauchen keins.
|
||||
md.preprocessors.deregister("html_block")
|
||||
md.inlinePatterns.deregister("html")
|
||||
md.treeprocessors.register(_VerweiseUmschreiben(md, von), "verweise", 1)
|
||||
return md.convert(quelle)
|
||||
|
||||
|
||||
_UEBERSCHRIFT = re.compile(r'<h([1-6]) id="([^"]*)">(.*?)</h\1>', re.S)
|
||||
|
||||
|
||||
def _klartext(fragment: str) -> str:
|
||||
text = re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", fragment))).strip()
|
||||
# Tags werden zu Leerzeichen, auch vor Satzzeichen ("Merkmale ).").
|
||||
return re.sub(r" ([.,;:!?)“])", r"\1", text)
|
||||
|
||||
|
||||
def _abschnitte(seite_html: str) -> tuple[str, list[Abschnitt]]:
|
||||
"""Seite an den Überschriften teilen. Rückgabe: Titel (erste
|
||||
Überschrift) und Abschnitte; der Text vor der zweiten Überschrift
|
||||
gehört zum Titel-Abschnitt."""
|
||||
|
||||
teile = _UEBERSCHRIFT.split(seite_html)
|
||||
# teile: [vorlauf, ebene, id, titel, text, ebene, id, titel, text, ...]
|
||||
abschnitte = [
|
||||
Abschnitt(titel=_klartext(teile[i + 2]), anker=teile[i + 1], text=_klartext(teile[i + 3]))
|
||||
for i in range(1, len(teile) - 1, 4)
|
||||
]
|
||||
titel = abschnitte[0].titel if abschnitte else ""
|
||||
return titel, abschnitte
|
||||
|
||||
|
||||
def _dateien() -> list[Path]:
|
||||
dateien = [MANUALS_DIR / name for name in EINZELDATEIEN]
|
||||
for ordner in ORDNER:
|
||||
dateien += sorted((MANUALS_DIR / ordner).glob("*.md"))
|
||||
return [d for d in dateien if d.is_file()]
|
||||
|
||||
|
||||
_sperre = threading.Lock()
|
||||
_stand: tuple = ()
|
||||
_anleitungen: dict[str, Anleitung] = {}
|
||||
|
||||
|
||||
def alle() -> dict[str, Anleitung]:
|
||||
"""Alle Anleitungen nach Pfad, bei geänderten Dateien neu eingelesen.
|
||||
Fehlt der Klon, ist das Ergebnis leer (Hilfe zeigt dann einen Hinweis)."""
|
||||
|
||||
global _stand, _anleitungen
|
||||
dateien = _dateien()
|
||||
stand = tuple((str(d), d.stat().st_mtime_ns) for d in dateien)
|
||||
with _sperre:
|
||||
if stand != _stand:
|
||||
neu = {}
|
||||
for datei in dateien:
|
||||
pfad = datei.relative_to(MANUALS_DIR).with_suffix("").as_posix()
|
||||
pfad = "" if pfad == "README" else pfad
|
||||
seite_html = _umwandeln(datei.read_text(encoding="utf-8"), pfad or "README")
|
||||
titel, abschnitte = _abschnitte(seite_html)
|
||||
neu[pfad] = Anleitung(pfad=pfad, titel=titel or pfad, html=seite_html, abschnitte=abschnitte)
|
||||
_anleitungen, _stand = neu, stand
|
||||
return _anleitungen
|
||||
|
||||
|
||||
def nachrangig(pfad: str) -> bool:
|
||||
return pfad in NACHRANGIG
|
||||
@@ -17,6 +17,7 @@ from routers import (
|
||||
dashboard,
|
||||
geraete,
|
||||
gruppen,
|
||||
hilfe,
|
||||
installationsmedium,
|
||||
organisation,
|
||||
personalisierung,
|
||||
@@ -86,3 +87,4 @@ app.include_router(admin_kunden.router)
|
||||
app.include_router(admin_accounts.router)
|
||||
app.include_router(admin_dashboard.router)
|
||||
app.include_router(suche.router)
|
||||
app.include_router(hilfe.router)
|
||||
@@ -18,6 +18,7 @@ httpx==0.28.1
|
||||
idna==3.18
|
||||
Jinja2==3.1.6
|
||||
makefun==1.16.0
|
||||
Markdown==3.11.2
|
||||
MarkupSafe==3.0.3
|
||||
psycopg==3.3.4
|
||||
psycopg-binary==3.3.4
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
import anleitungen
|
||||
from auth import get_current_user
|
||||
from templating import templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Nutzer-Wunsch 30.10.2026: Anleitungen im Portal, siehe anleitungen.py.
|
||||
@router.get("/hilfe")
|
||||
@router.get("/hilfe/{pfad:path}")
|
||||
def hilfe(request: Request, pfad: str = "", user=Depends(get_current_user)):
|
||||
alle = anleitungen.alle()
|
||||
anleitung = alle.get(pfad.strip("/"))
|
||||
if anleitung is None and (pfad or alle):
|
||||
raise HTTPException(status_code=404, detail="Anleitung nicht gefunden.")
|
||||
return templates.TemplateResponse(request, "hilfe.html", {"user": user, "anleitung": anleitung})
|
||||
+72
-22
@@ -2,6 +2,7 @@ import unicodedata
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
import anleitungen
|
||||
from anode_client import anode_request
|
||||
from auth import get_current_user
|
||||
from templating import org_ressourcen_freigeschaltet, templates
|
||||
@@ -13,55 +14,77 @@ router = APIRouter()
|
||||
# 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.
|
||||
# Seit 30.10.2026 außerdem die Abschnitte der Anleitungen (anleitungen.py);
|
||||
# tests/test_anleitungen.py prüft, dass die Anleitungsverweise unten passen.
|
||||
#
|
||||
# Eintrag: (Titel, Weg in der Oberfläche, URL, Stichworte)
|
||||
# Eintrag: (Titel, Weg in der Oberfläche, URL, Stichworte[, Anleitung])
|
||||
# Anleitung = Pfad unter /hilfe (manuals ohne .md), optional mit #Anker.
|
||||
FUNKTIONEN_KUNDE = [
|
||||
("Dashboard", "Dashboard", "/dashboard",
|
||||
"übersicht start erste schritte flotten-gesundheit ampel status"),
|
||||
"übersicht start erste schritte flotten-gesundheit ampel status",
|
||||
"kunden-admin/flotte-im-blick#dashboard"),
|
||||
("Letzte Aktivitäten", "Dashboard › Letzte Aktivitäten", "/dashboard#aktivitaeten",
|
||||
"aktivität protokoll verlauf agent-lauf fehler installationen ereignisse ältere"),
|
||||
"aktivität protokoll verlauf agent-lauf fehler installationen ereignisse ältere",
|
||||
"kunden-admin/flotte-im-blick#dashboard"),
|
||||
("Installationsmedium erstellen", "Installationsmedium › Erstellen", "/installationsmedium/erstellen",
|
||||
"usb stick iso download herunterladen installieren installation neues gerät medium wlan bereitstellungsvorlage"),
|
||||
"usb stick iso download herunterladen installieren installation neues gerät medium wlan bereitstellungsvorlage",
|
||||
"kunden-admin/installationsmedium-erstellen"),
|
||||
("Ausgegebene Medien", "Installationsmedium › Medien", "/installationsmedium#ausgegebene-medien",
|
||||
"medium deaktivieren widerrufen löschen verlängern aufladen abgelaufen aktivierung"),
|
||||
"medium deaktivieren widerrufen löschen verlängern aufladen abgelaufen aktivierung",
|
||||
"kunden-admin/installationsmedium-erstellen#alle-ausgegebenen-medien"),
|
||||
("Kontingent und Gültigkeit", "Installationsmedium › Medien › Kontingent und Gültigkeit", "/installationsmedium#kontingent",
|
||||
"kontingent gültigkeit ablauf 90 tage anzahl geräte"),
|
||||
"kontingent gültigkeit ablauf 90 tage anzahl geräte",
|
||||
"kunden-admin/installationsmedium-erstellen#kontingent-oder-gültigkeit-erhöhen"),
|
||||
("Deine Geräte (Flotte)", "Flotte", "/geraete",
|
||||
"geräte gerät rechner computer notebook laptop pc hostname liste flotte"),
|
||||
"geräte gerät rechner computer notebook laptop pc hostname liste flotte",
|
||||
"kunden-admin/flotte-im-blick#geräteliste"),
|
||||
("Gerät verwalten", "Flotte › Gerät › Aktionen", "/geraete",
|
||||
"aktionen gruppe zuweisen standort verschieben debug-modus protokoll log hardware"),
|
||||
"aktionen gruppe zuweisen standort verschieben debug-modus protokoll log hardware",
|
||||
"kunden-admin/flotte-im-blick#geräteseite-auftragskatalog"),
|
||||
("Gerät deprovisionieren oder löschen", "Flotte › Gerät › Aktionen", "/geraete",
|
||||
"deprovisionieren löschen entfernen ausmustern zurückgeben neu installieren reprovisionieren"),
|
||||
"deprovisionieren löschen entfernen ausmustern zurückgeben neu installieren reprovisionieren",
|
||||
"kunden-admin/flotte-im-blick#geräteseite-auftragskatalog"),
|
||||
("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"),
|
||||
"sperren verschlüsselt verschlüsselung home wiederherstellungsschlüssel qr-code benutzer lokaler benutzer",
|
||||
"kunden-admin/personalisierung"),
|
||||
("Lehrergerät festlegen", "Flotte › Gerät › Unterrichtssteuerung", "/geraete",
|
||||
"lehrergerät lehrer master veyon unterricht"),
|
||||
"lehrergerät lehrer master veyon unterricht",
|
||||
"kunden-admin/veyon#2-einschalten-und-lehrergerät-wählen"),
|
||||
("Unterrichtssteuerung (Veyon)", "Organisation › Standorte/Zwecke › Merkmale › Unterrichtssteuerung", "/struktur",
|
||||
"veyon unterrichtssteuerung klassenraum lehrer schüler bildschirm sperren beobachten schlüssel erneuern"),
|
||||
"veyon unterrichtssteuerung klassenraum lehrer schüler bildschirm sperren beobachten schlüssel erneuern",
|
||||
"kunden-admin/veyon"),
|
||||
("Standorte/Zwecke", "Organisation › Standorte/Zwecke", "/struktur",
|
||||
"standort zweck oe organisationseinheit raum klassenraum gebäude struktur workspace zuweisen merkmale"),
|
||||
"standort zweck oe organisationseinheit raum klassenraum gebäude struktur workspace zuweisen merkmale",
|
||||
"kunden-admin/struktur-gruppen-merkmale"),
|
||||
("Gruppen", "Organisation › Gruppen", "/gruppen",
|
||||
"gruppe gerätegruppe gruppen merkmale mehrere geräte"),
|
||||
"gruppe gerätegruppe gruppen merkmale mehrere geräte",
|
||||
"kunden-admin/struktur-gruppen-merkmale#gruppen-anlegen-und-geräte-zuordnen"),
|
||||
("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"),
|
||||
"wlan wifi ssid funknetz passwort schul-wlan netzwerk internet",
|
||||
"kunden-admin/installationsmedium-erstellen#vorher-wlan-der-organisation"),
|
||||
("E-Mail-Domänen für die Personalisierung", "Organisation › E-Mail-Domänen", "/organisation#email-domaenen",
|
||||
"e-mail mail domäne adresse personalisierung einladung"),
|
||||
"e-mail mail domäne adresse personalisierung einladung",
|
||||
"kunden-admin/personalisierung#hinweise"),
|
||||
("Mein Konto", "Organisation › Mein Konto", "/organisation#konto",
|
||||
"mein konto passwort ändern e-mail anmeldung profil"),
|
||||
"mein konto passwort ändern e-mail anmeldung profil",
|
||||
"kunden-admin/portal-anmelden#passwort-ändern"),
|
||||
("Konten der Verwaltung", "Organisation › Konten", "/organisation/konten",
|
||||
"konten kolleg kollegin verwaltung admin zugang anlegen benutzer portal passwort zurücksetzen"),
|
||||
"konten kolleg kollegin verwaltung admin zugang anlegen benutzer portal passwort zurücksetzen",
|
||||
"kunden-admin/konten"),
|
||||
("Workspace", "Workspace", "/workspace",
|
||||
"workspace apps programme software zusammenstellung vorlage standard merkmale"),
|
||||
"workspace apps programme software zusammenstellung vorlage standard merkmale",
|
||||
"kunden-admin/workspace"),
|
||||
]
|
||||
|
||||
# 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"),
|
||||
"ressourcen lesezeichen nextcloud gemeinsame ablage netzlaufwerk smb freigabe dienste",
|
||||
"kunden-admin/ressourcen"),
|
||||
]
|
||||
|
||||
FUNKTIONEN_ADMIN = [
|
||||
@@ -111,10 +134,36 @@ def _passt(woerter: list[str], *felder: str) -> bool:
|
||||
|
||||
def _funktionen(woerter: list[str], eintraege: list[tuple]) -> list[dict]:
|
||||
treffer = []
|
||||
for titel, weg, url, stichworte in eintraege:
|
||||
for titel, weg, url, stichworte, *anleitung in eintraege:
|
||||
if _passt(woerter, titel, weg, stichworte):
|
||||
# Treffer im Titel zuerst
|
||||
treffer.append((not _passt(woerter, titel), {"titel": titel, "weg": weg, "url": url}))
|
||||
treffer.append((not _passt(woerter, titel), {
|
||||
"titel": titel, "weg": weg, "url": url,
|
||||
"anleitung": f"/hilfe/{anleitung[0]}" if anleitung else None,
|
||||
}))
|
||||
return [t for _, t in sorted(treffer, key=lambda t: t[0])]
|
||||
|
||||
|
||||
def _anleitungen(woerter: list[str]) -> list[dict]:
|
||||
"""Abschnitte der Anleitungen (Nutzer-Wunsch 30.10.2026). Reihenfolge:
|
||||
Treffer in der Überschrift vor Treffern im Text, Änderungsprotokoll und
|
||||
Roadmap hinter den eigentlichen Anleitungen. Die Übersicht (README)
|
||||
zählt nicht, sie verweist nur auf die anderen."""
|
||||
|
||||
treffer = []
|
||||
for pfad, anleitung in anleitungen.alle().items():
|
||||
if not pfad:
|
||||
continue
|
||||
for nummer, abschnitt in enumerate(anleitung.abschnitte):
|
||||
if not _passt(woerter, anleitung.titel, abschnitt.titel, abschnitt.text):
|
||||
continue
|
||||
erster = nummer == 0
|
||||
treffer.append(((anleitungen.nachrangig(pfad), not _passt(woerter, abschnitt.titel)), {
|
||||
"titel": abschnitt.titel,
|
||||
"weg": "Anleitung" if erster else f"Anleitung: {anleitung.titel}",
|
||||
"url": f"/hilfe/{pfad}" if erster else f"/hilfe/{pfad}#{abschnitt.anker}",
|
||||
"text": abschnitt.text,
|
||||
}))
|
||||
return [t for _, t in sorted(treffer, key=lambda t: t[0])]
|
||||
|
||||
|
||||
@@ -169,6 +218,7 @@ def suchen(user, q: str) -> dict:
|
||||
|
||||
return {
|
||||
"Funktionen": funktionen[:MAX_TREFFER],
|
||||
"Anleitungen": _anleitungen(woerter)[:MAX_TREFFER],
|
||||
"Organisationen": organisationen[:MAX_TREFFER],
|
||||
"Geräte": geraete_treffer[:MAX_TREFFER],
|
||||
"Workspaces": workspaces[:MAX_TREFFER],
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
Schlüssel {{ "fehlen (Setup Mode)" if firmware.setup_mode else ("vorhanden" if firmware.setup_mode == false else "unbekannt") }},
|
||||
Drittanbieter-CA {{ "ja" if firmware.third_party_ca else ("nein" if firmware.third_party_ca == false else "unbekannt") }}{% endif %},
|
||||
TPM {{ firmware.tpm_version if firmware.tpm_version not in (none, "", "none") else "keins" }}.
|
||||
<a href="https://git.tuxflotte.de/admin/manuals/src/branch/main/kunden-admin/secure-boot.md" target="_blank" rel="noopener">Anleitung</a></small></p>
|
||||
<a href="/hilfe/kunden-admin/secure-boot">Anleitung</a></small></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endmacro %}
|
||||
@@ -298,6 +298,7 @@
|
||||
{% 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><a href="/hilfe" role="button" class="secondary outline" aria-label="Hilfe" title="Anleitungen">❓</a></li>
|
||||
<li class="kopf-email">{{ user.email }}</li>
|
||||
<li><a href="/logout" role="button" class="secondary outline">Abmelden</a></li>
|
||||
{% endif %}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ anleitung.titel if anleitung else "Hilfe" }} — Tuxflotte{% endblock %}
|
||||
{% block content %}
|
||||
{# Nutzer-Wunsch 30.10.2026, siehe anleitungen.py #}
|
||||
{% if anleitung %}
|
||||
{% if anleitung.pfad %}<p><small><a href="/hilfe">‹ Alle Anleitungen</a></small></p>{% endif %}
|
||||
<article class="anleitung">
|
||||
{{ anleitung.html | safe }}
|
||||
</article>
|
||||
{% else %}
|
||||
<h2>Hilfe</h2>
|
||||
<p>Die Anleitungen sind gerade nicht verfügbar. Bitte versuch es später noch einmal.</p>
|
||||
{% endif %}
|
||||
<style>
|
||||
article.anleitung h1 { font-size: 1.6rem; }
|
||||
article.anleitung h2 { font-size: 1.3rem; margin-top: 1.6rem; }
|
||||
article.anleitung h3 { font-size: 1.1rem; }
|
||||
article.anleitung table { margin-bottom: 1rem; }
|
||||
article.anleitung :target { scroll-margin-top: 1rem; background: var(--pico-mark-background-color); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -18,7 +18,7 @@
|
||||
<ul class="suchtreffer">
|
||||
{% for t in treffer %}
|
||||
<li>
|
||||
<a href="{{ t.url }}">{{ t.titel }}</a>
|
||||
<a href="{{ t.url }}">{{ t.titel }}</a>{% if t.anleitung %} · <small><a href="{{ t.anleitung }}">Anleitung</a></small>{% endif %}
|
||||
{% if t.weg %}<br><small>{{ t.weg }}</small>{% endif %}
|
||||
{% if t.text %}<br><small class="suchtreffer-text">{{ t.text | truncate(160) }}</small>{% endif %}
|
||||
</li>
|
||||
@@ -30,7 +30,7 @@
|
||||
<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>
|
||||
<p><small>Durchsucht Funktionen und Seiten des Flotten-Managements, die <a href="/hilfe">Anleitungen</a>, 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; }
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Tests für die Anleitungen unter /hilfe und in der Suche (anleitungen.py,
|
||||
routers/suche.py). Ohne Datenbank. Der zweite Teil prüft gegen einen echten
|
||||
manuals-Klon und läuft nur, wenn KUNDENPLATTFORM_MANUALS_DIR darauf zeigt:
|
||||
|
||||
KUNDENPLATTFORM_MANUALS_DIR=../manuals python -m unittest tests.test_anleitungen
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
os.environ.setdefault("KUNDENPLATTFORM_SESSION_SECRET", "nur-fuer-tests-" + "x" * 32)
|
||||
|
||||
import anleitungen # noqa: E402
|
||||
|
||||
ECHTER_KLON = Path(os.environ.get("KUNDENPLATTFORM_MANUALS_DIR", "/nicht/vorhanden"))
|
||||
|
||||
|
||||
class AnleitungenTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.verzeichnis = tempfile.TemporaryDirectory()
|
||||
wurzel = Path(self.verzeichnis.name)
|
||||
(wurzel / "kunden-admin").mkdir()
|
||||
(wurzel / "nutzer").mkdir()
|
||||
(wurzel / "README.md").write_text(
|
||||
"# Übersicht\n\n[Admin](kunden-admin/) und [Veyon](kunden-admin/veyon.md)\n", encoding="utf-8")
|
||||
(wurzel / "kunden-admin" / "veyon.md").write_text(
|
||||
"# Veyon einrichten\n\nEinleitung.\n\n## Wann es ankommt\n\nNach dem **nächsten** Lauf (siehe [Master](../nutzer/master.md#änderungen-sehen)).\n\n"
|
||||
"Extern: [Portal](https://flotten-management.tuxflotte.de)\n\n<script>alert(1)</script>\n",
|
||||
encoding="utf-8")
|
||||
(wurzel / "nutzer" / "master.md").write_text("# Master\n\n## Änderungen sehen\n\nText.\n", encoding="utf-8")
|
||||
anleitungen.MANUALS_DIR = wurzel
|
||||
anleitungen._stand = ()
|
||||
|
||||
def tearDown(self):
|
||||
self.verzeichnis.cleanup()
|
||||
|
||||
def test_seiten_und_abschnitte(self):
|
||||
alle = anleitungen.alle()
|
||||
self.assertEqual(set(alle), {"", "kunden-admin/veyon", "nutzer/master"})
|
||||
veyon = alle["kunden-admin/veyon"]
|
||||
self.assertEqual(veyon.titel, "Veyon einrichten")
|
||||
self.assertEqual([a.anker for a in veyon.abschnitte], ["veyon-einrichten", "wann-es-ankommt"])
|
||||
self.assertEqual(veyon.abschnitte[1].text.split(" Extern")[0], "Nach dem nächsten Lauf (siehe Master).")
|
||||
|
||||
def test_verweise(self):
|
||||
alle = anleitungen.alle()
|
||||
self.assertIn('href="/hilfe"', alle[""].html)
|
||||
self.assertIn('href="/hilfe/kunden-admin/veyon"', alle[""].html)
|
||||
veyon = alle["kunden-admin/veyon"].html
|
||||
self.assertIn('href="/hilfe/nutzer/master#änderungen-sehen"', veyon)
|
||||
self.assertIn('href="https://flotten-management.tuxflotte.de" rel="noopener" target="_blank"', veyon)
|
||||
self.assertIn('id="änderungen-sehen"', alle["nutzer/master"].html)
|
||||
|
||||
def test_kein_roh_html(self):
|
||||
self.assertNotIn("<script>", anleitungen.alle()["kunden-admin/veyon"].html)
|
||||
|
||||
def test_aenderung_wird_neu_eingelesen(self):
|
||||
anleitungen.alle()
|
||||
datei = anleitungen.MANUALS_DIR / "nutzer" / "master.md"
|
||||
datei.write_text("# Master neu\n", encoding="utf-8")
|
||||
os.utime(datei, ns=(1, 1))
|
||||
self.assertEqual(anleitungen.alle()["nutzer/master"].titel, "Master neu")
|
||||
|
||||
|
||||
@unittest.skipUnless(ECHTER_KLON.is_dir(), "KUNDENPLATTFORM_MANUALS_DIR zeigt nicht auf einen manuals-Klon.")
|
||||
class EchterKlonTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
anleitungen.MANUALS_DIR = ECHTER_KLON
|
||||
anleitungen._stand = ()
|
||||
self.alle = anleitungen.alle()
|
||||
|
||||
def _pruefen(self, ziel: str):
|
||||
pfad, _, anker = ziel.partition("#")
|
||||
self.assertIn(pfad, self.alle, ziel)
|
||||
if anker:
|
||||
self.assertIn(anker, [a.anker for a in self.alle[pfad].abschnitte], ziel)
|
||||
|
||||
def test_verweise_der_suche(self):
|
||||
suche = importlib.import_module("routers.suche")
|
||||
for eintrag in suche.FUNKTIONEN_KUNDE + suche.FUNKTIONEN_RESSOURCEN + suche.FUNKTIONEN_ADMIN:
|
||||
if len(eintrag) > 4:
|
||||
self._pruefen(eintrag[4])
|
||||
|
||||
def test_verweise_zwischen_anleitungen(self):
|
||||
import re
|
||||
|
||||
for anleitung in self.alle.values():
|
||||
for href in re.findall(r'href="/hilfe/?([^"]*)"', anleitung.html):
|
||||
self._pruefen(href)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user