Add shared work-item provider contracts
This commit is contained in:
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
|
||||
SearchProviderRegistration,
|
||||
SearchSourceProviderRegistration,
|
||||
)
|
||||
from govoplan_core.core.tasks import WorkItemProviderRegistration
|
||||
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
||||
|
||||
|
||||
@@ -34,7 +35,9 @@ MigrationTaskPhase = Literal[
|
||||
"post_migration_backfill",
|
||||
"post_migration_verify",
|
||||
]
|
||||
MigrationTaskSafety = Literal["automatic", "requires_review", "forward_only", "destructive"]
|
||||
MigrationTaskSafety = Literal[
|
||||
"automatic", "requires_review", "forward_only", "destructive"
|
||||
]
|
||||
MigrationTaskStatus = Literal["ok", "warning", "blocked", "skipped"]
|
||||
|
||||
|
||||
@@ -75,8 +78,6 @@ class NavItem:
|
||||
surface_id: str | None = None
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrontendRoute:
|
||||
path: str
|
||||
@@ -147,7 +148,9 @@ class ModuleMigrationTaskResult:
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
ModuleMigrationTaskExecutor = Callable[[ModuleMigrationTaskContext], ModuleMigrationTaskResult | None]
|
||||
ModuleMigrationTaskExecutor = Callable[
|
||||
[ModuleMigrationTaskContext], ModuleMigrationTaskResult | None
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -204,7 +207,9 @@ class ModuleUninstallGuardResult:
|
||||
message: str
|
||||
|
||||
|
||||
UninstallGuardProvider = Callable[[object | None, str], Iterable[ModuleUninstallGuardResult]]
|
||||
UninstallGuardProvider = Callable[
|
||||
[object | None, str], Iterable[ModuleUninstallGuardResult]
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -224,7 +229,9 @@ class ModuleContext:
|
||||
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
|
||||
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
|
||||
DocumentationType = Literal["admin", "user"]
|
||||
DocumentationConfigurationState = Literal["enabled", "disabled", "inherited", "unavailable"]
|
||||
DocumentationConfigurationState = Literal[
|
||||
"enabled", "disabled", "inherited", "unavailable"
|
||||
]
|
||||
DocumentationSourceKind = Literal[
|
||||
"manifest",
|
||||
"route",
|
||||
@@ -289,16 +296,23 @@ def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str
|
||||
"""
|
||||
|
||||
raw_kind = topic.metadata.get("kind")
|
||||
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
|
||||
kind = (
|
||||
raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
|
||||
)
|
||||
if kind != "workflow" or "user" not in topic.documentation_types:
|
||||
return ()
|
||||
if not topic.conditions:
|
||||
return ("user workflow topics must declare at least one scope-conditioned alternative",)
|
||||
return (
|
||||
"user workflow topics must declare at least one scope-conditioned alternative",
|
||||
)
|
||||
|
||||
unscoped_alternatives = tuple(
|
||||
index
|
||||
for index, condition in enumerate(topic.conditions, start=1)
|
||||
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
|
||||
if not any(
|
||||
scope.strip()
|
||||
for scope in (*condition.required_scopes, *condition.any_scopes)
|
||||
)
|
||||
)
|
||||
if not unscoped_alternatives:
|
||||
return ()
|
||||
@@ -370,14 +384,11 @@ class CapabilityDocumentation:
|
||||
class ResourceAclProvider(Protocol):
|
||||
resource_type: str
|
||||
|
||||
def can_read(self, principal: object, resource_id: str) -> bool:
|
||||
...
|
||||
def can_read(self, principal: object, resource_id: str) -> bool: ...
|
||||
|
||||
def can_write(self, principal: object, resource_id: str) -> bool:
|
||||
...
|
||||
def can_write(self, principal: object, resource_id: str) -> bool: ...
|
||||
|
||||
def explain(self, principal: object, resource_id: str) -> AccessDecision:
|
||||
...
|
||||
def explain(self, principal: object, resource_id: str) -> AccessDecision: ...
|
||||
|
||||
|
||||
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
|
||||
@@ -436,12 +447,17 @@ class ModuleManifest:
|
||||
ownership_providers: tuple[OwnershipProviderRegistration, ...] = ()
|
||||
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
||||
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
|
||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
|
||||
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
||||
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
|
||||
capability_documentation: Mapping[str, CapabilityDocumentation] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
||||
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
|
||||
work_item_providers: tuple["WorkItemProviderRegistration", ...] = ()
|
||||
operational_check_providers: tuple[
|
||||
"OperationalCheckProviderRegistration",
|
||||
...,
|
||||
|
||||
+351
-121
@@ -48,13 +48,21 @@ from govoplan_core.core.provider_governance import (
|
||||
ModuleArchitectureDeclaration,
|
||||
module_architecture_issues,
|
||||
)
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
from govoplan_core.core.versioning import (
|
||||
format_version_range,
|
||||
version_range_is_valid,
|
||||
version_satisfies_range,
|
||||
)
|
||||
from govoplan_core.core.search import (
|
||||
RegisteredSearchProvider,
|
||||
RegisteredSearchSourceProvider,
|
||||
SearchProvider,
|
||||
SearchSourceProvider,
|
||||
)
|
||||
from govoplan_core.core.tasks import (
|
||||
RegisteredWorkItemProvider,
|
||||
WorkItemProvider,
|
||||
)
|
||||
from govoplan_core.core.views import (
|
||||
ViewSurface,
|
||||
module_view_surface_id,
|
||||
@@ -69,7 +77,9 @@ from govoplan_core.core.workflows import (
|
||||
_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_.-]*$")
|
||||
_SCOPE_RE = re.compile(r"^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$")
|
||||
_WILDCARD_RE = re.compile(r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$")
|
||||
_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_]*)+$")
|
||||
|
||||
|
||||
@@ -90,7 +100,9 @@ class PlatformRegistry:
|
||||
self._manifests: dict[str, ModuleManifest] = {}
|
||||
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
|
||||
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
|
||||
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
|
||||
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = (
|
||||
defaultdict(list)
|
||||
)
|
||||
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
|
||||
self._capability_factories: dict[str, CapabilityFactory] = {}
|
||||
self._capability_factory_owners: dict[str, str] = {}
|
||||
@@ -99,10 +111,10 @@ class PlatformRegistry:
|
||||
self._tenant_entitlement_resolver = TenantModuleEntitlementResolver(self)
|
||||
self._search_provider_registrations: list[RegisteredSearchProvider] = []
|
||||
self._search_providers: dict[str, SearchProvider] = {}
|
||||
self._search_source_registrations: list[
|
||||
RegisteredSearchSourceProvider
|
||||
] = []
|
||||
self._search_source_registrations: list[RegisteredSearchSourceProvider] = []
|
||||
self._search_sources: dict[str, SearchSourceProvider] = {}
|
||||
self._work_item_provider_registrations: list[RegisteredWorkItemProvider] = []
|
||||
self._work_item_providers: dict[str, WorkItemProvider] = {}
|
||||
|
||||
def register(self, manifest: ModuleManifest) -> ModuleManifest:
|
||||
if manifest.id in self._manifests:
|
||||
@@ -133,6 +145,13 @@ class PlatformRegistry:
|
||||
registration=registration,
|
||||
)
|
||||
)
|
||||
for registration in manifest.work_item_providers:
|
||||
self._work_item_provider_registrations.append(
|
||||
RegisteredWorkItemProvider(
|
||||
module_id=manifest.id,
|
||||
registration=registration,
|
||||
)
|
||||
)
|
||||
return manifest
|
||||
|
||||
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
|
||||
@@ -148,24 +167,29 @@ class PlatformRegistry:
|
||||
self._tenant_summary_batch_providers = dict(
|
||||
replacement._tenant_summary_batch_providers
|
||||
)
|
||||
self._delete_veto_providers = defaultdict(list, {
|
||||
resource_type: list(providers)
|
||||
for resource_type, providers in replacement._delete_veto_providers.items()
|
||||
})
|
||||
self._delete_veto_providers = defaultdict(
|
||||
list,
|
||||
{
|
||||
resource_type: list(providers)
|
||||
for resource_type, providers in replacement._delete_veto_providers.items()
|
||||
},
|
||||
)
|
||||
self._ownership_providers = dict(replacement._ownership_providers)
|
||||
self._capability_factories = dict(replacement._capability_factories)
|
||||
self._capability_factory_owners = dict(
|
||||
replacement._capability_factory_owners
|
||||
)
|
||||
self._capability_factory_owners = dict(replacement._capability_factory_owners)
|
||||
self._search_provider_registrations = list(
|
||||
replacement._search_provider_registrations
|
||||
)
|
||||
self._search_source_registrations = list(
|
||||
replacement._search_source_registrations
|
||||
)
|
||||
self._work_item_provider_registrations = list(
|
||||
replacement._work_item_provider_registrations
|
||||
)
|
||||
self._capabilities.clear()
|
||||
self._search_providers.clear()
|
||||
self._search_sources.clear()
|
||||
self._work_item_providers.clear()
|
||||
self._tenant_entitlement_resolver.invalidate()
|
||||
return snapshot
|
||||
|
||||
@@ -183,7 +207,10 @@ class PlatformRegistry:
|
||||
|
||||
def integration_enabled(self, module_id: str, dependency_id: str) -> bool:
|
||||
manifest = self.require_module(module_id)
|
||||
return dependency_id in manifest.dependencies or (dependency_id in manifest.optional_dependencies and self.has_module(dependency_id))
|
||||
return dependency_id in manifest.dependencies or (
|
||||
dependency_id in manifest.optional_dependencies
|
||||
and self.has_module(dependency_id)
|
||||
)
|
||||
|
||||
def manifests(self) -> tuple[ModuleManifest, ...]:
|
||||
return tuple(self._topologically_sorted())
|
||||
@@ -216,13 +243,26 @@ class PlatformRegistry:
|
||||
)
|
||||
|
||||
def permissions(self) -> tuple[PermissionDefinition, ...]:
|
||||
return tuple(permission for manifest in self.manifests() for permission in manifest.permissions)
|
||||
return tuple(
|
||||
permission
|
||||
for manifest in self.manifests()
|
||||
for permission in manifest.permissions
|
||||
)
|
||||
|
||||
def role_templates(self) -> tuple[RoleTemplate, ...]:
|
||||
return tuple(template for manifest in self.manifests() for template in manifest.role_templates)
|
||||
return tuple(
|
||||
template
|
||||
for manifest in self.manifests()
|
||||
for template in manifest.role_templates
|
||||
)
|
||||
|
||||
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))
|
||||
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(
|
||||
@@ -232,7 +272,11 @@ class PlatformRegistry:
|
||||
)
|
||||
|
||||
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
||||
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
||||
return tuple(
|
||||
provider
|
||||
for manifest in self.manifests()
|
||||
for provider in manifest.resource_acl_providers
|
||||
)
|
||||
|
||||
def register_ownership_provider(
|
||||
self,
|
||||
@@ -295,7 +339,9 @@ class PlatformRegistry:
|
||||
self._search_providers.clear()
|
||||
self._search_sources.clear()
|
||||
|
||||
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
|
||||
def register_capability_factory(
|
||||
self, module_id: str, name: str, factory: CapabilityFactory
|
||||
) -> None:
|
||||
if name in self._capability_factories:
|
||||
raise RegistryError(f"Duplicate capability: {name}")
|
||||
self._capability_factories[name] = factory
|
||||
@@ -446,9 +492,7 @@ class PlatformRegistry:
|
||||
]:
|
||||
if self._capability_context is None:
|
||||
if self._search_source_registrations:
|
||||
raise RegistryError(
|
||||
"Search source context is not configured."
|
||||
)
|
||||
raise RegistryError("Search source context is not configured.")
|
||||
return ()
|
||||
providers: list[
|
||||
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
|
||||
@@ -463,14 +507,51 @@ class PlatformRegistry:
|
||||
key = f"{registered.module_id}:{registered.registration.id}"
|
||||
provider = self._search_sources.get(key)
|
||||
if provider is None:
|
||||
provider = registered.registration.create(
|
||||
self._capability_context
|
||||
)
|
||||
provider = registered.registration.create(self._capability_context)
|
||||
self._search_sources[key] = provider
|
||||
providers.append((registered, provider))
|
||||
return tuple(providers)
|
||||
|
||||
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
|
||||
def work_item_provider_registrations(
|
||||
self,
|
||||
) -> tuple[RegisteredWorkItemProvider, ...]:
|
||||
return tuple(
|
||||
sorted(
|
||||
self._work_item_provider_registrations,
|
||||
key=lambda item: (
|
||||
item.registration.order,
|
||||
item.module_id,
|
||||
item.registration.id,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def work_item_providers(
|
||||
self,
|
||||
) -> tuple[tuple[RegisteredWorkItemProvider, WorkItemProvider], ...]:
|
||||
if self._capability_context is None:
|
||||
if self._work_item_provider_registrations:
|
||||
raise RegistryError("Work-item provider context is not configured.")
|
||||
return ()
|
||||
providers: list[tuple[RegisteredWorkItemProvider, WorkItemProvider]] = []
|
||||
for registered in self.work_item_provider_registrations():
|
||||
execution = current_tenant_execution_context()
|
||||
if execution is not None:
|
||||
try:
|
||||
execution.require_module(registered.module_id)
|
||||
except TenantModuleUnavailable:
|
||||
continue
|
||||
key = f"{registered.module_id}:{registered.registration.id}"
|
||||
provider = self._work_item_providers.get(key)
|
||||
if provider is None:
|
||||
provider = registered.registration.create(self._capability_context)
|
||||
self._work_item_providers[key] = provider
|
||||
providers.append((registered, provider))
|
||||
return tuple(providers)
|
||||
|
||||
def register_tenant_summary_provider(
|
||||
self, module_id: str, provider: TenantSummaryProvider
|
||||
) -> None:
|
||||
self._tenant_summary_providers[module_id] = provider
|
||||
|
||||
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
|
||||
@@ -488,37 +569,56 @@ class PlatformRegistry:
|
||||
) -> Mapping[str, TenantSummaryBatchProvider]:
|
||||
return dict(self._tenant_summary_batch_providers)
|
||||
|
||||
def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None:
|
||||
self._delete_veto_providers[resource_type].append(DeleteVetoProviderRegistration(
|
||||
module_id=module_id,
|
||||
resource_type=resource_type,
|
||||
provider=provider,
|
||||
))
|
||||
def register_delete_veto(
|
||||
self, module_id: str, resource_type: str, provider: DeleteVetoProvider
|
||||
) -> None:
|
||||
self._delete_veto_providers[resource_type].append(
|
||||
DeleteVetoProviderRegistration(
|
||||
module_id=module_id,
|
||||
resource_type=resource_type,
|
||||
provider=provider,
|
||||
)
|
||||
)
|
||||
|
||||
def delete_veto_providers(self, resource_type: str) -> tuple[DeleteVetoProvider, ...]:
|
||||
return tuple(registration.provider for registration in self._delete_veto_providers.get(resource_type, ()))
|
||||
def delete_veto_providers(
|
||||
self, resource_type: str
|
||||
) -> tuple[DeleteVetoProvider, ...]:
|
||||
return tuple(
|
||||
registration.provider
|
||||
for registration in self._delete_veto_providers.get(resource_type, ())
|
||||
)
|
||||
|
||||
def delete_veto_provider_registrations(self, resource_type: str) -> tuple[DeleteVetoProviderRegistration, ...]:
|
||||
def delete_veto_provider_registrations(
|
||||
self, resource_type: str
|
||||
) -> tuple[DeleteVetoProviderRegistration, ...]:
|
||||
return tuple(self._delete_veto_providers.get(resource_type, ()))
|
||||
|
||||
def collect_delete_veto_issues(self, resource_type: str, session: object, tenant_id: str, resource_id: str) -> tuple[DeleteVetoIssue, ...]:
|
||||
def collect_delete_veto_issues(
|
||||
self, resource_type: str, session: object, tenant_id: str, resource_id: str
|
||||
) -> tuple[DeleteVetoIssue, ...]:
|
||||
issues: list[DeleteVetoIssue] = []
|
||||
for registration in self.delete_veto_provider_registrations(resource_type):
|
||||
try:
|
||||
result = registration.provider(session, tenant_id, resource_id)
|
||||
except Exception as exc:
|
||||
issues.append(DeleteVetoIssue(
|
||||
severity="blocker",
|
||||
code="module_veto",
|
||||
message=str(exc),
|
||||
module_id=registration.module_id,
|
||||
details={
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
},
|
||||
))
|
||||
issues.append(
|
||||
DeleteVetoIssue(
|
||||
severity="blocker",
|
||||
code="module_veto",
|
||||
message=str(exc),
|
||||
module_id=registration.module_id,
|
||||
details={
|
||||
"resource_type": resource_type,
|
||||
"resource_id": resource_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
continue
|
||||
issues.extend(_normalize_delete_veto_result(result, registration=registration, resource_id=resource_id))
|
||||
issues.extend(
|
||||
_normalize_delete_veto_result(
|
||||
result, registration=registration, resource_id=resource_id
|
||||
)
|
||||
)
|
||||
return tuple(issues)
|
||||
|
||||
def validate(self) -> RegistrySnapshot:
|
||||
@@ -539,18 +639,29 @@ class PlatformRegistry:
|
||||
return RegistrySnapshot(
|
||||
manifests=ordered,
|
||||
permissions=tuple(permissions.values()),
|
||||
role_templates=tuple(template for manifest in ordered for template in manifest.role_templates),
|
||||
role_templates=tuple(
|
||||
template for manifest in ordered for template in manifest.role_templates
|
||||
),
|
||||
nav_items=self.nav_items(),
|
||||
)
|
||||
|
||||
def _topologically_sorted(self) -> Iterable[ModuleManifest]:
|
||||
incoming: dict[str, set[str]] = {module_id: set(manifest.dependencies) for module_id, manifest in self._manifests.items()}
|
||||
incoming: dict[str, set[str]] = {
|
||||
module_id: set(manifest.dependencies)
|
||||
for module_id, manifest in self._manifests.items()
|
||||
}
|
||||
dependents: dict[str, set[str]] = defaultdict(set)
|
||||
for module_id, dependencies in incoming.items():
|
||||
for dependency in dependencies:
|
||||
dependents[dependency].add(module_id)
|
||||
|
||||
ready = deque(sorted(module_id for module_id, dependencies in incoming.items() if not dependencies))
|
||||
ready = deque(
|
||||
sorted(
|
||||
module_id
|
||||
for module_id, dependencies in incoming.items()
|
||||
if not dependencies
|
||||
)
|
||||
)
|
||||
ordered: list[str] = []
|
||||
while ready:
|
||||
module_id = ready.popleft()
|
||||
@@ -561,8 +672,16 @@ class PlatformRegistry:
|
||||
ready.append(dependent)
|
||||
|
||||
if len(ordered) != len(self._manifests):
|
||||
unresolved = ", ".join(sorted(module_id for module_id, dependencies in incoming.items() if dependencies))
|
||||
raise RegistryError(f"Module dependency cycle or unresolved dependency: {unresolved}")
|
||||
unresolved = ", ".join(
|
||||
sorted(
|
||||
module_id
|
||||
for module_id, dependencies in incoming.items()
|
||||
if dependencies
|
||||
)
|
||||
)
|
||||
raise RegistryError(
|
||||
f"Module dependency cycle or unresolved dependency: {unresolved}"
|
||||
)
|
||||
return (self._manifests[module_id] for module_id in ordered)
|
||||
|
||||
|
||||
@@ -622,21 +741,29 @@ def _normalize_delete_veto_result(
|
||||
if result is None:
|
||||
return ()
|
||||
if isinstance(result, DeleteVetoIssue):
|
||||
return (_attribute_delete_veto_issue(result, registration=registration, resource_id=resource_id),)
|
||||
return (
|
||||
_attribute_delete_veto_issue(
|
||||
result, registration=registration, resource_id=resource_id
|
||||
),
|
||||
)
|
||||
if isinstance(result, str):
|
||||
return (DeleteVetoIssue(
|
||||
severity="blocker",
|
||||
code="module_veto",
|
||||
message=result,
|
||||
module_id=registration.module_id,
|
||||
details={
|
||||
"resource_type": registration.resource_type,
|
||||
"resource_id": resource_id,
|
||||
},
|
||||
),)
|
||||
return (
|
||||
DeleteVetoIssue(
|
||||
severity="blocker",
|
||||
code="module_veto",
|
||||
message=result,
|
||||
module_id=registration.module_id,
|
||||
details={
|
||||
"resource_type": registration.resource_type,
|
||||
"resource_id": resource_id,
|
||||
},
|
||||
),
|
||||
)
|
||||
if isinstance(result, Iterable):
|
||||
return tuple(
|
||||
_attribute_delete_veto_issue(issue, registration=registration, resource_id=resource_id)
|
||||
_attribute_delete_veto_issue(
|
||||
issue, registration=registration, resource_id=resource_id
|
||||
)
|
||||
for issue in result
|
||||
if isinstance(issue, DeleteVetoIssue)
|
||||
)
|
||||
@@ -656,7 +783,9 @@ def _attribute_delete_veto_issue(
|
||||
}
|
||||
if issue.module_id == registration.module_id and issue.details == details:
|
||||
return issue
|
||||
return replace(issue, module_id=issue.module_id or registration.module_id, details=details)
|
||||
return replace(
|
||||
issue, module_id=issue.module_id or registration.module_id, details=details
|
||||
)
|
||||
|
||||
|
||||
def _validate_manifest_relationships(
|
||||
@@ -667,16 +796,24 @@ def _validate_manifest_relationships(
|
||||
) -> None:
|
||||
for dependency in manifest.dependencies:
|
||||
if dependency not in known_modules:
|
||||
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} depends on unknown module {dependency!r}"
|
||||
)
|
||||
for capability in manifest.required_capabilities:
|
||||
if capability not in available_capabilities:
|
||||
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} requires unavailable capability {capability!r}"
|
||||
)
|
||||
for dependency in manifest.optional_dependencies:
|
||||
if dependency == manifest.id:
|
||||
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} cannot list itself as an optional dependency"
|
||||
)
|
||||
|
||||
|
||||
def _collect_manifest_permissions(manifests: tuple[ModuleManifest, ...]) -> dict[str, PermissionDefinition]:
|
||||
def _collect_manifest_permissions(
|
||||
manifests: tuple[ModuleManifest, ...],
|
||||
) -> dict[str, PermissionDefinition]:
|
||||
permissions: dict[str, PermissionDefinition] = {}
|
||||
for manifest in manifests:
|
||||
for permission in manifest.permissions:
|
||||
@@ -697,10 +834,17 @@ def _validate_manifest_permission(
|
||||
f"{permission.module_id!r}; expected {permission_namespace!r}"
|
||||
)
|
||||
if not _SCOPE_RE.match(permission.scope):
|
||||
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
|
||||
raise RegistryError(
|
||||
f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}"
|
||||
)
|
||||
expected_prefix = f"{permission.module_id}:{permission.resource}:"
|
||||
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
|
||||
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
|
||||
if (
|
||||
not permission.scope.startswith(expected_prefix)
|
||||
or permission.scope.rsplit(":", 1)[-1] != permission.action
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Permission fields do not match scope {permission.scope!r}"
|
||||
)
|
||||
if permission.scope in seen_permissions:
|
||||
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
|
||||
|
||||
@@ -731,8 +875,7 @@ def _validate_role_template_scopes(
|
||||
)
|
||||
for scope in template.permissions:
|
||||
if template.default_authenticated and (
|
||||
scope in {"*", "tenant:*", "system:*"}
|
||||
or _WILDCARD_RE.match(scope)
|
||||
scope in {"*", "tenant:*", "system:*"} or _WILDCARD_RE.match(scope)
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Default authenticated role template {template.slug!r} "
|
||||
@@ -740,7 +883,9 @@ def _validate_role_template_scopes(
|
||||
)
|
||||
if _role_template_scope_known(scope, known_scopes):
|
||||
continue
|
||||
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
||||
raise RegistryError(
|
||||
f"Role template {template.slug!r} references unknown permission {scope!r}"
|
||||
)
|
||||
|
||||
|
||||
def _role_template_scope_known(scope: str, known_scopes: set[str]) -> bool:
|
||||
@@ -882,9 +1027,7 @@ def _validate_architecture_declarations(manifest: ModuleManifest) -> None:
|
||||
f"Module {manifest.id!r} provider authority modes are not declared "
|
||||
"by its architecture metadata: " + ", ".join(sorted(missing_modes))
|
||||
)
|
||||
unknown_target_providers = set(
|
||||
architecture.target_tested_providers
|
||||
) - provider_ids
|
||||
unknown_target_providers = set(architecture.target_tested_providers) - provider_ids
|
||||
if unknown_target_providers:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} target-tested providers are not declared: "
|
||||
@@ -961,8 +1104,7 @@ def _validate_workflow_definition_contributions(
|
||||
contribution.definition_key,
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Workflow contribution key is invalid: "
|
||||
f"{contribution.definition_key!r}"
|
||||
f"Workflow contribution key is invalid: {contribution.definition_key!r}"
|
||||
)
|
||||
if contribution.definition_key in seen_keys:
|
||||
raise RegistryError(
|
||||
@@ -1003,7 +1145,10 @@ def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
|
||||
f"Module {manifest.id!r} capability {capability!r} "
|
||||
"documentation must have a summary"
|
||||
)
|
||||
if metadata.contract_version is not None and not metadata.contract_version.strip():
|
||||
if (
|
||||
metadata.contract_version is not None
|
||||
and not metadata.contract_version.strip()
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} capability {capability!r} "
|
||||
"documentation contract version must not be empty"
|
||||
@@ -1046,20 +1191,24 @@ def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
|
||||
|
||||
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
||||
if not _MODULE_ID_RE.match(manifest.id):
|
||||
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
|
||||
raise RegistryError(
|
||||
f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}"
|
||||
)
|
||||
if not manifest.name.strip():
|
||||
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty name")
|
||||
if not manifest.version.strip():
|
||||
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty version")
|
||||
if (
|
||||
manifest.permission_namespace is not None
|
||||
and not _MODULE_ID_RE.match(manifest.permission_namespace)
|
||||
if manifest.permission_namespace is not None and not _MODULE_ID_RE.match(
|
||||
manifest.permission_namespace
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} has invalid permission namespace "
|
||||
f"{manifest.permission_namespace!r}"
|
||||
)
|
||||
if manifest.compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION:
|
||||
if (
|
||||
manifest.compatibility.manifest_contract_version
|
||||
!= SUPPORTED_MANIFEST_CONTRACT_VERSION
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} uses unsupported manifest contract version "
|
||||
f"{manifest.compatibility.manifest_contract_version!r}; supported version is "
|
||||
@@ -1069,9 +1218,15 @@ def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
||||
|
||||
def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
|
||||
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
|
||||
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
|
||||
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
|
||||
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
|
||||
_validate_dependency_list(
|
||||
manifest.id, "optional_dependencies", manifest.optional_dependencies
|
||||
)
|
||||
_validate_capability_list(
|
||||
manifest.id, "required_capabilities", manifest.required_capabilities
|
||||
)
|
||||
_validate_capability_list(
|
||||
manifest.id, "optional_capabilities", manifest.optional_capabilities
|
||||
)
|
||||
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
||||
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
|
||||
provider_ids: set[str] = set()
|
||||
@@ -1100,25 +1255,51 @@ def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
|
||||
f"{registration.id!r}"
|
||||
)
|
||||
source_ids.add(registration.id)
|
||||
work_provider_ids: set[str] = set()
|
||||
for registration in manifest.work_item_providers:
|
||||
if not _INTERFACE_NAME_RE.match(registration.id):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} work-item provider id must be "
|
||||
f"namespaced: {registration.id!r}"
|
||||
)
|
||||
if registration.id in work_provider_ids:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} declares duplicate work-item provider "
|
||||
f"{registration.id!r}"
|
||||
)
|
||||
work_provider_ids.add(registration.id)
|
||||
|
||||
|
||||
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
|
||||
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
|
||||
if overlap:
|
||||
joined = ", ".join(sorted(overlap))
|
||||
raise RegistryError(f"Module {manifest.id!r} lists dependencies as both required and optional: {joined}")
|
||||
capability_overlap = set(manifest.required_capabilities) & set(manifest.optional_capabilities)
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} lists dependencies as both required and optional: {joined}"
|
||||
)
|
||||
capability_overlap = set(manifest.required_capabilities) & set(
|
||||
manifest.optional_capabilities
|
||||
)
|
||||
if capability_overlap:
|
||||
joined = ", ".join(sorted(capability_overlap))
|
||||
raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}")
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_manifest_migration_spec(manifest: ModuleManifest) -> None:
|
||||
if manifest.migration_spec is not None:
|
||||
if manifest.migration_spec.module_id != manifest.id:
|
||||
raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}")
|
||||
if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location:
|
||||
raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location")
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}"
|
||||
)
|
||||
if (
|
||||
manifest.migration_spec.metadata is None
|
||||
and not manifest.migration_spec.script_location
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} migration spec must declare metadata or script location"
|
||||
)
|
||||
|
||||
|
||||
def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||
@@ -1126,15 +1307,24 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||
return
|
||||
frontend = manifest.frontend
|
||||
if frontend.module_id != manifest.id:
|
||||
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
|
||||
if frontend.asset_manifest_contract_version != SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}"
|
||||
)
|
||||
if (
|
||||
frontend.asset_manifest_contract_version
|
||||
!= SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} uses unsupported frontend asset manifest contract version "
|
||||
f"{frontend.asset_manifest_contract_version!r}; supported version is "
|
||||
f"{SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION!r}"
|
||||
)
|
||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(
|
||||
frontend.package_name
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}"
|
||||
)
|
||||
if frontend.public_routes and manifest.public_tenant_resolver is None:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} exposes public frontend routes without a "
|
||||
@@ -1160,7 +1350,9 @@ def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
||||
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)
|
||||
surface_id = item.surface_id or navigation_view_surface_id(
|
||||
manifest.id, item.path
|
||||
)
|
||||
_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)
|
||||
@@ -1176,8 +1368,7 @@ def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
||||
known_ids=known_ids,
|
||||
)
|
||||
parent_by_id = {
|
||||
surface.id: surface.parent_id or root_id
|
||||
for surface in frontend.view_surfaces
|
||||
surface.id: surface.parent_id or root_id for surface in frontend.view_surfaces
|
||||
}
|
||||
_validate_view_surface_hierarchy(manifest.id, parent_by_id)
|
||||
|
||||
@@ -1257,9 +1448,13 @@ def _validate_view_surface_id(module_id: str, surface_id: str) -> None:
|
||||
|
||||
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
||||
if not path.startswith("/"):
|
||||
raise RegistryError(f"Frontend route for module {module_id!r} must start with '/': {path!r}")
|
||||
raise RegistryError(
|
||||
f"Frontend route for module {module_id!r} must start with '/': {path!r}"
|
||||
)
|
||||
if not component.strip():
|
||||
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
|
||||
raise RegistryError(
|
||||
f"Frontend route {path!r} for module {module_id!r} must declare a component"
|
||||
)
|
||||
|
||||
|
||||
def _validate_public_frontend_route_uniqueness(
|
||||
@@ -1281,7 +1476,9 @@ def _validate_public_frontend_route_uniqueness(
|
||||
|
||||
|
||||
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
||||
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = (
|
||||
defaultdict(list)
|
||||
)
|
||||
for manifest in manifests:
|
||||
for provided in manifest.provides_interfaces:
|
||||
providers[provided.name].append((manifest, provided))
|
||||
@@ -1321,44 +1518,69 @@ def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _validate_dependency_list(module_id: str, field_name: str, dependencies: tuple[str, ...]) -> None:
|
||||
def _validate_dependency_list(
|
||||
module_id: str, field_name: str, dependencies: tuple[str, ...]
|
||||
) -> None:
|
||||
if len(dependencies) != len(set(dependencies)):
|
||||
raise RegistryError(f"Module {module_id!r} has duplicate {field_name}")
|
||||
for dependency in dependencies:
|
||||
if dependency == module_id:
|
||||
raise RegistryError(f"Module {module_id!r} cannot depend on itself")
|
||||
if not _MODULE_ID_RE.match(dependency):
|
||||
raise RegistryError(f"Module {module_id!r} has invalid dependency id {dependency!r}")
|
||||
raise RegistryError(
|
||||
f"Module {module_id!r} has invalid dependency id {dependency!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_capability_list(module_id: str, field_name: str, capabilities: tuple[str, ...]) -> None:
|
||||
def _validate_capability_list(
|
||||
module_id: str, field_name: str, capabilities: tuple[str, ...]
|
||||
) -> None:
|
||||
if len(capabilities) != len(set(capabilities)):
|
||||
raise RegistryError(f"Module {module_id!r} has duplicate {field_name}")
|
||||
for capability in capabilities:
|
||||
if not capability.strip() or any(part == "" for part in capability.split(".")):
|
||||
raise RegistryError(f"Module {module_id!r} has invalid capability name {capability!r}")
|
||||
raise RegistryError(
|
||||
f"Module {module_id!r} has invalid capability name {capability!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_interface_providers(module_id: str, providers: tuple[ModuleInterfaceProvider, ...]) -> None:
|
||||
def _validate_interface_providers(
|
||||
module_id: str, providers: tuple[ModuleInterfaceProvider, ...]
|
||||
) -> None:
|
||||
names = [provider.name for provider in providers]
|
||||
if len(names) != len(set(names)):
|
||||
raise RegistryError(f"Module {module_id!r} has duplicate provided interface names")
|
||||
raise RegistryError(
|
||||
f"Module {module_id!r} has duplicate provided interface names"
|
||||
)
|
||||
for provider in providers:
|
||||
_validate_interface_name(module_id, provider.name)
|
||||
if not provider.version.strip():
|
||||
raise RegistryError(f"Module {module_id!r} provides interface {provider.name!r} without a version")
|
||||
raise RegistryError(
|
||||
f"Module {module_id!r} provides interface {provider.name!r} without a version"
|
||||
)
|
||||
|
||||
|
||||
def _validate_interface_requirements(module_id: str, requirements: tuple[ModuleInterfaceRequirement, ...]) -> None:
|
||||
def _validate_interface_requirements(
|
||||
module_id: str, requirements: tuple[ModuleInterfaceRequirement, ...]
|
||||
) -> None:
|
||||
names = [requirement.name for requirement in requirements]
|
||||
if len(names) != len(set(names)):
|
||||
raise RegistryError(f"Module {module_id!r} has duplicate required interface names")
|
||||
raise RegistryError(
|
||||
f"Module {module_id!r} has duplicate required interface names"
|
||||
)
|
||||
for requirement in requirements:
|
||||
_validate_interface_name(module_id, requirement.name)
|
||||
if requirement.version_min is not None and not requirement.version_min.strip():
|
||||
raise RegistryError(f"Module {module_id!r} has blank minimum version for interface {requirement.name!r}")
|
||||
if requirement.version_max_exclusive is not None and not requirement.version_max_exclusive.strip():
|
||||
raise RegistryError(f"Module {module_id!r} has blank maximum version for interface {requirement.name!r}")
|
||||
raise RegistryError(
|
||||
f"Module {module_id!r} has blank minimum version for interface {requirement.name!r}"
|
||||
)
|
||||
if (
|
||||
requirement.version_max_exclusive is not None
|
||||
and not requirement.version_max_exclusive.strip()
|
||||
):
|
||||
raise RegistryError(
|
||||
f"Module {module_id!r} has blank maximum version for interface {requirement.name!r}"
|
||||
)
|
||||
if not version_range_is_valid(
|
||||
version_min=requirement.version_min,
|
||||
version_max_exclusive=requirement.version_max_exclusive,
|
||||
@@ -1367,7 +1589,9 @@ def _validate_interface_requirements(module_id: str, requirements: tuple[ModuleI
|
||||
version_min=requirement.version_min,
|
||||
version_max_exclusive=requirement.version_max_exclusive,
|
||||
)
|
||||
raise RegistryError(f"Module {module_id!r} has invalid interface range {version_range!r}")
|
||||
raise RegistryError(
|
||||
f"Module {module_id!r} has invalid interface range {version_range!r}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_interface_name(module_id: str, name: str) -> None:
|
||||
@@ -1377,8 +1601,14 @@ def _validate_interface_name(module_id: str, name: str) -> None:
|
||||
|
||||
def _validate_nav_item(module_id: str, item: NavItem) -> None:
|
||||
if not item.path.startswith("/"):
|
||||
raise RegistryError(f"Navigation item for module {module_id!r} must start with '/': {item.path!r}")
|
||||
raise RegistryError(
|
||||
f"Navigation item for module {module_id!r} must start with '/': {item.path!r}"
|
||||
)
|
||||
if not item.label.strip():
|
||||
raise RegistryError(f"Navigation item {item.path!r} for module {module_id!r} must declare a label")
|
||||
raise RegistryError(
|
||||
f"Navigation item {item.path!r} for module {module_id!r} must declare a label"
|
||||
)
|
||||
if item.icon is not None and not item.icon.strip():
|
||||
raise RegistryError(f"Navigation item {item.path!r} for module {module_id!r} has an empty icon name")
|
||||
raise RegistryError(
|
||||
f"Navigation item {item.path!r} for module {module_id!r} has an empty icon name"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
|
||||
|
||||
WorkItemStatus = Literal[
|
||||
"open",
|
||||
"in_progress",
|
||||
"deferred",
|
||||
"blocked",
|
||||
"completed",
|
||||
"cancelled",
|
||||
]
|
||||
WorkItemPriority = Literal["low", "normal", "high", "urgent"]
|
||||
WorkAssignmentKind = Literal[
|
||||
"account",
|
||||
"group",
|
||||
"role",
|
||||
"function",
|
||||
"function_assignment",
|
||||
"anyone",
|
||||
]
|
||||
WORK_ITEM_CONTRACT_VERSION = "1"
|
||||
CAPABILITY_TASK_COMMANDS = "tasks.commands"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkAssignmentRef:
|
||||
kind: WorkAssignmentKind
|
||||
id: str
|
||||
label: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.kind not in {
|
||||
"account",
|
||||
"group",
|
||||
"role",
|
||||
"function",
|
||||
"function_assignment",
|
||||
"anyone",
|
||||
}:
|
||||
raise ValueError(f"Unsupported work-assignment kind: {self.kind!r}.")
|
||||
if not self.id.strip():
|
||||
raise ValueError("Work assignments require an id.")
|
||||
if len(self.id) > 255:
|
||||
raise ValueError("Work assignment ids are limited to 255 characters.")
|
||||
if self.label is not None and len(self.label) > 500:
|
||||
raise ValueError("Work assignment labels are limited to 500 characters.")
|
||||
if self.kind == "anyone" and self.id != "*":
|
||||
raise ValueError("Broad work assignments use the canonical '*' id.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkSourceRef:
|
||||
module_id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
revision: str | None = None
|
||||
url: str | None = None
|
||||
label: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
required = (self.module_id, self.resource_type, self.resource_id)
|
||||
if any(not value.strip() for value in required):
|
||||
raise ValueError("Work source references require module, type, and id.")
|
||||
limits = {
|
||||
"module_id": 100,
|
||||
"resource_type": 100,
|
||||
"resource_id": 255,
|
||||
"revision": 255,
|
||||
"url": 1_500,
|
||||
"label": 500,
|
||||
}
|
||||
for field_name, limit in limits.items():
|
||||
value = getattr(self, field_name)
|
||||
if value is not None and len(value) > limit:
|
||||
raise ValueError(
|
||||
f"Work source {field_name} is limited to {limit} characters."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkItem:
|
||||
id: str
|
||||
provider_id: str
|
||||
owner_module: str
|
||||
tenant_id: str
|
||||
title: str
|
||||
status: WorkItemStatus = "open"
|
||||
priority: WorkItemPriority = "normal"
|
||||
summary: str | None = None
|
||||
required_action: str | None = None
|
||||
action_url: str | None = None
|
||||
due_at: datetime | None = None
|
||||
deferred_until: datetime | None = None
|
||||
assignments: tuple[WorkAssignmentRef, ...] = ()
|
||||
sources: tuple[WorkSourceRef, ...] = ()
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
revision: str = "1"
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
required = {
|
||||
"id": self.id,
|
||||
"provider_id": self.provider_id,
|
||||
"owner_module": self.owner_module,
|
||||
"tenant_id": self.tenant_id,
|
||||
"title": self.title,
|
||||
"revision": self.revision,
|
||||
}
|
||||
if any(not value.strip() for value in required.values()):
|
||||
raise ValueError(
|
||||
"Work items require stable identity, owner, tenant, and title."
|
||||
)
|
||||
limits = {
|
||||
"id": 255,
|
||||
"provider_id": 200,
|
||||
"owner_module": 100,
|
||||
"tenant_id": 255,
|
||||
"title": 500,
|
||||
"summary": 4_000,
|
||||
"required_action": 500,
|
||||
"action_url": 1_500,
|
||||
"revision": 255,
|
||||
}
|
||||
for field_name, limit in limits.items():
|
||||
value = getattr(self, field_name)
|
||||
if value is not None and len(value) > limit:
|
||||
raise ValueError(
|
||||
f"Work item {field_name} is limited to {limit} characters."
|
||||
)
|
||||
if len(self.assignments) > 100 or len(self.sources) > 100:
|
||||
raise ValueError("Work items support at most 100 assignments and sources.")
|
||||
_validate_action_url(self.action_url)
|
||||
if self.status not in {
|
||||
"open",
|
||||
"in_progress",
|
||||
"deferred",
|
||||
"blocked",
|
||||
"completed",
|
||||
"cancelled",
|
||||
}:
|
||||
raise ValueError(f"Unsupported work-item status: {self.status!r}.")
|
||||
if self.priority not in {"low", "normal", "high", "urgent"}:
|
||||
raise ValueError(f"Unsupported work-item priority: {self.priority!r}.")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkItemQuery:
|
||||
tenant_id: str
|
||||
statuses: tuple[WorkItemStatus, ...] = (
|
||||
"open",
|
||||
"in_progress",
|
||||
"deferred",
|
||||
"blocked",
|
||||
)
|
||||
priorities: tuple[WorkItemPriority, ...] = ()
|
||||
provider_ids: tuple[str, ...] = ()
|
||||
owner_modules: tuple[str, ...] = ()
|
||||
due_before: datetime | None = None
|
||||
text: str = ""
|
||||
limit: int = 100
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.tenant_id.strip():
|
||||
raise ValueError("Work-item queries require a tenant.")
|
||||
if not 1 <= self.limit <= 500:
|
||||
raise ValueError("Work-item query limits must be between 1 and 500.")
|
||||
normalized = self.text.strip()
|
||||
if len(normalized) > 500:
|
||||
raise ValueError("Work-item query text is limited to 500 characters.")
|
||||
if len(self.provider_ids) > 50 or len(self.owner_modules) > 50:
|
||||
raise ValueError("Work-item queries support at most 50 provider filters.")
|
||||
object.__setattr__(self, "text", normalized)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkItemPage:
|
||||
items: tuple[WorkItem, ...]
|
||||
total: int
|
||||
truncated: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.total < len(self.items):
|
||||
raise ValueError(
|
||||
"Work-item totals cannot be smaller than the returned page."
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class WorkItemProvider(Protocol):
|
||||
def list_items(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: WorkItemQuery,
|
||||
) -> WorkItemPage:
|
||||
"""Return only items the current principal may discover and act on."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskCreateCommand:
|
||||
tenant_id: str
|
||||
title: str
|
||||
idempotency_key: str
|
||||
summary: str | None = None
|
||||
priority: WorkItemPriority = "normal"
|
||||
due_at: datetime | None = None
|
||||
required_action: str | None = None
|
||||
action_url: str | None = None
|
||||
assignments: tuple[WorkAssignmentRef, ...] = ()
|
||||
sources: tuple[WorkSourceRef, ...] = ()
|
||||
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.tenant_id.strip() or not self.title.strip():
|
||||
raise ValueError("Task commands require a tenant and title.")
|
||||
if not self.idempotency_key.strip() or len(self.idempotency_key) > 255:
|
||||
raise ValueError("Task commands require a bounded idempotency key.")
|
||||
if not self.assignments:
|
||||
raise ValueError("Explicit tasks require at least one assignment.")
|
||||
if self.priority not in {"low", "normal", "high", "urgent"}:
|
||||
raise ValueError(f"Unsupported task priority: {self.priority!r}.")
|
||||
limits = {
|
||||
"title": 500,
|
||||
"summary": 4_000,
|
||||
"required_action": 500,
|
||||
"action_url": 1_500,
|
||||
}
|
||||
for field_name, limit in limits.items():
|
||||
value = getattr(self, field_name)
|
||||
if value is not None and len(value) > limit:
|
||||
raise ValueError(
|
||||
f"Task command {field_name} is limited to {limit} characters."
|
||||
)
|
||||
if len(self.assignments) > 100 or len(self.sources) > 100:
|
||||
raise ValueError(
|
||||
"Task commands support at most 100 assignments and sources."
|
||||
)
|
||||
_validate_action_url(self.action_url)
|
||||
|
||||
|
||||
def _validate_action_url(value: str | None) -> None:
|
||||
if value is None:
|
||||
return
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
return
|
||||
if (
|
||||
not candidate.startswith("/")
|
||||
or candidate.startswith("//")
|
||||
or "\\" in candidate
|
||||
or any(ord(character) < 32 or ord(character) == 127 for character in candidate)
|
||||
):
|
||||
raise ValueError("Work-item action URLs must be application-relative paths.")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TaskCommandProvider(Protocol):
|
||||
def create_task(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
command: TaskCreateCommand,
|
||||
) -> WorkItem: ...
|
||||
|
||||
|
||||
WorkItemProviderFactory = Callable[[ModuleContext], WorkItemProvider]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkItemProviderRegistration:
|
||||
id: str
|
||||
factory: WorkItemProviderFactory
|
||||
order: int = 100
|
||||
|
||||
def create(self, context: ModuleContext) -> WorkItemProvider:
|
||||
provider = self.factory(context)
|
||||
if not isinstance(provider, WorkItemProvider):
|
||||
raise TypeError(
|
||||
f"Work-item provider {self.id!r} does not implement WorkItemProvider."
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegisteredWorkItemProvider:
|
||||
module_id: str
|
||||
registration: WorkItemProviderRegistration
|
||||
|
||||
|
||||
def task_command_provider(registry: object | None) -> TaskCommandProvider | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(CAPABILITY_TASK_COMMANDS)
|
||||
):
|
||||
return None
|
||||
provider = registry.capability(CAPABILITY_TASK_COMMANDS)
|
||||
return provider if isinstance(provider, TaskCommandProvider) else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_TASK_COMMANDS",
|
||||
"RegisteredWorkItemProvider",
|
||||
"TaskCommandProvider",
|
||||
"TaskCreateCommand",
|
||||
"WORK_ITEM_CONTRACT_VERSION",
|
||||
"WorkAssignmentKind",
|
||||
"WorkAssignmentRef",
|
||||
"WorkItem",
|
||||
"WorkItemPage",
|
||||
"WorkItemPriority",
|
||||
"WorkItemProvider",
|
||||
"WorkItemProviderFactory",
|
||||
"WorkItemProviderRegistration",
|
||||
"WorkItemQuery",
|
||||
"WorkItemStatus",
|
||||
"WorkSourceRef",
|
||||
"task_command_provider",
|
||||
]
|
||||
@@ -107,7 +107,7 @@ class Settings(BaseSettings):
|
||||
default=(
|
||||
"tenancy,organizations,identity,idm,access,admin,dashboard,policy,"
|
||||
"audit,campaigns,files,mail,calendar,poll,scheduling,connectors,"
|
||||
"datasources,dataflow,dist_lists,templates,workflow_engine,workflow,views,search,risk_compliance,"
|
||||
"datasources,dataflow,dist_lists,templates,workflow_engine,workflow,tasks,views,search,risk_compliance,"
|
||||
"postbox,notifications,docs,ops"
|
||||
),
|
||||
alias="ENABLED_MODULES",
|
||||
|
||||
Reference in New Issue
Block a user