Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9055f3437f | ||
|
|
92dc91885b | ||
|
|
d6560b343a | ||
|
|
7984573c81 | ||
|
|
d6db344d81 | ||
|
|
77eb7339e6 | ||
|
|
281f35310b | ||
|
|
e05d5b4a54 | ||
|
|
0b4e7b6391 | ||
|
|
36c0afadd4 | ||
|
|
b5d3983844 | ||
|
|
30ea95854b | ||
|
|
f14692a15e | ||
|
|
8cbab781bf | ||
|
|
2d18ecc9b6 | ||
|
|
1f50505305 | ||
|
|
6c9ff55d27 | ||
|
|
64df8b0032 | ||
|
|
1a9e83ce0c | ||
|
|
abe2f78fc3 | ||
|
|
6607a3eeae | ||
|
|
0d8a49c8af | ||
|
|
c1ea7bb8f1 | ||
|
|
e2bf104c53 | ||
|
|
af18f072d8 | ||
|
|
da15645dba | ||
|
|
511930ea56 | ||
|
|
4d36ab2747 | ||
|
|
94c000f351 | ||
|
|
be52b716ca | ||
|
|
901baf8352 | ||
|
|
7cdd1e1c78 | ||
|
|
e9f3106657 | ||
|
|
9dab1c842a | ||
|
|
2f912ff619 | ||
|
|
a08bc7c204 | ||
|
|
bbe3c7d163 | ||
|
|
0f41e02428 | ||
|
|
0c9997593c | ||
|
|
b4ef4cd038 | ||
|
|
9746cc8974 | ||
|
|
f2ade1d624 |
@@ -0,0 +1,270 @@
|
||||
name: Module Package Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing protected version tag to publish
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-packages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Select and validate protected release tag
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||
esac
|
||||
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||
echo "Release tag is not contained in main" >&2
|
||||
exit 1
|
||||
}
|
||||
git checkout --detach "$tag"
|
||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||
- name: Validate package versions
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
expected = tag.removeprefix("v")
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
if project.get("version") != expected:
|
||||
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||
webui = Path("webui/package.json")
|
||||
if webui.is_file():
|
||||
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||
if package.get("version") != expected:
|
||||
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||
release = Path("webui/package.release.json")
|
||||
if release.is_file():
|
||||
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||
if (
|
||||
release_package.get("name") != package.get("name")
|
||||
or release_package.get("version") != expected
|
||||
):
|
||||
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||
PY
|
||||
- name: Build immutable package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||
rm -rf dist .package-webui
|
||||
python -m build --wheel --outdir dist
|
||||
python -m twine check dist/*.whl
|
||||
if [[ -f webui/package.json ]]; then
|
||||
mkdir .package-webui
|
||||
cp -a webui/. .package-webui/
|
||||
rm -rf .package-webui/node_modules .package-webui/dist
|
||||
if [[ -f .package-webui/package.release.json ]]; then
|
||||
cp .package-webui/package.release.json .package-webui/package.json
|
||||
fi
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = ".package-webui/package.json";
|
||||
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||
for (const group of groups) {
|
||||
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||
if (!name.startsWith("@govoplan/")) continue;
|
||||
if (typeof specifier !== "string") {
|
||||
throw new Error(`${group}.${name} must use a string version`);
|
||||
}
|
||||
const packageSlug = name.slice("@govoplan/".length);
|
||||
if (!packageSlug.endsWith("-webui")) {
|
||||
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||
}
|
||||
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const gitTag = specifier.match(
|
||||
new RegExp(
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
),
|
||||
);
|
||||
if (gitTag) {
|
||||
packageJson[group][name] = gitTag[1];
|
||||
continue;
|
||||
}
|
||||
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||
throw new Error(
|
||||
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
delete packageJson.private;
|
||||
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
npm pkg delete private --prefix .package-webui
|
||||
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||
fi
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
artifacts = []
|
||||
for path in sorted(Path("dist").iterdir()):
|
||||
if path.suffix not in {".whl", ".tgz"}:
|
||||
continue
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||
payload = {
|
||||
"schema_version": "1",
|
||||
"repository": os.environ["GITEA_REPOSITORY"],
|
||||
"tag": os.environ["RELEASE_TAG"],
|
||||
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
Path("dist/package-artifacts.json").write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
- name: Retain package hash evidence
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||
with:
|
||||
name: module-packages-${{ gitea.ref_name }}
|
||||
path: dist/package-artifacts.json
|
||||
- name: Check immutable registry state
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||
token = os.environ["PACKAGE_TOKEN"]
|
||||
|
||||
def should_publish(kind, name, version, path):
|
||||
package_url = "/".join(
|
||||
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||
)
|
||||
request = Request(
|
||||
package_url,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
files = json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(f"{kind} package {name}=={version} is not published yet")
|
||||
return True
|
||||
raise
|
||||
if not isinstance(files, list) or len(files) != 1:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||
)
|
||||
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if files[0].get("sha256") != expected_sha256:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||
)
|
||||
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||
return False
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
wheels = tuple(Path("dist").glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise SystemExit("release build must contain exactly one wheel")
|
||||
publish_pypi = should_publish(
|
||||
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||
)
|
||||
|
||||
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||
if len(tarballs) > 1:
|
||||
raise SystemExit("release build must contain at most one npm package")
|
||||
publish_npm = False
|
||||
if tarballs:
|
||||
webui = json.loads(
|
||||
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
publish_npm = should_publish(
|
||||
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||
)
|
||||
|
||||
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||
PY
|
||||
- name: Publish wheel and WebUI package
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_USERNAME"
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||
python -m twine upload --non-interactive \
|
||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||
dist/*.whl
|
||||
else
|
||||
echo "Exact wheel is already present; skipping immutable retry."
|
||||
fi
|
||||
shopt -s nullglob
|
||||
webui_packages=(dist/*.tgz)
|
||||
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||
npmrc="$(mktemp)"
|
||||
trap 'rm -f "$npmrc"' EXIT
|
||||
chmod 600 "$npmrc"
|
||||
printf '%s\n' \
|
||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||
> "$npmrc"
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||
--ignore-scripts --access public \
|
||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||
elif (( ${#webui_packages[@]} )); then
|
||||
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||
fi
|
||||
@@ -27,5 +27,7 @@ tools/checks/check-focused.sh
|
||||
- Keep documentation-layer behavior in this module, not core.
|
||||
- Read configuration, module manifests, routes, permissions, and capability metadata through core contracts.
|
||||
- Do not import feature-module internals. Modules should contribute documentation metadata through manifests, capabilities, generated docs, or typed DTOs.
|
||||
- Treat documentation as part of every behavior change. Update the owning module's manifest-driven `DocumentationTopic` contributions for each affected user and administrator workflow, setting, permission, limitation, and operational consequence.
|
||||
- Require every module manifest to provide a static user and administrator documentation baseline, even when configured-state details come from `documentation_providers`. Validate the complete workspace with `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py`.
|
||||
- Prefer role-aware and configuration-aware documentation over global manuals.
|
||||
- Keep active backlog state in Gitea issues; keep durable context in repository docs and synced wiki pages.
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
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.
|
||||
|
||||
## Ownership
|
||||
@@ -22,6 +24,14 @@ This repository owns:
|
||||
|
||||
Core owns module discovery, configuration package loading, route registry, RBAC evaluation, capability registry, and shared WebUI shell behavior.
|
||||
|
||||
While Docs is installed, its managed `docs_reader` tenant role is an automatic
|
||||
authenticated-member baseline. Access derives its narrow read grant from the
|
||||
active Docs manifest without per-user assignments or authorization-time writes,
|
||||
so ordinary users can open their configured handbook without an administrator
|
||||
assigning documentation access one account at a time.
|
||||
Administrative documentation remains separately protected by
|
||||
`docs:documentation:admin` or the applicable administration scope.
|
||||
|
||||
## Documentation model
|
||||
|
||||
The docs module should render documentation in three layers:
|
||||
@@ -47,8 +57,92 @@ Frontend package:
|
||||
|
||||
Platform module manifests, configuration packages, release catalogs, and governance rules are documented in `govoplan-core/docs/`.
|
||||
|
||||
Every module manifest must contribute a static documentation baseline for both
|
||||
the `user` and `admin` projections through `ModuleManifest.documentation`.
|
||||
Runtime providers may add actor- and configuration-specific detail, but they do
|
||||
not replace that baseline. A behavior change is complete only when the owning
|
||||
module updates the affected workflows, settings, permissions, limitations, and
|
||||
operational consequences. Validate workspace coverage with:
|
||||
|
||||
```sh
|
||||
cd /mnt/DATA/git/govoplan
|
||||
./tools/checks/check-manifest-shapes.py
|
||||
```
|
||||
|
||||
Feature content remains in the owning module. Docs indexes and renders the
|
||||
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
|
||||
provider implementation:
|
||||
|
||||
```python
|
||||
ModuleManifest(
|
||||
capability_factories={"example.lookup": build_lookup},
|
||||
capability_documentation={
|
||||
"example.lookup": CapabilityDocumentation(
|
||||
label="Example lookup",
|
||||
summary="Resolves records through the versioned lookup contract.",
|
||||
contract_version="2",
|
||||
stability="stable",
|
||||
audience=("module_admin",),
|
||||
),
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Docs also projects the configured module release catalog and configuration
|
||||
package catalog through the public Core catalog contracts. Catalog trust,
|
||||
freshness, provenance, descriptions, and package requirements remain visible
|
||||
as typed evidence; provider objects, credentials, and secret configuration are
|
||||
never imported into the Docs WebUI.
|
||||
|
||||
Module-owned external-provider runtime state is also projected through the
|
||||
Core contract. Administrative documentation may show sanitized binding-level
|
||||
state; ordinary-user documentation receives only aggregate configured, active,
|
||||
authority, health, freshness, conflict, recovery, and observation fields.
|
||||
URLs, credential references, provider error text, and binding identifiers are
|
||||
excluded from the user projection.
|
||||
|
||||
## Concept documents
|
||||
|
||||
- `docs/DOCUMENTATION_LAYER_CONCEPT.md` defines the configured, available, and evidence documentation model.
|
||||
- `docs/INSTANCE_AWARE_DOCUMENTATION.md` defines the runtime context and condition model.
|
||||
- `docs/DOCUMENTATION_EXPERIENCE_CONCEPT.md` defines the workflow, structure/reference, and design-pattern documentation experience.
|
||||
- Core's `DocumentationHelpLink` is the stable route/field/blocker entry point
|
||||
into a topic or help context, with hosted fallback when this module is not
|
||||
active.
|
||||
|
||||
@@ -26,7 +26,7 @@ It should not start from a full product manual. The current tenant, installed
|
||||
modules, enabled routes, permissions, and configuration decide what is shown as
|
||||
the default path.
|
||||
|
||||
Tracking issue: `add-ideas/govoplan-docs#15`.
|
||||
Tracking issue: `GovOPlaN/govoplan-docs#15`.
|
||||
|
||||
## Editorial Pillars
|
||||
|
||||
@@ -184,6 +184,15 @@ Each topic should support stable anchors and related-topic links:
|
||||
- pattern topics link to examples and the components that implement them
|
||||
- unavailable topics link to the blocker and the actor who can resolve it
|
||||
|
||||
Core exposes `DocumentationHelpLink` for those entry points. A caller supplies
|
||||
either a stable topic id (`/docs?type=user&topic=...`) or help-context id
|
||||
(`/docs?type=user&context=...`), plus an optional topic anchor. Route help uses
|
||||
the context form; field labels and blocker callouts can use either form. The
|
||||
component opens the configured Help Center when Docs is active and falls back
|
||||
to the hosted documentation site when the optional module is absent. Modules
|
||||
must reference topic/context ids, not reproduce Docs routing or inspect another
|
||||
module's implementation.
|
||||
|
||||
## Source Contract Direction
|
||||
|
||||
The current `DocumentationTopic` contract can carry the first version through
|
||||
@@ -255,6 +264,9 @@ Workflow topics:
|
||||
- keep each step actionable
|
||||
- mention blockers where the user would encounter them
|
||||
- link to field/reference topics instead of repeating field tables
|
||||
- for every user-facing workflow, declare one or more conditions and put
|
||||
`required_scopes` or `any_scopes` on every condition alternative; the release
|
||||
gate rejects an unscoped alternative
|
||||
|
||||
Reference topics:
|
||||
|
||||
|
||||
@@ -93,6 +93,25 @@ register durable topics directly in its `ModuleManifest.documentation` tuple.
|
||||
Use this for stable explanations such as the module purpose, common workflows,
|
||||
policy hierarchy, route meaning, and links to public docs or repository docs.
|
||||
|
||||
Every manifest must retain at least one static topic for each of the `user` and
|
||||
`admin` projections. A shared topic may serve both only when its language and
|
||||
disclosure level are appropriate for both audiences. A module that is still a
|
||||
seed should state that limitation plainly rather than documenting an unfinished
|
||||
screen as available. `documentation_providers` enrich this baseline; they do
|
||||
not replace it because a provider may be unavailable before configuration or
|
||||
database access succeeds.
|
||||
|
||||
Documentation is part of a behavior change's completion criteria. The owning
|
||||
module updates affected workflows, fields and settings, permissions, optional
|
||||
integration behavior, failure or limitation explanations, and operator
|
||||
consequences in the same change. The workspace manifest-shape check enforces
|
||||
the static audience baseline:
|
||||
|
||||
```sh
|
||||
cd /mnt/DATA/git/govoplan
|
||||
./tools/checks/check-manifest-shapes.py
|
||||
```
|
||||
|
||||
When the text depends on active configuration, a module registers a provider in
|
||||
`ModuleManifest.documentation_providers`. The provider receives a
|
||||
`DocumentationContext` with the active registry, principal, settings, and a
|
||||
@@ -100,17 +119,17 @@ database session when available. The module can then describe the effective
|
||||
state without the docs module importing feature internals.
|
||||
|
||||
For example, `govoplan-mail` contributes static documentation for reusable mail
|
||||
profiles and a runtime tenant policy topic. If the effective tenant mail policy
|
||||
limits sending to approved profile ids and disables user, group, and
|
||||
campaign-local profiles, the runtime docs state that users can choose approved
|
||||
profiles but cannot bring arbitrary SMTP or IMAP servers at lower scopes. If the
|
||||
policy allows lower scopes, the same topic explains which scopes may define
|
||||
profiles and whether credential inheritance is locked.
|
||||
profiles and runtime effective-policy topics. The provider evaluates the
|
||||
current actor and scope. A custom-profile task appears only when that actor has
|
||||
the relevant authority and the effective user policy permits it; the task then
|
||||
states the host/domain bounds and separate credential authority needed to
|
||||
complete it.
|
||||
|
||||
Runtime providers must avoid leaking secrets. They should summarize posture,
|
||||
counts, source provenance, and enabling conditions rather than exposing
|
||||
credentials, hostnames, profile ids, or raw policy payloads unless the route is
|
||||
explicitly intended for that level of detail.
|
||||
counts, source provenance, and enabling conditions. A provider may disclose a
|
||||
host or domain pattern only when the actor is authorized and that exact value is
|
||||
necessary to complete the documented task. Credentials, usernames, profile
|
||||
ids, source ids, unrelated topology, and raw policy payloads remain excluded.
|
||||
|
||||
### Conditions And Unlocks
|
||||
|
||||
@@ -123,9 +142,10 @@ Documentation topics can declare conditions:
|
||||
- required scopes or one-of scope alternatives
|
||||
- configuration keys that influence the topic
|
||||
|
||||
The docs API classifies satisfied topics into their requested layer. Unsatisfied
|
||||
topics remain visible as available or evidence documentation when safe, with a
|
||||
reason such as a missing module, capability, or scope.
|
||||
The docs API classifies satisfied topics into their requested layer.
|
||||
Unsatisfied topics can remain visible in the administrator projection when
|
||||
safe. The user projection omits them so a protected body or metadata payload is
|
||||
not disclosed merely because a condition failed.
|
||||
|
||||
Topics can also declare related modules and unlock notes. This lets a module
|
||||
state that additional behavior becomes available when another module is
|
||||
@@ -136,7 +156,7 @@ then to public module documentation for broader examples.
|
||||
|
||||
GovOPlaN has two documentation presentations over the same source model.
|
||||
|
||||
Admin documentation is allowed to be technical. It can expose installed module
|
||||
Admin documentation is a separately authorized technical projection. It can expose installed module
|
||||
ids, route contributions, API paths, permissions, capability names, server
|
||||
configuration keys, policy source chains, migration notes, and operator
|
||||
evidence. This is the right place to explain exactly why a setting is available,
|
||||
@@ -157,8 +177,10 @@ whether the user may choose an approved mail profile or add a local mail server.
|
||||
|
||||
The first presentation rule is:
|
||||
|
||||
- admin docs show technical context and evidence tables
|
||||
- user docs show plain-language topics, examples, limits, and escalation paths
|
||||
- admin docs require `docs:documentation:admin` or the compatible settings
|
||||
administration authority and show technical context and evidence tables
|
||||
- user docs are the default and show only active plain-language topics,
|
||||
examples, limits, and safe escalation paths
|
||||
- both presentations are filtered by installed modules, active configuration,
|
||||
permissions, and safe disclosure rules
|
||||
|
||||
@@ -210,7 +232,50 @@ The UI should make the current context explicit enough to avoid confusion, but i
|
||||
|
||||
## Governance
|
||||
|
||||
Durable context belongs in repository docs and synced wiki pages. Active work belongs in Gitea issues. Runtime documentation should link both where helpful, but it should distinguish stable explanation from changing backlog state.
|
||||
### Source Ownership
|
||||
|
||||
Documentation ownership follows behavior ownership:
|
||||
|
||||
- runtime documentation topics belong to the module that owns the route,
|
||||
policy, workflow, capability, or data model being explained
|
||||
- repository docs belong to the repository that owns the implementation or
|
||||
durable architecture decision
|
||||
- synced Gitea wiki pages are a publication surface for durable context, not a
|
||||
separate source of truth
|
||||
- active Gitea issues are the source of truth for current work state,
|
||||
acceptance criteria, blockers, and triage decisions
|
||||
|
||||
The docs module renders and classifies documentation. It should not become the
|
||||
owner of feature-module explanations, and it should not copy backlog state into
|
||||
runtime documentation as if it were stable product behavior. When runtime docs
|
||||
link to an issue, they must present it as changing work state. When runtime docs
|
||||
link to repository docs or wiki pages, they may present the linked material as
|
||||
durable context if the owning repository treats it that way.
|
||||
|
||||
Durable context belongs in repository docs and synced wiki pages. Active work
|
||||
belongs in Gitea issues. Runtime documentation should link both where helpful,
|
||||
but it should distinguish stable explanation from changing backlog state.
|
||||
|
||||
### Privacy And Permission Boundaries
|
||||
|
||||
Documentation is still a governed interface. Role-aware documentation must never
|
||||
use help text as a side channel for data, configuration, or capability details
|
||||
that the actor could not otherwise see.
|
||||
|
||||
User-facing topics may explain that a feature is unavailable and identify the
|
||||
kind of blocker, such as missing permission, disabled module, locked policy, or
|
||||
administrator configuration. They should not expose internal module ids, raw
|
||||
scope names, policy payloads, hostnames, tenant identifiers, profile ids, or
|
||||
other operational details unless the actor is already allowed to inspect that
|
||||
information.
|
||||
|
||||
Admin-facing topics may expose technical provenance, route ids, API paths,
|
||||
capabilities, configuration keys, policy source chains, and migration notes when
|
||||
the actor has the relevant administrative permission. Even then, runtime
|
||||
providers must summarize secrets and sensitive values as posture, counts, or
|
||||
source provenance. Credentials, tokens, private keys, raw payloads, and
|
||||
person-specific data stay out of documentation responses unless a dedicated
|
||||
audited administration route explicitly provides them.
|
||||
|
||||
Documentation sources should be auditable when they affect compliance, operator procedure, or policy explanation. Configuration-derived documentation should identify the source configuration package or policy source where possible.
|
||||
|
||||
|
||||
@@ -23,9 +23,24 @@ The documentation context is built from:
|
||||
| `available` | Installed features hidden by missing permissions or unmet conditions. |
|
||||
| `evidence` | Hints for unavailable optional modules, missing capabilities, or external evidence sources. |
|
||||
|
||||
Normal user documentation should emphasize `always` and `configured` content.
|
||||
Admin documentation may show all layers plus route, permission, module, and
|
||||
capability diagnostics.
|
||||
Normal user documentation returns only active, safely projected topics. Admin
|
||||
documentation may show all layers plus route, permission, module, and capability
|
||||
diagnostics, but it requires the separate administrative documentation
|
||||
authority.
|
||||
|
||||
The managed `docs_reader` role grants `docs:documentation:read` automatically
|
||||
to every authenticated tenant membership while Docs is installed. This only
|
||||
opens the user projection: each contributed workflow still needs its own
|
||||
module, capability, permission, and runtime-policy conditions so baseline Help
|
||||
Center access does not imply authority to perform every documented task.
|
||||
|
||||
This permission binding is a strict source contract. Every user workflow topic
|
||||
must declare at least one `DocumentationCondition`, and every alternative in
|
||||
its `conditions` tuple must include `required_scopes` or `any_scopes`. Module,
|
||||
capability, or configuration conditions alone are not sufficient because topic
|
||||
conditions are alternatives: one unscoped alternative would bypass all scoped
|
||||
ones. Manifest validation blocks a release containing such a topic, and Docs
|
||||
omits a non-compliant runtime-provider topic from the user projection.
|
||||
|
||||
## Conditions
|
||||
|
||||
@@ -37,7 +52,11 @@ Documentation topics can declare:
|
||||
- required capabilities
|
||||
- required scopes
|
||||
- any-of scope sets
|
||||
- related configuration keys
|
||||
- related configuration keys as technical provenance
|
||||
|
||||
Configuration keys are descriptive metadata; they are not evaluated as
|
||||
conditions. Configuration-dependent guidance belongs in an owning module's
|
||||
runtime provider.
|
||||
|
||||
The API returns both a human-readable `reason` and structured `blockers`:
|
||||
|
||||
@@ -52,10 +71,27 @@ The API returns both a human-readable `reason` and structured `blockers`:
|
||||
}
|
||||
```
|
||||
|
||||
The WebUI must display the reason and, when present, the blocker lists. This
|
||||
prevents dead-end instructions: users see whether a feature is unavailable
|
||||
because a module is missing, a capability is not registered, or their current
|
||||
permissions do not expose it.
|
||||
Raw blockers and scope names are part of the administrator projection. The user
|
||||
projection omits inactive topics instead of returning their protected body,
|
||||
metadata, and blocker identifiers. A module can contribute a separate, safe
|
||||
plain-language escalation topic when users need to know that an administrator
|
||||
must enable something.
|
||||
|
||||
## Task metadata
|
||||
|
||||
Workflow topics can supply `outcome`, `prerequisites`, `steps`, `result`, and
|
||||
`verification`. Runtime providers can additionally supply:
|
||||
|
||||
- `current_configuration`: bounded plain-language facts for this actor and
|
||||
installation;
|
||||
- `limitations`: bounded, actionable caveats; and
|
||||
- `constraints`: records with `id`, `label`, `description`, and optional
|
||||
user-safe `values` needed to complete the task.
|
||||
|
||||
The Docs API validates and whitelists those fields for the user projection.
|
||||
Modules remain responsible for authorization and for preserving policy
|
||||
semantics. Constraints must never contain secrets, internal policy-source ids,
|
||||
unrelated topology, or raw policy payloads.
|
||||
|
||||
## Module Guidance
|
||||
|
||||
@@ -68,3 +104,32 @@ should contribute conditional topics such as:
|
||||
|
||||
The docs module remains the renderer. Feature modules own their subject matter
|
||||
and describe unlocks through manifest metadata.
|
||||
|
||||
## Ownership And Disclosure Rules
|
||||
|
||||
The ownership rule is the same for all documentation layers: the module or
|
||||
repository that owns the behavior owns the durable explanation. The docs module
|
||||
owns classification, filtering, search, route contribution, and rendering. It
|
||||
does not own feature-module business rules, policy semantics, or current issue
|
||||
state.
|
||||
|
||||
Use these sources for these purposes:
|
||||
|
||||
| Source | Purpose |
|
||||
| --- | --- |
|
||||
| Runtime docs providers | Effective, actor-aware explanation of the configured system. |
|
||||
| Repository docs | Durable architecture, module contracts, runbooks, and governance decisions. |
|
||||
| Synced Gitea wiki pages | Published copy of durable documentation for browsing and linking. |
|
||||
| Gitea issues | Active backlog state, acceptance criteria, blockers, and closure evidence. |
|
||||
|
||||
Runtime docs may link to issues when an unavailable feature is planned or a
|
||||
known limitation is relevant, but the UI must label that link as active work.
|
||||
It must not treat open issues as shipped behavior.
|
||||
|
||||
Safe disclosure is evaluated before a topic is returned. User docs contain
|
||||
only active topics, runtime and HTTPS public links, and a bounded metadata
|
||||
whitelist. Admin docs can
|
||||
include route ids, scopes, capability names, configuration keys, and policy
|
||||
provenance only when the actor has permission to inspect those details. Secrets,
|
||||
tokens, private keys, credentials, raw policy payloads, and unrelated personal
|
||||
data are never returned as documentation content.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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`.
|
||||
|
||||
## 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.
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -17,14 +17,14 @@
|
||||
"README.md"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
"vite": "^7.3.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
+6
-3
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-docs"
|
||||
version = "0.1.8"
|
||||
version = "0.1.22"
|
||||
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.8",
|
||||
"govoplan-access>=0.1.8",
|
||||
"govoplan-core>=0.1.37",
|
||||
"govoplan-access>=0.1.18",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@@ -22,3 +22,6 @@ govoplan_docs = ["py.typed"]
|
||||
|
||||
[project.entry-points."govoplan.modules"]
|
||||
docs = "govoplan_docs.backend.manifest:get_manifest"
|
||||
|
||||
[project.scripts]
|
||||
govoplan-docs-export-public = "govoplan_docs.public_export:main"
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.6"
|
||||
__version__ = "0.1.22"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,496 @@
|
||||
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,
|
||||
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)
|
||||
items = [
|
||||
item
|
||||
for entry in selected
|
||||
if (
|
||||
item := semantic_entry_payload(
|
||||
session,
|
||||
_registry(request),
|
||||
principal,
|
||||
entry=entry,
|
||||
editor=editor,
|
||||
requested_locale=locale,
|
||||
)
|
||||
)
|
||||
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"]
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Docs-owned persistence models."""
|
||||
|
||||
from govoplan_docs.backend.db.models import (
|
||||
SemanticDocumentationEntry,
|
||||
SemanticDocumentationRevision,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SemanticDocumentationEntry",
|
||||
"SemanticDocumentationRevision",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -1,20 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationSourceDefinition,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleManifest,
|
||||
ModuleInterfaceProvider,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ModuleArchitectureDeclaration,
|
||||
ModuleArchitectureDocumentation,
|
||||
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_SCOPES = (DOCS_READ_SCOPE, "system:settings:read", "admin:settings:read")
|
||||
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_SCOPE,
|
||||
"system:settings:read",
|
||||
"admin:settings:read",
|
||||
)
|
||||
DOCS_READ_SCOPES = (DOCS_READ_SCOPE, *DOCS_ADMIN_READ_SCOPES)
|
||||
|
||||
ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||
layer="governance_accountability",
|
||||
kind="presentation",
|
||||
maturity="vertical_slice",
|
||||
evidence=(
|
||||
ModuleMaturityEvidence(
|
||||
kind="test",
|
||||
reference="tests/test_docs_context.py",
|
||||
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(
|
||||
kind="documentation",
|
||||
reference="docs/DOCUMENTATION_LAYER_CONCEPT.md",
|
||||
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=(
|
||||
"Architecture declarations are in staged adoption, so undeclared modules remain visible as pending.",
|
||||
),
|
||||
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"),
|
||||
documentation=ModuleArchitectureDocumentation(
|
||||
security=("docs/DOCUMENTATION_LAYER_CONCEPT.md",),
|
||||
operations=("docs/DOCUMENTATION_LAYER_CONCEPT.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
@@ -38,36 +124,299 @@ def _route_factory(context: ModuleContext):
|
||||
return router
|
||||
|
||||
|
||||
def _dsar_provider(_context: ModuleContext) -> DocsDsarProvider:
|
||||
return DocsDsarProvider()
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="docs",
|
||||
name="Docs",
|
||||
version="0.1.8",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("policy", "audit", "ops", "workflow", "search"),
|
||||
version="0.1.22",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
),
|
||||
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=(
|
||||
_permission(
|
||||
DOCS_READ_SCOPE,
|
||||
"View configured documentation",
|
||||
"Read documentation generated from installed modules, visible routes, permissions, and evidence sources.",
|
||||
"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(
|
||||
DOCS_ADMIN_READ_SCOPE,
|
||||
"View administrative documentation",
|
||||
"Read technical module, route, permission, configuration, and evidence documentation.",
|
||||
),
|
||||
),
|
||||
role_templates=(
|
||||
RoleTemplate(
|
||||
slug="docs_reader",
|
||||
name="Documentation reader",
|
||||
description="Read the configured-system documentation browser.",
|
||||
description="Read the configured-system documentation browser. This role is granted automatically to every authenticated tenant member while Docs is installed.",
|
||||
permissions=(DOCS_READ_SCOPE,),
|
||||
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(
|
||||
slug="docs_admin",
|
||||
name="Documentation administrator",
|
||||
description="Read user guidance and the technical configured-system documentation projection.",
|
||||
permissions=(DOCS_READ_SCOPE, DOCS_ADMIN_READ_SCOPE),
|
||||
),
|
||||
),
|
||||
route_factory=_route_factory,
|
||||
nav_items=(NavItem(path="/docs", label="Docs", icon="reports", required_any=DOCS_READ_SCOPES, order=880),),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/docs",
|
||||
label="Docs",
|
||||
icon="reports",
|
||||
required_any=DOCS_READ_SCOPES,
|
||||
order=880,
|
||||
),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id="docs",
|
||||
package_name="@govoplan/docs-webui",
|
||||
routes=(FrontendRoute(path="/docs", component="DocsPage", required_any=DOCS_READ_SCOPES, order=880),),
|
||||
nav_items=(NavItem(path="/docs", label="Docs", icon="reports", required_any=DOCS_READ_SCOPES, order=880),),
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/docs",
|
||||
component="DocsPage",
|
||||
required_any=DOCS_READ_SCOPES,
|
||||
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=(
|
||||
NavItem(
|
||||
path="/docs",
|
||||
label="Docs",
|
||||
icon="reports",
|
||||
required_any=DOCS_READ_SCOPES,
|
||||
order=880,
|
||||
),
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
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."
|
||||
),
|
||||
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."
|
||||
),
|
||||
}
|
||||
},
|
||||
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(
|
||||
id="docs.configured-system-documentation",
|
||||
title="Configured system documentation",
|
||||
@@ -81,14 +430,14 @@ manifest = ModuleManifest(
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Dokumentation dieses Systems",
|
||||
"summary": "Diese Dokumentation beginnt mit den installierten Modulen, der aktiven Konfiguration und den Funktionen, die fuer 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.",
|
||||
"summary": "Diese Dokumentation beginnt mit den installierten Modulen, der aktiven Konfiguration und den Funktionen, die für diese Rolle sichtbar sind.",
|
||||
"body": "Module können feste Dokumentationsabschnitte beitragen. Wenn Inhalte von Mandantenregeln, installierten Integrationen oder Betriebsoptionen abhängen, kann ein Modul laufzeitbasierte Dokumentation registrieren.",
|
||||
},
|
||||
},
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Public GovOPlaN module documentation",
|
||||
href="https://govplan.add-ideas.de/",
|
||||
label="Public GovOPlaN documentation",
|
||||
href="https://govoplan.add-ideas.de/docs",
|
||||
kind="public",
|
||||
),
|
||||
DocumentationLink(
|
||||
@@ -104,6 +453,86 @@ manifest = ModuleManifest(
|
||||
),
|
||||
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(
|
||||
id="docs.reference.institutional-governance-architecture",
|
||||
title="Institutional governance architecture",
|
||||
summary="GovOPlaN models institutional responsibility, governed work, formal outcomes, evidence, and external-system authority without turning every concept into Core or one monolithic application.",
|
||||
body=(
|
||||
"Organizations, Identity, IDM, Access, and Policy answer different parts of who may act. "
|
||||
"Mandate, service, procedure-party, and formal-decision semantics are being introduced as shared contracts and become modules only after independent lifecycle and reuse are proven. "
|
||||
"External integrations separately declare technical maturity and whether GovOPlaN is authoritative, mirrors an external source, synchronizes under governance, adds an overlay, or retains only a link."
|
||||
),
|
||||
layer="always",
|
||||
documentation_types=("admin",),
|
||||
audience=("tenant_admin", "operator", "module_admin", "product_owner"),
|
||||
order=15,
|
||||
translations={
|
||||
"de": {
|
||||
"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.",
|
||||
"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=(
|
||||
DocumentationLink(
|
||||
label="Institutional governance target architecture",
|
||||
href="govoplan/docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md",
|
||||
kind="repository",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Core module architecture",
|
||||
href="govoplan-core/docs/MODULE_ARCHITECTURE.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"architecture_topics": [
|
||||
"institutional context",
|
||||
"module ownership",
|
||||
"source authority",
|
||||
"integration maturity",
|
||||
"product packages",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="docs.pattern.field-help",
|
||||
title="Field help marker",
|
||||
@@ -114,6 +543,17 @@ manifest = ModuleManifest(
|
||||
audience=("user", "tenant_admin", "operator", "module_admin"),
|
||||
order=20,
|
||||
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=(
|
||||
DocumentationLink(
|
||||
label="Documentation experience concept",
|
||||
@@ -138,6 +578,111 @@ manifest = ModuleManifest(
|
||||
"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(
|
||||
id="docs.reference.organization-identity-idm-access-boundary",
|
||||
@@ -154,25 +699,112 @@ manifest = ModuleManifest(
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator", "user"),
|
||||
related_modules=("organizations", "identity", "idm", "access"),
|
||||
order=21,
|
||||
order=23,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
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=(
|
||||
DocumentationLink(label="Organizations", href="/organizations", kind="runtime"),
|
||||
DocumentationLink(
|
||||
label="Organizations", href="/organizations", kind="runtime"
|
||||
),
|
||||
DocumentationLink(label="IDM assignments", href="/idm", kind="runtime"),
|
||||
DocumentationLink(label="Access administration", href="/admin", kind="runtime"),
|
||||
DocumentationLink(
|
||||
label="Access administration", href="/admin", kind="runtime"
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"admin_explanation": "Function-to-role effects are owned by Access. IDM assignment changes can be governed independently from organization model changes.",
|
||||
"user_explanation": "A person can hold a function because IDM links their identity to the organization function. Access decides which application permissions that function gives.",
|
||||
"module_boundaries": [
|
||||
{"module": "organizations", "owns": "unit types, structures, relations, units, and function definitions"},
|
||||
{
|
||||
"module": "organizations",
|
||||
"owns": "unit types, structures, relations, units, and function definitions",
|
||||
},
|
||||
{"module": "identity", "owns": "identities and account links"},
|
||||
{"module": "idm", "owns": "identity-to-function assignments, delegation, acting-for links, and synchronization mapping"},
|
||||
{"module": "access", "owns": "roles, permissions, and accepted function-to-role mappings"},
|
||||
{
|
||||
"module": "idm",
|
||||
"owns": "identity-to-function assignments, delegation, acting-for links, and synchronization mapping",
|
||||
},
|
||||
{
|
||||
"module": "access",
|
||||
"owns": "roles, permissions, and accepted function-to-role mappings",
|
||||
},
|
||||
],
|
||||
},
|
||||
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=(
|
||||
DocumentationSourceDefinition(
|
||||
id="docs.project.wiki",
|
||||
kind="wiki",
|
||||
label="GovOPlaN Docs wiki",
|
||||
provenance={"source": "gitea_wiki"},
|
||||
link=DocumentationLink(
|
||||
label="GovOPlaN Docs wiki",
|
||||
href="https://git.add-ideas.de/GovOPlaN/govoplan-docs/wiki",
|
||||
kind="wiki",
|
||||
),
|
||||
inspection={
|
||||
"href": "https://git.add-ideas.de/GovOPlaN/govoplan-docs/wiki",
|
||||
},
|
||||
),
|
||||
),
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import 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 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,
|
||||
SemanticDocumentationRevision.id
|
||||
== SemanticDocumentationEntry.published_revision_id,
|
||||
)
|
||||
.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)
|
||||
for item in requests:
|
||||
reference = item.reference
|
||||
if (
|
||||
reference.tenant_id != principal.tenant_id
|
||||
or reference.module_id != "docs"
|
||||
or reference.resource_type != RESOURCE_TYPE
|
||||
):
|
||||
continue
|
||||
entry = db.get(SemanticDocumentationEntry, reference.resource_id)
|
||||
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,
|
||||
)
|
||||
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 = (
|
||||
db.get(SemanticDocumentationRevision, entry.published_revision_id)
|
||||
if entry is not None and entry.published_revision_id
|
||||
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
|
||||
)
|
||||
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",
|
||||
]
|
||||
@@ -0,0 +1,980 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.semantic_documentation import (
|
||||
SemanticDocumentationSubjectReference,
|
||||
SemanticDocumentationSubjectResolution,
|
||||
resolve_semantic_documentation_subject,
|
||||
semantic_documentation_fingerprint,
|
||||
)
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
from govoplan_docs.backend.db.models import (
|
||||
SemanticDocumentationEntry,
|
||||
SemanticDocumentationRevision,
|
||||
)
|
||||
|
||||
|
||||
SEMANTIC_PUBLICATION_POLICY_KEY = "docs.semantic_publication_policy"
|
||||
SEMANTIC_PUBLICATION_MODES = frozenset({"direct", "reviewer_required"})
|
||||
SEMANTIC_LIFECYCLE_STATES = frozenset(
|
||||
{"draft", "published", "superseded", "retired"}
|
||||
)
|
||||
_LOCALE_RE = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$")
|
||||
|
||||
|
||||
class SemanticDocumentationError(ValueError):
|
||||
"""Base semantic-documentation service error."""
|
||||
|
||||
|
||||
class SemanticDocumentationNotFoundError(SemanticDocumentationError):
|
||||
pass
|
||||
|
||||
|
||||
class SemanticDocumentationConflictError(SemanticDocumentationError):
|
||||
pass
|
||||
|
||||
|
||||
class SemanticDocumentationAuthorizationError(SemanticDocumentationError):
|
||||
pass
|
||||
|
||||
|
||||
class SemanticDocumentationSubjectError(SemanticDocumentationError):
|
||||
pass
|
||||
|
||||
|
||||
def publication_policy(session: Session, tenant_id: str) -> str:
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
settings = tenant.settings if tenant is not None else {}
|
||||
raw = settings.get(SEMANTIC_PUBLICATION_POLICY_KEY) if settings else None
|
||||
return str(raw) if raw in SEMANTIC_PUBLICATION_MODES else "reviewer_required"
|
||||
|
||||
|
||||
def set_publication_policy(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
mode: str,
|
||||
) -> str:
|
||||
if mode not in SEMANTIC_PUBLICATION_MODES:
|
||||
raise SemanticDocumentationError(
|
||||
"Semantic publication policy must be direct or reviewer_required."
|
||||
)
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
if tenant is None:
|
||||
raise SemanticDocumentationNotFoundError("The active tenant is unavailable.")
|
||||
settings = dict(tenant.settings or {})
|
||||
settings[SEMANTIC_PUBLICATION_POLICY_KEY] = mode
|
||||
tenant.settings = settings
|
||||
session.flush()
|
||||
return mode
|
||||
|
||||
|
||||
def create_semantic_entry(
|
||||
session: Session,
|
||||
registry: object,
|
||||
principal: object,
|
||||
*,
|
||||
subject: SemanticDocumentationSubjectReference,
|
||||
locale: str,
|
||||
content: Mapping[str, object],
|
||||
change_reason: str,
|
||||
now: datetime | None = None,
|
||||
) -> SemanticDocumentationEntry:
|
||||
tenant_id = _principal_tenant_id(principal)
|
||||
_require_tenant(subject, tenant_id)
|
||||
clean_locale = _locale(locale)
|
||||
current_subject = _resolved_current_subject(
|
||||
session,
|
||||
registry,
|
||||
principal,
|
||||
subject,
|
||||
)
|
||||
stable_key = current_subject.stable_key
|
||||
existing = (
|
||||
session.query(SemanticDocumentationEntry)
|
||||
.filter(
|
||||
SemanticDocumentationEntry.tenant_id == tenant_id,
|
||||
SemanticDocumentationEntry.subject_stable_key == stable_key,
|
||||
SemanticDocumentationEntry.locale == clean_locale,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
raise SemanticDocumentationConflictError(
|
||||
"Semantic documentation already exists for this subject and locale."
|
||||
)
|
||||
actor_id = _principal_account_id(principal)
|
||||
anchor = current_subject.anchor
|
||||
entry = SemanticDocumentationEntry(
|
||||
tenant_id=tenant_id,
|
||||
subject_stable_key=stable_key,
|
||||
subject_module_id=current_subject.module_id,
|
||||
subject_kind=current_subject.subject_kind,
|
||||
subject_id=current_subject.subject_id,
|
||||
anchor_kind=anchor.kind if anchor else None,
|
||||
anchor_id=anchor.id if anchor else None,
|
||||
locale=clean_locale,
|
||||
lifecycle_state="draft",
|
||||
current_revision=1,
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
)
|
||||
session.add(entry)
|
||||
session.flush()
|
||||
revision = _new_revision(
|
||||
entry,
|
||||
revision=1,
|
||||
lifecycle_state="draft",
|
||||
action="create",
|
||||
content=content,
|
||||
subject=current_subject,
|
||||
actor_id=actor_id,
|
||||
change_reason=change_reason,
|
||||
now=now,
|
||||
)
|
||||
session.add(revision)
|
||||
session.flush()
|
||||
entry.current_revision_id = revision.id
|
||||
session.flush()
|
||||
return entry
|
||||
|
||||
|
||||
def update_semantic_entry(
|
||||
session: Session,
|
||||
registry: object,
|
||||
principal: object,
|
||||
*,
|
||||
entry_id: str,
|
||||
expected_revision: int,
|
||||
content: Mapping[str, object],
|
||||
change_reason: str,
|
||||
now: datetime | None = None,
|
||||
) -> SemanticDocumentationEntry:
|
||||
entry = get_semantic_entry(session, principal, entry_id=entry_id)
|
||||
_require_editable(entry)
|
||||
_require_expected_revision(entry, expected_revision)
|
||||
subject = _resolved_current_subject(
|
||||
session,
|
||||
registry,
|
||||
principal,
|
||||
entry_subject_reference(entry),
|
||||
)
|
||||
actor_id = _principal_account_id(principal)
|
||||
revision = _new_revision(
|
||||
entry,
|
||||
revision=entry.current_revision + 1,
|
||||
lifecycle_state="draft",
|
||||
action="edit",
|
||||
content=content,
|
||||
subject=subject,
|
||||
actor_id=actor_id,
|
||||
change_reason=change_reason,
|
||||
now=now,
|
||||
)
|
||||
session.add(revision)
|
||||
session.flush()
|
||||
entry.current_revision += 1
|
||||
entry.current_revision_id = revision.id
|
||||
entry.lifecycle_state = "draft"
|
||||
entry.updated_by = actor_id
|
||||
session.flush()
|
||||
return entry
|
||||
|
||||
|
||||
def publish_semantic_entry(
|
||||
session: Session,
|
||||
registry: object,
|
||||
principal: object,
|
||||
*,
|
||||
entry_id: str,
|
||||
expected_revision: int,
|
||||
change_reason: str,
|
||||
now: datetime | None = None,
|
||||
) -> SemanticDocumentationEntry:
|
||||
entry = get_semantic_entry(session, principal, entry_id=entry_id)
|
||||
_require_editable(entry)
|
||||
_require_expected_revision(entry, expected_revision)
|
||||
current = current_revision(session, entry)
|
||||
actor_id = _principal_account_id(principal)
|
||||
mode = publication_policy(session, entry.tenant_id)
|
||||
if mode == "reviewer_required" and current.authored_by == actor_id:
|
||||
raise SemanticDocumentationAuthorizationError(
|
||||
"The configured publication policy requires another reviewer."
|
||||
)
|
||||
subject = _resolved_current_subject(
|
||||
session,
|
||||
registry,
|
||||
principal,
|
||||
entry_subject_reference(entry, revision=current),
|
||||
)
|
||||
timestamp = _utc(now)
|
||||
revision = _new_revision(
|
||||
entry,
|
||||
revision=entry.current_revision + 1,
|
||||
lifecycle_state="published",
|
||||
action="publish",
|
||||
content=current.content,
|
||||
subject=subject,
|
||||
actor_id=current.authored_by,
|
||||
reviewer_id=actor_id,
|
||||
change_reason=change_reason,
|
||||
now=timestamp,
|
||||
)
|
||||
revision.published_at = timestamp
|
||||
revision.provenance = {
|
||||
**revision.provenance,
|
||||
"publication_policy": mode,
|
||||
"published_by": actor_id,
|
||||
}
|
||||
session.add(revision)
|
||||
session.flush()
|
||||
entry.current_revision += 1
|
||||
entry.current_revision_id = revision.id
|
||||
entry.published_revision_id = revision.id
|
||||
entry.lifecycle_state = "published"
|
||||
entry.updated_by = actor_id
|
||||
entry.published_by = actor_id
|
||||
entry.published_at = timestamp
|
||||
session.flush()
|
||||
return entry
|
||||
|
||||
|
||||
def supersede_semantic_entry(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
entry_id: str,
|
||||
replacement_entry_id: str,
|
||||
expected_revision: int,
|
||||
change_reason: str,
|
||||
now: datetime | None = None,
|
||||
) -> SemanticDocumentationEntry:
|
||||
entry = get_semantic_entry(session, principal, entry_id=entry_id)
|
||||
_require_editable(entry)
|
||||
_require_expected_revision(entry, expected_revision)
|
||||
if entry.id == replacement_entry_id:
|
||||
raise SemanticDocumentationError("An entry cannot supersede itself.")
|
||||
replacement = get_semantic_entry(
|
||||
session,
|
||||
principal,
|
||||
entry_id=replacement_entry_id,
|
||||
)
|
||||
if replacement.published_revision_id is None:
|
||||
raise SemanticDocumentationError(
|
||||
"The replacement semantic documentation must be published."
|
||||
)
|
||||
current = current_revision(session, entry)
|
||||
actor_id = _principal_account_id(principal)
|
||||
revision = _lifecycle_revision(
|
||||
entry,
|
||||
current=current,
|
||||
lifecycle_state="superseded",
|
||||
action="supersede",
|
||||
actor_id=actor_id,
|
||||
change_reason=change_reason,
|
||||
now=now,
|
||||
provenance={"replacement_entry_id": replacement.id},
|
||||
)
|
||||
session.add(revision)
|
||||
session.flush()
|
||||
entry.current_revision += 1
|
||||
entry.current_revision_id = revision.id
|
||||
entry.lifecycle_state = "superseded"
|
||||
entry.superseded_by_entry_id = replacement.id
|
||||
entry.updated_by = actor_id
|
||||
session.flush()
|
||||
return entry
|
||||
|
||||
|
||||
def retire_semantic_entry(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
entry_id: str,
|
||||
expected_revision: int,
|
||||
change_reason: str,
|
||||
now: datetime | None = None,
|
||||
) -> SemanticDocumentationEntry:
|
||||
entry = get_semantic_entry(session, principal, entry_id=entry_id)
|
||||
_require_editable(entry)
|
||||
_require_expected_revision(entry, expected_revision)
|
||||
current = current_revision(session, entry)
|
||||
actor_id = _principal_account_id(principal)
|
||||
timestamp = _utc(now)
|
||||
revision = _lifecycle_revision(
|
||||
entry,
|
||||
current=current,
|
||||
lifecycle_state="retired",
|
||||
action="retire",
|
||||
actor_id=actor_id,
|
||||
change_reason=change_reason,
|
||||
now=timestamp,
|
||||
)
|
||||
session.add(revision)
|
||||
session.flush()
|
||||
entry.current_revision += 1
|
||||
entry.current_revision_id = revision.id
|
||||
entry.lifecycle_state = "retired"
|
||||
entry.retired_by = actor_id
|
||||
entry.retired_at = timestamp
|
||||
entry.updated_by = actor_id
|
||||
session.flush()
|
||||
return entry
|
||||
|
||||
|
||||
def get_semantic_entry(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
entry_id: str,
|
||||
) -> SemanticDocumentationEntry:
|
||||
tenant_id = _principal_tenant_id(principal)
|
||||
entry = (
|
||||
session.query(SemanticDocumentationEntry)
|
||||
.filter(
|
||||
SemanticDocumentationEntry.id == entry_id,
|
||||
SemanticDocumentationEntry.tenant_id == tenant_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if entry is None:
|
||||
raise SemanticDocumentationNotFoundError(
|
||||
"Semantic documentation entry not found."
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
def list_semantic_entries(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
module_id: str | None = None,
|
||||
subject_kind: str | None = None,
|
||||
) -> tuple[SemanticDocumentationEntry, ...]:
|
||||
query = session.query(SemanticDocumentationEntry).filter(
|
||||
SemanticDocumentationEntry.tenant_id == _principal_tenant_id(principal)
|
||||
)
|
||||
if module_id:
|
||||
query = query.filter(SemanticDocumentationEntry.subject_module_id == module_id)
|
||||
if subject_kind:
|
||||
query = query.filter(SemanticDocumentationEntry.subject_kind == subject_kind)
|
||||
return tuple(
|
||||
query.order_by(
|
||||
SemanticDocumentationEntry.subject_module_id.asc(),
|
||||
SemanticDocumentationEntry.subject_kind.asc(),
|
||||
SemanticDocumentationEntry.subject_id.asc(),
|
||||
SemanticDocumentationEntry.locale.asc(),
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def semantic_entry_history(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
entry_id: str,
|
||||
) -> tuple[SemanticDocumentationRevision, ...]:
|
||||
entry = get_semantic_entry(session, principal, entry_id=entry_id)
|
||||
return tuple(
|
||||
session.query(SemanticDocumentationRevision)
|
||||
.filter(
|
||||
SemanticDocumentationRevision.entry_id == entry.id,
|
||||
SemanticDocumentationRevision.tenant_id == entry.tenant_id,
|
||||
)
|
||||
.order_by(SemanticDocumentationRevision.revision.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def current_revision(
|
||||
session: Session,
|
||||
entry: SemanticDocumentationEntry,
|
||||
) -> SemanticDocumentationRevision:
|
||||
revision = (
|
||||
session.get(SemanticDocumentationRevision, entry.current_revision_id)
|
||||
if entry.current_revision_id
|
||||
else None
|
||||
)
|
||||
if revision is None or revision.entry_id != entry.id:
|
||||
raise SemanticDocumentationError(
|
||||
"Semantic documentation current revision is unavailable."
|
||||
)
|
||||
return revision
|
||||
|
||||
|
||||
def published_revision(
|
||||
session: Session,
|
||||
entry: SemanticDocumentationEntry,
|
||||
) -> SemanticDocumentationRevision | None:
|
||||
if not entry.published_revision_id:
|
||||
return None
|
||||
revision = session.get(
|
||||
SemanticDocumentationRevision,
|
||||
entry.published_revision_id,
|
||||
)
|
||||
return revision if revision is not None and revision.entry_id == entry.id else None
|
||||
|
||||
|
||||
def entry_subject_reference(
|
||||
entry: SemanticDocumentationEntry,
|
||||
*,
|
||||
revision: SemanticDocumentationRevision | None = None,
|
||||
) -> SemanticDocumentationSubjectReference:
|
||||
from govoplan_core.core.semantic_documentation import (
|
||||
SemanticDocumentationSubjectAnchor,
|
||||
)
|
||||
|
||||
return SemanticDocumentationSubjectReference(
|
||||
module_id=entry.subject_module_id,
|
||||
tenant_id=entry.tenant_id,
|
||||
subject_kind=entry.subject_kind,
|
||||
subject_id=entry.subject_id,
|
||||
anchor=(
|
||||
SemanticDocumentationSubjectAnchor(
|
||||
kind=entry.anchor_kind,
|
||||
id=entry.anchor_id,
|
||||
)
|
||||
if entry.anchor_kind and entry.anchor_id
|
||||
else None
|
||||
),
|
||||
observed_revision=revision.subject_revision if revision else None,
|
||||
observed_fingerprint=revision.subject_fingerprint if revision else None,
|
||||
)
|
||||
|
||||
|
||||
def resolve_entry_subject(
|
||||
session: Session,
|
||||
registry: object,
|
||||
principal: object,
|
||||
*,
|
||||
entry: SemanticDocumentationEntry,
|
||||
revision: SemanticDocumentationRevision,
|
||||
) -> SemanticDocumentationSubjectResolution | None:
|
||||
return resolve_semantic_documentation_subject(
|
||||
registry,
|
||||
session,
|
||||
principal,
|
||||
reference=entry_subject_reference(entry, revision=revision),
|
||||
)
|
||||
|
||||
|
||||
def content_visible_to_principal(
|
||||
content: Mapping[str, object],
|
||||
principal: object,
|
||||
) -> bool:
|
||||
audience = tuple(str(item) for item in content.get("audience", ()) or ())
|
||||
classification = str(content.get("classification") or "internal")
|
||||
if classification == "restricted" and not audience:
|
||||
return False
|
||||
if not audience or "authenticated" in audience:
|
||||
return True
|
||||
tokens = _principal_audience_tokens(principal)
|
||||
return any(selector in tokens for selector in audience)
|
||||
|
||||
|
||||
def semantic_entry_payload(
|
||||
session: Session,
|
||||
registry: object,
|
||||
principal: object,
|
||||
*,
|
||||
entry: SemanticDocumentationEntry,
|
||||
editor: bool,
|
||||
requested_locale: str | None = None,
|
||||
) -> dict[str, object] | None:
|
||||
current = current_revision(session, entry)
|
||||
published = published_revision(session, entry)
|
||||
selected = current if editor else published
|
||||
if selected is None:
|
||||
return None
|
||||
resolution = resolve_entry_subject(
|
||||
session,
|
||||
registry,
|
||||
principal,
|
||||
entry=entry,
|
||||
revision=selected,
|
||||
)
|
||||
if resolution is None:
|
||||
return None
|
||||
unavailable = resolution.availability == "temporarily_unavailable"
|
||||
if unavailable and not editor:
|
||||
return None
|
||||
if not content_visible_to_principal(selected.content, principal):
|
||||
return None
|
||||
subject = resolution.subject
|
||||
required_scopes = subject.required_scopes if subject is not None else ()
|
||||
if any(not _principal_has(principal, scope) for scope in required_scopes):
|
||||
return None
|
||||
return {
|
||||
"id": entry.id,
|
||||
"tenant_id": entry.tenant_id,
|
||||
"subject": entry_subject_reference(entry, revision=selected).to_dict(),
|
||||
"subject_stable_key": entry.subject_stable_key,
|
||||
"subject_resolution": resolution.to_dict(),
|
||||
"locale": entry.locale,
|
||||
"requested_locale": requested_locale or entry.locale,
|
||||
"locale_fallback": bool(requested_locale and requested_locale != entry.locale),
|
||||
"lifecycle_state": entry.lifecycle_state,
|
||||
"effective_state": selected.lifecycle_state,
|
||||
"pending_draft": bool(
|
||||
published is not None and current.id != published.id
|
||||
),
|
||||
"current_revision": entry.current_revision,
|
||||
"selected_revision": selected.revision,
|
||||
"published_revision": published.revision if published else None,
|
||||
"content": {} if unavailable else dict(selected.content),
|
||||
"content_redacted": unavailable,
|
||||
"content_hash": selected.content_hash,
|
||||
"subject_revision": selected.subject_revision,
|
||||
"subject_fingerprint": selected.subject_fingerprint,
|
||||
"authorship": {
|
||||
"created_by": entry.created_by,
|
||||
"updated_by": entry.updated_by,
|
||||
"authored_by": selected.authored_by,
|
||||
"reviewed_by": selected.reviewed_by,
|
||||
"published_by": entry.published_by,
|
||||
},
|
||||
"published_at": entry.published_at.isoformat() if entry.published_at else None,
|
||||
"retired_at": entry.retired_at.isoformat() if entry.retired_at else None,
|
||||
"superseded_by_entry_id": entry.superseded_by_entry_id,
|
||||
"created_at": entry.created_at.isoformat(),
|
||||
"updated_at": entry.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def select_locale_entries(
|
||||
entries: Sequence[SemanticDocumentationEntry],
|
||||
*,
|
||||
locale: str,
|
||||
) -> tuple[SemanticDocumentationEntry, ...]:
|
||||
requested = _locale(locale)
|
||||
language = requested.split("-", 1)[0]
|
||||
grouped: dict[str, list[SemanticDocumentationEntry]] = {}
|
||||
for entry in entries:
|
||||
grouped.setdefault(entry.subject_stable_key, []).append(entry)
|
||||
selected: list[SemanticDocumentationEntry] = []
|
||||
for candidates in grouped.values():
|
||||
order = (
|
||||
requested,
|
||||
language,
|
||||
"de",
|
||||
"en",
|
||||
)
|
||||
candidate = next(
|
||||
(
|
||||
item
|
||||
for target in order
|
||||
for item in candidates
|
||||
if item.locale.casefold() == target.casefold()
|
||||
),
|
||||
sorted(candidates, key=lambda item: item.locale)[0],
|
||||
)
|
||||
selected.append(candidate)
|
||||
return tuple(
|
||||
sorted(
|
||||
selected,
|
||||
key=lambda item: (
|
||||
item.subject_module_id,
|
||||
item.subject_kind,
|
||||
item.subject_id,
|
||||
item.anchor_kind or "",
|
||||
item.anchor_id or "",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def revision_payload(revision: SemanticDocumentationRevision) -> dict[str, object]:
|
||||
return {
|
||||
"id": revision.id,
|
||||
"entry_id": revision.entry_id,
|
||||
"revision": revision.revision,
|
||||
"lifecycle_state": revision.lifecycle_state,
|
||||
"action": revision.action,
|
||||
"change_reason": revision.change_reason,
|
||||
"content": dict(revision.content),
|
||||
"content_hash": revision.content_hash,
|
||||
"subject_revision": revision.subject_revision,
|
||||
"subject_fingerprint": revision.subject_fingerprint,
|
||||
"authored_by": revision.authored_by,
|
||||
"reviewed_by": revision.reviewed_by,
|
||||
"published_at": (
|
||||
revision.published_at.isoformat() if revision.published_at else None
|
||||
),
|
||||
"provenance": dict(revision.provenance or {}),
|
||||
"recoverable": revision.recoverable,
|
||||
"created_at": revision.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _new_revision(
|
||||
entry: SemanticDocumentationEntry,
|
||||
*,
|
||||
revision: int,
|
||||
lifecycle_state: str,
|
||||
action: str,
|
||||
content: Mapping[str, object],
|
||||
subject: SemanticDocumentationSubjectReference,
|
||||
actor_id: str,
|
||||
change_reason: str,
|
||||
now: datetime | None,
|
||||
reviewer_id: str | None = None,
|
||||
) -> SemanticDocumentationRevision:
|
||||
normalized = _normalize_content(content)
|
||||
timestamp = _utc(now)
|
||||
return SemanticDocumentationRevision(
|
||||
tenant_id=entry.tenant_id,
|
||||
entry_id=entry.id,
|
||||
revision=revision,
|
||||
lifecycle_state=lifecycle_state,
|
||||
action=action,
|
||||
change_reason=_bounded_required(change_reason, "Change reason", 1000),
|
||||
content=normalized,
|
||||
content_hash=semantic_documentation_fingerprint(normalized),
|
||||
subject_revision=subject.observed_revision,
|
||||
subject_fingerprint=subject.observed_fingerprint,
|
||||
authored_by=actor_id,
|
||||
reviewed_by=reviewer_id,
|
||||
provenance={
|
||||
"subject_stable_key": subject.stable_key,
|
||||
"recorded_at": timestamp.isoformat(),
|
||||
"action_by": reviewer_id or actor_id,
|
||||
},
|
||||
recoverable=True,
|
||||
search_text=_search_text(normalized),
|
||||
)
|
||||
|
||||
|
||||
def _lifecycle_revision(
|
||||
entry: SemanticDocumentationEntry,
|
||||
*,
|
||||
current: SemanticDocumentationRevision,
|
||||
lifecycle_state: Literal["superseded", "retired"],
|
||||
action: str,
|
||||
actor_id: str,
|
||||
change_reason: str,
|
||||
now: datetime | None,
|
||||
provenance: Mapping[str, object] | None = None,
|
||||
) -> SemanticDocumentationRevision:
|
||||
timestamp = _utc(now)
|
||||
return SemanticDocumentationRevision(
|
||||
tenant_id=entry.tenant_id,
|
||||
entry_id=entry.id,
|
||||
revision=entry.current_revision + 1,
|
||||
lifecycle_state=lifecycle_state,
|
||||
action=action,
|
||||
change_reason=_bounded_required(change_reason, "Change reason", 1000),
|
||||
content=dict(current.content),
|
||||
content_hash=current.content_hash,
|
||||
subject_revision=current.subject_revision,
|
||||
subject_fingerprint=current.subject_fingerprint,
|
||||
authored_by=current.authored_by,
|
||||
reviewed_by=actor_id,
|
||||
provenance={
|
||||
**dict(current.provenance or {}),
|
||||
**dict(provenance or {}),
|
||||
"recorded_at": timestamp.isoformat(),
|
||||
"action_by": actor_id,
|
||||
},
|
||||
recoverable=True,
|
||||
search_text=current.search_text,
|
||||
)
|
||||
|
||||
|
||||
def _resolved_current_subject(
|
||||
session: Session,
|
||||
registry: object,
|
||||
principal: object,
|
||||
reference: SemanticDocumentationSubjectReference,
|
||||
) -> SemanticDocumentationSubjectReference:
|
||||
resolution = resolve_semantic_documentation_subject(
|
||||
registry,
|
||||
session,
|
||||
principal,
|
||||
reference=reference,
|
||||
)
|
||||
if resolution is None:
|
||||
raise SemanticDocumentationNotFoundError(
|
||||
"Semantic documentation subject not found."
|
||||
)
|
||||
if resolution.availability not in {"available", "changed"} or resolution.subject is None:
|
||||
raise SemanticDocumentationSubjectError(
|
||||
"Semantic documentation subject is not currently available."
|
||||
)
|
||||
return resolution.subject.reference
|
||||
|
||||
|
||||
def _normalize_content(content: Mapping[str, object]) -> dict[str, object]:
|
||||
allowed = {
|
||||
"title",
|
||||
"summary",
|
||||
"body",
|
||||
"meaning",
|
||||
"intended_use",
|
||||
"non_intended_use",
|
||||
"examples",
|
||||
"owner_account_id",
|
||||
"steward_account_id",
|
||||
"audience",
|
||||
"classification",
|
||||
"links",
|
||||
}
|
||||
unknown = sorted(str(key) for key in content if key not in allowed)
|
||||
if unknown:
|
||||
raise SemanticDocumentationError(
|
||||
"Unsupported semantic content fields: " + ", ".join(unknown)
|
||||
)
|
||||
normalized = {
|
||||
"title": _plain_text(content.get("title"), "Title", 500, required=True),
|
||||
"summary": _plain_text(content.get("summary"), "Summary", 4000),
|
||||
"body": _plain_text(content.get("body"), "Body", 200_000),
|
||||
"meaning": _plain_text(content.get("meaning"), "Meaning", 20_000),
|
||||
"intended_use": _plain_text(
|
||||
content.get("intended_use"), "Intended use", 20_000
|
||||
),
|
||||
"non_intended_use": _plain_text(
|
||||
content.get("non_intended_use"), "Non-intended use", 20_000
|
||||
),
|
||||
"examples": _string_list(content.get("examples"), "Examples", 50, 4000),
|
||||
"owner_account_id": _optional_id(content.get("owner_account_id")),
|
||||
"steward_account_id": _optional_id(content.get("steward_account_id")),
|
||||
"audience": _audience(content.get("audience")),
|
||||
"classification": str(content.get("classification") or "internal"),
|
||||
"links": _links(content.get("links")),
|
||||
}
|
||||
if normalized["classification"] not in {"internal", "restricted"}:
|
||||
raise SemanticDocumentationError(
|
||||
"Semantic content classification must be internal or restricted."
|
||||
)
|
||||
if normalized["classification"] == "restricted" and not normalized["audience"]:
|
||||
raise SemanticDocumentationError(
|
||||
"Restricted semantic documentation requires an audience."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _plain_text(
|
||||
value: object,
|
||||
label: str,
|
||||
maximum: int,
|
||||
*,
|
||||
required: bool = False,
|
||||
) -> str:
|
||||
text = str(value or "").strip()
|
||||
if required and not text:
|
||||
raise SemanticDocumentationError(f"{label} is required.")
|
||||
if len(text) > maximum:
|
||||
raise SemanticDocumentationError(
|
||||
f"{label} is limited to {maximum} characters."
|
||||
)
|
||||
if any(ord(character) < 32 and character not in "\n\t" for character in text):
|
||||
raise SemanticDocumentationError(f"{label} contains control characters.")
|
||||
if "<script" in text.casefold() or "javascript:" in text.casefold():
|
||||
raise SemanticDocumentationError(f"{label} contains unsafe active content.")
|
||||
return text
|
||||
|
||||
|
||||
def _string_list(
|
||||
value: object,
|
||||
label: str,
|
||||
maximum_items: int,
|
||||
maximum_length: int,
|
||||
) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
raise SemanticDocumentationError(f"{label} must be a list.")
|
||||
if len(value) > maximum_items:
|
||||
raise SemanticDocumentationError(
|
||||
f"{label} are limited to {maximum_items} items."
|
||||
)
|
||||
return list(
|
||||
dict.fromkeys(
|
||||
_plain_text(item, label, maximum_length, required=True) for item in value
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _audience(value: object) -> list[str]:
|
||||
selectors = _string_list(value, "Audience", 100, 500)
|
||||
prefixes = ("account:", "group:", "role:", "function:", "scope:")
|
||||
if any(
|
||||
selector != "authenticated" and not selector.startswith(prefixes)
|
||||
for selector in selectors
|
||||
):
|
||||
raise SemanticDocumentationError(
|
||||
"Semantic audience selectors must be typed."
|
||||
)
|
||||
return selectors
|
||||
|
||||
|
||||
def _links(value: object) -> list[dict[str, str]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
raise SemanticDocumentationError("Links must be a list.")
|
||||
if len(value) > 50:
|
||||
raise SemanticDocumentationError("Links are limited to 50 items.")
|
||||
result: list[dict[str, str]] = []
|
||||
for item in value:
|
||||
if not isinstance(item, Mapping) or set(item) != {"label", "href"}:
|
||||
raise SemanticDocumentationError(
|
||||
"Semantic links require only label and href."
|
||||
)
|
||||
label = _plain_text(item.get("label"), "Link label", 300, required=True)
|
||||
href = _plain_text(item.get("href"), "Link href", 2000, required=True)
|
||||
if not (
|
||||
(href.startswith("/") and not href.startswith("//"))
|
||||
or href.startswith("https://")
|
||||
):
|
||||
raise SemanticDocumentationError(
|
||||
"Semantic links must use HTTPS or a local path."
|
||||
)
|
||||
if href.startswith("https://") and "@" in href.split("/", 3)[2]:
|
||||
raise SemanticDocumentationError("Semantic links cannot contain credentials.")
|
||||
result.append({"label": label, "href": href})
|
||||
return result
|
||||
|
||||
|
||||
def _search_text(content: Mapping[str, object]) -> str:
|
||||
values = [
|
||||
content.get("title"),
|
||||
content.get("summary"),
|
||||
content.get("body"),
|
||||
content.get("meaning"),
|
||||
content.get("intended_use"),
|
||||
content.get("non_intended_use"),
|
||||
*(content.get("examples") or ()),
|
||||
]
|
||||
return "\n".join(str(value) for value in values if value).strip()
|
||||
|
||||
|
||||
def _principal_audience_tokens(principal: object) -> frozenset[str]:
|
||||
return frozenset(
|
||||
{
|
||||
"authenticated",
|
||||
f"account:{_principal_account_id(principal)}",
|
||||
*(f"group:{value}" for value in getattr(principal, "group_ids", ())),
|
||||
*(f"role:{value}" for value in getattr(principal, "role_ids", ())),
|
||||
*(
|
||||
f"function:{value}"
|
||||
for value in getattr(principal, "function_assignment_ids", ())
|
||||
),
|
||||
*(f"scope:{value}" for value in getattr(principal, "scopes", ())),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _principal_has(principal: object, scope: str) -> bool:
|
||||
checker = getattr(principal, "has", None)
|
||||
return bool(checker(scope)) if callable(checker) else scope in getattr(
|
||||
principal, "scopes", ()
|
||||
)
|
||||
|
||||
|
||||
def _principal_tenant_id(principal: object) -> str:
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||
if not tenant_id:
|
||||
raise SemanticDocumentationAuthorizationError(
|
||||
"Semantic documentation requires an active tenant."
|
||||
)
|
||||
return tenant_id
|
||||
|
||||
|
||||
def _principal_account_id(principal: object) -> str:
|
||||
account_id = str(getattr(principal, "account_id", "") or "").strip()
|
||||
if not account_id:
|
||||
raise SemanticDocumentationAuthorizationError(
|
||||
"Semantic documentation requires an authenticated account."
|
||||
)
|
||||
return account_id
|
||||
|
||||
|
||||
def _require_tenant(
|
||||
reference: SemanticDocumentationSubjectReference,
|
||||
tenant_id: str,
|
||||
) -> None:
|
||||
if reference.tenant_id != tenant_id:
|
||||
raise SemanticDocumentationNotFoundError(
|
||||
"Semantic documentation subject not found."
|
||||
)
|
||||
|
||||
|
||||
def _require_expected_revision(
|
||||
entry: SemanticDocumentationEntry,
|
||||
expected_revision: int,
|
||||
) -> None:
|
||||
if entry.current_revision != expected_revision:
|
||||
raise SemanticDocumentationConflictError(
|
||||
"Semantic documentation changed; reload before saving."
|
||||
)
|
||||
|
||||
|
||||
def _require_editable(entry: SemanticDocumentationEntry) -> None:
|
||||
if entry.lifecycle_state in {"superseded", "retired"}:
|
||||
raise SemanticDocumentationConflictError(
|
||||
f"{entry.lifecycle_state.title()} semantic documentation cannot be edited."
|
||||
)
|
||||
|
||||
|
||||
def _locale(value: str) -> str:
|
||||
clean = str(value or "").strip()
|
||||
if not _LOCALE_RE.fullmatch(clean):
|
||||
raise SemanticDocumentationError("Semantic documentation locale is invalid.")
|
||||
return clean
|
||||
|
||||
|
||||
def _optional_id(value: object) -> str | None:
|
||||
clean = str(value or "").strip()
|
||||
if not clean:
|
||||
return None
|
||||
if len(clean) > 255 or any(ord(character) < 32 for character in clean):
|
||||
raise SemanticDocumentationError("Account references must be bounded text.")
|
||||
return clean
|
||||
|
||||
|
||||
def _bounded_required(value: object, label: str, maximum: int) -> str:
|
||||
return _plain_text(value, label, maximum, required=True)
|
||||
|
||||
|
||||
def _utc(value: datetime | None) -> datetime:
|
||||
timestamp = value or datetime.now(UTC)
|
||||
if timestamp.tzinfo is None:
|
||||
return timestamp.replace(tzinfo=UTC)
|
||||
return timestamp.astimezone(UTC)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SEMANTIC_LIFECYCLE_STATES",
|
||||
"SEMANTIC_PUBLICATION_MODES",
|
||||
"SEMANTIC_PUBLICATION_POLICY_KEY",
|
||||
"SemanticDocumentationAuthorizationError",
|
||||
"SemanticDocumentationConflictError",
|
||||
"SemanticDocumentationError",
|
||||
"SemanticDocumentationNotFoundError",
|
||||
"SemanticDocumentationSubjectError",
|
||||
"content_visible_to_principal",
|
||||
"create_semantic_entry",
|
||||
"current_revision",
|
||||
"entry_subject_reference",
|
||||
"get_semantic_entry",
|
||||
"list_semantic_entries",
|
||||
"publication_policy",
|
||||
"publish_semantic_entry",
|
||||
"published_revision",
|
||||
"resolve_entry_subject",
|
||||
"retire_semantic_entry",
|
||||
"revision_payload",
|
||||
"select_locale_entries",
|
||||
"semantic_entry_history",
|
||||
"semantic_entry_payload",
|
||||
"set_publication_policy",
|
||||
"supersede_semantic_entry",
|
||||
"update_semantic_entry",
|
||||
]
|
||||
@@ -0,0 +1,616 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any, Literal, Mapping
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
DocumentationSourceDefinition,
|
||||
DocumentationType,
|
||||
ModuleManifest,
|
||||
)
|
||||
from govoplan_core.security.redaction import redact_secret_values
|
||||
|
||||
|
||||
class _SourceModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class DocumentationSourceProvenance(_SourceModel):
|
||||
source: str
|
||||
version: str | None = None
|
||||
revision: str | None = None
|
||||
published_at: str | None = None
|
||||
checksum: str | None = None
|
||||
|
||||
|
||||
class DocumentationSourceVisibility(_SourceModel):
|
||||
documentation_types: list[DocumentationType]
|
||||
required_modules: list[str] = Field(default_factory=list)
|
||||
any_modules: list[str] = Field(default_factory=list)
|
||||
missing_modules: list[str] = Field(default_factory=list)
|
||||
required_capabilities: list[str] = Field(default_factory=list)
|
||||
required_scopes: list[str] = Field(default_factory=list)
|
||||
any_scopes: list[str] = Field(default_factory=list)
|
||||
configuration_keys: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ManifestInspection(_SourceModel):
|
||||
kind: Literal["manifest"] = "manifest"
|
||||
module_id: str
|
||||
name: str
|
||||
version: str
|
||||
dependencies: list[str]
|
||||
optional_dependencies: list[str]
|
||||
|
||||
|
||||
class RouteInspection(_SourceModel):
|
||||
kind: Literal["route"] = "route"
|
||||
path: str
|
||||
component: str | None = None
|
||||
order: int
|
||||
|
||||
|
||||
class CapabilityInspection(_SourceModel):
|
||||
kind: Literal["capability"] = "capability"
|
||||
capability: str
|
||||
label: str | None = None
|
||||
summary: str | None = None
|
||||
contract_version: str | None = None
|
||||
stability: Literal["experimental", "stable", "deprecated"] | None = None
|
||||
audience: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PolicyInspection(_SourceModel):
|
||||
kind: Literal["policy"] = "policy"
|
||||
capability: str
|
||||
label: str | None = None
|
||||
summary: str | None = None
|
||||
contract_version: str | None = None
|
||||
stability: Literal["experimental", "stable", "deprecated"] | None = None
|
||||
audience: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ReleaseCatalogEntryInspection(_SourceModel):
|
||||
id: str
|
||||
name: str
|
||||
version: str | None = None
|
||||
description: str | None = None
|
||||
action: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ReleaseCatalogInspection(_SourceModel):
|
||||
kind: Literal["release_catalog"] = "release_catalog"
|
||||
catalog_type: Literal["modules", "configuration_packages"]
|
||||
channel: str | None = None
|
||||
sequence: int | None = None
|
||||
generated_at: str | None = None
|
||||
entry_count: int
|
||||
signed: bool
|
||||
trusted: bool
|
||||
cache_used: bool
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
entries: list[ReleaseCatalogEntryInspection] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConfigurationPackageInspection(_SourceModel):
|
||||
kind: Literal["configuration_package"] = "configuration_package"
|
||||
package_id: str
|
||||
name: str | None = None
|
||||
version: str | None = None
|
||||
schema_version: str | None = None
|
||||
description: str | None = None
|
||||
publisher: str | None = None
|
||||
category: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
required_modules: list[str] = Field(default_factory=list)
|
||||
required_capabilities: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WikiInspection(_SourceModel):
|
||||
kind: Literal["wiki"] = "wiki"
|
||||
href: str
|
||||
published_at: str | None = None
|
||||
revision: str | None = None
|
||||
|
||||
|
||||
class RepositoryInspection(_SourceModel):
|
||||
kind: Literal["repository"] = "repository"
|
||||
href: str
|
||||
revision: str | None = None
|
||||
|
||||
|
||||
DocumentationSourceInspection = Annotated[
|
||||
ManifestInspection
|
||||
| RouteInspection
|
||||
| CapabilityInspection
|
||||
| PolicyInspection
|
||||
| ReleaseCatalogInspection
|
||||
| ConfigurationPackageInspection
|
||||
| WikiInspection
|
||||
| RepositoryInspection,
|
||||
Field(discriminator="kind"),
|
||||
]
|
||||
|
||||
|
||||
class DocumentationSourceItem(_SourceModel):
|
||||
id: str
|
||||
kind: str
|
||||
owner_module_id: str
|
||||
label: str
|
||||
state: Literal["configured", "disabled", "unavailable"]
|
||||
state_reason: str | None = None
|
||||
inspection_url: str
|
||||
provenance: DocumentationSourceProvenance
|
||||
visibility: DocumentationSourceVisibility
|
||||
inspection: DocumentationSourceInspection
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
return self.model_dump(mode="json", exclude={"inspection"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegisteredDocumentationSource:
|
||||
item: DocumentationSourceItem
|
||||
condition: DocumentationCondition
|
||||
documentation_types: tuple[DocumentationType, ...]
|
||||
configuration_key: str | None = None
|
||||
|
||||
|
||||
class DocumentationSourceRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._sources: dict[str, RegisteredDocumentationSource] = {}
|
||||
|
||||
def register(self, source: RegisteredDocumentationSource) -> None:
|
||||
if source.item.id in self._sources:
|
||||
raise ValueError(f"Duplicate documentation source id: {source.item.id}")
|
||||
self._sources[source.item.id] = source
|
||||
|
||||
def sources(self) -> tuple[RegisteredDocumentationSource, ...]:
|
||||
return tuple(
|
||||
self._sources[source_id]
|
||||
for source_id in sorted(self._sources)
|
||||
)
|
||||
|
||||
def get(self, source_id: str) -> RegisteredDocumentationSource | None:
|
||||
return self._sources.get(source_id)
|
||||
|
||||
|
||||
def build_documentation_source_registry(
|
||||
manifests: tuple[ModuleManifest, ...],
|
||||
*,
|
||||
include_runtime_catalogs: bool = True,
|
||||
) -> DocumentationSourceRegistry:
|
||||
registry = DocumentationSourceRegistry()
|
||||
for manifest in manifests:
|
||||
registry.register(_manifest_source(manifest))
|
||||
for source in _route_sources(manifest):
|
||||
registry.register(source)
|
||||
for source in _capability_sources(manifest):
|
||||
registry.register(source)
|
||||
for definition in manifest.documentation_sources:
|
||||
registry.register(_defined_source(manifest, definition))
|
||||
for source in _linked_sources(manifest):
|
||||
if registry.get(source.item.id) is None:
|
||||
registry.register(source)
|
||||
if include_runtime_catalogs:
|
||||
for source in _catalog_sources():
|
||||
registry.register(source)
|
||||
return registry
|
||||
|
||||
|
||||
def _manifest_source(manifest: ModuleManifest) -> RegisteredDocumentationSource:
|
||||
return _registered_source(
|
||||
source_id=f"{manifest.id}.manifest",
|
||||
kind="manifest",
|
||||
owner_module_id=manifest.id,
|
||||
label=f"{manifest.name} module manifest",
|
||||
provenance={"source": "module_manifest", "version": manifest.version},
|
||||
inspection=ManifestInspection(
|
||||
module_id=manifest.id,
|
||||
name=manifest.name,
|
||||
version=manifest.version,
|
||||
dependencies=list(manifest.dependencies),
|
||||
optional_dependencies=list(manifest.optional_dependencies),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _route_sources(manifest: ModuleManifest) -> tuple[RegisteredDocumentationSource, ...]:
|
||||
if manifest.frontend is None:
|
||||
return ()
|
||||
return tuple(
|
||||
_registered_source(
|
||||
source_id=_derived_source_id(manifest.id, "route", route.path),
|
||||
kind="route",
|
||||
owner_module_id=manifest.id,
|
||||
label=f"{manifest.name} route {route.path}",
|
||||
provenance={"source": "frontend_manifest", "version": manifest.version},
|
||||
inspection=RouteInspection(
|
||||
path=route.path,
|
||||
component=route.component,
|
||||
order=route.order,
|
||||
),
|
||||
condition=DocumentationCondition(
|
||||
required_modules=(manifest.id,),
|
||||
required_scopes=route.required_all,
|
||||
any_scopes=route.required_any,
|
||||
),
|
||||
)
|
||||
for route in manifest.frontend.routes
|
||||
)
|
||||
|
||||
|
||||
def _capability_sources(manifest: ModuleManifest) -> tuple[RegisteredDocumentationSource, ...]:
|
||||
sources: list[RegisteredDocumentationSource] = []
|
||||
for capability in sorted(manifest.capability_factories):
|
||||
metadata = manifest.capability_documentation.get(capability)
|
||||
is_policy = capability.startswith("policy.")
|
||||
kind = "policy" if is_policy else "capability"
|
||||
inspection: DocumentationSourceInspection = (
|
||||
PolicyInspection(
|
||||
capability=capability,
|
||||
label=metadata.label if metadata else None,
|
||||
summary=metadata.summary if metadata else None,
|
||||
contract_version=metadata.contract_version if metadata else None,
|
||||
stability=metadata.stability if metadata else None,
|
||||
audience=list(metadata.audience) if metadata else [],
|
||||
)
|
||||
if is_policy
|
||||
else CapabilityInspection(
|
||||
capability=capability,
|
||||
label=metadata.label if metadata else None,
|
||||
summary=metadata.summary if metadata else None,
|
||||
contract_version=metadata.contract_version if metadata else None,
|
||||
stability=metadata.stability if metadata else None,
|
||||
audience=list(metadata.audience) if metadata else [],
|
||||
)
|
||||
)
|
||||
sources.append(_registered_source(
|
||||
source_id=_derived_source_id(manifest.id, kind, capability),
|
||||
kind=kind,
|
||||
owner_module_id=manifest.id,
|
||||
label=metadata.label if metadata else f"{manifest.name} {kind} {capability}",
|
||||
provenance={"source": "module_manifest", "version": manifest.version},
|
||||
inspection=inspection,
|
||||
condition=DocumentationCondition(required_modules=(manifest.id,)),
|
||||
documentation_types=metadata.documentation_types if metadata else ("admin",),
|
||||
))
|
||||
return tuple(sources)
|
||||
|
||||
|
||||
def _defined_source(
|
||||
manifest: ModuleManifest,
|
||||
definition: DocumentationSourceDefinition,
|
||||
) -> RegisteredDocumentationSource:
|
||||
inspection = _defined_inspection(definition)
|
||||
return _registered_source(
|
||||
source_id=definition.id,
|
||||
kind=definition.kind,
|
||||
owner_module_id=manifest.id,
|
||||
label=definition.label,
|
||||
provenance={
|
||||
"source": str(definition.provenance.get("source") or "module_manifest"),
|
||||
**_safe_source_fields(definition.provenance),
|
||||
},
|
||||
inspection=inspection,
|
||||
condition=definition.condition,
|
||||
documentation_types=definition.documentation_types,
|
||||
state=definition.state,
|
||||
state_reason=definition.state_reason,
|
||||
configuration_key=definition.configuration_key,
|
||||
)
|
||||
|
||||
|
||||
def _defined_inspection(definition: DocumentationSourceDefinition) -> DocumentationSourceInspection:
|
||||
safe = _safe_source_fields(definition.inspection)
|
||||
if definition.kind == "configuration_package":
|
||||
return ConfigurationPackageInspection(
|
||||
package_id=str(safe.get("package_id") or definition.id),
|
||||
name=_optional_text(safe.get("name")),
|
||||
version=_optional_text(safe.get("version")),
|
||||
schema_version=_optional_text(safe.get("schema_version")),
|
||||
description=_optional_text(safe.get("description")),
|
||||
publisher=_optional_text(safe.get("publisher")),
|
||||
category=_optional_text(safe.get("category")),
|
||||
tags=_string_list(safe.get("tags")),
|
||||
required_modules=_string_list(safe.get("required_modules")),
|
||||
required_capabilities=_string_list(safe.get("required_capabilities")),
|
||||
)
|
||||
href = definition.link.href if definition.link else str(safe.get("href") or "")
|
||||
if definition.kind == "wiki":
|
||||
return WikiInspection(
|
||||
href=href,
|
||||
published_at=_optional_text(safe.get("published_at")),
|
||||
revision=_optional_text(safe.get("revision")),
|
||||
)
|
||||
if definition.kind == "repository":
|
||||
return RepositoryInspection(
|
||||
href=href,
|
||||
revision=_optional_text(safe.get("revision")),
|
||||
)
|
||||
if definition.kind == "route":
|
||||
return RouteInspection(
|
||||
path=str(safe.get("path") or ""),
|
||||
component=_optional_text(safe.get("component")),
|
||||
order=int(safe.get("order") or 0),
|
||||
)
|
||||
if definition.kind == "policy":
|
||||
return PolicyInspection(
|
||||
capability=str(safe.get("capability") or definition.id),
|
||||
label=_optional_text(safe.get("label")),
|
||||
summary=_optional_text(safe.get("summary")),
|
||||
contract_version=_optional_text(safe.get("contract_version")),
|
||||
stability=_capability_stability(safe.get("stability")),
|
||||
audience=_string_list(safe.get("audience")),
|
||||
)
|
||||
if definition.kind == "capability":
|
||||
return CapabilityInspection(
|
||||
capability=str(safe.get("capability") or definition.id),
|
||||
label=_optional_text(safe.get("label")),
|
||||
summary=_optional_text(safe.get("summary")),
|
||||
contract_version=_optional_text(safe.get("contract_version")),
|
||||
stability=_capability_stability(safe.get("stability")),
|
||||
audience=_string_list(safe.get("audience")),
|
||||
)
|
||||
return ManifestInspection(
|
||||
module_id=str(safe.get("module_id") or definition.id.split(".", 1)[0]),
|
||||
name=str(safe.get("name") or definition.label),
|
||||
version=str(safe.get("version") or ""),
|
||||
dependencies=_string_list(safe.get("dependencies")),
|
||||
optional_dependencies=_string_list(safe.get("optional_dependencies")),
|
||||
)
|
||||
|
||||
|
||||
def _linked_sources(manifest: ModuleManifest) -> tuple[RegisteredDocumentationSource, ...]:
|
||||
sources: list[RegisteredDocumentationSource] = []
|
||||
for topic in manifest.documentation:
|
||||
for link in topic.links:
|
||||
if link.kind not in {"wiki", "repository"}:
|
||||
continue
|
||||
source_id = _derived_source_id(manifest.id, link.kind, link.href)
|
||||
inspection: DocumentationSourceInspection = (
|
||||
WikiInspection(href=link.href)
|
||||
if link.kind == "wiki"
|
||||
else RepositoryInspection(href=link.href)
|
||||
)
|
||||
sources.append(_registered_source(
|
||||
source_id=source_id,
|
||||
kind=link.kind,
|
||||
owner_module_id=manifest.id,
|
||||
label=link.label,
|
||||
provenance={"source": "documentation_link", "version": manifest.version},
|
||||
inspection=inspection,
|
||||
condition=topic.conditions[0] if len(topic.conditions) == 1 else DocumentationCondition(),
|
||||
documentation_types=topic.documentation_types,
|
||||
))
|
||||
return tuple(sources)
|
||||
|
||||
|
||||
def _registered_source(
|
||||
*,
|
||||
source_id: str,
|
||||
kind: str,
|
||||
owner_module_id: str,
|
||||
label: str,
|
||||
provenance: Mapping[str, Any],
|
||||
inspection: DocumentationSourceInspection,
|
||||
condition: DocumentationCondition | None = None,
|
||||
documentation_types: tuple[DocumentationType, ...] = ("admin",),
|
||||
state: Literal["configured", "disabled", "unavailable"] = "configured",
|
||||
state_reason: str | None = None,
|
||||
configuration_key: str | None = None,
|
||||
) -> RegisteredDocumentationSource:
|
||||
clean_provenance = _safe_source_fields(provenance)
|
||||
return RegisteredDocumentationSource(
|
||||
item=DocumentationSourceItem(
|
||||
id=source_id,
|
||||
kind=kind,
|
||||
owner_module_id=owner_module_id,
|
||||
label=label,
|
||||
state=state,
|
||||
state_reason=state_reason,
|
||||
inspection_url=f"/api/v1/docs/sources/{source_id}",
|
||||
provenance=DocumentationSourceProvenance(
|
||||
source=str(clean_provenance.get("source") or "unknown"),
|
||||
version=_optional_text(clean_provenance.get("version")),
|
||||
revision=_optional_text(clean_provenance.get("revision")),
|
||||
published_at=_optional_text(clean_provenance.get("published_at")),
|
||||
checksum=_optional_text(clean_provenance.get("checksum")),
|
||||
),
|
||||
visibility=_visibility_payload(condition or DocumentationCondition(), documentation_types),
|
||||
inspection=inspection,
|
||||
),
|
||||
condition=condition or DocumentationCondition(),
|
||||
documentation_types=documentation_types,
|
||||
configuration_key=configuration_key,
|
||||
)
|
||||
|
||||
|
||||
def _catalog_sources() -> tuple[RegisteredDocumentationSource, ...]:
|
||||
from govoplan_core.core.configuration_packages import validate_configuration_package_catalog
|
||||
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
||||
|
||||
return (
|
||||
*_release_catalog_sources(
|
||||
"modules",
|
||||
"Module release catalog",
|
||||
validate_module_package_catalog(),
|
||||
),
|
||||
*_release_catalog_sources(
|
||||
"configuration_packages",
|
||||
"Configuration package catalog",
|
||||
validate_configuration_package_catalog(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _release_catalog_sources(
|
||||
catalog_type: Literal["modules", "configuration_packages"],
|
||||
label: str,
|
||||
validation: Mapping[str, object],
|
||||
) -> tuple[RegisteredDocumentationSource, ...]:
|
||||
entry_key = "modules" if catalog_type == "modules" else "packages"
|
||||
raw_entries = validation.get(entry_key)
|
||||
entries = [item for item in raw_entries if isinstance(item, Mapping)] if isinstance(raw_entries, list) else []
|
||||
configured = bool(validation.get("configured"))
|
||||
valid = bool(validation.get("valid"))
|
||||
state: Literal["configured", "disabled", "unavailable"] = (
|
||||
"configured" if configured and valid else "unavailable" if configured else "disabled"
|
||||
)
|
||||
reason = _optional_text(validation.get("error"))
|
||||
if state == "disabled":
|
||||
reason = "No catalog source is configured."
|
||||
catalog_id = f"docs.release-catalog.{catalog_type.replace('_', '-')}"
|
||||
catalog_source = _registered_source(
|
||||
source_id=catalog_id,
|
||||
kind="release_catalog",
|
||||
owner_module_id="docs",
|
||||
label=label,
|
||||
provenance={
|
||||
"source": "core_catalog_contract",
|
||||
"published_at": validation.get("generated_at"),
|
||||
},
|
||||
inspection=ReleaseCatalogInspection(
|
||||
catalog_type=catalog_type,
|
||||
channel=_optional_text(validation.get("channel")),
|
||||
sequence=_optional_int(validation.get("sequence")),
|
||||
generated_at=_optional_text(validation.get("generated_at")),
|
||||
entry_count=len(entries),
|
||||
signed=bool(validation.get("signed")),
|
||||
trusted=bool(validation.get("trusted")),
|
||||
cache_used=bool(validation.get("cache_used")),
|
||||
warnings=_string_list(validation.get("warnings")),
|
||||
entries=[_release_catalog_entry(item, catalog_type) for item in entries],
|
||||
),
|
||||
state=state,
|
||||
state_reason=reason,
|
||||
)
|
||||
if catalog_type == "modules" or not valid:
|
||||
return (catalog_source,)
|
||||
return (
|
||||
catalog_source,
|
||||
*(
|
||||
_configuration_package_source(item, validation)
|
||||
for item in entries
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _release_catalog_entry(
|
||||
item: Mapping[str, object],
|
||||
catalog_type: Literal["modules", "configuration_packages"],
|
||||
) -> ReleaseCatalogEntryInspection:
|
||||
item_id = (
|
||||
_optional_text(item.get("module_id"))
|
||||
if catalog_type == "modules"
|
||||
else _optional_text(item.get("package_id"))
|
||||
)
|
||||
return ReleaseCatalogEntryInspection(
|
||||
id=item_id or "unknown",
|
||||
name=_optional_text(item.get("name")) or item_id or "Unknown",
|
||||
version=_optional_text(item.get("version")),
|
||||
description=_optional_text(item.get("description")),
|
||||
action=_optional_text(item.get("action")),
|
||||
tags=_string_list(item.get("tags")),
|
||||
)
|
||||
|
||||
|
||||
def _configuration_package_source(
|
||||
item: Mapping[str, object],
|
||||
validation: Mapping[str, object],
|
||||
) -> RegisteredDocumentationSource:
|
||||
package_id = _optional_text(item.get("package_id")) or "unknown"
|
||||
required_modules = [
|
||||
str(requirement.get("module_id"))
|
||||
for requirement in item.get("required_modules", ())
|
||||
if isinstance(requirement, Mapping) and requirement.get("module_id")
|
||||
] if isinstance(item.get("required_modules"), (list, tuple)) else []
|
||||
return _registered_source(
|
||||
source_id=_derived_source_id("docs", "configuration-package", package_id),
|
||||
kind="configuration_package",
|
||||
owner_module_id="docs",
|
||||
label=_optional_text(item.get("name")) or package_id,
|
||||
provenance={
|
||||
"source": "configuration_package_catalog",
|
||||
"version": item.get("version"),
|
||||
"published_at": validation.get("generated_at"),
|
||||
},
|
||||
inspection=ConfigurationPackageInspection(
|
||||
package_id=package_id,
|
||||
name=_optional_text(item.get("name")),
|
||||
version=_optional_text(item.get("version")),
|
||||
schema_version=_optional_text(item.get("schema_version")),
|
||||
description=_optional_text(item.get("description")),
|
||||
publisher=_optional_text(item.get("publisher")),
|
||||
category=_optional_text(item.get("category")),
|
||||
tags=_string_list(item.get("tags")),
|
||||
required_modules=required_modules,
|
||||
required_capabilities=_string_list(item.get("required_capabilities")),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _visibility_payload(
|
||||
condition: DocumentationCondition,
|
||||
documentation_types: tuple[DocumentationType, ...],
|
||||
) -> DocumentationSourceVisibility:
|
||||
return DocumentationSourceVisibility(
|
||||
documentation_types=list(documentation_types),
|
||||
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 _derived_source_id(module_id: str, kind: str, value: str) -> str:
|
||||
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
|
||||
return f"{module_id}.{kind}.{digest}"
|
||||
|
||||
|
||||
def _safe_source_fields(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
redacted = redact_secret_values(dict(value))
|
||||
if not isinstance(redacted, Mapping):
|
||||
return {}
|
||||
return {
|
||||
str(key): item
|
||||
for key, item in redacted.items()
|
||||
if isinstance(item, (str, int, float, bool, list, tuple)) or item is None
|
||||
}
|
||||
|
||||
|
||||
def _optional_text(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text[:2_000] if text else None
|
||||
|
||||
|
||||
def _optional_int(value: object) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _capability_stability(
|
||||
value: object,
|
||||
) -> Literal["experimental", "stable", "deprecated"] | None:
|
||||
text = _optional_text(value)
|
||||
return text if text in {"experimental", "stable", "deprecated"} else None
|
||||
|
||||
|
||||
def _string_list(value: object) -> list[str]:
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return []
|
||||
return [str(item)[:500] for item in value[:100]]
|
||||
@@ -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())
|
||||
+755
-5
@@ -1,17 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from inspect import signature
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_access.backend.manifest import get_manifest as get_access_manifest
|
||||
from govoplan_core.core.modules import DocumentationCondition, DocumentationLink, DocumentationTopic, ModuleManifest, NavItem
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationConfigurationDecision,
|
||||
DocumentationConfigurationProviderRegistration,
|
||||
DocumentationSourceDefinition,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ModuleArchitectureDeclaration,
|
||||
ModuleMaturityEvidence,
|
||||
)
|
||||
from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest
|
||||
from govoplan_docs.backend.api.v1.routes import (
|
||||
_classify_documentation,
|
||||
_condition_visibility,
|
||||
_documentation_topic_anchor,
|
||||
_documentation_topic_groups,
|
||||
_documentation_provider_state,
|
||||
_module_payload,
|
||||
_visible_documentation_sources,
|
||||
docs_context,
|
||||
)
|
||||
from govoplan_docs.backend.manifest import get_manifest as get_docs_manifest
|
||||
from govoplan_docs.backend.sources import (
|
||||
_release_catalog_sources,
|
||||
build_documentation_source_registry,
|
||||
)
|
||||
|
||||
|
||||
class FakePrincipal:
|
||||
@@ -26,6 +52,177 @@ class FakePrincipal:
|
||||
|
||||
|
||||
class DocsContextTests(unittest.TestCase):
|
||||
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:
|
||||
state = {
|
||||
"configured": True,
|
||||
"active": True,
|
||||
"health": "healthy",
|
||||
"freshness": "current",
|
||||
"conflict": "clear",
|
||||
"recovery": "ready",
|
||||
"binding_ref": "calendar:sync-source:secret-context",
|
||||
"bindings": [{"binding_ref": "calendar:sync-source:secret-context"}],
|
||||
}
|
||||
|
||||
user = _documentation_provider_state(state, technical=False)
|
||||
technical = _documentation_provider_state(state, technical=True)
|
||||
|
||||
self.assertNotIn("binding_ref", user)
|
||||
self.assertNotIn("bindings", user)
|
||||
self.assertEqual("healthy", user["health"])
|
||||
self.assertIn("bindings", technical)
|
||||
|
||||
def test_module_architecture_projection_hides_evidence_from_user_docs(self) -> None:
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
architecture=ModuleArchitectureDeclaration(
|
||||
layer="governance_accountability",
|
||||
kind="governance",
|
||||
maturity="vertical_slice",
|
||||
evidence=(
|
||||
ModuleMaturityEvidence(
|
||||
kind="test",
|
||||
reference="tests/test_example.py",
|
||||
summary="Private implementation evidence.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/EXAMPLE.md",
|
||||
summary="Architecture explanation.",
|
||||
),
|
||||
),
|
||||
known_limits=("Example limit.",),
|
||||
supported_authority_modes=("linked_reference",),
|
||||
owned_concepts=("examples",),
|
||||
),
|
||||
)
|
||||
|
||||
technical = _module_payload(manifest, technical=True)
|
||||
user = _module_payload(manifest, technical=False)
|
||||
|
||||
self.assertEqual("vertical_slice", technical["architecture"]["maturity"])
|
||||
self.assertEqual(2, len(technical["architecture"]["evidence"]))
|
||||
self.assertEqual([], user["architecture"]["evidence"])
|
||||
self.assertEqual(["Example limit."], user["architecture"]["known_limits"])
|
||||
|
||||
def test_topics_are_filtered_by_installed_or_selected_version(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="2.1.0",
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="example.legacy",
|
||||
title="Legacy",
|
||||
summary="Legacy behavior",
|
||||
version_max_exclusive="2.0.0",
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="example.current",
|
||||
title="Current",
|
||||
summary="Current behavior",
|
||||
version_min="2.0.0",
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="example.universal",
|
||||
title="Universal",
|
||||
summary="All versions",
|
||||
),
|
||||
),
|
||||
))
|
||||
principal = FakePrincipal({"docs:documentation:read"})
|
||||
|
||||
installed = _classify_documentation(
|
||||
registry,
|
||||
principal,
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="admin",
|
||||
locale="en",
|
||||
)
|
||||
selected = _classify_documentation(
|
||||
registry,
|
||||
principal,
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="admin",
|
||||
locale="en",
|
||||
target_version="1.9.0",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{topic["id"] for topic in installed["configured"]},
|
||||
{"example.current", "example.universal"},
|
||||
)
|
||||
self.assertEqual(
|
||||
{topic["id"] for topic in selected["configured"]},
|
||||
{"example.legacy", "example.universal"},
|
||||
)
|
||||
universal = next(
|
||||
topic for topic in selected["configured"]
|
||||
if topic["id"] == "example.universal"
|
||||
)
|
||||
self.assertEqual(universal["version"]["fallback"], "unversioned")
|
||||
|
||||
def test_docs_reader_is_the_managed_authenticated_tenant_default(self) -> None:
|
||||
manifest = get_docs_manifest()
|
||||
roles = {template.slug: template for template in manifest.role_templates}
|
||||
|
||||
self.assertTrue(roles["docs_reader"].default_authenticated)
|
||||
self.assertTrue(roles["docs_reader"].managed)
|
||||
self.assertEqual(roles["docs_reader"].level, "tenant")
|
||||
self.assertEqual(
|
||||
roles["docs_reader"].permissions,
|
||||
("docs:documentation:read",),
|
||||
)
|
||||
|
||||
def test_topic_groups_preserve_layer_classification(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(get_tenancy_manifest())
|
||||
@@ -58,7 +255,7 @@ class DocsContextTests(unittest.TestCase):
|
||||
self.assertIn("access.workflow.grant-user-access", configured_ids)
|
||||
self.assertIn("docs.pattern.field-help", always_ids)
|
||||
|
||||
def test_unavailable_topic_still_appears_in_kind_group(self) -> None:
|
||||
def test_user_projection_omits_topics_without_required_access(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(get_tenancy_manifest())
|
||||
registry.register(get_access_manifest())
|
||||
@@ -74,11 +271,212 @@ class DocsContextTests(unittest.TestCase):
|
||||
locale="en",
|
||||
)
|
||||
groups = _documentation_topic_groups(layers)
|
||||
workflows = {topic["id"]: topic for topic in groups["workflow"]}
|
||||
workflow_ids = {topic["id"] for topic in groups["workflow"]}
|
||||
|
||||
self.assertIn("access.workflow.grant-user-access", workflows)
|
||||
self.assertFalse(workflows["access.workflow.grant-user-access"]["active"])
|
||||
self.assertIn("access.workflow.grant-user-access", {topic["id"] for topic in layers["available"]})
|
||||
self.assertNotIn("access.workflow.grant-user-access", workflow_ids)
|
||||
self.assertNotIn("access.workflow.grant-user-access", {topic["id"] for topic in layers["available"]})
|
||||
|
||||
def test_user_projection_fails_closed_for_unscoped_workflow_topics(self) -> None:
|
||||
unscoped_static_topic = DocumentationTopic(
|
||||
id="example.workflow.unscoped-static",
|
||||
title="Unscoped static task",
|
||||
summary="This topic must not be rendered.",
|
||||
documentation_types=("user",),
|
||||
metadata={"kind": "workflow"},
|
||||
)
|
||||
unscoped_runtime_topic = DocumentationTopic(
|
||||
id="example.workflow.unscoped-runtime",
|
||||
title="Unscoped runtime task",
|
||||
summary="This provider topic must not be rendered.",
|
||||
documentation_types=("user",),
|
||||
conditions=(DocumentationCondition(required_modules=("example",)),),
|
||||
metadata={"kind": "workflow"},
|
||||
)
|
||||
scoped_runtime_topic = DocumentationTopic(
|
||||
id="example.workflow.scoped-runtime",
|
||||
title="Scoped runtime task",
|
||||
summary="This provider topic remains visible.",
|
||||
documentation_types=("user",),
|
||||
conditions=(DocumentationCondition(required_scopes=("docs:documentation:read",)),),
|
||||
metadata={"kind": "workflow"},
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="9.9.9",
|
||||
documentation=(unscoped_static_topic,),
|
||||
documentation_providers=(
|
||||
lambda context: (unscoped_runtime_topic, scoped_runtime_topic),
|
||||
),
|
||||
))
|
||||
|
||||
layers = _classify_documentation(
|
||||
registry,
|
||||
FakePrincipal({"docs:documentation:read"}),
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="user",
|
||||
locale="en",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[topic["id"] for topic in _documentation_topic_groups(layers)["workflow"]],
|
||||
["example.workflow.scoped-runtime"],
|
||||
)
|
||||
|
||||
def test_user_projection_is_a_bounded_safe_whitelist(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="9.9.9",
|
||||
nav_items=(NavItem(path="/example", label="Example", required_any=("docs:documentation:read",)),),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="example.workflow.safe",
|
||||
title="Safe task",
|
||||
summary="A task the actor may perform.",
|
||||
documentation_types=("user",),
|
||||
conditions=(DocumentationCondition(required_scopes=("docs:documentation:read",)),),
|
||||
links=(
|
||||
DocumentationLink(label="Open task", href="/example", kind="runtime"),
|
||||
DocumentationLink(label="Unsafe runtime", href="javascript:alert(1)", kind="runtime"),
|
||||
DocumentationLink(label="Backslash runtime", href="/\\evil.invalid", kind="runtime"),
|
||||
DocumentationLink(label="Invisible runtime", href="/hidden", kind="runtime"),
|
||||
DocumentationLink(label="Shell settings", href="/settings?section=example", kind="runtime"),
|
||||
DocumentationLink(label="Public help", href="https://example.invalid/help", kind="public"),
|
||||
DocumentationLink(label="Malformed public", href="https://[", kind="public"),
|
||||
DocumentationLink(label="Repository", href="example/docs/SECRET.md", kind="repository"),
|
||||
DocumentationLink(label="API", href="/api/v1/example", kind="api"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"screen": "Example",
|
||||
"steps": ["Do the safe thing."],
|
||||
"help_contexts": ["example.list"],
|
||||
"current_configuration": ["The bounded option is enabled."],
|
||||
"constraints": [{
|
||||
"id": "domain",
|
||||
"label": "Domain",
|
||||
"description": "Use an approved domain.",
|
||||
"values": ["*.example.invalid"],
|
||||
"secret": "must-not-pass",
|
||||
}],
|
||||
"raw_policy": {"secret": "must-not-pass"},
|
||||
"api_path": "/api/v1/internal",
|
||||
},
|
||||
),
|
||||
),
|
||||
))
|
||||
|
||||
layers = _classify_documentation(
|
||||
registry,
|
||||
FakePrincipal({"docs:documentation:read"}),
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="user",
|
||||
locale="en",
|
||||
)
|
||||
topic = layers["configured"][0]
|
||||
|
||||
self.assertEqual(
|
||||
[link["href"] for link in topic["links"]],
|
||||
["/example", "/settings?section=example", "https://example.invalid/help"],
|
||||
)
|
||||
self.assertEqual(topic["conditions"], [])
|
||||
self.assertEqual(topic["configuration_keys"], [])
|
||||
self.assertEqual(topic["blockers"], {
|
||||
"modules": [],
|
||||
"capabilities": [],
|
||||
"scopes": [],
|
||||
"configuration": [],
|
||||
})
|
||||
self.assertNotIn("raw_policy", topic["metadata"])
|
||||
self.assertNotIn("api_path", topic["metadata"])
|
||||
self.assertEqual(topic["metadata"]["help_contexts"], ["example.list"])
|
||||
self.assertEqual(topic["metadata"]["constraints"][0], {
|
||||
"id": "domain",
|
||||
"label": "Domain",
|
||||
"description": "Use an approved domain.",
|
||||
"values": ["*.example.invalid"],
|
||||
})
|
||||
|
||||
def test_runtime_provider_failure_is_diagnostic_only(self) -> None:
|
||||
def failing_provider(_context):
|
||||
raise RuntimeError("sensitive provider detail")
|
||||
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="private-module",
|
||||
name="Private module",
|
||||
version="1.0.0",
|
||||
documentation_providers=(failing_provider,),
|
||||
))
|
||||
principal = FakePrincipal({"docs:documentation:read"})
|
||||
|
||||
user_layers = _classify_documentation(
|
||||
registry,
|
||||
principal,
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="user",
|
||||
locale="en",
|
||||
)
|
||||
admin_layers = _classify_documentation(
|
||||
registry,
|
||||
principal,
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="admin",
|
||||
locale="en",
|
||||
)
|
||||
|
||||
self.assertFalse(any(user_layers.values()))
|
||||
self.assertEqual(
|
||||
[topic["id"] for topic in admin_layers["evidence"]],
|
||||
["private-module.runtime-documentation-unavailable"],
|
||||
)
|
||||
|
||||
def test_docs_default_to_user_and_admin_projection_requires_admin_authority(self) -> None:
|
||||
query_default = signature(docs_context).parameters["documentation_type"].default
|
||||
self.assertEqual(query_default.default, "user")
|
||||
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
docs_context(
|
||||
SimpleNamespace(),
|
||||
documentation_type="admin",
|
||||
locale="en",
|
||||
principal=FakePrincipal({"docs:documentation:read"}),
|
||||
)
|
||||
self.assertEqual(raised.exception.status_code, 403)
|
||||
|
||||
def test_user_actor_projection_does_not_disclose_identity_or_scope_count(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(get_docs_manifest())
|
||||
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(govoplan_registry=registry)))
|
||||
empty_layers = {"always": [], "configured": [], "available": [], "evidence": []}
|
||||
|
||||
with patch("govoplan_docs.backend.api.v1.routes._documentation_layers", return_value=empty_layers):
|
||||
payload = docs_context(
|
||||
request,
|
||||
documentation_type="user",
|
||||
locale="en",
|
||||
principal=FakePrincipal({"docs:documentation:read"}),
|
||||
)
|
||||
|
||||
self.assertEqual(payload["actor"]["available_documentation_types"], ["user"])
|
||||
self.assertNotIn("tenant_id", payload["actor"])
|
||||
self.assertNotIn("user_id", payload["actor"])
|
||||
self.assertNotIn("scope_count", payload["actor"])
|
||||
self.assertEqual(payload["layers"]["configured"]["permissions"], [])
|
||||
self.assertEqual(payload["layers"]["available"]["routes"], [])
|
||||
self.assertEqual(payload["layers"]["evidence"]["sources"], [])
|
||||
self.assertEqual(
|
||||
[(item["module_id"], item["path"]) for item in payload["layers"]["configured"]["routes"]],
|
||||
[("docs", "/docs")],
|
||||
)
|
||||
self.assertEqual(payload["summary"]["visible_route_count"], 1)
|
||||
|
||||
def test_topic_anchor_is_stable(self) -> None:
|
||||
self.assertEqual(
|
||||
@@ -86,6 +484,358 @@ class DocsContextTests(unittest.TestCase):
|
||||
"docs-topic-access-access-workflow-grant-user-access",
|
||||
)
|
||||
|
||||
def test_condition_visibility_reports_module_capability_and_scope_blockers(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
condition = DocumentationCondition(
|
||||
required_modules=("mail",),
|
||||
any_modules=("files", "campaigns"),
|
||||
missing_modules=("legacy",),
|
||||
required_capabilities=("mail.delivery",),
|
||||
required_scopes=("docs:documentation:read",),
|
||||
any_scopes=("mail:profile:read", "admin:policies:read"),
|
||||
)
|
||||
principal = FakePrincipal({"docs:documentation:read"})
|
||||
|
||||
active, reason, blockers = _condition_visibility(condition, {"legacy"}, registry, principal)
|
||||
|
||||
self.assertFalse(active)
|
||||
self.assertEqual(
|
||||
reason,
|
||||
"missing modules: mail; "
|
||||
"requires one installed module from: files, campaigns; "
|
||||
"not active when installed: legacy; "
|
||||
"missing capabilities: mail.delivery; "
|
||||
"requires one scope from: mail:profile:read, admin:policies:read",
|
||||
)
|
||||
self.assertEqual(blockers["modules"], ["mail", "files", "campaigns", "legacy"])
|
||||
self.assertEqual(blockers["capabilities"], ["mail.delivery"])
|
||||
self.assertEqual(blockers["scopes"], ["mail:profile:read", "admin:policies:read"])
|
||||
self.assertEqual(blockers["configuration"], [])
|
||||
|
||||
def test_condition_visibility_accepts_any_module_scope_and_capability(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register_capability_factory("mail", "mail.delivery", lambda context: object())
|
||||
condition = DocumentationCondition(
|
||||
any_modules=("files", "campaigns"),
|
||||
required_capabilities=("mail.delivery",),
|
||||
any_scopes=("mail:profile:read", "admin:policies:read"),
|
||||
)
|
||||
principal = FakePrincipal({"admin:policies:read"})
|
||||
|
||||
active, reason, blockers = _condition_visibility(condition, {"campaigns"}, registry, principal)
|
||||
|
||||
self.assertTrue(active)
|
||||
self.assertEqual(reason, "conditions satisfied")
|
||||
self.assertEqual(blockers, {
|
||||
"modules": [],
|
||||
"capabilities": [],
|
||||
"scopes": [],
|
||||
"configuration": [],
|
||||
})
|
||||
|
||||
def test_configuration_states_gate_conditions_without_disclosing_values(self) -> None:
|
||||
states = {
|
||||
"feature.enabled": "enabled",
|
||||
"feature.inherited": "inherited",
|
||||
"feature.disabled": "disabled",
|
||||
"feature.unavailable": "unavailable",
|
||||
}
|
||||
|
||||
def resolve(_context, keys):
|
||||
return {
|
||||
key: DocumentationConfigurationDecision(
|
||||
key=key,
|
||||
state=states[key], # type: ignore[arg-type]
|
||||
source="tenant" if states[key] == "enabled" else "system",
|
||||
reason="State metadata only.",
|
||||
)
|
||||
for key in keys
|
||||
}
|
||||
|
||||
topics = tuple(
|
||||
DocumentationTopic(
|
||||
id=f"example.{key}",
|
||||
title=key,
|
||||
summary="Configuration-aware topic.",
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_scopes=("docs:documentation:read",),
|
||||
configuration_keys=(key,),
|
||||
),
|
||||
),
|
||||
configuration_keys=(key,),
|
||||
)
|
||||
for key in states
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation=topics,
|
||||
documentation_configuration_providers=(
|
||||
DocumentationConfigurationProviderRegistration(
|
||||
keys=tuple(states),
|
||||
resolve=resolve,
|
||||
),
|
||||
),
|
||||
))
|
||||
|
||||
layers = _classify_documentation(
|
||||
registry,
|
||||
FakePrincipal({"docs:documentation:read"}),
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="admin",
|
||||
locale="en",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
{topic["id"] for topic in layers["configured"]},
|
||||
{"example.feature.enabled", "example.feature.inherited"},
|
||||
)
|
||||
self.assertEqual(
|
||||
[topic["id"] for topic in layers["available"]],
|
||||
["example.feature.disabled"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[topic["id"] for topic in layers["evidence"]],
|
||||
["example.feature.unavailable"],
|
||||
)
|
||||
rendered = repr(layers)
|
||||
self.assertNotIn("raw value", rendered.casefold())
|
||||
self.assertNotIn("password", rendered.casefold())
|
||||
|
||||
def test_source_registry_covers_every_kind_and_redacts_explicit_payloads(self) -> None:
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.2.3",
|
||||
capability_factories={
|
||||
"example.lookup": lambda _context: object(),
|
||||
"policy.example": lambda _context: object(),
|
||||
},
|
||||
capability_documentation={
|
||||
"example.lookup": CapabilityDocumentation(
|
||||
label="Example lookup",
|
||||
summary="Resolves example records through a stable provider contract.",
|
||||
contract_version="2",
|
||||
audience=("module_admin",),
|
||||
),
|
||||
},
|
||||
frontend=FrontendModule(
|
||||
module_id="example",
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/example",
|
||||
component="ExamplePage",
|
||||
required_all=("example:item:read",),
|
||||
),
|
||||
),
|
||||
),
|
||||
documentation_sources=(
|
||||
DocumentationSourceDefinition(
|
||||
id="example.configuration.package",
|
||||
kind="configuration_package",
|
||||
label="Example package",
|
||||
inspection={
|
||||
"package_id": "example.package",
|
||||
"schema_version": "2",
|
||||
"password": "must-not-leak",
|
||||
},
|
||||
),
|
||||
DocumentationSourceDefinition(
|
||||
id="example.project.wiki",
|
||||
kind="wiki",
|
||||
label="Example wiki",
|
||||
link=DocumentationLink(
|
||||
label="Wiki",
|
||||
href="https://example.invalid/wiki",
|
||||
kind="wiki",
|
||||
),
|
||||
),
|
||||
DocumentationSourceDefinition(
|
||||
id="example.repository.handbook",
|
||||
kind="repository",
|
||||
label="Example handbook",
|
||||
link=DocumentationLink(
|
||||
label="Handbook",
|
||||
href="example/docs/HANDBOOK.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
sources = build_documentation_source_registry(
|
||||
(manifest,),
|
||||
include_runtime_catalogs=False,
|
||||
).sources()
|
||||
|
||||
self.assertEqual(
|
||||
{source.item.kind for source in sources},
|
||||
{
|
||||
"manifest",
|
||||
"route",
|
||||
"capability",
|
||||
"policy",
|
||||
"configuration_package",
|
||||
"wiki",
|
||||
"repository",
|
||||
},
|
||||
)
|
||||
self.assertNotIn("must-not-leak", repr(sources))
|
||||
capability_source = next(
|
||||
source for source in sources
|
||||
if source.item.kind == "capability"
|
||||
)
|
||||
self.assertEqual(capability_source.item.label, "Example lookup")
|
||||
self.assertEqual(capability_source.item.inspection.summary, "Resolves example records through a stable provider contract.")
|
||||
self.assertEqual(capability_source.item.inspection.contract_version, "2")
|
||||
self.assertEqual(capability_source.item.inspection.audience, ["module_admin"])
|
||||
for source in sources:
|
||||
self.assertTrue(source.item.id.startswith("example."))
|
||||
self.assertEqual("example", source.item.owner_module_id)
|
||||
self.assertEqual(
|
||||
f"/api/v1/docs/sources/{source.item.id}",
|
||||
source.item.inspection_url,
|
||||
)
|
||||
|
||||
def test_release_and_configuration_catalogs_become_typed_sources(self) -> None:
|
||||
release_sources = _release_catalog_sources(
|
||||
"modules",
|
||||
"Module release catalog",
|
||||
{
|
||||
"configured": True,
|
||||
"valid": True,
|
||||
"channel": "stable",
|
||||
"sequence": 12,
|
||||
"generated_at": "2026-07-31T10:00:00Z",
|
||||
"signed": True,
|
||||
"trusted": True,
|
||||
"cache_used": False,
|
||||
"warnings": [],
|
||||
"modules": [{
|
||||
"module_id": "files",
|
||||
"name": "Files",
|
||||
"version": "0.1.10",
|
||||
"description": "Managed files.",
|
||||
"action": "install",
|
||||
"tags": ["official"],
|
||||
}],
|
||||
},
|
||||
)
|
||||
self.assertEqual(len(release_sources), 1)
|
||||
release = release_sources[0].item
|
||||
self.assertEqual(release.kind, "release_catalog")
|
||||
self.assertEqual(release.state, "configured")
|
||||
self.assertEqual(release.inspection.entry_count, 1)
|
||||
self.assertEqual(release.inspection.entries[0].description, "Managed files.")
|
||||
|
||||
configuration_sources = _release_catalog_sources(
|
||||
"configuration_packages",
|
||||
"Configuration package catalog",
|
||||
{
|
||||
"configured": True,
|
||||
"valid": True,
|
||||
"channel": "stable",
|
||||
"signed": True,
|
||||
"trusted": True,
|
||||
"packages": [{
|
||||
"package_id": "public-service.base",
|
||||
"name": "Public service base",
|
||||
"version": "3",
|
||||
"description": "Baseline configuration for a public service.",
|
||||
"required_modules": [{"module_id": "access"}],
|
||||
"required_capabilities": ["policy.retention"],
|
||||
"tags": ["reference"],
|
||||
}],
|
||||
},
|
||||
)
|
||||
self.assertEqual(len(configuration_sources), 2)
|
||||
package = next(
|
||||
source.item for source in configuration_sources
|
||||
if source.item.kind == "configuration_package"
|
||||
)
|
||||
self.assertEqual(package.inspection.package_id, "public-service.base")
|
||||
self.assertEqual(package.inspection.required_modules, ["access"])
|
||||
self.assertEqual(package.inspection.required_capabilities, ["policy.retention"])
|
||||
|
||||
def test_docs_manifest_links_repository_and_wiki_sources(self) -> None:
|
||||
sources = build_documentation_source_registry(
|
||||
(get_docs_manifest(),),
|
||||
include_runtime_catalogs=False,
|
||||
).sources()
|
||||
|
||||
source_kinds = {source.item.kind for source in sources}
|
||||
self.assertIn("repository", source_kinds)
|
||||
self.assertIn("wiki", source_kinds)
|
||||
self.assertTrue(any(
|
||||
source.item.inspection.href.endswith("/govoplan-docs/wiki")
|
||||
for source in sources
|
||||
if source.item.kind == "wiki"
|
||||
))
|
||||
self.assertTrue(any(
|
||||
"DOCUMENTATION_LAYER_CONCEPT.md" in source.item.inspection.href
|
||||
for source in sources
|
||||
if source.item.kind == "repository"
|
||||
))
|
||||
|
||||
def test_source_visibility_hides_unauthorized_ids(self) -> None:
|
||||
manifest = ModuleManifest(
|
||||
id="example",
|
||||
name="Example",
|
||||
version="1.0.0",
|
||||
documentation_sources=(
|
||||
DocumentationSourceDefinition(
|
||||
id="example.protected.handbook",
|
||||
kind="repository",
|
||||
label="Protected handbook",
|
||||
condition=DocumentationCondition(
|
||||
required_scopes=("example:handbook:read",),
|
||||
),
|
||||
link=DocumentationLink(
|
||||
label="Protected",
|
||||
href="example/docs/PROTECTED.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
registry = PlatformRegistry()
|
||||
registry.register(manifest)
|
||||
sources = build_documentation_source_registry(
|
||||
(manifest,),
|
||||
include_runtime_catalogs=False,
|
||||
).sources()
|
||||
|
||||
denied = _visible_documentation_sources(
|
||||
sources,
|
||||
registry,
|
||||
FakePrincipal({"docs:documentation:admin"}),
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="admin",
|
||||
locale="en",
|
||||
)
|
||||
allowed = _visible_documentation_sources(
|
||||
sources,
|
||||
registry,
|
||||
FakePrincipal({
|
||||
"docs:documentation:admin",
|
||||
"example:handbook:read",
|
||||
}),
|
||||
settings=None,
|
||||
session=None,
|
||||
documentation_type="admin",
|
||||
locale="en",
|
||||
)
|
||||
|
||||
denied_ids = {source.item.id for source in denied}
|
||||
allowed_ids = {source.item.id for source in allowed}
|
||||
self.assertNotIn("example.protected.handbook", denied_ids)
|
||||
self.assertIn("example.protected.handbook", allowed_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -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(9, 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()
|
||||
@@ -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()
|
||||
+10
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/docs-webui",
|
||||
"version": "0.1.8",
|
||||
"version": "0.1.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -12,15 +12,18 @@
|
||||
"import": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.8",
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.6"
|
||||
"vite": "^7.3.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
+231
-6
@@ -1,5 +1,42 @@
|
||||
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
|
||||
|
||||
export type DocsModuleArchitecture = {
|
||||
contract_version: string;
|
||||
layer: string;
|
||||
kind: string;
|
||||
maturity: string;
|
||||
evidence: Array<{ kind: string; reference: string; summary: string }>;
|
||||
known_limits: string[];
|
||||
supported_authority_modes: string[];
|
||||
owned_concepts: string[];
|
||||
non_owned_concepts: string[];
|
||||
reference_packages: string[];
|
||||
target_tested_providers: string[];
|
||||
documentation: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export type DocsExternalProvider = {
|
||||
id: string;
|
||||
module_id: string;
|
||||
label: string;
|
||||
maturity: string;
|
||||
operations: string[];
|
||||
authority_modes: string[];
|
||||
behavior?: Record<string, unknown>;
|
||||
known_outage_behavior?: string | null;
|
||||
runtime_state?: {
|
||||
configured?: boolean;
|
||||
active?: boolean;
|
||||
authority_mode?: string | null;
|
||||
authority_modes?: string[];
|
||||
health?: string;
|
||||
freshness?: string;
|
||||
conflict?: string;
|
||||
recovery?: string;
|
||||
observed_at?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type DocsModule = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -16,6 +53,9 @@ export type DocsModule = {
|
||||
capabilities: string[];
|
||||
documentation_count: number;
|
||||
documentation_provider_count: number;
|
||||
architecture?: DocsModuleArchitecture | null;
|
||||
external_provider_count: number;
|
||||
external_providers: DocsExternalProvider[];
|
||||
};
|
||||
|
||||
export type DocsRoute = {
|
||||
@@ -54,9 +94,34 @@ export type DocsOptionalModuleEvidence = {
|
||||
};
|
||||
|
||||
export type DocsSource = {
|
||||
id: string;
|
||||
kind: string;
|
||||
owner_module_id: string;
|
||||
label: string;
|
||||
state: "configured" | "disabled" | "unavailable";
|
||||
state_reason?: string | null;
|
||||
inspection_url: string;
|
||||
provenance: {
|
||||
source: string;
|
||||
layer: string;
|
||||
version?: string | null;
|
||||
revision?: string | null;
|
||||
published_at?: string | null;
|
||||
checksum?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type DocsSourceDetail = DocsSource & {
|
||||
visibility: {
|
||||
documentation_types: string[];
|
||||
required_modules: string[];
|
||||
any_modules: string[];
|
||||
missing_modules: string[];
|
||||
required_capabilities: string[];
|
||||
required_scopes: string[];
|
||||
any_scopes: string[];
|
||||
configuration_keys: string[];
|
||||
};
|
||||
inspection: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DocsDocumentationCondition = {
|
||||
@@ -94,30 +159,59 @@ export type DocsDocumentationTopic = {
|
||||
modules: string[];
|
||||
capabilities: string[];
|
||||
scopes: string[];
|
||||
configuration: string[];
|
||||
};
|
||||
audience: string[];
|
||||
order: number;
|
||||
i18n_key: string;
|
||||
locale: string;
|
||||
translation_locale: string;
|
||||
structured_translation_locale: string;
|
||||
structured_translation_version?: string | null;
|
||||
version: {
|
||||
resolved: string;
|
||||
minimum?: string | null;
|
||||
maximum_exclusive?: string | null;
|
||||
range: string;
|
||||
fallback: "unversioned" | "matching_range" | string;
|
||||
};
|
||||
conditions: DocsDocumentationCondition[];
|
||||
links: DocsDocumentationLink[];
|
||||
related_modules: string[];
|
||||
unlocks: string[];
|
||||
configuration_keys: string[];
|
||||
configuration_states: Array<{
|
||||
key: string;
|
||||
state: "enabled" | "disabled" | "inherited" | "unavailable";
|
||||
source?: string | null;
|
||||
reason?: string | null;
|
||||
}>;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type DocsContext = {
|
||||
versions: {
|
||||
mode: "installed" | "selected";
|
||||
selected_version?: string | null;
|
||||
status: "installed" | "stable" | "older_supported" | "unsupported" | string;
|
||||
latest_version?: string | null;
|
||||
stable_version?: string | null;
|
||||
supported_versions: string[];
|
||||
installed_versions: Record<string, string>;
|
||||
fallback_policy: string;
|
||||
};
|
||||
actor: {
|
||||
tenant_id: string;
|
||||
user_id: string;
|
||||
scope_count: number;
|
||||
tenant_id?: string;
|
||||
user_id?: string;
|
||||
scope_count?: number;
|
||||
documentation_type: "admin" | "user";
|
||||
locale: string;
|
||||
available_documentation_types: Array<"admin" | "user">;
|
||||
};
|
||||
summary: {
|
||||
module_count: number;
|
||||
architecture_declared_module_count: number;
|
||||
external_provider_count: number;
|
||||
visible_route_count: number;
|
||||
available_route_count: number;
|
||||
permission_count: number;
|
||||
@@ -160,10 +254,141 @@ export type DocsContext = {
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchDocsContext(settings: ApiSettings, options: { documentationType?: "admin" | "user"; locale?: string } = {}): Promise<DocsContext> {
|
||||
export function fetchDocsContext(settings: ApiSettings, options: { documentationType?: "admin" | "user"; locale?: string; version?: string | null } = {}): Promise<DocsContext> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.documentationType) params.set("type", options.documentationType);
|
||||
if (options.locale) params.set("locale", options.locale);
|
||||
if (options.version) params.set("version", options.version);
|
||||
const query = params.toString();
|
||||
return apiFetch(settings, `/api/v1/docs/context${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export function fetchDocsSource(
|
||||
settings: ApiSettings,
|
||||
sourceId: string,
|
||||
options: { documentationType?: "admin" | "user"; locale?: string } = {}
|
||||
): Promise<DocsSourceDetail> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.documentationType) params.set("type", options.documentationType);
|
||||
if (options.locale) params.set("locale", options.locale);
|
||||
const query = params.toString();
|
||||
return apiFetch(settings, `/api/v1/docs/context${query ? `?${query}` : ""}`);
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/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 })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
|
||||
import { DescriptionList } from "@govoplan/core-webui";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { ChevronDown, ChevronRight, Eye } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
ExplorerTree,
|
||||
LoadingFrame,
|
||||
PageTitle,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
WorkspaceLayout,
|
||||
adminErrorMessage,
|
||||
useGuardedNavigate,
|
||||
usePlatformLanguage,
|
||||
type ApiSettings
|
||||
type ApiSettings,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
fetchDocsContext,
|
||||
fetchDocsSource,
|
||||
type DocsContext,
|
||||
type DocsDocumentationTopic,
|
||||
type DocsModule,
|
||||
type DocsOptionalModuleEvidence,
|
||||
type DocsRoute,
|
||||
type DocsSource
|
||||
type DocsSource,
|
||||
type DocsSourceDetail
|
||||
} from "../../api/docs";
|
||||
|
||||
type DocumentationType = "admin" | "user";
|
||||
@@ -65,20 +72,30 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
const [context, setContext] = useState<DocsContext | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const loadSequence = useRef(0);
|
||||
const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search));
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set());
|
||||
const locale = localeFromSearch(location.search) ?? language;
|
||||
const selectedVersion = versionFromSearch(location.search);
|
||||
const adminDocs = documentationType === "admin";
|
||||
|
||||
async function load() {
|
||||
const sequence = ++loadSequence.current;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setContext(null);
|
||||
try {
|
||||
setContext(await fetchDocsContext(settings, { documentationType, locale }));
|
||||
const nextContext = await fetchDocsContext(settings, { documentationType, locale, version: selectedVersion });
|
||||
if (sequence !== loadSequence.current) return;
|
||||
if (nextContext.actor.documentation_type !== documentationType) {
|
||||
throw new Error("Documentation response type did not match the requested projection.");
|
||||
}
|
||||
setContext(nextContext);
|
||||
} catch (err) {
|
||||
if (sequence !== loadSequence.current) return;
|
||||
setError(adminErrorMessage(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (sequence === loadSequence.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +104,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
setDocumentationType((current) => current === nextType ? current : nextType);
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, documentationType, locale]);
|
||||
useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, documentationType, locale, selectedVersion]);
|
||||
|
||||
const treeNodes = useMemo(() => docsTreeNodes(context, adminDocs), [context, adminDocs]);
|
||||
const pages = useMemo(() => flattenTreePages(treeNodes), [treeNodes]);
|
||||
@@ -117,11 +134,37 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
}, [treeNodes, selectedPage?.id]);
|
||||
|
||||
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">
|
||||
<div className="docs-sidebar-header">
|
||||
<div className="section-title">i18n:govoplan-docs.help_center.f3f3a34b</div>
|
||||
<AudienceToggle selected={documentationType} onSelect={selectDocumentationType} />
|
||||
<AudienceToggle
|
||||
selected={documentationType}
|
||||
onSelect={selectDocumentationType}
|
||||
canViewAdmin={context?.actor.available_documentation_types.includes("admin") ?? documentationType === "admin"}
|
||||
/>
|
||||
<label className="docs-version-selector" title={context?.versions.fallback_policy}>
|
||||
<span>Version</span>
|
||||
<select
|
||||
value={selectedVersion ?? ""}
|
||||
onChange={(event) => selectVersion(event.target.value || null)}
|
||||
>
|
||||
<option value="">Installed versions</option>
|
||||
{selectedVersion && !context?.versions.supported_versions.includes(selectedVersion) &&
|
||||
<option value={selectedVersion}>{selectedVersion} (unsupported)</option>
|
||||
}
|
||||
{(context?.versions.supported_versions ?? []).map((version) => (
|
||||
<option key={version} value={version}>{version}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
||||
<ExplorerTree
|
||||
@@ -146,21 +189,20 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
/>
|
||||
</nav>
|
||||
</aside>
|
||||
<section className="workspace-content docs-workspace-content">
|
||||
<div className="content-pad workspace-data-page docs-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={loading}>i18n:govoplan-docs.help_center.f3f3a34b</PageTitle>
|
||||
<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>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void load()} disabled={loading}><RefreshCw size={16} /> i18n:govoplan-docs.reload.cce71553</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-docs.loading_documentation_context.1c091645">
|
||||
)}
|
||||
>
|
||||
<PageLayout
|
||||
archetype="workspace"
|
||||
title="i18n:govoplan-docs.help_center.f3f3a34b"
|
||||
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"}
|
||||
actions={<PageActionBar variant="workspace" refreshable reloadAction={{ onReload: () => void load(), loading }} />}
|
||||
loading={loading}
|
||||
loadingLabel="i18n:govoplan-docs.loading_documentation_context.1c091645"
|
||||
error={error}
|
||||
mode="workspace"
|
||||
documentationType={documentationType}
|
||||
className="docs-page"
|
||||
>
|
||||
<div className="docs-content">
|
||||
<main className="docs-page-main">
|
||||
<SelectedPageContent
|
||||
@@ -174,14 +216,14 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
grantedPermissions={grantedPermissions}
|
||||
evidenceModules={context?.layers.evidence.optional_modules ?? []}
|
||||
evidenceSources={context?.layers.evidence.sources ?? []}
|
||||
settings={settings}
|
||||
locale={locale}
|
||||
/>
|
||||
</main>
|
||||
<PageOutline items={outlineItems} />
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
|
||||
function selectDocumentationType(type: DocumentationType) {
|
||||
@@ -190,6 +232,13 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
||||
}
|
||||
|
||||
function selectVersion(version: string | null) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (version) params.set("version", version);
|
||||
else params.delete("version");
|
||||
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
||||
}
|
||||
|
||||
function selectPage(page: DocsPageNode) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("topic", page.id);
|
||||
@@ -209,7 +258,13 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
||||
}
|
||||
}
|
||||
|
||||
function AudienceToggle({ selected, onSelect }: { selected: DocumentationType; onSelect: (type: DocumentationType) => void }) {
|
||||
function AudienceToggle({ selected, onSelect, canViewAdmin }: { selected: DocumentationType; onSelect: (type: DocumentationType) => void; canViewAdmin: boolean }) {
|
||||
const options: Array<{ id: DocumentationType; label: string }> = [
|
||||
{ id: "user", label: "i18n:govoplan-docs.user_docs.1e38e8d3" }
|
||||
];
|
||||
if (canViewAdmin) {
|
||||
options.unshift({ id: "admin", label: "i18n:govoplan-docs.admin_docs.bf504a56" });
|
||||
}
|
||||
return (
|
||||
<SegmentedControl
|
||||
className="docs-audience-toggle"
|
||||
@@ -219,10 +274,7 @@ function AudienceToggle({ selected, onSelect }: { selected: DocumentationType; o
|
||||
ariaLabel="i18n:govoplan-docs.documentation_type.5a66690c"
|
||||
value={selected}
|
||||
onChange={onSelect}
|
||||
options={[
|
||||
{ id: "admin", label: "i18n:govoplan-docs.admin_docs.bf504a56" },
|
||||
{ id: "user", label: "i18n:govoplan-docs.user_docs.1e38e8d3" }
|
||||
]}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -251,7 +303,9 @@ function SelectedPageContent({
|
||||
availableRoutes,
|
||||
grantedPermissions,
|
||||
evidenceModules,
|
||||
evidenceSources
|
||||
evidenceSources,
|
||||
settings,
|
||||
locale
|
||||
}: {
|
||||
page: DocsPageNode | null;
|
||||
adminDocs: boolean;
|
||||
@@ -263,6 +317,8 @@ function SelectedPageContent({
|
||||
grantedPermissions: Array<{ scope: string; label: string; category: string }>;
|
||||
evidenceModules: DocsOptionalModuleEvidence[];
|
||||
evidenceSources: DocsSource[];
|
||||
settings: ApiSettings;
|
||||
locale: string;
|
||||
}) {
|
||||
if (!page) {
|
||||
return (
|
||||
@@ -292,7 +348,13 @@ function SelectedPageContent({
|
||||
<section id="docs-admin-permissions" className="docs-reference-block">
|
||||
<h3>i18n:govoplan-docs.granted_permissions.0a232e78</h3>
|
||||
<PermissionList permissions={grantedPermissions} />
|
||||
<EvidenceList modules={evidenceModules} sources={evidenceSources} />
|
||||
<EvidenceList
|
||||
modules={evidenceModules}
|
||||
sources={evidenceSources}
|
||||
settings={settings}
|
||||
documentationType={documentationType}
|
||||
locale={locale}
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
@@ -351,10 +413,26 @@ function DocumentationTopicList({ topics, emptyText, showTechnical = false, docu
|
||||
}
|
||||
|
||||
function TopicMetadata({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
|
||||
if (topic.kind === "workflow") return <WorkflowDetails topic={topic} documentationType={documentationType} topicById={topicById} />;
|
||||
if (topic.kind === "reference") return <ReferenceDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} />;
|
||||
if (topic.kind === "pattern") return <PatternDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} />;
|
||||
return <RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} />;
|
||||
const configuredDetails = <ConfiguredDetails topic={topic} />;
|
||||
if (topic.kind === "workflow") return <>{configuredDetails}<WorkflowDetails topic={topic} documentationType={documentationType} topicById={topicById} /></>;
|
||||
if (topic.kind === "reference") return <>{configuredDetails}<ReferenceDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} /></>;
|
||||
if (topic.kind === "pattern") return <>{configuredDetails}<PatternDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} /></>;
|
||||
return <>{configuredDetails}<RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} /></>;
|
||||
}
|
||||
|
||||
function ConfiguredDetails({ topic }: { topic: DocsDocumentationTopic }) {
|
||||
const currentConfiguration = metadataList(topic.metadata, "current_configuration");
|
||||
const limitations = metadataList(topic.metadata, "limitations");
|
||||
const constraints = metadataRecords(topic.metadata, "constraints");
|
||||
const prefix = topicAnchorId(topic);
|
||||
if (!currentConfiguration.length && !limitations.length && !constraints.length) return null;
|
||||
return (
|
||||
<div className="docs-topic-details">
|
||||
{!!currentConfiguration.length && <DetailList id={`${prefix}-current-configuration`} title="i18n:govoplan-docs.this_system.b13a51ad" items={currentConfiguration} />}
|
||||
{!!constraints.length && <ConstraintDetails id={`${prefix}-constraints`} constraints={constraints} />}
|
||||
{!!limitations.length && <DetailList id={`${prefix}-limitations`} title="i18n:govoplan-docs.details.a6b3c45f" items={limitations} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowDetails({ topic, documentationType, topicById }: { topic: DocsDocumentationTopic; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
|
||||
@@ -383,6 +461,30 @@ function WorkflowDetails({ topic, documentationType, topicById }: { topic: DocsD
|
||||
);
|
||||
}
|
||||
|
||||
function ConstraintDetails({ id, constraints }: { id: string; constraints: Record<string, unknown>[] }) {
|
||||
return (
|
||||
<div className="docs-detail-block" id={id}>
|
||||
<h4>i18n:govoplan-docs.requirements.09a428f9</h4>
|
||||
<DescriptionList variant="inline" density="compact">
|
||||
{constraints.map((constraint, index) => {
|
||||
const label = metadataString(constraint, "label");
|
||||
const description = metadataString(constraint, "description");
|
||||
const values = metadataList(constraint, "values");
|
||||
return (
|
||||
<div key={metadataString(constraint, "id") || `${label}-${index}`}>
|
||||
<dt>{label}</dt>
|
||||
<dd>
|
||||
{description}
|
||||
{!!values.length && <span className="muted block">{values.join(", ")}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</DescriptionList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferenceDetails({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
|
||||
const route = metadataString(topic.metadata, "route");
|
||||
const screen = metadataString(topic.metadata, "screen");
|
||||
@@ -391,11 +493,11 @@ function ReferenceDetails({ topic, showTechnical, documentationType, topicById }
|
||||
return (
|
||||
<div className="docs-topic-details">
|
||||
{(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>}
|
||||
{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>}
|
||||
</dl>
|
||||
</DescriptionList>
|
||||
}
|
||||
{!!fields.length && <ReferenceFieldTable id={`${topicAnchorId(topic)}-fields`} fields={fields} showTechnical={showTechnical} />}
|
||||
<RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} />
|
||||
@@ -404,34 +506,18 @@ function ReferenceDetails({ topic, showTechnical, documentationType, topicById }
|
||||
}
|
||||
|
||||
function ReferenceFieldTable({ id, fields, showTechnical }: { id: string; fields: Record<string, unknown>[]; showTechnical: boolean }) {
|
||||
const columns: DataGridColumn<Record<string, unknown>>[] = [
|
||||
{ id: "field", header: "i18n:govoplan-docs.field.7558c082", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (field) => metadataString(field, "label"), render: (field) => <strong>{metadataString(field, "label")}</strong> },
|
||||
{ id: "meaning", header: "i18n:govoplan-docs.meaning.584d8aa0", width: "minmax(260px, 1.3fr)", minWidth: 220, resizable: true, filterable: true, value: (field) => [metadataString(field, "user_description"), metadataString(field, "admin_description"), metadataString(field, "provenance")].join(" "), render: (field) => <div>{metadataString(field, "user_description")}{showTechnical && metadataString(field, "admin_description") && <span className="muted block">{metadataString(field, "admin_description")}</span>}{showTechnical && metadataString(field, "provenance") && <span className="muted block">{metadataString(field, "provenance")}</span>}</div> },
|
||||
...(showTechnical ? [
|
||||
{ id: "api", header: "i18n:govoplan-docs.api_mapping.e969f6f7", width: "minmax(190px, .8fr)", minWidth: 170, resizable: true, filterable: true, value: (field: Record<string, unknown>) => `${metadataString(field, "api_path")} ${metadataString(field, "api_field")}`, render: (field: Record<string, unknown>) => <div>{metadataString(field, "api_path")}<span className="muted block">{metadataString(field, "api_field")}</span></div> },
|
||||
{ id: "permission", header: "i18n:govoplan-docs.permission.d71c9448", width: 190, filterable: true, value: (field: Record<string, unknown>) => metadataString(field, "permission_scope") || "-", render: (field: Record<string, unknown>) => metadataString(field, "permission_scope") || "-" }
|
||||
] satisfies DataGridColumn<Record<string, unknown>>[] : []),
|
||||
{ id: "validation", header: "i18n:govoplan-docs.validation.ef7f6d9c", width: "minmax(180px, .7fr)", minWidth: 160, resizable: true, filterable: true, value: (field) => metadataString(field, "validation") || "-", render: (field) => metadataString(field, "validation") || "-" }
|
||||
];
|
||||
return (
|
||||
<div className="admin-table-wrap" id={id}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>i18n:govoplan-docs.field.7558c082</th>
|
||||
<th>i18n:govoplan-docs.meaning.584d8aa0</th>
|
||||
{showTechnical && <th>i18n:govoplan-docs.api_mapping.e969f6f7</th>}
|
||||
{showTechnical && <th>i18n:govoplan-docs.permission.d71c9448</th>}
|
||||
<th>i18n:govoplan-docs.validation.ef7f6d9c</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((field) =>
|
||||
<tr key={metadataString(field, "field_id") || metadataString(field, "label")}>
|
||||
<td><strong>{metadataString(field, "label")}</strong></td>
|
||||
<td>
|
||||
{metadataString(field, "user_description")}
|
||||
{showTechnical && metadataString(field, "admin_description") && <span className="muted block">{metadataString(field, "admin_description")}</span>}
|
||||
{showTechnical && metadataString(field, "provenance") && <span className="muted block">{metadataString(field, "provenance")}</span>}
|
||||
</td>
|
||||
{showTechnical && <td>{metadataString(field, "api_path")}<span className="muted block">{metadataString(field, "api_field")}</span></td>}
|
||||
{showTechnical && <td>{metadataString(field, "permission_scope") || "-"}</td>}
|
||||
<td>{metadataString(field, "validation") || "-"}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div id={id}>
|
||||
<DataGrid id={`${id}-grid`} rows={fields} columns={columns} getRowKey={(field) => metadataString(field, "field_id") || metadataString(field, "label")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -517,20 +603,21 @@ function UnavailableDocumentationReason({ topic }: { topic: DocsDocumentationTop
|
||||
const rows = [
|
||||
["i18n:govoplan-docs.modules.04e9462c", topic.blockers.modules],
|
||||
["i18n:govoplan-docs.capabilities.ca09c54b", topic.blockers.capabilities],
|
||||
["i18n:govoplan-docs.permissions.d06d5557", topic.blockers.scopes]
|
||||
["i18n:govoplan-docs.permissions.d06d5557", topic.blockers.scopes],
|
||||
["Configuration", topic.blockers.configuration]
|
||||
].filter(([, values]) => Array.isArray(values) && values.length);
|
||||
return (
|
||||
<div className="docs-unavailable-reason">
|
||||
<p className="muted">{topic.reason}</p>
|
||||
{!!rows.length &&
|
||||
<dl className="detail-list compact">
|
||||
<DescriptionList variant="inline" density="compact">
|
||||
{rows.map(([label, values]) =>
|
||||
<div key={String(label)}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{(values as string[]).join(", ")}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</DescriptionList>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
@@ -771,8 +858,28 @@ function uniqueTopics(topics: DocsDocumentationTopic[]): DocsDocumentationTopic[
|
||||
|
||||
function selectedPageFromSearch(search: string, pages: DocsPageNode[]): DocsPageNode | null {
|
||||
if (!pages.length) return null;
|
||||
const requested = new URLSearchParams(search).get("topic") || "";
|
||||
return pages.find((page) => page.id === requested) ?? pages[0];
|
||||
const params = new URLSearchParams(search);
|
||||
const requested = params.get("topic") || "";
|
||||
if (requested) return pages.find((page) => page.id === requested) ?? pages[0];
|
||||
const helpContext = params.get("context") || "";
|
||||
if (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;
|
||||
}
|
||||
const moduleId = params.get("module") || helpContextModuleId(fallbackContext || helpContext);
|
||||
const moduleTopic = pages.find((page) => page.kind === "topic" && page.topic.source_module_id === moduleId);
|
||||
if (moduleTopic) return moduleTopic;
|
||||
}
|
||||
return pages[0];
|
||||
}
|
||||
|
||||
function helpContextModuleId(value: string): string {
|
||||
const prefix = value.split(".", 1)[0];
|
||||
if (prefix === "campaign") return "campaigns";
|
||||
if (prefix === "address-book") return "addresses";
|
||||
return prefix;
|
||||
}
|
||||
|
||||
function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): OutlineItem[] {
|
||||
@@ -795,6 +902,9 @@ function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): Outl
|
||||
const prefix = topicAnchorId(topic);
|
||||
const items: OutlineItem[] = [{ id: prefix, label: topic.title }];
|
||||
if (topic.summary) items.push({ id: `${prefix}-summary`, label: "i18n:govoplan-docs.summary.d6b9936d" });
|
||||
if (metadataList(topic.metadata, "current_configuration").length) items.push({ id: `${prefix}-current-configuration`, label: "i18n:govoplan-docs.this_system.b13a51ad" });
|
||||
if (metadataRecords(topic.metadata, "constraints").length) items.push({ id: `${prefix}-constraints`, label: "i18n:govoplan-docs.requirements.09a428f9" });
|
||||
if (metadataList(topic.metadata, "limitations").length) items.push({ id: `${prefix}-limitations`, label: "i18n:govoplan-docs.details.a6b3c45f" });
|
||||
if (topic.kind === "workflow") {
|
||||
if (metadataString(topic.metadata, "outcome")) items.push({ id: `${prefix}-outcome`, label: "i18n:govoplan-docs.outcome.10172bd3" });
|
||||
if (metadataList(topic.metadata, "prerequisites").length) items.push({ id: `${prefix}-prerequisites`, label: "i18n:govoplan-docs.prerequisites.fdf2407f" });
|
||||
@@ -814,7 +924,12 @@ function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): Outl
|
||||
}
|
||||
|
||||
function documentationTypeFromSearch(search: string): DocumentationType {
|
||||
return new URLSearchParams(search).get("type") === "user" ? "user" : "admin";
|
||||
return new URLSearchParams(search).get("type") === "admin" ? "admin" : "user";
|
||||
}
|
||||
|
||||
function versionFromSearch(search: string): string | null {
|
||||
const value = new URLSearchParams(search).get("version")?.trim();
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function localeFromSearch(search: string): string | null {
|
||||
@@ -861,56 +976,38 @@ function metadataRecords(metadata: Record<string, unknown>, key: string): Record
|
||||
|
||||
function ModuleTable({ modules }: { modules: DocsModule[] }) {
|
||||
if (!modules.length) return <p className="muted">i18n:govoplan-docs.no_configured_modules_found.f6f9ce24</p>;
|
||||
const columns: DataGridColumn<DocsModule>[] = [
|
||||
{ id: "module", header: "i18n:govoplan-docs.module.b8ff0289", width: "minmax(220px, 1fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (module) => `${module.name} ${module.id} ${module.version}`, render: (module) => <div><strong>{module.name}</strong><span className="muted block">{module.id} {module.version}</span></div> },
|
||||
{ id: "architecture", header: "i18n:govoplan-docs.architecture.4ca303a3", width: "minmax(210px, .9fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (module) => module.architecture ? `${module.architecture.layer} ${module.architecture.kind} ${module.architecture.maturity}` : "undeclared", render: (module) => module.architecture ? <div><strong>{module.architecture.maturity}</strong><span className="muted block">{module.architecture.layer} · {module.architecture.kind}</span>{module.architecture.known_limits.length ? <span className="muted block">{module.architecture.known_limits.length} i18n:govoplan-docs.known_limits.31871a6b</span> : null}</div> : <span className="muted">i18n:govoplan-docs.staged_declaration_pending.7ce9c1a1</span> },
|
||||
{ id: "authority", header: "i18n:govoplan-docs.source_authority.0a863835", width: "minmax(260px, 1fr)", minWidth: 230, resizable: true, filterable: true, value: (module) => `${module.architecture?.supported_authority_modes.join(" ") ?? ""} ${module.external_provider_count} ${module.external_providers.map((provider) => `${provider.runtime_state?.health ?? "unobserved"} ${provider.runtime_state?.freshness ?? ""} ${provider.runtime_state?.conflict ?? ""} ${provider.runtime_state?.recovery ?? ""}`).join(" ")}`, render: (module) => <div>{module.architecture?.supported_authority_modes.length ? module.architecture.supported_authority_modes.join(", ") : "-"}<span className="muted block">{module.external_provider_count} i18n:govoplan-docs.external_providers.d618fc54</span>{module.external_providers.map((provider) => <span className="muted block" key={provider.id}>{provider.label}: {provider.runtime_state ? `${provider.runtime_state.health ?? "unknown"} · ${provider.runtime_state.freshness ?? "unknown"} · ${provider.runtime_state.conflict ?? "unknown"} · ${provider.runtime_state.recovery ?? "unknown"}` : "unobserved"}</span>)}</div> },
|
||||
{ id: "routes", header: "i18n:govoplan-docs.routes.03730e58", width: 170, sortable: true, value: (module) => module.route_count, render: (module) => <>{module.route_count} i18n:govoplan-docs.route.200e2a66 {module.nav_count} nav</> },
|
||||
{ id: "permissions", header: "i18n:govoplan-docs.permissions.d06d5557", width: 130, sortable: true, value: (module) => module.permission_count },
|
||||
{ id: "frontend", header: "i18n:govoplan-docs.frontend.152d1cf2", width: "minmax(180px, .8fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (module) => module.frontend_package || "-", render: (module) => module.frontend_package || "-" },
|
||||
{ id: "capabilities", header: "i18n:govoplan-docs.capabilities.ca09c54b", width: "minmax(260px, 1.2fr)", minWidth: 220, resizable: true, filterable: true, value: (module) => module.capabilities.join(" "), render: (module) => <div>{module.capabilities.length ? module.capabilities.join(", ") : "-"}{module.documentation_count || module.documentation_provider_count ? <span className="muted block">{module.documentation_count} i18n:govoplan-docs.docs.5dc1e9b8 {module.documentation_provider_count} provider</span> : null}</div> }
|
||||
];
|
||||
return (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr><th>i18n:govoplan-docs.module.b8ff0289</th><th>i18n:govoplan-docs.routes.03730e58</th><th>i18n:govoplan-docs.permissions.d06d5557</th><th>i18n:govoplan-docs.frontend.152d1cf2</th><th>i18n:govoplan-docs.capabilities.ca09c54b</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modules.map((module) =>
|
||||
<tr key={module.id}>
|
||||
<td><strong>{module.name}</strong><span className="muted block">{module.id} {module.version}</span></td>
|
||||
<td>{module.route_count} i18n:govoplan-docs.route.200e2a66 {module.nav_count} nav</td>
|
||||
<td>{module.permission_count}</td>
|
||||
<td>{module.frontend_package || "-"}</td>
|
||||
<td>{module.capabilities.length ? module.capabilities.join(", ") : "-"}{module.documentation_count || module.documentation_provider_count ? <span className="muted block">{module.documentation_count} i18n:govoplan-docs.docs.5dc1e9b8 {module.documentation_provider_count} provider</span> : null}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataGrid id="docs-configured-modules" rows={modules} columns={columns} getRowKey={(module) => module.id} />
|
||||
);
|
||||
}
|
||||
|
||||
function RouteTable({ routes, emptyText }: { routes: DocsRoute[]; emptyText: string }) {
|
||||
if (!routes.length) return <p className="muted">{emptyText}</p>;
|
||||
const columns: DataGridColumn<DocsRoute>[] = [
|
||||
{ id: "route", header: "i18n:govoplan-docs.route.4999528e", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, sortable: true, filterable: true, value: (route) => `${route.label} ${route.path}`, render: (route) => <div><strong>{route.label}</strong><span className="muted block">{route.path}</span></div> },
|
||||
{ id: "module", header: "i18n:govoplan-docs.module.b8ff0289", width: 160, sortable: true, filterable: true, value: (route) => route.module_id },
|
||||
{ id: "source", header: "i18n:govoplan-docs.source.6da13add", width: 160, sortable: true, filterable: true, value: (route) => route.source },
|
||||
{ id: "requirements", header: "i18n:govoplan-docs.requirements.09a428f9", width: "minmax(220px, 1fr)", minWidth: 180, resizable: true, filterable: true, value: (route) => routeRequirements(route) },
|
||||
{ id: "status", header: "i18n:govoplan-docs.status.bae7d5be", width: 160, sortable: true, filterable: true, value: (route) => route.visible ? "visible" : route.reason, render: (route) => <StatusBadge status={route.visible ? "success" : "warning"} label={route.visible ? "visible" : route.reason} /> }
|
||||
];
|
||||
return (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr><th>i18n:govoplan-docs.route.4999528e</th><th>i18n:govoplan-docs.module.b8ff0289</th><th>i18n:govoplan-docs.source.6da13add</th><th>i18n:govoplan-docs.requirements.09a428f9</th><th>i18n:govoplan-docs.status.bae7d5be</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{routes.map((route) =>
|
||||
<tr key={`${route.source}-${route.module_id}-${route.path}`}>
|
||||
<td><strong>{route.label}</strong><span className="muted block">{route.path}</span></td>
|
||||
<td>{route.module_id}</td>
|
||||
<td>{route.source}</td>
|
||||
<td>{routeRequirements(route)}</td>
|
||||
<td><StatusBadge status={route.visible ? "success" : "warning"} label={route.visible ? "visible" : route.reason} /></td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DataGrid id={`docs-routes-${emptyText}`} rows={routes} columns={columns} getRowKey={(route) => `${route.source}-${route.module_id}-${route.path}`} />
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
return (
|
||||
<dl className="detail-list">
|
||||
<DescriptionList variant="inline">
|
||||
{permissions.slice(0, 24).map((permission) =>
|
||||
<div key={permission.scope}>
|
||||
<dt>{permission.category}</dt>
|
||||
@@ -918,14 +1015,45 @@ function PermissionList({ permissions }: { permissions: Array<{ scope: string; l
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceList({ modules, sources }: { modules: DocsOptionalModuleEvidence[]; sources: DocsSource[] }) {
|
||||
function EvidenceList({
|
||||
modules,
|
||||
sources,
|
||||
settings,
|
||||
documentationType,
|
||||
locale
|
||||
}: {
|
||||
modules: DocsOptionalModuleEvidence[];
|
||||
sources: DocsSource[];
|
||||
settings: ApiSettings;
|
||||
documentationType: DocumentationType;
|
||||
locale: string;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<DocsSourceDetail | null>(null);
|
||||
const [loadingSourceId, setLoadingSourceId] = useState("");
|
||||
const [sourceError, setSourceError] = useState("");
|
||||
|
||||
if (!modules.length && !sources.length) return <p className="muted">i18n:govoplan-docs.no_evidence_sources_found.be3bb2f6</p>;
|
||||
|
||||
async function inspectSource(source: DocsSource) {
|
||||
setLoadingSourceId(source.id);
|
||||
setSourceError("");
|
||||
try {
|
||||
setSelected(await fetchDocsSource(settings, source.id, { documentationType, locale }));
|
||||
} catch (error) {
|
||||
setSourceError(adminErrorMessage(error));
|
||||
} finally {
|
||||
setLoadingSourceId("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className="detail-list">
|
||||
<>
|
||||
{sourceError && <DismissibleAlert tone="danger" resetKey={sourceError}>{sourceError}</DismissibleAlert>}
|
||||
<DescriptionList variant="inline">
|
||||
{modules.map((item) =>
|
||||
<div key={`${item.source_module_id}-${item.module_id}`}>
|
||||
<dt><StatusBadge status={item.status === "installed" ? "success" : "inactive"} label={item.status} /></dt>
|
||||
@@ -933,15 +1061,100 @@ function EvidenceList({ modules, sources }: { modules: DocsOptionalModuleEvidenc
|
||||
</div>
|
||||
)}
|
||||
{sources.map((item) =>
|
||||
<div key={item.source}>
|
||||
<dt>{item.layer}</dt>
|
||||
<dd><strong>{item.label}</strong><span className="muted"> · {item.source}</span></dd>
|
||||
<div key={item.id}>
|
||||
<dt><StatusBadge status={item.state === "configured" ? "success" : item.state === "disabled" ? "inactive" : "warning"} label={item.state} /></dt>
|
||||
<dd>
|
||||
<strong>{item.label}</strong>
|
||||
<span className="muted"> · {item.owner_module_id} · {item.kind} · {item.provenance.source}</span>
|
||||
{item.state_reason && <span className="muted block">{item.state_reason}</span>}
|
||||
</dd>
|
||||
<dd>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="icon-button"
|
||||
title="i18n:govoplan-docs.inspect_source.bcc1739d"
|
||||
aria-label="i18n:govoplan-docs.inspect_source.bcc1739d"
|
||||
disabled={loadingSourceId === item.id}
|
||||
onClick={() => void inspectSource(item)}
|
||||
>
|
||||
<Eye size={16} />
|
||||
</Button>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</DescriptionList>
|
||||
<Dialog
|
||||
open={selected !== null}
|
||||
title={selected?.label ?? "i18n:govoplan-docs.source_details.6dc79c75"}
|
||||
onClose={() => setSelected(null)}
|
||||
footer={<Button onClick={() => setSelected(null)}>i18n:govoplan-docs.close.87b84f71</Button>}
|
||||
>
|
||||
{selected && <SourceInspection source={selected} />}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceInspection({ source }: { source: DocsSourceDetail }) {
|
||||
const provenance = compactSourceRecord(source.provenance);
|
||||
const visibility = compactSourceRecord(source.visibility);
|
||||
const inspection = compactSourceRecord(source.inspection);
|
||||
return (
|
||||
<div className="stack">
|
||||
<div>
|
||||
<StatusBadge
|
||||
status={source.state === "configured" ? "success" : source.state === "disabled" ? "inactive" : "warning"}
|
||||
label={source.state}
|
||||
/>
|
||||
{source.state_reason && <p className="muted">{source.state_reason}</p>}
|
||||
</div>
|
||||
<SourceInspectionGroup title="i18n:govoplan-docs.provenance.73e80298" values={provenance} />
|
||||
<SourceInspectionGroup title="i18n:govoplan-docs.visibility.80ab5798" values={visibility} />
|
||||
<SourceInspectionGroup title="i18n:govoplan-docs.inspection.83dc17ca" values={inspection} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceInspectionGroup({ title, values }: { title: string; values: Array<[string, unknown]> }) {
|
||||
if (!values.length) return null;
|
||||
return (
|
||||
<section>
|
||||
<h3>{title}</h3>
|
||||
<DescriptionList variant="inline">
|
||||
{values.map(([key, value]) =>
|
||||
<div key={key}>
|
||||
<dt>{humanizeSourceKey(key)}</dt>
|
||||
<dd>{formatSourceValue(value)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</DescriptionList>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function compactSourceRecord(value: object): Array<[string, unknown]> {
|
||||
return Object.entries(value).filter(([, item]) => {
|
||||
if (item === null || item === undefined || item === "") return false;
|
||||
return !Array.isArray(item) || item.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function humanizeSourceKey(value: string): string {
|
||||
const words = value.replaceAll("_", " ");
|
||||
return words.charAt(0).toUpperCase() + words.slice(1);
|
||||
}
|
||||
|
||||
function formatSourceValue(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.every((item) => ["string", "number", "boolean"].includes(typeof item))) {
|
||||
return value.join(", ");
|
||||
}
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
if (value && typeof value === "object") return JSON.stringify(value, null, 2);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function routeRequirements(route: DocsRoute): string {
|
||||
const parts = [];
|
||||
if (route.required_all.length) parts.push(`all: ${route.required_all.join(", ")}`);
|
||||
|
||||
@@ -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: {}
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-docs.advanced.203a8d6b": "Advanced",
|
||||
"i18n:govoplan-docs.available_documentation.db8bcc42": "Available documentation",
|
||||
"i18n:govoplan-docs.available_routes.c2635868": "Available routes",
|
||||
"i18n:govoplan-docs.architecture.4ca303a3": "Architecture",
|
||||
"i18n:govoplan-docs.basics.5fcebeef": "Basics",
|
||||
"i18n:govoplan-docs.capabilities.ca09c54b": "Capabilities",
|
||||
"i18n:govoplan-docs.common_tasks.9f825c48": "Common tasks",
|
||||
@@ -25,10 +26,12 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-docs.evidence.7ea014de": "Evidence",
|
||||
"i18n:govoplan-docs.field.7558c082": "Field",
|
||||
"i18n:govoplan-docs.frontend.152d1cf2": "Frontend",
|
||||
"i18n:govoplan-docs.external_providers.d618fc54": "external providers",
|
||||
"i18n:govoplan-docs.granted_permissions.0a232e78": "Granted permissions",
|
||||
"i18n:govoplan-docs.guidance_for_the_functions_available_in_this_ins.723b7cad": "Guidance for the functions available in this installation.",
|
||||
"i18n:govoplan-docs.help_center.f3f3a34b": "Help Center",
|
||||
"i18n:govoplan-docs.loading_documentation_context.1c091645": "Loading documentation context...",
|
||||
"i18n:govoplan-docs.known_limits.31871a6b": "known limits",
|
||||
"i18n:govoplan-docs.meaning.584d8aa0": "Meaning",
|
||||
"i18n:govoplan-docs.module.b8ff0289": "Module",
|
||||
"i18n:govoplan-docs.modules.04e9462c": "Modules",
|
||||
@@ -74,7 +77,15 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-docs.screen.c4878ec4": "Screen",
|
||||
"i18n:govoplan-docs.section.5e498158": "Section",
|
||||
"i18n:govoplan-docs.source.6da13add": "Source",
|
||||
"i18n:govoplan-docs.source_authority.0a863835": "Source authority",
|
||||
"i18n:govoplan-docs.source_details.6dc79c75": "Source details",
|
||||
"i18n:govoplan-docs.inspect_source.bcc1739d": "Inspect source",
|
||||
"i18n:govoplan-docs.inspection.83dc17ca": "Inspection",
|
||||
"i18n:govoplan-docs.provenance.73e80298": "Provenance",
|
||||
"i18n:govoplan-docs.visibility.80ab5798": "Visibility",
|
||||
"i18n:govoplan-docs.close.87b84f71": "Close",
|
||||
"i18n:govoplan-docs.status.bae7d5be": "Status",
|
||||
"i18n:govoplan-docs.staged_declaration_pending.7ce9c1a1": "Staged declaration pending",
|
||||
"i18n:govoplan-docs.steps.6041435e": "Steps",
|
||||
"i18n:govoplan-docs.summary.d6b9936d": "Summary",
|
||||
"i18n:govoplan-docs.technical_documentation_for_the_modules_and_conf.267e739e": "Technical documentation for the modules and configuration active in this installation.",
|
||||
@@ -100,6 +111,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-docs.advanced.203a8d6b": "Fortgeschritten",
|
||||
"i18n:govoplan-docs.available_documentation.db8bcc42": "Verfügbare Dokumentation",
|
||||
"i18n:govoplan-docs.available_routes.c2635868": "Verfügbare Routen",
|
||||
"i18n:govoplan-docs.architecture.4ca303a3": "Architektur",
|
||||
"i18n:govoplan-docs.basics.5fcebeef": "Grundlagen",
|
||||
"i18n:govoplan-docs.capabilities.ca09c54b": "Fähigkeiten",
|
||||
"i18n:govoplan-docs.common_tasks.9f825c48": "Häufige Aufgaben",
|
||||
@@ -116,10 +128,12 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-docs.evidence.7ea014de": "Evidence",
|
||||
"i18n:govoplan-docs.field.7558c082": "Feld",
|
||||
"i18n:govoplan-docs.frontend.152d1cf2": "Frontend",
|
||||
"i18n:govoplan-docs.external_providers.d618fc54": "externe Anbieter",
|
||||
"i18n:govoplan-docs.granted_permissions.0a232e78": "Gewährte Berechtigungen",
|
||||
"i18n:govoplan-docs.guidance_for_the_functions_available_in_this_ins.723b7cad": "Anleitung für die in dieser Installation verfügbaren Funktionen.",
|
||||
"i18n:govoplan-docs.help_center.f3f3a34b": "Hilfezentrum",
|
||||
"i18n:govoplan-docs.loading_documentation_context.1c091645": "Dokumentationskontext wird geladen...",
|
||||
"i18n:govoplan-docs.known_limits.31871a6b": "bekannte Einschränkungen",
|
||||
"i18n:govoplan-docs.meaning.584d8aa0": "Bedeutung",
|
||||
"i18n:govoplan-docs.module.b8ff0289": "Modul",
|
||||
"i18n:govoplan-docs.modules.04e9462c": "Module",
|
||||
@@ -165,7 +179,15 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-docs.screen.c4878ec4": "Ansicht",
|
||||
"i18n:govoplan-docs.section.5e498158": "Bereich",
|
||||
"i18n:govoplan-docs.source.6da13add": "Quelle",
|
||||
"i18n:govoplan-docs.source_authority.0a863835": "Quellenhoheit",
|
||||
"i18n:govoplan-docs.source_details.6dc79c75": "Quelldetails",
|
||||
"i18n:govoplan-docs.inspect_source.bcc1739d": "Quelle anzeigen",
|
||||
"i18n:govoplan-docs.inspection.83dc17ca": "Prüfdaten",
|
||||
"i18n:govoplan-docs.provenance.73e80298": "Herkunft",
|
||||
"i18n:govoplan-docs.visibility.80ab5798": "Sichtbarkeit",
|
||||
"i18n:govoplan-docs.close.87b84f71": "Schließen",
|
||||
"i18n:govoplan-docs.status.bae7d5be": "Status",
|
||||
"i18n:govoplan-docs.staged_declaration_pending.7ce9c1a1": "Deklaration steht noch aus",
|
||||
"i18n:govoplan-docs.steps.6041435e": "Schritte",
|
||||
"i18n:govoplan-docs.summary.d6b9936d": "Zusammenfassung",
|
||||
"i18n:govoplan-docs.technical_documentation_for_the_modules_and_conf.267e739e": "Technische Dokumentation für die in dieser Installation aktiven Module und Konfiguration.",
|
||||
|
||||
+6
-3
@@ -3,8 +3,10 @@ import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
|
||||
const DocsPage = lazy(() => import("./features/docs/DocsPage"));
|
||||
const SemanticDocumentationPage = lazy(() => import("./features/docs/SemanticDocumentationPage"));
|
||||
|
||||
const docsReadScopes = ["docs:documentation:read", "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 = {
|
||||
en: generatedTranslations.en,
|
||||
@@ -14,13 +16,14 @@ const translations = {
|
||||
export const docsModule: PlatformWebModule = {
|
||||
id: "docs",
|
||||
label: "i18n:govoplan-docs.docs.68a41942",
|
||||
version: "1.0.0",
|
||||
version: "0.1.19",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["policy", "audit", "ops", "workflow", "search"],
|
||||
translations,
|
||||
navItems: [{ to: "/docs", label: "i18n:govoplan-docs.docs.68a41942", iconName: "reports", anyOf: docsReadScopes, order: 880 }],
|
||||
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 }) }]
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user