feat: add search and external integration contracts
This commit is contained in:
47
docs/EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md
Normal file
47
docs/EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# External References And Integration Maturity
|
||||
|
||||
GovOPlaN integrations use a shared external-reference contract instead of
|
||||
storing connector-specific URLs and identifiers in every module.
|
||||
|
||||
An external reference identifies an object by:
|
||||
|
||||
- external system instance
|
||||
- object type
|
||||
- stable external object ID
|
||||
- optional connector configuration
|
||||
- canonical HTTP(S) URL without embedded credentials
|
||||
- optional source version, ETag, observation time, and non-secret metadata
|
||||
|
||||
The identity key is `system:object_type:object_id`. A GovOPlaN object may retain
|
||||
multiple references, but one reference must never silently change its identity.
|
||||
Moving or escalating work creates a new object and an explicit relationship; it
|
||||
does not rewrite either object's history.
|
||||
|
||||
## Integration Maturity
|
||||
|
||||
Maturity is cumulative:
|
||||
|
||||
1. `discover`: identify configured external systems and their health.
|
||||
2. `link`: retain and open stable external references.
|
||||
3. `search`: include authorized external objects in GovOPlaN search.
|
||||
4. `read`: display authoritative external content.
|
||||
5. `publish`: create or update external content from GovOPlaN.
|
||||
6. `synchronize`: reconcile changes in both directions with conflict handling.
|
||||
7. `migrate`: perform a governed, verifiable transfer into GovOPlaN.
|
||||
8. `replace`: provide the native operational capability without the external tool.
|
||||
|
||||
Connectors must declare and document the maturity they actually implement.
|
||||
`synchronize` requires durable cursors, idempotency, provenance, conflict
|
||||
handling, deletion semantics, and observable failures. A link-only connector
|
||||
must not imply that GovOPlaN holds an authoritative copy.
|
||||
|
||||
## Domain Ownership
|
||||
|
||||
- Domain modules own native GovOPlaN objects and their authorization.
|
||||
- Connectors own protocols, credentials, discovery, transport, and sync state.
|
||||
- Search owns indexing and result aggregation, but source modules remain
|
||||
responsible for authorization.
|
||||
- Core owns only the stable DTOs and extension contracts.
|
||||
|
||||
The Python contract is
|
||||
`govoplan_core.core.external_references.ExternalObjectReference`.
|
||||
@@ -412,7 +412,7 @@ Goal: cover internal support and public issue reporting.
|
||||
|
||||
Create or refine in this order:
|
||||
|
||||
1. `govoplan-issue-reporting`: public/internal reports, categories, intake,
|
||||
1. `govoplan-tickets`: public/internal reports, requests, incidents, queues,
|
||||
location, evidence, and triage.
|
||||
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
|
||||
escalation, and resolution evidence.
|
||||
|
||||
@@ -5,6 +5,9 @@ before deciding to replace specialist workflows. This document is the core
|
||||
strategy index. The executable connector catalogue lives in
|
||||
`govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`.
|
||||
|
||||
The canonical cumulative maturity model and external-object DTO are documented
|
||||
in [EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md](EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md).
|
||||
|
||||
## Strategy Labels
|
||||
|
||||
Use one or more of these labels for every external system family:
|
||||
|
||||
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")
|
||||
|
||||
36
tests/test_external_reference_contract.py
Normal file
36
tests/test_external_reference_contract.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.external_references import (
|
||||
ExternalObjectReference,
|
||||
ExternalReferenceValidationError,
|
||||
)
|
||||
|
||||
|
||||
class ExternalReferenceContractTests(unittest.TestCase):
|
||||
def test_reference_has_stable_identity_and_ordered_maturity(self) -> None:
|
||||
reference = ExternalObjectReference(
|
||||
system="openproject-main",
|
||||
object_type="work_package",
|
||||
object_id="42",
|
||||
maturity="synchronize",
|
||||
canonical_url="https://projects.example.test/work_packages/42",
|
||||
)
|
||||
|
||||
self.assertEqual("openproject-main:work_package:42", reference.identity_key)
|
||||
self.assertTrue(reference.supports("read"))
|
||||
self.assertFalse(reference.supports("replace"))
|
||||
|
||||
def test_reference_rejects_credentials_in_urls(self) -> None:
|
||||
with self.assertRaises(ExternalReferenceValidationError):
|
||||
ExternalObjectReference(
|
||||
system="wiki",
|
||||
object_type="page",
|
||||
object_id="Main_Page",
|
||||
canonical_url="https://user:secret@example.test/wiki/Main_Page",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
82
tests/test_search_contract.py
Normal file
82
tests/test_search_contract.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.search import (
|
||||
SearchDocument,
|
||||
SearchProvider,
|
||||
SearchProviderRegistration,
|
||||
SearchQuery,
|
||||
SearchResult,
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
def search(self, session, principal, *, query):
|
||||
del session, principal
|
||||
return (
|
||||
SearchResult(
|
||||
provider_id="test",
|
||||
module_id="test",
|
||||
resource_type="record",
|
||||
resource_id="1",
|
||||
title=query.text,
|
||||
url="/test/1",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SearchContractTests(unittest.TestCase):
|
||||
def test_query_and_restricted_document_are_bounded(self) -> None:
|
||||
query = SearchQuery(text=" permit ", tenant_id="tenant-1", limit=20)
|
||||
document = SearchDocument(
|
||||
tenant_id="tenant-1",
|
||||
module_id="cases",
|
||||
resource_type="case",
|
||||
resource_id="case-1",
|
||||
title="Permit",
|
||||
url="/cases/case-1",
|
||||
acl_tokens=("account:account-1",),
|
||||
)
|
||||
|
||||
self.assertEqual("permit", query.text)
|
||||
self.assertEqual("restricted", document.visibility)
|
||||
with self.assertRaises(ValueError):
|
||||
SearchDocument(
|
||||
tenant_id="tenant-1",
|
||||
module_id="cases",
|
||||
resource_type="case",
|
||||
resource_id="case-1",
|
||||
title="Permit",
|
||||
url="/cases/case-1",
|
||||
)
|
||||
|
||||
def test_manifest_provider_registration_resolves_lazily(self) -> None:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="test",
|
||||
name="Test",
|
||||
version="1",
|
||||
search_providers=(
|
||||
SearchProviderRegistration(
|
||||
id="test.records",
|
||||
factory=lambda context: _Provider(),
|
||||
resource_types=("record",),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
registry.configure_capability_context(
|
||||
ModuleContext(registry=registry, settings=object())
|
||||
)
|
||||
|
||||
providers = registry.search_providers()
|
||||
self.assertEqual(1, len(providers))
|
||||
self.assertIsInstance(providers[0][1], SearchProvider)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
21
webui/package-lock.json
generated
21
webui/package-lock.json
generated
@@ -27,6 +27,7 @@
|
||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
|
||||
},
|
||||
@@ -396,6 +397,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-search/webui": {
|
||||
"name": "@govoplan/search-webui",
|
||||
"version": "0.1.14",
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": ">=7.18.2 <8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../govoplan-views/webui": {
|
||||
"name": "@govoplan/views-webui",
|
||||
"version": "0.1.0",
|
||||
@@ -1230,6 +1247,10 @@
|
||||
"resolved": "../../govoplan-scheduling/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/search-webui": {
|
||||
"resolved": "../../govoplan-search/webui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@govoplan/views-webui": {
|
||||
"resolved": "../../govoplan-views/webui",
|
||||
"link": true
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react";
|
||||
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, ViewsRuntimeUiCapability } from "../types";
|
||||
import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types";
|
||||
import HelpMenu from "./HelpMenu";
|
||||
import LanguageMenu from "./LanguageMenu";
|
||||
import LoginModal from "../features/auth/LoginModal";
|
||||
@@ -37,6 +37,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
const projection = useEffectiveView();
|
||||
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>("views.runtime");
|
||||
const ViewSelector = viewsRuntime?.Selector ?? null;
|
||||
const searchRuntime = usePlatformUiCapability<SearchRuntimeUiCapability>("search.runtime");
|
||||
const GlobalSearch = searchRuntime?.GlobalSearch ?? null;
|
||||
|
||||
const activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
|
||||
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
||||
@@ -200,6 +202,10 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
||||
|
||||
<div className="titlebar-spacer" />
|
||||
|
||||
{auth && GlobalSearch &&
|
||||
<GlobalSearch settings={settings} auth={auth} />
|
||||
}
|
||||
|
||||
<LanguageMenu />
|
||||
<HelpMenu auth={auth} />
|
||||
|
||||
|
||||
@@ -406,6 +406,30 @@ export type ViewsRuntimeUiCapability = {
|
||||
Selector?: ComponentType<ViewSelectorProps>;
|
||||
};
|
||||
|
||||
export type SearchContextContribution = {
|
||||
id: string;
|
||||
moduleId: string;
|
||||
label: string;
|
||||
pathPrefixes: string[];
|
||||
resourceTypes?: string[];
|
||||
placeholder?: string;
|
||||
surfaceId?: string;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type SearchContextsUiCapability = {
|
||||
contexts: SearchContextContribution[];
|
||||
};
|
||||
|
||||
export type GlobalSearchProps = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
};
|
||||
|
||||
export type SearchRuntimeUiCapability = {
|
||||
GlobalSearch: ComponentType<GlobalSearchProps>;
|
||||
};
|
||||
|
||||
export type DashboardWidgetSize = "small" | "medium" | "wide" | "full";
|
||||
|
||||
export type DashboardWidgetTone = "neutral" | "good" | "warning" | "danger" | "info";
|
||||
|
||||
Reference in New Issue
Block a user