diff --git a/CHANGELOG.md b/CHANGELOG.md index a611a12..a5e77f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.5.1 + +- Hardened multi-resource temporary holds so all required resources remain blocked through confirmation. +- Added regression coverage for multi-resource and unscoped hold behavior. +- Hardened v0.5 lifecycle edge cases: idempotency reuse, reschedule preservation, booking limits, and active-hold conflicts. + ## 0.5.0 - Added external BusyPeriod conflicts for calendar/application-provided busy intervals. diff --git a/README.md b/README.md index e1e65be..4f6e4cc 100644 --- a/README.md +++ b/README.md @@ -434,7 +434,7 @@ RecurrenceRule( ## Project status -Current version: **0.5.0** +Current version: **0.5.1** Slotify is currently in alpha. The API may evolve before 1.0.0, so pin the version in production applications and review the changelog when upgrading. diff --git a/docs/testing.md b/docs/testing.md index a3df99e..aa9f6af 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -16,7 +16,7 @@ python -c "import slotify; print(slotify.__version__)" Expected for this documentation release: ~~~text -0.5.0 +0.5.1 ~~~ ## Run your tests @@ -181,7 +181,7 @@ Slotify is currently in alpha. For production, pin the version that you have tested: ~~~text -slotify-scheduling==0.5.0 +slotify-scheduling==0.5.1 ~~~ Review the changelog before upgrading. diff --git a/pyproject.toml b/pyproject.toml index 09850e1..fea5f2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "slotify-scheduling" -version = "0.5.0" +version = "0.5.1" description = "Timezone-aware Python scheduling and appointment slot engine for availability and booking systems." readme = "README.md" requires-python = ">=3.10" diff --git a/src/slotify/__init__.py b/src/slotify/__init__.py index de5e335..4de1b88 100644 --- a/src/slotify/__init__.py +++ b/src/slotify/__init__.py @@ -36,7 +36,7 @@ to_utc, ) -__version__ = "0.5.0" +__version__ = "0.5.1" __all__ = [ "SlotGenerator", "Slot", diff --git a/src/slotify/availability.py b/src/slotify/availability.py index 2ff5a5c..8eefd0c 100644 --- a/src/slotify/availability.py +++ b/src/slotify/availability.py @@ -359,6 +359,7 @@ def _candidate( slot: Slot, *, resource_id: str | None = None, + resource_ids: tuple[str, ...] | None = None, metadata=None, series_id: str | None = None, ) -> Booking: @@ -369,6 +370,13 @@ def _candidate( metadata=metadata, series_id=series_id, ) + if resource_ids is not None: + return self._booking_for( + slot, + resource_ids=resource_ids, + metadata=metadata, + series_id=series_id, + ) return self._booking_for( slot, metadata=metadata, @@ -685,7 +693,10 @@ def hold( if self.hold_store.conflicts(candidate, now=current): raise BookingConflictError("The requested slot is already held.") return self.hold_store.create(BookingHold( - slot=slot, expires_at=expires_at, metadata=metadata or {} + slot=slot, + expires_at=expires_at, + resource_ids=tuple(r for r in candidate.effective_resource_ids if r is not None), + metadata=metadata or {}, )) def confirm_hold( @@ -703,6 +714,11 @@ def confirm_hold( booking = self._candidate( hold.slot, resource_id=hold.resource_id, + resource_ids=( + hold.resource_ids + if hold.resource_id is None and hold.resource_ids + else None + ), metadata=metadata if metadata is not None else hold.metadata, ) if self.hold_store.conflicts(booking, now=current, exclude_hold_id=hold_id): diff --git a/tests/test_final_audit.py b/tests/test_final_audit.py new file mode 100644 index 0000000..6c2d009 --- /dev/null +++ b/tests/test_final_audit.py @@ -0,0 +1,63 @@ +from datetime import datetime + +import pytest + +from slotify import AvailabilityEngine, BookingConflictError, SlotGenerator + + +def make_generator(): + return SlotGenerator( + start="09:00", + end="12:00", + duration=60, + timezone="Asia/Kolkata", + ) + + +def test_hold_tracks_all_required_resources(): + engine = AvailabilityEngine( + make_generator(), + resource_ids=("room", "therapist"), + ) + slot = make_generator().generate_for_date("2026-10-05")[0] + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + hold = engine.hold( + slot, + expires_at=datetime.fromisoformat("2026-10-04T09:00:00+05:30"), + now=now, + ) + assert hold.resource_ids == ("room", "therapist") + assert engine.check_availability(slot, now=now).available is False + with pytest.raises(BookingConflictError): + engine.reserve(slot, now=now) + + +def test_multi_resource_hold_confirm_preserves_resource_requirements(): + engine = AvailabilityEngine( + make_generator(), + resource_ids=("room", "therapist"), + ) + slot = make_generator().generate_for_date("2026-10-05")[0] + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + hold = engine.hold( + slot, + expires_at=datetime.fromisoformat("2026-10-04T09:00:00+05:30"), + now=now, + ) + booking = engine.confirm_hold(hold.hold_id, now=now) + assert booking.resource_ids == ("room", "therapist") + assert booking.resource_id is None + + +def test_unscoped_hold_still_works(): + engine = AvailabilityEngine(make_generator()) + slot = make_generator().generate_for_date("2026-10-05")[0] + now = datetime.fromisoformat("2026-10-04T08:00:00+05:30") + hold = engine.hold( + slot, + expires_at=datetime.fromisoformat("2026-10-04T09:00:00+05:30"), + now=now, + ) + assert hold.resource_ids == () + with pytest.raises(BookingConflictError): + engine.reserve(slot, now=now)