diff --git a/src/govoplan_poll/backend/capabilities.py b/src/govoplan_poll/backend/capabilities.py index 290d6d0..fd39e8d 100644 --- a/src/govoplan_poll/backend/capabilities.py +++ b/src/govoplan_poll/backend/capabilities.py @@ -1,6 +1,9 @@ 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, @@ -8,6 +11,7 @@ from govoplan_core.core.poll import ( PollCreateCommand, PollInvitationCommand, PollInvitationRef, + PollOptionRequest, PollOptionRef, PollOptionUpdateCommand, PollRef, @@ -16,6 +20,31 @@ from govoplan_core.core.poll import ( 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, @@ -26,7 +55,9 @@ from govoplan_poll.backend.schemas import ( ) from govoplan_poll.backend.service import ( PollError, + PollMutationOwner, close_poll, + add_poll_option, create_poll, create_poll_invitation, decide_poll, @@ -34,8 +65,15 @@ from govoplan_poll.backend.service import ( get_poll_response_for_respondents, list_poll_responses, open_poll, + poll_mutation_owner, + poll_owner_ref, poll_result_summary_by_id, + response_datetime, + remove_poll_option, + revoke_poll_invitation_with_replay, + set_poll_workflow_context, submit_poll_response, + update_poll_snapshot, update_poll_option, ) @@ -48,6 +86,32 @@ def _poll_ref(poll: object) -> PollRef: ) +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 @@ -68,12 +132,47 @@ def _response_ref(response: object) -> PollResponseRef: ) return PollResponseRef( invitation_id=_response_invitation_id(response), - submitted_at=response.submitted_at, + 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, @@ -114,7 +213,13 @@ class SqlPollSchedulingProvider(PollSchedulingProvider): metadata=dict(command.metadata), ) try: - poll = create_poll(session, tenant_id=tenant_id, user_id=user_id, payload=payload) + 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) @@ -145,6 +250,272 @@ class SqlPollSchedulingProvider(PollSchedulingProvider): 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, @@ -187,17 +558,26 @@ class SqlPollSchedulingProvider(PollSchedulingProvider): command: PollUpdateCommand, ) -> PollRef: try: - poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id) + 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 - poll.title = command.title - poll.description = command.description - poll.visibility = command.visibility - poll.result_visibility = command.result_visibility - poll.allow_anonymous = command.allow_anonymous - poll.allow_response_update = command.allow_response_update - poll.closes_at = command.closes_at - session.flush() return _poll_ref(poll) def update_option( @@ -210,6 +590,11 @@ class SqlPollSchedulingProvider(PollSchedulingProvider): 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, @@ -219,6 +604,7 @@ class SqlPollSchedulingProvider(PollSchedulingProvider): 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 @@ -239,11 +625,17 @@ class SqlPollSchedulingProvider(PollSchedulingProvider): 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 @@ -268,15 +660,30 @@ class SqlPollSchedulingProvider(PollSchedulingProvider): context_resource_id: str, ) -> PollRef: try: - poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id) + 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 - poll.workflow_state = workflow_state - poll.workflow_steps = [dict(step) for step in workflow_steps] - poll.context_module = context_module - poll.context_resource_type = context_resource_type - poll.context_resource_id = context_resource_id - session.flush() return _poll_ref(poll) def result_summary(self, session: object, *, tenant_id: str, poll_id: str) -> Mapping[str, object]: @@ -287,7 +694,17 @@ class SqlPollSchedulingProvider(PollSchedulingProvider): def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> tuple[PollResponseRef, ...]: try: - responses = list_poll_responses(session, tenant_id=tenant_id, poll_id=poll_id) + 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) @@ -302,12 +719,18 @@ class SqlPollSchedulingProvider(PollSchedulingProvider): 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 @@ -316,7 +739,17 @@ class SqlPollSchedulingProvider(PollSchedulingProvider): @staticmethod def _transition(callback, session: object, *, tenant_id: str, poll_id: str) -> PollRef: try: - poll = callback(session, tenant_id=tenant_id, poll_id=poll_id) + 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) diff --git a/src/govoplan_poll/backend/db/models.py b/src/govoplan_poll/backend/db/models.py index 27145dc..bc0ffa4 100644 --- a/src/govoplan_poll/backend/db/models.py +++ b/src/govoplan_poll/backend/db/models.py @@ -42,6 +42,11 @@ class Poll(Base, TimestampMixin): context_module: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) context_resource_type: Mapped[str | None] = mapped_column(String(100), nullable=True) context_resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + participation_gateway_: Mapped[dict[str, Any] | None] = mapped_column( + "participation_gateway", + JSON, + nullable=True, + ) workflow_state: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) workflow_steps: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False) allow_anonymous: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) @@ -128,11 +133,50 @@ class PollInvitation(Base, TimestampMixin): expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + response_gateway_: Mapped[dict[str, Any] | None] = mapped_column("response_gateway", JSON, nullable=True) + participation_policy_: Mapped[dict[str, Any] | None] = mapped_column("participation_policy", JSON, nullable=True) metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) poll: Mapped[Poll] = relationship(back_populates="invitations") +class PollParticipationSubmission(Base, TimestampMixin): + __tablename__ = "poll_participation_submissions" + __table_args__ = ( + UniqueConstraint( + "invitation_id", + "idempotency_key", + name="uq_poll_participation_submission_idempotency", + ), + Index( + "ix_poll_participation_submission_poll_created", + "tenant_id", + "poll_id", + "created_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + poll_id: Mapped[str] = mapped_column( + ForeignKey("poll_polls.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + invitation_id: Mapped[str] = mapped_column( + ForeignKey("poll_invitations.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + response_id: Mapped[str] = mapped_column( + ForeignKey("poll_responses.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + request_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False) + + class PollLifecycleTransition(Base, TimestampMixin): __tablename__ = "poll_lifecycle_transitions" __table_args__ = ( @@ -157,4 +201,12 @@ class PollLifecycleTransition(Base, TimestampMixin): poll: Mapped[Poll] = relationship(back_populates="lifecycle_transitions") -__all__ = ["Poll", "PollInvitation", "PollLifecycleTransition", "PollOption", "PollResponse", "new_uuid"] +__all__ = [ + "Poll", + "PollInvitation", + "PollLifecycleTransition", + "PollOption", + "PollParticipationSubmission", + "PollResponse", + "new_uuid", +] diff --git a/src/govoplan_poll/backend/manifest.py b/src/govoplan_poll/backend/manifest.py index b0f469b..fd00c38 100644 --- a/src/govoplan_poll/backend/manifest.py +++ b/src/govoplan_poll/backend/manifest.py @@ -15,11 +15,12 @@ from govoplan_core.core.modules import ( ) from govoplan_core.db.base import Base from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING +from govoplan_poll.backend.participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY from govoplan_poll.backend.db import models as poll_models # noqa: F401 - populate Poll ORM metadata MODULE_ID = "poll" MODULE_NAME = "Poll" -MODULE_VERSION = "0.1.9" +MODULE_VERSION = "0.1.10" READ_SCOPE = "poll:poll:read" WRITE_SCOPE = "poll:poll:write" ADMIN_SCOPE = "poll:poll:admin" @@ -107,6 +108,10 @@ def _poll_scheduling_provider(context: ModuleContext) -> object: return SqlPollSchedulingProvider() +def _poll_participation_gateway_provider(context: ModuleContext) -> object: + return _poll_scheduling_provider(context) + + manifest = ModuleManifest( id=MODULE_ID, name=MODULE_NAME, @@ -115,17 +120,21 @@ manifest = ModuleManifest( optional_dependencies=("access", "calendar", "campaigns", "portal", "mail", "notifications", "forms_runtime"), optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), provides_interfaces=( - ModuleInterfaceProvider(name="poll.option_selection", version="0.1.9"), - ModuleInterfaceProvider(name="poll.availability_matrix", version="0.1.9"), - ModuleInterfaceProvider(name="poll.response_collection", version="0.1.9"), - ModuleInterfaceProvider(name="poll.workflow_context", version="0.1.9"), - ModuleInterfaceProvider(name="poll.signed_participation", version="0.1.9"), + ModuleInterfaceProvider(name="poll.option_selection", version=MODULE_VERSION), + ModuleInterfaceProvider(name="poll.availability_matrix", version=MODULE_VERSION), + ModuleInterfaceProvider(name="poll.response_collection", version=MODULE_VERSION), + ModuleInterfaceProvider(name="poll.workflow_context", version=MODULE_VERSION), + ModuleInterfaceProvider(name="poll.signed_participation", version=MODULE_VERSION), + ModuleInterfaceProvider(name="poll.governed_participation", version=MODULE_VERSION), ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, route_factory=_poll_router, tenant_summary_providers=(_tenant_summary,), - capability_factories={CAPABILITY_POLL_SCHEDULING: _poll_scheduling_provider}, + capability_factories={ + CAPABILITY_POLL_SCHEDULING: _poll_scheduling_provider, + CAPABILITY_POLL_PARTICIPATION_GATEWAY: _poll_participation_gateway_provider, + }, migration_spec=MigrationSpec( module_id=MODULE_ID, metadata=Base.metadata, @@ -136,6 +145,7 @@ manifest = ModuleManifest( poll_models.PollInvitation, poll_models.PollLifecycleTransition, poll_models.PollOption, + poll_models.PollParticipationSubmission, poll_models.PollResponse, label="Poll", ), @@ -147,6 +157,7 @@ manifest = ModuleManifest( poll_models.PollInvitation, poll_models.PollLifecycleTransition, poll_models.PollOption, + poll_models.PollParticipationSubmission, poll_models.PollResponse, label="Poll", ), diff --git a/src/govoplan_poll/backend/migrations/versions/4c5d6e7f8a9b_v0110_governed_participation.py b/src/govoplan_poll/backend/migrations/versions/4c5d6e7f8a9b_v0110_governed_participation.py new file mode 100644 index 0000000..29b02f4 --- /dev/null +++ b/src/govoplan_poll/backend/migrations/versions/4c5d6e7f8a9b_v0110_governed_participation.py @@ -0,0 +1,104 @@ +"""v0.1.10 governed signed participation + +Revision ID: 4c5d6e7f8a9b +Revises: 3b4c5d6e7f8a +Create Date: 2026-07-21 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "4c5d6e7f8a9b" +down_revision = "3b4c5d6e7f8a" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "poll_invitations", + sa.Column("response_gateway", sa.JSON(), nullable=True), + ) + op.add_column( + "poll_invitations", + sa.Column("participation_policy", sa.JSON(), nullable=True), + ) + op.create_table( + "poll_participation_submissions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("poll_id", sa.String(length=36), nullable=False), + sa.Column("invitation_id", sa.String(length=36), nullable=False), + sa.Column("response_id", sa.String(length=36), nullable=False), + sa.Column("idempotency_key", sa.String(length=255), nullable=False), + sa.Column("request_fingerprint", sa.String(length=64), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["poll_id"], + ["poll_polls.id"], + name=op.f("fk_poll_participation_submissions_poll_id_poll_polls"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["invitation_id"], + ["poll_invitations.id"], + name=op.f( + "fk_poll_participation_submissions_invitation_id_poll_invitations" + ), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["response_id"], + ["poll_responses.id"], + name=op.f("fk_poll_participation_submissions_response_id_poll_responses"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint( + "id", + name=op.f("pk_poll_participation_submissions"), + ), + sa.UniqueConstraint( + "invitation_id", + "idempotency_key", + name="uq_poll_participation_submission_idempotency", + ), + ) + op.create_index( + op.f("ix_poll_participation_submissions_tenant_id"), + "poll_participation_submissions", + ["tenant_id"], + unique=False, + ) + op.create_index( + op.f("ix_poll_participation_submissions_poll_id"), + "poll_participation_submissions", + ["poll_id"], + unique=False, + ) + op.create_index( + op.f("ix_poll_participation_submissions_invitation_id"), + "poll_participation_submissions", + ["invitation_id"], + unique=False, + ) + op.create_index( + op.f("ix_poll_participation_submissions_response_id"), + "poll_participation_submissions", + ["response_id"], + unique=False, + ) + op.create_index( + "ix_poll_participation_submission_poll_created", + "poll_participation_submissions", + ["tenant_id", "poll_id", "created_at"], + unique=False, + ) + + +def downgrade() -> None: + op.drop_table("poll_participation_submissions") + op.drop_column("poll_invitations", "participation_policy") + op.drop_column("poll_invitations", "response_gateway") diff --git a/src/govoplan_poll/backend/migrations/versions/5d6e7f8a9b0c_v0110_poll_participation_owner.py b/src/govoplan_poll/backend/migrations/versions/5d6e7f8a9b0c_v0110_poll_participation_owner.py new file mode 100644 index 0000000..e0631ad --- /dev/null +++ b/src/govoplan_poll/backend/migrations/versions/5d6e7f8a9b0c_v0110_poll_participation_owner.py @@ -0,0 +1,64 @@ +"""v0.1.10 durable Poll participation owner + +Revision ID: 5d6e7f8a9b0c +Revises: 4c5d6e7f8a9b +Create Date: 2026-07-21 00:00:01.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "5d6e7f8a9b0c" +down_revision = "4c5d6e7f8a9b" +branch_labels = None +depends_on = None + + +def _backfill_existing_governed_polls() -> None: + """Retain the owner of installations that already used revision 4c5.""" + + polls = sa.table( + "poll_polls", + sa.column("id", sa.String(length=36)), + sa.column("participation_gateway", sa.JSON()), + ) + invitations = sa.table( + "poll_invitations", + sa.column("id", sa.String(length=36)), + sa.column("poll_id", sa.String(length=36)), + sa.column("response_gateway", sa.JSON()), + ) + connection = op.get_bind() + rows = connection.execute( + sa.select( + invitations.c.poll_id, + invitations.c.response_gateway, + ).order_by(invitations.c.poll_id.asc(), invitations.c.id.asc()) + ).mappings() + claimed_poll_ids: set[str] = set() + for row in rows: + poll_id = row["poll_id"] + gateway = row["response_gateway"] + if poll_id in claimed_poll_ids or not isinstance(gateway, dict): + continue + connection.execute( + polls.update() + .where(polls.c.id == poll_id) + .where(polls.c.participation_gateway.is_(None)) + .values(participation_gateway=gateway) + ) + claimed_poll_ids.add(poll_id) + + +def upgrade() -> None: + op.add_column( + "poll_polls", + sa.Column("participation_gateway", sa.JSON(), nullable=True), + ) + _backfill_existing_governed_polls() + + +def downgrade() -> None: + op.drop_column("poll_polls", "participation_gateway") diff --git a/src/govoplan_poll/backend/participation.py b/src/govoplan_poll/backend/participation.py new file mode 100644 index 0000000..bdab5da --- /dev/null +++ b/src/govoplan_poll/backend/participation.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +import hashlib +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime +from typing import Protocol, runtime_checkable + +from govoplan_core.core.poll import ( + PollAnswerRequest, + PollInvitationRef, + PollOptionRequest, + PollResponseRef, +) + + +CAPABILITY_POLL_PARTICIPATION_GATEWAY = "poll.participation_gateway" +# Policy-attestation identifier, not a credential or credential default. +ANONYMOUS_PASSWORD_REQUIREMENT = "anonymous_password" # nosec B105 # noqa: S105 +PARTICIPATION_POLICY_VERSION = 1 + + +def participation_token_fingerprint(token: str) -> str: + """Return a non-reversible identifier suitable for audit/throttle keys.""" + + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +@dataclass(frozen=True, slots=True) +class PollResponseGatewayRef: + """Stable identity of the module resource governing a participation link.""" + + module_id: str + resource_type: str + resource_id: str + + +@dataclass(frozen=True, slots=True) +class PollParticipationPolicy: + """Generic response rules snapshotted onto one signed invitation. + + ``single_choice`` treats every non-``unavailable`` availability answer as + a selection. Capacity is reserved only by ``available`` answers (and by + selected answers for non-availability polls), so ``maybe`` never consumes + a place. + """ + + version: int = PARTICIPATION_POLICY_VERSION + single_choice: bool = False + allow_maybe: bool = True + max_participants_per_option: int | None = None + allow_comments: bool = False + participant_email_required: bool = False + anonymous_password_required: bool = False + + +@dataclass(frozen=True, slots=True) +class PollGovernedInvitationCommand: + gateway: PollResponseGatewayRef + policy: PollParticipationPolicy + respondent_id: str | None = None + respondent_label: str | None = None + email: str | None = None + expires_at: datetime | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class PollGovernedResponseCommand: + """Submission already authorized by the named in-process gateway. + + Passwords never cross this boundary. A gateway that owns a password + verifier reports the completed check through ``verified_requirements``. + Poll independently re-enforces the remaining snapshotted rules while + holding its Poll-row lock. + """ + + respondent_id: str | None = None + respondent_label: str | None = None + participant_email: str | None = None + participant_is_authenticated: bool = False + answers: tuple[PollAnswerRequest, ...] = () + comment: str | None = None + verified_requirements: frozenset[str] = frozenset() + idempotency_key: str | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class PollGovernedResponseRef: + response: PollResponseRef + participant_email: str | None = None + comment: str | None = None + replayed: bool = False + + +@dataclass(frozen=True, slots=True) +class PollOptionMutationRef: + id: str + position: int + replayed: bool = False + invalidated_response_count: int = 0 + + +@dataclass(frozen=True, slots=True) +class PollInvitationRevocationRef: + id: str + revoked_at: datetime + replayed: bool = False + + +@dataclass(frozen=True, slots=True) +class PollInvitationExpiryRef: + id: str + expires_at: datetime | None + replayed: bool = False + + +@dataclass(frozen=True, slots=True) +class PollParticipationContextRef: + invitation_id: str + tenant_id: str + poll_id: str + gateway: PollResponseGatewayRef + policy: PollParticipationPolicy + respondent_id: str | None = None + respondent_label: str | None = None + email: str | None = None + response: PollGovernedResponseRef | None = None + + +@runtime_checkable +class PollParticipationGatewayProvider(Protocol): + def create_governed_invitation( + self, + session: object, + *, + tenant_id: str, + poll_id: str, + command: PollGovernedInvitationCommand, + ) -> PollInvitationRef: + ... + + 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: + """Resolve and prefill one valid invitation for the exact gateway.""" + + ... + + def submit_governed_response( + self, + session: object, + *, + token: str, + gateway: PollResponseGatewayRef, + command: PollGovernedResponseCommand, + ) -> PollGovernedResponseRef: + ... + + def resolve_authenticated_participation( + self, + session: object, + *, + tenant_id: str, + poll_id: str, + invitation_id: str, + gateway: PollResponseGatewayRef, + respondent_id: str, + ) -> PollParticipationContextRef: + """Resolve one governed invitation without retaining its public token.""" + + ... + + def submit_authenticated_response( + self, + session: object, + *, + tenant_id: str, + poll_id: str, + invitation_id: str, + gateway: PollResponseGatewayRef, + respondent_id: str, + command: PollGovernedResponseCommand, + ) -> PollGovernedResponseRef: + """Submit atomically for the exact authenticated invitation identity.""" + + ... + + def add_option( + self, + session: object, + *, + tenant_id: str, + poll_id: str, + command: PollOptionRequest, + ) -> PollOptionMutationRef: + ... + + def remove_option( + self, + session: object, + *, + tenant_id: str, + poll_id: str, + option_id: str, + ) -> PollOptionMutationRef: + ... + + def revoke_invitation( + self, + session: object, + *, + tenant_id: str, + poll_id: str, + invitation_id: str, + ) -> PollInvitationRevocationRef: + ... + + def update_invitation_expiry( + self, + session: object, + *, + tenant_id: str, + poll_id: str, + invitation_id: str, + gateway: PollResponseGatewayRef, + expires_at: datetime | None, + ) -> PollInvitationExpiryRef: + """Expire or extend a non-revoked governed invitation in place.""" + + ... + + +def poll_participation_gateway_provider(registry: object | None) -> PollParticipationGatewayProvider | None: + if registry is None or not hasattr(registry, "has_capability"): + return None + if not registry.has_capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY): + return None + capability = registry.capability(CAPABILITY_POLL_PARTICIPATION_GATEWAY) + return capability if isinstance(capability, PollParticipationGatewayProvider) else None + + +__all__ = [ + "ANONYMOUS_PASSWORD_REQUIREMENT", + "CAPABILITY_POLL_PARTICIPATION_GATEWAY", + "PARTICIPATION_POLICY_VERSION", + "PollGovernedInvitationCommand", + "PollGovernedResponseCommand", + "PollGovernedResponseRef", + "PollInvitationRevocationRef", + "PollInvitationExpiryRef", + "PollOptionMutationRef", + "PollParticipationContextRef", + "PollParticipationGatewayProvider", + "PollParticipationPolicy", + "PollResponseGatewayRef", + "participation_token_fingerprint", + "poll_participation_gateway_provider", +] diff --git a/src/govoplan_poll/backend/participation_service.py b/src/govoplan_poll/backend/participation_service.py new file mode 100644 index 0000000..c95e680 --- /dev/null +++ b/src/govoplan_poll/backend/participation_service.py @@ -0,0 +1,730 @@ +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from datetime import datetime +from typing import Any + +from pydantic import ValidationError +from sqlalchemy.orm import Session + +from govoplan_poll.backend.db.models import ( + Poll, + PollInvitation, + PollParticipationSubmission, + PollResponse, +) +from govoplan_poll.backend.participation import ( + ANONYMOUS_PASSWORD_REQUIREMENT, + PollGovernedResponseCommand, + PollParticipationPolicy, + PollResponseGatewayRef, +) +from govoplan_poll.backend.schemas import ( + PollAnswerInput, + PollParticipationPolicyInput, + PollResponseGatewayInput, + PollSubmitResponseRequest, +) +from govoplan_poll.backend.service import ( + PollError, + _assert_governed_invitation_gateway_allowed, + _assert_poll_accepts_responses, + _existing_response, + _lock_poll_for_response, + _now, + assert_no_sensitive_participation_metadata, + get_poll_invitation_by_token, + normalize_response_answers, + response_datetime, +) + + +GENERIC_PARTICIPATION_ERROR = "Poll invitation not found" +MAX_COMMENT_LENGTH = 4_000 + + +def response_gateway_payload(gateway: PollResponseGatewayRef) -> dict[str, str]: + try: + return PollResponseGatewayInput( + module_id=gateway.module_id, + resource_type=gateway.resource_type, + resource_id=gateway.resource_id, + ).model_dump(mode="json") + except ValidationError as exc: + raise PollError("Invalid participation response gateway") from exc + + +def participation_policy_payload(policy: PollParticipationPolicy) -> dict[str, Any]: + try: + return PollParticipationPolicyInput( + version=policy.version, + single_choice=policy.single_choice, + allow_maybe=policy.allow_maybe, + max_participants_per_option=policy.max_participants_per_option, + allow_comments=policy.allow_comments, + participant_email_required=policy.participant_email_required, + anonymous_password_required=policy.anonymous_password_required, + ).model_dump(mode="json") + except ValidationError as exc: + raise PollError("Invalid participation policy") from exc + + +def response_gateway_ref(value: Mapping[str, Any] | None) -> PollResponseGatewayRef: + try: + parsed = PollResponseGatewayInput.model_validate(value) + except ValidationError as exc: + raise PollError(GENERIC_PARTICIPATION_ERROR) from exc + return PollResponseGatewayRef( + module_id=parsed.module_id, + resource_type=parsed.resource_type, + resource_id=parsed.resource_id, + ) + + +def participation_policy_ref(value: Mapping[str, Any] | None) -> PollParticipationPolicy: + try: + parsed = PollParticipationPolicyInput.model_validate(value) + except ValidationError as exc: + raise PollError(GENERIC_PARTICIPATION_ERROR) from exc + return PollParticipationPolicy( + version=parsed.version, + single_choice=parsed.single_choice, + allow_maybe=parsed.allow_maybe, + max_participants_per_option=parsed.max_participants_per_option, + allow_comments=parsed.allow_comments, + participant_email_required=parsed.participant_email_required, + anonymous_password_required=parsed.anonymous_password_required, + ) + + +def governed_invitation( + session: Session, + *, + token: str, + gateway: PollResponseGatewayRef, + lock: bool = False, +) -> PollInvitation: + """Resolve a gateway-bound token without revealing which check failed.""" + + try: + invitation = get_poll_invitation_by_token(session, token=token) + _assert_governed_invitation(invitation, gateway=gateway) + if lock: + invitation = ( + session.query(PollInvitation) + .filter(PollInvitation.id == invitation.id) + .populate_existing() + .with_for_update() + .one_or_none() + ) + _assert_governed_invitation(invitation, gateway=gateway) + except (PollError, ValidationError) as exc: + raise PollError(GENERIC_PARTICIPATION_ERROR) from exc + if invitation is None: + raise PollError(GENERIC_PARTICIPATION_ERROR) + return invitation + + +def _assert_governed_invitation( + invitation: PollInvitation | None, + *, + gateway: PollResponseGatewayRef, + authenticated_respondent_id: str | None = None, +) -> None: + if invitation is None or invitation.poll.deleted_at is not None: + raise PollError(GENERIC_PARTICIPATION_ERROR) + if invitation.revoked_at is not None: + raise PollError(GENERIC_PARTICIPATION_ERROR) + if ( + invitation.expires_at is not None + and response_datetime(invitation.expires_at) <= _now() + ): + raise PollError(GENERIC_PARTICIPATION_ERROR) + _assert_governed_invitation_gateway_allowed( + poll=invitation.poll, + gateway=response_gateway_payload(gateway), + ) + if response_gateway_ref(invitation.response_gateway_) != gateway: + raise PollError(GENERIC_PARTICIPATION_ERROR) + participation_policy_ref(invitation.participation_policy_) + if authenticated_respondent_id is not None and ( + invitation.respondent_id is not None + and invitation.respondent_id != authenticated_respondent_id + ): + raise PollError(GENERIC_PARTICIPATION_ERROR) + + +def governed_invitation_by_id( + session: Session, + *, + tenant_id: str, + poll_id: str, + invitation_id: str, + gateway: PollResponseGatewayRef, + respondent_id: str, + lock: bool = False, +) -> PollInvitation: + """Resolve an active governed invitation for one authenticated identity.""" + + if not respondent_id: + raise PollError(GENERIC_PARTICIPATION_ERROR) + try: + query = session.query(PollInvitation).filter( + PollInvitation.tenant_id == tenant_id, + PollInvitation.poll_id == poll_id, + PollInvitation.id == invitation_id, + ) + if lock: + query = query.populate_existing().with_for_update() + invitation = query.one_or_none() + _assert_governed_invitation( + invitation, + gateway=gateway, + authenticated_respondent_id=respondent_id, + ) + except PollError as exc: + raise PollError(GENERIC_PARTICIPATION_ERROR) from exc + if invitation is None: + raise PollError(GENERIC_PARTICIPATION_ERROR) + return invitation + + +def update_governed_invitation_expiry( + session: Session, + *, + tenant_id: str, + poll_id: str, + invitation_id: str, + gateway: PollResponseGatewayRef, + expires_at: datetime | None, +) -> tuple[PollInvitation, bool]: + """Update expiry without rotating the bearer token or reviving revocation.""" + + poll = _lock_poll_for_response( + session, + tenant_id=tenant_id, + poll_id=poll_id, + ) + invitation = ( + session.query(PollInvitation) + .filter( + PollInvitation.tenant_id == tenant_id, + PollInvitation.poll_id == poll.id, + PollInvitation.id == invitation_id, + ) + .populate_existing() + .with_for_update() + .one_or_none() + ) + try: + if invitation is None or invitation.revoked_at is not None: + raise PollError(GENERIC_PARTICIPATION_ERROR) + _assert_governed_invitation_gateway_allowed( + poll=poll, + gateway=response_gateway_payload(gateway), + ) + if response_gateway_ref(invitation.response_gateway_) != gateway: + raise PollError(GENERIC_PARTICIPATION_ERROR) + participation_policy_ref(invitation.participation_policy_) + except (PollError, ValidationError) as exc: + raise PollError(GENERIC_PARTICIPATION_ERROR) from exc + + normalized_expiry = response_datetime(expires_at) + replayed = response_datetime(invitation.expires_at) == normalized_expiry + if not replayed: + invitation.expires_at = normalized_expiry + session.flush() + return invitation, replayed + + +def _normalize_email(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip().casefold() + if not normalized: + return None + if ( + normalized.count("@") != 1 + or any(character.isspace() for character in normalized) + or not all(normalized.split("@", 1)) + ): + raise PollError("Participant email is invalid") + return normalized + + +def _canonical_respondent_id( + invitation: PollInvitation, + *, + respondent_id: str | None, + participant_email: str | None, + participant_is_authenticated: bool, +) -> str: + if invitation.respondent_id: + return invitation.respondent_id + if participant_is_authenticated and respondent_id: + return respondent_id + if participant_email and invitation.email is None: + email_fingerprint = hashlib.sha256(participant_email.encode("utf-8")).hexdigest() + return f"invitation:{invitation.id}:email:{email_fingerprint}" + return f"invitation:{invitation.id}" + + +def _normalize_comment(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if len(normalized) > MAX_COMMENT_LENGTH: + raise PollError(f"Participation comments cannot exceed {MAX_COMMENT_LENGTH} characters") + return normalized or None + + +def _answer_reserves_capacity(poll: Poll, answer: Mapping[str, Any]) -> bool: + if poll.kind == "availability": + return answer.get("value") == "available" + return True + + +def _validate_gateway_rules( + *, + invitation: PollInvitation, + policy: PollParticipationPolicy, + command: PollGovernedResponseCommand, +) -> tuple[str | None, str | None, list[PollAnswerInput]]: + normalized_email = _normalize_email(command.participant_email or invitation.email) + invitation_email = _normalize_email(invitation.email) + if invitation_email is not None and normalized_email != invitation_email: + raise PollError("Participant email does not match this invitation") + if ( + not command.participant_is_authenticated + and policy.participant_email_required + and normalized_email is None + ): + raise PollError("Participant email is required") + if ( + not command.participant_is_authenticated + and policy.anonymous_password_required + and ANONYMOUS_PASSWORD_REQUIREMENT not in command.verified_requirements + ): + raise PollError("Anonymous password verification is required") + + comment = _normalize_comment(command.comment) + if comment is not None and not policy.allow_comments: + raise PollError("Comments are not enabled for this participation link") + + answers = [ + PollAnswerInput( + option_id=answer.option_id, + option_key=answer.option_key, + value=answer.value, + rank=answer.rank, + ) + for answer in command.answers + ] + return normalized_email, comment, answers + + +def _validate_normalized_gateway_answers( + *, + poll: Poll, + policy: PollParticipationPolicy, + normalized_answers: list[dict[str, Any]], +) -> None: + """Enforce policy against Poll-resolved option identities and values.""" + + if not policy.allow_maybe and any( + answer.get("value") == "maybe" or answer.get("option_key") == "maybe" + for answer in normalized_answers + ): + raise PollError("Maybe responses are not enabled for this participation link") + selected_count = sum( + ( + answer.get("value") != "unavailable" + if poll.kind == "availability" + else True + ) + for answer in normalized_answers + ) + if policy.single_choice and selected_count > 1: + raise PollError("Only one poll option may be selected") + + +def _selected_capacity_option_ids( + poll: Poll, + normalized_answers: list[dict[str, Any]], +) -> set[str]: + return { + str(answer["option_id"]) + for answer in normalized_answers + if answer.get("option_id") and _answer_reserves_capacity(poll, answer) + } + + +def _enforce_capacity( + session: Session, + *, + poll: Poll, + existing: PollResponse | None, + normalized_answers: list[dict[str, Any]], + limit: int | None, +) -> None: + if limit is None: + return + selected = _selected_capacity_option_ids(poll, normalized_answers) + if not selected: + return + counts = dict.fromkeys(selected, 0) + responses = ( + session.query(PollResponse) + .filter( + PollResponse.tenant_id == poll.tenant_id, + PollResponse.poll_id == poll.id, + PollResponse.deleted_at.is_(None), + ) + .order_by(PollResponse.id.asc()) + .all() + ) + for response in responses: + if existing is not None and response.id == existing.id: + continue + for answer in response.answers or []: + if ( + isinstance(answer, Mapping) + and answer.get("option_id") in counts + and _answer_reserves_capacity(poll, answer) + ): + counts[str(answer["option_id"])] += 1 + full = {option_id for option_id, count in counts.items() if count >= limit} + if full: + raise PollError("Participant limit reached for one or more poll options") + + +def _normalize_idempotency_key(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip() + if not normalized: + raise PollError("Idempotency key cannot be empty") + if len(normalized) > 255: + raise PollError("Idempotency key cannot be longer than 255 characters") + return normalized + + +def _submission_fingerprint( + *, + gateway: PollResponseGatewayRef, + command: PollGovernedResponseCommand, + normalized_email: str | None, + comment: str | None, +) -> str: + value = { + "gateway": response_gateway_payload(gateway), + "respondent_id": command.respondent_id, + "respondent_label": command.respondent_label, + "participant_email": normalized_email, + "participant_is_authenticated": command.participant_is_authenticated, + "answers": [ + { + "option_id": answer.option_id, + "option_key": answer.option_key, + "value": answer.value, + "rank": answer.rank, + } + for answer in command.answers + ], + "comment": comment, + "verified_requirements": sorted(command.verified_requirements), + "metadata": dict(command.metadata), + } + encoded = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + default=str, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _idempotent_submission( + session: Session, + *, + invitation_id: str, + idempotency_key: str | None, + request_fingerprint: str, +) -> PollResponse | None: + if idempotency_key is None: + return None + submission = ( + session.query(PollParticipationSubmission) + .filter( + PollParticipationSubmission.invitation_id == invitation_id, + PollParticipationSubmission.idempotency_key == idempotency_key, + ) + .one_or_none() + ) + if submission is None: + return None + if submission.request_fingerprint != request_fingerprint: + raise PollError("Idempotency key was already used for a different response") + response = ( + session.query(PollResponse) + .filter(PollResponse.id == submission.response_id) + .one_or_none() + ) + if response is None: + raise PollError("Idempotent response record is no longer available") + return response + + +def submit_governed_poll_response( + session: Session, + *, + token: str, + gateway: PollResponseGatewayRef, + command: PollGovernedResponseCommand, +) -> tuple[PollInvitation, PollResponse, bool]: + """Submit through a bound gateway and enforce its snapshot atomically.""" + + initial = governed_invitation(session, token=token, gateway=gateway) + poll = _lock_poll_for_response( + session, + tenant_id=initial.tenant_id, + poll_id=initial.poll_id, + ) + invitation = governed_invitation(session, token=token, gateway=gateway, lock=True) + if invitation.poll_id != poll.id: + raise PollError(GENERIC_PARTICIPATION_ERROR) + return _submit_locked_governed_response( + session, + poll=poll, + invitation=invitation, + gateway=gateway, + command=command, + ) + + +def submit_authenticated_poll_response( + session: Session, + *, + tenant_id: str, + poll_id: str, + invitation_id: str, + gateway: PollResponseGatewayRef, + respondent_id: str, + command: PollGovernedResponseCommand, +) -> tuple[PollInvitation, PollResponse, bool]: + """Submit by governed invitation id for one authenticated respondent.""" + + if ( + not command.participant_is_authenticated + or not respondent_id + or command.respondent_id != respondent_id + ): + raise PollError(GENERIC_PARTICIPATION_ERROR) + initial = governed_invitation_by_id( + session, + tenant_id=tenant_id, + poll_id=poll_id, + invitation_id=invitation_id, + gateway=gateway, + respondent_id=respondent_id, + ) + poll = _lock_poll_for_response( + session, + tenant_id=tenant_id, + poll_id=poll_id, + ) + invitation = governed_invitation_by_id( + session, + tenant_id=tenant_id, + poll_id=poll_id, + invitation_id=invitation_id, + gateway=gateway, + respondent_id=respondent_id, + lock=True, + ) + if initial.id != invitation.id or invitation.poll_id != poll.id: + raise PollError(GENERIC_PARTICIPATION_ERROR) + return _submit_locked_governed_response( + session, + poll=poll, + invitation=invitation, + gateway=gateway, + command=command, + ) + + +def _submit_locked_governed_response( + session: Session, + *, + poll: Poll, + invitation: PollInvitation, + gateway: PollResponseGatewayRef, + command: PollGovernedResponseCommand, +) -> tuple[PollInvitation, PollResponse, bool]: + policy = participation_policy_ref(invitation.participation_policy_) + assert_no_sensitive_participation_metadata(command.metadata) + normalized_email, comment, answer_inputs = _validate_gateway_rules( + invitation=invitation, + policy=policy, + command=command, + ) + respondent_id = _canonical_respondent_id( + invitation, + respondent_id=command.respondent_id, + participant_email=normalized_email, + participant_is_authenticated=command.participant_is_authenticated, + ) + idempotency_key = _normalize_idempotency_key(command.idempotency_key) + request_fingerprint = _submission_fingerprint( + gateway=gateway, + command=command, + normalized_email=normalized_email, + comment=comment, + ) + replay = _idempotent_submission( + session, + invitation_id=invitation.id, + idempotency_key=idempotency_key, + request_fingerprint=request_fingerprint, + ) + if replay is not None: + return invitation, replay, True + + _assert_poll_accepts_responses(poll) + payload = PollSubmitResponseRequest( + respondent_id=respondent_id, + respondent_label=( + command.respondent_label + or invitation.respondent_label + or invitation.email + or normalized_email + ), + answers=answer_inputs, + metadata={ + key: value + for key, value in command.metadata.items() + if key not in {"invitation_id", "participant_email", "comment", "response_gateway"} + }, + ) + normalized_answers = normalize_response_answers(poll, payload) + _validate_normalized_gateway_answers( + poll=poll, + policy=policy, + normalized_answers=normalized_answers, + ) + existing = _existing_response(session, poll=poll, respondent_id=respondent_id) + _enforce_capacity( + session, + poll=poll, + existing=existing, + normalized_answers=normalized_answers, + limit=policy.max_participants_per_option, + ) + trusted_metadata = dict(payload.metadata) + trusted_metadata["invitation_id"] = invitation.id + trusted_metadata["response_gateway"] = response_gateway_payload(gateway) + if normalized_email is not None: + trusted_metadata["participant_email"] = normalized_email + if comment is not None: + trusted_metadata["comment"] = comment + now = _now() + if existing is not None: + if not poll.allow_response_update: + raise PollError("Response updates are not allowed for this poll") + existing.answers = normalized_answers + existing.respondent_label = payload.respondent_label + existing.submitted_at = now + existing.metadata_ = trusted_metadata + response = existing + else: + response = PollResponse( + tenant_id=poll.tenant_id, + poll_id=poll.id, + respondent_id=respondent_id, + respondent_label=payload.respondent_label, + answers=normalized_answers, + submitted_at=now, + metadata_=trusted_metadata, + ) + session.add(response) + session.flush() + if idempotency_key is not None: + session.add( + PollParticipationSubmission( + tenant_id=poll.tenant_id, + poll_id=poll.id, + invitation_id=invitation.id, + response_id=response.id, + idempotency_key=idempotency_key, + request_fingerprint=request_fingerprint, + ) + ) + invitation.last_used_at = response.submitted_at + session.flush() + return invitation, response, False + + +def response_metadata(response: PollResponse) -> tuple[str | None, str | None]: + metadata = response.metadata_ or {} + email = metadata.get("participant_email") + comment = metadata.get("comment") + return ( + email if isinstance(email, str) else None, + comment if isinstance(comment, str) else None, + ) + + +def response_for_invitation( + session: Session, + *, + invitation: PollInvitation, + respondent_id: str | None = None, + participant_email: str | None = None, +) -> PollResponse | None: + normalized_email = _normalize_email(participant_email) + if invitation.respondent_id: + response_respondent_id = invitation.respondent_id + elif respondent_id: + response_respondent_id = respondent_id + elif invitation.email: + response_respondent_id = f"invitation:{invitation.id}" + elif normalized_email: + response_respondent_id = _canonical_respondent_id( + invitation, + respondent_id=None, + participant_email=normalized_email, + participant_is_authenticated=False, + ) + else: + return None + return ( + session.query(PollResponse) + .filter( + PollResponse.tenant_id == invitation.tenant_id, + PollResponse.poll_id == invitation.poll_id, + PollResponse.respondent_id == response_respondent_id, + PollResponse.deleted_at.is_(None), + ) + .order_by(PollResponse.submitted_at.desc(), PollResponse.id.desc()) + .first() + ) + + +__all__ = [ + "GENERIC_PARTICIPATION_ERROR", + "MAX_COMMENT_LENGTH", + "governed_invitation", + "governed_invitation_by_id", + "participation_policy_payload", + "participation_policy_ref", + "response_for_invitation", + "response_gateway_payload", + "response_gateway_ref", + "response_metadata", + "submit_authenticated_poll_response", + "submit_governed_poll_response", + "update_governed_invitation_expiry", +] diff --git a/src/govoplan_poll/backend/router.py b/src/govoplan_poll/backend/router.py index 8ce11eb..a8f12a5 100644 --- a/src/govoplan_poll/backend/router.py +++ b/src/govoplan_poll/backend/router.py @@ -28,7 +28,12 @@ from govoplan_poll.backend.schemas import ( PollUpdateRequest, ) from govoplan_poll.backend.service import ( + INVALID_POLL_OWNER, + OWNED_POLL_MUTATION_REQUIRED, + OWNED_POLL_PROJECTION_RESTRICTED, + POLL_OWNERSHIP_FIELDS_RESTRICTED, PollError, + assert_generic_poll_projection_allowed, create_poll_invitation, create_poll, get_poll_by_invitation_token, @@ -66,9 +71,28 @@ def _poll_http_error(exc: PollError) -> HTTPException: return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) if str(exc) == "Poll results are not visible": return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) + if str(exc) == OWNED_POLL_PROJECTION_RESTRICTED: + return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) + if str(exc) in { + INVALID_POLL_OWNER, + OWNED_POLL_MUTATION_REQUIRED, + POLL_OWNERSHIP_FIELDS_RESTRICTED, + }: + return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) +def _public_participation_http_error(exc: PollError) -> HTTPException: + """Keep invalid, revoked, expired, and governed links indistinguishable.""" + + if str(exc).startswith("Poll invitation"): + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Poll invitation not found", + ) + return _poll_http_error(exc) + + def _poll_response(poll) -> PollResponse: return PollResponse.model_validate(poll_response(poll)) @@ -428,6 +452,7 @@ def api_list_poll_responses( actor_ids=_principal_actor_ids(principal), can_manage=_can_manage_polls(principal), ) + assert_generic_poll_projection_allowed(poll) _require_sensitive_poll_data_access(principal, poll) require_visible_poll_results( session, @@ -499,6 +524,7 @@ def api_list_poll_invitations( actor_ids=_principal_actor_ids(principal), can_manage=_can_manage_polls(principal), ) + assert_generic_poll_projection_allowed(poll) _require_sensitive_poll_data_access(principal, poll) invitations = list_poll_invitations(session, tenant_id=principal.tenant_id, poll_id=poll_id) except PollError as exc: @@ -538,7 +564,7 @@ def api_get_public_poll( try: return _poll_response(get_poll_by_invitation_token(session, token=token)) except PollError as exc: - raise _poll_http_error(exc) from exc + raise _public_participation_http_error(exc) from exc @router.post("/public/{token}/responses", response_model=PollResponseItem, status_code=status.HTTP_201_CREATED) @@ -550,7 +576,7 @@ def api_submit_public_poll_response( try: response = submit_poll_response_with_token(session, token=token, payload=payload) except PollError as exc: - raise _poll_http_error(exc) from exc + raise _public_participation_http_error(exc) from exc result = PollResponseItem.model_validate(poll_response_item(response)) session.commit() return result diff --git a/src/govoplan_poll/backend/schemas.py b/src/govoplan_poll/backend/schemas.py index 693b7e7..1b096db 100644 --- a/src/govoplan_poll/backend/schemas.py +++ b/src/govoplan_poll/backend/schemas.py @@ -226,6 +226,26 @@ class PollResponseListResponse(BaseModel): responses: list[PollResponseItem] = Field(default_factory=list) +class PollResponseGatewayInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + module_id: str = Field(min_length=1, max_length=100) + resource_type: str = Field(min_length=1, max_length=100) + resource_id: str = Field(min_length=1, max_length=255) + + +class PollParticipationPolicyInput(BaseModel): + model_config = ConfigDict(extra="forbid") + + version: Literal[1] = 1 + single_choice: bool = False + allow_maybe: bool = True + max_participants_per_option: int | None = Field(default=None, ge=1) + allow_comments: bool = False + participant_email_required: bool = False + anonymous_password_required: bool = False + + class PollInvitationCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") @@ -233,8 +253,18 @@ class PollInvitationCreateRequest(BaseModel): respondent_label: str | None = Field(default=None, max_length=500) email: str | None = Field(default=None, max_length=320) expires_at: datetime | None = None + response_gateway: PollResponseGatewayInput | None = None + participation_policy: PollParticipationPolicyInput | None = None metadata: dict[str, Any] = Field(default_factory=dict) + @model_validator(mode="after") + def validate_governed_participation(self) -> "PollInvitationCreateRequest": + if (self.response_gateway is None) != (self.participation_policy is None): + raise ValueError( + "response_gateway and participation_policy must be configured together" + ) + return self + class PollInvitationResponse(BaseModel): id: str @@ -248,6 +278,8 @@ class PollInvitationResponse(BaseModel): last_used_at: datetime | None = None created_at: datetime updated_at: datetime + response_gateway: PollResponseGatewayInput | None = None + participation_policy: PollParticipationPolicyInput | None = None metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/src/govoplan_poll/backend/service.py b/src/govoplan_poll/backend/service.py index f383f2f..5702e7d 100644 --- a/src/govoplan_poll/backend/service.py +++ b/src/govoplan_poll/backend/service.py @@ -26,6 +26,26 @@ class PollError(ValueError): pass +@dataclass(frozen=True, slots=True) +class PollMutationOwner: + module_id: str + resource_type: str + resource_id: str + + +GOVERNED_PARTICIPATION_REQUIRED = "Poll participation is governed by its owning module" +GOVERNED_PARTICIPATION_GATEWAY_MISMATCH = "Participation gateway does not own this poll" +GOVERNED_INVITATION_CAPABILITY_REQUIRED = ( + "Governed invitations must be created through the participation gateway capability" +) +OWNED_POLL_MUTATION_REQUIRED = "Poll mutations must use its owning module capability" +OWNED_POLL_PROJECTION_RESTRICTED = ( + "Poll respondent and invitation data is managed by its owning module" +) +POLL_OWNERSHIP_FIELDS_RESTRICTED = ( + "Poll context and workflow fields require an owning module capability" +) +INVALID_POLL_OWNER = "Poll owning module context is invalid" POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice", "availability"} POLL_INITIAL_STATUSES = {"draft", "open"} CHOICE_POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice"} @@ -65,6 +85,115 @@ def _now() -> datetime: return utcnow() +def poll_owner_ref( + *, + module_id: object, + resource_type: object, + resource_id: object, +) -> PollMutationOwner: + values = (module_id, resource_type, resource_id) + if any(not isinstance(value, str) or not value.strip() for value in values): + raise PollError(INVALID_POLL_OWNER) + return PollMutationOwner( + module_id=module_id, + resource_type=resource_type, + resource_id=resource_id, + ) + + +def poll_mutation_owner(poll: Poll) -> PollMutationOwner | None: + """Resolve one immutable owner from module context and gateway state.""" + + context_values = ( + poll.context_module, + poll.context_resource_type, + poll.context_resource_id, + ) + context_owner = None + if any(value is not None for value in context_values): + context_owner = poll_owner_ref( + module_id=poll.context_module, + resource_type=poll.context_resource_type, + resource_id=poll.context_resource_id, + ) + + gateway_owner = None + if poll.participation_gateway_ is not None: + gateway = poll.participation_gateway_ + if not isinstance(gateway, dict) or set(gateway) != { + "module_id", + "resource_type", + "resource_id", + }: + raise PollError(INVALID_POLL_OWNER) + gateway_owner = poll_owner_ref( + module_id=gateway.get("module_id"), + resource_type=gateway.get("resource_type"), + resource_id=gateway.get("resource_id"), + ) + + if ( + context_owner is not None + and gateway_owner is not None + and context_owner != gateway_owner + ): + raise PollError(INVALID_POLL_OWNER) + return context_owner or gateway_owner + + +def _assert_poll_mutation_owner( + poll: Poll, + *, + mutation_owner: PollMutationOwner | None, +) -> None: + expected = poll_mutation_owner(poll) + if expected != mutation_owner: + raise PollError(OWNED_POLL_MUTATION_REQUIRED) + + +def assert_generic_poll_projection_allowed(poll: Poll) -> None: + """Keep raw module-owned respondent data behind its policy-owning module.""" + + if poll_mutation_owner(poll) is not None: + raise PollError(OWNED_POLL_PROJECTION_RESTRICTED) + + +def _assert_poll_projection_owner( + poll: Poll, + *, + projection_owner: PollMutationOwner | None, +) -> None: + if poll_mutation_owner(poll) != projection_owner: + raise PollError(OWNED_POLL_PROJECTION_RESTRICTED) + + +_SENSITIVE_METADATA_TOKENS = {"credential", "password", "secret", "token"} + + +def assert_no_sensitive_participation_metadata(value: Any) -> None: + """Reject secret-like keys before public participation metadata is stored.""" + + if isinstance(value, dict): + for key, nested in value.items(): + key_text = str(key) + key_text = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", key_text) + key_text = re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", "_", key_text) + key_tokens = { + token + for token in re.split(r"[^a-z0-9]+", key_text.casefold()) + if token + } + compact_key = re.sub(r"[^a-z0-9]+", "", key_text.casefold()) + if key_tokens & _SENSITIVE_METADATA_TOKENS or any( + token in compact_key for token in _SENSITIVE_METADATA_TOKENS + ): + raise PollError("Sensitive participation metadata keys are not allowed") + assert_no_sensitive_participation_metadata(nested) + elif isinstance(value, (list, tuple)): + for nested in value: + assert_no_sensitive_participation_metadata(nested) + + def _active_options(poll: Poll) -> list[PollOption]: return [option for option in poll.options if option.deleted_at is None] @@ -159,7 +288,33 @@ def _ensure_valid_poll_payload(payload: PollCreateRequest) -> tuple[list[PollOpt return options, min_choices, max_choices -def create_poll(session: Session, *, tenant_id: str, user_id: str | None, payload: PollCreateRequest) -> Poll: +def _declared_poll_owner(payload: PollCreateRequest) -> PollMutationOwner | None: + context_values = ( + payload.context_module, + payload.context_resource_type, + payload.context_resource_id, + ) + if not any(value is not None for value in context_values): + return None + return poll_owner_ref( + module_id=payload.context_module, + resource_type=payload.context_resource_type, + resource_id=payload.context_resource_id, + ) + + +def create_poll( + session: Session, + *, + tenant_id: str, + user_id: str | None, + payload: PollCreateRequest, + mutation_owner: PollMutationOwner | None = None, +) -> Poll: + declared_owner = _declared_poll_owner(payload) + declares_workflow = payload.workflow_state is not None or bool(payload.workflow_steps) + if declared_owner != mutation_owner or (declares_workflow and mutation_owner is None): + raise PollError(POLL_OWNERSHIP_FIELDS_RESTRICTED) options, min_choices, max_choices = _ensure_valid_poll_payload(payload) base_slug = slugify(payload.slug or payload.title) poll = Poll( @@ -342,8 +497,50 @@ def require_visible_poll_results( raise PollError("Poll results are not visible") -def update_poll(session: Session, *, tenant_id: str, poll_id: str, payload: PollUpdateRequest) -> Poll: - poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id) +def update_poll( + session: Session, + *, + tenant_id: str, + poll_id: str, + payload: PollUpdateRequest, + mutation_owner: PollMutationOwner | None = None, +) -> Poll: + poll = _lock_poll_for_response(session, tenant_id=tenant_id, poll_id=poll_id) + _assert_poll_mutation_owner(poll, mutation_owner=mutation_owner) + ownership_fields = { + "context_module", + "context_resource_type", + "context_resource_id", + "workflow_state", + "workflow_steps", + } + if mutation_owner is None and ownership_fields & payload.model_fields_set: + raise PollError(POLL_OWNERSHIP_FIELDS_RESTRICTED) + context_fields = { + "context_module", + "context_resource_type", + "context_resource_id", + } + if mutation_owner is not None and context_fields & payload.model_fields_set: + requested_owner = poll_owner_ref( + module_id=( + payload.context_module + if payload.context_module is not None + else poll.context_module + ), + resource_type=( + payload.context_resource_type + if payload.context_resource_type is not None + else poll.context_resource_type + ), + resource_id=( + payload.context_resource_id + if payload.context_resource_id is not None + else poll.context_resource_id + ), + ) + if requested_owner != mutation_owner: + raise PollError(OWNED_POLL_MUTATION_REQUIRED) if poll.status in {"closed", "decided", "archived"}: raise PollError("Closed, decided, or archived polls cannot be edited") for field in ( @@ -381,6 +578,75 @@ def update_poll(session: Session, *, tenant_id: str, poll_id: str, payload: Poll return poll +def set_poll_workflow_context( + session: Session, + *, + tenant_id: str, + poll_id: str, + workflow_state: str, + workflow_steps: list[dict[str, object]], + context_module: str, + context_resource_type: str, + context_resource_id: str, + mutation_owner: PollMutationOwner | None, + requested_owner: PollMutationOwner, +) -> Poll: + """Synchronize owner-controlled workflow context at any lifecycle state.""" + + poll = _lock_poll_for_response(session, tenant_id=tenant_id, poll_id=poll_id) + _assert_poll_mutation_owner(poll, mutation_owner=mutation_owner) + exact_requested_owner = poll_owner_ref( + module_id=context_module, + resource_type=context_resource_type, + resource_id=context_resource_id, + ) + if ( + requested_owner != exact_requested_owner + or mutation_owner != exact_requested_owner + ): + raise PollError(OWNED_POLL_MUTATION_REQUIRED) + poll.workflow_state = workflow_state + poll.workflow_steps = workflow_steps + poll.context_module = context_module + poll.context_resource_type = context_resource_type + poll.context_resource_id = context_resource_id + session.flush() + return poll + + +def update_poll_snapshot( + session: Session, + *, + tenant_id: str, + poll_id: str, + title: str, + description: str | None, + visibility: str, + result_visibility: str, + allow_anonymous: bool, + allow_response_update: bool, + closes_at: datetime | None, + mutation_owner: PollMutationOwner | None, +) -> Poll: + """Apply a trusted capability's complete mutable Poll policy snapshot.""" + + poll = _lock_poll_for_response(session, tenant_id=tenant_id, poll_id=poll_id) + _assert_poll_mutation_owner(poll, mutation_owner=mutation_owner) + if poll.status in {"closed", "decided", "archived"}: + raise PollError("Closed, decided, or archived polls cannot be edited") + if poll.opens_at is not None and closes_at is not None and closes_at <= poll.opens_at: + raise PollError("closes_at must be after opens_at") + poll.title = title + poll.description = description + poll.visibility = visibility + poll.result_visibility = result_visibility + poll.allow_anonymous = allow_anonymous + poll.allow_response_update = allow_response_update + poll.closes_at = closes_at + session.flush() + return poll + + def _lock_poll_for_transition(session: Session, *, tenant_id: str, poll_id: str) -> Poll: poll = ( session.query(Poll) @@ -521,11 +787,13 @@ def transition_poll( idempotency_key: str | None = None, actor_user_id: str | None = None, actor_api_key_id: str | None = None, + mutation_owner: PollMutationOwner | None = None, transition_engine: PollTransitionEngine = DEFAULT_POLL_TRANSITION_ENGINE, ) -> PollTransitionResult: """Apply one policy-controlled transition and append its durable audit record.""" poll = _lock_poll_for_transition(session, tenant_id=tenant_id, poll_id=poll_id) + _assert_poll_mutation_owner(poll, mutation_owner=mutation_owner) normalized_key = _normalize_idempotency_key(idempotency_key) decision_option = _transition_decision_option( poll, @@ -600,6 +868,7 @@ def open_poll( tenant_id: str, poll_id: str, idempotency_key: str | None = None, + mutation_owner: PollMutationOwner | None = None, ) -> Poll: return transition_poll( session, @@ -607,6 +876,7 @@ def open_poll( poll_id=poll_id, action="open", idempotency_key=idempotency_key, + mutation_owner=mutation_owner, ).poll @@ -616,6 +886,7 @@ def draft_poll( tenant_id: str, poll_id: str, idempotency_key: str | None = None, + mutation_owner: PollMutationOwner | None = None, ) -> Poll: return transition_poll( session, @@ -623,6 +894,7 @@ def draft_poll( poll_id=poll_id, action="draft", idempotency_key=idempotency_key, + mutation_owner=mutation_owner, ).poll @@ -632,6 +904,7 @@ def close_poll( tenant_id: str, poll_id: str, idempotency_key: str | None = None, + mutation_owner: PollMutationOwner | None = None, ) -> Poll: return transition_poll( session, @@ -639,6 +912,7 @@ def close_poll( poll_id=poll_id, action="close", idempotency_key=idempotency_key, + mutation_owner=mutation_owner, ).poll @@ -649,6 +923,7 @@ def decide_poll( poll_id: str, payload: PollDecisionRequest, idempotency_key: str | None = None, + mutation_owner: PollMutationOwner | None = None, ) -> Poll: return transition_poll( session, @@ -658,6 +933,7 @@ def decide_poll( option_id=payload.option_id, option_key=payload.option_key, idempotency_key=idempotency_key, + mutation_owner=mutation_owner, ).poll @@ -667,6 +943,7 @@ def archive_poll( tenant_id: str, poll_id: str, idempotency_key: str | None = None, + mutation_owner: PollMutationOwner | None = None, ) -> Poll: return transition_poll( session, @@ -674,6 +951,7 @@ def archive_poll( poll_id=poll_id, action="archive", idempotency_key=idempotency_key, + mutation_owner=mutation_owner, ).poll @@ -683,6 +961,7 @@ def unarchive_poll( tenant_id: str, poll_id: str, idempotency_key: str | None = None, + mutation_owner: PollMutationOwner | None = None, ) -> Poll: return transition_poll( session, @@ -690,6 +969,7 @@ def unarchive_poll( poll_id=poll_id, action="unarchive", idempotency_key=idempotency_key, + mutation_owner=mutation_owner, ).poll @@ -825,6 +1105,47 @@ def _lock_poll_for_response(session: Session, *, tenant_id: str, poll_id: str) - return poll +def poll_requires_governed_participation(*, poll: Poll) -> bool: + """Whether responses must pass through the owning module's gateway.""" + + return poll_mutation_owner(poll) is not None + + +def _assert_ordinary_participation_allowed(*, poll: Poll) -> None: + if poll_requires_governed_participation(poll=poll): + raise PollError(GOVERNED_PARTICIPATION_REQUIRED) + + +def _assert_governed_invitation_gateway_allowed( + *, + poll: Poll, + gateway: dict[str, Any], + adopt: bool = False, +) -> None: + """Bind governed invitations to one stable Poll owner. + + Context-owned Polls use their declared module resource. A standalone Poll + may opt into governance; its first governed invitation establishes the + owner for all later invitations. + """ + + try: + gateway_owner = poll_owner_ref( + module_id=gateway.get("module_id"), + resource_type=gateway.get("resource_type"), + resource_id=gateway.get("resource_id"), + ) + expected_owner = poll_mutation_owner(poll) + except PollError as exc: + raise PollError(GOVERNED_PARTICIPATION_GATEWAY_MISMATCH) from exc + if expected_owner is not None and expected_owner != gateway_owner: + raise PollError(GOVERNED_PARTICIPATION_GATEWAY_MISMATCH) + if poll.participation_gateway_ is None and adopt: + poll.participation_gateway_ = dict(gateway) + elif expected_owner is None: + raise PollError(GOVERNED_PARTICIPATION_GATEWAY_MISMATCH) + + def get_poll_response_for_respondents( session: Session, *, @@ -832,10 +1153,12 @@ def get_poll_response_for_respondents( poll_id: str, respondent_ids: tuple[str, ...], invitation_id: str | None = None, + projection_owner: PollMutationOwner | None = None, ) -> PollResponse | None: """Resolve a response from server-trusted respondent identities.""" - get_poll(session, tenant_id=tenant_id, poll_id=poll_id) + poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id) + _assert_poll_projection_owner(poll, projection_owner=projection_owner) ids = tuple(dict.fromkeys(value for value in respondent_ids if value)) if ids: response = ( @@ -856,7 +1179,14 @@ def get_poll_response_for_respondents( return next( ( response - for response in reversed(list_poll_responses(session, tenant_id=tenant_id, poll_id=poll_id)) + for response in reversed( + list_poll_responses( + session, + tenant_id=tenant_id, + poll_id=poll_id, + projection_owner=projection_owner, + ) + ) if (response.metadata_ or {}).get("invitation_id") == invitation_id ), None, @@ -873,6 +1203,7 @@ def update_poll_option( description: str | None, value: dict[str, Any] | None, metadata: dict[str, Any], + mutation_owner: PollMutationOwner | None = None, ) -> PollOption: """Update one option and invalidate only answers bound to that option. @@ -885,6 +1216,7 @@ def update_poll_option( tenant_id=tenant_id, poll_id=poll_id, ) + _assert_poll_mutation_owner(poll, mutation_owner=mutation_owner) if poll.status not in {"draft", "open"}: raise PollError("Only draft or open poll options can be edited") option = ( @@ -912,10 +1244,23 @@ def update_poll_option( if not changed: return option - responses = ( + responses = _locked_active_poll_responses(session, poll=poll) + if responses and not poll.allow_response_update: + raise PollError("Poll options cannot be edited after responses when response updates are disabled") + option.label = label + option.description = description + option.value = normalized_value + option.metadata_ = normalized_metadata + _invalidate_option_answers(responses, option_id=option.id) + session.flush() + return option + + +def _locked_active_poll_responses(session: Session, *, poll: Poll) -> list[PollResponse]: + return ( session.query(PollResponse) .filter( - PollResponse.tenant_id == tenant_id, + PollResponse.tenant_id == poll.tenant_id, PollResponse.poll_id == poll.id, PollResponse.deleted_at.is_(None), ) @@ -924,32 +1269,154 @@ def update_poll_option( .with_for_update() .all() ) - if responses and not poll.allow_response_update: - raise PollError("Poll options cannot be edited after responses when response updates are disabled") - option.label = label - option.description = description - option.value = normalized_value - option.metadata_ = normalized_metadata + + +def _invalidate_option_answers( + responses: list[PollResponse], + *, + option_id: str, +) -> int: + invalidated = 0 for response in responses: retained_answers = [ answer for answer in (response.answers or []) - if not isinstance(answer, dict) or answer.get("option_id") != option.id + if not isinstance(answer, dict) or answer.get("option_id") != option_id ] if retained_answers != (response.answers or []): + invalidated += 1 response.answers = retained_answers if not retained_answers: response.deleted_at = _now() + return invalidated + + +def add_poll_option( + session: Session, + *, + tenant_id: str, + poll_id: str, + option: PollOptionInput, + mutation_owner: PollMutationOwner | None = None, +) -> tuple[PollOption, bool]: + """Append an option; an exact keyed retry returns the existing option.""" + + poll = _lock_poll_for_response(session, tenant_id=tenant_id, poll_id=poll_id) + _assert_poll_mutation_owner(poll, mutation_owner=mutation_owner) + if not option.label.strip(): + raise PollError("Poll option label cannot be empty") + key = option.key or slugify(option.label, fallback="option") + existing = ( + session.query(PollOption) + .filter(PollOption.tenant_id == tenant_id, PollOption.poll_id == poll.id, PollOption.key == key) + .one_or_none() + ) + normalized_value = dict(option.value) if option.value is not None else None + normalized_metadata = dict(option.metadata) + if existing is not None: + if ( + existing.deleted_at is None + and existing.label == option.label + and existing.description == option.description + and existing.value == normalized_value + and (existing.metadata_ or {}) == normalized_metadata + ): + return existing, True + raise PollError(f"Duplicate poll option key: {key}") + if poll.status not in {"draft", "open"}: + raise PollError("Only draft or open poll options can be edited") + position = max((stored.position for stored in poll.options), default=-1) + 1 + created = PollOption( + tenant_id=tenant_id, + poll_id=poll.id, + key=key, + label=option.label, + description=option.description, + position=position, + value=normalized_value, + metadata_=normalized_metadata, + ) + session.add(created) + _synchronize_mutable_choice_bounds( + poll, + active_option_count=len(_active_options(poll)) + 1, + ) session.flush() - return option + return created, False + + +def remove_poll_option( + session: Session, + *, + tenant_id: str, + poll_id: str, + option_id: str, + mutation_owner: PollMutationOwner | None = None, +) -> tuple[PollOption, bool, int]: + """Soft-remove an option and invalidate only answers tied to it.""" + + poll = _lock_poll_for_response(session, tenant_id=tenant_id, poll_id=poll_id) + _assert_poll_mutation_owner(poll, mutation_owner=mutation_owner) + option = ( + session.query(PollOption) + .filter( + PollOption.tenant_id == tenant_id, + PollOption.poll_id == poll.id, + PollOption.id == option_id, + ) + .populate_existing() + .with_for_update() + .one_or_none() + ) + if option is None: + raise PollError("Unknown poll option") + if option.deleted_at is not None: + return option, True, 0 + if poll.status not in {"draft", "open"}: + raise PollError("Only draft or open poll options can be edited") + remaining_count = sum( + stored.deleted_at is None and stored.id != option.id for stored in poll.options + ) + required_count = 1 if poll.kind == "availability" else 2 + if poll.kind == "yes_no_maybe": + required_count = 3 + if remaining_count < required_count or poll.min_choices > remaining_count: + raise PollError("Poll option cannot be removed because too few options would remain") + responses = _locked_active_poll_responses(session, poll=poll) + if responses and not poll.allow_response_update: + raise PollError("Poll options cannot be edited after responses when response updates are disabled") + invalidated = _invalidate_option_answers(responses, option_id=option.id) + option.deleted_at = _now() + _synchronize_mutable_choice_bounds( + poll, + active_option_count=remaining_count, + ) + session.flush() + return option, False, invalidated + + +def _synchronize_mutable_choice_bounds( + poll: Poll, + *, + active_option_count: int, +) -> None: + """Keep response bounds coherent after generic option mutations.""" + + if poll.kind == "availability": + poll.min_choices = 1 + poll.max_choices = active_option_count + elif poll.max_choices is not None and poll.max_choices > active_option_count: + poll.max_choices = active_option_count def submit_poll_response(session: Session, *, tenant_id: str, poll_id: str, payload: PollSubmitResponseRequest) -> PollResponse: + assert_no_sensitive_participation_metadata(payload.metadata) poll = _lock_poll_for_response( session, tenant_id=tenant_id, poll_id=poll_id, ) + _assert_ordinary_participation_allowed(poll=poll) _assert_poll_accepts_responses(poll) if payload.respondent_id is None and not poll.allow_anonymous: raise PollError("Anonymous responses are not allowed for this poll") @@ -1070,8 +1537,15 @@ def poll_response_item(response: PollResponse) -> dict[str, Any]: } -def list_poll_responses(session: Session, *, tenant_id: str, poll_id: str) -> list[PollResponse]: - get_poll(session, tenant_id=tenant_id, poll_id=poll_id) +def list_poll_responses( + session: Session, + *, + tenant_id: str, + poll_id: str, + projection_owner: PollMutationOwner | None = None, +) -> list[PollResponse]: + poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id) + _assert_poll_projection_owner(poll, projection_owner=projection_owner) return ( session.query(PollResponse) .filter(PollResponse.tenant_id == tenant_id, PollResponse.poll_id == poll_id, PollResponse.deleted_at.is_(None)) @@ -1090,10 +1564,29 @@ def create_poll_invitation( tenant_id: str, poll_id: str, payload: PollInvitationCreateRequest, + governed_capability: bool = False, ) -> tuple[PollInvitation, str]: - get_poll(session, tenant_id=tenant_id, poll_id=poll_id) + assert_no_sensitive_participation_metadata(payload.metadata) if payload.expires_at is not None and response_datetime(payload.expires_at) <= _now(): raise PollError("Invitation expiry must be in the future") + # Invitation creation and direct response writes use the same Poll lock so + # a Poll cannot concurrently acquire a governed owner while an ordinary + # invitation or response slips through. + poll = _lock_poll_for_response( + session, + tenant_id=tenant_id, + poll_id=poll_id, + ) + if payload.response_gateway is None: + _assert_ordinary_participation_allowed(poll=poll) + else: + if not governed_capability: + raise PollError(GOVERNED_INVITATION_CAPABILITY_REQUIRED) + _assert_governed_invitation_gateway_allowed( + poll=poll, + gateway=payload.response_gateway.model_dump(mode="json"), + adopt=True, + ) for _attempt in range(5): token = secrets.token_urlsafe(32) token_hash = _token_hash(token) @@ -1110,6 +1603,16 @@ def create_poll_invitation( respondent_label=payload.respondent_label, email=payload.email, expires_at=payload.expires_at, + response_gateway_=( + payload.response_gateway.model_dump(mode="json") + if payload.response_gateway is not None + else None + ), + participation_policy_=( + payload.participation_policy.model_dump(mode="json") + if payload.participation_policy is not None + else None + ), metadata_=payload.metadata, ) session.add(invitation) @@ -1117,8 +1620,15 @@ def create_poll_invitation( return invitation, token -def list_poll_invitations(session: Session, *, tenant_id: str, poll_id: str) -> list[PollInvitation]: - get_poll(session, tenant_id=tenant_id, poll_id=poll_id) +def list_poll_invitations( + session: Session, + *, + tenant_id: str, + poll_id: str, + projection_owner: PollMutationOwner | None = None, +) -> list[PollInvitation]: + poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id) + _assert_poll_projection_owner(poll, projection_owner=projection_owner) return ( session.query(PollInvitation) .filter(PollInvitation.tenant_id == tenant_id, PollInvitation.poll_id == poll_id) @@ -1138,11 +1648,57 @@ def get_poll_invitation(session: Session, *, tenant_id: str, poll_id: str, invit return invitation -def revoke_poll_invitation(session: Session, *, tenant_id: str, poll_id: str, invitation_id: str) -> PollInvitation: - invitation = get_poll_invitation(session, tenant_id=tenant_id, poll_id=poll_id, invitation_id=invitation_id) +def revoke_poll_invitation_with_replay( + session: Session, + *, + tenant_id: str, + poll_id: str, + invitation_id: str, + mutation_owner: PollMutationOwner | None = None, +) -> tuple[PollInvitation, bool]: + """Revoke under one row lock and report whether it was already revoked.""" + + poll = _lock_poll_for_response( + session, + tenant_id=tenant_id, + poll_id=poll_id, + ) + _assert_poll_mutation_owner(poll, mutation_owner=mutation_owner) + invitation = ( + session.query(PollInvitation) + .filter( + PollInvitation.tenant_id == tenant_id, + PollInvitation.poll_id == poll_id, + PollInvitation.id == invitation_id, + ) + .populate_existing() + .with_for_update() + .one_or_none() + ) + if invitation is None: + raise PollError("Poll invitation not found") + replayed = invitation.revoked_at is not None if invitation.revoked_at is None: invitation.revoked_at = _now() session.flush() + return invitation, replayed + + +def revoke_poll_invitation( + session: Session, + *, + tenant_id: str, + poll_id: str, + invitation_id: str, + mutation_owner: PollMutationOwner | None = None, +) -> PollInvitation: + invitation, _replayed = revoke_poll_invitation_with_replay( + session, + tenant_id=tenant_id, + poll_id=poll_id, + invitation_id=invitation_id, + mutation_owner=mutation_owner, + ) return invitation @@ -1158,11 +1714,27 @@ def get_poll_invitation_by_token(session: Session, *, token: str) -> PollInvitat def get_poll_by_invitation_token(session: Session, *, token: str) -> Poll: - return get_poll_invitation_by_token(session, token=token).poll + invitation = get_poll_invitation_by_token(session, token=token) + if ( + invitation.response_gateway_ is not None + or invitation.participation_policy_ is not None + or poll_requires_governed_participation(poll=invitation.poll) + ): + # A gateway-bound invitation must never fall back to Poll's legacy + # public route, even if its policy data is corrupt or its gateway is + # temporarily unavailable. + raise PollError("Poll invitation not found") + return invitation.poll def submit_poll_response_with_token(session: Session, *, token: str, payload: PollSubmitResponseRequest) -> PollResponse: invitation = get_poll_invitation_by_token(session, token=token) + if ( + invitation.response_gateway_ is not None + or invitation.participation_policy_ is not None + or poll_requires_governed_participation(poll=invitation.poll) + ): + raise PollError("Poll invitation not found") metadata = dict(payload.metadata) metadata["invitation_id"] = invitation.id response_payload = PollSubmitResponseRequest( @@ -1195,6 +1767,8 @@ def poll_invitation_response(invitation: PollInvitation, *, token: str | None = "last_used_at": response_datetime(invitation.last_used_at), "created_at": response_datetime(invitation.created_at), "updated_at": response_datetime(invitation.updated_at), + "response_gateway": invitation.response_gateway_, + "participation_policy": invitation.participation_policy_, "metadata": invitation.metadata_ or {}, } diff --git a/tests/test_manifest.py b/tests/test_manifest.py index f8feb43..80f8a4c 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -4,6 +4,7 @@ import unittest from govoplan_core.core.modules import ModuleManifest from govoplan_poll.backend.manifest import get_manifest +from govoplan_poll.backend.participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY class PollManifestTests(unittest.TestCase): @@ -21,6 +22,9 @@ class PollManifestTests(unittest.TestCase): self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.provides_interfaces}) self.assertIn("poll.workflow_context", {interface.name for interface in manifest.provides_interfaces}) self.assertIn("poll.signed_participation", {interface.name for interface in manifest.provides_interfaces}) + self.assertIn("poll.governed_participation", {interface.name for interface in manifest.provides_interfaces}) + self.assertIn(CAPABILITY_POLL_PARTICIPATION_GATEWAY, manifest.capability_factories) + self.assertEqual(manifest.version, "0.1.10") self.assertIn("poll:response:write", {permission.scope for permission in manifest.permissions}) diff --git a/tests/test_participation_gateway.py b/tests/test_participation_gateway.py new file mode 100644 index 0000000..80d21bb --- /dev/null +++ b/tests/test_participation_gateway.py @@ -0,0 +1,1158 @@ +from __future__ import annotations + +import unittest +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import MagicMock + +from fastapi import HTTPException +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.poll import ( + PollAnswerRequest, + PollCapabilityError, + PollCreateCommand, + PollOptionRequest, + PollSubmitResponseCommand, + PollUpdateCommand, +) +from govoplan_core.db.base import Base, utcnow +from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider +from govoplan_poll.backend.db.models import ( + Poll, + PollInvitation, + PollLifecycleTransition, + PollOption, + PollParticipationSubmission, + PollResponse, +) +from govoplan_poll.backend.participation import ( + ANONYMOUS_PASSWORD_REQUIREMENT, + PollGovernedInvitationCommand, + PollGovernedResponseCommand, + PollParticipationPolicy, + PollResponseGatewayRef, +) +from govoplan_poll.backend.router import ( + api_close_poll, + api_create_poll, + api_create_poll_invitation, + api_get_public_poll, + api_list_poll_invitations, + api_list_poll_responses, + api_revoke_poll_invitation, + api_submit_poll_response, + api_submit_public_poll_response, + api_update_poll, +) +from govoplan_poll.backend.schemas import ( + PollAnswerInput, + PollCreateRequest, + PollInvitationCreateRequest, + PollOptionInput, + PollSubmitResponseRequest, + PollUpdateRequest, +) +from govoplan_poll.backend.service import ( + PollError, + _lock_poll_for_response, + add_poll_option, + create_poll, + create_poll_invitation, +) + + +class PollParticipationGatewayTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite:///:memory:") + self.tables = [ + Poll.__table__, + PollOption.__table__, + PollResponse.__table__, + PollInvitation.__table__, + PollParticipationSubmission.__table__, + PollLifecycleTransition.__table__, + ] + Base.metadata.create_all(self.engine, tables=self.tables) + self.Session = sessionmaker(bind=self.engine) + self.session: Session = self.Session() + self.provider = SqlPollSchedulingProvider() + self.gateway = PollResponseGatewayRef( + module_id="example_gateway", + resource_type="availability_request", + resource_id="request-1", + ) + + def tearDown(self) -> None: + self.session.close() + Base.metadata.drop_all(self.engine, tables=list(reversed(self.tables))) + self.engine.dispose() + + def _poll(self) -> Poll: + poll = create_poll( + self.session, + tenant_id="tenant-1", + user_id="owner-1", + payload=PollCreateRequest( + title="Availability", + kind="availability", + status="open", + options=[ + PollOptionInput(key="slot-1", label="Monday"), + PollOptionInput(key="slot-2", label="Tuesday"), + ], + ), + ) + return poll + + def _invitation( + self, + poll: Poll, + *, + policy: PollParticipationPolicy | None = None, + email: str | None = None, + expires_at=None, + ): + return self.provider.create_governed_invitation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + command=PollGovernedInvitationCommand( + gateway=self.gateway, + policy=policy or PollParticipationPolicy(), + email=email, + expires_at=expires_at, + ), + ) + + @staticmethod + def _principal(*scopes: str) -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="account-1", + membership_id="membership-1", + tenant_id="tenant-1", + scopes=frozenset(scopes), + display_name="Participant One", + ), + account=SimpleNamespace(id="account-1"), + user=SimpleNamespace(id="membership-1"), + ) + + @staticmethod + def _answers(*values: str) -> tuple[PollAnswerRequest, ...]: + return tuple( + PollAnswerRequest(option_key=f"slot-{index}", value=value) + for index, value in enumerate(values, start=1) + ) + + def test_gateway_bound_invitation_cannot_use_legacy_public_routes(self) -> None: + poll = self._poll() + invitation_ref = self._invitation(poll) + + with self.assertRaises(HTTPException) as view_error: + api_get_public_poll(invitation_ref.token, session=self.session) + with self.assertRaises(HTTPException) as response_error: + api_submit_public_poll_response( + invitation_ref.token, + PollSubmitResponseRequest(), + session=self.session, + ) + with self.assertRaises(PollCapabilityError) as wrong_gateway: + self.provider.resolve_participation( + self.session, + token=invitation_ref.token, + gateway=PollResponseGatewayRef( + module_id="other", + resource_type="availability_request", + resource_id="request-1", + ), + ) + + self.assertEqual(view_error.exception.status_code, 404) + self.assertEqual(response_error.exception.status_code, 404) + self.assertEqual(view_error.exception.detail, "Poll invitation not found") + self.assertEqual(response_error.exception.detail, "Poll invitation not found") + self.assertEqual(str(wrong_gateway.exception), "Poll invitation not found") + + def test_module_owned_poll_has_no_ordinary_participation_bypass(self) -> None: + poll = self._poll() + legacy_invitation, legacy_token = create_poll_invitation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + payload=PollInvitationCreateRequest(respondent_id="membership-1"), + ) + poll.context_module = self.gateway.module_id + poll.context_resource_type = self.gateway.resource_type + poll.context_resource_id = self.gateway.resource_id + self.session.flush() + + with self.assertRaises(HTTPException) as direct_response: + api_submit_poll_response( + poll.id, + PollSubmitResponseRequest( + answers=[ + PollAnswerInput(option_key="slot-1", value="available") + ] + ), + session=self.session, + principal=self._principal("poll:response:write"), + ) + with self.assertRaises(HTTPException) as ordinary_invitation: + api_create_poll_invitation( + poll.id, + PollInvitationCreateRequest(respondent_id="membership-2"), + session=self.session, + principal=self._principal("poll:poll:write"), + ) + with self.assertRaises(HTTPException) as forged_governed_invitation: + api_create_poll_invitation( + poll.id, + PollInvitationCreateRequest( + respondent_id="membership-2", + response_gateway={ + "module_id": self.gateway.module_id, + "resource_type": self.gateway.resource_type, + "resource_id": self.gateway.resource_id, + }, + participation_policy={}, + ), + session=self.session, + principal=self._principal("poll:poll:write"), + ) + with self.assertRaises(HTTPException) as legacy_view: + api_get_public_poll(legacy_token, session=self.session) + with self.assertRaises(HTTPException) as legacy_response: + api_submit_public_poll_response( + legacy_token, + PollSubmitResponseRequest( + answers=[ + PollAnswerInput(option_key="slot-1", value="available") + ] + ), + session=self.session, + ) + + self.assertEqual(direct_response.exception.status_code, 400) + self.assertEqual( + direct_response.exception.detail, + "Poll participation is governed by its owning module", + ) + self.assertEqual(ordinary_invitation.exception.status_code, 400) + self.assertEqual(forged_governed_invitation.exception.status_code, 400) + self.assertEqual( + forged_governed_invitation.exception.detail, + "Governed invitations must be created through the participation gateway capability", + ) + self.assertEqual(legacy_view.exception.status_code, 404) + self.assertEqual(legacy_response.exception.status_code, 404) + self.assertIsNone(legacy_invitation.last_used_at) + self.assertEqual(self.session.query(PollResponse).count(), 0) + + with self.assertRaisesRegex( + PollCapabilityError, + "Participation gateway does not own this poll", + ): + self.provider.create_governed_invitation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + command=PollGovernedInvitationCommand( + gateway=PollResponseGatewayRef( + module_id="other", + resource_type=self.gateway.resource_type, + resource_id=self.gateway.resource_id, + ), + policy=PollParticipationPolicy(), + ), + ) + self.assertIsNotNone(self._invitation(poll).id) + + def test_governance_adoption_blocks_existing_legacy_links_but_not_other_polls(self) -> None: + governed_poll = self._poll() + _legacy_invitation, legacy_token = create_poll_invitation( + self.session, + tenant_id="tenant-1", + poll_id=governed_poll.id, + payload=PollInvitationCreateRequest(), + ) + self._invitation(governed_poll) + + with self.assertRaises(HTTPException) as legacy_view: + api_get_public_poll(legacy_token, session=self.session) + self.assertEqual(legacy_view.exception.status_code, 404) + with self.assertRaisesRegex( + PollCapabilityError, + "Poll participation is governed by its owning module", + ): + self.provider.submit_response( + self.session, + tenant_id="tenant-1", + poll_id=governed_poll.id, + command=PollSubmitResponseCommand( + respondent_id="account-1", + respondent_label="Participant One", + answers=(), + metadata={}, + ), + ) + + ordinary_poll = self._poll() + ordinary_invitation, ordinary_token = create_poll_invitation( + self.session, + tenant_id="tenant-1", + poll_id=ordinary_poll.id, + payload=PollInvitationCreateRequest(), + ) + self.assertEqual( + api_get_public_poll(ordinary_token, session=self.session).id, + ordinary_poll.id, + ) + self.assertEqual(ordinary_invitation.poll_id, ordinary_poll.id) + + def test_module_owner_controls_management_options_revocation_and_raw_data(self) -> None: + poll_ref = self.provider.create_poll( + self.session, + tenant_id="tenant-1", + user_id="owner-1", + command=PollCreateCommand( + title="Owned availability", + kind="availability", + status="open", + visibility="private", + result_visibility="organizer", + context_module=self.gateway.module_id, + context_resource_type=self.gateway.resource_type, + context_resource_id=self.gateway.resource_id, + options=( + PollOptionRequest(key="slot-1", label="Monday"), + PollOptionRequest(key="slot-2", label="Tuesday"), + ), + ), + ) + poll = self.session.query(Poll).filter(Poll.id == poll_ref.id).one() + invitation = self._invitation(poll) + manager = self._principal("poll:poll:read", "poll:poll:write") + + with self.assertRaises(HTTPException) as update_error: + api_update_poll( + poll.id, + PollUpdateRequest(title="Generic overwrite"), + session=self.session, + principal=manager, + ) + with self.assertRaises(HTTPException) as transition_error: + api_close_poll( + poll.id, + idempotency_key=None, + session=self.session, + principal=manager, + ) + with self.assertRaisesRegex( + PollError, + "Poll mutations must use its owning module capability", + ): + add_poll_option( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + option=PollOptionInput(key="slot-3", label="Wednesday"), + ) + with self.assertRaises(HTTPException) as revoke_error: + api_revoke_poll_invitation( + poll.id, + invitation.id, + session=self.session, + principal=manager, + ) + with self.assertRaises(HTTPException) as responses_error: + api_list_poll_responses( + poll.id, + session=self.session, + principal=manager, + ) + with self.assertRaises(HTTPException) as invitations_error: + api_list_poll_invitations( + poll.id, + session=self.session, + principal=manager, + ) + + self.assertEqual(update_error.exception.status_code, 409) + self.assertEqual(transition_error.exception.status_code, 409) + self.assertEqual(revoke_error.exception.status_code, 409) + self.assertEqual(responses_error.exception.status_code, 403) + self.assertEqual(invitations_error.exception.status_code, 403) + self.assertEqual(poll.title, "Owned availability") + self.assertEqual(poll.status, "open") + self.assertIsNone( + self.session.query(PollInvitation) + .filter(PollInvitation.id == invitation.id) + .one() + .revoked_at + ) + + self.provider.update_poll( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + command=PollUpdateCommand( + title="Owner update", + description=None, + visibility="private", + result_visibility="organizer", + allow_anonymous=False, + allow_response_update=True, + closes_at=None, + ), + ) + added = self.provider.add_option( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + command=PollOptionRequest(key="slot-3", label="Wednesday"), + ) + revoked = self.provider.revoke_invitation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + ) + closed = self.provider.close_poll( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + ) + + self.assertEqual(poll.title, "Owner update") + self.assertEqual(added.position, 2) + self.assertIsNotNone(revoked.revoked_at) + self.assertEqual(closed.status, "closed") + self.assertEqual(self.session.query(PollLifecycleTransition).count(), 1) + + def test_ordinary_api_cannot_claim_module_context_but_remains_manageable(self) -> None: + principal = self._principal("poll:poll:write") + poll = self._poll() + + with self.assertRaises(HTTPException) as create_error: + api_create_poll( + PollCreateRequest( + title="Forged module Poll", + kind="yes_no", + context_module="scheduling", + context_resource_type="scheduling_request", + context_resource_id="request-forged", + workflow_state="collecting_availability", + ), + session=self.session, + principal=principal, + ) + with self.assertRaises(HTTPException) as ownership_error: + api_update_poll( + poll.id, + PollUpdateRequest( + context_module="scheduling", + context_resource_type="scheduling_request", + context_resource_id="request-forged", + workflow_state="collecting_availability", + ), + session=self.session, + principal=principal, + ) + self.assertEqual(create_error.exception.status_code, 409) + self.assertEqual(ownership_error.exception.status_code, 409) + self.assertIsNone(poll.context_module) + + updated = api_update_poll( + poll.id, + PollUpdateRequest(title="Ordinary update"), + session=self.session, + principal=principal, + ) + closed = api_close_poll( + poll.id, + idempotency_key=None, + session=self.session, + principal=principal, + ) + self.assertEqual(updated.title, "Ordinary update") + self.assertEqual(closed.poll.status, "closed") + + def test_response_lock_requests_database_row_lock_for_concurrent_capacity(self) -> None: + session = MagicMock() + query = MagicMock() + poll = MagicMock() + session.query.return_value = query + query.filter.return_value = query + query.populate_existing.return_value = query + query.with_for_update.return_value = query + query.first.return_value = poll + + result = _lock_poll_for_response( + session, + tenant_id="tenant-1", + poll_id="poll-1", + ) + + self.assertIs(result, poll) + query.with_for_update.assert_called_once_with() + + def test_invalid_revoked_and_expired_links_have_the_same_public_error(self) -> None: + poll = self._poll() + active = self._invitation(poll) + revoked = self._invitation(poll) + expired = self._invitation(poll, expires_at=utcnow() + timedelta(minutes=1)) + self.session.query(PollInvitation).filter(PollInvitation.id == revoked.id).one().revoked_at = utcnow() + self.session.query(PollInvitation).filter( + PollInvitation.id == expired.id + ).one().expires_at = utcnow() - timedelta(seconds=1) + self.session.flush() + + details: list[tuple[int, str]] = [] + for token in ("not-a-token", active.token, revoked.token, expired.token): + with self.assertRaises(HTTPException) as caught: + api_get_public_poll(token, session=self.session) + details.append((caught.exception.status_code, caught.exception.detail)) + + self.assertEqual(details, [(404, "Poll invitation not found")] * 4) + + def test_policy_is_reenforced_by_poll_after_gateway_authorization(self) -> None: + poll = self._poll() + invitation = self._invitation( + poll, + policy=PollParticipationPolicy( + single_choice=True, + allow_maybe=False, + allow_comments=False, + participant_email_required=True, + anonymous_password_required=True, + ), + ) + + attempts = ( + ( + PollGovernedResponseCommand( + answers=self._answers("available"), + verified_requirements=frozenset({ANONYMOUS_PASSWORD_REQUIREMENT}), + ), + "email is required", + ), + ( + PollGovernedResponseCommand( + participant_email="person@example.test", + answers=self._answers("available"), + ), + "password verification is required", + ), + ( + PollGovernedResponseCommand( + participant_email="person@example.test", + answers=self._answers("maybe"), + verified_requirements=frozenset({ANONYMOUS_PASSWORD_REQUIREMENT}), + ), + "Maybe responses are not enabled", + ), + ( + PollGovernedResponseCommand( + participant_email="person@example.test", + answers=self._answers("available", "maybe"), + verified_requirements=frozenset({ANONYMOUS_PASSWORD_REQUIREMENT}), + ), + "Maybe responses are not enabled", + ), + ( + PollGovernedResponseCommand( + participant_email="person@example.test", + answers=self._answers("available"), + comment="Not permitted", + verified_requirements=frozenset({ANONYMOUS_PASSWORD_REQUIREMENT}), + ), + "Comments are not enabled", + ), + ) + for command, message in attempts: + with self.subTest(message=message), self.assertRaisesRegex( + PollCapabilityError, + message, + ): + self.provider.submit_governed_response( + self.session, + token=invitation.token, + gateway=self.gateway, + command=command, + ) + + accepted = self.provider.submit_governed_response( + self.session, + token=invitation.token, + gateway=self.gateway, + command=PollGovernedResponseCommand( + participant_email=" Person@Example.Test ", + answers=self._answers("available"), + verified_requirements=frozenset({ANONYMOUS_PASSWORD_REQUIREMENT}), + ), + ) + with self.assertRaisesRegex(PollCapabilityError, "Poll invitation not found"): + self.provider.resolve_participation( + self.session, + token=invitation.token, + gateway=self.gateway, + participant_email="person@example.test", + ) + protected_context = self.provider.resolve_participation( + self.session, + token=invitation.token, + gateway=self.gateway, + participant_email="person@example.test", + verified_requirements=frozenset({ANONYMOUS_PASSWORD_REQUIREMENT}), + ) + self.assertEqual(accepted.participant_email, "person@example.test") + self.assertIsNotNone(protected_context.response) + stored_invitation = self.session.query(PollInvitation).filter(PollInvitation.id == invitation.id).one() + self.assertEqual(stored_invitation.participation_policy_["anonymous_password_required"], True) + self.assertNotIn("correct horse", str(stored_invitation.participation_policy_)) + self.assertFalse(any("password" in column.name for column in PollInvitation.__table__.columns)) + + def test_authenticated_participant_does_not_need_anonymous_challenges(self) -> None: + poll = self._poll() + invitation = self._invitation( + poll, + policy=PollParticipationPolicy( + participant_email_required=True, + anonymous_password_required=True, + ), + ) + + result = self.provider.submit_governed_response( + self.session, + token=invitation.token, + gateway=self.gateway, + command=PollGovernedResponseCommand( + respondent_id="account-1", + participant_is_authenticated=True, + answers=self._answers("available"), + ), + ) + context = self.provider.resolve_participation( + self.session, + token=invitation.token, + gateway=self.gateway, + respondent_id="account-1", + participant_is_authenticated=True, + ) + anonymous_context = self.provider.resolve_participation( + self.session, + token=invitation.token, + gateway=self.gateway, + respondent_id="account-1", + participant_is_authenticated=False, + verified_requirements=frozenset({ANONYMOUS_PASSWORD_REQUIREMENT}), + ) + + self.assertEqual(result.response.respondent_id, "account-1") + self.assertIsNotNone(context.response) + self.assertEqual(context.response.response.respondent_id, "account-1") + self.assertIsNone(anonymous_context.response) + + def test_authenticated_invitation_id_path_is_bound_and_tokenless(self) -> None: + poll = self._poll() + invitation = self.provider.create_governed_invitation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + command=PollGovernedInvitationCommand( + gateway=self.gateway, + policy=PollParticipationPolicy(max_participants_per_option=1), + respondent_id="account-1", + ), + ) + response = self.provider.submit_authenticated_response( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + gateway=self.gateway, + respondent_id="account-1", + command=PollGovernedResponseCommand( + respondent_id="account-1", + participant_is_authenticated=True, + answers=self._answers("available"), + ), + ) + context = self.provider.resolve_authenticated_participation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + gateway=self.gateway, + respondent_id="account-1", + ) + + self.assertEqual(response.response.respondent_id, "account-1") + self.assertEqual(context.response.response.answers, response.response.answers) + with self.assertRaisesRegex(PollCapabilityError, "Poll invitation not found"): + self.provider.resolve_authenticated_participation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + gateway=self.gateway, + respondent_id="account-2", + ) + with self.assertRaisesRegex(PollCapabilityError, "Poll invitation not found"): + self.provider.resolve_authenticated_participation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + gateway=PollResponseGatewayRef( + module_id="other", + resource_type="availability_request", + resource_id="request-1", + ), + respondent_id="account-1", + ) + with self.assertRaisesRegex(PollCapabilityError, "Poll invitation not found"): + self.provider.submit_authenticated_response( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + gateway=self.gateway, + respondent_id="account-1", + command=PollGovernedResponseCommand( + respondent_id="account-1", + participant_is_authenticated=False, + answers=self._answers("available"), + ), + ) + self.provider.revoke_invitation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + ) + with self.assertRaisesRegex(PollCapabilityError, "Poll invitation not found"): + self.provider.resolve_authenticated_participation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + gateway=self.gateway, + respondent_id="account-1", + ) + + def test_allow_maybe_uses_resolved_option_identity(self) -> None: + poll = create_poll( + self.session, + tenant_id="tenant-1", + user_id="owner-1", + payload=PollCreateRequest( + title="Decision", + kind="yes_no_maybe", + status="open", + ), + ) + maybe = next(option for option in poll.options if option.key == "maybe") + invitation = self._invitation( + poll, + policy=PollParticipationPolicy(allow_maybe=False), + ) + + with self.assertRaisesRegex(PollCapabilityError, "Maybe responses are not enabled"): + self.provider.submit_governed_response( + self.session, + token=invitation.token, + gateway=self.gateway, + command=PollGovernedResponseCommand( + answers=(PollAnswerRequest(option_id=maybe.id),), + ), + ) + + def test_invalid_policy_uses_stable_capability_error(self) -> None: + poll = self._poll() + + with self.assertRaisesRegex(PollCapabilityError, "Invalid participation policy"): + self._invitation( + poll, + policy=PollParticipationPolicy(max_participants_per_option=0), + ) + + def test_secret_like_gateway_metadata_is_never_persisted(self) -> None: + poll = self._poll() + with self.assertRaisesRegex(PollCapabilityError, "Sensitive participation metadata"): + self.provider.create_governed_invitation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + command=PollGovernedInvitationCommand( + gateway=self.gateway, + policy=PollParticipationPolicy(), + metadata={"anonymous_password": "correct horse"}, + ), + ) + invitation = self._invitation(poll) + with self.assertRaisesRegex(PollCapabilityError, "Sensitive participation metadata"): + self.provider.submit_governed_response( + self.session, + token=invitation.token, + gateway=self.gateway, + command=PollGovernedResponseCommand( + answers=self._answers("available"), + metadata={"nested": {"credential": "correct horse"}}, + ), + ) + self.assertNotIn("correct horse", str(self.session.query(PollInvitation).all())) + self.assertEqual(self.session.query(PollResponse).count(), 0) + + for key in ("passwordHash", "accessToken", "credentialvalue"): + with self.subTest(key=key), self.assertRaisesRegex( + PollCapabilityError, + "Sensitive participation metadata", + ): + self.provider.create_governed_invitation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + command=PollGovernedInvitationCommand( + gateway=self.gateway, + policy=PollParticipationPolicy(), + metadata={key: "sensitive"}, + ), + ) + + def test_capacity_editing_prefill_and_idempotency_share_one_locked_path(self) -> None: + poll = self._poll() + policy = PollParticipationPolicy( + max_participants_per_option=1, + allow_comments=True, + participant_email_required=True, + ) + first_invitation = self._invitation(poll, policy=policy) + second_invitation = self._invitation(poll, policy=policy) + first_command = PollGovernedResponseCommand( + participant_email="one@example.test", + answers=self._answers("available"), + comment=" First choice ", + idempotency_key="submission-1", + ) + + first = self.provider.submit_governed_response( + self.session, + token=first_invitation.token, + gateway=self.gateway, + command=first_command, + ) + replay = self.provider.submit_governed_response( + self.session, + token=first_invitation.token, + gateway=self.gateway, + command=first_command, + ) + context = self.provider.resolve_participation( + self.session, + token=first_invitation.token, + gateway=self.gateway, + participant_email="one@example.test", + ) + + self.assertTrue(replay.replayed) + self.assertEqual(replay.response.submitted_at, first.response.submitted_at) + self.assertEqual(context.response.response.answers, first.response.answers) + self.assertEqual(context.response.comment, "First choice") + self.assertEqual(self.session.query(PollParticipationSubmission).count(), 1) + + with self.assertRaisesRegex(PollCapabilityError, "different response"): + self.provider.submit_governed_response( + self.session, + token=first_invitation.token, + gateway=self.gateway, + command=PollGovernedResponseCommand( + participant_email="one@example.test", + answers=self._answers("unavailable"), + idempotency_key="submission-1", + ), + ) + with self.assertRaisesRegex(PollCapabilityError, "Participant limit reached"): + self.provider.submit_governed_response( + self.session, + token=second_invitation.token, + gateway=self.gateway, + command=PollGovernedResponseCommand( + participant_email="two@example.test", + answers=self._answers("available"), + ), + ) + + edited = self.provider.submit_governed_response( + self.session, + token=first_invitation.token, + gateway=self.gateway, + command=PollGovernedResponseCommand( + participant_email="one@example.test", + answers=self._answers("unavailable"), + comment="Changed", + idempotency_key="submission-2", + ), + ) + claimed = self.provider.submit_governed_response( + self.session, + token=second_invitation.token, + gateway=self.gateway, + command=PollGovernedResponseCommand( + participant_email="two@example.test", + answers=self._answers("available"), + ), + ) + current_resource_replay = self.provider.submit_governed_response( + self.session, + token=first_invitation.token, + gateway=self.gateway, + command=first_command, + ) + self.assertEqual(edited.response.respondent_id, first.response.respondent_id) + self.assertNotEqual(claimed.response.respondent_id, first.response.respondent_id) + self.assertTrue(current_resource_replay.replayed) + self.assertEqual(current_resource_replay.response.answers[0].value, "unavailable") + self.assertEqual(current_resource_replay.comment, "Changed") + + def test_option_mutations_and_invitation_revocation_are_safe_and_idempotent(self) -> None: + poll = self._poll() + invitation = self._invitation(poll) + added = self.provider.add_option( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + command=PollOptionRequest(key="slot-3", label="Wednesday"), + ) + replayed_add = self.provider.add_option( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + command=PollOptionRequest(key="slot-3", label="Wednesday"), + ) + self.assertEqual(poll.min_choices, 1) + self.assertEqual(poll.max_choices, 3) + response = self.provider.submit_governed_response( + self.session, + token=invitation.token, + gateway=self.gateway, + command=PollGovernedResponseCommand( + participant_email="person@example.test", + answers=self._answers("available", "maybe", "unavailable"), + ), + ) + + removed = self.provider.remove_option( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + option_id=poll.options[0].id, + ) + replayed_remove = self.provider.remove_option( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + option_id=poll.options[0].id, + ) + self.assertEqual(poll.min_choices, 1) + self.assertEqual(poll.max_choices, 2) + remaining = self.provider.resolve_participation( + self.session, + token=invitation.token, + gateway=self.gateway, + participant_email="person@example.test", + ).response + + self.assertFalse(added.replayed) + self.assertTrue(replayed_add.replayed) + self.assertEqual(replayed_add.id, added.id) + self.assertEqual(removed.invalidated_response_count, 1) + self.assertTrue(replayed_remove.replayed) + self.assertEqual(len(remaining.response.answers), 2) + self.assertEqual(remaining.response.answers[0].option_key, "slot-2") + self.assertEqual(response.response.respondent_id, remaining.response.respondent_id) + + revoked = self.provider.revoke_invitation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + ) + replayed_revoke = self.provider.revoke_invitation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + ) + self.assertFalse(revoked.replayed) + self.assertTrue(replayed_revoke.replayed) + self.assertEqual(revoked.revoked_at, replayed_revoke.revoked_at) + with self.assertRaisesRegex(PollCapabilityError, "Poll invitation not found"): + self.provider.resolve_participation( + self.session, + token=invitation.token, + gateway=self.gateway, + ) + + def test_invitation_expiry_updates_preserve_token_and_exact_owner(self) -> None: + poll = self._poll() + invitation = self._invitation( + poll, + expires_at=utcnow() + timedelta(hours=1), + ) + stored = ( + self.session.query(PollInvitation) + .filter(PollInvitation.id == invitation.id) + .one() + ) + token_hash = stored.token_hash + expired_at = utcnow() - timedelta(minutes=1) + + expired = self.provider.update_invitation_expiry( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + gateway=self.gateway, + expires_at=expired_at, + ) + with self.assertRaisesRegex(PollCapabilityError, "Poll invitation not found"): + self.provider.resolve_participation( + self.session, + token=invitation.token, + gateway=self.gateway, + ) + future_expiry = utcnow() + timedelta(days=1) + extended = self.provider.update_invitation_expiry( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + gateway=self.gateway, + expires_at=future_expiry, + ) + replayed = self.provider.update_invitation_expiry( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + gateway=self.gateway, + expires_at=future_expiry, + ) + context = self.provider.resolve_participation( + self.session, + token=invitation.token, + gateway=self.gateway, + ) + + self.assertFalse(expired.replayed) + self.assertFalse(extended.replayed) + self.assertTrue(replayed.replayed) + self.assertEqual(context.invitation_id, invitation.id) + self.assertEqual(stored.token_hash, token_hash) + with self.assertRaisesRegex(PollCapabilityError, "Poll invitation not found"): + self.provider.update_invitation_expiry( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + gateway=PollResponseGatewayRef( + module_id="other", + resource_type=self.gateway.resource_type, + resource_id=self.gateway.resource_id, + ), + expires_at=None, + ) + stored.participation_policy_ = {"version": 999} + self.session.flush() + with self.assertRaises(PollCapabilityError) as corrupt_policy: + self.provider.update_invitation_expiry( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + gateway=self.gateway, + expires_at=None, + ) + self.assertEqual(str(corrupt_policy.exception), "Poll invitation not found") + self.provider.revoke_invitation( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + ) + with self.assertRaisesRegex(PollCapabilityError, "Poll invitation not found"): + self.provider.update_invitation_expiry( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + invitation_id=invitation.id, + gateway=self.gateway, + expires_at=None, + ) + + def test_option_mutation_replays_survive_close_and_bounds_stay_valid(self) -> None: + poll = self._poll() + added = self.provider.add_option( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + command=PollOptionRequest(key="slot-3", label="Wednesday"), + ) + removed = self.provider.remove_option( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + option_id=poll.options[0].id, + ) + poll.status = "closed" + self.session.flush() + + replayed_add = self.provider.add_option( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + command=PollOptionRequest(key="slot-3", label="Wednesday"), + ) + replayed_remove = self.provider.remove_option( + self.session, + tenant_id="tenant-1", + poll_id=poll.id, + option_id=removed.id, + ) + + self.assertEqual(replayed_add.id, added.id) + self.assertTrue(replayed_add.replayed) + self.assertTrue(replayed_remove.replayed) + + multiple_choice = create_poll( + self.session, + tenant_id="tenant-1", + user_id="owner-1", + payload=PollCreateRequest( + title="Choose", + kind="multiple_choice", + status="open", + max_choices=3, + options=[ + PollOptionInput(key=f"choice-{index}", label=f"Choice {index}") + for index in range(3) + ], + ), + ) + self.provider.remove_option( + self.session, + tenant_id="tenant-1", + poll_id=multiple_choice.id, + option_id=multiple_choice.options[0].id, + ) + self.assertEqual(multiple_choice.max_choices, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_service.py b/tests/test_service.py index 67268b5..7b2c700 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -22,6 +22,7 @@ from govoplan_poll.backend.service import ( create_poll, create_poll_invitation, open_poll, + poll_owner_ref, poll_result_summary_by_id, submit_poll_response, submit_poll_response_with_token, @@ -172,6 +173,11 @@ class PollServiceTests(unittest.TestCase): ], options=[PollOptionInput(key="slot-1", label="Monday")], ), + mutation_owner=poll_owner_ref( + module_id="scheduling", + resource_type="scheduling_request", + resource_id="request-1", + ), ) self.assertEqual(poll.context_module, "scheduling")