from __future__ import annotations from collections.abc import Mapping, Sequence from datetime import datetime, timezone from sqlalchemy import func, or_ from sqlalchemy.orm import Session from govoplan_core.core.dsar import ( DsarErasureActionRef, DsarExecutionResultRef, DsarRecordRef, DsarSubjectRef, dsar_capability_name, ) from govoplan_scheduling.backend.db.models import ( SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest, ) SCHEDULING_DSAR_CAPABILITY = dsar_capability_name("scheduling") _MAX_RECORDS = 5_000 _TERMINAL_REQUEST_STATUSES = frozenset( {"decided", "handed_off", "cancelled", "archived"} ) _TERMINAL_NOTIFICATION_STATUSES = frozenset({"sent", "failed", "skipped", "cancelled"}) class SchedulingDsarProvider: provider_id = "scheduling" module_id = "scheduling" def search_subject( self, session: object, *, tenant_id: str, subject: DsarSubjectRef, ) -> Sequence[DsarRecordRef]: db = _session(session) references = _scheduling_references(subject) membership_ids = _membership_ids(subject) respondent_ids = _respondent_ids(subject) account_ids = _account_ids(subject) email = _subject_email(subject) if not any( ( references, membership_ids, respondent_ids, account_ids, email, ) ): return () participants = _matching_participants( db, tenant_id=tenant_id, participant_id=references.get("participant"), respondent_ids=respondent_ids, account_ids=account_ids, email=email, ) participant_ids = {row.id for row in participants} request_ids = {row.request_id for row in participants} if references.get("request"): request_ids.add(references["request"]) requests = _matching_requests( db, tenant_id=tenant_id, request_ids=request_ids, organizer_ids=membership_ids, ) requests_by_id = {row.id: row for row in requests} request_ids = set(requests_by_id) notifications = _matching_notifications( db, tenant_id=tenant_id, request_ids=request_ids, participant_ids=participant_ids, respondent_ids=respondent_ids, email=email, ) notifications_by_participant: dict[str, list[SchedulingNotification]] = {} for notification in notifications: if notification.participant_id: notifications_by_participant.setdefault( notification.participant_id, [] ).append(notification) records: list[DsarRecordRef] = [] def append(record: DsarRecordRef) -> None: if len(records) >= _MAX_RECORDS: raise ValueError( "Scheduling DSAR match limit exceeded; narrow the subject selectors." ) records.append(record) for request in requests: participant_context = request.id in {row.request_id for row in participants} organizer_context = bool( request.organizer_user_id and request.organizer_user_id in membership_ids ) immutable = _request_is_evidence(request) append( _record( "scheduling_request", request.id, "scheduling_request", request.title, { "match_fields": ( ["organizer_user_id"] if organizer_context else [] ), "participant_context": participant_context, "title": request.title, "description": request.description, "location": request.location, "timezone": request.timezone, "status": request.status, "deadline_at": _iso(request.deadline_at), "allow_external_participants": request.allow_external_participants, "allow_participant_updates": request.allow_participant_updates, "result_visibility": request.result_visibility, "participant_visibility": request.participant_visibility, "notify_on_answers": request.notify_on_answers, "single_choice": request.single_choice, "max_participants_per_option": request.max_participants_per_option, "allow_maybe": request.allow_maybe, "allow_comments": request.allow_comments, "participant_email_required": request.participant_email_required, "calendar_integration_enabled": request.calendar_integration_enabled, "calendar_freebusy_enabled": request.calendar_freebusy_enabled, "calendar_hold_enabled": request.calendar_hold_enabled, "create_calendar_event_on_decision": request.create_calendar_event_on_decision, "handed_off_at": _iso(request.handed_off_at), "cancelled_at": _iso(request.cancelled_at), "deleted_at": _iso(request.deleted_at), }, observed_at=request.updated_at, immutable=immutable, retention_reason=( "Decided, handed-off, cancelled, archived, or deleted scheduling state is institutional decision and coordination evidence." if immutable else None ), source_path=f"/scheduling?request={request.id}", ) ) for slot in _candidate_slots( db, tenant_id=tenant_id, request_ids=request_ids, ): request = requests_by_id[slot.request_id] immutable = _request_is_evidence(request) or slot.deleted_at is not None append( _record( "scheduling_candidate_slot", slot.id, "scheduling_candidate_context", slot.label, { "request_id": slot.request_id, "label": slot.label, "description": slot.description, "start_at": _iso(slot.start_at), "end_at": _iso(slot.end_at), "timezone": slot.timezone, "location": slot.location, "position": slot.position, "freebusy_checked_at": _iso(slot.freebusy_checked_at), "freebusy_status": slot.freebusy_status, "deleted_at": _iso(slot.deleted_at), }, observed_at=slot.updated_at, immutable=immutable, retention_reason=( "Candidate timing retained with terminal Scheduling decision evidence." if immutable else None ), source_path=f"/scheduling?request={slot.request_id}", ) ) for participant in participants: request = requests_by_id.get(participant.request_id) if request is None: continue matching_fields = _participant_matching_fields( participant, participant_id=references.get("participant"), respondent_ids=respondent_ids, account_ids=account_ids, email=email, ) immutable = _request_is_evidence(request) or _participant_is_evidence( participant, notifications_by_participant.get(participant.id, ()), ) erasable_without_coordination = not immutable and _participant_is_unengaged( participant, notifications_by_participant.get(participant.id, ()), ) append( _record( "scheduling_participant", participant.id, "scheduling_participation", participant.display_name or "Scheduling participant", { "match_fields": matching_fields, "request_id": participant.request_id, "display_name": participant.display_name, "email": _normalized_email(participant.email), "participant_type": participant.participant_type, "required": participant.required, "status": participant.status, "account_bound_at": _iso(participant.account_bound_at), "last_invited_at": _iso(participant.last_invited_at), "responded_at": _iso(participant.responded_at), "response_comment": _bounded_text(participant.response_comment), "deleted_at": _iso(participant.deleted_at), "erasable_without_coordination": erasable_without_coordination, }, observed_at=participant.updated_at, immutable=immutable, retention_reason=( "Responded, notified, removed, or terminal participant state is retained with Poll and Scheduling decision evidence." if immutable else None ), source_path=f"/scheduling?request={participant.request_id}", ) ) for notification in notifications: immutable = bool( notification.sent_at or notification.status in _TERMINAL_NOTIFICATION_STATUSES ) append( _record( "scheduling_notification", notification.id, "scheduling_notification_evidence", f"Scheduling {notification.event_kind} notification", { "match_fields": _notification_matching_fields( notification, participant_ids=participant_ids, respondent_ids=respondent_ids, email=email, ), "request_id": notification.request_id, "participant_id": ( notification.participant_id if notification.participant_id in participant_ids else None ), "event_kind": notification.event_kind, "channel": notification.channel, "recipient": _matching_recipient( notification.recipient, respondent_ids=respondent_ids, email=email, ), "status": notification.status, "sent_at": _iso(notification.sent_at), }, observed_at=notification.updated_at, immutable=immutable, retention_reason=( "Terminal Scheduling notification state is delivery and participant-access evidence." if immutable else None ), ) ) return tuple(records) def plan_erasure( self, session: object, *, tenant_id: str, subject: DsarSubjectRef, records: Sequence[DsarRecordRef], ) -> Sequence[DsarErasureActionRef]: del session actions: list[DsarErasureActionRef] = [] for record in records: if ( record.provider_id != self.provider_id or record.module_id != self.module_id ): raise ValueError("Scheduling DSAR received a foreign provider record.") actions.append( _action( f"scheduling:{'retain' if record.immutable_evidence else 'review'}:{record.resource_type}:{record.resource_id}", "retain" if record.immutable_evidence else "manual_review", record, f"{'Retain' if record.immutable_evidence else 'Review'} {record.title}", record.retention_reason or "Scheduling data can overlap Poll responses, Calendar effects, shared participants, and institutional decisions; review it through the owning lifecycle controls.", executable=False, ) ) if ( record.resource_type == "scheduling_participant" and record.data.get("erasable_without_coordination") is True and record.data.get("match_fields") ): actions.append( _action( f"scheduling:anonymize:scheduling_participant:{record.resource_id}", "anonymize", record, "Anonymize unengaged Scheduling participant", "A participant without invitation, response, self-enrollment, notification, or terminal decision evidence can be removed without changing Poll or Calendar state.", executable=True, irreversible=True, metadata={"tenant_id": tenant_id}, ) ) action_ids = [action.action_id for action in actions] if len(action_ids) != len(set(action_ids)): raise ValueError("Scheduling DSAR produced duplicate action ids.") return tuple(actions) def execute_erasure( self, session: object, *, tenant_id: str, subject: DsarSubjectRef, actions: Sequence[DsarErasureActionRef], request_id: str, ) -> Sequence[DsarExecutionResultRef]: db = _session(session) results: list[DsarExecutionResultRef] = [] for action in actions: if ( action.provider_id != self.provider_id or action.module_id != self.module_id or action.metadata.get("tenant_id") != tenant_id or not action.action_id.startswith( "scheduling:anonymize:scheduling_participant:" ) ): results.append( _blocked(action, "The Scheduling DSAR action is stale or invalid.") ) continue results.append( _anonymize_unengaged_participant( db, tenant_id=tenant_id, subject=subject, action=action, request_id=request_id, ) ) db.flush() return tuple(results) def _matching_participants( session: Session, *, tenant_id: str, participant_id: str | None, respondent_ids: set[str], account_ids: set[str], email: str | None, ) -> list[SchedulingParticipant]: conditions = [] if participant_id: conditions.append(SchedulingParticipant.id == participant_id) if respondent_ids: conditions.append(SchedulingParticipant.respondent_id.in_(respondent_ids)) if account_ids: conditions.append(SchedulingParticipant.bound_account_id.in_(account_ids)) if email: conditions.append(func.lower(SchedulingParticipant.email) == email) if not conditions: return [] candidates = _bounded_rows( session.query(SchedulingParticipant) .filter(SchedulingParticipant.tenant_id == tenant_id, or_(*conditions)) .order_by(SchedulingParticipant.id) ) return [ row for row in candidates if _participant_matching_fields( row, participant_id=participant_id, respondent_ids=respondent_ids, account_ids=account_ids, email=email, ) ] def _matching_requests( session: Session, *, tenant_id: str, request_ids: set[str], organizer_ids: set[str], ) -> list[SchedulingRequest]: conditions = [] if request_ids: conditions.append(SchedulingRequest.id.in_(request_ids)) if organizer_ids: conditions.append(SchedulingRequest.organizer_user_id.in_(organizer_ids)) if not conditions: return [] return _bounded_rows( session.query(SchedulingRequest) .filter(SchedulingRequest.tenant_id == tenant_id, or_(*conditions)) .order_by(SchedulingRequest.id) ) def _candidate_slots( session: Session, *, tenant_id: str, request_ids: set[str], ) -> list[SchedulingCandidateSlot]: if not request_ids: return [] return _bounded_rows( session.query(SchedulingCandidateSlot) .filter( SchedulingCandidateSlot.tenant_id == tenant_id, SchedulingCandidateSlot.request_id.in_(request_ids), ) .order_by(SchedulingCandidateSlot.request_id, SchedulingCandidateSlot.position) ) def _matching_notifications( session: Session, *, tenant_id: str, request_ids: set[str], participant_ids: set[str], respondent_ids: set[str], email: str | None, ) -> list[SchedulingNotification]: if not request_ids: return [] conditions = [] if participant_ids: conditions.append(SchedulingNotification.participant_id.in_(participant_ids)) if respondent_ids: conditions.append(SchedulingNotification.recipient.in_(respondent_ids)) if email: conditions.append(func.lower(SchedulingNotification.recipient) == email) if not conditions: return [] candidates = _bounded_rows( session.query(SchedulingNotification) .filter( SchedulingNotification.tenant_id == tenant_id, SchedulingNotification.request_id.in_(request_ids), or_(*conditions), ) .order_by(SchedulingNotification.id) ) return [ row for row in candidates if _notification_matching_fields( row, participant_ids=participant_ids, respondent_ids=respondent_ids, email=email, ) ] def _participant_matching_fields( row: SchedulingParticipant, *, participant_id: str | None, respondent_ids: set[str], account_ids: set[str], email: str | None, ) -> list[str]: fields = [] if participant_id and row.id == participant_id: fields.append("id") if row.respondent_id and row.respondent_id in respondent_ids: fields.append("respondent_id") if row.bound_account_id and row.bound_account_id in account_ids: fields.append("bound_account_id") if email and _normalized_email(row.email) == email: fields.append("email") return fields def _notification_matching_fields( row: SchedulingNotification, *, participant_ids: set[str], respondent_ids: set[str], email: str | None, ) -> list[str]: fields = [] if row.participant_id and row.participant_id in participant_ids: fields.append("participant_id") recipient = str(row.recipient or "").strip() if recipient in respondent_ids: fields.append("recipient") if email and _normalized_email(recipient) == email: fields.append("recipient") return list(dict.fromkeys(fields)) def _matching_recipient( value: object, *, respondent_ids: set[str], email: str | None, ) -> str | None: candidate = str(value or "").strip() if candidate in respondent_ids: return candidate if email and _normalized_email(candidate) == email: return email return None def _request_is_evidence(row: SchedulingRequest) -> bool: return bool( row.status in _TERMINAL_REQUEST_STATUSES or row.handed_off_at or row.cancelled_at or row.deleted_at ) def _participant_is_evidence( row: SchedulingParticipant, notifications: Sequence[SchedulingNotification], ) -> bool: return bool( row.poll_invitation_id or row.responded_at or row.response_comment or row.self_enrollment_link_id or row.self_enrollment_proof_hash or row.status in {"responded", "removed"} or notifications ) def _participant_is_unengaged( row: SchedulingParticipant, notifications: Sequence[SchedulingNotification], ) -> bool: return bool( row.deleted_at is None and row.status in {"draft", "invited"} and row.poll_invitation_id is None and row.responded_at is None and not row.response_comment and row.self_enrollment_link_id is None and row.self_enrollment_proof_hash is None and not notifications ) def _anonymize_unengaged_participant( session: Session, *, tenant_id: str, subject: DsarSubjectRef, action: DsarErasureActionRef, request_id: str, ) -> DsarExecutionResultRef: row = ( session.query(SchedulingParticipant) .filter( SchedulingParticipant.id == action.resource_id, SchedulingParticipant.tenant_id == tenant_id, ) .with_for_update() .one_or_none() ) if row is None: return _result( action, "unchanged", "The Scheduling participant was already absent.", {"request_id": request_id}, ) if _participant_is_anonymized(row): return _result( action, "unchanged", "The Scheduling participant was already anonymized.", {"request_id": request_id}, ) if not _participant_matches_subject(row, subject): return _blocked( action, "The Scheduling participant identity changed after planning." ) request = ( session.query(SchedulingRequest) .filter( SchedulingRequest.id == row.request_id, SchedulingRequest.tenant_id == tenant_id, ) .with_for_update() .one_or_none() ) if request is None or _request_is_evidence(request): return _blocked( action, "The Scheduling request is absent or became retained decision evidence.", ) notifications = _participant_notifications(session, row) if not _participant_is_unengaged(row, notifications): return _blocked( action, "The participant gained invitation, response, enrollment, or notification evidence after planning.", ) now = datetime.now(timezone.utc) row.respondent_id = None row.display_name = None row.email = None row.status = "removed" row.poll_invitation_id = None row.participation_gateway = None row.self_enrollment_link_id = None row.self_enrollment_proof_hash = None row.bound_account_id = None row.account_bound_at = None row.last_invited_at = None row.responded_at = None row.response_comment = None row.deleted_at = now row.metadata_ = None return _result( action, "executed", "The unengaged Scheduling participant was anonymized and retired.", {"request_id": request_id}, ) def _participant_notifications( session: Session, row: SchedulingParticipant, ) -> list[SchedulingNotification]: conditions = [SchedulingNotification.participant_id == row.id] email = _normalized_email(row.email) if email: conditions.append(func.lower(SchedulingNotification.recipient) == email) return _bounded_rows( session.query(SchedulingNotification) .filter( SchedulingNotification.tenant_id == row.tenant_id, SchedulingNotification.request_id == row.request_id, or_(*conditions), ) .order_by(SchedulingNotification.id) ) def _participant_matches_subject( row: SchedulingParticipant, subject: DsarSubjectRef, ) -> bool: return bool( _participant_matching_fields( row, participant_id=_scheduling_references(subject).get("participant"), respondent_ids=_respondent_ids(subject), account_ids=_account_ids(subject), email=_subject_email(subject), ) ) def _participant_is_anonymized(row: SchedulingParticipant) -> bool: return bool( row.deleted_at is not None and row.status == "removed" and row.respondent_id is None and row.display_name is None and row.email is None and row.poll_invitation_id is None and row.self_enrollment_link_id is None and row.self_enrollment_proof_hash is None and row.bound_account_id is None and row.response_comment is None and row.metadata_ is None ) def _scheduling_references(subject: DsarSubjectRef) -> dict[str, str]: aliases = { "scheduling.request": "request", "scheduling.request_id": "request", "scheduling.participant": "participant", "scheduling.participant_id": "participant", } references: dict[str, str] = {} for key, target in aliases.items(): value = str(subject.external_references.get(key) or "").strip() if value and target not in references: references[target] = value return references def _membership_ids(subject: DsarSubjectRef) -> set[str]: values = [subject.membership_id] values.extend( subject.external_references.get(key) for key in ( "scheduling.user", "scheduling.membership", "access.membership", "membership_id", ) ) return _identifiers(values) def _respondent_ids(subject: DsarSubjectRef) -> set[str]: values = [subject.membership_id, subject.identity_id, subject.account_id] values.extend( subject.external_references.get(key) for key in ( "scheduling.respondent", "poll.respondent", "access.membership", "membership_id", "identity_id", "account_id", ) ) return _identifiers(values) def _account_ids(subject: DsarSubjectRef) -> set[str]: values = [subject.account_id] values.extend( subject.external_references.get(key) for key in ("scheduling.account", "access.account", "account_id") ) return _identifiers(values) def _identifiers(values: Sequence[object]) -> set[str]: return {text for value in values if (text := str(value or "").strip())} def _subject_email(subject: DsarSubjectRef) -> str | None: candidates = [subject.email] candidates.extend( subject.external_references.get(key) for key in ("scheduling.email", "scheduling.participant_email") ) normalized = { email for value in candidates if (email := _normalized_email(value)) is not None } return normalized.pop() if len(normalized) == 1 else None def _normalized_email(value: object) -> str | None: if not isinstance(value, str): return None normalized = value.strip().casefold() return normalized or None def _bounded_text(value: str | None) -> str | None: return value[:2_000] if value else None def _record( resource_type: str, resource_id: str, category: str, title: str, data: Mapping[str, object], *, observed_at: datetime | None = None, immutable: bool = False, retention_reason: str | None = None, source_path: str | None = None, ) -> DsarRecordRef: return DsarRecordRef( provider_id="scheduling", module_id="scheduling", resource_type=resource_type, resource_id=resource_id, category=category, title=title, data=data, observed_at=observed_at, immutable_evidence=immutable, retention_reason=retention_reason, source_path=source_path, ) def _action( action_id: str, kind: str, record: DsarRecordRef, title: str, rationale: str, *, executable: bool, irreversible: bool = False, metadata: Mapping[str, object] | None = None, ) -> DsarErasureActionRef: return DsarErasureActionRef( action_id=action_id, provider_id="scheduling", module_id="scheduling", kind=kind, # type: ignore[arg-type] resource_type=record.resource_type, resource_id=record.resource_id, title=title, rationale=rationale, executable=executable, irreversible=irreversible, metadata=metadata or {}, ) def _result( action: DsarErasureActionRef, status: str, summary: str, evidence: Mapping[str, object] | None = None, ) -> DsarExecutionResultRef: return DsarExecutionResultRef( action_id=action.action_id, status=status, # type: ignore[arg-type] summary=summary, evidence=evidence or {}, ) def _blocked(action: DsarErasureActionRef, summary: str) -> DsarExecutionResultRef: return _result(action, "blocked", summary) def _session(value: object) -> Session: if not isinstance(value, Session): raise TypeError("Scheduling DSAR provider requires a SQLAlchemy session.") return value def _bounded_rows(query: object) -> list[object]: rows = query.limit(_MAX_RECORDS + 1).all() # type: ignore[attr-defined] if len(rows) > _MAX_RECORDS: raise ValueError( "Scheduling DSAR match limit exceeded; narrow the subject selectors." ) return rows def _iso(value: datetime | None) -> str | None: if value is None: return None if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc) return value.isoformat() __all__ = ["SCHEDULING_DSAR_CAPABILITY", "SchedulingDsarProvider"]