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
otf_api/api.py DELETED
@@ -1,1682 +0,0 @@
1
- import atexit
2
- import contextlib
3
- from concurrent.futures import ThreadPoolExecutor
4
- from datetime import date, datetime, time, timedelta
5
- from functools import partial
6
- from json import JSONDecodeError
7
- from logging import getLogger
8
- from typing import Any, Literal
9
-
10
- import attrs
11
- import httpx
12
- import pendulum
13
- from cachetools import TTLCache, cached
14
- from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
15
- from yarl import URL
16
-
17
- from otf_api import exceptions as exc
18
- from otf_api import filters, models
19
- from otf_api.auth import OtfUser
20
- from otf_api.models.enums import HISTORICAL_BOOKING_STATUSES
21
- from otf_api.utils import ensure_date, ensure_datetime, ensure_list, get_booking_id, get_booking_uuid, get_class_uuid
22
-
23
- API_BASE_URL = "api.orangetheory.co"
24
- API_IO_BASE_URL = "api.orangetheory.io"
25
- API_TELEMETRY_BASE_URL = "api.yuzu.orangetheory.com"
26
- HEADERS = {
27
- "content-type": "application/json",
28
- "accept": "application/json",
29
- "user-agent": "okhttp/4.12.0",
30
- }
31
- LOGGER = getLogger(__name__)
32
- LOGGED_ONCE: set[str] = set()
33
-
34
-
35
- @attrs.define(init=False)
36
- class Otf:
37
- member: models.MemberDetail
38
- member_uuid: str
39
- home_studio: models.StudioDetail
40
- home_studio_uuid: str
41
- user: OtfUser
42
- session: httpx.Client
43
-
44
- def __init__(self, user: OtfUser | None = None):
45
- """Initialize the OTF API client.
46
-
47
- Args:
48
- user (OtfUser): The user to authenticate as.
49
- """
50
- self.user = user or OtfUser()
51
- self.member_uuid = self.user.member_uuid
52
-
53
- self.session = httpx.Client(
54
- headers=HEADERS, auth=self.user.httpx_auth, timeout=httpx.Timeout(20.0, connect=60.0)
55
- )
56
- atexit.register(self.session.close)
57
-
58
- self.member = self.get_member_detail()
59
- self.home_studio = self.member.home_studio
60
- self.home_studio_uuid = self.home_studio.studio_uuid
61
-
62
- def __eq__(self, other):
63
- if not isinstance(other, Otf):
64
- return False
65
- return self.member_uuid == other.member_uuid
66
-
67
- def __hash__(self):
68
- # Combine immutable attributes into a single hash value
69
- return hash(self.member_uuid)
70
-
71
- @retry(
72
- retry=retry_if_exception_type((exc.OtfRequestError, httpx.HTTPStatusError)),
73
- stop=stop_after_attempt(3),
74
- wait=wait_exponential(multiplier=1, min=4, max=10),
75
- reraise=True,
76
- )
77
- def _do(
78
- self,
79
- method: str,
80
- base_url: str,
81
- url: str,
82
- params: dict[str, Any] | None = None,
83
- headers: dict[str, str] | None = None,
84
- **kwargs: Any,
85
- ) -> Any:
86
- """Perform an API request."""
87
-
88
- headers = headers or {}
89
- params = params or {}
90
- params = {k: v for k, v in params.items() if v is not None}
91
-
92
- full_url = str(URL.build(scheme="https", host=base_url, path=url))
93
-
94
- LOGGER.debug(f"Making {method!r} request to {full_url}, params: {params}")
95
-
96
- request = self.session.build_request(method, full_url, headers=headers, params=params, **kwargs)
97
- response = self.session.send(request)
98
-
99
- try:
100
- response.raise_for_status()
101
- except httpx.RequestError as e:
102
- LOGGER.exception(f"Error making request: {e}")
103
- LOGGER.exception(f"Response: {response.text}")
104
- raise
105
- except httpx.HTTPStatusError as e:
106
- if e.response.status_code == 404:
107
- raise exc.ResourceNotFoundError("Resource not found")
108
-
109
- try:
110
- resp_text = e.response.json()
111
- except JSONDecodeError:
112
- resp_text = e.response.text
113
-
114
- LOGGER.exception(f"Error making request - {resp_text!r}: {type(e).__name__} {e}")
115
-
116
- LOGGER.info(f"Request details: {vars(request)}")
117
- LOGGER.info(f"Response details: {vars(response)}")
118
-
119
- raise
120
-
121
- except Exception as e:
122
- LOGGER.exception(f"Error making request: {e}")
123
- raise
124
-
125
- if not response.text:
126
- if method == "GET":
127
- raise exc.OtfRequestError("Empty response", None, response=response, request=request)
128
-
129
- LOGGER.debug(f"Request {method!r} to {full_url!r} returned no content")
130
- return None
131
-
132
- try:
133
- resp = response.json()
134
- except JSONDecodeError as e:
135
- LOGGER.error(f"Error decoding JSON: {e}")
136
- LOGGER.error(f"Response: {response.text}")
137
- raise
138
-
139
- if (
140
- "Status" in resp
141
- and isinstance(resp["Status"], int)
142
- and not (resp["Status"] >= 200 and resp["Status"] <= 299)
143
- ):
144
- LOGGER.error(f"Error making request: {resp}")
145
- raise exc.OtfRequestError("Error making request", None, response=response, request=request)
146
-
147
- return resp
148
-
149
- def _classes_request(
150
- self,
151
- method: str,
152
- url: str,
153
- params: dict[str, Any] | None = None,
154
- headers: dict[str, Any] | None = None,
155
- **kwargs: Any,
156
- ) -> Any:
157
- """Perform an API request to the classes API."""
158
- return self._do(method, API_IO_BASE_URL, url, params, headers=headers, **kwargs)
159
-
160
- def _default_request(
161
- self,
162
- method: str,
163
- url: str,
164
- params: dict[str, Any] | None = None,
165
- headers: dict[str, Any] | None = None,
166
- **kwargs: Any,
167
- ) -> Any:
168
- """Perform an API request to the default API."""
169
- return self._do(method, API_BASE_URL, url, params, headers=headers, **kwargs)
170
-
171
- def _telemetry_request(
172
- self, method: str, url: str, params: dict[str, Any] | None = None, headers: dict[str, Any] | None = None
173
- ) -> Any:
174
- """Perform an API request to the Telemetry API."""
175
- return self._do(method, API_TELEMETRY_BASE_URL, url, params, headers=headers)
176
-
177
- def _performance_summary_request(
178
- self, method: str, url: str, params: dict[str, Any] | None = None, headers: dict[str, Any] | None = None
179
- ) -> Any:
180
- """Perform an API request to the performance summary API."""
181
- perf_api_headers = {"koji-member-id": self.member_uuid, "koji-member-email": self.user.email_address}
182
- headers = perf_api_headers | (headers or {})
183
-
184
- return self._do(method, API_IO_BASE_URL, url, params, headers=headers)
185
-
186
- def _get_classes_raw(self, studio_uuids: list[str]) -> dict:
187
- """Retrieve raw class data."""
188
- return self._classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})
189
-
190
- def _cancel_booking_raw(self, booking_uuid: str) -> dict:
191
- """Cancel a booking by booking_uuid."""
192
- return self._default_request(
193
- "DELETE", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}", params={"confirmed": "true"}
194
- )
195
-
196
- def _book_class_raw(self, class_uuid, body):
197
- try:
198
- resp = self._default_request("PUT", f"/member/members/{self.member_uuid}/bookings", json=body)
199
- except exc.OtfRequestError as e:
200
- resp_obj = e.response.json()
201
-
202
- if resp_obj["code"] == "ERROR":
203
- err_code = resp_obj["data"]["errorCode"]
204
- if err_code == "603":
205
- raise exc.AlreadyBookedError(f"Class {class_uuid} is already booked.")
206
- if err_code == "602":
207
- raise exc.OutsideSchedulingWindowError(f"Class {class_uuid} is outside the scheduling window.")
208
-
209
- raise
210
- except Exception as e:
211
- raise exc.OtfException(f"Error booking class {class_uuid}: {e}")
212
- return resp
213
-
214
- def _book_class_new_raw(self, body: dict[str, str | bool]) -> dict:
215
- """Book a class by class_id."""
216
-
217
- return self._classes_request("POST", "/v1/bookings/me", json=body)
218
-
219
- def _get_booking_raw(self, booking_uuid: str) -> dict:
220
- """Retrieve raw booking data."""
221
- return self._default_request("GET", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}")
222
-
223
- def _get_bookings_raw(self, start_date: str | None, end_date: str | None, status: str | list[str] | None) -> dict:
224
- """Retrieve raw bookings data."""
225
-
226
- if isinstance(status, list):
227
- status = ",".join(status)
228
-
229
- return self._default_request(
230
- "GET",
231
- f"/member/members/{self.member_uuid}/bookings",
232
- params={"startDate": start_date, "endDate": end_date, "statuses": status},
233
- )
234
-
235
- def _get_bookings_new_raw(
236
- self,
237
- ends_before: datetime,
238
- starts_after: datetime,
239
- include_canceled: bool = True,
240
- expand: bool = False,
241
- ) -> dict:
242
- """Retrieve raw bookings data."""
243
-
244
- params: dict[str, bool | str] = {
245
- "ends_before": pendulum.instance(ends_before).strftime("%Y-%m-%dT%H:%M:%SZ"),
246
- "starts_after": pendulum.instance(starts_after).strftime("%Y-%m-%dT%H:%M:%SZ"),
247
- }
248
-
249
- params["include_canceled"] = include_canceled if include_canceled is not None else True
250
- params["expand"] = expand if expand is not None else False
251
-
252
- return self._classes_request("GET", "/v1/bookings/me", params=params)
253
-
254
- def _cancel_booking_new_raw(self, booking_id: str) -> dict:
255
- """Cancel a booking by booking_id."""
256
- return self._classes_request("DELETE", f"/v1/bookings/me/{booking_id}")
257
-
258
- def _get_member_detail_raw(self) -> dict:
259
- """Retrieve raw member details."""
260
- return self._default_request(
261
- "GET", f"/member/members/{self.member_uuid}", params={"include": "memberAddresses,memberClassSummary"}
262
- )
263
-
264
- def _get_member_membership_raw(self) -> dict:
265
- """Retrieve raw member membership details."""
266
- return self._default_request("GET", f"/member/members/{self.member_uuid}/memberships")
267
-
268
- def _get_performance_summaries_raw(self, limit: int | None = None) -> dict:
269
- """Retrieve raw performance summaries data."""
270
- params = {"limit": limit} if limit else {}
271
- return self._performance_summary_request("GET", "/v1/performance-summaries", params=params)
272
-
273
- def _get_performance_summary_raw(self, performance_summary_id: str) -> dict:
274
- """Retrieve raw performance summary data."""
275
- return self._performance_summary_request("GET", f"/v1/performance-summaries/{performance_summary_id}")
276
-
277
- def _get_hr_history_raw(self) -> dict:
278
- """Retrieve raw heart rate history."""
279
- return self._telemetry_request("GET", "/v1/physVars/maxHr/history", params={"memberUuid": self.member_uuid})
280
-
281
- def _get_telemetry_raw(self, performance_summary_id: str, max_data_points: int = 150) -> dict:
282
- """Retrieve raw telemetry data."""
283
- return self._telemetry_request(
284
- "GET",
285
- "/v1/performance/summary",
286
- params={"classHistoryUuid": performance_summary_id, "maxDataPoints": max_data_points},
287
- )
288
-
289
- def _get_studio_detail_raw(self, studio_uuid: str) -> dict:
290
- """Retrieve raw studio details."""
291
- return self._default_request("GET", f"/mobile/v1/studios/{studio_uuid}")
292
-
293
- def _get_studios_by_geo_raw(
294
- self, latitude: float | None, longitude: float | None, distance: int, page_index: int, page_size: int
295
- ) -> dict:
296
- """Retrieve raw studios by geo data."""
297
- return self._default_request(
298
- "GET",
299
- "/mobile/v1/studios",
300
- params={
301
- "latitude": latitude,
302
- "longitude": longitude,
303
- "distance": distance,
304
- "pageIndex": page_index,
305
- "pageSize": page_size,
306
- },
307
- )
308
-
309
- def _get_body_composition_list_raw(self) -> dict:
310
- """Retrieve raw body composition list."""
311
- return self._default_request("GET", f"/member/members/{self.user.cognito_id}/body-composition")
312
-
313
- def _get_challenge_tracker_raw(self) -> dict:
314
- """Retrieve raw challenge tracker data."""
315
- return self._default_request("GET", f"/challenges/v3.1/member/{self.member_uuid}")
316
-
317
- def _get_benchmarks_raw(self, challenge_category_id: int, equipment_id: int, challenge_subcategory_id: int) -> dict:
318
- """Retrieve raw fitness benchmark data."""
319
- return self._default_request(
320
- "GET",
321
- f"/challenges/v3/member/{self.member_uuid}/benchmarks",
322
- params={
323
- "equipmentId": equipment_id,
324
- "challengeTypeId": challenge_category_id,
325
- "challengeSubTypeId": challenge_subcategory_id,
326
- },
327
- )
328
-
329
- def _get_sms_notification_settings_raw(self) -> dict:
330
- """Retrieve raw SMS notification settings."""
331
- return self._default_request("GET", url="/sms/v1/preferences", params={"phoneNumber": self.member.phone_number})
332
-
333
- def _get_email_notification_settings_raw(self) -> dict:
334
- """Retrieve raw email notification settings."""
335
- return self._default_request("GET", url="/otfmailing/v2/preferences", params={"email": self.member.email})
336
-
337
- def _get_member_lifetime_stats_raw(self, select_time: str) -> dict:
338
- """Retrieve raw lifetime stats data."""
339
- return self._default_request("GET", f"/performance/v2/{self.member_uuid}/over-time/{select_time}")
340
-
341
- def _get_member_services_raw(self, active_only: bool) -> dict:
342
- """Retrieve raw member services data."""
343
- return self._default_request(
344
- "GET", f"/member/members/{self.member_uuid}/services", params={"activeOnly": str(active_only).lower()}
345
- )
346
-
347
- def _get_aspire_data_raw(self, datetime: str | None, unit: str | None) -> dict:
348
- """Retrieve raw aspire wearable data."""
349
- return self._default_request(
350
- "GET", f"/member/wearables/{self.member_uuid}/wearable-daily", params={"datetime": datetime, "unit": unit}
351
- )
352
-
353
- def _get_member_purchases_raw(self) -> dict:
354
- """Retrieve raw member purchases data."""
355
- return self._default_request("GET", f"/member/members/{self.member_uuid}/purchases")
356
-
357
- def _get_favorite_studios_raw(self) -> dict:
358
- """Retrieve raw favorite studios data."""
359
- return self._default_request("GET", f"/member/members/{self.member_uuid}/favorite-studios")
360
-
361
- def _get_studio_services_raw(self, studio_uuid: str) -> dict:
362
- """Retrieve raw studio services data."""
363
- return self._default_request("GET", f"/member/studios/{studio_uuid}/services")
364
-
365
- def _get_out_of_studio_workout_history_raw(self) -> dict:
366
- """Retrieve raw out-of-studio workout history data."""
367
- return self._default_request("GET", f"/member/members/{self.member_uuid}/out-of-studio-workout")
368
-
369
- def _add_favorite_studio_raw(self, studio_uuids: list[str]) -> dict:
370
- """Retrieve raw response from adding a studio to favorite studios."""
371
- return self._default_request("POST", "/mobile/v1/members/favorite-studios", json={"studioUUIds": studio_uuids})
372
-
373
- def _remove_favorite_studio_raw(self, studio_uuids: list[str]) -> dict:
374
- """Retrieve raw response from removing a studio from favorite studios."""
375
- return self._default_request(
376
- "DELETE", "/mobile/v1/members/favorite-studios", json={"studioUUIds": studio_uuids}
377
- )
378
-
379
- def _get_challenge_tracker_detail_raw(self, challenge_category_id: int) -> dict:
380
- """Retrieve raw challenge tracker detail data."""
381
- return self._default_request(
382
- "GET",
383
- f"/challenges/v1/member/{self.member_uuid}/participation",
384
- params={"challengeTypeId": challenge_category_id},
385
- )
386
-
387
- def _update_sms_notification_settings_raw(self, promotional_enabled: bool, transactional_enabled: bool) -> dict:
388
- """Retrieve raw response from updating SMS notification settings."""
389
- return self._default_request(
390
- "POST",
391
- "/sms/v1/preferences",
392
- json={
393
- "promosms": promotional_enabled,
394
- "source": "OTF",
395
- "transactionalsms": transactional_enabled,
396
- "phoneNumber": self.member.phone_number,
397
- },
398
- )
399
-
400
- def _update_email_notification_settings_raw(self, promotional_enabled: bool, transactional_enabled: bool) -> dict:
401
- """Retrieve raw response from updating email notification settings."""
402
- return self._default_request(
403
- "POST",
404
- "/otfmailing/v2/preferences",
405
- json={
406
- "promotionalEmail": promotional_enabled,
407
- "source": "OTF",
408
- "transactionalEmail": transactional_enabled,
409
- "email": self.member.email,
410
- },
411
- )
412
-
413
- def _rate_class_raw(
414
- self, class_uuid: str, performance_summary_id: str, class_rating: int, coach_rating: int
415
- ) -> dict:
416
- """Retrieve raw response from rating a class and coach."""
417
- return self._default_request(
418
- "POST",
419
- "/mobile/v1/members/classes/ratings",
420
- json={
421
- "classUUId": class_uuid,
422
- "otBeatClassHistoryUUId": performance_summary_id,
423
- "classRating": class_rating,
424
- "coachRating": coach_rating,
425
- },
426
- )
427
-
428
- def _update_member_name_raw(self, first_name: str, last_name: str) -> dict:
429
- """Retrieve raw response from updating member name."""
430
- return self._default_request(
431
- "PUT",
432
- f"/member/members/{self.member_uuid}",
433
- json={"firstName": first_name, "lastName": last_name},
434
- )
435
-
436
- def _get_all_bookings_new(self) -> list[models.BookingV2]:
437
- """Get bookings from the new endpoint with no date filters."""
438
- start_date = pendulum.datetime(1970, 1, 1)
439
- end_date = pendulum.today().start_of("day").add(days=45)
440
- return self.get_bookings_new(start_date, end_date, exclude_cancelled=False)
441
-
442
- def _get_app_config_raw(self) -> dict[str, Any]:
443
- return self._default_request("GET", "/member/app-configurations", headers={"SIGV4AUTH_REQUIRED": "true"})
444
-
445
- def get_bookings_new(
446
- self,
447
- start_date: datetime | str | None = None,
448
- end_date: datetime | str | None = None,
449
- exclude_cancelled: bool = True,
450
- ) -> list[models.BookingV2]:
451
- """Get the bookings for the user. If no dates are provided, it will return all bookings
452
- between today and 45 days from now.
453
-
454
- Warning:
455
- ---
456
- If you do not exclude cancelled bookings, you may receive multiple bookings for the same workout, such
457
- as when a class changes from a 2G to a 3G. Apparently the system actually creates a new booking for the
458
- new class, which is normally transparent to the user.
459
-
460
- Args:
461
- start_dtme (datetime | date | str | None): The start date for the bookings. Default is None.
462
- end_dtme (datetime | date | str | None): The end date for the bookings. Default is None.
463
- exclude_cancelled (bool): Whether to exclude canceled bookings. Default is True.
464
- Returns:
465
- list[BookingV2]: The bookings for the user.
466
- """
467
-
468
- expand = True # this doesn't seem to have an effect? so leaving it out of the argument list
469
-
470
- # leaving the parameter as `exclude_canceled` for backwards compatibility
471
- include_canceled = not exclude_cancelled
472
-
473
- end_date = ensure_datetime(end_date, time(23, 59, 59))
474
- start_date = ensure_datetime(start_date)
475
-
476
- end_date = end_date or pendulum.today().start_of("day").add(days=45)
477
- start_date = start_date or pendulum.datetime(1970, 1, 1).start_of("day")
478
-
479
- bookings_resp = self._get_bookings_new_raw(
480
- ends_before=end_date, starts_after=start_date, include_canceled=include_canceled, expand=expand
481
- )
482
-
483
- return [models.BookingV2(**b) for b in bookings_resp["items"]]
484
-
485
- def get_booking_new(self, booking_id: str) -> models.BookingV2:
486
- """Get a booking by ID."""
487
- all_bookings = self._get_all_bookings_new()
488
- booking = next((b for b in all_bookings if b.booking_id == booking_id), None)
489
- if not booking:
490
- raise exc.ResourceNotFoundError(f"Booking with ID {booking_id} not found")
491
- return booking
492
-
493
- def get_classes(
494
- self,
495
- start_date: date | str | None = None,
496
- end_date: date | str | None = None,
497
- studio_uuids: list[str] | None = None,
498
- include_home_studio: bool | None = None,
499
- filters: list[filters.ClassFilter] | filters.ClassFilter | None = None,
500
- ) -> list[models.OtfClass]:
501
- """Get the classes for the user.
502
-
503
- Returns a list of classes that are available for the user, based on the studio UUIDs provided. If no studio
504
- UUIDs are provided, it will default to the user's home studio.
505
-
506
- Args:
507
- start_date (date | str | None): The start date for the classes. Default is None.
508
- end_date (date | str | None): The end date for the classes. Default is None.
509
- studio_uuids (list[str] | None): The studio UUIDs to get the classes for. Default is None, which will\
510
- default to the user's home studio only.
511
- include_home_studio (bool | None): Whether to include the home studio in the classes. Default is True.
512
- filters (list[ClassFilter] | ClassFilter | None): A list of filters to apply to the classes, or a single\
513
- filter. Filters are applied as an OR operation. Default is None.
514
-
515
- Returns:
516
- list[OtfClass]: The classes for the user.
517
- """
518
-
519
- start_date = ensure_date(start_date)
520
- end_date = ensure_date(end_date)
521
-
522
- classes = self._get_classes(studio_uuids, include_home_studio)
523
-
524
- # remove those that are cancelled *by the studio*
525
- classes = [c for c in classes if not c.is_cancelled]
526
-
527
- bookings = self.get_bookings(status=models.BookingStatus.Booked)
528
- booked_classes = {b.class_uuid for b in bookings}
529
-
530
- for otf_class in classes:
531
- otf_class.is_booked = otf_class.class_uuid in booked_classes
532
-
533
- # filter by provided start_date/end_date, if provided
534
- classes = self._filter_classes_by_date(classes, start_date, end_date)
535
-
536
- # filter by provided filters, if provided
537
- classes = self._filter_classes_by_filters(classes, filters)
538
-
539
- # sort by start time, then by name
540
- classes = sorted(classes, key=lambda x: (x.starts_at, x.name))
541
-
542
- return classes
543
-
544
- def _get_classes(
545
- self, studio_uuids: list[str] | None = None, include_home_studio: bool | None = None
546
- ) -> list[models.OtfClass]:
547
- """Handles the actual request to get classes.
548
-
549
- Args:
550
- studio_uuids (list[str] | None): The studio UUIDs to get the classes for. Default is None, which will\
551
- default to the user's home studio only.
552
- include_home_studio (bool): Whether to include the home studio in the classes. Default is True.
553
-
554
- Returns:
555
- list[OtfClass]: The classes for the user.
556
- """
557
-
558
- studio_uuids = ensure_list(studio_uuids) or [self.home_studio_uuid]
559
- studio_uuids = list(set(studio_uuids)) # remove duplicates
560
-
561
- if len(studio_uuids) > 50:
562
- LOGGER.warning("Cannot request classes for more than 50 studios at a time.")
563
- studio_uuids = studio_uuids[:50]
564
-
565
- if include_home_studio and self.home_studio_uuid not in studio_uuids:
566
- if len(studio_uuids) == 50:
567
- LOGGER.warning("Cannot include home studio, request already includes 50 studios.")
568
- else:
569
- studio_uuids.append(self.home_studio_uuid)
570
-
571
- classes_resp = self._get_classes_raw(studio_uuids)
572
-
573
- studio_dict = {s: self.get_studio_detail(s) for s in studio_uuids}
574
- classes: list[models.OtfClass] = []
575
-
576
- for c in classes_resp["items"]:
577
- c["studio"] = studio_dict[c["studio"]["id"]] # the one (?) place where ID actually means UUID
578
- c["is_home_studio"] = c["studio"].studio_uuid == self.home_studio_uuid
579
- classes.append(models.OtfClass(**c))
580
-
581
- return classes
582
-
583
- def _filter_classes_by_date(
584
- self, classes: list[models.OtfClass], start_date: date | None, end_date: date | None
585
- ) -> list[models.OtfClass]:
586
- """Filter classes by start and end dates, as well as the max date the booking endpoint will accept.
587
-
588
- Args:
589
- classes (list[OtfClass]): The classes to filter.
590
- start_date (date | None): The start date to filter by.
591
- end_date (date | None): The end date to filter by.
592
-
593
- Returns:
594
- list[OtfClass]: The filtered classes.
595
- """
596
-
597
- # this endpoint returns classes that the `book_class` endpoint will reject, this filters them out
598
- max_date = datetime.today().date() + timedelta(days=29)
599
-
600
- classes = [c for c in classes if c.starts_at.date() <= max_date]
601
-
602
- # if not start date or end date, we're done
603
- if not start_date and not end_date:
604
- return classes
605
-
606
- if start_date := ensure_date(start_date):
607
- classes = [c for c in classes if c.starts_at.date() >= start_date]
608
-
609
- if end_date := ensure_date(end_date):
610
- classes = [c for c in classes if c.starts_at.date() <= end_date]
611
-
612
- return classes
613
-
614
- def _filter_classes_by_filters(
615
- self, classes: list[models.OtfClass], filters: list[filters.ClassFilter] | filters.ClassFilter | None
616
- ) -> list[models.OtfClass]:
617
- """Filter classes by the provided filters.
618
-
619
- Args:
620
- classes (list[OtfClass]): The classes to filter.
621
- filters (list[ClassFilter] | ClassFilter | None): The filters to apply.
622
-
623
- Returns:
624
- list[OtfClass]: The filtered classes.
625
- """
626
- if not filters:
627
- return classes
628
-
629
- filters = ensure_list(filters)
630
- filtered_classes: list[models.OtfClass] = []
631
-
632
- # apply each filter as an OR operation
633
- for f in filters:
634
- filtered_classes.extend(f.filter_classes(classes))
635
-
636
- # remove duplicates
637
- classes = list({c.class_uuid: c for c in filtered_classes}.values())
638
-
639
- return classes
640
-
641
- def get_booking(self, booking_uuid: str) -> models.Booking:
642
- """Get a specific booking by booking_uuid.
643
-
644
- Args:
645
- booking_uuid (str): The booking UUID to get.
646
-
647
- Returns:
648
- BookingList: The booking.
649
-
650
- Raises:
651
- ValueError: If booking_uuid is None or empty string.
652
- """
653
- if not booking_uuid:
654
- raise ValueError("booking_uuid is required")
655
-
656
- data = self._get_booking_raw(booking_uuid)
657
- return models.Booking(**data["data"])
658
-
659
- def get_booking_from_class(self, otf_class: str | models.OtfClass) -> models.Booking:
660
- """Get a specific booking by class_uuid or OtfClass object.
661
-
662
- Args:
663
- otf_class (str | OtfClass): The class UUID or the OtfClass object to get the booking for.
664
-
665
- Returns:
666
- Booking: The booking.
667
-
668
- Raises:
669
- BookingNotFoundError: If the booking does not exist.
670
- ValueError: If class_uuid is None or empty string.
671
- """
672
-
673
- class_uuid = get_class_uuid(otf_class)
674
-
675
- all_bookings = self.get_bookings(exclude_cancelled=False, exclude_checkedin=False)
676
-
677
- if booking := next((b for b in all_bookings if b.class_uuid == class_uuid), None):
678
- return booking
679
-
680
- raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
681
-
682
- def get_booking_from_class_new(self, otf_class: str | models.OtfClass | models.BookingV2Class) -> models.BookingV2:
683
- """Get a specific booking by class_uuid or OtfClass object.
684
-
685
- Args:
686
- otf_class (str | OtfClass | BookingV2Class): The class UUID or the OtfClass object to get the booking for.
687
-
688
- Returns:
689
- BookingV2: The booking.
690
-
691
- Raises:
692
- BookingNotFoundError: If the booking does not exist.
693
- ValueError: If class_uuid is None or empty string.
694
- """
695
-
696
- class_uuid = get_class_uuid(otf_class)
697
-
698
- all_bookings = self._get_all_bookings_new()
699
-
700
- if booking := next((b for b in all_bookings if b.class_uuid == class_uuid), None):
701
- return booking
702
-
703
- raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
704
-
705
- def book_class(self, otf_class: str | models.OtfClass) -> models.Booking:
706
- """Book a class by providing either the class_uuid or the OtfClass object.
707
-
708
- Args:
709
- otf_class (str | OtfClass): The class UUID or the OtfClass object to book.
710
-
711
- Returns:
712
- Booking: The booking.
713
-
714
- Raises:
715
- AlreadyBookedError: If the class is already booked.
716
- OutsideSchedulingWindowError: If the class is outside the scheduling window.
717
- ValueError: If class_uuid is None or empty string.
718
- OtfException: If there is an error booking the class.
719
- """
720
-
721
- class_uuid = get_class_uuid(otf_class)
722
-
723
- self._check_class_already_booked(class_uuid)
724
-
725
- if isinstance(otf_class, models.OtfClass):
726
- self._check_for_booking_conflicts(otf_class)
727
-
728
- body = {"classUUId": class_uuid, "confirmed": False, "waitlist": False}
729
-
730
- resp = self._book_class_raw(class_uuid, body)
731
-
732
- # get the booking uuid - we will only use this to return a Booking object using `get_booking`
733
- # this is an attempt to improve on OTF's terrible data model
734
- booking_uuid = resp["data"]["savedBookings"][0]["classBookingUUId"]
735
-
736
- booking = self.get_booking(booking_uuid)
737
-
738
- return booking
739
-
740
- def book_class_new(self, class_id: str) -> models.BookingV2:
741
- """Book a class by providing the class_id.
742
-
743
- Args:
744
- class_id (str): The class ID to book.
745
-
746
- Returns:
747
- BookingV2: The booking.
748
- """
749
- if not class_id:
750
- raise ValueError("class_id is required")
751
-
752
- body = {"class_id": class_id, "confirmed": False, "waitlist": False}
753
-
754
- resp = self._book_class_new_raw(body)
755
-
756
- new_booking = models.BookingV2(**resp)
757
-
758
- return new_booking
759
-
760
- def _check_class_already_booked(self, class_uuid: str) -> None:
761
- """Check if the class is already booked.
762
-
763
- Args:
764
- class_uuid (str): The class UUID to check.
765
-
766
- Raises:
767
- AlreadyBookedError: If the class is already booked.
768
- """
769
- existing_booking = None
770
-
771
- with contextlib.suppress(exc.BookingNotFoundError):
772
- existing_booking = self.get_booking_from_class(class_uuid)
773
-
774
- if not existing_booking:
775
- return
776
-
777
- if existing_booking.status != models.BookingStatus.Cancelled:
778
- raise exc.AlreadyBookedError(
779
- f"Class {class_uuid} is already booked.", booking_uuid=existing_booking.booking_uuid
780
- )
781
-
782
- def _check_for_booking_conflicts(self, otf_class: models.OtfClass) -> None:
783
- """Check for booking conflicts with the provided class.
784
-
785
- Checks the member's bookings to see if the provided class overlaps with any existing bookings. If a conflict is
786
- found, a ConflictingBookingError is raised.
787
- """
788
-
789
- bookings = self.get_bookings(start_date=otf_class.starts_at.date(), end_date=otf_class.starts_at.date())
790
- if not bookings:
791
- return
792
-
793
- for booking in bookings:
794
- booking_start = booking.otf_class.starts_at
795
- booking_end = booking.otf_class.ends_at
796
- # Check for overlap
797
- if not (otf_class.ends_at < booking_start or otf_class.starts_at > booking_end):
798
- raise exc.ConflictingBookingError(
799
- f"You already have a booking that conflicts with this class ({booking.otf_class.class_uuid}).",
800
- booking_uuid=booking.booking_uuid,
801
- )
802
-
803
- def cancel_booking(self, booking: str | models.Booking) -> None:
804
- """Cancel a booking by providing either the booking_uuid or the Booking object.
805
-
806
- Args:
807
- booking (str | Booking): The booking UUID or the Booking object to cancel.
808
-
809
- Raises:
810
- ValueError: If booking_uuid is None or empty string
811
- BookingNotFoundError: If the booking does not exist.
812
- """
813
- if isinstance(booking, models.BookingV2):
814
- LOGGER.warning("BookingV2 object provided, using the new cancel booking endpoint (`cancel_booking_new`)")
815
- self.cancel_booking_new(booking)
816
-
817
- booking_uuid = get_booking_uuid(booking)
818
-
819
- if booking == booking_uuid: # ensure this booking exists by calling the booking endpoint
820
- _ = self.get_booking(booking_uuid) # allow the exception to be raised if it doesn't exist
821
-
822
- resp = self._cancel_booking_raw(booking_uuid)
823
- if resp["code"] == "NOT_AUTHORIZED" and resp["message"].startswith("This class booking has"):
824
- raise exc.BookingAlreadyCancelledError(
825
- f"Booking {booking_uuid} is already cancelled.", booking_uuid=booking_uuid
826
- )
827
-
828
- def cancel_booking_new(self, booking: str | models.BookingV2) -> None:
829
- """Cancel a booking by providing either the booking_id or the BookingV2 object.
830
-
831
- Args:
832
- booking (str | BookingV2): The booking ID or the BookingV2 object to cancel.
833
-
834
- Raises:
835
- ValueError: If booking_id is None or empty string
836
- BookingNotFoundError: If the booking does not exist.
837
- """
838
-
839
- if isinstance(booking, models.Booking):
840
- LOGGER.warning("Booking object provided, using the old cancel booking endpoint (`cancel_booking`)")
841
- self.cancel_booking(booking)
842
-
843
- booking_id = get_booking_id(booking)
844
-
845
- if booking == booking_id:
846
- _ = self.get_booking_new(booking_id) # allow the exception to be raised if it doesn't exist
847
-
848
- self._cancel_booking_new_raw(booking_id)
849
-
850
- def get_bookings(
851
- self,
852
- start_date: date | str | None = None,
853
- end_date: date | str | None = None,
854
- status: models.BookingStatus | list[models.BookingStatus] | None = None,
855
- exclude_cancelled: bool = True,
856
- exclude_checkedin: bool = True,
857
- ) -> list[models.Booking]:
858
- """Get the member's bookings.
859
-
860
- Args:
861
- start_date (date | str | None): The start date for the bookings. Default is None.
862
- end_date (date | str | None): The end date for the bookings. Default is None.
863
- status (BookingStatus | list[BookingStatus] | None): The status(es) to filter by. Default is None.
864
- exclude_cancelled (bool): Whether to exclude cancelled bookings. Default is True.
865
- exclude_checkedin (bool): Whether to exclude checked-in bookings. Default is True.
866
-
867
- Returns:
868
- list[Booking]: The member's bookings.
869
-
870
- Warning:
871
- ---
872
- Incorrect statuses do not cause any bad status code, they just return no results.
873
-
874
- Tip:
875
- ---
876
- `CheckedIn` - you must provide dates if you want to get bookings with a status of CheckedIn. If you do not
877
- provide dates, the endpoint will return no results for this status.
878
-
879
- Dates Notes:
880
- ---
881
- If dates are provided, the endpoint will return bookings where the class date is within the provided
882
- date range. If no dates are provided, it will go back 45 days and forward about 30 days.
883
- """
884
-
885
- if exclude_cancelled and status == models.BookingStatus.Cancelled:
886
- LOGGER.warning(
887
- "Cannot exclude cancelled bookings when status is Cancelled. Setting exclude_cancelled to False."
888
- )
889
- exclude_cancelled = False
890
-
891
- if isinstance(start_date, date):
892
- start_date = start_date.isoformat()
893
-
894
- if isinstance(end_date, date):
895
- end_date = end_date.isoformat()
896
-
897
- if isinstance(status, list):
898
- status_value = ",".join(status)
899
- elif isinstance(status, models.BookingStatus):
900
- status_value = status.value
901
- elif isinstance(status, str):
902
- status_value = status
903
- else:
904
- status_value = None
905
-
906
- resp = self._get_bookings_raw(start_date, end_date, status_value)["data"]
907
-
908
- # add studio details for each booking, instead of using the different studio model returned by this endpoint
909
- studio_uuids = {b["class"]["studio"]["studioUUId"] for b in resp}
910
- studios = {studio_uuid: self.get_studio_detail(studio_uuid) for studio_uuid in studio_uuids}
911
-
912
- for b in resp:
913
- b["class"]["studio"] = studios[b["class"]["studio"]["studioUUId"]]
914
- b["is_home_studio"] = b["class"]["studio"].studio_uuid == self.home_studio_uuid
915
-
916
- bookings = [models.Booking(**b) for b in resp]
917
- bookings = sorted(bookings, key=lambda x: x.otf_class.starts_at)
918
-
919
- if exclude_cancelled:
920
- bookings = [b for b in bookings if b.status != models.BookingStatus.Cancelled]
921
-
922
- if exclude_checkedin:
923
- bookings = [b for b in bookings if b.status != models.BookingStatus.CheckedIn]
924
-
925
- return bookings
926
-
927
- def get_historical_bookings(self) -> list[models.Booking]:
928
- """Get the member's historical bookings. This will go back 45 days and return all bookings
929
- for that time period.
930
-
931
- Returns:
932
- list[Booking]: The member's historical bookings.
933
- """
934
- # api goes back 45 days but we'll go back 47 to be safe
935
- start_date = datetime.today().date() - timedelta(days=47)
936
- end_date = datetime.today().date()
937
-
938
- return self.get_bookings(
939
- start_date=start_date,
940
- end_date=end_date,
941
- status=HISTORICAL_BOOKING_STATUSES,
942
- exclude_cancelled=False,
943
- exclude_checkedin=False,
944
- )
945
-
946
- def get_member_detail(self) -> models.MemberDetail:
947
- """Get the member details.
948
-
949
- Returns:
950
- MemberDetail: The member details.
951
- """
952
-
953
- resp = self._get_member_detail_raw()
954
- data = resp["data"]
955
-
956
- # use standard StudioDetail model instead of the one returned by this endpoint
957
- home_studio_uuid = data["homeStudio"]["studioUUId"]
958
- data["home_studio"] = self.get_studio_detail(home_studio_uuid)
959
-
960
- return models.MemberDetail(**data)
961
-
962
- def get_member_membership(self) -> models.MemberMembership:
963
- """Get the member's membership details.
964
-
965
- Returns:
966
- MemberMembership: The member's membership details.
967
- """
968
-
969
- data = self._get_member_membership_raw()
970
- return models.MemberMembership(**data["data"])
971
-
972
- def get_member_purchases(self) -> list[models.MemberPurchase]:
973
- """Get the member's purchases, including monthly subscriptions and class packs.
974
-
975
- Returns:
976
- list[MemberPurchase]: The member's purchases.
977
- """
978
- purchases = self._get_member_purchases_raw()["data"]
979
-
980
- for p in purchases:
981
- p["studio"] = self.get_studio_detail(p["studio"]["studioUUId"])
982
-
983
- return [models.MemberPurchase(**purchase) for purchase in purchases]
984
-
985
- def _get_member_lifetime_stats(
986
- self, select_time: models.StatsTime = models.StatsTime.AllTime
987
- ) -> models.StatsResponse:
988
- """Get the member's lifetime stats.
989
-
990
- Args:
991
- select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
992
-
993
- Notes:
994
- ---
995
- The time period provided in the path does not do anything, and the endpoint always returns the same data.
996
- It is being provided anyway, in case this changes in the future.
997
-
998
- Returns:
999
- StatsResponse: The member's lifetime stats.
1000
- """
1001
-
1002
- data = self._get_member_lifetime_stats_raw(select_time.value)
1003
-
1004
- stats = models.StatsResponse(**data["data"])
1005
-
1006
- return stats
1007
-
1008
- def get_member_lifetime_stats_in_studio(
1009
- self, select_time: models.StatsTime = models.StatsTime.AllTime
1010
- ) -> models.InStudioStatsData:
1011
- """Get the member's lifetime stats in studio.
1012
-
1013
- Args:
1014
- select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
1015
-
1016
- Returns:
1017
- InStudioStatsData: The member's lifetime stats in studio.
1018
- """
1019
-
1020
- data = self._get_member_lifetime_stats(select_time)
1021
-
1022
- return data.in_studio.get_by_time(select_time)
1023
-
1024
- def get_member_lifetime_stats_out_of_studio(
1025
- self, select_time: models.StatsTime = models.StatsTime.AllTime
1026
- ) -> models.OutStudioStatsData:
1027
- """Get the member's lifetime stats out of studio.
1028
-
1029
- Args:
1030
- select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
1031
-
1032
- Returns:
1033
- OutStudioStatsData: The member's lifetime stats out of studio.
1034
- """
1035
-
1036
- data = self._get_member_lifetime_stats(select_time)
1037
-
1038
- return data.out_studio.get_by_time(select_time)
1039
-
1040
- def get_out_of_studio_workout_history(self) -> list[models.OutOfStudioWorkoutHistory]:
1041
- """Get the member's out of studio workout history.
1042
-
1043
- Returns:
1044
- list[OutOfStudioWorkoutHistory]: The member's out of studio workout history.
1045
- """
1046
- data = self._get_out_of_studio_workout_history_raw()
1047
-
1048
- return [models.OutOfStudioWorkoutHistory(**workout) for workout in data["data"]]
1049
-
1050
- def get_favorite_studios(self) -> list[models.StudioDetail]:
1051
- """Get the member's favorite studios.
1052
-
1053
- Returns:
1054
- list[StudioDetail]: The member's favorite studios.
1055
- """
1056
- data = self._get_favorite_studios_raw()
1057
- studio_uuids = [studio["studioUUId"] for studio in data["data"]]
1058
- return [self.get_studio_detail(studio_uuid) for studio_uuid in studio_uuids]
1059
-
1060
- def add_favorite_studio(self, studio_uuids: list[str] | str) -> list[models.StudioDetail]:
1061
- """Add a studio to the member's favorite studios.
1062
-
1063
- Args:
1064
- studio_uuids (list[str] | str): The studio UUID or list of studio UUIDs to add to the member's favorite\
1065
- studios. If a string is provided, it will be converted to a list.
1066
-
1067
- Returns:
1068
- list[StudioDetail]: The new favorite studios.
1069
- """
1070
- studio_uuids = ensure_list(studio_uuids)
1071
-
1072
- if not studio_uuids:
1073
- raise ValueError("studio_uuids is required")
1074
-
1075
- resp = self._add_favorite_studio_raw(studio_uuids)
1076
-
1077
- new_faves = resp.get("data", {}).get("studios", [])
1078
-
1079
- return [models.StudioDetail(**studio) for studio in new_faves]
1080
-
1081
- def remove_favorite_studio(self, studio_uuids: list[str] | str) -> None:
1082
- """Remove a studio from the member's favorite studios.
1083
-
1084
- Args:
1085
- studio_uuids (list[str] | str): The studio UUID or list of studio UUIDs to remove from the member's\
1086
- favorite studios. If a string is provided, it will be converted to a list.
1087
-
1088
- Returns:
1089
- None
1090
- """
1091
- studio_uuids = ensure_list(studio_uuids)
1092
-
1093
- if not studio_uuids:
1094
- raise ValueError("studio_uuids is required")
1095
-
1096
- # keeping the convention of regular/raw methods even though this method doesn't return anything
1097
- # in case that changes in the future
1098
- self._remove_favorite_studio_raw(studio_uuids)
1099
-
1100
- def get_studio_services(self, studio_uuid: str | None = None) -> list[models.StudioService]:
1101
- """Get the services available at a specific studio. If no studio UUID is provided, the member's home studio
1102
- will be used.
1103
-
1104
- Args:
1105
- studio_uuid (str, optional): The studio UUID to get services for.
1106
-
1107
- Returns:
1108
- list[StudioService]: The services available at the studio.
1109
- """
1110
- studio_uuid = studio_uuid or self.home_studio_uuid
1111
- data = self._get_studio_services_raw(studio_uuid)
1112
-
1113
- for d in data["data"]:
1114
- d["studio"] = self.get_studio_detail(studio_uuid)
1115
-
1116
- return [models.StudioService(**d) for d in data["data"]]
1117
-
1118
- @cached(cache=TTLCache(maxsize=1024, ttl=600))
1119
- def get_studio_detail(self, studio_uuid: str | None = None) -> models.StudioDetail:
1120
- """Get detailed information about a specific studio. If no studio UUID is provided, it will default to the
1121
- user's home studio.
1122
-
1123
- If the studio is not found, it will return a StudioDetail object with default values.
1124
-
1125
- Args:
1126
- studio_uuid (str, optional): The studio UUID to get detailed information about.
1127
-
1128
- Returns:
1129
- StudioDetail: Detailed information about the studio.
1130
- """
1131
- studio_uuid = studio_uuid or self.home_studio_uuid
1132
-
1133
- try:
1134
- res = self._get_studio_detail_raw(studio_uuid)
1135
- except exc.ResourceNotFoundError:
1136
- return models.StudioDetail.create_empty_model(studio_uuid)
1137
-
1138
- return models.StudioDetail(**res["data"])
1139
-
1140
- def get_studios_by_geo(
1141
- self, latitude: float | None = None, longitude: float | None = None
1142
- ) -> list[models.StudioDetail]:
1143
- """Alias for search_studios_by_geo."""
1144
-
1145
- return self.search_studios_by_geo(latitude, longitude)
1146
-
1147
- def search_studios_by_geo(
1148
- self, latitude: float | None = None, longitude: float | None = None, distance: int = 50
1149
- ) -> list[models.StudioDetail]:
1150
- """Search for studios by geographic location.
1151
-
1152
- Args:
1153
- latitude (float, optional): Latitude of the location to search around, if None uses home studio latitude.
1154
- longitude (float, optional): Longitude of the location to search around, if None uses home studio longitude.
1155
- distance (int, optional): The distance in miles to search around the location. Default is 50.
1156
-
1157
- Returns:
1158
- list[StudioDetail]: List of studios that match the search criteria.
1159
- """
1160
- latitude = latitude or self.home_studio.location.latitude
1161
- longitude = longitude or self.home_studio.location.longitude
1162
-
1163
- return self._get_studios_by_geo(latitude, longitude, distance)
1164
-
1165
- def _get_all_studios(self) -> list[models.StudioDetail]:
1166
- """Gets all studios. Marked as private to avoid random users calling it. Useful for testing and validating
1167
- models.
1168
-
1169
- Returns:
1170
- list[StudioDetail]: List of studios that match the search criteria.
1171
- """
1172
- # long/lat being None will cause the endpoint to return all studios
1173
- return self._get_studios_by_geo(None, None)
1174
-
1175
- def _get_studios_by_geo(
1176
- self, latitude: float | None, longitude: float | None, distance: int = 50
1177
- ) -> list[models.StudioDetail]:
1178
- """
1179
- Searches for studios by geographic location.
1180
-
1181
- Args:
1182
- latitude (float | None): Latitude of the location.
1183
- longitude (float | None): Longitude of the location.
1184
-
1185
- Returns:
1186
- list[models.StudioDetail]: List of studios matching the search criteria.
1187
- """
1188
- distance = min(distance, 250) # max distance is 250 miles
1189
- page_size = 100
1190
- page_index = 1
1191
- LOGGER.debug(
1192
- "Starting studio search",
1193
- extra={
1194
- "latitude": latitude,
1195
- "longitude": longitude,
1196
- "distance": distance,
1197
- "page_index": page_index,
1198
- "page_size": page_size,
1199
- },
1200
- )
1201
-
1202
- all_results: dict[str, dict[str, Any]] = {}
1203
-
1204
- while True:
1205
- res = self._get_studios_by_geo_raw(latitude, longitude, distance, page_index, page_size)
1206
-
1207
- studios = res["data"].get("studios", [])
1208
- total_count = res["data"].get("pagination", {}).get("totalCount", 0)
1209
-
1210
- all_results.update({studio["studioUUId"]: studio for studio in studios})
1211
- if len(all_results) >= total_count or not studios:
1212
- break
1213
-
1214
- page_index += 1
1215
-
1216
- LOGGER.info("Studio search completed, fetched %d of %d studios", len(all_results), total_count, stacklevel=2)
1217
-
1218
- return [models.StudioDetail(**studio) for studio in all_results.values()]
1219
-
1220
- def get_body_composition_list(self) -> list[models.BodyCompositionData]:
1221
- """Get the member's body composition list.
1222
-
1223
- Returns:
1224
- list[BodyCompositionData]: The member's body composition list.
1225
- """
1226
- data = self._get_body_composition_list_raw()
1227
- return [models.BodyCompositionData(**item) for item in data["data"]]
1228
-
1229
- def get_challenge_tracker(self) -> models.ChallengeTracker:
1230
- """Get the member's challenge tracker content.
1231
-
1232
- Returns:
1233
- ChallengeTracker: The member's challenge tracker content.
1234
- """
1235
- data = self._get_challenge_tracker_raw()
1236
- return models.ChallengeTracker(**data["Dto"])
1237
-
1238
- def get_benchmarks(
1239
- self,
1240
- challenge_category_id: int = 0,
1241
- equipment_id: models.EquipmentType | Literal[0] = 0,
1242
- challenge_subcategory_id: int = 0,
1243
- ) -> list[models.FitnessBenchmark]:
1244
- """Get the member's challenge tracker participation details.
1245
-
1246
- Args:
1247
- challenge_category_id (int): The challenge type ID.
1248
- equipment_id (EquipmentType | Literal[0]): The equipment ID, default is 0 - this doesn't seem\
1249
- to be have any impact on the results.
1250
- challenge_subcategory_id (int): The challenge sub type ID. Default is 0 - this doesn't seem\
1251
- to be have any impact on the results.
1252
-
1253
- Returns:
1254
- list[FitnessBenchmark]: The member's challenge tracker details.
1255
- """
1256
- data = self._get_benchmarks_raw(int(challenge_category_id), int(equipment_id), challenge_subcategory_id)
1257
- return [models.FitnessBenchmark(**item) for item in data["Dto"]]
1258
-
1259
- def get_benchmarks_by_equipment(self, equipment_id: models.EquipmentType) -> list[models.FitnessBenchmark]:
1260
- """Get the member's challenge tracker participation details by equipment.
1261
-
1262
- Args:
1263
- equipment_id (EquipmentType): The equipment type ID.
1264
-
1265
- Returns:
1266
- list[FitnessBenchmark]: The member's challenge tracker details.
1267
- """
1268
- benchmarks = self.get_benchmarks(equipment_id=equipment_id)
1269
-
1270
- benchmarks = [b for b in benchmarks if b.equipment_id == equipment_id]
1271
-
1272
- return benchmarks
1273
-
1274
- def get_benchmarks_by_challenge_category(self, challenge_category_id: int) -> list[models.FitnessBenchmark]:
1275
- """Get the member's challenge tracker participation details by challenge.
1276
-
1277
- Args:
1278
- challenge_category_id (int): The challenge type ID.
1279
-
1280
- Returns:
1281
- list[FitnessBenchmark]: The member's challenge tracker details.
1282
- """
1283
- benchmarks = self.get_benchmarks(challenge_category_id=challenge_category_id)
1284
-
1285
- benchmarks = [b for b in benchmarks if b.challenge_category_id == challenge_category_id]
1286
-
1287
- return benchmarks
1288
-
1289
- def get_challenge_tracker_detail(self, challenge_category_id: int) -> models.FitnessBenchmark:
1290
- """Get details about a challenge. This endpoint does not (usually) return member participation, but rather
1291
- details about the challenge itself.
1292
-
1293
- Args:
1294
- challenge_category_id (int): The challenge type ID.
1295
-
1296
- Returns:
1297
- FitnessBenchmark: Details about the challenge.
1298
- """
1299
-
1300
- data = self._get_challenge_tracker_detail_raw(int(challenge_category_id))
1301
-
1302
- if len(data["Dto"]) > 1:
1303
- LOGGER.warning("Multiple challenge participations found, returning the first one.")
1304
-
1305
- if len(data["Dto"]) == 0:
1306
- raise exc.ResourceNotFoundError(f"Challenge {challenge_category_id} not found")
1307
-
1308
- return models.FitnessBenchmark(**data["Dto"][0])
1309
-
1310
- def get_performance_summary(self, performance_summary_id: str) -> models.PerformanceSummary:
1311
- """Get the details for a performance summary. Generally should not be called directly. This
1312
-
1313
- Args:
1314
- performance_summary_id (str): The performance summary ID.
1315
-
1316
- Returns:
1317
- dict[str, Any]: The performance summary details.
1318
- """
1319
-
1320
- warning_msg = "This endpoint does not return all data, consider using `get_workouts` instead."
1321
- if warning_msg not in LOGGED_ONCE:
1322
- LOGGER.warning(warning_msg)
1323
-
1324
- resp = self._get_performance_summary_raw(performance_summary_id)
1325
- return models.PerformanceSummary(**resp)
1326
-
1327
- def get_hr_history(self) -> list[models.TelemetryHistoryItem]:
1328
- """Get the heartrate history for the user.
1329
-
1330
- Returns a list of history items that contain the max heartrate, start/end bpm for each zone,
1331
- the change from the previous, the change bucket, and the assigned at time.
1332
-
1333
- Returns:
1334
- list[HistoryItem]: The heartrate history for the user.
1335
-
1336
- """
1337
- resp = self._get_hr_history_raw()
1338
- return [models.TelemetryHistoryItem(**item) for item in resp["history"]]
1339
-
1340
- def get_telemetry(self, performance_summary_id: str, max_data_points: int = 150) -> models.Telemetry:
1341
- """Get the telemetry for a performance summary.
1342
-
1343
- This returns an object that contains the max heartrate, start/end bpm for each zone,
1344
- and a list of telemetry items that contain the heartrate, splat points, calories, and timestamp.
1345
-
1346
- Args:
1347
- performance_summary_id (str): The performance summary id.
1348
- max_data_points (int): The max data points to use for the telemetry. Default is 150, to match the app.
1349
-
1350
- Returns:
1351
- TelemetryItem: The telemetry for the class history.
1352
- """
1353
-
1354
- res = self._get_telemetry_raw(performance_summary_id, max_data_points)
1355
- return models.Telemetry(**res)
1356
-
1357
- def get_sms_notification_settings(self) -> models.SmsNotificationSettings:
1358
- """Get the member's SMS notification settings.
1359
-
1360
- Returns:
1361
- SmsNotificationSettings: The member's SMS notification settings.
1362
- """
1363
- res = self._get_sms_notification_settings_raw()
1364
-
1365
- return models.SmsNotificationSettings(**res["data"])
1366
-
1367
- def update_sms_notification_settings(
1368
- self, promotional_enabled: bool | None = None, transactional_enabled: bool | None = None
1369
- ) -> models.SmsNotificationSettings:
1370
- """Update the member's SMS notification settings. Arguments not provided will be left unchanged.
1371
-
1372
- Args:
1373
- promotional_enabled (bool | None): Whether to enable promotional SMS notifications.
1374
- transactional_enabled (bool | None): Whether to enable transactional SMS notifications.
1375
-
1376
- Returns:
1377
- SmsNotificationSettings: The updated SMS notification settings.
1378
-
1379
- Warning:
1380
- ---
1381
- This endpoint seems to accept almost anything, converting values to truthy/falsey and
1382
- updating the settings accordingly. The one error I've gotten is with -1
1383
-
1384
- ```
1385
- ERROR - Response:
1386
- {
1387
- "code": "ER_WARN_DATA_OUT_OF_RANGE",
1388
- "message": "An unexpected server error occurred, please try again.",
1389
- "details": [
1390
- {
1391
- "message": "ER_WARN_DATA_OUT_OF_RANGE: Out of range value for column 'IsPromotionalSMSOptIn' at row 1",
1392
- "additionalInfo": ""
1393
- }
1394
- ]
1395
- }
1396
- ```
1397
- """
1398
-
1399
- current_settings = self.get_sms_notification_settings()
1400
-
1401
- promotional_enabled = (
1402
- promotional_enabled if promotional_enabled is not None else current_settings.is_promotional_sms_opt_in
1403
- )
1404
- transactional_enabled = (
1405
- transactional_enabled if transactional_enabled is not None else current_settings.is_transactional_sms_opt_in
1406
- )
1407
-
1408
- self._update_sms_notification_settings_raw(promotional_enabled, transactional_enabled) # type: ignore
1409
-
1410
- # the response returns nothing useful, so we just query the settings again
1411
- new_settings = self.get_sms_notification_settings()
1412
- return new_settings
1413
-
1414
- def get_email_notification_settings(self) -> models.EmailNotificationSettings:
1415
- """Get the member's email notification settings.
1416
-
1417
- Returns:
1418
- EmailNotificationSettings: The member's email notification settings.
1419
- """
1420
- res = self._get_email_notification_settings_raw()
1421
-
1422
- return models.EmailNotificationSettings(**res["data"])
1423
-
1424
- def update_email_notification_settings(
1425
- self, promotional_enabled: bool | None = None, transactional_enabled: bool | None = None
1426
- ) -> models.EmailNotificationSettings:
1427
- """Update the member's email notification settings. Arguments not provided will be left unchanged.
1428
-
1429
- Args:
1430
- promotional_enabled (bool | None): Whether to enable promotional email notifications.
1431
- transactional_enabled (bool | None): Whether to enable transactional email notifications.
1432
-
1433
- Returns:
1434
- EmailNotificationSettings: The updated email notification settings.
1435
- """
1436
- current_settings = self.get_email_notification_settings()
1437
-
1438
- promotional_enabled = (
1439
- promotional_enabled if promotional_enabled is not None else current_settings.is_promotional_email_opt_in
1440
- )
1441
- transactional_enabled = (
1442
- transactional_enabled
1443
- if transactional_enabled is not None
1444
- else current_settings.is_transactional_email_opt_in
1445
- )
1446
-
1447
- self._update_email_notification_settings_raw(promotional_enabled, transactional_enabled) # type: ignore
1448
-
1449
- # the response returns nothing useful, so we just query the settings again
1450
- new_settings = self.get_email_notification_settings()
1451
- return new_settings
1452
-
1453
- def update_member_name(self, first_name: str | None = None, last_name: str | None = None) -> models.MemberDetail:
1454
- """Update the member's name. Will return the original member details if no names are provided.
1455
-
1456
- Args:
1457
- first_name (str | None): The first name to update to. Default is None.
1458
- last_name (str | None): The last name to update to. Default is None.
1459
-
1460
- Returns:
1461
- MemberDetail: The updated member details or the original member details if no changes were made.
1462
- """
1463
-
1464
- if not first_name and not last_name:
1465
- LOGGER.warning("No names provided, nothing to update.")
1466
- return self.member
1467
-
1468
- first_name = first_name or self.member.first_name
1469
- last_name = last_name or self.member.last_name
1470
-
1471
- if first_name == self.member.first_name and last_name == self.member.last_name:
1472
- LOGGER.warning("No changes to names, nothing to update.")
1473
- return self.member
1474
-
1475
- assert first_name is not None, "First name is required"
1476
- assert last_name is not None, "Last name is required"
1477
-
1478
- res = self._update_member_name_raw(first_name, last_name)
1479
-
1480
- return models.MemberDetail(**res["data"])
1481
-
1482
- def rate_class(
1483
- self,
1484
- class_uuid: str,
1485
- performance_summary_id: str,
1486
- class_rating: Literal[0, 1, 2, 3],
1487
- coach_rating: Literal[0, 1, 2, 3],
1488
- ):
1489
- """Rate a class and coach. A simpler method is provided in `rate_class_from_workout`.
1490
-
1491
- The class rating must be between 0 and 4.
1492
- 0 is the same as dismissing the prompt to rate the class/coach in the app.
1493
- 1 through 3 is a range from bad to good.
1494
-
1495
- Args:
1496
- class_uuid (str): The class UUID.
1497
- performance_summary_id (str): The performance summary ID.
1498
- class_rating (int): The class rating. Must be 0, 1, 2, or 3.
1499
- coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
1500
-
1501
- Returns:
1502
- None
1503
-
1504
- """
1505
-
1506
- body_class_rating = models.get_class_rating_value(class_rating)
1507
- body_coach_rating = models.get_coach_rating_value(coach_rating)
1508
-
1509
- try:
1510
- self._rate_class_raw(class_uuid, performance_summary_id, body_class_rating, body_coach_rating)
1511
- except exc.OtfRequestError as e:
1512
- if e.response.status_code == 403:
1513
- raise exc.AlreadyRatedError(f"Workout {performance_summary_id} is already rated.") from None
1514
- raise
1515
-
1516
- def get_workout_from_booking(self, booking: str | models.BookingV2) -> models.Workout:
1517
- """Get a workout for a specific booking.
1518
-
1519
- Args:
1520
- booking (str | Booking): The booking ID or BookingV2 object to get the workout for.
1521
-
1522
- Returns:
1523
- Workout: The member's workout.
1524
-
1525
- Raises:
1526
- BookingNotFoundError: If the booking does not exist.
1527
- ResourceNotFoundError: If the workout does not exist.
1528
- """
1529
- booking_id = get_booking_id(booking)
1530
-
1531
- booking = self.get_booking_new(booking_id)
1532
-
1533
- if not booking.workout or not booking.workout.performance_summary_id:
1534
- raise exc.ResourceNotFoundError(f"Workout for booking {booking_id} not found.")
1535
-
1536
- perf_summary = self._get_performance_summary_raw(booking.workout.performance_summary_id)
1537
- telemetry = self.get_telemetry(booking.workout.performance_summary_id)
1538
- workout = models.Workout(**perf_summary, v2_booking=booking, telemetry=telemetry)
1539
-
1540
- return workout
1541
-
1542
- def get_workouts(
1543
- self, start_date: date | str | None = None, end_date: date | str | None = None
1544
- ) -> list[models.Workout]:
1545
- """Get the member's workouts, using the new bookings endpoint and the performance summary endpoint.
1546
-
1547
- Args:
1548
- start_date (date | str | None): The start date for the workouts. If None, defaults to 30 days ago.
1549
- end_date (date | str | None): The end date for the workouts. If None, defaults to today.
1550
-
1551
- Returns:
1552
- list[Workout]: The member's workouts.
1553
- """
1554
- start_date = ensure_date(start_date) or pendulum.today().subtract(days=30).date()
1555
- end_date = ensure_date(end_date) or datetime.today().date()
1556
-
1557
- start_dtme = pendulum.datetime(start_date.year, start_date.month, start_date.day, 0, 0, 0)
1558
- end_dtme = pendulum.datetime(end_date.year, end_date.month, end_date.day, 23, 59, 59)
1559
-
1560
- bookings = self.get_bookings_new(start_dtme, end_dtme, exclude_cancelled=False)
1561
- bookings_dict = {b.workout.id: b for b in bookings if b.workout}
1562
-
1563
- perf_summaries_dict = self._get_perf_summaries_threaded(list(bookings_dict.keys()))
1564
- telemetry_dict = self._get_telemetry_threaded(list(perf_summaries_dict.keys()))
1565
- perf_summary_to_class_uuid_map = self._get_perf_summary_to_class_uuid_mapping()
1566
-
1567
- workouts: list[models.Workout] = []
1568
- for perf_id, perf_summary in perf_summaries_dict.items():
1569
- workout = models.Workout(
1570
- **perf_summary,
1571
- v2_booking=bookings_dict[perf_id],
1572
- telemetry=telemetry_dict.get(perf_id),
1573
- class_uuid=perf_summary_to_class_uuid_map.get(perf_id),
1574
- )
1575
- workouts.append(workout)
1576
-
1577
- return workouts
1578
-
1579
- def _get_perf_summary_to_class_uuid_mapping(self) -> dict[str, str | None]:
1580
- """Get a mapping of performance summary IDs to class UUIDs. These will be used
1581
- when rating a class.
1582
-
1583
- Returns:
1584
- dict[str, str | None]: A dictionary mapping performance summary IDs to class UUIDs.
1585
- """
1586
- perf_summaries = self._get_performance_summaries_raw()["items"]
1587
- return {item["id"]: item["class"].get("ot_base_class_uuid") for item in perf_summaries}
1588
-
1589
- def _get_perf_summaries_threaded(self, performance_summary_ids: list[str]) -> dict[str, dict[str, Any]]:
1590
- """Get performance summaries in a ThreadPoolExecutor, to speed up the process.
1591
-
1592
- Args:
1593
- performance_summary_ids (list[str]): The performance summary IDs to get.
1594
-
1595
- Returns:
1596
- dict[str, dict[str, Any]]: A dictionary of performance summaries, keyed by performance summary ID.
1597
- """
1598
-
1599
- with ThreadPoolExecutor(max_workers=10) as pool:
1600
- perf_summaries = pool.map(self._get_performance_summary_raw, performance_summary_ids)
1601
-
1602
- perf_summaries_dict = {perf_summary["id"]: perf_summary for perf_summary in perf_summaries}
1603
- return perf_summaries_dict
1604
-
1605
- def _get_telemetry_threaded(
1606
- self, performance_summary_ids: list[str], max_data_points: int = 150
1607
- ) -> dict[str, models.Telemetry]:
1608
- """Get telemetry in a ThreadPoolExecutor, to speed up the process.
1609
-
1610
- Args:
1611
- performance_summary_ids (list[str]): The performance summary IDs to get.
1612
- max_data_points (int): The max data points to use for the telemetry. Default is 150.
1613
-
1614
- Returns:
1615
- dict[str, Telemetry]: A dictionary of telemetry, keyed by performance summary ID.
1616
- """
1617
- partial_fn = partial(self.get_telemetry, max_data_points=max_data_points)
1618
- with ThreadPoolExecutor(max_workers=10) as pool:
1619
- telemetry = pool.map(partial_fn, performance_summary_ids)
1620
- telemetry_dict = {perf_summary.performance_summary_id: perf_summary for perf_summary in telemetry}
1621
- return telemetry_dict
1622
-
1623
- def rate_class_from_workout(
1624
- self,
1625
- workout: models.Workout,
1626
- class_rating: Literal[0, 1, 2, 3],
1627
- coach_rating: Literal[0, 1, 2, 3],
1628
- ) -> models.Workout:
1629
- """Rate a class and coach. The class rating must be 0, 1, 2, or 3. 0 is the same as dismissing the prompt to
1630
- rate the class/coach. 1 - 3 is a range from bad to good.
1631
-
1632
- Args:
1633
- workout (Workout): The workout to rate.
1634
- class_rating (int): The class rating. Must be 0, 1, 2, or 3.
1635
- coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
1636
-
1637
- Returns:
1638
- Workout: The updated workout with the new ratings.
1639
-
1640
- Raises:
1641
- AlreadyRatedError: If the performance summary is already rated.
1642
- ClassNotRatableError: If the performance summary is not rateable.
1643
- """
1644
-
1645
- if not workout.ratable or not workout.class_uuid:
1646
- raise exc.ClassNotRatableError(f"Workout {workout.performance_summary_id} is not rateable.")
1647
-
1648
- if workout.class_rating is not None or workout.coach_rating is not None:
1649
- raise exc.AlreadyRatedError(f"Workout {workout.performance_summary_id} already rated.")
1650
-
1651
- self.rate_class(workout.class_uuid, workout.performance_summary_id, class_rating, coach_rating)
1652
-
1653
- return self.get_workout_from_booking(workout.booking_id)
1654
-
1655
- # the below do not return any data for me, so I can't test them
1656
-
1657
- def _get_member_services(self, active_only: bool = True) -> Any:
1658
- """Get the member's services.
1659
-
1660
- Args:
1661
- active_only (bool): Whether to only include active services. Default is True.
1662
-
1663
- Returns:
1664
- Any: The member's services.
1665
- """
1666
- data = self._get_member_services_raw(active_only)
1667
- return data
1668
-
1669
- def _get_aspire_data(self, datetime: str | None = None, unit: str | None = None) -> Any:
1670
- """Get data from the member's aspire wearable.
1671
-
1672
- Note: I don't have an aspire wearable, so I can't test this.
1673
-
1674
- Args:
1675
- datetime (str | None): The date and time to get data for. Default is None.
1676
- unit (str | None): The measurement unit. Default is None.
1677
-
1678
- Returns:
1679
- Any: The member's aspire data.
1680
- """
1681
- data = self._get_aspire_data_raw(datetime, unit)
1682
- return data