From ed39f83688297a5f6ae5bdd0285df04d4c5102ab Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 04:01:56 +0200 Subject: [PATCH] feat(scheduling): expose invitation delivery capability --- src/govoplan_scheduling/backend/schemas.py | 1 + src/govoplan_scheduling/backend/service.py | 8 ++ tests/test_participant_privacy.py | 24 +++++- tests/test_service.py | 96 ++++++++++++++++++++++ 4 files changed, 128 insertions(+), 1 deletion(-) diff --git a/src/govoplan_scheduling/backend/schemas.py b/src/govoplan_scheduling/backend/schemas.py index 30cad6e..525c289 100644 --- a/src/govoplan_scheduling/backend/schemas.py +++ b/src/govoplan_scheduling/backend/schemas.py @@ -320,6 +320,7 @@ class SchedulingRequestResponse(BaseModel): anonymous_password_protection_enabled: bool public_participation_policy_enforcement_available: bool | None = None public_participation_policy_enforcement_reason: str | None = None + participant_invitation_delivery_available: bool | None = None effective_participant_visibility: SchedulingParticipantVisibility participant_aggregate: SchedulingParticipantAggregateResponse participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index 19b8530..54d035b 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -3859,6 +3859,11 @@ def scheduling_request_response( "details": {}, } public_policy_available = _poll_participation_provider() is not None + participant_invitation_delivery_available = ( + notification_dispatch_provider(get_registry()) is not None + if full_roster + else None + ) return { "id": request.id, "tenant_id": request.tenant_id if full_roster else None, @@ -3890,6 +3895,9 @@ def scheduling_request_response( if public_policy_available or not full_roster else PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON ), + "participant_invitation_delivery_available": ( + participant_invitation_delivery_available + ), "effective_participant_visibility": visibility_decision["effective_visibility"], "participant_aggregate": _participant_aggregate(active_participants), "participant_visibility_decision": visibility_decision, diff --git a/tests/test_participant_privacy.py b/tests/test_participant_privacy.py index 3a1247c..b257f5c 100644 --- a/tests/test_participant_privacy.py +++ b/tests/test_participant_privacy.py @@ -2,6 +2,7 @@ from __future__ import annotations import unittest from datetime import datetime, timezone +from unittest.mock import patch from sqlalchemy import create_engine from sqlalchemy.orm import Session, sessionmaker @@ -184,7 +185,11 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase): def test_secure_default_returns_own_row_and_aggregate_counts(self) -> None: request = self._request() - payload = self._participant_projection(request) + with patch( + "govoplan_scheduling.backend.service.notification_dispatch_provider", + return_value=object(), + ): + payload = self._participant_projection(request) response = SchedulingRequestResponse.model_validate(payload) self.assertEqual(request.participant_visibility, "aggregates_only") @@ -212,6 +217,7 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase): self.assertIsNone(response.create_calendar_event_on_decision) self.assertIsNone(response.calendar_event_id) self.assertIsNone(response.public_participation_policy_enforcement_available) + self.assertIsNone(response.participant_invitation_delivery_available) self.assertEqual(response.metadata, {}) slot = response.slots[0] self.assertIsNone(slot.poll_option_id) @@ -287,6 +293,22 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase): "connector-uid-internal", ) self.assertEqual(response.slots[0].tentative_hold_event_id, "hold-internal") + self.assertFalse(response.participant_invitation_delivery_available) + + with patch( + "govoplan_scheduling.backend.service.notification_dispatch_provider", + return_value=object(), + ): + delivery_enabled = SchedulingRequestResponse.model_validate( + scheduling_request_response( + request, + actor_ids=("manager-1",), + actor_user_id="manager-1", + can_manage=True, + ) + ) + + self.assertTrue(delivery_enabled.participant_invitation_delivery_available) def test_optional_policy_can_reduce_but_cannot_broaden_visibility(self) -> None: restricting_policy = _PrivacyPolicy("aggregates_only") diff --git a/tests/test_service.py b/tests/test_service.py index 59fd74d..628e98e 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1548,6 +1548,102 @@ class SchedulingServiceTests(unittest.TestCase): serialized = repr({"payload": notification.payload, "metadata": notification.metadata_}) self.assertTrue(all(token not in serialized for token in tokens)) + def test_send_without_delivery_provider_preserves_existing_invitation(self) -> None: + request, _automatic_tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload().model_copy( + update={"calendar": SchedulingCalendarPreferences()} + ), + ) + participant = request.participants[0] + token = self._issue_copy(request, participant) + invitation_id = participant.poll_invitation_id + + with ( + patch( + "govoplan_scheduling.backend.service.notification_dispatch_provider", + return_value=None, + ), + self.assertRaisesRegex( + SchedulingError, + "Notification delivery is unavailable; copy the link instead", + ), + ): + issue_scheduling_participant_invitation( + self.session, + tenant_id=request.tenant_id, + request_id=request.id, + participant_id=participant.id, + action="send", + ) + + self.assertEqual(participant.poll_invitation_id, invitation_id) + invitation = self.session.query(PollInvitation).filter( + PollInvitation.id == invitation_id + ).one() + self.assertIsNone(invitation.revoked_at) + self.assertEqual( + get_public_scheduling_participation( + self.session, + request_id=request.id, + token=token, + payload=SchedulingPublicParticipationAccessRequest(), + client_address="127.0.0.1", + )["request_id"], + request.id, + ) + + def test_send_without_delivery_target_preserves_existing_invitation(self) -> None: + request, _automatic_tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="user-1", + payload=self._payload().model_copy( + update={"calendar": SchedulingCalendarPreferences()} + ), + ) + participant = request.participants[0] + token = self._issue_copy(request, participant) + invitation_id = participant.poll_invitation_id + participant.email = None + self.session.flush() + + with ( + patch( + "govoplan_scheduling.backend.service.notification_dispatch_provider", + return_value=object(), + ), + self.assertRaisesRegex( + SchedulingError, + "Participant has no deliverable email address or account", + ), + ): + issue_scheduling_participant_invitation( + self.session, + tenant_id=request.tenant_id, + request_id=request.id, + participant_id=participant.id, + action="send", + ) + + self.assertEqual(participant.poll_invitation_id, invitation_id) + invitation = self.session.query(PollInvitation).filter( + PollInvitation.id == invitation_id + ).one() + self.assertIsNone(invitation.revoked_at) + self.assertEqual( + get_public_scheduling_participation( + self.session, + request_id=request.id, + token=token, + payload=SchedulingPublicParticipationAccessRequest(), + client_address="127.0.0.1", + )["request_id"], + request.id, + ) + def test_invitation_router_rotates_revokes_and_enforces_management_policy(self) -> None: request, automatic_tokens = create_scheduling_request( self.session,