diff --git a/src/govoplan_scheduling/backend/router.py b/src/govoplan_scheduling/backend/router.py index 3bd82d3..08700b8 100644 --- a/src/govoplan_scheduling/backend/router.py +++ b/src/govoplan_scheduling/backend/router.py @@ -11,10 +11,12 @@ from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALEND from govoplan_core.db.session import get_session from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE from govoplan_scheduling.backend.schemas import ( - SchedulingAvailabilityResponseRequest, SchedulingAddressLookupCandidate, SchedulingAddressLookupResponse, + SchedulingAvailabilityResponse, + SchedulingAvailabilityResponseRequest, SchedulingCalendarActionResponse, + SchedulingCandidateSlotUpdateRequest, SchedulingDecisionRequest, SchedulingNotificationCreateRequest, SchedulingNotificationListResponse, @@ -39,6 +41,7 @@ from govoplan_scheduling.backend.service import ( create_tentative_calendar_holds, decide_scheduling_request, evaluate_calendar_freebusy, + get_scheduling_availability_response, get_visible_scheduling_request, list_visible_scheduling_notifications, list_visible_scheduling_requests, @@ -49,6 +52,7 @@ from govoplan_scheduling.backend.service import ( scheduling_request_response, scheduling_request_summary, submit_scheduling_availability, + update_scheduling_candidate_slot, update_scheduling_request, ) @@ -232,6 +236,26 @@ def api_submit_scheduling_availability( return response +@router.get("/requests/{request_id}/responses/me", response_model=SchedulingAvailabilityResponse) +def api_get_my_scheduling_availability( + request_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> SchedulingAvailabilityResponse: + _require_scope(principal, RESPOND_SCOPE) + try: + response = get_scheduling_availability_response( + session, + tenant_id=principal.tenant_id, + request_id=request_id, + actor_ids=_principal_actor_ids(principal), + respondent_id=principal.account_id, + ) + except SchedulingError as exc: + raise _scheduling_http_error(exc) from exc + return SchedulingAvailabilityResponse.model_validate(response) + + @router.get("/requests/{request_id}", response_model=SchedulingRequestResponse) def api_get_scheduling_request( request_id: str, @@ -281,6 +305,30 @@ def api_update_scheduling_request( return response +@router.patch("/requests/{request_id}/slots/{slot_id}", response_model=SchedulingRequestResponse) +def api_update_scheduling_candidate_slot( + request_id: str, + slot_id: str, + payload: SchedulingCandidateSlotUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> SchedulingRequestResponse: + _require_scope(principal, WRITE_SCOPE) + try: + request = update_scheduling_candidate_slot( + session, + tenant_id=principal.tenant_id, + request_id=request_id, + slot_id=slot_id, + payload=payload, + ) + except SchedulingError as exc: + raise _scheduling_http_error(exc) from exc + response = _request_response(request, principal=principal) + session.commit() + return response + + @router.post("/requests/{request_id}/open", response_model=SchedulingStatusResponse) def api_open_scheduling_request( request_id: str, diff --git a/src/govoplan_scheduling/backend/schemas.py b/src/govoplan_scheduling/backend/schemas.py index 0b31663..bdbcb6c 100644 --- a/src/govoplan_scheduling/backend/schemas.py +++ b/src/govoplan_scheduling/backend/schemas.py @@ -41,6 +41,30 @@ class SchedulingCandidateSlotInput(BaseModel): return self +class SchedulingCandidateSlotUpdateRequest(BaseModel): + """Partial update of one candidate slot. + + A semantic option change invalidates existing answers for this slot in the + backing Poll. Exact repeats are safe no-ops. + """ + + model_config = ConfigDict(extra="forbid") + + 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 + 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 + + @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: + raise ValueError("end_at must be after start_at") + return self + + class SchedulingParticipantInput(BaseModel): model_config = ConfigDict(extra="forbid") @@ -183,6 +207,19 @@ class SchedulingAvailabilityResponseRequest(BaseModel): return self +class SchedulingAvailabilityAnswerResponse(BaseModel): + slot_id: str + value: SchedulingAvailabilityValue + + +class SchedulingAvailabilityResponse(BaseModel): + request_id: str + participant_id: str + has_response: bool = False + submitted_at: datetime | None = None + answers: list[SchedulingAvailabilityAnswerResponse] = Field(default_factory=list) + + class SchedulingPollOptionResultResponse(BaseModel): option_id: str option_key: str diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index b6a9fb9..3e82187 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -17,6 +17,7 @@ from govoplan_core.core.poll import ( PollCapabilityError, PollCreateCommand, PollInvitationCommand, + PollOptionUpdateCommand, PollOptionRequest, PollResponseSubmissionProvider, PollSchedulingProvider, @@ -29,6 +30,7 @@ from govoplan_core.db.base import utcnow from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest from govoplan_scheduling.backend.schemas import ( SchedulingAvailabilityResponseRequest, + SchedulingCandidateSlotUpdateRequest, SchedulingDecisionRequest, SchedulingRequestCreateRequest, SchedulingRequestUpdateRequest, @@ -117,6 +119,38 @@ def _active_participants(request: SchedulingRequest) -> list[SchedulingParticipa return [participant for participant in request.participants if participant.deleted_at is None] +def _participant_for_actor( + request: SchedulingRequest, + *, + actor_ids: tuple[str, ...], +) -> SchedulingParticipant: + ids = _actor_ids(actor_ids) + participants = [ + participant + for participant in _active_participants(request) + if participant.respondent_id in ids or participant.email in ids + ] + if len(participants) != 1: + raise SchedulingPermissionError( + "A unique participant assignment is required to respond" + ) + return participants[0] + + +def _participant_respondent_id( + participant: SchedulingParticipant, + *, + fallback_respondent_id: str | None, +) -> str: + if participant.respondent_id: + return participant.respondent_id + if participant.poll_invitation_id: + return f"invitation:{participant.poll_invitation_id}" + if fallback_respondent_id: + return fallback_respondent_id + raise SchedulingPermissionError("An authenticated account is required to respond") + + def _poll_option_inputs(request: SchedulingRequest) -> list[PollOptionRequest]: options: list[PollOptionRequest] = [] for slot in _active_slots(request): @@ -1034,21 +1068,10 @@ def submit_scheduling_availability( raise SchedulingError("Scheduling request has no backing poll") if respondent_id is None: raise SchedulingPermissionError("An authenticated account is required to respond") - ids = _actor_ids(actor_ids) - participants = [ - participant - for participant in _active_participants(request) - if participant.respondent_id in ids or participant.email in ids - ] - if len(participants) != 1: - raise SchedulingPermissionError( - "A unique participant assignment is required to respond" - ) - participant = participants[0] - canonical_respondent_id = participant.respondent_id or ( - f"invitation:{participant.poll_invitation_id}" - if participant.poll_invitation_id - else respondent_id + participant = _participant_for_actor(request, actor_ids=actor_ids) + canonical_respondent_id = _participant_respondent_id( + participant, + fallback_respondent_id=respondent_id, ) answers: list[PollAnswerRequest] = [] for answer in payload.answers: @@ -1101,6 +1124,68 @@ def submit_scheduling_availability( return request +def get_scheduling_availability_response( + session: Session, + *, + tenant_id: str, + request_id: str, + actor_ids: tuple[str, ...], + respondent_id: str | None, +) -> dict[str, Any]: + """Return only the caller's still-valid availability answers.""" + + request = get_visible_scheduling_request( + session, + tenant_id=tenant_id, + request_id=request_id, + actor_ids=actor_ids, + ) + if request.poll_id is None: + raise SchedulingError("Scheduling request has no backing poll") + participant = _participant_for_actor(request, actor_ids=actor_ids) + canonical_respondent_id = _participant_respondent_id( + 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 + slot_by_option_id = { + slot.poll_option_id: slot + for slot in _active_slots(request) + if slot.poll_option_id is not None + } + answers: list[dict[str, str]] = [] + if response is not None: + for answer in response.answers: + slot = slot_by_option_id.get(answer.option_id) + if slot is None or answer.value not in {"available", "maybe", "unavailable"}: + continue + answers.append({"slot_id": slot.id, "value": str(answer.value)}) + return { + "request_id": request.id, + "participant_id": participant.id, + "has_response": response is not None, + "submitted_at": response_datetime(response.submitted_at) if response is not None else None, + "answers": answers, + } + + def require_visible_scheduling_results( session: Session, *, @@ -1170,6 +1255,103 @@ def update_scheduling_request( return request +def update_scheduling_candidate_slot( + session: Session, + *, + tenant_id: str, + request_id: str, + slot_id: str, + payload: SchedulingCandidateSlotUpdateRequest, +) -> SchedulingRequest: + """Update one slot and invalidate only its Poll answers atomically.""" + + request = _lock_scheduling_request( + session, + tenant_id=tenant_id, + request_id=request_id, + lock_slots=True, + ) + if request.status not in {"draft", "collecting"}: + raise SchedulingError("Only draft or collecting scheduling request slots can be edited") + if request.poll_id is None: + raise SchedulingError("Scheduling request has no backing poll") + slot = _selected_slot(request, slot_id=slot_id) + if slot.poll_option_id is None: + raise SchedulingError("Scheduling slot has no backing poll option") + + supplied = payload.model_fields_set + for field in ("label", "start_at", "end_at", "timezone"): + if field in supplied and getattr(payload, field) is None: + raise SchedulingError(f"{field} cannot be null") + + label = payload.label if "label" in supplied else slot.label + description = payload.description if "description" in supplied else slot.description + start_at = payload.start_at if "start_at" in supplied else slot.start_at + end_at = payload.end_at if "end_at" in supplied else slot.end_at + timezone_name = payload.timezone if "timezone" in supplied else slot.timezone + location = payload.location if "location" in supplied else slot.location + metadata = (payload.metadata or {}) if "metadata" in supplied else (slot.metadata_ or {}) + normalized_start = response_datetime(start_at) + normalized_end = response_datetime(end_at) + if normalized_end <= normalized_start: + raise SchedulingError("end_at must be after start_at") + + temporal_or_location_changed = ( + normalized_start != response_datetime(slot.start_at) + or normalized_end != response_datetime(slot.end_at) + or timezone_name != slot.timezone + or location != slot.location + ) + changed = ( + label != slot.label + or description != slot.description + or temporal_or_location_changed + or metadata != (slot.metadata_ or {}) + ) + if not changed: + return request + if temporal_or_location_changed and slot.tentative_hold_event_id: + raise SchedulingError( + "A slot with a tentative calendar hold cannot change time, timezone, or location" + ) + + try: + _poll_provider().update_option( + session, + tenant_id=tenant_id, + poll_id=request.poll_id, + option_id=slot.poll_option_id, + command=PollOptionUpdateCommand( + label=label, + description=description, + value={ + "slot_id": slot.id, + "start_at": normalized_start.isoformat(), + "end_at": normalized_end.isoformat(), + "timezone": timezone_name, + "location": location, + }, + metadata=metadata, + ), + ) + except PollCapabilityError as exc: + raise SchedulingError(str(exc)) from exc + + slot.label = label + slot.description = description + slot.start_at = start_at + slot.end_at = end_at + slot.timezone = timezone_name + slot.location = location + slot.metadata_ = metadata + if temporal_or_location_changed: + slot.freebusy_checked_at = None + slot.freebusy_status = None + slot.freebusy_conflicts = [] + session.flush() + return request + + def open_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest: request = _lock_scheduling_request( session, @@ -1303,7 +1485,7 @@ def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: s try: provider = _poll_provider() poll = provider.get_poll(session, tenant_id=tenant_id, poll_id=request.poll_id) - if poll.status not in {"closed", "decided", "archived"}: + if poll.status == "open": provider.close_poll(session, tenant_id=tenant_id, poll_id=request.poll_id) except PollCapabilityError as exc: raise SchedulingError(str(exc)) from exc diff --git a/tests/test_response_editing.py b/tests/test_response_editing.py new file mode 100644 index 0000000..bfbe8f0 --- /dev/null +++ b/tests/test_response_editing.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from govoplan_core.auth import ApiPrincipal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.modules import ModuleContext +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.db.base import Base +from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse +from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest +from govoplan_scheduling.backend.db.models import ( + SchedulingCandidateSlot, + SchedulingNotification, + SchedulingParticipant, + SchedulingRequest, +) +from govoplan_scheduling.backend.manifest import RESPOND_SCOPE, WRITE_SCOPE +from govoplan_scheduling.backend.router import ( + api_get_my_scheduling_availability, + api_submit_scheduling_availability, + api_update_scheduling_candidate_slot, +) +from govoplan_scheduling.backend.runtime import configure_runtime +from govoplan_scheduling.backend.schemas import ( + SchedulingAvailabilityAnswerInput, + SchedulingAvailabilityResponseRequest, + SchedulingCalendarPreferences, + SchedulingCandidateSlotInput, + SchedulingCandidateSlotUpdateRequest, + SchedulingParticipantInput, + SchedulingRequestCreateRequest, +) +from govoplan_scheduling.backend.service import ( + SchedulingError, + cancel_scheduling_request, + create_scheduling_request, + update_scheduling_candidate_slot, +) + + +class SchedulingResponseEditingTests(unittest.TestCase): + def setUp(self) -> None: + registry = PlatformRegistry() + registry.register(get_poll_manifest()) + registry.configure_capability_context(ModuleContext(registry=registry, settings=object())) + configure_runtime(registry=registry) + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all( + self.engine, + tables=[ + Poll.__table__, + PollOption.__table__, + PollResponse.__table__, + PollInvitation.__table__, + SchedulingRequest.__table__, + SchedulingCandidateSlot.__table__, + SchedulingParticipant.__table__, + SchedulingNotification.__table__, + ], + ) + self.Session = sessionmaker(bind=self.engine) + self.session: Session = self.Session() + + def tearDown(self) -> None: + self.session.close() + Base.metadata.drop_all( + self.engine, + tables=[ + SchedulingNotification.__table__, + SchedulingParticipant.__table__, + SchedulingCandidateSlot.__table__, + SchedulingRequest.__table__, + PollInvitation.__table__, + PollResponse.__table__, + PollOption.__table__, + Poll.__table__, + ], + ) + self.engine.dispose() + + @staticmethod + def _principal(account_id: str, *, email: str | None, scopes: set[str]) -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id=account_id, + membership_id=f"membership-{account_id}", + tenant_id="tenant-1", + email=email, + display_name=account_id, + scopes=frozenset(scopes), + ), + account=SimpleNamespace(id=account_id), + user=SimpleNamespace(id=f"membership-{account_id}"), + ) + + def _request(self, *, status: str = "collecting") -> SchedulingRequest: + start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc) + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="organizer-1", + payload=SchedulingRequestCreateRequest( + title="Response editing", + status=status, + calendar=SchedulingCalendarPreferences(), + slots=[ + SchedulingCandidateSlotInput( + label="Monday", + start_at=start, + end_at=start + timedelta(hours=1), + ), + SchedulingCandidateSlotInput( + label="Tuesday", + start_at=start + timedelta(days=1), + end_at=start + timedelta(days=1, hours=1), + ), + ], + participants=[ + SchedulingParticipantInput( + display_name="Alice", + email="alice@example.test", + ) + ], + ), + ) + return request + + def _submit_both(self, request: SchedulingRequest) -> None: + api_submit_scheduling_availability( + request.id, + SchedulingAvailabilityResponseRequest( + answers=[ + SchedulingAvailabilityAnswerInput(slot_id=request.slots[0].id, value="available"), + SchedulingAvailabilityAnswerInput(slot_id=request.slots[1].id, value="maybe"), + ] + ), + session=self.session, + principal=self._principal( + "alice-account", + email="alice@example.test", + scopes={RESPOND_SCOPE}, + ), + ) + + def test_current_participant_response_is_prefilled_and_changed_slot_is_invalidated(self) -> None: + request = self._request() + self._submit_both(request) + alice = self._principal( + "alice-account", + email="alice@example.test", + scopes={RESPOND_SCOPE}, + ) + + before = api_get_my_scheduling_availability( + request.id, + session=self.session, + principal=alice, + ) + + self.assertTrue(before.has_response) + self.assertIsNotNone(before.submitted_at) + self.assertEqual(before.participant_id, request.participants[0].id) + self.assertEqual( + [(answer.slot_id, answer.value) for answer in before.answers], + [(request.slots[0].id, "available"), (request.slots[1].id, "maybe")], + ) + + 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}), + ) + after = api_get_my_scheduling_availability( + request.id, + session=self.session, + principal=alice, + ) + + self.assertTrue(after.has_response) + self.assertEqual( + [(answer.slot_id, answer.value) for answer in after.answers], + [(request.slots[1].id, "maybe")], + ) + poll_response = self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).one() + self.assertEqual( + [answer["option_id"] for answer in poll_response.answers], + [request.slots[1].poll_option_id], + ) + + # Re-submit and repeat the exact option snapshot: an idempotent no-op + # must preserve both current answers. + self._submit_both(request) + 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}), + ) + unchanged = api_get_my_scheduling_availability( + request.id, + session=self.session, + principal=alice, + ) + self.assertEqual(len(unchanged.answers), 2) + + def test_calendar_hold_rejection_leaves_slot_and_answers_unchanged(self) -> None: + request = self._request() + self._submit_both(request) + slot = request.slots[0] + original_start = slot.start_at + slot.tentative_hold_event_id = "event-1" + self.session.flush() + + with self.assertRaisesRegex(SchedulingError, "tentative calendar hold"): + update_scheduling_candidate_slot( + self.session, + tenant_id="tenant-1", + request_id=request.id, + slot_id=slot.id, + payload=SchedulingCandidateSlotUpdateRequest( + start_at=original_start + timedelta(minutes=15), + ), + ) + + self.assertEqual(slot.start_at, original_start) + response = self.session.query(PollResponse).filter(PollResponse.poll_id == request.poll_id).one() + self.assertEqual(len(response.answers), 2) + + def test_cancelling_draft_request_preserves_draft_poll(self) -> None: + request = self._request(status="draft") + + cancelled = cancel_scheduling_request( + self.session, + tenant_id="tenant-1", + request_id=request.id, + ) + poll = self.session.query(Poll).filter(Poll.id == request.poll_id).one() + + self.assertEqual(cancelled.status, "cancelled") + self.assertIsNotNone(cancelled.cancelled_at) + self.assertEqual(poll.status, "draft") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_service.py b/tests/test_service.py index 9ecedb2..7a963a3 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -19,7 +19,7 @@ from govoplan_core.core.registry import PlatformRegistry from govoplan_access.backend.db.models import Account, User from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarOutboxOperation, CalendarSyncSource from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest -from govoplan_poll.backend.db.models import Poll, PollInvitation, PollOption, PollResponse +from govoplan_poll.backend.db.models import Poll, PollInvitation, PollLifecycleTransition, PollOption, PollResponse from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest from govoplan_poll.backend.router import api_get_poll, api_list_polls, api_submit_poll_response from govoplan_poll.backend.schemas import PollAnswerInput, PollSubmitResponseRequest @@ -86,6 +86,7 @@ class SchedulingServiceTests(unittest.TestCase): PollOption.__table__, PollResponse.__table__, PollInvitation.__table__, + PollLifecycleTransition.__table__, CalendarCollection.__table__, CalendarEvent.__table__, CalendarSyncSource.__table__, @@ -119,6 +120,7 @@ class SchedulingServiceTests(unittest.TestCase): CalendarEvent.__table__, CalendarCollection.__table__, PollInvitation.__table__, + PollLifecycleTransition.__table__, PollResponse.__table__, PollOption.__table__, Poll.__table__,