feat(poll): enforce governed participation ownership

This commit is contained in:
2026-07-21 21:17:04 +02:00
parent f839545605
commit 156486fcee
13 changed files with 3515 additions and 53 deletions

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from datetime import datetime
from pydantic import ValidationError
from govoplan_core.core.poll import (
PollAnswerRef,
@@ -8,6 +11,7 @@ from govoplan_core.core.poll import (
PollCreateCommand,
PollInvitationCommand,
PollInvitationRef,
PollOptionRequest,
PollOptionRef,
PollOptionUpdateCommand,
PollRef,
@@ -16,6 +20,31 @@ from govoplan_core.core.poll import (
PollSubmitResponseCommand,
PollUpdateCommand,
)
from govoplan_poll.backend.participation import (
ANONYMOUS_PASSWORD_REQUIREMENT,
PollGovernedInvitationCommand,
PollGovernedResponseCommand,
PollGovernedResponseRef,
PollInvitationExpiryRef,
PollInvitationRevocationRef,
PollOptionMutationRef,
PollParticipationContextRef,
PollResponseGatewayRef,
)
from govoplan_poll.backend.participation_service import (
governed_invitation,
governed_invitation_by_id,
participation_policy_payload,
participation_policy_ref,
response_for_invitation,
response_gateway_payload,
response_gateway_ref,
response_metadata,
submit_governed_poll_response,
submit_authenticated_poll_response,
update_governed_invitation_expiry,
)
from govoplan_poll.backend.db.models import PollInvitation
from govoplan_poll.backend.schemas import (
PollAnswerInput,
PollCreateRequest,
@@ -26,7 +55,9 @@ from govoplan_poll.backend.schemas import (
)
from govoplan_poll.backend.service import (
PollError,
PollMutationOwner,
close_poll,
add_poll_option,
create_poll,
create_poll_invitation,
decide_poll,
@@ -34,8 +65,15 @@ from govoplan_poll.backend.service import (
get_poll_response_for_respondents,
list_poll_responses,
open_poll,
poll_mutation_owner,
poll_owner_ref,
poll_result_summary_by_id,
response_datetime,
remove_poll_option,
revoke_poll_invitation_with_replay,
set_poll_workflow_context,
submit_poll_response,
update_poll_snapshot,
update_poll_option,
)
@@ -48,6 +86,32 @@ def _poll_ref(poll: object) -> PollRef:
)
def _command_owner(command: PollCreateCommand) -> PollMutationOwner | None:
context = (
command.context_module,
command.context_resource_type,
command.context_resource_id,
)
if not any(value is not None for value in context):
return None
return poll_owner_ref(
module_id=command.context_module,
resource_type=command.context_resource_type,
resource_id=command.context_resource_id,
)
def _stored_owner(
session: object,
*,
tenant_id: str,
poll_id: str,
) -> PollMutationOwner | None:
return poll_mutation_owner(
get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
)
def _response_invitation_id(response: object) -> str | None:
value = (response.metadata_ or {}).get("invitation_id")
return value if isinstance(value, str) else None
@@ -68,12 +132,47 @@ def _response_ref(response: object) -> PollResponseRef:
)
return PollResponseRef(
invitation_id=_response_invitation_id(response),
submitted_at=response.submitted_at,
submitted_at=response_datetime(response.submitted_at),
respondent_id=response.respondent_id,
answers=tuple(answers),
)
def _participation_context_ref(
session: object,
*,
invitation: PollInvitation,
respondent_id: str | None = None,
participant_email: str | None = None,
) -> PollParticipationContextRef:
policy = participation_policy_ref(invitation.participation_policy_)
response = response_for_invitation(
session,
invitation=invitation,
respondent_id=respondent_id,
participant_email=participant_email,
)
response_ref = None
if response is not None:
email, comment = response_metadata(response)
response_ref = PollGovernedResponseRef(
response=_response_ref(response),
participant_email=email,
comment=comment,
)
return PollParticipationContextRef(
invitation_id=invitation.id,
tenant_id=invitation.tenant_id,
poll_id=invitation.poll_id,
gateway=response_gateway_ref(invitation.response_gateway_),
policy=policy,
respondent_id=invitation.respondent_id or respondent_id,
respondent_label=invitation.respondent_label,
email=invitation.email,
response=response_ref,
)
class SqlPollSchedulingProvider(PollSchedulingProvider):
def create_poll(
self,
@@ -114,7 +213,13 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
metadata=dict(command.metadata),
)
try:
poll = create_poll(session, tenant_id=tenant_id, user_id=user_id, payload=payload)
poll = create_poll(
session,
tenant_id=tenant_id,
user_id=user_id,
payload=payload,
mutation_owner=_command_owner(command),
)
except PollError as exc:
raise PollCapabilityError(str(exc)) from exc
return _poll_ref(poll)
@@ -145,6 +250,272 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
raise PollCapabilityError(str(exc)) from exc
return PollInvitationRef(id=invitation.id, token=token)
def create_governed_invitation(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollGovernedInvitationCommand,
) -> PollInvitationRef:
try:
payload = PollInvitationCreateRequest(
respondent_id=command.respondent_id,
respondent_label=command.respondent_label,
email=command.email,
expires_at=command.expires_at,
response_gateway=response_gateway_payload(command.gateway),
participation_policy=participation_policy_payload(command.policy),
metadata=dict(command.metadata),
)
invitation, token = create_poll_invitation(
session,
tenant_id=tenant_id,
poll_id=poll_id,
payload=payload,
governed_capability=True,
)
except (PollError, ValidationError) as exc:
raise PollCapabilityError(str(exc)) from exc
return PollInvitationRef(id=invitation.id, token=token)
def resolve_participation(
self,
session: object,
*,
token: str,
gateway: PollResponseGatewayRef,
respondent_id: str | None = None,
participant_email: str | None = None,
participant_is_authenticated: bool = False,
verified_requirements: frozenset[str] = frozenset(),
) -> PollParticipationContextRef:
try:
invitation = governed_invitation(
session,
token=token,
gateway=gateway,
)
policy = participation_policy_ref(invitation.participation_policy_)
if (
not participant_is_authenticated
and policy.anonymous_password_required
and ANONYMOUS_PASSWORD_REQUIREMENT not in verified_requirements
):
raise PollError("Poll invitation not found")
return _participation_context_ref(
session,
invitation=invitation,
respondent_id=(respondent_id if participant_is_authenticated else None),
participant_email=participant_email,
)
except (PollError, ValidationError) as exc:
raise PollCapabilityError(str(exc)) from exc
def submit_governed_response(
self,
session: object,
*,
token: str,
gateway: PollResponseGatewayRef,
command: PollGovernedResponseCommand,
) -> PollGovernedResponseRef:
try:
_invitation, response, replayed = submit_governed_poll_response(
session,
token=token,
gateway=gateway,
command=command,
)
except (PollError, ValidationError) as exc:
raise PollCapabilityError(str(exc)) from exc
email, comment = response_metadata(response)
return PollGovernedResponseRef(
response=_response_ref(response),
participant_email=email,
comment=comment,
replayed=replayed,
)
def resolve_authenticated_participation(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
respondent_id: str,
) -> PollParticipationContextRef:
try:
invitation = governed_invitation_by_id(
session,
tenant_id=tenant_id,
poll_id=poll_id,
invitation_id=invitation_id,
gateway=gateway,
respondent_id=respondent_id,
)
return _participation_context_ref(
session,
invitation=invitation,
respondent_id=respondent_id,
)
except (PollError, ValidationError) as exc:
raise PollCapabilityError(str(exc)) from exc
def submit_authenticated_response(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
respondent_id: str,
command: PollGovernedResponseCommand,
) -> PollGovernedResponseRef:
try:
_invitation, response, replayed = submit_authenticated_poll_response(
session,
tenant_id=tenant_id,
poll_id=poll_id,
invitation_id=invitation_id,
gateway=gateway,
respondent_id=respondent_id,
command=command,
)
except (PollError, ValidationError) as exc:
raise PollCapabilityError(str(exc)) from exc
email, comment = response_metadata(response)
return PollGovernedResponseRef(
response=_response_ref(response),
participant_email=email,
comment=comment,
replayed=replayed,
)
def add_option(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
command: PollOptionRequest,
) -> PollOptionMutationRef:
try:
mutation_owner = _stored_owner(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
option, replayed = add_poll_option(
session,
tenant_id=tenant_id,
poll_id=poll_id,
option=PollOptionInput(
key=command.key,
label=command.label,
description=command.description,
value=dict(command.value) if command.value is not None else None,
metadata=dict(command.metadata),
),
mutation_owner=mutation_owner,
)
except (PollError, ValidationError) as exc:
raise PollCapabilityError(str(exc)) from exc
return PollOptionMutationRef(
id=option.id,
position=option.position,
replayed=replayed,
)
def remove_option(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
option_id: str,
) -> PollOptionMutationRef:
try:
mutation_owner = _stored_owner(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
option, replayed, invalidated = remove_poll_option(
session,
tenant_id=tenant_id,
poll_id=poll_id,
option_id=option_id,
mutation_owner=mutation_owner,
)
except (PollError, ValidationError) as exc:
raise PollCapabilityError(str(exc)) from exc
return PollOptionMutationRef(
id=option.id,
position=option.position,
replayed=replayed,
invalidated_response_count=invalidated,
)
def revoke_invitation(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
) -> PollInvitationRevocationRef:
try:
mutation_owner = _stored_owner(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
invitation, replayed = revoke_poll_invitation_with_replay(
session,
tenant_id=tenant_id,
poll_id=poll_id,
invitation_id=invitation_id,
mutation_owner=mutation_owner,
)
except (PollError, ValidationError) as exc:
raise PollCapabilityError(str(exc)) from exc
return PollInvitationRevocationRef(
id=invitation.id,
revoked_at=response_datetime(invitation.revoked_at),
replayed=replayed,
)
def update_invitation_expiry(
self,
session: object,
*,
tenant_id: str,
poll_id: str,
invitation_id: str,
gateway: PollResponseGatewayRef,
expires_at: datetime | None,
) -> PollInvitationExpiryRef:
try:
invitation, replayed = update_governed_invitation_expiry(
session,
tenant_id=tenant_id,
poll_id=poll_id,
invitation_id=invitation_id,
gateway=gateway,
expires_at=expires_at,
)
except (PollError, ValidationError) as exc:
raise PollCapabilityError(str(exc)) from exc
return PollInvitationExpiryRef(
id=invitation.id,
expires_at=response_datetime(invitation.expires_at),
replayed=replayed,
)
def submit_response(
self,
session: object,
@@ -187,17 +558,26 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
command: PollUpdateCommand,
) -> PollRef:
try:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
mutation_owner = _stored_owner(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
poll = update_poll_snapshot(
session,
tenant_id=tenant_id,
poll_id=poll_id,
title=command.title,
description=command.description,
visibility=command.visibility,
result_visibility=command.result_visibility,
allow_anonymous=command.allow_anonymous,
allow_response_update=command.allow_response_update,
closes_at=command.closes_at,
mutation_owner=mutation_owner,
)
except PollError as exc:
raise PollCapabilityError(str(exc)) from exc
poll.title = command.title
poll.description = command.description
poll.visibility = command.visibility
poll.result_visibility = command.result_visibility
poll.allow_anonymous = command.allow_anonymous
poll.allow_response_update = command.allow_response_update
poll.closes_at = command.closes_at
session.flush()
return _poll_ref(poll)
def update_option(
@@ -210,6 +590,11 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
command: PollOptionUpdateCommand,
) -> PollOptionRef:
try:
mutation_owner = _stored_owner(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
option = update_poll_option(
session,
tenant_id=tenant_id,
@@ -219,6 +604,7 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
description=command.description,
value=dict(command.value) if command.value is not None else None,
metadata=dict(command.metadata),
mutation_owner=mutation_owner,
)
except PollError as exc:
raise PollCapabilityError(str(exc)) from exc
@@ -239,11 +625,17 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
option_id: str | None,
) -> PollRef:
try:
mutation_owner = _stored_owner(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
poll = decide_poll(
session,
tenant_id=tenant_id,
poll_id=poll_id,
payload=PollDecisionRequest(option_id=option_id),
mutation_owner=mutation_owner,
)
except PollError as exc:
raise PollCapabilityError(str(exc)) from exc
@@ -268,15 +660,30 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
context_resource_id: str,
) -> PollRef:
try:
poll = get_poll(session, tenant_id=tenant_id, poll_id=poll_id)
mutation_owner = _stored_owner(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
requested_owner = poll_owner_ref(
module_id=context_module,
resource_type=context_resource_type,
resource_id=context_resource_id,
)
poll = set_poll_workflow_context(
session,
tenant_id=tenant_id,
poll_id=poll_id,
workflow_state=workflow_state,
workflow_steps=[dict(step) for step in workflow_steps],
context_module=context_module,
context_resource_type=context_resource_type,
context_resource_id=context_resource_id,
mutation_owner=mutation_owner,
requested_owner=requested_owner,
)
except PollError as exc:
raise PollCapabilityError(str(exc)) from exc
poll.workflow_state = workflow_state
poll.workflow_steps = [dict(step) for step in workflow_steps]
poll.context_module = context_module
poll.context_resource_type = context_resource_type
poll.context_resource_id = context_resource_id
session.flush()
return _poll_ref(poll)
def result_summary(self, session: object, *, tenant_id: str, poll_id: str) -> Mapping[str, object]:
@@ -287,7 +694,17 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
def list_responses(self, session: object, *, tenant_id: str, poll_id: str) -> tuple[PollResponseRef, ...]:
try:
responses = list_poll_responses(session, tenant_id=tenant_id, poll_id=poll_id)
projection_owner = _stored_owner(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
responses = list_poll_responses(
session,
tenant_id=tenant_id,
poll_id=poll_id,
projection_owner=projection_owner,
)
except PollError as exc:
raise PollCapabilityError(str(exc)) from exc
return tuple(_response_ref(response) for response in responses)
@@ -302,12 +719,18 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
invitation_id: str | None = None,
) -> PollResponseRef | None:
try:
projection_owner = _stored_owner(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
response = get_poll_response_for_respondents(
session,
tenant_id=tenant_id,
poll_id=poll_id,
respondent_ids=tuple(respondent_ids),
invitation_id=invitation_id,
projection_owner=projection_owner,
)
except PollError as exc:
raise PollCapabilityError(str(exc)) from exc
@@ -316,7 +739,17 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
@staticmethod
def _transition(callback, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
try:
poll = callback(session, tenant_id=tenant_id, poll_id=poll_id)
mutation_owner = _stored_owner(
session,
tenant_id=tenant_id,
poll_id=poll_id,
)
poll = callback(
session,
tenant_id=tenant_id,
poll_id=poll_id,
mutation_owner=mutation_owner,
)
except PollError as exc:
raise PollCapabilityError(str(exc)) from exc
return _poll_ref(poll)