chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:21 +02:00
parent 670f0d9b6f
commit 5413ddfc32
22 changed files with 2732 additions and 27 deletions

View File

@@ -0,0 +1,5 @@
"""GovOPlaN configured-system documentation module."""
__all__ = ["__version__"]
__version__ = "0.1.6"

View File

@@ -0,0 +1 @@
"""Backend integration for the GovOPlaN docs module."""

View File

@@ -0,0 +1 @@
"""Docs API package."""

View File

@@ -0,0 +1 @@
"""Docs API v1 package."""

View 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",
},
]

View 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

View File

@@ -0,0 +1 @@