codex-services 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,318 @@
1
+ """
2
+ codex_services.booking._shared.exceptions
3
+ ==========================================
4
+ Custom exceptions for the booking engine.
5
+
6
+ Hierarchy:
7
+ BookingEngineError (base)
8
+ ├── NoAvailabilityError — no free slots for the request
9
+ ├── InvalidServiceDurationError — incorrect service duration
10
+ ├── InvalidBookingDateError — incorrect/invalid date
11
+ ├── ResourceNotAvailableError — specific resource is not available
12
+ ├── SlotAlreadyBookedError — race condition: slot was booked by another user
13
+ └── ChainBuildError — chain was partially assembled but not completed
14
+
15
+ Usage in the service layer:
16
+ from codex_services.booking._shared.exceptions import NoAvailabilityError
17
+
18
+ try:
19
+ result = finder.find(request, availability)
20
+ if not result.has_solutions:
21
+ raise NoAvailabilityError(
22
+ date=request.booking_date,
23
+ service_ids=[s.service_id for s in request.service_requests],
24
+ )
25
+ except NoAvailabilityError as e:
26
+ # Django view will catch and return a user-friendly message
27
+ return render(request, "booking/no_slots.html", {"error": str(e)})
28
+
29
+ Imports:
30
+ from codex_services.booking._shared.exceptions import (
31
+ BookingEngineError,
32
+ NoAvailabilityError,
33
+ InvalidServiceDurationError,
34
+ InvalidBookingDateError,
35
+ ResourceNotAvailableError,
36
+ )
37
+ """
38
+
39
+ from datetime import date
40
+
41
+
42
+ class BookingEngineError(Exception):
43
+ """
44
+ Base exception of the booking engine.
45
+
46
+ All other exceptions inherit from it.
47
+ A Django view can catch BookingEngineError for unified handling
48
+ of all engine errors.
49
+
50
+ Example:
51
+ try:
52
+ result = service.book(...)
53
+ except BookingEngineError as e:
54
+ messages.error(request, str(e))
55
+ return redirect("booking:wizard")
56
+ """
57
+
58
+ default_message: str = "Booking system error"
59
+
60
+ def __init__(self, message: str | None = None) -> None:
61
+ super().__init__(message or self.default_message)
62
+
63
+
64
+ class NoAvailabilityError(BookingEngineError):
65
+ """
66
+ The engine found no schedule options for the request.
67
+
68
+ Raised when ChainFinder.find() returns an empty EngineResult.
69
+ Translated to a user-friendly message in the Django view.
70
+
71
+ Attributes:
72
+ booking_date: Date searched for.
73
+ service_ids: List of service IDs from the request.
74
+
75
+ Example:
76
+ raise NoAvailabilityError(
77
+ booking_date=date(2024, 5, 10),
78
+ service_ids=["5", "12"],
79
+ )
80
+ # str(e) -> "No free slots on 10.05.2024 for the selected services."
81
+ """
82
+
83
+ default_message = "Unfortunately, there are no available slots for these services on the selected date."
84
+
85
+ def __init__(
86
+ self,
87
+ booking_date: date | None = None,
88
+ service_ids: list[str] | None = None,
89
+ message: str | None = None,
90
+ ) -> None:
91
+ self.booking_date = booking_date
92
+ self.service_ids = service_ids or []
93
+
94
+ if message:
95
+ final_message = message
96
+ elif booking_date:
97
+ date_str = booking_date.strftime("%d.%m.%Y")
98
+ final_message = f"No free slots on {date_str} for the selected services. Please try choosing another date."
99
+ else:
100
+ final_message = self.default_message
101
+
102
+ super().__init__(final_message)
103
+
104
+
105
+ class InvalidServiceDurationError(BookingEngineError):
106
+ """
107
+ Incorrect service duration.
108
+
109
+ Raised if duration_minutes <= 0 or exceeds the explicit maximum.
110
+
111
+ Attributes:
112
+ service_id: ID of the problematic service.
113
+ duration_minutes: The passed duration value.
114
+
115
+ Example:
116
+ raise InvalidServiceDurationError(service_id="5", duration_minutes=0)
117
+ # str(e) -> "Service 5: incorrect duration 0 min."
118
+ """
119
+
120
+ default_message = "Incorrect service duration."
121
+
122
+ def __init__(
123
+ self,
124
+ service_id: str | None = None,
125
+ duration_minutes: int | None = None,
126
+ message: str | None = None,
127
+ ) -> None:
128
+ self.service_id = service_id
129
+ self.duration_minutes = duration_minutes
130
+
131
+ if message:
132
+ final_message = message
133
+ elif service_id is not None and duration_minutes is not None:
134
+ final_message = (
135
+ f"Service {service_id}: incorrect duration {duration_minutes} min. Duration must be greater than 0."
136
+ )
137
+ else:
138
+ final_message = self.default_message
139
+
140
+ super().__init__(final_message)
141
+
142
+
143
+ class InvalidBookingDateError(BookingEngineError):
144
+ """
145
+ Booking date is invalid.
146
+
147
+ Example reasons:
148
+ - Date in the past
149
+ - Date beyond max_advance_days
150
+ - Salon is closed on this day
151
+
152
+ Attributes:
153
+ booking_date: Problematic date.
154
+ reason: Human-readable explanation.
155
+
156
+ Example:
157
+ raise InvalidBookingDateError(
158
+ booking_date=date(2020, 1, 1),
159
+ reason="Date in the past",
160
+ )
161
+ """
162
+
163
+ default_message = "The selected date is unavailable for booking."
164
+
165
+ def __init__(
166
+ self,
167
+ booking_date: date | None = None,
168
+ reason: str | None = None,
169
+ message: str | None = None,
170
+ ) -> None:
171
+ self.booking_date = booking_date
172
+ self.reason = reason
173
+
174
+ if message:
175
+ final_message = message
176
+ elif booking_date and reason:
177
+ date_str = booking_date.strftime("%d.%m.%Y")
178
+ final_message = f"Date {date_str} is unavailable: {reason}."
179
+ elif booking_date:
180
+ date_str = booking_date.strftime("%d.%m.%Y")
181
+ final_message = f"Date {date_str} is unavailable for booking."
182
+ else:
183
+ final_message = self.default_message
184
+
185
+ super().__init__(final_message)
186
+
187
+
188
+ class SlotAlreadyBookedError(BookingEngineError):
189
+ """
190
+ Slot was free during display but became booked by the time of confirmation.
191
+
192
+ Race condition: client A and client B are viewing the same slot simultaneously.
193
+ A clicks "Book" first -> B receives this error.
194
+
195
+ Attributes:
196
+ resource_id: Resource ID.
197
+ service_id: Service ID.
198
+ booking_date: Booking date.
199
+ slot_time: Selected time (string "HH:MM").
200
+
201
+ Example:
202
+ raise SlotAlreadyBookedError(
203
+ resource_id="3",
204
+ service_id="5",
205
+ booking_date=date(2024, 5, 10),
206
+ slot_time="14:00",
207
+ )
208
+ # str(e) -> "Slot 14:00 on 10.05.2024 was booked. Please choose another time."
209
+ """
210
+
211
+ default_message = "The selected slot was booked. Please choose another time."
212
+
213
+ def __init__(
214
+ self,
215
+ resource_id: str | None = None,
216
+ service_id: str | None = None,
217
+ booking_date: date | None = None,
218
+ slot_time: str | None = None,
219
+ message: str | None = None,
220
+ ) -> None:
221
+ self.resource_id = resource_id
222
+ self.service_id = service_id
223
+ self.booking_date = booking_date
224
+ self.slot_time = slot_time
225
+
226
+ if message:
227
+ final_message = message
228
+ elif slot_time and booking_date:
229
+ date_str = booking_date.strftime("%d.%m.%Y")
230
+ final_message = (
231
+ f"Slot {slot_time} on {date_str} was booked while you were processing. Please choose another time."
232
+ )
233
+ else:
234
+ final_message = self.default_message
235
+
236
+ super().__init__(final_message)
237
+
238
+
239
+ class ChainBuildError(BookingEngineError):
240
+ """
241
+ The engine could not assemble a chain for all services in the request.
242
+
243
+ Differs from NoAvailabilityError: here the chain was PARTIALLY assembled,
244
+ but not to the end (constraint violation, incompatible services, etc.).
245
+
246
+ Used when:
247
+ - max_chain_duration_minutes is exceeded
248
+ - Services are incompatible (future: excludes/tags)
249
+ - group_size > available parallel slots
250
+
251
+ Attributes:
252
+ failed_at_index: Index of the service (in service_requests) where assembly failed.
253
+ reason: Technical reason (for logs).
254
+
255
+ Example:
256
+ raise ChainBuildError(
257
+ failed_at_index=2,
258
+ reason="max_chain_duration_minutes=180 exceeded at service 'Coloring'",
259
+ )
260
+ """
261
+
262
+ default_message = "Could not find a schedule for all selected services."
263
+
264
+ def __init__(
265
+ self,
266
+ failed_at_index: int | None = None,
267
+ reason: str | None = None,
268
+ message: str | None = None,
269
+ ) -> None:
270
+ self.failed_at_index = failed_at_index
271
+ self.reason = reason
272
+
273
+ if message:
274
+ final_message = message
275
+ elif reason:
276
+ final_message = f"Could not assemble the chain: {reason}."
277
+ else:
278
+ final_message = self.default_message
279
+
280
+ super().__init__(final_message)
281
+
282
+
283
+ class ResourceNotAvailableError(BookingEngineError):
284
+ """
285
+ Specific resource is not available for booking.
286
+
287
+ Used in RESOURCE_LOCKED mode when the selected resource
288
+ does not work on this day or their schedule is empty.
289
+
290
+ Attributes:
291
+ resource_id: Resource's ID.
292
+ booking_date: Date attempted to book.
293
+
294
+ Example:
295
+ raise ResourceNotAvailableError(resource_id="3", booking_date=date(2024,5,10))
296
+ # str(e) -> "Resource unavailable on 10.05.2024."
297
+ """
298
+
299
+ default_message = "The selected resource is unavailable on this date."
300
+
301
+ def __init__(
302
+ self,
303
+ resource_id: str | None = None,
304
+ booking_date: date | None = None,
305
+ message: str | None = None,
306
+ ) -> None:
307
+ self.resource_id = resource_id
308
+ self.booking_date = booking_date
309
+
310
+ if message:
311
+ final_message = message
312
+ elif booking_date:
313
+ date_str = booking_date.strftime("%d.%m.%Y")
314
+ final_message = f"Resource unavailable on {date_str}. Please try another date."
315
+ else:
316
+ final_message = self.default_message
317
+
318
+ super().__init__(final_message)
@@ -0,0 +1,66 @@
1
+ """
2
+ codex_services.booking._shared.interfaces
3
+ ==========================================
4
+ Protocol contracts for booking-specific adapters.
5
+ Implement these protocols when building a new adapter for the booking engine.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import date, datetime, time
11
+ from typing import TYPE_CHECKING, Protocol
12
+
13
+ if TYPE_CHECKING:
14
+ from codex_services.booking._shared.dto import ResourceAvailability
15
+
16
+
17
+ class ScheduleProvider(Protocol):
18
+ """Provides working schedules for resources."""
19
+
20
+ def get_working_hours(self, resource_id: str, target_date: date) -> tuple[time, time] | None:
21
+ """Return (start, end) of working day or None if day off."""
22
+ ...
23
+
24
+ def get_break_interval(self, resource_id: str, target_date: date) -> tuple[datetime, datetime] | None:
25
+ """Return (start, end) of break or None."""
26
+ ...
27
+
28
+
29
+ class BusySlotsProvider(Protocol):
30
+ """Provides busy time slots for resources."""
31
+
32
+ def get_busy_intervals(
33
+ self, resource_ids: list[str], target_date: date
34
+ ) -> dict[str, list[tuple[datetime, datetime]]]:
35
+ """Return {resource_id: [(start, end), ...]} of busy times."""
36
+ ...
37
+
38
+
39
+ class AvailabilityProvider(Protocol):
40
+ """
41
+ Full availability provider — the main adapter contract.
42
+ Implement this protocol for each framework adapter (Django, SQLAlchemy, etc.).
43
+ """
44
+
45
+ def build_resources_availability(
46
+ self,
47
+ resource_ids: list[str],
48
+ target_date: date,
49
+ cache_ttl: int = 300,
50
+ exclude_appointment_ids: list[int] | None = None,
51
+ ) -> dict[str, ResourceAvailability]:
52
+ """Build availability for a single date. Return {resource_id: ResourceAvailability}."""
53
+ ...
54
+
55
+ def build_availability_batch(
56
+ self,
57
+ resource_ids: list[str],
58
+ start_date: date,
59
+ end_date: date,
60
+ ) -> dict[date, dict[str, ResourceAvailability]]:
61
+ """
62
+ Build availability for a date range in a single batch.
63
+ Must avoid N+1 queries — group appointments in memory.
64
+ Return {date: {resource_id: ResourceAvailability}}.
65
+ """
66
+ ...
@@ -0,0 +1,132 @@
1
+ """
2
+ codex_services.booking._shared.validators
3
+ ==========================================
4
+ Validators for ensuring booking data correctness.
5
+
6
+ Used by ChainFinder to verify found solutions,
7
+ and can also be used independently in tests or the service layer.
8
+
9
+ Imports:
10
+ from codex_services.booking import BookingValidator
11
+ """
12
+
13
+ from datetime import datetime
14
+
15
+ from codex_services.booking._shared.dto import BookingSolution
16
+
17
+
18
+ class BookingValidator:
19
+ """
20
+ A set of correctness checks for booking data.
21
+ Unaffected by the ORM — operates exclusively on DTOs.
22
+
23
+ Used by:
24
+ - ChainFinder: Ensuring found chains have no conflicts.
25
+ - Adapter: Final verification before creating Appointment instances in the DB.
26
+ - Tests: Isolated logic verification without Django.
27
+
28
+ Example:
29
+ v = BookingValidator()
30
+
31
+ # Check if a slot is free:
32
+ ok = v.is_slot_free(
33
+ slot_start=datetime(2024,5,10,10,0),
34
+ slot_end=datetime(2024,5,10,11,0),
35
+ busy_intervals=[(datetime(2024,5,10,9,0), datetime(2024,5,10,9,30))],
36
+ )
37
+ # → True (no overlap)
38
+
39
+ # Check entire chain for conflicts:
40
+ ok = v.no_conflicts(solutions)
41
+ """
42
+
43
+ def is_slot_free(
44
+ self,
45
+ slot_start: datetime,
46
+ slot_end: datetime,
47
+ busy_intervals: list[tuple[datetime, datetime]],
48
+ ) -> bool:
49
+ """
50
+ Verifies that the slot [slot_start, slot_end) does not overlap
51
+ with any of the busy intervals.
52
+
53
+ Uses a "half-open" interval — [start, end). If slot_end == busy_start,
54
+ it is NOT considered a conflict (adjacent slots are allowed).
55
+
56
+ Args:
57
+ slot_start: Start of the slot to check.
58
+ slot_end: End of the slot to check.
59
+ busy_intervals: List of busy intervals [(start, end), ...].
60
+
61
+ Returns:
62
+ True if the slot is free. False if there is an overlap.
63
+
64
+ Example:
65
+ # Busy 10:00-11:00. Requesting 10:30-11:30 → conflict:
66
+ is_slot_free(10:30, 11:30, [(10:00, 11:00)]) → False
67
+
68
+ # Requesting 11:00-12:00 → OK (adjacent slots):
69
+ is_slot_free(11:00, 12:00, [(10:00, 11:00)]) → True
70
+ """
71
+ return all(not (slot_start < busy_end and slot_end > busy_start) for busy_start, busy_end in busy_intervals)
72
+
73
+ def no_conflicts(
74
+ self,
75
+ solutions: list[BookingSolution],
76
+ ) -> bool:
77
+ """
78
+ Verifies that there are no resource conflicts within a set of solutions.
79
+ A resource cannot be occupied by two services simultaneously.
80
+
81
+ Groups solutions by resource_id and checks each group for overlaps.
82
+ Used by ChainFinder after assembling the chain for final verification.
83
+
84
+ Args:
85
+ solutions: List of BookingSolution objects (found slots).
86
+
87
+ Returns:
88
+ True if no conflicts exist. False if at least one resource is double-booked.
89
+
90
+ Example:
91
+ no_conflicts([
92
+ SingleServiceSolution(resource_id="1", start=9:00, gap_end=10:10),
93
+ SingleServiceSolution(resource_id="1", start=10:10, gap_end=11:10),
94
+ ])
95
+ # → True (slots are adjacent, no overlap)
96
+ """
97
+ by_resource: dict[str, list[BookingSolution]] = {}
98
+ for sol in solutions:
99
+ by_resource.setdefault(sol.resource_id, []).append(sol)
100
+
101
+ for _resource_id, resource_solutions in by_resource.items():
102
+ if len(resource_solutions) < 2:
103
+ continue
104
+ sorted_sols = sorted(resource_solutions, key=lambda s: s.start_time)
105
+ for i in range(len(sorted_sols) - 1):
106
+ current = sorted_sols[i]
107
+ next_sol = sorted_sols[i + 1]
108
+ # For solutions with gap_end_time, use it; otherwise use end_time
109
+ block_end = getattr(current, "gap_end_time", current.end_time)
110
+ if next_sol.start_time < block_end:
111
+ return False
112
+
113
+ return True
114
+
115
+ def solution_fits_in_windows(
116
+ self,
117
+ solution: BookingSolution,
118
+ free_windows: list[tuple[datetime, datetime]],
119
+ ) -> bool:
120
+ """
121
+ Verifies that a solution's slot fits entirely inside one of the
122
+ resource's free windows.
123
+
124
+ Args:
125
+ solution: Found slot for a single service.
126
+ free_windows: Resource's free windows (from ResourceAvailability).
127
+
128
+ Returns:
129
+ True if the slot fits perfectly inside one of the given free windows.
130
+ """
131
+ block_end = getattr(solution, "gap_end_time", solution.end_time)
132
+ return any(solution.start_time >= w_start and block_end <= w_end for w_start, w_end in free_windows)
@@ -0,0 +1,7 @@
1
+ """codex_services.booking.room: Room booking (1 booking per day).
2
+
3
+ TODO: Implement room booking type with:
4
+ - RoomBookingRequest (inherits BookingRequest)
5
+ - RoomAvailability (inherits ResourceAvailability)
6
+ - RoomBookingResult (inherits BookingResult)
7
+ """
@@ -0,0 +1,58 @@
1
+ """Booking engine — chain service scheduling with backtracking."""
2
+
3
+ from codex_services.booking._shared.exceptions import (
4
+ BookingEngineError,
5
+ ChainBuildError,
6
+ InvalidBookingDateError,
7
+ InvalidServiceDurationError,
8
+ NoAvailabilityError,
9
+ ResourceNotAvailableError,
10
+ SlotAlreadyBookedError,
11
+ )
12
+ from codex_services.booking._shared.interfaces import AvailabilityProvider, BusySlotsProvider, ScheduleProvider
13
+
14
+ from .api import find_nearest_slots, find_slots
15
+ from .chain_finder import ChainFinder
16
+ from .dto import (
17
+ BookingChainSolution,
18
+ BookingEngineRequest,
19
+ EngineResult,
20
+ MasterAvailability,
21
+ ServiceRequest,
22
+ SingleServiceSolution,
23
+ WaitlistEntry,
24
+ )
25
+ from .modes import BookingMode
26
+ from .scorer import BookingScorer, ScoringWeights
27
+
28
+ __all__ = [
29
+ # High-level facade (dict-based API)
30
+ "find_slots",
31
+ "find_nearest_slots",
32
+ # Engine
33
+ "ChainFinder",
34
+ "BookingScorer",
35
+ "ScoringWeights",
36
+ # Interfaces
37
+ "AvailabilityProvider",
38
+ "BusySlotsProvider",
39
+ "ScheduleProvider",
40
+ # Request / availability DTOs
41
+ "BookingEngineRequest",
42
+ "ServiceRequest",
43
+ "MasterAvailability",
44
+ "BookingMode",
45
+ # Result DTOs
46
+ "EngineResult",
47
+ "BookingChainSolution",
48
+ "SingleServiceSolution",
49
+ "WaitlistEntry",
50
+ # Exceptions
51
+ "BookingEngineError",
52
+ "NoAvailabilityError",
53
+ "InvalidServiceDurationError",
54
+ "InvalidBookingDateError",
55
+ "ResourceNotAvailableError",
56
+ "SlotAlreadyBookedError",
57
+ "ChainBuildError",
58
+ ]