Compare commits

...

3 Commits

16 changed files with 3583 additions and 55 deletions

View File

@@ -50,6 +50,72 @@ and authenticated API routes for:
- signed participation links for reduced/no-Access participation - signed participation links for reduced/no-Access participation
- context and workflow metadata for modules such as Scheduling - context and workflow metadata for modules such as Scheduling
## Governed signed participation
The v0.1.10 contract lets a consuming module bind an invitation to an exact
response gateway (`module_id`, `resource_type`, and `resource_id`) and snapshot
generic participation rules onto it. A bound invitation cannot use the legacy
`GET /poll/public/{token}` or `POST /poll/public/{token}/responses` routes: both
return the same generic not-found response used for invalid, expired, and
revoked links. There is deliberately no browser-facing Poll bypass when the
owning gateway is missing.
That boundary is Poll-wide. Setting `context_module` declares a module-owned
Poll; for a standalone Poll, its first governed invitation opts the whole Poll
into governed participation. From then on, direct authenticated response
writes, ordinary invitation creation, and every legacy public link fail closed,
including links created before governance was enabled. Governed invitations can
only be created through the in-process participation capability and their
gateway must match the Poll's declared module resource (or the standalone
Poll's first gateway). Polls that never opt into governance retain their
ordinary authenticated and signed-link behavior.
Module ownership also protects management boundaries. Generic Poll update,
lifecycle, option, and invitation-revocation paths cannot mutate an owned Poll,
and generic raw-response and invitation projections cannot bypass the owning
module's privacy policy. The in-process capability supplies the exact module,
resource type, and resource id again for every mutation; Poll compares that
owner while holding its row lock. Aggregate result summaries remain reusable.
Consumers resolve and submit bound invitations through the
`poll.participation_gateway` in-process capability. The policy covers:
- at most one non-`unavailable` selection
- whether `maybe` is accepted
- a maximum number of definitive participants per option
- response comments (trimmed and limited to 4,000 characters)
- email for anonymous participants
- an anonymous-password verification requirement
For availability polls, `available` reserves capacity while `maybe` does not.
Poll re-enforces these rules under its Poll-row lock, so concurrent final-place
claims serialize on PostgreSQL. A gateway-owned password never crosses the
capability boundary: the owning module stores the sole salted verifier,
throttles attempts before checking it, and supplies only the
`anonymous_password` verification attestation. Poll rejects secret-like
invitation/response metadata and stores no password column.
Authenticated module flows do not need to retain a public bearer token. They
can resolve and submit by exact tenant, Poll, governed invitation, gateway, and
respondent identifiers. Poll rejects mismatched identities and applies the same
Poll-row lock and policy checks used by the public-token path. A consumer can
lazily create that governed invitation, retain only its id, and discard the raw
token when no public link is needed.
An owning gateway can update a non-revoked invitation's expiry in place. A past
timestamp expires the existing link immediately; a later future timestamp or
`null` reactivates that same link without exposing or rotating its bearer token.
Revoked invitations remain revoked, and exact retries are idempotent.
The capability also supports durable invitation-scoped idempotency keys,
response prefill, option addition/removal, and invitation revocation. Exact
submission retries return the existing response; key reuse with different
content is rejected. Because a response is an editable resource, replay returns
its current representation rather than an immutable snapshot of the first
submission. Option removal and option changes invalidate only answers bound to
that option, and repeated removal/revocation is an idempotent replay even after
the Poll has moved out of an editable lifecycle state.
## Scheduling As A Poll-Backed Workflow ## Scheduling As A Poll-Backed Workflow
Scheduling should use Poll as the reusable response collection primitive, not Scheduling should use Poll as the reusable response collection primitive, not

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-poll" name = "govoplan-poll"
version = "0.1.9" version = "0.1.10"
description = "GovOPlaN lightweight poll and availability decision module seed." description = "GovOPlaN lightweight poll and availability decision module seed."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

