diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py index 79395df..0203944 100644 --- a/src/govoplan_core/core/modules.py +++ b/src/govoplan_core/core/modules.py @@ -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", ..., diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index 510def7..1b1106a 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -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 ::: {permission.scope!r}") + raise RegistryError( + f"Permission scope must be ::: {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" + ) diff --git a/src/govoplan_core/core/tasks.py b/src/govoplan_core/core/tasks.py new file mode 100644 index 0000000..6adf601 --- /dev/null +++ b/src/govoplan_core/core/tasks.py @@ -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", +] diff --git a/src/govoplan_core/settings.py b/src/govoplan_core/settings.py index 3e35647..1cb174f 100644 --- a/src/govoplan_core/settings.py +++ b/src/govoplan_core/settings.py @@ -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", diff --git a/tests/test_work_item_contracts.py b/tests/test_work_item_contracts.py new file mode 100644 index 0000000..e7d3298 --- /dev/null +++ b/tests/test_work_item_contracts.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import unittest +from datetime import UTC, datetime + +from govoplan_core.core.modules import ModuleContext, ModuleManifest +from govoplan_core.core.registry import PlatformRegistry, RegistryError +from govoplan_core.core.tasks import ( + WorkAssignmentRef, + WorkItem, + WorkItemPage, + WorkItemProviderRegistration, + WorkItemQuery, + WorkSourceRef, +) + + +class _Provider: + def list_items(self, session, principal, *, query): + del session, principal + return WorkItemPage( + items=( + WorkItem( + id="task-1", + provider_id="example.items", + owner_module="example", + tenant_id=query.tenant_id, + title="Review request", + assignments=(WorkAssignmentRef(kind="account", id="account-1"),), + sources=( + WorkSourceRef( + module_id="cases", + resource_type="case", + resource_id="case-1", + ), + ), + due_at=datetime(2026, 8, 7, tzinfo=UTC), + ), + ), + total=1, + ) + + +class WorkItemContractTests(unittest.TestCase): + def test_provider_registration_is_lazy_and_stable(self) -> None: + registry = PlatformRegistry() + registry.register( + ModuleManifest( + id="example", + name="Example", + version="1", + work_item_providers=( + WorkItemProviderRegistration( + id="example.items", + factory=lambda _context: _Provider(), + ), + ), + ) + ) + registry.validate() + registry.configure_capability_context( + ModuleContext(registry=registry, settings=object()) + ) + registered, provider = registry.work_item_providers()[0] + self.assertEqual("example.items", registered.registration.id) + page = provider.list_items( + object(), + object(), + query=WorkItemQuery(tenant_id="tenant-1"), + ) + self.assertEqual("Review request", page.items[0].title) + + def test_provider_ids_must_be_namespaced(self) -> None: + registry = PlatformRegistry() + registry.register( + ModuleManifest( + id="example", + name="Example", + version="1", + work_item_providers=( + WorkItemProviderRegistration( + id="items", + factory=lambda _context: _Provider(), + ), + ), + ) + ) + with self.assertRaisesRegex(RegistryError, "namespaced"): + registry.validate() + + def test_contract_rejects_unbounded_or_incomplete_values(self) -> None: + with self.assertRaisesRegex(ValueError, "tenant"): + WorkItemQuery(tenant_id="") + with self.assertRaisesRegex(ValueError, "assignment"): + WorkAssignmentRef(kind="account", id="") + with self.assertRaisesRegex(ValueError, "module"): + WorkSourceRef(module_id="", resource_type="case", resource_id="1") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package-lock.json b/webui/package-lock.json index 8b9530a..117ba2e 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -41,6 +41,7 @@ "@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui", "@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui", "@govoplan/search-webui": "file:../../govoplan-search/webui", + "@govoplan/tasks-webui": "file:../../govoplan-tasks/webui", "@govoplan/templates-webui": "file:../../govoplan-templates/webui", "@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui", @@ -639,6 +640,22 @@ } } }, + "../../govoplan-tasks/webui": { + "name": "@govoplan/tasks-webui", + "version": "0.1.18", + "peerDependencies": { + "@govoplan/core-webui": "^0.1.18", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } + }, "../../govoplan-templates/webui": { "name": "@govoplan/templates-webui", "version": "0.1.18", @@ -1604,6 +1621,10 @@ "resolved": "../../govoplan-search/webui", "link": true }, + "node_modules/@govoplan/tasks-webui": { + "resolved": "../../govoplan-tasks/webui", + "link": true + }, "node_modules/@govoplan/templates-webui": { "resolved": "../../govoplan-templates/webui", "link": true diff --git a/webui/package.json b/webui/package.json index d2b98a6..42400ae 100644 --- a/webui/package.json +++ b/webui/package.json @@ -87,6 +87,7 @@ "@govoplan/search-webui": "file:../../govoplan-search/webui", "@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui", "@govoplan/templates-webui": "file:../../govoplan-templates/webui", + "@govoplan/tasks-webui": "file:../../govoplan-tasks/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui", "@govoplan/voting-webui": "file:../../govoplan-voting/webui", "@govoplan/workflow-webui": "file:../../govoplan-workflow/webui", diff --git a/webui/scripts/test-module-permutations.mjs b/webui/scripts/test-module-permutations.mjs index 65e0da1..b2fbfe6 100644 --- a/webui/scripts/test-module-permutations.mjs +++ b/webui/scripts/test-module-permutations.mjs @@ -35,6 +35,7 @@ const packageByModule = { risk_compliance: "@govoplan/risk-compliance-webui", scheduling: "@govoplan/scheduling-webui", search: "@govoplan/search-webui", + tasks: "@govoplan/tasks-webui", tenancy: "@govoplan/tenancy-webui", templates: "@govoplan/templates-webui", views: "@govoplan/views-webui", @@ -93,8 +94,10 @@ const cases = [ { name: "risk-compliance-only", modules: ["risk_compliance"] }, { name: "docs-and-ops", modules: ["access", "docs", "ops"] }, { name: "approvals-only", modules: ["access", "approvals"] }, + { name: "tasks-only", modules: ["access", "tasks"] }, + { name: "tasks-with-contributors", modules: ["access", "approvals", "postbox", "workflow", "dashboard", "tasks"] }, { name: "voting-only", modules: ["access", "voting"] }, - { name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity_trust", "encryption", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "reporting", "records", "risk_compliance", "search", "voting"] } + { name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity_trust", "encryption", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "reporting", "records", "risk_compliance", "search", "tasks", "voting"] } ]; const npmExec = process.env.npm_execpath;