Files
govoplan-connectors/src/govoplan_connectors/backend/service_desk_search.py
T
zemion 889cafaf20
Module Package Release / publish-packages (push) Successful in 17s
fix(connectors): retain exact CSV source evidence
Release v0.1.27. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:19:38 +02:00

316 lines
12 KiB
Python

from __future__ import annotations
from govoplan_connectors.backend.search_principal import principal_acl_tokens as _principal_acl_tokens
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.core.external_references import ExternalObjectReference
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchResourceType,
)
from govoplan_connectors.backend.db.models import (
ConnectorConfiguration,
ConnectorServiceDeskObject,
ConnectorServiceDeskProfile,
)
from govoplan_connectors.backend.service_desk_connector import (
SERVICE_DESK_PROVIDER_ID,
SERVICE_DESK_READ_SCOPE,
SERVICE_DESK_RESOURCE_TYPE,
)
_SEARCH_MATURITIES = ("search", "read", "publish", "synchronize", "migrate", "replace")
class ExternalServiceDeskSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=SERVICE_DESK_PROVIDER_ID,
module_id="connectors",
resource_type=SERVICE_DESK_RESOURCE_TYPE,
label="External service-desk tickets",
requires_authorization_recheck=True,
),
)
def backfill(
self, session: object, *, request: SearchBackfillRequest
) -> SearchBackfillPage:
_assert_source(request.provider_id, request.resource_type)
db = _session(session)
query = (
select(ConnectorServiceDeskObject, ConnectorServiceDeskProfile)
.join(
ConnectorServiceDeskProfile,
ConnectorServiceDeskProfile.id == ConnectorServiceDeskObject.profile_id,
)
.join(
ConnectorConfiguration,
ConnectorConfiguration.id
== ConnectorServiceDeskProfile.configuration_id,
)
.where(
ConnectorServiceDeskObject.tenant_id == request.tenant_id,
ConnectorServiceDeskObject.object_type == "ticket",
ConnectorServiceDeskObject.status != "deleted",
ConnectorServiceDeskProfile.tenant_id == request.tenant_id,
ConnectorServiceDeskProfile.status == "active",
ConnectorConfiguration.tenant_id == request.tenant_id,
ConnectorConfiguration.status == "active",
ConnectorServiceDeskProfile.discovered_configuration_revision
== ConnectorConfiguration.resource_revision,
ConnectorServiceDeskProfile.discovered_configuration_hash
== ConnectorConfiguration.effective_hash,
ConnectorServiceDeskProfile.desired_maturity.in_(_SEARCH_MATURITIES),
ConnectorServiceDeskProfile.discovered_maturity.in_(_SEARCH_MATURITIES),
)
)
if request.cursor:
query = query.where(ConnectorServiceDeskObject.id > request.cursor)
rows = tuple(
db.execute(
query.order_by(ConnectorServiceDeskObject.id.asc()).limit(request.limit + 1)
)
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
watermark = db.scalar(
select(func.max(ConnectorServiceDeskObject.updated_at))
.join(
ConnectorServiceDeskProfile,
ConnectorServiceDeskProfile.id == ConnectorServiceDeskObject.profile_id,
)
.join(
ConnectorConfiguration,
ConnectorConfiguration.id
== ConnectorServiceDeskProfile.configuration_id,
)
.where(
ConnectorServiceDeskObject.tenant_id == request.tenant_id,
ConnectorServiceDeskObject.object_type == "ticket",
ConnectorServiceDeskObject.status != "deleted",
ConnectorServiceDeskProfile.tenant_id == request.tenant_id,
ConnectorServiceDeskProfile.status == "active",
ConnectorConfiguration.tenant_id == request.tenant_id,
ConnectorConfiguration.status == "active",
ConnectorServiceDeskProfile.discovered_configuration_revision
== ConnectorConfiguration.resource_revision,
ConnectorServiceDeskProfile.discovered_configuration_hash
== ConnectorConfiguration.effective_hash,
ConnectorServiceDeskProfile.desired_maturity.in_(_SEARCH_MATURITIES),
ConnectorServiceDeskProfile.discovered_maturity.in_(_SEARCH_MATURITIES),
)
)
return SearchBackfillPage(
documents=tuple(search_document(db, row, profile) for row, profile in selected),
next_cursor=selected[-1][0].id if has_more and selected else None,
complete=not has_more,
high_watermark=watermark.isoformat() if watermark else None,
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
db = _session(session)
tenant_id = str(getattr(principal, "tenant_id", "") or "")
can_read = _has_scope(principal, SERVICE_DESK_READ_SCOPE)
tokens = set(_principal_acl_tokens(principal))
decisions = {item.reference.key: False for item in requests}
if not tenant_id or not can_read:
return decisions
for request in requests:
reference = request.reference
if (
reference.tenant_id != tenant_id
or reference.module_id != "connectors"
or reference.resource_type != SERVICE_DESK_RESOURCE_TYPE
):
continue
joined = db.execute(
select(ConnectorServiceDeskObject, ConnectorServiceDeskProfile)
.join(
ConnectorServiceDeskProfile,
ConnectorServiceDeskProfile.id
== ConnectorServiceDeskObject.profile_id,
)
.join(
ConnectorConfiguration,
ConnectorConfiguration.id
== ConnectorServiceDeskProfile.configuration_id,
)
.where(
ConnectorServiceDeskObject.tenant_id == tenant_id,
ConnectorServiceDeskObject.id == reference.resource_id,
ConnectorServiceDeskObject.object_type == "ticket",
ConnectorServiceDeskObject.status != "deleted",
ConnectorServiceDeskProfile.tenant_id == tenant_id,
ConnectorServiceDeskProfile.status == "active",
ConnectorConfiguration.tenant_id == tenant_id,
ConnectorConfiguration.status == "active",
ConnectorServiceDeskProfile.discovered_configuration_revision
== ConnectorConfiguration.resource_revision,
ConnectorServiceDeskProfile.discovered_configuration_hash
== ConnectorConfiguration.effective_hash,
ConnectorServiceDeskProfile.desired_maturity.in_(_SEARCH_MATURITIES),
ConnectorServiceDeskProfile.discovered_maturity.in_(_SEARCH_MATURITIES),
)
).first()
if joined is None:
continue
row, _profile = joined
decisions[reference.key] = row.visibility == "tenant" or bool(
tokens.intersection(str(value) for value in row.acl_tokens or ())
)
return decisions
def create_external_service_desk_search_source(
_context: ModuleContext,
) -> ExternalServiceDeskSearchSource:
return ExternalServiceDeskSearchSource()
def search_document(
session: Session,
row: ConnectorServiceDeskObject,
profile: ConnectorServiceDeskProfile | None = None,
) -> SearchDocument:
if profile is None:
profile = session.scalar(
select(ConnectorServiceDeskProfile).where(
ConnectorServiceDeskProfile.tenant_id == row.tenant_id,
ConnectorServiceDeskProfile.id == row.profile_id,
)
)
if profile is None:
raise ValueError("External service-desk profile is unavailable.")
data = dict(row.mapped_data or {})
queue = data.get("queue") if isinstance(data.get("queue"), Mapping) else {}
state = data.get("state") if isinstance(data.get("state"), Mapping) else {}
priority = data.get("priority") if isinstance(data.get("priority"), Mapping) else {}
articles = [value for value in data.get("articles") or () if isinstance(value, Mapping)]
article_subjects = tuple(
str(value.get("subject") or "")[:500]
for value in articles
if value.get("subject")
)
article_body = "\n\n".join(
str(value.get("body") or "") for value in articles if value.get("body")
)[:200_000]
dynamic_fields = data.get("dynamic_fields")
dynamic_keywords = tuple(
f"{key}:{str(value)[:200]}"
for key, value in (
dynamic_fields.items() if isinstance(dynamic_fields, Mapping) else ()
)
)
external_reference = ExternalObjectReference(
system=profile.product if profile.product != "unknown" else "znuny_otrs",
object_type=row.object_type,
object_id=row.external_id,
maturity=profile.discovered_maturity,
authority_mode=profile.source_authority_mode,
connector_id=profile.id,
canonical_url=row.canonical_url,
version=row.source_revision,
etag=row.content_hash,
observed_at=row.observed_at,
metadata={
"ticket_number": row.external_ticket_number,
"title": row.title,
"queue": queue.get("name"),
"state": state.get("name"),
},
)
return SearchDocument(
tenant_id=row.tenant_id,
module_id="connectors",
provider_id=SERVICE_DESK_PROVIDER_ID,
resource_type=SERVICE_DESK_RESOURCE_TYPE,
resource_id=row.id,
title=(
f"{row.external_ticket_number}: {row.title}"
if row.external_ticket_number
else row.title
),
url=(
"/connectors/service-desk?profileId="
f"{quote(row.profile_id, safe='')}&objectId={quote(row.id, safe='')}"
),
summary=(article_subjects[0] if article_subjects else None),
body=article_body or None,
keywords=tuple(
dict.fromkeys(
value
for value in (
str(queue.get("name") or ""),
str(state.get("name") or ""),
str(priority.get("name") or ""),
*article_subjects,
*dynamic_keywords,
)
if value
)
)[:100],
visibility=row.visibility,
acl_tokens=(
tuple(str(value) for value in row.acl_tokens or ())
if row.visibility == "restricted"
else ()
),
external_reference=external_reference,
metadata={
"profile_id": row.profile_id,
"external_ticket_id": row.external_id,
"external_ticket_number": row.external_ticket_number,
"target_queue_ref": data.get("target_queue_ref"),
"integration_mode": profile.integration_mode,
"source_authority_mode": profile.source_authority_mode,
"status": row.status,
},
source_revision=row.source_revision,
change_cursor=row.change_cursor,
source_updated_at=row.source_updated_at or row.observed_at,
requires_authorization_recheck=True,
)
def _has_scope(principal: object, required: str) -> bool:
check = getattr(principal, "has", None)
if callable(check):
return bool(check(required))
return required in getattr(principal, "scopes", ())
def _assert_source(provider_id: str, resource_type: str) -> None:
if provider_id != SERVICE_DESK_PROVIDER_ID or resource_type != SERVICE_DESK_RESOURCE_TYPE:
raise ValueError("Unsupported external service-desk Search source.")
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("External service-desk Search requires a SQLAlchemy session.")
return value
__all__ = [
"ExternalServiceDeskSearchSource",
"create_external_service_desk_search_source",
"search_document",
]