feat(scheduling): retire replaced participant responses
This commit is contained in:
@@ -67,7 +67,7 @@ from govoplan_scheduling.backend.service import (
|
||||
submit_scheduling_availability,
|
||||
submit_public_scheduling_participation,
|
||||
update_scheduling_candidate_slot,
|
||||
update_scheduling_request_with_invitation_tokens,
|
||||
update_scheduling_request_with_change_log,
|
||||
)
|
||||
|
||||
|
||||
@@ -431,7 +431,7 @@ def api_update_scheduling_request(
|
||||
request_id=request_id,
|
||||
)
|
||||
try:
|
||||
request, _invitation_tokens = update_scheduling_request_with_invitation_tokens(
|
||||
request, participant_mutations = update_scheduling_request_with_change_log(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
request_id=request_id,
|
||||
@@ -439,6 +439,21 @@ def api_update_scheduling_request(
|
||||
)
|
||||
except SchedulingError as exc:
|
||||
raise _scheduling_http_error(exc) from exc
|
||||
for mutation in participant_mutations:
|
||||
_audit_invitation_action(
|
||||
session,
|
||||
principal=principal,
|
||||
request_id=request_id,
|
||||
participant_id=mutation.participant_id,
|
||||
action=mutation.action,
|
||||
details={
|
||||
"replacement_participant_id": mutation.replacement_participant_id,
|
||||
"changed_fields": list(mutation.changed_fields),
|
||||
"invitation_revoked": mutation.invitation_revoked,
|
||||
"retired_response_count": mutation.retired_response_count,
|
||||
"notification_id": mutation.notification_id,
|
||||
},
|
||||
)
|
||||
response = _request_response(request, principal=principal)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
@@ -132,6 +132,20 @@ class SchedulingCandidateSlotReconcileInput(SchedulingCandidateSlotInput):
|
||||
|
||||
class SchedulingParticipantReconcileInput(SchedulingParticipantInput):
|
||||
id: str | None = Field(default=None, max_length=36)
|
||||
revision: str | None = Field(
|
||||
default=None,
|
||||
min_length=64,
|
||||
max_length=64,
|
||||
pattern=r"^[0-9a-f]{64}$",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_existing_revision(self) -> "SchedulingParticipantReconcileInput":
|
||||
if self.id is not None and self.revision is None:
|
||||
raise ValueError("revision is required for an existing scheduling participant")
|
||||
if self.id is None and self.revision is not None:
|
||||
raise ValueError("revision can only be supplied for an existing scheduling participant")
|
||||
return self
|
||||
|
||||
|
||||
class SchedulingRequestCreateRequest(BaseModel):
|
||||
@@ -252,6 +266,7 @@ class SchedulingCandidateSlotResponse(BaseModel):
|
||||
|
||||
class SchedulingParticipantResponse(BaseModel):
|
||||
id: str
|
||||
revision: str | None = None
|
||||
is_current_participant: bool = False
|
||||
respondent_id: str | None = None
|
||||
display_name: str | None = None
|
||||
|
||||
@@ -24,8 +24,11 @@ from govoplan_core.core.poll import (
|
||||
PollOptionUpdateCommand,
|
||||
PollOptionRequest,
|
||||
PollResponseRef,
|
||||
PollResponseRetirementCommand,
|
||||
PollResponseRetirementProvider,
|
||||
PollSchedulingProvider,
|
||||
PollUpdateCommand,
|
||||
poll_response_retirement_provider,
|
||||
poll_scheduling_provider,
|
||||
)
|
||||
from govoplan_core.core.poll_participation import (
|
||||
@@ -100,13 +103,31 @@ class SchedulingInvitationActionResult:
|
||||
replayed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SchedulingParticipantMutation:
|
||||
action: str
|
||||
participant_id: str
|
||||
replacement_participant_id: str | None = None
|
||||
changed_fields: tuple[str, ...] = ()
|
||||
invitation_revoked: bool = False
|
||||
retired_response_count: int = 0
|
||||
notification_id: str | None = None
|
||||
|
||||
|
||||
WORKFLOW_DRAFT = "draft"
|
||||
WORKFLOW_COLLECTING = "collecting_availability"
|
||||
WORKFLOW_CLOSED = "availability_closed"
|
||||
WORKFLOW_DECIDED = "slot_decided"
|
||||
WORKFLOW_HANDED_OFF = "handed_off"
|
||||
WORKFLOW_CANCELLED = "cancelled"
|
||||
NOTIFICATION_KINDS = {"invitation", "reminder", "decision", "cancellation"}
|
||||
NOTIFICATION_KINDS = {
|
||||
"invitation",
|
||||
"reminder",
|
||||
"decision",
|
||||
"cancellation",
|
||||
"participant_removed",
|
||||
"participant_replaced",
|
||||
}
|
||||
PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON = (
|
||||
"Poll signed-participation policy enforcement is unavailable; restricted "
|
||||
"public links are disabled until the Poll gateway contract is installed."
|
||||
@@ -157,6 +178,29 @@ def scheduling_slot_revision(slot: SchedulingCandidateSlot) -> str:
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def scheduling_participant_revision(participant: SchedulingParticipant) -> str:
|
||||
"""Hash the participant state that makes an identity edit security-sensitive."""
|
||||
|
||||
snapshot = {
|
||||
"respondent_id": participant.respondent_id,
|
||||
"display_name": participant.display_name,
|
||||
"email": participant.email,
|
||||
"participant_type": participant.participant_type,
|
||||
"required": participant.required,
|
||||
"status": participant.status,
|
||||
"poll_invitation_id": participant.poll_invitation_id,
|
||||
"participation_gateway": participant.participation_gateway,
|
||||
"metadata": participant.metadata_ or {},
|
||||
}
|
||||
encoded = json.dumps(
|
||||
_jsonable(snapshot),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return utcnow()
|
||||
|
||||
@@ -343,6 +387,10 @@ def _poll_participation_provider() -> PollParticipationGatewayProvider | None:
|
||||
return poll_participation_gateway_provider(get_registry())
|
||||
|
||||
|
||||
def _poll_response_retirement_provider() -> PollResponseRetirementProvider | None:
|
||||
return poll_response_retirement_provider(get_registry())
|
||||
|
||||
|
||||
def _public_participation_gateway(request_id: str) -> PollResponseGatewayRef:
|
||||
return PollResponseGatewayRef(
|
||||
module_id="scheduling",
|
||||
@@ -801,7 +849,14 @@ def _notification_dispatch_request(
|
||||
else f"/poll/public/{invitation_token}"
|
||||
)
|
||||
if notification.event_kind == "invitation" and invitation_token
|
||||
else f"/scheduling?request_id={request.id}"
|
||||
else (
|
||||
None
|
||||
if notification.event_kind in {
|
||||
"participant_removed",
|
||||
"participant_replaced",
|
||||
}
|
||||
else f"/scheduling?request_id={request.id}"
|
||||
)
|
||||
),
|
||||
payload=_jsonable(payload),
|
||||
metadata={
|
||||
@@ -818,6 +873,8 @@ def _notification_subject(request: SchedulingRequest, event_kind: str) -> str:
|
||||
"reminder": "Scheduling reminder",
|
||||
"decision": "Scheduling decision",
|
||||
"cancellation": "Scheduling cancelled",
|
||||
"participant_removed": "Scheduling access removed",
|
||||
"participant_replaced": "Scheduling access changed",
|
||||
}
|
||||
return f"{labels.get(event_kind, 'Scheduling update')}: {request.title}"
|
||||
|
||||
@@ -847,6 +904,16 @@ def _notification_body_intro(request: SchedulingRequest, event_kind: str) -> str
|
||||
return f"A final slot was selected for {request.title}."
|
||||
if event_kind == "cancellation":
|
||||
return f"The scheduling request {request.title} was cancelled."
|
||||
if event_kind == "participant_replaced":
|
||||
return (
|
||||
"Your participant identity for this scheduling request was "
|
||||
"replaced and its previous response link has been revoked."
|
||||
)
|
||||
if event_kind == "participant_removed":
|
||||
return (
|
||||
"You were removed from this scheduling request and your previous "
|
||||
"response link has been revoked."
|
||||
)
|
||||
return f"There is an update for {request.title}."
|
||||
|
||||
|
||||
@@ -2054,7 +2121,11 @@ def _update_scheduling_request_with_invitation_tokens(
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
payload: SchedulingRequestUpdateRequest,
|
||||
) -> tuple[SchedulingRequest, dict[str, str]]:
|
||||
) -> tuple[
|
||||
SchedulingRequest,
|
||||
dict[str, str],
|
||||
tuple[SchedulingParticipantMutation, ...],
|
||||
]:
|
||||
request = _lock_scheduling_request(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -2158,6 +2229,7 @@ def _update_scheduling_request_with_invitation_tokens(
|
||||
if payload.metadata is not None:
|
||||
request.metadata_ = payload.metadata
|
||||
invitation_tokens: dict[str, str] = {}
|
||||
participant_mutations: tuple[SchedulingParticipantMutation, ...] = ()
|
||||
slots_changed = False
|
||||
if payload.slots is not None:
|
||||
slots_changed = _reconcile_scheduling_slots(
|
||||
@@ -2166,7 +2238,7 @@ def _update_scheduling_request_with_invitation_tokens(
|
||||
supplied_slots=payload.slots,
|
||||
)
|
||||
if payload.participants is not None:
|
||||
invitation_tokens = _reconcile_scheduling_participants(
|
||||
invitation_tokens, participant_mutations = _reconcile_scheduling_participants(
|
||||
session,
|
||||
request=request,
|
||||
supplied_participants=payload.participants,
|
||||
@@ -2225,7 +2297,7 @@ def _update_scheduling_request_with_invitation_tokens(
|
||||
reset_missing=True,
|
||||
)
|
||||
session.flush()
|
||||
return request, invitation_tokens
|
||||
return request, invitation_tokens, participant_mutations
|
||||
|
||||
|
||||
def update_scheduling_request(
|
||||
@@ -2235,11 +2307,13 @@ def update_scheduling_request(
|
||||
request_id: str,
|
||||
payload: SchedulingRequestUpdateRequest,
|
||||
) -> SchedulingRequest:
|
||||
request, _invitation_tokens = _update_scheduling_request_with_invitation_tokens(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
request_id=request_id,
|
||||
payload=payload,
|
||||
request, _invitation_tokens, _participant_mutations = (
|
||||
_update_scheduling_request_with_invitation_tokens(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
request_id=request_id,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
return request
|
||||
|
||||
@@ -2253,12 +2327,38 @@ def update_scheduling_request_with_invitation_tokens(
|
||||
) -> tuple[SchedulingRequest, dict[str, str]]:
|
||||
"""Compatibility wrapper; explicit invitation actions own raw tokens."""
|
||||
|
||||
return _update_scheduling_request_with_invitation_tokens(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
request_id=request_id,
|
||||
payload=payload,
|
||||
request, invitation_tokens, _participant_mutations = (
|
||||
_update_scheduling_request_with_invitation_tokens(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
request_id=request_id,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
return request, invitation_tokens
|
||||
|
||||
|
||||
def update_scheduling_request_with_change_log(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
request_id: str,
|
||||
payload: SchedulingRequestUpdateRequest,
|
||||
) -> tuple[
|
||||
SchedulingRequest,
|
||||
tuple[SchedulingParticipantMutation, ...],
|
||||
]:
|
||||
"""Update one request and expose privacy-safe participant audit facts."""
|
||||
|
||||
request, _invitation_tokens, participant_mutations = (
|
||||
_update_scheduling_request_with_invitation_tokens(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
request_id=request_id,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
return request, participant_mutations
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -2598,13 +2698,115 @@ def _normalized_participant_identity(value: str | None) -> str | None:
|
||||
def _participant_identity_values(
|
||||
participant: SchedulingParticipant | SchedulingParticipantReconcileInput,
|
||||
) -> tuple[str, ...]:
|
||||
metadata = (
|
||||
participant.metadata_
|
||||
if isinstance(participant, SchedulingParticipant)
|
||||
else participant.metadata
|
||||
) or {}
|
||||
directory = metadata.get("directory_selection")
|
||||
directory_identity = None
|
||||
if isinstance(directory, dict):
|
||||
reference_id = directory.get("reference_id")
|
||||
kind = directory.get("kind")
|
||||
source_module = directory.get("source_module")
|
||||
if isinstance(reference_id, str) and reference_id.strip():
|
||||
directory_identity = ":".join(
|
||||
(
|
||||
"directory",
|
||||
str(source_module or "unknown").strip().casefold(),
|
||||
str(kind or "person").strip().casefold(),
|
||||
reference_id.strip(),
|
||||
)
|
||||
)
|
||||
identities = (
|
||||
_normalized_participant_identity(participant.respondent_id),
|
||||
_normalized_participant_identity(participant.email),
|
||||
directory_identity,
|
||||
)
|
||||
return tuple(dict.fromkeys(value for value in identities if value is not None))
|
||||
|
||||
|
||||
def _canonical_participant_identity(
|
||||
participant: SchedulingParticipant | SchedulingParticipantReconcileInput,
|
||||
) -> tuple[str, str] | None:
|
||||
respondent_id = _normalized_participant_identity(participant.respondent_id)
|
||||
if respondent_id and not respondent_id.startswith("scheduling-participant:"):
|
||||
return ("respondent", respondent_id)
|
||||
directory_identity = next(
|
||||
(
|
||||
identity
|
||||
for identity in _participant_identity_values(participant)
|
||||
if identity.startswith("directory:")
|
||||
),
|
||||
None,
|
||||
)
|
||||
if directory_identity is not None:
|
||||
return ("directory", directory_identity)
|
||||
email = _normalized_participant_identity(participant.email)
|
||||
if email is not None:
|
||||
return ("email", email)
|
||||
return None
|
||||
|
||||
|
||||
def _participant_identity_changed(
|
||||
participant: SchedulingParticipant,
|
||||
supplied: SchedulingParticipantReconcileInput,
|
||||
) -> bool:
|
||||
return (
|
||||
participant.participant_type != supplied.participant_type
|
||||
or _canonical_participant_identity(participant)
|
||||
!= _canonical_participant_identity(supplied)
|
||||
)
|
||||
|
||||
|
||||
def _participant_changed_fields(
|
||||
participant: SchedulingParticipant,
|
||||
supplied: SchedulingParticipantReconcileInput,
|
||||
) -> tuple[str, ...]:
|
||||
comparisons = {
|
||||
"respondent_id": (
|
||||
_normalized_participant_identity(participant.respondent_id),
|
||||
_normalized_participant_identity(supplied.respondent_id),
|
||||
),
|
||||
"display_name": (participant.display_name, supplied.display_name),
|
||||
"email": (
|
||||
_normalized_participant_identity(participant.email),
|
||||
_normalized_participant_identity(supplied.email),
|
||||
),
|
||||
"participant_type": (
|
||||
participant.participant_type,
|
||||
supplied.participant_type,
|
||||
),
|
||||
"required": (participant.required, supplied.required),
|
||||
"metadata": (participant.metadata_ or {}, dict(supplied.metadata)),
|
||||
}
|
||||
return tuple(
|
||||
field
|
||||
for field, (current, requested) in comparisons.items()
|
||||
if current != requested
|
||||
)
|
||||
|
||||
|
||||
def _participant_response_identities(
|
||||
participant: SchedulingParticipant,
|
||||
) -> tuple[str, ...]:
|
||||
stored_response_identity = (participant.metadata_ or {}).get(
|
||||
"poll_response_respondent_id"
|
||||
)
|
||||
candidates = (
|
||||
participant.respondent_id,
|
||||
participant.email,
|
||||
stored_response_identity if isinstance(stored_response_identity, str) else None,
|
||||
)
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
normalized
|
||||
for value in candidates
|
||||
if (normalized := _normalized_participant_identity(value)) is not None
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _validate_participant_reconciliation(
|
||||
request: SchedulingRequest,
|
||||
current_by_id: dict[str, SchedulingParticipant],
|
||||
@@ -2633,20 +2835,15 @@ def _validate_participant_reconciliation(
|
||||
)
|
||||
for item in supplied_participants:
|
||||
if item.id is None:
|
||||
if (item.respondent_id or "").startswith("scheduling-participant:"):
|
||||
raise SchedulingError(
|
||||
"Server-owned scheduling participant identities cannot be supplied for a new participant"
|
||||
)
|
||||
continue
|
||||
participant = current_by_id[item.id]
|
||||
if participant.poll_invitation_id is None:
|
||||
continue
|
||||
identity_changed = (
|
||||
_normalized_participant_identity(item.respondent_id)
|
||||
!= _normalized_participant_identity(participant.respondent_id)
|
||||
or _normalized_participant_identity(item.email)
|
||||
!= _normalized_participant_identity(participant.email)
|
||||
or item.display_name != participant.display_name
|
||||
)
|
||||
if identity_changed:
|
||||
raise SchedulingError(
|
||||
"An invited participant's identity, name, and email cannot be changed; remove and add the participant instead"
|
||||
if item.revision != scheduling_participant_revision(participant):
|
||||
raise SchedulingConflictError(
|
||||
"Scheduling participant changed while it was being edited; reload and try again"
|
||||
)
|
||||
|
||||
|
||||
@@ -2910,8 +3107,8 @@ def _reconcile_scheduling_participants(
|
||||
*,
|
||||
request: SchedulingRequest,
|
||||
supplied_participants: list[SchedulingParticipantReconcileInput],
|
||||
) -> dict[str, str]:
|
||||
"""Apply a complete participant collection and revoke removed links."""
|
||||
) -> tuple[dict[str, str], tuple[SchedulingParticipantMutation, ...]]:
|
||||
"""Reconcile participants without reassigning responses to new identities."""
|
||||
|
||||
current_participants = _active_participants(request)
|
||||
current_by_id = {participant.id: participant for participant in current_participants}
|
||||
@@ -2920,56 +3117,183 @@ def _reconcile_scheduling_participants(
|
||||
current_by_id,
|
||||
supplied_participants,
|
||||
)
|
||||
supplied_ids = {
|
||||
item.id
|
||||
replacement_inputs = {
|
||||
cast(str, item.id): item
|
||||
for item in supplied_participants
|
||||
if item.id is not None
|
||||
and _participant_identity_changed(current_by_id[item.id], item)
|
||||
}
|
||||
retained_ids = {
|
||||
item.id
|
||||
for item in supplied_participants
|
||||
if item.id is not None and item.id not in replacement_inputs
|
||||
}
|
||||
removed_participants = [
|
||||
participant
|
||||
for participant in current_participants
|
||||
if participant.id not in supplied_ids
|
||||
if participant.id not in retained_ids
|
||||
]
|
||||
participants_with_contact_link_changes = [
|
||||
current_by_id[cast(str, item.id)]
|
||||
for item in supplied_participants
|
||||
if item.id is not None
|
||||
and item.id not in replacement_inputs
|
||||
and current_by_id[item.id].poll_invitation_id is not None
|
||||
and _normalized_participant_identity(current_by_id[item.id].email)
|
||||
!= _normalized_participant_identity(item.email)
|
||||
]
|
||||
if (removed_participants or participants_with_contact_link_changes) and request.poll_id is None:
|
||||
raise SchedulingError("Scheduling request has no backing poll")
|
||||
participation_provider = _poll_participation_provider()
|
||||
if (
|
||||
any(participant.poll_invitation_id for participant in removed_participants)
|
||||
and _poll_participation_provider() is None
|
||||
any(
|
||||
participant.poll_invitation_id
|
||||
for participant in (
|
||||
*removed_participants,
|
||||
*participants_with_contact_link_changes,
|
||||
)
|
||||
)
|
||||
and participation_provider is None
|
||||
):
|
||||
raise SchedulingError(
|
||||
"Poll participation invitation revocation capability is unavailable"
|
||||
)
|
||||
retirement_candidates = [
|
||||
participant
|
||||
for participant in removed_participants
|
||||
if _participant_response_identities(participant)
|
||||
or participant.poll_invitation_id is not None
|
||||
]
|
||||
retirement_provider = _poll_response_retirement_provider()
|
||||
if retirement_candidates and retirement_provider is None:
|
||||
raise SchedulingError(
|
||||
"Poll response retirement capability is unavailable; participants with possible responses cannot be removed safely"
|
||||
)
|
||||
|
||||
mutations: list[SchedulingParticipantMutation] = []
|
||||
for item in supplied_participants:
|
||||
if item.id is None:
|
||||
if item.id is None or item.id in replacement_inputs:
|
||||
continue
|
||||
participant = current_by_id[item.id]
|
||||
if participant.poll_invitation_id is None:
|
||||
participant.respondent_id = item.respondent_id
|
||||
participant.display_name = item.display_name
|
||||
participant.email = item.email
|
||||
changed_fields = _participant_changed_fields(participant, item)
|
||||
if not changed_fields:
|
||||
continue
|
||||
invitation_revoked = False
|
||||
if (
|
||||
"email" in changed_fields
|
||||
and participant.poll_invitation_id is not None
|
||||
):
|
||||
if participation_provider is None: # Guarded above.
|
||||
raise SchedulingError(
|
||||
"Poll participation invitation revocation capability is unavailable"
|
||||
)
|
||||
try:
|
||||
participation_provider.revoke_invitation(
|
||||
session,
|
||||
tenant_id=request.tenant_id,
|
||||
poll_id=cast(str, request.poll_id),
|
||||
invitation_id=participant.poll_invitation_id,
|
||||
)
|
||||
except PollCapabilityError as exc:
|
||||
raise SchedulingError(str(exc)) from exc
|
||||
participant.poll_invitation_id = None
|
||||
participant.participation_gateway = None
|
||||
if participant.status == "invited":
|
||||
participant.status = "draft"
|
||||
invitation_revoked = True
|
||||
participant.respondent_id = item.respondent_id
|
||||
participant.display_name = item.display_name
|
||||
participant.email = item.email
|
||||
participant.participant_type = item.participant_type
|
||||
participant.required = item.required
|
||||
participant.metadata_ = dict(item.metadata)
|
||||
mutations.append(
|
||||
SchedulingParticipantMutation(
|
||||
action=(
|
||||
"scheduling.participant_contact_updated"
|
||||
if "email" in changed_fields
|
||||
else "scheduling.participant_updated"
|
||||
),
|
||||
participant_id=participant.id,
|
||||
changed_fields=changed_fields,
|
||||
invitation_revoked=invitation_revoked,
|
||||
)
|
||||
)
|
||||
|
||||
invitation_tokens: dict[str, str] = {}
|
||||
replacement_participants: dict[str, SchedulingParticipant] = {}
|
||||
for item in supplied_participants:
|
||||
if item.id is not None:
|
||||
if item.id is not None and item.id not in replacement_inputs:
|
||||
continue
|
||||
replaced_participant = (
|
||||
current_by_id[item.id]
|
||||
if item.id is not None
|
||||
else None
|
||||
)
|
||||
respondent_id = item.respondent_id
|
||||
if (
|
||||
replaced_participant is not None
|
||||
and (respondent_id or "").startswith("scheduling-participant:")
|
||||
):
|
||||
respondent_id = None
|
||||
metadata = dict(item.metadata)
|
||||
if replaced_participant is not None:
|
||||
metadata["identity_replacement"] = {
|
||||
"replaces_participant_id": replaced_participant.id,
|
||||
}
|
||||
participant = SchedulingParticipant(
|
||||
tenant_id=request.tenant_id,
|
||||
request=request,
|
||||
respondent_id=item.respondent_id,
|
||||
respondent_id=respondent_id,
|
||||
display_name=item.display_name,
|
||||
email=item.email,
|
||||
participant_type=item.participant_type,
|
||||
required=item.required,
|
||||
status="draft",
|
||||
metadata_=dict(item.metadata),
|
||||
metadata_=metadata,
|
||||
)
|
||||
session.add(participant)
|
||||
session.flush()
|
||||
if replaced_participant is not None:
|
||||
replacement_participants[replaced_participant.id] = participant
|
||||
|
||||
participation_provider = _poll_participation_provider()
|
||||
for participant in removed_participants:
|
||||
replacement = replacement_participants.get(participant.id)
|
||||
retirement_count = 0
|
||||
response_identities = _participant_response_identities(participant)
|
||||
if response_identities or participant.poll_invitation_id is not None:
|
||||
if retirement_provider is None: # Guarded above.
|
||||
raise SchedulingError(
|
||||
"Poll response retirement capability is unavailable"
|
||||
)
|
||||
try:
|
||||
retirement = retirement_provider.retire_responses(
|
||||
session,
|
||||
tenant_id=request.tenant_id,
|
||||
poll_id=cast(str, request.poll_id),
|
||||
command=PollResponseRetirementCommand(
|
||||
respondent_ids=response_identities,
|
||||
invitation_id=participant.poll_invitation_id,
|
||||
reason=(
|
||||
"scheduling_participant_replaced"
|
||||
if replacement is not None
|
||||
else "scheduling_participant_removed"
|
||||
),
|
||||
idempotency_key=(
|
||||
f"scheduling:{request.id}:participant:{participant.id}:removed"
|
||||
),
|
||||
metadata={
|
||||
"source_module": "scheduling",
|
||||
"source_resource_type": "participant",
|
||||
"source_resource_id": participant.id,
|
||||
"scheduling_request_id": request.id,
|
||||
},
|
||||
),
|
||||
)
|
||||
except PollCapabilityError as exc:
|
||||
raise SchedulingError(str(exc)) from exc
|
||||
retirement_count = retirement.newly_retired_count
|
||||
invitation_revoked = False
|
||||
if participant.poll_invitation_id is not None:
|
||||
if participation_provider is None: # Guarded above.
|
||||
raise SchedulingError(
|
||||
@@ -2984,10 +3308,73 @@ def _reconcile_scheduling_participants(
|
||||
)
|
||||
except PollCapabilityError as exc:
|
||||
raise SchedulingError(str(exc)) from exc
|
||||
invitation_revoked = True
|
||||
notification_id = None
|
||||
if (
|
||||
replacement is not None
|
||||
or participant.poll_invitation_id is not None
|
||||
or participant.status == "responded"
|
||||
or retirement_count > 0
|
||||
):
|
||||
notifications = create_scheduling_notification_jobs(
|
||||
session,
|
||||
tenant_id=request.tenant_id,
|
||||
request_id=request.id,
|
||||
event_kind=(
|
||||
"participant_replaced"
|
||||
if replacement is not None
|
||||
else "participant_removed"
|
||||
),
|
||||
metadata={
|
||||
"reason": (
|
||||
"participant_identity_replaced"
|
||||
if replacement is not None
|
||||
else "participant_removed"
|
||||
),
|
||||
"replacement_participant_id": (
|
||||
replacement.id if replacement is not None else None
|
||||
),
|
||||
},
|
||||
participant_ids=frozenset({participant.id}),
|
||||
)
|
||||
notification_id = notifications[0].id
|
||||
retired_at = _now()
|
||||
participant.metadata_ = {
|
||||
**(participant.metadata_ or {}),
|
||||
"participant_retirement": {
|
||||
"reason": (
|
||||
"identity_replaced"
|
||||
if replacement is not None
|
||||
else "removed"
|
||||
),
|
||||
"retired_at": retired_at.isoformat(),
|
||||
"retired_response_count": retirement_count,
|
||||
"replacement_participant_id": (
|
||||
replacement.id if replacement is not None else None
|
||||
),
|
||||
},
|
||||
}
|
||||
participant.status = "removed"
|
||||
participant.deleted_at = _now()
|
||||
participant.deleted_at = retired_at
|
||||
mutations.append(
|
||||
SchedulingParticipantMutation(
|
||||
action=(
|
||||
"scheduling.participant_identity_replaced"
|
||||
if replacement is not None
|
||||
else "scheduling.participant_removed"
|
||||
),
|
||||
participant_id=participant.id,
|
||||
replacement_participant_id=(
|
||||
replacement.id if replacement is not None else None
|
||||
),
|
||||
changed_fields=("identity",) if replacement is not None else (),
|
||||
invitation_revoked=invitation_revoked,
|
||||
retired_response_count=retirement_count,
|
||||
notification_id=notification_id,
|
||||
)
|
||||
)
|
||||
|
||||
return invitation_tokens
|
||||
return invitation_tokens, tuple(mutations)
|
||||
|
||||
|
||||
def update_scheduling_candidate_slot(
|
||||
@@ -3300,6 +3687,11 @@ def scheduling_participant_response(
|
||||
expose_email = include_management_fields or is_current_participant
|
||||
return {
|
||||
"id": participant.id,
|
||||
"revision": (
|
||||
scheduling_participant_revision(participant)
|
||||
if include_management_fields
|
||||
else None
|
||||
),
|
||||
"is_current_participant": is_current_participant,
|
||||
"respondent_id": (
|
||||
participant.respondent_id
|
||||
|
||||
Reference in New Issue
Block a user