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.auth import ApiPrincipal from govoplan_core.core.events import PlatformEvent from govoplan_core.core.modules import ModuleContext from govoplan_core.core.search import ( SearchAuthorizationRequest, SearchBackfillPage, SearchBackfillRequest, SearchDocument, SearchIndexChange, SearchResourceReference, SearchResourceType, ) from govoplan_campaign.backend.capabilities import CampaignAccessService from govoplan_campaign.backend.db.models import Campaign, CampaignShare PROVIDER_ID = "campaigns.campaigns" RESOURCE_TYPE = "campaign" READ_SCOPE = "campaigns:campaign:read" class CampaignSearchSource: def resource_types(self) -> Sequence[SearchResourceType]: return ( SearchResourceType( provider_id=PROVIDER_ID, module_id="campaigns", resource_type=RESOURCE_TYPE, label="Campaigns", requires_authorization_recheck=True, ), ) def backfill( self, session: object, *, request: SearchBackfillRequest, ) -> SearchBackfillPage: _assert_source(request.provider_id, request.resource_type) db = _session(session) statement = select(Campaign).where( Campaign.tenant_id == request.tenant_id, Campaign.status != "deleted", ) if request.cursor: statement = statement.where(Campaign.id > request.cursor) rows = list( db.scalars( statement.order_by(Campaign.id).limit(request.limit + 1) ) ) has_more = len(rows) > request.limit selected = rows[: request.limit] shares = _shares_by_campaign(db, selected) high_watermark = db.scalar( select(func.max(Campaign.updated_at)).where( Campaign.tenant_id == request.tenant_id, Campaign.status != "deleted", ) ) return SearchBackfillPage( documents=tuple( _document(row, shares=shares.get(row.id, ())) for row in selected ), next_cursor=selected[-1].id if has_more and selected else None, complete=not has_more, high_watermark=( high_watermark.isoformat() if high_watermark is not None else None ), ) def authorize( self, session: object, principal: object, *, requests: Sequence[SearchAuthorizationRequest], ) -> Mapping[str, bool]: decisions = {item.reference.key: False for item in requests} if not isinstance(principal, ApiPrincipal) or not principal.has(READ_SCOPE): return decisions db = _session(session) access = CampaignAccessService() user_id = str(getattr(principal.user, "id", "") or principal.membership_id or "") for request in requests: reference = request.reference if ( reference.tenant_id != principal.tenant_id or reference.module_id != "campaigns" or reference.resource_type != RESOURCE_TYPE ): continue decisions[reference.key] = access.can_read_campaign( db, tenant_id=principal.tenant_id, campaign_id=reference.resource_id, user_id=user_id, group_ids=principal.group_ids, tenant_admin=principal.has("tenant:*"), ) return decisions def index_changes_for_event( self, session: object, *, event: PlatformEvent, delivery_key: str, ) -> Sequence[SearchIndexChange]: if ( event.module_id != "campaigns" or event.tenant is None or event.resource is None or event.resource.type != RESOURCE_TYPE or event.resource.id is None ): return () db = _session(session) row = db.get(Campaign, event.resource.id) deleted = row is None or row.tenant_id != event.tenant.id or row.status == "deleted" cursor = event.event_id document = None if not deleted: document = _document( row, shares=tuple( db.scalars( select(CampaignShare).where( CampaignShare.campaign_id == row.id, CampaignShare.revoked_at.is_(None), ) ) ), change_cursor=cursor, ) reference = SearchResourceReference( tenant_id=event.tenant.id, module_id="campaigns", resource_type=RESOURCE_TYPE, resource_id=event.resource.id, ) return ( SearchIndexChange( change_id=f"{delivery_key}:{PROVIDER_ID}", provider_id=PROVIDER_ID, kind="delete" if deleted else "upsert", reference=reference, source_revision=( document.source_revision if document is not None else cursor ), cursor=cursor, document=document, occurred_at=event.occurred_at, ), ) def create_campaign_search_source(_context: ModuleContext) -> CampaignSearchSource: return CampaignSearchSource() def _document( row: Campaign, *, shares: Sequence[CampaignShare], change_cursor: str | None = None, ) -> SearchDocument: tokens = [f"scope:{READ_SCOPE}"] if row.owner_user_id: tokens.append(f"membership:{row.owner_user_id}") if row.owner_group_id: tokens.append(f"group:{row.owner_group_id}") for share in shares: prefix = "membership" if share.target_type == "user" else share.target_type if prefix in {"membership", "group"}: tokens.append(f"{prefix}:{share.target_id}") updated_at = row.updated_at or row.created_at return SearchDocument( tenant_id=row.tenant_id, module_id="campaigns", provider_id=PROVIDER_ID, resource_type=RESOURCE_TYPE, resource_id=row.id, title=row.name, url=f"/campaigns/{quote(row.id, safe='')}", summary=row.description[:4000] if row.description else None, body=" ".join( value for value in (row.external_id, row.description) if value )[:200_000], keywords=(row.external_id[:200], row.status[:200]), visibility="restricted", acl_tokens=tuple(dict.fromkeys(tokens)), metadata={ "external_id": row.external_id, "status": row.status, "current_version_id": row.current_version_id, }, source_revision=f"{row.current_version_id or 'none'}:{updated_at.isoformat()}", change_cursor=change_cursor, source_updated_at=updated_at, requires_authorization_recheck=True, ) def _shares_by_campaign( session: Session, rows: Sequence[Campaign], ) -> dict[str, tuple[CampaignShare, ...]]: ids = [row.id for row in rows] grouped: dict[str, list[CampaignShare]] = {item: [] for item in ids} if not ids: return {} for share in session.scalars( select(CampaignShare).where( CampaignShare.campaign_id.in_(ids), CampaignShare.revoked_at.is_(None), ) ): grouped.setdefault(share.campaign_id, []).append(share) return {key: tuple(value) for key, value in grouped.items()} def _assert_source(provider_id: str, resource_type: str) -> None: if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE: raise ValueError("Unsupported Campaign search source.") def _session(value: object) -> Session: if not isinstance(value, Session): raise TypeError("Campaign search requires a SQLAlchemy session.") return value __all__ = [ "CampaignSearchSource", "PROVIDER_ID", "RESOURCE_TYPE", "create_campaign_search_source", ]