View File

@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.9" __version__ = "0.1.10"

View File

@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from datetime import datetime
from pydantic import ValidationError
from govoplan_core.core.poll import ( from govoplan_core.core.poll import (
PollAnswerRef, PollAnswerRef,
@@ -8,6 +11,7 @@ from govoplan_core.core.poll import (
PollCreateCommand, PollCreateCommand,
PollInvitationCommand, PollInvitationCommand,
PollInvitationRef, PollInvitationRef,
PollOptionRequest,
PollOptionRef, PollOptionRef,
PollOptionUpdateCommand, PollOptionUpdateCommand,
PollRef, PollRef,
@@ -16,6 +20,31 @@ from govoplan_core.core.poll import (
PollSubmitResponseCommand, PollSubmitResponseCommand,
PollUpdateCommand, 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 ( from govoplan_poll.backend.schemas import (
PollAnswerInput, PollAnswerInput,
PollCreateRequest, PollCreateRequest,
@@ -26,7 +55,9 @@ from govoplan_poll.backend.schemas import (
) )
from govoplan_poll.backend.service import ( from govoplan_poll.backend.service import (
PollError, PollError,
PollMutationOwner,
close_poll, close_poll,
add_poll_option,
create_poll, create_poll,
create_poll_invitation, create_poll_invitation,
decide_poll, decide_poll,
@@ -34,8 +65,15 @@ from govoplan_poll.backend.service import (
get_poll_response_for_respondents, get_poll_response_for_respondents,
list_poll_responses, list_poll_responses,
open_poll, open_poll,
poll_mutation_owner,
poll_owner_ref,
poll_result_summary_by_id, poll_result_summary_by_id,
response_datetime,
remove_poll_option,
revoke_poll_invitation_with_replay,
set_poll_workflow_context,
submit_poll_response, submit_poll_response,
update_poll_snapshot,
update_poll_option, 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: def _response_invitation_id(response: object) -> str | None:
value = (response.metadata_ or {}).get("invitation_id") value = (response.metadata_ or {}).get("invitation_id")
return value if isinstance(value, str) else None return value if isinstance(value, str) else None
@@ -68,12 +132,47 @@ def _response_ref(response: object) -> PollResponseRef:
) )
return PollResponseRef( return PollResponseRef(
invitation_id=_response_invitation_id(response), invitation_id=_response_invitation_id(response),
submitted_at=response.submitted_at, submitted_at=response_datetime(response.submitted_at),
respondent_id=response.respondent_id, respondent_id=response.respondent_id,
answers=tuple(answers), 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): class SqlPollSchedulingProvider(PollSchedulingProvider):
def create_poll( def create_poll(
self, self,
@@ -114,7 +213,13 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
metadata=dict(command.metadata), metadata=dict(command.metadata),
) )
try: 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: except PollError as exc:
raise PollCapabilityError(str(exc)) from exc raise PollCapabilityError(str(exc)) from exc
return _poll_ref(poll) return _poll_ref(poll)
@@ -145,6 +250,272 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
raise PollCapabilityError(str(exc)) from exc raise PollCapabilityError(str(exc)) from exc
return PollInvitationRef(id=invitation.id, token=token) 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( def submit_response(
self, self,
session: object, session: object,
@@ -187,17 +558,26 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
command: PollUpdateCommand, command: PollUpdateCommand,
) -> PollRef: ) -> PollRef:
try: 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: except PollError as exc:
raise PollCapabilityError(str(exc)) from 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) return _poll_ref(poll)
def update_option( def update_option(
@@ -210,6 +590,11 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
command: PollOptionUpdateCommand, command: PollOptionUpdateCommand,
) -> PollOptionRef: ) -> PollOptionRef:
try: try:
mutation_owner = _stored_owner(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
option = update_poll_option( option = update_poll_option(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -219,6 +604,7 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
description=command.description, description=command.description,
value=dict(command.value) if command.value is not None else None, value=dict(command.value) if command.value is not None else None,
metadata=dict(command.metadata), metadata=dict(command.metadata),
mutation_owner=mutation_owner,
) )
except PollError as exc: except PollError as exc:
raise PollCapabilityError(str(exc)) from exc raise PollCapabilityError(str(exc)) from exc
@@ -239,11 +625,17 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
option_id: str | None, option_id: str | None,
) -> PollRef: ) -> PollRef:
try: try:
mutation_owner = _stored_owner(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
poll = decide_poll( poll = decide_poll(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
poll_id=poll_id, poll_id=poll_id,
payload=PollDecisionRequest(option_id=option_id), payload=PollDecisionRequest(option_id=option_id),
mutation_owner=mutation_owner,
) )
except PollError as exc: except PollError as exc:
raise PollCapabilityError(str(exc)) from exc raise PollCapabilityError(str(exc)) from exc
@@ -268,15 +660,30 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
context_resource_id: str, context_resource_id: str,
) -> PollRef: ) -> PollRef:
try: 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: except PollError as exc:
raise PollCapabilityError(str(exc)) from 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) return _poll_ref(poll)
def result_summary(self, session: object, *, tenant_id: str, poll_id: str) -> Mapping[str, object]: 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, ...]: def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> tuple[PollResponseRef, ...]:
try: 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: except PollError as exc:
raise PollCapabilityError(str(exc)) from exc raise PollCapabilityError(str(exc)) from exc
return tuple(_response_ref(response) for response in responses) return tuple(_response_ref(response) for response in responses)
@@ -302,12 +719,18 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
invitation_id: str | None = None, invitation_id: str | None = None,
) -> PollResponseRef | None: ) -> PollResponseRef | None:
try: try:
projection_owner = _stored_owner(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
response = get_poll_response_for_respondents( response = get_poll_response_for_respondents(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
poll_id=poll_id, poll_id=poll_id,
respondent_ids=tuple(respondent_ids), respondent_ids=tuple(respondent_ids),
invitation_id=invitation_id, invitation_id=invitation_id,
projection_owner=projection_owner,
) )
except PollError as exc: except PollError as exc:
raise PollCapabilityError(str(exc)) from exc raise PollCapabilityError(str(exc)) from exc
@@ -316,7 +739,17 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
@staticmethod @staticmethod
def _transition(callback, session: object, *, tenant_id: str, poll_id: str) -> PollRef: def _transition(callback, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
try: 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: except PollError as exc:
raise PollCapabilityError(str(exc)) from exc raise PollCapabilityError(str(exc)) from exc
return _poll_ref(poll) return _poll_ref(poll)

View File

@@ -42,6 +42,11 @@ class Poll(Base, TimestampMixin):
context_module: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) 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_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
context_resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=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_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) 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) 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) 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) 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) 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) metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
poll: Mapped[Poll] = relationship(back_populates="invitations") 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): class PollLifecycleTransition(Base, TimestampMixin):
__tablename__ = "poll_lifecycle_transitions" __tablename__ = "poll_lifecycle_transitions"
__table_args__ = ( __table_args__ = (
@@ -157,4 +201,12 @@ class PollLifecycleTransition(Base, TimestampMixin):
poll: Mapped[Poll] = relationship(back_populates="lifecycle_transitions") 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",
]

View File

@@ -15,11 +15,12 @@ from govoplan_core.core.modules import (
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING 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 from govoplan_poll.backend.db import models as poll_models # noqa: F401 - populate Poll ORM metadata
MODULE_ID = "poll" MODULE_ID = "poll"
MODULE_NAME = "Poll" MODULE_NAME = "Poll"
MODULE_VERSION = "0.1.9" MODULE_VERSION = "0.1.10"
READ_SCOPE = "poll:poll:read" READ_SCOPE = "poll:poll:read"
WRITE_SCOPE = "poll:poll:write" WRITE_SCOPE = "poll:poll:write"
ADMIN_SCOPE = "poll:poll:admin" ADMIN_SCOPE = "poll:poll:admin"
@@ -107,6 +108,10 @@ def _poll_scheduling_provider(context: ModuleContext) -> object:
return SqlPollSchedulingProvider() return SqlPollSchedulingProvider()
def _poll_participation_gateway_provider(context: ModuleContext) -> object:
return _poll_scheduling_provider(context)
manifest = ModuleManifest( manifest = ModuleManifest(
id=MODULE_ID, id=MODULE_ID,
name=MODULE_NAME, name=MODULE_NAME,
@@ -115,17 +120,21 @@ manifest = ModuleManifest(
optional_dependencies=("access", "calendar", "campaigns", "portal", "mail", "notifications", "forms_runtime"), optional_dependencies=("access", "calendar", "campaigns", "portal", "mail", "notifications", "forms_runtime"),
optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name="poll.option_selection", version="0.1.9"), ModuleInterfaceProvider(name="poll.option_selection", version=MODULE_VERSION),
ModuleInterfaceProvider(name="poll.availability_matrix", version="0.1.9"), ModuleInterfaceProvider(name="poll.availability_matrix", version=MODULE_VERSION),
ModuleInterfaceProvider(name="poll.response_collection", version="0.1.9"), ModuleInterfaceProvider(name="poll.response_collection", version=MODULE_VERSION),
ModuleInterfaceProvider(name="poll.workflow_context", version="0.1.9"), ModuleInterfaceProvider(name="poll.workflow_context", version=MODULE_VERSION),
ModuleInterfaceProvider(name="poll.signed_participation", version="0.1.9"), ModuleInterfaceProvider(name="poll.signed_participation", version=MODULE_VERSION),
ModuleInterfaceProvider(name="poll.governed_participation", version=MODULE_VERSION),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
route_factory=_poll_router, route_factory=_poll_router,
tenant_summary_providers=(_tenant_summary,), 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( migration_spec=MigrationSpec(
module_id=MODULE_ID, module_id=MODULE_ID,
metadata=Base.metadata, metadata=Base.metadata,
@@ -136,6 +145,7 @@ manifest = ModuleManifest(
poll_models.PollInvitation, poll_models.PollInvitation,
poll_models.PollLifecycleTransition, poll_models.PollLifecycleTransition,
poll_models.PollOption, poll_models.PollOption,
poll_models.PollParticipationSubmission,
poll_models.PollResponse, poll_models.PollResponse,
label="Poll", label="Poll",
), ),
@@ -147,6 +157,7 @@ manifest = ModuleManifest(
poll_models.PollInvitation, poll_models.PollInvitation,
poll_models.PollLifecycleTransition, poll_models.PollLifecycleTransition,
poll_models.PollOption, poll_models.PollOption,
poll_models.PollParticipationSubmission,
poll_models.PollResponse, poll_models.PollResponse,
label="Poll", label="Poll",
), ),

View File

@@ -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")

View File

@@ -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")

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -28,7 +28,12 @@ from govoplan_poll.backend.schemas import (
PollUpdateRequest, PollUpdateRequest,
) )
from govoplan_poll.backend.service import ( from govoplan_poll.backend.service import (
INVALID_POLL_OWNER,
OWNED_POLL_MUTATION_REQUIRED,
OWNED_POLL_PROJECTION_RESTRICTED,
POLL_OWNERSHIP_FIELDS_RESTRICTED,
PollError, PollError,
assert_generic_poll_projection_allowed,
create_poll_invitation, create_poll_invitation,
create_poll, create_poll,
get_poll_by_invitation_token, 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)) return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
if str(exc) == "Poll results are not visible": if str(exc) == "Poll results are not visible":
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) 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)) 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: def _poll_response(poll) -> PollResponse:
return PollResponse.model_validate(poll_response(poll)) return PollResponse.model_validate(poll_response(poll))
@@ -428,6 +452,7 @@ def api_list_poll_responses(
actor_ids=_principal_actor_ids(principal), actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_polls(principal), can_manage=_can_manage_polls(principal),
) )
assert_generic_poll_projection_allowed(poll)
_require_sensitive_poll_data_access(principal, poll) _require_sensitive_poll_data_access(principal, poll)
require_visible_poll_results( require_visible_poll_results(
session, session,
@@ -499,6 +524,7 @@ def api_list_poll_invitations(
actor_ids=_principal_actor_ids(principal), actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_polls(principal), can_manage=_can_manage_polls(principal),
) )
assert_generic_poll_projection_allowed(poll)
_require_sensitive_poll_data_access(principal, poll) _require_sensitive_poll_data_access(principal, poll)
invitations = list_poll_invitations(session, tenant_id=principal.tenant_id, poll_id=poll_id) invitations = list_poll_invitations(session, tenant_id=principal.tenant_id, poll_id=poll_id)
except PollError as exc: except PollError as exc:
@@ -538,7 +564,7 @@ def api_get_public_poll(
try: try:
return _poll_response(get_poll_by_invitation_token(session, token=token)) return _poll_response(get_poll_by_invitation_token(session, token=token))
except PollError as exc: 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) @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: try:
response = submit_poll_response_with_token(session, token=token, payload=payload) response = submit_poll_response_with_token(session, token=token, payload=payload)
except PollError as exc: 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)) result = PollResponseItem.model_validate(poll_response_item(response))
session.commit() session.commit()
return result return result

