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:
adminandClaude Opus 5.5 committed 2026-10-30 09:19:11 +01:00
1 parent 86a68e3cd7
commit acd0406181
11 files changed
+377 -25

No files matched your search

+5
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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)
+1
View File
@@ -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
+18
View File
@@ -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
View File
@@ -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],
+1 -1
View File
@@ -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 %}
+1
View File
@@ -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 %}
+21
View File
@@ -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 %}
+2 -2
View File
@@ -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; }
+100
View File
@@ -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()