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_calendar.backend.db.models import CalendarCollection, CalendarEvent from govoplan_calendar.backend.service import calendar_is_visible_to_principal PROVIDER_ID = "calendar.events" RESOURCE_TYPE = "calendar_event" READ_SCOPE = "calendar:event:read" ADMIN_SCOPE = "calendar:calendar:admin" class CalendarSearchSource: def resource_types(self) -> Sequence[SearchResourceType]: return ( SearchResourceType( provider_id=PROVIDER_ID, module_id="calendar", resource_type=RESOURCE_TYPE, label="Calendar events", 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(CalendarEvent, CalendarCollection) .join( CalendarCollection, CalendarCollection.id == CalendarEvent.calendar_id, ) .where( CalendarEvent.tenant_id == request.tenant_id, CalendarEvent.deleted_at.is_(None), CalendarCollection.tenant_id == request.tenant_id, CalendarCollection.deleted_at.is_(None), ) ) if request.cursor: statement = statement.where(CalendarEvent.id > request.cursor) rows = list( db.execute( statement.order_by(CalendarEvent.id).limit(request.limit + 1) ).all() ) has_more = len(rows) > request.limit selected = rows[: request.limit] high_watermark = db.scalar( select(func.max(CalendarEvent.updated_at)).where( CalendarEvent.tenant_id == request.tenant_id, CalendarEvent.deleted_at.is_(None), ) ) return SearchBackfillPage( documents=tuple( _document(event, calendar=calendar) for event, calendar in selected ), next_cursor=( selected[-1][0].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 valid = tuple( item for item in requests if item.reference.tenant_id == principal.tenant_id and item.reference.module_id == "calendar" and item.reference.resource_type == RESOURCE_TYPE ) if not valid: return decisions db = _session(session) ids = {item.reference.resource_id for item in valid} events = { event.id: (event, calendar) for event, calendar in db.execute( select(CalendarEvent, CalendarCollection) .join( CalendarCollection, CalendarCollection.id == CalendarEvent.calendar_id, ) .where( CalendarEvent.id.in_(ids), CalendarEvent.tenant_id == principal.tenant_id, CalendarEvent.deleted_at.is_(None), CalendarCollection.tenant_id == principal.tenant_id, CalendarCollection.deleted_at.is_(None), ) ).all() } user_id = str( getattr(principal.user, "id", "") or principal.membership_id or "" ) for item in valid: match = events.get(item.reference.resource_id) if match is None: continue _event, calendar = match decisions[item.reference.key] = calendar_is_visible_to_principal( calendar, user_id=user_id, group_ids=principal.group_ids, can_admin=principal.has(ADMIN_SCOPE), ) return decisions def index_changes_for_event( self, session: object, *, event: PlatformEvent, delivery_key: str, ) -> Sequence[SearchIndexChange]: if ( event.module_id != "calendar" 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(CalendarEvent, event.resource.id) calendar = ( db.get(CalendarCollection, row.calendar_id) if row is not None and row.tenant_id == event.tenant.id else None ) deleted = ( row is None or row.tenant_id != event.tenant.id or row.deleted_at is not None or calendar is None or calendar.deleted_at is not None ) cursor = event.event_id document = ( None if deleted else _document(row, calendar=calendar, change_cursor=cursor) ) reference = SearchResourceReference( tenant_id=event.tenant.id, module_id="calendar", 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_calendar_search_source( _context: ModuleContext, ) -> CalendarSearchSource: return CalendarSearchSource() def _document( event: CalendarEvent, *, calendar: CalendarCollection, change_cursor: str | None = None, ) -> SearchDocument: tokens = [f"scope:{READ_SCOPE}"] if calendar.visibility == "private": if calendar.owner_type == "user" and calendar.owner_id: tokens.append(f"membership:{calendar.owner_id}") elif calendar.owner_type == "group" and calendar.owner_id: tokens.append(f"group:{calendar.owner_id}") elif calendar.created_by_user_id: tokens.append(f"membership:{calendar.created_by_user_id}") updated_at = event.updated_at or event.created_at keywords = tuple( value for value in ( calendar.name, event.location, event.status, *tuple(event.categories or ()), ) if value )[:100] return SearchDocument( tenant_id=event.tenant_id, module_id="calendar", provider_id=PROVIDER_ID, resource_type=RESOURCE_TYPE, resource_id=event.id, title=event.summary, url=f"/calendar?eventId={quote(event.id, safe='')}", summary=(event.description or event.location or "")[:4000] or None, body=" ".join( value for value in (event.description, event.location, calendar.name) if value )[:200_000], keywords=tuple(value[:200] for value in keywords), visibility="restricted", acl_tokens=tuple(dict.fromkeys(tokens)), metadata={ "calendar_id": event.calendar_id, "calendar_name": calendar.name, "start_at": event.start_at.isoformat(), "end_at": event.end_at.isoformat() if event.end_at else None, "all_day": event.all_day, "status": event.status, }, source_revision=f"{event.sequence}:{updated_at.isoformat()}", change_cursor=change_cursor, source_updated_at=updated_at, requires_authorization_recheck=True, ) def _assert_source(provider_id: str, resource_type: str) -> None: if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE: raise ValueError("Unsupported Calendar search source.") def _session(value: object) -> Session: if not isinstance(value, Session): raise TypeError("Calendar search requires a SQLAlchemy session.") return value __all__ = [ "CalendarSearchSource", "PROVIDER_ID", "RESOURCE_TYPE", "create_calendar_search_source", ]