diff --git a/src/govoplan_docs/backend/api/v1/routes.py b/src/govoplan_docs/backend/api/v1/routes.py index a04171e..5a403c6 100644 --- a/src/govoplan_docs/backend/api/v1/routes.py +++ b/src/govoplan_docs/backend/api/v1/routes.py @@ -20,6 +20,11 @@ from govoplan_core.core.modules import ( user_workflow_scope_condition_issues, ) from govoplan_core.core.registry import PlatformRegistry +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 @@ -42,6 +47,7 @@ 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) @@ -51,10 +57,18 @@ def docs_context( detail="Administrative documentation requires documentation-administrator authority", ) registry = _registry(request) + 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) + documentation_layers = _documentation_layers( + request, + registry, + principal, + documentation_type=documentation_type, + locale=resolved_locale, + target_version=target_version, + ) evidence_sources = ( _documentation_source_summaries( request, @@ -81,6 +95,10 @@ def docs_context( ) 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, @@ -243,6 +261,50 @@ def _documentation_actor( 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]]], *, @@ -471,13 +533,14 @@ def _documentation_layers( *, 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) + 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) + 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: @@ -628,6 +691,7 @@ def _classify_documentation( 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()} @@ -646,11 +710,34 @@ def _classify_documentation( 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, ) - 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 + 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, @@ -686,6 +773,7 @@ def _classify_documentation( documentation_type=documentation_type, visible_runtime_paths=visible_runtime_paths, configuration=configuration, + resolved_version=resolved_version, )) return layers @@ -698,6 +786,7 @@ def _collect_documentation_topics( 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(): @@ -713,7 +802,13 @@ def _collect_documentation_topics( session=session, documentation_type=documentation_type, locale=locale, - data={"source_module_id": manifest.id}, + 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: @@ -1060,6 +1155,7 @@ def _documentation_topic_payload( documentation_type: DocumentationType, visible_runtime_paths: frozenset[str], configuration: Mapping[str, DocumentationConfigurationDecision], + resolved_version: str, ) -> dict[str, Any]: module_id = topic.source_module_id or source_module_id translation_locale, translation = _translation_for_locale(topic, locale) @@ -1090,6 +1186,21 @@ def _documentation_topic_payload( "i18n_key": topic.i18n_key or topic.id, "locale": locale, "translation_locale": translation_locale, + "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), diff --git a/tests/test_docs_context.py b/tests/test_docs_context.py index a3a4779..a1ee6a8 100644 --- a/tests/test_docs_context.py +++ b/tests/test_docs_context.py @@ -46,6 +46,66 @@ class FakePrincipal: class DocsContextTests(unittest.TestCase): + def test_topics_are_filtered_by_installed_or_selected_version(self) -> None: + registry = PlatformRegistry() + registry.register(ModuleManifest( + id="example", + name="Example", + version="2.1.0", + documentation=( + DocumentationTopic( + id="example.legacy", + title="Legacy", + summary="Legacy behavior", + version_max_exclusive="2.0.0", + ), + DocumentationTopic( + id="example.current", + title="Current", + summary="Current behavior", + version_min="2.0.0", + ), + DocumentationTopic( + id="example.universal", + title="Universal", + summary="All versions", + ), + ), + )) + principal = FakePrincipal({"docs:documentation:read"}) + + installed = _classify_documentation( + registry, + principal, + settings=None, + session=None, + documentation_type="admin", + locale="en", + ) + selected = _classify_documentation( + registry, + principal, + settings=None, + session=None, + documentation_type="admin", + locale="en", + target_version="1.9.0", + ) + + self.assertEqual( + {topic["id"] for topic in installed["configured"]}, + {"example.current", "example.universal"}, + ) + self.assertEqual( + {topic["id"] for topic in selected["configured"]}, + {"example.legacy", "example.universal"}, + ) + universal = next( + topic for topic in selected["configured"] + if topic["id"] == "example.universal" + ) + self.assertEqual(universal["version"]["fallback"], "unversioned") + def test_docs_reader_is_the_managed_authenticated_tenant_default(self) -> None: manifest = get_docs_manifest() roles = {template.slug: template for template in manifest.role_templates} diff --git a/webui/src/api/docs.ts b/webui/src/api/docs.ts index 69e5431..86fe5be 100644 --- a/webui/src/api/docs.ts +++ b/webui/src/api/docs.ts @@ -126,6 +126,13 @@ export type DocsDocumentationTopic = { i18n_key: string; locale: string; translation_locale: string; + version: { + resolved: string; + minimum?: string | null; + maximum_exclusive?: string | null; + range: string; + fallback: "unversioned" | "matching_range" | string; + }; conditions: DocsDocumentationCondition[]; links: DocsDocumentationLink[]; related_modules: string[]; @@ -141,6 +148,16 @@ export type DocsDocumentationTopic = { }; export type DocsContext = { + versions: { + mode: "installed" | "selected"; + selected_version?: string | null; + status: "installed" | "stable" | "older_supported" | "unsupported" | string; + latest_version?: string | null; + stable_version?: string | null; + supported_versions: string[]; + installed_versions: Record; + fallback_policy: string; + }; actor: { tenant_id?: string; user_id?: string; @@ -193,10 +210,11 @@ export type DocsContext = { }; }; -export function fetchDocsContext(settings: ApiSettings, options: { documentationType?: "admin" | "user"; locale?: string } = {}): Promise { +export function fetchDocsContext(settings: ApiSettings, options: { documentationType?: "admin" | "user"; locale?: string; version?: string | null } = {}): Promise { const params = new URLSearchParams(); if (options.documentationType) params.set("type", options.documentationType); if (options.locale) params.set("locale", options.locale); + if (options.version) params.set("version", options.version); const query = params.toString(); return apiFetch(settings, `/api/v1/docs/context${query ? `?${query}` : ""}`); } diff --git a/webui/src/features/docs/DocsPage.tsx b/webui/src/features/docs/DocsPage.tsx index d87b446..187c501 100644 --- a/webui/src/features/docs/DocsPage.tsx +++ b/webui/src/features/docs/DocsPage.tsx @@ -74,6 +74,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) { const [documentationType, setDocumentationType] = useState(() => documentationTypeFromSearch(location.search)); const [expandedNodes, setExpandedNodes] = useState>(() => new Set()); const locale = localeFromSearch(location.search) ?? language; + const selectedVersion = versionFromSearch(location.search); const adminDocs = documentationType === "admin"; async function load() { @@ -82,7 +83,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) { setError(""); setContext(null); try { - const nextContext = await fetchDocsContext(settings, { documentationType, locale }); + const nextContext = await fetchDocsContext(settings, { documentationType, locale, version: selectedVersion }); if (sequence !== loadSequence.current) return; if (nextContext.actor.documentation_type !== documentationType) { throw new Error("Documentation response type did not match the requested projection."); @@ -101,7 +102,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) { setDocumentationType((current) => current === nextType ? current : nextType); }, [location.search]); - useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, documentationType, locale]); + useEffect(() => { void load(); }, [settings.apiBaseUrl, settings.apiKey, settings.accessToken, documentationType, locale, selectedVersion]); const treeNodes = useMemo(() => docsTreeNodes(context, adminDocs), [context, adminDocs]); const pages = useMemo(() => flattenTreePages(treeNodes), [treeNodes]); @@ -140,6 +141,21 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) { onSelect={selectDocumentationType} canViewAdmin={context?.actor.available_documentation_types.includes("admin") ?? documentationType === "admin"} /> +