diff --git a/src/govoplan_poll/backend/capabilities.py b/src/govoplan_poll/backend/capabilities.py index 6909353..9b6cb42 100644 --- a/src/govoplan_poll/backend/capabilities.py +++ b/src/govoplan_poll/backend/capabilities.py @@ -32,6 +32,7 @@ from govoplan_poll.backend.participation import ( PollInvitationRevocationRef, PollOptionMutationRef, PollParticipationContextRef, + PollPublicInvitationRef, PollResponseGatewayRef, ) from govoplan_poll.backend.participation_service import ( @@ -323,6 +324,28 @@ class SqlPollSchedulingProvider(PollSchedulingProvider): except (PollError, ValidationError) as 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( self, session: object, diff --git a/src/govoplan_poll/backend/manifest.py b/src/govoplan_poll/backend/manifest.py index bcdcbef..aee8da6 100644 --- a/src/govoplan_poll/backend/manifest.py +++ b/src/govoplan_poll/backend/manifest.py @@ -90,7 +90,8 @@ DOCUMENTATION = ( 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." + "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",), @@ -117,6 +118,21 @@ def _poll_router(_context: ModuleContext): 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: del context from govoplan_poll.backend.capabilities import SqlPollSchedulingProvider @@ -147,6 +163,7 @@ manifest = ModuleManifest( permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, route_factory=_poll_router, + public_tenant_resolver=_public_tenant_resolver, tenant_summary_providers=(_tenant_summary,), capability_factories={ CAPABILITY_POLL_SCHEDULING: _poll_scheduling_provider, diff --git a/src/govoplan_poll/backend/participation.py b/src/govoplan_poll/backend/participation.py index 1defa54..e7faded 100644 --- a/src/govoplan_poll/backend/participation.py +++ b/src/govoplan_poll/backend/participation.py @@ -19,6 +19,7 @@ from govoplan_core.core.poll_participation import ( PollParticipationContextRef, PollParticipationGatewayProvider, PollParticipationPolicy, + PollPublicInvitationRef, PollResponseGatewayRef, participation_token_fingerprint, poll_participation_gateway_provider, @@ -37,6 +38,7 @@ __all__ = [ "PollParticipationContextRef", "PollParticipationGatewayProvider", "PollParticipationPolicy", + "PollPublicInvitationRef", "PollResponseGatewayRef", "participation_token_fingerprint", "poll_participation_gateway_provider", diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 9c8d5a8..4e3c945 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -18,6 +18,7 @@ class PollManifestTests(unittest.TestCase): self.assertFalse(manifest.required_capabilities) self.assertIn("auth.principalResolver", manifest.optional_capabilities) self.assertIsNotNone(manifest.route_factory) + self.assertIsNotNone(manifest.public_tenant_resolver) self.assertIsNotNone(manifest.migration_spec) 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}) diff --git a/tests/test_service.py b/tests/test_service.py index 7b2c700..f315363 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -2,6 +2,7 @@ from __future__ import annotations import unittest from datetime import datetime, timezone +from types import SimpleNamespace from sqlalchemy import create_engine 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.core.poll import PollResponseRef, PollResponseSubmissionProvider, PollSchedulingProvider 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.schemas import ( PollAnswerInput, @@ -304,6 +306,13 @@ class PollServiceTests(unittest.TestCase): poll_id=poll.id, 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( self.session,