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

@@ -31,6 +31,7 @@ from govoplan_scheduling.backend.schemas import (
) )
from govoplan_scheduling.backend.runtime import get_registry from govoplan_scheduling.backend.runtime import get_registry
from govoplan_scheduling.backend.service import ( from govoplan_scheduling.backend.service import (
SchedulingConflictError,
SchedulingError, SchedulingError,
SchedulingPermissionError, SchedulingPermissionError,
cancel_scheduling_request, cancel_scheduling_request,
@@ -115,6 +116,8 @@ def _can_manage_scheduling(principal: ApiPrincipal) -> bool:
def _scheduling_http_error(exc: SchedulingError) -> HTTPException: 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): if isinstance(exc, SchedulingPermissionError):
return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc))
if str(exc) == "Scheduling request not found": if str(exc) == "Scheduling request not found":

View File

@@ -2,8 +2,9 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal 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"] 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"] 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): class SchedulingCalendarPreferences(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -28,12 +39,14 @@ class SchedulingCandidateSlotInput(BaseModel):
label: str | None = Field(default=None, max_length=500) label: str | None = Field(default=None, max_length=500)
description: str | None = None description: str | None = None
start_at: datetime start_at: AwareDatetime
end_at: datetime end_at: AwareDatetime
timezone: str | None = Field(default=None, max_length=100) timezone: str | None = Field(default=None, max_length=100)
location: str | None = Field(default=None, max_length=500) location: str | None = Field(default=None, max_length=500)
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
_validate_timezone = field_validator("timezone")(_known_timezone)
@model_validator(mode="after") @model_validator(mode="after")
def validate_range(self) -> "SchedulingCandidateSlotInput": def validate_range(self) -> "SchedulingCandidateSlotInput":
if self.end_at <= self.start_at: 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) label: str | None = Field(default=None, min_length=1, max_length=500)
description: str | None = None description: str | None = None
start_at: datetime | None = None start_at: AwareDatetime | None = None
end_at: datetime | None = None end_at: AwareDatetime | None = None
timezone: str | None = Field(default=None, min_length=1, max_length=100) timezone: str | None = Field(default=None, min_length=1, max_length=100)
location: str | None = Field(default=None, max_length=500) location: str | None = Field(default=None, max_length=500)
metadata: dict[str, Any] | None = None metadata: dict[str, Any] | None = None
_validate_timezone = field_validator("timezone")(_known_timezone)
@model_validator(mode="after") @model_validator(mode="after")
def validate_range(self) -> "SchedulingCandidateSlotUpdateRequest": 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: 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 create_participant_invitations: bool = True
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
_validate_timezone = field_validator("timezone")(_known_timezone)
class SchedulingRequestUpdateRequest(BaseModel): class SchedulingRequestUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
@@ -119,6 +136,7 @@ class SchedulingCandidateSlotResponse(BaseModel):
timezone: str timezone: str
location: str | None = None location: str | None = None
position: int position: int
revision: str
freebusy_checked_at: datetime | None = None freebusy_checked_at: datetime | None = None
freebusy_status: str | None = None freebusy_status: str | None = None
freebusy_conflicts: list[dict[str, Any]] = Field(default_factory=list) freebusy_conflicts: list[dict[str, Any]] = Field(default_factory=list)
@@ -192,6 +210,7 @@ class SchedulingAvailabilityAnswerInput(BaseModel):
slot_id: str slot_id: str
value: SchedulingAvailabilityValue value: SchedulingAvailabilityValue
option_revision: str = Field(min_length=64, max_length=64, pattern=r"^[0-9a-f]{64}$")
class SchedulingAvailabilityResponseRequest(BaseModel): class SchedulingAvailabilityResponseRequest(BaseModel):

View File

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

View File

