fix(responses): reject stale scheduling answers
This commit is contained in:
@@ -31,6 +31,7 @@ from govoplan_scheduling.backend.schemas import (
|
||||
)
|
||||
from govoplan_scheduling.backend.runtime import get_registry
|
||||
from govoplan_scheduling.backend.service import (
|
||||
SchedulingConflictError,
|
||||
SchedulingError,
|
||||
SchedulingPermissionError,
|
||||
cancel_scheduling_request,
|
||||
@@ -115,6 +116,8 @@ def _can_manage_scheduling(principal: ApiPrincipal) -> bool:
|
||||
|
||||
|
||||
def _scheduling_http_error(exc: SchedulingError) -> HTTPException:
|
||||
if isinstance(exc, SchedulingConflictError):
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, SchedulingPermissionError):
|
||||
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
|
||||
if str(exc) == "Scheduling request not found":
|
||||
|
||||
@@ -2,8 +2,9 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
SchedulingStatus = Literal["draft", "collecting", "closed", "decided", "handed_off", "cancelled", "archived"]
|
||||
@@ -13,6 +14,16 @@ SchedulingResultVisibility = Literal["organizer", "after_response", "after_close
|
||||
SchedulingAvailabilityValue = Literal["available", "maybe", "unavailable"]
|
||||
|
||||
|
||||
def _known_timezone(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
ZoneInfo(value)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise ValueError("timezone must be a valid IANA timezone") from exc
|
||||
return value
|
||||
|
||||
|
||||
class SchedulingCalendarPreferences(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -28,12 +39,14 @@ class SchedulingCandidateSlotInput(BaseModel):
|
||||
|
||||
label: str | None = Field(default=None, max_length=500)
|
||||
description: str | None = None
|
||||
start_at: datetime
|
||||
end_at: datetime
|
||||
start_at: AwareDatetime
|
||||
end_at: AwareDatetime
|
||||
timezone: str | None = Field(default=None, max_length=100)
|
||||
location: str | None = Field(default=None, max_length=500)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
_validate_timezone = field_validator("timezone")(_known_timezone)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_range(self) -> "SchedulingCandidateSlotInput":
|
||||
if self.end_at <= self.start_at:
|
||||
@@ -52,12 +65,14 @@ class SchedulingCandidateSlotUpdateRequest(BaseModel):
|
||||
|
||||
label: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
description: str | None = None
|
||||
start_at: datetime | None = None
|
||||
end_at: datetime | None = None
|
||||
start_at: AwareDatetime | None = None
|
||||
end_at: AwareDatetime | None = None
|
||||
timezone: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
location: str | None = Field(default=None, max_length=500)
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
_validate_timezone = field_validator("timezone")(_known_timezone)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_range(self) -> "SchedulingCandidateSlotUpdateRequest":
|
||||
if self.start_at is not None and self.end_at is not None and self.end_at <= self.start_at:
|
||||
@@ -94,6 +109,8 @@ class SchedulingRequestCreateRequest(BaseModel):
|
||||
create_participant_invitations: bool = True
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
_validate_timezone = field_validator("timezone")(_known_timezone)
|
||||
|
||||
|
||||
class SchedulingRequestUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -119,6 +136,7 @@ class SchedulingCandidateSlotResponse(BaseModel):
|
||||
timezone: str
|
||||
location: str | None = None
|
||||
position: int
|
||||
revision: str
|
||||
freebusy_checked_at: datetime | None = None
|
||||
freebusy_status: str | None = None
|
||||
freebusy_conflicts: list[dict[str, Any]] = Field(default_factory=list)
|
||||
@@ -192,6 +210,7 @@ class SchedulingAvailabilityAnswerInput(BaseModel):
|
||||
|
||||
slot_id: str
|
||||
value: SchedulingAvailabilityValue
|
||||
option_revision: str = Field(min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class SchedulingAvailabilityResponseRequest(BaseModel):
|
||||
|
||||
@@ -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