Resolve public poll invitations to tenant policy

This commit is contained in:
2026-08-04 09:29:36 +02:00
parent 5e9aa58eda
commit 3c126a7ee1
5 changed files with 53 additions and 1 deletions
+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,
+18 -1
View File
@@ -90,7 +90,8 @@ DOCUMENTATION = (
body=( body=(
"An invitation or signed participation link determines which poll and participant identity a response belongs to. " "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. " "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." "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", layer="configured",
documentation_types=("user",), documentation_types=("user",),
@@ -117,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
@@ -147,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,
@@ -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",
+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})
+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,