from __future__ import annotations from collections.abc import Mapping, Sequence from datetime import datetime from pydantic import ValidationError from govoplan_core.core.poll import ( PollAnswerRef, PollCapabilityError, PollCreateCommand, PollInvitationCommand, PollInvitationRef, PollOptionOrderCommand, PollOptionRequest, PollOptionRef, PollOptionUpdateCommand, PollRef, PollResponseRef, PollResponseRetirementCommand, PollResponseRetirementRef, PollSchedulingProvider, PollSubmitResponseCommand, PollUpdateCommand, ) from govoplan_poll.backend.participation import ( ANONYMOUS_PASSWORD_REQUIREMENT, PollGovernedInvitationCommand, PollGovernedResponseCommand, PollGovernedResponseRef, PollInvitationExpiryRef, PollInvitationRevocationRef, PollOptionMutationRef, PollParticipationContextRef, PollResponseGatewayRef, ) from govoplan_poll.backend.participation_service import ( governed_invitation, governed_invitation_by_id, participation_policy_payload, participation_policy_ref, response_for_invitation, response_gateway_payload, response_gateway_ref, response_metadata, submit_governed_poll_response, submit_authenticated_poll_response, update_governed_invitation_expiry, ) from govoplan_poll.backend.db.models import PollInvitation from govoplan_poll.backend.schemas import ( PollAnswerInput, PollCreateRequest, PollDecisionRequest, PollInvitationCreateRequest, PollOptionInput, PollSubmitResponseRequest, ) from govoplan_poll.backend.service import ( PollError, PollMutationOwner, close_poll, add_poll_option, create_poll, create_poll_invitation, decide_poll, get_poll, get_poll_response_for_respondents, list_poll_responses, open_poll, poll_mutation_owner, poll_owner_ref, poll_result_summary_by_id, response_datetime, retire_poll_responses, remove_poll_option, reorder_poll_options, revoke_poll_invitation_with_replay, set_poll_workflow_context, submit_poll_response, update_poll_snapshot, update_poll_option, ) def _poll_ref(poll: object) -> PollRef: return PollRef( id=poll.id, status=poll.status, options=tuple( PollOptionRef(id=option.id, position=option.position) for option in sorted( (option for option in poll.options if option.deleted_at is None), key=lambda item: (item.position, item.id), ) ), ) def _command_owner(command: PollCreateCommand) -> PollMutationOwner | None: context = ( command.context_module, command.context_resource_type, command.context_resource_id, ) if not any(value is not None for value in context): return None return poll_owner_ref( module_id=command.context_module, resource_type=command.context_resource_type, resource_id=command.context_resource_id, ) def _stored_owner( session: object, *, tenant_id: str, poll_id: str, ) -> PollMutationOwner | None: return poll_mutation_owner( get_poll(session, tenant_id=tenant_id, poll_id=poll_id) ) def _response_invitation_id(response: object) -> str | None: value = (response.metadata_ or {}).get("invitation_id") return value if isinstance(value, str) else None def _response_ref(response: object) -> PollResponseRef: answers: list[PollAnswerRef] = [] for answer in response.answers or []: if not isinstance(answer, Mapping): continue answers.append( PollAnswerRef( option_id=answer.get("option_id"), option_key=answer.get("option_key"), value=answer.get("value"), rank=answer.get("rank"), ) ) return PollResponseRef( invitation_id=_response_invitation_id(response), submitted_at=response_datetime(response.submitted_at), respondent_id=response.respondent_id, answers=tuple(answers), ) def _participation_context_ref( session: object, *, invitation: PollInvitation, respondent_id: str | None = None, participant_email: str | None = None, ) -> PollParticipationContextRef: policy = participation_policy_ref(invitation.participation_policy_) response = response_for_invitation( session, invitation=invitation, respondent_id=respondent_id, participant_email=participant_email, ) response_ref = None if response is not None: email, comment = response_metadata(response) response_ref = PollGovernedResponseRef( response=_response_ref(response), participant_email=email, comment=comment, ) return PollParticipationContextRef( invitation_id=invitation.id, tenant_id=invitation.tenant_id, poll_id=invitation.poll_id, gateway=response_gateway_ref(invitation.response_gateway_), policy=policy, respondent_id=invitation.respondent_id or respondent_id, respondent_label=invitation.respondent_label, email=invitation.email, response=response_ref, ) class SqlPollSchedulingProvider(PollSchedulingProvider): def create_poll( self, session: object, *, tenant_id: str, user_id: str | None, command: PollCreateCommand, ) -> PollRef: payload = PollCreateRequest( title=command.title, description=command.description, kind=command.kind, status=command.status, visibility=command.visibility, result_visibility=command.result_visibility, context_module=command.context_module, context_resource_type=command.context_resource_type, context_resource_id=command.context_resource_id, workflow_state=command.workflow_state, workflow_steps=[dict(step) for step in command.workflow_steps], allow_anonymous=command.allow_anonymous, allow_response_update=command.allow_response_update, min_choices=command.min_choices, max_choices=command.max_choices, opens_at=command.opens_at, closes_at=command.closes_at, options=[ PollOptionInput( key=option.key, label=option.label, description=option.description, value=dict(option.value) if option.value is not None else None, metadata=dict(option.metadata), ) for option in command.options ], metadata=dict(command.metadata), ) try: poll = create_poll( session, tenant_id=tenant_id, user_id=user_id, payload=payload, mutation_owner=_command_owner(command), ) except PollError as exc: raise PollCapabilityError(str(exc)) from exc return _poll_ref(poll) def create_invitation( self, session: object, *, tenant_id: str, poll_id: str, command: PollInvitationCommand, ) -> PollInvitationRef: payload = PollInvitationCreateRequest( respondent_id=command.respondent_id, respondent_label=command.respondent_label, email=command.email, expires_at=command.expires_at, metadata=dict(command.metadata), ) try: invitation, token = create_poll_invitation( session, tenant_id=tenant_id, poll_id=poll_id, payload=payload, ) except PollError as exc: raise PollCapabilityError(str(exc)) from exc return PollInvitationRef(id=invitation.id, token=token) def create_governed_invitation( self, session: object, *, tenant_id: str, poll_id: str, command: PollGovernedInvitationCommand, ) -> PollInvitationRef: try: payload = PollInvitationCreateRequest( respondent_id=command.respondent_id, respondent_label=command.respondent_label, email=command.email, expires_at=command.expires_at, response_gateway=response_gateway_payload(command.gateway), participation_policy=participation_policy_payload(command.policy), metadata=dict(command.metadata), ) invitation, token = create_poll_invitation( session, tenant_id=tenant_id, poll_id=poll_id, payload=payload, governed_capability=True, ) except (PollError, ValidationError) as exc: raise PollCapabilityError(str(exc)) from exc return PollInvitationRef(id=invitation.id, token=token) def resolve_participation( self, session: object, *, token: str, gateway: PollResponseGatewayRef, respondent_id: str | None = None, participant_email: str | None = None, participant_is_authenticated: bool = False, verified_requirements: frozenset[str] = frozenset(), ) -> PollParticipationContextRef: try: invitation = governed_invitation( session, token=token, gateway=gateway, ) policy = participation_policy_ref(invitation.participation_policy_) if ( not participant_is_authenticated and policy.anonymous_password_required and ANONYMOUS_PASSWORD_REQUIREMENT not in verified_requirements ): raise PollError("Poll invitation not found") return _participation_context_ref( session, invitation=invitation, respondent_id=(respondent_id if participant_is_authenticated else None), participant_email=participant_email, ) except (PollError, ValidationError) as exc: raise PollCapabilityError(str(exc)) from exc def submit_governed_response( self, session: object, *, token: str, gateway: PollResponseGatewayRef, command: PollGovernedResponseCommand, ) -> PollGovernedResponseRef: try: _invitation, response, replayed = submit_governed_poll_response( session, token=token, gateway=gateway, command=command, ) except (PollError, ValidationError) as exc: raise PollCapabilityError(str(exc)) from exc email, comment = response_metadata(response) return PollGovernedResponseRef( response=_response_ref(response), participant_email=email, comment=comment, replayed=replayed, ) def resolve_authenticated_participation( self, session: object, *, tenant_id: str, poll_id: str, invitation_id: str, gateway: PollResponseGatewayRef, respondent_id: str, ) -> PollParticipationContextRef: try: invitation = governed_invitation_by_id( session, tenant_id=tenant_id, poll_id=poll_id, invitation_id=invitation_id, gateway=gateway, respondent_id=respondent_id, ) return _participation_context_ref( session, invitation=invitation, respondent_id=respondent_id, ) except (PollError, ValidationError) as exc: raise PollCapabilityError(str(exc)) from exc def submit_authenticated_response( self, session: object, *, tenant_id: str, poll_id: str, invitation_id: str, gateway: PollResponseGatewayRef, respondent_id: str, command: PollGovernedResponseCommand, ) -> PollGovernedResponseRef: try: _invitation, response, replayed = submit_authenticated_poll_response( session, tenant_id=tenant_id, poll_id=poll_id, invitation_id=invitation_id, gateway=gateway, respondent_id=respondent_id, command=command, ) except (PollError, ValidationError) as exc: raise PollCapabilityError(str(exc)) from exc email, comment = response_metadata(response) return PollGovernedResponseRef( response=_response_ref(response), participant_email=email, comment=comment, replayed=replayed, ) def add_option( self, session: object, *, tenant_id: str, poll_id: str, command: PollOptionRequest, ) -> PollOptionMutationRef: try: mutation_owner = _stored_owner( session, tenant_id=tenant_id, poll_id=poll_id, ) option, replayed = add_poll_option( session, tenant_id=tenant_id, poll_id=poll_id, option=PollOptionInput( key=command.key, label=command.label, description=command.description, value=dict(command.value) if command.value is not None else None, metadata=dict(command.metadata), ), mutation_owner=mutation_owner, ) except (PollError, ValidationError) as exc: raise PollCapabilityError(str(exc)) from exc return PollOptionMutationRef( id=option.id, position=option.position, replayed=replayed, ) def remove_option( self, session: object, *, tenant_id: str, poll_id: str, option_id: str, ) -> PollOptionMutationRef: try: mutation_owner = _stored_owner( session, tenant_id=tenant_id, poll_id=poll_id, ) option, replayed, invalidated = remove_poll_option( session, tenant_id=tenant_id, poll_id=poll_id, option_id=option_id, mutation_owner=mutation_owner, ) except (PollError, ValidationError) as exc: raise PollCapabilityError(str(exc)) from exc return PollOptionMutationRef( id=option.id, position=option.position, replayed=replayed, invalidated_response_count=invalidated, ) def revoke_invitation( self, session: object, *, tenant_id: str, poll_id: str, invitation_id: str, ) -> PollInvitationRevocationRef: try: mutation_owner = _stored_owner( session, tenant_id=tenant_id, poll_id=poll_id, ) invitation, replayed = revoke_poll_invitation_with_replay( session, tenant_id=tenant_id, poll_id=poll_id, invitation_id=invitation_id, mutation_owner=mutation_owner, ) except (PollError, ValidationError) as exc: raise PollCapabilityError(str(exc)) from exc return PollInvitationRevocationRef( id=invitation.id, revoked_at=response_datetime(invitation.revoked_at), replayed=replayed, ) def update_invitation_expiry( self, session: object, *, tenant_id: str, poll_id: str, invitation_id: str, gateway: PollResponseGatewayRef, expires_at: datetime | None, ) -> PollInvitationExpiryRef: try: invitation, replayed = update_governed_invitation_expiry( session, tenant_id=tenant_id, poll_id=poll_id, invitation_id=invitation_id, gateway=gateway, expires_at=expires_at, ) except (PollError, ValidationError) as exc: raise PollCapabilityError(str(exc)) from exc return PollInvitationExpiryRef( id=invitation.id, expires_at=response_datetime(invitation.expires_at), replayed=replayed, ) def submit_response( self, session: object, *, tenant_id: str, poll_id: str, command: PollSubmitResponseCommand, ) -> PollResponseRef: payload = PollSubmitResponseRequest( respondent_id=command.respondent_id, respondent_label=command.respondent_label, answers=[ PollAnswerInput( option_id=answer.option_id, option_key=answer.option_key, value=answer.value, rank=answer.rank, ) for answer in command.answers ], metadata=dict(command.metadata), ) try: response = submit_poll_response( session, tenant_id=tenant_id, poll_id=poll_id, payload=payload, ) except PollError as exc: raise PollCapabilityError(str(exc)) from exc return _response_ref(response) def update_poll( self, session: object, *, tenant_id: str, poll_id: str, command: PollUpdateCommand, ) -> PollRef: try: mutation_owner = _stored_owner( session, tenant_id=tenant_id, poll_id=poll_id, ) poll = update_poll_snapshot( session, tenant_id=tenant_id, poll_id=poll_id, title=command.title, description=command.description, visibility=command.visibility, result_visibility=command.result_visibility, allow_anonymous=command.allow_anonymous, allow_response_update=command.allow_response_update, closes_at=command.closes_at, mutation_owner=mutation_owner, ) except PollError as exc: raise PollCapabilityError(str(exc)) from exc return _poll_ref(poll) def update_option( self, session: object, *, tenant_id: str, poll_id: str, option_id: str, command: PollOptionUpdateCommand, ) -> PollOptionRef: try: mutation_owner = _stored_owner( session, tenant_id=tenant_id, poll_id=poll_id, ) option = update_poll_option( session, tenant_id=tenant_id, poll_id=poll_id, option_id=option_id, label=command.label, description=command.description, value=dict(command.value) if command.value is not None else None, metadata=dict(command.metadata), mutation_owner=mutation_owner, ) except PollError as exc: raise PollCapabilityError(str(exc)) from exc return PollOptionRef(id=option.id, position=option.position) def reorder_options( self, session: object, *, tenant_id: str, poll_id: str, command: PollOptionOrderCommand, ) -> PollRef: try: mutation_owner = _stored_owner( session, tenant_id=tenant_id, poll_id=poll_id, ) poll = reorder_poll_options( session, tenant_id=tenant_id, poll_id=poll_id, option_ids=command.option_ids, mutation_owner=mutation_owner, ) except PollError as exc: raise PollCapabilityError(str(exc)) from exc return _poll_ref(poll) def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef: return self._transition(open_poll, session, tenant_id=tenant_id, poll_id=poll_id) def close_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef: return self._transition(close_poll, session, tenant_id=tenant_id, poll_id=poll_id) def decide_poll( self, session: object, *, tenant_id: str, poll_id: str, option_id: str | None, ) -> PollRef: try: mutation_owner = _stored_owner( session, tenant_id=tenant_id, poll_id=poll_id, ) poll = decide_poll( session, tenant_id=tenant_id, poll_id=poll_id, payload=PollDecisionRequest(option_id=option_id), mutation_owner=mutation_owner, ) except PollError as exc: raise PollCapabilityError(str(exc)) from exc return _poll_ref(poll) def get_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef: try: return _poll_ref(get_poll(session, tenant_id=tenant_id, poll_id=poll_id)) except PollError as exc: raise PollCapabilityError(str(exc)) from exc def set_workflow_context( self, session: object, *, tenant_id: str, poll_id: str, workflow_state: str, workflow_steps: Sequence[Mapping[str, object]], context_module: str, context_resource_type: str, context_resource_id: str, ) -> PollRef: try: mutation_owner = _stored_owner( session, tenant_id=tenant_id, poll_id=poll_id, ) requested_owner = poll_owner_ref( module_id=context_module, resource_type=context_resource_type, resource_id=context_resource_id, ) poll = set_poll_workflow_context( session, tenant_id=tenant_id, poll_id=poll_id, workflow_state=workflow_state, workflow_steps=[dict(step) for step in workflow_steps], context_module=context_module, context_resource_type=context_resource_type, context_resource_id=context_resource_id, mutation_owner=mutation_owner, requested_owner=requested_owner, ) except PollError as exc: raise PollCapabilityError(str(exc)) from exc return _poll_ref(poll) def result_summary(self, session: object, *, tenant_id: str, poll_id: str) -> Mapping[str, object]: try: return poll_result_summary_by_id(session, tenant_id=tenant_id, poll_id=poll_id) except PollError as exc: raise PollCapabilityError(str(exc)) from exc def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> tuple[PollResponseRef, ...]: try: projection_owner = _stored_owner( session, tenant_id=tenant_id, poll_id=poll_id, ) responses = list_poll_responses( session, tenant_id=tenant_id, poll_id=poll_id, projection_owner=projection_owner, ) except PollError as exc: raise PollCapabilityError(str(exc)) from exc return tuple(_response_ref(response) for response in responses) def get_response( self, session: object, *, tenant_id: str, poll_id: str, respondent_ids: Sequence[str], invitation_id: str | None = None, ) -> PollResponseRef | None: try: projection_owner = _stored_owner( session, tenant_id=tenant_id, poll_id=poll_id, ) response = get_poll_response_for_respondents( session, tenant_id=tenant_id, poll_id=poll_id, respondent_ids=tuple(respondent_ids), invitation_id=invitation_id, projection_owner=projection_owner, ) except PollError as exc: raise PollCapabilityError(str(exc)) from exc return _response_ref(response) if response is not None else None def retire_responses( self, session: object, *, tenant_id: str, poll_id: str, command: PollResponseRetirementCommand, ) -> PollResponseRetirementRef: try: mutation_owner = _stored_owner( session, tenant_id=tenant_id, poll_id=poll_id, ) responses, retired_at, retired_count, replayed = retire_poll_responses( session, tenant_id=tenant_id, poll_id=poll_id, respondent_ids=command.respondent_ids, invitation_id=command.invitation_id, reason=command.reason, idempotency_key=command.idempotency_key, metadata=dict(command.metadata), mutation_owner=mutation_owner, ) except PollError as exc: raise PollCapabilityError(str(exc)) from exc return PollResponseRetirementRef( response_ids=tuple(response.id for response in responses), retired_at=response_datetime(retired_at), newly_retired_count=retired_count, replayed=replayed, ) @staticmethod def _transition(callback, session: object, *, tenant_id: str, poll_id: str) -> PollRef: try: mutation_owner = _stored_owner( session, tenant_id=tenant_id, poll_id=poll_id, ) poll = callback( session, tenant_id=tenant_id, poll_id=poll_id, mutation_owner=mutation_owner, ) except PollError as exc: raise PollCapabilityError(str(exc)) from exc return _poll_ref(poll)