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.
- codex_services/__init__.py +1 -0
- codex_services/booking/__init__.py +37 -0
- codex_services/booking/_shared/__init__.py +1 -0
- codex_services/booking/_shared/calculator.py +330 -0
- codex_services/booking/_shared/dto.py +82 -0
- codex_services/booking/_shared/exceptions.py +318 -0
- codex_services/booking/_shared/interfaces.py +66 -0
- codex_services/booking/_shared/validators.py +132 -0
- codex_services/booking/room/__init__.py +7 -0
- codex_services/booking/slot_master/__init__.py +58 -0
- codex_services/booking/slot_master/api.py +171 -0
- codex_services/booking/slot_master/chain_finder.py +450 -0
- codex_services/booking/slot_master/dto.py +446 -0
- codex_services/booking/slot_master/modes.py +44 -0
- codex_services/booking/slot_master/scorer.py +168 -0
- codex_services/calendar/__init__.py +5 -0
- codex_services/calendar/engine.py +101 -0
- codex_services/py.typed +0 -0
- codex_services-0.1.0.dist-info/METADATA +113 -0
- codex_services-0.1.0.dist-info/RECORD +21 -0
- codex_services-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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")
|