feat(scheduling): retire replaced participant responses

This commit is contained in:
2026-07-22 03:40:04 +02:00
parent 835ad82916
commit 448546e487
5 changed files with 724 additions and 48 deletions

View File

@@ -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",