feat(poll): enforce governed participation ownership
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
268
src/govoplan_poll/backend/participation.py
Normal file
268
src/govoplan_poll/backend/participation.py
Normal 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",
|
||||||
|
]
|
||||||
730
src/govoplan_poll/backend/participation_service.py
Normal file
730
src/govoplan_poll/backend/participation_service.py
Normal 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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1158
tests/test_participation_gateway.py
Normal file
1158
tests/test_participation_gateway.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user