1758 lines
63 KiB
Python
1758 lines
63 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any, Mapping
|
|
from urllib.parse import urlsplit
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
|
|
from govoplan_core.auth import ApiPrincipal, has_scope, require_any_scope
|
|
from govoplan_core.core.modules import (
|
|
DocumentationConfigurationDecision,
|
|
DocumentationCondition,
|
|
DocumentationContext,
|
|
DocumentationLink,
|
|
DocumentationTopic,
|
|
DocumentationType,
|
|
FrontendRoute,
|
|
ModuleManifest,
|
|
NavItem,
|
|
PermissionDefinition,
|
|
localized_documentation_metadata,
|
|
user_workflow_scope_condition_issues,
|
|
)
|
|
from govoplan_core.core.registry import PlatformRegistry
|
|
from govoplan_core.core.provider_governance import (
|
|
ExternalProviderStateContext,
|
|
collect_external_provider_states,
|
|
)
|
|
from govoplan_core.core.versioning import (
|
|
format_version_range,
|
|
version_satisfies_range,
|
|
version_tuple,
|
|
)
|
|
from govoplan_core.db.session import get_database
|
|
|
|
from govoplan_docs.backend.manifest import DOCS_ADMIN_READ_SCOPES, DOCS_READ_SCOPES
|
|
from govoplan_docs.backend.api.v1.semantic_routes import router as semantic_router
|
|
from govoplan_docs.backend.sources import (
|
|
RegisteredDocumentationSource,
|
|
build_documentation_source_registry,
|
|
)
|
|
from govoplan_docs.backend.semantic_service import (
|
|
list_semantic_entries,
|
|
prefetch_semantic_revisions,
|
|
select_locale_entries,
|
|
semantic_entry_payload,
|
|
)
|
|
|
|
router = APIRouter(prefix="/docs", tags=["docs"])
|
|
router.include_router(semantic_router)
|
|
|
|
TOPIC_KINDS = ("workflow", "reference", "pattern", "system")
|
|
_CONFIGURATION_ACTIVE_STATES = frozenset({"enabled", "inherited"})
|
|
_CONFIGURATION_STATES = frozenset(
|
|
{"enabled", "disabled", "inherited", "unavailable"}
|
|
)
|
|
|
|
|
|
@router.get("/context")
|
|
def docs_context(
|
|
request: Request,
|
|
documentation_type: DocumentationType = Query(default="user", alias="type", pattern="^(admin|user)$"),
|
|
locale: str | None = Query(default=None, min_length=2, max_length=20),
|
|
version: str | None = Query(default=None, min_length=1, max_length=40),
|
|
principal: ApiPrincipal = Depends(require_any_scope(*DOCS_READ_SCOPES)),
|
|
) -> 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)
|
|
external_provider_states = _external_provider_states(registry, principal)
|
|
target_version = version if isinstance(version, str) else None
|
|
resolved_locale = _preferred_locale(request, locale)
|
|
route_items = _route_items(registry.manifests(), principal)
|
|
visible_route_items = [item for item in route_items if item["visible"]]
|
|
documentation_layers = _documentation_layers(
|
|
request,
|
|
registry,
|
|
principal,
|
|
documentation_type=documentation_type,
|
|
locale=resolved_locale,
|
|
target_version=target_version,
|
|
)
|
|
evidence_sources = (
|
|
_documentation_source_summaries(
|
|
request,
|
|
registry,
|
|
principal,
|
|
documentation_type=documentation_type,
|
|
locale=resolved_locale,
|
|
)
|
|
if documentation_type == "admin"
|
|
else []
|
|
)
|
|
if documentation_type == "admin":
|
|
catalog = _admin_documentation_catalog(
|
|
registry,
|
|
principal,
|
|
external_provider_states=external_provider_states,
|
|
route_items=route_items,
|
|
visible_route_items=visible_route_items,
|
|
)
|
|
else:
|
|
catalog = _user_documentation_catalog(
|
|
registry,
|
|
external_provider_states=external_provider_states,
|
|
visible_route_items=visible_route_items,
|
|
documentation_layers=documentation_layers,
|
|
)
|
|
topic_groups = _documentation_topic_groups(documentation_layers)
|
|
return {
|
|
"versions": _documentation_version_context(
|
|
registry,
|
|
target_version=target_version,
|
|
),
|
|
"actor": _documentation_actor(
|
|
principal,
|
|
documentation_type=documentation_type,
|
|
locale=resolved_locale,
|
|
can_read_admin=can_read_admin_documentation,
|
|
),
|
|
"summary": _documentation_summary(
|
|
catalog,
|
|
documentation_layers=documentation_layers,
|
|
topic_groups=topic_groups,
|
|
),
|
|
"topic_groups": topic_groups,
|
|
"layers": _documentation_layer_payload(
|
|
catalog,
|
|
documentation_layers=documentation_layers,
|
|
evidence_sources=evidence_sources,
|
|
),
|
|
}
|
|
|
|
|
|
@router.get("/sources")
|
|
def list_documentation_sources(
|
|
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]:
|
|
_require_documentation_type_access(principal, documentation_type)
|
|
registry = _registry(request)
|
|
items = _documentation_source_items(
|
|
request,
|
|
registry,
|
|
principal,
|
|
documentation_type=documentation_type,
|
|
locale=_preferred_locale(request, locale),
|
|
)
|
|
return {
|
|
"items": [source.item.summary() for source in items],
|
|
"total": len(items),
|
|
}
|
|
|
|
|
|
@router.get("/sources/{source_id}")
|
|
def inspect_documentation_source(
|
|
source_id: str,
|
|
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]:
|
|
_require_documentation_type_access(principal, documentation_type)
|
|
registry = _registry(request)
|
|
items = _documentation_source_items(
|
|
request,
|
|
registry,
|
|
principal,
|
|
documentation_type=documentation_type,
|
|
locale=_preferred_locale(request, locale),
|
|
)
|
|
source = next((item for item in items if item.item.id == source_id), None)
|
|
if source is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Documentation source not found",
|
|
)
|
|
return source.item.model_dump(mode="json")
|
|
|
|
|
|
def _require_documentation_type_access(
|
|
principal: ApiPrincipal,
|
|
documentation_type: DocumentationType,
|
|
) -> None:
|
|
if documentation_type == "admin" and not _has_any_scope(
|
|
principal,
|
|
DOCS_ADMIN_READ_SCOPES,
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Administrative documentation requires documentation-administrator authority",
|
|
)
|
|
|
|
|
|
def _admin_documentation_catalog(
|
|
registry: PlatformRegistry,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
external_provider_states: Mapping[str, Mapping[str, object]],
|
|
route_items: list[dict[str, Any]],
|
|
visible_route_items: list[dict[str, Any]],
|
|
) -> dict[str, list[dict[str, Any]]]:
|
|
return {
|
|
"modules": [
|
|
_module_payload(
|
|
manifest,
|
|
technical=True,
|
|
external_provider_states=external_provider_states,
|
|
)
|
|
for manifest in registry.manifests()
|
|
],
|
|
"permissions": [
|
|
_permission_payload(permission, principal)
|
|
for permission in registry.permissions()
|
|
],
|
|
"visible_routes": visible_route_items,
|
|
"available_routes": [item for item in route_items if not item["visible"]],
|
|
"optional_modules": _optional_module_evidence(registry.manifests()),
|
|
}
|
|
|
|
|
|
def _user_documentation_catalog(
|
|
registry: PlatformRegistry,
|
|
*,
|
|
external_provider_states: Mapping[str, Mapping[str, object]],
|
|
visible_route_items: list[dict[str, Any]],
|
|
documentation_layers: Mapping[str, list[dict[str, Any]]],
|
|
) -> dict[str, list[dict[str, Any]]]:
|
|
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)
|
|
),
|
|
}
|
|
return {
|
|
"modules": [
|
|
_module_payload(
|
|
manifest,
|
|
technical=False,
|
|
external_provider_states=external_provider_states,
|
|
)
|
|
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": [],
|
|
}
|
|
|
|
|
|
def _documentation_actor(
|
|
principal: ApiPrincipal,
|
|
*,
|
|
documentation_type: DocumentationType,
|
|
locale: str,
|
|
can_read_admin: bool,
|
|
) -> dict[str, Any]:
|
|
actor: dict[str, Any] = {
|
|
"documentation_type": documentation_type,
|
|
"locale": locale,
|
|
"available_documentation_types": [
|
|
"user",
|
|
*(["admin"] if can_read_admin else []),
|
|
],
|
|
}
|
|
if documentation_type == "admin":
|
|
actor.update(
|
|
tenant_id=principal.tenant_id,
|
|
user_id=principal.user.id,
|
|
scope_count=len(principal.scopes),
|
|
)
|
|
return actor
|
|
|
|
|
|
def _documentation_version_context(
|
|
registry: PlatformRegistry,
|
|
*,
|
|
target_version: str | None,
|
|
) -> dict[str, Any]:
|
|
manifests = registry.manifests()
|
|
installed_versions = {manifest.id: manifest.version for manifest in manifests}
|
|
supported_versions = sorted(
|
|
{
|
|
*(manifest.version for manifest in manifests),
|
|
*(
|
|
topic.version_min
|
|
for manifest in manifests
|
|
for topic in manifest.documentation
|
|
if topic.version_min
|
|
),
|
|
},
|
|
key=version_tuple,
|
|
reverse=True,
|
|
)
|
|
latest = supported_versions[0] if supported_versions else None
|
|
if target_version is None:
|
|
status_name = "installed"
|
|
elif target_version == latest:
|
|
status_name = "stable"
|
|
elif target_version in supported_versions:
|
|
status_name = "older_supported"
|
|
else:
|
|
status_name = "unsupported"
|
|
return {
|
|
"mode": "selected" if target_version else "installed",
|
|
"selected_version": target_version,
|
|
"status": status_name,
|
|
"latest_version": latest,
|
|
"stable_version": latest,
|
|
"supported_versions": supported_versions,
|
|
"installed_versions": installed_versions,
|
|
"fallback_policy": (
|
|
"Topics without bounds apply to every version. Bounded topics are "
|
|
"hidden outside their declared half-open version range."
|
|
),
|
|
}
|
|
|
|
|
|
def _documentation_summary(
|
|
catalog: Mapping[str, list[dict[str, Any]]],
|
|
*,
|
|
documentation_layers: Mapping[str, list[dict[str, Any]]],
|
|
topic_groups: Mapping[str, list[dict[str, Any]]],
|
|
) -> dict[str, int]:
|
|
permissions = catalog["permissions"]
|
|
return {
|
|
"module_count": len(catalog["modules"]),
|
|
"architecture_declared_module_count": sum(
|
|
1 for item in catalog["modules"] if item.get("architecture")
|
|
),
|
|
"external_provider_count": sum(
|
|
int(item.get("external_provider_count") or 0)
|
|
for item in catalog["modules"]
|
|
),
|
|
"visible_route_count": len(catalog["visible_routes"]),
|
|
"available_route_count": len(catalog["available_routes"]),
|
|
"permission_count": len(permissions),
|
|
"granted_permission_count": sum(
|
|
1 for item in permissions if item["granted"]
|
|
),
|
|
"optional_module_count": len(catalog["optional_modules"]),
|
|
"documentation_topic_count": sum(
|
|
len(layer) for layer in documentation_layers.values()
|
|
),
|
|
"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"]),
|
|
}
|
|
|
|
|
|
def _documentation_layer_payload(
|
|
catalog: Mapping[str, list[dict[str, Any]]],
|
|
*,
|
|
documentation_layers: Mapping[str, list[dict[str, Any]]],
|
|
evidence_sources: list[dict[str, Any]],
|
|
) -> dict[str, dict[str, object]]:
|
|
permissions = catalog["permissions"]
|
|
return {
|
|
"always": {
|
|
"documentation": documentation_layers["always"],
|
|
},
|
|
"configured": {
|
|
"modules": catalog["modules"],
|
|
"routes": catalog["visible_routes"],
|
|
"permissions": permissions,
|
|
"documentation": documentation_layers["configured"],
|
|
},
|
|
"available": {
|
|
"routes": catalog["available_routes"],
|
|
"permissions": [item for item in permissions if not item["granted"]],
|
|
"documentation": documentation_layers["available"],
|
|
},
|
|
"evidence": {
|
|
"optional_modules": catalog["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,
|
|
*,
|
|
technical: bool,
|
|
external_provider_states: Mapping[str, Mapping[str, object]] | None = None,
|
|
) -> dict[str, Any]:
|
|
frontend = manifest.frontend
|
|
migration = manifest.migration_spec
|
|
architecture = manifest.architecture.to_dict() if manifest.architecture else None
|
|
if architecture is not None and not technical:
|
|
architecture = {
|
|
"contract_version": architecture["contract_version"],
|
|
"layer": architecture["layer"],
|
|
"kind": architecture["kind"],
|
|
"maturity": architecture["maturity"],
|
|
"known_limits": architecture["known_limits"],
|
|
"supported_authority_modes": architecture[
|
|
"supported_authority_modes"
|
|
],
|
|
"owned_concepts": architecture["owned_concepts"],
|
|
"non_owned_concepts": architecture["non_owned_concepts"],
|
|
"reference_packages": architecture["reference_packages"],
|
|
"target_tested_providers": architecture[
|
|
"target_tested_providers"
|
|
],
|
|
"evidence": [],
|
|
"documentation": {},
|
|
}
|
|
return {
|
|
"id": manifest.id,
|
|
"name": manifest.name,
|
|
"version": manifest.version if technical else "",
|
|
"dependencies": list(manifest.dependencies) if technical else [],
|
|
"optional_dependencies": list(manifest.optional_dependencies) if technical else [],
|
|
"permission_count": len(manifest.permissions) if technical else 0,
|
|
"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) if technical 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 and technical else None,
|
|
"backend_route_contributed": manifest.route_factory is not None if technical else False,
|
|
"migration_module_id": migration.module_id if migration and technical else None,
|
|
"capabilities": sorted(manifest.capability_factories) if technical else [],
|
|
"documentation_count": len(manifest.documentation) if technical else 0,
|
|
"documentation_provider_count": len(manifest.documentation_providers) if technical else 0,
|
|
"architecture": architecture,
|
|
"external_provider_count": len(manifest.external_providers),
|
|
"external_providers": [
|
|
{
|
|
**(
|
|
declaration.to_dict()
|
|
if technical
|
|
else {
|
|
"id": declaration.id,
|
|
"module_id": declaration.module_id,
|
|
"label": declaration.label,
|
|
"maturity": declaration.maturity,
|
|
"operations": list(declaration.operations),
|
|
"authority_modes": list(declaration.authority_modes),
|
|
"known_outage_behavior": declaration.behavior.outage,
|
|
}
|
|
),
|
|
"runtime_state": _documentation_provider_state(
|
|
(external_provider_states or {}).get(declaration.id),
|
|
technical=technical,
|
|
),
|
|
}
|
|
for declaration in manifest.external_providers
|
|
],
|
|
}
|
|
|
|
|
|
def _documentation_provider_state(
|
|
state: Mapping[str, object] | None,
|
|
*,
|
|
technical: bool,
|
|
) -> dict[str, object] | None:
|
|
if state is None:
|
|
return None
|
|
if technical:
|
|
return dict(state)
|
|
return {
|
|
key: state.get(key)
|
|
for key in (
|
|
"configured",
|
|
"active",
|
|
"authority_mode",
|
|
"authority_modes",
|
|
"health",
|
|
"freshness",
|
|
"conflict",
|
|
"recovery",
|
|
"observed_at",
|
|
)
|
|
}
|
|
|
|
|
|
def _external_provider_states(
|
|
registry: PlatformRegistry,
|
|
principal: ApiPrincipal,
|
|
) -> dict[str, dict[str, object]]:
|
|
registrations = registry.external_provider_state_providers()
|
|
if not registrations:
|
|
return {}
|
|
tenant_id = str(principal.tenant_id or "").strip() or None
|
|
try:
|
|
with get_database().session() as session:
|
|
return collect_external_provider_states(
|
|
registrations,
|
|
ExternalProviderStateContext(
|
|
session=session,
|
|
tenant_id=tenant_id,
|
|
principal=principal,
|
|
),
|
|
)
|
|
except Exception: # noqa: BLE001 - return sanitized per-provider diagnostics.
|
|
return collect_external_provider_states(
|
|
registrations,
|
|
ExternalProviderStateContext(
|
|
session=None,
|
|
tenant_id=tenant_id,
|
|
principal=principal,
|
|
),
|
|
)
|
|
|
|
|
|
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]:
|
|
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,
|
|
target_version: str | None = None,
|
|
) -> 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, target_version=target_version)
|
|
except RuntimeError:
|
|
return _classify_documentation(registry, principal, settings=settings, session=None, documentation_type=documentation_type, locale=locale, target_version=target_version)
|
|
|
|
|
|
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 _documentation_source_summaries(
|
|
request: Request,
|
|
registry: PlatformRegistry,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
documentation_type: DocumentationType,
|
|
locale: str,
|
|
) -> list[dict[str, Any]]:
|
|
return [
|
|
source.item.summary()
|
|
for source in _documentation_source_items(
|
|
request,
|
|
registry,
|
|
principal,
|
|
documentation_type=documentation_type,
|
|
locale=locale,
|
|
)
|
|
]
|
|
|
|
|
|
def _documentation_source_items(
|
|
request: Request,
|
|
registry: PlatformRegistry,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
documentation_type: DocumentationType,
|
|
locale: str,
|
|
) -> list[RegisteredDocumentationSource]:
|
|
source_registry = build_documentation_source_registry(registry.manifests())
|
|
settings = _settings(request)
|
|
try:
|
|
with get_database().SessionLocal() as session:
|
|
return _visible_documentation_sources(
|
|
source_registry.sources(),
|
|
registry,
|
|
principal,
|
|
settings=settings,
|
|
session=session,
|
|
documentation_type=documentation_type,
|
|
locale=locale,
|
|
)
|
|
except RuntimeError:
|
|
return _visible_documentation_sources(
|
|
source_registry.sources(),
|
|
registry,
|
|
principal,
|
|
settings=settings,
|
|
session=None,
|
|
documentation_type=documentation_type,
|
|
locale=locale,
|
|
)
|
|
|
|
|
|
def _visible_documentation_sources(
|
|
sources: tuple[RegisteredDocumentationSource, ...],
|
|
registry: PlatformRegistry,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
settings: object | None,
|
|
session: object | None,
|
|
documentation_type: DocumentationType,
|
|
locale: str,
|
|
) -> list[RegisteredDocumentationSource]:
|
|
installed = {manifest.id for manifest in registry.manifests()}
|
|
context = DocumentationContext(
|
|
registry=registry,
|
|
principal=principal,
|
|
settings=settings,
|
|
session=session,
|
|
documentation_type=documentation_type,
|
|
locale=locale,
|
|
)
|
|
visible: list[RegisteredDocumentationSource] = []
|
|
for source in sources:
|
|
if documentation_type not in source.documentation_types:
|
|
continue
|
|
keys = tuple(dict.fromkeys((
|
|
*source.condition.configuration_keys,
|
|
*([source.configuration_key] if source.configuration_key else []),
|
|
)))
|
|
configuration = _resolve_documentation_configuration(
|
|
registry,
|
|
source.item.owner_module_id,
|
|
keys,
|
|
context=context,
|
|
)
|
|
active, reason, blockers = _condition_visibility(
|
|
source.condition,
|
|
installed,
|
|
registry,
|
|
principal,
|
|
configuration=configuration,
|
|
)
|
|
if blockers["scopes"]:
|
|
continue
|
|
state = source.item.state
|
|
source_configuration = (
|
|
configuration.get(source.configuration_key)
|
|
if source.configuration_key
|
|
else None
|
|
)
|
|
if state == "configured" and source_configuration is not None:
|
|
if source_configuration.state == "unavailable":
|
|
state = "unavailable"
|
|
reason = source_configuration.reason or reason
|
|
elif source_configuration.state == "disabled":
|
|
state = "disabled"
|
|
reason = source_configuration.reason or reason
|
|
if state == "configured" and not active:
|
|
state = (
|
|
"unavailable"
|
|
if any(
|
|
decision.state == "unavailable"
|
|
for decision in configuration.values()
|
|
)
|
|
or blockers["modules"]
|
|
or blockers["capabilities"]
|
|
else "disabled"
|
|
)
|
|
item = source.item.model_copy(update={
|
|
"state": state,
|
|
"state_reason": None if state == "configured" else reason,
|
|
})
|
|
visible.append(RegisteredDocumentationSource(
|
|
item=item,
|
|
condition=source.condition,
|
|
documentation_types=source.documentation_types,
|
|
configuration_key=source.configuration_key,
|
|
))
|
|
return visible
|
|
|
|
|
|
def _classify_documentation(
|
|
registry: PlatformRegistry,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
settings: object | None,
|
|
session: object | None,
|
|
documentation_type: DocumentationType,
|
|
locale: str,
|
|
target_version: str | None = None,
|
|
) -> dict[str, list[dict[str, Any]]]:
|
|
layers: dict[str, list[dict[str, Any]]] = {"always": [], "configured": [], "available": [], "evidence": []}
|
|
installed = {manifest.id for manifest in registry.manifests()}
|
|
visible_area_modules = frozenset(
|
|
str(item["module_id"])
|
|
for item in _route_items(registry.manifests(), principal)
|
|
if item["visible"]
|
|
) if documentation_type == "user" else frozenset(installed)
|
|
visible_runtime_paths = frozenset([
|
|
"/settings", # Authenticated shell route, not contributed by a module manifest.
|
|
*(
|
|
str(item["path"])
|
|
for item in _route_items(registry.manifests(), principal)
|
|
if item["visible"]
|
|
),
|
|
]) if documentation_type == "user" else frozenset()
|
|
context = DocumentationContext(
|
|
registry=registry,
|
|
principal=principal,
|
|
settings=settings,
|
|
session=session,
|
|
documentation_type=documentation_type,
|
|
locale=locale,
|
|
data={
|
|
"target_version": target_version,
|
|
"installed_versions": {
|
|
manifest.id: manifest.version for manifest in registry.manifests()
|
|
},
|
|
},
|
|
)
|
|
topics = _collect_documentation_topics(
|
|
registry,
|
|
principal,
|
|
settings=settings,
|
|
session=session,
|
|
documentation_type=documentation_type,
|
|
locale=locale,
|
|
target_version=target_version,
|
|
)
|
|
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
|
|
module_id = topic.source_module_id or source_module_id
|
|
manifest = registry.get(module_id)
|
|
resolved_version = target_version or (manifest.version if manifest else "0")
|
|
if not version_satisfies_range(
|
|
resolved_version,
|
|
version_min=topic.version_min,
|
|
version_max_exclusive=topic.version_max_exclusive,
|
|
):
|
|
continue
|
|
configuration_keys = _documentation_configuration_keys(topic)
|
|
configuration = _resolve_documentation_configuration(
|
|
registry,
|
|
topic.source_module_id or source_module_id,
|
|
configuration_keys,
|
|
context=context,
|
|
)
|
|
active, reason, blockers = _documentation_visibility(
|
|
topic,
|
|
installed,
|
|
registry,
|
|
principal,
|
|
configuration=configuration,
|
|
)
|
|
if documentation_type == "user" and not active:
|
|
continue
|
|
target_layer = _documentation_target_layer(
|
|
topic,
|
|
active,
|
|
blockers,
|
|
configuration=configuration,
|
|
)
|
|
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,
|
|
documentation_type=documentation_type,
|
|
visible_runtime_paths=visible_runtime_paths,
|
|
configuration=configuration,
|
|
resolved_version=resolved_version,
|
|
visible_area_modules=visible_area_modules,
|
|
))
|
|
return layers
|
|
|
|
|
|
def _collect_documentation_topics(
|
|
registry: PlatformRegistry,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
settings: object | None,
|
|
session: object | None,
|
|
documentation_type: DocumentationType,
|
|
locale: str,
|
|
target_version: str | None = None,
|
|
) -> list[tuple[str, DocumentationTopic]]:
|
|
topics: list[tuple[str, DocumentationTopic]] = []
|
|
for manifest in registry.manifests():
|
|
for topic in manifest.documentation:
|
|
if not user_workflow_scope_condition_issues(topic):
|
|
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,
|
|
"target_version": target_version,
|
|
"installed_versions": {
|
|
item.id: item.version for item in registry.manifests()
|
|
},
|
|
},
|
|
)
|
|
for provider in manifest.documentation_providers:
|
|
try:
|
|
provided_topics = tuple(provider(context))
|
|
except Exception as exc:
|
|
if documentation_type != "admin":
|
|
continue
|
|
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:
|
|
if not user_workflow_scope_condition_issues(topic):
|
|
topics.append((manifest.id, topic))
|
|
if session is not None:
|
|
try:
|
|
topics.extend(
|
|
("docs", topic)
|
|
for topic in _semantic_documentation_topics(
|
|
registry,
|
|
principal,
|
|
session=session,
|
|
locale=locale,
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
if documentation_type == "admin":
|
|
topics.append(
|
|
(
|
|
"docs",
|
|
DocumentationTopic(
|
|
id="docs.semantic-runtime-unavailable",
|
|
title="Tenant semantic documentation unavailable",
|
|
summary="Stored tenant semantics could not be projected for this request.",
|
|
body="Static module documentation remains available. Check the Docs database migration, subject providers, and application logs.",
|
|
layer="evidence",
|
|
documentation_types=("admin",),
|
|
source_module_id="docs",
|
|
metadata={
|
|
"kind": "system",
|
|
"error_type": type(exc).__name__,
|
|
},
|
|
),
|
|
)
|
|
)
|
|
return topics
|
|
|
|
|
|
def _semantic_documentation_topics(
|
|
registry: PlatformRegistry,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
session: object,
|
|
locale: str,
|
|
) -> tuple[DocumentationTopic, ...]:
|
|
entries = select_locale_entries(
|
|
list_semantic_entries(session, principal),
|
|
locale=locale,
|
|
)
|
|
revisions = prefetch_semantic_revisions(session, principal, entries=entries, editor=False)
|
|
topics: list[DocumentationTopic] = []
|
|
for entry in entries:
|
|
payload = semantic_entry_payload(
|
|
session,
|
|
registry,
|
|
principal,
|
|
entry=entry,
|
|
editor=False,
|
|
requested_locale=locale,
|
|
revisions=revisions,
|
|
)
|
|
if payload is None:
|
|
continue
|
|
content = payload["content"]
|
|
resolution = payload["subject_resolution"]
|
|
subject = resolution.get("subject") if isinstance(resolution, Mapping) else None
|
|
route = subject.get("route") if isinstance(subject, Mapping) else None
|
|
route_anchor = (
|
|
subject.get("route_anchor") if isinstance(subject, Mapping) else None
|
|
)
|
|
links = [
|
|
DocumentationLink(
|
|
label=str(item["label"]),
|
|
href=str(item["href"]),
|
|
kind="runtime" if str(item["href"]).startswith("/") else "external",
|
|
)
|
|
for item in content.get("links", ())
|
|
if isinstance(item, Mapping) and item.get("label") and item.get("href")
|
|
]
|
|
if isinstance(route, str) and route.startswith("/"):
|
|
links.insert(
|
|
0,
|
|
DocumentationLink(
|
|
label="Open configured subject",
|
|
href=(f"{route}#{route_anchor}" if route_anchor else route),
|
|
kind="runtime",
|
|
),
|
|
)
|
|
body_parts = [
|
|
str(content.get(key) or "").strip()
|
|
for key in (
|
|
"meaning",
|
|
"body",
|
|
"intended_use",
|
|
"non_intended_use",
|
|
)
|
|
]
|
|
topics.append(
|
|
DocumentationTopic(
|
|
id=f"docs.semantic.{entry.id}",
|
|
title=str(content.get("title") or "Semantic documentation"),
|
|
summary=str(content.get("summary") or "Tenant semantic guidance"),
|
|
body="\n\n".join(part for part in body_parts if part),
|
|
layer="configured",
|
|
documentation_types=("admin", "user"),
|
|
source_module_id="docs",
|
|
order=200,
|
|
links=tuple(links),
|
|
related_modules=(entry.subject_module_id,),
|
|
metadata={
|
|
"kind": "reference",
|
|
"source_badge": "tenant_semantic",
|
|
"semantic_entry_id": entry.id,
|
|
"semantic_subject": payload["subject"],
|
|
"semantic_subject_stable_key": entry.subject_stable_key,
|
|
"subject_availability": resolution.get("availability"),
|
|
"locale": payload["locale"],
|
|
"requested_locale": payload["requested_locale"],
|
|
"locale_fallback": payload["locale_fallback"],
|
|
"lifecycle_state": payload["lifecycle_state"],
|
|
"pending_draft": payload["pending_draft"],
|
|
"route": route,
|
|
"route_anchor": route_anchor,
|
|
"help_contexts": [
|
|
_semantic_help_context(entry, route_anchor=route_anchor),
|
|
],
|
|
},
|
|
)
|
|
)
|
|
return tuple(topics)
|
|
|
|
|
|
def _semantic_help_context(
|
|
entry: object,
|
|
*,
|
|
route_anchor: object | None,
|
|
) -> str:
|
|
values = [
|
|
"semantic",
|
|
str(getattr(entry, "subject_module_id")),
|
|
str(getattr(entry, "subject_kind")),
|
|
str(getattr(entry, "subject_id")),
|
|
]
|
|
if route_anchor:
|
|
values.append(str(route_anchor))
|
|
return ".".join(values)
|
|
|
|
|
|
def _topic_matches_documentation_type(topic: DocumentationTopic, documentation_type: DocumentationType) -> bool:
|
|
return documentation_type in (topic.documentation_types or ("admin",))
|
|
|
|
|
|
def _documentation_configuration_keys(
|
|
topic: DocumentationTopic,
|
|
) -> tuple[str, ...]:
|
|
return tuple(dict.fromkeys((
|
|
*topic.configuration_keys,
|
|
*(
|
|
key
|
|
for condition in topic.conditions
|
|
for key in condition.configuration_keys
|
|
),
|
|
)))
|
|
|
|
|
|
def _resolve_documentation_configuration(
|
|
registry: PlatformRegistry,
|
|
module_id: str,
|
|
keys: tuple[str, ...],
|
|
*,
|
|
context: DocumentationContext,
|
|
) -> dict[str, DocumentationConfigurationDecision]:
|
|
decisions = {
|
|
key: DocumentationConfigurationDecision(
|
|
key=key,
|
|
state="unavailable",
|
|
reason="No configuration-state provider is registered.",
|
|
)
|
|
for key in keys
|
|
}
|
|
manifest = registry.get(module_id)
|
|
if manifest is None or not keys:
|
|
return decisions
|
|
|
|
for registration in manifest.documentation_configuration_providers:
|
|
requested = tuple(key for key in keys if key in registration.keys)
|
|
if not requested:
|
|
continue
|
|
try:
|
|
provided = registration.resolve(context, requested)
|
|
except Exception as exc:
|
|
for key in requested:
|
|
decisions[key] = DocumentationConfigurationDecision(
|
|
key=key,
|
|
state="unavailable",
|
|
reason=f"Configuration-state provider failed ({type(exc).__name__}).",
|
|
)
|
|
continue
|
|
for key in requested:
|
|
decision = provided.get(key)
|
|
if (
|
|
not isinstance(decision, DocumentationConfigurationDecision)
|
|
or decision.key != key
|
|
or decision.state not in _CONFIGURATION_STATES
|
|
):
|
|
decisions[key] = DocumentationConfigurationDecision(
|
|
key=key,
|
|
state="unavailable",
|
|
reason="Configuration-state provider returned an invalid decision.",
|
|
)
|
|
continue
|
|
decisions[key] = DocumentationConfigurationDecision(
|
|
key=key,
|
|
state=decision.state,
|
|
source=_bounded_configuration_text(decision.source),
|
|
reason=_bounded_configuration_text(decision.reason),
|
|
)
|
|
return decisions
|
|
|
|
|
|
def _bounded_configuration_text(value: object | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
clean = str(value).strip()
|
|
return clean[:500] if clean else None
|
|
|
|
|
|
def _documentation_configuration_payload(
|
|
decision: DocumentationConfigurationDecision,
|
|
) -> dict[str, str | None]:
|
|
return {
|
|
"key": decision.key,
|
|
"state": decision.state,
|
|
"source": decision.source,
|
|
"reason": decision.reason,
|
|
}
|
|
|
|
|
|
def _documentation_visibility(
|
|
topic: DocumentationTopic,
|
|
installed: set[str],
|
|
registry: PlatformRegistry,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
configuration: Mapping[str, DocumentationConfigurationDecision] | None = None,
|
|
) -> tuple[bool, str, dict[str, list[str]]]:
|
|
if not topic.conditions:
|
|
return True, "documented", {
|
|
"modules": [],
|
|
"capabilities": [],
|
|
"scopes": [],
|
|
"configuration": [],
|
|
}
|
|
|
|
reasons: list[str] = []
|
|
blockers = {
|
|
"modules": [],
|
|
"capabilities": [],
|
|
"scopes": [],
|
|
"configuration": [],
|
|
}
|
|
for condition in topic.conditions:
|
|
active, reason, condition_blockers = _condition_visibility(
|
|
condition,
|
|
installed,
|
|
registry,
|
|
principal,
|
|
configuration=configuration,
|
|
)
|
|
if active:
|
|
return True, reason, {
|
|
"modules": [],
|
|
"capabilities": [],
|
|
"scopes": [],
|
|
"configuration": [],
|
|
}
|
|
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,
|
|
*,
|
|
configuration: Mapping[str, DocumentationConfigurationDecision] | None = None,
|
|
) -> tuple[bool, str, dict[str, list[str]]]:
|
|
missing_required_modules = _missing_required_modules(condition, installed)
|
|
unsatisfied_any_modules = _unsatisfied_any_modules(condition, installed)
|
|
conflicting_modules = _conflicting_modules(condition, installed)
|
|
missing_capabilities = _missing_required_capabilities(condition, registry)
|
|
missing_scopes = _missing_required_scopes(condition, principal)
|
|
unsatisfied_any_scopes = _unsatisfied_any_scopes(condition, principal)
|
|
unavailable_configuration = _unavailable_configuration_keys(
|
|
condition,
|
|
configuration or {},
|
|
)
|
|
blockers = _condition_blockers(
|
|
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,
|
|
unavailable_configuration=unavailable_configuration,
|
|
)
|
|
|
|
if _condition_has_no_blockers(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,
|
|
unavailable_configuration=unavailable_configuration,
|
|
)
|
|
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 _unavailable_configuration_keys(
|
|
condition: DocumentationCondition,
|
|
configuration: Mapping[str, DocumentationConfigurationDecision],
|
|
) -> list[str]:
|
|
return [
|
|
key
|
|
for key in condition.configuration_keys
|
|
if configuration.get(
|
|
key,
|
|
DocumentationConfigurationDecision(key=key, state="unavailable"),
|
|
).state not in _CONFIGURATION_ACTIVE_STATES
|
|
]
|
|
|
|
|
|
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],
|
|
unavailable_configuration: list[str],
|
|
) -> dict[str, list[str]]:
|
|
blockers = {
|
|
"modules": [],
|
|
"capabilities": [],
|
|
"scopes": [],
|
|
"configuration": [],
|
|
}
|
|
_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)
|
|
_extend_unique(blockers["configuration"], unavailable_configuration)
|
|
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 any(blockers.values())
|
|
|
|
|
|
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],
|
|
unavailable_configuration: list[str],
|
|
) -> str:
|
|
parts: list[str] = []
|
|
if missing_required_modules:
|
|
parts.append("missing modules: " + ", ".join(missing_required_modules))
|
|
if unsatisfied_any_modules:
|
|
parts.append("requires one installed module from: " + ", ".join(unsatisfied_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 unsatisfied_any_scopes:
|
|
parts.append("requires one scope from: " + ", ".join(unsatisfied_any_scopes))
|
|
if unavailable_configuration:
|
|
parts.append(
|
|
"configuration is disabled or unavailable: "
|
|
+ ", ".join(unavailable_configuration)
|
|
)
|
|
return "; ".join(parts)
|
|
|
|
|
|
def _documentation_target_layer(
|
|
topic: DocumentationTopic,
|
|
active: bool,
|
|
blockers: dict[str, list[str]],
|
|
*,
|
|
configuration: Mapping[str, DocumentationConfigurationDecision] | None = None,
|
|
) -> str:
|
|
if topic.layer == "always":
|
|
return "always"
|
|
if active:
|
|
return topic.layer
|
|
if blockers["modules"] or blockers["capabilities"]:
|
|
return "evidence"
|
|
if any(
|
|
decision.state == "unavailable"
|
|
for decision in (configuration or {}).values()
|
|
):
|
|
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,
|
|
documentation_type: DocumentationType,
|
|
visible_runtime_paths: frozenset[str],
|
|
configuration: Mapping[str, DocumentationConfigurationDecision],
|
|
resolved_version: str,
|
|
visible_area_modules: frozenset[str] = frozenset(),
|
|
) -> dict[str, Any]:
|
|
module_id = topic.source_module_id or source_module_id
|
|
translation_locale, translation = _translation_for_locale(topic, locale)
|
|
structured_translation_locale = _structured_translation_locale(topic, locale)
|
|
localized_metadata = localized_documentation_metadata(
|
|
topic, structured_translation_locale
|
|
)
|
|
kind = _documentation_topic_kind(topic)
|
|
payload = {
|
|
"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", ()))),
|
|
"configuration": sorted(
|
|
dict.fromkeys(blockers.get("configuration", ()))
|
|
),
|
|
},
|
|
"audience": list(topic.audience),
|
|
"order": topic.order,
|
|
"i18n_key": topic.i18n_key or topic.id,
|
|
"locale": locale,
|
|
"translation_locale": translation_locale,
|
|
"structured_translation_locale": structured_translation_locale,
|
|
"structured_translation_version": topic.structured_translation_version,
|
|
"version": {
|
|
"resolved": resolved_version,
|
|
"minimum": topic.version_min,
|
|
"maximum_exclusive": topic.version_max_exclusive,
|
|
"range": format_version_range(
|
|
version_min=topic.version_min,
|
|
version_max_exclusive=topic.version_max_exclusive,
|
|
),
|
|
"fallback": (
|
|
"unversioned"
|
|
if topic.version_min is None
|
|
and topic.version_max_exclusive is None
|
|
else "matching_range"
|
|
),
|
|
},
|
|
"conditions": [_documentation_condition_payload(condition) for condition in topic.conditions],
|
|
"links": [_documentation_link_payload(link) for link in topic.links],
|
|
"related_modules": list(topic.related_modules),
|
|
"area_module_ids": sorted({
|
|
module_id,
|
|
*(
|
|
area for area in (
|
|
*topic.related_modules,
|
|
*_bounded_string_list(localized_metadata.get("areas"), maximum_items=32, maximum_length=255),
|
|
)
|
|
if area in visible_area_modules
|
|
),
|
|
}),
|
|
"unlocks": list(topic.unlocks),
|
|
"configuration_keys": sorted({*topic.configuration_keys, *(key for condition in topic.conditions for key in condition.configuration_keys)}),
|
|
"configuration_states": [
|
|
_documentation_configuration_payload(configuration[key])
|
|
for key in sorted(configuration)
|
|
],
|
|
"metadata": localized_metadata,
|
|
}
|
|
if documentation_type == "admin":
|
|
return payload
|
|
return {
|
|
"id": payload["id"],
|
|
"source_module_id": payload["source_module_id"],
|
|
"area_module_ids": payload["area_module_ids"],
|
|
"kind": payload["kind"],
|
|
"anchor_id": payload["anchor_id"],
|
|
"title": payload["title"],
|
|
"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": [],
|
|
"configuration": [],
|
|
},
|
|
"audience": [],
|
|
"order": payload["order"],
|
|
"i18n_key": "",
|
|
"locale": locale,
|
|
"translation_locale": payload["translation_locale"],
|
|
"structured_translation_locale": payload[
|
|
"structured_translation_locale"
|
|
],
|
|
"structured_translation_version": payload[
|
|
"structured_translation_version"
|
|
],
|
|
"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": [],
|
|
"configuration_states": [],
|
|
"metadata": _user_topic_metadata(kind, localized_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
|
|
tags = _bounded_string_list(metadata.get("tags"), maximum_items=32, maximum_length=80)
|
|
if tags:
|
|
projected["tags"] = tags
|
|
if kind == "reference" and isinstance(metadata.get("fields"), list):
|
|
fields = [_user_field_metadata(item) for item in metadata["fields"][:64] if isinstance(item, Mapping)]
|
|
if fields:
|
|
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:
|
|
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 _structured_translation_locale(topic: DocumentationTopic, locale: str) -> str:
|
|
for candidate in _locale_candidates(locale):
|
|
if candidate in topic.structured_translations:
|
|
return candidate
|
|
return "source"
|
|
|
|
|
|
def _locale_candidates(locale: str) -> tuple[str, ...]:
|
|
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,
|
|
}
|