feat(poll): enforce governed participation ownership
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user