Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/slotify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
to_utc,
)

__version__ = "0.5.0"
__version__ = "0.5.1"
__all__ = [
"SlotGenerator",
"Slot",
Expand Down
18 changes: 17 additions & 1 deletion src/slotify/availability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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):
Expand Down
63 changes: 63 additions & 0 deletions tests/test_final_audit.py
Original file line number Diff line number Diff line change
@@ -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)
Loading