18 Commits
Author SHA1 Message Date
zemion cbe434de02 Release govoplan-docs v0.1.23: unify help discovery and batch semantic reads
Module Package Release / publish-packages (push) Successful in 12s
2026-09-08 01:32:36 +02:00
zemion 1ae23b4e9d chore: reconcile published Docs release history
# Conflicts:
#	pyproject.toml
#	src/govoplan_docs/backend/manifest.py
#	webui/package.json
2026-08-24 12:17:28 +02:00
zemion 9055f3437f feat(docs): bind semantic publication to help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:39 +02:00
zemion 92dc91885b feat(docs): localize structured metadata and gate coverage 2026-08-24 10:48:21 +02:00
zemion 12fe709a34 feat(docs): localize structured metadata and gate coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-22 20:25:37 +02:00
zemion d6560b343a docs(docs): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 11s
2026-08-22 06:12:27 +02:00
zemion 7984573c81 fix: resolve semantic documentation deep links
Module Package Release / publish-packages (push) Successful in 12s
2026-08-21 15:54:08 +02:00
zemion d6db344d81 feat: add tenant semantic documentation lifecycle 2026-08-21 15:37:01 +02:00
zemion 77eb7339e6 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:45 +02:00
zemion 281f35310b Adopt semantic help center action layout 2026-08-19 14:26:26 +02:00
zemion e05d5b4a54 Adopt shared WebUI structural primitives 2026-08-18 13:17:31 +02:00
zemion 0b4e7b6391 Adopt shared WebUI layout primitives 2026-08-18 11:30:39 +02:00
zemion 36c0afadd4 docs: update institutional architecture link 2026-08-17 16:52:56 +02:00
zemion b5d3983844 feat: export manifest documentation for public sites 2026-08-07 14:53:14 +02:00
zemion 30ea95854b Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:44 +02:00
zemion f14692a15e Release v0.1.17
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 20:33:58 +02:00
zemion 8cbab781bf Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:06 +02:00
zemion 2d18ecc9b6 feat: document contextual and temporal help 2026-08-05 00:03:32 +02:00
32 changed files with 5822 additions and 157 deletions
+37
View File
@@ -6,6 +6,8 @@
GovOPlaN Docs provides the documentation layer for configured GovOPlaN systems. GovOPlaN Docs provides the documentation layer for configured GovOPlaN systems.
It also stores tenant-owned semantic documentation for stable configured subjects. See [Tenant semantic documentation](docs/SEMANTIC_DOCUMENTATION.md) for lifecycle, authorization, publication policy, export, recovery, and provider obligations.
It answers "what does this installation provide for me?" before it answers "what could the full product do?". The goal is to reduce documentation complexity by starting from installed modules, enabled configuration, tenant policy, role, route availability, and visible capabilities. It answers "what does this installation provide for me?" before it answers "what could the full product do?". The goal is to reduce documentation complexity by starting from installed modules, enabled configuration, tenant policy, role, route availability, and visible capabilities.
## Ownership ## Ownership
@@ -70,6 +72,41 @@ cd /mnt/DATA/git/govoplan
Feature content remains in the owning module. Docs indexes and renders the Feature content remains in the owning module. Docs indexes and renders the
contributions without importing feature implementations. contributions without importing feature implementations.
## Public documentation export
The public website is generated from the same static `DocumentationTopic`
contributions that power the in-product documentation. Export every installed
module, or every module checkout in a workspace, with:
```bash
govoplan-docs-export-public \
--workspace-root /mnt/DATA/git \
--output public/docs/v1/catalog.json \
--coverage-output docs/DOCUMENTATION_COVERAGE.md \
--coverage-baseline docs/DOCUMENTATION_COVERAGE_BASELINE.json
```
Use `--check` in publication CI to reject a stale checked-in catalog. Dynamic
`documentation_providers` remain instance-only because their output can depend
on permissions, policy, configuration, and live provider state; the export
records which modules have such additional documentation.
Static topics localize title, summary, and body through `translations`.
Rendered metadata such as steps, fields, limitations, consequences, and
verification uses Core's opt-in `structured_translation_version="1"` plus
`structured_translations` contract. The registry validates exact shape before
Docs overlays the requested locale. The public catalog reports structured
adoption separately. A reviewed coverage-baseline file sets monotonic minima
and maxima so publication CI also rejects localization or coverage regressions
after generated output is refreshed.
Pressing `F1` resolves the focused field or action first, then its containing
dialog or section, current page, and owning module. The shell sends the focused
context together with `fallback_context` and `module`; Docs selects the first
visible exact, page-level, or module-level topic after applying audience and
permission filtering. Modules announce exact route and control identities in
static topic `metadata.help_contexts`.
Capabilities can provide generic documentation without exposing their runtime Capabilities can provide generic documentation without exposing their runtime
provider implementation: provider implementation:
+43
View File
@@ -3,6 +3,49 @@
The docs module renders documentation for the current GovOPlaN instance instead The docs module renders documentation for the current GovOPlaN instance instead
of showing a static product manual. 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 ## Inputs
The documentation context is built from: The documentation context is built from:
+25
View File
@@ -0,0 +1,25 @@
# Docs Interface Pattern Migration
The Docs route composes the platform interface language without owning a
parallel shell. Core owns its navigation/content pane geometry and page frame;
Docs owns audience filtering, version selection, topic navigation, configured
documentation projection, and evidence presentation.
## Surface contract
- `WorkspaceLayout` owns the outline/content regions, pane width, scrolling,
accessible labels, and narrow-layout navigation behavior.
- `PageLayout` owns the content inset, sticky heading, audience-aware page
identity, reload action placement, error and loading regions, and responsive
action flow.
- `ExplorerTree`, `SegmentedControl`, `DataGrid`, `DescriptionList`, dialogs,
alerts, and status badges retain their Core interaction and accessibility
contracts.
- Docs-specific CSS remains limited to the documentation tree, reading column,
topic typography, page outline, and reference content. It no longer defines
the outer workspace grid or page skeleton.
The module keeps user/admin audience boundaries, installed-version selection,
topic URLs, configured-state projection, and evidence visibility unchanged.
The platform layout checker rejects restoring a raw Docs page or workspace
frame.
+57
View File
@@ -0,0 +1,57 @@
# Tenant semantic documentation
Docs stores tenant-specific explanations of stable configured subjects supplied by owning modules. Forms and Workflow are the first providers; additional modules can implement the Core semantic-subject contract without Docs importing their internals.
## Lifecycle and authorization
An entry is unique by tenant, stable subject identity, anchor, and locale. Every create, edit, publish, supersede, and retire operation appends an immutable revision and requires optimistic concurrency. A published revision remains reader-visible while a newer draft is being reviewed.
The tenant setting `docs.semantic_publication_policy` accepts:
- `reviewer_required` (default): the account that authored the current draft cannot publish it.
- `direct`: an author with the publish permission may publish their own draft.
Creation, editing, publication, supersession, retirement, tenant export, and policy changes use separate permission scopes. All mutations write audit events containing stable references and revision numbers, never the authored prose.
Reads always intersect:
- active tenant isolation;
- current authorization returned by the owning subject provider;
- scopes required by that subject;
- the semantic entry's classification and typed audience selectors;
- publication state and requested locale.
The same read-time check protects direct entry URLs, search authorization rechecks, contextual consumers, and tenant export. Provider denial is indistinguishable from absence. Changed, superseded, missing, and temporarily unavailable subjects are represented explicitly; locale fallback is exposed in the response.
## Content and safety
Semantic content is bounded plain text. Links must be local absolute paths or HTTPS URLs without embedded credentials. Restricted content requires at least one typed audience selector: `account:`, `group:`, `role:`, `function:`, `scope:`, or `authenticated`.
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.
Published authorship and review references are retained as configuration-governance evidence during data-subject erasure. Draft attribution is returned for governed manual review rather than silently anonymized because ownership and stewardship may need reassignment first.
## Provider obligations
An owning module registers exactly `documentation.semantic_subjects.<module_id>` with Core contract version `1`. It must return only tenant-local, currently authorized descriptors; stable subject and anchor IDs; current revision and fingerprint; localized labels; and relevant route and scope metadata. Resolution must return `None` when a principal may not learn whether a subject exists.
Provider unavailability does not expose stored content. Subject deletion or replacement must produce an explicit missing or superseded resolution so configurators can govern the documentation lifecycle.
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.15", "version": "0.1.23",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
@@ -17,7 +17,7 @@
"README.md" "README.md"
], ],
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.15", "@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+6 -3
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-docs" name = "govoplan-docs"
version = "0.1.15" version = "0.1.23"
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation." description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.15", "govoplan-core>=0.1.45",
"govoplan-access>=0.1.15", "govoplan-access>=0.1.18",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -22,3 +22,6 @@ govoplan_docs = ["py.typed"]
[project.entry-points."govoplan.modules"] [project.entry-points."govoplan.modules"]
docs = "govoplan_docs.backend.manifest:get_manifest" docs = "govoplan_docs.backend.manifest:get_manifest"
[project.scripts]
govoplan-docs-export-public = "govoplan_docs.public_export:main"
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.15" __version__ = "0.1.23"
+192 -2
View File
@@ -17,6 +17,7 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
localized_documentation_metadata,
user_workflow_scope_condition_issues, user_workflow_scope_condition_issues,
) )
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
@@ -32,12 +33,20 @@ from govoplan_core.core.versioning import (
from govoplan_core.db.session import get_database from govoplan_core.db.session import get_database
from govoplan_docs.backend.manifest import DOCS_ADMIN_READ_SCOPES, DOCS_READ_SCOPES from govoplan_docs.backend.manifest import DOCS_ADMIN_READ_SCOPES, DOCS_READ_SCOPES
from govoplan_docs.backend.api.v1.semantic_routes import router as semantic_router
from govoplan_docs.backend.sources import ( from govoplan_docs.backend.sources import (
RegisteredDocumentationSource, RegisteredDocumentationSource,
build_documentation_source_registry, build_documentation_source_registry,
) )
from govoplan_docs.backend.semantic_service import (
list_semantic_entries,
prefetch_semantic_revisions,
select_locale_entries,
semantic_entry_payload,
)
router = APIRouter(prefix="/docs", tags=["docs"]) router = APIRouter(prefix="/docs", tags=["docs"])
router.include_router(semantic_router)
TOPIC_KINDS = ("workflow", "reference", "pattern", "system") TOPIC_KINDS = ("workflow", "reference", "pattern", "system")
_CONFIGURATION_ACTIVE_STATES = frozenset({"enabled", "inherited"}) _CONFIGURATION_ACTIVE_STATES = frozenset({"enabled", "inherited"})
@@ -822,6 +831,11 @@ def _classify_documentation(
) -> dict[str, list[dict[str, Any]]]: ) -> dict[str, list[dict[str, Any]]]:
layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []} layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []}
installed = {manifest.id for manifest in registry.manifests()} 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([ visible_runtime_paths = frozenset([
"/settings", # Authenticated shell route, not contributed by a module manifest. "/settings", # Authenticated shell route, not contributed by a module manifest.
*( *(
@@ -901,6 +915,7 @@ def _classify_documentation(
visible_runtime_paths=visible_runtime_paths, visible_runtime_paths=visible_runtime_paths,
configuration=configuration, configuration=configuration,
resolved_version=resolved_version, resolved_version=resolved_version,
visible_area_modules=visible_area_modules,
)) ))
return layers return layers
@@ -958,9 +973,150 @@ def _collect_documentation_topics(
for topic in provided_topics: for topic in provided_topics:
if not user_workflow_scope_condition_issues(topic): if not user_workflow_scope_condition_issues(topic):
topics.append((manifest.id, topic)) topics.append((manifest.id, topic))
if session is not None:
try:
topics.extend(
("docs", topic)
for topic in _semantic_documentation_topics(
registry,
principal,
session=session,
locale=locale,
)
)
except Exception as exc:
if documentation_type == "admin":
topics.append(
(
"docs",
DocumentationTopic(
id="docs.semantic-runtime-unavailable",
title="Tenant semantic documentation unavailable",
summary="Stored tenant semantics could not be projected for this request.",
body="Static module documentation remains available. Check the Docs database migration, subject providers, and application logs.",
layer="evidence",
documentation_types=("admin",),
source_module_id="docs",
metadata={
"kind": "system",
"error_type": type(exc).__name__,
},
),
)
)
return topics return topics
def _semantic_documentation_topics(
registry: PlatformRegistry,
principal: ApiPrincipal,
*,
session: object,
locale: str,
) -> tuple[DocumentationTopic, ...]:
entries = select_locale_entries(
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(
session,
registry,
principal,
entry=entry,
editor=False,
requested_locale=locale,
revisions=revisions,
)
if payload is None:
continue
content = payload["content"]
resolution = payload["subject_resolution"]
subject = resolution.get("subject") if isinstance(resolution, Mapping) else None
route = subject.get("route") if isinstance(subject, Mapping) else None
route_anchor = (
subject.get("route_anchor") if isinstance(subject, Mapping) else None
)
links = [
DocumentationLink(
label=str(item["label"]),
href=str(item["href"]),
kind="runtime" if str(item["href"]).startswith("/") else "external",
)
for item in content.get("links", ())
if isinstance(item, Mapping) and item.get("label") and item.get("href")
]
if isinstance(route, str) and route.startswith("/"):
links.insert(
0,
DocumentationLink(
label="Open configured subject",
href=(f"{route}#{route_anchor}" if route_anchor else route),
kind="runtime",
),
)
body_parts = [
str(content.get(key) or "").strip()
for key in (
"meaning",
"body",
"intended_use",
"non_intended_use",
)
]
topics.append(
DocumentationTopic(
id=f"docs.semantic.{entry.id}",
title=str(content.get("title") or "Semantic documentation"),
summary=str(content.get("summary") or "Tenant semantic guidance"),
body="\n\n".join(part for part in body_parts if part),
layer="configured",
documentation_types=("admin", "user"),
source_module_id="docs",
order=200,
links=tuple(links),
related_modules=(entry.subject_module_id,),
metadata={
"kind": "reference",
"source_badge": "tenant_semantic",
"semantic_entry_id": entry.id,
"semantic_subject": payload["subject"],
"semantic_subject_stable_key": entry.subject_stable_key,
"subject_availability": resolution.get("availability"),
"locale": payload["locale"],
"requested_locale": payload["requested_locale"],
"locale_fallback": payload["locale_fallback"],
"lifecycle_state": payload["lifecycle_state"],
"pending_draft": payload["pending_draft"],
"route": route,
"route_anchor": route_anchor,
"help_contexts": [
_semantic_help_context(entry, route_anchor=route_anchor),
],
},
)
)
return tuple(topics)
def _semantic_help_context(
entry: object,
*,
route_anchor: object | None,
) -> str:
values = [
"semantic",
str(getattr(entry, "subject_module_id")),
str(getattr(entry, "subject_kind")),
str(getattr(entry, "subject_id")),
]
if route_anchor:
values.append(str(route_anchor))
return ".".join(values)
def _topic_matches_documentation_type(topic: DocumentationTopic, documentation_type: DocumentationType) -> bool: def _topic_matches_documentation_type(topic: DocumentationTopic, documentation_type: DocumentationType) -> bool:
return documentation_type in (topic.documentation_types or ("admin",)) return documentation_type in (topic.documentation_types or ("admin",))
@@ -1283,9 +1439,14 @@ def _documentation_topic_payload(
visible_runtime_paths: frozenset[str], visible_runtime_paths: frozenset[str],
configuration: Mapping[str, DocumentationConfigurationDecision], configuration: Mapping[str, DocumentationConfigurationDecision],
resolved_version: str, resolved_version: str,
visible_area_modules: frozenset[str] = frozenset(),
) -> dict[str, Any]: ) -> dict[str, Any]:
module_id = topic.source_module_id or source_module_id module_id = topic.source_module_id or source_module_id
translation_locale, translation = _translation_for_locale(topic, locale) translation_locale, translation = _translation_for_locale(topic, locale)
structured_translation_locale = _structured_translation_locale(topic, locale)
localized_metadata = localized_documentation_metadata(
topic, structured_translation_locale
)
kind = _documentation_topic_kind(topic) kind = _documentation_topic_kind(topic)
payload = { payload = {
"id": topic.id, "id": topic.id,
@@ -1313,6 +1474,8 @@ def _documentation_topic_payload(
"i18n_key": topic.i18n_key or topic.id, "i18n_key": topic.i18n_key or topic.id,
"locale": locale, "locale": locale,
"translation_locale": translation_locale, "translation_locale": translation_locale,
"structured_translation_locale": structured_translation_locale,
"structured_translation_version": topic.structured_translation_version,
"version": { "version": {
"resolved": resolved_version, "resolved": resolved_version,
"minimum": topic.version_min, "minimum": topic.version_min,
@@ -1331,19 +1494,30 @@ def _documentation_topic_payload(
"conditions": [_documentation_condition_payload(condition) for condition in topic.conditions], "conditions": [_documentation_condition_payload(condition) for condition in topic.conditions],
"links": [_documentation_link_payload(link) for link in topic.links], "links": [_documentation_link_payload(link) for link in topic.links],
"related_modules": list(topic.related_modules), "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), "unlocks": list(topic.unlocks),
"configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}), "configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}),
"configuration_states": [ "configuration_states": [
_documentation_configuration_payload(configuration[key]) _documentation_configuration_payload(configuration[key])
for key in sorted(configuration) for key in sorted(configuration)
], ],
"metadata": dict(topic.metadata), "metadata": localized_metadata,
} }
if documentation_type == "admin": if documentation_type == "admin":
return payload return payload
return { return {
"id": payload["id"], "id": payload["id"],
"source_module_id": payload["source_module_id"], "source_module_id": payload["source_module_id"],
"area_module_ids": payload["area_module_ids"],
"kind": payload["kind"], "kind": payload["kind"],
"anchor_id": payload["anchor_id"], "anchor_id": payload["anchor_id"],
"title": payload["title"], "title": payload["title"],
@@ -1365,6 +1539,12 @@ def _documentation_topic_payload(
"i18n_key": "", "i18n_key": "",
"locale": locale, "locale": locale,
"translation_locale": payload["translation_locale"], "translation_locale": payload["translation_locale"],
"structured_translation_locale": payload[
"structured_translation_locale"
],
"structured_translation_version": payload[
"structured_translation_version"
],
"conditions": [], "conditions": [],
"links": [ "links": [
_documentation_link_payload(link) _documentation_link_payload(link)
@@ -1375,7 +1555,7 @@ def _documentation_topic_payload(
"unlocks": list(topic.unlocks), "unlocks": list(topic.unlocks),
"configuration_keys": [], "configuration_keys": [],
"configuration_states": [], "configuration_states": [],
"metadata": _user_topic_metadata(kind, topic.metadata), "metadata": _user_topic_metadata(kind, localized_metadata),
} }
@@ -1396,6 +1576,9 @@ def _user_topic_metadata(kind: str, metadata: Mapping[str, Any]) -> dict[str, An
value = _bounded_string(metadata.get(key), maximum=255) value = _bounded_string(metadata.get(key), maximum=255)
if value: if value:
projected[key] = 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): if kind == "reference" and isinstance(metadata.get("fields"), list):
fields = [_user_field_metadata(item) for item in metadata["fields"][:64] if isinstance(item, Mapping)] fields = [_user_field_metadata(item) for item in metadata["fields"][:64] if isinstance(item, Mapping)]
if fields: if fields:
@@ -1516,6 +1699,13 @@ def _translation_for_locale(topic: DocumentationTopic, locale: str) -> tuple[str
return "source", {} return "source", {}
def _structured_translation_locale(topic: DocumentationTopic, locale: str) -> str:
for candidate in _locale_candidates(locale):
if candidate in topic.structured_translations:
return candidate
return "source"
def _locale_candidates(locale: str) -> tuple[str, ...]: def _locale_candidates(locale: str) -> tuple[str, ...]:
normalized = _normalize_locale(locale) normalized = _normalize_locale(locale)
base = normalized.split("-", 1)[0] base = normalized.split("-", 1)[0]
@@ -0,0 +1,499 @@
from __future__ import annotations
from collections.abc import Callable
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, require_any_scope
from govoplan_core.core.semantic_documentation import (
SemanticDocumentationContractError,
SemanticDocumentationSubjectQuery,
SemanticDocumentationSubjectReference,
list_semantic_documentation_subjects,
)
from govoplan_core.db.session import get_session
from govoplan_docs.backend.manifest import (
DOCS_ADMIN_READ_SCOPE,
DOCS_READ_SCOPE,
DOCS_SEMANTIC_CREATE_SCOPE,
DOCS_SEMANTIC_EDIT_SCOPE,
DOCS_SEMANTIC_EXPORT_SCOPE,
DOCS_SEMANTIC_POLICY_SCOPE,
DOCS_SEMANTIC_PUBLISH_SCOPE,
DOCS_SEMANTIC_RETIRE_SCOPE,
DOCS_SEMANTIC_SUPERSEDE_SCOPE,
)
from govoplan_docs.backend.semantic_schemas import (
SemanticDocumentationCreateRequest,
SemanticDocumentationPolicyUpdateRequest,
SemanticDocumentationSupersedeRequest,
SemanticDocumentationTransitionRequest,
SemanticDocumentationUpdateRequest,
)
from govoplan_docs.backend.semantic_service import (
SemanticDocumentationAuthorizationError,
SemanticDocumentationConflictError,
SemanticDocumentationError,
SemanticDocumentationNotFoundError,
content_visible_to_principal,
create_semantic_entry,
get_semantic_entry,
list_semantic_entries,
prefetch_semantic_revisions,
publication_policy,
publish_semantic_entry,
retire_semantic_entry,
revision_payload,
select_locale_entries,
semantic_entry_history,
semantic_entry_payload,
set_publication_policy,
supersede_semantic_entry,
update_semantic_entry,
)
router = APIRouter(prefix="/semantic", tags=["docs-semantic"])
READ_SCOPES = (
DOCS_READ_SCOPE,
DOCS_ADMIN_READ_SCOPE,
DOCS_SEMANTIC_CREATE_SCOPE,
DOCS_SEMANTIC_EDIT_SCOPE,
DOCS_SEMANTIC_PUBLISH_SCOPE,
DOCS_SEMANTIC_SUPERSEDE_SCOPE,
DOCS_SEMANTIC_RETIRE_SCOPE,
)
EDITOR_SCOPES = READ_SCOPES[2:]
SessionDep = Annotated[Session, Depends(get_session)]
ReadPrincipal = Annotated[ApiPrincipal, Depends(require_any_scope(*READ_SCOPES))]
@router.get("/policy")
def get_policy(
response: Response,
session: SessionDep,
principal: ReadPrincipal,
) -> dict[str, str]:
_private(response)
return {"mode": publication_policy(session, principal.tenant_id)}
@router.put("/policy")
def update_policy(
payload: SemanticDocumentationPolicyUpdateRequest,
response: Response,
session: SessionDep,
principal: Annotated[
ApiPrincipal, Depends(require_any_scope(DOCS_SEMANTIC_POLICY_SCOPE))
],
) -> dict[str, str]:
_private(response)
try:
mode = set_publication_policy(
session,
tenant_id=principal.tenant_id,
mode=payload.mode,
)
_audit(session, principal, "docs.semantic.policy.updated", None, {"mode": mode})
session.commit()
return {"mode": mode}
except SemanticDocumentationError as exc:
session.rollback()
raise _http_error(exc) from exc
@router.get("/subjects")
def list_subjects(
request: Request,
response: Response,
session: SessionDep,
principal: ReadPrincipal,
query: str = Query(default="", max_length=300),
subject_kind: list[str] | None = Query(default=None, max_length=120),
module_id: str | None = Query(default=None, max_length=80),
limit: int = Query(default=50, ge=1, le=200),
cursor: str | None = Query(default=None, max_length=1000),
) -> dict[str, object]:
_private(response)
try:
pages = list_semantic_documentation_subjects(
_registry(request),
session,
principal,
request=SemanticDocumentationSubjectQuery(
tenant_id=principal.tenant_id,
query=query,
subject_kinds=tuple(subject_kind or ()),
limit=limit,
cursor=cursor,
),
)
except (SemanticDocumentationContractError, TypeError) as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
items = [
{
"module_id": provider_module_id,
"subjects": [item.to_dict() for item in page.subjects],
"next_cursor": page.next_cursor,
"has_more": page.has_more,
}
for provider_module_id, page in pages
if module_id is None or provider_module_id == module_id
]
return {"providers": items, "total": sum(len(item["subjects"]) for item in items)}
@router.get("/entries")
def list_entries(
request: Request,
response: Response,
session: SessionDep,
principal: ReadPrincipal,
locale: str = Query(default="de", min_length=2, max_length=20),
module_id: str | None = Query(default=None, max_length=80),
subject_kind: str | None = Query(default=None, max_length=120),
include_drafts: bool = Query(default=False),
) -> dict[str, object]:
_private(response)
editor = include_drafts and _has_any(principal, EDITOR_SCOPES)
entries = list_semantic_entries(
session,
principal,
module_id=module_id,
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
if (
item := semantic_entry_payload(
session,
_registry(request),
principal,
entry=entry,
editor=editor,
requested_locale=locale,
revisions=revisions,
)
)
is not None
]
return {"items": items, "total": len(items)}
@router.get("/entries/{entry_id}")
def inspect_entry(
entry_id: str,
request: Request,
response: Response,
session: SessionDep,
principal: ReadPrincipal,
include_draft: bool = Query(default=False),
) -> dict[str, object]:
_private(response)
try:
entry = get_semantic_entry(session, principal, entry_id=entry_id)
item = semantic_entry_payload(
session,
_registry(request),
principal,
entry=entry,
editor=include_draft and _has_any(principal, EDITOR_SCOPES),
)
if item is None:
raise SemanticDocumentationNotFoundError("Entry not found.")
return item
except SemanticDocumentationError as exc:
raise _http_error(exc) from exc
@router.get("/entries/{entry_id}/history")
def inspect_history(
entry_id: str,
response: Response,
session: SessionDep,
principal: Annotated[ApiPrincipal, Depends(require_any_scope(*EDITOR_SCOPES))],
) -> dict[str, object]:
_private(response)
try:
items = semantic_entry_history(session, principal, entry_id=entry_id)
return {"items": [revision_payload(item) for item in items], "total": len(items)}
except SemanticDocumentationError as exc:
raise _http_error(exc) from exc
@router.post("/entries", status_code=status.HTTP_201_CREATED)
def create_entry(
payload: SemanticDocumentationCreateRequest,
request: Request,
response: Response,
session: SessionDep,
principal: Annotated[
ApiPrincipal, Depends(require_any_scope(DOCS_SEMANTIC_CREATE_SCOPE))
],
) -> dict[str, object]:
return _mutate(
response,
session,
principal,
action="created",
callback=lambda: create_semantic_entry(
session,
_registry(request),
principal,
subject=SemanticDocumentationSubjectReference.from_mapping(
payload.subject.model_dump(mode="json")
),
locale=payload.locale,
content=payload.content.model_dump(mode="json"),
change_reason=payload.change_reason,
),
registry=_registry(request),
)
@router.put("/entries/{entry_id}")
def update_entry(
entry_id: str,
payload: SemanticDocumentationUpdateRequest,
request: Request,
response: Response,
session: SessionDep,
principal: Annotated[
ApiPrincipal, Depends(require_any_scope(DOCS_SEMANTIC_EDIT_SCOPE))
],
) -> dict[str, object]:
return _mutate(
response,
session,
principal,
action="updated",
callback=lambda: update_semantic_entry(
session,
_registry(request),
principal,
entry_id=entry_id,
expected_revision=payload.expected_revision,
content=payload.content.model_dump(mode="json"),
change_reason=payload.change_reason,
),
registry=_registry(request),
)
@router.post("/entries/{entry_id}/publish")
def publish_entry(
entry_id: str,
payload: SemanticDocumentationTransitionRequest,
request: Request,
response: Response,
session: SessionDep,
principal: Annotated[
ApiPrincipal, Depends(require_any_scope(DOCS_SEMANTIC_PUBLISH_SCOPE))
],
) -> dict[str, object]:
return _mutate(
response,
session,
principal,
action="published",
callback=lambda: publish_semantic_entry(
session,
_registry(request),
principal,
entry_id=entry_id,
expected_revision=payload.expected_revision,
change_reason=payload.change_reason,
),
registry=_registry(request),
)
@router.post("/entries/{entry_id}/supersede")
def supersede_entry(
entry_id: str,
payload: SemanticDocumentationSupersedeRequest,
request: Request,
response: Response,
session: SessionDep,
principal: Annotated[
ApiPrincipal, Depends(require_any_scope(DOCS_SEMANTIC_SUPERSEDE_SCOPE))
],
) -> dict[str, object]:
return _mutate(
response,
session,
principal,
action="superseded",
callback=lambda: supersede_semantic_entry(
session,
principal,
entry_id=entry_id,
replacement_entry_id=payload.replacement_entry_id,
expected_revision=payload.expected_revision,
change_reason=payload.change_reason,
),
registry=_registry(request),
)
@router.post("/entries/{entry_id}/retire")
def retire_entry(
entry_id: str,
payload: SemanticDocumentationTransitionRequest,
request: Request,
response: Response,
session: SessionDep,
principal: Annotated[
ApiPrincipal, Depends(require_any_scope(DOCS_SEMANTIC_RETIRE_SCOPE))
],
) -> dict[str, object]:
return _mutate(
response,
session,
principal,
action="retired",
callback=lambda: retire_semantic_entry(
session,
principal,
entry_id=entry_id,
expected_revision=payload.expected_revision,
change_reason=payload.change_reason,
),
registry=_registry(request),
)
@router.get("/export")
def export_tenant_semantics(
request: Request,
response: Response,
session: SessionDep,
principal: Annotated[
ApiPrincipal, Depends(require_any_scope(DOCS_SEMANTIC_EXPORT_SCOPE))
],
) -> dict[str, object]:
_private(response)
entries = list_semantic_entries(session, principal)
response.headers["Content-Disposition"] = (
'attachment; filename="govoplan-semantic-documentation.json"'
)
exported: list[dict[str, object]] = []
for entry in entries:
payload = semantic_entry_payload(
session,
_registry(request),
principal,
entry=entry,
editor=True,
)
if payload is None or payload.get("content_redacted"):
continue
exported.append(
{
"entry": payload,
"history": [
revision_payload(item)
for item in semantic_entry_history(
session, principal, entry_id=entry.id
)
if content_visible_to_principal(item.content, principal)
],
}
)
return {
"schema_version": "1",
"tenant_id": principal.tenant_id,
"entries": exported,
}
def _mutate(
response: Response,
session: Session,
principal: ApiPrincipal,
*,
action: str,
callback: Callable[[], Any],
registry: object,
) -> dict[str, object]:
_private(response)
try:
entry = callback()
_audit(
session,
principal,
f"docs.semantic.{action}",
entry.id,
{
"revision": entry.current_revision,
"subject_stable_key": entry.subject_stable_key,
"locale": entry.locale,
},
)
session.commit()
result = semantic_entry_payload(
session,
registry,
principal,
entry=entry,
editor=True,
)
if result is None:
raise SemanticDocumentationNotFoundError("Entry not found.")
return result
except (SemanticDocumentationError, SemanticDocumentationContractError) as exc:
session.rollback()
raise _http_error(exc) from exc
def _audit(
session: Session,
principal: ApiPrincipal,
action: str,
entry_id: str | None,
details: dict[str, object],
) -> None:
audit_from_principal(
session,
principal,
action=action,
object_type="semantic_documentation",
object_id=entry_id,
details=details,
)
def _private(response: Response) -> None:
response.headers["Cache-Control"] = "private, no-store"
def _registry(request: Request) -> object:
registry = getattr(request.app.state, "govoplan_registry", None)
if registry is None or not hasattr(registry, "capability_names"):
raise HTTPException(status_code=500, detail="Module registry is unavailable.")
return registry
def _has_any(principal: ApiPrincipal, scopes: tuple[str, ...]) -> bool:
return any(principal.has(scope) for scope in scopes)
def _http_error(exc: Exception) -> HTTPException:
if isinstance(exc, SemanticDocumentationNotFoundError):
return HTTPException(status_code=404, detail=str(exc))
if isinstance(exc, SemanticDocumentationAuthorizationError):
return HTTPException(status_code=403, detail=str(exc))
if isinstance(exc, SemanticDocumentationConflictError):
return HTTPException(status_code=409, detail=str(exc))
return HTTPException(status_code=422, detail=str(exc))
__all__ = ["router"]
+11
View File
@@ -0,0 +1,11 @@
"""Docs-owned persistence models."""
from govoplan_docs.backend.db.models import (
SemanticDocumentationEntry,
SemanticDocumentationRevision,
)
__all__ = [
"SemanticDocumentationEntry",
"SemanticDocumentationRevision",
]
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class SemanticDocumentationEntry(Base, TimestampMixin):
__tablename__ = "docs_semantic_entries"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"subject_stable_key",
"locale",
name="uq_docs_semantic_entry_subject_locale",
),
Index(
"ix_docs_semantic_entries_tenant_state",
"tenant_id",
"lifecycle_state",
),
Index(
"ix_docs_semantic_entries_subject",
"tenant_id",
"subject_module_id",
"subject_kind",
"subject_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
subject_stable_key: Mapped[str] = mapped_column(
String(80), nullable=False, index=True
)
subject_module_id: Mapped[str] = mapped_column(
String(80), nullable=False, index=True
)
subject_kind: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
anchor_kind: Mapped[str | None] = mapped_column(
String(120), nullable=True, index=True
)
anchor_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
locale: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
lifecycle_state: Mapped[str] = mapped_column(
String(30), nullable=False, default="draft", index=True
)
current_revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
current_revision_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
published_revision_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
superseded_by_entry_id: Mapped[str | None] = mapped_column(
ForeignKey("docs_semantic_entries.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
created_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
updated_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
published_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
published_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
retired_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
retired_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
revisions: Mapped[list["SemanticDocumentationRevision"]] = relationship(
back_populates="entry",
cascade="all, delete-orphan",
foreign_keys="SemanticDocumentationRevision.entry_id",
order_by="SemanticDocumentationRevision.revision",
)
superseded_by: Mapped["SemanticDocumentationEntry | None"] = relationship(
remote_side="SemanticDocumentationEntry.id",
foreign_keys=[superseded_by_entry_id],
)
class SemanticDocumentationRevision(Base, TimestampMixin):
__tablename__ = "docs_semantic_revisions"
__table_args__ = (
UniqueConstraint(
"entry_id",
"revision",
name="uq_docs_semantic_revision_number",
),
Index(
"ix_docs_semantic_revisions_entry",
"entry_id",
"revision",
),
Index(
"ix_docs_semantic_revisions_tenant_state",
"tenant_id",
"lifecycle_state",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
entry_id: Mapped[str] = mapped_column(
ForeignKey("docs_semantic_entries.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
lifecycle_state: Mapped[str] = mapped_column(String(30), nullable=False)
action: Mapped[str] = mapped_column(String(30), nullable=False)
change_reason: Mapped[str] = mapped_column(String(1000), nullable=False)
content: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
content_hash: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
subject_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
subject_fingerprint: Mapped[str | None] = mapped_column(
String(80), nullable=True, index=True
)
authored_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
reviewed_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
published_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
recoverable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
search_text: Mapped[str] = mapped_column(Text, nullable=False, default="")
entry: Mapped[SemanticDocumentationEntry] = relationship(
back_populates="revisions",
foreign_keys=[entry_id],
)
__all__ = [
"SemanticDocumentationEntry",
"SemanticDocumentationRevision",
"new_uuid",
]
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_docs.backend.db.models import (
SemanticDocumentationEntry,
SemanticDocumentationRevision,
)
DOCS_DSAR_CAPABILITY = dsar_capability_name("docs")
_MAX_REVISIONS = 5_000
class DocsDsarProvider:
provider_id = "docs"
module_id = "docs"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
identifiers = _identifiers(subject)
if not identifiers:
return ()
revisions = (
db.query(SemanticDocumentationRevision)
.filter(SemanticDocumentationRevision.tenant_id == tenant_id)
.order_by(SemanticDocumentationRevision.entry_id, SemanticDocumentationRevision.revision)
.limit(_MAX_REVISIONS + 1)
.all()
)
if len(revisions) > _MAX_REVISIONS:
raise ValueError(
"Docs DSAR revision limit exceeded; use an exact semantic-entry reference."
)
explicit_entry = subject.external_references.get("docs.semantic_entry")
entries = {
row.id: row
for row in db.query(SemanticDocumentationEntry).filter(
SemanticDocumentationEntry.tenant_id == tenant_id
)
}
records: list[DsarRecordRef] = []
for revision in revisions:
entry = entries.get(revision.entry_id)
if entry is None or (explicit_entry and entry.id != explicit_entry):
continue
matches = _matches(entry, revision, identifiers)
if not matches and not explicit_entry:
continue
immutable = revision.lifecycle_state in {
"published",
"superseded",
"retired",
}
records.append(
DsarRecordRef(
provider_id=self.provider_id,
module_id=self.module_id,
resource_type="semantic_documentation_revision",
resource_id=revision.id,
category="configured_semantic_documentation_attribution",
title="Semantic documentation attribution",
data={
"entry_id": entry.id,
"revision": revision.revision,
"lifecycle_state": revision.lifecycle_state,
"subject_module_id": entry.subject_module_id,
"subject_kind": entry.subject_kind,
"subject_id": entry.subject_id,
"locale": entry.locale,
"matching_reference_fields": matches,
},
observed_at=_aware(revision.created_at),
immutable_evidence=immutable,
retention_reason=(
"Published semantic-documentation authorship and review history "
"is retained as configuration-governance evidence."
if immutable
else None
),
source_path=f"/docs/semantic?entryId={entry.id}",
)
)
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del session, tenant_id, subject
return tuple(
DsarErasureActionRef(
action_id=f"docs:retain:{record.resource_id}",
provider_id=self.provider_id,
module_id=self.module_id,
kind="retain" if record.immutable_evidence else "manual_review",
resource_type=record.resource_type,
resource_id=record.resource_id,
title="Review semantic documentation attribution",
rationale=(
record.retention_reason
or "Draft attribution may be anonymized only after a configurator "
"confirms that ownership and stewardship remain accountable."
),
executable=False,
metadata={"immutable_evidence": record.immutable_evidence},
)
for record in records
)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del session, tenant_id, subject
return tuple(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"Docs semantic attribution requires governed manual review and "
"was not changed automatically."
),
evidence={"request_id": request_id},
)
for action in actions
)
def _identifiers(subject: DsarSubjectRef) -> frozenset[str]:
return frozenset(
str(value).strip()
for value in (
subject.account_id,
subject.identity_id,
subject.membership_id,
subject.external_references.get("access.account"),
)
if str(value or "").strip()
)
def _matches(
entry: SemanticDocumentationEntry,
revision: SemanticDocumentationRevision,
identifiers: frozenset[str],
) -> list[str]:
fields = {
"entry.created_by": entry.created_by,
"entry.updated_by": entry.updated_by,
"entry.published_by": entry.published_by,
"entry.retired_by": entry.retired_by,
"revision.authored_by": revision.authored_by,
"revision.reviewed_by": revision.reviewed_by,
"content.owner_account_id": revision.content.get("owner_account_id"),
"content.steward_account_id": revision.content.get("steward_account_id"),
}
return sorted(
field
for field, value in fields.items()
if str(value or "").strip() in identifiers
)
def _aware(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Docs DSAR requires a SQLAlchemy session.")
return value
__all__ = ["DOCS_DSAR_CAPABILITY", "DocsDsarProvider"]
+529 -10
View File
@@ -1,30 +1,53 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import ( from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
) )
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition, DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationSourceDefinition, DocumentationSourceDefinition,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
FrontendRoute, FrontendRoute,
MigrationSpec,
ModuleContext, ModuleContext,
ModuleManifest, ModuleManifest,
ModuleInterfaceProvider,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.provider_governance import ( from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration, ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation, ModuleArchitectureDocumentation,
ModuleMaturityEvidence, ModuleMaturityEvidence,
) )
from govoplan_core.db.base import Base
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_docs.backend.db import models as docs_models
from govoplan_docs.backend.dsar_provider import DOCS_DSAR_CAPABILITY, DocsDsarProvider
from govoplan_docs.backend.search_source import (
create_semantic_documentation_search_source,
)
DOCS_READ_SCOPE = "docs:documentation:read" DOCS_READ_SCOPE = "docs:documentation:read"
DOCS_ADMIN_READ_SCOPE = "docs:documentation:admin" DOCS_ADMIN_READ_SCOPE = "docs:documentation:admin"
DOCS_SEMANTIC_CREATE_SCOPE = "docs:semantic:create"
DOCS_SEMANTIC_EDIT_SCOPE = "docs:semantic:edit"
DOCS_SEMANTIC_PUBLISH_SCOPE = "docs:semantic:publish"
DOCS_SEMANTIC_SUPERSEDE_SCOPE = "docs:semantic:supersede"
DOCS_SEMANTIC_RETIRE_SCOPE = "docs:semantic:retire"
DOCS_SEMANTIC_EXPORT_SCOPE = "docs:semantic:export"
DOCS_SEMANTIC_POLICY_SCOPE = "docs:semantic:policy"
DOCS_ADMIN_READ_SCOPES = ( DOCS_ADMIN_READ_SCOPES = (
DOCS_ADMIN_READ_SCOPE, DOCS_ADMIN_READ_SCOPE,
"system:settings:read", "system:settings:read",
@@ -42,16 +65,36 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
reference="tests/test_docs_context.py", reference="tests/test_docs_context.py",
summary="Tests audience-safe configured documentation and architecture projections.", summary="Tests audience-safe configured documentation and architecture projections.",
), ),
ModuleMaturityEvidence(
kind="test",
reference="tests/test_semantic_documentation.py",
summary="Tests tenant isolation, immutable revision lifecycle, publication policy, subject reauthorization, search, localization, and DSAR projection.",
),
ModuleMaturityEvidence( ModuleMaturityEvidence(
kind="documentation", kind="documentation",
reference="docs/DOCUMENTATION_LAYER_CONCEPT.md", reference="docs/DOCUMENTATION_LAYER_CONCEPT.md",
summary="Defines the manifest-driven documentation boundary.", summary="Defines the manifest-driven documentation boundary.",
), ),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/INTERFACE_PATTERN_MIGRATION.md",
summary="Records the Core-owned Docs workspace and page-layout boundary.",
),
ModuleMaturityEvidence(
kind="documentation",
reference="docs/SEMANTIC_DOCUMENTATION.md",
summary="Defines semantic authoring, authorization, lifecycle, export, and recovery behavior.",
),
), ),
known_limits=( known_limits=(
"Architecture declarations are in staged adoption, so undeclared modules remain visible as pending.", "Architecture declarations are in staged adoption, so undeclared modules remain visible as pending.",
), ),
owned_concepts=("configured documentation projection", "documentation audience filtering"), owned_concepts=(
"configured documentation projection",
"documentation audience filtering",
"tenant semantic documentation revisions",
"semantic documentation publication lifecycle",
),
non_owned_concepts=("module feature behavior", "module evidence generation"), non_owned_concepts=("module feature behavior", "module evidence generation"),
documentation=ModuleArchitectureDocumentation( documentation=ModuleArchitectureDocumentation(
security=("docs/DOCUMENTATION_LAYER_CONCEPT.md",), security=("docs/DOCUMENTATION_LAYER_CONCEPT.md",),
@@ -81,21 +124,82 @@ def _route_factory(context: ModuleContext):
return router return router
def _dsar_provider(_context: ModuleContext) -> DocsDsarProvider:
return DocsDsarProvider()
manifest = ModuleManifest( manifest = ModuleManifest(
id="docs", id="docs",
name="Docs", name="Docs",
version="0.1.15", version="0.1.23",
required_capabilities=( required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
), ),
optional_dependencies=("policy", "audit", "ops", "workflow_engine", "search"), provides_interfaces=(
ModuleInterfaceProvider(name=DOCS_DSAR_CAPABILITY, version="0.1.0"),
),
capability_factories={DOCS_DSAR_CAPABILITY: _dsar_provider},
capability_documentation={
DOCS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Docs data-subject request provider",
summary=(
"Exports minimized tenant semantic-documentation authorship, "
"review, ownership, and stewardship references while retaining "
"published configuration-governance evidence."
),
contract_version="0.1.0",
),
},
optional_dependencies=(
"policy",
"audit",
"ops",
"workflow",
"forms",
"search",
),
permissions=( permissions=(
_permission( _permission(
DOCS_READ_SCOPE, DOCS_READ_SCOPE,
"View configured documentation", "View configured documentation",
"Read user documentation generated for the current actor from installed modules and effective configuration.", "Read user documentation generated for the current actor from installed modules and effective configuration.",
), ),
_permission(
DOCS_SEMANTIC_CREATE_SCOPE,
"Create semantic documentation",
"Create tenant-owned semantic documentation for configured subjects.",
),
_permission(
DOCS_SEMANTIC_EDIT_SCOPE,
"Edit semantic documentation",
"Edit drafts using immutable revisions and optimistic concurrency.",
),
_permission(
DOCS_SEMANTIC_PUBLISH_SCOPE,
"Publish semantic documentation",
"Review and publish semantic documentation under tenant policy.",
),
_permission(
DOCS_SEMANTIC_SUPERSEDE_SCOPE,
"Supersede semantic documentation",
"Replace semantic documentation with another published entry.",
),
_permission(
DOCS_SEMANTIC_RETIRE_SCOPE,
"Retire semantic documentation",
"Retire semantic documentation while retaining its revision history.",
),
_permission(
DOCS_SEMANTIC_EXPORT_SCOPE,
"Export tenant semantic documentation",
"Export tenant-owned semantic entries and immutable history.",
),
_permission(
DOCS_SEMANTIC_POLICY_SCOPE,
"Configure semantic publication policy",
"Choose direct publication or independent reviewer publication.",
),
_permission( _permission(
DOCS_ADMIN_READ_SCOPE, DOCS_ADMIN_READ_SCOPE,
"View administrative documentation", "View administrative documentation",
@@ -110,6 +214,43 @@ manifest = ModuleManifest(
permissions=(DOCS_READ_SCOPE,), permissions=(DOCS_READ_SCOPE,),
default_authenticated=True, default_authenticated=True,
), ),
RoleTemplate(
slug="semantic_documentation_author",
name="Semantic documentation author",
description="Discover configured subjects and create or revise their semantic documentation.",
permissions=(
DOCS_READ_SCOPE,
DOCS_SEMANTIC_CREATE_SCOPE,
DOCS_SEMANTIC_EDIT_SCOPE,
),
),
RoleTemplate(
slug="semantic_documentation_reviewer",
name="Semantic documentation reviewer",
description="Review, publish, supersede, and retire semantic documentation.",
permissions=(
DOCS_READ_SCOPE,
DOCS_SEMANTIC_PUBLISH_SCOPE,
DOCS_SEMANTIC_SUPERSEDE_SCOPE,
DOCS_SEMANTIC_RETIRE_SCOPE,
),
),
RoleTemplate(
slug="semantic_documentation_manager",
name="Semantic documentation manager",
description="Administer semantic authoring, review, lifecycle, policy, and tenant export.",
permissions=(
DOCS_READ_SCOPE,
DOCS_ADMIN_READ_SCOPE,
DOCS_SEMANTIC_CREATE_SCOPE,
DOCS_SEMANTIC_EDIT_SCOPE,
DOCS_SEMANTIC_PUBLISH_SCOPE,
DOCS_SEMANTIC_SUPERSEDE_SCOPE,
DOCS_SEMANTIC_RETIRE_SCOPE,
DOCS_SEMANTIC_EXPORT_SCOPE,
DOCS_SEMANTIC_POLICY_SCOPE,
),
),
RoleTemplate( RoleTemplate(
slug="docs_admin", slug="docs_admin",
name="Documentation administrator", name="Documentation administrator",
@@ -137,6 +278,18 @@ manifest = ModuleManifest(
required_any=DOCS_READ_SCOPES, required_any=DOCS_READ_SCOPES,
order=880, order=880,
), ),
FrontendRoute(
path="/docs/semantic",
component="SemanticDocumentationPage",
required_any=(
DOCS_SEMANTIC_CREATE_SCOPE,
DOCS_SEMANTIC_EDIT_SCOPE,
DOCS_SEMANTIC_PUBLISH_SCOPE,
DOCS_SEMANTIC_SUPERSEDE_SCOPE,
DOCS_SEMANTIC_RETIRE_SCOPE,
),
order=881,
),
), ),
nav_items=( nav_items=(
NavItem( NavItem(
@@ -149,6 +302,167 @@ manifest = ModuleManifest(
), ),
), ),
documentation=( 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",
summary="Explain what configured forms, fields, workflows, steps, and other stable subjects mean in this tenant.",
body=(
"Authors select an authorized subject supplied by its owning module and create locale-specific plain-text guidance. "
"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. "
"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"),
audience=("tenant_admin", "module_admin", "documentation_author"),
order=11,
conditions=(
DocumentationCondition(
any_scopes=(
DOCS_SEMANTIC_CREATE_SCOPE,
DOCS_SEMANTIC_EDIT_SCOPE,
DOCS_SEMANTIC_PUBLISH_SCOPE,
DOCS_SEMANTIC_POLICY_SCOPE,
),
),
),
links=(
DocumentationLink(
label="Semantic documentation administration",
href="/docs/semantic",
kind="runtime",
),
DocumentationLink(
label="Semantic documentation operations",
href="govoplan-docs/docs/SEMANTIC_DOCUMENTATION.md",
kind="repository",
),
),
translations={
"de": {
"title": "Semantische Dokumentation des Mandanten",
"summary": "Erläutern, was konfigurierte Formulare, Felder, Workflows, Schritte und andere stabile Fachobjekte in diesem Mandanten bedeuten.",
"body": (
"Autorinnen und Autoren wählen ein berechtigtes Fachobjekt aus, das sein besitzendes Modul bereitstellt, "
"und verfassen sprachspezifische Hinweise als Klartext. Jeder Speichervorgang erzeugt eine unveränderliche Revision. "
"Die Mandantenrichtlinie legt direkte Veröffentlichung oder eine unabhängige Prüfung fest. Veröffentlichte Inhalte "
"unterliegen weiterhin der aktuellen Berechtigung für das Fachobjekt, der Zielgruppe und Klassifizierung der Dokumentation, "
"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. "
"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",
"help_contexts": ["docs.semantic-documentation.publish"],
},
),
DocumentationTopic(
id="docs.data-subject-requests",
title="Review Docs semantic attribution in a data-subject request",
summary="Export minimized author, reviewer, owner, and steward references without disclosing unrelated tenant-authored guidance.",
body=(
"Docs matches exact account and namespaced semantic-entry references within the active tenant. "
"The projection identifies the entry, revision, subject, locale, lifecycle state, and fields that matched, but excludes authored body content. "
"Published, superseded, and retired attribution is immutable configuration-governance evidence and is retained with a reason. "
"Draft attribution requires manual governance review so ownership or stewardship can be reassigned before any anonymization; Docs performs no automatic erasure."
),
layer="configured",
documentation_types=("admin",),
audience=("privacy_officer", "documentation_administrator", "operator"),
order=12,
conditions=(
DocumentationCondition(
required_modules=("docs", "access"),
any_scopes=(
"access:privacy:read",
"access:privacy:manage",
"access:privacy:erase",
),
),
),
links=(
DocumentationLink(
label="Data-subject requests",
href="/admin?section=tenant-data-subject-requests",
kind="runtime",
),
DocumentationLink(
label="Semantic documentation operations",
href="govoplan-docs/docs/SEMANTIC_DOCUMENTATION.md",
kind="repository",
),
),
related_modules=("access", "audit", "policy"),
translations={
"de": {
"title": "Semantische Docs-Zuordnungen in einer Betroffenenanfrage prüfen",
"summary": "Minimierte Verweise auf Autorenschaft, Prüfung, Eigentümerschaft und fachliche Zuständigkeit exportieren, ohne unbeteiligte mandanteneigene Hinweise offenzulegen.",
"body": (
"Docs gleicht exakte Konto- und namensraumgebundene Verweise auf semantische Einträge innerhalb des aktiven Mandanten ab. "
"Die Projektion nennt Eintrag, Revision, Fachobjekt, Sprache, Lebenszyklusstatus und die übereinstimmenden Felder, schließt den "
"verfassten Inhalt jedoch aus. Zuordnungen veröffentlichter, abgelöster und stillgelegter Inhalte sind unveränderliche Nachweise "
"der Konfigurationssteuerung und werden mit Begründung aufbewahrt. Zuordnungen aus Entwürfen erfordern eine manuelle fachliche "
"Prüfung, damit Eigentümerschaft oder Zuständigkeit vor einer möglichen Anonymisierung neu zugewiesen werden können; Docs führt "
"keine automatische Löschung aus."
),
}
},
metadata={
"kind": "workflow",
"route": "/admin?section=tenant-data-subject-requests",
"help_contexts": ["admin.privacy.data-subject-requests"],
},
),
DocumentationTopic( DocumentationTopic(
id="docs.configured-system-documentation", id="docs.configured-system-documentation",
title="Configured system documentation", title="Configured system documentation",
@@ -162,14 +476,14 @@ manifest = ModuleManifest(
translations={ translations={
"de": { "de": {
"title": "Dokumentation dieses Systems", "title": "Dokumentation dieses Systems",
"summary": "Diese Dokumentation beginnt mit den installierten Modulen, der aktiven Konfiguration und den Funktionen, die fuer diese Rolle sichtbar sind.", "summary": "Diese Dokumentation beginnt mit den installierten Modulen, der aktiven Konfiguration und den Funktionen, die für diese Rolle sichtbar sind.",
"body": "Module koennen feste Dokumentationsabschnitte beitragen. Wenn Inhalte von Tenant-Regeln, installierten Integrationen oder Betriebsoptionen abhaengen, kann ein Modul laufzeitbasierte Dokumentation registrieren.", "body": "Module können feste Dokumentationsabschnitte beitragen. Wenn Inhalte von Mandantenregeln, installierten Integrationen oder Betriebsoptionen abhängen, kann ein Modul laufzeitbasierte Dokumentation registrieren.",
}, },
}, },
links=( links=(
DocumentationLink( DocumentationLink(
label="Public GovOPlaN module documentation", label="Public GovOPlaN documentation",
href="https://govplan.add-ideas.de/", href="https://govoplan.add-ideas.de/docs",
kind="public", kind="public",
), ),
DocumentationLink( DocumentationLink(
@@ -185,6 +499,43 @@ manifest = ModuleManifest(
), ),
metadata={"kind": "system"}, metadata={"kind": "system"},
), ),
DocumentationTopic(
id="docs.public-manifest-export",
title="Public documentation from module manifests",
summary="Publish the static documentation baseline from every module without maintaining a second content source.",
body=(
"The public exporter reads DocumentationTopic contributions from all installed packages or sibling module checkouts, "
"projects German and English content, and records documentation coverage. Runtime providers remain in the authenticated "
"Docs surface because their output can depend on the current actor, policy, configuration, and live service state. "
"Rendered steps, fields, limitations, consequences, and verification use Core's versioned same-shape structured-translation contract. "
"Publication CI runs both the source-digest check and a reviewed monotonic coverage baseline, so regenerating the catalog cannot conceal a regression."
),
layer="always",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator", "module_admin", "publisher"),
order=12,
translations={
"de": {
"title": "Öffentliche Dokumentation aus Modulmanifesten",
"summary": "Die statische Dokumentationsbasis aller Module veröffentlichen, ohne eine zweite Inhaltsquelle zu pflegen.",
"body": (
"Der öffentliche Export liest die DocumentationTopic-Beiträge aus allen installierten Paketen oder benachbarten Modulquellen, "
"projiziert deutsche und englische Inhalte und weist Dokumentationslücken aus. Laufzeit-Provider verbleiben in der authentifizierten "
"Dokumentationsoberfläche, da ihre Ausgabe von Rolle, Richtlinie, Konfiguration und Dienstzustand abhängen kann. "
"Gerenderte Schritte, Felder, Einschränkungen, Folgen und Prüfhinweise verwenden den versionierten, formgleichen Vertrag für strukturierte Übersetzungen in Core. "
"Die Veröffentlichungs-CI prüft sowohl den Quelldigest als auch einen freigegebenen monotonen Abdeckungsstand, damit das Neuerzeugen des Katalogs keine Verschlechterung verdecken kann."
),
}
},
links=(
DocumentationLink(
label="Public documentation",
href="https://govoplan.add-ideas.de/docs",
kind="public",
),
),
metadata={"kind": "system"},
),
DocumentationTopic( DocumentationTopic(
id="docs.reference.institutional-governance-architecture", id="docs.reference.institutional-governance-architecture",
title="Institutional governance architecture", title="Institutional governance architecture",
@@ -202,13 +553,13 @@ manifest = ModuleManifest(
"de": { "de": {
"title": "Architektur der institutionellen Steuerung", "title": "Architektur der institutionellen Steuerung",
"summary": "GovOPlaN modelliert institutionelle Verantwortung, gesteuerte Arbeit, formale Ergebnisse, Nachweise und die Datenhoheit externer Systeme, ohne alle Begriffe in den Kern oder eine monolithische Anwendung zu ziehen.", "summary": "GovOPlaN modelliert institutionelle Verantwortung, gesteuerte Arbeit, formale Ergebnisse, Nachweise und die Datenhoheit externer Systeme, ohne alle Begriffe in den Kern oder eine monolithische Anwendung zu ziehen.",
"body": "Organisationen, Identitaeten, IDM, Zugriff und Richtlinien beantworten unterschiedliche Teile der Frage, wer handeln darf. Mandate, Leistungen, Verfahrensbeteiligte und formale Entscheidungen beginnen als gemeinsame Vertraege und werden erst bei nachgewiesenem eigenstaendigem Lebenszyklus zu Modulen. Integrationen erklaeren technische Reife und Datenhoheit getrennt.", "body": "Organisationen, Identitäten, IDM, Zugriff und Richtlinien beantworten unterschiedliche Teile der Frage, wer handeln darf. Mandate, Leistungen, Verfahrensbeteiligte und formale Entscheidungen beginnen als gemeinsame Verträge und werden erst bei nachgewiesenem eigenständigem Lebenszyklus zu Modulen. Integrationen erklären technische Reife und Datenhoheit getrennt.",
}, },
}, },
links=( links=(
DocumentationLink( DocumentationLink(
label="Institutional governance target architecture", label="Institutional governance target architecture",
href="govoplan/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md", href="govoplan/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md",
kind="repository", kind="repository",
), ),
DocumentationLink( DocumentationLink(
@@ -238,6 +589,17 @@ manifest = ModuleManifest(
audience=("user", "tenant_admin", "operator", "module_admin"), audience=("user", "tenant_admin", "operator", "module_admin"),
order=20, order=20,
i18n_key="docs.topic.pattern.field_help", i18n_key="docs.topic.pattern.field_help",
translations={
"de": {
"title": "Hinweis am Feld",
"summary": "Eine kleine Hilfemarkierung neben einer Beschriftung gibt lokalen Kontext, ohne dichte Formulare in Handbücher zu verwandeln.",
"body": (
"Verwenden Sie die Markierung für kurze Erläuterungen zu einem Feld, einer Option oder einem kompakten Begriff. "
"Verweisen Sie auf ein Ablauf- oder Referenzthema, wenn Schritte, API-Zuordnung, Richtlinienherkunft oder betriebliche "
"Einzelheiten benötigt werden."
),
}
},
links=( links=(
DocumentationLink( DocumentationLink(
label="Documentation experience concept", label="Documentation experience concept",
@@ -262,6 +624,111 @@ manifest = ModuleManifest(
"access.workflow.grant-user-access", "access.workflow.grant-user-access",
], ],
}, },
structured_translation_version="1",
structured_translations={
"de": {
"purpose": (
"Beschriftungen bleiben schnell erfassbar, während kurze Erläuterungen bei Bedarf verfügbar sind."
),
"when_used": (
"Formular- und Umschalterbeschriftungen, Zeilen mit wirksamen Werten und kompakte Verwaltungsbegriffe."
),
"user_explanation": (
"Öffnen Sie die Markierung, wenn eine Beschriftung unklar ist. Sie erläutert die lokale Auswahl in ein oder zwei Sätzen."
),
"admin_explanation": (
"Feldhilfe bleibt am Feld. Längere Verfahrens-, API- oder Richtlinienerläuterungen gehören in verknüpfte Ablauf- oder Referenzthemen."
),
}
},
),
DocumentationTopic(
id="docs.pattern.contextual-help",
title="Context-sensitive help",
summary="Press F1 on a page, field, action, or dialog to open help for the current interface context.",
body=(
"GovOPlaN first resolves help for the focused field or action, then its dialog or section, "
"the current page, and the owning module. Exact documentation is shown when available; "
"otherwise the page or module documentation is used. The titlebar help button opens the "
"current page context. Documentation remains filtered by the current account's audience, "
"permissions, and configured modules."
),
layer="always",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator", "module_admin"),
order=21,
i18n_key="docs.topic.pattern.contextual_help",
translations={
"de": {
"title": "Kontextsensitive Hilfe",
"summary": "Mit F1 Hilfe zum aktuellen Seiten-, Feld-, Aktions- oder Dialogkontext öffnen.",
"body": (
"GovOPlaN löst Hilfe zuerst für das fokussierte Feld oder die fokussierte Aktion auf, danach für den Dialog oder Abschnitt, "
"die aktuelle Seite und schließlich das besitzende Modul. Ist eine genaue Dokumentation vorhanden, wird sie angezeigt; andernfalls "
"dient die Seiten- oder Moduldokumentation als Rückfall. Die Hilfe-Schaltfläche in der Titelleiste öffnet den aktuellen Seitenkontext. "
"Die Dokumentation bleibt nach Zielgruppe, Berechtigungen und konfigurierten Modulen des aktuellen Kontos gefiltert."
),
}
},
links=(
DocumentationLink(
label="Contextual help contract",
href="govoplan-core/docs/CONTEXTUAL_HELP_CONTRACT.md",
kind="repository",
),
),
metadata={
"kind": "pattern",
"pattern_id": "contextual-f1-help",
"help_contexts": [
"core.contextual-help",
"core.titlebar.language",
],
"component_refs": [
"govoplan-core/webui/src/layout/HelpMenu.tsx",
"govoplan-core/webui/src/utils/helpContext.ts",
],
},
),
DocumentationTopic(
id="docs.reference.temporal-data-context",
title="Temporal data context",
summary="Choose whether pages show currently valid records, records valid at a selected time, or all valid-time states.",
body=(
"The titlebar calendar controls valid time across participating modules. Current is the neutral "
"default. At time selects records valid at the chosen instant, while All includes historical and "
"future valid-time states. Recorded time remains separate: it describes when the platform learned "
"or stored a fact. Permissions are evaluated now, so temporal selection never restores historical access."
),
layer="always",
documentation_types=("admin", "user"),
audience=("user", "tenant_admin", "operator", "module_admin"),
order=22,
i18n_key="docs.topic.reference.temporal_data_context",
translations={
"de": {
"title": "Zeitlicher Datenkontext",
"summary": "Festlegen, ob Seiten aktuell gültige Datensätze, zu einem gewählten Zeitpunkt gültige Datensätze oder alle Gültigkeitszustände zeigen.",
"body": (
"Die Kalendersteuerung in der Titelleiste setzt die Gültigkeitszeit für beteiligte Module. Aktuell ist die neutrale "
"Voreinstellung. Zeitpunkt zeigt Datensätze, die zum gewählten Moment gültig sind; Alle umfasst historische und zukünftige "
"Gültigkeitszustände. Die Aufzeichnungszeit bleibt davon getrennt und beschreibt, wann die Plattform eine Tatsache erfahren "
"oder gespeichert hat. Berechtigungen werden stets gegenwärtig ausgewertet; eine Zeitauswahl stellt daher niemals frühere "
"Zugriffsrechte wieder her."
),
}
},
links=(
DocumentationLink(
label="Temporal data read contract",
href="govoplan-core/docs/TEMPORAL_DATA_CONTEXT.md",
kind="repository",
),
),
metadata={
"kind": "reference",
"help_contexts": ["core.temporal-data-context"],
},
), ),
DocumentationTopic( DocumentationTopic(
id="docs.reference.organization-identity-idm-access-boundary", id="docs.reference.organization-identity-idm-access-boundary",
@@ -278,12 +745,25 @@ manifest = ModuleManifest(
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("tenant_admin", "access_admin", "operator", "user"), audience=("tenant_admin", "access_admin", "operator", "user"),
related_modules=("organizations", "identity", "idm", "access"), related_modules=("organizations", "identity", "idm", "access"),
order=21, order=23,
conditions=( conditions=(
DocumentationCondition( DocumentationCondition(
required_modules=("organizations", "identity", "idm", "access"), required_modules=("organizations", "identity", "idm", "access"),
), ),
), ),
translations={
"de": {
"title": "Abgrenzung von Organisationen, Identität, IDM und Zugriff",
"summary": "Organizations definiert Strukturen und Funktionen, Identity Personen und Konten, IDM die Zuordnung von Identitäten zu Funktionen und Access die daraus entstehenden Rollen und Rechte.",
"body": (
"Verwenden Sie Organizations für Einheitstypen, Strukturen, Beziehungen, Organisationseinheiten und Funktionsdefinitionen. "
"Identity verwaltet normalisierte Identitäten und Kontoverknüpfungen. IDM ordnet eine Identität oder ein Konto einer Funktion "
"in einer Organisationseinheit zu und bildet dabei auch Delegation oder Handeln für andere ab. Access überführt anerkannte "
"Funktionsmerkmale in Rollen und Berechtigungen. Diese Aufteilung trennt das Organisationsmodell vom Identitätslebenszyklus "
"und hält Autorisierungsentscheidungen ausdrücklich nachvollziehbar."
),
}
},
links=( links=(
DocumentationLink( DocumentationLink(
label="Organizations", href="/organizations", kind="runtime" label="Organizations", href="/organizations", kind="runtime"
@@ -313,6 +793,17 @@ manifest = ModuleManifest(
}, },
], ],
}, },
structured_translation_version="1",
structured_translations={
"de": {
"admin_explanation": (
"Auswirkungen von Funktionen auf Rollen gehören Access. Änderungen an IDM-Zuordnungen können unabhängig von Änderungen am Organisationsmodell gesteuert werden."
),
"user_explanation": (
"Eine Person kann eine Funktion innehaben, weil IDM ihre Identität mit der Organisationsfunktion verknüpft. Access entscheidet, welche Anwendungsberechtigungen diese Funktion gewährt."
),
}
},
), ),
), ),
documentation_sources=( documentation_sources=(
@@ -331,6 +822,34 @@ manifest = ModuleManifest(
}, },
), ),
), ),
migration_spec=MigrationSpec(
module_id="docs",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
docs_models.SemanticDocumentationRevision,
docs_models.SemanticDocumentationEntry,
label="Docs semantic documentation",
),
retirement_notes=(
"Destructive retirement removes tenant semantic entries and immutable "
"revision history after the installer captures a database snapshot."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
docs_models.SemanticDocumentationEntry,
docs_models.SemanticDocumentationRevision,
label="Docs semantic documentation",
),
),
search_sources=(
SearchSourceProviderRegistration(
id="docs.semantic_documentation",
factory=create_semantic_documentation_search_source,
),
),
architecture=ARCHITECTURE, architecture=ARCHITECTURE,
) )
@@ -0,0 +1 @@
"""Docs-owned database migrations."""
@@ -0,0 +1 @@
"""Docs migration revisions."""
@@ -0,0 +1,170 @@
"""Add tenant semantic documentation and immutable revisions.
Revision ID: d3e7a1c5f9b2
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d3e7a1c5f9b2"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"docs_semantic_entries",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("subject_stable_key", sa.String(length=80), nullable=False),
sa.Column("subject_module_id", sa.String(length=80), nullable=False),
sa.Column("subject_kind", sa.String(length=120), nullable=False),
sa.Column("subject_id", sa.String(length=255), nullable=False),
sa.Column("anchor_kind", sa.String(length=120), nullable=True),
sa.Column("anchor_id", sa.String(length=255), nullable=True),
sa.Column("locale", sa.String(length=20), nullable=False),
sa.Column("lifecycle_state", sa.String(length=30), nullable=False),
sa.Column("current_revision", sa.Integer(), nullable=False),
sa.Column("current_revision_id", sa.String(length=36), nullable=True),
sa.Column("published_revision_id", sa.String(length=36), nullable=True),
sa.Column("superseded_by_entry_id", sa.String(length=36), nullable=True),
sa.Column("created_by", sa.String(length=255), nullable=False),
sa.Column("updated_by", sa.String(length=255), nullable=False),
sa.Column("published_by", sa.String(length=255), nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("retired_by", sa.String(length=255), nullable=True),
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["superseded_by_entry_id"],
["docs_semantic_entries.id"],
name=op.f(
"fk_docs_semantic_entries_superseded_by_entry_id_docs_semantic_entries"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_docs_semantic_entries")),
sa.UniqueConstraint(
"tenant_id",
"subject_stable_key",
"locale",
name="uq_docs_semantic_entry_subject_locale",
),
)
_entry_indexes()
op.create_table(
"docs_semantic_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("entry_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("lifecycle_state", sa.String(length=30), nullable=False),
sa.Column("action", sa.String(length=30), nullable=False),
sa.Column("change_reason", sa.String(length=1000), nullable=False),
sa.Column("content", sa.JSON(), nullable=False),
sa.Column("content_hash", sa.String(length=80), nullable=False),
sa.Column("subject_revision", sa.String(length=255), nullable=True),
sa.Column("subject_fingerprint", sa.String(length=80), nullable=True),
sa.Column("authored_by", sa.String(length=255), nullable=False),
sa.Column("reviewed_by", sa.String(length=255), nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("recoverable", sa.Boolean(), nullable=False),
sa.Column("search_text", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["entry_id"],
["docs_semantic_entries.id"],
name=op.f("fk_docs_semantic_revisions_entry_id_docs_semantic_entries"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_docs_semantic_revisions")),
sa.UniqueConstraint(
"entry_id",
"revision",
name="uq_docs_semantic_revision_number",
),
)
_revision_indexes()
def downgrade() -> None:
op.drop_table("docs_semantic_revisions")
op.drop_table("docs_semantic_entries")
def _entry_indexes() -> None:
for column in (
"tenant_id",
"subject_stable_key",
"subject_module_id",
"subject_kind",
"subject_id",
"anchor_kind",
"anchor_id",
"locale",
"lifecycle_state",
"current_revision_id",
"published_revision_id",
"superseded_by_entry_id",
"created_by",
"updated_by",
"published_by",
"published_at",
"retired_by",
"retired_at",
):
op.create_index(
op.f(f"ix_docs_semantic_entries_{column}"),
"docs_semantic_entries",
[column],
unique=False,
)
op.create_index(
"ix_docs_semantic_entries_tenant_state",
"docs_semantic_entries",
["tenant_id", "lifecycle_state"],
unique=False,
)
op.create_index(
"ix_docs_semantic_entries_subject",
"docs_semantic_entries",
["tenant_id", "subject_module_id", "subject_kind", "subject_id"],
unique=False,
)
def _revision_indexes() -> None:
for column in (
"tenant_id",
"entry_id",
"content_hash",
"subject_fingerprint",
"authored_by",
"reviewed_by",
"published_at",
):
op.create_index(
op.f(f"ix_docs_semantic_revisions_{column}"),
"docs_semantic_revisions",
[column],
unique=False,
)
op.create_index(
"ix_docs_semantic_revisions_entry",
"docs_semantic_revisions",
["entry_id", "revision"],
unique=False,
)
op.create_index(
"ix_docs_semantic_revisions_tenant_state",
"docs_semantic_revisions",
["tenant_id", "lifecycle_state"],
unique=False,
)
+296
View File
@@ -0,0 +1,296 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.events import PlatformEvent
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchIndexChange,
SearchResourceReference,
SearchResourceType,
)
from govoplan_docs.backend.db.models import (
SemanticDocumentationEntry,
SemanticDocumentationRevision,
)
from govoplan_docs.backend.semantic_service import (
prefetch_semantic_revisions,
published_revision,
semantic_entry_payload,
)
PROVIDER_ID = "docs.semantic_documentation"
RESOURCE_TYPE = "semantic_documentation"
DOCS_READ_SCOPE = "docs:documentation:read"
class SemanticDocumentationSearchSource:
def __init__(self, registry: object) -> None:
self._registry = registry
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="docs",
resource_type=RESOURCE_TYPE,
label="Semantic documentation",
requires_authorization_recheck=True,
),
)
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
_assert_source(request.provider_id, request.resource_type)
db = _session(session)
statement = (
select(SemanticDocumentationEntry, SemanticDocumentationRevision)
.join(
SemanticDocumentationRevision,
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,
SemanticDocumentationEntry.lifecycle_state.in_(("draft", "published")),
)
)
if request.cursor:
statement = statement.where(
SemanticDocumentationEntry.id > request.cursor
)
rows = list(
db.execute(
statement.order_by(SemanticDocumentationEntry.id).limit(
request.limit + 1
)
).all()
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
high_watermark = db.scalar(
select(func.max(SemanticDocumentationEntry.updated_at)).where(
SemanticDocumentationEntry.tenant_id == request.tenant_id,
SemanticDocumentationEntry.published_revision_id.is_not(None),
)
)
return SearchBackfillPage(
documents=tuple(_document(entry, revision) for entry, revision in selected),
next_cursor=selected[-1][0].id if has_more and selected else None,
complete=not has_more,
high_watermark=(
high_watermark.isoformat() if high_watermark is not None else None
),
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
decisions = {item.reference.key: False for item in requests}
if not isinstance(principal, ApiPrincipal) or not principal.has(DOCS_READ_SCOPE):
return decisions
db = _session(session)
valid_requests = [
item
for item in requests
if (
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
or entry.published_revision_id != item.source_revision
or entry.lifecycle_state not in {"draft", "published"}
):
continue
payload = semantic_entry_payload(
db,
self._registry,
principal,
entry=entry,
editor=False,
revisions=revisions,
)
decisions[reference.key] = bool(
payload
and payload["subject_resolution"]["availability"]
in {"available", "changed"}
)
return decisions
def index_changes_for_event(
self,
session: object,
*,
event: PlatformEvent,
delivery_key: str,
) -> Sequence[SearchIndexChange]:
if (
event.module_id != "docs"
or event.tenant is None
or event.resource is None
or event.resource.type != RESOURCE_TYPE
or event.resource.id is None
):
return ()
db = _session(session)
entry = db.get(SemanticDocumentationEntry, event.resource.id)
revision = (
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(
entry is not None
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
reference = SearchResourceReference(
tenant_id=event.tenant.id,
module_id="docs",
resource_type=RESOURCE_TYPE,
resource_id=event.resource.id,
)
return (
SearchIndexChange(
change_id=f"{delivery_key}:{PROVIDER_ID}",
provider_id=PROVIDER_ID,
kind="upsert" if document is not None else "delete",
reference=reference,
source_revision=(
document.source_revision if document is not None else cursor
),
cursor=cursor,
document=document,
occurred_at=event.occurred_at,
),
)
def create_semantic_documentation_search_source(
context: ModuleContext,
) -> SemanticDocumentationSearchSource:
return SemanticDocumentationSearchSource(context.registry)
def _document(
entry: SemanticDocumentationEntry,
revision: SemanticDocumentationRevision,
*,
change_cursor: str | None = None,
) -> SearchDocument:
content = revision.content
title = str(content.get("title") or "Semantic documentation")
summary = str(content.get("summary") or "")[:4000] or None
keywords = tuple(
str(value)[:200]
for value in (
entry.subject_module_id,
entry.subject_kind,
entry.locale,
"semantic documentation",
)
)
return SearchDocument(
tenant_id=entry.tenant_id,
module_id="docs",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=entry.id,
title=title,
url=(
f"/docs/semantic?entryId={quote(entry.id, safe='')}"
f"&locale={quote(entry.locale, safe='')}"
),
summary=summary,
body=revision.search_text[:200_000] or None,
keywords=keywords,
visibility="restricted",
acl_tokens=(f"scope:{DOCS_READ_SCOPE}",),
metadata={
"source_badge": "tenant_semantic",
"subject_module_id": entry.subject_module_id,
"subject_kind": entry.subject_kind,
"subject_id": entry.subject_id,
"anchor_kind": entry.anchor_kind,
"anchor_id": entry.anchor_id,
"locale": entry.locale,
"classification": content.get("classification", "internal"),
},
source_revision=revision.id,
change_cursor=change_cursor,
source_updated_at=entry.updated_at or entry.created_at,
requires_authorization_recheck=True,
)
def _assert_source(provider_id: str, resource_type: str) -> None:
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
raise ValueError("Unsupported Docs search source.")
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Semantic documentation search requires a SQLAlchemy session.")
return value
__all__ = [
"PROVIDER_ID",
"RESOURCE_TYPE",
"SemanticDocumentationSearchSource",
"create_semantic_documentation_search_source",
]
@@ -0,0 +1,137 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
SemanticLifecycleState = Literal["draft", "published", "superseded", "retired"]
SemanticSubjectAvailability = Literal[
"available",
"changed",
"superseded",
"missing",
"temporarily_unavailable",
]
class _StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
class SemanticSubjectAnchorPayload(_StrictModel):
kind: str = Field(min_length=1, max_length=120)
id: str = Field(min_length=1, max_length=255)
class SemanticSubjectReferencePayload(_StrictModel):
module_id: str = Field(min_length=1, max_length=80)
tenant_id: str = Field(min_length=1, max_length=255)
subject_kind: str = Field(min_length=1, max_length=120)
subject_id: str = Field(min_length=1, max_length=255)
anchor: SemanticSubjectAnchorPayload | None = None
observed_revision: str | None = Field(default=None, max_length=255)
observed_fingerprint: str | None = Field(default=None, max_length=80)
class SemanticDocumentationLinkPayload(_StrictModel):
label: str = Field(min_length=1, max_length=300)
href: str = Field(min_length=1, max_length=2000)
@field_validator("href")
@classmethod
def validate_href(cls, value: str) -> str:
if value.startswith("/") and not value.startswith("//"):
return value
if value.startswith("https://"):
return value
raise ValueError("Semantic documentation links must use HTTPS or a local path.")
class SemanticDocumentationContentPayload(_StrictModel):
title: str = Field(min_length=1, max_length=500)
summary: str = Field(default="", max_length=4000)
body: str = Field(default="", max_length=200_000)
meaning: str = Field(default="", max_length=20_000)
intended_use: str = Field(default="", max_length=20_000)
non_intended_use: str = Field(default="", max_length=20_000)
examples: list[str] = Field(default_factory=list, max_length=50)
owner_account_id: str | None = Field(default=None, max_length=255)
steward_account_id: str | None = Field(default=None, max_length=255)
audience: list[str] = Field(default_factory=list, max_length=100)
classification: Literal["internal", "restricted"] = "internal"
links: list[SemanticDocumentationLinkPayload] = Field(
default_factory=list,
max_length=50,
)
@field_validator("examples")
@classmethod
def validate_examples(cls, values: list[str]) -> list[str]:
if any(not value.strip() or len(value) > 4000 for value in values):
raise ValueError("Examples must contain bounded non-empty text.")
return list(dict.fromkeys(value.strip() for value in values))
@field_validator("audience")
@classmethod
def validate_audience(cls, values: list[str]) -> list[str]:
prefixes = ("account:", "group:", "role:", "function:", "scope:")
normalized = list(dict.fromkeys(value.strip() for value in values))
if any(
not value
or len(value) > 500
or (value != "authenticated" and not value.startswith(prefixes))
for value in normalized
):
raise ValueError(
"Audience entries must be authenticated or typed account, group, "
"role, function, or scope selectors."
)
return normalized
@model_validator(mode="after")
def validate_restricted_audience(self):
if self.classification == "restricted" and not self.audience:
raise ValueError("Restricted semantic documentation requires an audience.")
return self
class SemanticDocumentationCreateRequest(_StrictModel):
subject: SemanticSubjectReferencePayload
locale: str = Field(min_length=2, max_length=20)
content: SemanticDocumentationContentPayload
change_reason: str = Field(min_length=1, max_length=1000)
class SemanticDocumentationUpdateRequest(_StrictModel):
expected_revision: int = Field(ge=1)
content: SemanticDocumentationContentPayload
change_reason: str = Field(min_length=1, max_length=1000)
class SemanticDocumentationTransitionRequest(_StrictModel):
expected_revision: int = Field(ge=1)
change_reason: str = Field(min_length=1, max_length=1000)
class SemanticDocumentationSupersedeRequest(SemanticDocumentationTransitionRequest):
replacement_entry_id: str = Field(min_length=1, max_length=36)
class SemanticDocumentationPolicyUpdateRequest(_StrictModel):
mode: Literal["direct", "reviewer_required"]
__all__ = [
"SemanticDocumentationContentPayload",
"SemanticDocumentationCreateRequest",
"SemanticDocumentationLinkPayload",
"SemanticDocumentationPolicyUpdateRequest",
"SemanticDocumentationSupersedeRequest",
"SemanticDocumentationTransitionRequest",
"SemanticDocumentationUpdateRequest",
"SemanticLifecycleState",
"SemanticSubjectAnchorPayload",
"SemanticSubjectAvailability",
"SemanticSubjectReferencePayload",
]
File diff suppressed because it is too large Load Diff
+593
View File
@@ -0,0 +1,593 @@
from __future__ import annotations
import argparse
import hashlib
import importlib
import json
import os
import sys
import tomllib
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import (
DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
ModuleManifest,
localizable_documentation_metadata_keys,
)
SCHEMA_VERSION = "1"
DEFAULT_LOCALE = "de"
SUPPORTED_LOCALES = ("de", "en")
TOPIC_KINDS = (
"workflow",
"operator-workflow",
"guide",
"runbook",
"reference",
"pattern",
"system",
)
@dataclass(frozen=True, slots=True)
class ManifestSource:
manifest: ModuleManifest
repository: str | None = None
def collect_manifest_sources(
*,
workspace_root: Path | None = None,
) -> tuple[ManifestSource, ...]:
"""Collect manifests without requiring every optional module to be installed."""
by_id = {
manifest.id: ManifestSource(manifest=manifest)
for manifest in discover_module_manifests(ignore_load_errors=True)
}
if workspace_root is not None:
for source in _workspace_manifest_sources(workspace_root):
by_id[source.manifest.id] = source
return tuple(by_id[module_id] for module_id in sorted(by_id))
def build_public_catalog(
sources: Sequence[ManifestSource],
*,
generated_at: datetime | None = None,
) -> dict[str, Any]:
modules = [_module_payload(source) for source in sources]
digest_input = {
"schema_version": SCHEMA_VERSION,
"default_locale": DEFAULT_LOCALE,
"supported_locales": list(SUPPORTED_LOCALES),
"modules": modules,
}
source_digest = _sha256(digest_input)
timestamp = generated_at or _generated_at()
return {
**digest_input,
"generated_at": timestamp.astimezone(UTC).isoformat().replace("+00:00", "Z"),
"source_digest": source_digest,
"summary": _coverage_summary(modules),
}
def documentation_coverage_markdown(catalog: Mapping[str, Any]) -> str:
summary = _mapping(catalog.get("summary"))
modules = [item for item in catalog.get("modules", ()) if isinstance(item, Mapping)]
gaps = [item for item in summary.get("gaps", ()) if isinstance(item, Mapping)]
lines = [
"# Public documentation coverage",
"",
"This report is generated from the static `DocumentationTopic` entries, including their structured workflow and field metadata, in every module manifest.",
"Configured-instance topics from runtime providers remain in the authenticated Docs module because they may depend on permissions, policy, and live state.",
"",
f"- Modules: {summary.get('module_count', 0)}",
f"- Static topics: {summary.get('topic_count', 0)}",
f"- Topics with complete German title, summary, and body: {summary.get('german_complete_topic_count', 0)}",
f"- Modules with runtime documentation providers: {summary.get('runtime_provider_module_count', 0)}",
f"- Source digest: `{catalog.get('source_digest', '')}`",
"",
"## Expansion priorities",
"",
"1. Add German title, summary, and body translations to every public topic; German is the reference target.",
"2. Give every user-facing module at least one scope-conditioned workflow topic and one field/consequence reference.",
"3. Give every configurable module an administrator topic covering permissions, policy provenance, retention, and operational consequences.",
"4. Keep live provider-state and instance-specific limitations in `documentation_providers`; do not publish them as generic facts.",
"5. Adopt the versioned structured-localization contract for steps, fields, limitations, consequences, and verification; the coverage column tracks this migration independently from title/body completeness.",
"",
"## Module gaps",
"",
"| Module | Topics | Missing German | Structured German | Missing coverage |",
"| --- | ---: | ---: | ---: | --- |",
]
gaps_by_id = {str(item.get("module_id")): item for item in gaps}
for module in modules:
module_id = str(module.get("id", ""))
coverage = _mapping(module.get("coverage"))
gap = gaps_by_id.get(module_id, {})
missing = ", ".join(str(item) for item in gap.get("missing", ())) or "-"
lines.append(
f"| `{module_id}` | {coverage.get('topic_count', 0)} | "
f"{coverage.get('missing_german_topic_count', 0)} | "
f"{coverage.get('german_structured_complete_topic_count', 0)}/"
f"{coverage.get('structured_localizable_topic_count', 0)} | {missing} |"
)
lines.extend(("", "Generated file. Edit module manifests, then regenerate this report.", ""))
return "\n".join(lines)
def write_public_catalog(
output: Path,
catalog: Mapping[str, Any],
*,
coverage_output: Path | None = None,
) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(
json.dumps(catalog, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
if coverage_output is not None:
coverage_output.parent.mkdir(parents=True, exist_ok=True)
coverage_output.write_text(
documentation_coverage_markdown(catalog),
encoding="utf-8",
)
def catalog_matches_sources(output: Path, catalog: Mapping[str, Any]) -> bool:
if not output.is_file():
return False
try:
current = json.loads(output.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
return current.get("source_digest") == catalog.get("source_digest")
def _workspace_manifest_sources(workspace_root: Path) -> tuple[ManifestSource, ...]:
declarations: list[tuple[Path, Mapping[str, str]]] = []
for repository in sorted(workspace_root.glob("govoplan-*")):
pyproject = repository / "pyproject.toml"
if not pyproject.is_file():
continue
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
raw = data.get("project", {}).get("entry-points", {}).get("govoplan.modules", {})
if not isinstance(raw, Mapping) or not raw:
continue
declarations.append(
(repository, {str(key): str(value) for key, value in raw.items()})
)
# Add every module source before imports so optional contracts resolve
# independently of package installation order.
for repository, _entry_points in reversed(declarations):
source_root = str(repository / "src")
if source_root not in sys.path:
sys.path.insert(0, source_root)
sources: list[ManifestSource] = []
for repository, entry_points in declarations:
for target in entry_points.values():
module_name, separator, attribute = target.partition(":")
if not separator or not module_name or not attribute:
raise ValueError(f"Invalid GovOPlaN module entry point: {target!r}")
loaded = getattr(importlib.import_module(module_name), attribute)
manifest = loaded() if callable(loaded) else loaded
if not isinstance(manifest, ModuleManifest):
raise TypeError(f"Entry point {target!r} did not return ModuleManifest")
sources.append(
ManifestSource(
manifest=manifest,
repository=f"https://git.add-ideas.de/GovOPlaN/{repository.name}",
)
)
return tuple(sources)
def _module_payload(source: ManifestSource) -> dict[str, Any]:
manifest = source.manifest
topics = [
_topic_payload(manifest.id, topic)
for topic in sorted(manifest.documentation, key=lambda item: (item.order, item.id))
]
return {
"id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"repository": source.repository or _default_repository(manifest.id),
"dependencies": list(manifest.dependencies),
"optional_dependencies": list(manifest.optional_dependencies),
"runtime_documentation_provider_count": len(manifest.documentation_providers),
"topics": topics,
"coverage": _module_coverage(manifest, topics),
}
def _topic_payload(module_id: str, topic: DocumentationTopic) -> dict[str, Any]:
return {
"id": topic.id,
"source_module_id": topic.source_module_id or module_id,
"kind": _topic_kind(topic),
"layer": topic.layer,
"documentation_types": list(topic.documentation_types),
"audience": list(topic.audience),
"order": topic.order,
"localizations": {
locale: _localized_topic(topic, locale) for locale in SUPPORTED_LOCALES
},
"links": [_link_payload(link) for link in topic.links],
"related_modules": list(topic.related_modules),
"unlocks": list(topic.unlocks),
"version_min": topic.version_min,
"version_max_exclusive": topic.version_max_exclusive,
"is_conditioned": bool(topic.conditions or topic.configuration_keys),
"conditions": [_condition_payload(item) for item in topic.conditions],
"configuration_keys": list(topic.configuration_keys),
"content": _public_value(
{
key: value
for key, value in topic.metadata.items()
if key != "kind"
}
),
**_structured_content_payload(topic),
}
def _localized_topic(topic: DocumentationTopic, locale: str) -> dict[str, Any]:
translation = topic.translations.get(locale, {})
translated_fields = [
field
for field in ("title", "summary", "body")
if str(translation.get(field, "")).strip()
]
return {
"title": str(translation.get("title") or topic.title),
"summary": str(translation.get("summary") or topic.summary),
"body": str(translation.get("body") or topic.body),
"source_locale": locale if translated_fields else "en",
"translated_fields": translated_fields,
"complete": len(translated_fields) == 3,
}
def _localized_content(topic: DocumentationTopic, locale: str) -> dict[str, Any]:
localizable_keys = localizable_documentation_metadata_keys(topic)
translation = topic.structured_translations.get(locale, {})
translated_fields = sorted(set(localizable_keys).intersection(translation))
complete = locale == "en" or not localizable_keys or (
topic.structured_translation_version
== DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION
and len(translated_fields) == len(localizable_keys)
)
return {
"content": _public_value(dict(translation)),
"source_locale": locale if translated_fields else "en",
"translated_fields": translated_fields,
"complete": complete,
}
def _structured_content_payload(topic: DocumentationTopic) -> dict[str, Any]:
if not localizable_documentation_metadata_keys(topic):
return {}
return {
"structured_translation_version": topic.structured_translation_version,
"content_localizations": {
locale: _localized_content(topic, locale)
for locale in SUPPORTED_LOCALES
if locale != "en"
},
}
def _module_coverage(
manifest: ModuleManifest,
topics: Sequence[Mapping[str, Any]],
) -> dict[str, Any]:
kinds = {str(topic.get("kind")) for topic in topics}
has_user = any("user" in topic.get("documentation_types", ()) for topic in topics)
has_admin = any("admin" in topic.get("documentation_types", ()) for topic in topics)
missing_german = [
str(topic.get("id"))
for topic in topics
if not _mapping(_mapping(topic.get("localizations")).get("de")).get("complete")
]
structured_localizable = [
topic
for topic in manifest.documentation
if localizable_documentation_metadata_keys(topic)
]
german_structured_complete = [
topic
for topic in structured_localizable
if _localized_content(topic, "de")["complete"]
]
missing_german_structured = [
topic.id
for topic in structured_localizable
if not _localized_content(topic, "de")["complete"]
]
missing: list[str] = []
if not has_user:
missing.append("user documentation")
if not has_admin:
missing.append("administrator documentation")
if manifest.frontend is not None and "workflow" not in kinds:
missing.append("user workflow")
if "reference" not in kinds:
missing.append("field/consequence reference")
if missing_german:
missing.append("complete German localization")
return {
"topic_count": len(topics),
"user_topic_count": sum(
"user" in topic.get("documentation_types", ()) for topic in topics
),
"admin_topic_count": sum(
"admin" in topic.get("documentation_types", ()) for topic in topics
),
"missing_german_topic_count": len(missing_german),
"missing_german_topic_ids": missing_german,
"structured_localizable_topic_count": len(structured_localizable),
"structured_translation_contract_topic_count": sum(
topic.structured_translation_version
== DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION
for topic in structured_localizable
),
"german_structured_complete_topic_count": len(german_structured_complete),
"missing_german_structured_topic_count": len(missing_german_structured),
"missing_german_structured_topic_ids": missing_german_structured,
"kinds": sorted(kinds),
"missing": missing,
}
def _coverage_summary(modules: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
topic_count = sum(len(module.get("topics", ())) for module in modules)
missing_german = sum(
int(_mapping(module.get("coverage")).get("missing_german_topic_count", 0))
for module in modules
)
gaps = [
{
"module_id": str(module.get("id")),
"missing": list(_mapping(module.get("coverage")).get("missing", ())),
}
for module in modules
if _mapping(module.get("coverage")).get("missing")
]
return {
"module_count": len(modules),
"topic_count": topic_count,
"german_complete_topic_count": topic_count - missing_german,
"structured_localizable_topic_count": sum(
int(
_mapping(module.get("coverage")).get(
"structured_localizable_topic_count", 0
)
)
for module in modules
),
"structured_translation_contract_topic_count": sum(
int(
_mapping(module.get("coverage")).get(
"structured_translation_contract_topic_count", 0
)
)
for module in modules
),
"german_structured_complete_topic_count": sum(
int(
_mapping(module.get("coverage")).get(
"german_structured_complete_topic_count", 0
)
)
for module in modules
),
"runtime_provider_module_count": sum(
int(module.get("runtime_documentation_provider_count", 0)) > 0
for module in modules
),
"gap_module_count": len(gaps),
"gaps": gaps,
}
def coverage_baseline(catalog: Mapping[str, Any]) -> dict[str, Any]:
"""Build the monotonic public-documentation coverage baseline."""
summary = _mapping(catalog.get("summary"))
topic_count = int(summary.get("topic_count", 0))
german_complete = int(summary.get("german_complete_topic_count", 0))
structured_localizable = int(
summary.get("structured_localizable_topic_count", 0)
)
structured_complete = int(
summary.get("german_structured_complete_topic_count", 0)
)
return {
"schema_version": "1",
"minimum": {
"german_complete_topic_count": german_complete,
"structured_translation_contract_topic_count": int(
summary.get("structured_translation_contract_topic_count", 0)
),
"german_structured_complete_topic_count": structured_complete,
},
"maximum": {
"missing_german_topic_count": topic_count - german_complete,
"gap_module_count": int(summary.get("gap_module_count", 0)),
"missing_german_structured_topic_count": (
structured_localizable - structured_complete
),
},
}
def coverage_regression_issues(
catalog: Mapping[str, Any], baseline: Mapping[str, Any]
) -> tuple[str, ...]:
"""Return monotonic coverage failures against a reviewed baseline."""
if str(baseline.get("schema_version")) != "1":
return ("unsupported coverage baseline schema_version",)
summary = _mapping(catalog.get("summary"))
minimum = _mapping(baseline.get("minimum"))
maximum = _mapping(baseline.get("maximum"))
actual = {
"german_complete_topic_count": int(
summary.get("german_complete_topic_count", 0)
),
"structured_translation_contract_topic_count": int(
summary.get("structured_translation_contract_topic_count", 0)
),
"german_structured_complete_topic_count": int(
summary.get("german_structured_complete_topic_count", 0)
),
"missing_german_topic_count": int(summary.get("topic_count", 0))
- int(summary.get("german_complete_topic_count", 0)),
"gap_module_count": int(summary.get("gap_module_count", 0)),
"missing_german_structured_topic_count": int(
summary.get("structured_localizable_topic_count", 0)
)
- int(summary.get("german_structured_complete_topic_count", 0)),
}
issues: list[str] = []
for key, expected in minimum.items():
if key in actual and actual[key] < int(expected):
issues.append(
f"coverage {key} regressed: {actual[key]} is below {int(expected)}"
)
for key, expected in maximum.items():
if key in actual and actual[key] > int(expected):
issues.append(
f"coverage {key} regressed: {actual[key]} exceeds {int(expected)}"
)
return tuple(issues)
def _topic_kind(topic: DocumentationTopic) -> str:
raw = topic.metadata.get("kind")
if isinstance(raw, str):
normalized = raw.strip().lower().replace("_", "-")
if normalized in TOPIC_KINDS:
return normalized
return "system"
def _link_payload(link: DocumentationLink) -> dict[str, str]:
return {"label": link.label, "href": link.href, "kind": link.kind}
def _condition_payload(condition: DocumentationCondition) -> dict[str, list[str]]:
return {
"required_modules": list(condition.required_modules),
"any_modules": list(condition.any_modules),
"missing_modules": list(condition.missing_modules),
"required_capabilities": list(condition.required_capabilities),
"required_scopes": list(condition.required_scopes),
"any_scopes": list(condition.any_scopes),
"configuration_keys": list(condition.configuration_keys),
}
def _public_value(value: object) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, Mapping):
return {str(key): _public_value(item) for key, item in value.items()}
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return [_public_value(item) for item in value]
raise TypeError(
"Static documentation metadata must contain only JSON-compatible values; "
f"received {type(value).__name__}."
)
def _default_repository(module_id: str) -> str:
slug = {"campaigns": "campaign"}.get(module_id, module_id.replace("_", "-"))
return f"https://git.add-ideas.de/GovOPlaN/govoplan-{slug}"
def _sha256(value: object) -> str:
encoded = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _generated_at() -> datetime:
raw_epoch = os.environ.get("SOURCE_DATE_EPOCH")
if raw_epoch:
return datetime.fromtimestamp(int(raw_epoch), tz=UTC)
return datetime.now(UTC)
def _mapping(value: object) -> Mapping[str, Any]:
return value if isinstance(value, Mapping) else {}
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Export all static GovOPlaN module documentation for a public site."
)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--coverage-output", type=Path)
parser.add_argument("--workspace-root", type=Path)
parser.add_argument("--coverage-baseline", type=Path)
parser.add_argument(
"--check",
action="store_true",
help="Fail when the existing output does not match current manifest content.",
)
return parser
def main(argv: Iterable[str] | None = None) -> int:
args = _parser().parse_args(list(argv) if argv is not None else None)
catalog = build_public_catalog(
collect_manifest_sources(workspace_root=args.workspace_root)
)
if args.check:
failed = False
if not catalog_matches_sources(args.output, catalog):
print(f"Public documentation catalog is stale: {args.output}", file=sys.stderr)
failed = True
if args.coverage_baseline is not None:
try:
baseline = json.loads(
args.coverage_baseline.read_text(encoding="utf-8")
)
except (OSError, json.JSONDecodeError) as exc:
print(f"Invalid coverage baseline: {exc}", file=sys.stderr)
return 1
for issue in coverage_regression_issues(catalog, _mapping(baseline)):
print(f"Public documentation {issue}", file=sys.stderr)
failed = True
return int(failed)
write_public_catalog(
args.output,
catalog,
coverage_output=args.coverage_output,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+72
View File
@@ -52,6 +52,78 @@ class FakePrincipal:
class DocsContextTests(unittest.TestCase): 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(
ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation=(
DocumentationTopic(
id="example.reference.localized",
title="Reference",
summary="Reference summary.",
documentation_types=("admin",),
metadata={
"kind": "reference",
"limitations": ["English limitation."],
},
structured_translation_version="1",
structured_translations={
"de": {
"limitations": ["Deutsche Einschränkung."]
}
},
),
),
)
)
layers = _classify_documentation(
registry,
FakePrincipal({"docs:documentation:read"}),
settings=None,
session=None,
documentation_type="admin",
locale="de-DE",
)
topic = layers["configured"][0]
self.assertEqual("de", topic["structured_translation_locale"])
self.assertEqual("1", topic["structured_translation_version"])
self.assertEqual(
["Deutsche Einschränkung."], topic["metadata"]["limitations"]
)
def test_user_provider_state_omits_binding_details(self) -> None: def test_user_provider_state_omits_binding_details(self) -> None:
state = { state = {
"configured": True, "configured": True,
+240
View File
@@ -0,0 +1,240 @@
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from govoplan_core.core.modules import DocumentationTopic, ModuleManifest
from govoplan_docs.backend.manifest import get_manifest as get_docs_manifest
from govoplan_docs.public_export import (
ManifestSource,
build_public_catalog,
catalog_matches_sources,
coverage_baseline,
coverage_regression_issues,
documentation_coverage_markdown,
write_public_catalog,
)
class PublicDocumentationExportTests(unittest.TestCase):
def test_docs_manifest_has_complete_german_public_coverage(self) -> None:
module = build_public_catalog(
(ManifestSource(get_docs_manifest()),)
)["modules"][0]
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"])
self.assertEqual(
2, module["coverage"]["german_structured_complete_topic_count"]
)
self.assertEqual(
[], module["coverage"]["missing_german_structured_topic_ids"]
)
self.assertEqual([], module["coverage"]["missing"])
def test_catalog_projects_localized_manifest_topics_and_gaps(self) -> None:
manifest = ModuleManifest(
id="example",
name="Example",
version="1.2.3",
documentation=(
DocumentationTopic(
id="example.workflow",
title="Use example",
summary="Perform the example workflow.",
body="Open and finish it.",
documentation_types=("user", "admin"),
metadata={
"kind": "workflow",
"steps": ["Review", "Execute"],
"verification": "Confirm completion.",
},
translations={
"de": {
"title": "Beispiel verwenden",
"summary": "Den Beispielablauf durchführen.",
"body": "Öffnen und abschließen.",
}
},
structured_translation_version="1",
structured_translations={
"de": {
"steps": ["Prüfen", "Ausführen"],
"verification": "Den Abschluss bestätigen.",
}
},
),
),
)
catalog = build_public_catalog(
(ManifestSource(manifest, "https://example.invalid/repository"),)
)
topic = catalog["modules"][0]["topics"][0]
self.assertEqual("Beispiel verwenden", topic["localizations"]["de"]["title"])
self.assertTrue(topic["localizations"]["de"]["complete"])
self.assertEqual("workflow", topic["kind"])
self.assertEqual(["Review", "Execute"], topic["content"]["steps"])
self.assertEqual(
["Prüfen", "Ausführen"],
topic["content_localizations"]["de"]["content"]["steps"],
)
self.assertTrue(topic["content_localizations"]["de"]["complete"])
self.assertIn(
"field/consequence reference",
catalog["modules"][0]["coverage"]["missing"],
)
def test_digest_check_ignores_generation_timestamp(self) -> None:
source = ManifestSource(
ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation=(
DocumentationTopic(
id="example.reference",
title="Reference",
summary="Reference summary",
metadata={"kind": "reference"},
),
),
)
)
first = build_public_catalog((source,))
second = build_public_catalog((source,))
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "catalog.json"
write_public_catalog(output, first)
self.assertTrue(catalog_matches_sources(output, second))
parsed = json.loads(output.read_text(encoding="utf-8"))
parsed["source_digest"] = "stale"
output.write_text(json.dumps(parsed), encoding="utf-8")
self.assertFalse(catalog_matches_sources(output, second))
def test_coverage_report_identifies_generated_source(self) -> None:
catalog = build_public_catalog(
(
ManifestSource(
ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation=(),
)
),
)
)
report = documentation_coverage_markdown(catalog)
self.assertIn("Public documentation coverage", report)
self.assertIn("`example`", report)
self.assertIn("Edit module manifests", report)
def test_catalog_preserves_structured_static_documentation(self) -> None:
manifest = ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation=(
DocumentationTopic(
id="example.workflow",
title="Run example",
summary="Run it safely.",
metadata={
"kind": "workflow",
"prerequisites": ["Approved input"],
"steps": ["Review", "Execute"],
"fields": [
{
"label": "Mode",
"user_description": "Selected behavior.",
}
],
},
),
),
)
topic = build_public_catalog((ManifestSource(manifest),))["modules"][0]["topics"][0]
self.assertEqual(["Review", "Execute"], topic["content"]["steps"])
self.assertEqual("Mode", topic["content"]["fields"][0]["label"])
def test_catalog_preserves_specialized_documentation_kinds(self) -> None:
manifest = ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation=(
DocumentationTopic(
id="example.runbook",
title="Recover example",
summary="Restore the example safely.",
metadata={"kind": "operator_workflow"},
),
),
)
topic = build_public_catalog((ManifestSource(manifest),))["modules"][0]["topics"][0]
self.assertEqual("operator-workflow", topic["kind"])
def test_coverage_baseline_rejects_text_and_structured_regressions(self) -> None:
healthy = build_public_catalog(
(
ManifestSource(
ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation=(
DocumentationTopic(
id="example.reference",
title="Reference",
summary="Reference summary.",
body="Reference body.",
translations={
"de": {
"title": "Referenz",
"summary": "Referenzzusammenfassung.",
"body": "Referenzinhalt.",
}
},
metadata={
"kind": "reference",
"limitations": ["Source limitation."],
},
structured_translation_version="1",
structured_translations={
"de": {
"limitations": [
"Einschränkung der Quelle."
]
}
},
),
),
)
),
)
)
baseline = coverage_baseline(healthy)
self.assertEqual((), coverage_regression_issues(healthy, baseline))
regressed = json.loads(json.dumps(healthy))
regressed["summary"]["german_complete_topic_count"] = 0
regressed["summary"]["german_structured_complete_topic_count"] = 0
issues = coverage_regression_issues(regressed, baseline)
self.assertTrue(any("german_complete_topic_count" in issue for issue in issues))
self.assertTrue(
any("german_structured_complete_topic_count" in issue for issue in issues)
)
if __name__ == "__main__":
unittest.main()
+373
View File
@@ -0,0 +1,373 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillRequest,
)
from govoplan_core.core.semantic_documentation import (
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
SemanticDocumentationSubjectDescriptor,
SemanticDocumentationSubjectPage,
SemanticDocumentationSubjectReference,
SemanticDocumentationSubjectResolution,
semantic_documentation_fingerprint,
semantic_documentation_subject_capability,
)
from govoplan_core.db.base import Base
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
from govoplan_docs.backend.dsar_provider import DocsDsarProvider
from govoplan_docs.backend.search_source import SemanticDocumentationSearchSource
from govoplan_docs.backend.semantic_service import (
SemanticDocumentationAuthorizationError,
SemanticDocumentationConflictError,
create_semantic_entry,
publication_policy,
publish_semantic_entry,
select_locale_entries,
semantic_entry_payload,
set_publication_policy,
update_semantic_entry,
)
from govoplan_core.core.dsar import DsarSubjectRef
class _SubjectProvider:
provider_id = "forms.semantic_subjects"
module_id = "forms"
contract_version = SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
def __init__(self) -> None:
self.revision = "7"
self.denied_accounts: set[str] = set()
def reference(self, tenant_id: str) -> SemanticDocumentationSubjectReference:
fingerprint = semantic_documentation_fingerprint(
{"id": "form-1", "revision": self.revision}
)
return SemanticDocumentationSubjectReference(
module_id="forms",
tenant_id=tenant_id,
subject_kind="form",
subject_id="form-1",
observed_revision=self.revision,
observed_fingerprint=fingerprint,
)
def descriptor(self, tenant_id: str) -> SemanticDocumentationSubjectDescriptor:
return SemanticDocumentationSubjectDescriptor(
reference=self.reference(tenant_id),
labels={"de": "Anwohnerparkausweis", "en": "Resident permit"},
descriptions={"de": "Konfiguriertes Formular"},
route="/forms/form-1",
required_scopes=("forms:form:read",),
)
def list_subjects(self, session, principal, *, request):
del session
if principal.account_id in self.denied_accounts:
return SemanticDocumentationSubjectPage()
return SemanticDocumentationSubjectPage(
subjects=(self.descriptor(request.tenant_id),)
)
def resolve_subject(self, session, principal, *, reference):
del session
if principal.account_id in self.denied_accounts:
return None
descriptor = self.descriptor(reference.tenant_id)
availability = (
"changed"
if reference.observed_revision
and reference.observed_revision != descriptor.reference.observed_revision
else "available"
)
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability=availability,
subject=descriptor,
)
class _Registry:
def __init__(self, provider: _SubjectProvider) -> None:
self.provider = provider
self.name = semantic_documentation_subject_capability("forms")
def capability_names(self):
return (self.name,)
def has_capability(self, name):
return name == self.name
def capability(self, name):
return self.provider if name == self.name else None
def _principal(
account_id: str,
*,
tenant_id: str = "tenant-1",
scopes: frozenset[str] | None = None,
) -> ApiPrincipal:
principal = PrincipalRef(
account_id=account_id,
membership_id=f"membership-{account_id}",
tenant_id=tenant_id,
scopes=scopes or frozenset({"docs:documentation:read", "forms:form:read"}),
)
return ApiPrincipal(
principal=principal,
account=SimpleNamespace(id=account_id),
user=SimpleNamespace(id=account_id),
)
def _content(title: str = "Permit form") -> dict[str, object]:
return {
"title": title,
"summary": "Tenant meaning",
"body": "Use this form for residents.",
"meaning": "Configured resident permit request.",
"intended_use": "Resident permits",
"non_intended_use": "Visitor permits",
"examples": [],
"owner_account_id": "author",
"steward_account_id": "reviewer",
"audience": [],
"classification": "internal",
"links": [],
}
class SemanticDocumentationTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
create_scope_tables(self.engine)
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.session.add(
Tenant(id="tenant-1", slug="tenant-1", name="Tenant One", settings={})
)
self.session.commit()
self.provider = _SubjectProvider()
self.registry = _Registry(self.provider)
self.author = _principal("author")
self.reviewer = _principal("reviewer")
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_revision_review_policy_visibility_and_concurrency(self) -> None:
entry = create_semantic_entry(
self.session,
self.registry,
self.author,
subject=self.provider.reference("tenant-1"),
locale="de",
content=_content(),
change_reason="Initial tenant semantics",
)
self.assertEqual("reviewer_required", publication_policy(self.session, "tenant-1"))
with self.assertRaises(SemanticDocumentationAuthorizationError):
publish_semantic_entry(
self.session,
self.registry,
self.author,
entry_id=entry.id,
expected_revision=1,
change_reason="Self publish",
)
entry = publish_semantic_entry(
self.session,
self.registry,
self.reviewer,
entry_id=entry.id,
expected_revision=1,
change_reason="Independent review",
)
self.assertEqual(2, entry.current_revision)
reader_payload = semantic_entry_payload(
self.session,
self.registry,
self.author,
entry=entry,
editor=False,
)
self.assertEqual("Permit form", reader_payload["content"]["title"])
update_semantic_entry(
self.session,
self.registry,
self.author,
entry_id=entry.id,
expected_revision=2,
content=_content("Updated permit form"),
change_reason="Clarify meaning",
)
with self.assertRaises(SemanticDocumentationConflictError):
update_semantic_entry(
self.session,
self.registry,
self.author,
entry_id=entry.id,
expected_revision=2,
content=_content("Stale edit"),
change_reason="Stale",
)
reader_payload = semantic_entry_payload(
self.session,
self.registry,
self.author,
entry=entry,
editor=False,
)
self.assertEqual("Permit form", reader_payload["content"]["title"])
self.assertTrue(reader_payload["pending_draft"])
def test_subject_reauthorization_tenant_and_changed_state(self) -> None:
entry = create_semantic_entry(
self.session,
self.registry,
self.author,
subject=self.provider.reference("tenant-1"),
locale="de",
content=_content(),
change_reason="Initial",
)
publish_semantic_entry(
self.session,
self.registry,
self.reviewer,
entry_id=entry.id,
expected_revision=1,
change_reason="Review",
)
self.provider.revision = "8"
payload = semantic_entry_payload(
self.session,
self.registry,
self.author,
entry=entry,
editor=False,
)
self.assertEqual("changed", payload["subject_resolution"]["availability"])
self.provider.denied_accounts.add("author")
self.assertIsNone(
semantic_entry_payload(
self.session,
self.registry,
self.author,
entry=entry,
editor=False,
)
)
with self.assertRaises(Exception):
create_semantic_entry(
self.session,
self.registry,
_principal("other", tenant_id="tenant-2"),
subject=self.provider.reference("tenant-1"),
locale="de",
content=_content(),
change_reason="Foreign",
)
def test_locale_search_and_dsar_projection(self) -> None:
entries = []
for locale, title in (("de", "Deutsch"), ("en", "English")):
entry = create_semantic_entry(
self.session,
self.registry,
self.author,
subject=self.provider.reference("tenant-1"),
locale=locale,
content=_content(title),
change_reason="Initial",
)
publish_semantic_entry(
self.session,
self.registry,
self.reviewer,
entry_id=entry.id,
expected_revision=1,
change_reason="Review",
)
entries.append(entry)
self.assertEqual("de", select_locale_entries(entries, locale="de-AT")[0].locale)
source = SemanticDocumentationSearchSource(self.registry)
page = source.backfill(
self.session,
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id="docs.semantic_documentation",
resource_type="semantic_documentation",
rebuild_id="rebuild-1",
),
)
self.assertEqual(2, len(page.documents))
request = SearchAuthorizationRequest(
reference=page.documents[0].reference,
source_revision=page.documents[0].source_revision,
)
self.assertTrue(source.authorize(self.session, self.author, requests=(request,))[request.reference.key])
self.provider.denied_accounts.add("author")
self.assertFalse(source.authorize(self.session, self.author, requests=(request,))[request.reference.key])
records = DocsDsarProvider().search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="author"),
)
self.assertGreaterEqual(len(records), 2)
self.assertNotIn("body", records[0].data)
actions = DocsDsarProvider().plan_erasure(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(account_id="author"),
records=records,
)
self.assertTrue(all(not action.executable for action in actions))
def test_direct_publication_policy_is_explicit(self) -> None:
self.assertEqual(
"direct",
set_publication_policy(
self.session,
tenant_id="tenant-1",
mode="direct",
),
)
entry = create_semantic_entry(
self.session,
self.registry,
self.author,
subject=self.provider.reference("tenant-1"),
locale="de",
content=_content(),
change_reason="Initial",
)
published = publish_semantic_entry(
self.session,
self.registry,
self.author,
entry_id=entry.id,
expected_revision=1,
change_reason="Direct publication",
)
self.assertEqual("published", published.lifecycle_state)
if __name__ == "__main__":
unittest.main()
+397
View File
@@ -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()
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.15", "version": "0.1.23",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -12,8 +12,11 @@
"import": "./src/index.ts" "import": "./src/index.ts"
} }
}, },
"scripts": {
"typecheck": "tsc --noEmit"
},
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.15", "@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
+118
View File
@@ -166,6 +166,8 @@ export type DocsDocumentationTopic = {
i18n_key: string; i18n_key: string;
locale: string; locale: string;
translation_locale: string; translation_locale: string;
structured_translation_locale: string;
structured_translation_version?: string | null;
version: { version: {
resolved: string; resolved: string;
minimum?: string | null; minimum?: string | null;
@@ -176,6 +178,7 @@ export type DocsDocumentationTopic = {
conditions: DocsDocumentationCondition[]; conditions: DocsDocumentationCondition[];
links: DocsDocumentationLink[]; links: DocsDocumentationLink[];
related_modules: string[]; related_modules: string[];
area_module_ids?: string[];
unlocks: string[]; unlocks: string[];
configuration_keys: string[]; configuration_keys: string[];
configuration_states: Array<{ configuration_states: Array<{
@@ -275,3 +278,118 @@ export function fetchDocsSource(
`/api/v1/docs/sources/${encodeURIComponent(sourceId)}${query ? `?${query}` : ""}` `/api/v1/docs/sources/${encodeURIComponent(sourceId)}${query ? `?${query}` : ""}`
); );
} }
export type SemanticSubjectReference = {
module_id: string;
tenant_id: string;
subject_kind: string;
subject_id: string;
anchor?: { kind: string; id: string } | null;
observed_revision?: string | null;
observed_fingerprint?: string | null;
};
export type SemanticSubjectDescriptor = {
reference: SemanticSubjectReference;
labels: Record<string, string>;
descriptions: Record<string, string>;
route?: string | null;
route_anchor?: string | null;
};
export type SemanticContent = {
title: string;
summary: string;
body: string;
meaning: string;
intended_use: string;
non_intended_use: string;
examples: string[];
owner_account_id: string | null;
steward_account_id: string | null;
audience: string[];
classification: "internal" | "restricted";
links: Array<{ label: string; href: string }>;
};
export type SemanticEntry = {
id: string;
subject: SemanticSubjectReference;
subject_resolution: {
availability: "available" | "changed" | "superseded" | "missing" | "temporarily_unavailable";
reason_code?: string | null;
};
locale: string;
requested_locale: string;
locale_fallback: boolean;
lifecycle_state: "draft" | "published" | "superseded" | "retired";
pending_draft: boolean;
current_revision: number;
published_revision?: number | null;
content: SemanticContent;
content_redacted: boolean;
updated_at: string;
};
export async function fetchSemanticSubjects(
settings: ApiSettings,
query = ""
): Promise<SemanticSubjectDescriptor[]> {
const params = new URLSearchParams({ query });
const response = await apiFetch<{ providers: Array<{ subjects: SemanticSubjectDescriptor[] }> }>(
settings,
`/api/v1/docs/semantic/subjects?${params}`
);
return response.providers.flatMap((provider) => provider.subjects);
}
export async function fetchSemanticEntries(
settings: ApiSettings,
locale: string,
includeDrafts = true
): Promise<SemanticEntry[]> {
const params = new URLSearchParams({ locale, include_drafts: String(includeDrafts) });
const response = await apiFetch<{ items: SemanticEntry[] }>(
settings,
`/api/v1/docs/semantic/entries?${params}`
);
return response.items;
}
export function createSemanticEntry(
settings: ApiSettings,
payload: { subject: SemanticSubjectReference; locale: string; content: SemanticContent; change_reason: string }
): Promise<SemanticEntry> {
return apiFetch(settings, "/api/v1/docs/semantic/entries", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function updateSemanticEntry(
settings: ApiSettings,
entryId: string,
payload: { expected_revision: number; content: SemanticContent; change_reason: string }
): Promise<SemanticEntry> {
return apiFetch(settings, `/api/v1/docs/semantic/entries/${encodeURIComponent(entryId)}`, {
method: "PUT",
body: JSON.stringify(payload)
});
}
export function transitionSemanticEntry(
settings: ApiSettings,
entryId: string,
transition: "publish" | "retire",
expectedRevision: number,
changeReason: string
): Promise<SemanticEntry> {
return apiFetch(
settings,
`/api/v1/docs/semantic/entries/${encodeURIComponent(entryId)}/${transition}`,
{
method: "POST",
body: JSON.stringify({ expected_revision: expectedRevision, change_reason: changeReason })
}
);
}
+110 -76
View File
@@ -1,16 +1,24 @@
import { DescriptionList } from "@govoplan/core-webui";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation } from "react-router"; import { Link, useLocation } from "react-router";
import { ChevronDown, ChevronRight, Eye, RefreshCw } from "lucide-react"; import { ChevronDown, ChevronRight, Eye } from "lucide-react";
import { import {
Button, Button,
DataGrid, DataGrid,
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
ExplorerTree, ExplorerTree,
LoadingFrame, FilterBar,
PageTitle, FormField,
MultiSelectFilter,
PageActionBar,
PageLayout,
SegmentedControl, SegmentedControl,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatusBadge, StatusBadge,
WorkspaceLayout,
adminErrorMessage, adminErrorMessage,
useGuardedNavigate, useGuardedNavigate,
usePlatformLanguage, usePlatformLanguage,
@@ -28,6 +36,7 @@ import {
type DocsSource, type DocsSource,
type DocsSourceDetail type DocsSourceDetail
} from "../../api/docs"; } from "../../api/docs";
import { ancestorOccurrenceIds, documentationTagOptions, documentationTags, matchesDocumentationTopic, qualifyTreeOccurrences, selectedTreeOccurrence, topicAreaIds } from "./docsDiscovery";
type DocumentationType = "admin" | "user"; type DocumentationType = "admin" | "user";
@@ -66,13 +75,15 @@ type OutlineItem = {
export default function DocsPage({ settings }: { settings: ApiSettings }) { export default function DocsPage({ settings }: { settings: ApiSettings }) {
const location = useLocation(); const location = useLocation();
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
const { language } = usePlatformLanguage(); const { language, translateText } = usePlatformLanguage();
const [context, setContext] = useState<DocsContext | null>(null); const [context, setContext] = useState<DocsContext | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const loadSequence = useRef(0); const loadSequence = useRef(0);
const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search)); const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search));
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set()); 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 locale = localeFromSearch(location.search) ?? language;
const selectedVersion = versionFromSearch(location.search); const selectedVersion = versionFromSearch(location.search);
const adminDocs = documentationType === "admin"; const adminDocs = documentationType === "admin";
@@ -107,6 +118,12 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
const treeNodes = useMemo(() => docsTreeNodes(context, adminDocs), [context, adminDocs]); const treeNodes = useMemo(() => docsTreeNodes(context, adminDocs), [context, adminDocs]);
const pages = useMemo(() => flattenTreePages(treeNodes), [treeNodes]); const pages = useMemo(() => flattenTreePages(treeNodes), [treeNodes]);
const selectedPage = selectedPageFromSearch(location.search, pages); 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 topicById = useMemo(() => topicIndex(context), [context]);
const visibleRoutes = context?.layers.configured.routes ?? []; const visibleRoutes = context?.layers.configured.routes ?? [];
const availableRoutes = context?.layers.available.routes ?? []; const availableRoutes = context?.layers.available.routes ?? [];
@@ -120,7 +137,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
useEffect(() => { useEffect(() => {
if (!selectedPage) return; if (!selectedPage) return;
const ancestorIds = ancestorNodeIdsForPage(treeNodes, selectedPage.id); const ancestorIds = ancestorOccurrenceIds(treeNodes, selectedNode?.id ?? "");
if (!ancestorIds.length) return; if (!ancestorIds.length) return;
setExpandedNodes((current) => { setExpandedNodes((current) => {
const next = new Set(current); const next = new Set(current);
@@ -129,10 +146,17 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
} }
return next; return next;
}); });
}, [treeNodes, selectedPage?.id]); }, [treeNodes, selectedNode?.id]);
return ( return (
<div className="workspace module-workspace docs-workspace"> <WorkspaceLayout
className="module-workspace docs-workspace"
primarySize="wide"
primaryLabel="i18n:govoplan-docs.documentation_outline.6f836b99"
contentLabel="i18n:govoplan-docs.help_center.f3f3a34b"
documentationType={documentationType}
contentClassName="docs-workspace-content"
primary={(
<aside className="section-sidebar docs-outline" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99"> <aside className="section-sidebar docs-outline" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
<div className="docs-sidebar-header"> <div className="docs-sidebar-header">
<div className="section-title">i18n:govoplan-docs.help_center.f3f3a34b</div> <div className="section-title">i18n:govoplan-docs.help_center.f3f3a34b</div>
@@ -156,14 +180,24 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
))} ))}
</select> </select>
</label> </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> </div>
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99"> <nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
<ExplorerTree <ExplorerTree
nodes={treeNodes} nodes={visibleTreeNodes}
getNodeId={(node) => node.id} getNodeId={(node) => node.id}
getNodeLabel={(node) => node.title} getNodeLabel={(node) => node.title}
getNodeChildren={(node) => node.children} getNodeChildren={(node) => node.children}
activeId={activeNodeIdForPage(treeNodes, selectedPage?.id ?? "")} activeId={selectedNode?.id ?? ""}
expandedIds={expandedNodes} expandedIds={expandedNodes}
depth={0} depth={0}
childrenBaseClassName="docs-tree-children" childrenBaseClassName="docs-tree-children"
@@ -172,32 +206,40 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
toggleBaseClassName="docs-tree-toggle" toggleBaseClassName="docs-tree-toggle"
nodeButtonBaseClassName="docs-tree-page" nodeButtonBaseClassName="docs-tree-page"
getNodeWrapStyle={(_node, context) => ({ paddingLeft: `${context.depth * 14}px` })} 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" />} renderToggleIcon={(_node, context) => context.hasChildren ? context.expanded ? <ChevronDown size={15} /> : <ChevronRight size={15} /> : <span className="docs-tree-toggle-placeholder" />}
renderNodeContent={(node) => node.title} renderNodeContent={(node) => node.title}
onToggle={(node) => toggleNode(node.id)} onToggle={(node) => toggleNode(node.id)}
onOpen={(node) => selectPage(node.page)} onOpen={(node) => { selectPage(node.page, node.id); setSearchQuery(""); setSelectedTags(null); }}
/> />
</nav> </nav>
</aside> </aside>
<section className="workspace-content docs-workspace-content"> )}
<div className="content-pad workspace-data-page docs-page"> >
<div className="page-heading split workspace-heading"> <PageLayout
<div> archetype="workspace"
<PageTitle loading={loading}>i18n:govoplan-docs.help_center.f3f3a34b</PageTitle> title="i18n:govoplan-docs.help_center.f3f3a34b"
<p>{adminDocs ? "i18n:govoplan-docs.technical_documentation_for_the_modules_and_conf.267e739e" : "i18n:govoplan-docs.guidance_for_the_functions_available_in_this_ins.723b7cad"}</p> description={adminDocs ? "i18n:govoplan-docs.technical_documentation_for_the_modules_and_conf.267e739e" : "i18n:govoplan-docs.guidance_for_the_functions_available_in_this_ins.723b7cad"}
</div> actions={<PageActionBar variant="workspace" refreshable reloadAction={{ onReload: () => void load(), loading }} />}
<div className="button-row compact-actions"> loading={loading}
<Button onClick={() => void load()} disabled={loading}><RefreshCw size={16} /> i18n:govoplan-docs.reload.cce71553</Button> loadingLabel="i18n:govoplan-docs.loading_documentation_context.1c091645"
</div> error={error}
</div> mode="workspace"
documentationType={documentationType}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} className="docs-page"
>
<LoadingFrame loading={loading} label="i18n:govoplan-docs.loading_documentation_context.1c091645">
<div className="docs-content"> <div className="docs-content">
<main className="docs-page-main"> <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} page={selectedPage}
adminDocs={adminDocs} adminDocs={adminDocs}
documentationType={documentationType} documentationType={documentationType}
@@ -210,14 +252,12 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
evidenceSources={context?.layers.evidence.sources ?? []} evidenceSources={context?.layers.evidence.sources ?? []}
settings={settings} settings={settings}
locale={locale} locale={locale}
/> />}
</main> </main>
<PageOutline items={outlineItems} /> {!filtering && <PageOutline items={outlineItems} />}
</div>
</LoadingFrame>
</div>
</section>
</div> </div>
</PageLayout>
</WorkspaceLayout>
); );
function selectDocumentationType(type: DocumentationType) { function selectDocumentationType(type: DocumentationType) {
@@ -233,9 +273,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
navigate(`${location.pathname}?${params.toString()}`, { replace: true }); navigate(`${location.pathname}?${params.toString()}`, { replace: true });
} }
function selectPage(page: DocsPageNode) { function selectPage(page: DocsPageNode, occurrenceId?: string) {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
params.set("topic", page.id); params.set("topic", page.id);
if (occurrenceId) params.set("occurrence", occurrenceId);
else params.delete("occurrence");
navigate(`${location.pathname}?${params.toString()}`, { replace: true }); navigate(`${location.pathname}?${params.toString()}`, { replace: true });
} }
@@ -459,7 +501,7 @@ function ConstraintDetails({ id, constraints }: { id: string; constraints: Recor
return ( return (
<div className="docs-detail-block" id={id}> <div className="docs-detail-block" id={id}>
<h4>i18n:govoplan-docs.requirements.09a428f9</h4> <h4>i18n:govoplan-docs.requirements.09a428f9</h4>
<dl className="detail-list compact"> <DescriptionList variant="inline" density="compact">
{constraints.map((constraint, index) => { {constraints.map((constraint, index) => {
const label = metadataString(constraint, "label"); const label = metadataString(constraint, "label");
const description = metadataString(constraint, "description"); const description = metadataString(constraint, "description");
@@ -474,7 +516,7 @@ function ConstraintDetails({ id, constraints }: { id: string; constraints: Recor
</div> </div>
); );
})} })}
</dl> </DescriptionList>
</div> </div>
); );
} }
@@ -487,11 +529,11 @@ function ReferenceDetails({ topic, showTechnical, documentationType, topicById }
return ( return (
<div className="docs-topic-details"> <div className="docs-topic-details">
{(route || screen || section) && {(route || screen || section) &&
<dl className="detail-list compact"> <DescriptionList variant="inline" density="compact">
{screen && <div><dt>i18n:govoplan-docs.screen.c4878ec4</dt><dd>{screen}</dd></div>} {screen && <div><dt>i18n:govoplan-docs.screen.c4878ec4</dt><dd>{screen}</dd></div>}
{section && <div><dt>i18n:govoplan-docs.section.5e498158</dt><dd>{section}</dd></div>} {section && <div><dt>i18n:govoplan-docs.section.5e498158</dt><dd>{section}</dd></div>}
{showTechnical && route && <div><dt>i18n:govoplan-docs.route.4999528e</dt><dd>{route}</dd></div>} {showTechnical && route && <div><dt>i18n:govoplan-docs.route.4999528e</dt><dd>{route}</dd></div>}
</dl> </DescriptionList>
} }
{!!fields.length && <ReferenceFieldTable id={`${topicAnchorId(topic)}-fields`} fields={fields} showTechnical={showTechnical} />} {!!fields.length && <ReferenceFieldTable id={`${topicAnchorId(topic)}-fields`} fields={fields} showTechnical={showTechnical} />}
<RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} /> <RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} />
@@ -604,14 +646,14 @@ function UnavailableDocumentationReason({ topic }: { topic: DocsDocumentationTop
<div className="docs-unavailable-reason"> <div className="docs-unavailable-reason">
<p className="muted">{topic.reason}</p> <p className="muted">{topic.reason}</p>
{!!rows.length && {!!rows.length &&
<dl className="detail-list compact"> <DescriptionList variant="inline" density="compact">
{rows.map(([label, values]) => {rows.map(([label, values]) =>
<div key={String(label)}> <div key={String(label)}>
<dt>{label}</dt> <dt>{label}</dt>
<dd>{(values as string[]).join(", ")}</dd> <dd>{(values as string[]).join(", ")}</dd>
</div> </div>
)} )}
</dl> </DescriptionList>
} }
</div> </div>
); );
@@ -623,8 +665,6 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
const workflowTopics = context?.topic_groups.workflow ?? []; const workflowTopics = context?.topic_groups.workflow ?? [];
const referenceTopics = context?.topic_groups.reference ?? []; const referenceTopics = context?.topic_groups.reference ?? [];
const patternTopics = context?.topic_groups.pattern ?? []; 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 workingTopics = uniqueTopics([...patternTopics, ...workflowTopics]);
const troubleshootingTopics = allTopics.filter((topic) => !topic.active || topic.layer === "evidence"); const troubleshootingTopics = allTopics.filter((topic) => !topic.active || topic.layer === "evidence");
const basicsNode = categoryTreeNode( const basicsNode = categoryTreeNode(
@@ -666,15 +706,15 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
), ),
{ {
id: "tree:modules", id: "tree:modules",
title: "i18n:govoplan-docs.modules.04e9462c", title: "i18n:govoplan-docs.topic_areas",
page: { page: {
id: "modules:overview", id: "modules:overview",
title: "i18n:govoplan-docs.modules.04e9462c", title: "i18n:govoplan-docs.topic_areas",
kind: "topic-list", kind: "topic-list",
topics: moduleTopics, topics: allTopics,
emptyText: "i18n:govoplan-docs.no_module_topics_found.0cbbdc9b" emptyText: "i18n:govoplan-docs.no_module_topics_found.0cbbdc9b"
}, },
children: moduleTreeNodes(moduleTopics, context) children: moduleTreeNodes(allTopics, context)
} }
]; ];
if (adminDocs) { if (adminDocs) {
@@ -689,7 +729,7 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
emptyText: "i18n:govoplan-docs.no_administration_topics_found.6213dff2" emptyText: "i18n:govoplan-docs.no_administration_topics_found.6213dff2"
}, },
children: [ children: [
...referenceTopics.map(topicTreeNode), ...referenceTopics.map((topic) => topicTreeNode(topic)),
{ {
id: "tree:administration:technical-reference", id: "tree:administration:technical-reference",
title: "i18n:govoplan-docs.technical_reference.f271430d", title: "i18n:govoplan-docs.technical_reference.f271430d",
@@ -711,7 +751,7 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
"i18n:govoplan-docs.no_troubleshooting_topics_found.8c275468" "i18n:govoplan-docs.no_troubleshooting_topics_found.8c275468"
)); ));
} }
return nodes; return qualifyTreeOccurrences(nodes);
} }
function categoryTreeNode(id: string, title: string, topics: DocsDocumentationTopic[], emptyText: string): DocsTreeNode { function categoryTreeNode(id: string, title: string, topics: DocsDocumentationTopic[], emptyText: string): DocsTreeNode {
@@ -751,9 +791,11 @@ function topicTreeNode(topic: DocsDocumentationTopic): DocsTreeNode {
function moduleTreeNodes(topics: DocsDocumentationTopic[], context: DocsContext | null): DocsTreeNode[] { function moduleTreeNodes(topics: DocsDocumentationTopic[], context: DocsContext | null): DocsTreeNode[] {
const topicsByModule = new Map<string, DocsDocumentationTopic[]>(); const topicsByModule = new Map<string, DocsDocumentationTopic[]>();
for (const topic of topics) { 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); moduleTopics.push(topic);
topicsByModule.set(topic.source_module_id, moduleTopics); topicsByModule.set(areaId, moduleTopics);
}
} }
const configuredModules = context?.layers.configured.modules ?? []; const configuredModules = context?.layers.configured.modules ?? [];
const moduleOrder = new Map(configuredModules.map((module, index) => [module.id, index])); const moduleOrder = new Map(configuredModules.map((module, index) => [module.id, index]));
@@ -817,23 +859,12 @@ function defaultExpandedNodeIds(nodes: DocsTreeNode[]): string[] {
return nodes.filter((node) => node.children.length).map((node) => node.id); return nodes.filter((node) => node.children.length).map((node) => node.id);
} }
function ancestorNodeIdsForPage(nodes: DocsTreeNode[], pageId: string, ancestors: string[] = []): string[] { function filterTopicTree(nodes: DocsTreeNode[], matchingIds: Set<string>): DocsTreeNode[] {
for (const node of nodes) { return nodes.flatMap((node) => {
if (node.page.id === pageId) return ancestors; const children = filterTopicTree(node.children, matchingIds);
const match = ancestorNodeIdsForPage(node.children, pageId, [...ancestors, node.id]); const matches = node.page.kind === "topic" && matchingIds.has(node.page.topic.id);
if (match.length) return match; return matches || children.length ? [{ ...node, children }] : [];
} });
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 allDocumentationTopics(context: DocsContext | null): DocsDocumentationTopic[] { function allDocumentationTopics(context: DocsContext | null): DocsDocumentationTopic[] {
@@ -857,9 +888,12 @@ function selectedPageFromSearch(search: string, pages: DocsPageNode[]): DocsPage
if (requested) return pages.find((page) => page.id === requested) ?? pages[0]; if (requested) return pages.find((page) => page.id === requested) ?? pages[0];
const helpContext = params.get("context") || ""; const helpContext = params.get("context") || "";
if (helpContext) { if (helpContext) {
const exact = pages.find((page) => page.kind === "topic" && metadataList(page.topic.metadata, "help_contexts").includes(helpContext)); const fallbackContext = params.get("fallback_context") || "";
for (const contextId of [helpContext, fallbackContext].filter(Boolean)) {
const exact = pages.find((page) => page.kind === "topic" && metadataList(page.topic.metadata, "help_contexts").includes(contextId));
if (exact) return exact; if (exact) return exact;
const moduleId = helpContextModuleId(helpContext); }
const moduleId = params.get("module") || helpContextModuleId(fallbackContext || helpContext);
const moduleTopic = pages.find((page) => page.kind === "topic" && page.topic.source_module_id === moduleId); const moduleTopic = pages.find((page) => page.kind === "topic" && page.topic.source_module_id === moduleId);
if (moduleTopic) return moduleTopic; if (moduleTopic) return moduleTopic;
} }
@@ -998,7 +1032,7 @@ function RouteTable({ routes, emptyText }: { routes: DocsRoute[]; emptyText: str
function PermissionList({ permissions }: { permissions: Array<{ scope: string; label: string; category: string }> }) { function PermissionList({ permissions }: { permissions: Array<{ scope: string; label: string; category: string }> }) {
if (!permissions.length) return <p className="muted">i18n:govoplan-docs.no_granted_platform_permissions_found.36010898</p>; if (!permissions.length) return <p className="muted">i18n:govoplan-docs.no_granted_platform_permissions_found.36010898</p>;
return ( return (
<dl className="detail-list"> <DescriptionList variant="inline">
{permissions.slice(0, 24).map((permission) => {permissions.slice(0, 24).map((permission) =>
<div key={permission.scope}> <div key={permission.scope}>
<dt>{permission.category}</dt> <dt>{permission.category}</dt>
@@ -1006,7 +1040,7 @@ function PermissionList({ permissions }: { permissions: Array<{ scope: string; l
</div> </div>
)} )}
{permissions.length > 24 && <div><dt>i18n:govoplan-docs.more.4bab2d8f</dt><dd>{permissions.length - 24} i18n:govoplan-docs.additional_permissions.8042cb01</dd></div>} {permissions.length > 24 && <div><dt>i18n:govoplan-docs.more.4bab2d8f</dt><dd>{permissions.length - 24} i18n:govoplan-docs.additional_permissions.8042cb01</dd></div>}
</dl> </DescriptionList>
); );
} }
@@ -1044,7 +1078,7 @@ function EvidenceList({
return ( return (
<> <>
{sourceError && <DismissibleAlert tone="danger" resetKey={sourceError}>{sourceError}</DismissibleAlert>} {sourceError && <DismissibleAlert tone="danger" resetKey={sourceError}>{sourceError}</DismissibleAlert>}
<dl className="detail-list"> <DescriptionList variant="inline">
{modules.map((item) => {modules.map((item) =>
<div key={`${item.source_module_id}-${item.module_id}`}> <div key={`${item.source_module_id}-${item.module_id}`}>
<dt><StatusBadge status={item.status === "installed" ? "success" : "inactive"} label={item.status} /></dt> <dt><StatusBadge status={item.status === "installed" ? "success" : "inactive"} label={item.status} /></dt>
@@ -1073,7 +1107,7 @@ function EvidenceList({
</dd> </dd>
</div> </div>
)} )}
</dl> </DescriptionList>
<Dialog <Dialog
open={selected !== null} open={selected !== null}
title={selected?.label ?? "i18n:govoplan-docs.source_details.6dc79c75"} title={selected?.label ?? "i18n:govoplan-docs.source_details.6dc79c75"}
@@ -1111,14 +1145,14 @@ function SourceInspectionGroup({ title, values }: { title: string; values: Array
return ( return (
<section> <section>
<h3>{title}</h3> <h3>{title}</h3>
<dl className="detail-list"> <DescriptionList variant="inline">
{values.map(([key, value]) => {values.map(([key, value]) =>
<div key={key}> <div key={key}>
<dt>{humanizeSourceKey(key)}</dt> <dt>{humanizeSourceKey(key)}</dt>
<dd>{formatSourceValue(value)}</dd> <dd>{formatSourceValue(value)}</dd>
</div> </div>
)} )}
</dl> </DescriptionList>
</section> </section>
); );
} }
@@ -1131,7 +1165,7 @@ function compactSourceRecord(value: object): Array<[string, unknown]> {
} }
function humanizeSourceKey(value: string): string { function humanizeSourceKey(value: string): string {
const words = value.replaceAll("_", " "); const words = value.replace(/_/g, " ");
return words.charAt(0).toUpperCase() + words.slice(1); return words.charAt(0).toUpperCase() + words.slice(1);
} }
@@ -0,0 +1,317 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router";
import { Archive, Check, Plus } from "lucide-react";
import {
Button,
Card,
ContentGrid,
DismissibleAlert,
FormField,
FormGrid,
PageActionBar,
PageLayout,
StatePanel,
StatusBadge,
WorkspaceLayout,
adminErrorMessage,
usePlatformLanguage,
useUnsavedDraftGuard,
type ApiSettings
} from "@govoplan/core-webui";
import {
createSemanticEntry,
fetchSemanticEntries,
fetchSemanticSubjects,
transitionSemanticEntry,
updateSemanticEntry,
type SemanticContent,
type SemanticEntry,
type SemanticSubjectDescriptor
} from "../../api/docs";
const emptyContent = (): SemanticContent => ({
title: "",
summary: "",
body: "",
meaning: "",
intended_use: "",
non_intended_use: "",
examples: [],
owner_account_id: null,
steward_account_id: null,
audience: [],
classification: "internal",
links: []
});
export default function SemanticDocumentationPage({ settings }: { settings: ApiSettings }) {
const { language } = usePlatformLanguage();
const [searchParams, setSearchParams] = useSearchParams();
const [subjects, setSubjects] = useState<SemanticSubjectDescriptor[]>([]);
const [entries, setEntries] = useState<SemanticEntry[]>([]);
const [selectedSubject, setSelectedSubject] = useState<SemanticSubjectDescriptor | null>(null);
const [selectedEntry, setSelectedEntry] = useState<SemanticEntry | null>(null);
const [content, setContent] = useState<SemanticContent>(emptyContent);
const [locale, setLocale] = useState(searchParams.get("locale") || language || "de");
const [reason, setReason] = useState("Document configured meaning");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const baseline = selectedEntry?.content ?? emptyContent();
const dirty = Boolean(selectedSubject) && (
JSON.stringify(content) !== JSON.stringify(baseline)
|| (!selectedEntry && Boolean(content.title || content.body || content.meaning))
);
const valid = Boolean(selectedSubject && content.title.trim() && reason.trim());
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: resetDraft
});
async function load() {
setLoading(true);
setError("");
try {
const [nextSubjects, nextEntries] = await Promise.all([
fetchSemanticSubjects(settings),
fetchSemanticEntries(settings, locale, true)
]);
setSubjects(nextSubjects);
setEntries(nextEntries);
const requestedEntry = searchParams.get("entryId");
const entry = nextEntries.find((item) => item.id === requestedEntry) ?? selectedEntry;
if (entry) {
selectExisting(entry, nextSubjects);
} else {
const requestedSubject = nextSubjects.find((item) => (
item.reference.module_id === searchParams.get("module")
&& item.reference.subject_kind === searchParams.get("subjectKind")
&& item.reference.subject_id === searchParams.get("subjectId")
&& (item.route_anchor ?? "") === (searchParams.get("routeAnchor") ?? "")
));
if (requestedSubject) selectNew(requestedSubject, nextEntries);
}
} catch (reason) {
setError(adminErrorMessage(reason));
} finally {
setLoading(false);
}
}
useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
async function save(): Promise<boolean> {
if (!selectedSubject || !valid) return false;
setSaving(true);
setError("");
try {
const next = selectedEntry
? await updateSemanticEntry(settings, selectedEntry.id, {
expected_revision: selectedEntry.current_revision,
content,
change_reason: reason
})
: await createSemanticEntry(settings, {
subject: selectedSubject.reference,
locale,
content,
change_reason: reason
});
setSelectedEntry(next);
setContent(next.content);
setEntries((current) => [next, ...current.filter((item) => item.id !== next.id)]);
setSearchParams({ entryId: next.id, locale: next.locale }, { replace: true });
setSuccess(`Saved immutable revision ${next.current_revision}.`);
return true;
} catch (reason) {
setError(adminErrorMessage(reason));
return false;
} finally {
setSaving(false);
}
}
async function transition(kind: "publish" | "retire") {
if (!selectedEntry || dirty) return;
setSaving(true);
setError("");
try {
const next = await transitionSemanticEntry(
settings,
selectedEntry.id,
kind,
selectedEntry.current_revision,
reason
);
setSelectedEntry(next);
setEntries((current) => [next, ...current.filter((item) => item.id !== next.id)]);
setSuccess(kind === "publish" ? "Semantic documentation published." : "Semantic documentation retired.");
} catch (reason) {
setError(adminErrorMessage(reason));
} finally {
setSaving(false);
}
}
function selectExisting(entry: SemanticEntry, availableSubjects = subjects) {
const subject = availableSubjects.find((item) => sameSubject(item.reference, entry.subject));
setSelectedEntry(entry);
setSelectedSubject(subject ?? descriptorFromEntry(entry));
setContent(entry.content_redacted ? emptyContent() : entry.content);
setLocale(entry.locale);
setSearchParams({ entryId: entry.id, locale: entry.locale }, { replace: true });
}
function selectNew(subject: SemanticSubjectDescriptor, availableEntries = entries) {
const existing = availableEntries.find((entry) => entry.locale === locale && sameSubject(entry.subject, subject.reference));
if (existing) {
selectExisting(existing);
return;
}
setSelectedSubject(subject);
setSelectedEntry(null);
setContent({ ...emptyContent(), title: localized(subject.labels, locale) });
setSearchParams({ locale }, { replace: true });
}
function resetDraft() {
setContent(selectedEntry?.content ?? emptyContent());
}
const sourceStatus = selectedEntry?.subject_resolution.availability ?? "available";
const sortedEntries = useMemo(
() => [...entries].sort((left, right) => left.content.title.localeCompare(right.content.title)),
[entries]
);
return (
<WorkspaceLayout
className="module-workspace docs-semantic-workspace"
primaryLabel="Semantic documentation"
contentLabel="Semantic documentation editor"
primary={(
<aside className="section-sidebar" aria-label="Semantic documentation subjects">
<div className="section-title">Documented subjects</div>
<nav className="section-nav">
{sortedEntries.map((entry) => (
<button
type="button"
key={entry.id}
className={selectedEntry?.id === entry.id ? "active" : ""}
onClick={() => selectExisting(entry)}
>
<span>{entry.content.title}</span>
<small>{entry.subject.subject_kind} · {entry.locale}</small>
</button>
))}
</nav>
<div className="section-title">Available configured subjects</div>
<nav className="section-nav">
{subjects.map((subject) => (
<button type="button" key={subjectKey(subject)} onClick={() => selectNew(subject)}>
<Plus size={14} aria-hidden="true" />
<span>{localized(subject.labels, locale)}</span>
<small>{subject.reference.module_id} · {subject.reference.subject_kind}</small>
</button>
))}
</nav>
</aside>
)}
>
<PageLayout
archetype="editor"
title="Semantic documentation"
description="Explain the tenant-specific meaning and intended use of stable configured subjects."
loading={loading}
error={error}
success={success}
actions={(
<PageActionBar
variant="editor"
state={saving ? "saving" : !valid && dirty ? "invalid" : dirty ? "dirty" : "clean"}
refreshable
reloadAction={{ onReload: () => void load(), loading }}
primaryActions={selectedEntry?.lifecycle_state === "draft" && !dirty ? (
<Button helpContextId="docs.semantic-documentation.publish" helpModuleId="docs" onClick={() => void transition("publish")}>
<Check size={16} /> Publish
</Button>
) : null}
destructiveActions={selectedEntry && !["retired", "superseded"].includes(selectedEntry.lifecycle_state) && !dirty ? (
<Button variant="danger" onClick={() => void transition("retire")}>
<Archive size={16} /> Retire
</Button>
) : null}
discardAction={{ label: "Discard", onClick: resetDraft }}
saveAction={{ label: selectedEntry ? "Save revision" : "Create entry", onClick: () => void save() }}
/>
)}
>
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
{!selectedSubject ? (
<StatePanel size="fill" title="Select a configured subject" description="Choose an existing entry or an authorized subject from an installed module." />
) : (
<ContentGrid columns={2} collapseAt="workspace">
<Card
title={localized(selectedSubject.labels, locale)}
actions={<><StatusBadge status={selectedEntry?.lifecycle_state ?? "draft"} /><StatusBadge status={sourceStatus} /></>}
>
<FormGrid columns={1}>
<FormField label="Locale" help="Entries are locale-specific. Visible fallback is applied at read time.">
<input value={locale} onChange={(event) => setLocale(event.target.value)} maxLength={20} />
</FormField>
<FormField label="Title"><input value={content.title} onChange={(event) => setContent({ ...content, title: event.target.value })} /></FormField>
<FormField label="Summary"><textarea value={content.summary} onChange={(event) => setContent({ ...content, summary: event.target.value })} rows={3} /></FormField>
<FormField label="Meaning"><textarea value={content.meaning} onChange={(event) => setContent({ ...content, meaning: event.target.value })} rows={5} /></FormField>
<FormField label="Body"><textarea value={content.body} onChange={(event) => setContent({ ...content, body: event.target.value })} rows={10} /></FormField>
</FormGrid>
</Card>
<Card title="Governance and use">
<FormGrid columns={1}>
<FormField label="Intended use"><textarea value={content.intended_use} onChange={(event) => setContent({ ...content, intended_use: event.target.value })} rows={5} /></FormField>
<FormField label="Not intended for"><textarea value={content.non_intended_use} onChange={(event) => setContent({ ...content, non_intended_use: event.target.value })} rows={5} /></FormField>
<FormField label="Classification">
<select value={content.classification} onChange={(event) => setContent({ ...content, classification: event.target.value as SemanticContent["classification"] })}>
<option value="internal">Internal</option>
<option value="restricted">Restricted</option>
</select>
</FormField>
<FormField label="Audience selectors" help="One per line: authenticated, account:, group:, role:, function:, or scope:.">
<textarea value={content.audience.join("\n")} onChange={(event) => setContent({ ...content, audience: event.target.value.split("\n").map((item) => item.trim()).filter(Boolean) })} rows={5} />
</FormField>
<FormField label="Change reason"><input value={reason} onChange={(event) => setReason(event.target.value)} maxLength={1000} /></FormField>
</FormGrid>
</Card>
</ContentGrid>
)}
</PageLayout>
</WorkspaceLayout>
);
}
function sameSubject(left: SemanticEntry["subject"], right: SemanticEntry["subject"]): boolean {
return left.module_id === right.module_id
&& left.subject_kind === right.subject_kind
&& left.subject_id === right.subject_id
&& left.anchor?.kind === right.anchor?.kind
&& left.anchor?.id === right.anchor?.id;
}
function subjectKey(subject: SemanticSubjectDescriptor): string {
const reference = subject.reference;
return [reference.module_id, reference.subject_kind, reference.subject_id, reference.anchor?.kind, reference.anchor?.id].filter(Boolean).join(":");
}
function localized(values: Record<string, string>, locale: string): string {
return values[locale] ?? values[locale.split("-")[0]] ?? values.de ?? values.en ?? Object.values(values)[0] ?? "Configured subject";
}
function descriptorFromEntry(entry: SemanticEntry): SemanticSubjectDescriptor {
return {
reference: entry.subject,
labels: { [entry.locale]: entry.content.title },
descriptions: {}
};
}
+73
View File
@@ -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 [];
}
+16
View File
@@ -2,6 +2,14 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = { export const generatedTranslations: PlatformTranslations = {
"en": { "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.additional_permissions.8042cb01": "additional permissions",
"i18n:govoplan-docs.about_govoplan.6b2d7127": "About GovOPlaN", "i18n:govoplan-docs.about_govoplan.6b2d7127": "About GovOPlaN",
"i18n:govoplan-docs.admin_docs.bf504a56": "Admin docs", "i18n:govoplan-docs.admin_docs.bf504a56": "Admin docs",
@@ -104,6 +112,14 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-docs.your_documentation.8a4cd9a3": "Your documentation" "i18n:govoplan-docs.your_documentation.8a4cd9a3": "Your documentation"
}, },
"de": { "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.additional_permissions.8042cb01": "additional permissions",
"i18n:govoplan-docs.about_govoplan.6b2d7127": "Über GovOPlaN", "i18n:govoplan-docs.about_govoplan.6b2d7127": "Über GovOPlaN",
"i18n:govoplan-docs.admin_docs.bf504a56": "Administrationsdokumentation", "i18n:govoplan-docs.admin_docs.bf504a56": "Administrationsdokumentation",
+5 -2
View File
@@ -3,8 +3,10 @@ import type { PlatformWebModule } from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations"; import { generatedTranslations } from "./i18n/generatedTranslations";
const DocsPage = lazy(() => import("./features/docs/DocsPage")); const DocsPage = lazy(() => import("./features/docs/DocsPage"));
const SemanticDocumentationPage = lazy(() => import("./features/docs/SemanticDocumentationPage"));
const docsReadScopes = ["docs:documentation:read", "docs:documentation:admin", "system:settings:read", "admin:settings:read"]; const docsReadScopes = ["docs:documentation:read", "docs:documentation:admin", "system:settings:read", "admin:settings:read"];
const semanticEditorScopes = ["docs:semantic:create", "docs:semantic:edit", "docs:semantic:publish", "docs:semantic:supersede", "docs:semantic:retire"];
const translations = { const translations = {
en: generatedTranslations.en, en: generatedTranslations.en,
@@ -14,13 +16,14 @@ const translations = {
export const docsModule: PlatformWebModule = { export const docsModule: PlatformWebModule = {
id: "docs", id: "docs",
label: "i18n:govoplan-docs.docs.68a41942", label: "i18n:govoplan-docs.docs.68a41942",
version: "0.1.10", version: "0.1.19",
dependencies: ["access"], dependencies: ["access"],
optionalDependencies: ["policy", "audit", "ops", "workflow", "search"], optionalDependencies: ["policy", "audit", "ops", "workflow", "search"],
translations, translations,
navItems: [{ to: "/docs", label: "i18n:govoplan-docs.docs.68a41942", iconName: "reports", anyOf: docsReadScopes, order: 880 }], navItems: [{ to: "/docs", label: "i18n:govoplan-docs.docs.68a41942", iconName: "reports", anyOf: docsReadScopes, order: 880 }],
routes: [ routes: [
{ path: "/docs", anyOf: docsReadScopes, order: 880, render: ({ settings }) => createElement(DocsPage, { settings }) }] { path: "/docs", anyOf: docsReadScopes, order: 880, render: ({ settings }) => createElement(DocsPage, { settings }) },
{ path: "/docs/semantic", anyOf: semanticEditorScopes, order: 881, render: ({ settings }) => createElement(SemanticDocumentationPage, { settings }) }]
}; };
+32
View File
@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"preserveSymlinks": true,
"baseUrl": ".",
"paths": {
"@govoplan/core-webui": ["../../govoplan-core/webui/src/index.ts"],
"@govoplan/core-webui/*": ["../../govoplan-core/webui/src/*"],
"lucide-react": ["../../govoplan-core/webui/node_modules/lucide-react/dist/lucide-react.d.ts"],
"react": ["../../govoplan-core/webui/node_modules/@types/react/index.d.ts"],
"react/jsx-runtime": ["../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"],
"react-router": ["../../govoplan-core/webui/node_modules/react-router/dist/production/index.d.ts"]
}
},
"include": ["src", "../../govoplan-core/webui/src/vite-env.d.ts"]
}