otf-api 0.12.0__py3-none-any.whl → 0.13.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.
- otf_api/__init__.py +35 -3
- otf_api/api/__init__.py +3 -0
- otf_api/api/_compat.py +77 -0
- otf_api/api/api.py +80 -0
- otf_api/api/bookings/__init__.py +3 -0
- otf_api/api/bookings/booking_api.py +541 -0
- otf_api/api/bookings/booking_client.py +112 -0
- otf_api/api/client.py +203 -0
- otf_api/api/members/__init__.py +3 -0
- otf_api/api/members/member_api.py +187 -0
- otf_api/api/members/member_client.py +112 -0
- otf_api/api/studios/__init__.py +3 -0
- otf_api/api/studios/studio_api.py +173 -0
- otf_api/api/studios/studio_client.py +120 -0
- otf_api/api/utils.py +307 -0
- otf_api/api/workouts/__init__.py +3 -0
- otf_api/api/workouts/workout_api.py +333 -0
- otf_api/api/workouts/workout_client.py +140 -0
- otf_api/auth/__init__.py +1 -1
- otf_api/auth/auth.py +155 -89
- otf_api/auth/user.py +5 -17
- otf_api/auth/utils.py +27 -2
- otf_api/cache.py +132 -0
- otf_api/exceptions.py +18 -6
- otf_api/models/__init__.py +25 -21
- otf_api/models/bookings/__init__.py +23 -0
- otf_api/models/bookings/bookings.py +134 -0
- otf_api/models/{bookings_v2.py → bookings/bookings_v2.py} +72 -31
- otf_api/models/bookings/classes.py +124 -0
- otf_api/models/{enums.py → bookings/enums.py} +7 -81
- otf_api/{filters.py → models/bookings/filters.py} +39 -11
- otf_api/models/{ratings.py → bookings/ratings.py} +2 -6
- otf_api/models/members/__init__.py +5 -0
- otf_api/models/members/member_detail.py +149 -0
- otf_api/models/members/member_membership.py +26 -0
- otf_api/models/members/member_purchases.py +29 -0
- otf_api/models/members/notifications.py +17 -0
- otf_api/models/mixins.py +48 -1
- otf_api/models/studios/__init__.py +5 -0
- otf_api/models/studios/enums.py +11 -0
- otf_api/models/studios/studio_detail.py +93 -0
- otf_api/models/studios/studio_services.py +36 -0
- otf_api/models/workouts/__init__.py +31 -0
- otf_api/models/{body_composition_list.py → workouts/body_composition_list.py} +140 -71
- otf_api/models/workouts/challenge_tracker_content.py +50 -0
- otf_api/models/workouts/challenge_tracker_detail.py +99 -0
- otf_api/models/workouts/enums.py +70 -0
- otf_api/models/workouts/lifetime_stats.py +96 -0
- otf_api/models/workouts/out_of_studio_workout_history.py +32 -0
- otf_api/models/{performance_summary.py → workouts/performance_summary.py} +19 -5
- otf_api/models/workouts/telemetry.py +88 -0
- otf_api/models/{workout.py → workouts/workout.py} +34 -20
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/METADATA +4 -2
- otf_api-0.13.0.dist-info/RECORD +59 -0
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/WHEEL +1 -1
- otf_api/api.py +0 -1682
- otf_api/logging.py +0 -19
- otf_api/models/bookings.py +0 -109
- otf_api/models/challenge_tracker_content.py +0 -59
- otf_api/models/challenge_tracker_detail.py +0 -88
- otf_api/models/classes.py +0 -70
- otf_api/models/lifetime_stats.py +0 -78
- otf_api/models/member_detail.py +0 -121
- otf_api/models/member_membership.py +0 -26
- otf_api/models/member_purchases.py +0 -29
- otf_api/models/notifications.py +0 -17
- otf_api/models/out_of_studio_workout_history.py +0 -32
- otf_api/models/studio_detail.py +0 -71
- otf_api/models/studio_services.py +0 -36
- otf_api/models/telemetry.py +0 -84
- otf_api/utils.py +0 -164
- otf_api-0.12.0.dist-info/RECORD +0 -38
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/licenses/LICENSE +0 -0
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,541 @@
|
|
1
|
+
import typing
|
2
|
+
from datetime import date, datetime, time, timedelta
|
3
|
+
from logging import getLogger
|
4
|
+
from typing import Literal
|
5
|
+
|
6
|
+
import pendulum
|
7
|
+
|
8
|
+
from otf_api import exceptions as exc
|
9
|
+
from otf_api import models
|
10
|
+
from otf_api.api import utils
|
11
|
+
from otf_api.api.client import OtfClient
|
12
|
+
from otf_api.models.bookings import HISTORICAL_BOOKING_STATUSES, ClassFilter
|
13
|
+
|
14
|
+
from .booking_client import BookingClient
|
15
|
+
|
16
|
+
if typing.TYPE_CHECKING:
|
17
|
+
from otf_api import Otf
|
18
|
+
|
19
|
+
LOGGER = getLogger(__name__)
|
20
|
+
|
21
|
+
|
22
|
+
class BookingApi:
|
23
|
+
def __init__(self, otf: "Otf", otf_client: OtfClient):
|
24
|
+
"""Initialize the Booking API client.
|
25
|
+
|
26
|
+
Args:
|
27
|
+
otf (Otf): The OTF API client.
|
28
|
+
otf_client (OtfClient): The OTF client to use for requests.
|
29
|
+
"""
|
30
|
+
self.otf = otf
|
31
|
+
self.client = BookingClient(otf_client)
|
32
|
+
|
33
|
+
def get_bookings_new(
|
34
|
+
self,
|
35
|
+
start_date: datetime | date | str | None = None,
|
36
|
+
end_date: datetime | date | str | None = None,
|
37
|
+
exclude_cancelled: bool = True,
|
38
|
+
remove_duplicates: bool = True,
|
39
|
+
) -> list[models.BookingV2]:
|
40
|
+
"""Get the bookings for the user.
|
41
|
+
|
42
|
+
If no dates are provided, it will return all bookings between today and 45 days from now.
|
43
|
+
|
44
|
+
Args:
|
45
|
+
start_date (datetime | date | str | None): The start date for the bookings. Default is None.
|
46
|
+
end_date (datetime | date | str | None): The end date for the bookings. Default is None.
|
47
|
+
exclude_cancelled (bool): Whether to exclude cancelled bookings. Default is True.
|
48
|
+
remove_duplicates (bool): When True, only keeps the most recent booking for a given class_id.\
|
49
|
+
This is helpful to avoid duplicates caused by cancel/rebook scenarios, class changes, etc.\
|
50
|
+
Default is True.
|
51
|
+
|
52
|
+
Returns:
|
53
|
+
list[BookingV2]: The bookings for the user.
|
54
|
+
|
55
|
+
Note:
|
56
|
+
Setting `exclude_cancelled` to `False` will return all bookings, which may result in multiple bookings for\
|
57
|
+
the same `class_id`. Setting `exclude_cancelled` to `True` will prevent this, but has the side effect of\
|
58
|
+
not returning *any* results for a cancelled (and not rebooked) booking. If you want a unique list of\
|
59
|
+
bookings that includes cancelled bookings, you should set `exclude_cancelled` to `False` and\
|
60
|
+
`remove_duplicates` to `True`.
|
61
|
+
|
62
|
+
Warning:
|
63
|
+
If you do not set either `exclude_cancelled` or `remove_duplicates` to True you may receive multiple\
|
64
|
+
bookings for the same workout. This will happen if you cancel and rebook or if a class changes, such\
|
65
|
+
as from a 2G to a 3G. Apparently the system actually creates a new booking for the new class, which\
|
66
|
+
is normally transparent to the user.
|
67
|
+
"""
|
68
|
+
expand = True # this doesn't seem to have an effect? so leaving it out of the argument list
|
69
|
+
|
70
|
+
# leaving the parameter as `exclude_canceled` for backwards compatibility
|
71
|
+
include_canceled = not exclude_cancelled
|
72
|
+
|
73
|
+
end_date = utils.ensure_datetime(end_date, time(23, 59, 59))
|
74
|
+
start_date = utils.ensure_datetime(start_date)
|
75
|
+
|
76
|
+
end_date = end_date or pendulum.today().start_of("day").add(days=45)
|
77
|
+
start_date = start_date or pendulum.today().start_of("day")
|
78
|
+
|
79
|
+
bookings_resp = self.client.get_bookings_new(
|
80
|
+
ends_before=end_date, starts_after=start_date, include_canceled=include_canceled, expand=expand
|
81
|
+
)
|
82
|
+
|
83
|
+
results = [models.BookingV2.create(**b, api=self) for b in bookings_resp]
|
84
|
+
|
85
|
+
if not remove_duplicates:
|
86
|
+
return results
|
87
|
+
|
88
|
+
# remove duplicates by class_id, keeping the one with the most recent updated_at timestamp
|
89
|
+
seen_classes: dict[str, models.BookingV2] = {}
|
90
|
+
|
91
|
+
for booking in results:
|
92
|
+
class_id = booking.otf_class.class_id
|
93
|
+
if class_id not in seen_classes:
|
94
|
+
seen_classes[class_id] = booking
|
95
|
+
continue
|
96
|
+
|
97
|
+
existing_booking = seen_classes[class_id]
|
98
|
+
if exclude_cancelled:
|
99
|
+
LOGGER.warning(
|
100
|
+
f"Duplicate class_id {class_id} found when `exclude_cancelled` is True, "
|
101
|
+
"this is unexpected behavior."
|
102
|
+
)
|
103
|
+
if booking.updated_at > existing_booking.updated_at:
|
104
|
+
seen_classes[class_id] = booking
|
105
|
+
|
106
|
+
results = list(seen_classes.values())
|
107
|
+
results = sorted(results, key=lambda x: x.starts_at)
|
108
|
+
|
109
|
+
return results
|
110
|
+
|
111
|
+
def get_bookings_new_by_date(
|
112
|
+
self,
|
113
|
+
start_date: datetime | date | str | None = None,
|
114
|
+
end_date: datetime | date | str | None = None,
|
115
|
+
) -> dict[datetime, models.BookingV2]:
|
116
|
+
"""Get the bookings for the user, returned in a dictionary keyed by start datetime.
|
117
|
+
|
118
|
+
This is a convenience method that calls `get_bookings_new` and returns a dictionary instead\
|
119
|
+
of a list. Because this returns a dictionary, it will only return the most recent booking for each class_id.
|
120
|
+
It will also include cancelled bookings.
|
121
|
+
|
122
|
+
Returns:
|
123
|
+
dict[datetime, BookingV2]: A dictionary of bookings keyed by their start datetime.
|
124
|
+
"""
|
125
|
+
bookings = self.get_bookings_new(
|
126
|
+
start_date=start_date,
|
127
|
+
end_date=end_date,
|
128
|
+
exclude_cancelled=False,
|
129
|
+
remove_duplicates=True,
|
130
|
+
)
|
131
|
+
|
132
|
+
bookings_by_date = {b.starts_at: b for b in bookings}
|
133
|
+
return bookings_by_date
|
134
|
+
|
135
|
+
def get_booking_new(self, booking_id: str) -> models.BookingV2:
|
136
|
+
"""Get a booking by ID from the new bookings endpoint.
|
137
|
+
|
138
|
+
Args:
|
139
|
+
booking_id (str): The booking ID to get.
|
140
|
+
|
141
|
+
Returns:
|
142
|
+
BookingV2: The booking.
|
143
|
+
|
144
|
+
Raises:
|
145
|
+
ValueError: If booking_id is None or empty string.
|
146
|
+
ResourceNotFoundError: If the booking with the given ID does not exist.
|
147
|
+
"""
|
148
|
+
all_bookings = self._get_all_bookings_new()
|
149
|
+
booking = next((b for b in all_bookings if b.booking_id == booking_id), None)
|
150
|
+
if not booking:
|
151
|
+
raise exc.ResourceNotFoundError(f"Booking with ID {booking_id} not found")
|
152
|
+
return booking
|
153
|
+
|
154
|
+
def get_classes(
|
155
|
+
self,
|
156
|
+
start_date: date | str | None = None,
|
157
|
+
end_date: date | str | None = None,
|
158
|
+
studio_uuids: list[str] | None = None,
|
159
|
+
include_home_studio: bool = True,
|
160
|
+
filters: list[ClassFilter] | ClassFilter | None = None,
|
161
|
+
) -> list[models.OtfClass]:
|
162
|
+
"""Get the classes for the user.
|
163
|
+
|
164
|
+
Returns a list of classes that are available for the user, based on the studio UUIDs provided. If no studio
|
165
|
+
UUIDs are provided, it will default to the user's home studio.
|
166
|
+
|
167
|
+
Args:
|
168
|
+
start_date (date | str | None): The start date for the classes. Default is None.
|
169
|
+
end_date (date | str | None): The end date for the classes. Default is None.
|
170
|
+
studio_uuids (list[str] | None): The studio UUIDs to get the classes for. Default is None, which will\
|
171
|
+
default to the user's home studio only.
|
172
|
+
include_home_studio (bool | None): Whether to include the home studio in the classes. Default is True.
|
173
|
+
filters (list[ClassFilter] | ClassFilter | None): A list of filters to apply to the classes, or a single\
|
174
|
+
filter. Filters are applied as an OR operation. Default is None.
|
175
|
+
|
176
|
+
Returns:
|
177
|
+
list[OtfClass]: The classes for the user.
|
178
|
+
"""
|
179
|
+
start_date = utils.ensure_date(start_date)
|
180
|
+
end_date = utils.ensure_date(end_date)
|
181
|
+
studio_uuids = utils.get_studio_uuid_list(self.otf.home_studio_uuid, studio_uuids, include_home_studio)
|
182
|
+
|
183
|
+
# get the classes and add the studio details
|
184
|
+
classes_resp = self.client.get_classes(studio_uuids)
|
185
|
+
studio_dict = self.otf.studios._get_studio_detail_threaded(studio_uuids)
|
186
|
+
classes: list[models.OtfClass] = []
|
187
|
+
|
188
|
+
for c in classes_resp:
|
189
|
+
c["studio"] = studio_dict[c["studio"]["id"]] # the one (?) place where ID actually means UUID
|
190
|
+
c["is_home_studio"] = c["studio"].studio_uuid == self.otf.home_studio_uuid
|
191
|
+
classes.append(models.OtfClass(**c))
|
192
|
+
|
193
|
+
# additional data filtering and enrichment
|
194
|
+
|
195
|
+
# remove those that are cancelled *by the studio*
|
196
|
+
classes = [c for c in classes if not c.is_cancelled]
|
197
|
+
|
198
|
+
bookings = self.get_bookings(status=models.BookingStatus.Booked)
|
199
|
+
booked_classes = {b.class_uuid for b in bookings}
|
200
|
+
|
201
|
+
for otf_class in classes:
|
202
|
+
otf_class.is_booked = otf_class.class_uuid in booked_classes
|
203
|
+
|
204
|
+
# filter by provided start_date/end_date, if provided
|
205
|
+
classes = utils.filter_classes_by_date(classes, start_date, end_date)
|
206
|
+
|
207
|
+
# filter by provided filters, if provided
|
208
|
+
classes = utils.filter_classes_by_filters(classes, filters)
|
209
|
+
|
210
|
+
# sort by start time, then by name
|
211
|
+
classes = sorted(classes, key=lambda x: (x.starts_at, x.name))
|
212
|
+
|
213
|
+
return classes
|
214
|
+
|
215
|
+
def get_booking(self, booking_uuid: str) -> models.Booking:
|
216
|
+
"""Get a specific booking by booking_uuid, from the old bookings endpoint.
|
217
|
+
|
218
|
+
Args:
|
219
|
+
booking_uuid (str): The booking UUID to get.
|
220
|
+
|
221
|
+
Returns:
|
222
|
+
BookingList: The booking.
|
223
|
+
|
224
|
+
Raises:
|
225
|
+
ValueError: If booking_uuid is None or empty string.
|
226
|
+
"""
|
227
|
+
if not booking_uuid:
|
228
|
+
raise ValueError("booking_uuid is required")
|
229
|
+
|
230
|
+
data = self.client.get_booking(booking_uuid)
|
231
|
+
return models.Booking.create(**data, api=self)
|
232
|
+
|
233
|
+
def get_booking_from_class(self, otf_class: str | models.OtfClass) -> models.Booking:
|
234
|
+
"""Get a specific booking by class_uuid or OtfClass object.
|
235
|
+
|
236
|
+
Args:
|
237
|
+
otf_class (str | OtfClass): The class UUID or the OtfClass object to get the booking for.
|
238
|
+
|
239
|
+
Returns:
|
240
|
+
Booking: The booking.
|
241
|
+
|
242
|
+
Raises:
|
243
|
+
BookingNotFoundError: If the booking does not exist.
|
244
|
+
ValueError: If class_uuid is None or empty string.
|
245
|
+
"""
|
246
|
+
class_uuid = utils.get_class_uuid(otf_class)
|
247
|
+
|
248
|
+
all_bookings = self.get_bookings(exclude_cancelled=False, exclude_checkedin=False)
|
249
|
+
|
250
|
+
if booking := next((b for b in all_bookings if b.class_uuid == class_uuid), None):
|
251
|
+
return booking
|
252
|
+
|
253
|
+
raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
|
254
|
+
|
255
|
+
def get_booking_from_class_new(self, otf_class: str | models.OtfClass | models.BookingV2Class) -> models.BookingV2:
|
256
|
+
"""Get a specific booking by class_uuid or OtfClass object.
|
257
|
+
|
258
|
+
Args:
|
259
|
+
otf_class (str | OtfClass | BookingV2Class): The class UUID or the OtfClass object to get the booking for.
|
260
|
+
|
261
|
+
Returns:
|
262
|
+
BookingV2: The booking.
|
263
|
+
|
264
|
+
Raises:
|
265
|
+
BookingNotFoundError: If the booking does not exist.
|
266
|
+
ValueError: If class_uuid is None or empty string.
|
267
|
+
"""
|
268
|
+
class_uuid = utils.get_class_uuid(otf_class)
|
269
|
+
|
270
|
+
all_bookings = self._get_all_bookings_new()
|
271
|
+
|
272
|
+
if booking := next((b for b in all_bookings if b.class_uuid == class_uuid), None):
|
273
|
+
return booking
|
274
|
+
|
275
|
+
raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
|
276
|
+
|
277
|
+
def book_class(self, otf_class: str | models.OtfClass) -> models.Booking:
|
278
|
+
"""Book a class by providing either the class_uuid or the OtfClass object.
|
279
|
+
|
280
|
+
Args:
|
281
|
+
otf_class (str | OtfClass): The class UUID or the OtfClass object to book.
|
282
|
+
|
283
|
+
Returns:
|
284
|
+
Booking: The booking.
|
285
|
+
|
286
|
+
Raises:
|
287
|
+
AlreadyBookedError: If the class is already booked.
|
288
|
+
OutsideSchedulingWindowError: If the class is outside the scheduling window.
|
289
|
+
ValueError: If class_uuid is None or empty string.
|
290
|
+
OtfException: If there is an error booking the class.
|
291
|
+
"""
|
292
|
+
class_uuid = utils.get_class_uuid(otf_class)
|
293
|
+
|
294
|
+
try:
|
295
|
+
existing_booking = self.get_booking_from_class(class_uuid)
|
296
|
+
if existing_booking.status != models.BookingStatus.Cancelled:
|
297
|
+
raise exc.AlreadyBookedError(
|
298
|
+
f"Class {class_uuid} is already booked.", booking_uuid=existing_booking.booking_uuid
|
299
|
+
)
|
300
|
+
except exc.BookingNotFoundError:
|
301
|
+
pass
|
302
|
+
|
303
|
+
if isinstance(otf_class, models.OtfClass):
|
304
|
+
bookings = self.get_bookings(start_date=otf_class.starts_at.date(), end_date=otf_class.starts_at.date())
|
305
|
+
utils.check_for_booking_conflicts(bookings, otf_class)
|
306
|
+
|
307
|
+
body = {"classUUId": class_uuid, "confirmed": False, "waitlist": False}
|
308
|
+
|
309
|
+
resp = self.client.put_class(body)
|
310
|
+
|
311
|
+
# get the booking uuid - we will only use this to return a Booking object using `get_booking`
|
312
|
+
# this is an attempt to improve on OTF's terrible data model
|
313
|
+
booking_uuid = resp["savedBookings"][0]["classBookingUUId"]
|
314
|
+
|
315
|
+
booking = self.get_booking(booking_uuid)
|
316
|
+
|
317
|
+
return booking
|
318
|
+
|
319
|
+
def book_class_new(self, class_id: str | models.BookingV2Class) -> models.BookingV2:
|
320
|
+
"""Book a class by providing either the class_id or the BookingV2Class object.
|
321
|
+
|
322
|
+
This uses the new booking endpoint.
|
323
|
+
|
324
|
+
Args:
|
325
|
+
class_id (str): The class ID to book.
|
326
|
+
|
327
|
+
Returns:
|
328
|
+
BookingV2: The booking.
|
329
|
+
|
330
|
+
Raises:
|
331
|
+
OtfException: If there is an error booking the class.
|
332
|
+
TypeError: If the input is not a string or BookingV2Class.
|
333
|
+
"""
|
334
|
+
class_id = utils.get_class_id(class_id)
|
335
|
+
|
336
|
+
body = {"class_id": class_id, "confirmed": False, "waitlist": False}
|
337
|
+
|
338
|
+
resp = self.client.post_class_new(body)
|
339
|
+
|
340
|
+
new_booking = models.BookingV2.create(**resp, api=self)
|
341
|
+
|
342
|
+
return new_booking
|
343
|
+
|
344
|
+
def cancel_booking(self, booking: str | models.Booking) -> None:
|
345
|
+
"""Cancel a booking by providing either the booking_uuid or the Booking object.
|
346
|
+
|
347
|
+
Args:
|
348
|
+
booking (str | Booking): The booking UUID or the Booking object to cancel.
|
349
|
+
|
350
|
+
Raises:
|
351
|
+
ValueError: If booking_uuid is None or empty string
|
352
|
+
BookingNotFoundError: If the booking does not exist.
|
353
|
+
"""
|
354
|
+
if isinstance(booking, models.BookingV2):
|
355
|
+
LOGGER.warning("BookingV2 object provided, using the new cancel booking endpoint (`cancel_booking_new`)")
|
356
|
+
self.cancel_booking_new(booking)
|
357
|
+
|
358
|
+
booking_uuid = utils.get_booking_uuid(booking)
|
359
|
+
|
360
|
+
if booking == booking_uuid: # ensure this booking exists by calling the booking endpoint
|
361
|
+
_ = self.get_booking(booking_uuid) # allow the exception to be raised if it doesn't exist
|
362
|
+
|
363
|
+
self.client.delete_booking(booking_uuid)
|
364
|
+
|
365
|
+
def cancel_booking_new(self, booking: str | models.BookingV2) -> None:
|
366
|
+
"""Cancel a booking by providing either the booking_id or the BookingV2 object.
|
367
|
+
|
368
|
+
Args:
|
369
|
+
booking (str | BookingV2): The booking ID or the BookingV2 object to cancel.
|
370
|
+
|
371
|
+
Raises:
|
372
|
+
ValueError: If booking_id is None or empty string
|
373
|
+
BookingNotFoundError: If the booking does not exist.
|
374
|
+
"""
|
375
|
+
if isinstance(booking, models.Booking):
|
376
|
+
LOGGER.warning("Booking object provided, using the old cancel booking endpoint (`cancel_booking`)")
|
377
|
+
self.cancel_booking(booking)
|
378
|
+
|
379
|
+
booking_id = utils.get_booking_id(booking)
|
380
|
+
|
381
|
+
if booking == booking_id:
|
382
|
+
_ = self.get_booking_new(booking_id) # allow the exception to be raised if it doesn't exist
|
383
|
+
|
384
|
+
self.client.delete_booking_new(booking_id)
|
385
|
+
|
386
|
+
def get_bookings(
|
387
|
+
self,
|
388
|
+
start_date: date | str | None = None,
|
389
|
+
end_date: date | str | None = None,
|
390
|
+
status: models.BookingStatus | list[models.BookingStatus] | None = None,
|
391
|
+
exclude_cancelled: bool = True,
|
392
|
+
exclude_checkedin: bool = True,
|
393
|
+
) -> list[models.Booking]:
|
394
|
+
"""Get the member's bookings.
|
395
|
+
|
396
|
+
If no dates are provided, it will return all bookings between today and 45 days from now.
|
397
|
+
|
398
|
+
Args:
|
399
|
+
start_date (date | str | None): The start date for the bookings. Default is None.
|
400
|
+
end_date (date | str | None): The end date for the bookings. Default is None.
|
401
|
+
status (BookingStatus | list[BookingStatus] | None): The status(es) to filter by. Default is None.
|
402
|
+
exclude_cancelled (bool): Whether to exclude cancelled bookings. Default is True.
|
403
|
+
exclude_checkedin (bool): Whether to exclude checked-in bookings. Default is True.
|
404
|
+
|
405
|
+
Returns:
|
406
|
+
list[Booking]: The member's bookings.
|
407
|
+
|
408
|
+
Warning:
|
409
|
+
Incorrect statuses do not cause any bad status code, they just return no results.
|
410
|
+
|
411
|
+
Tip:
|
412
|
+
`CheckedIn` - you must provide dates if you want to get bookings with a status of CheckedIn. If you do not
|
413
|
+
provide dates, the endpoint will return no results for this status.
|
414
|
+
"""
|
415
|
+
if exclude_cancelled and status == models.BookingStatus.Cancelled:
|
416
|
+
LOGGER.warning(
|
417
|
+
"Cannot exclude cancelled bookings when status is Cancelled. Setting exclude_cancelled to False."
|
418
|
+
)
|
419
|
+
exclude_cancelled = False
|
420
|
+
|
421
|
+
if isinstance(start_date, date):
|
422
|
+
start_date = start_date.isoformat()
|
423
|
+
|
424
|
+
if isinstance(end_date, date):
|
425
|
+
end_date = end_date.isoformat()
|
426
|
+
|
427
|
+
if isinstance(status, list):
|
428
|
+
status_value = ",".join(status)
|
429
|
+
elif isinstance(status, models.BookingStatus):
|
430
|
+
status_value = status.value
|
431
|
+
elif isinstance(status, str):
|
432
|
+
status_value = status
|
433
|
+
else:
|
434
|
+
status_value = None
|
435
|
+
|
436
|
+
resp = self.client.get_bookings(start_date, end_date, status_value)
|
437
|
+
|
438
|
+
# add studio details for each booking, instead of using the different studio model returned by this endpoint
|
439
|
+
studio_uuids = {b["class"]["studio"]["studioUUId"] for b in resp}
|
440
|
+
studios = {studio_uuid: self.otf.studios.get_studio_detail(studio_uuid) for studio_uuid in studio_uuids}
|
441
|
+
|
442
|
+
for b in resp:
|
443
|
+
b["class"]["studio"] = studios[b["class"]["studio"]["studioUUId"]]
|
444
|
+
b["is_home_studio"] = b["class"]["studio"].studio_uuid == self.otf.home_studio_uuid
|
445
|
+
|
446
|
+
bookings = [models.Booking.create(**b, api=self) for b in resp]
|
447
|
+
bookings = sorted(bookings, key=lambda x: x.otf_class.starts_at)
|
448
|
+
|
449
|
+
if exclude_cancelled:
|
450
|
+
bookings = [b for b in bookings if b.status != models.BookingStatus.Cancelled]
|
451
|
+
|
452
|
+
if exclude_checkedin:
|
453
|
+
bookings = [b for b in bookings if b.status != models.BookingStatus.CheckedIn]
|
454
|
+
|
455
|
+
return bookings
|
456
|
+
|
457
|
+
def get_historical_bookings(self) -> list[models.Booking]:
|
458
|
+
"""Get the member's historical bookings.
|
459
|
+
|
460
|
+
This will go back 45 days and return all bookings for that time period.
|
461
|
+
|
462
|
+
Returns:
|
463
|
+
list[Booking]: The member's historical bookings.
|
464
|
+
"""
|
465
|
+
# api goes back 45 days but we'll go back 47 to be safe
|
466
|
+
start_date = datetime.today().date() - timedelta(days=47)
|
467
|
+
end_date = datetime.today().date()
|
468
|
+
|
469
|
+
return self.get_bookings(
|
470
|
+
start_date=start_date,
|
471
|
+
end_date=end_date,
|
472
|
+
status=HISTORICAL_BOOKING_STATUSES,
|
473
|
+
exclude_cancelled=False,
|
474
|
+
exclude_checkedin=False,
|
475
|
+
)
|
476
|
+
|
477
|
+
def rate_class(
|
478
|
+
self,
|
479
|
+
class_uuid: str,
|
480
|
+
performance_summary_id: str,
|
481
|
+
class_rating: Literal[0, 1, 2, 3],
|
482
|
+
coach_rating: Literal[0, 1, 2, 3],
|
483
|
+
) -> None:
|
484
|
+
"""Rate a class and coach. A simpler method is provided in `rate_class_from_workout`.
|
485
|
+
|
486
|
+
The class rating must be between 0 and 4.
|
487
|
+
0 is the same as dismissing the prompt to rate the class/coach in the app.
|
488
|
+
1 through 3 is a range from bad to good.
|
489
|
+
|
490
|
+
Args:
|
491
|
+
class_uuid (str): The class UUID.
|
492
|
+
performance_summary_id (str): The performance summary ID.
|
493
|
+
class_rating (int): The class rating. Must be 0, 1, 2, or 3.
|
494
|
+
coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
|
495
|
+
|
496
|
+
Returns:
|
497
|
+
None
|
498
|
+
|
499
|
+
"""
|
500
|
+
body_class_rating = models.get_class_rating_value(class_rating)
|
501
|
+
body_coach_rating = models.get_coach_rating_value(coach_rating)
|
502
|
+
|
503
|
+
try:
|
504
|
+
self.client.post_class_rating(class_uuid, performance_summary_id, body_class_rating, body_coach_rating)
|
505
|
+
except exc.OtfRequestError as e:
|
506
|
+
if e.response.status_code == 403:
|
507
|
+
raise exc.AlreadyRatedError(f"Workout {performance_summary_id} is already rated.") from None
|
508
|
+
raise
|
509
|
+
|
510
|
+
def _get_all_bookings_new(
|
511
|
+
self, exclude_cancelled: bool = True, remove_duplicates: bool = True
|
512
|
+
) -> list[models.BookingV2]:
|
513
|
+
"""Get bookings from the new endpoint with no date filters.
|
514
|
+
|
515
|
+
This is marked as private to avoid random users calling it.
|
516
|
+
Useful for testing and validating models.
|
517
|
+
|
518
|
+
Args:
|
519
|
+
exclude_cancelled (bool): Whether to exclude cancelled bookings. Default is True.
|
520
|
+
remove_duplicates (bool): Whether to remove duplicate bookings. Default is True.
|
521
|
+
|
522
|
+
Returns:
|
523
|
+
list[BookingV2]: List of bookings that match the search criteria.
|
524
|
+
"""
|
525
|
+
start_date = pendulum.datetime(1970, 1, 1)
|
526
|
+
end_date = pendulum.today().start_of("day").add(days=45)
|
527
|
+
return self.get_bookings_new(start_date, end_date, exclude_cancelled, remove_duplicates)
|
528
|
+
|
529
|
+
def _get_all_bookings_new_by_date(self) -> dict[datetime, models.BookingV2]:
|
530
|
+
"""Get all bookings from the new endpoint by date.
|
531
|
+
|
532
|
+
This is marked as private to avoid random users calling it.
|
533
|
+
Useful for testing and validating models.
|
534
|
+
|
535
|
+
Returns:
|
536
|
+
dict[datetime, BookingV2]: Dictionary of bookings by date.
|
537
|
+
"""
|
538
|
+
start_date = pendulum.datetime(1970, 1, 1)
|
539
|
+
end_date = pendulum.today().start_of("day").add(days=45)
|
540
|
+
bookings = self.get_bookings_new_by_date(start_date, end_date)
|
541
|
+
return bookings
|
@@ -0,0 +1,112 @@
|
|
1
|
+
from datetime import datetime
|
2
|
+
from typing import Any
|
3
|
+
|
4
|
+
import pendulum
|
5
|
+
|
6
|
+
from otf_api.api.client import API_IO_BASE_URL, OtfClient
|
7
|
+
|
8
|
+
|
9
|
+
class BookingClient:
|
10
|
+
"""Client for managing bookings and classes in the OTF API.
|
11
|
+
|
12
|
+
This class provides methods to retrieve classes, book classes, cancel bookings, and rate classes.
|
13
|
+
"""
|
14
|
+
|
15
|
+
def __init__(self, client: OtfClient):
|
16
|
+
self.client = client
|
17
|
+
self.member_uuid = client.member_uuid
|
18
|
+
|
19
|
+
def classes_request(
|
20
|
+
self,
|
21
|
+
method: str,
|
22
|
+
path: str,
|
23
|
+
params: dict[str, Any] | None = None,
|
24
|
+
headers: dict[str, Any] | None = None,
|
25
|
+
**kwargs,
|
26
|
+
) -> Any: # noqa: ANN401
|
27
|
+
"""Perform an API request to the classes API."""
|
28
|
+
return self.client.do(method, API_IO_BASE_URL, path, params, headers=headers, **kwargs)
|
29
|
+
|
30
|
+
def get_classes(self, studio_uuids: list[str]) -> dict:
|
31
|
+
"""Retrieve raw class data."""
|
32
|
+
return self.classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})["items"]
|
33
|
+
|
34
|
+
def delete_booking(self, booking_uuid: str) -> dict:
|
35
|
+
"""Cancel a booking by booking_uuid."""
|
36
|
+
resp = self.client.default_request(
|
37
|
+
"DELETE", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}", params={"confirmed": "true"}
|
38
|
+
)
|
39
|
+
|
40
|
+
return resp
|
41
|
+
|
42
|
+
def put_class(self, body: dict) -> dict:
|
43
|
+
"""Book a class by class_uuid.
|
44
|
+
|
45
|
+
Args:
|
46
|
+
body (dict): The request body containing booking details.
|
47
|
+
|
48
|
+
Returns:
|
49
|
+
dict: The response from the booking request.
|
50
|
+
|
51
|
+
Raises:
|
52
|
+
AlreadyBookedError: If the class is already booked.
|
53
|
+
OutsideSchedulingWindowError: If the class is outside the scheduling window.
|
54
|
+
OtfException: If there is an error booking the class.
|
55
|
+
"""
|
56
|
+
return self.client.default_request("PUT", f"/member/members/{self.member_uuid}/bookings", json=body)["data"]
|
57
|
+
|
58
|
+
def post_class_new(self, body: dict[str, str | bool]) -> dict:
|
59
|
+
"""Book a class by class_id."""
|
60
|
+
return self.classes_request("POST", "/v1/bookings/me", json=body)
|
61
|
+
|
62
|
+
def get_booking(self, booking_uuid: str) -> dict:
|
63
|
+
"""Retrieve raw booking data."""
|
64
|
+
return self.client.default_request("GET", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}")["data"]
|
65
|
+
|
66
|
+
def get_bookings(self, start_date: str | None, end_date: str | None, status: str | list[str] | None) -> dict:
|
67
|
+
"""Retrieve raw bookings data."""
|
68
|
+
if isinstance(status, list):
|
69
|
+
status = ",".join(status)
|
70
|
+
|
71
|
+
return self.client.default_request(
|
72
|
+
"GET",
|
73
|
+
f"/member/members/{self.member_uuid}/bookings",
|
74
|
+
params={"startDate": start_date, "endDate": end_date, "statuses": status},
|
75
|
+
)["data"]
|
76
|
+
|
77
|
+
def get_bookings_new(
|
78
|
+
self,
|
79
|
+
ends_before: datetime,
|
80
|
+
starts_after: datetime,
|
81
|
+
include_canceled: bool = True,
|
82
|
+
expand: bool = False,
|
83
|
+
) -> dict:
|
84
|
+
"""Retrieve raw bookings data."""
|
85
|
+
params: dict[str, bool | str] = {
|
86
|
+
"ends_before": pendulum.instance(ends_before).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
87
|
+
"starts_after": pendulum.instance(starts_after).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
88
|
+
}
|
89
|
+
|
90
|
+
params["include_canceled"] = include_canceled if include_canceled is not None else True
|
91
|
+
params["expand"] = expand if expand is not None else False
|
92
|
+
|
93
|
+
return self.classes_request("GET", "/v1/bookings/me", params=params)["items"]
|
94
|
+
|
95
|
+
def delete_booking_new(self, booking_id: str) -> None:
|
96
|
+
"""Cancel a booking by booking_id."""
|
97
|
+
self.classes_request("DELETE", f"/v1/bookings/me/{booking_id}")
|
98
|
+
|
99
|
+
def post_class_rating(
|
100
|
+
self, class_uuid: str, performance_summary_id: str, class_rating: int, coach_rating: int
|
101
|
+
) -> dict:
|
102
|
+
"""Retrieve raw response from rating a class and coach."""
|
103
|
+
return self.client.default_request(
|
104
|
+
"POST",
|
105
|
+
"/mobile/v1/members/classes/ratings",
|
106
|
+
json={
|
107
|
+
"classUUId": class_uuid,
|
108
|
+
"otBeatClassHistoryUUId": performance_summary_id,
|
109
|
+
"classRating": class_rating,
|
110
|
+
"coachRating": coach_rating,
|
111
|
+
},
|
112
|
+
)
|