feat(scheduling): expose invitation delivery capability
This commit is contained in:
@@ -320,6 +320,7 @@ class SchedulingRequestResponse(BaseModel):
|
|||||||
anonymous_password_protection_enabled: bool
|
anonymous_password_protection_enabled: bool
|
||||||
public_participation_policy_enforcement_available: bool | None = None
|
public_participation_policy_enforcement_available: bool | None = None
|
||||||
public_participation_policy_enforcement_reason: str | None = None
|
public_participation_policy_enforcement_reason: str | None = None
|
||||||
|
participant_invitation_delivery_available: bool | None = None
|
||||||
effective_participant_visibility: SchedulingParticipantVisibility
|
effective_participant_visibility: SchedulingParticipantVisibility
|
||||||
participant_aggregate: SchedulingParticipantAggregateResponse
|
participant_aggregate: SchedulingParticipantAggregateResponse
|
||||||
participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse
|
participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse
|
||||||
|
|||||||
@@ -3859,6 +3859,11 @@ def scheduling_request_response(
|
|||||||
"details": {},
|
"details": {},
|
||||||
}
|
}
|
||||||
public_policy_available = _poll_participation_provider() is not None
|
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 {
|
return {
|
||||||
"id": request.id,
|
"id": request.id,
|
||||||
"tenant_id": request.tenant_id if full_roster else None,
|
"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
|
if public_policy_available or not full_roster
|
||||||
else PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON
|
else PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON
|
||||||
),
|
),
|
||||||
|
"participant_invitation_delivery_available": (
|
||||||
|
participant_invitation_delivery_available
|
||||||
|
),
|
||||||
"effective_participant_visibility": visibility_decision["effective_visibility"],
|
"effective_participant_visibility": visibility_decision["effective_visibility"],
|
||||||
"participant_aggregate": _participant_aggregate(active_participants),
|
"participant_aggregate": _participant_aggregate(active_participants),
|
||||||
"participant_visibility_decision": visibility_decision,
|
"participant_visibility_decision": visibility_decision,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -184,6 +185,10 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
|||||||
def test_secure_default_returns_own_row_and_aggregate_counts(self) -> None:
|
def test_secure_default_returns_own_row_and_aggregate_counts(self) -> None:
|
||||||
request = self._request()
|
request = self._request()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_scheduling.backend.service.notification_dispatch_provider",
|
||||||
|
return_value=object(),
|
||||||
|
):
|
||||||
payload = self._participant_projection(request)
|
payload = self._participant_projection(request)
|
||||||
response = SchedulingRequestResponse.model_validate(payload)
|
response = SchedulingRequestResponse.model_validate(payload)
|
||||||
|
|
||||||
@@ -212,6 +217,7 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
|||||||
self.assertIsNone(response.create_calendar_event_on_decision)
|
self.assertIsNone(response.create_calendar_event_on_decision)
|
||||||
self.assertIsNone(response.calendar_event_id)
|
self.assertIsNone(response.calendar_event_id)
|
||||||
self.assertIsNone(response.public_participation_policy_enforcement_available)
|
self.assertIsNone(response.public_participation_policy_enforcement_available)
|
||||||
|
self.assertIsNone(response.participant_invitation_delivery_available)
|
||||||
self.assertEqual(response.metadata, {})
|
self.assertEqual(response.metadata, {})
|
||||||
slot = response.slots[0]
|
slot = response.slots[0]
|
||||||
self.assertIsNone(slot.poll_option_id)
|
self.assertIsNone(slot.poll_option_id)
|
||||||
@@ -287,6 +293,22 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
|||||||
"connector-uid-internal",
|
"connector-uid-internal",
|
||||||
)
|
)
|
||||||
self.assertEqual(response.slots[0].tentative_hold_event_id, "hold-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:
|
def test_optional_policy_can_reduce_but_cannot_broaden_visibility(self) -> None:
|
||||||
restricting_policy = _PrivacyPolicy("aggregates_only")
|
restricting_policy = _PrivacyPolicy("aggregates_only")
|
||||||
|
|||||||
@@ -1548,6 +1548,102 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
serialized = repr({"payload": notification.payload, "metadata": notification.metadata_})
|
serialized = repr({"payload": notification.payload, "metadata": notification.metadata_})
|
||||||
self.assertTrue(all(token not in serialized for token in tokens))
|
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:
|
def test_invitation_router_rotates_revokes_and_enforces_management_policy(self) -> None:
|
||||||
request, automatic_tokens = create_scheduling_request(
|
request, automatic_tokens = create_scheduling_request(
|
||||||
self.session,
|
self.session,
|
||||||
|
|||||||
Reference in New Issue
Block a user