feat(responses): preserve editable availability history
This commit is contained in:
@@ -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_core.db.session import get_session
|
||||||
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
|
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
|
||||||
from govoplan_scheduling.backend.schemas import (
|
from govoplan_scheduling.backend.schemas import (
|
||||||
SchedulingAvailabilityResponseRequest,
|
|
||||||
SchedulingAddressLookupCandidate,
|
SchedulingAddressLookupCandidate,
|
||||||
SchedulingAddressLookupResponse,
|
SchedulingAddressLookupResponse,
|
||||||
|
SchedulingAvailabilityResponse,
|
||||||
|
SchedulingAvailabilityResponseRequest,
|
||||||
SchedulingCalendarActionResponse,
|
SchedulingCalendarActionResponse,
|
||||||
|
SchedulingCandidateSlotUpdateRequest,
|
||||||
SchedulingDecisionRequest,
|
SchedulingDecisionRequest,
|
||||||
SchedulingNotificationCreateRequest,
|
SchedulingNotificationCreateRequest,
|
||||||
SchedulingNotificationListResponse,
|
SchedulingNotificationListResponse,
|
||||||
@@ -39,6 +41,7 @@ from govoplan_scheduling.backend.service import (
|
|||||||
create_tentative_calendar_holds,
|
create_tentative_calendar_holds,
|
||||||
decide_scheduling_request,
|
decide_scheduling_request,
|
||||||
evaluate_calendar_freebusy,
|
evaluate_calendar_freebusy,
|
||||||
|
get_scheduling_availability_response,
|
||||||
get_visible_scheduling_request,
|
get_visible_scheduling_request,
|
||||||
list_visible_scheduling_notifications,
|
list_visible_scheduling_notifications,
|
||||||
list_visible_scheduling_requests,
|
list_visible_scheduling_requests,
|
||||||
@@ -49,6 +52,7 @@ from govoplan_scheduling.backend.service import (
|
|||||||
scheduling_request_response,
|
scheduling_request_response,
|
||||||
scheduling_request_summary,
|
scheduling_request_summary,
|
||||||
submit_scheduling_availability,
|
submit_scheduling_availability,
|
||||||
|
update_scheduling_candidate_slot,
|
||||||
update_scheduling_request,
|
update_scheduling_request,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -232,6 +236,26 @@ def api_submit_scheduling_availability(
|
|||||||
return response
|
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)
|
@router.get("/requests/{request_id}", response_model=SchedulingRequestResponse)
|
||||||
def api_get_scheduling_request(
|
def api_get_scheduling_request(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
@@ -281,6 +305,30 @@ def api_update_scheduling_request(
|
|||||||
return response
|
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)
|
@router.post("/requests/{request_id}/open", response_model=SchedulingStatusResponse)
|
||||||
def api_open_scheduling_request(
|
def api_open_scheduling_request(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
|
|||||||
@@ -41,6 +41,30 @@ class SchedulingCandidateSlotInput(BaseModel):
|
|||||||
return self
|
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):
|
class SchedulingParticipantInput(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -183,6 +207,19 @@ class SchedulingAvailabilityResponseRequest(BaseModel):
|
|||||||
return self
|
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):
|
class SchedulingPollOptionResultResponse(BaseModel):
|
||||||
option_id: str
|
option_id: str
|
||||||
option_key: str
|
option_key: str
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from govoplan_core.core.poll import (
|
|||||||
PollCapabilityError,
|
PollCapabilityError,
|
||||||
PollCreateCommand,
|
PollCreateCommand,
|
||||||
PollInvitationCommand,
|
PollInvitationCommand,
|
||||||
|
PollOptionUpdateCommand,
|
||||||
PollOptionRequest,
|
PollOptionRequest,
|
||||||
PollResponseSubmissionProvider,
|
PollResponseSubmissionProvider,
|
||||||
PollSchedulingProvider,
|
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.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest
|
||||||
from govoplan_scheduling.backend.schemas import (
|
from govoplan_scheduling.backend.schemas import (
|
||||||
SchedulingAvailabilityResponseRequest,
|
SchedulingAvailabilityResponseRequest,
|
||||||
|
SchedulingCandidateSlotUpdateRequest,
|
||||||
SchedulingDecisionRequest,
|
SchedulingDecisionRequest,
|
||||||
SchedulingRequestCreateRequest,
|
SchedulingRequestCreateRequest,
|
||||||
SchedulingRequestUpdateRequest,
|
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]
|
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]:
|
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):
|
||||||
@@ -1034,21 +1068,10 @@ def submit_scheduling_availability(
|
|||||||
raise SchedulingError("Scheduling request has no backing poll")
|
raise SchedulingError("Scheduling request has no backing poll")
|
||||||
if respondent_id is None:
|
if respondent_id is None:
|
||||||
raise SchedulingPermissionError("An authenticated account is required to respond")
|
raise SchedulingPermissionError("An authenticated account is required to respond")
|
||||||
ids = _actor_ids(actor_ids)
|
participant = _participant_for_actor(request, actor_ids=actor_ids)
|
||||||
participants = [
|
canonical_respondent_id = _participant_respondent_id(
|
||||||
participant
|
participant,
|
||||||
for participant in _active_participants(request)
|
fallback_respondent_id=respondent_id,
|
||||||
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
|
|
||||||
)
|
)
|
||||||
answers: list[PollAnswerRequest] = []
|
answers: list[PollAnswerRequest] = []
|
||||||
for answer in payload.answers:
|
for answer in payload.answers:
|
||||||
@@ -1101,6 +1124,68 @@ def submit_scheduling_availability(
|
|||||||
return request
|
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(
|
def require_visible_scheduling_results(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -1170,6 +1255,103 @@ def update_scheduling_request(
|
|||||||
return 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:
|
def open_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest:
|
||||||
request = _lock_scheduling_request(
|
request = _lock_scheduling_request(
|
||||||
session,
|
session,
|
||||||
@@ -1303,7 +1485,7 @@ def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: s
|
|||||||
try:
|
try:
|
||||||
provider = _poll_provider()
|
provider = _poll_provider()
|
||||||
poll = provider.get_poll(session, tenant_id=tenant_id, poll_id=request.poll_id)
|
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)
|
provider.close_poll(session, tenant_id=tenant_id, poll_id=request.poll_id)
|
||||||
except PollCapabilityError as exc:
|
except PollCapabilityError as exc:
|
||||||
raise SchedulingError(str(exc)) from exc
|
raise SchedulingError(str(exc)) from exc
|
||||||
|
|||||||
254
tests/test_response_editing.py
Normal file
254
tests/test_response_editing.py
Normal file
@@ -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()
|
||||||
@@ -19,7 +19,7 @@ from govoplan_core.core.registry import PlatformRegistry
|
|||||||
from govoplan_access.backend.db.models import Account, User
|
from govoplan_access.backend.db.models import Account, User
|
||||||
from govoplan_calendar.backend.db.models import CalendarCollection, CalendarEvent, CalendarOutboxOperation, CalendarSyncSource
|
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_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.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.router import api_get_poll, api_list_polls, api_submit_poll_response
|
||||||
from govoplan_poll.backend.schemas import PollAnswerInput, PollSubmitResponseRequest
|
from govoplan_poll.backend.schemas import PollAnswerInput, PollSubmitResponseRequest
|
||||||
@@ -86,6 +86,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
PollOption.__table__,
|
PollOption.__table__,
|
||||||
PollResponse.__table__,
|
PollResponse.__table__,
|
||||||
PollInvitation.__table__,
|
PollInvitation.__table__,
|
||||||
|
PollLifecycleTransition.__table__,
|
||||||
CalendarCollection.__table__,
|
CalendarCollection.__table__,
|
||||||
CalendarEvent.__table__,
|
CalendarEvent.__table__,
|
||||||
CalendarSyncSource.__table__,
|
CalendarSyncSource.__table__,
|
||||||
@@ -119,6 +120,7 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
CalendarEvent.__table__,
|
CalendarEvent.__table__,
|
||||||
CalendarCollection.__table__,
|
CalendarCollection.__table__,
|
||||||
PollInvitation.__table__,
|
PollInvitation.__table__,
|
||||||
|
PollLifecycleTransition.__table__,
|
||||||
PollResponse.__table__,
|
PollResponse.__table__,
|
||||||
PollOption.__table__,
|
PollOption.__table__,
|
||||||
Poll.__table__,
|
Poll.__table__,
|
||||||
|
|||||||
Reference in New Issue
Block a user