Add product presentation extension contracts
This commit is contained in:
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
|
||||
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
|
||||
SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1"
|
||||
|
||||
PermissionLevel = Literal["system", "tenant"]
|
||||
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
|
||||
@@ -97,6 +98,38 @@ class PublicFrontendRoute:
|
||||
order: int = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProductAreaContribution:
|
||||
"""Assign module-owned surfaces to a user-facing product area."""
|
||||
|
||||
id: str
|
||||
module_id: str
|
||||
label: str
|
||||
icon: str
|
||||
surface_ids: tuple[str, ...]
|
||||
description: str | None = None
|
||||
order: int = 100
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QuickAccessTool:
|
||||
"""Declare a compact module-owned tool for an optional Quick Access rail."""
|
||||
|
||||
id: str
|
||||
module_id: str
|
||||
category_id: str
|
||||
label: str
|
||||
surface_id: str
|
||||
icon: str
|
||||
description: str | None = None
|
||||
full_page_path: str | None = None
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
default_enabled: bool = True
|
||||
modes: tuple[str, ...] = ("browse",)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrontendModule:
|
||||
module_id: str
|
||||
@@ -111,6 +144,8 @@ class FrontendModule:
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||
product_areas: tuple[ProductAreaContribution, ...] = ()
|
||||
quick_access_tools: tuple[QuickAccessTool, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -21,11 +21,13 @@ PlatformInterfaceKind = Literal[
|
||||
"frontend_route",
|
||||
"navigation",
|
||||
"permission",
|
||||
"product_area",
|
||||
"provided_interface",
|
||||
"public_route",
|
||||
"search_provider",
|
||||
"search_source",
|
||||
"settings_route",
|
||||
"quick_access_tool",
|
||||
"view_surface",
|
||||
]
|
||||
|
||||
@@ -217,6 +219,45 @@ def manifest_interface_declarations(
|
||||
},
|
||||
)
|
||||
)
|
||||
for area in frontend.product_areas:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=f"{manifest.id}.{area.id}",
|
||||
module_id=manifest.id,
|
||||
kind="product_area",
|
||||
label=area.label,
|
||||
required_all=(),
|
||||
required_any=(),
|
||||
metadata={
|
||||
"area_id": area.id,
|
||||
"icon": area.icon,
|
||||
"description": area.description,
|
||||
"order": area.order,
|
||||
"surface_ids": list(area.surface_ids),
|
||||
},
|
||||
)
|
||||
)
|
||||
for tool in frontend.quick_access_tools:
|
||||
declarations.append(
|
||||
PlatformInterfaceDeclaration(
|
||||
id=tool.id,
|
||||
module_id=manifest.id,
|
||||
kind="quick_access_tool",
|
||||
label=tool.label,
|
||||
path=tool.full_page_path,
|
||||
required_all=tool.required_all,
|
||||
required_any=tool.required_any,
|
||||
metadata={
|
||||
"category_id": tool.category_id,
|
||||
"surface_id": tool.surface_id,
|
||||
"icon": tool.icon,
|
||||
"description": tool.description,
|
||||
"order": tool.order,
|
||||
"default_enabled": tool.default_enabled,
|
||||
"modes": list(tool.modes),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
frontend_navigation = {
|
||||
declaration.id: declaration
|
||||
|
||||
@@ -16,7 +16,9 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAreaContribution,
|
||||
PublicFrontendRoute,
|
||||
QuickAccessTool,
|
||||
ResourceAclProvider,
|
||||
RoleTemplate,
|
||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||
@@ -81,6 +83,10 @@ _WILDCARD_RE = re.compile(
|
||||
r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$"
|
||||
)
|
||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||
_PRESENTATION_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{1,79}$")
|
||||
_QUICK_ACCESS_TOOL_ID_RE = re.compile(
|
||||
r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+$"
|
||||
)
|
||||
|
||||
|
||||
class RegistryError(ValueError):
|
||||
@@ -633,6 +639,7 @@ class PlatformRegistry:
|
||||
)
|
||||
permissions = _collect_manifest_permissions(ordered)
|
||||
_validate_public_frontend_route_uniqueness(ordered)
|
||||
_validate_presentation_catalog(ordered)
|
||||
_validate_interface_closure(ordered)
|
||||
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
||||
|
||||
@@ -926,6 +933,31 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
_validate_workflow_definition_contributions(manifest)
|
||||
|
||||
|
||||
def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||
area_definitions: dict[str, tuple[str, str]] = {}
|
||||
tool_owners: dict[str, str] = {}
|
||||
for manifest in manifests:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
continue
|
||||
for area in frontend.product_areas:
|
||||
definition = (area.label, area.icon)
|
||||
previous = area_definitions.get(area.id)
|
||||
if previous is not None and previous != definition:
|
||||
raise RegistryError(
|
||||
f"Product area {area.id!r} has conflicting labels or icons"
|
||||
)
|
||||
area_definitions[area.id] = definition
|
||||
for tool in frontend.quick_access_tools:
|
||||
previous_owner = tool_owners.get(tool.id)
|
||||
if previous_owner is not None:
|
||||
raise RegistryError(
|
||||
f"Duplicate Quick Access tool {tool.id!r} in modules "
|
||||
f"{previous_owner!r} and {manifest.id!r}"
|
||||
)
|
||||
tool_owners[tool.id] = manifest.id
|
||||
|
||||
|
||||
def _validate_architecture_declarations(manifest: ModuleManifest) -> None:
|
||||
architecture = manifest.architecture
|
||||
if architecture is not None:
|
||||
@@ -1340,6 +1372,102 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||
if item.surface_id is not None:
|
||||
_validate_view_surface_id(manifest.id, item.surface_id)
|
||||
_validate_view_surfaces(manifest)
|
||||
_validate_presentation_contributions(manifest)
|
||||
|
||||
|
||||
def _validate_presentation_contributions(manifest: ModuleManifest) -> None:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
return
|
||||
known_surface_ids = {
|
||||
surface.id for surface in manifest_view_surfaces(manifest)
|
||||
}
|
||||
seen_area_memberships: set[tuple[str, str]] = set()
|
||||
for area in frontend.product_areas:
|
||||
_validate_product_area(manifest.id, area, known_surface_ids)
|
||||
for surface_id in area.surface_ids:
|
||||
membership = (area.id, surface_id)
|
||||
if membership in seen_area_memberships:
|
||||
raise RegistryError(
|
||||
f"Duplicate product-area membership {area.id!r}/{surface_id!r} "
|
||||
f"in module {manifest.id!r}"
|
||||
)
|
||||
seen_area_memberships.add(membership)
|
||||
seen_tools: set[str] = set()
|
||||
for tool in frontend.quick_access_tools:
|
||||
_validate_quick_access_tool(manifest.id, tool, known_surface_ids)
|
||||
if tool.id in seen_tools:
|
||||
raise RegistryError(
|
||||
f"Duplicate Quick Access tool {tool.id!r} in module {manifest.id!r}"
|
||||
)
|
||||
seen_tools.add(tool.id)
|
||||
|
||||
|
||||
def _validate_product_area(
|
||||
module_id: str,
|
||||
area: ProductAreaContribution,
|
||||
known_surface_ids: set[str],
|
||||
) -> None:
|
||||
if area.module_id != module_id:
|
||||
raise RegistryError(
|
||||
f"Product area contribution {area.id!r} belongs to {area.module_id!r}, "
|
||||
f"not module {module_id!r}"
|
||||
)
|
||||
if not _PRESENTATION_ID_RE.fullmatch(area.id):
|
||||
raise RegistryError(f"Invalid product area id: {area.id!r}")
|
||||
if not area.label.strip() or not area.icon.strip():
|
||||
raise RegistryError(
|
||||
f"Product area {area.id!r} in module {module_id!r} needs a label and icon"
|
||||
)
|
||||
if not area.surface_ids:
|
||||
raise RegistryError(
|
||||
f"Product area {area.id!r} in module {module_id!r} has no surfaces"
|
||||
)
|
||||
unknown = set(area.surface_ids) - known_surface_ids
|
||||
if unknown:
|
||||
raise RegistryError(
|
||||
f"Product area {area.id!r} in module {module_id!r} references unknown "
|
||||
f"surfaces: {', '.join(sorted(unknown))}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_quick_access_tool(
|
||||
module_id: str,
|
||||
tool: QuickAccessTool,
|
||||
known_surface_ids: set[str],
|
||||
) -> None:
|
||||
if tool.module_id != module_id:
|
||||
raise RegistryError(
|
||||
f"Quick Access tool {tool.id!r} belongs to {tool.module_id!r}, "
|
||||
f"not module {module_id!r}"
|
||||
)
|
||||
if not _QUICK_ACCESS_TOOL_ID_RE.fullmatch(tool.id) or not tool.id.startswith(
|
||||
f"{module_id}."
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Quick Access tool id must be module-namespaced: {tool.id!r}"
|
||||
)
|
||||
if not _PRESENTATION_ID_RE.fullmatch(tool.category_id):
|
||||
raise RegistryError(
|
||||
f"Invalid Quick Access category id: {tool.category_id!r}"
|
||||
)
|
||||
if tool.surface_id not in known_surface_ids:
|
||||
raise RegistryError(
|
||||
f"Quick Access tool {tool.id!r} references unknown surface "
|
||||
f"{tool.surface_id!r}"
|
||||
)
|
||||
if not tool.label.strip() or not tool.icon.strip():
|
||||
raise RegistryError(
|
||||
f"Quick Access tool {tool.id!r} needs a label and icon"
|
||||
)
|
||||
if tool.full_page_path is not None and not tool.full_page_path.startswith("/"):
|
||||
raise RegistryError(
|
||||
f"Quick Access tool {tool.id!r} has an invalid full-page path"
|
||||
)
|
||||
if not tool.modes or any(not mode.strip() for mode in tool.modes):
|
||||
raise RegistryError(
|
||||
f"Quick Access tool {tool.id!r} must declare at least one valid mode"
|
||||
)
|
||||
|
||||
|
||||
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
||||
|
||||
@@ -2,8 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Mapping, Protocol, runtime_checkable
|
||||
|
||||
|
||||
VIEWS_MODULE_ID = "views"
|
||||
@@ -17,6 +17,8 @@ ViewSurfaceKind = Literal[
|
||||
"section",
|
||||
"action",
|
||||
"selector",
|
||||
"product_area",
|
||||
"quick_access",
|
||||
]
|
||||
|
||||
_SURFACE_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{2,159}$")
|
||||
@@ -42,6 +44,7 @@ class EffectiveView:
|
||||
revision_id: str | None
|
||||
name: str | None
|
||||
visible_surface_ids: frozenset[str]
|
||||
presentation: Mapping[str, object] = field(default_factory=dict)
|
||||
locked: bool = False
|
||||
projection_active: bool = False
|
||||
provenance: tuple[dict[str, object], ...] = ()
|
||||
|
||||
@@ -11,7 +11,16 @@ from govoplan_core.core.module_entitlements import (
|
||||
module_entitlement_payload,
|
||||
tenant_module_entitlement_state,
|
||||
)
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
|
||||
from govoplan_core.core.modules import (
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
ProductAreaContribution,
|
||||
PublicFrontendRoute,
|
||||
QuickAccessTool,
|
||||
SUPPORTED_PRESENTATION_CONTRACT_VERSION,
|
||||
)
|
||||
from govoplan_core.core.platform_interfaces import (
|
||||
manifest_interface_catalog,
|
||||
platform_interface_catalog,
|
||||
@@ -132,6 +141,36 @@ def _view_surface_payload(surface: ViewSurface) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _product_area_payload(area: ProductAreaContribution) -> dict[str, object]:
|
||||
return {
|
||||
"id": area.id,
|
||||
"module_id": area.module_id,
|
||||
"label": area.label,
|
||||
"description": area.description,
|
||||
"icon": area.icon,
|
||||
"surface_ids": list(area.surface_ids),
|
||||
"order": area.order,
|
||||
}
|
||||
|
||||
|
||||
def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]:
|
||||
return {
|
||||
"id": tool.id,
|
||||
"module_id": tool.module_id,
|
||||
"category_id": tool.category_id,
|
||||
"label": tool.label,
|
||||
"description": tool.description,
|
||||
"surface_id": tool.surface_id,
|
||||
"icon": tool.icon,
|
||||
"full_page_path": tool.full_page_path,
|
||||
"required_all": list(tool.required_all),
|
||||
"required_any": list(tool.required_any),
|
||||
"order": tool.order,
|
||||
"default_enabled": tool.default_enabled,
|
||||
"modes": list(tool.modes),
|
||||
}
|
||||
|
||||
|
||||
def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]:
|
||||
return [
|
||||
_view_surface_payload(surface)
|
||||
@@ -217,6 +256,13 @@ def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
|
||||
"settings_routes": [_frontend_route_payload(route, manifest.id) for route in frontend.settings_routes],
|
||||
"view_surface_contract_version": VIEW_SURFACE_CONTRACT_VERSION,
|
||||
"view_surfaces": _frontend_view_surfaces(manifest),
|
||||
"presentation_contract_version": SUPPORTED_PRESENTATION_CONTRACT_VERSION,
|
||||
"product_areas": [
|
||||
_product_area_payload(area) for area in frontend.product_areas
|
||||
],
|
||||
"quick_access_tools": [
|
||||
_quick_access_tool_payload(tool) for tool in frontend.quick_access_tools
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user