feat(scheduling): retire replaced participant responses
This commit is contained in:
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.poll import PollCapabilityError
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
@@ -63,6 +64,7 @@ from govoplan_scheduling.backend.service import (
|
||||
get_public_scheduling_participation,
|
||||
issue_scheduling_participant_invitation,
|
||||
scheduling_request_summary,
|
||||
scheduling_participant_revision,
|
||||
scheduling_slot_revision,
|
||||
submit_scheduling_availability,
|
||||
submit_public_scheduling_participation,
|
||||
@@ -86,6 +88,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
||||
PollInvitation.__table__,
|
||||
PollParticipationSubmission.__table__,
|
||||
PollLifecycleTransition.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
SchedulingRequest.__table__,
|
||||
SchedulingCandidateSlot.__table__,
|
||||
SchedulingParticipant.__table__,
|
||||
@@ -107,6 +110,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
||||
PollParticipationSubmission.__table__,
|
||||
PollInvitation.__table__,
|
||||
PollLifecycleTransition.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
PollResponse.__table__,
|
||||
PollOption.__table__,
|
||||
Poll.__table__,
|
||||
@@ -1054,6 +1058,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
||||
participants=[
|
||||
SchedulingParticipantReconcileInput(
|
||||
id=alice.id,
|
||||
revision=scheduling_participant_revision(alice),
|
||||
respondent_id=alice.respondent_id,
|
||||
display_name=alice.display_name,
|
||||
email=alice.email,
|
||||
@@ -1125,6 +1130,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
||||
participants=[
|
||||
SchedulingParticipantReconcileInput(
|
||||
id=alice.id,
|
||||
revision=scheduling_participant_revision(alice),
|
||||
respondent_id=alice.respondent_id,
|
||||
display_name=alice.display_name,
|
||||
email=alice.email,
|
||||
@@ -1277,6 +1283,237 @@ class SchedulingResponseEditingTests(unittest.TestCase):
|
||||
{first_slot.id: "available", second_slot.id: "maybe"},
|
||||
)
|
||||
|
||||
def test_identity_replacement_revokes_access_retires_response_and_notifies(self) -> None:
|
||||
request = self._request()
|
||||
self._submit_both(request)
|
||||
original = request.participants[0]
|
||||
invitation_id = original.poll_invitation_id
|
||||
self.assertIsNotNone(invitation_id)
|
||||
active_response = (
|
||||
self.session.query(PollResponse)
|
||||
.filter(
|
||||
PollResponse.poll_id == request.poll_id,
|
||||
PollResponse.deleted_at.is_(None),
|
||||
)
|
||||
.one()
|
||||
)
|
||||
original_answers = [dict(answer) for answer in active_response.answers]
|
||||
|
||||
with patch("govoplan_scheduling.backend.router.audit_event") as audit:
|
||||
updated = api_update_scheduling_request(
|
||||
request.id,
|
||||
SchedulingRequestUpdateRequest(
|
||||
participants=[
|
||||
SchedulingParticipantReconcileInput(
|
||||
id=original.id,
|
||||
revision=scheduling_participant_revision(original),
|
||||
respondent_id=original.respondent_id,
|
||||
display_name="Alice Replacement",
|
||||
email="replacement@example.test",
|
||||
participant_type=original.participant_type,
|
||||
required=original.required,
|
||||
metadata=original.metadata_ or {},
|
||||
)
|
||||
]
|
||||
),
|
||||
session=self.session,
|
||||
principal=self._principal(
|
||||
"organizer-1",
|
||||
email=None,
|
||||
scopes={WRITE_SCOPE},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(len(updated.participants), 1)
|
||||
replacement = updated.participants[0]
|
||||
self.assertNotEqual(replacement.id, original.id)
|
||||
self.assertEqual(replacement.email, "replacement@example.test")
|
||||
self.assertIsNone(replacement.poll_invitation_id)
|
||||
self.assertEqual(original.status, "removed")
|
||||
self.assertIsNotNone(original.deleted_at)
|
||||
self.assertEqual(
|
||||
original.metadata_["participant_retirement"][
|
||||
"replacement_participant_id"
|
||||
],
|
||||
replacement.id,
|
||||
)
|
||||
invitation = (
|
||||
self.session.query(PollInvitation)
|
||||
.filter(PollInvitation.id == invitation_id)
|
||||
.one()
|
||||
)
|
||||
self.assertIsNotNone(invitation.revoked_at)
|
||||
self.assertIsNotNone(active_response.deleted_at)
|
||||
self.assertEqual(active_response.answers, original_answers)
|
||||
self.assertEqual(
|
||||
active_response.metadata_["response_retirement"]["reason"],
|
||||
"scheduling_participant_replaced",
|
||||
)
|
||||
self.assertEqual(
|
||||
scheduling_request_summary(
|
||||
self.session,
|
||||
tenant_id=request.tenant_id,
|
||||
request_id=request.id,
|
||||
)["response_count"],
|
||||
0,
|
||||
)
|
||||
notice = (
|
||||
self.session.query(SchedulingNotification)
|
||||
.filter(
|
||||
SchedulingNotification.participant_id == original.id,
|
||||
SchedulingNotification.event_kind == "participant_replaced",
|
||||
)
|
||||
.one()
|
||||
)
|
||||
self.assertEqual(notice.recipient, "alice@example.test")
|
||||
audit_calls = [call.kwargs for call in audit.call_args_list]
|
||||
replacement_audit = next(
|
||||
item
|
||||
for item in audit_calls
|
||||
if item["action"] == "scheduling.participant_identity_replaced"
|
||||
)
|
||||
self.assertEqual(
|
||||
replacement_audit["details"]["replacement_participant_id"],
|
||||
replacement.id,
|
||||
)
|
||||
self.assertEqual(replacement_audit["details"]["retired_response_count"], 1)
|
||||
self.assertNotIn("alice@example.test", repr(audit_calls))
|
||||
self.assertNotIn("replacement@example.test", repr(audit_calls))
|
||||
|
||||
def test_stable_account_corrections_keep_identity_and_only_revoke_stale_link(self) -> None:
|
||||
request = self._request(
|
||||
participants=[
|
||||
SchedulingParticipantInput(
|
||||
respondent_id="alice-account",
|
||||
display_name="Ailce",
|
||||
email="alice@example.test",
|
||||
participant_type="internal",
|
||||
)
|
||||
]
|
||||
)
|
||||
self._submit_both(request)
|
||||
participant = request.participants[0]
|
||||
invitation_id = participant.poll_invitation_id
|
||||
self.assertIsNotNone(invitation_id)
|
||||
|
||||
renamed = api_update_scheduling_request(
|
||||
request.id,
|
||||
SchedulingRequestUpdateRequest(
|
||||
participants=[
|
||||
SchedulingParticipantReconcileInput(
|
||||
id=participant.id,
|
||||
revision=scheduling_participant_revision(participant),
|
||||
respondent_id=participant.respondent_id,
|
||||
display_name="Alice",
|
||||
email=participant.email,
|
||||
participant_type=participant.participant_type,
|
||||
required=participant.required,
|
||||
metadata=participant.metadata_ or {},
|
||||
)
|
||||
]
|
||||
),
|
||||
session=self.session,
|
||||
principal=self._principal(
|
||||
"organizer-1",
|
||||
email=None,
|
||||
scopes={WRITE_SCOPE},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(renamed.participants[0].id, participant.id)
|
||||
self.assertEqual(participant.display_name, "Alice")
|
||||
self.assertEqual(participant.poll_invitation_id, invitation_id)
|
||||
self.assertIsNone(
|
||||
self.session.query(PollInvitation)
|
||||
.filter(PollInvitation.id == invitation_id)
|
||||
.one()
|
||||
.revoked_at
|
||||
)
|
||||
|
||||
with patch("govoplan_scheduling.backend.router.audit_event") as audit:
|
||||
corrected = api_update_scheduling_request(
|
||||
request.id,
|
||||
SchedulingRequestUpdateRequest(
|
||||
participants=[
|
||||
SchedulingParticipantReconcileInput(
|
||||
id=participant.id,
|
||||
revision=scheduling_participant_revision(participant),
|
||||
respondent_id=participant.respondent_id,
|
||||
display_name=participant.display_name,
|
||||
email="alice.corrected@example.test",
|
||||
participant_type=participant.participant_type,
|
||||
required=participant.required,
|
||||
metadata=participant.metadata_ or {},
|
||||
)
|
||||
]
|
||||
),
|
||||
session=self.session,
|
||||
principal=self._principal(
|
||||
"organizer-1",
|
||||
email=None,
|
||||
scopes={WRITE_SCOPE},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(corrected.participants[0].id, participant.id)
|
||||
self.assertEqual(participant.email, "alice.corrected@example.test")
|
||||
self.assertIsNone(participant.poll_invitation_id)
|
||||
self.assertIsNotNone(
|
||||
self.session.query(PollInvitation)
|
||||
.filter(PollInvitation.id == invitation_id)
|
||||
.one()
|
||||
.revoked_at
|
||||
)
|
||||
self.assertEqual(
|
||||
self.session.query(PollResponse)
|
||||
.filter(
|
||||
PollResponse.poll_id == request.poll_id,
|
||||
PollResponse.deleted_at.is_(None),
|
||||
)
|
||||
.count(),
|
||||
1,
|
||||
)
|
||||
contact_audit = next(
|
||||
call.kwargs
|
||||
for call in audit.call_args_list
|
||||
if call.kwargs["action"] == "scheduling.participant_contact_updated"
|
||||
)
|
||||
self.assertTrue(contact_audit["details"]["invitation_revoked"])
|
||||
|
||||
def test_stale_participant_revision_is_rejected_without_revoking_access(self) -> None:
|
||||
request = self._request()
|
||||
participant = request.participants[0]
|
||||
invitation_id = participant.poll_invitation_id
|
||||
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
api_update_scheduling_request(
|
||||
request.id,
|
||||
SchedulingRequestUpdateRequest(
|
||||
participants=[
|
||||
SchedulingParticipantReconcileInput(
|
||||
id=participant.id,
|
||||
revision="0" * 64,
|
||||
respondent_id=participant.respondent_id,
|
||||
display_name="Stale update",
|
||||
email=participant.email,
|
||||
participant_type=participant.participant_type,
|
||||
required=participant.required,
|
||||
metadata=participant.metadata_ or {},
|
||||
)
|
||||
]
|
||||
),
|
||||
session=self.session,
|
||||
principal=self._principal(
|
||||
"organizer-1",
|
||||
email=None,
|
||||
scopes={WRITE_SCOPE},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 409)
|
||||
self.assertEqual(participant.poll_invitation_id, invitation_id)
|
||||
self.assertIsNone(participant.deleted_at)
|
||||
|
||||
def test_draft_edit_does_not_issue_link_for_added_participant(self) -> None:
|
||||
request = self._request(
|
||||
status="draft",
|
||||
|
||||
Reference in New Issue
Block a user