fix(responses): reject stale scheduling answers
This commit is contained in:
@@ -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 [],
|
||||
|
||||
Reference in New Issue
Block a user