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:
|
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.
|
location, evidence, and triage.
|
||||||
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
|
2. `govoplan-helpdesk`: service desk tickets, queues, SLAs, assignments,
|
||||||
escalation, and resolution evidence.
|
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
|
strategy index. The executable connector catalogue lives in
|
||||||
`govoplan-connectors/docs/PUBLIC_SECTOR_INTEGRATION_CATALOGUE.md`.
|
`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
|
## Strategy Labels
|
||||||
|
|
||||||
Use one or more of these labels for every external system family:
|
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:
|
if TYPE_CHECKING:
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
from govoplan_core.core.search import SearchProviderRegistration
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||||
@@ -356,6 +357,7 @@ class ModuleManifest:
|
|||||||
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, ...] = ()
|
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
|
||||||
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
||||||
|
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
||||||
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
|
||||||
on_activate: LifecycleHook | None = None
|
on_activate: LifecycleHook | None = None
|
||||||
on_deactivate: LifecycleHook | None = None
|
on_deactivate: LifecycleHook | None = None
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from govoplan_core.core.modules import (
|
|||||||
user_workflow_scope_condition_issues,
|
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.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 (
|
from govoplan_core.core.views import (
|
||||||
ViewSurface,
|
ViewSurface,
|
||||||
module_view_surface_id,
|
module_view_surface_id,
|
||||||
@@ -60,6 +61,8 @@ class PlatformRegistry:
|
|||||||
self._capability_factories: dict[str, CapabilityFactory] = {}
|
self._capability_factories: dict[str, CapabilityFactory] = {}
|
||||||
self._capabilities: dict[str, object] = {}
|
self._capabilities: dict[str, object] = {}
|
||||||
self._capability_context: ModuleContext | None = None
|
self._capability_context: ModuleContext | None = None
|
||||||
|
self._search_provider_registrations: list[RegisteredSearchProvider] = []
|
||||||
|
self._search_providers: dict[str, SearchProvider] = {}
|
||||||
|
|
||||||
def register(self, manifest: ModuleManifest) -> ModuleManifest:
|
def register(self, manifest: ModuleManifest) -> ModuleManifest:
|
||||||
if manifest.id in self._manifests:
|
if manifest.id in self._manifests:
|
||||||
@@ -72,6 +75,13 @@ class PlatformRegistry:
|
|||||||
self.register_delete_veto(manifest.id, resource_type, provider)
|
self.register_delete_veto(manifest.id, resource_type, provider)
|
||||||
for name, factory in manifest.capability_factories.items():
|
for name, factory in manifest.capability_factories.items():
|
||||||
self.register_capability_factory(manifest.id, name, factory)
|
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
|
return manifest
|
||||||
|
|
||||||
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
|
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
|
||||||
@@ -89,7 +99,11 @@ class PlatformRegistry:
|
|||||||
for resource_type, providers in replacement._delete_veto_providers.items()
|
for resource_type, providers in replacement._delete_veto_providers.items()
|
||||||
})
|
})
|
||||||
self._capability_factories = dict(replacement._capability_factories)
|
self._capability_factories = dict(replacement._capability_factories)
|
||||||
|
self._search_provider_registrations = list(
|
||||||
|
replacement._search_provider_registrations
|
||||||
|
)
|
||||||
self._capabilities.clear()
|
self._capabilities.clear()
|
||||||
|
self._search_providers.clear()
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
def get(self, module_id: str) -> ModuleManifest | None:
|
def get(self, module_id: str) -> ModuleManifest | None:
|
||||||
@@ -133,6 +147,7 @@ class PlatformRegistry:
|
|||||||
def configure_capability_context(self, context: ModuleContext) -> None:
|
def configure_capability_context(self, context: ModuleContext) -> None:
|
||||||
self._capability_context = context
|
self._capability_context = context
|
||||||
self._capabilities.clear()
|
self._capabilities.clear()
|
||||||
|
self._search_providers.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:
|
if name in self._capability_factories:
|
||||||
@@ -160,6 +175,37 @@ class PlatformRegistry:
|
|||||||
raise RegistryError(f"Required capability is not available: {name}")
|
raise RegistryError(f"Required capability is not available: {name}")
|
||||||
return capability
|
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:
|
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
|
||||||
self._tenant_summary_providers[module_id] = provider
|
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_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
|
||||||
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
||||||
_validate_interface_requirements(manifest.id, manifest.requires_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:
|
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_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_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")
|
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")
|
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
|
||||||
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
|
||||||
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")
|
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/policy-webui": "file:../../govoplan-policy/webui",
|
||||||
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
||||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||||
|
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
||||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/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": {
|
"../../govoplan-views/webui": {
|
||||||
"name": "@govoplan/views-webui",
|
"name": "@govoplan/views-webui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -1230,6 +1247,10 @@
|
|||||||
"resolved": "../../govoplan-scheduling/webui",
|
"resolved": "../../govoplan-scheduling/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/search-webui": {
|
||||||
|
"resolved": "../../govoplan-search/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@govoplan/views-webui": {
|
"node_modules/@govoplan/views-webui": {
|
||||||
"resolved": "../../govoplan-views/webui",
|
"resolved": "../../govoplan-views/webui",
|
||||||
"link": true
|
"link": true
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||||
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
||||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||||
|
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
||||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
|
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, useState, useEffect } from "react";
|
import { useRef, useState, useEffect } from "react";
|
||||||
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-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 HelpMenu from "./HelpMenu";
|
||||||
import LanguageMenu from "./LanguageMenu";
|
import LanguageMenu from "./LanguageMenu";
|
||||||
import LoginModal from "../features/auth/LoginModal";
|
import LoginModal from "../features/auth/LoginModal";
|
||||||
@@ -37,6 +37,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
const projection = useEffectiveView();
|
const projection = useEffectiveView();
|
||||||
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>("views.runtime");
|
const viewsRuntime = usePlatformUiCapability<ViewsRuntimeUiCapability>("views.runtime");
|
||||||
const ViewSelector = viewsRuntime?.Selector ?? null;
|
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 activeTenant = auth?.active_tenant ?? auth?.tenant ?? null;
|
||||||
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
const tenants = auth?.tenants ?? (activeTenant ? [activeTenant] : []);
|
||||||
@@ -200,6 +202,10 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
|
|||||||
|
|
||||||
<div className="titlebar-spacer" />
|
<div className="titlebar-spacer" />
|
||||||
|
|
||||||
|
{auth && GlobalSearch &&
|
||||||
|
<GlobalSearch settings={settings} auth={auth} />
|
||||||
|
}
|
||||||
|
|
||||||
<LanguageMenu />
|
<LanguageMenu />
|
||||||
<HelpMenu auth={auth} />
|
<HelpMenu auth={auth} />
|
||||||
|
|
||||||
|
|||||||
@@ -406,6 +406,30 @@ export type ViewsRuntimeUiCapability = {
|
|||||||
Selector?: ComponentType<ViewSelectorProps>;
|
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 DashboardWidgetSize = "small" | "medium" | "wide" | "full";
|
||||||
|
|
||||||
export type DashboardWidgetTone = "neutral" | "good" | "warning" | "danger" | "info";
|
export type DashboardWidgetTone = "neutral" | "good" | "warning" | "danger" | "info";
|
||||||
|
|||||||
Reference in New Issue
Block a user