View File

@@ -226,6 +226,26 @@ class PollResponseListResponse(BaseModel):
responses: list[PollResponseItem] = Field(default_factory=list) 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): class PollInvitationCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -233,8 +253,18 @@ class PollInvitationCreateRequest(BaseModel):
respondent_label: str | None = Field(default=None, max_length=500) respondent_label: str | None = Field(default=None, max_length=500)
email: str | None = Field(default=None, max_length=320) email: str | None = Field(default=None, max_length=320)
expires_at: datetime | None = None expires_at: datetime | None = None
response_gateway: PollResponseGatewayInput | None = None
participation_policy: PollParticipationPolicyInput | None = None
metadata: dict[str, Any] = Field(default_factory=dict) 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): class PollInvitationResponse(BaseModel):
id: str id: str
@@ -248,6 +278,8 @@ class PollInvitationResponse(BaseModel):
last_used_at: datetime | None = None last_used_at: datetime | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
response_gateway: PollResponseGatewayInput | None = None
participation_policy: PollParticipationPolicyInput | None = None
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)

View File

@@ -26,6 +26,26 @@ class PollError(ValueError):
pass 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_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice", "availability"}
POLL_INITIAL_STATUSES = {"draft", "open"} POLL_INITIAL_STATUSES = {"draft", "open"}
CHOICE_POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice"} CHOICE_POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ranked_choice"}
@@ -65,6 +85,115 @@ def _now() -> datetime:
return utcnow() 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]: def _active_options(poll: Poll) -> list[PollOption]:
return [option for option in poll.options if option.deleted_at is None] 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 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) options, min_choices, max_choices = _ensure_valid_poll_payload(payload)
base_slug = slugify(payload.slug or payload.title) base_slug = slugify(payload.slug or payload.title)
poll = Poll( poll = Poll(
@@ -342,8 +497,50 @@ def require_visible_poll_results(
raise PollError("Poll results are not visible") raise PollError("Poll results are not visible")
def update_poll(session: Session, *, tenant_id: str, poll_id: str, payload: PollUpdateRequest) -> Poll: def update_poll(
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id) 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"}: if poll.status in {"closed", "decided", "archived"}:
raise PollError("Closed, decided, or archived polls cannot be edited") raise PollError("Closed, decided, or archived polls cannot be edited")
for field in ( for field in (
@@ -381,6 +578,75 @@ def update_poll(session: Session, *, tenant_id: str, poll_id: str, payload: Poll
return 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: def _lock_poll_for_transition(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
poll = ( poll = (
session.query(Poll) session.query(Poll)
@@ -521,11 +787,13 @@ def transition_poll(
idempotency_key: str | None = None, idempotency_key: str | None = None,
actor_user_id: str | None = None, actor_user_id: str | None = None,
actor_api_key_id: str | None = None, actor_api_key_id: str | None = None,
mutation_owner: PollMutationOwner | None = None,
transition_engine: PollTransitionEngine = DEFAULT_POLL_TRANSITION_ENGINE, transition_engine: PollTransitionEngine = DEFAULT_POLL_TRANSITION_ENGINE,
) -> PollTransitionResult: ) -> PollTransitionResult:
"""Apply one policy-controlled transition and append its durable audit record.""" """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) 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) normalized_key = _normalize_idempotency_key(idempotency_key)
decision_option = _transition_decision_option( decision_option = _transition_decision_option(
poll, poll,
@@ -600,6 +868,7 @@ def open_poll(
tenant_id: str, tenant_id: str,
poll_id: str, poll_id: str,
idempotency_key: str | None = None, idempotency_key: str | None = None,
mutation_owner: PollMutationOwner | None = None,
) -> Poll: ) -> Poll:
return transition_poll( return transition_poll(
session, session,
@@ -607,6 +876,7 @@ def open_poll(
poll_id=poll_id, poll_id=poll_id,
action="open", action="open",
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
mutation_owner=mutation_owner,
).poll ).poll
@@ -616,6 +886,7 @@ def draft_poll(
tenant_id: str, tenant_id: str,
poll_id: str, poll_id: str,
idempotency_key: str | None = None, idempotency_key: str | None = None,
mutation_owner: PollMutationOwner | None = None,
) -> Poll: ) -> Poll:
return transition_poll( return transition_poll(
session, session,
@@ -623,6 +894,7 @@ def draft_poll(
poll_id=poll_id, poll_id=poll_id,
action="draft", action="draft",
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
mutation_owner=mutation_owner,
).poll ).poll
@@ -632,6 +904,7 @@ def close_poll(
tenant_id: str, tenant_id: str,
poll_id: str, poll_id: str,
idempotency_key: str | None = None, idempotency_key: str | None = None,
mutation_owner: PollMutationOwner | None = None,
) -> Poll: ) -> Poll:
return transition_poll( return transition_poll(
session, session,
@@ -639,6 +912,7 @@ def close_poll(
poll_id=poll_id, poll_id=poll_id,
action="close", action="close",
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
mutation_owner=mutation_owner,
).poll ).poll
@@ -649,6 +923,7 @@ def decide_poll(
poll_id: str, poll_id: str,
payload: PollDecisionRequest, payload: PollDecisionRequest,
idempotency_key: str | None = None, idempotency_key: str | None = None,
mutation_owner: PollMutationOwner | None = None,
) -> Poll: ) -> Poll:
return transition_poll( return transition_poll(
session, session,
@@ -658,6 +933,7 @@ def decide_poll(
option_id=payload.option_id, option_id=payload.option_id,
option_key=payload.option_key, option_key=payload.option_key,
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
mutation_owner=mutation_owner,
).poll ).poll
@@ -667,6 +943,7 @@ def archive_poll(
tenant_id: str, tenant_id: str,
poll_id: str, poll_id: str,
idempotency_key: str | None = None, idempotency_key: str | None = None,
mutation_owner: PollMutationOwner | None = None,
) -> Poll: ) -> Poll:
return transition_poll( return transition_poll(
session, session,
@@ -674,6 +951,7 @@ def archive_poll(
poll_id=poll_id, poll_id=poll_id,
action="archive", action="archive",
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
mutation_owner=mutation_owner,
).poll ).poll
@@ -683,6 +961,7 @@ def unarchive_poll(
tenant_id: str, tenant_id: str,
poll_id: str, poll_id: str,
idempotency_key: str | None = None, idempotency_key: str | None = None,
mutation_owner: PollMutationOwner | None = None,
) -> Poll: ) -> Poll:
return transition_poll( return transition_poll(
session, session,
@@ -690,6 +969,7 @@ def unarchive_poll(
poll_id=poll_id, poll_id=poll_id,
action="unarchive", action="unarchive",
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
mutation_owner=mutation_owner,
).poll ).poll
@@ -825,6 +1105,47 @@ def _lock_poll_for_response(session: Session, *, tenant_id: str, poll_id: str) -
return poll 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( def get_poll_response_for_respondents(
session: Session, session: Session,
*, *,
@@ -832,10 +1153,12 @@ def get_poll_response_for_respondents(
poll_id: str, poll_id: str,
respondent_ids: tuple[str, ...], respondent_ids: tuple[str, ...],
invitation_id: str | None = None, invitation_id: str | None = None,
projection_owner: PollMutationOwner | None = None,
) -> PollResponse | None: ) -> PollResponse | None:
"""Resolve a response from server-trusted respondent identities.""" """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)) ids = tuple(dict.fromkeys(value for value in respondent_ids if value))
if ids: if ids:
response = ( response = (
@@ -856,7 +1179,14 @@ def get_poll_response_for_respondents(
return next( return next(
( (
response 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 if (response.metadata_ or {}).get("invitation_id") == invitation_id
), ),
None, None,
@@ -873,6 +1203,7 @@ def update_poll_option(
description: str | None, description: str | None,
value: dict[str, Any] | None, value: dict[str, Any] | None,
metadata: dict[str, Any], metadata: dict[str, Any],
mutation_owner: PollMutationOwner | None = None,
) -> PollOption: ) -> PollOption:
"""Update one option and invalidate only answers bound to that option. """Update one option and invalidate only answers bound to that option.
@@ -885,6 +1216,7 @@ def update_poll_option(
tenant_id=tenant_id, tenant_id=tenant_id,
poll_id=poll_id, poll_id=poll_id,
) )
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
if poll.status not in {"draft", "open"}: if poll.status not in {"draft", "open"}:
raise PollError("Only draft or open poll options can be edited") raise PollError("Only draft or open poll options can be edited")
option = ( option = (
@@ -912,10 +1244,23 @@ def update_poll_option(
if not changed: if not changed:
return option 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) session.query(PollResponse)
.filter( .filter(
PollResponse.tenant_id == tenant_id, PollResponse.tenant_id == poll.tenant_id,
PollResponse.poll_id == poll.id, PollResponse.poll_id == poll.id,
PollResponse.deleted_at.is_(None), PollResponse.deleted_at.is_(None),
) )
@@ -924,32 +1269,154 @@ def update_poll_option(
.with_for_update() .with_for_update()
.all() .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 def _invalidate_option_answers(
option.description = description responses: list[PollResponse],
option.value = normalized_value *,
option.metadata_ = normalized_metadata option_id: str,
) -> int:
invalidated = 0
for response in responses: for response in responses:
retained_answers = [ retained_answers = [
answer answer
for answer in (response.answers or []) 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 []): if retained_answers != (response.answers or []):
invalidated += 1
response.answers = retained_answers response.answers = retained_answers
if not retained_answers: if not retained_answers:
response.deleted_at = _now() 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() 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: 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( poll = _lock_poll_for_response(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
poll_id=poll_id, poll_id=poll_id,
) )
_assert_ordinary_participation_allowed(poll=poll)
_assert_poll_accepts_responses(poll) _assert_poll_accepts_responses(poll)
if payload.respondent_id is None and not poll.allow_anonymous: if payload.respondent_id is None and not poll.allow_anonymous:
raise PollError("Anonymous responses are not allowed for this poll") 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]: def list_poll_responses(
get_poll(session, tenant_id=tenant_id, poll_id=poll_id) 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 ( return (
session.query(PollResponse) session.query(PollResponse)
.filter(PollResponse.tenant_id == tenant_id, PollResponse.poll_id == poll_id, PollResponse.deleted_at.is_(None)) .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, tenant_id: str,
poll_id: str, poll_id: str,
payload: PollInvitationCreateRequest, payload: PollInvitationCreateRequest,
governed_capability: bool = False,
) -> tuple[PollInvitation, str]: ) -> 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(): if payload.expires_at is not None and response_datetime(payload.expires_at) <= _now():
raise PollError("Invitation expiry must be in the future") 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): for _attempt in range(5):
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
token_hash = _token_hash(token) token_hash = _token_hash(token)
@@ -1110,6 +1603,16 @@ def create_poll_invitation(
respondent_label=payload.respondent_label, respondent_label=payload.respondent_label,
email=payload.email, email=payload.email,
expires_at=payload.expires_at, 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, metadata_=payload.metadata,
) )
session.add(invitation) session.add(invitation)
@@ -1117,8 +1620,15 @@ def create_poll_invitation(
return invitation, token return invitation, token
def list_poll_invitations(session: Session, *, tenant_id: str, poll_id: str) -> list[PollInvitation]: def list_poll_invitations(
get_poll(session, tenant_id=tenant_id, poll_id=poll_id) 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 ( return (
session.query(PollInvitation) session.query(PollInvitation)
.filter(PollInvitation.tenant_id == tenant_id, PollInvitation.poll_id == poll_id) .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 return invitation
def revoke_poll_invitation(session: Session, *, tenant_id: str, poll_id: str, invitation_id: str) -> PollInvitation: def revoke_poll_invitation_with_replay(
invitation = get_poll_invitation(session, tenant_id=tenant_id, poll_id=poll_id, invitation_id=invitation_id) 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: if invitation.revoked_at is None:
invitation.revoked_at = _now() invitation.revoked_at = _now()
session.flush() 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 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: 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: def submit_poll_response_with_token(session: Session, *, token: str, payload: PollSubmitResponseRequest) -> PollResponse:
invitation = get_poll_invitation_by_token(session, token=token) 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 = dict(payload.metadata)
metadata["invitation_id"] = invitation.id metadata["invitation_id"] = invitation.id
response_payload = PollSubmitResponseRequest( 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), "last_used_at": response_datetime(invitation.last_used_at),
"created_at": response_datetime(invitation.created_at), "created_at": response_datetime(invitation.created_at),
"updated_at": response_datetime(invitation.updated_at), "updated_at": response_datetime(invitation.updated_at),
"response_gateway": invitation.response_gateway_,
"participation_policy": invitation.participation_policy_,
"metadata": invitation.metadata_ or {}, "metadata": invitation.metadata_ or {},
} }

View File

@@ -4,6 +4,7 @@ import unittest
from govoplan_core.core.modules import ModuleManifest from govoplan_core.core.modules import ModuleManifest
from govoplan_poll.backend.manifest import get_manifest from govoplan_poll.backend.manifest import get_manifest
from govoplan_poll.backend.participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
class PollManifestTests(unittest.TestCase): 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.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.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.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}) self.assertIn("poll:response:write", {permission.scope for permission in manifest.permissions})

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@ from govoplan_poll.backend.service import (
create_poll, create_poll,
create_poll_invitation, create_poll_invitation,
open_poll, open_poll,
poll_owner_ref,
poll_result_summary_by_id, poll_result_summary_by_id,
submit_poll_response, submit_poll_response,
submit_poll_response_with_token, submit_poll_response_with_token,
@@ -172,6 +173,11 @@ class PollServiceTests(unittest.TestCase):
], ],
options=[PollOptionInput(key="slot-1", label="Monday")], 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") self.assertEqual(poll.context_module, "scheduling")