chore: sync GovOPlaN module split state
This commit is contained in:
5
src/govoplan_docs/__init__.py
Normal file
5
src/govoplan_docs/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""GovOPlaN configured-system documentation module."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.6"
|
||||
1
src/govoplan_docs/backend/__init__.py
Normal file
1
src/govoplan_docs/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend integration for the GovOPlaN docs module."""
|
||||
1
src/govoplan_docs/backend/api/__init__.py
Normal file
1
src/govoplan_docs/backend/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Docs API package."""
|
||||
1
src/govoplan_docs/backend/api/v1/__init__.py
Normal file
1
src/govoplan_docs/backend/api/v1/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Docs API v1 package."""
|
||||
556
src/govoplan_docs/backend/api/v1/routes.py
Normal file
556
src/govoplan_docs/backend/api/v1/routes.py
Normal file
@@ -0,0 +1,556 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_any_scope
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
DocumentationContext,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
DocumentationType,
|
||||
FrontendRoute,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
from govoplan_docs.backend.manifest import DOCS_READ_SCOPES
|
||||
|
||||
router = APIRouter(prefix="/docs", tags=["docs"])
|
||||
|
||||
TOPIC_KINDS = ("workflow", "reference", "pattern", "system")
|
||||
|
||||
|
||||
@router.get("/context")
|
||||
def docs_context(
|
||||
request: Request,
|
||||
documentation_type: DocumentationType = Query(default="admin", alias="type", pattern="^(admin|user)$"),
|
||||
locale: str | None = Query(default=None, min_length=2, max_length=20),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*DOCS_READ_SCOPES)),
|
||||
) -> dict[str, Any]:
|
||||
registry = _registry(request)
|
||||
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)
|
||||
visible_routes = [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_count = sum(len(layer) for layer in documentation_layers.values())
|
||||
topic_groups = _documentation_topic_groups(documentation_layers)
|
||||
|
||||
return {
|
||||
"actor": {
|
||||
"tenant_id": principal.tenant_id,
|
||||
"user_id": principal.user.id,
|
||||
"scope_count": len(principal.scopes),
|
||||
"documentation_type": documentation_type,
|
||||
"locale": resolved_locale,
|
||||
},
|
||||
"summary": {
|
||||
"module_count": len(modules),
|
||||
"visible_route_count": len(visible_routes),
|
||||
"available_route_count": len(available_routes),
|
||||
"permission_count": len(permissions),
|
||||
"granted_permission_count": sum(1 for item in permissions if item["granted"]),
|
||||
"optional_module_count": len(optional_modules),
|
||||
"documentation_topic_count": documentation_count,
|
||||
"configured_documentation_topic_count": len(documentation_layers["configured"]),
|
||||
"workflow_topic_count": len(topic_groups["workflow"]),
|
||||
"reference_topic_count": len(topic_groups["reference"]),
|
||||
"pattern_topic_count": len(topic_groups["pattern"]),
|
||||
"system_topic_count": len(topic_groups["system"]),
|
||||
},
|
||||
"topic_groups": topic_groups,
|
||||
"layers": {
|
||||
"always": {
|
||||
"documentation": documentation_layers["always"],
|
||||
},
|
||||
"configured": {
|
||||
"modules": modules,
|
||||
"routes": visible_routes,
|
||||
"permissions": permissions,
|
||||
"documentation": documentation_layers["configured"],
|
||||
},
|
||||
"available": {
|
||||
"routes": available_routes,
|
||||
"permissions": [item for item in permissions if not item["granted"]],
|
||||
"documentation": documentation_layers["available"],
|
||||
},
|
||||
"evidence": {
|
||||
"optional_modules": optional_modules,
|
||||
"sources": _evidence_sources(),
|
||||
"documentation": documentation_layers["evidence"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _registry(request: Request) -> PlatformRegistry:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
if not isinstance(registry, PlatformRegistry):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="GovOPlaN module registry is not configured")
|
||||
return registry
|
||||
|
||||
|
||||
def _module_payload(manifest: ModuleManifest) -> dict[str, Any]:
|
||||
frontend = manifest.frontend
|
||||
migration = manifest.migration_spec
|
||||
return {
|
||||
"id": manifest.id,
|
||||
"name": manifest.name,
|
||||
"version": manifest.version,
|
||||
"dependencies": list(manifest.dependencies),
|
||||
"optional_dependencies": list(manifest.optional_dependencies),
|
||||
"permission_count": len(manifest.permissions),
|
||||
"role_template_count": len(manifest.role_templates),
|
||||
"nav_count": len(manifest.nav_items) + (len(frontend.nav_items) if frontend else 0),
|
||||
"route_count": (1 if manifest.route_factory else 0) + (len(frontend.routes) if frontend else 0),
|
||||
"frontend_package": frontend.package_name if frontend else None,
|
||||
"backend_route_contributed": manifest.route_factory is not None,
|
||||
"migration_module_id": migration.module_id if migration else None,
|
||||
"capabilities": sorted(manifest.capability_factories),
|
||||
"documentation_count": len(manifest.documentation),
|
||||
"documentation_provider_count": len(manifest.documentation_providers),
|
||||
}
|
||||
|
||||
|
||||
def _permission_payload(permission: PermissionDefinition, principal: ApiPrincipal) -> dict[str, Any]:
|
||||
return {
|
||||
"scope": permission.scope,
|
||||
"label": permission.label,
|
||||
"description": permission.description,
|
||||
"category": permission.category,
|
||||
"level": permission.level,
|
||||
"module_id": permission.module_id,
|
||||
"resource": permission.resource,
|
||||
"action": permission.action,
|
||||
"deprecated": permission.deprecated,
|
||||
"granted": has_scope(principal, permission.scope),
|
||||
}
|
||||
|
||||
|
||||
def _route_items(manifests: tuple[ModuleManifest, ...], principal: ApiPrincipal) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
for manifest in manifests:
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for item in manifest.nav_items:
|
||||
key = ("nav", item.path)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
items.append(_nav_item_payload(manifest.id, item, principal, source="nav"))
|
||||
if manifest.frontend is None:
|
||||
continue
|
||||
nav_by_path = {item.path: item for item in manifest.frontend.nav_items}
|
||||
for item in manifest.frontend.nav_items:
|
||||
key = ("frontend_nav", item.path)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
items.append(_nav_item_payload(manifest.id, item, principal, source="frontend_nav"))
|
||||
for route in manifest.frontend.routes:
|
||||
key = ("frontend_route", route.path)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
items.append(_frontend_route_payload(manifest.id, route, nav_by_path.get(route.path), principal))
|
||||
return sorted(items, key=lambda item: (int(item["order"]), str(item["path"]), str(item["source"])))
|
||||
|
||||
|
||||
def _nav_item_payload(module_id: str, item: NavItem, principal: ApiPrincipal, *, source: str) -> dict[str, Any]:
|
||||
visible, reason = _visibility(item.required_all, item.required_any, principal)
|
||||
return {
|
||||
"module_id": module_id,
|
||||
"path": item.path,
|
||||
"label": item.label,
|
||||
"icon": item.icon,
|
||||
"section": item.section,
|
||||
"source": source,
|
||||
"component": None,
|
||||
"required_all": list(item.required_all),
|
||||
"required_any": list(item.required_any),
|
||||
"order": item.order,
|
||||
"visible": visible,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
def _frontend_route_payload(module_id: str, route: FrontendRoute, nav_item: NavItem | None, principal: ApiPrincipal) -> dict[str, Any]:
|
||||
visible, reason = _visibility(route.required_all, route.required_any, principal)
|
||||
return {
|
||||
"module_id": module_id,
|
||||
"path": route.path,
|
||||
"label": nav_item.label if nav_item else route.component,
|
||||
"icon": nav_item.icon if nav_item else None,
|
||||
"section": nav_item.section if nav_item else None,
|
||||
"source": "frontend_route",
|
||||
"component": route.component,
|
||||
"required_all": list(route.required_all),
|
||||
"required_any": list(route.required_any),
|
||||
"order": route.order,
|
||||
"visible": visible,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
def _visibility(required_all: tuple[str, ...], required_any: tuple[str, ...], principal: ApiPrincipal) -> tuple[bool, str]:
|
||||
missing_all = [scope for scope in required_all if not has_scope(principal, scope)]
|
||||
any_satisfied = not required_any or any(has_scope(principal, scope) for scope in required_any)
|
||||
if not missing_all and any_satisfied:
|
||||
return True, "visible"
|
||||
reasons: list[str] = []
|
||||
if missing_all:
|
||||
reasons.append("missing " + ", ".join(missing_all))
|
||||
if not any_satisfied:
|
||||
reasons.append("requires one of " + ", ".join(required_any))
|
||||
return False, "; ".join(reasons)
|
||||
|
||||
|
||||
def _optional_module_evidence(manifests: tuple[ModuleManifest, ...]) -> list[dict[str, str]]:
|
||||
installed = {manifest.id for manifest in manifests}
|
||||
evidence: list[dict[str, str]] = []
|
||||
for manifest in manifests:
|
||||
for module_id in manifest.optional_dependencies:
|
||||
evidence.append({
|
||||
"module_id": module_id,
|
||||
"source_module_id": manifest.id,
|
||||
"status": "installed" if module_id in installed else "not_installed",
|
||||
"reason": f"{manifest.name} declares optional integration with {module_id}.",
|
||||
})
|
||||
return sorted(evidence, key=lambda item: (item["status"], item["module_id"], item["source_module_id"]))
|
||||
|
||||
|
||||
def _documentation_layers(
|
||||
request: Request,
|
||||
registry: PlatformRegistry,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
documentation_type: DocumentationType,
|
||||
locale: str,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
settings = _settings(request)
|
||||
try:
|
||||
with get_database().SessionLocal() as session:
|
||||
return _classify_documentation(registry, principal, settings=settings, session=session, documentation_type=documentation_type, locale=locale)
|
||||
except RuntimeError:
|
||||
return _classify_documentation(registry, principal, settings=settings, session=None, documentation_type=documentation_type, locale=locale)
|
||||
|
||||
|
||||
def _settings(request: Request) -> object | None:
|
||||
direct_settings = getattr(request.app.state, "govoplan_settings", None)
|
||||
if direct_settings is not None:
|
||||
return direct_settings
|
||||
lifecycle = getattr(request.app.state, "govoplan_lifecycle", None)
|
||||
return getattr(lifecycle, "settings", None)
|
||||
|
||||
|
||||
def _classify_documentation(
|
||||
registry: PlatformRegistry,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
settings: object | None,
|
||||
session: object | None,
|
||||
documentation_type: DocumentationType,
|
||||
locale: str,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []}
|
||||
installed = {manifest.id for manifest in registry.manifests()}
|
||||
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)):
|
||||
if not _topic_matches_documentation_type(topic, documentation_type):
|
||||
continue
|
||||
active, reason, blockers = _documentation_visibility(topic, installed, registry, principal)
|
||||
target_layer = _documentation_target_layer(topic, active, blockers)
|
||||
if target_layer not in layers:
|
||||
target_layer = "evidence"
|
||||
layers[target_layer].append(_documentation_topic_payload(
|
||||
source_module_id,
|
||||
topic,
|
||||
active=active,
|
||||
reason=reason,
|
||||
target_layer=target_layer,
|
||||
blockers=blockers,
|
||||
locale=locale,
|
||||
))
|
||||
return layers
|
||||
|
||||
|
||||
def _collect_documentation_topics(
|
||||
registry: PlatformRegistry,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
settings: object | None,
|
||||
session: object | None,
|
||||
documentation_type: DocumentationType,
|
||||
locale: str,
|
||||
) -> list[tuple[str, DocumentationTopic]]:
|
||||
topics: list[tuple[str, DocumentationTopic]] = []
|
||||
for manifest in registry.manifests():
|
||||
for topic in manifest.documentation:
|
||||
topics.append((manifest.id, topic))
|
||||
if not manifest.documentation_providers:
|
||||
continue
|
||||
context = DocumentationContext(
|
||||
registry=registry,
|
||||
principal=principal,
|
||||
settings=settings,
|
||||
session=session,
|
||||
documentation_type=documentation_type,
|
||||
locale=locale,
|
||||
data={"source_module_id": manifest.id},
|
||||
)
|
||||
for provider in manifest.documentation_providers:
|
||||
try:
|
||||
provided_topics = tuple(provider(context))
|
||||
except Exception as exc:
|
||||
provided_topics = (
|
||||
DocumentationTopic(
|
||||
id=f"{manifest.id}.runtime-documentation-unavailable",
|
||||
title=f"{manifest.name} runtime documentation unavailable",
|
||||
summary="The module registered runtime documentation, but it could not be evaluated for this request.",
|
||||
body="The static module documentation remains available. Check the module API and logs if this persists.",
|
||||
layer="evidence",
|
||||
documentation_types=(documentation_type,),
|
||||
source_module_id=manifest.id,
|
||||
metadata={"error_type": type(exc).__name__},
|
||||
),
|
||||
)
|
||||
for topic in provided_topics:
|
||||
topics.append((manifest.id, topic))
|
||||
return topics
|
||||
|
||||
|
||||
def _topic_matches_documentation_type(topic: DocumentationTopic, documentation_type: DocumentationType) -> bool:
|
||||
return documentation_type in (topic.documentation_types or ("admin",))
|
||||
|
||||
|
||||
def _documentation_visibility(
|
||||
topic: DocumentationTopic,
|
||||
installed: set[str],
|
||||
registry: PlatformRegistry,
|
||||
principal: ApiPrincipal,
|
||||
) -> tuple[bool, str, dict[str, list[str]]]:
|
||||
if not topic.conditions:
|
||||
return True, "documented", {"modules": [], "capabilities": [], "scopes": []}
|
||||
|
||||
reasons: list[str] = []
|
||||
blockers = {"modules": [], "capabilities": [], "scopes": []}
|
||||
for condition in topic.conditions:
|
||||
active, reason, condition_blockers = _condition_visibility(condition, installed, registry, principal)
|
||||
if active:
|
||||
return True, reason, {"modules": [], "capabilities": [], "scopes": []}
|
||||
reasons.append(reason)
|
||||
for key, values in condition_blockers.items():
|
||||
blockers[key].extend(value for value in values if value not in blockers[key])
|
||||
return False, "; ".join(reason for reason in reasons if reason) or "documentation conditions are not satisfied", blockers
|
||||
|
||||
|
||||
def _condition_visibility(
|
||||
condition: DocumentationCondition,
|
||||
installed: set[str],
|
||||
registry: PlatformRegistry,
|
||||
principal: ApiPrincipal,
|
||||
) -> tuple[bool, str, dict[str, list[str]]]:
|
||||
blockers = {"modules": [], "capabilities": [], "scopes": []}
|
||||
missing_required_modules = [module_id for module_id in condition.required_modules if module_id not in installed]
|
||||
if missing_required_modules:
|
||||
blockers["modules"].extend(missing_required_modules)
|
||||
if condition.any_modules and not any(module_id in installed for module_id in condition.any_modules):
|
||||
blockers["modules"].extend(module_id for module_id in condition.any_modules if module_id not in blockers["modules"])
|
||||
conflicting_modules = [module_id for module_id in condition.missing_modules if module_id in installed]
|
||||
if conflicting_modules:
|
||||
blockers["modules"].extend(conflicting_modules)
|
||||
missing_capabilities = [name for name in condition.required_capabilities if not registry.has_capability(name)]
|
||||
if missing_capabilities:
|
||||
blockers["capabilities"].extend(missing_capabilities)
|
||||
missing_scopes = [scope for scope in condition.required_scopes if not has_scope(principal, scope)]
|
||||
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"]:
|
||||
return True, "conditions satisfied", blockers
|
||||
|
||||
parts: list[str] = []
|
||||
if 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):
|
||||
parts.append("requires one installed module from: " + ", ".join(condition.any_modules))
|
||||
if conflicting_modules:
|
||||
parts.append("not active when installed: " + ", ".join(conflicting_modules))
|
||||
if missing_capabilities:
|
||||
parts.append("missing capabilities: " + ", ".join(missing_capabilities))
|
||||
if missing_scopes:
|
||||
parts.append("missing scopes: " + ", ".join(missing_scopes))
|
||||
if missing_any_scopes:
|
||||
parts.append("requires one scope from: " + ", ".join(missing_any_scopes))
|
||||
return False, "; ".join(parts), blockers
|
||||
|
||||
|
||||
def _documentation_target_layer(topic: DocumentationTopic, active: bool, blockers: dict[str, list[str]]) -> str:
|
||||
if topic.layer == "always":
|
||||
return "always"
|
||||
if active:
|
||||
return topic.layer
|
||||
if blockers["modules"] or blockers["capabilities"]:
|
||||
return "evidence"
|
||||
return "available"
|
||||
|
||||
|
||||
def _documentation_topic_payload(
|
||||
source_module_id: str,
|
||||
topic: DocumentationTopic,
|
||||
*,
|
||||
active: bool,
|
||||
reason: str,
|
||||
target_layer: str,
|
||||
blockers: dict[str, list[str]],
|
||||
locale: str,
|
||||
) -> dict[str, Any]:
|
||||
module_id = topic.source_module_id or source_module_id
|
||||
translation_locale, translation = _translation_for_locale(topic, locale)
|
||||
kind = _documentation_topic_kind(topic)
|
||||
return {
|
||||
"id": topic.id,
|
||||
"source_module_id": module_id,
|
||||
"kind": kind,
|
||||
"anchor_id": _documentation_topic_anchor(module_id, topic.id),
|
||||
"title": str(translation.get("title") or topic.title),
|
||||
"summary": str(translation.get("summary") or topic.summary),
|
||||
"body": str(translation.get("body") or topic.body),
|
||||
"layer": topic.layer,
|
||||
"target_layer": target_layer,
|
||||
"documentation_types": list(topic.documentation_types),
|
||||
"active": active,
|
||||
"reason": reason,
|
||||
"blockers": {
|
||||
"modules": sorted(dict.fromkeys(blockers.get("modules", ()))),
|
||||
"capabilities": sorted(dict.fromkeys(blockers.get("capabilities", ()))),
|
||||
"scopes": sorted(dict.fromkeys(blockers.get("scopes", ()))),
|
||||
},
|
||||
"audience": list(topic.audience),
|
||||
"order": topic.order,
|
||||
"i18n_key": topic.i18n_key or topic.id,
|
||||
"locale": locale,
|
||||
"translation_locale": translation_locale,
|
||||
"conditions": [_documentation_condition_payload(condition) for condition in topic.conditions],
|
||||
"links": [_documentation_link_payload(link) for link in topic.links],
|
||||
"related_modules": list(topic.related_modules),
|
||||
"unlocks": list(topic.unlocks),
|
||||
"configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}),
|
||||
"metadata": dict(topic.metadata),
|
||||
}
|
||||
|
||||
|
||||
def _documentation_topic_kind(topic: DocumentationTopic) -> str:
|
||||
raw_kind = topic.metadata.get("kind")
|
||||
if isinstance(raw_kind, str):
|
||||
normalized = raw_kind.strip().lower().replace("_", "-")
|
||||
if normalized in TOPIC_KINDS:
|
||||
return normalized
|
||||
return "system"
|
||||
|
||||
|
||||
def _documentation_topic_anchor(source_module_id: str, topic_id: str) -> str:
|
||||
raw = f"docs-topic-{source_module_id}-{topic_id}"
|
||||
cleaned = "".join(char if char.isalnum() or char in {"_", "-"} else "-" for char in raw)
|
||||
while "--" in cleaned:
|
||||
cleaned = cleaned.replace("--", "-")
|
||||
return cleaned.strip("-").lower()
|
||||
|
||||
|
||||
def _documentation_topic_groups(documentation_layers: dict[str, list[dict[str, Any]]]) -> dict[str, list[dict[str, Any]]]:
|
||||
groups: dict[str, list[dict[str, Any]]] = {kind: [] for kind in TOPIC_KINDS}
|
||||
for topic in _all_documentation_topics(documentation_layers):
|
||||
kind = str(topic.get("kind") or "system")
|
||||
groups.setdefault(kind, []).append(topic)
|
||||
return groups
|
||||
|
||||
|
||||
def _all_documentation_topics(documentation_layers: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
|
||||
topics: list[dict[str, Any]] = []
|
||||
for layer in ("always", "configured", "available", "evidence"):
|
||||
topics.extend(documentation_layers.get(layer, ()))
|
||||
return topics
|
||||
|
||||
|
||||
def _translation_for_locale(topic: DocumentationTopic, locale: str) -> tuple[str, Mapping[str, str]]:
|
||||
translations = topic.translations or {}
|
||||
for candidate in _locale_candidates(locale):
|
||||
translation = translations.get(candidate)
|
||||
if translation:
|
||||
return candidate, translation
|
||||
return "source", {}
|
||||
|
||||
|
||||
def _locale_candidates(locale: str) -> tuple[str, ...]:
|
||||
normalized = _normalize_locale(locale)
|
||||
base = normalized.split("-", 1)[0]
|
||||
candidates = [normalized]
|
||||
if base and base != normalized:
|
||||
candidates.append(base)
|
||||
if "en" not in candidates:
|
||||
candidates.append("en")
|
||||
return tuple(candidates)
|
||||
|
||||
|
||||
def _preferred_locale(request: Request, explicit_locale: str | None) -> str:
|
||||
if explicit_locale:
|
||||
return _normalize_locale(explicit_locale)
|
||||
accept_language = request.headers.get("accept-language", "")
|
||||
first = accept_language.split(",", 1)[0].split(";", 1)[0].strip()
|
||||
return _normalize_locale(first or "en")
|
||||
|
||||
|
||||
def _normalize_locale(value: str) -> str:
|
||||
clean = "".join(char for char in value.strip().replace("_", "-") if char.isalnum() or char == "-")
|
||||
if not clean:
|
||||
return "en"
|
||||
parts = [part for part in clean.split("-") if part]
|
||||
if not parts:
|
||||
return "en"
|
||||
head = parts[0].lower()
|
||||
tail = [part.upper() if len(part) == 2 else part for part in parts[1:]]
|
||||
return "-".join([head, *tail])[:20]
|
||||
|
||||
|
||||
def _documentation_condition_payload(condition: DocumentationCondition) -> dict[str, list[str]]:
|
||||
return {
|
||||
"required_modules": list(condition.required_modules),
|
||||
"any_modules": list(condition.any_modules),
|
||||
"missing_modules": list(condition.missing_modules),
|
||||
"required_capabilities": list(condition.required_capabilities),
|
||||
"required_scopes": list(condition.required_scopes),
|
||||
"any_scopes": list(condition.any_scopes),
|
||||
"configuration_keys": list(condition.configuration_keys),
|
||||
}
|
||||
|
||||
|
||||
def _documentation_link_payload(link: DocumentationLink) -> dict[str, str]:
|
||||
return {
|
||||
"label": link.label,
|
||||
"href": link.href,
|
||||
"kind": link.kind,
|
||||
}
|
||||
|
||||
|
||||
def _evidence_sources() -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"label": "Documentation layer concept",
|
||||
"source": "govoplan-docs/docs/DOCUMENTATION_LAYER_CONCEPT.md",
|
||||
"layer": "evidence",
|
||||
},
|
||||
{
|
||||
"label": "Master roadmap",
|
||||
"source": "govoplan-core/docs/GOVOPLAN_MASTER_ROADMAP.md",
|
||||
"layer": "evidence",
|
||||
},
|
||||
]
|
||||
146
src/govoplan_docs/backend/manifest.py
Normal file
146
src/govoplan_docs/backend/manifest.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
ModuleContext,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
|
||||
DOCS_READ_SCOPE = "docs:documentation:read"
|
||||
DOCS_READ_SCOPES = (DOCS_READ_SCOPE, "system:settings:read", "admin:settings:read")
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category="Documentation",
|
||||
level="tenant",
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
def _route_factory(context: ModuleContext):
|
||||
del context
|
||||
from govoplan_docs.backend.api.v1.routes import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="docs",
|
||||
name="Docs",
|
||||
version="0.1.6",
|
||||
dependencies=("access",),
|
||||
optional_dependencies=("policy", "audit", "ops", "workflow", "search"),
|
||||
permissions=(
|
||||
_permission(
|
||||
DOCS_READ_SCOPE,
|
||||
"View configured documentation",
|
||||
"Read documentation generated from installed modules, visible routes, permissions, and evidence sources.",
|
||||
),
|
||||
),
|
||||
role_templates=(
|
||||
RoleTemplate(
|
||||
slug="docs_reader",
|
||||
name="Documentation reader",
|
||||
description="Read the configured-system documentation browser.",
|
||||
permissions=(DOCS_READ_SCOPE,),
|
||||
),
|
||||
),
|
||||
route_factory=_route_factory,
|
||||
nav_items=(NavItem(path="/docs", label="Docs", icon="reports", required_any=DOCS_READ_SCOPES, order=880),),
|
||||
frontend=FrontendModule(
|
||||
module_id="docs",
|
||||
package_name="@govoplan/docs-webui",
|
||||
routes=(FrontendRoute(path="/docs", component="DocsPage", required_any=DOCS_READ_SCOPES, order=880),),
|
||||
nav_items=(NavItem(path="/docs", label="Docs", icon="reports", required_any=DOCS_READ_SCOPES, order=880),),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="docs.configured-system-documentation",
|
||||
title="Configured system documentation",
|
||||
summary="This documentation page starts with installed modules, active configuration, visible routes, and permissions for the current actor.",
|
||||
body="Module manifests can contribute durable documentation topics. Modules can also register runtime documentation providers when content depends on tenant policy, installed integrations, or operational settings.",
|
||||
layer="always",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "operator", "module_admin"),
|
||||
order=10,
|
||||
i18n_key="docs.topic.configured_system_documentation",
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Dokumentation dieses Systems",
|
||||
"summary": "Diese Dokumentation beginnt mit den installierten Modulen, der aktiven Konfiguration und den Funktionen, die fuer diese Rolle sichtbar sind.",
|
||||
"body": "Module koennen feste Dokumentationsabschnitte beitragen. Wenn Inhalte von Tenant-Regeln, installierten Integrationen oder Betriebsoptionen abhaengen, kann ein Modul laufzeitbasierte Dokumentation registrieren.",
|
||||
},
|
||||
},
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Public GovOPlaN module documentation",
|
||||
href="https://govplan.add-ideas.de/",
|
||||
kind="public",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Documentation layer concept",
|
||||
href="govoplan-docs/docs/DOCUMENTATION_LAYER_CONCEPT.md",
|
||||
kind="repository",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Instance-aware documentation contract",
|
||||
href="govoplan-docs/docs/INSTANCE_AWARE_DOCUMENTATION.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
metadata={"kind": "system"},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="docs.pattern.field-help",
|
||||
title="Field help marker",
|
||||
summary="A small help marker next to a label gives local context without turning dense forms into manuals.",
|
||||
body="Use the marker for short explanations of a field, option, or compact term. Link to a workflow or reference topic when the reader needs steps, API mapping, policy provenance, or operational detail.",
|
||||
layer="always",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "tenant_admin", "operator", "module_admin"),
|
||||
order=20,
|
||||
i18n_key="docs.topic.pattern.field_help",
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Documentation experience concept",
|
||||
href="govoplan-docs/docs/DOCUMENTATION_EXPERIENCE_CONCEPT.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
metadata={
|
||||
"kind": "pattern",
|
||||
"pattern_id": "field-help-marker",
|
||||
"purpose": "Keep labels scannable while making short explanations available on demand.",
|
||||
"when_used": "Form labels, toggle labels, effective-value rows, and compact admin terms.",
|
||||
"user_explanation": "Open the marker when a label is unclear. It should explain the local choice in one or two sentences.",
|
||||
"admin_explanation": "Field help stays local. Longer procedural, API, or policy explanations belong in linked workflow or reference topics.",
|
||||
"component_refs": [
|
||||
"govoplan-core/webui/src/components/help/FieldLabel.tsx",
|
||||
"govoplan-core/webui/src/components/help/InlineHelp.tsx",
|
||||
"govoplan-core/webui/src/utils/fieldHelp.ts",
|
||||
],
|
||||
"related_topic_ids": [
|
||||
"access.reference.admin-access-fields",
|
||||
"access.workflow.grant-user-access",
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
1
src/govoplan_docs/py.typed
Normal file
1
src/govoplan_docs/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user