Add governed View surface runtime
This commit is contained in:
@@ -4,6 +4,8 @@ from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal, Protocol, TYPE_CHECKING
|
||||
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import APIRouter
|
||||
|
||||
@@ -57,6 +59,7 @@ class NavItem:
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
surface_id: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -68,6 +71,7 @@ class FrontendRoute:
|
||||
required_all: tuple[str, ...] = ()
|
||||
required_any: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
surface_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -92,6 +96,7 @@ class FrontendModule:
|
||||
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||
nav_items: tuple[NavItem, ...] = ()
|
||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||
view_surfaces: tuple[ViewSurface, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -25,6 +25,13 @@ from govoplan_core.core.modules import (
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
from govoplan_core.core.views import (
|
||||
ViewSurface,
|
||||
module_view_surface_id,
|
||||
navigation_view_surface_id,
|
||||
route_view_surface_id,
|
||||
validate_view_surface_id,
|
||||
)
|
||||
|
||||
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
|
||||
@@ -113,6 +120,13 @@ class PlatformRegistry:
|
||||
def nav_items(self) -> tuple[NavItem, ...]:
|
||||
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
|
||||
|
||||
def view_surfaces(self) -> tuple[ViewSurface, ...]:
|
||||
return tuple(
|
||||
surface
|
||||
for manifest in self.manifests()
|
||||
for surface in manifest_view_surfaces(manifest)
|
||||
)
|
||||
|
||||
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
||||
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
||||
|
||||
@@ -230,6 +244,53 @@ class PlatformRegistry:
|
||||
return (self._manifests[module_id] for module_id in ordered)
|
||||
|
||||
|
||||
def manifest_view_surfaces(manifest: ModuleManifest) -> tuple[ViewSurface, ...]:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
return ()
|
||||
root_id = module_view_surface_id(manifest.id)
|
||||
surfaces = [
|
||||
ViewSurface(
|
||||
id=root_id,
|
||||
module_id=manifest.id,
|
||||
kind="module",
|
||||
label=manifest.name,
|
||||
order=min((item.order for item in frontend.nav_items), default=100),
|
||||
)
|
||||
]
|
||||
surfaces.extend(
|
||||
ViewSurface(
|
||||
id=item.surface_id or navigation_view_surface_id(manifest.id, item.path),
|
||||
module_id=manifest.id,
|
||||
kind="navigation",
|
||||
label=item.label,
|
||||
parent_id=root_id,
|
||||
order=item.order,
|
||||
)
|
||||
for item in frontend.nav_items
|
||||
)
|
||||
surfaces.extend(
|
||||
ViewSurface(
|
||||
id=route.surface_id or route_view_surface_id(manifest.id, route.path),
|
||||
module_id=manifest.id,
|
||||
kind="route",
|
||||
label=route.component,
|
||||
parent_id=root_id,
|
||||
description=route.path,
|
||||
order=route.order,
|
||||
)
|
||||
for route in (*frontend.routes, *frontend.settings_routes)
|
||||
)
|
||||
surfaces.extend(
|
||||
replace(
|
||||
surface,
|
||||
parent_id=surface.parent_id or root_id,
|
||||
)
|
||||
for surface in frontend.view_surfaces
|
||||
)
|
||||
return tuple(surfaces)
|
||||
|
||||
|
||||
def _normalize_delete_veto_result(
|
||||
result: object,
|
||||
*,
|
||||
@@ -438,8 +499,90 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||
for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes):
|
||||
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
if route.surface_id is not None:
|
||||
_validate_view_surface_id(manifest.id, route.surface_id)
|
||||
for item in frontend.nav_items:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
if item.surface_id is not None:
|
||||
_validate_view_surface_id(manifest.id, item.surface_id)
|
||||
_validate_view_surfaces(manifest)
|
||||
|
||||
|
||||
def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
return
|
||||
root_id = module_view_surface_id(manifest.id)
|
||||
_validate_view_surface_id(manifest.id, root_id)
|
||||
known_ids = {root_id}
|
||||
for item in frontend.nav_items:
|
||||
surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path)
|
||||
_validate_view_surface_id(manifest.id, surface_id)
|
||||
if surface_id in known_ids:
|
||||
raise RegistryError(
|
||||
f"Duplicate view surface id {surface_id!r} in module {manifest.id!r}"
|
||||
)
|
||||
known_ids.add(surface_id)
|
||||
for route in (*frontend.routes, *frontend.settings_routes):
|
||||
surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path)
|
||||
_validate_view_surface_id(manifest.id, surface_id)
|
||||
if surface_id in known_ids:
|
||||
raise RegistryError(
|
||||
f"Duplicate view surface id {surface_id!r} in module {manifest.id!r}"
|
||||
)
|
||||
known_ids.add(surface_id)
|
||||
for surface in frontend.view_surfaces:
|
||||
if surface.module_id != manifest.id:
|
||||
raise RegistryError(
|
||||
f"View surface {surface.id!r} belongs to {surface.module_id!r}, "
|
||||
f"not module {manifest.id!r}"
|
||||
)
|
||||
_validate_view_surface_id(manifest.id, surface.id)
|
||||
if surface.id in known_ids:
|
||||
raise RegistryError(
|
||||
f"Duplicate view surface id {surface.id!r} in module {manifest.id!r}"
|
||||
)
|
||||
known_ids.add(surface.id)
|
||||
for surface in frontend.view_surfaces:
|
||||
parent_id = surface.parent_id or root_id
|
||||
if parent_id not in known_ids:
|
||||
raise RegistryError(
|
||||
f"View surface {surface.id!r} in module {manifest.id!r} "
|
||||
f"references unknown parent {parent_id!r}"
|
||||
)
|
||||
if surface.required and not surface.default_visible:
|
||||
raise RegistryError(
|
||||
f"Required view surface {surface.id!r} in module {manifest.id!r} "
|
||||
"must be visible by default"
|
||||
)
|
||||
parent_by_id = {
|
||||
surface.id: surface.parent_id or root_id
|
||||
for surface in frontend.view_surfaces
|
||||
}
|
||||
for surface_id in parent_by_id:
|
||||
path: set[str] = set()
|
||||
current_id: str | None = surface_id
|
||||
while current_id in parent_by_id:
|
||||
if current_id in path:
|
||||
raise RegistryError(
|
||||
f"View surface hierarchy in module {manifest.id!r} "
|
||||
f"contains a cycle at {current_id!r}"
|
||||
)
|
||||
path.add(current_id)
|
||||
current_id = parent_by_id[current_id]
|
||||
|
||||
|
||||
def _validate_view_surface_id(module_id: str, surface_id: str) -> None:
|
||||
if not validate_view_surface_id(surface_id):
|
||||
raise RegistryError(
|
||||
f"View surface id for module {module_id!r} is invalid: {surface_id!r}"
|
||||
)
|
||||
if not surface_id.startswith(f"{module_id}."):
|
||||
raise RegistryError(
|
||||
f"View surface id for module {module_id!r} must start with "
|
||||
f"{module_id!r} followed by '.': {surface_id!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
||||
|
||||
94
src/govoplan_core/core/views.py
Normal file
94
src/govoplan_core/core/views.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
VIEWS_MODULE_ID = "views"
|
||||
CAPABILITY_VIEWS_RESOLVER = "views.resolver"
|
||||
VIEW_SURFACE_CONTRACT_VERSION = "1"
|
||||
|
||||
ViewSurfaceKind = Literal[
|
||||
"module",
|
||||
"navigation",
|
||||
"route",
|
||||
"section",
|
||||
"action",
|
||||
"selector",
|
||||
]
|
||||
|
||||
_SURFACE_ID_RE = re.compile(r"^[a-z][a-z0-9_.-]{2,159}$")
|
||||
_SURFACE_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ViewSurface:
|
||||
id: str
|
||||
module_id: str
|
||||
kind: ViewSurfaceKind
|
||||
label: str
|
||||
parent_id: str | None = None
|
||||
description: str | None = None
|
||||
order: int = 100
|
||||
default_visible: bool = True
|
||||
required: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EffectiveView:
|
||||
view_id: str | None
|
||||
revision_id: str | None
|
||||
name: str | None
|
||||
visible_surface_ids: frozenset[str]
|
||||
locked: bool = False
|
||||
provenance: tuple[dict[str, object], ...] = ()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ViewResolver(Protocol):
|
||||
def resolve_effective_view(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
group_ids: Iterable[str] = (),
|
||||
) -> EffectiveView: ...
|
||||
|
||||
|
||||
def module_view_surface_id(module_id: str) -> str:
|
||||
return f"{module_id}.module"
|
||||
|
||||
|
||||
def navigation_view_surface_id(module_id: str, path: str) -> str:
|
||||
return f"{module_id}.nav.{_surface_slug(path)}"
|
||||
|
||||
|
||||
def route_view_surface_id(module_id: str, path: str) -> str:
|
||||
return f"{module_id}.route.{_surface_slug(path)}"
|
||||
|
||||
|
||||
def validate_view_surface_id(value: str) -> bool:
|
||||
return bool(_SURFACE_ID_RE.fullmatch(value))
|
||||
|
||||
|
||||
def _surface_slug(value: str) -> str:
|
||||
normalized = _SURFACE_SLUG_RE.sub(".", value.strip().lower()).strip(".")
|
||||
return normalized or "root"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_VIEWS_RESOLVER",
|
||||
"EffectiveView",
|
||||
"VIEWS_MODULE_ID",
|
||||
"VIEW_SURFACE_CONTRACT_VERSION",
|
||||
"ViewResolver",
|
||||
"ViewSurface",
|
||||
"ViewSurfaceKind",
|
||||
"module_view_surface_id",
|
||||
"navigation_view_surface_id",
|
||||
"route_view_surface_id",
|
||||
"validate_view_surface_id",
|
||||
]
|
||||
@@ -6,8 +6,14 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
|
||||
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
|
||||
from govoplan_core.core.views import (
|
||||
VIEW_SURFACE_CONTRACT_VERSION,
|
||||
ViewSurface,
|
||||
navigation_view_surface_id,
|
||||
route_view_surface_id,
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.i18n import system_i18n_payload
|
||||
|
||||
@@ -19,7 +25,7 @@ def _registry(request: Request) -> PlatformRegistry:
|
||||
return registry
|
||||
|
||||
|
||||
def _nav_item_payload(item: NavItem) -> dict[str, object]:
|
||||
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"path": item.path,
|
||||
"label": item.label,
|
||||
@@ -28,16 +34,26 @@ def _nav_item_payload(item: NavItem) -> dict[str, object]:
|
||||
"required_all": list(item.required_all),
|
||||
"required_any": list(item.required_any),
|
||||
"order": item.order,
|
||||
"surface_id": (
|
||||
item.surface_id or navigation_view_surface_id(module_id, item.path)
|
||||
if module_id
|
||||
else item.surface_id
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
|
||||
def _frontend_route_payload(route: FrontendRoute, module_id: str | None = None) -> dict[str, object]:
|
||||
return {
|
||||
"path": route.path,
|
||||
"component": route.component,
|
||||
"required_all": list(route.required_all),
|
||||
"required_any": list(route.required_any),
|
||||
"order": route.order,
|
||||
"surface_id": (
|
||||
route.surface_id or route_view_surface_id(module_id, route.path)
|
||||
if module_id
|
||||
else route.surface_id
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +65,29 @@ def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, obje
|
||||
}
|
||||
|
||||
|
||||
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
|
||||
def _view_surface_payload(surface: ViewSurface) -> dict[str, object]:
|
||||
return {
|
||||
"id": surface.id,
|
||||
"module_id": surface.module_id,
|
||||
"kind": surface.kind,
|
||||
"label": surface.label,
|
||||
"parent_id": surface.parent_id,
|
||||
"description": surface.description,
|
||||
"order": surface.order,
|
||||
"default_visible": surface.default_visible,
|
||||
"required": surface.required,
|
||||
}
|
||||
|
||||
|
||||
def _frontend_view_surfaces(manifest: ModuleManifest) -> list[dict[str, object]]:
|
||||
return [
|
||||
_view_surface_payload(surface)
|
||||
for surface in manifest_view_surfaces(manifest)
|
||||
]
|
||||
|
||||
|
||||
def _frontend_payload(manifest: ModuleManifest) -> dict[str, object] | None:
|
||||
frontend = manifest.frontend
|
||||
if frontend is None:
|
||||
return None
|
||||
return {
|
||||
@@ -60,12 +98,14 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No
|
||||
"asset_manifest_public_key_id": frontend.asset_manifest_public_key_id,
|
||||
"asset_manifest_integrity": frontend.asset_manifest_integrity,
|
||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||
"routes": [_frontend_route_payload(route) for route in frontend.routes],
|
||||
"routes": [_frontend_route_payload(route, manifest.id) for route in frontend.routes],
|
||||
"public_routes": [
|
||||
_public_frontend_route_payload(route) for route in frontend.public_routes
|
||||
],
|
||||
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
||||
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
||||
"nav": [_nav_item_payload(item, manifest.id) for item in frontend.nav_items],
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
@@ -125,8 +165,8 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
|
||||
"optional_dependencies": list(manifest.optional_dependencies),
|
||||
"enabled": True,
|
||||
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
|
||||
"nav": [_nav_item_payload(item) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest.frontend),
|
||||
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest),
|
||||
}
|
||||
for manifest in registry.manifests()
|
||||
]
|
||||
|
||||
@@ -17,7 +17,7 @@ class Settings(BaseSettings):
|
||||
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
|
||||
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
|
||||
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
|
||||
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,notifications,docs,ops", alias="ENABLED_MODULES")
|
||||
enabled_modules: str = Field(default="tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,views,notifications,docs,ops", alias="ENABLED_MODULES")
|
||||
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
|
||||
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
||||
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
||||
|
||||
Reference in New Issue
Block a user