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.
Files changed (74) hide show
  1. otf_api/__init__.py +35 -3
  2. otf_api/api/__init__.py +3 -0
  3. otf_api/api/_compat.py +77 -0
  4. otf_api/api/api.py +80 -0
  5. otf_api/api/bookings/__init__.py +3 -0
  6. otf_api/api/bookings/booking_api.py +541 -0
  7. otf_api/api/bookings/booking_client.py +112 -0
  8. otf_api/api/client.py +203 -0
  9. otf_api/api/members/__init__.py +3 -0
  10. otf_api/api/members/member_api.py +187 -0
  11. otf_api/api/members/member_client.py +112 -0
  12. otf_api/api/studios/__init__.py +3 -0
  13. otf_api/api/studios/studio_api.py +173 -0
  14. otf_api/api/studios/studio_client.py +120 -0
  15. otf_api/api/utils.py +307 -0
  16. otf_api/api/workouts/__init__.py +3 -0
  17. otf_api/api/workouts/workout_api.py +333 -0
  18. otf_api/api/workouts/workout_client.py +140 -0
  19. otf_api/auth/__init__.py +1 -1
  20. otf_api/auth/auth.py +155 -89
  21. otf_api/auth/user.py +5 -17
  22. otf_api/auth/utils.py +27 -2
  23. otf_api/cache.py +132 -0
  24. otf_api/exceptions.py +18 -6
  25. otf_api/models/__init__.py +25 -21
  26. otf_api/models/bookings/__init__.py +23 -0
  27. otf_api/models/bookings/bookings.py +134 -0
  28. otf_api/models/{bookings_v2.py → bookings/bookings_v2.py} +72 -31
  29. otf_api/models/bookings/classes.py +124 -0
  30. otf_api/models/{enums.py → bookings/enums.py} +7 -81
  31. otf_api/{filters.py → models/bookings/filters.py} +39 -11
  32. otf_api/models/{ratings.py → bookings/ratings.py} +2 -6
  33. otf_api/models/members/__init__.py +5 -0
  34. otf_api/models/members/member_detail.py +149 -0
  35. otf_api/models/members/member_membership.py +26 -0
  36. otf_api/models/members/member_purchases.py +29 -0
  37. otf_api/models/members/notifications.py +17 -0
  38. otf_api/models/mixins.py +48 -1
  39. otf_api/models/studios/__init__.py +5 -0
  40. otf_api/models/studios/enums.py +11 -0
  41. otf_api/models/studios/studio_detail.py +93 -0
  42. otf_api/models/studios/studio_services.py +36 -0
  43. otf_api/models/workouts/__init__.py +31 -0
  44. otf_api/models/{body_composition_list.py → workouts/body_composition_list.py} +140 -71
  45. otf_api/models/workouts/challenge_tracker_content.py +50 -0
  46. otf_api/models/workouts/challenge_tracker_detail.py +99 -0
  47. otf_api/models/workouts/enums.py +70 -0
  48. otf_api/models/workouts/lifetime_stats.py +96 -0
  49. otf_api/models/workouts/out_of_studio_workout_history.py +32 -0
  50. otf_api/models/{performance_summary.py → workouts/performance_summary.py} +19 -5
  51. otf_api/models/workouts/telemetry.py +88 -0
  52. otf_api/models/{workout.py → workouts/workout.py} +34 -20
  53. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/METADATA +4 -2
  54. otf_api-0.13.0.dist-info/RECORD +59 -0
  55. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/WHEEL +1 -1
  56. otf_api/api.py +0 -1682
  57. otf_api/logging.py +0 -19
  58. otf_api/models/bookings.py +0 -109
  59. otf_api/models/challenge_tracker_content.py +0 -59
  60. otf_api/models/challenge_tracker_detail.py +0 -88
  61. otf_api/models/classes.py +0 -70
  62. otf_api/models/lifetime_stats.py +0 -78
  63. otf_api/models/member_detail.py +0 -121
  64. otf_api/models/member_membership.py +0 -26
  65. otf_api/models/member_purchases.py +0 -29
  66. otf_api/models/notifications.py +0 -17
  67. otf_api/models/out_of_studio_workout_history.py +0 -32
  68. otf_api/models/studio_detail.py +0 -71
  69. otf_api/models/studio_services.py +0 -36
  70. otf_api/models/telemetry.py +0 -84
  71. otf_api/utils.py +0 -164
  72. otf_api-0.12.0.dist-info/RECORD +0 -38
  73. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/licenses/LICENSE +0 -0
  74. {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
+ )