fix(responses): reject stale scheduling answers

This commit is contained in:
2026-07-20 18:35:57 +02:00
parent 0f33e25c83
commit bbf503a7d7
5 changed files with 311 additions and 39 deletions

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any
@@ -19,6 +21,7 @@ from govoplan_core.core.poll import (
PollInvitationCommand,
PollOptionUpdateCommand,
PollOptionRequest,
PollResponseRef,
PollResponseSubmissionProvider,
PollSchedulingProvider,
PollSubmitResponseCommand,
@@ -46,6 +49,10 @@ class SchedulingPermissionError(SchedulingError):
pass
class SchedulingConflictError(SchedulingError):
pass
WORKFLOW_DRAFT = "draft"
WORKFLOW_COLLECTING = "collecting_availability"
WORKFLOW_CLOSED = "availability_closed"
@@ -74,6 +81,27 @@ def _jsonable(value: Any) -> Any:
return value
def scheduling_slot_revision(slot: SchedulingCandidateSlot) -> str:
"""Stable semantic revision used to reject responses from stale forms."""
snapshot = {
"label": slot.label,
"description": slot.description,
"start_at": response_datetime(slot.start_at),
"end_at": response_datetime(slot.end_at),
"timezone": slot.timezone,
"location": slot.location,
"metadata": slot.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()
@@ -151,6 +179,28 @@ def _participant_respondent_id(
raise SchedulingPermissionError("An authenticated account is required to respond")
def _participant_poll_response(
session: Session,
*,
request: SchedulingRequest,
participant: SchedulingParticipant,
actor_ids: tuple[str, ...],
canonical_respondent_id: str,
) -> PollResponseRef | None:
if request.poll_id is None:
raise SchedulingError("Scheduling request has no backing poll")
try:
return _poll_provider().get_response(
session,
tenant_id=request.tenant_id,
poll_id=request.poll_id,
respondent_ids=_actor_ids((*actor_ids, canonical_respondent_id)),
invitation_id=participant.poll_invitation_id,
)
except PollCapabilityError as exc:
raise SchedulingError(str(exc)) from exc
def _poll_option_inputs(request: SchedulingRequest) -> list[PollOptionRequest]:
options: list[PollOptionRequest] = []
for slot in _active_slots(request):
@@ -737,21 +787,22 @@ def refresh_participant_response_state(
*,
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 = {
participant.poll_invitation_id: participant
for participant in _active_participants(request)
for participant in participants
if participant.poll_invitation_id is not None
}
by_respondent_id = {
participant.respondent_id: participant
for participant in _active_participants(request)
if participant.respondent_id is not None
}
if not by_invitation_id and not by_respondent_id:
return
by_respondent_id: dict[str, SchedulingParticipant] = {}
for participant in participants:
stored_identity = (participant.metadata_ or {}).get("poll_response_respondent_id")
for identity in (participant.respondent_id, stored_identity):
if isinstance(identity, str) and identity:
by_respondent_id[identity] = participant
try:
responses = _poll_provider().list_responses(
session,
@@ -764,9 +815,11 @@ def refresh_participant_response_state(
ids = _actor_ids(actor_ids)
actor_participants = [
participant
for participant in _active_participants(request)
for participant in participants
if participant.respondent_id in ids or participant.email in ids
]
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:
@@ -781,7 +834,19 @@ def refresh_participant_response_state(
and len(actor_participants) == 1
):
participant = actor_participants[0]
if participant is not None and participant.status != "responded":
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(
@@ -793,6 +858,24 @@ def refresh_participant_response_state(
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
if changed:
session.flush()
@@ -1073,11 +1156,24 @@ def submit_scheduling_availability(
participant,
fallback_respondent_id=respondent_id,
)
existing_response = _participant_poll_response(
session,
request=request,
participant=participant,
actor_ids=actor_ids,
canonical_respondent_id=canonical_respondent_id,
)
if existing_response is not None and existing_response.respondent_id:
canonical_respondent_id = existing_response.respondent_id
answers: list[PollAnswerRequest] = []
for answer in payload.answers:
slot = _selected_slot(request, slot_id=answer.slot_id)
if slot.poll_option_id is None:
raise SchedulingError("Scheduling slot has no backing poll option")
if answer.option_revision != scheduling_slot_revision(slot):
raise SchedulingConflictError(
"Scheduling options changed after this response form was loaded; reload before responding"
)
answers.append(
PollAnswerRequest(
option_id=slot.poll_option_id,
@@ -1108,6 +1204,11 @@ def submit_scheduling_availability(
first_response = participant.status != "responded"
participant.status = "responded"
participant.responded_at = response_datetime(response.submitted_at)
if response.respondent_id:
participant.metadata_ = {
**(participant.metadata_ or {}),
"poll_response_respondent_id": response.respondent_id,
}
if first_response:
_emit_scheduling_center_notification(
session,
@@ -1147,33 +1248,26 @@ def get_scheduling_availability_response(
participant,
fallback_respondent_id=respondent_id,
)
try:
responses = _poll_provider().list_responses(
session,
tenant_id=tenant_id,
poll_id=request.poll_id,
)
except PollCapabilityError as exc:
raise SchedulingError(str(exc)) from exc
matching = [
response
for response in responses
if response.respondent_id == canonical_respondent_id
or (
participant.poll_invitation_id is not None
and response.invitation_id == participant.poll_invitation_id
)
]
response = max(matching, key=lambda item: response_datetime(item.submitted_at)) if matching else None
response = _participant_poll_response(
session,
request=request,
participant=participant,
actor_ids=actor_ids,
canonical_respondent_id=canonical_respondent_id,
)
slot_by_option_id = {
slot.poll_option_id: slot
for slot in _active_slots(request)
if slot.poll_option_id is not None
}
slot_by_option_key = {
f"slot-{slot.position + 1}": slot
for slot in _active_slots(request)
}
answers: list[dict[str, str]] = []
if response is not None:
for answer in response.answers:
slot = slot_by_option_id.get(answer.option_id)
slot = slot_by_option_id.get(answer.option_id) or slot_by_option_key.get(answer.option_key)
if slot is None or answer.value not in {"available", "maybe", "unavailable"}:
continue
answers.append({"slot_id": slot.id, "value": str(answer.value)})
@@ -1337,6 +1431,12 @@ def update_scheduling_candidate_slot(
except PollCapabilityError as exc:
raise SchedulingError(str(exc)) from exc
refresh_participant_response_state(
session,
request=request,
reset_missing=True,
)
slot.label = label
slot.description = description
slot.start_at = start_at
@@ -1532,6 +1632,7 @@ def scheduling_slot_response(slot: SchedulingCandidateSlot) -> dict[str, Any]:
"timezone": slot.timezone,
"location": slot.location,
"position": slot.position,
"revision": scheduling_slot_revision(slot),
"freebusy_checked_at": response_datetime(slot.freebusy_checked_at),
"freebusy_status": slot.freebusy_status,
"freebusy_conflicts": slot.freebusy_conflicts or [],