9 Commits
Author SHA1 Message Date
zemion f02fd11ea8 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 19:52:12 +02:00
zemion 26d81cc681 Release v0.1.15
Module Package Release / publish-packages (push) Successful in 10s
2026-08-04 15:10:20 +02:00
zemion 59ac558e7e Make package publication retries hash-safe 2026-08-04 14:32:20 +02:00
zemion 93528f4146 Harden module package publication 2026-08-04 14:02:41 +02:00
zemion 3c126a7ee1 Resolve public poll invitations to tenant policy 2026-08-04 09:29:36 +02:00
zemion 5e9aa58eda Add protected package release workflow 2026-08-04 04:14:06 +02:00
zemion 10354268b7 docs: declare institutional architecture boundary 2026-08-01 17:48:38 +02:00
zemion 652b7e1593 refactor: make poll mutation effects explicit 2026-07-29 19:01:57 +02:00
zemion fc0246b0f0 perf: bound and batch visible poll listings 2026-07-29 14:16:29 +02:00
13 changed files with 1056 additions and 132 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+16
View File
@@ -0,0 +1,16 @@
# GovOPlaN Poll Codex Guide
## Scope
This repository owns reusable poll definitions, options, invitations, signed participation, responses, closing semantics, and result aggregation.
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Poll internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Boundaries
- Scheduling adds meeting-specific workflow and Calendar integration; Evaluation owns surveys and scoring.
- Keep Access optional and preserve atomic participation constraints.
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-poll" name = "govoplan-poll"
version = "0.1.11" version = "0.1.16"
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.11", "govoplan-core>=0.1.16",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
+1 -1
View File
@@ -2,4 +2,4 @@
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "0.1.11" __version__ = "0.1.16"
+23
View File
@@ -32,6 +32,7 @@ from govoplan_poll.backend.participation import (
PollInvitationRevocationRef, PollInvitationRevocationRef,
PollOptionMutationRef, PollOptionMutationRef,
PollParticipationContextRef, PollParticipationContextRef,
PollPublicInvitationRef,
PollResponseGatewayRef, PollResponseGatewayRef,
) )
from govoplan_poll.backend.participation_service import ( from govoplan_poll.backend.participation_service import (
@@ -323,6 +324,28 @@ class SqlPollSchedulingProvider(PollSchedulingProvider):
except (PollError, ValidationError) as exc: except (PollError, ValidationError) as exc:
raise PollCapabilityError(str(exc)) from exc raise PollCapabilityError(str(exc)) from exc
def resolve_public_invitation(
self,
session: object,
*,
token: str,
gateway: PollResponseGatewayRef,
) -> PollPublicInvitationRef:
try:
invitation = governed_invitation(
session,
token=token,
gateway=gateway,
)
except (PollError, ValidationError) as exc:
raise PollCapabilityError(str(exc)) from exc
return PollPublicInvitationRef(
invitation_id=invitation.id,
tenant_id=invitation.tenant_id,
poll_id=invitation.poll_id,
gateway=gateway,
)
def submit_governed_response( def submit_governed_response(
self, self,
session: object, session: object,
+48 -3
View File
@@ -13,6 +13,7 @@ from govoplan_core.core.modules import (
PermissionDefinition, PermissionDefinition,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
from govoplan_core.core.poll_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_core.db.base import Base
@@ -20,7 +21,7 @@ from govoplan_poll.backend.db import models as poll_models # noqa: F401 - popul
MODULE_ID = "poll" MODULE_ID = "poll"
MODULE_NAME = "Poll" MODULE_NAME = "Poll"
MODULE_VERSION = "0.1.11" MODULE_VERSION = "0.1.16"
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"
@@ -77,11 +78,27 @@ DOCUMENTATION = (
"or adapter-provided participant flows." "or adapter-provided participant flows."
), ),
layer="available", layer="available",
documentation_types=("admin",), documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner"), audience=("user", "operator", "module_admin", "product_owner"),
related_modules=("scheduling", "evaluation", "calendar", "campaigns", "portal"), related_modules=("scheduling", "evaluation", "calendar", "campaigns", "portal"),
metadata={"seed": True}, metadata={"seed": True},
), ),
DocumentationTopic(
id="poll.participation-and-results",
title="Respond to a poll",
summary="Polls can collect single or multiple choices, yes/no, yes/no/maybe, ranked choices, and availability responses.",
body=(
"An invitation or signed participation link determines which poll and participant identity a response belongs to. "
"The poll policy controls anonymity, response updates, result visibility, open and close times, and whether Maybe is allowed. "
"Submitting a response is atomic: capacity and choice constraints are checked before the saved response replaces any earlier answer. "
"A valid signed link also resolves its tenant before Poll runs, so tenant module policy can withdraw the public surface without exposing another tenant's state."
),
layer="configured",
documentation_types=("user",),
audience=("user", "participant", "organizer"),
related_modules=("scheduling", "evaluation"),
metadata={"kind": "reference"},
),
) )
@@ -101,6 +118,21 @@ def _poll_router(_context: ModuleContext):
return router return router
def _public_tenant_resolver(request: object, session: object) -> str | None:
path_params = getattr(request, "path_params", {})
token = str(path_params.get("token") or "").strip()
path = str(getattr(getattr(request, "url", None), "path", ""))
if not token or "/poll/public/" not in path:
return None
from govoplan_poll.backend.service import PollError, get_poll_by_invitation_token
try:
poll = get_poll_by_invitation_token(session, token=token)
except PollError:
return None
return poll.tenant_id
def _poll_scheduling_provider(context: ModuleContext) -> object: def _poll_scheduling_provider(context: ModuleContext) -> object:
del context del context
from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider
@@ -131,6 +163,7 @@ manifest = ModuleManifest(
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
route_factory=_poll_router, route_factory=_poll_router,
public_tenant_resolver=_public_tenant_resolver,
tenant_summary_providers=(_tenant_summary,), tenant_summary_providers=(_tenant_summary,),
capability_factories={ capability_factories={
CAPABILITY_POLL_SCHEDULING: _poll_scheduling_provider, CAPABILITY_POLL_SCHEDULING: _poll_scheduling_provider,
@@ -164,6 +197,18 @@ manifest = ModuleManifest(
), ),
), ),
documentation=DOCUMENTATION, documentation=DOCUMENTATION,
architecture=declared_module_architecture(
layer="communication_participation",
kind="domain",
maturity="vertical_slice",
documentation_ref="README.md",
test_ref="tests/test_service.py",
known_limits=("Advanced voting methods, production notification profiles, and reference accessibility evidence remain incomplete.",),
owned_concepts=("poll", "poll option", "poll invitation", "poll response"),
non_owned_concepts=("scheduling request", "calendar event", "evaluation rubric"),
recovery_docs=("README.md",),
security_docs=("README.md",),
),
) )
+371
View File
@@ -0,0 +1,371 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Literal, Protocol
MAX_RETIREMENT_RESPONDENT_IDS = 500
MAX_RETIREMENT_RESPONSES = 1000
OWNERSHIP_FIELDS = frozenset(
{
"context_module",
"context_resource_type",
"context_resource_id",
"workflow_state",
"workflow_steps",
}
)
DIRECT_UPDATE_FIELDS = (
"title",
"description",
"visibility",
"result_visibility",
"context_module",
"context_resource_type",
"context_resource_id",
"workflow_state",
"allow_anonymous",
"allow_response_update",
)
ResponseDisposition = Literal[
"preserve",
"invalidate_affected_answers",
"retire",
"reject",
]
class PollMutationPlanError(ValueError):
pass
class PollLike(Protocol):
status: str
kind: str
min_choices: int
max_choices: int | None
opens_at: datetime | None
closes_at: datetime | None
class RetirableResponse(Protocol):
id: str
deleted_at: datetime | None
metadata_: dict[str, Any] | None
@dataclass(frozen=True, slots=True)
class ExistingResponseDecision:
change: str
disposition: ResponseDisposition
reason: str
@dataclass(frozen=True, slots=True)
class PollUpdatePlan:
values: Mapping[str, object]
response_decision: ExistingResponseDecision
def apply(self, poll: object) -> None:
for field, value in self.values.items():
setattr(poll, field, value)
@dataclass(frozen=True, slots=True)
class ResponseRetirementSelector:
respondent_ids: tuple[str, ...]
invitation_id: str | None
reason: str
idempotency_key: str
@dataclass(frozen=True, slots=True)
class ResponseRetirementPlan:
responses: tuple[RetirableResponse, ...]
retired_at: datetime | None
disposition: Literal["retire", "replay", "noop"]
@property
def newly_retired_count(self) -> int:
return len(self.responses) if self.disposition == "retire" else 0
def apply(
self,
*,
reason: str,
idempotency_key: str,
metadata: Mapping[str, object],
) -> None:
if self.disposition != "retire" or self.retired_at is None:
return
retirement = {
"idempotency_key": idempotency_key,
"reason": reason,
"retired_at": self.retired_at.isoformat(),
"context": dict(metadata),
}
for response in self.responses:
response.metadata_ = {
**(response.metadata_ or {}),
"response_retirement": retirement,
}
response.deleted_at = self.retired_at
def plan_poll_update(
poll: PollLike,
values: Mapping[str, object],
*,
active_option_count: int,
) -> PollUpdatePlan:
if poll.status in {"closed", "decided", "archived"}:
raise PollMutationPlanError(
"Closed, decided, or archived polls cannot be edited"
)
updates: dict[str, object] = {}
for field in DIRECT_UPDATE_FIELDS:
value = values.get(field)
if value is not None:
updates[field] = value
for field in ("workflow_steps", "metadata"):
value = values.get(field)
if value is not None:
updates["metadata_" if field == "metadata" else field] = value
min_choices = (
poll.min_choices
if values.get("min_choices") is None
else int(values["min_choices"]) # type: ignore[arg-type]
)
max_choices = (
poll.max_choices
if values.get("max_choices") is None
else int(values["max_choices"]) # type: ignore[arg-type]
)
if values.get("min_choices") is not None or values.get("max_choices") is not None:
min_choices, max_choices = validate_choice_bounds(
poll.kind,
min_choices,
max_choices,
active_option_count,
)
updates["min_choices"] = min_choices
updates["max_choices"] = max_choices
opens_at = (
values["opens_at"]
if values.get("opens_at") is not None
else poll.opens_at
)
closes_at = (
values["closes_at"]
if values.get("closes_at") is not None
else poll.closes_at
)
if values.get("opens_at") is not None:
updates["opens_at"] = opens_at
if values.get("closes_at") is not None:
updates["closes_at"] = closes_at
if (
isinstance(opens_at, datetime)
and isinstance(closes_at, datetime)
and _comparable_datetime(closes_at) <= _comparable_datetime(opens_at)
):
raise PollMutationPlanError("closes_at must be after opens_at")
return PollUpdatePlan(
values=updates,
response_decision=ExistingResponseDecision(
change="poll_policy_or_scope",
disposition="preserve",
reason=(
"Poll metadata, policy, timing, and owner-approved scope changes "
"do not alter stable option identities or submitted answers."
),
),
)
def decide_existing_response_impact(
change: Literal[
"option_content",
"option_remove",
"option_reorder",
"participant_remove",
"poll_policy_or_scope",
],
*,
has_active_responses: bool,
allow_response_update: bool,
) -> ExistingResponseDecision:
if not has_active_responses:
return ExistingResponseDecision(
change=change,
disposition="preserve",
reason="No active responses are affected.",
)
if change in {"option_content", "option_remove"}:
if not allow_response_update:
return ExistingResponseDecision(
change=change,
disposition="reject",
reason=(
"Poll options cannot be edited after responses when "
"response updates are disabled"
),
)
return ExistingResponseDecision(
change=change,
disposition="invalidate_affected_answers",
reason=(
"Only answers bound to the changed stable option identity are "
"invalidated; empty responses are retired."
),
)
if change == "participant_remove":
return ExistingResponseDecision(
change=change,
disposition="retire",
reason="Responses for the removed participant leave live results.",
)
return ExistingResponseDecision(
change=change,
disposition="preserve",
reason="Stable response and option identities remain valid.",
)
def normalize_retirement_selector(
*,
respondent_ids: Sequence[str],
invitation_id: str | None,
reason: str,
idempotency_key: str,
) -> ResponseRetirementSelector:
normalized_ids = tuple(
dict.fromkeys(value.strip() for value in respondent_ids if value.strip())
)
if len(normalized_ids) > MAX_RETIREMENT_RESPONDENT_IDS:
raise PollMutationPlanError(
"Response retirement targets too many participant identities"
)
normalized_invitation_id = (
invitation_id.strip()
if invitation_id is not None 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 PollMutationPlanError(
"Response retirement requires a trusted participant identity"
)
if not normalized_reason or len(normalized_reason) > 120:
raise PollMutationPlanError("Response retirement reason is invalid")
if not normalized_key or len(normalized_key) > 255:
raise PollMutationPlanError(
"Response retirement idempotency key is invalid"
)
return ResponseRetirementSelector(
respondent_ids=normalized_ids,
invitation_id=normalized_invitation_id,
reason=normalized_reason,
idempotency_key=normalized_key,
)
def plan_response_retirement(
responses: Sequence[RetirableResponse],
*,
idempotency_key: str,
now: datetime,
) -> ResponseRetirementPlan:
replayed = tuple(
response
for response in responses
if isinstance((response.metadata_ or {}).get("response_retirement"), dict)
and (response.metadata_ or {})["response_retirement"].get(
"idempotency_key"
)
== idempotency_key
)
if replayed:
retired_at = max(
(
_comparable_datetime(response.deleted_at)
for response in replayed
if response.deleted_at is not None
),
default=None,
)
return ResponseRetirementPlan(
responses=replayed,
retired_at=retired_at,
disposition="replay",
)
active = tuple(response for response in responses if response.deleted_at is None)
if active:
return ResponseRetirementPlan(
responses=active,
retired_at=now,
disposition="retire",
)
return ResponseRetirementPlan(
responses=(),
retired_at=None,
disposition="noop",
)
def validate_choice_bounds(
kind: str,
min_choices: int,
max_choices: int | None,
option_count: int,
) -> tuple[int, int | None]:
if kind in {"single_choice", "yes_no", "yes_no_maybe"}:
return 1, 1
if kind == "ranked_choice":
min_choices = max(1, min_choices)
if max_choices is None:
max_choices = option_count
if min_choices > option_count:
raise PollMutationPlanError(
"min_choices cannot be greater than the number of options"
)
if max_choices is not None:
if max_choices < min_choices:
raise PollMutationPlanError(
"max_choices cannot be smaller than min_choices"
)
if max_choices > option_count:
raise PollMutationPlanError(
"max_choices cannot be greater than the number of options"
)
return min_choices, max_choices
def _comparable_datetime(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
__all__ = [
"MAX_RETIREMENT_RESPONDENT_IDS",
"MAX_RETIREMENT_RESPONSES",
"ExistingResponseDecision",
"PollMutationPlanError",
"PollUpdatePlan",
"ResponseRetirementPlan",
"ResponseRetirementSelector",
"decide_existing_response_impact",
"normalize_retirement_selector",
"plan_poll_update",
"plan_response_retirement",
"validate_choice_bounds",
]
@@ -19,6 +19,7 @@ from govoplan_core.core.poll_participation import (
PollParticipationContextRef, PollParticipationContextRef,
PollParticipationGatewayProvider, PollParticipationGatewayProvider,
PollParticipationPolicy, PollParticipationPolicy,
PollPublicInvitationRef,
PollResponseGatewayRef, PollResponseGatewayRef,
participation_token_fingerprint, participation_token_fingerprint,
poll_participation_gateway_provider, poll_participation_gateway_provider,
@@ -37,6 +38,7 @@ __all__ = [
"PollParticipationContextRef", "PollParticipationContextRef",
"PollParticipationGatewayProvider", "PollParticipationGatewayProvider",
"PollParticipationPolicy", "PollParticipationPolicy",
"PollPublicInvitationRef",
"PollResponseGatewayRef", "PollResponseGatewayRef",
"participation_token_fingerprint", "participation_token_fingerprint",
"poll_participation_gateway_provider", "poll_participation_gateway_provider",
+2
View File
@@ -176,6 +176,7 @@ def _require_sensitive_poll_data_scope(principal: ApiPrincipal) -> None:
def api_list_polls( def api_list_polls(
status_filter: str | None = Query(default=None, alias="status"), status_filter: str | None = Query(default=None, alias="status"),
kind: str | None = None, kind: str | None = None,
limit: int = 100,
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> PollListResponse: ) -> PollListResponse:
@@ -187,6 +188,7 @@ def api_list_polls(
can_manage=_can_manage_polls(principal), can_manage=_can_manage_polls(principal),
status=status_filter, status=status_filter,
kind=kind, kind=kind,
limit=limit,
) )
return PollListResponse(polls=[_poll_response(poll) for poll in polls]) return PollListResponse(polls=[_poll_response(poll) for poll in polls])
+146 -124
View File
@@ -10,10 +10,20 @@ from typing import Any, Callable
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, selectinload
from govoplan_core.db.base import utcnow from govoplan_core.db.base import utcnow
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse
from govoplan_poll.backend.mutation_plans import (
MAX_RETIREMENT_RESPONSES,
OWNERSHIP_FIELDS,
PollMutationPlanError,
decide_existing_response_impact,
normalize_retirement_selector,
plan_poll_update,
plan_response_retirement,
validate_choice_bounds,
)
from govoplan_poll.backend.schemas import ( from govoplan_poll.backend.schemas import (
PollCreateRequest, PollCreateRequest,
PollDecisionRequest, PollDecisionRequest,
@@ -266,21 +276,15 @@ def _normalize_options(kind: str, options: list[PollOptionInput]) -> list[PollOp
def _validate_choice_bounds(kind: str, min_choices: int, max_choices: int | None, option_count: int) -> tuple[int, int | None]: def _validate_choice_bounds(kind: str, min_choices: int, max_choices: int | None, option_count: int) -> tuple[int, int | None]:
if kind in {"single_choice", "yes_no", "yes_no_maybe"}: try:
return 1, 1 return validate_choice_bounds(
if kind == "ranked_choice": kind,
if min_choices < 1: min_choices,
min_choices = 1 max_choices,
if max_choices is None: option_count,
max_choices = option_count )
if min_choices > option_count: except PollMutationPlanError as exc:
raise PollError("min_choices cannot be greater than the number of options") raise PollError(str(exc)) from exc
if max_choices is not None:
if max_choices < min_choices:
raise PollError("max_choices cannot be smaller than min_choices")
if max_choices > option_count:
raise PollError("max_choices cannot be greater than the number of options")
return min_choices, max_choices
def _ensure_valid_poll_payload(payload: PollCreateRequest) -> tuple[list[PollOptionInput], int, int | None]: def _ensure_valid_poll_payload(payload: PollCreateRequest) -> tuple[list[PollOptionInput], int, int | None]:
@@ -366,13 +370,28 @@ def create_poll(
return poll return poll
def list_polls(session: Session, *, tenant_id: str, status: str | None = None, kind: str | None = None) -> list[Poll]: def list_polls(
query = session.query(Poll).filter(Poll.tenant_id == tenant_id, Poll.deleted_at.is_(None)) session: Session,
*,
tenant_id: str,
status: str | None = None,
kind: str | None = None,
limit: int = 100,
) -> list[Poll]:
query = (
session.query(Poll)
.options(selectinload(Poll.options))
.filter(Poll.tenant_id == tenant_id, Poll.deleted_at.is_(None))
)
if status: if status:
query = query.filter(Poll.status == status) query = query.filter(Poll.status == status)
if kind: if kind:
query = query.filter(Poll.kind == kind) query = query.filter(Poll.kind == kind)
return query.order_by(Poll.created_at.desc(), Poll.title.asc()).all() return (
query.order_by(Poll.created_at.desc(), Poll.title.asc())
.limit(max(1, min(limit, 200)))
.all()
)
def get_poll(session: Session, *, tenant_id: str, poll_id: str) -> Poll: def get_poll(session: Session, *, tenant_id: str, poll_id: str) -> Poll:
@@ -432,12 +451,45 @@ def list_visible_polls(
can_manage: bool = False, can_manage: bool = False,
status: str | None = None, status: str | None = None,
kind: str | None = None, kind: str | None = None,
limit: int = 100,
) -> list[Poll]: ) -> list[Poll]:
return [ query = (
poll session.query(Poll)
for poll in list_polls(session, tenant_id=tenant_id, status=status, kind=kind) .options(selectinload(Poll.options))
if poll_is_visible(session, poll=poll, actor_ids=actor_ids, can_manage=can_manage) .filter(Poll.tenant_id == tenant_id, Poll.deleted_at.is_(None))
] )
if status:
query = query.filter(Poll.status == status)
if kind:
query = query.filter(Poll.kind == kind)
if not can_manage:
ids = _actor_ids(actor_ids)
invitation_exists = (
session.query(PollInvitation.id)
.filter(
PollInvitation.tenant_id == tenant_id,
PollInvitation.poll_id == Poll.id,
PollInvitation.respondent_id.in_(ids or ("",)),
PollInvitation.revoked_at.is_(None),
or_(
PollInvitation.expires_at.is_(None),
PollInvitation.expires_at > _now(),
),
)
.exists()
)
query = query.filter(
or_(
Poll.created_by_user_id.in_(ids or ("",)),
Poll.visibility.in_(("tenant", "public")),
invitation_exists,
)
)
return (
query.order_by(Poll.created_at.desc(), Poll.title.asc())
.limit(max(1, min(limit, 200)))
.all()
)
def get_visible_poll( def get_visible_poll(
@@ -514,14 +566,31 @@ def update_poll(
) -> Poll: ) -> Poll:
poll = _lock_poll_for_response(session, tenant_id=tenant_id, poll_id=poll_id) poll = _lock_poll_for_response(session, tenant_id=tenant_id, poll_id=poll_id)
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner) _assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
ownership_fields = { _validate_poll_update_ownership(
"context_module", poll,
"context_resource_type", payload,
"context_resource_id", mutation_owner=mutation_owner,
"workflow_state", )
"workflow_steps", try:
} plan = plan_poll_update(
if mutation_owner is None and ownership_fields & payload.model_fields_set: poll,
payload.model_dump(exclude_unset=True),
active_option_count=len(_active_options(poll)),
)
except PollMutationPlanError as exc:
raise PollError(str(exc)) from exc
plan.apply(poll)
session.flush()
return poll
def _validate_poll_update_ownership(
poll: Poll,
payload: PollUpdateRequest,
*,
mutation_owner: PollMutationOwner | None,
) -> None:
if mutation_owner is None and OWNERSHIP_FIELDS & payload.model_fields_set:
raise PollError(POLL_OWNERSHIP_FIELDS_RESTRICTED) raise PollError(POLL_OWNERSHIP_FIELDS_RESTRICTED)
context_fields = { context_fields = {
"context_module", "context_module",
@@ -548,41 +617,6 @@ def update_poll(
) )
if requested_owner != mutation_owner: if requested_owner != mutation_owner:
raise PollError(OWNED_POLL_MUTATION_REQUIRED) raise PollError(OWNED_POLL_MUTATION_REQUIRED)
if poll.status in {"closed", "decided", "archived"}:
raise PollError("Closed, decided, or archived polls cannot be edited")
for field in (
"title",
"description",
"visibility",
"result_visibility",
"context_module",
"context_resource_type",
"context_resource_id",
"workflow_state",
"allow_anonymous",
"allow_response_update",
):
value = getattr(payload, field)
if value is not None:
setattr(poll, field, value)
if payload.workflow_steps is not None:
poll.workflow_steps = payload.workflow_steps
if payload.min_choices is not None or payload.max_choices is not None:
min_choices = poll.min_choices if payload.min_choices is None else payload.min_choices
max_choices = poll.max_choices if payload.max_choices is None else payload.max_choices
min_choices, max_choices = _validate_choice_bounds(poll.kind, min_choices, max_choices, len(_active_options(poll)))
poll.min_choices = min_choices
poll.max_choices = max_choices
if payload.opens_at is not None:
poll.opens_at = payload.opens_at
if payload.closes_at is not None:
poll.closes_at = payload.closes_at
if poll.opens_at is not None and poll.closes_at is not None and poll.closes_at <= poll.opens_at:
raise PollError("closes_at must be after opens_at")
if payload.metadata is not None:
poll.metadata_ = payload.metadata
session.flush()
return poll
def set_poll_workflow_context( def set_poll_workflow_context(
@@ -1472,8 +1506,13 @@ def update_poll_option(
return option return option
responses = _locked_active_poll_responses(session, poll=poll) responses = _locked_active_poll_responses(session, poll=poll)
if responses and not poll.allow_response_update: decision = decide_existing_response_impact(
raise PollError("Poll options cannot be edited after responses when response updates are disabled") "option_content",
has_active_responses=bool(responses),
allow_response_update=poll.allow_response_update,
)
if decision.disposition == "reject":
raise PollError(decision.reason)
option.label = label option.label = label
option.description = description option.description = description
option.value = normalized_value option.value = normalized_value
@@ -1665,8 +1704,13 @@ def remove_poll_option(
if remaining_count < required_count or poll.min_choices > remaining_count: if remaining_count < required_count or poll.min_choices > remaining_count:
raise PollError("Poll option cannot be removed because too few options would remain") raise PollError("Poll option cannot be removed because too few options would remain")
responses = _locked_active_poll_responses(session, poll=poll) responses = _locked_active_poll_responses(session, poll=poll)
if responses and not poll.allow_response_update: decision = decide_existing_response_impact(
raise PollError("Poll options cannot be edited after responses when response updates are disabled") "option_remove",
has_active_responses=bool(responses),
allow_response_update=poll.allow_response_update,
)
if decision.disposition == "reject":
raise PollError(decision.reason)
invalidated = _invalidate_option_answers(responses, option_id=option.id) invalidated = _invalidate_option_answers(responses, option_id=option.id)
option.deleted_at = _now() option.deleted_at = _now()
_synchronize_mutable_choice_bounds( _synchronize_mutable_choice_bounds(
@@ -1863,20 +1907,15 @@ def retire_poll_responses(
) -> tuple[list[PollResponse], datetime | None, int, bool]: ) -> tuple[list[PollResponse], datetime | None, int, bool]:
"""Soft-delete owner-selected responses without erasing their answers.""" """Soft-delete owner-selected responses without erasing their answers."""
normalized_ids = tuple( try:
dict.fromkeys(value.strip() for value in respondent_ids if value.strip()) selector = normalize_retirement_selector(
respondent_ids=respondent_ids,
invitation_id=invitation_id,
reason=reason,
idempotency_key=idempotency_key,
) )
normalized_invitation_id = ( except PollMutationPlanError as exc:
invitation_id.strip() if invitation_id and invitation_id.strip() else None raise PollError(str(exc)) from exc
)
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) assert_no_sensitive_participation_metadata(metadata)
poll = _lock_poll_for_response( poll = _lock_poll_for_response(
@@ -1886,12 +1925,12 @@ def retire_poll_responses(
) )
_assert_poll_mutation_owner(poll, mutation_owner=mutation_owner) _assert_poll_mutation_owner(poll, mutation_owner=mutation_owner)
conditions = [] conditions = []
if normalized_ids: if selector.respondent_ids:
conditions.append(PollResponse.respondent_id.in_(normalized_ids)) conditions.append(PollResponse.respondent_id.in_(selector.respondent_ids))
if normalized_invitation_id is not None: if selector.invitation_id is not None:
conditions.append( conditions.append(
PollResponse.metadata_["invitation_id"].as_string() PollResponse.metadata_["invitation_id"].as_string()
== normalized_invitation_id == selector.invitation_id
) )
responses = ( responses = (
session.query(PollResponse) session.query(PollResponse)
@@ -1903,46 +1942,29 @@ def retire_poll_responses(
.order_by(PollResponse.submitted_at.asc(), PollResponse.id.asc()) .order_by(PollResponse.submitted_at.asc(), PollResponse.id.asc())
.populate_existing() .populate_existing()
.with_for_update() .with_for_update()
.limit(MAX_RETIREMENT_RESPONSES + 1)
.all() .all()
) )
active = [response for response in responses if response.deleted_at is None] if len(responses) > MAX_RETIREMENT_RESPONSES:
if active: raise PollError("Response retirement matches too many responses")
retired_at = _now() plan = plan_response_retirement(
retirement = { responses,
"idempotency_key": normalized_key, idempotency_key=selector.idempotency_key,
"reason": normalized_reason, now=_now(),
"retired_at": retired_at.isoformat(), )
"context": dict(metadata), if plan.disposition == "retire":
} plan.apply(
for response in active: reason=selector.reason,
response.metadata_ = { idempotency_key=selector.idempotency_key,
**(response.metadata_ or {}), metadata=metadata,
"response_retirement": retirement, )
}
response.deleted_at = retired_at
session.flush() session.flush()
return active, retired_at, len(active), False return (
list(plan.responses),
replayed = [ plan.retired_at,
response plan.newly_retired_count,
for response in responses plan.disposition == "replay",
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:
+1
View File
@@ -18,6 +18,7 @@ class PollManifestTests(unittest.TestCase):
self.assertFalse(manifest.required_capabilities) self.assertFalse(manifest.required_capabilities)
self.assertIn("auth.principalResolver", manifest.optional_capabilities) self.assertIn("auth.principalResolver", manifest.optional_capabilities)
self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.public_tenant_resolver)
self.assertIsNotNone(manifest.migration_spec) self.assertIsNotNone(manifest.migration_spec)
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.provides_interfaces}) self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.provides_interfaces})
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.provides_interfaces}) self.assertIn("poll.workflow_context", {interface.name for interface in manifest.provides_interfaces})
+163
View File
@@ -0,0 +1,163 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from govoplan_poll.backend.mutation_plans import (
MAX_RETIREMENT_RESPONDENT_IDS,
PollMutationPlanError,
decide_existing_response_impact,
normalize_retirement_selector,
plan_poll_update,
plan_response_retirement,
)
def poll(**overrides: object) -> SimpleNamespace:
values = {
"status": "open",
"kind": "availability",
"title": "Availability",
"description": None,
"visibility": "private",
"result_visibility": "organizer",
"context_module": "scheduling",
"context_resource_type": "scheduling_request",
"context_resource_id": "request-1",
"workflow_state": "collecting",
"workflow_steps": [],
"allow_anonymous": False,
"allow_response_update": True,
"min_choices": 1,
"max_choices": 2,
"opens_at": None,
"closes_at": None,
"metadata_": {},
}
values.update(overrides)
return SimpleNamespace(**values)
def response(
response_id: str,
*,
deleted_at: datetime | None = None,
idempotency_key: str | None = None,
) -> SimpleNamespace:
metadata = {}
if idempotency_key is not None:
metadata["response_retirement"] = {
"idempotency_key": idempotency_key
}
return SimpleNamespace(
id=response_id,
deleted_at=deleted_at,
metadata_=metadata,
)
class PollMutationPlanTests(unittest.TestCase):
def test_poll_update_is_planned_before_mutation_and_preserves_responses(
self,
) -> None:
item = poll()
plan = plan_poll_update(
item, # type: ignore[arg-type]
{
"title": "Revised",
"context_resource_id": "request-2",
"max_choices": 1,
},
active_option_count=2,
)
self.assertEqual("Availability", item.title)
self.assertEqual("preserve", plan.response_decision.disposition)
plan.apply(item)
self.assertEqual("Revised", item.title)
self.assertEqual("request-2", item.context_resource_id)
self.assertEqual(1, item.max_choices)
def test_poll_update_rejects_invalid_window_without_mutation(self) -> None:
item = poll(opens_at=datetime.now(timezone.utc))
with self.assertRaisesRegex(
PollMutationPlanError,
"closes_at must be after opens_at",
):
plan_poll_update(
item, # type: ignore[arg-type]
{"closes_at": item.opens_at - timedelta(minutes=1)},
active_option_count=2,
)
self.assertIsNone(item.closes_at)
def test_existing_response_decision_table(self) -> None:
cases = (
("option_content", True, True, "invalidate_affected_answers"),
("option_remove", True, False, "reject"),
("option_reorder", True, False, "preserve"),
("participant_remove", True, False, "retire"),
("poll_policy_or_scope", True, False, "preserve"),
("option_remove", False, False, "preserve"),
)
for change, has_responses, allow_updates, expected in cases:
with self.subTest(change=change, has_responses=has_responses):
decision = decide_existing_response_impact(
change, # type: ignore[arg-type]
has_active_responses=has_responses,
allow_response_update=allow_updates,
)
self.assertEqual(expected, decision.disposition)
def test_retirement_selector_is_deduplicated_and_bounded(self) -> None:
selector = normalize_retirement_selector(
respondent_ids=(" person-1 ", "person-1", ""),
invitation_id=None,
reason=" participant removed ",
idempotency_key=" request:participant:removed ",
)
self.assertEqual(("person-1",), selector.respondent_ids)
self.assertEqual("participant removed", selector.reason)
with self.assertRaisesRegex(
PollMutationPlanError,
"too many participant identities",
):
normalize_retirement_selector(
respondent_ids=tuple(
f"person-{index}"
for index in range(MAX_RETIREMENT_RESPONDENT_IDS + 1)
),
invitation_id=None,
reason="participant removed",
idempotency_key="bounded",
)
def test_retirement_replay_precedes_new_active_response(self) -> None:
retired_at = datetime.now(timezone.utc) - timedelta(minutes=1)
already_retired = response(
"response-old",
deleted_at=retired_at,
idempotency_key="remove-1",
)
newly_submitted = response("response-new")
plan = plan_response_retirement(
(already_retired, newly_submitted),
idempotency_key="remove-1",
now=datetime.now(timezone.utc),
)
self.assertEqual("replay", plan.disposition)
self.assertEqual(("response-old",), tuple(item.id for item in plan.responses))
plan.apply(
reason="participant removed",
idempotency_key="remove-1",
metadata={},
)
self.assertIsNone(newly_submitted.deleted_at)
if __name__ == "__main__":
unittest.main()
+9
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import unittest import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -9,6 +10,7 @@ from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.core.poll import PollResponseRef, PollResponseSubmissionProvider, PollSchedulingProvider from govoplan_core.core.poll import PollResponseRef, PollResponseSubmissionProvider, PollSchedulingProvider
from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider
from govoplan_poll.backend.manifest import get_manifest
from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse
from govoplan_poll.backend.schemas import ( from govoplan_poll.backend.schemas import (
PollAnswerInput, PollAnswerInput,
@@ -304,6 +306,13 @@ class PollServiceTests(unittest.TestCase):
poll_id=poll.id, poll_id=poll.id,
payload=PollInvitationCreateRequest(respondent_label="External participant"), payload=PollInvitationCreateRequest(respondent_label="External participant"),
) )
resolver = get_manifest().public_tenant_resolver
self.assertIsNotNone(resolver)
request = SimpleNamespace(
path_params={"token": token},
url=SimpleNamespace(path=f"/api/v1/poll/public/{token}"),
)
self.assertEqual("tenant-1", resolver(request, self.session))
response = submit_poll_response_with_token( response = submit_poll_response_with_token(
self.session, self.session,