feat: strengthen module contracts and shared WebUI runtime
This commit is contained in:
@@ -68,6 +68,12 @@ AUTH_CAPABILITY_NAMES = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
DEFAULT_CAPABILITY_PROVIDERS: Mapping[str, str] = {
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: ACCESS_MODULE_ID,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR: ACCESS_MODULE_ID,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: ACCESS_MODULE_ID,
|
||||
}
|
||||
|
||||
AuthMethod = Literal["session", "api_key", "service_account"]
|
||||
AccessSubjectKind = Literal[
|
||||
"identity",
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any, Iterable, Sequence
|
||||
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, Session, mapped_column
|
||||
|
||||
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, publish_platform_event
|
||||
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event
|
||||
from govoplan_core.db.base import Base, utcnow
|
||||
|
||||
WATERMARK_PREFIX = "seq:"
|
||||
@@ -96,13 +96,14 @@ def record_change(
|
||||
payload=payload or {},
|
||||
)
|
||||
session.add(entry)
|
||||
_publish_change_event(entry)
|
||||
_publish_change_event(session, entry)
|
||||
return entry
|
||||
|
||||
|
||||
def _publish_change_event(entry: ChangeSequenceEntry) -> None:
|
||||
def _publish_change_event(session: Session, entry: ChangeSequenceEntry) -> None:
|
||||
event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation)
|
||||
publish_platform_event(
|
||||
emit_platform_event(
|
||||
session,
|
||||
PlatformEvent(
|
||||
type=event_type,
|
||||
module_id=entry.module_id,
|
||||
|
||||
@@ -111,96 +111,189 @@ def validate_definition_graph(
|
||||
nodes: Sequence[DefinitionNode],
|
||||
edges: Sequence[DefinitionEdge],
|
||||
) -> tuple[DefinitionDiagnostic, ...]:
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
constraints = library.constraints
|
||||
if not nodes:
|
||||
return (_error("graph.empty", "Add nodes before saving the definition."),)
|
||||
if len(nodes) > constraints.max_nodes:
|
||||
constraints = library.constraints
|
||||
diagnostics = _graph_size_diagnostics(
|
||||
node_count=len(nodes),
|
||||
edge_count=len(edges),
|
||||
constraints=constraints,
|
||||
)
|
||||
node_by_id = {node.id: node for node in nodes}
|
||||
definitions: dict[str, DefinitionNodeType] = {}
|
||||
for item in library.node_types:
|
||||
definitions.setdefault(item.type, item)
|
||||
diagnostics.extend(_identifier_diagnostics(nodes=nodes, edges=edges))
|
||||
incoming, undirected, topology_edges, edge_diagnostics = (
|
||||
_validate_definition_edges(
|
||||
edges=edges,
|
||||
node_by_id=node_by_id,
|
||||
definitions=definitions,
|
||||
)
|
||||
)
|
||||
diagnostics.extend(edge_diagnostics)
|
||||
diagnostics.extend(
|
||||
_node_port_diagnostics(
|
||||
nodes=nodes,
|
||||
definitions=definitions,
|
||||
incoming=incoming,
|
||||
library_id=library.id,
|
||||
)
|
||||
)
|
||||
diagnostics.extend(
|
||||
_node_count_diagnostics(
|
||||
nodes=nodes,
|
||||
definitions=definitions,
|
||||
constraints=constraints,
|
||||
)
|
||||
)
|
||||
diagnostics.extend(
|
||||
_topology_diagnostics(
|
||||
nodes=nodes,
|
||||
node_by_id=node_by_id,
|
||||
topology_edges=topology_edges,
|
||||
undirected=undirected,
|
||||
constraints=constraints,
|
||||
)
|
||||
)
|
||||
return tuple(diagnostics)
|
||||
|
||||
|
||||
def _graph_size_diagnostics(
|
||||
*,
|
||||
node_count: int,
|
||||
edge_count: int,
|
||||
constraints: DefinitionGraphConstraints,
|
||||
) -> list[DefinitionDiagnostic]:
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
if node_count > constraints.max_nodes:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"graph.node_limit",
|
||||
f"Definitions are limited to {constraints.max_nodes:,} nodes.",
|
||||
)
|
||||
)
|
||||
if len(edges) > constraints.max_edges:
|
||||
if edge_count > constraints.max_edges:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"graph.edge_limit",
|
||||
f"Definitions are limited to {constraints.max_edges:,} edges.",
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
node_by_id = {node.id: node for node in nodes}
|
||||
if len(node_by_id) != len(nodes):
|
||||
|
||||
def _identifier_diagnostics(
|
||||
*,
|
||||
nodes: Sequence[DefinitionNode],
|
||||
edges: Sequence[DefinitionEdge],
|
||||
) -> list[DefinitionDiagnostic]:
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
if len({node.id for node in nodes}) != len(nodes):
|
||||
diagnostics.append(_error("graph.duplicate_node", "Node identifiers must be unique."))
|
||||
if len({edge.id for edge in edges}) != len(edges):
|
||||
diagnostics.append(_error("graph.duplicate_edge", "Edge identifiers must be unique."))
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _validate_definition_edges(
|
||||
*,
|
||||
edges: Sequence[DefinitionEdge],
|
||||
node_by_id: Mapping[str, DefinitionNode],
|
||||
definitions: Mapping[str, DefinitionNodeType],
|
||||
) -> tuple[
|
||||
dict[str, list[DefinitionEdge]],
|
||||
dict[str, set[str]],
|
||||
list[DefinitionEdge],
|
||||
list[DefinitionDiagnostic],
|
||||
]:
|
||||
incoming: dict[str, list[DefinitionEdge]] = {node_id: [] for node_id in node_by_id}
|
||||
undirected: dict[str, set[str]] = {node_id: set() for node_id in node_by_id}
|
||||
topology_edges: list[DefinitionEdge] = []
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
for edge in edges:
|
||||
if edge.source not in node_by_id:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"edge.unknown_source",
|
||||
f"Edge {edge.id!r} references an unknown source node.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if edge.target not in node_by_id:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"edge.unknown_target",
|
||||
f"Edge {edge.id!r} references an unknown target node.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if edge.source == edge.target:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"edge.self_reference",
|
||||
"A node cannot connect to itself.",
|
||||
node_id=edge.source,
|
||||
)
|
||||
)
|
||||
structural_error = _edge_structure_diagnostic(edge, node_by_id)
|
||||
if structural_error is not None:
|
||||
diagnostics.append(structural_error)
|
||||
continue
|
||||
topology_edges.append(edge)
|
||||
source_definition = library.node_type(node_by_id[edge.source].type)
|
||||
target_definition = library.node_type(node_by_id[edge.target].type)
|
||||
if source_definition is not None and edge.source_port not in {
|
||||
port.id for port in source_definition.output_ports
|
||||
}:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"edge.unknown_source_port",
|
||||
f"Node {edge.source!r} has no output port {edge.source_port!r}.",
|
||||
node_id=edge.source,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if target_definition is not None and edge.target_port not in {
|
||||
port.id for port in target_definition.input_ports
|
||||
}:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"edge.unknown_target_port",
|
||||
f"Node {edge.target!r} has no input port {edge.target_port!r}.",
|
||||
node_id=edge.target,
|
||||
)
|
||||
)
|
||||
port_error = _edge_port_diagnostic(
|
||||
edge,
|
||||
source_definition=definitions.get(node_by_id[edge.source].type),
|
||||
target_definition=definitions.get(node_by_id[edge.target].type),
|
||||
)
|
||||
if port_error is not None:
|
||||
diagnostics.append(port_error)
|
||||
continue
|
||||
incoming[edge.target].append(edge)
|
||||
undirected[edge.source].add(edge.target)
|
||||
undirected[edge.target].add(edge.source)
|
||||
return incoming, undirected, topology_edges, diagnostics
|
||||
|
||||
|
||||
def _edge_structure_diagnostic(
|
||||
edge: DefinitionEdge,
|
||||
node_by_id: Mapping[str, DefinitionNode],
|
||||
) -> DefinitionDiagnostic | None:
|
||||
if edge.source not in node_by_id:
|
||||
return _error(
|
||||
"edge.unknown_source",
|
||||
f"Edge {edge.id!r} references an unknown source node.",
|
||||
)
|
||||
if edge.target not in node_by_id:
|
||||
return _error(
|
||||
"edge.unknown_target",
|
||||
f"Edge {edge.id!r} references an unknown target node.",
|
||||
)
|
||||
if edge.source == edge.target:
|
||||
return _error(
|
||||
"edge.self_reference",
|
||||
"A node cannot connect to itself.",
|
||||
node_id=edge.source,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _edge_port_diagnostic(
|
||||
edge: DefinitionEdge,
|
||||
*,
|
||||
source_definition: DefinitionNodeType | None,
|
||||
target_definition: DefinitionNodeType | None,
|
||||
) -> DefinitionDiagnostic | None:
|
||||
if source_definition is not None and edge.source_port not in {
|
||||
port.id for port in source_definition.output_ports
|
||||
}:
|
||||
return _error(
|
||||
"edge.unknown_source_port",
|
||||
f"Node {edge.source!r} has no output port {edge.source_port!r}.",
|
||||
node_id=edge.source,
|
||||
)
|
||||
if target_definition is not None and edge.target_port not in {
|
||||
port.id for port in target_definition.input_ports
|
||||
}:
|
||||
return _error(
|
||||
"edge.unknown_target_port",
|
||||
f"Node {edge.target!r} has no input port {edge.target_port!r}.",
|
||||
node_id=edge.target,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _node_port_diagnostics(
|
||||
*,
|
||||
nodes: Sequence[DefinitionNode],
|
||||
definitions: Mapping[str, DefinitionNodeType],
|
||||
incoming: Mapping[str, Sequence[DefinitionEdge]],
|
||||
library_id: str,
|
||||
) -> list[DefinitionDiagnostic]:
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
for node in nodes:
|
||||
definition = library.node_type(node.type)
|
||||
definition = definitions.get(node.type)
|
||||
if definition is None:
|
||||
diagnostics.append(
|
||||
_error(
|
||||
"node.unsupported_type",
|
||||
f"Node type {node.type!r} is not in the {library.id!r} library.",
|
||||
f"Node type {node.type!r} is not in the {library_id!r} library.",
|
||||
node_id=node.id,
|
||||
field="type",
|
||||
)
|
||||
@@ -231,10 +324,19 @@ def validate_definition_graph(
|
||||
node_id=node.id,
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _node_count_diagnostics(
|
||||
*,
|
||||
nodes: Sequence[DefinitionNode],
|
||||
definitions: Mapping[str, DefinitionNodeType],
|
||||
constraints: DefinitionGraphConstraints,
|
||||
) -> list[DefinitionDiagnostic]:
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
for count_constraint in constraints.node_counts:
|
||||
count = sum(
|
||||
count_constraint.matches(node, library.node_type(node.type))
|
||||
count_constraint.matches(node, definitions.get(node.type))
|
||||
for node in nodes
|
||||
)
|
||||
if count < count_constraint.minimum:
|
||||
@@ -259,7 +361,18 @@ def validate_definition_graph(
|
||||
),
|
||||
)
|
||||
)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def _topology_diagnostics(
|
||||
*,
|
||||
nodes: Sequence[DefinitionNode],
|
||||
node_by_id: Mapping[str, DefinitionNode],
|
||||
topology_edges: Sequence[DefinitionEdge],
|
||||
undirected: Mapping[str, set[str]],
|
||||
constraints: DefinitionGraphConstraints,
|
||||
) -> list[DefinitionDiagnostic]:
|
||||
diagnostics: list[DefinitionDiagnostic] = []
|
||||
_, cyclic = definition_topological_order(nodes, topology_edges)
|
||||
if cyclic and not constraints.allow_cycles:
|
||||
diagnostics.append(_error("graph.cycle", "Definition edges must form an acyclic graph."))
|
||||
@@ -269,7 +382,7 @@ def validate_definition_graph(
|
||||
diagnostics.append(
|
||||
_error("graph.disconnected", "Every node must belong to one connected definition.")
|
||||
)
|
||||
return tuple(diagnostics)
|
||||
return diagnostics
|
||||
|
||||
|
||||
def definition_topological_order(
|
||||
|
||||
@@ -7,11 +7,16 @@ from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, Protocol, runtime_checkable
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import event as sqlalchemy_event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
_TRACE_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
|
||||
_PENDING_EVENTS_KEY = "govoplan.pending_platform_events"
|
||||
CAPABILITY_PLATFORM_EVENT_OUTBOX = "platform.eventOutbox"
|
||||
|
||||
|
||||
def new_event_id() -> str:
|
||||
@@ -102,6 +107,21 @@ class PlatformEvent:
|
||||
EventHandler = Callable[[PlatformEvent], None]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PlatformEventOutbox(Protocol):
|
||||
def enqueue(self, session: object, event: PlatformEvent) -> object:
|
||||
...
|
||||
|
||||
def dispatch_pending(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
dispatcher: EventHandler,
|
||||
limit: int = 100,
|
||||
) -> Mapping[str, int]:
|
||||
...
|
||||
|
||||
|
||||
def current_event_trace() -> EventTrace | None:
|
||||
return _current_trace.get()
|
||||
|
||||
@@ -189,5 +209,85 @@ def publish_platform_event(event: PlatformEvent) -> None:
|
||||
platform_event_bus().publish(event)
|
||||
|
||||
|
||||
def platform_event_outbox(
|
||||
registry: object | None = None,
|
||||
) -> PlatformEventOutbox | None:
|
||||
if registry is None:
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
|
||||
registry = get_registry()
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_PLATFORM_EVENT_OUTBOX)
|
||||
return capability if isinstance(capability, PlatformEventOutbox) else None
|
||||
|
||||
|
||||
def emit_platform_event(
|
||||
session: Session,
|
||||
event: PlatformEvent,
|
||||
*,
|
||||
registry: object | None = None,
|
||||
) -> None:
|
||||
"""Persist an event with its transaction or publish it after commit.
|
||||
|
||||
The durable outbox is optional so reduced module combinations remain
|
||||
usable. Without it, the event is kept on the SQLAlchemy session and only
|
||||
delivered to the process-local bus after the outer transaction commits.
|
||||
"""
|
||||
|
||||
traced = ensure_event_trace(event)
|
||||
outbox = platform_event_outbox(registry)
|
||||
if outbox is not None:
|
||||
outbox.enqueue(session, traced)
|
||||
return
|
||||
transaction = (
|
||||
session.get_nested_transaction()
|
||||
or session.get_transaction()
|
||||
or session.begin()
|
||||
)
|
||||
pending_by_transaction = session.info.setdefault(
|
||||
_PENDING_EVENTS_KEY,
|
||||
{},
|
||||
)
|
||||
pending_by_transaction.setdefault(transaction, []).append(
|
||||
(platform_event_bus(), traced)
|
||||
)
|
||||
|
||||
|
||||
@sqlalchemy_event.listens_for(Session, "after_commit")
|
||||
def _publish_committed_events(session: Session) -> None:
|
||||
transaction = session.get_nested_transaction() or session.get_transaction()
|
||||
if transaction is None:
|
||||
return
|
||||
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
|
||||
if not isinstance(pending_by_transaction, dict):
|
||||
return
|
||||
pending = pending_by_transaction.pop(transaction, ())
|
||||
parent = transaction.parent
|
||||
if parent is not None:
|
||||
pending_by_transaction.setdefault(parent, []).extend(pending)
|
||||
else:
|
||||
for bus, event in pending:
|
||||
bus.publish(event)
|
||||
if not pending_by_transaction:
|
||||
session.info.pop(_PENDING_EVENTS_KEY, None)
|
||||
|
||||
|
||||
@sqlalchemy_event.listens_for(Session, "after_rollback")
|
||||
def _discard_rolled_back_events(session: Session) -> None:
|
||||
transaction = session.get_nested_transaction() or session.get_transaction()
|
||||
pending_by_transaction = session.info.get(_PENDING_EVENTS_KEY)
|
||||
if transaction is None or not isinstance(pending_by_transaction, dict):
|
||||
return
|
||||
pending_by_transaction.pop(transaction, None)
|
||||
if transaction.parent is None or not pending_by_transaction:
|
||||
session.info.pop(_PENDING_EVENTS_KEY, None)
|
||||
|
||||
|
||||
def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {key: item for key, item in value.items() if item is not None}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
@@ -8,6 +8,7 @@ from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
IDM_MODULE_ID = "idm"
|
||||
CAPABILITY_IDM_DIRECTORY = "idm.directory"
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS = "idm.function_assignments"
|
||||
|
||||
IdmStatus = Literal["active", "inactive", "suspended"]
|
||||
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
||||
@@ -30,6 +31,18 @@ class OrganizationFunctionAssignmentRef:
|
||||
status: IdmStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationFunctionIncumbencyRef:
|
||||
tenant_id: str
|
||||
function_id: str
|
||||
assignments: tuple[OrganizationFunctionAssignmentRef, ...] = ()
|
||||
function_active: bool = True
|
||||
|
||||
@property
|
||||
def vacant(self) -> bool:
|
||||
return not self.assignments
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdmDirectory(Protocol):
|
||||
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||
@@ -40,6 +53,7 @@ class IdmDirectory(Protocol):
|
||||
identity_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
|
||||
@@ -48,5 +62,47 @@ class IdmDirectory(Protocol):
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
|
||||
def organization_function_assignments_for_identities(
|
||||
self,
|
||||
identity_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
|
||||
...
|
||||
|
||||
def organization_function_assignments_for_accounts(
|
||||
self,
|
||||
account_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Mapping[str, Sequence[OrganizationFunctionAssignmentRef]]:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdmFunctionAssignmentDirectory(Protocol):
|
||||
"""Reverse lookup for effective incumbency and vacancy decisions."""
|
||||
|
||||
def organization_function_assignments_for_function(
|
||||
self,
|
||||
function_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
|
||||
def organization_function_incumbencies(
|
||||
self,
|
||||
function_ids: Sequence[str],
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at: datetime | None = None,
|
||||
) -> Mapping[str, OrganizationFunctionIncumbencyRef]:
|
||||
...
|
||||
|
||||
@@ -67,6 +67,17 @@ class ModuleLifecycleManager:
|
||||
def mounted_module_ids(self) -> tuple[str, ...]:
|
||||
return tuple(sorted(self._mounted_modules))
|
||||
|
||||
def live_apply_enabled(self) -> bool:
|
||||
configured = getattr(
|
||||
self.settings,
|
||||
"module_live_apply_enabled",
|
||||
None,
|
||||
)
|
||||
if configured is not None:
|
||||
return bool(configured)
|
||||
app_env = str(getattr(self.settings, "app_env", "dev")).casefold()
|
||||
return app_env in {"dev", "development", "local", "test", "testing"}
|
||||
|
||||
def apply_enabled_modules(
|
||||
self,
|
||||
requested_enabled: Sequence[str],
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
|
||||
from govoplan_core.core.access import DEFAULT_CAPABILITY_PROVIDERS
|
||||
from govoplan_core.core.discovery import iter_module_entry_points
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.db.session import get_database
|
||||
@@ -17,8 +18,8 @@ from govoplan_core.server.registry import parse_enabled_modules
|
||||
|
||||
MODULE_SETTINGS_KEY = "module_management"
|
||||
INSTALL_PLAN_KEY = "install_plan"
|
||||
REQUIRED_PLATFORM_MODULES = ("access",)
|
||||
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
||||
REQUIRED_PLATFORM_MODULES: tuple[str, ...] = ()
|
||||
PROTECTED_MODULES = ("admin",)
|
||||
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
|
||||
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
||||
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
||||
@@ -109,6 +110,7 @@ def startup_candidate_module_ids(
|
||||
if desired is not None:
|
||||
candidates.extend(str(item).strip() for item in desired if str(item).strip())
|
||||
candidates.extend(REQUIRED_PLATFORM_MODULES)
|
||||
candidates.extend(DEFAULT_CAPABILITY_PROVIDERS.values())
|
||||
if "admin" in fallback:
|
||||
candidates.append("admin")
|
||||
return tuple(dict.fromkeys(candidates))
|
||||
@@ -404,46 +406,148 @@ def plan_desired_enabled_modules(
|
||||
*,
|
||||
protected_modules: Iterable[str] = PROTECTED_MODULES,
|
||||
) -> ModuleStatePlan:
|
||||
requested = {str(item).strip() for item in requested_enabled if str(item).strip()}
|
||||
protected = {str(item).strip() for item in protected_modules if str(item).strip()}
|
||||
requested.update(protected)
|
||||
requested = _normalized_module_ids(requested_enabled)
|
||||
requested.update(_normalized_module_ids(protected_modules))
|
||||
missing = sorted(module_id for module_id in requested if module_id not in available)
|
||||
if missing:
|
||||
raise ModuleManagementError("Unknown or uninstalled modules: " + ", ".join(missing))
|
||||
|
||||
added_dependencies: set[str] = set()
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
|
||||
def visit(module_id: str) -> None:
|
||||
if module_id in visited:
|
||||
return
|
||||
if module_id in visiting:
|
||||
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
|
||||
manifest = available.get(module_id)
|
||||
if manifest is None:
|
||||
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
|
||||
visiting.add(module_id)
|
||||
for dependency_id in manifest.dependencies:
|
||||
if dependency_id not in available:
|
||||
raise ModuleManagementError(f"Module {module_id!r} depends on uninstalled module {dependency_id!r}.")
|
||||
if dependency_id not in requested:
|
||||
added_dependencies.add(dependency_id)
|
||||
requested.add(dependency_id)
|
||||
visit(dependency_id)
|
||||
visiting.remove(module_id)
|
||||
visited.add(module_id)
|
||||
ordered.append(module_id)
|
||||
|
||||
for module_id in sorted(requested):
|
||||
visit(module_id)
|
||||
added_dependencies = _expand_enabled_module_closure(requested, available)
|
||||
ordered = _order_enabled_modules(requested, available, added_dependencies)
|
||||
return ModuleStatePlan(
|
||||
enabled_modules=tuple(dict.fromkeys(ordered)),
|
||||
added_dependencies=tuple(sorted(added_dependencies)),
|
||||
)
|
||||
|
||||
|
||||
def _normalized_module_ids(values: Iterable[str]) -> set[str]:
|
||||
return {str(item).strip() for item in values if str(item).strip()}
|
||||
|
||||
|
||||
def _expand_enabled_module_closure(
|
||||
requested: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
) -> set[str]:
|
||||
added: set[str] = set()
|
||||
while True:
|
||||
dependencies = {
|
||||
dependency
|
||||
for module_id in requested
|
||||
for dependency in available[module_id].dependencies
|
||||
}
|
||||
_require_installed_modules(
|
||||
dependencies,
|
||||
available,
|
||||
message="Required modules are not installed: ",
|
||||
)
|
||||
dependency_additions = dependencies - requested
|
||||
added.update(dependency_additions)
|
||||
requested.update(dependency_additions)
|
||||
|
||||
providers = _missing_capability_providers(requested, available)
|
||||
_require_installed_modules(
|
||||
providers,
|
||||
available,
|
||||
message="Required capability providers are not installed: ",
|
||||
)
|
||||
additions = providers - requested
|
||||
if not additions:
|
||||
return added
|
||||
added.update(additions)
|
||||
requested.update(additions)
|
||||
|
||||
|
||||
def _missing_capability_providers(
|
||||
requested: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
) -> set[str]:
|
||||
provided = {
|
||||
capability
|
||||
for module_id in requested
|
||||
for capability in available[module_id].capability_factories
|
||||
}
|
||||
return {
|
||||
provider_id
|
||||
for module_id in requested
|
||||
for capability in available[module_id].required_capabilities
|
||||
if capability not in provided
|
||||
for provider_id in (DEFAULT_CAPABILITY_PROVIDERS.get(capability),)
|
||||
if provider_id is not None
|
||||
}
|
||||
|
||||
|
||||
def _require_installed_modules(
|
||||
module_ids: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
*,
|
||||
message: str,
|
||||
) -> None:
|
||||
missing = sorted(module_id for module_id in module_ids if module_id not in available)
|
||||
if missing:
|
||||
raise ModuleManagementError(message + ", ".join(missing))
|
||||
|
||||
|
||||
def _order_enabled_modules(
|
||||
requested: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
added_dependencies: set[str],
|
||||
) -> list[str]:
|
||||
visiting: set[str] = set()
|
||||
visited: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for module_id in sorted(requested):
|
||||
_visit_enabled_module(
|
||||
module_id,
|
||||
requested=requested,
|
||||
available=available,
|
||||
added_dependencies=added_dependencies,
|
||||
visiting=visiting,
|
||||
visited=visited,
|
||||
ordered=ordered,
|
||||
)
|
||||
return ordered
|
||||
|
||||
|
||||
def _visit_enabled_module(
|
||||
module_id: str,
|
||||
*,
|
||||
requested: set[str],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
added_dependencies: set[str],
|
||||
visiting: set[str],
|
||||
visited: set[str],
|
||||
ordered: list[str],
|
||||
) -> None:
|
||||
if module_id in visited:
|
||||
return
|
||||
if module_id in visiting:
|
||||
raise ModuleManagementError(f"Module dependency cycle includes {module_id!r}.")
|
||||
manifest = available.get(module_id)
|
||||
if manifest is None:
|
||||
raise ModuleManagementError(f"Module {module_id!r} is required but not installed.")
|
||||
visiting.add(module_id)
|
||||
for dependency_id in manifest.dependencies:
|
||||
if dependency_id not in available:
|
||||
raise ModuleManagementError(
|
||||
f"Module {module_id!r} depends on uninstalled module {dependency_id!r}."
|
||||
)
|
||||
if dependency_id not in requested:
|
||||
added_dependencies.add(dependency_id)
|
||||
requested.add(dependency_id)
|
||||
_visit_enabled_module(
|
||||
dependency_id,
|
||||
requested=requested,
|
||||
available=available,
|
||||
added_dependencies=added_dependencies,
|
||||
visiting=visiting,
|
||||
visited=visited,
|
||||
ordered=ordered,
|
||||
)
|
||||
visiting.remove(module_id)
|
||||
visited.add(module_id)
|
||||
ordered.append(module_id)
|
||||
|
||||
|
||||
def module_dependents(available: Mapping[str, ModuleManifest]) -> dict[str, tuple[str, ...]]:
|
||||
dependents: dict[str, list[str]] = {module_id: [] for module_id in available}
|
||||
for manifest in available.values():
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
POSTBOX_MODULE_ID = "postbox"
|
||||
CAPABILITY_POSTBOX_DIRECTORY = "postbox.directory"
|
||||
CAPABILITY_POSTBOX_ACCESS = "postbox.access"
|
||||
CAPABILITY_POSTBOX_MESSAGES = "postbox.messages"
|
||||
CAPABILITY_POSTBOX_DELIVERY = "postbox.delivery"
|
||||
CAPABILITY_POSTBOX_EVIDENCE = "postbox.evidence"
|
||||
|
||||
PostboxAction = Literal["discover", "read", "send", "acknowledge", "administer"]
|
||||
PostboxMessageListState = Literal["all", "unread", "read", "acknowledged"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxActorRef:
|
||||
account_id: str
|
||||
identity_id: str | None = None
|
||||
selected_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
authorized_actions: frozenset[PostboxAction] = frozenset()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxTargetRef:
|
||||
postbox_id: str | None = None
|
||||
address_key: str | None = None
|
||||
template_id: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
function_id: str | None = None
|
||||
context_key: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDeliveryTemplateRef:
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
description: str | None
|
||||
published_revision_id: str
|
||||
function_type_id: str | None
|
||||
scope_kind: str
|
||||
scope_id: str | None
|
||||
classification: str
|
||||
allow_vacant_delivery: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxOrganizationFunctionTargetRef:
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
function_type_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxOrganizationUnitTargetRef:
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
unit_type_id: str | None = None
|
||||
parent_id: str | None = None
|
||||
functions: tuple[PostboxOrganizationFunctionTargetRef, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDeliveryCatalogRef:
|
||||
postboxes: tuple["PostboxDirectoryEntryRef", ...] = ()
|
||||
templates: tuple[PostboxDeliveryTemplateRef, ...] = ()
|
||||
organization_units: tuple[PostboxOrganizationUnitTargetRef, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxAccessDecisionRef:
|
||||
allowed: bool
|
||||
action: PostboxAction
|
||||
postbox_id: str
|
||||
reason_code: str
|
||||
explanation: str
|
||||
organization_unit_id: str | None = None
|
||||
function_id: str | None = None
|
||||
assignment_ids: tuple[str, ...] = ()
|
||||
assignment_sources: tuple[str, ...] = ()
|
||||
selected_assignment_id: str | None = None
|
||||
holder_count: int = 0
|
||||
vacant: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDirectoryEntryRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
address: str
|
||||
address_key: str
|
||||
name: str
|
||||
status: str
|
||||
classification: str
|
||||
organization_unit_id: str | None = None
|
||||
organization_unit_name: str | None = None
|
||||
function_id: str | None = None
|
||||
function_name: str | None = None
|
||||
context_key: str | None = None
|
||||
template_revision_id: str | None = None
|
||||
holder_count: int = 0
|
||||
vacant: bool = True
|
||||
access: PostboxAccessDecisionRef | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxParticipantRef:
|
||||
kind: str
|
||||
reference_type: str
|
||||
reference_id: str | None = None
|
||||
label: str | None = None
|
||||
address: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxAttachmentRef:
|
||||
reference_type: str
|
||||
reference_id: str
|
||||
name: str | None = None
|
||||
media_type: str | None = None
|
||||
size_bytes: int | None = None
|
||||
digest: str | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxMessageRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
postbox_id: str
|
||||
subject: str
|
||||
body_text: str | None
|
||||
status: str
|
||||
classification: str
|
||||
sender_label: str | None
|
||||
delivered_at: datetime
|
||||
read_at: datetime | None = None
|
||||
acknowledged_at: datetime | None = None
|
||||
expires_at: datetime | None = None
|
||||
withdrawn_at: datetime | None = None
|
||||
producer_module: str | None = None
|
||||
producer_resource_type: str | None = None
|
||||
producer_resource_id: str | None = None
|
||||
encryption_profile: str = "plaintext_v1"
|
||||
key_epoch: int = 1
|
||||
ciphertext_ref: str | None = None
|
||||
signed_manifest_ref: str | None = None
|
||||
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDeliveryRequest:
|
||||
tenant_id: str
|
||||
target: PostboxTargetRef
|
||||
producer_module: str
|
||||
producer_resource_type: str
|
||||
producer_resource_id: str | None
|
||||
idempotency_key: str
|
||||
subject: str
|
||||
body_text: str | None = None
|
||||
sender_label: str | None = None
|
||||
classification: str = "internal"
|
||||
participants: tuple[PostboxParticipantRef, ...] = ()
|
||||
attachments: tuple[PostboxAttachmentRef, ...] = ()
|
||||
expires_at: datetime | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PostboxDeliveryResult:
|
||||
delivery_id: str
|
||||
postbox_id: str
|
||||
message_id: str
|
||||
address: str
|
||||
status: str
|
||||
vacant: bool
|
||||
holder_count: int
|
||||
duplicate: bool = False
|
||||
evidence: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
class PostboxDeliveryRejected(RuntimeError):
|
||||
"""A delivery was rejected before the provider accepted any effect."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
temporary: bool = False,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.temporary = temporary
|
||||
|
||||
|
||||
class PostboxDeliveryOutcomeUnknown(RuntimeError):
|
||||
"""The provider may have accepted an effect and must not be bypassed."""
|
||||
|
||||
def __init__(self, code: str, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxDirectoryProvider(Protocol):
|
||||
def list_visible_postboxes(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor: PostboxActorRef,
|
||||
) -> Sequence[PostboxDirectoryEntryRef]:
|
||||
...
|
||||
|
||||
def resolve_postbox(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
target: PostboxTargetRef,
|
||||
materialize: bool = False,
|
||||
) -> PostboxDirectoryEntryRef | None:
|
||||
...
|
||||
|
||||
def delivery_catalog(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> PostboxDeliveryCatalogRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxAccessProvider(Protocol):
|
||||
def explain_access(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
postbox_id: str,
|
||||
actor: PostboxActorRef,
|
||||
action: PostboxAction,
|
||||
) -> PostboxAccessDecisionRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxMessagesProvider(Protocol):
|
||||
def list_messages(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
postbox_ids: Sequence[str],
|
||||
actor: PostboxActorRef,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
query: str | None = None,
|
||||
state: PostboxMessageListState = "all",
|
||||
) -> Sequence[PostboxMessageRef]:
|
||||
...
|
||||
|
||||
def get_message(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
message_id: str,
|
||||
actor: PostboxActorRef,
|
||||
) -> PostboxMessageRef | None:
|
||||
...
|
||||
|
||||
def mark_message(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
message_id: str,
|
||||
actor: PostboxActorRef,
|
||||
state: Literal["read", "acknowledged"],
|
||||
) -> PostboxMessageRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxDeliveryProvider(Protocol):
|
||||
def deliver(
|
||||
self,
|
||||
session: object,
|
||||
request: PostboxDeliveryRequest,
|
||||
) -> PostboxDeliveryResult:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PostboxEvidenceProvider(Protocol):
|
||||
def link_evidence(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
message_id: str,
|
||||
attachment: PostboxAttachmentRef,
|
||||
) -> PostboxMessageRef:
|
||||
...
|
||||
|
||||
|
||||
def _postbox_provider(
|
||||
registry: object | None,
|
||||
*,
|
||||
capability_name: str,
|
||||
provider_type: type,
|
||||
) -> object | None:
|
||||
if registry is None or not hasattr(registry, "has_capability"):
|
||||
return None
|
||||
if not registry.has_capability(capability_name):
|
||||
return None
|
||||
capability = registry.capability(capability_name)
|
||||
return capability if isinstance(capability, provider_type) else None
|
||||
|
||||
|
||||
def postbox_directory_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxDirectoryProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_DIRECTORY,
|
||||
provider_type=PostboxDirectoryProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxDirectoryProvider) else None
|
||||
|
||||
|
||||
def postbox_access_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxAccessProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_ACCESS,
|
||||
provider_type=PostboxAccessProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxAccessProvider) else None
|
||||
|
||||
|
||||
def postbox_messages_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxMessagesProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_MESSAGES,
|
||||
provider_type=PostboxMessagesProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxMessagesProvider) else None
|
||||
|
||||
|
||||
def postbox_delivery_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxDeliveryProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_DELIVERY,
|
||||
provider_type=PostboxDeliveryProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxDeliveryProvider) else None
|
||||
|
||||
|
||||
def postbox_evidence_provider(
|
||||
registry: object | None,
|
||||
) -> PostboxEvidenceProvider | None:
|
||||
provider = _postbox_provider(
|
||||
registry,
|
||||
capability_name=CAPABILITY_POSTBOX_EVIDENCE,
|
||||
provider_type=PostboxEvidenceProvider,
|
||||
)
|
||||
return provider if isinstance(provider, PostboxEvidenceProvider) else None
|
||||
@@ -0,0 +1,369 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_DIRECTORY,
|
||||
AccessDirectory,
|
||||
GroupRef,
|
||||
UserRef,
|
||||
)
|
||||
|
||||
|
||||
ReferenceAvailability = Literal["available", "inactive", "unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReferenceOption:
|
||||
"""A stable identifier paired with presentation-only directory metadata."""
|
||||
|
||||
value: str
|
||||
label: str
|
||||
description: str | None = None
|
||||
kind: str | None = None
|
||||
availability: ReferenceAvailability = "available"
|
||||
disabled: bool = False
|
||||
source_module: str | None = None
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"value": self.value,
|
||||
"label": self.label,
|
||||
"description": self.description,
|
||||
"kind": self.kind,
|
||||
"availability": self.availability,
|
||||
"disabled": self.disabled,
|
||||
"source_module": self.source_module,
|
||||
"provenance": dict(self.provenance),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReferenceSearchRequest:
|
||||
kind: str
|
||||
tenant_id: str | None
|
||||
query: str = ""
|
||||
selected_values: tuple[str, ...] = ()
|
||||
limit: int = 50
|
||||
context: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ReferenceOptionProvider(Protocol):
|
||||
"""Optional module-neutral provider for typed reference selectors."""
|
||||
|
||||
def search_reference_options(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ReferenceSearchRequest,
|
||||
) -> Sequence[ReferenceOption]:
|
||||
...
|
||||
|
||||
|
||||
def reference_option_provider(
|
||||
registry: object | None,
|
||||
capability_name: str,
|
||||
) -> ReferenceOptionProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(capability_name)
|
||||
):
|
||||
return None
|
||||
provider = registry.capability(capability_name)
|
||||
return provider if isinstance(provider, ReferenceOptionProvider) else None
|
||||
|
||||
|
||||
def access_scope_reference_options(
|
||||
registry: object | None,
|
||||
principal: object,
|
||||
*,
|
||||
scope_type: str,
|
||||
query: str = "",
|
||||
selected_values: Sequence[str] = (),
|
||||
limit: int = 50,
|
||||
administrative: bool = False,
|
||||
) -> tuple[ReferenceOption, ...]:
|
||||
"""Return canonical user-account or group references for definition scopes."""
|
||||
|
||||
clean_type = str(scope_type or "").strip().casefold()
|
||||
if clean_type not in {"user", "group"}:
|
||||
raise ValueError("Scope reference type must be user or group.")
|
||||
normalized_limit = max(1, min(int(limit), 200))
|
||||
selected = tuple(
|
||||
dict.fromkeys(
|
||||
str(value).strip() for value in selected_values if str(value).strip()
|
||||
)
|
||||
)
|
||||
directory = _access_directory(registry)
|
||||
if directory is None:
|
||||
return _fallback_scope_options(
|
||||
principal,
|
||||
scope_type=clean_type,
|
||||
query=query,
|
||||
selected_values=selected,
|
||||
limit=normalized_limit,
|
||||
)
|
||||
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "")
|
||||
if not tenant_id:
|
||||
return tuple(
|
||||
_unavailable_option(value, kind=clean_type) for value in selected
|
||||
)
|
||||
if clean_type == "user":
|
||||
candidates = _user_options(
|
||||
directory.users_for_tenant(tenant_id),
|
||||
principal=principal,
|
||||
administrative=administrative,
|
||||
)
|
||||
else:
|
||||
candidates = _group_options(
|
||||
directory.groups_for_tenant(tenant_id),
|
||||
principal=principal,
|
||||
administrative=administrative,
|
||||
)
|
||||
return _filter_and_retain_options(
|
||||
candidates,
|
||||
query=query,
|
||||
selected_values=selected,
|
||||
limit=normalized_limit,
|
||||
kind=clean_type,
|
||||
)
|
||||
|
||||
|
||||
def access_scope_reference_provider_available(registry: object | None) -> bool:
|
||||
return _access_directory(registry) is not None
|
||||
|
||||
|
||||
def validate_access_scope_reference(
|
||||
registry: object | None,
|
||||
*,
|
||||
tenant_id: str | None,
|
||||
scope_type: str,
|
||||
scope_id: str | None,
|
||||
preserve_existing: str | None = None,
|
||||
) -> str | None:
|
||||
"""Validate new directory-backed scope references while preserving history."""
|
||||
|
||||
clean_type = str(scope_type or "").strip().casefold()
|
||||
clean_id = str(scope_id or "").strip() or None
|
||||
if clean_type not in {"user", "group"}:
|
||||
return clean_id
|
||||
if not clean_id:
|
||||
raise ValueError(f"{clean_type.capitalize()} definitions require a scope ID.")
|
||||
if clean_id == preserve_existing:
|
||||
return clean_id
|
||||
|
||||
directory = _access_directory(registry)
|
||||
if directory is None:
|
||||
return clean_id
|
||||
if not tenant_id:
|
||||
raise ValueError("Directory-backed scopes require an active tenant.")
|
||||
|
||||
if clean_type == "group":
|
||||
group = directory.get_group(clean_id)
|
||||
if group is None or group.tenant_id != tenant_id:
|
||||
raise ValueError("The selected group is not available in the active tenant.")
|
||||
if group.status != "active":
|
||||
raise ValueError("Inactive groups cannot be selected for new definitions.")
|
||||
return group.id
|
||||
|
||||
matching_user = next(
|
||||
(
|
||||
user
|
||||
for user in directory.users_for_tenant(tenant_id)
|
||||
if user.account_id == clean_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matching_user is None:
|
||||
raise ValueError("The selected user account is not available in the active tenant.")
|
||||
if matching_user.status != "active":
|
||||
raise ValueError("Inactive users cannot be selected for new definitions.")
|
||||
account = directory.get_account(matching_user.account_id)
|
||||
if account is not None and account.status != "active":
|
||||
raise ValueError("Inactive user accounts cannot be selected for new definitions.")
|
||||
return matching_user.account_id
|
||||
|
||||
|
||||
def _access_directory(registry: object | None) -> AccessDirectory | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||
):
|
||||
return None
|
||||
provider = registry.capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||
return provider if isinstance(provider, AccessDirectory) else None
|
||||
|
||||
|
||||
def _user_options(
|
||||
users: Sequence[UserRef],
|
||||
*,
|
||||
principal: object,
|
||||
administrative: bool,
|
||||
) -> tuple[ReferenceOption, ...]:
|
||||
principal_account_id = str(getattr(principal, "account_id", "") or "")
|
||||
options: dict[str, ReferenceOption] = {}
|
||||
for user in users:
|
||||
if not administrative and user.account_id != principal_account_id:
|
||||
continue
|
||||
inactive = user.status != "active"
|
||||
label = user.display_name or user.email or user.account_id
|
||||
detail_parts = [part for part in (user.email, "Inactive" if inactive else None) if part]
|
||||
options[user.account_id] = ReferenceOption(
|
||||
value=user.account_id,
|
||||
label=label,
|
||||
description=" · ".join(detail_parts) or None,
|
||||
kind="user",
|
||||
availability="inactive" if inactive else "available",
|
||||
disabled=inactive,
|
||||
source_module="access",
|
||||
provenance={
|
||||
"membership_id": user.id,
|
||||
"tenant_id": user.tenant_id,
|
||||
"account_id": user.account_id,
|
||||
},
|
||||
)
|
||||
return tuple(options.values())
|
||||
|
||||
|
||||
def _group_options(
|
||||
groups: Sequence[GroupRef],
|
||||
*,
|
||||
principal: object,
|
||||
administrative: bool,
|
||||
) -> tuple[ReferenceOption, ...]:
|
||||
permitted = {
|
||||
str(value)
|
||||
for value in getattr(principal, "group_ids", ())
|
||||
if str(value)
|
||||
}
|
||||
return tuple(
|
||||
ReferenceOption(
|
||||
value=group.id,
|
||||
label=group.name or group.id,
|
||||
description="Inactive" if group.status != "active" else None,
|
||||
kind="group",
|
||||
availability=(
|
||||
"inactive" if group.status != "active" else "available"
|
||||
),
|
||||
disabled=group.status != "active",
|
||||
source_module="access",
|
||||
provenance={"tenant_id": group.tenant_id, "group_id": group.id},
|
||||
)
|
||||
for group in groups
|
||||
if administrative or group.id in permitted
|
||||
)
|
||||
|
||||
|
||||
def _filter_and_retain_options(
|
||||
options: Sequence[ReferenceOption],
|
||||
*,
|
||||
query: str,
|
||||
selected_values: Sequence[str],
|
||||
limit: int,
|
||||
kind: str,
|
||||
) -> tuple[ReferenceOption, ...]:
|
||||
by_value = {option.value: option for option in options}
|
||||
needle = str(query or "").strip().casefold()
|
||||
matches = [
|
||||
option
|
||||
for option in options
|
||||
if not needle
|
||||
or needle
|
||||
in " ".join(
|
||||
(
|
||||
option.value,
|
||||
option.label,
|
||||
option.description or "",
|
||||
)
|
||||
).casefold()
|
||||
][:limit]
|
||||
returned = {option.value for option in matches}
|
||||
for value in selected_values:
|
||||
if value in returned:
|
||||
continue
|
||||
matches.append(by_value.get(value) or _unavailable_option(value, kind=kind))
|
||||
returned.add(value)
|
||||
return tuple(matches)
|
||||
|
||||
|
||||
def _fallback_scope_options(
|
||||
principal: object,
|
||||
*,
|
||||
scope_type: str,
|
||||
query: str,
|
||||
selected_values: Sequence[str],
|
||||
limit: int,
|
||||
) -> tuple[ReferenceOption, ...]:
|
||||
candidates: list[ReferenceOption] = []
|
||||
if scope_type == "user":
|
||||
account_id = str(getattr(principal, "account_id", "") or "")
|
||||
if account_id:
|
||||
candidates.append(
|
||||
ReferenceOption(
|
||||
value=account_id,
|
||||
label=(
|
||||
str(getattr(principal, "display_name", "") or "")
|
||||
or str(getattr(principal, "email", "") or "")
|
||||
or account_id
|
||||
),
|
||||
description="Current user · Access directory unavailable",
|
||||
kind="user",
|
||||
source_module="core",
|
||||
provenance={"fallback": True},
|
||||
)
|
||||
)
|
||||
else:
|
||||
candidates.extend(
|
||||
ReferenceOption(
|
||||
value=str(group_id),
|
||||
label=str(group_id),
|
||||
description="Permitted group · Access directory unavailable",
|
||||
kind="group",
|
||||
source_module="core",
|
||||
provenance={"fallback": True},
|
||||
)
|
||||
for group_id in getattr(principal, "group_ids", ())
|
||||
if str(group_id)
|
||||
)
|
||||
return _filter_and_retain_options(
|
||||
candidates,
|
||||
query=query,
|
||||
selected_values=selected_values,
|
||||
limit=limit,
|
||||
kind=scope_type,
|
||||
)
|
||||
|
||||
|
||||
def _unavailable_option(value: str, *, kind: str) -> ReferenceOption:
|
||||
return ReferenceOption(
|
||||
value=value,
|
||||
label=f"Unavailable {kind}",
|
||||
description=value,
|
||||
kind=kind,
|
||||
availability="unavailable",
|
||||
disabled=True,
|
||||
source_module=None,
|
||||
provenance={"retained_reference": True},
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ReferenceAvailability",
|
||||
"ReferenceOption",
|
||||
"ReferenceOptionProvider",
|
||||
"ReferenceSearchRequest",
|
||||
"access_scope_reference_options",
|
||||
"access_scope_reference_provider_available",
|
||||
"reference_option_provider",
|
||||
"validate_access_scope_reference",
|
||||
]
|
||||
@@ -514,59 +514,86 @@ def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
||||
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}
|
||||
_validate_view_surface_id(manifest.id, 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)
|
||||
_register_view_surface_id(manifest.id, surface_id, known_ids)
|
||||
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)
|
||||
_register_view_surface_id(manifest.id, surface_id, known_ids)
|
||||
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)
|
||||
_validate_declared_view_surface(manifest.id, surface)
|
||||
_register_view_surface_id(manifest.id, surface.id, known_ids)
|
||||
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"
|
||||
)
|
||||
_validate_view_surface_definition(
|
||||
manifest.id,
|
||||
surface,
|
||||
root_id=root_id,
|
||||
known_ids=known_ids,
|
||||
)
|
||||
parent_by_id = {
|
||||
surface.id: surface.parent_id or root_id
|
||||
for surface in frontend.view_surfaces
|
||||
}
|
||||
_validate_view_surface_hierarchy(manifest.id, parent_by_id)
|
||||
|
||||
|
||||
def _register_view_surface_id(
|
||||
module_id: str,
|
||||
surface_id: str,
|
||||
known_ids: set[str],
|
||||
) -> None:
|
||||
_validate_view_surface_id(module_id, surface_id)
|
||||
if surface_id in known_ids:
|
||||
raise RegistryError(
|
||||
f"Duplicate view surface id {surface_id!r} in module {module_id!r}"
|
||||
)
|
||||
known_ids.add(surface_id)
|
||||
|
||||
|
||||
def _validate_declared_view_surface(
|
||||
module_id: str,
|
||||
surface: ViewSurface,
|
||||
) -> None:
|
||||
if surface.module_id != module_id:
|
||||
raise RegistryError(
|
||||
f"View surface {surface.id!r} belongs to {surface.module_id!r}, "
|
||||
f"not module {module_id!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_view_surface_definition(
|
||||
module_id: str,
|
||||
surface: ViewSurface,
|
||||
*,
|
||||
root_id: str,
|
||||
known_ids: set[str],
|
||||
) -> None:
|
||||
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 {module_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 {module_id!r} "
|
||||
"must be visible by default"
|
||||
)
|
||||
|
||||
|
||||
def _validate_view_surface_hierarchy(
|
||||
module_id: str,
|
||||
parent_by_id: Mapping[str, str],
|
||||
) -> None:
|
||||
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"View surface hierarchy in module {module_id!r} "
|
||||
f"contains a cycle at {current_id!r}"
|
||||
)
|
||||
path.add(current_id)
|
||||
|
||||
@@ -41,39 +41,73 @@ def parse_tabular_csv(
|
||||
raise TabularSourceValidationError("CSV delimiter must be one character.")
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(csv_text), delimiter=delimiter)
|
||||
if not reader.fieldnames or any(not str(name or "").strip() for name in reader.fieldnames):
|
||||
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
|
||||
normalized_headers = [str(name).strip() for name in reader.fieldnames]
|
||||
normalized_headers[0] = normalized_headers[0].lstrip("\ufeff")
|
||||
if any(not name for name in normalized_headers):
|
||||
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
|
||||
if len(set(normalized_headers)) != len(normalized_headers):
|
||||
raise TabularSourceValidationError("CSV column names must be unique.")
|
||||
original_headers, normalized_headers = _csv_headers(reader.fieldnames)
|
||||
rows: list[dict[str, object]] = []
|
||||
for row in reader:
|
||||
extras = row.get(None)
|
||||
if extras and any(str(value or "").strip() for value in extras):
|
||||
raise TabularSourceValidationError(
|
||||
"A CSV row contains more values than the header defines."
|
||||
)
|
||||
values = [row.get(original) for original in reader.fieldnames]
|
||||
if all(value is None or not value.strip() for value in values):
|
||||
_validate_csv_row_shape(row)
|
||||
if _csv_row_is_empty(row, original_headers):
|
||||
continue
|
||||
if len(rows) >= max_rows:
|
||||
raise TabularSourceValidationError(
|
||||
f"CSV snapshots are limited to {max_rows:,} rows."
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
normalized_headers[position]: _csv_scalar(row.get(original))
|
||||
for position, original in enumerate(reader.fieldnames)
|
||||
}
|
||||
)
|
||||
rows.append(_csv_row(row, original_headers, normalized_headers))
|
||||
return tuple(rows)
|
||||
except csv.Error as exc:
|
||||
raise TabularSourceValidationError(f"CSV input could not be parsed: {exc}") from exc
|
||||
|
||||
|
||||
def _csv_headers(
|
||||
fieldnames: Sequence[str | None] | None,
|
||||
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
if not fieldnames:
|
||||
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
|
||||
original = tuple(str(name or "") for name in fieldnames)
|
||||
normalized = tuple(
|
||||
name.strip().lstrip("\ufeff") if position == 0 else name.strip()
|
||||
for position, name in enumerate(original)
|
||||
)
|
||||
if any(not name for name in normalized):
|
||||
raise TabularSourceValidationError("CSV input requires a non-empty header row.")
|
||||
if len(set(normalized)) != len(normalized):
|
||||
raise TabularSourceValidationError("CSV column names must be unique.")
|
||||
return original, normalized
|
||||
|
||||
|
||||
def _validate_csv_row_shape(row: Mapping[str | None, object]) -> None:
|
||||
extras = row.get(None)
|
||||
if isinstance(extras, list) and any(str(value or "").strip() for value in extras):
|
||||
raise TabularSourceValidationError(
|
||||
"A CSV row contains more values than the header defines."
|
||||
)
|
||||
|
||||
|
||||
def _csv_row_is_empty(
|
||||
row: Mapping[str | None, str | list[str] | None],
|
||||
headers: Sequence[str],
|
||||
) -> bool:
|
||||
return all(
|
||||
value is None or isinstance(value, list) or not value.strip()
|
||||
for value in (row.get(header) for header in headers)
|
||||
)
|
||||
|
||||
|
||||
def _csv_row(
|
||||
row: Mapping[str | None, str | list[str] | None],
|
||||
original_headers: Sequence[str],
|
||||
normalized_headers: Sequence[str],
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
normalized: _csv_scalar(value if isinstance(value, str) else None)
|
||||
for original, normalized in zip(
|
||||
original_headers,
|
||||
normalized_headers,
|
||||
strict=True,
|
||||
)
|
||||
for value in (row.get(original),)
|
||||
}
|
||||
|
||||
|
||||
def _csv_scalar(value: str | None) -> object:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user