Release Connectors v0.1.22 with service-desk federation
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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 _principal_acl_tokens(principal: object) -> tuple[str, ...]:
|
||||
values: list[str] = []
|
||||
for prefix, attribute in (
|
||||
("account", "account_id"),
|
||||
("membership", "membership_id"),
|
||||
("identity", "identity_id"),
|
||||
):
|
||||
value = getattr(principal, attribute, None)
|
||||
if value:
|
||||
values.append(f"{prefix}:{value}")
|
||||
for prefix, attribute in (
|
||||
("group", "group_ids"),
|
||||
("role", "role_ids"),
|
||||
("function", "function_assignment_ids"),
|
||||
("scope", "scopes"),
|
||||
):
|
||||
values.extend(
|
||||
f"{prefix}:{value}"
|
||||
for value in getattr(principal, attribute, ())
|
||||
if value
|
||||
)
|
||||
return tuple(dict.fromkeys(values))[:500]
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
Reference in New Issue
Block a user