feat: add search and external integration contracts
This commit is contained in:
137
src/govoplan_core/core/external_references.py
Normal file
137
src/govoplan_core/core/external_references.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
IntegrationMaturity = Literal[
|
||||
"discover",
|
||||
"link",
|
||||
"search",
|
||||
"read",
|
||||
"publish",
|
||||
"synchronize",
|
||||
"migrate",
|
||||
"replace",
|
||||
]
|
||||
|
||||
INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = (
|
||||
"discover",
|
||||
"link",
|
||||
"search",
|
||||
"read",
|
||||
"publish",
|
||||
"synchronize",
|
||||
"migrate",
|
||||
"replace",
|
||||
)
|
||||
|
||||
|
||||
class ExternalReferenceValidationError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExternalObjectReference:
|
||||
"""Stable identity and provenance for an object owned by another system."""
|
||||
|
||||
system: str
|
||||
object_type: str
|
||||
object_id: str
|
||||
maturity: IntegrationMaturity = "link"
|
||||
connector_id: str | None = None
|
||||
canonical_url: str | None = None
|
||||
version: str | None = None
|
||||
etag: str | None = None
|
||||
observed_at: datetime | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in ("system", "object_type", "object_id"):
|
||||
value = str(getattr(self, field_name) or "").strip()
|
||||
if not value:
|
||||
raise ExternalReferenceValidationError(
|
||||
f"External reference {field_name} is required."
|
||||
)
|
||||
if len(value) > 255:
|
||||
raise ExternalReferenceValidationError(
|
||||
f"External reference {field_name} is limited to 255 characters."
|
||||
)
|
||||
object.__setattr__(self, field_name, value)
|
||||
if self.maturity not in INTEGRATION_MATURITY_ORDER:
|
||||
raise ExternalReferenceValidationError(
|
||||
f"Unsupported integration maturity: {self.maturity!r}."
|
||||
)
|
||||
if self.connector_id is not None:
|
||||
connector_id = self.connector_id.strip()
|
||||
if not connector_id:
|
||||
raise ExternalReferenceValidationError(
|
||||
"External reference connector_id cannot be blank."
|
||||
)
|
||||
object.__setattr__(self, "connector_id", connector_id)
|
||||
if self.canonical_url is not None:
|
||||
object.__setattr__(
|
||||
self,
|
||||
"canonical_url",
|
||||
_validated_reference_url(self.canonical_url),
|
||||
)
|
||||
|
||||
@property
|
||||
def identity_key(self) -> str:
|
||||
return f"{self.system}:{self.object_type}:{self.object_id}"
|
||||
|
||||
def supports(self, maturity: IntegrationMaturity) -> bool:
|
||||
return integration_maturity_rank(self.maturity) >= integration_maturity_rank(
|
||||
maturity
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"system": self.system,
|
||||
"object_type": self.object_type,
|
||||
"object_id": self.object_id,
|
||||
"maturity": self.maturity,
|
||||
"connector_id": self.connector_id,
|
||||
"canonical_url": self.canonical_url,
|
||||
"version": self.version,
|
||||
"etag": self.etag,
|
||||
"observed_at": (
|
||||
self.observed_at.isoformat() if self.observed_at is not None else None
|
||||
),
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
|
||||
def integration_maturity_rank(maturity: IntegrationMaturity) -> int:
|
||||
try:
|
||||
return INTEGRATION_MATURITY_ORDER.index(maturity)
|
||||
except ValueError as exc:
|
||||
raise ExternalReferenceValidationError(
|
||||
f"Unsupported integration maturity: {maturity!r}."
|
||||
) from exc
|
||||
|
||||
|
||||
def _validated_reference_url(value: str) -> str:
|
||||
normalized = value.strip()
|
||||
parsed = urlsplit(normalized)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
||||
raise ExternalReferenceValidationError(
|
||||
"External reference URLs must use HTTP or HTTPS."
|
||||
)
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise ExternalReferenceValidationError(
|
||||
"External reference URLs must not contain credentials."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ExternalObjectReference",
|
||||
"ExternalReferenceValidationError",
|
||||
"INTEGRATION_MATURITY_ORDER",
|
||||
"IntegrationMaturity",
|
||||
"integration_maturity_rank",
|
||||
]
|
||||
@@ -8,6 +8,7 @@ from govoplan_core.core.views import ViewSurface
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import APIRouter
|
||||
from govoplan_core.core.search import SearchProviderRegistration
|
||||
|
||||
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||
@@ -356,6 +357,7 @@ class ModuleManifest:
|
||||
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)
|
||||
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
||||
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
||||
on_activate: LifecycleHook | None = None
|
||||
on_deactivate: LifecycleHook | None = None
|
||||
|
||||
@@ -25,6 +25,7 @@ from govoplan_core.core.modules import (
|
||||
user_workflow_scope_condition_issues,
|
||||
)
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
from govoplan_core.core.search import RegisteredSearchProvider, SearchProvider
|
||||
from govoplan_core.core.views import (
|
||||
ViewSurface,
|
||||
module_view_surface_id,
|
||||
@@ -60,6 +61,8 @@ class PlatformRegistry:
|
||||
self._capability_factories: dict[str, CapabilityFactory] = {}
|
||||
self._capabilities: dict[str, object] = {}
|
||||
self._capability_context: ModuleContext | None = None
|
||||
self._search_provider_registrations: list[RegisteredSearchProvider] = []
|
||||
self._search_providers: dict[str, SearchProvider] = {}
|
||||
|
||||
def register(self, manifest: ModuleManifest) -> ModuleManifest:
|
||||
if manifest.id in self._manifests:
|
||||
@@ -72,6 +75,13 @@ class PlatformRegistry:
|
||||
self.register_delete_veto(manifest.id, resource_type, provider)
|
||||
for name, factory in manifest.capability_factories.items():
|
||||
self.register_capability_factory(manifest.id, name, factory)
|
||||
for registration in manifest.search_providers:
|
||||
self._search_provider_registrations.append(
|
||||
RegisteredSearchProvider(
|
||||
module_id=manifest.id,
|
||||
registration=registration,
|
||||
)
|
||||
)
|
||||
return manifest
|
||||
|
||||
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
|
||||
@@ -89,7 +99,11 @@ class PlatformRegistry:
|
||||
for resource_type, providers in replacement._delete_veto_providers.items()
|
||||
})
|
||||
self._capability_factories = dict(replacement._capability_factories)
|
||||
self._search_provider_registrations = list(
|
||||
replacement._search_provider_registrations
|
||||
)
|
||||
self._capabilities.clear()
|
||||
self._search_providers.clear()
|
||||
return snapshot
|
||||
|
||||
def get(self, module_id: str) -> ModuleManifest | None:
|
||||
@@ -133,6 +147,7 @@ class PlatformRegistry:
|
||||
def configure_capability_context(self, context: ModuleContext) -> None:
|
||||
self._capability_context = context
|
||||
self._capabilities.clear()
|
||||
self._search_providers.clear()
|
||||
|
||||
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
|
||||
if name in self._capability_factories:
|
||||
@@ -160,6 +175,37 @@ class PlatformRegistry:
|
||||
raise RegistryError(f"Required capability is not available: {name}")
|
||||
return capability
|
||||
|
||||
def search_provider_registrations(
|
||||
self,
|
||||
) -> tuple[RegisteredSearchProvider, ...]:
|
||||
return tuple(
|
||||
sorted(
|
||||
self._search_provider_registrations,
|
||||
key=lambda item: (
|
||||
item.registration.order,
|
||||
item.module_id,
|
||||
item.registration.id,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def search_providers(
|
||||
self,
|
||||
) -> tuple[tuple[RegisteredSearchProvider, SearchProvider], ...]:
|
||||
if self._capability_context is None:
|
||||
if self._search_provider_registrations:
|
||||
raise RegistryError("Search provider context is not configured.")
|
||||
return ()
|
||||
providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = []
|
||||
for registered in self.search_provider_registrations():
|
||||
key = f"{registered.module_id}:{registered.registration.id}"
|
||||
provider = self._search_providers.get(key)
|
||||
if provider is None:
|
||||
provider = registered.registration.create(self._capability_context)
|
||||
self._search_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
|
||||
|
||||
@@ -462,6 +508,19 @@ def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
|
||||
_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()
|
||||
for registration in manifest.search_providers:
|
||||
if not _INTERFACE_NAME_RE.match(registration.id):
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} search provider id must be namespaced: "
|
||||
f"{registration.id!r}"
|
||||
)
|
||||
if registration.id in provider_ids:
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} declares duplicate search provider "
|
||||
f"{registration.id!r}"
|
||||
)
|
||||
provider_ids.add(registration.id)
|
||||
|
||||
|
||||
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
|
||||
|
||||
178
src/govoplan_core/core/search.py
Normal file
178
src/govoplan_core/core/search.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from govoplan_core.core.external_references import ExternalObjectReference
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
|
||||
|
||||
SearchContextKind = Literal["global", "module", "resource"]
|
||||
SearchVisibility = Literal["tenant", "restricted"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SearchQuery:
|
||||
text: str
|
||||
tenant_id: str
|
||||
module_ids: tuple[str, ...] = ()
|
||||
resource_types: tuple[str, ...] = ()
|
||||
context_kind: SearchContextKind = "global"
|
||||
context_id: str | None = None
|
||||
limit: int = 25
|
||||
offset: int = 0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
normalized_text = self.text.strip()
|
||||
if len(normalized_text) > 500:
|
||||
raise ValueError("Search text is limited to 500 characters.")
|
||||
if not self.tenant_id.strip():
|
||||
raise ValueError("Search queries require a tenant.")
|
||||
if not 1 <= self.limit <= 200:
|
||||
raise ValueError("Search result limits must be between 1 and 200.")
|
||||
if self.offset < 0:
|
||||
raise ValueError("Search offsets cannot be negative.")
|
||||
object.__setattr__(self, "text", normalized_text)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SearchResult:
|
||||
provider_id: str
|
||||
module_id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
title: str
|
||||
url: str
|
||||
summary: str | None = None
|
||||
score: float = 0.0
|
||||
highlights: tuple[str, ...] = ()
|
||||
breadcrumbs: tuple[str, ...] = ()
|
||||
external_reference: ExternalObjectReference | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SearchDocument:
|
||||
tenant_id: str
|
||||
module_id: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
title: str
|
||||
url: str
|
||||
summary: str | None = None
|
||||
body: str | None = None
|
||||
keywords: tuple[str, ...] = ()
|
||||
visibility: SearchVisibility = "restricted"
|
||||
acl_tokens: tuple[str, ...] = ()
|
||||
external_reference: ExternalObjectReference | None = None
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
required = {
|
||||
"tenant_id": self.tenant_id,
|
||||
"module_id": self.module_id,
|
||||
"resource_type": self.resource_type,
|
||||
"resource_id": self.resource_id,
|
||||
"title": self.title,
|
||||
"url": self.url,
|
||||
}
|
||||
for field_name, value in required.items():
|
||||
if not str(value or "").strip():
|
||||
raise ValueError(f"Search document {field_name} is required.")
|
||||
if self.visibility == "restricted" and not self.acl_tokens:
|
||||
raise ValueError(
|
||||
"Restricted search documents require at least one ACL token."
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SearchProvider(Protocol):
|
||||
def search(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: SearchQuery,
|
||||
) -> Sequence[SearchResult]:
|
||||
"""Return only results the principal may currently read."""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SearchIndexWriter(Protocol):
|
||||
def upsert_document(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
document: SearchDocument,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
def delete_document(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
module_id: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
) -> bool:
|
||||
...
|
||||
|
||||
|
||||
SearchProviderFactory = Callable[[ModuleContext], SearchProvider]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SearchProviderRegistration:
|
||||
id: str
|
||||
factory: SearchProviderFactory
|
||||
resource_types: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
|
||||
def create(self, context: ModuleContext) -> SearchProvider:
|
||||
provider = self.factory(context)
|
||||
if not isinstance(provider, SearchProvider):
|
||||
raise TypeError(
|
||||
f"Search provider {self.id!r} does not implement SearchProvider."
|
||||
)
|
||||
return provider
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RegisteredSearchProvider:
|
||||
module_id: str
|
||||
registration: SearchProviderRegistration
|
||||
|
||||
|
||||
CAPABILITY_SEARCH_INDEX_WRITER = "search.index_writer"
|
||||
|
||||
|
||||
def search_index_writer(registry: object | None) -> SearchIndexWriter | None:
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not hasattr(registry, "capability")
|
||||
or not registry.has_capability(CAPABILITY_SEARCH_INDEX_WRITER)
|
||||
):
|
||||
return None
|
||||
writer = registry.capability(CAPABILITY_SEARCH_INDEX_WRITER)
|
||||
return writer if isinstance(writer, SearchIndexWriter) else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAPABILITY_SEARCH_INDEX_WRITER",
|
||||
"RegisteredSearchProvider",
|
||||
"SearchContextKind",
|
||||
"SearchDocument",
|
||||
"SearchIndexWriter",
|
||||
"SearchProvider",
|
||||
"SearchProviderFactory",
|
||||
"SearchProviderRegistration",
|
||||
"SearchQuery",
|
||||
"SearchResult",
|
||||
"SearchVisibility",
|
||||
"search_index_writer",
|
||||
]
|
||||
@@ -45,7 +45,15 @@ class Settings(BaseSettings):
|
||||
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
|
||||
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
|
||||
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
|
||||
enabled_modules: str = Field(default="tenancy,organizations,identity,idm,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,views,postbox,notifications,docs,ops", alias="ENABLED_MODULES")
|
||||
enabled_modules: str = Field(
|
||||
default=(
|
||||
"tenancy,organizations,identity,idm,access,admin,dashboard,policy,"
|
||||
"audit,campaigns,files,mail,calendar,poll,scheduling,connectors,"
|
||||
"datasources,dataflow,workflow,views,search,postbox,notifications,"
|
||||
"docs,ops"
|
||||
),
|
||||
alias="ENABLED_MODULES",
|
||||
)
|
||||
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
|
||||
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
||||
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
||||
|
||||
Reference in New Issue
Block a user