Compare commits
7 Commits
e2e816cb21
...
8fc030772b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fc030772b | |||
| 52cd362343 | |||
| 8ceb3935f5 | |||
| 8292c9d709 | |||
| 38ecff60a5 | |||
| eb003208e4 | |||
| cac7733bee |
32
README.md
32
README.md
@@ -116,6 +116,30 @@ submission. Option removal and option changes invalidate only answers bound to
|
|||||||
that option, and repeated removal/revocation is an idempotent replay even after
|
that option, and repeated removal/revocation is an idempotent replay even after
|
||||||
the Poll has moved out of an editable lifecycle state.
|
the Poll has moved out of an editable lifecycle state.
|
||||||
|
|
||||||
|
Owning modules can also retire a participant's responses through the optional
|
||||||
|
response-retirement extension. Retirement is idempotent and soft-deletes the
|
||||||
|
live rows so aggregation and capacity checks stop counting them, while answers
|
||||||
|
and a reason/source retirement record remain available for audit. The boundary
|
||||||
|
accepts only server-trusted respondent or invitation identities and rejects
|
||||||
|
secret-like metadata.
|
||||||
|
|
||||||
|
## Identified response invariant
|
||||||
|
|
||||||
|
Poll stores at most one active response for each identified respondent in a
|
||||||
|
Poll. PostgreSQL and SQLite enforce this with the partial unique index
|
||||||
|
`uq_poll_responses_active_respondent`; anonymous and tombstoned responses do
|
||||||
|
not participate in that invariant. If two submissions race, the losing insert
|
||||||
|
is rolled back to a savepoint and follows the ordinary update policy against
|
||||||
|
the winning row. Unrelated integrity errors are not converted into response
|
||||||
|
updates.
|
||||||
|
|
||||||
|
The migration deterministically retains the latest active row by
|
||||||
|
`submitted_at DESC, id DESC` and tombstones older duplicates. Apply it while
|
||||||
|
Poll response writes are quiesced or while the platform maintenance lock is
|
||||||
|
held. The released migration-head baseline must only be advanced as part of
|
||||||
|
the reviewed release that includes this migration; it is not a development
|
||||||
|
head ledger.
|
||||||
|
|
||||||
## Scheduling As A Poll-Backed Workflow
|
## Scheduling As A Poll-Backed Workflow
|
||||||
|
|
||||||
Scheduling should use Poll as the reusable response collection primitive, not
|
Scheduling should use Poll as the reusable response collection primitive, not
|
||||||
@@ -172,3 +196,11 @@ Focused manifest verification:
|
|||||||
cd /mnt/DATA/git/govoplan-poll
|
cd /mnt/DATA/git/govoplan-poll
|
||||||
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests
|
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run the optional two-session PostgreSQL race check against a disposable or
|
||||||
|
development database account that may create schemas:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GOVOPLAN_POLL_TEST_POSTGRES_URL=postgresql+psycopg://user@localhost/database \
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python -m pytest -q tests/test_response_uniqueness.py
|
||||||
|
```
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-poll"
|
name = "govoplan-poll"
|
||||||
version = "0.1.10"
|
version = "0.1.11"
|
||||||
description = "GovOPlaN lightweight poll and availability decision module seed."
|
description = "GovOPlaN lightweight poll and availability decision module seed."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.9",
|
"govoplan-core>=0.1.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.10"
|
__version__ = "0.1.11"
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ from govoplan_core.core.poll import (
|
|||||||
PollCreateCommand,
|
PollCreateCommand,
|
||||||
PollInvitationCommand,
|
PollInvitationCommand,
|
||||||
PollInvitationRef,
|
PollInvitationRef,
|
||||||
|
PollOptionOrderCommand,
|
||||||
PollOptionRequest,
|
PollOptionRequest,
|
||||||
PollOptionRef,
|
PollOptionRef,
|
||||||
PollOptionUpdateCommand,
|
PollOptionUpdateCommand,
|
||||||
PollRef,
|
PollRef,
|
||||||
PollResponseRef,
|
PollResponseRef,
|
||||||
|
PollResponseRetirementCommand,
|
||||||
|
PollResponseRetirementRef,
|
||||||
PollSchedulingProvider,
|
PollSchedulingProvider,
|
||||||
PollSubmitResponseCommand,
|
PollSubmitResponseCommand,
|
||||||
PollUpdateCommand,
|
PollUpdateCommand,
|
||||||
@@ -69,7 +72,9 @@ from govoplan_poll.backend.service import (
|
|||||||
poll_owner_ref,
|
poll_owner_ref,
|
||||||
poll_result_summary_by_id,
|
poll_result_summary_by_id,
|
||||||
response_datetime,
|
response_datetime,
|
||||||
|
retire_poll_responses,
|
||||||
remove_poll_option,
|
remove_poll_option,
|
||||||
|
reorder_poll_options,
|
||||||
revoke_poll_invitation_with_replay,
|
revoke_poll_invitation_with_replay,
|
||||||
set_poll_workflow_context,
|
set_poll_workflow_context,
|
||||||
submit_poll_response,
|
submit_poll_response,
|
||||||
@@ -82,7 +87,13 @@ def _poll_ref(poll: object) -> PollRef:
|
|||||||
return PollRef(
|
return PollRef(
|
||||||
id=poll.id,
|
id=poll.id,
|
||||||
status=poll.status,
|
status=poll.status,
|
||||||
options=tuple(PollOptionRef(id=option.id, position=option.position) for option in poll.options),
|
options=tuple(
|
||||||
|
PollOptionRef(id=option.id, position=option.position)
|
||||||
|
for option in sorted(
|
||||||
|
(option for option in poll.options if option.deleted_at is None),
|
||||||
|
key=lambda item: (item.position, item.id),
|
||||||
|
)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -610,6 +621,31 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
|
|||||||
raise PollCapabilityError(str(exc)) from exc
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
return PollOptionRef(id=option.id, position=option.position)
|
return PollOptionRef(id=option.id, position=option.position)
|
||||||
|
|
||||||
|
def reorder_options(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollOptionOrderCommand,
|
||||||
|
) -> PollRef:
|
||||||
|
try:
|
||||||
|
mutation_owner = _stored_owner(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
)
|
||||||
|
poll = reorder_poll_options(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
option_ids=command.option_ids,
|
||||||
|
mutation_owner=mutation_owner,
|
||||||
|
)
|
||||||
|
except PollError as exc:
|
||||||
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
|
return _poll_ref(poll)
|
||||||
|
|
||||||
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
def open_poll(self, session: object, *, tenant_id: str, poll_id: str) -> PollRef:
|
||||||
return self._transition(open_poll, session, tenant_id=tenant_id, poll_id=poll_id)
|
return self._transition(open_poll, session, tenant_id=tenant_id, poll_id=poll_id)
|
||||||
|
|
||||||
@@ -736,6 +772,40 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
|
|||||||
raise PollCapabilityError(str(exc)) from exc
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
return _response_ref(response) if response is not None else None
|
return _response_ref(response) if response is not None else None
|
||||||
|
|
||||||
|
def retire_responses(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
command: PollResponseRetirementCommand,
|
||||||
|
) -> PollResponseRetirementRef:
|
||||||
|
try:
|
||||||
|
mutation_owner = _stored_owner(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
)
|
||||||
|
responses, retired_at, retired_count, replayed = retire_poll_responses(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
respondent_ids=command.respondent_ids,
|
||||||
|
invitation_id=command.invitation_id,
|
||||||
|
reason=command.reason,
|
||||||
|
idempotency_key=command.idempotency_key,
|
||||||
|
metadata=dict(command.metadata),
|
||||||
|
mutation_owner=mutation_owner,
|
||||||
|
)
|
||||||
|
except PollError as exc:
|
||||||
|
raise PollCapabilityError(str(exc)) from exc
|
||||||
|
return PollResponseRetirementRef(
|
||||||
|
response_ids=tuple(response.id for response in responses),
|
||||||
|
retired_at=response_datetime(retired_at),
|
||||||
|
newly_retired_count=retired_count,
|
||||||
|
replayed=replayed,
|
||||||
|
)
|
||||||
|
|
||||||
@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:
|
||||||
|
|||||||
@@ -99,6 +99,18 @@ class PollResponse(Base, TimestampMixin):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_poll_responses_poll_submitted", "poll_id", "submitted_at"),
|
Index("ix_poll_responses_poll_submitted", "poll_id", "submitted_at"),
|
||||||
Index("ix_poll_responses_poll_respondent", "poll_id", "respondent_id"),
|
Index("ix_poll_responses_poll_respondent", "poll_id", "respondent_id"),
|
||||||
|
Index(
|
||||||
|
"uq_poll_responses_active_respondent",
|
||||||
|
"poll_id",
|
||||||
|
"respondent_id",
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=text(
|
||||||
|
"deleted_at IS NULL AND respondent_id IS NOT NULL"
|
||||||
|
),
|
||||||
|
postgresql_where=text(
|
||||||
|
"deleted_at IS NULL AND respondent_id IS NOT NULL"
|
||||||
|
),
|
||||||
|
),
|
||||||
Index("ix_poll_responses_tenant_poll", "tenant_id", "poll_id"),
|
Index("ix_poll_responses_tenant_poll", "tenant_id", "poll_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ from govoplan_core.core.modules import (
|
|||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
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_core.core.poll_participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
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.10"
|
MODULE_VERSION = "0.1.11"
|
||||||
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"
|
||||||
@@ -121,6 +121,7 @@ manifest = ModuleManifest(
|
|||||||
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=MODULE_VERSION),
|
ModuleInterfaceProvider(name="poll.option_selection", version=MODULE_VERSION),
|
||||||
|
ModuleInterfaceProvider(name="poll.option_ordering", version=MODULE_VERSION),
|
||||||
ModuleInterfaceProvider(name="poll.availability_matrix", version=MODULE_VERSION),
|
ModuleInterfaceProvider(name="poll.availability_matrix", version=MODULE_VERSION),
|
||||||
ModuleInterfaceProvider(name="poll.response_collection", version=MODULE_VERSION),
|
ModuleInterfaceProvider(name="poll.response_collection", version=MODULE_VERSION),
|
||||||
ModuleInterfaceProvider(name="poll.workflow_context", version=MODULE_VERSION),
|
ModuleInterfaceProvider(name="poll.workflow_context", version=MODULE_VERSION),
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""v0.1.11 active identified Poll response uniqueness
|
||||||
|
|
||||||
|
Revision ID: 6e7f8a9b0c1d
|
||||||
|
Revises: 5d6e7f8a9b0c
|
||||||
|
Create Date: 2026-07-22 00:00:00.000000
|
||||||
|
|
||||||
|
This migration deduplicates active identified responses before creating the
|
||||||
|
partial unique index. Run it while Poll response writes are quiesced or while
|
||||||
|
the platform maintenance lock is held. PostgreSQL additionally takes a table
|
||||||
|
lock so an unexpected writer cannot enter between cleanup and index creation.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "6e7f8a9b0c1d"
|
||||||
|
down_revision = "5d6e7f8a9b0c"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
_INDEX_NAME = "uq_poll_responses_active_respondent"
|
||||||
|
_ACTIVE_IDENTIFIED_PREDICATE = (
|
||||||
|
"deleted_at IS NULL AND respondent_id IS NOT NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _tombstone_duplicate_active_responses() -> None:
|
||||||
|
responses = sa.table(
|
||||||
|
"poll_responses",
|
||||||
|
sa.column("id", sa.String(length=36)),
|
||||||
|
sa.column("poll_id", sa.String(length=36)),
|
||||||
|
sa.column("respondent_id", sa.String(length=255)),
|
||||||
|
sa.column("submitted_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.column("deleted_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.column("updated_at", sa.DateTime(timezone=True)),
|
||||||
|
)
|
||||||
|
ranked = (
|
||||||
|
sa.select(
|
||||||
|
responses.c.id.label("id"),
|
||||||
|
sa.func.row_number()
|
||||||
|
.over(
|
||||||
|
partition_by=(
|
||||||
|
responses.c.poll_id,
|
||||||
|
responses.c.respondent_id,
|
||||||
|
),
|
||||||
|
order_by=(
|
||||||
|
responses.c.submitted_at.desc(),
|
||||||
|
responses.c.id.desc(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.label("response_rank"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
responses.c.deleted_at.is_(None),
|
||||||
|
responses.c.respondent_id.is_not(None),
|
||||||
|
)
|
||||||
|
.subquery("ranked_active_poll_responses")
|
||||||
|
)
|
||||||
|
duplicate_ids = sa.select(ranked.c.id).where(ranked.c.response_rank > 1)
|
||||||
|
migration_timestamp = sa.func.current_timestamp()
|
||||||
|
op.get_bind().execute(
|
||||||
|
responses.update()
|
||||||
|
.where(
|
||||||
|
responses.c.id.in_(duplicate_ids),
|
||||||
|
responses.c.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
deleted_at=migration_timestamp,
|
||||||
|
updated_at=migration_timestamp,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
connection = op.get_bind()
|
||||||
|
if connection.dialect.name == "postgresql":
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"LOCK TABLE poll_responses IN SHARE ROW EXCLUSIVE MODE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_tombstone_duplicate_active_responses()
|
||||||
|
op.create_index(
|
||||||
|
_INDEX_NAME,
|
||||||
|
"poll_responses",
|
||||||
|
["poll_id", "respondent_id"],
|
||||||
|
unique=True,
|
||||||
|
sqlite_where=sa.text(_ACTIVE_IDENTIFIED_PREDICATE),
|
||||||
|
postgresql_where=sa.text(_ACTIVE_IDENTIFIED_PREDICATE),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Deduplicated rows remain tombstoned: a downgrade must not silently revive
|
||||||
|
# responses that were no longer part of the active result set.
|
||||||
|
op.drop_index(_INDEX_NAME, table_name="poll_responses")
|
||||||
@@ -1,254 +1,29 @@
|
|||||||
from __future__ import annotations
|
"""Backward-compatible governed-participation contract imports.
|
||||||
|
|
||||||
import hashlib
|
The provider contract belongs to Core so consumers can resolve Poll through
|
||||||
from collections.abc import Mapping
|
the platform capability registry without importing Poll implementation
|
||||||
from dataclasses import dataclass, field
|
internals. This module intentionally preserves the original public import
|
||||||
from datetime import datetime
|
path for existing Poll integrations.
|
||||||
from typing import Protocol, runtime_checkable
|
"""
|
||||||
|
|
||||||
from govoplan_core.core.poll import (
|
from govoplan_core.core.poll_participation import (
|
||||||
PollAnswerRequest,
|
ANONYMOUS_PASSWORD_REQUIREMENT,
|
||||||
PollInvitationRef,
|
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
|
||||||
PollOptionRequest,
|
PARTICIPATION_POLICY_VERSION,
|
||||||
PollResponseRef,
|
PollGovernedInvitationCommand,
|
||||||
|
PollGovernedResponseCommand,
|
||||||
|
PollGovernedResponseRef,
|
||||||
|
PollInvitationExpiryRef,
|
||||||
|
PollInvitationRevocationRef,
|
||||||
|
PollOptionMutationRef,
|
||||||
|
PollParticipationContextRef,
|
||||||
|
PollParticipationGatewayProvider,
|
||||||
|
PollParticipationPolicy,
|
||||||
|
PollResponseGatewayRef,
|
||||||
|
participation_token_fingerprint,
|
||||||
|
poll_participation_gateway_provider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
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__ = [
|
__all__ = [
|
||||||
"ANONYMOUS_PASSWORD_REQUIREMENT",
|
"ANONYMOUS_PASSWORD_REQUIREMENT",
|
||||||
"CAPABILITY_POLL_PARTICIPATION_GATEWAY",
|
"CAPABILITY_POLL_PARTICIPATION_GATEWAY",
|
||||||
@@ -256,8 +31,8 @@ __all__ = [
|
|||||||
"PollGovernedInvitationCommand",
|
"PollGovernedInvitationCommand",
|
||||||
"PollGovernedResponseCommand",
|
"PollGovernedResponseCommand",
|
||||||
"PollGovernedResponseRef",
|
"PollGovernedResponseRef",
|
||||||
"PollInvitationRevocationRef",
|
|
||||||
"PollInvitationExpiryRef",
|
"PollInvitationExpiryRef",
|
||||||
|
"PollInvitationRevocationRef",
|
||||||
"PollOptionMutationRef",
|
"PollOptionMutationRef",
|
||||||
"PollParticipationContextRef",
|
"PollParticipationContextRef",
|
||||||
"PollParticipationGatewayProvider",
|
"PollParticipationGatewayProvider",
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ from govoplan_poll.backend.service import (
|
|||||||
_assert_governed_invitation_gateway_allowed,
|
_assert_governed_invitation_gateway_allowed,
|
||||||
_assert_poll_accepts_responses,
|
_assert_poll_accepts_responses,
|
||||||
_existing_response,
|
_existing_response,
|
||||||
|
_insert_or_reconcile_identified_response,
|
||||||
_lock_poll_for_response,
|
_lock_poll_for_response,
|
||||||
_now,
|
_now,
|
||||||
|
_update_existing_response,
|
||||||
assert_no_sensitive_participation_metadata,
|
assert_no_sensitive_participation_metadata,
|
||||||
get_poll_invitation_by_token,
|
get_poll_invitation_by_token,
|
||||||
normalize_response_answers,
|
normalize_response_answers,
|
||||||
@@ -632,25 +634,32 @@ def _submit_locked_governed_response(
|
|||||||
trusted_metadata["comment"] = comment
|
trusted_metadata["comment"] = comment
|
||||||
now = _now()
|
now = _now()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
if not poll.allow_response_update:
|
response = _update_existing_response(
|
||||||
raise PollError("Response updates are not allowed for this poll")
|
session,
|
||||||
existing.answers = normalized_answers
|
poll=poll,
|
||||||
existing.respondent_label = payload.respondent_label
|
response=existing,
|
||||||
existing.submitted_at = now
|
answers=normalized_answers,
|
||||||
existing.metadata_ = trusted_metadata
|
respondent_label=payload.respondent_label,
|
||||||
response = existing
|
submitted_at=now,
|
||||||
|
metadata=trusted_metadata,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
response = PollResponse(
|
response, _reconciled = _insert_or_reconcile_identified_response(
|
||||||
tenant_id=poll.tenant_id,
|
session,
|
||||||
poll_id=poll.id,
|
poll=poll,
|
||||||
respondent_id=respondent_id,
|
respondent_id=respondent_id,
|
||||||
respondent_label=payload.respondent_label,
|
respondent_label=payload.respondent_label,
|
||||||
answers=normalized_answers,
|
answers=normalized_answers,
|
||||||
submitted_at=now,
|
submitted_at=now,
|
||||||
metadata_=trusted_metadata,
|
metadata=trusted_metadata,
|
||||||
|
conflict_validator=lambda winner: _enforce_capacity(
|
||||||
|
session,
|
||||||
|
poll=poll,
|
||||||
|
existing=winner,
|
||||||
|
normalized_answers=normalized_answers,
|
||||||
|
limit=policy.max_participants_per_option,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
session.add(response)
|
|
||||||
session.flush()
|
|
||||||
if idempotency_key is not None:
|
if idempotency_key is not None:
|
||||||
session.add(
|
session.add(
|
||||||
PollParticipationSubmission(
|
PollParticipationSubmission(
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.db.base import utcnow
|
from govoplan_core.db.base import utcnow
|
||||||
@@ -50,6 +52,7 @@ POLL_KINDS = {"single_choice", "multiple_choice", "yes_no", "yes_no_maybe", "ran
|
|||||||
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"}
|
||||||
AVAILABILITY_VALUES = {"available", "maybe", "unavailable"}
|
AVAILABILITY_VALUES = {"available", "maybe", "unavailable"}
|
||||||
|
ACTIVE_RESPONSE_UNIQUE_INDEX = "uq_poll_responses_active_respondent"
|
||||||
YES_NO_OPTIONS = (
|
YES_NO_OPTIONS = (
|
||||||
PollOptionInput(key="yes", label="Yes"),
|
PollOptionInput(key="yes", label="Yes"),
|
||||||
PollOptionInput(key="no", label="No"),
|
PollOptionInput(key="no", label="No"),
|
||||||
@@ -1081,13 +1084,37 @@ def _existing_response(session: Session, *, poll: Poll, respondent_id: str | Non
|
|||||||
return (
|
return (
|
||||||
session.query(PollResponse)
|
session.query(PollResponse)
|
||||||
.filter(PollResponse.poll_id == poll.id, PollResponse.respondent_id == respondent_id, PollResponse.deleted_at.is_(None))
|
.filter(PollResponse.poll_id == poll.id, PollResponse.respondent_id == respondent_id, PollResponse.deleted_at.is_(None))
|
||||||
.order_by(PollResponse.submitted_at.desc())
|
.order_by(PollResponse.submitted_at.desc(), PollResponse.id.desc())
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _share_lock_poll_for_response(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
) -> Poll:
|
||||||
|
"""Coordinate response writes with lifecycle changes without serializing peers."""
|
||||||
|
|
||||||
|
poll = (
|
||||||
|
session.query(Poll)
|
||||||
|
.filter(
|
||||||
|
Poll.tenant_id == tenant_id,
|
||||||
|
Poll.id == poll_id,
|
||||||
|
Poll.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.populate_existing()
|
||||||
|
.with_for_update(read=True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if poll is None:
|
||||||
|
raise PollError("Poll not found")
|
||||||
|
return poll
|
||||||
|
|
||||||
|
|
||||||
def _lock_poll_for_response(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
|
def _lock_poll_for_response(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
|
||||||
"""Serialize the read-or-create path for identified respondents."""
|
"""Serialize policy-sensitive response and option mutations."""
|
||||||
|
|
||||||
poll = (
|
poll = (
|
||||||
session.query(Poll)
|
session.query(Poll)
|
||||||
@@ -1105,6 +1132,106 @@ def _lock_poll_for_response(session: Session, *, tenant_id: str, poll_id: str) -
|
|||||||
return poll
|
return poll
|
||||||
|
|
||||||
|
|
||||||
|
def _is_active_response_uniqueness_conflict(
|
||||||
|
session: Session,
|
||||||
|
error: IntegrityError,
|
||||||
|
) -> bool:
|
||||||
|
"""Recognize only the active identified-response invariant violation."""
|
||||||
|
|
||||||
|
original = error.orig
|
||||||
|
constraint_name = getattr(
|
||||||
|
getattr(original, "diag", None),
|
||||||
|
"constraint_name",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if constraint_name is not None:
|
||||||
|
return constraint_name == ACTIVE_RESPONSE_UNIQUE_INDEX
|
||||||
|
|
||||||
|
if session.get_bind().dialect.name != "sqlite":
|
||||||
|
return False
|
||||||
|
message = " ".join(str(original).casefold().split())
|
||||||
|
return (
|
||||||
|
"unique constraint failed:" in message
|
||||||
|
and "poll_responses.poll_id" in message
|
||||||
|
and "poll_responses.respondent_id" in message
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_existing_response(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
poll: Poll,
|
||||||
|
response: PollResponse,
|
||||||
|
answers: list[dict[str, Any]],
|
||||||
|
respondent_label: str | None,
|
||||||
|
submitted_at: datetime,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
) -> PollResponse:
|
||||||
|
if not poll.allow_response_update:
|
||||||
|
raise PollError("Response updates are not allowed for this poll")
|
||||||
|
response.answers = answers
|
||||||
|
response.respondent_label = respondent_label
|
||||||
|
response.submitted_at = submitted_at
|
||||||
|
response.metadata_ = metadata
|
||||||
|
session.flush()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_or_reconcile_identified_response(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
poll: Poll,
|
||||||
|
respondent_id: str,
|
||||||
|
respondent_label: str | None,
|
||||||
|
answers: list[dict[str, Any]],
|
||||||
|
submitted_at: datetime,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
conflict_validator: Callable[[PollResponse], None] | None = None,
|
||||||
|
) -> tuple[PollResponse, bool]:
|
||||||
|
"""Insert under a savepoint, or update the row that won the same race."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with session.begin_nested():
|
||||||
|
response = PollResponse(
|
||||||
|
tenant_id=poll.tenant_id,
|
||||||
|
poll_id=poll.id,
|
||||||
|
respondent_id=respondent_id,
|
||||||
|
respondent_label=respondent_label,
|
||||||
|
answers=answers,
|
||||||
|
submitted_at=submitted_at,
|
||||||
|
metadata_=metadata,
|
||||||
|
)
|
||||||
|
session.add(response)
|
||||||
|
session.flush()
|
||||||
|
except IntegrityError as error:
|
||||||
|
if not _is_active_response_uniqueness_conflict(session, error):
|
||||||
|
raise
|
||||||
|
winner = _existing_response(
|
||||||
|
session,
|
||||||
|
poll=poll,
|
||||||
|
respondent_id=respondent_id,
|
||||||
|
)
|
||||||
|
if winner is None:
|
||||||
|
# A correctly identified conflict always has a visible winner after
|
||||||
|
# the savepoint rollback. Preserve the database failure otherwise.
|
||||||
|
raise
|
||||||
|
if conflict_validator is not None:
|
||||||
|
conflict_validator(winner)
|
||||||
|
return (
|
||||||
|
_update_existing_response(
|
||||||
|
session,
|
||||||
|
poll=poll,
|
||||||
|
response=winner,
|
||||||
|
answers=answers,
|
||||||
|
respondent_label=respondent_label,
|
||||||
|
submitted_at=submitted_at,
|
||||||
|
metadata=metadata,
|
||||||
|
),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
return response, False
|
||||||
|
|
||||||
|
|
||||||
def poll_requires_governed_participation(*, poll: Poll) -> bool:
|
def poll_requires_governed_participation(*, poll: Poll) -> bool:
|
||||||
"""Whether responses must pass through the owning module's gateway."""
|
"""Whether responses must pass through the owning module's gateway."""
|
||||||
|
|
||||||
@@ -1256,6 +1383,61 @@ def update_poll_option(
|
|||||||
return option
|
return option
|
||||||
|
|
||||||
|
|
||||||
|
def reorder_poll_options(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
option_ids: tuple[str, ...],
|
||||||
|
mutation_owner: PollMutationOwner | None = None,
|
||||||
|
) -> Poll:
|
||||||
|
"""Atomically replace the complete active option order.
|
||||||
|
|
||||||
|
Position is presentation state, so existing answers remain attached to
|
||||||
|
their stable option identities. The Poll row is locked first to keep the
|
||||||
|
lock order consistent with response and option mutations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
poll = _lock_poll_for_response(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
)
|
||||||
|
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
|
||||||
|
requested_ids = tuple(option_ids)
|
||||||
|
if len(requested_ids) != len(set(requested_ids)):
|
||||||
|
raise PollError("Poll option order cannot contain duplicate options")
|
||||||
|
options = (
|
||||||
|
session.query(PollOption)
|
||||||
|
.filter(
|
||||||
|
PollOption.tenant_id == tenant_id,
|
||||||
|
PollOption.poll_id == poll.id,
|
||||||
|
PollOption.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(PollOption.id.asc())
|
||||||
|
.populate_existing()
|
||||||
|
.with_for_update()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_id = {option.id: option for option in options}
|
||||||
|
if set(requested_ids) != set(by_id):
|
||||||
|
raise PollError(
|
||||||
|
"Poll option order must contain every active option exactly once"
|
||||||
|
)
|
||||||
|
current_ids = tuple(
|
||||||
|
option.id
|
||||||
|
for option in sorted(options, key=lambda item: (item.position, item.id))
|
||||||
|
)
|
||||||
|
if requested_ids == current_ids:
|
||||||
|
return poll
|
||||||
|
if poll.status not in {"draft", "open"}:
|
||||||
|
raise PollError("Only draft or open poll options can be reordered")
|
||||||
|
for position, option_id in enumerate(requested_ids):
|
||||||
|
by_id[option_id].position = position
|
||||||
|
session.flush()
|
||||||
|
return poll
|
||||||
|
|
||||||
|
|
||||||
def _locked_active_poll_responses(session: Session, *, poll: Poll) -> list[PollResponse]:
|
def _locked_active_poll_responses(session: Session, *, poll: Poll) -> list[PollResponse]:
|
||||||
return (
|
return (
|
||||||
session.query(PollResponse)
|
session.query(PollResponse)
|
||||||
@@ -1411,7 +1593,7 @@ def _synchronize_mutable_choice_bounds(
|
|||||||
|
|
||||||
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)
|
assert_no_sensitive_participation_metadata(payload.metadata)
|
||||||
poll = _lock_poll_for_response(
|
poll = _share_lock_poll_for_response(
|
||||||
session,
|
session,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
poll_id=poll_id,
|
poll_id=poll_id,
|
||||||
@@ -1422,27 +1604,40 @@ def submit_poll_response(session: Session, *, tenant_id: str, poll_id: str, payl
|
|||||||
raise PollError("Anonymous responses are not allowed for this poll")
|
raise PollError("Anonymous responses are not allowed for this poll")
|
||||||
answers = normalize_response_answers(poll, payload)
|
answers = normalize_response_answers(poll, payload)
|
||||||
existing = _existing_response(session, poll=poll, respondent_id=payload.respondent_id)
|
existing = _existing_response(session, poll=poll, respondent_id=payload.respondent_id)
|
||||||
|
submitted_at = _now()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
if not poll.allow_response_update:
|
return _update_existing_response(
|
||||||
raise PollError("Response updates are not allowed for this poll")
|
session,
|
||||||
existing.answers = answers
|
poll=poll,
|
||||||
existing.respondent_label = payload.respondent_label
|
response=existing,
|
||||||
existing.submitted_at = _now()
|
answers=answers,
|
||||||
existing.metadata_ = payload.metadata
|
respondent_label=payload.respondent_label,
|
||||||
session.flush()
|
submitted_at=submitted_at,
|
||||||
return existing
|
metadata=payload.metadata,
|
||||||
|
)
|
||||||
|
if payload.respondent_id is None:
|
||||||
response = PollResponse(
|
response = PollResponse(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
poll_id=poll.id,
|
poll_id=poll.id,
|
||||||
respondent_id=payload.respondent_id,
|
respondent_id=None,
|
||||||
respondent_label=payload.respondent_label,
|
respondent_label=payload.respondent_label,
|
||||||
answers=answers,
|
answers=answers,
|
||||||
submitted_at=_now(),
|
submitted_at=submitted_at,
|
||||||
metadata_=payload.metadata,
|
metadata_=payload.metadata,
|
||||||
)
|
)
|
||||||
session.add(response)
|
session.add(response)
|
||||||
session.flush()
|
session.flush()
|
||||||
return response
|
return response
|
||||||
|
response, _reconciled = _insert_or_reconcile_identified_response(
|
||||||
|
session,
|
||||||
|
poll=poll,
|
||||||
|
respondent_id=payload.respondent_id,
|
||||||
|
respondent_label=payload.respondent_label,
|
||||||
|
answers=answers,
|
||||||
|
submitted_at=submitted_at,
|
||||||
|
metadata=payload.metadata,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def poll_option_response(option: PollOption) -> dict[str, Any]:
|
def poll_option_response(option: PollOption) -> dict[str, Any]:
|
||||||
@@ -1554,6 +1749,102 @@ def list_poll_responses(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def retire_poll_responses(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
poll_id: str,
|
||||||
|
respondent_ids: tuple[str, ...],
|
||||||
|
invitation_id: str | None,
|
||||||
|
reason: str,
|
||||||
|
idempotency_key: str,
|
||||||
|
metadata: dict[str, Any],
|
||||||
|
mutation_owner: PollMutationOwner | None = None,
|
||||||
|
) -> tuple[list[PollResponse], datetime | None, int, bool]:
|
||||||
|
"""Soft-delete owner-selected responses without erasing their answers."""
|
||||||
|
|
||||||
|
normalized_ids = tuple(
|
||||||
|
dict.fromkeys(value.strip() for value in respondent_ids if value.strip())
|
||||||
|
)
|
||||||
|
normalized_invitation_id = (
|
||||||
|
invitation_id.strip() if invitation_id and invitation_id.strip() else None
|
||||||
|
)
|
||||||
|
normalized_reason = reason.strip()
|
||||||
|
normalized_key = idempotency_key.strip()
|
||||||
|
if not normalized_ids and normalized_invitation_id is None:
|
||||||
|
raise PollError("Response retirement requires a trusted participant identity")
|
||||||
|
if not normalized_reason or len(normalized_reason) > 120:
|
||||||
|
raise PollError("Response retirement reason is invalid")
|
||||||
|
if not normalized_key or len(normalized_key) > 255:
|
||||||
|
raise PollError("Response retirement idempotency key is invalid")
|
||||||
|
assert_no_sensitive_participation_metadata(metadata)
|
||||||
|
|
||||||
|
poll = _lock_poll_for_response(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
poll_id=poll_id,
|
||||||
|
)
|
||||||
|
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
|
||||||
|
conditions = []
|
||||||
|
if normalized_ids:
|
||||||
|
conditions.append(PollResponse.respondent_id.in_(normalized_ids))
|
||||||
|
if normalized_invitation_id is not None:
|
||||||
|
conditions.append(
|
||||||
|
PollResponse.metadata_["invitation_id"].as_string()
|
||||||
|
== normalized_invitation_id
|
||||||
|
)
|
||||||
|
responses = (
|
||||||
|
session.query(PollResponse)
|
||||||
|
.filter(
|
||||||
|
PollResponse.tenant_id == tenant_id,
|
||||||
|
PollResponse.poll_id == poll.id,
|
||||||
|
or_(*conditions),
|
||||||
|
)
|
||||||
|
.order_by(PollResponse.submitted_at.asc(), PollResponse.id.asc())
|
||||||
|
.populate_existing()
|
||||||
|
.with_for_update()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
active = [response for response in responses if response.deleted_at is None]
|
||||||
|
if active:
|
||||||
|
retired_at = _now()
|
||||||
|
retirement = {
|
||||||
|
"idempotency_key": normalized_key,
|
||||||
|
"reason": normalized_reason,
|
||||||
|
"retired_at": retired_at.isoformat(),
|
||||||
|
"context": dict(metadata),
|
||||||
|
}
|
||||||
|
for response in active:
|
||||||
|
response.metadata_ = {
|
||||||
|
**(response.metadata_ or {}),
|
||||||
|
"response_retirement": retirement,
|
||||||
|
}
|
||||||
|
response.deleted_at = retired_at
|
||||||
|
session.flush()
|
||||||
|
return active, retired_at, len(active), False
|
||||||
|
|
||||||
|
replayed = [
|
||||||
|
response
|
||||||
|
for response in responses
|
||||||
|
if isinstance((response.metadata_ or {}).get("response_retirement"), dict)
|
||||||
|
and (response.metadata_ or {})["response_retirement"].get(
|
||||||
|
"idempotency_key"
|
||||||
|
)
|
||||||
|
== normalized_key
|
||||||
|
]
|
||||||
|
if replayed:
|
||||||
|
retired_at = max(
|
||||||
|
(
|
||||||
|
response_datetime(response.deleted_at)
|
||||||
|
for response in replayed
|
||||||
|
if response.deleted_at is not None
|
||||||
|
),
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
return replayed, retired_at, 0, True
|
||||||
|
return [], None, 0, False
|
||||||
|
|
||||||
|
|
||||||
def _token_hash(token: str) -> str:
|
def _token_hash(token: str) -> str:
|
||||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class PollManifestTests(unittest.TestCase):
|
|||||||
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("poll.governed_participation", {interface.name for interface in manifest.provides_interfaces})
|
||||||
self.assertIn(CAPABILITY_POLL_PARTICIPATION_GATEWAY, manifest.capability_factories)
|
self.assertIn(CAPABILITY_POLL_PARTICIPATION_GATEWAY, manifest.capability_factories)
|
||||||
self.assertEqual(manifest.version, "0.1.10")
|
self.assertEqual(manifest.version, "0.1.11")
|
||||||
self.assertIn("poll:response:write", {permission.scope for permission in manifest.permissions})
|
self.assertIn("poll:response:write", {permission.scope for permission in manifest.permissions})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
210
tests/test_migrations.py
Normal file
210
tests/test_migrations.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from alembic.runtime.migration import MigrationContext
|
||||||
|
from sqlalchemy import create_engine, inspect
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.db.migrations import alembic_config
|
||||||
|
from govoplan_poll.backend.db.models import PollResponse
|
||||||
|
from govoplan_poll.backend.manifest import get_manifest
|
||||||
|
from govoplan_poll.backend.schemas import (
|
||||||
|
PollCreateRequest,
|
||||||
|
PollOptionInput,
|
||||||
|
)
|
||||||
|
from govoplan_poll.backend.service import _existing_response, create_poll
|
||||||
|
|
||||||
|
|
||||||
|
_PREVIOUS_POLL_HEAD = "5d6e7f8a9b0c"
|
||||||
|
_POLL_HEAD = "6e7f8a9b0c1d"
|
||||||
|
_INDEX_NAME = "uq_poll_responses_active_respondent"
|
||||||
|
|
||||||
|
|
||||||
|
class PollMigrationTests(unittest.TestCase):
|
||||||
|
@staticmethod
|
||||||
|
def _config(url: str):
|
||||||
|
return alembic_config(
|
||||||
|
database_url=url,
|
||||||
|
enabled_modules=("poll",),
|
||||||
|
manifest_factories=(get_manifest,),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_deduplicates_deterministically_and_enforces_partial_index(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(
|
||||||
|
prefix="govoplan-poll-migration-"
|
||||||
|
) as directory:
|
||||||
|
url = f"sqlite:///{Path(directory) / 'poll.db'}"
|
||||||
|
config = self._config(url)
|
||||||
|
command.upgrade(config, _PREVIOUS_POLL_HEAD)
|
||||||
|
engine = create_engine(url)
|
||||||
|
latest = datetime(2026, 7, 22, 10, 0, tzinfo=timezone.utc)
|
||||||
|
historical_deleted_at = latest - timedelta(days=2)
|
||||||
|
historical_updated_at = latest - timedelta(days=1)
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
poll = create_poll(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="owner-1",
|
||||||
|
payload=PollCreateRequest(
|
||||||
|
title="Migration probe",
|
||||||
|
kind="single_choice",
|
||||||
|
status="open",
|
||||||
|
allow_anonymous=True,
|
||||||
|
options=[
|
||||||
|
PollOptionInput(key="yes", label="Yes"),
|
||||||
|
PollOptionInput(key="no", label="No"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
poll_id = poll.id
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
PollResponse(
|
||||||
|
id="identified-oldest",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll_id,
|
||||||
|
respondent_id="person-1",
|
||||||
|
answers=[],
|
||||||
|
submitted_at=latest - timedelta(hours=1),
|
||||||
|
),
|
||||||
|
PollResponse(
|
||||||
|
id="identified-latest-a",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll_id,
|
||||||
|
respondent_id="person-1",
|
||||||
|
answers=[],
|
||||||
|
submitted_at=latest,
|
||||||
|
),
|
||||||
|
PollResponse(
|
||||||
|
id="identified-latest-b",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll_id,
|
||||||
|
respondent_id="person-1",
|
||||||
|
answers=[],
|
||||||
|
submitted_at=latest,
|
||||||
|
),
|
||||||
|
PollResponse(
|
||||||
|
id="anonymous-a",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll_id,
|
||||||
|
respondent_id=None,
|
||||||
|
answers=[],
|
||||||
|
submitted_at=latest,
|
||||||
|
),
|
||||||
|
PollResponse(
|
||||||
|
id="anonymous-b",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll_id,
|
||||||
|
respondent_id=None,
|
||||||
|
answers=[],
|
||||||
|
submitted_at=latest,
|
||||||
|
),
|
||||||
|
PollResponse(
|
||||||
|
id="already-deleted",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll_id,
|
||||||
|
respondent_id="person-1",
|
||||||
|
answers=[],
|
||||||
|
submitted_at=latest + timedelta(hours=1),
|
||||||
|
deleted_at=historical_deleted_at,
|
||||||
|
updated_at=historical_updated_at,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
self.assertEqual(
|
||||||
|
_existing_response(
|
||||||
|
session,
|
||||||
|
poll=poll,
|
||||||
|
respondent_id="person-1",
|
||||||
|
).id,
|
||||||
|
"identified-latest-b",
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
command.upgrade(config, "heads")
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
rows = {
|
||||||
|
response.id: response
|
||||||
|
for response in session.query(PollResponse).all()
|
||||||
|
}
|
||||||
|
self.assertIsNone(rows["identified-latest-b"].deleted_at)
|
||||||
|
tombstoned = (
|
||||||
|
rows["identified-oldest"],
|
||||||
|
rows["identified-latest-a"],
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(response.deleted_at is not None for response in tombstoned)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
response.deleted_at == response.updated_at
|
||||||
|
for response in tombstoned
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
len({response.deleted_at for response in tombstoned}),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
self.assertIsNone(rows["anonymous-a"].deleted_at)
|
||||||
|
self.assertIsNone(rows["anonymous-b"].deleted_at)
|
||||||
|
self.assertEqual(
|
||||||
|
rows["already-deleted"].deleted_at,
|
||||||
|
historical_deleted_at.replace(tzinfo=None),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
rows["already-deleted"].updated_at,
|
||||||
|
historical_updated_at.replace(tzinfo=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
PollResponse(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll_id,
|
||||||
|
respondent_id="person-1",
|
||||||
|
answers=[],
|
||||||
|
submitted_at=latest + timedelta(hours=2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
session.flush()
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
PollResponse(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll_id,
|
||||||
|
respondent_id=None,
|
||||||
|
answers=[],
|
||||||
|
submitted_at=latest + timedelta(hours=2),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with engine.connect() as connection:
|
||||||
|
heads = set(
|
||||||
|
MigrationContext.configure(connection).get_current_heads()
|
||||||
|
)
|
||||||
|
indexes = {
|
||||||
|
item["name"]: item
|
||||||
|
for item in inspect(connection).get_indexes(
|
||||||
|
"poll_responses"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
self.assertIn(_POLL_HEAD, heads)
|
||||||
|
self.assertIn(_INDEX_NAME, indexes)
|
||||||
|
self.assertTrue(indexes[_INDEX_NAME]["unique"])
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
32
tests/test_participation_contract.py
Normal file
32
tests/test_participation_contract.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from govoplan_core.core.poll_participation import (
|
||||||
|
CAPABILITY_POLL_PARTICIPATION_GATEWAY as CORE_CAPABILITY,
|
||||||
|
PollGovernedInvitationCommand as CorePollGovernedInvitationCommand,
|
||||||
|
PollParticipationGatewayProvider as CorePollParticipationGatewayProvider,
|
||||||
|
PollParticipationPolicy as CorePollParticipationPolicy,
|
||||||
|
)
|
||||||
|
from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider
|
||||||
|
from govoplan_poll.backend.participation import (
|
||||||
|
CAPABILITY_POLL_PARTICIPATION_GATEWAY,
|
||||||
|
PollGovernedInvitationCommand,
|
||||||
|
PollParticipationGatewayProvider,
|
||||||
|
PollParticipationPolicy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PollParticipationCompatibilityTests(unittest.TestCase):
|
||||||
|
def test_legacy_imports_are_exact_core_contract_re_exports(self) -> None:
|
||||||
|
self.assertIs(CORE_CAPABILITY, CAPABILITY_POLL_PARTICIPATION_GATEWAY)
|
||||||
|
self.assertIs(CorePollGovernedInvitationCommand, PollGovernedInvitationCommand)
|
||||||
|
self.assertIs(CorePollParticipationPolicy, PollParticipationPolicy)
|
||||||
|
self.assertIs(CorePollParticipationGatewayProvider, PollParticipationGatewayProvider)
|
||||||
|
|
||||||
|
def test_sql_provider_implements_the_core_contract(self) -> None:
|
||||||
|
self.assertIsInstance(SqlPollSchedulingProvider(), CorePollParticipationGatewayProvider)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -5,7 +5,14 @@ import unittest
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
from govoplan_core.core.poll import PollCapabilityError, PollOptionUpdateCommand, PollSchedulingProvider
|
from govoplan_core.core.poll import (
|
||||||
|
PollCapabilityError,
|
||||||
|
PollOptionOrderCommand,
|
||||||
|
PollOptionUpdateCommand,
|
||||||
|
PollResponseRetirementCommand,
|
||||||
|
PollResponseRetirementProvider,
|
||||||
|
PollSchedulingProvider,
|
||||||
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider
|
from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider
|
||||||
from govoplan_poll.backend.db.models import Poll, PollOption, PollResponse
|
from govoplan_poll.backend.db.models import Poll, PollOption, PollResponse
|
||||||
@@ -127,6 +134,62 @@ class PollResponseEditingTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(len(first.answers), 2)
|
self.assertEqual(len(first.answers), 2)
|
||||||
|
|
||||||
|
def test_provider_retires_response_from_live_results_but_keeps_audit_history(self) -> None:
|
||||||
|
poll = self._poll()
|
||||||
|
response = self._submit_both(poll, "person-1")
|
||||||
|
response.metadata_ = {
|
||||||
|
"invitation_id": "invitation-1",
|
||||||
|
"participant_email": "alice@example.test",
|
||||||
|
}
|
||||||
|
provider = SqlPollSchedulingProvider()
|
||||||
|
command = PollResponseRetirementCommand(
|
||||||
|
respondent_ids=("person-1", "alice@example.test"),
|
||||||
|
invitation_id="invitation-1",
|
||||||
|
reason="scheduling_participant_removed",
|
||||||
|
idempotency_key="scheduling:request-1:participant-1:removed",
|
||||||
|
metadata={
|
||||||
|
"source_module": "scheduling",
|
||||||
|
"source_resource_type": "participant",
|
||||||
|
"source_resource_id": "participant-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsInstance(provider, PollResponseRetirementProvider)
|
||||||
|
retired = provider.retire_responses(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
command=command,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(retired.response_ids, (response.id,))
|
||||||
|
self.assertEqual(retired.newly_retired_count, 1)
|
||||||
|
self.assertFalse(retired.replayed)
|
||||||
|
self.assertIsNotNone(response.deleted_at)
|
||||||
|
self.assertEqual(len(response.answers), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
response.metadata_["response_retirement"]["idempotency_key"],
|
||||||
|
command.idempotency_key,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
provider.list_responses(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
),
|
||||||
|
(),
|
||||||
|
)
|
||||||
|
|
||||||
|
replayed = provider.retire_responses(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
command=command,
|
||||||
|
)
|
||||||
|
self.assertTrue(replayed.replayed)
|
||||||
|
self.assertEqual(replayed.newly_retired_count, 0)
|
||||||
|
self.assertEqual(replayed.response_ids, (response.id,))
|
||||||
|
|
||||||
def test_fully_invalidated_response_is_no_longer_active_or_counted(self) -> None:
|
def test_fully_invalidated_response_is_no_longer_active_or_counted(self) -> None:
|
||||||
poll = self._poll()
|
poll = self._poll()
|
||||||
response = submit_poll_response(
|
response = submit_poll_response(
|
||||||
@@ -174,6 +237,61 @@ class PollResponseEditingTests(unittest.TestCase):
|
|||||||
self.assertEqual(poll.options[0].label, "Monday")
|
self.assertEqual(poll.options[0].label, "Monday")
|
||||||
self.assertEqual(len(response.answers), 2)
|
self.assertEqual(len(response.answers), 2)
|
||||||
|
|
||||||
|
def test_reorder_preserves_option_identity_and_existing_answers(self) -> None:
|
||||||
|
poll = self._poll(allow_response_update=False)
|
||||||
|
response = self._submit_both(poll, "person-1")
|
||||||
|
first_id, second_id = (option.id for option in poll.options)
|
||||||
|
original_answers = [dict(answer) for answer in response.answers]
|
||||||
|
provider = SqlPollSchedulingProvider()
|
||||||
|
|
||||||
|
reordered = provider.reorder_options(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
command=PollOptionOrderCommand(option_ids=(second_id, first_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[(option.id, option.position) for option in reordered.options],
|
||||||
|
[(second_id, 0), (first_id, 1)],
|
||||||
|
)
|
||||||
|
self.assertEqual(response.answers, original_answers)
|
||||||
|
self.assertIsNone(response.deleted_at)
|
||||||
|
|
||||||
|
replayed = provider.reorder_options(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
command=PollOptionOrderCommand(option_ids=(second_id, first_id)),
|
||||||
|
)
|
||||||
|
self.assertEqual(replayed.options, reordered.options)
|
||||||
|
self.assertEqual(response.answers, original_answers)
|
||||||
|
|
||||||
|
def test_reorder_rejects_partial_or_duplicate_active_option_order(self) -> None:
|
||||||
|
poll = self._poll()
|
||||||
|
first_id, second_id = (option.id for option in poll.options)
|
||||||
|
provider = SqlPollSchedulingProvider()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(PollCapabilityError, "every active option"):
|
||||||
|
provider.reorder_options(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
command=PollOptionOrderCommand(option_ids=(first_id,)),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(PollCapabilityError, "duplicate"):
|
||||||
|
provider.reorder_options(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll.id,
|
||||||
|
command=PollOptionOrderCommand(option_ids=(first_id, first_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[(option.id, option.position) for option in poll.options],
|
||||||
|
[(first_id, 0), (second_id, 1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
353
tests/test_response_uniqueness.py
Normal file
353
tests/test_response_uniqueness.py
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.db.base import Base, utcnow
|
||||||
|
from govoplan_poll.backend import service as poll_service
|
||||||
|
from govoplan_poll.backend.db.models import Poll, PollOption, PollResponse
|
||||||
|
from govoplan_poll.backend.schemas import (
|
||||||
|
PollAnswerInput,
|
||||||
|
PollCreateRequest,
|
||||||
|
PollOptionInput,
|
||||||
|
PollSubmitResponseRequest,
|
||||||
|
)
|
||||||
|
from govoplan_poll.backend.service import (
|
||||||
|
PollError,
|
||||||
|
_share_lock_poll_for_response,
|
||||||
|
create_poll,
|
||||||
|
submit_poll_response,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PollResponseUniquenessTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
self.tables = [Poll.__table__, PollOption.__table__, PollResponse.__table__]
|
||||||
|
Base.metadata.create_all(self.engine, tables=self.tables)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
self.session: Session = self.Session()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
Base.metadata.drop_all(self.engine, tables=list(reversed(self.tables)))
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def _poll(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
allow_anonymous: bool = False,
|
||||||
|
allow_response_update: bool = True,
|
||||||
|
) -> Poll:
|
||||||
|
return create_poll(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="owner-1",
|
||||||
|
payload=PollCreateRequest(
|
||||||
|
title="One response each",
|
||||||
|
kind="single_choice",
|
||||||
|
status="open",
|
||||||
|
allow_anonymous=allow_anonymous,
|
||||||
|
allow_response_update=allow_response_update,
|
||||||
|
options=[
|
||||||
|
PollOptionInput(key="yes", label="Yes"),
|
||||||
|
PollOptionInput(key="no", label="No"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _payload(
|
||||||
|
respondent_id: str | None,
|
||||||
|
*,
|
||||||
|
option_key: str = "yes",
|
||||||
|
) -> PollSubmitResponseRequest:
|
||||||
|
return PollSubmitResponseRequest(
|
||||||
|
respondent_id=respondent_id,
|
||||||
|
respondent_label=respondent_id,
|
||||||
|
answers=[PollAnswerInput(option_key=option_key)],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _hide_first_lookup(self):
|
||||||
|
original = poll_service._existing_response
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def hide_once(session, *, poll, respondent_id):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
return None
|
||||||
|
return original(
|
||||||
|
session,
|
||||||
|
poll=poll,
|
||||||
|
respondent_id=respondent_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return patch.object(poll_service, "_existing_response", side_effect=hide_once)
|
||||||
|
|
||||||
|
def test_sqlite_invariant_reconciles_a_racing_insert_to_normal_update(self) -> None:
|
||||||
|
poll = self._poll()
|
||||||
|
winner = submit_poll_response(
|
||||||
|
self.session,
|
||||||
|
tenant_id=poll.tenant_id,
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=self._payload("person-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
with self._hide_first_lookup():
|
||||||
|
reconciled = submit_poll_response(
|
||||||
|
self.session,
|
||||||
|
tenant_id=poll.tenant_id,
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=self._payload("person-1", option_key="no"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(reconciled.id, winner.id)
|
||||||
|
self.assertEqual(reconciled.answers[0]["option_key"], "no")
|
||||||
|
self.assertEqual(
|
||||||
|
self.session.query(PollResponse)
|
||||||
|
.filter(PollResponse.deleted_at.is_(None))
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_racing_insert_obeys_update_disabled_policy(self) -> None:
|
||||||
|
poll = self._poll()
|
||||||
|
winner = submit_poll_response(
|
||||||
|
self.session,
|
||||||
|
tenant_id=poll.tenant_id,
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=self._payload("person-1"),
|
||||||
|
)
|
||||||
|
poll.allow_response_update = False
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
with self._hide_first_lookup():
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
PollError,
|
||||||
|
"Response updates are not allowed",
|
||||||
|
):
|
||||||
|
submit_poll_response(
|
||||||
|
self.session,
|
||||||
|
tenant_id=poll.tenant_id,
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=self._payload("person-1", option_key="no"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(winner.answers[0]["option_key"], "yes")
|
||||||
|
self.assertEqual(self.session.query(PollResponse).count(), 1)
|
||||||
|
|
||||||
|
def test_anonymous_and_tombstoned_responses_do_not_conflict(self) -> None:
|
||||||
|
poll = self._poll(allow_anonymous=True)
|
||||||
|
anonymous_one = submit_poll_response(
|
||||||
|
self.session,
|
||||||
|
tenant_id=poll.tenant_id,
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=self._payload(None),
|
||||||
|
)
|
||||||
|
anonymous_two = submit_poll_response(
|
||||||
|
self.session,
|
||||||
|
tenant_id=poll.tenant_id,
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=self._payload(None, option_key="no"),
|
||||||
|
)
|
||||||
|
identified_one = submit_poll_response(
|
||||||
|
self.session,
|
||||||
|
tenant_id=poll.tenant_id,
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=self._payload("person-1"),
|
||||||
|
)
|
||||||
|
identified_one.deleted_at = utcnow()
|
||||||
|
self.session.flush()
|
||||||
|
identified_two = submit_poll_response(
|
||||||
|
self.session,
|
||||||
|
tenant_id=poll.tenant_id,
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=self._payload("person-1", option_key="no"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertNotEqual(anonymous_one.id, anonymous_two.id)
|
||||||
|
self.assertNotEqual(identified_one.id, identified_two.id)
|
||||||
|
self.assertEqual(self.session.query(PollResponse).count(), 4)
|
||||||
|
|
||||||
|
def test_unrelated_integrity_error_is_not_reconciled(self) -> None:
|
||||||
|
poll = self._poll()
|
||||||
|
self.session.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE TRIGGER reject_blocked_poll_response
|
||||||
|
BEFORE INSERT ON poll_responses
|
||||||
|
WHEN NEW.respondent_id = 'blocked'
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'blocked by unrelated invariant');
|
||||||
|
END
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(IntegrityError) as caught:
|
||||||
|
submit_poll_response(
|
||||||
|
self.session,
|
||||||
|
tenant_id=poll.tenant_id,
|
||||||
|
poll_id=poll.id,
|
||||||
|
payload=self._payload("blocked"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsInstance(caught.exception.orig, sqlite3.IntegrityError)
|
||||||
|
self.assertIn("blocked by unrelated invariant", str(caught.exception.orig))
|
||||||
|
|
||||||
|
def test_response_read_lock_is_shared(self) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
query = MagicMock()
|
||||||
|
poll = MagicMock()
|
||||||
|
session.query.return_value = query
|
||||||
|
query.filter.return_value = query
|
||||||
|
query.populate_existing.return_value = query
|
||||||
|
query.with_for_update.return_value = query
|
||||||
|
query.first.return_value = poll
|
||||||
|
|
||||||
|
result = _share_lock_poll_for_response(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id="poll-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(result, poll)
|
||||||
|
query.with_for_update.assert_called_once_with(read=True)
|
||||||
|
|
||||||
|
def test_orm_mirrors_both_partial_index_predicates(self) -> None:
|
||||||
|
index = next(
|
||||||
|
index
|
||||||
|
for index in PollResponse.__table__.indexes
|
||||||
|
if index.name == "uq_poll_responses_active_respondent"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(index.unique)
|
||||||
|
self.assertEqual(
|
||||||
|
str(index.dialect_options["sqlite"]["where"]),
|
||||||
|
"deleted_at IS NULL AND respondent_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
str(index.dialect_options["postgresql"]["where"]),
|
||||||
|
"deleted_at IS NULL AND respondent_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(
|
||||||
|
os.environ.get("GOVOPLAN_POLL_TEST_POSTGRES_URL"),
|
||||||
|
"set GOVOPLAN_POLL_TEST_POSTGRES_URL for the two-session PostgreSQL check",
|
||||||
|
)
|
||||||
|
class PollResponsePostgresConcurrencyTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
database_url = os.environ["GOVOPLAN_POLL_TEST_POSTGRES_URL"]
|
||||||
|
self.schema = f"poll_response_race_{uuid.uuid4().hex}"
|
||||||
|
self.admin_engine = create_engine(database_url)
|
||||||
|
with self.admin_engine.begin() as connection:
|
||||||
|
connection.execute(text(f'CREATE SCHEMA "{self.schema}"'))
|
||||||
|
self.engine = create_engine(
|
||||||
|
database_url,
|
||||||
|
connect_args={"options": f"-c search_path={self.schema}"},
|
||||||
|
)
|
||||||
|
self.tables = [Poll.__table__, PollOption.__table__, PollResponse.__table__]
|
||||||
|
Base.metadata.create_all(self.engine, tables=self.tables)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
try:
|
||||||
|
Base.metadata.drop_all(
|
||||||
|
self.engine,
|
||||||
|
tables=list(reversed(self.tables)),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self.engine.dispose()
|
||||||
|
with self.admin_engine.begin() as connection:
|
||||||
|
connection.execute(text(f'DROP SCHEMA "{self.schema}"'))
|
||||||
|
self.admin_engine.dispose()
|
||||||
|
|
||||||
|
def test_two_sessions_converge_on_one_active_response(self) -> None:
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
poll = create_poll(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="owner-1",
|
||||||
|
payload=PollCreateRequest(
|
||||||
|
title="Concurrent response",
|
||||||
|
kind="single_choice",
|
||||||
|
status="open",
|
||||||
|
options=[
|
||||||
|
PollOptionInput(key="yes", label="Yes"),
|
||||||
|
PollOptionInput(key="no", label="No"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
poll_id = poll.id
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
original = poll_service._existing_response
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
thread_state = threading.local()
|
||||||
|
|
||||||
|
def synchronize_first_lookup(session, *, poll, respondent_id):
|
||||||
|
response = original(
|
||||||
|
session,
|
||||||
|
poll=poll,
|
||||||
|
respondent_id=respondent_id,
|
||||||
|
)
|
||||||
|
lookup_count = getattr(thread_state, "lookup_count", 0) + 1
|
||||||
|
thread_state.lookup_count = lookup_count
|
||||||
|
if lookup_count == 1:
|
||||||
|
self.assertIsNone(response)
|
||||||
|
barrier.wait(timeout=10)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def submit(option_key: str) -> str:
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
response = submit_poll_response(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
poll_id=poll_id,
|
||||||
|
payload=PollSubmitResponseRequest(
|
||||||
|
respondent_id="person-1",
|
||||||
|
respondent_label="Person One",
|
||||||
|
answers=[PollAnswerInput(option_key=option_key)],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
response_id = response.id
|
||||||
|
session.commit()
|
||||||
|
return response_id
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
poll_service,
|
||||||
|
"_existing_response",
|
||||||
|
side_effect=synchronize_first_lookup,
|
||||||
|
):
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||||
|
response_ids = tuple(
|
||||||
|
executor.map(submit, ("yes", "no"))
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(set(response_ids)), 1)
|
||||||
|
with Session(self.engine) as session:
|
||||||
|
active = (
|
||||||
|
session.query(PollResponse)
|
||||||
|
.filter(
|
||||||
|
PollResponse.poll_id == poll_id,
|
||||||
|
PollResponse.respondent_id == "person-1",
|
||||||
|
PollResponse.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
self.assertEqual(len(active), 1)
|
||||||
|
self.assertIn(active[0].answers[0]["option_key"], {"yes", "no"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user