Module Package Release / publish-packages (push) Successful in 17s
Release v0.1.27. Coordinated integrity review: GovOPlaN/govoplan-core#298.
316 lines
12 KiB
Python
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",
|
|
]
|