feat(responses): preserve editable availability history
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user