From bb7cea03df73d9feedb07a886d5ad8004c41a781 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 21 Jul 2026 12:59:25 +0200 Subject: [PATCH] refactor(scheduling): simplify candidate slot updates --- src/govoplan_scheduling/backend/service.py | 193 ++++++++++++++------- 1 file changed, 131 insertions(+), 62 deletions(-) diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index 86e76fd..0ed4c19 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -2,8 +2,9 @@ from __future__ import annotations import hashlib import json +from dataclasses import dataclass from datetime import datetime, timezone -from typing import Any +from typing import Any, cast from sqlalchemy.orm import Session, object_session @@ -1416,6 +1417,121 @@ def update_scheduling_request( return request +@dataclass(frozen=True, slots=True) +class _CandidateSlotChanges: + label: str + description: str | None + start_at: datetime + end_at: datetime + timezone: str + location: str | None + metadata: dict[str, Any] + normalized_start: datetime + normalized_end: datetime + + +def _candidate_slot_changes( + slot: SchedulingCandidateSlot, + payload: SchedulingCandidateSlotUpdateRequest, +) -> _CandidateSlotChanges: + 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 = cast(str, payload.label if "label" in supplied else slot.label) + description = payload.description if "description" in supplied else slot.description + start_at = cast(datetime, payload.start_at if "start_at" in supplied else slot.start_at) + end_at = cast(datetime, payload.end_at if "end_at" in supplied else slot.end_at) + timezone_name = cast(str, 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 = cast(datetime, response_datetime(start_at)) + normalized_end = cast(datetime, response_datetime(end_at)) + if normalized_end <= normalized_start: + raise SchedulingError("end_at must be after start_at") + return _CandidateSlotChanges( + label=label, + description=description, + start_at=start_at, + end_at=end_at, + timezone=timezone_name, + location=location, + metadata=metadata, + normalized_start=normalized_start, + normalized_end=normalized_end, + ) + + +def _candidate_slot_change_flags( + slot: SchedulingCandidateSlot, + changes: _CandidateSlotChanges, +) -> tuple[bool, bool]: + temporal_or_location_changed = ( + changes.normalized_start != response_datetime(slot.start_at) + or changes.normalized_end != response_datetime(slot.end_at) + or changes.timezone != slot.timezone + or changes.location != slot.location + ) + changed = ( + changes.label != slot.label + or changes.description != slot.description + or temporal_or_location_changed + or changes.metadata != (slot.metadata_ or {}) + ) + return changed, temporal_or_location_changed + + +def _update_candidate_poll_option( + session: Session, + *, + tenant_id: str, + request: SchedulingRequest, + slot: SchedulingCandidateSlot, + changes: _CandidateSlotChanges, +) -> None: + try: + _poll_provider().update_option( + session, + tenant_id=tenant_id, + poll_id=cast(str, request.poll_id), + option_id=cast(str, slot.poll_option_id), + command=PollOptionUpdateCommand( + label=changes.label, + description=changes.description, + value={ + "slot_id": slot.id, + "start_at": changes.normalized_start.isoformat(), + "end_at": changes.normalized_end.isoformat(), + "timezone": changes.timezone, + "location": changes.location, + }, + metadata=changes.metadata, + ), + ) + except PollCapabilityError as exc: + raise SchedulingError(str(exc)) from exc + + +def _apply_candidate_slot_changes( + slot: SchedulingCandidateSlot, + changes: _CandidateSlotChanges, + *, + temporal_or_location_changed: bool, +) -> None: + slot.label = changes.label + slot.description = changes.description + slot.start_at = changes.start_at + slot.end_at = changes.end_at + slot.timezone = changes.timezone + slot.location = changes.location + slot.metadata_ = changes.metadata + if temporal_or_location_changed: + slot.freebusy_checked_at = None + slot.freebusy_status = None + slot.freebusy_conflicts = [] + + def update_scheduling_candidate_slot( session: Session, *, @@ -1440,35 +1556,8 @@ def update_scheduling_candidate_slot( 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 {}) - ) + changes = _candidate_slot_changes(slot, payload) + changed, temporal_or_location_changed = _candidate_slot_change_flags(slot, changes) if not changed: return request if temporal_or_location_changed and slot.tentative_hold_event_id: @@ -1476,27 +1565,13 @@ def update_scheduling_candidate_slot( "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 + _update_candidate_poll_option( + session, + tenant_id=tenant_id, + request=request, + slot=slot, + changes=changes, + ) refresh_participant_response_state( session, @@ -1504,17 +1579,11 @@ def update_scheduling_candidate_slot( reset_missing=True, ) - 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 = [] + _apply_candidate_slot_changes( + slot, + changes, + temporal_or_location_changed=temporal_or_location_changed, + ) session.flush() return request