diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index b1fbbe0..86e76fd 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -787,16 +787,9 @@ def list_visible_scheduling_notifications( return visible -def refresh_participant_response_state( - session: Session, - *, - request: SchedulingRequest, - actor_ids: tuple[str, ...] = (), - reset_missing: bool = False, -) -> None: - if request.poll_id is None: - return - participants = _active_participants(request) +def _participant_response_indexes( + participants: list[SchedulingParticipant], +) -> tuple[dict[str, SchedulingParticipant], dict[str, SchedulingParticipant]]: by_invitation_id = { participant.poll_invitation_id: participant for participant in participants @@ -808,6 +801,97 @@ def refresh_participant_response_state( for identity in (participant.respondent_id, stored_identity): if isinstance(identity, str) and identity: by_respondent_id[identity] = participant + return by_invitation_id, by_respondent_id + + +def _response_participant( + response: PollResponseRef, + *, + by_invitation_id: dict[str, SchedulingParticipant], + by_respondent_id: dict[str, SchedulingParticipant], + actor_ids: set[str], + actor_participants: list[SchedulingParticipant], +) -> SchedulingParticipant | None: + participant = by_invitation_id.get(response.invitation_id) + if participant is None and response.respondent_id is not None: + participant = by_respondent_id.get(response.respondent_id) + # Poll deliberately ignores client-supplied invitation identifiers on + # authenticated submissions. While reconciling for that same actor, map + # the server-authenticated respondent to their unique Scheduling row. + if participant is None and response.respondent_id in actor_ids and len(actor_participants) == 1: + return actor_participants[0] + return participant + + +def _apply_participant_response( + session: Session, + *, + request: SchedulingRequest, + participant: SchedulingParticipant, + response: PollResponseRef, +) -> bool: + changed = False + if response.respondent_id: + metadata = participant.metadata_ or {} + if metadata.get("poll_response_respondent_id") != response.respondent_id: + participant.metadata_ = { + **metadata, + "poll_response_respondent_id": response.respondent_id, + } + changed = True + if participant.status == "responded": + return changed + participant.status = "responded" + participant.responded_at = response_datetime(response.submitted_at) + _emit_scheduling_center_notification( + session, + request=request, + participant=participant, + event_kind="scheduling.participant_responded", + subject=f"Scheduling response: {request.title}", + body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.", + ) + return True + + +def _reset_missing_participant_responses( + participants: list[SchedulingParticipant], + *, + matched_participant_ids: set[str], + unmatched_response_count: int, +) -> bool: + changed = False + for participant in participants: + if participant.status != "responded" or participant.id in matched_participant_ids: + continue + stored_identity = (participant.metadata_ or {}).get("poll_response_respondent_id") + if not isinstance(stored_identity, str) and unmatched_response_count: + # Preserve an older projection while an active response exists + # that cannot yet be mapped safely to a participant. + continue + participant.status = "invited" if participant.poll_invitation_id else "draft" + participant.responded_at = None + if isinstance(stored_identity, str): + participant.metadata_ = { + key: value + for key, value in (participant.metadata_ or {}).items() + if key != "poll_response_respondent_id" + } + changed = True + return changed + + +def refresh_participant_response_state( + session: Session, + *, + request: SchedulingRequest, + actor_ids: tuple[str, ...] = (), + reset_missing: bool = False, +) -> None: + if request.poll_id is None: + return + participants = _active_participants(request) + by_invitation_id, by_respondent_id = _participant_response_indexes(participants) try: responses = _poll_provider().list_responses( session, @@ -826,61 +910,29 @@ def refresh_participant_response_state( matched_participant_ids: set[str] = set() unmatched_response_count = 0 for response in responses: - participant = by_invitation_id.get(response.invitation_id) - if participant is None and response.respondent_id is not None: - participant = by_respondent_id.get(response.respondent_id) - # Poll deliberately ignores client-supplied invitation identifiers on - # authenticated submissions. While reconciling for that same actor, - # map the server-authenticated respondent id to their unique Scheduling - # participant row (which may have been invited by email only). - if ( - participant is None - and response.respondent_id in ids - and len(actor_participants) == 1 - ): - participant = actor_participants[0] + participant = _response_participant( + response, + by_invitation_id=by_invitation_id, + by_respondent_id=by_respondent_id, + actor_ids=ids, + actor_participants=actor_participants, + ) if participant is None: unmatched_response_count += 1 continue matched_participant_ids.add(participant.id) - if response.respondent_id: - metadata = participant.metadata_ or {} - if metadata.get("poll_response_respondent_id") != response.respondent_id: - participant.metadata_ = { - **metadata, - "poll_response_respondent_id": response.respondent_id, - } - changed = True - if participant.status != "responded": - participant.status = "responded" - participant.responded_at = response_datetime(response.submitted_at) - _emit_scheduling_center_notification( - session, - request=request, - participant=participant, - event_kind="scheduling.participant_responded", - subject=f"Scheduling response: {request.title}", - body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.", - ) - changed = True - if reset_missing: - for participant in participants: - if participant.status != "responded" or participant.id in matched_participant_ids: - continue - stored_identity = (participant.metadata_ or {}).get("poll_response_respondent_id") - if not isinstance(stored_identity, str) and unmatched_response_count: - # Do not reset an older projection while an active response - # exists that cannot yet be mapped safely to a participant. - continue - participant.status = "invited" if participant.poll_invitation_id else "draft" - participant.responded_at = None - if isinstance(stored_identity, str): - participant.metadata_ = { - key: value - for key, value in (participant.metadata_ or {}).items() - if key != "poll_response_respondent_id" - } - changed = True + changed = _apply_participant_response( + session, + request=request, + participant=participant, + response=response, + ) or changed + if reset_missing and _reset_missing_participant_responses( + participants, + matched_participant_ids=matched_participant_ids, + unmatched_response_count=unmatched_response_count, + ): + changed = True if changed: session.flush()