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 @@
1
+ """codex-services: Reusable business logic engines for Codex platform."""
@@ -0,0 +1,37 @@
1
+ """codex_services.booking: Unified booking engine library.
2
+
3
+ Provides different booking strategies (slot_master, room, etc.)
4
+ with shared utilities and base DTOs.
5
+ """
6
+
7
+ from codex_services.booking._shared.calculator import SlotCalculator
8
+ from codex_services.booking._shared.dto import (
9
+ BookingRequest,
10
+ BookingResult,
11
+ BookingSolution,
12
+ ResourceAvailability,
13
+ )
14
+ from codex_services.booking._shared.exceptions import BookingEngineError
15
+ from codex_services.booking._shared.interfaces import (
16
+ AvailabilityProvider,
17
+ BusySlotsProvider,
18
+ ScheduleProvider,
19
+ )
20
+ from codex_services.booking._shared.validators import BookingValidator
21
+
22
+ __all__ = [
23
+ # Base DTOs
24
+ "ResourceAvailability",
25
+ "BookingRequest",
26
+ "BookingSolution",
27
+ "BookingResult",
28
+ # Shared utilities
29
+ "SlotCalculator",
30
+ "BookingValidator",
31
+ # Interfaces
32
+ "AvailabilityProvider",
33
+ "BusySlotsProvider",
34
+ "ScheduleProvider",
35
+ # Base exception
36
+ "BookingEngineError",
37
+ ]
@@ -0,0 +1 @@
1
+ """Shared utilities and base DTOs for all booking types."""
@@ -0,0 +1,330 @@
1
+ """
2
+ codex_services.booking._shared.calculator
3
+ ==========================================
4
+ Basic slot operations inside time windows.
5
+
6
+ This is "math without ORM" — works with datetime objects, does not know about Django.
7
+ Used inside ChainFinder, and can also be applied independently.
8
+
9
+ Imports:
10
+ from codex_services.booking import SlotCalculator
11
+ """
12
+
13
+ from datetime import datetime, timedelta
14
+
15
+
16
+ class SlotCalculator:
17
+ """
18
+ Slot generator inside a free time window (sliding window).
19
+
20
+ Does not depend on ORM. Works with datetime objects.
21
+
22
+ Examples:
23
+ ```python
24
+ calc = SlotCalculator(step_minutes=30)
25
+
26
+ # Get all possible slots in a window from 9:00 to 12:00 for a 60 min service:
27
+ slots = calc.find_slots_in_window(
28
+ window_start=datetime(2024, 5, 10, 9, 0),
29
+ window_end=datetime(2024, 5, 10, 12, 0),
30
+ duration_minutes=60,
31
+ )
32
+ # -> [datetime(2024,5,10,9,0), datetime(2024,5,10,9,30), ...]
33
+ ```
34
+
35
+ ```python
36
+ # Get free windows from a working day:
37
+ windows = calc.merge_free_windows(
38
+ work_start=datetime(2024,5,10,9,0),
39
+ work_end=datetime(2024,5,10,18,0),
40
+ busy_intervals=[(datetime(2024,5,10,10,0), datetime(2024,5,10,11,0))],
41
+ )
42
+ # -> [(9:00, 10:00), (11:10, 18:00)]
43
+ ```
44
+ """
45
+
46
+ def __init__(self, step_minutes: int = 30) -> None:
47
+ """
48
+ Args:
49
+ step_minutes: Grid slot step in minutes. Defaults to 30.
50
+ Determines the interval at which slots are offered.
51
+ """
52
+ if step_minutes <= 0:
53
+ raise ValueError(f"step_minutes должен быть > 0, получен: {step_minutes}")
54
+ self.step_minutes = step_minutes
55
+ self._step_delta = timedelta(minutes=step_minutes)
56
+
57
+ def find_slots_in_window(
58
+ self,
59
+ window_start: datetime,
60
+ window_end: datetime,
61
+ duration_minutes: int,
62
+ min_start: datetime | None = None,
63
+ grid_anchor: datetime | None = None,
64
+ ) -> list[datetime]:
65
+ """
66
+ Returns a list of possible start times within the window.
67
+
68
+ Algorithm: sliding window with a step of self.step_minutes.
69
+ Each candidate is checked: does the service [slot, slot+duration]
70
+ fit within the end of the window.
71
+
72
+ Args:
73
+ window_start: Start of the free time window.
74
+ window_end: End of the free time window.
75
+ duration_minutes: Duration of the service in minutes.
76
+ min_start: Minimum acceptable start time (e.g., "no earlier than
77
+ 15 minutes from the current time"). None = no limit.
78
+ grid_anchor: Anchor for grid alignment (e.g., shifts start).
79
+ If provided, slots will be aligned to (grid_anchor + N * step).
80
+
81
+ Returns:
82
+ List of datetime — possible service start moments.
83
+ Empty list if the service does not fit in the window.
84
+
85
+ Example:
86
+ # Window 9:00-11:00, service 60 min, step 30 min:
87
+ # -> [9:00, 9:30, 10:00] (10:30 + 60 min = 11:30, does not fit)
88
+ """
89
+ duration_delta = timedelta(minutes=duration_minutes)
90
+
91
+ # Быстрая проверка: окно в принципе достаточно для услуги
92
+ if window_end - window_start < duration_delta:
93
+ return []
94
+
95
+ slots: list[datetime] = []
96
+ current = window_start
97
+
98
+ # Если есть min_start — двигаем указатель к ближайшему шагу сетки
99
+ if min_start and current < min_start:
100
+ current = min_start
101
+
102
+ # Если задан якорь — выравниваем текущую позицию по сетке относительно якоря
103
+ # Если якоря нет — по умолчанию выравниваем по началу окна (обратная совместимость)
104
+ anchor = grid_anchor or window_start
105
+ current = self._align_to_grid(current, anchor)
106
+
107
+ while current + duration_delta <= window_end:
108
+ slots.append(current)
109
+ current += self._step_delta
110
+
111
+ return slots
112
+
113
+ def merge_free_windows(
114
+ self,
115
+ work_start: datetime,
116
+ work_end: datetime,
117
+ busy_intervals: list[tuple[datetime, datetime]],
118
+ break_interval: tuple[datetime, datetime] | None = None,
119
+ buffer_minutes: int = 0,
120
+ min_duration_minutes: int = 0,
121
+ ) -> list[tuple[datetime, datetime]]:
122
+ """
123
+ Calculates a list of free windows from a working day.
124
+
125
+ Subtracts from the interval [work_start, work_end]:
126
+ - busy_intervals (booked appointments)
127
+ - break_interval (resource's break)
128
+ - buffer_minutes (buffer after each booked interval)
129
+
130
+ Args:
131
+ work_start: Start of the resource's working day.
132
+ work_end: End of the resource's working day.
133
+ busy_intervals: List of busy segments [(start, end), ...].
134
+ break_interval: Resource's break (lunch), tuple (start, end) or None.
135
+ buffer_minutes: Buffer in minutes added after each booked appointment.
136
+ min_duration_minutes: Minimum window length. Windows shorter than this value
137
+ are discarded as "junk".
138
+
139
+ Returns:
140
+ List of free windows [(start, end), ...] sorted by time.
141
+
142
+ Example:
143
+ ```python
144
+ # Work day 9:00-18:00, booked 10:00-11:00, lunch 13:00-14:00
145
+ windows = calc.merge_free_windows(
146
+ work_start=dt(9,0), work_end=dt(18,0),
147
+ busy_intervals=[(dt(10,0), dt(11,0))],
148
+ break_interval=(dt(13,0), dt(14,0)),
149
+ buffer_minutes=10,
150
+ )
151
+ # -> [(9:00, 10:00), (11:10, 13:00), (14:00, 18:00)]
152
+ ```
153
+ """
154
+ buffer_delta = timedelta(minutes=buffer_minutes)
155
+
156
+ # Collect all "busy" intervals: appointments + break
157
+ blocked: list[tuple[datetime, datetime]] = []
158
+ for b_start, b_end in busy_intervals:
159
+ # Apply buffer after the booked appointment
160
+ blocked.append((b_start, b_end + buffer_delta))
161
+
162
+ if break_interval:
163
+ blocked.append(break_interval)
164
+
165
+ # Sort and merge overlapping intervals
166
+ blocked = self._merge_intervals(blocked)
167
+
168
+ # Calculate free windows
169
+ free_windows: list[tuple[datetime, datetime]] = []
170
+ current_ptr = work_start
171
+
172
+ for b_start, b_end in blocked:
173
+ # Cut off segments outside the working day
174
+ b_start = max(b_start, work_start)
175
+ b_end = min(b_end, work_end)
176
+
177
+ if b_start > current_ptr:
178
+ # Junk window filter
179
+ if min_duration_minutes > 0:
180
+ duration = (b_start - current_ptr).total_seconds() / 60
181
+ if duration < min_duration_minutes:
182
+ current_ptr = max(current_ptr, b_end)
183
+ continue
184
+
185
+ free_windows.append((current_ptr, b_start))
186
+
187
+ current_ptr = max(current_ptr, b_end)
188
+
189
+ # Remaining time after the last busy block
190
+ if current_ptr < work_end:
191
+ if min_duration_minutes > 0:
192
+ duration = (work_end - current_ptr).total_seconds() / 60
193
+ if duration >= min_duration_minutes:
194
+ free_windows.append((current_ptr, work_end))
195
+ else:
196
+ free_windows.append((current_ptr, work_end))
197
+
198
+ return free_windows
199
+
200
+ def find_gaps(
201
+ self,
202
+ free_windows: list[tuple[datetime, datetime]],
203
+ min_gap_minutes: int,
204
+ ) -> list[tuple[datetime, datetime, int]]:
205
+ """
206
+ Finds all free windows of minimum length in the resource's schedule.
207
+
208
+ Used for:
209
+ - Load analysis: how much free time the resource has
210
+ - Notifications: resource is free for N minutes -> offer to user
211
+
212
+ Args:
213
+ free_windows: Free windows from ResourceAvailability.free_windows
214
+ min_gap_minutes: Minimum window length in minutes.
215
+
216
+ Returns:
217
+ List (window_start, window_end, duration_minutes) — windows
218
+ longer than min_gap_minutes, sorted by start time.
219
+
220
+ Examples:
221
+ ```python
222
+ # Search for windows >= 60 minutes:
223
+ gaps = calc.find_gaps(free_windows, min_gap_minutes=60)
224
+ # -> [(9:00, 10:00, 60), (11:30, 14:00, 150), (16:00, 18:00, 120)]
225
+ ```
226
+ """
227
+ result: list[tuple[datetime, datetime, int]] = []
228
+
229
+ for w_start, w_end in free_windows:
230
+ duration = int((w_end - w_start).total_seconds() / 60)
231
+ if duration >= min_gap_minutes:
232
+ result.append((w_start, w_end, duration))
233
+
234
+ result.sort(key=lambda x: x[0])
235
+ return result
236
+
237
+ def split_window_by_service(
238
+ self,
239
+ window_start: datetime,
240
+ window_end: datetime,
241
+ service_start: datetime,
242
+ service_end: datetime,
243
+ ) -> list[tuple[datetime, datetime]]:
244
+ """
245
+ Splits a free window into parts around a booked service segment.
246
+
247
+ Used for dynamic calculation: "if we put a service here —
248
+ what windows will remain free for the next ones?"
249
+
250
+ Args:
251
+ window_start: Start of the free window.
252
+ window_end: End of the free window.
253
+ service_start: Start of the booked segment (must be inside the window).
254
+ service_end: End of the booked segment (must be inside the window).
255
+
256
+ Returns:
257
+ List of remaining free windows (0, 1, or 2 elements).
258
+
259
+ Example:
260
+ # Window 9:00-18:00, service 11:00-12:00:
261
+ split_window_by_service(9:00, 18:00, 11:00, 12:00)
262
+ # -> [(9:00, 11:00), (12:00, 18:00)]
263
+
264
+ # Service at the start of the window:
265
+ split_window_by_service(9:00, 18:00, 9:00, 11:00)
266
+ # -> [(11:00, 18:00)]
267
+ """
268
+ remaining: list[tuple[datetime, datetime]] = []
269
+
270
+ # Part before the service
271
+ if service_start > window_start:
272
+ remaining.append((window_start, service_start))
273
+
274
+ # Part after the service
275
+ if service_end < window_end:
276
+ remaining.append((service_end, window_end))
277
+
278
+ return remaining
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Internal helper methods
282
+ # ---------------------------------------------------------------------------
283
+
284
+ def _merge_intervals(self, intervals: list[tuple[datetime, datetime]]) -> list[tuple[datetime, datetime]]:
285
+ """
286
+ Merges overlapping or adjacent intervals.
287
+ Returns a sorted list of non-overlapping intervals.
288
+
289
+ Internal method. Used in merge_free_windows.
290
+ """
291
+ if not intervals:
292
+ return []
293
+
294
+ sorted_intervals = sorted(intervals, key=lambda x: x[0])
295
+ merged: list[tuple[datetime, datetime]] = [sorted_intervals[0]]
296
+
297
+ for start, end in sorted_intervals[1:]:
298
+ last_start, last_end = merged[-1]
299
+ if start <= last_end:
300
+ # Overlap — expand current block
301
+ merged[-1] = (last_start, max(last_end, end))
302
+ else:
303
+ merged.append((start, end))
304
+
305
+ return merged
306
+
307
+ def _align_to_grid(self, target: datetime, grid_origin: datetime) -> datetime:
308
+ """
309
+ Aligns target to the nearest grid step relative to grid_origin.
310
+ Returns the first grid moment >= target.
311
+
312
+ Internal method. Used to align min_start to the grid.
313
+
314
+ Example (step 30 min, origin 9:00, target 9:17):
315
+ -> 9:30 (nearest step >= 9:17)
316
+ """
317
+ delta_seconds = (target - grid_origin).total_seconds()
318
+ step_seconds = self._step_delta.total_seconds()
319
+
320
+ if delta_seconds <= 0:
321
+ return grid_origin
322
+
323
+ # Number of full steps
324
+ full_steps = int(delta_seconds / step_seconds)
325
+ aligned = grid_origin + timedelta(seconds=full_steps * step_seconds)
326
+
327
+ if aligned < target:
328
+ aligned += self._step_delta
329
+
330
+ return aligned
@@ -0,0 +1,82 @@
1
+ """
2
+ codex_services.booking._shared.dto
3
+ ===================================
4
+ Base DTOs shared across all booking types.
5
+
6
+ Each booking type (slot_master, room, etc.) inherits from these
7
+ and adds domain-specific fields.
8
+ """
9
+
10
+ from datetime import date, datetime
11
+
12
+ from codex_core.core.base_dto import BaseDTO
13
+ from pydantic import Field, model_validator
14
+
15
+
16
+ class ResourceAvailability(BaseDTO):
17
+ """
18
+ Base availability of a resource (executor, room, equipment, etc.).
19
+
20
+ Fields:
21
+ resource_id (str): Unique identifier of the resource.
22
+ free_windows (list[tuple[datetime, datetime]]): Available time windows.
23
+ Each tuple is (start, end) where start < end.
24
+ """
25
+
26
+ resource_id: str
27
+ free_windows: list[tuple[datetime, datetime]] = Field(default_factory=list)
28
+
29
+ @model_validator(mode="after")
30
+ def validate_windows_order(self) -> "ResourceAvailability":
31
+ """Validate that each free window is chronologically correct."""
32
+ for start, end in self.free_windows:
33
+ if start >= end:
34
+ raise ValueError(f"Resource {self.resource_id}: start={start} >= end={end}")
35
+ return self
36
+
37
+ def __repr__(self) -> str:
38
+ return f"<ResourceAvailability resource={self.resource_id} windows={len(self.free_windows)}>"
39
+
40
+
41
+ class BookingRequest(BaseDTO):
42
+ """
43
+ Base booking request.
44
+
45
+ Fields:
46
+ booking_date (date): Target date for the booking.
47
+ """
48
+
49
+ booking_date: date
50
+
51
+
52
+ class BookingSolution(BaseDTO):
53
+ """
54
+ Base result — one booked slot.
55
+
56
+ Fields:
57
+ resource_id (str): Assigned resource identifier.
58
+ start_time (datetime): Scheduled start time.
59
+ end_time (datetime): Scheduled end time.
60
+ """
61
+
62
+ resource_id: str
63
+ start_time: datetime
64
+ end_time: datetime
65
+
66
+ @property
67
+ def duration_minutes(self) -> int:
68
+ """Calculate actual duration in minutes."""
69
+ return int((self.end_time - self.start_time).total_seconds() / 60)
70
+
71
+
72
+ class BookingResult(BaseDTO):
73
+ """
74
+ Base result — list of solutions.
75
+
76
+ Concrete booking types override the `solutions` field with a specific type.
77
+ """
78
+
79
+ @property
80
+ def has_solutions(self) -> bool:
81
+ """Return True if at least one solution was found."""
82
+ raise NotImplementedError("Subclasses must implement has_solutions")