otf-api 0.12.0__py3-none-any.whl → 0.13.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- otf_api/__init__.py +35 -3
- otf_api/api/__init__.py +3 -0
- otf_api/api/_compat.py +77 -0
- otf_api/api/api.py +80 -0
- otf_api/api/bookings/__init__.py +3 -0
- otf_api/api/bookings/booking_api.py +541 -0
- otf_api/api/bookings/booking_client.py +112 -0
- otf_api/api/client.py +203 -0
- otf_api/api/members/__init__.py +3 -0
- otf_api/api/members/member_api.py +187 -0
- otf_api/api/members/member_client.py +112 -0
- otf_api/api/studios/__init__.py +3 -0
- otf_api/api/studios/studio_api.py +173 -0
- otf_api/api/studios/studio_client.py +120 -0
- otf_api/api/utils.py +307 -0
- otf_api/api/workouts/__init__.py +3 -0
- otf_api/api/workouts/workout_api.py +333 -0
- otf_api/api/workouts/workout_client.py +140 -0
- otf_api/auth/__init__.py +1 -1
- otf_api/auth/auth.py +155 -89
- otf_api/auth/user.py +5 -17
- otf_api/auth/utils.py +27 -2
- otf_api/cache.py +132 -0
- otf_api/exceptions.py +18 -6
- otf_api/models/__init__.py +25 -21
- otf_api/models/bookings/__init__.py +23 -0
- otf_api/models/bookings/bookings.py +134 -0
- otf_api/models/{bookings_v2.py → bookings/bookings_v2.py} +72 -31
- otf_api/models/bookings/classes.py +124 -0
- otf_api/models/{enums.py → bookings/enums.py} +7 -81
- otf_api/{filters.py → models/bookings/filters.py} +39 -11
- otf_api/models/{ratings.py → bookings/ratings.py} +2 -6
- otf_api/models/members/__init__.py +5 -0
- otf_api/models/members/member_detail.py +149 -0
- otf_api/models/members/member_membership.py +26 -0
- otf_api/models/members/member_purchases.py +29 -0
- otf_api/models/members/notifications.py +17 -0
- otf_api/models/mixins.py +48 -1
- otf_api/models/studios/__init__.py +5 -0
- otf_api/models/studios/enums.py +11 -0
- otf_api/models/studios/studio_detail.py +93 -0
- otf_api/models/studios/studio_services.py +36 -0
- otf_api/models/workouts/__init__.py +31 -0
- otf_api/models/{body_composition_list.py → workouts/body_composition_list.py} +140 -71
- otf_api/models/workouts/challenge_tracker_content.py +50 -0
- otf_api/models/workouts/challenge_tracker_detail.py +99 -0
- otf_api/models/workouts/enums.py +70 -0
- otf_api/models/workouts/lifetime_stats.py +96 -0
- otf_api/models/workouts/out_of_studio_workout_history.py +32 -0
- otf_api/models/{performance_summary.py → workouts/performance_summary.py} +19 -5
- otf_api/models/workouts/telemetry.py +88 -0
- otf_api/models/{workout.py → workouts/workout.py} +34 -20
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/METADATA +4 -2
- otf_api-0.13.0.dist-info/RECORD +59 -0
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/WHEEL +1 -1
- otf_api/api.py +0 -1682
- otf_api/logging.py +0 -19
- otf_api/models/bookings.py +0 -109
- otf_api/models/challenge_tracker_content.py +0 -59
- otf_api/models/challenge_tracker_detail.py +0 -88
- otf_api/models/classes.py +0 -70
- otf_api/models/lifetime_stats.py +0 -78
- otf_api/models/member_detail.py +0 -121
- otf_api/models/member_membership.py +0 -26
- otf_api/models/member_purchases.py +0 -29
- otf_api/models/notifications.py +0 -17
- otf_api/models/out_of_studio_workout_history.py +0 -32
- otf_api/models/studio_detail.py +0 -71
- otf_api/models/studio_services.py +0 -36
- otf_api/models/telemetry.py +0 -84
- otf_api/utils.py +0 -164
- otf_api-0.12.0.dist-info/RECORD +0 -38
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/licenses/LICENSE +0 -0
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/top_level.txt +0 -0
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
|