Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d36ab2747 | |||
| 94c000f351 | |||
| be52b716ca | |||
| 901baf8352 | |||
| 7cdd1e1c78 | |||
| e9f3106657 | |||
| 9dab1c842a | |||
| 2f912ff619 | |||
| a08bc7c204 | |||
| bbe3c7d163 | |||
| 0f41e02428 | |||
| 0c9997593c | |||
| b4ef4cd038 | |||
| 9746cc8974 | |||
| f2ade1d624 | |||
| ec748e3fcf |
@@ -18,8 +18,8 @@ cd /mnt/DATA/git/govoplan-core
|
|||||||
For combined checks once implementation starts, run:
|
For combined checks once implementation starts, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /mnt/DATA/git/govoplan-core
|
cd /mnt/DATA/git/govoplan
|
||||||
./scripts/check-focused.sh
|
tools/checks/check-focused.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Working Rules
|
## Working Rules
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -1,5 +1,9 @@
|
|||||||
# govoplan-docs
|
# govoplan-docs
|
||||||
|
|
||||||
|
<!-- govoplan-repository-type:start -->
|
||||||
|
**Repository type:** module (platform).
|
||||||
|
<!-- govoplan-repository-type:end -->
|
||||||
|
|
||||||
GovOPlaN Docs provides the documentation layer for configured GovOPlaN systems.
|
GovOPlaN Docs provides the documentation layer for configured GovOPlaN systems.
|
||||||
|
|
||||||
It answers "what does this installation provide for me?" before it answers "what could the full product do?". The goal is to reduce documentation complexity by starting from installed modules, enabled configuration, tenant policy, role, route availability, and visible capabilities.
|
It answers "what does this installation provide for me?" before it answers "what could the full product do?". The goal is to reduce documentation complexity by starting from installed modules, enabled configuration, tenant policy, role, route availability, and visible capabilities.
|
||||||
@@ -18,6 +22,14 @@ This repository owns:
|
|||||||
|
|
||||||
Core owns module discovery, configuration package loading, route registry, RBAC evaluation, capability registry, and shared WebUI shell behavior.
|
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
|
## Documentation model
|
||||||
|
|
||||||
The docs module should render documentation in three layers:
|
The docs module should render documentation in three layers:
|
||||||
|
|||||||
@@ -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
|
modules, enabled routes, permissions, and configuration decide what is shown as
|
||||||
the default path.
|
the default path.
|
||||||
|
|
||||||
Tracking issue: `add-ideas/govoplan-docs#15`.
|
Tracking issue: `GovOPlaN/govoplan-docs#15`.
|
||||||
|
|
||||||
## Editorial Pillars
|
## Editorial Pillars
|
||||||
|
|
||||||
@@ -255,6 +255,9 @@ Workflow topics:
|
|||||||
- keep each step actionable
|
- keep each step actionable
|
||||||
- mention blockers where the user would encounter them
|
- mention blockers where the user would encounter them
|
||||||
- link to field/reference topics instead of repeating field tables
|
- 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:
|
Reference topics:
|
||||||
|
|
||||||
|
|||||||
@@ -100,17 +100,17 @@ database session when available. The module can then describe the effective
|
|||||||
state without the docs module importing feature internals.
|
state without the docs module importing feature internals.
|
||||||
|
|
||||||
For example, `govoplan-mail` contributes static documentation for reusable mail
|
For example, `govoplan-mail` contributes static documentation for reusable mail
|
||||||
profiles and a runtime tenant policy topic. If the effective tenant mail policy
|
profiles and runtime effective-policy topics. The provider evaluates the
|
||||||
limits sending to approved profile ids and disables user, group, and
|
current actor and scope. A custom-profile task appears only when that actor has
|
||||||
campaign-local profiles, the runtime docs state that users can choose approved
|
the relevant authority and the effective user policy permits it; the task then
|
||||||
profiles but cannot bring arbitrary SMTP or IMAP servers at lower scopes. If the
|
states the host/domain bounds and separate credential authority needed to
|
||||||
policy allows lower scopes, the same topic explains which scopes may define
|
complete it.
|
||||||
profiles and whether credential inheritance is locked.
|
|
||||||
|
|
||||||
Runtime providers must avoid leaking secrets. They should summarize posture,
|
Runtime providers must avoid leaking secrets. They should summarize posture,
|
||||||
counts, source provenance, and enabling conditions rather than exposing
|
counts, source provenance, and enabling conditions. A provider may disclose a
|
||||||
credentials, hostnames, profile ids, or raw policy payloads unless the route is
|
host or domain pattern only when the actor is authorized and that exact value is
|
||||||
explicitly intended for that level of detail.
|
necessary to complete the documented task. Credentials, usernames, profile
|
||||||
|
ids, source ids, unrelated topology, and raw policy payloads remain excluded.
|
||||||
|
|
||||||
### Conditions And Unlocks
|
### Conditions And Unlocks
|
||||||
|
|
||||||
@@ -123,9 +123,10 @@ Documentation topics can declare conditions:
|
|||||||
- required scopes or one-of scope alternatives
|
- required scopes or one-of scope alternatives
|
||||||
- configuration keys that influence the topic
|
- configuration keys that influence the topic
|
||||||
|
|
||||||
The docs API classifies satisfied topics into their requested layer. Unsatisfied
|
The docs API classifies satisfied topics into their requested layer.
|
||||||
topics remain visible as available or evidence documentation when safe, with a
|
Unsatisfied topics can remain visible in the administrator projection when
|
||||||
reason such as a missing module, capability, or scope.
|
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
|
Topics can also declare related modules and unlock notes. This lets a module
|
||||||
state that additional behavior becomes available when another module is
|
state that additional behavior becomes available when another module is
|
||||||
@@ -136,7 +137,7 @@ then to public module documentation for broader examples.
|
|||||||
|
|
||||||
GovOPlaN has two documentation presentations over the same source model.
|
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
|
ids, route contributions, API paths, permissions, capability names, server
|
||||||
configuration keys, policy source chains, migration notes, and operator
|
configuration keys, policy source chains, migration notes, and operator
|
||||||
evidence. This is the right place to explain exactly why a setting is available,
|
evidence. This is the right place to explain exactly why a setting is available,
|
||||||
@@ -157,8 +158,10 @@ whether the user may choose an approved mail profile or add a local mail server.
|
|||||||
|
|
||||||
The first presentation rule is:
|
The first presentation rule is:
|
||||||
|
|
||||||
- admin docs show technical context and evidence tables
|
- admin docs require `docs:documentation:admin` or the compatible settings
|
||||||
- user docs show plain-language topics, examples, limits, and escalation paths
|
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,
|
- both presentations are filtered by installed modules, active configuration,
|
||||||
permissions, and safe disclosure rules
|
permissions, and safe disclosure rules
|
||||||
|
|
||||||
@@ -210,7 +213,50 @@ The UI should make the current context explicit enough to avoid confusion, but i
|
|||||||
|
|
||||||
## Governance
|
## 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.
|
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. |
|
| `available` | Installed features hidden by missing permissions or unmet conditions. |
|
||||||
| `evidence` | Hints for unavailable optional modules, missing capabilities, or external evidence sources. |
|
| `evidence` | Hints for unavailable optional modules, missing capabilities, or external evidence sources. |
|
||||||
|
|
||||||
Normal user documentation should emphasize `always` and `configured` content.
|
Normal user documentation returns only active, safely projected topics. Admin
|
||||||
Admin documentation may show all layers plus route, permission, module, and
|
documentation may show all layers plus route, permission, module, and capability
|
||||||
capability diagnostics.
|
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
|
## Conditions
|
||||||
|
|
||||||
@@ -37,7 +52,11 @@ Documentation topics can declare:
|
|||||||
- required capabilities
|
- required capabilities
|
||||||
- required scopes
|
- required scopes
|
||||||
- any-of scope sets
|
- 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`:
|
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
|
Raw blockers and scope names are part of the administrator projection. The user
|
||||||
prevents dead-end instructions: users see whether a feature is unavailable
|
projection omits inactive topics instead of returning their protected body,
|
||||||
because a module is missing, a capability is not registered, or their current
|
metadata, and blocker identifiers. A module can contribute a separate, safe
|
||||||
permissions do not expose it.
|
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
|
## Module Guidance
|
||||||
|
|
||||||
@@ -68,3 +104,32 @@ should contribute conditional topics such as:
|
|||||||
|
|
||||||
The docs module remains the renderer. Feature modules own their subject matter
|
The docs module remains the renderer. Feature modules own their subject matter
|
||||||
and describe unlocks through manifest metadata.
|
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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/docs-webui",
|
"name": "@govoplan/docs-webui",
|
||||||
"version": "0.1.7",
|
"version": "0.1.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.7",
|
"@govoplan/core-webui": "^0.1.10",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-docs"
|
name = "govoplan-docs"
|
||||||
version = "0.1.7"
|
version = "0.1.10"
|
||||||
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
|
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.7",
|
"govoplan-core>=0.1.10",
|
||||||
"govoplan-access>=0.1.7",
|
"govoplan-access>=0.1.10",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.6"
|
__version__ = "0.1.10"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, Mapping
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
|
||||||
@@ -15,11 +16,12 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
user_workflow_scope_condition_issues,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
|
|
||||||
from govoplan_docs.backend.manifest import DOCS_READ_SCOPES
|
from govoplan_docs.backend.manifest import DOCS_ADMIN_READ_SCOPES, DOCS_READ_SCOPES
|
||||||
|
|
||||||
router = APIRouter(prefix="/docs", tags=["docs"])
|
router = APIRouter(prefix="/docs", tags=["docs"])
|
||||||
|
|
||||||
@@ -29,30 +31,58 @@ TOPIC_KINDS = ("workflow", "reference", "pattern", "system")
|
|||||||
@router.get("/context")
|
@router.get("/context")
|
||||||
def docs_context(
|
def docs_context(
|
||||||
request: Request,
|
request: Request,
|
||||||
documentation_type: DocumentationType = Query(default="admin", alias="type", pattern="^(admin|user)$"),
|
documentation_type: DocumentationType = Query(default="user", alias="type", pattern="^(admin|user)$"),
|
||||||
locale: str | None = Query(default=None, min_length=2, max_length=20),
|
locale: str | None = Query(default=None, min_length=2, max_length=20),
|
||||||
principal: ApiPrincipal = Depends(require_any_scope(*DOCS_READ_SCOPES)),
|
principal: ApiPrincipal = Depends(require_any_scope(*DOCS_READ_SCOPES)),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
can_read_admin_documentation = _has_any_scope(principal, DOCS_ADMIN_READ_SCOPES)
|
||||||
|
if documentation_type == "admin" and not can_read_admin_documentation:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Administrative documentation requires documentation-administrator authority",
|
||||||
|
)
|
||||||
registry = _registry(request)
|
registry = _registry(request)
|
||||||
resolved_locale = _preferred_locale(request, locale)
|
resolved_locale = _preferred_locale(request, locale)
|
||||||
modules = [_module_payload(manifest) for manifest in registry.manifests()]
|
|
||||||
permissions = [_permission_payload(permission, principal) for permission in registry.permissions()]
|
|
||||||
route_items = _route_items(registry.manifests(), principal)
|
route_items = _route_items(registry.manifests(), principal)
|
||||||
visible_routes = [item for item in route_items if item["visible"]]
|
visible_route_items = [item for item in route_items if item["visible"]]
|
||||||
available_routes = [item for item in route_items if not item["visible"]]
|
|
||||||
optional_modules = _optional_module_evidence(registry.manifests())
|
|
||||||
documentation_layers = _documentation_layers(request, registry, principal, documentation_type=documentation_type, locale=resolved_locale)
|
documentation_layers = _documentation_layers(request, registry, principal, documentation_type=documentation_type, locale=resolved_locale)
|
||||||
|
if documentation_type == "admin":
|
||||||
|
visible_routes = visible_route_items
|
||||||
|
modules = [_module_payload(manifest, technical=True) for manifest in registry.manifests()]
|
||||||
|
permissions = [_permission_payload(permission, principal) for permission in registry.permissions()]
|
||||||
|
available_routes = [item for item in route_items if not item["visible"]]
|
||||||
|
optional_modules = _optional_module_evidence(registry.manifests())
|
||||||
|
else:
|
||||||
|
visible_module_ids = {
|
||||||
|
*(str(item["module_id"]) for item in visible_route_items),
|
||||||
|
*(str(topic["source_module_id"]) for topic in _all_documentation_topics(documentation_layers)),
|
||||||
|
}
|
||||||
|
modules = [
|
||||||
|
_module_payload(manifest, technical=False)
|
||||||
|
for manifest in registry.manifests()
|
||||||
|
if manifest.id in visible_module_ids
|
||||||
|
]
|
||||||
|
permissions = []
|
||||||
|
visible_routes = _user_route_payloads(visible_route_items)
|
||||||
|
available_routes = []
|
||||||
|
optional_modules = []
|
||||||
documentation_count = sum(len(layer) for layer in documentation_layers.values())
|
documentation_count = sum(len(layer) for layer in documentation_layers.values())
|
||||||
topic_groups = _documentation_topic_groups(documentation_layers)
|
topic_groups = _documentation_topic_groups(documentation_layers)
|
||||||
|
|
||||||
|
actor_payload: dict[str, Any] = {
|
||||||
|
"documentation_type": documentation_type,
|
||||||
|
"locale": resolved_locale,
|
||||||
|
"available_documentation_types": ["user", *(["admin"] if can_read_admin_documentation else [])],
|
||||||
|
}
|
||||||
|
if documentation_type == "admin":
|
||||||
|
actor_payload.update(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
scope_count=len(principal.scopes),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"actor": {
|
"actor": actor_payload,
|
||||||
"tenant_id": principal.tenant_id,
|
|
||||||
"user_id": principal.user.id,
|
|
||||||
"scope_count": len(principal.scopes),
|
|
||||||
"documentation_type": documentation_type,
|
|
||||||
"locale": resolved_locale,
|
|
||||||
},
|
|
||||||
"summary": {
|
"summary": {
|
||||||
"module_count": len(modules),
|
"module_count": len(modules),
|
||||||
"visible_route_count": len(visible_routes),
|
"visible_route_count": len(visible_routes),
|
||||||
@@ -85,7 +115,7 @@ def docs_context(
|
|||||||
},
|
},
|
||||||
"evidence": {
|
"evidence": {
|
||||||
"optional_modules": optional_modules,
|
"optional_modules": optional_modules,
|
||||||
"sources": _evidence_sources(),
|
"sources": _evidence_sources() if documentation_type == "admin" else [],
|
||||||
"documentation": documentation_layers["evidence"],
|
"documentation": documentation_layers["evidence"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -99,28 +129,57 @@ def _registry(request: Request) -> PlatformRegistry:
|
|||||||
return registry
|
return registry
|
||||||
|
|
||||||
|
|
||||||
def _module_payload(manifest: ModuleManifest) -> dict[str, Any]:
|
def _module_payload(manifest: ModuleManifest, *, technical: bool) -> dict[str, Any]:
|
||||||
frontend = manifest.frontend
|
frontend = manifest.frontend
|
||||||
migration = manifest.migration_spec
|
migration = manifest.migration_spec
|
||||||
return {
|
return {
|
||||||
"id": manifest.id,
|
"id": manifest.id,
|
||||||
"name": manifest.name,
|
"name": manifest.name,
|
||||||
"version": manifest.version,
|
"version": manifest.version if technical else "",
|
||||||
"dependencies": list(manifest.dependencies),
|
"dependencies": list(manifest.dependencies) if technical else [],
|
||||||
"optional_dependencies": list(manifest.optional_dependencies),
|
"optional_dependencies": list(manifest.optional_dependencies) if technical else [],
|
||||||
"permission_count": len(manifest.permissions),
|
"permission_count": len(manifest.permissions) if technical else 0,
|
||||||
"role_template_count": len(manifest.role_templates),
|
"role_template_count": len(manifest.role_templates) if technical else 0,
|
||||||
"nav_count": len(manifest.nav_items) + (len(frontend.nav_items) if frontend else 0),
|
"nav_count": len(manifest.nav_items) + (len(frontend.nav_items) if frontend else 0) if technical else 0,
|
||||||
"route_count": (1 if manifest.route_factory else 0) + (len(frontend.routes) if frontend else 0),
|
"route_count": (1 if manifest.route_factory else 0) + (len(frontend.routes) if frontend else 0) if technical else 0,
|
||||||
"frontend_package": frontend.package_name if frontend else None,
|
"frontend_package": frontend.package_name if frontend and technical else None,
|
||||||
"backend_route_contributed": manifest.route_factory is not None,
|
"backend_route_contributed": manifest.route_factory is not None if technical else False,
|
||||||
"migration_module_id": migration.module_id if migration else None,
|
"migration_module_id": migration.module_id if migration and technical else None,
|
||||||
"capabilities": sorted(manifest.capability_factories),
|
"capabilities": sorted(manifest.capability_factories) if technical else [],
|
||||||
"documentation_count": len(manifest.documentation),
|
"documentation_count": len(manifest.documentation) if technical else 0,
|
||||||
"documentation_provider_count": len(manifest.documentation_providers),
|
"documentation_provider_count": len(manifest.documentation_providers) if technical else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_route_payload(item: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"module_id": item["module_id"],
|
||||||
|
"path": item["path"],
|
||||||
|
"label": item["label"],
|
||||||
|
"icon": item["icon"],
|
||||||
|
"section": item["section"],
|
||||||
|
"source": "visible",
|
||||||
|
"component": None,
|
||||||
|
"required_all": [],
|
||||||
|
"required_any": [],
|
||||||
|
"order": item["order"],
|
||||||
|
"visible": True,
|
||||||
|
"reason": "visible",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_route_payloads(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for item in items:
|
||||||
|
key = (str(item["module_id"]), str(item["path"]))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append(_user_route_payload(item))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _permission_payload(permission: PermissionDefinition, principal: ApiPrincipal) -> dict[str, Any]:
|
def _permission_payload(permission: PermissionDefinition, principal: ApiPrincipal) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"scope": permission.scope,
|
"scope": permission.scope,
|
||||||
@@ -260,11 +319,21 @@ def _classify_documentation(
|
|||||||
) -> dict[str, list[dict[str, Any]]]:
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []}
|
layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []}
|
||||||
installed = {manifest.id for manifest in registry.manifests()}
|
installed = {manifest.id for manifest in registry.manifests()}
|
||||||
|
visible_runtime_paths = frozenset([
|
||||||
|
"/settings", # Authenticated shell route, not contributed by a module manifest.
|
||||||
|
*(
|
||||||
|
str(item["path"])
|
||||||
|
for item in _route_items(registry.manifests(), principal)
|
||||||
|
if item["visible"]
|
||||||
|
),
|
||||||
|
]) if documentation_type == "user" else frozenset()
|
||||||
topics = _collect_documentation_topics(registry, principal, settings=settings, session=session, documentation_type=documentation_type, locale=locale)
|
topics = _collect_documentation_topics(registry, principal, settings=settings, session=session, documentation_type=documentation_type, locale=locale)
|
||||||
for source_module_id, topic in sorted(topics, key=lambda item: (item[1].order, item[0], item[1].id)):
|
for source_module_id, topic in sorted(topics, key=lambda item: (item[1].order, item[0], item[1].id)):
|
||||||
if not _topic_matches_documentation_type(topic, documentation_type):
|
if not _topic_matches_documentation_type(topic, documentation_type):
|
||||||
continue
|
continue
|
||||||
active, reason, blockers = _documentation_visibility(topic, installed, registry, principal)
|
active, reason, blockers = _documentation_visibility(topic, installed, registry, principal)
|
||||||
|
if documentation_type == "user" and not active:
|
||||||
|
continue
|
||||||
target_layer = _documentation_target_layer(topic, active, blockers)
|
target_layer = _documentation_target_layer(topic, active, blockers)
|
||||||
if target_layer not in layers:
|
if target_layer not in layers:
|
||||||
target_layer = "evidence"
|
target_layer = "evidence"
|
||||||
@@ -276,6 +345,8 @@ def _classify_documentation(
|
|||||||
target_layer=target_layer,
|
target_layer=target_layer,
|
||||||
blockers=blockers,
|
blockers=blockers,
|
||||||
locale=locale,
|
locale=locale,
|
||||||
|
documentation_type=documentation_type,
|
||||||
|
visible_runtime_paths=visible_runtime_paths,
|
||||||
))
|
))
|
||||||
return layers
|
return layers
|
||||||
|
|
||||||
@@ -292,7 +363,8 @@ def _collect_documentation_topics(
|
|||||||
topics: list[tuple[str, DocumentationTopic]] = []
|
topics: list[tuple[str, DocumentationTopic]] = []
|
||||||
for manifest in registry.manifests():
|
for manifest in registry.manifests():
|
||||||
for topic in manifest.documentation:
|
for topic in manifest.documentation:
|
||||||
topics.append((manifest.id, topic))
|
if not user_workflow_scope_condition_issues(topic):
|
||||||
|
topics.append((manifest.id, topic))
|
||||||
if not manifest.documentation_providers:
|
if not manifest.documentation_providers:
|
||||||
continue
|
continue
|
||||||
context = DocumentationContext(
|
context = DocumentationContext(
|
||||||
@@ -308,6 +380,8 @@ def _collect_documentation_topics(
|
|||||||
try:
|
try:
|
||||||
provided_topics = tuple(provider(context))
|
provided_topics = tuple(provider(context))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
if documentation_type != "admin":
|
||||||
|
continue
|
||||||
provided_topics = (
|
provided_topics = (
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id=f"{manifest.id}.runtime-documentation-unavailable",
|
id=f"{manifest.id}.runtime-documentation-unavailable",
|
||||||
@@ -321,7 +395,8 @@ def _collect_documentation_topics(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
for topic in provided_topics:
|
for topic in provided_topics:
|
||||||
topics.append((manifest.id, topic))
|
if not user_workflow_scope_condition_issues(topic):
|
||||||
|
topics.append((manifest.id, topic))
|
||||||
return topics
|
return topics
|
||||||
|
|
||||||
|
|
||||||
@@ -356,42 +431,117 @@ def _condition_visibility(
|
|||||||
registry: PlatformRegistry,
|
registry: PlatformRegistry,
|
||||||
principal: ApiPrincipal,
|
principal: ApiPrincipal,
|
||||||
) -> tuple[bool, str, dict[str, list[str]]]:
|
) -> tuple[bool, str, dict[str, list[str]]]:
|
||||||
blockers = {"modules": [], "capabilities": [], "scopes": []}
|
missing_required_modules = _missing_required_modules(condition, installed)
|
||||||
missing_required_modules = [module_id for module_id in condition.required_modules if module_id not in installed]
|
unsatisfied_any_modules = _unsatisfied_any_modules(condition, installed)
|
||||||
if missing_required_modules:
|
conflicting_modules = _conflicting_modules(condition, installed)
|
||||||
blockers["modules"].extend(missing_required_modules)
|
missing_capabilities = _missing_required_capabilities(condition, registry)
|
||||||
if condition.any_modules and not any(module_id in installed for module_id in condition.any_modules):
|
missing_scopes = _missing_required_scopes(condition, principal)
|
||||||
blockers["modules"].extend(module_id for module_id in condition.any_modules if module_id not in blockers["modules"])
|
unsatisfied_any_scopes = _unsatisfied_any_scopes(condition, principal)
|
||||||
conflicting_modules = [module_id for module_id in condition.missing_modules if module_id in installed]
|
blockers = _condition_blockers(
|
||||||
if conflicting_modules:
|
missing_required_modules=missing_required_modules,
|
||||||
blockers["modules"].extend(conflicting_modules)
|
unsatisfied_any_modules=unsatisfied_any_modules,
|
||||||
missing_capabilities = [name for name in condition.required_capabilities if not registry.has_capability(name)]
|
conflicting_modules=conflicting_modules,
|
||||||
if missing_capabilities:
|
missing_capabilities=missing_capabilities,
|
||||||
blockers["capabilities"].extend(missing_capabilities)
|
missing_scopes=missing_scopes,
|
||||||
missing_scopes = [scope for scope in condition.required_scopes if not has_scope(principal, scope)]
|
unsatisfied_any_scopes=unsatisfied_any_scopes,
|
||||||
missing_any_scopes = list(condition.any_scopes) if condition.any_scopes and not any(has_scope(principal, scope) for scope in condition.any_scopes) else []
|
)
|
||||||
if missing_scopes:
|
|
||||||
blockers["scopes"].extend(missing_scopes)
|
|
||||||
if missing_any_scopes:
|
|
||||||
blockers["scopes"].extend(scope for scope in missing_any_scopes if scope not in blockers["scopes"])
|
|
||||||
|
|
||||||
if not blockers["modules"] and not blockers["capabilities"] and not blockers["scopes"]:
|
if _condition_has_no_blockers(blockers):
|
||||||
return True, "conditions satisfied", blockers
|
return True, "conditions satisfied", blockers
|
||||||
|
|
||||||
|
reason = _condition_blocker_reason(
|
||||||
|
missing_required_modules=missing_required_modules,
|
||||||
|
unsatisfied_any_modules=unsatisfied_any_modules,
|
||||||
|
conflicting_modules=conflicting_modules,
|
||||||
|
missing_capabilities=missing_capabilities,
|
||||||
|
missing_scopes=missing_scopes,
|
||||||
|
unsatisfied_any_scopes=unsatisfied_any_scopes,
|
||||||
|
)
|
||||||
|
return False, reason, blockers
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_required_modules(condition: DocumentationCondition, installed: set[str]) -> list[str]:
|
||||||
|
return [module_id for module_id in condition.required_modules if module_id not in installed]
|
||||||
|
|
||||||
|
|
||||||
|
def _unsatisfied_any_modules(condition: DocumentationCondition, installed: set[str]) -> list[str]:
|
||||||
|
if not condition.any_modules:
|
||||||
|
return []
|
||||||
|
if any(module_id in installed for module_id in condition.any_modules):
|
||||||
|
return []
|
||||||
|
return list(condition.any_modules)
|
||||||
|
|
||||||
|
|
||||||
|
def _conflicting_modules(condition: DocumentationCondition, installed: set[str]) -> list[str]:
|
||||||
|
return [module_id for module_id in condition.missing_modules if module_id in installed]
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_required_capabilities(condition: DocumentationCondition, registry: PlatformRegistry) -> list[str]:
|
||||||
|
return [name for name in condition.required_capabilities if not registry.has_capability(name)]
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_required_scopes(condition: DocumentationCondition, principal: ApiPrincipal) -> list[str]:
|
||||||
|
return [scope for scope in condition.required_scopes if not has_scope(principal, scope)]
|
||||||
|
|
||||||
|
|
||||||
|
def _unsatisfied_any_scopes(condition: DocumentationCondition, principal: ApiPrincipal) -> list[str]:
|
||||||
|
if not condition.any_scopes:
|
||||||
|
return []
|
||||||
|
if any(has_scope(principal, scope) for scope in condition.any_scopes):
|
||||||
|
return []
|
||||||
|
return list(condition.any_scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def _condition_blockers(
|
||||||
|
*,
|
||||||
|
missing_required_modules: list[str],
|
||||||
|
unsatisfied_any_modules: list[str],
|
||||||
|
conflicting_modules: list[str],
|
||||||
|
missing_capabilities: list[str],
|
||||||
|
missing_scopes: list[str],
|
||||||
|
unsatisfied_any_scopes: list[str],
|
||||||
|
) -> dict[str, list[str]]:
|
||||||
|
blockers = {"modules": [], "capabilities": [], "scopes": []}
|
||||||
|
_extend_unique(blockers["modules"], missing_required_modules)
|
||||||
|
_extend_unique(blockers["modules"], unsatisfied_any_modules)
|
||||||
|
_extend_unique(blockers["modules"], conflicting_modules)
|
||||||
|
_extend_unique(blockers["capabilities"], missing_capabilities)
|
||||||
|
_extend_unique(blockers["scopes"], missing_scopes)
|
||||||
|
_extend_unique(blockers["scopes"], unsatisfied_any_scopes)
|
||||||
|
return blockers
|
||||||
|
|
||||||
|
|
||||||
|
def _extend_unique(target: list[str], values: list[str]) -> None:
|
||||||
|
target.extend(value for value in values if value not in target)
|
||||||
|
|
||||||
|
|
||||||
|
def _condition_has_no_blockers(blockers: dict[str, list[str]]) -> bool:
|
||||||
|
return not blockers["modules"] and not blockers["capabilities"] and not blockers["scopes"]
|
||||||
|
|
||||||
|
|
||||||
|
def _condition_blocker_reason(
|
||||||
|
*,
|
||||||
|
missing_required_modules: list[str],
|
||||||
|
unsatisfied_any_modules: list[str],
|
||||||
|
conflicting_modules: list[str],
|
||||||
|
missing_capabilities: list[str],
|
||||||
|
missing_scopes: list[str],
|
||||||
|
unsatisfied_any_scopes: list[str],
|
||||||
|
) -> str:
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
if missing_required_modules:
|
if missing_required_modules:
|
||||||
parts.append("missing modules: " + ", ".join(missing_required_modules))
|
parts.append("missing modules: " + ", ".join(missing_required_modules))
|
||||||
if condition.any_modules and not any(module_id in installed for module_id in condition.any_modules):
|
if unsatisfied_any_modules:
|
||||||
parts.append("requires one installed module from: " + ", ".join(condition.any_modules))
|
parts.append("requires one installed module from: " + ", ".join(unsatisfied_any_modules))
|
||||||
if conflicting_modules:
|
if conflicting_modules:
|
||||||
parts.append("not active when installed: " + ", ".join(conflicting_modules))
|
parts.append("not active when installed: " + ", ".join(conflicting_modules))
|
||||||
if missing_capabilities:
|
if missing_capabilities:
|
||||||
parts.append("missing capabilities: " + ", ".join(missing_capabilities))
|
parts.append("missing capabilities: " + ", ".join(missing_capabilities))
|
||||||
if missing_scopes:
|
if missing_scopes:
|
||||||
parts.append("missing scopes: " + ", ".join(missing_scopes))
|
parts.append("missing scopes: " + ", ".join(missing_scopes))
|
||||||
if missing_any_scopes:
|
if unsatisfied_any_scopes:
|
||||||
parts.append("requires one scope from: " + ", ".join(missing_any_scopes))
|
parts.append("requires one scope from: " + ", ".join(unsatisfied_any_scopes))
|
||||||
return False, "; ".join(parts), blockers
|
return "; ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def _documentation_target_layer(topic: DocumentationTopic, active: bool, blockers: dict[str, list[str]]) -> str:
|
def _documentation_target_layer(topic: DocumentationTopic, active: bool, blockers: dict[str, list[str]]) -> str:
|
||||||
@@ -413,11 +563,13 @@ def _documentation_topic_payload(
|
|||||||
target_layer: str,
|
target_layer: str,
|
||||||
blockers: dict[str, list[str]],
|
blockers: dict[str, list[str]],
|
||||||
locale: str,
|
locale: str,
|
||||||
|
documentation_type: DocumentationType,
|
||||||
|
visible_runtime_paths: frozenset[str],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
module_id = topic.source_module_id or source_module_id
|
module_id = topic.source_module_id or source_module_id
|
||||||
translation_locale, translation = _translation_for_locale(topic, locale)
|
translation_locale, translation = _translation_for_locale(topic, locale)
|
||||||
kind = _documentation_topic_kind(topic)
|
kind = _documentation_topic_kind(topic)
|
||||||
return {
|
payload = {
|
||||||
"id": topic.id,
|
"id": topic.id,
|
||||||
"source_module_id": module_id,
|
"source_module_id": module_id,
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
@@ -447,6 +599,134 @@ def _documentation_topic_payload(
|
|||||||
"configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}),
|
"configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}),
|
||||||
"metadata": dict(topic.metadata),
|
"metadata": dict(topic.metadata),
|
||||||
}
|
}
|
||||||
|
if documentation_type == "admin":
|
||||||
|
return payload
|
||||||
|
return {
|
||||||
|
"id": payload["id"],
|
||||||
|
"source_module_id": payload["source_module_id"],
|
||||||
|
"kind": payload["kind"],
|
||||||
|
"anchor_id": payload["anchor_id"],
|
||||||
|
"title": payload["title"],
|
||||||
|
"summary": payload["summary"],
|
||||||
|
"body": payload["body"],
|
||||||
|
"layer": payload["layer"],
|
||||||
|
"target_layer": payload["target_layer"],
|
||||||
|
"documentation_types": ["user"],
|
||||||
|
"active": True,
|
||||||
|
"reason": "documented",
|
||||||
|
"blockers": {"modules": [], "capabilities": [], "scopes": []},
|
||||||
|
"audience": [],
|
||||||
|
"order": payload["order"],
|
||||||
|
"i18n_key": "",
|
||||||
|
"locale": locale,
|
||||||
|
"translation_locale": payload["translation_locale"],
|
||||||
|
"conditions": [],
|
||||||
|
"links": [
|
||||||
|
_documentation_link_payload(link)
|
||||||
|
for link in topic.links
|
||||||
|
if _user_link_allowed(link, visible_runtime_paths=visible_runtime_paths)
|
||||||
|
],
|
||||||
|
"related_modules": [],
|
||||||
|
"unlocks": list(topic.unlocks),
|
||||||
|
"configuration_keys": [],
|
||||||
|
"metadata": _user_topic_metadata(kind, topic.metadata),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_topic_metadata(kind: str, metadata: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
projected: dict[str, Any] = {}
|
||||||
|
scalar_keys = ("kind", "screen", "section", "outcome", "result", "verification", "purpose", "when_used", "user_explanation")
|
||||||
|
list_keys = ("prerequisites", "steps", "current_configuration", "limitations", "related_topic_ids", "help_contexts")
|
||||||
|
id_keys = ("tree_parent_id", "parent_topic_id", "parent_id")
|
||||||
|
for key in scalar_keys:
|
||||||
|
value = _bounded_string(metadata.get(key), maximum=2_000)
|
||||||
|
if value:
|
||||||
|
projected[key] = value
|
||||||
|
for key in list_keys:
|
||||||
|
values = _bounded_string_list(metadata.get(key), maximum_items=64, maximum_length=2_000)
|
||||||
|
if values:
|
||||||
|
projected[key] = values
|
||||||
|
for key in id_keys:
|
||||||
|
value = _bounded_string(metadata.get(key), maximum=255)
|
||||||
|
if value:
|
||||||
|
projected[key] = value
|
||||||
|
if kind == "reference" and isinstance(metadata.get("fields"), list):
|
||||||
|
fields = [_user_field_metadata(item) for item in metadata["fields"][:64] if isinstance(item, Mapping)]
|
||||||
|
if fields:
|
||||||
|
projected["fields"] = [field for field in fields if field]
|
||||||
|
constraints = _user_constraints(metadata.get("constraints"))
|
||||||
|
if constraints:
|
||||||
|
projected["constraints"] = constraints
|
||||||
|
return projected
|
||||||
|
|
||||||
|
|
||||||
|
def _user_field_metadata(field: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
allowed = ("field_id", "label", "user_description", "validation")
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key in allowed
|
||||||
|
if (value := _bounded_string(field.get(key), maximum=2_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_constraints(value: object) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
constraints: list[dict[str, Any]] = []
|
||||||
|
for item in value[:32]:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
continue
|
||||||
|
constraint = {
|
||||||
|
key: text
|
||||||
|
for key in ("id", "label", "description")
|
||||||
|
if (text := _bounded_string(item.get(key), maximum=2_000 if key == "description" else 255))
|
||||||
|
}
|
||||||
|
values = _bounded_string_list(item.get("values"), maximum_items=64, maximum_length=500)
|
||||||
|
if values:
|
||||||
|
constraint["values"] = values
|
||||||
|
if constraint.get("label") and constraint.get("description"):
|
||||||
|
constraints.append(constraint)
|
||||||
|
return constraints
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_string(value: object, *, maximum: int) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return ""
|
||||||
|
clean = value.strip()
|
||||||
|
return clean if len(clean) <= maximum else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded_string_list(value: object, *, maximum_items: int, maximum_length: int) -> list[str]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
clean
|
||||||
|
for item in value[:maximum_items]
|
||||||
|
if (clean := _bounded_string(item, maximum=maximum_length))
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _user_link_allowed(link: DocumentationLink, *, visible_runtime_paths: frozenset[str]) -> bool:
|
||||||
|
href = link.href.strip()
|
||||||
|
if link.kind == "runtime":
|
||||||
|
if not href.startswith("/") or href.startswith("//") or "\\" in href:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(href)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return not parsed.scheme and not parsed.netloc and parsed.path in visible_runtime_paths
|
||||||
|
if link.kind != "public":
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(href)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return parsed.scheme == "https" and bool(parsed.netloc) and not parsed.username and not parsed.password
|
||||||
|
|
||||||
|
|
||||||
|
def _has_any_scope(principal: ApiPrincipal, scopes: tuple[str, ...]) -> bool:
|
||||||
|
return any(has_scope(principal, scope) for scope in scopes)
|
||||||
|
|
||||||
|
|
||||||
def _documentation_topic_kind(topic: DocumentationTopic) -> str:
|
def _documentation_topic_kind(topic: DocumentationTopic) -> str:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
@@ -14,7 +15,9 @@ from govoplan_core.core.modules import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
DOCS_READ_SCOPE = "docs:documentation:read"
|
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_ADMIN_READ_SCOPES = (DOCS_ADMIN_READ_SCOPE, "system:settings:read", "admin:settings:read")
|
||||||
|
DOCS_READ_SCOPES = (DOCS_READ_SCOPE, *DOCS_ADMIN_READ_SCOPES)
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
@@ -41,22 +44,34 @@ def _route_factory(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="docs",
|
id="docs",
|
||||||
name="Docs",
|
name="Docs",
|
||||||
version="0.1.7",
|
version="0.1.10",
|
||||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||||
optional_dependencies=("policy", "audit", "ops", "workflow", "search"),
|
optional_dependencies=("policy", "audit", "ops", "workflow", "search"),
|
||||||
permissions=(
|
permissions=(
|
||||||
_permission(
|
_permission(
|
||||||
DOCS_READ_SCOPE,
|
DOCS_READ_SCOPE,
|
||||||
"View configured documentation",
|
"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_ADMIN_READ_SCOPE,
|
||||||
|
"View administrative documentation",
|
||||||
|
"Read technical module, route, permission, configuration, and evidence documentation.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
role_templates=(
|
role_templates=(
|
||||||
RoleTemplate(
|
RoleTemplate(
|
||||||
slug="docs_reader",
|
slug="docs_reader",
|
||||||
name="Documentation 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,),
|
permissions=(DOCS_READ_SCOPE,),
|
||||||
|
default_authenticated=True,
|
||||||
|
),
|
||||||
|
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,
|
route_factory=_route_factory,
|
||||||
@@ -155,6 +170,11 @@ manifest = ModuleManifest(
|
|||||||
audience=("tenant_admin", "access_admin", "operator", "user"),
|
audience=("tenant_admin", "access_admin", "operator", "user"),
|
||||||
related_modules=("organizations", "identity", "idm", "access"),
|
related_modules=("organizations", "identity", "idm", "access"),
|
||||||
order=21,
|
order=21,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("organizations", "identity", "idm", "access"),
|
||||||
|
),
|
||||||
|
),
|
||||||
links=(
|
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="IDM assignments", href="/idm", kind="runtime"),
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from inspect import signature
|
||||||
from types import SimpleNamespace
|
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_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.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest
|
from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest
|
||||||
from govoplan_docs.backend.api.v1.routes import (
|
from govoplan_docs.backend.api.v1.routes import (
|
||||||
_classify_documentation,
|
_classify_documentation,
|
||||||
|
_condition_visibility,
|
||||||
_documentation_topic_anchor,
|
_documentation_topic_anchor,
|
||||||
_documentation_topic_groups,
|
_documentation_topic_groups,
|
||||||
|
docs_context,
|
||||||
)
|
)
|
||||||
from govoplan_docs.backend.manifest import get_manifest as get_docs_manifest
|
from govoplan_docs.backend.manifest import get_manifest as get_docs_manifest
|
||||||
|
|
||||||
@@ -26,6 +33,18 @@ class FakePrincipal:
|
|||||||
|
|
||||||
|
|
||||||
class DocsContextTests(unittest.TestCase):
|
class DocsContextTests(unittest.TestCase):
|
||||||
|
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:
|
def test_topic_groups_preserve_layer_classification(self) -> None:
|
||||||
registry = PlatformRegistry()
|
registry = PlatformRegistry()
|
||||||
registry.register(get_tenancy_manifest())
|
registry.register(get_tenancy_manifest())
|
||||||
@@ -58,7 +77,7 @@ class DocsContextTests(unittest.TestCase):
|
|||||||
self.assertIn("access.workflow.grant-user-access", configured_ids)
|
self.assertIn("access.workflow.grant-user-access", configured_ids)
|
||||||
self.assertIn("docs.pattern.field-help", always_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 = PlatformRegistry()
|
||||||
registry.register(get_tenancy_manifest())
|
registry.register(get_tenancy_manifest())
|
||||||
registry.register(get_access_manifest())
|
registry.register(get_access_manifest())
|
||||||
@@ -74,11 +93,207 @@ class DocsContextTests(unittest.TestCase):
|
|||||||
locale="en",
|
locale="en",
|
||||||
)
|
)
|
||||||
groups = _documentation_topic_groups(layers)
|
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.assertNotIn("access.workflow.grant-user-access", workflow_ids)
|
||||||
self.assertFalse(workflows["access.workflow.grant-user-access"]["active"])
|
self.assertNotIn("access.workflow.grant-user-access", {topic["id"] for topic in layers["available"]})
|
||||||
self.assertIn("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": []})
|
||||||
|
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:
|
def test_topic_anchor_is_stable(self) -> None:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -86,6 +301,49 @@ class DocsContextTests(unittest.TestCase):
|
|||||||
"docs-topic-access-access-workflow-grant-user-access",
|
"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"])
|
||||||
|
|
||||||
|
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": []})
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/docs-webui",
|
"name": "@govoplan/docs-webui",
|
||||||
"version": "0.1.7",
|
"version": "0.1.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.7",
|
"@govoplan/core-webui": "^0.1.10",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -110,11 +110,12 @@ export type DocsDocumentationTopic = {
|
|||||||
|
|
||||||
export type DocsContext = {
|
export type DocsContext = {
|
||||||
actor: {
|
actor: {
|
||||||
tenant_id: string;
|
tenant_id?: string;
|
||||||
user_id: string;
|
user_id?: string;
|
||||||
scope_count: number;
|
scope_count?: number;
|
||||||
documentation_type: "admin" | "user";
|
documentation_type: "admin" | "user";
|
||||||
locale: string;
|
locale: string;
|
||||||
|
available_documentation_types: Array<"admin" | "user">;
|
||||||
};
|
};
|
||||||
summary: {
|
summary: {
|
||||||
module_count: number;
|
module_count: number;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
|
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
DataGrid,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
ExplorerTree,
|
ExplorerTree,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
@@ -12,7 +13,8 @@ import {
|
|||||||
adminErrorMessage,
|
adminErrorMessage,
|
||||||
useGuardedNavigate,
|
useGuardedNavigate,
|
||||||
usePlatformLanguage,
|
usePlatformLanguage,
|
||||||
type ApiSettings
|
type ApiSettings,
|
||||||
|
type DataGridColumn
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
fetchDocsContext,
|
fetchDocsContext,
|
||||||
@@ -65,20 +67,29 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
|||||||
const [context, setContext] = useState<DocsContext | null>(null);
|
const [context, setContext] = useState<DocsContext | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const loadSequence = useRef(0);
|
||||||
const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search));
|
const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search));
|
||||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set());
|
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set());
|
||||||
const locale = localeFromSearch(location.search) ?? language;
|
const locale = localeFromSearch(location.search) ?? language;
|
||||||
const adminDocs = documentationType === "admin";
|
const adminDocs = documentationType === "admin";
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
const sequence = ++loadSequence.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
setContext(null);
|
||||||
try {
|
try {
|
||||||
setContext(await fetchDocsContext(settings, { documentationType, locale }));
|
const nextContext = await fetchDocsContext(settings, { documentationType, locale });
|
||||||
|
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) {
|
} catch (err) {
|
||||||
|
if (sequence !== loadSequence.current) return;
|
||||||
setError(adminErrorMessage(err));
|
setError(adminErrorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (sequence === loadSequence.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +132,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
|
|||||||
<aside className="section-sidebar docs-outline" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
<aside className="section-sidebar docs-outline" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
||||||
<div className="docs-sidebar-header">
|
<div className="docs-sidebar-header">
|
||||||
<div className="section-title">i18n:govoplan-docs.help_center.f3f3a34b</div>
|
<div className="section-title">i18n:govoplan-docs.help_center.f3f3a34b</div>
|
||||||
<AudienceToggle selected={documentationType} onSelect={selectDocumentationType} />
|
<AudienceToggle
|
||||||
|
selected={documentationType}
|
||||||
|
onSelect={selectDocumentationType}
|
||||||
|
canViewAdmin={context?.actor.available_documentation_types.includes("admin") ?? documentationType === "admin"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
|
||||||
<ExplorerTree
|
<ExplorerTree
|
||||||
@@ -209,7 +224,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 (
|
return (
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
className="docs-audience-toggle"
|
className="docs-audience-toggle"
|
||||||
@@ -219,10 +240,7 @@ function AudienceToggle({ selected, onSelect }: { selected: DocumentationType; o
|
|||||||
ariaLabel="i18n:govoplan-docs.documentation_type.5a66690c"
|
ariaLabel="i18n:govoplan-docs.documentation_type.5a66690c"
|
||||||
value={selected}
|
value={selected}
|
||||||
onChange={onSelect}
|
onChange={onSelect}
|
||||||
options={[
|
options={options}
|
||||||
{ id: "admin", label: "i18n:govoplan-docs.admin_docs.bf504a56" },
|
|
||||||
{ id: "user", label: "i18n:govoplan-docs.user_docs.1e38e8d3" }
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -351,10 +369,26 @@ function DocumentationTopicList({ topics, emptyText, showTechnical = false, docu
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TopicMetadata({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
|
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} />;
|
const configuredDetails = <ConfiguredDetails topic={topic} />;
|
||||||
if (topic.kind === "reference") return <ReferenceDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} />;
|
if (topic.kind === "workflow") return <>{configuredDetails}<WorkflowDetails topic={topic} documentationType={documentationType} topicById={topicById} /></>;
|
||||||
if (topic.kind === "pattern") return <PatternDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} />;
|
if (topic.kind === "reference") return <>{configuredDetails}<ReferenceDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} /></>;
|
||||||
return <RelatedTopics topic={topic} 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> }) {
|
function WorkflowDetails({ topic, documentationType, topicById }: { topic: DocsDocumentationTopic; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
|
||||||
@@ -383,6 +417,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>
|
||||||
|
<dl className="detail-list 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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ReferenceDetails({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
|
function ReferenceDetails({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
|
||||||
const route = metadataString(topic.metadata, "route");
|
const route = metadataString(topic.metadata, "route");
|
||||||
const screen = metadataString(topic.metadata, "screen");
|
const screen = metadataString(topic.metadata, "screen");
|
||||||
@@ -404,34 +462,18 @@ function ReferenceDetails({ topic, showTechnical, documentationType, topicById }
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ReferenceFieldTable({ id, fields, showTechnical }: { id: string; fields: Record<string, unknown>[]; showTechnical: boolean }) {
|
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 (
|
return (
|
||||||
<div className="admin-table-wrap" id={id}>
|
<div id={id}>
|
||||||
<table className="admin-table">
|
<DataGrid id={`${id}-grid`} rows={fields} columns={columns} getRowKey={(field) => metadataString(field, "field_id") || metadataString(field, "label")} />
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -771,8 +813,25 @@ function uniqueTopics(topics: DocsDocumentationTopic[]): DocsDocumentationTopic[
|
|||||||
|
|
||||||
function selectedPageFromSearch(search: string, pages: DocsPageNode[]): DocsPageNode | null {
|
function selectedPageFromSearch(search: string, pages: DocsPageNode[]): DocsPageNode | null {
|
||||||
if (!pages.length) return null;
|
if (!pages.length) return null;
|
||||||
const requested = new URLSearchParams(search).get("topic") || "";
|
const params = new URLSearchParams(search);
|
||||||
return pages.find((page) => page.id === requested) ?? pages[0];
|
const requested = params.get("topic") || "";
|
||||||
|
if (requested) return pages.find((page) => page.id === requested) ?? pages[0];
|
||||||
|
const helpContext = params.get("context") || "";
|
||||||
|
if (helpContext) {
|
||||||
|
const exact = pages.find((page) => page.kind === "topic" && metadataList(page.topic.metadata, "help_contexts").includes(helpContext));
|
||||||
|
if (exact) return exact;
|
||||||
|
const moduleId = helpContextModuleId(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[] {
|
function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): OutlineItem[] {
|
||||||
@@ -795,6 +854,9 @@ function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): Outl
|
|||||||
const prefix = topicAnchorId(topic);
|
const prefix = topicAnchorId(topic);
|
||||||
const items: OutlineItem[] = [{ id: prefix, label: topic.title }];
|
const items: OutlineItem[] = [{ id: prefix, label: topic.title }];
|
||||||
if (topic.summary) items.push({ id: `${prefix}-summary`, label: "i18n:govoplan-docs.summary.d6b9936d" });
|
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 (topic.kind === "workflow") {
|
||||||
if (metadataString(topic.metadata, "outcome")) items.push({ id: `${prefix}-outcome`, label: "i18n:govoplan-docs.outcome.10172bd3" });
|
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" });
|
if (metadataList(topic.metadata, "prerequisites").length) items.push({ id: `${prefix}-prerequisites`, label: "i18n:govoplan-docs.prerequisites.fdf2407f" });
|
||||||
@@ -814,7 +876,7 @@ function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): Outl
|
|||||||
}
|
}
|
||||||
|
|
||||||
function documentationTypeFromSearch(search: string): DocumentationType {
|
function documentationTypeFromSearch(search: string): DocumentationType {
|
||||||
return new URLSearchParams(search).get("type") === "user" ? "user" : "admin";
|
return new URLSearchParams(search).get("type") === "admin" ? "admin" : "user";
|
||||||
}
|
}
|
||||||
|
|
||||||
function localeFromSearch(search: string): string | null {
|
function localeFromSearch(search: string): string | null {
|
||||||
@@ -861,49 +923,29 @@ function metadataRecords(metadata: Record<string, unknown>, key: string): Record
|
|||||||
|
|
||||||
function ModuleTable({ modules }: { modules: DocsModule[] }) {
|
function ModuleTable({ modules }: { modules: DocsModule[] }) {
|
||||||
if (!modules.length) return <p className="muted">i18n:govoplan-docs.no_configured_modules_found.f6f9ce24</p>;
|
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: "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 (
|
return (
|
||||||
<div className="admin-table-wrap">
|
<DataGrid id="docs-configured-modules" rows={modules} columns={columns} getRowKey={(module) => module.id} />
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RouteTable({ routes, emptyText }: { routes: DocsRoute[]; emptyText: string }) {
|
function RouteTable({ routes, emptyText }: { routes: DocsRoute[]; emptyText: string }) {
|
||||||
if (!routes.length) return <p className="muted">{emptyText}</p>;
|
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 (
|
return (
|
||||||
<div className="admin-table-wrap">
|
<DataGrid id={`docs-routes-${emptyText}`} rows={routes} columns={columns} getRowKey={(route) => `${route.source}-${route.module_id}-${route.path}`} />
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { generatedTranslations } from "./i18n/generatedTranslations";
|
|||||||
|
|
||||||
const DocsPage = lazy(() => import("./features/docs/DocsPage"));
|
const DocsPage = lazy(() => import("./features/docs/DocsPage"));
|
||||||
|
|
||||||
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 translations = {
|
const translations = {
|
||||||
en: generatedTranslations.en,
|
en: generatedTranslations.en,
|
||||||
@@ -14,7 +14,7 @@ const translations = {
|
|||||||
export const docsModule: PlatformWebModule = {
|
export const docsModule: PlatformWebModule = {
|
||||||
id: "docs",
|
id: "docs",
|
||||||
label: "i18n:govoplan-docs.docs.68a41942",
|
label: "i18n:govoplan-docs.docs.68a41942",
|
||||||
version: "1.0.0",
|
version: "0.1.10",
|
||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["policy", "audit", "ops", "workflow", "search"],
|
optionalDependencies: ["policy", "audit", "ops", "workflow", "search"],
|
||||||
translations,
|
translations,
|
||||||
@@ -24,4 +24,4 @@ export const docsModule: PlatformWebModule = {
|
|||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default docsModule;
|
export default docsModule;
|
||||||
|
|||||||
Reference in New Issue
Block a user