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", ]