Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbe434de02 | ||
|
|
1ae23b4e9d | ||
|
|
9055f3437f | ||
|
|
92dc91885b |
@@ -3,6 +3,49 @@
|
||||
The docs module renders documentation for the current GovOPlaN instance instead
|
||||
of showing a static product manual.
|
||||
|
||||
## Finding and grouping help
|
||||
|
||||
The Help Center searches authorized titles, summaries, body text, area names,
|
||||
and public topic tags. The shared multi-selection list filter uses OR between
|
||||
selected tags; Select all removes the restriction, Clear all yields no results.
|
||||
Search results deduplicate semantic topic IDs. Selecting a result clears the
|
||||
filters and opens its topic.
|
||||
|
||||
Topics by area groups each topic under its source module and authorized related
|
||||
modules. Contributors may add public keyword strings in `metadata.tags` and
|
||||
additional stable module IDs in `metadata.areas`. The Docs-owned
|
||||
user projection accepts up to 32 tags of at most 80 characters and 32 explicit
|
||||
area IDs of at most 255 characters. The
|
||||
`area_module_ids` response is authoritative: user responses omit related areas
|
||||
without an actor-visible route, while preserving the topic's source area.
|
||||
Existing role, tenant, locale, version, and configured-state topic authorization
|
||||
is unchanged. Tags are public descriptive content, not a place for private
|
||||
configuration, credentials, or hidden capability names.
|
||||
|
||||
Topic IDs identify content; parent-qualified navigation occurrence IDs identify
|
||||
one position in the tree. `topic` URLs remain supported; a tree click also sets
|
||||
`occurrence`, so reload highlights and reveals only the selected position.
|
||||
Expanding a repeated topic never expands its other occurrences.
|
||||
|
||||
### Deutsch
|
||||
|
||||
Die Suche berücksichtigt berechtigte Titel, Zusammenfassungen, Thementexte,
|
||||
Bereichsnamen und öffentliche Schlagwörter. Mehrere ausgewählte Schlagwörter
|
||||
werden mit ODER verknüpft. Alle auswählen entfernt die Einschränkung; Auswahl
|
||||
aufheben ergibt keine Treffer. Suchergebnisse enthalten jedes Thema einmal.
|
||||
Die Auswahl eines Treffers setzt die Filter zurück und öffnet das Thema.
|
||||
|
||||
Themen nach Bereich ordnet Beiträge dem Quellmodul und berechtigten verwandten
|
||||
Modulen zu. Modulautoren können öffentliche Stichwörter in `metadata.tags` und
|
||||
weitere stabile Modulkennungen in `metadata.areas` angeben. Für Benutzer werden
|
||||
verwandte Bereiche ohne sichtbare Route nicht ausgegeben. Suche und Filter
|
||||
erweitern niemals die Dokumentationsberechtigung. Zugangsdaten und private
|
||||
Konfiguration gehören nicht in Schlagwörter.
|
||||
|
||||
Ein Thema kann in mehreren Zweigen erscheinen. Eine positionsbezogene Kennung
|
||||
im Link speichert, welches Vorkommen ausgewählt wurde. Nur dieses Vorkommen
|
||||
wird hervorgehoben; Aufklappen öffnet nicht zugleich die anderen Fundstellen.
|
||||
|
||||
## Inputs
|
||||
|
||||
The documentation context is built from:
|
||||
|
||||
@@ -29,6 +29,21 @@ Semantic content is bounded plain text. Links must be local absolute paths or HT
|
||||
|
||||
Search indexes only published revisions and always requires provider reauthorization before returning a result. Generic public documentation generation reads static manifest topics only, so it cannot include tenant semantic entries. The separately authorized tenant export includes current entries and immutable history and sends `private, no-store`.
|
||||
|
||||
Collection/context projection and search authorization load required revisions
|
||||
in request-local batches of at most 400 identifiers. Read-only projection loads
|
||||
published content only, never pending draft bodies, and remains available if a
|
||||
pending draft reference is broken. Editors still receive an explicit error for
|
||||
an unavailable current revision. Revision tenant, entry, and publication-state
|
||||
references must agree before content is projected or indexed. An audience
|
||||
denial is checked before calling the subject provider; an allowed audience does
|
||||
not replace the provider's current permission checks.
|
||||
|
||||
This reduces Docs-owned database round trips without caching authorization
|
||||
between requests or importing subject-module internals. Owner-provider reads
|
||||
remain independent. The batch size is not a collection or export limit: complete
|
||||
semantic catalogues and separately authorized history exports can still require
|
||||
work proportional to their size.
|
||||
|
||||
## Backup, recovery, and module removal
|
||||
|
||||
Back up `docs_semantic_entries` and `docs_semantic_revisions` together with Core tenant and audit state. Restoring only one table breaks revision pointers and is unsupported. The installer blocks normal uninstall while rows remain. Destructive retirement is explicit, requires a database snapshot, and drops revision history before entries.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.20",
|
||||
"version": "0.1.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"README.md"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-docs"
|
||||
version = "0.1.21"
|
||||
version = "0.1.23"
|
||||
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.37",
|
||||
"govoplan-core>=0.1.45",
|
||||
"govoplan-access>=0.1.18",
|
||||
]
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.18"
|
||||
__version__ = "0.1.23"
|
||||
|
||||
@@ -40,6 +40,7 @@ from govoplan_docs.backend.sources import (
|
||||
)
|
||||
from govoplan_docs.backend.semantic_service import (
|
||||
list_semantic_entries,
|
||||
prefetch_semantic_revisions,
|
||||
select_locale_entries,
|
||||
semantic_entry_payload,
|
||||
)
|
||||
@@ -830,6 +831,11 @@ def _classify_documentation(
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []}
|
||||
installed = {manifest.id for manifest in registry.manifests()}
|
||||
visible_area_modules = frozenset(
|
||||
str(item["module_id"])
|
||||
for item in _route_items(registry.manifests(), principal)
|
||||
if item["visible"]
|
||||
) if documentation_type == "user" else frozenset(installed)
|
||||
visible_runtime_paths = frozenset([
|
||||
"/settings", # Authenticated shell route, not contributed by a module manifest.
|
||||
*(
|
||||
@@ -909,6 +915,7 @@ def _classify_documentation(
|
||||
visible_runtime_paths=visible_runtime_paths,
|
||||
configuration=configuration,
|
||||
resolved_version=resolved_version,
|
||||
visible_area_modules=visible_area_modules,
|
||||
))
|
||||
return layers
|
||||
|
||||
@@ -1011,6 +1018,7 @@ def _semantic_documentation_topics(
|
||||
list_semantic_entries(session, principal),
|
||||
locale=locale,
|
||||
)
|
||||
revisions = prefetch_semantic_revisions(session, principal, entries=entries, editor=False)
|
||||
topics: list[DocumentationTopic] = []
|
||||
for entry in entries:
|
||||
payload = semantic_entry_payload(
|
||||
@@ -1020,6 +1028,7 @@ def _semantic_documentation_topics(
|
||||
entry=entry,
|
||||
editor=False,
|
||||
requested_locale=locale,
|
||||
revisions=revisions,
|
||||
)
|
||||
if payload is None:
|
||||
continue
|
||||
@@ -1430,6 +1439,7 @@ def _documentation_topic_payload(
|
||||
visible_runtime_paths: frozenset[str],
|
||||
configuration: Mapping[str, DocumentationConfigurationDecision],
|
||||
resolved_version: str,
|
||||
visible_area_modules: frozenset[str] = frozenset(),
|
||||
) -> dict[str, Any]:
|
||||
module_id = topic.source_module_id or source_module_id
|
||||
translation_locale, translation = _translation_for_locale(topic, locale)
|
||||
@@ -1484,6 +1494,16 @@ def _documentation_topic_payload(
|
||||
"conditions": [_documentation_condition_payload(condition) for condition in topic.conditions],
|
||||
"links": [_documentation_link_payload(link) for link in topic.links],
|
||||
"related_modules": list(topic.related_modules),
|
||||
"area_module_ids": sorted({
|
||||
module_id,
|
||||
*(
|
||||
area for area in (
|
||||
*topic.related_modules,
|
||||
*_bounded_string_list(localized_metadata.get("areas"), maximum_items=32, maximum_length=255),
|
||||
)
|
||||
if area in visible_area_modules
|
||||
),
|
||||
}),
|
||||
"unlocks": list(topic.unlocks),
|
||||
"configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}),
|
||||
"configuration_states": [
|
||||
@@ -1497,6 +1517,7 @@ def _documentation_topic_payload(
|
||||
return {
|
||||
"id": payload["id"],
|
||||
"source_module_id": payload["source_module_id"],
|
||||
"area_module_ids": payload["area_module_ids"],
|
||||
"kind": payload["kind"],
|
||||
"anchor_id": payload["anchor_id"],
|
||||
"title": payload["title"],
|
||||
@@ -1555,6 +1576,9 @@ def _user_topic_metadata(kind: str, metadata: Mapping[str, Any]) -> dict[str, An
|
||||
value = _bounded_string(metadata.get(key), maximum=255)
|
||||
if value:
|
||||
projected[key] = value
|
||||
tags = _bounded_string_list(metadata.get("tags"), maximum_items=32, maximum_length=80)
|
||||
if tags:
|
||||
projected["tags"] = tags
|
||||
if kind == "reference" and isinstance(metadata.get("fields"), list):
|
||||
fields = [_user_field_metadata(item) for item in metadata["fields"][:64] if isinstance(item, Mapping)]
|
||||
if fields:
|
||||
|
||||
@@ -43,6 +43,7 @@ from govoplan_docs.backend.semantic_service import (
|
||||
create_semantic_entry,
|
||||
get_semantic_entry,
|
||||
list_semantic_entries,
|
||||
prefetch_semantic_revisions,
|
||||
publication_policy,
|
||||
publish_semantic_entry,
|
||||
retire_semantic_entry,
|
||||
@@ -167,6 +168,7 @@ def list_entries(
|
||||
subject_kind=subject_kind,
|
||||
)
|
||||
selected = entries if editor else select_locale_entries(entries, locale=locale)
|
||||
revisions = prefetch_semantic_revisions(session, principal, entries=selected, editor=editor)
|
||||
items = [
|
||||
item
|
||||
for entry in selected
|
||||
@@ -178,6 +180,7 @@ def list_entries(
|
||||
entry=entry,
|
||||
editor=editor,
|
||||
requested_locale=locale,
|
||||
revisions=revisions,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
|
||||
@@ -131,7 +131,7 @@ def _dsar_provider(_context: ModuleContext) -> DocsDsarProvider:
|
||||
manifest = ModuleManifest(
|
||||
id="docs",
|
||||
name="Docs",
|
||||
version="0.1.21",
|
||||
version="0.1.23",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
@@ -302,6 +302,46 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="docs.workflow.find-help",
|
||||
title="Find help by area and keyword",
|
||||
summary="Search visible help topics and use area and keyword tags to narrow the results.",
|
||||
body=(
|
||||
"Enter words from your question in Search help topics. Titles, summaries, topic text, area names, and contributed tags are searched together. "
|
||||
"Areas and tags is a multi-selection dropdown: selected tags match any of those tags; Select all removes the restriction and Clear all selects no topics. "
|
||||
"Topics by area includes all visible guidance associated with an area, including guidance contributed by another module. "
|
||||
"A topic can appear in several branches; only the occurrence you select is highlighted, and expanding it does not expand its other occurrences. "
|
||||
"Search results show each topic once. Choosing a result clears the filters and opens the topic. Search and tags only narrow the documentation already authorized for your role. "
|
||||
"Administrators and module authors contribute public keywords in DocumentationTopic metadata.tags and optional module-area IDs in metadata.areas; the source module and authorized related_modules also supply area tags. "
|
||||
"Use stable module IDs for areas and readable keywords for tags; do not put credentials, private configuration, or hidden capability names in public search tags. "
|
||||
"User area facets omit related modules without a visible route; administrative documentation still requires its separate permission."
|
||||
),
|
||||
layer="always",
|
||||
documentation_types=("admin", "user"),
|
||||
order=9,
|
||||
conditions=(DocumentationCondition(required_scopes=(DOCS_READ_SCOPE,)),),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Hilfe nach Bereich und Stichwort finden",
|
||||
"summary": "Sichtbare Hilfethemen durchsuchen und Ergebnisse mit Bereichen und Schlagwörtern eingrenzen.",
|
||||
"body": (
|
||||
"Geben Sie unter Hilfethemen suchen Wörter aus Ihrer Frage ein. Titel, Zusammenfassungen, Thementexte, Bereichsnamen und beigetragene Schlagwörter werden gemeinsam durchsucht. "
|
||||
"Bereiche und Schlagwörter ist eine Auswahlliste mit Mehrfachauswahl: Ein ausgewähltes Schlagwort genügt für einen Treffer. Alle auswählen entfernt die Einschränkung, Auswahl aufheben wählt keine Themen. "
|
||||
"Themen nach Bereich enthält sämtliche sichtbaren Hinweise eines Bereichs, auch Beiträge anderer Module. "
|
||||
"Ein Thema kann in mehreren Zweigen erscheinen; nur die angeklickte Stelle wird hervorgehoben, und das Aufklappen öffnet nicht zugleich die anderen Vorkommen. "
|
||||
"Suchergebnisse zeigen jedes Thema einmal. Die Auswahl eines Ergebnisses setzt die Filter zurück und öffnet das Thema. Suche und Schlagwörter grenzen ausschließlich die bereits für Ihre Rolle freigegebene Dokumentation ein. "
|
||||
"Administratoren und Modulautoren hinterlegen öffentliche Stichwörter in DocumentationTopic metadata.tags und optionale Modul-Bereichskennungen in metadata.areas; das Quellmodul und berechtigte related_modules liefern ebenfalls Bereichsschlagwörter. "
|
||||
"Verwenden Sie stabile Modulkennungen für Bereiche und lesbare Stichwörter für Schlagwörter. Zugangsdaten, private Konfiguration und Namen verborgener Fähigkeiten gehören nicht in öffentliche Suchschlagwörter. "
|
||||
"Bereichsfilter der Benutzerdokumentation zeigen keine zugeordneten Module ohne sichtbare Route. Administrative Dokumentation benötigt weiterhin ihre gesonderte Berechtigung."
|
||||
),
|
||||
},
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"tags": ["Help", "Hilfe", "Search", "Suche", "Tags", "Schlagwörter"],
|
||||
"help_contexts": ["docs.help-center.search"],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="docs.semantic-documentation",
|
||||
title="Tenant semantic documentation",
|
||||
@@ -311,7 +351,8 @@ manifest = ModuleManifest(
|
||||
"Every save creates an immutable revision. Tenant policy chooses direct publication or an independent reviewer. "
|
||||
"Published content remains subject to the subject's current authorization, the documentation audience and classification, tenant isolation, and locale selection. "
|
||||
"Changed, missing, superseded, or temporarily unavailable subjects are shown explicitly; direct links, contextual help, search, caches, and tenant exports apply the same read-time authorization. "
|
||||
"Retirement and supersession preserve history. Generic public documentation exports never include tenant semantic entries; administrators use the separately authorized tenant export."
|
||||
"Retirement and supersession preserve history. Generic public documentation exports never include tenant semantic entries; administrators use the separately authorized tenant export. "
|
||||
"Collection, configured-context, and search-authorization reads batch the required revisions in request-local groups of at most 400 identifiers instead of fetching revisions once per entry. Read-only views load published content, not pending draft bodies. Each revision must belong to the same tenant and entry, and a published pointer must reference a published revision; inconsistent references fail closed. Audience denial avoids unnecessary subject-provider work, while allowed results still require the owning subject's current authorization. No cross-request permission cache is introduced; complete tenant exports and full history remain separately authorized operations."
|
||||
),
|
||||
layer="always",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -351,11 +392,19 @@ manifest = ModuleManifest(
|
||||
"der Mandantentrennung und der Sprachauswahl. Geänderte, fehlende, abgelöste oder vorübergehend nicht verfügbare Fachobjekte "
|
||||
"werden ausdrücklich gekennzeichnet; Direktlinks, Kontexthilfe, Suche, Zwischenspeicher und Mandantenexporte wenden dieselbe "
|
||||
"Berechtigungsprüfung beim Lesen an. Stilllegung und Ablösung bewahren die Historie. Allgemeine öffentliche Dokumentationsexporte "
|
||||
"enthalten niemals semantische Mandanteneinträge; für diese steht der getrennt berechtigte Mandantenexport bereit."
|
||||
"enthalten niemals semantische Mandanteneinträge; für diese steht der getrennt berechtigte Mandantenexport bereit. "
|
||||
"Listen, Konfigurationskontext und Suchberechtigungsprüfung laden benötigte Revisionen anfragebezogen in Gruppen von höchstens 400 Kennungen statt einzeln je Eintrag. "
|
||||
"Nur lesbare Ansichten laden veröffentlichte Inhalte und keine offenen Entwurfstexte. Jede Revision muss zum selben Mandanten und Eintrag gehören; "
|
||||
"ein Veröffentlichungsverweis muss auf eine veröffentlichte Revision zeigen. Widersprüchliche Verweise werden abgewiesen. "
|
||||
"Bei einer nicht berechtigten Zielgruppe entfällt unnötige Arbeit des Fachobjekt-Providers; zulässige Ergebnisse erfordern weiterhin dessen aktuelle Berechtigungsprüfung. "
|
||||
"Es entsteht kein anfrageübergreifender Berechtigungszwischenspeicher. Vollständiger Mandantenexport und Historie bleiben getrennt berechtigte Vorgänge."
|
||||
),
|
||||
}
|
||||
},
|
||||
metadata={"kind": "workflow"},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"help_contexts": ["docs.semantic-documentation.publish"],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="docs.data-subject-requests",
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
@@ -23,7 +23,11 @@ from govoplan_docs.backend.db.models import (
|
||||
SemanticDocumentationEntry,
|
||||
SemanticDocumentationRevision,
|
||||
)
|
||||
from govoplan_docs.backend.semantic_service import semantic_entry_payload
|
||||
from govoplan_docs.backend.semantic_service import (
|
||||
prefetch_semantic_revisions,
|
||||
published_revision,
|
||||
semantic_entry_payload,
|
||||
)
|
||||
|
||||
|
||||
PROVIDER_ID = "docs.semantic_documentation"
|
||||
@@ -58,8 +62,12 @@ class SemanticDocumentationSearchSource:
|
||||
select(SemanticDocumentationEntry, SemanticDocumentationRevision)
|
||||
.join(
|
||||
SemanticDocumentationRevision,
|
||||
SemanticDocumentationRevision.id
|
||||
== SemanticDocumentationEntry.published_revision_id,
|
||||
and_(
|
||||
SemanticDocumentationRevision.id == SemanticDocumentationEntry.published_revision_id,
|
||||
SemanticDocumentationRevision.entry_id == SemanticDocumentationEntry.id,
|
||||
SemanticDocumentationRevision.tenant_id == SemanticDocumentationEntry.tenant_id,
|
||||
SemanticDocumentationRevision.lifecycle_state == "published",
|
||||
),
|
||||
)
|
||||
.where(
|
||||
SemanticDocumentationEntry.tenant_id == request.tenant_id,
|
||||
@@ -105,15 +113,32 @@ class SemanticDocumentationSearchSource:
|
||||
if not isinstance(principal, ApiPrincipal) or not principal.has(DOCS_READ_SCOPE):
|
||||
return decisions
|
||||
db = _session(session)
|
||||
for item in requests:
|
||||
reference = item.reference
|
||||
valid_requests = [
|
||||
item
|
||||
for item in requests
|
||||
if (
|
||||
reference.tenant_id != principal.tenant_id
|
||||
or reference.module_id != "docs"
|
||||
or reference.resource_type != RESOURCE_TYPE
|
||||
):
|
||||
continue
|
||||
entry = db.get(SemanticDocumentationEntry, reference.resource_id)
|
||||
item.reference.tenant_id == principal.tenant_id
|
||||
and item.reference.module_id == "docs"
|
||||
and item.reference.resource_type == RESOURCE_TYPE
|
||||
)
|
||||
]
|
||||
ids = sorted({item.reference.resource_id for item in valid_requests})
|
||||
entries: dict[str, SemanticDocumentationEntry] = {}
|
||||
for offset in range(0, len(ids), 400):
|
||||
rows = db.scalars(
|
||||
select(SemanticDocumentationEntry).where(
|
||||
SemanticDocumentationEntry.id.in_(ids[offset:offset + 400]),
|
||||
SemanticDocumentationEntry.tenant_id == principal.tenant_id,
|
||||
SemanticDocumentationEntry.lifecycle_state.in_(("draft", "published")),
|
||||
)
|
||||
)
|
||||
entries.update((entry.id, entry) for entry in rows)
|
||||
revisions = prefetch_semantic_revisions(
|
||||
db, principal, entries=tuple(entries.values()), editor=False,
|
||||
)
|
||||
for item in valid_requests:
|
||||
reference = item.reference
|
||||
entry = entries.get(reference.resource_id)
|
||||
if (
|
||||
entry is None
|
||||
or entry.tenant_id != principal.tenant_id
|
||||
@@ -127,6 +152,7 @@ class SemanticDocumentationSearchSource:
|
||||
principal,
|
||||
entry=entry,
|
||||
editor=False,
|
||||
revisions=revisions,
|
||||
)
|
||||
decisions[reference.key] = bool(
|
||||
payload
|
||||
@@ -153,8 +179,12 @@ class SemanticDocumentationSearchSource:
|
||||
db = _session(session)
|
||||
entry = db.get(SemanticDocumentationEntry, event.resource.id)
|
||||
revision = (
|
||||
db.get(SemanticDocumentationRevision, entry.published_revision_id)
|
||||
if entry is not None and entry.published_revision_id
|
||||
published_revision(db, entry)
|
||||
if (
|
||||
entry is not None
|
||||
and entry.tenant_id == event.tenant.id
|
||||
and entry.lifecycle_state in {"draft", "published"}
|
||||
)
|
||||
else None
|
||||
)
|
||||
visible = bool(
|
||||
@@ -162,6 +192,9 @@ class SemanticDocumentationSearchSource:
|
||||
and entry.tenant_id == event.tenant.id
|
||||
and entry.lifecycle_state in {"draft", "published"}
|
||||
and revision is not None
|
||||
and revision.entry_id == entry.id
|
||||
and revision.tenant_id == entry.tenant_id
|
||||
and revision.lifecycle_state == "published"
|
||||
)
|
||||
cursor = event.event_id
|
||||
document = _document(entry, revision, change_cursor=cursor) if visible else None
|
||||
|
||||
@@ -396,13 +396,21 @@ def semantic_entry_history(
|
||||
def current_revision(
|
||||
session: Session,
|
||||
entry: SemanticDocumentationEntry,
|
||||
*,
|
||||
revisions: Mapping[str, SemanticDocumentationRevision] | None = None,
|
||||
) -> SemanticDocumentationRevision:
|
||||
revision = None
|
||||
if entry.current_revision_id:
|
||||
revision = (
|
||||
session.get(SemanticDocumentationRevision, entry.current_revision_id)
|
||||
if entry.current_revision_id
|
||||
else None
|
||||
revisions.get(entry.current_revision_id)
|
||||
if revisions is not None
|
||||
else session.get(SemanticDocumentationRevision, entry.current_revision_id)
|
||||
)
|
||||
if revision is None or revision.entry_id != entry.id:
|
||||
if (
|
||||
revision is None
|
||||
or revision.entry_id != entry.id
|
||||
or revision.tenant_id != entry.tenant_id
|
||||
):
|
||||
raise SemanticDocumentationError(
|
||||
"Semantic documentation current revision is unavailable."
|
||||
)
|
||||
@@ -412,14 +420,60 @@ def current_revision(
|
||||
def published_revision(
|
||||
session: Session,
|
||||
entry: SemanticDocumentationEntry,
|
||||
*,
|
||||
revisions: Mapping[str, SemanticDocumentationRevision] | None = None,
|
||||
) -> SemanticDocumentationRevision | None:
|
||||
if not entry.published_revision_id:
|
||||
return None
|
||||
revision = session.get(
|
||||
SemanticDocumentationRevision,
|
||||
entry.published_revision_id,
|
||||
revision = (
|
||||
revisions.get(entry.published_revision_id)
|
||||
if revisions is not None
|
||||
else session.get(SemanticDocumentationRevision, entry.published_revision_id)
|
||||
)
|
||||
return revision if revision is not None and revision.entry_id == entry.id else None
|
||||
if (
|
||||
revision is None
|
||||
or revision.entry_id != entry.id
|
||||
or revision.tenant_id != entry.tenant_id
|
||||
or revision.lifecycle_state != "published"
|
||||
):
|
||||
return None
|
||||
return revision
|
||||
|
||||
|
||||
def prefetch_semantic_revisions(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
entries: Sequence[SemanticDocumentationEntry],
|
||||
editor: bool,
|
||||
) -> dict[str, SemanticDocumentationRevision]:
|
||||
"""Load only the revisions this request may project, in bounded SQL batches.
|
||||
|
||||
This is request-local data loading, never an authorization cache. Payload
|
||||
projection still validates each entry/revision pair and its owner subject.
|
||||
Readers have no reason to load the bodies of pending unpublished drafts.
|
||||
"""
|
||||
tenant_id = _principal_tenant_id(principal)
|
||||
revision_ids = sorted({
|
||||
revision_id
|
||||
for entry in entries
|
||||
if entry.tenant_id == tenant_id
|
||||
for revision_id in (
|
||||
entry.published_revision_id,
|
||||
entry.current_revision_id if editor else None,
|
||||
)
|
||||
if revision_id
|
||||
})
|
||||
revisions: dict[str, SemanticDocumentationRevision] = {}
|
||||
for offset in range(0, len(revision_ids), 400):
|
||||
rows = session.query(SemanticDocumentationRevision).filter(
|
||||
SemanticDocumentationRevision.tenant_id == tenant_id,
|
||||
SemanticDocumentationRevision.id.in_(revision_ids[offset:offset + 400]),
|
||||
)
|
||||
if not editor:
|
||||
rows = rows.filter(SemanticDocumentationRevision.lifecycle_state == "published")
|
||||
revisions.update((revision.id, revision) for revision in rows)
|
||||
return revisions
|
||||
|
||||
|
||||
def entry_subject_reference(
|
||||
@@ -487,12 +541,16 @@ def semantic_entry_payload(
|
||||
entry: SemanticDocumentationEntry,
|
||||
editor: bool,
|
||||
requested_locale: str | None = None,
|
||||
revisions: Mapping[str, SemanticDocumentationRevision] | None = None,
|
||||
) -> dict[str, object] | None:
|
||||
current = current_revision(session, entry)
|
||||
published = published_revision(session, entry)
|
||||
selected = current if editor else published
|
||||
if entry.tenant_id != _principal_tenant_id(principal):
|
||||
return None
|
||||
published = published_revision(session, entry, revisions=revisions)
|
||||
selected = current_revision(session, entry, revisions=revisions) if editor else published
|
||||
if selected is None:
|
||||
return None
|
||||
if not content_visible_to_principal(selected.content, principal):
|
||||
return None
|
||||
resolution = resolve_entry_subject(
|
||||
session,
|
||||
registry,
|
||||
@@ -505,8 +563,6 @@ def semantic_entry_payload(
|
||||
unavailable = resolution.availability == "temporarily_unavailable"
|
||||
if unavailable and not editor:
|
||||
return None
|
||||
if not content_visible_to_principal(selected.content, principal):
|
||||
return None
|
||||
subject = resolution.subject
|
||||
required_scopes = subject.required_scopes if subject is not None else ()
|
||||
if any(not _principal_has(principal, scope) for scope in required_scopes):
|
||||
@@ -523,7 +579,7 @@ def semantic_entry_payload(
|
||||
"lifecycle_state": entry.lifecycle_state,
|
||||
"effective_state": selected.lifecycle_state,
|
||||
"pending_draft": bool(
|
||||
published is not None and current.id != published.id
|
||||
published is not None and entry.current_revision_id != published.id
|
||||
),
|
||||
"current_revision": entry.current_revision,
|
||||
"selected_revision": selected.revision,
|
||||
@@ -965,6 +1021,7 @@ __all__ = [
|
||||
"entry_subject_reference",
|
||||
"get_semantic_entry",
|
||||
"list_semantic_entries",
|
||||
"prefetch_semantic_revisions",
|
||||
"publication_policy",
|
||||
"publish_semantic_entry",
|
||||
"published_revision",
|
||||
|
||||
@@ -52,6 +52,34 @@ class FakePrincipal:
|
||||
|
||||
|
||||
class DocsContextTests(unittest.TestCase):
|
||||
def test_public_topic_tags_and_areas_are_bounded_and_authorized(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="services", name="Services", version="1.0.0",
|
||||
nav_items=(NavItem(path="/services", label="Services", required_any=("services:read",)),),
|
||||
))
|
||||
registry.register(ModuleManifest(
|
||||
id="hidden", name="Hidden", version="1.0.0",
|
||||
nav_items=(NavItem(path="/hidden", label="Hidden", required_any=("hidden:read",)),),
|
||||
))
|
||||
registry.register(ModuleManifest(
|
||||
id="forms", name="Forms", version="1.0.0",
|
||||
documentation=(DocumentationTopic(
|
||||
id="forms.service-guidance", title="Service application", summary="Application help",
|
||||
documentation_types=("user", "admin"), related_modules=("services", "hidden", "uninstalled"),
|
||||
metadata={"kind": "reference", "tags": ["Application", "Antrag", None, 12, "x" * 81], "areas": ["services", "hidden"]},
|
||||
),),
|
||||
))
|
||||
user_layers = _classify_documentation(registry, FakePrincipal({"docs:documentation:read", "services:read"}), settings=None, session=None, documentation_type="user", locale="de")
|
||||
topic = next(item for layer in user_layers.values() for item in layer if item["id"] == "forms.service-guidance")
|
||||
self.assertEqual(topic["area_module_ids"], ["forms", "services"])
|
||||
self.assertEqual(topic["metadata"]["tags"], ["Application", "Antrag"])
|
||||
self.assertEqual(topic["related_modules"], [])
|
||||
self.assertNotIn("areas", topic["metadata"])
|
||||
hidden_layers = _classify_documentation(registry, FakePrincipal({"docs:documentation:read"}), settings=None, session=None, documentation_type="user", locale="de")
|
||||
topic = next(item for layer in hidden_layers.values() for item in layer if item["id"] == "forms.service-guidance")
|
||||
self.assertEqual(topic["area_module_ids"], ["forms"])
|
||||
|
||||
def test_structured_topic_metadata_uses_requested_locale(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
|
||||
@@ -24,7 +24,7 @@ class PublicDocumentationExportTests(unittest.TestCase):
|
||||
(ManifestSource(get_docs_manifest()),)
|
||||
)["modules"][0]
|
||||
|
||||
self.assertEqual(9, module["coverage"]["topic_count"])
|
||||
self.assertEqual(10, module["coverage"]["topic_count"])
|
||||
self.assertEqual(0, module["coverage"]["missing_german_topic_count"])
|
||||
self.assertEqual([], module["coverage"]["missing_german_topic_ids"])
|
||||
self.assertEqual(2, module["coverage"]["structured_localizable_topic_count"])
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillRequest,
|
||||
SearchResourceReference,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_docs.backend.db.models import (
|
||||
SemanticDocumentationEntry,
|
||||
SemanticDocumentationRevision,
|
||||
)
|
||||
from govoplan_docs.backend.search_source import (
|
||||
PROVIDER_ID,
|
||||
RESOURCE_TYPE,
|
||||
SemanticDocumentationSearchSource,
|
||||
)
|
||||
from govoplan_docs.backend.semantic_service import (
|
||||
SemanticDocumentationError,
|
||||
current_revision,
|
||||
list_semantic_entries,
|
||||
prefetch_semantic_revisions,
|
||||
semantic_entry_payload,
|
||||
)
|
||||
from test_semantic_documentation import (
|
||||
_content,
|
||||
_principal,
|
||||
_Registry,
|
||||
_SubjectProvider,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def select_queries(engine):
|
||||
statements: list[str] = []
|
||||
|
||||
def observe(_connection, _cursor, statement, _parameters, _context, _many):
|
||||
if statement.lstrip().upper().startswith("SELECT"):
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(engine, "before_cursor_execute", observe)
|
||||
try:
|
||||
yield statements
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", observe)
|
||||
|
||||
|
||||
class SemanticReadEfficiencyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
SemanticDocumentationEntry.__table__,
|
||||
SemanticDocumentationRevision.__table__,
|
||||
],
|
||||
)
|
||||
self.provider = _SubjectProvider()
|
||||
self.registry = _Registry(self.provider)
|
||||
self.principal = _principal("author")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.engine.dispose()
|
||||
|
||||
def seed(self, count: int) -> None:
|
||||
with Session(self.engine) as session:
|
||||
for index in range(count):
|
||||
identifier = f"entry-{index:04}"
|
||||
session.add(
|
||||
SemanticDocumentationEntry(
|
||||
id=identifier,
|
||||
tenant_id="tenant-1",
|
||||
subject_stable_key="same-subject",
|
||||
subject_module_id="forms",
|
||||
subject_kind="form",
|
||||
subject_id="form-1",
|
||||
locale=f"de-{index:03}",
|
||||
lifecycle_state="draft",
|
||||
current_revision=2,
|
||||
current_revision_id=f"draft-{index:04}",
|
||||
published_revision_id=f"published-{index:04}",
|
||||
created_by="author",
|
||||
updated_by="author",
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
for revision, state, title in (
|
||||
(1, "published", f"Published {index}"),
|
||||
(2, "draft", f"Unpublished secret {index}"),
|
||||
):
|
||||
session.add(
|
||||
SemanticDocumentationRevision(
|
||||
id=f"{state}-{index:04}",
|
||||
tenant_id="tenant-1",
|
||||
entry_id=identifier,
|
||||
revision=revision,
|
||||
lifecycle_state=state,
|
||||
action="publish" if revision == 1 else "save",
|
||||
change_reason="Fixture",
|
||||
content=_content(title),
|
||||
content_hash=f"hash-{state}-{index}",
|
||||
authored_by="author",
|
||||
search_text=title,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def test_reader_and_editor_batching_preserve_payloads_with_two_queries_for_forty_entries(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(40)
|
||||
for editor in (False, True):
|
||||
with self.subTest(editor=editor):
|
||||
with (
|
||||
Session(self.engine) as session,
|
||||
select_queries(self.engine) as ordinary_queries,
|
||||
):
|
||||
entries = list_semantic_entries(session, self.principal)
|
||||
ordinary = [
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
self.principal,
|
||||
entry=entry,
|
||||
editor=editor,
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
with (
|
||||
Session(self.engine) as session,
|
||||
select_queries(self.engine) as batch_queries,
|
||||
):
|
||||
entries = list_semantic_entries(session, self.principal)
|
||||
revisions = prefetch_semantic_revisions(
|
||||
session, self.principal, entries=entries, editor=editor
|
||||
)
|
||||
batched = [
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
self.principal,
|
||||
entry=entry,
|
||||
editor=editor,
|
||||
revisions=revisions,
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
if not editor:
|
||||
self.assertTrue(
|
||||
all(
|
||||
item.lifecycle_state == "published"
|
||||
for item in revisions.values()
|
||||
)
|
||||
)
|
||||
self.assertTrue(all(item["pending_draft"] for item in batched))
|
||||
self.assertTrue(
|
||||
all(
|
||||
"Unpublished" not in item["content"]["title"]
|
||||
for item in batched
|
||||
)
|
||||
)
|
||||
self.assertEqual(ordinary, batched)
|
||||
self.assertEqual(81 if editor else 41, len(ordinary_queries))
|
||||
self.assertEqual(2, len(batch_queries))
|
||||
|
||||
def test_revision_prefetch_chunks_large_collections(self) -> None:
|
||||
self.seed(401)
|
||||
with Session(self.engine) as session, select_queries(self.engine) as queries:
|
||||
entries = list_semantic_entries(session, self.principal)
|
||||
revisions = prefetch_semantic_revisions(
|
||||
session, self.principal, entries=entries, editor=False
|
||||
)
|
||||
self.assertEqual(401, len(revisions))
|
||||
self.assertEqual(
|
||||
3, len(queries), "One entry query and two bounded revision batches."
|
||||
)
|
||||
|
||||
def test_search_authorization_batches_rows_without_weakening_owner_audience_or_revision_checks(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(40)
|
||||
requests = tuple(
|
||||
SearchAuthorizationRequest(
|
||||
reference=SearchResourceReference(
|
||||
tenant_id="tenant-1",
|
||||
module_id="docs",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=f"entry-{index:04}",
|
||||
),
|
||||
source_revision=f"published-{index:04}",
|
||||
)
|
||||
for index in range(40)
|
||||
)
|
||||
source = SemanticDocumentationSearchSource(self.registry)
|
||||
with Session(self.engine) as session, select_queries(self.engine) as queries:
|
||||
decisions = source.authorize(session, self.principal, requests=requests)
|
||||
self.assertTrue(all(decisions.values()))
|
||||
self.assertEqual(2, len(queries))
|
||||
self.provider.denied_accounts.add(self.principal.account_id)
|
||||
with Session(self.engine) as session:
|
||||
self.assertFalse(
|
||||
any(
|
||||
source.authorize(
|
||||
session, self.principal, requests=requests
|
||||
).values()
|
||||
)
|
||||
)
|
||||
self.provider.denied_accounts.clear()
|
||||
stale = SearchAuthorizationRequest(
|
||||
reference=requests[0].reference, source_revision="old-publication"
|
||||
)
|
||||
with Session(self.engine) as session:
|
||||
self.assertFalse(
|
||||
source.authorize(session, self.principal, requests=(stale,))[
|
||||
stale.reference.key
|
||||
]
|
||||
)
|
||||
|
||||
def test_audience_denial_precedes_provider_work_and_foreign_entries_do_not_load_revisions(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(1)
|
||||
with Session(self.engine) as session:
|
||||
revision = session.get(SemanticDocumentationRevision, "published-0000")
|
||||
revision.content = {
|
||||
**revision.content,
|
||||
"classification": "restricted",
|
||||
"audience": ["account:other"],
|
||||
}
|
||||
session.commit()
|
||||
with (
|
||||
Session(self.engine) as session,
|
||||
patch.object(
|
||||
self.provider, "resolve_subject", wraps=self.provider.resolve_subject
|
||||
) as resolve,
|
||||
):
|
||||
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session, self.registry, self.principal, entry=entry, editor=False
|
||||
)
|
||||
)
|
||||
resolve.assert_not_called()
|
||||
with select_queries(self.engine) as queries:
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
_principal("other", tenant_id="tenant-other"),
|
||||
entry=entry,
|
||||
editor=False,
|
||||
)
|
||||
)
|
||||
self.assertEqual([], queries)
|
||||
|
||||
def test_cross_tenant_or_cross_entry_revision_references_fail_closed_in_reading_and_indexing(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(2)
|
||||
source = SemanticDocumentationSearchSource(self.registry)
|
||||
publication_event = PlatformEvent(
|
||||
type="docs.semantic.published",
|
||||
module_id="docs",
|
||||
tenant=EventTenantRef(id="tenant-1"),
|
||||
resource=EventObjectRef(type=RESOURCE_TYPE, id="entry-0000"),
|
||||
)
|
||||
with Session(self.engine) as session:
|
||||
changes = source.index_changes_for_event(
|
||||
session, event=publication_event, delivery_key="valid-fixture"
|
||||
)
|
||||
self.assertEqual("upsert", changes[0].kind)
|
||||
self.assertEqual("published-0000", changes[0].document.source_revision)
|
||||
for corruption in ("tenant", "entry"):
|
||||
with self.subTest(corruption=corruption), Session(self.engine) as session:
|
||||
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
||||
revision = session.get(SemanticDocumentationRevision, "published-0000")
|
||||
if corruption == "tenant":
|
||||
revision.tenant_id = "tenant-other"
|
||||
else:
|
||||
revision.tenant_id = "tenant-1"
|
||||
revision.entry_id = "entry-0001"
|
||||
revision.revision = 3
|
||||
session.commit()
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
self.principal,
|
||||
entry=entry,
|
||||
editor=False,
|
||||
)
|
||||
)
|
||||
page = SemanticDocumentationSearchSource(self.registry).backfill(
|
||||
session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
rebuild_id="fixture",
|
||||
limit=100,
|
||||
),
|
||||
)
|
||||
self.assertNotIn(
|
||||
"entry-0000", {document.resource_id for document in page.documents}
|
||||
)
|
||||
changes = source.index_changes_for_event(
|
||||
session,
|
||||
event=publication_event,
|
||||
delivery_key=f"invalid-{corruption}",
|
||||
)
|
||||
self.assertEqual("delete", changes[0].kind)
|
||||
self.assertIsNone(changes[0].document)
|
||||
|
||||
def test_published_reader_does_not_depend_on_unavailable_draft_and_editor_still_validates_it(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(1)
|
||||
with Session(self.engine) as session:
|
||||
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
||||
draft = session.get(
|
||||
SemanticDocumentationRevision, entry.current_revision_id
|
||||
)
|
||||
draft.tenant_id = "tenant-other"
|
||||
session.commit()
|
||||
self.assertEqual(
|
||||
"Published 0",
|
||||
semantic_entry_payload(
|
||||
session, self.registry, self.principal, entry=entry, editor=False
|
||||
)["content"]["title"],
|
||||
)
|
||||
with self.assertRaises(SemanticDocumentationError):
|
||||
current_revision(session, entry)
|
||||
|
||||
def test_unpublished_revision_pointer_cannot_promote_a_draft_into_reading_or_search(
|
||||
self,
|
||||
) -> None:
|
||||
self.seed(1)
|
||||
with Session(self.engine) as session:
|
||||
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
||||
entry.published_revision_id = entry.current_revision_id
|
||||
session.commit()
|
||||
revisions = prefetch_semantic_revisions(
|
||||
session, self.principal, entries=(entry,), editor=False
|
||||
)
|
||||
self.assertEqual({}, revisions)
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session,
|
||||
self.registry,
|
||||
self.principal,
|
||||
entry=entry,
|
||||
editor=False,
|
||||
revisions=revisions,
|
||||
)
|
||||
)
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
session, self.registry, self.principal, entry=entry, editor=False
|
||||
)
|
||||
)
|
||||
page = SemanticDocumentationSearchSource(self.registry).backfill(
|
||||
session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
rebuild_id="fixture",
|
||||
limit=100,
|
||||
),
|
||||
)
|
||||
self.assertEqual((), page.documents)
|
||||
changes = SemanticDocumentationSearchSource(
|
||||
self.registry
|
||||
).index_changes_for_event(
|
||||
session,
|
||||
delivery_key="draft-fixture",
|
||||
event=PlatformEvent(
|
||||
type="docs.semantic.published",
|
||||
module_id="docs",
|
||||
tenant=EventTenantRef(id="tenant-1"),
|
||||
resource=EventObjectRef(type=RESOURCE_TYPE, id=entry.id),
|
||||
),
|
||||
)
|
||||
self.assertEqual("delete", changes[0].kind)
|
||||
self.assertIsNone(changes[0].document)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.21",
|
||||
"version": "0.1.23",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -16,7 +16,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -178,6 +178,7 @@ export type DocsDocumentationTopic = {
|
||||
conditions: DocsDocumentationCondition[];
|
||||
links: DocsDocumentationLink[];
|
||||
related_modules: string[];
|
||||
area_module_ids?: string[];
|
||||
unlocks: string[];
|
||||
configuration_keys: string[];
|
||||
configuration_states: Array<{
|
||||
|
||||
@@ -8,9 +8,15 @@ import {
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
ExplorerTree,
|
||||
FilterBar,
|
||||
FormField,
|
||||
MultiSelectFilter,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
SegmentedControl,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatusBadge,
|
||||
WorkspaceLayout,
|
||||
adminErrorMessage,
|
||||
@@ -30,6 +36,7 @@ import {
|
||||
type DocsSource,
|
||||
type DocsSourceDetail
|
||||
} from "../../api/docs";
|
||||
import { ancestorOccurrenceIds, documentationTagOptions, documentationTags, matchesDocumentationTopic, qualifyTreeOccurrences, selectedTreeOccurrence, topicAreaIds } from "./docsDiscovery";
|
||||
|
||||
type DocumentationType = "admin" | "user";
|
||||
|
||||
@@ -68,13 +75,15 @@ type OutlineItem = {
|
||||
export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
const location = useLocation();
|
||||
const navigate = useGuardedNavigate();
|
||||
const { language } = usePlatformLanguage();
|
||||
const { language, translateText } = usePlatformLanguage();
|
||||
const [context, setContext] = useState<DocsContext | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const loadSequence = useRef(0);
|
||||
const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search));
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set());
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[] | null>(null);
|
||||
const locale = localeFromSearch(location.search) ?? language;
|
||||
const selectedVersion = versionFromSearch(location.search);
|
||||
const adminDocs = documentationType === "admin";
|
||||
@@ -109,6 +118,12 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
const treeNodes = useMemo(() => docsTreeNodes(context, adminDocs), [context, adminDocs]);
|
||||
const pages = useMemo(() => flattenTreePages(treeNodes), [treeNodes]);
|
||||
const selectedPage = selectedPageFromSearch(location.search, pages);
|
||||
const selectedNode = selectedTreeOccurrence(treeNodes, selectedPage?.id ?? "", new URLSearchParams(location.search).get("occurrence"));
|
||||
const allTopics = useMemo(() => allDocumentationTopics(context), [context]);
|
||||
const tagOptions = useMemo(() => documentationTagOptions(allTopics, context), [allTopics, context]);
|
||||
const matchingTopics = useMemo(() => allTopics.filter((topic) => matchesDocumentationTopic(topic, context, searchQuery, selectedTags)), [allTopics, context, searchQuery, selectedTags]);
|
||||
const filtering = Boolean(searchQuery.trim()) || selectedTags !== null;
|
||||
const visibleTreeNodes = useMemo(() => filtering ? filterTopicTree(treeNodes, new Set(matchingTopics.map((topic) => topic.id))) : treeNodes, [filtering, treeNodes, matchingTopics]);
|
||||
const topicById = useMemo(() => topicIndex(context), [context]);
|
||||
const visibleRoutes = context?.layers.configured.routes ?? [];
|
||||
const availableRoutes = context?.layers.available.routes ?? [];
|
||||
@@ -122,7 +137,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPage) return;
|
||||
const ancestorIds = ancestorNodeIdsForPage(treeNodes, selectedPage.id);
|
||||
const ancestorIds = ancestorOccurrenceIds(treeNodes, selectedNode?.id ?? "");
|
||||
if (!ancestorIds.length) return;
|
||||
setExpandedNodes((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -131,7 +146,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [treeNodes, selectedPage?.id]);
|
||||
}, [treeNodes, selectedNode?.id]);
|
||||
|
||||
return (
|
||||
<WorkspaceLayout
|
||||
@@ -165,14 +180,24 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<FilterBar layout="stack">
|
||||
<FormField label="i18n:govoplan-docs.search_topics">
|
||||
<input type="search" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} placeholder={translateText("i18n:govoplan-docs.search_topics_hint")} />
|
||||
</FormField>
|
||||
<MultiSelectFilter label="i18n:govoplan-docs.topic_tags" options={tagOptions} value={selectedTags} onChange={setSelectedTags} />
|
||||
{filtering && <>
|
||||
<span role="status" className="muted">{translateText("i18n:govoplan-docs.matching_topics").replace("{count}", String(matchingTopics.length))}</span>
|
||||
<Button onClick={() => { setSearchQuery(""); setSelectedTags(null); }}>{translateText("i18n:govoplan-docs.clear_topic_filters")}</Button>
|
||||
</>}
|
||||
</FilterBar>
|
||||
</div>
|
||||
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
||||
<ExplorerTree
|
||||
nodes={treeNodes}
|
||||
nodes={visibleTreeNodes}
|
||||
getNodeId={(node) => node.id}
|
||||
getNodeLabel={(node) => node.title}
|
||||
getNodeChildren={(node) => node.children}
|
||||
activeId={activeNodeIdForPage(treeNodes, selectedPage?.id ?? "")}
|
||||
activeId={selectedNode?.id ?? ""}
|
||||
expandedIds={expandedNodes}
|
||||
depth={0}
|
||||
childrenBaseClassName="docs-tree-children"
|
||||
@@ -181,11 +206,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
toggleBaseClassName="docs-tree-toggle"
|
||||
nodeButtonBaseClassName="docs-tree-page"
|
||||
getNodeWrapStyle={(_node, context) => ({ paddingLeft: `${context.depth * 14}px` })}
|
||||
getNodeButtonClassName={(node) => selectedPage?.id === node.page.id ? "is-active" : ""}
|
||||
getNodeButtonClassName={(node) => selectedNode?.id === node.id ? "is-active" : ""}
|
||||
renderToggleIcon={(_node, context) => context.hasChildren ? context.expanded ? <ChevronDown size={15} /> : <ChevronRight size={15} /> : <span className="docs-tree-toggle-placeholder" />}
|
||||
renderNodeContent={(node) => node.title}
|
||||
onToggle={(node) => toggleNode(node.id)}
|
||||
onOpen={(node) => selectPage(node.page)}
|
||||
onOpen={(node) => { selectPage(node.page, node.id); setSearchQuery(""); setSelectedTags(null); }}
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -205,7 +230,16 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
>
|
||||
<div className="docs-content">
|
||||
<main className="docs-page-main">
|
||||
<SelectedPageContent
|
||||
{filtering && <section aria-label={translateText("i18n:govoplan-docs.search_results")}>
|
||||
<h2>{translateText("i18n:govoplan-docs.search_results")}</h2>
|
||||
{matchingTopics.length ? <SelectionList label="i18n:govoplan-docs.search_results">{matchingTopics.map((topic) => <SelectionListItem
|
||||
key={topic.id} aria-label={topic.title} selected={selectedPage?.id === topic.id}
|
||||
onClick={() => { selectPage(topicPage(topic)); setSearchQuery(""); setSelectedTags(null); }}
|
||||
>
|
||||
<SelectionListItemContent title={topic.title} description={[topic.summary, documentationTags(topic, context).map((tag) => tag.label).join(" · ")].filter(Boolean).join(" — ")} />
|
||||
</SelectionListItem>)}</SelectionList> : <p className="muted">{translateText("i18n:govoplan-docs.no_matching_topics")}</p>}
|
||||
</section>}
|
||||
{!filtering && <SelectedPageContent
|
||||
page={selectedPage}
|
||||
adminDocs={adminDocs}
|
||||
documentationType={documentationType}
|
||||
@@ -218,9 +252,9 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
evidenceSources={context?.layers.evidence.sources ?? []}
|
||||
settings={settings}
|
||||
locale={locale}
|
||||
/>
|
||||
/>}
|
||||
</main>
|
||||
<PageOutline items={outlineItems} />
|
||||
{!filtering && <PageOutline items={outlineItems} />}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</WorkspaceLayout>
|
||||
@@ -239,9 +273,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
||||
}
|
||||
|
||||
function selectPage(page: DocsPageNode) {
|
||||
function selectPage(page: DocsPageNode, occurrenceId?: string) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("topic", page.id);
|
||||
if (occurrenceId) params.set("occurrence", occurrenceId);
|
||||
else params.delete("occurrence");
|
||||
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
||||
}
|
||||
|
||||
@@ -629,8 +665,6 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
|
||||
const workflowTopics = context?.topic_groups.workflow ?? [];
|
||||
const referenceTopics = context?.topic_groups.reference ?? [];
|
||||
const patternTopics = context?.topic_groups.pattern ?? [];
|
||||
const systemTopicIds = new Set(systemTopics.map((topic) => topic.id));
|
||||
const moduleTopics = allTopics.filter((topic) => !systemTopicIds.has(topic.id));
|
||||
const workingTopics = uniqueTopics([...patternTopics, ...workflowTopics]);
|
||||
const troubleshootingTopics = allTopics.filter((topic) => !topic.active || topic.layer === "evidence");
|
||||
const basicsNode = categoryTreeNode(
|
||||
@@ -672,15 +706,15 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
|
||||
),
|
||||
{
|
||||
id: "tree:modules",
|
||||
title: "i18n:govoplan-docs.modules.04e9462c",
|
||||
title: "i18n:govoplan-docs.topic_areas",
|
||||
page: {
|
||||
id: "modules:overview",
|
||||
title: "i18n:govoplan-docs.modules.04e9462c",
|
||||
title: "i18n:govoplan-docs.topic_areas",
|
||||
kind: "topic-list",
|
||||
topics: moduleTopics,
|
||||
topics: allTopics,
|
||||
emptyText: "i18n:govoplan-docs.no_module_topics_found.0cbbdc9b"
|
||||
},
|
||||
children: moduleTreeNodes(moduleTopics, context)
|
||||
children: moduleTreeNodes(allTopics, context)
|
||||
}
|
||||
];
|
||||
if (adminDocs) {
|
||||
@@ -695,7 +729,7 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
|
||||
emptyText: "i18n:govoplan-docs.no_administration_topics_found.6213dff2"
|
||||
},
|
||||
children: [
|
||||
...referenceTopics.map(topicTreeNode),
|
||||
...referenceTopics.map((topic) => topicTreeNode(topic)),
|
||||
{
|
||||
id: "tree:administration:technical-reference",
|
||||
title: "i18n:govoplan-docs.technical_reference.f271430d",
|
||||
@@ -717,7 +751,7 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
|
||||
"i18n:govoplan-docs.no_troubleshooting_topics_found.8c275468"
|
||||
));
|
||||
}
|
||||
return nodes;
|
||||
return qualifyTreeOccurrences(nodes);
|
||||
}
|
||||
|
||||
function categoryTreeNode(id: string, title: string, topics: DocsDocumentationTopic[], emptyText: string): DocsTreeNode {
|
||||
@@ -757,9 +791,11 @@ function topicTreeNode(topic: DocsDocumentationTopic): DocsTreeNode {
|
||||
function moduleTreeNodes(topics: DocsDocumentationTopic[], context: DocsContext | null): DocsTreeNode[] {
|
||||
const topicsByModule = new Map<string, DocsDocumentationTopic[]>();
|
||||
for (const topic of topics) {
|
||||
const moduleTopics = topicsByModule.get(topic.source_module_id) ?? [];
|
||||
for (const areaId of topicAreaIds(topic, context)) {
|
||||
const moduleTopics = topicsByModule.get(areaId) ?? [];
|
||||
moduleTopics.push(topic);
|
||||
topicsByModule.set(topic.source_module_id, moduleTopics);
|
||||
topicsByModule.set(areaId, moduleTopics);
|
||||
}
|
||||
}
|
||||
const configuredModules = context?.layers.configured.modules ?? [];
|
||||
const moduleOrder = new Map(configuredModules.map((module, index) => [module.id, index]));
|
||||
@@ -823,23 +859,12 @@ function defaultExpandedNodeIds(nodes: DocsTreeNode[]): string[] {
|
||||
return nodes.filter((node) => node.children.length).map((node) => node.id);
|
||||
}
|
||||
|
||||
function ancestorNodeIdsForPage(nodes: DocsTreeNode[], pageId: string, ancestors: string[] = []): string[] {
|
||||
for (const node of nodes) {
|
||||
if (node.page.id === pageId) return ancestors;
|
||||
const match = ancestorNodeIdsForPage(node.children, pageId, [...ancestors, node.id]);
|
||||
if (match.length) return match;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function activeNodeIdForPage(nodes: DocsTreeNode[], pageId: string): string {
|
||||
if (!pageId) return "";
|
||||
for (const node of nodes) {
|
||||
if (node.page.id === pageId) return node.id;
|
||||
const childId = activeNodeIdForPage(node.children, pageId);
|
||||
if (childId) return childId;
|
||||
}
|
||||
return "";
|
||||
function filterTopicTree(nodes: DocsTreeNode[], matchingIds: Set<string>): DocsTreeNode[] {
|
||||
return nodes.flatMap((node) => {
|
||||
const children = filterTopicTree(node.children, matchingIds);
|
||||
const matches = node.page.kind === "topic" && matchingIds.has(node.page.topic.id);
|
||||
return matches || children.length ? [{ ...node, children }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function allDocumentationTopics(context: DocsContext | null): DocsDocumentationTopic[] {
|
||||
@@ -1140,7 +1165,7 @@ function compactSourceRecord(value: object): Array<[string, unknown]> {
|
||||
}
|
||||
|
||||
function humanizeSourceKey(value: string): string {
|
||||
const words = value.replaceAll("_", " ");
|
||||
const words = value.replace(/_/g, " ");
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ export default function SemanticDocumentationPage({ settings }: { settings: ApiS
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void load(), loading }}
|
||||
primaryActions={selectedEntry?.lifecycle_state === "draft" && !dirty ? (
|
||||
<Button onClick={() => void transition("publish")}>
|
||||
<Button helpContextId="docs.semantic-documentation.publish" helpModuleId="docs" onClick={() => void transition("publish")}>
|
||||
<Check size={16} /> Publish
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { DocsContext, DocsDocumentationTopic } from "../../api/docs";
|
||||
|
||||
export type DocumentationTag = { value: string; label: string };
|
||||
|
||||
export function topicAreaIds(topic: DocsDocumentationTopic, context: DocsContext | null): string[] {
|
||||
const visibleModules = new Set(context?.layers.configured.modules.map((module) => module.id) ?? []);
|
||||
return [...new Set(topic.area_module_ids ?? [
|
||||
topic.source_module_id,
|
||||
...topic.related_modules.filter((id) => visibleModules.has(id))
|
||||
])];
|
||||
}
|
||||
|
||||
export function documentationTags(topic: DocsDocumentationTopic, context: DocsContext | null): DocumentationTag[] {
|
||||
const modules = new Map(context?.layers.configured.modules.map((module) => [module.id, module.name]) ?? []);
|
||||
const areas = topicAreaIds(topic, context).map((id) => ({ value: `area:${id}`, label: modules.get(id) ?? humanizeArea(id) }));
|
||||
const tags = Array.isArray(topic.metadata.tags) ? topic.metadata.tags : [];
|
||||
return [...areas, ...tags.filter((tag): tag is string => typeof tag === "string" && Boolean(tag.trim()))
|
||||
.map((tag) => ({ value: `tag:${normalizeSearch(tag)}`, label: tag.trim() }))]
|
||||
.filter((tag, index, all) => all.findIndex((other) => other.value === tag.value) === index);
|
||||
}
|
||||
|
||||
export function documentationTagOptions(topics: DocsDocumentationTopic[], context: DocsContext | null): DocumentationTag[] {
|
||||
const tags = new Map<string, DocumentationTag>();
|
||||
for (const topic of topics) for (const tag of documentationTags(topic, context)) tags.set(tag.value, tag);
|
||||
return [...tags.values()].sort((left, right) => left.label.localeCompare(right.label));
|
||||
}
|
||||
|
||||
export function matchesDocumentationTopic(
|
||||
topic: DocsDocumentationTopic, context: DocsContext | null, query: string, selectedTags: string[] | null
|
||||
): boolean {
|
||||
const tags = documentationTags(topic, context);
|
||||
if (selectedTags !== null && !tags.some((tag) => selectedTags.includes(tag.value))) return false;
|
||||
const content = normalizeSearch([topic.title, topic.summary, topic.body, ...tags.flatMap((tag) => [tag.label, tag.value])].join(" "));
|
||||
return normalizeSearch(query).split(/\s+/).filter(Boolean).every((word) => content.includes(word));
|
||||
}
|
||||
|
||||
function normalizeSearch(value: string): string {
|
||||
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function humanizeArea(value: string): string {
|
||||
return value.replace(/^govoplan[-_]/, "").split(/[-_]+/).filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
|
||||
}
|
||||
|
||||
type TreeNode<T> = { id: string; page: { id: string }; children: T[] };
|
||||
|
||||
/** Semantic topic IDs remain stable; navigation IDs identify one occurrence only. */
|
||||
export function qualifyTreeOccurrences<T extends TreeNode<T>>(nodes: T[], parentId = ""): T[] {
|
||||
return nodes.map((node) => {
|
||||
const id = `${parentId}/${encodeURIComponent(node.id)}`;
|
||||
return { ...node, id, children: qualifyTreeOccurrences(node.children, id) };
|
||||
});
|
||||
}
|
||||
|
||||
export function selectedTreeOccurrence<T extends TreeNode<T>>(nodes: T[], pageId: string, occurrenceId: string | null): T | null {
|
||||
const all = flattenTreeOccurrences(nodes);
|
||||
return all.find((node) => node.id === occurrenceId && node.page.id === pageId)
|
||||
?? all.find((node) => node.page.id === pageId) ?? null;
|
||||
}
|
||||
|
||||
export function flattenTreeOccurrences<T extends TreeNode<T>>(nodes: T[]): T[] {
|
||||
return nodes.flatMap((node) => [node, ...flattenTreeOccurrences(node.children)]);
|
||||
}
|
||||
|
||||
export function ancestorOccurrenceIds<T extends TreeNode<T>>(nodes: T[], occurrenceId: string, ancestors: string[] = []): string[] {
|
||||
for (const node of nodes) {
|
||||
if (node.id === occurrenceId) return ancestors;
|
||||
const found = ancestorOccurrenceIds(node.children, occurrenceId, [...ancestors, node.id]);
|
||||
if (found.length) return found;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -2,6 +2,14 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"i18n:govoplan-docs.search_topics": "Search help topics",
|
||||
"i18n:govoplan-docs.search_topics_hint": "Question, keyword or tag…",
|
||||
"i18n:govoplan-docs.topic_tags": "Areas and tags",
|
||||
"i18n:govoplan-docs.topic_areas": "Topics by area",
|
||||
"i18n:govoplan-docs.matching_topics": "{count} matching topics",
|
||||
"i18n:govoplan-docs.clear_topic_filters": "Clear filters",
|
||||
"i18n:govoplan-docs.search_results": "Matching help topics",
|
||||
"i18n:govoplan-docs.no_matching_topics": "No topics match. Try another keyword or clear the tag filter.",
|
||||
"i18n:govoplan-docs.additional_permissions.8042cb01": "additional permissions",
|
||||
"i18n:govoplan-docs.about_govoplan.6b2d7127": "About GovOPlaN",
|
||||
"i18n:govoplan-docs.admin_docs.bf504a56": "Admin docs",
|
||||
@@ -104,6 +112,14 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-docs.your_documentation.8a4cd9a3": "Your documentation"
|
||||
},
|
||||
"de": {
|
||||
"i18n:govoplan-docs.search_topics": "Hilfethemen suchen",
|
||||
"i18n:govoplan-docs.search_topics_hint": "Frage, Stichwort oder Schlagwort…",
|
||||
"i18n:govoplan-docs.topic_tags": "Bereiche und Schlagwörter",
|
||||
"i18n:govoplan-docs.topic_areas": "Themen nach Bereich",
|
||||
"i18n:govoplan-docs.matching_topics": "{count} passende Themen",
|
||||
"i18n:govoplan-docs.clear_topic_filters": "Filter zurücksetzen",
|
||||
"i18n:govoplan-docs.search_results": "Passende Hilfethemen",
|
||||
"i18n:govoplan-docs.no_matching_topics": "Keine passenden Themen. Versuchen Sie ein anderes Stichwort oder setzen Sie den Schlagwortfilter zurück.",
|
||||
"i18n:govoplan-docs.additional_permissions.8042cb01": "additional permissions",
|
||||
"i18n:govoplan-docs.about_govoplan.6b2d7127": "Über GovOPlaN",
|
||||
"i18n:govoplan-docs.admin_docs.bf504a56": "Administrationsdokumentation",
|
||||
|
||||
Reference in New Issue
Block a user