@@ -4,6 +4,8 @@ import unittest
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from types import SimpleNamespace from types import SimpleNamespace
from fastapi import HTTPException
from pydantic import ValidationError
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -40,6 +42,8 @@ from govoplan_scheduling.backend.service import (
SchedulingError, SchedulingError,
cancel_scheduling_request, cancel_scheduling_request,
create_scheduling_request, create_scheduling_request,
scheduling_request_summary,
scheduling_slot_revision,
update_scheduling_candidate_slot, update_scheduling_candidate_slot,
) )
@@ -99,7 +103,12 @@ class SchedulingResponseEditingTests(unittest.TestCase):
user=SimpleNamespace(id=f"membership-{account_id}"), user=SimpleNamespace(id=f"membership-{account_id}"),
) )
def _request(self, *, status: str = "collecting") -> SchedulingRequest: def _request(
self,
*,
status: str = "collecting",
allow_participant_updates: bool = True,
) -> SchedulingRequest:
start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc) start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc)
request, _tokens = create_scheduling_request( request, _tokens = create_scheduling_request(
self.session, self.session,
@@ -108,6 +117,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
payload=SchedulingRequestCreateRequest( payload=SchedulingRequestCreateRequest(
title="Response editing", title="Response editing",
status=status, status=status,
allow_participant_updates=allow_participant_updates,
calendar=SchedulingCalendarPreferences(), calendar=SchedulingCalendarPreferences(),
slots=[ slots=[
SchedulingCandidateSlotInput( SchedulingCandidateSlotInput(
@@ -136,8 +146,16 @@ class SchedulingResponseEditingTests(unittest.TestCase):
request.id, request.id,
SchedulingAvailabilityResponseRequest( SchedulingAvailabilityResponseRequest(
answers=[ answers=[
SchedulingAvailabilityAnswerInput(slot_id=request.slots[0].id, value="available"), SchedulingAvailabilityAnswerInput(
SchedulingAvailabilityAnswerInput(slot_id=request.slots[1].id, value="maybe"), slot_id=request.slots[0].id,
value="available",
option_revision=scheduling_slot_revision(request.slots[0]),
),
SchedulingAvailabilityAnswerInput(
slot_id=request.slots[1].id,
value="maybe",
option_revision=scheduling_slot_revision(request.slots[1]),
),
] ]
), ),
session=self.session, session=self.session,
@@ -227,7 +245,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
request_id=request.id, request_id=request.id,
slot_id=slot.id, slot_id=slot.id,
payload=SchedulingCandidateSlotUpdateRequest( payload=SchedulingCandidateSlotUpdateRequest(
start_at=original_start + timedelta(minutes=15), start_at=original_start.replace(tzinfo=timezone.utc) + timedelta(minutes=15),
), ),
) )
@@ -249,6 +267,115 @@ class SchedulingResponseEditingTests(unittest.TestCase):
self.assertIsNotNone(cancelled.cancelled_at) self.assertIsNotNone(cancelled.cancelled_at)
self.assertEqual(poll.status, "draft") self.assertEqual(poll.status, "draft")
def test_fully_invalidated_response_becomes_unanswered(self) -> None:
request = self._request()
alice = self._principal(
"alice-account",
email="alice@example.test",
scopes={RESPOND_SCOPE},
)
api_submit_scheduling_availability(
request.id,
SchedulingAvailabilityResponseRequest(
answers=[
SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id,
value="available",
option_revision=scheduling_slot_revision(request.slots[0]),
)
]
),
session=self.session,
principal=alice,
)
api_update_scheduling_candidate_slot(
request.id,
request.slots[0].id,
SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
session=self.session,
principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}),
)
current = api_get_my_scheduling_availability(
request.id,
session=self.session,
principal=alice,
)
self.assertFalse(current.has_response)
self.assertEqual(current.answers, [])
self.assertEqual(request.participants[0].status, "invited")
self.assertIsNone(request.participants[0].responded_at)
self.assertEqual(
scheduling_request_summary(self.session, tenant_id="tenant-1", request_id=request.id)["response_count"],
0,
)
def test_option_change_is_blocked_when_response_updates_are_disabled(self) -> None:
request = self._request(allow_participant_updates=False)
self._submit_both(request)
original_label = request.slots[0].label
with self.assertRaisesRegex(SchedulingError, "response updates are disabled"):
update_scheduling_candidate_slot(
self.session,
tenant_id="tenant-1",
request_id=request.id,
slot_id=request.slots[0].id,
payload=SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
)
self.assertEqual(request.slots[0].label, original_label)
response = self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).one()
self.assertEqual(len(response.answers), 2)
def test_stale_option_revision_is_rejected_without_writing_a_response(self) -> None:
request = self._request()
stale_revision = scheduling_slot_revision(request.slots[0])
api_update_scheduling_candidate_slot(
request.id,
request.slots[0].id,
SchedulingCandidateSlotUpdateRequest(label="Monday, revised"),
session=self.session,
principal=self._principal("organizer-1", email=None, scopes={WRITE_SCOPE}),
)
with self.assertRaises(HTTPException) as stale:
api_submit_scheduling_availability(
request.id,
SchedulingAvailabilityResponseRequest(
answers=[
SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id,
value="available",
option_revision=stale_revision,
)
]
),
session=self.session,
principal=self._principal(
"alice-account",
email="alice@example.test",
scopes={RESPOND_SCOPE},
),
)
self.assertEqual(stale.exception.status_code, 409)
self.assertEqual(self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).count(), 0)
def test_slot_inputs_require_aware_datetimes_and_known_timezones(self) -> None:
with self.assertRaises(ValidationError):
SchedulingCandidateSlotInput(
start_at=datetime(2026, 7, 20, 9),
end_at=datetime(2026, 7, 20, 10),
)
with self.assertRaisesRegex(ValidationError, "valid IANA timezone"):
SchedulingCandidateSlotInput(
start_at=datetime(2026, 7, 20, 9, tzinfo=timezone.utc),
end_at=datetime(2026, 7, 20, 10, tzinfo=timezone.utc),
timezone="Mars/Olympus_Mons",
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -44,6 +44,7 @@ from govoplan_scheduling.backend.router import (
api_create_tentative_calendar_holds, api_create_tentative_calendar_holds,
api_decide_scheduling_request, api_decide_scheduling_request,
api_evaluate_calendar_freebusy, api_evaluate_calendar_freebusy,
api_get_my_scheduling_availability,
api_get_scheduling_request, api_get_scheduling_request,
api_list_scheduling_requests, api_list_scheduling_requests,
api_scheduling_summary, api_scheduling_summary,
@@ -66,6 +67,7 @@ from govoplan_scheduling.backend.service import (
open_scheduling_request, open_scheduling_request,
require_visible_scheduling_results, require_visible_scheduling_results,
scheduling_request_summary, scheduling_request_summary,
scheduling_slot_revision,
update_scheduling_request, update_scheduling_request,
) )
from govoplan_scheduling.backend.runtime import configure_runtime from govoplan_scheduling.backend.runtime import configure_runtime
@@ -825,7 +827,12 @@ class SchedulingServiceTests(unittest.TestCase):
"attacker", "attacker",
email="alice@example.test", email="alice@example.test",
membership_id="alice-membership", membership_id="alice-membership",
scopes={SCHEDULING_READ_SCOPE, "poll:poll:read", "poll:response:write"}, scopes={
SCHEDULING_READ_SCOPE,
SCHEDULING_RESPOND_SCOPE,
"poll:poll:read",
"poll:response:write",
},
) )
response = api_submit_poll_response( response = api_submit_poll_response(
@@ -842,9 +849,19 @@ class SchedulingServiceTests(unittest.TestCase):
session=self.session, session=self.session,
principal=attacker, principal=attacker,
) )
current = api_get_my_scheduling_availability(
request.id,
session=self.session,
principal=attacker,
)
self.assertNotIn("invitation_id", response.metadata) self.assertNotIn("invitation_id", response.metadata)
self.assertEqual([request.id], [item.id for item in listed.requests]) self.assertEqual([request.id], [item.id for item in listed.requests])
self.assertTrue(current.has_response)
self.assertEqual(
[(answer.slot_id, answer.value) for answer in current.answers],
[(request.slots[0].id, "available")],
)
alice = next(participant for participant in request.participants if participant.display_name == "Alice") alice = next(participant for participant in request.participants if participant.display_name == "Alice")
bob = next(participant for participant in request.participants if participant.display_name == "Bob") bob = next(participant for participant in request.participants if participant.display_name == "Bob")
self.assertEqual(alice.status, "responded") self.assertEqual(alice.status, "responded")
@@ -883,10 +900,12 @@ class SchedulingServiceTests(unittest.TestCase):
SchedulingAvailabilityAnswerInput( SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id, slot_id=request.slots[0].id,
value="available", value="available",
option_revision=scheduling_slot_revision(request.slots[0]),
), ),
SchedulingAvailabilityAnswerInput( SchedulingAvailabilityAnswerInput(
slot_id=request.slots[1].id, slot_id=request.slots[1].id,
value="maybe", value="maybe",
option_revision=scheduling_slot_revision(request.slots[1]),
), ),
] ]
), ),
@@ -917,6 +936,7 @@ class SchedulingServiceTests(unittest.TestCase):
SchedulingAvailabilityAnswerInput( SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id, slot_id=request.slots[0].id,
value="unavailable", value="unavailable",
option_revision=scheduling_slot_revision(request.slots[0]),
) )
] ]
), ),
@@ -969,10 +989,12 @@ class SchedulingServiceTests(unittest.TestCase):
SchedulingAvailabilityAnswerInput( SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id, slot_id=request.slots[0].id,
value="unavailable", value="unavailable",
option_revision=scheduling_slot_revision(request.slots[0]),
), ),
SchedulingAvailabilityAnswerInput( SchedulingAvailabilityAnswerInput(
slot_id=request.slots[1].id, slot_id=request.slots[1].id,
value="maybe", value="maybe",
option_revision=scheduling_slot_revision(request.slots[1]),
), ),
] ]
), ),