otf-api 0.8.2__py3-none-any.whl → 0.9.1__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 +7 -4
- otf_api/api.py +699 -480
- otf_api/auth/__init__.py +4 -0
- otf_api/auth/auth.py +234 -0
- otf_api/auth/user.py +66 -0
- otf_api/auth/utils.py +129 -0
- otf_api/exceptions.py +38 -5
- otf_api/filters.py +97 -0
- otf_api/logging.py +19 -0
- otf_api/models/__init__.py +27 -38
- otf_api/models/body_composition_list.py +47 -50
- otf_api/models/bookings.py +63 -87
- otf_api/models/challenge_tracker_content.py +42 -21
- otf_api/models/challenge_tracker_detail.py +68 -48
- otf_api/models/classes.py +53 -62
- otf_api/models/enums.py +108 -30
- otf_api/models/lifetime_stats.py +59 -45
- otf_api/models/member_detail.py +95 -115
- otf_api/models/member_membership.py +18 -17
- otf_api/models/member_purchases.py +21 -127
- otf_api/models/mixins.py +37 -33
- otf_api/models/notifications.py +17 -0
- otf_api/models/out_of_studio_workout_history.py +22 -31
- otf_api/models/performance_summary_detail.py +47 -42
- otf_api/models/performance_summary_list.py +19 -37
- otf_api/models/studio_detail.py +51 -98
- otf_api/models/studio_services.py +27 -48
- otf_api/models/telemetry.py +14 -5
- otf_api/utils.py +134 -0
- {otf_api-0.8.2.dist-info → otf_api-0.9.1.dist-info}/METADATA +21 -10
- otf_api-0.9.1.dist-info/RECORD +35 -0
- {otf_api-0.8.2.dist-info → otf_api-0.9.1.dist-info}/WHEEL +1 -1
- otf_api/auth.py +0 -316
- otf_api/models/book_class.py +0 -89
- otf_api/models/cancel_booking.py +0 -49
- otf_api/models/favorite_studios.py +0 -106
- otf_api/models/latest_agreement.py +0 -21
- otf_api/models/telemetry_hr_history.py +0 -34
- otf_api/models/telemetry_max_hr.py +0 -13
- otf_api/models/total_classes.py +0 -8
- otf_api-0.8.2.dist-info/AUTHORS.md +0 -9
- otf_api-0.8.2.dist-info/RECORD +0 -36
- {otf_api-0.8.2.dist-info → otf_api-0.9.1.dist-info}/LICENSE +0 -0
otf_api/api.py
CHANGED
@@ -1,138 +1,64 @@
|
|
1
|
-
import
|
1
|
+
import atexit
|
2
2
|
import contextlib
|
3
|
-
import
|
3
|
+
import functools
|
4
4
|
from datetime import date, datetime, timedelta
|
5
|
-
from
|
6
|
-
from
|
5
|
+
from json import JSONDecodeError
|
6
|
+
from logging import getLogger
|
7
|
+
from typing import Any, Literal
|
7
8
|
|
8
|
-
import
|
9
|
-
import
|
9
|
+
import attrs
|
10
|
+
import httpx
|
10
11
|
from yarl import URL
|
11
12
|
|
12
|
-
from otf_api import
|
13
|
+
from otf_api import exceptions as exc
|
14
|
+
from otf_api import filters, models
|
13
15
|
from otf_api.auth import OtfUser
|
14
|
-
from otf_api.
|
15
|
-
AlreadyBookedError,
|
16
|
-
BookingAlreadyCancelledError,
|
17
|
-
BookingNotFoundError,
|
18
|
-
OutsideSchedulingWindowError,
|
19
|
-
)
|
16
|
+
from otf_api.utils import ensure_date, ensure_list, get_booking_uuid, get_class_uuid
|
20
17
|
|
21
18
|
API_BASE_URL = "api.orangetheory.co"
|
22
19
|
API_IO_BASE_URL = "api.orangetheory.io"
|
23
20
|
API_TELEMETRY_BASE_URL = "api.yuzu.orangetheory.com"
|
24
|
-
|
21
|
+
JSON_HEADERS = {"Content-Type": "application/json", "Accept": "application/json"}
|
22
|
+
LOGGER = getLogger(__name__)
|
25
23
|
|
26
24
|
|
25
|
+
@attrs.define(init=False)
|
27
26
|
class Otf:
|
28
|
-
|
27
|
+
member: models.MemberDetail
|
28
|
+
member_uuid: str
|
29
|
+
home_studio: models.StudioDetail
|
30
|
+
home_studio_uuid: str
|
29
31
|
user: OtfUser
|
30
|
-
|
32
|
+
session: httpx.Client
|
31
33
|
|
32
|
-
def __init__(
|
33
|
-
|
34
|
-
username: str | None = None,
|
35
|
-
password: str | None = None,
|
36
|
-
access_token: str | None = None,
|
37
|
-
id_token: str | None = None,
|
38
|
-
refresh_token: str | None = None,
|
39
|
-
device_key: str | None = None,
|
40
|
-
user: OtfUser | None = None,
|
41
|
-
):
|
42
|
-
"""Create a new Otf instance.
|
43
|
-
|
44
|
-
Authentication methods:
|
45
|
-
---
|
46
|
-
- Provide a username and password.
|
47
|
-
- Provide an access token and id token.
|
48
|
-
- Provide a user object.
|
49
|
-
|
50
|
-
Args:
|
51
|
-
username (str, optional): The username of the user. Default is None.
|
52
|
-
password (str, optional): The password of the user. Default is None.
|
53
|
-
access_token (str, optional): The access token. Default is None.
|
54
|
-
id_token (str, optional): The id token. Default is None.
|
55
|
-
refresh_token (str, optional): The refresh token. Default is None.
|
56
|
-
device_key (str, optional): The device key. Default is None.
|
57
|
-
user (OtfUser, optional): A user object. Default is None.
|
58
|
-
"""
|
59
|
-
|
60
|
-
self.member: models.MemberDetail
|
61
|
-
self.home_studio_uuid: str
|
62
|
-
|
63
|
-
if user:
|
64
|
-
self.user = user
|
65
|
-
elif username and password or (access_token and id_token):
|
66
|
-
self.user = OtfUser(
|
67
|
-
username=username,
|
68
|
-
password=password,
|
69
|
-
access_token=access_token,
|
70
|
-
id_token=id_token,
|
71
|
-
refresh_token=refresh_token,
|
72
|
-
device_key=device_key,
|
73
|
-
)
|
74
|
-
else:
|
75
|
-
raise ValueError("No valid authentication method provided")
|
76
|
-
|
77
|
-
# simplify access to member_id and member_uuid
|
78
|
-
self._member_id = self.user.member_id
|
79
|
-
self._member_uuid = self.user.member_uuid
|
80
|
-
self._perf_api_headers = {
|
81
|
-
"koji-member-id": self._member_id,
|
82
|
-
"koji-member-email": self.user.id_claims_data.email,
|
83
|
-
}
|
84
|
-
self.member = self._get_member_details_sync()
|
85
|
-
self.home_studio_uuid = self.member.home_studio.studio_uuid
|
34
|
+
def __init__(self, user: OtfUser | None = None):
|
35
|
+
"""Initialize the OTF API client.
|
86
36
|
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
This is used to get the member details when the API is first initialized, to let use initialize
|
91
|
-
without needing to await the member details.
|
92
|
-
|
93
|
-
Returns:
|
94
|
-
MemberDetail: The member details.
|
37
|
+
Args:
|
38
|
+
user (OtfUser): The user to authenticate as.
|
95
39
|
"""
|
96
|
-
|
97
|
-
|
98
|
-
return models.MemberDetail(**resp.json()["data"])
|
99
|
-
|
100
|
-
@property
|
101
|
-
def headers(self):
|
102
|
-
"""Get the headers for the API request."""
|
103
|
-
|
104
|
-
# check the token before making a request in case it has expired
|
105
|
-
|
106
|
-
self.user.cognito.check_token()
|
107
|
-
return {
|
108
|
-
"Authorization": f"Bearer {self.user.cognito.id_token}",
|
109
|
-
"Content-Type": "application/json",
|
110
|
-
"Accept": "application/json",
|
111
|
-
}
|
40
|
+
self.user = user or OtfUser()
|
41
|
+
self.member_uuid = self.user.member_uuid
|
112
42
|
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
self._session = aiohttp.ClientSession(headers=self.headers)
|
43
|
+
self.session = httpx.Client(
|
44
|
+
headers=JSON_HEADERS, auth=self.user.httpx_auth, timeout=httpx.Timeout(20.0, connect=60.0)
|
45
|
+
)
|
46
|
+
atexit.register(self.session.close)
|
118
47
|
|
119
|
-
|
48
|
+
self.member = self.get_member_detail()
|
49
|
+
self.home_studio = self.member.home_studio
|
50
|
+
self.home_studio_uuid = self.home_studio.studio_uuid
|
120
51
|
|
121
|
-
def
|
122
|
-
if not
|
123
|
-
return
|
52
|
+
def __eq__(self, other):
|
53
|
+
if not isinstance(other, Otf):
|
54
|
+
return False
|
55
|
+
return self.member_uuid == other.member_uuid
|
124
56
|
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
loop = asyncio.new_event_loop()
|
129
|
-
loop.run_until_complete(self._close_session())
|
57
|
+
def __hash__(self):
|
58
|
+
# Combine immutable attributes into a single hash value
|
59
|
+
return hash(self.member_uuid)
|
130
60
|
|
131
|
-
|
132
|
-
if not self.session.closed:
|
133
|
-
await self.session.close()
|
134
|
-
|
135
|
-
async def _do(
|
61
|
+
def _do(
|
136
62
|
self,
|
137
63
|
method: str,
|
138
64
|
base_url: str,
|
@@ -143,150 +69,214 @@ class Otf:
|
|
143
69
|
) -> Any:
|
144
70
|
"""Perform an API request."""
|
145
71
|
|
72
|
+
headers = headers or {}
|
146
73
|
params = params or {}
|
147
74
|
params = {k: v for k, v in params.items() if v is not None}
|
148
75
|
|
149
76
|
full_url = str(URL.build(scheme="https", host=base_url, path=url))
|
150
77
|
|
151
|
-
|
152
|
-
|
153
|
-
# ensure we have headers that contain the most up-to-date token
|
154
|
-
if not headers:
|
155
|
-
headers = self.headers
|
156
|
-
else:
|
157
|
-
headers.update(self.headers)
|
78
|
+
LOGGER.debug(f"Making {method!r} request to {full_url}, params: {params}")
|
158
79
|
|
159
|
-
|
160
|
-
|
161
|
-
with contextlib.suppress(Exception):
|
162
|
-
text = await response.text()
|
80
|
+
request = self.session.build_request(method, full_url, headers=headers, params=params, **kwargs)
|
81
|
+
response = self.session.send(request)
|
163
82
|
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
83
|
+
try:
|
84
|
+
response.raise_for_status()
|
85
|
+
except httpx.RequestError as e:
|
86
|
+
LOGGER.exception(f"Error making request: {e}")
|
87
|
+
LOGGER.exception(f"Response: {response.text}")
|
88
|
+
raise
|
89
|
+
except httpx.HTTPStatusError as e:
|
90
|
+
LOGGER.exception(f"Error making request: {e}")
|
91
|
+
LOGGER.exception(f"Response: {response.text}")
|
92
|
+
raise exc.OtfRequestError("Error making request", response=response, request=request)
|
93
|
+
except Exception as e:
|
94
|
+
LOGGER.exception(f"Error making request: {e}")
|
95
|
+
raise
|
96
|
+
|
97
|
+
if not response.text:
|
98
|
+
return None
|
173
99
|
|
174
|
-
|
100
|
+
try:
|
101
|
+
resp = response.json()
|
102
|
+
except JSONDecodeError as e:
|
103
|
+
LOGGER.error(f"Error decoding JSON: {e}")
|
104
|
+
LOGGER.error(f"Response: {response.text}")
|
105
|
+
raise
|
106
|
+
|
107
|
+
if (
|
108
|
+
"Status" in resp
|
109
|
+
and isinstance(resp["Status"], int)
|
110
|
+
and not (resp["Status"] >= 200 and resp["Status"] <= 299)
|
111
|
+
):
|
112
|
+
LOGGER.error(f"Error making request: {resp}")
|
113
|
+
raise exc.OtfRequestError("Error making request", response=response, request=request)
|
114
|
+
|
115
|
+
return resp
|
116
|
+
|
117
|
+
def _classes_request(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
|
175
118
|
"""Perform an API request to the classes API."""
|
176
|
-
return
|
119
|
+
return self._do(method, API_IO_BASE_URL, url, params)
|
177
120
|
|
178
|
-
|
121
|
+
def _default_request(self, method: str, url: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
|
179
122
|
"""Perform an API request to the default API."""
|
180
|
-
return
|
123
|
+
return self._do(method, API_BASE_URL, url, params, **kwargs)
|
181
124
|
|
182
|
-
|
125
|
+
def _telemetry_request(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
|
183
126
|
"""Perform an API request to the Telemetry API."""
|
184
|
-
return
|
127
|
+
return self._do(method, API_TELEMETRY_BASE_URL, url, params)
|
185
128
|
|
186
|
-
|
187
|
-
self, method: str, url: str, headers: dict[str, str], params: dict[str, Any] | None = None
|
188
|
-
) -> Any:
|
129
|
+
def _performance_summary_request(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
|
189
130
|
"""Perform an API request to the performance summary API."""
|
190
|
-
|
131
|
+
perf_api_headers = {"koji-member-id": self.member_uuid, "koji-member-email": self.user.email_address}
|
132
|
+
return self._do(method, API_IO_BASE_URL, url, params, perf_api_headers)
|
191
133
|
|
192
|
-
|
134
|
+
def get_classes(
|
193
135
|
self,
|
136
|
+
start_date: date | None = None,
|
137
|
+
end_date: date | None = None,
|
194
138
|
studio_uuids: list[str] | None = None,
|
195
|
-
include_home_studio: bool =
|
196
|
-
|
197
|
-
|
198
|
-
limit: int | None = None,
|
199
|
-
class_type: models.ClassType | list[models.ClassType] | None = None,
|
200
|
-
exclude_cancelled: bool = False,
|
201
|
-
day_of_week: list[models.DoW] | None = None,
|
202
|
-
start_time: list[str] | None = None,
|
203
|
-
exclude_unbookable: bool = True,
|
204
|
-
) -> models.OtfClassList:
|
139
|
+
include_home_studio: bool | None = None,
|
140
|
+
filters: list[filters.ClassFilter] | filters.ClassFilter | None = None,
|
141
|
+
) -> list[models.OtfClass]:
|
205
142
|
"""Get the classes for the user.
|
206
143
|
|
207
144
|
Returns a list of classes that are available for the user, based on the studio UUIDs provided. If no studio
|
208
145
|
UUIDs are provided, it will default to the user's home studio.
|
209
146
|
|
210
147
|
Args:
|
148
|
+
start_date (date | None): The start date for the classes. Default is None.
|
149
|
+
end_date (date | None): The end date for the classes. Default is None.
|
211
150
|
studio_uuids (list[str] | None): The studio UUIDs to get the classes for. Default is None, which will\
|
212
151
|
default to the user's home studio only.
|
213
152
|
include_home_studio (bool): Whether to include the home studio in the classes. Default is True.
|
214
|
-
|
215
|
-
|
216
|
-
limit (int | None): Limit the number of classes returned. Default is None.
|
217
|
-
class_type (ClassType | list[ClassType] | None): The class type to filter by. Default is None. Multiple\
|
218
|
-
class types can be provided, if there are multiple there will be a call per class type.
|
219
|
-
exclude_cancelled (bool): Whether to exclude cancelled classes. Default is False.
|
220
|
-
day_of_week (list[DoW] | None): The days of the week to filter by. Default is None.
|
221
|
-
start_time (list[str] | None): The start time to filter by. Default is None.
|
222
|
-
exclude_unbookable (bool): Whether to exclude classes that are outside the scheduling window. Default is\
|
223
|
-
True.
|
153
|
+
filters (list[ClassFilter] | ClassFilter | None): A list of filters to apply to the classes, or a single\
|
154
|
+
filter. Filters are applied as an OR operation. Default is None.
|
224
155
|
|
225
156
|
Returns:
|
226
|
-
|
157
|
+
list[OtfClass]: The classes for the user.
|
227
158
|
"""
|
228
159
|
|
229
|
-
|
230
|
-
studio_uuids = [self.home_studio_uuid]
|
231
|
-
elif include_home_studio and self.home_studio_uuid not in studio_uuids:
|
232
|
-
studio_uuids.append(self.home_studio_uuid)
|
160
|
+
classes = self._get_classes(studio_uuids, include_home_studio)
|
233
161
|
|
234
|
-
|
235
|
-
|
162
|
+
# remove those that are cancelled *by the studio*
|
163
|
+
classes = [c for c in classes if not c.is_cancelled]
|
236
164
|
|
237
|
-
|
238
|
-
|
239
|
-
classes_list.classes = [c for c in classes_list.classes if c.starts_at_local >= start_dtme]
|
165
|
+
bookings = self.get_bookings(status=models.BookingStatus.Booked)
|
166
|
+
booked_classes = {b.class_uuid for b in bookings}
|
240
167
|
|
241
|
-
|
242
|
-
|
243
|
-
classes_list.classes = [c for c in classes_list.classes if c.ends_at_local <= end_dtme]
|
168
|
+
for otf_class in classes:
|
169
|
+
otf_class.is_booked = otf_class.class_uuid in booked_classes
|
244
170
|
|
245
|
-
if
|
246
|
-
|
171
|
+
# filter by provided start_date/end_date, if provided
|
172
|
+
classes = self._filter_classes_by_date(classes, start_date, end_date)
|
247
173
|
|
248
|
-
|
249
|
-
|
174
|
+
# filter by provided filters, if provided
|
175
|
+
classes = self._filter_classes_by_filters(classes, filters)
|
250
176
|
|
251
|
-
|
252
|
-
|
177
|
+
# sort by start time, then by name
|
178
|
+
classes = sorted(classes, key=lambda x: (x.starts_at, x.name))
|
253
179
|
|
254
|
-
|
255
|
-
start_time = [start_time]
|
180
|
+
return classes
|
256
181
|
|
257
|
-
|
258
|
-
|
182
|
+
def _get_classes(
|
183
|
+
self, studio_uuids: list[str] | None = None, include_home_studio: bool | None = None
|
184
|
+
) -> list[models.OtfClass]:
|
185
|
+
"""Handles the actual request to get classes.
|
259
186
|
|
260
|
-
|
261
|
-
|
187
|
+
Args:
|
188
|
+
studio_uuids (list[str] | None): The studio UUIDs to get the classes for. Default is None, which will\
|
189
|
+
default to the user's home studio only.
|
190
|
+
include_home_studio (bool): Whether to include the home studio in the classes. Default is True.
|
262
191
|
|
263
|
-
|
264
|
-
|
192
|
+
Returns:
|
193
|
+
list[OtfClass]: The classes for the user.
|
194
|
+
"""
|
265
195
|
|
266
|
-
|
267
|
-
|
196
|
+
studio_uuids = ensure_list(studio_uuids) or [self.home_studio_uuid]
|
197
|
+
studio_uuids = list(set(studio_uuids)) # remove duplicates
|
268
198
|
|
269
|
-
if
|
270
|
-
|
271
|
-
|
272
|
-
]
|
199
|
+
if len(studio_uuids) > 50:
|
200
|
+
LOGGER.warning("Cannot request classes for more than 50 studios at a time.")
|
201
|
+
studio_uuids = studio_uuids[:50]
|
273
202
|
|
274
|
-
|
203
|
+
if include_home_studio and self.home_studio_uuid not in studio_uuids:
|
204
|
+
if len(studio_uuids) == 50:
|
205
|
+
LOGGER.warning("Cannot include home studio, request already includes 50 studios.")
|
206
|
+
else:
|
207
|
+
studio_uuids.append(self.home_studio_uuid)
|
275
208
|
|
276
|
-
|
277
|
-
# this endpoint returns classes that the `book_class` endpoint will reject, this filters them out
|
278
|
-
max_date = datetime.today().date() + timedelta(days=29)
|
279
|
-
classes_list.classes = [c for c in classes_list.classes if c.starts_at_local.date() <= max_date]
|
209
|
+
classes_resp = self._classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})
|
280
210
|
|
281
|
-
|
282
|
-
|
211
|
+
studio_dict = {s: self.get_studio_detail(s) for s in studio_uuids}
|
212
|
+
classes: list[models.OtfClass] = []
|
283
213
|
|
284
|
-
for
|
285
|
-
|
214
|
+
for c in classes_resp["items"]:
|
215
|
+
c["studio"] = studio_dict[c["studio"]["id"]] # the one (?) place where ID actually means UUID
|
216
|
+
c["is_home_studio"] = c["studio"].studio_uuid == self.home_studio_uuid
|
217
|
+
classes.append(models.OtfClass(**c))
|
286
218
|
|
287
|
-
return
|
219
|
+
return classes
|
288
220
|
|
289
|
-
|
221
|
+
def _filter_classes_by_date(
|
222
|
+
self, classes: list[models.OtfClass], start_date: date | None, end_date: date | None
|
223
|
+
) -> list[models.OtfClass]:
|
224
|
+
"""Filter classes by start and end dates, as well as the max date the booking endpoint will accept.
|
225
|
+
|
226
|
+
Args:
|
227
|
+
classes (list[OtfClass]): The classes to filter.
|
228
|
+
start_date (date | None): The start date to filter by.
|
229
|
+
end_date (date | None): The end date to filter by.
|
230
|
+
|
231
|
+
Returns:
|
232
|
+
list[OtfClass]: The filtered classes.
|
233
|
+
"""
|
234
|
+
|
235
|
+
# this endpoint returns classes that the `book_class` endpoint will reject, this filters them out
|
236
|
+
max_date = datetime.today().date() + timedelta(days=29)
|
237
|
+
|
238
|
+
classes = [c for c in classes if c.starts_at.date() <= max_date]
|
239
|
+
|
240
|
+
# if not start date or end date, we're done
|
241
|
+
if not start_date and not end_date:
|
242
|
+
return classes
|
243
|
+
|
244
|
+
if start_date := ensure_date(start_date):
|
245
|
+
classes = [c for c in classes if c.starts_at.date() >= start_date]
|
246
|
+
|
247
|
+
if end_date := ensure_date(end_date):
|
248
|
+
classes = [c for c in classes if c.starts_at.date() <= end_date]
|
249
|
+
|
250
|
+
return classes
|
251
|
+
|
252
|
+
def _filter_classes_by_filters(
|
253
|
+
self, classes: list[models.OtfClass], filters: list[filters.ClassFilter] | filters.ClassFilter | None
|
254
|
+
) -> list[models.OtfClass]:
|
255
|
+
"""Filter classes by the provided filters.
|
256
|
+
|
257
|
+
Args:
|
258
|
+
classes (list[OtfClass]): The classes to filter.
|
259
|
+
filters (list[ClassFilter] | ClassFilter | None): The filters to apply.
|
260
|
+
|
261
|
+
Returns:
|
262
|
+
list[OtfClass]: The filtered classes.
|
263
|
+
"""
|
264
|
+
if not filters:
|
265
|
+
return classes
|
266
|
+
|
267
|
+
filters = ensure_list(filters)
|
268
|
+
filtered_classes: list[models.OtfClass] = []
|
269
|
+
|
270
|
+
# apply each filter as an OR operation
|
271
|
+
for f in filters:
|
272
|
+
filtered_classes.extend(f.filter_classes(classes))
|
273
|
+
|
274
|
+
# remove duplicates
|
275
|
+
classes = list({c.class_uuid: c for c in filtered_classes}.values())
|
276
|
+
|
277
|
+
return classes
|
278
|
+
|
279
|
+
def get_booking(self, booking_uuid: str) -> models.Booking:
|
290
280
|
"""Get a specific booking by booking_uuid.
|
291
281
|
|
292
282
|
Args:
|
@@ -301,14 +291,14 @@ class Otf:
|
|
301
291
|
if not booking_uuid:
|
302
292
|
raise ValueError("booking_uuid is required")
|
303
293
|
|
304
|
-
data =
|
294
|
+
data = self._default_request("GET", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}")
|
305
295
|
return models.Booking(**data["data"])
|
306
296
|
|
307
|
-
|
297
|
+
def get_booking_from_class(self, otf_class: str | models.OtfClass) -> models.Booking:
|
308
298
|
"""Get a specific booking by class_uuid or OtfClass object.
|
309
299
|
|
310
300
|
Args:
|
311
|
-
|
301
|
+
otf_class (str | OtfClass): The class UUID or the OtfClass object to get the booking for.
|
312
302
|
|
313
303
|
Returns:
|
314
304
|
Booking: The booking.
|
@@ -318,24 +308,20 @@ class Otf:
|
|
318
308
|
ValueError: If class_uuid is None or empty string.
|
319
309
|
"""
|
320
310
|
|
321
|
-
class_uuid =
|
322
|
-
|
323
|
-
if not class_uuid:
|
324
|
-
raise ValueError("class_uuid is required")
|
311
|
+
class_uuid = get_class_uuid(otf_class)
|
325
312
|
|
326
|
-
all_bookings =
|
313
|
+
all_bookings = self.get_bookings(exclude_cancelled=False, exclude_checkedin=False)
|
327
314
|
|
328
|
-
|
329
|
-
|
330
|
-
return booking
|
315
|
+
if booking := next((b for b in all_bookings if b.class_uuid == class_uuid), None):
|
316
|
+
return booking
|
331
317
|
|
332
|
-
raise BookingNotFoundError(f"Booking for class {class_uuid} not found.")
|
318
|
+
raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
|
333
319
|
|
334
|
-
|
320
|
+
def book_class(self, otf_class: str | models.OtfClass) -> models.Booking:
|
335
321
|
"""Book a class by providing either the class_uuid or the OtfClass object.
|
336
322
|
|
337
323
|
Args:
|
338
|
-
|
324
|
+
otf_class (str | OtfClass): The class UUID or the OtfClass object to book.
|
339
325
|
|
340
326
|
Returns:
|
341
327
|
Booking: The booking.
|
@@ -344,82 +330,119 @@ class Otf:
|
|
344
330
|
AlreadyBookedError: If the class is already booked.
|
345
331
|
OutsideSchedulingWindowError: If the class is outside the scheduling window.
|
346
332
|
ValueError: If class_uuid is None or empty string.
|
347
|
-
|
333
|
+
OtfException: If there is an error booking the class.
|
348
334
|
"""
|
349
335
|
|
350
|
-
class_uuid =
|
351
|
-
if not class_uuid:
|
352
|
-
raise ValueError("class_uuid is required")
|
336
|
+
class_uuid = get_class_uuid(otf_class)
|
353
337
|
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
f"Class {class_uuid} is already booked.", booking_uuid=existing_booking.class_booking_uuid
|
359
|
-
)
|
338
|
+
self._check_class_already_booked(class_uuid)
|
339
|
+
|
340
|
+
if isinstance(otf_class, models.OtfClass):
|
341
|
+
self._check_for_booking_conflicts(otf_class)
|
360
342
|
|
361
343
|
body = {"classUUId": class_uuid, "confirmed": False, "waitlist": False}
|
362
344
|
|
363
|
-
|
345
|
+
try:
|
346
|
+
resp = self._default_request("PUT", f"/member/members/{self.member_uuid}/bookings", json=body)
|
347
|
+
except exc.OtfRequestError as e:
|
348
|
+
resp_obj = e.response.json()
|
364
349
|
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
350
|
+
if resp_obj["code"] == "ERROR":
|
351
|
+
err_code = resp_obj["data"]["errorCode"]
|
352
|
+
if err_code == "603":
|
353
|
+
raise exc.AlreadyBookedError(f"Class {class_uuid} is already booked.")
|
354
|
+
if err_code == "602":
|
355
|
+
raise exc.OutsideSchedulingWindowError(f"Class {class_uuid} is outside the scheduling window.")
|
370
356
|
|
371
|
-
raise
|
357
|
+
raise
|
358
|
+
except Exception as e:
|
359
|
+
raise exc.OtfException(f"Error booking class {class_uuid}: {e}")
|
372
360
|
|
373
|
-
# get the booking
|
374
|
-
|
361
|
+
# get the booking uuid - we will only use this to return a Booking object using `get_booking`
|
362
|
+
# this is an attempt to improve on OTF's terrible data model
|
363
|
+
booking_uuid = resp["data"]["savedBookings"][0]["classBookingUUId"]
|
375
364
|
|
376
|
-
booking =
|
365
|
+
booking = self.get_booking(booking_uuid)
|
377
366
|
|
378
367
|
return booking
|
379
368
|
|
380
|
-
|
369
|
+
def _check_class_already_booked(self, class_uuid: str) -> None:
|
370
|
+
"""Check if the class is already booked.
|
371
|
+
|
372
|
+
Args:
|
373
|
+
class_uuid (str): The class UUID to check.
|
374
|
+
|
375
|
+
Raises:
|
376
|
+
AlreadyBookedError: If the class is already booked.
|
377
|
+
"""
|
378
|
+
existing_booking = None
|
379
|
+
|
380
|
+
with contextlib.suppress(exc.BookingNotFoundError):
|
381
|
+
existing_booking = self.get_booking_from_class(class_uuid)
|
382
|
+
|
383
|
+
if not existing_booking:
|
384
|
+
return
|
385
|
+
|
386
|
+
if existing_booking.status != models.BookingStatus.Cancelled:
|
387
|
+
raise exc.AlreadyBookedError(
|
388
|
+
f"Class {class_uuid} is already booked.", booking_uuid=existing_booking.booking_uuid
|
389
|
+
)
|
390
|
+
|
391
|
+
def _check_for_booking_conflicts(self, otf_class: models.OtfClass) -> None:
|
392
|
+
"""Check for booking conflicts with the provided class.
|
393
|
+
|
394
|
+
Checks the member's bookings to see if the provided class overlaps with any existing bookings. If a conflict is
|
395
|
+
found, a ConflictingBookingError is raised.
|
396
|
+
"""
|
397
|
+
|
398
|
+
bookings = self.get_bookings(start_date=otf_class.starts_at.date(), end_date=otf_class.starts_at.date())
|
399
|
+
if not bookings:
|
400
|
+
return
|
401
|
+
|
402
|
+
for booking in bookings:
|
403
|
+
booking_start = booking.otf_class.starts_at
|
404
|
+
booking_end = booking.otf_class.ends_at
|
405
|
+
# Check for overlap
|
406
|
+
if not (otf_class.ends_at < booking_start or otf_class.starts_at > booking_end):
|
407
|
+
raise exc.ConflictingBookingError(
|
408
|
+
f"You already have a booking that conflicts with this class ({booking.otf_class.class_uuid}).",
|
409
|
+
booking_uuid=booking.booking_uuid,
|
410
|
+
)
|
411
|
+
|
412
|
+
def cancel_booking(self, booking: str | models.Booking) -> None:
|
381
413
|
"""Cancel a booking by providing either the booking_uuid or the Booking object.
|
382
414
|
|
383
415
|
Args:
|
384
416
|
booking (str | Booking): The booking UUID or the Booking object to cancel.
|
385
417
|
|
386
|
-
Returns:
|
387
|
-
CancelBooking: The cancelled booking.
|
388
|
-
|
389
418
|
Raises:
|
390
419
|
ValueError: If booking_uuid is None or empty string
|
391
420
|
BookingNotFoundError: If the booking does not exist.
|
392
421
|
"""
|
393
|
-
booking_uuid =
|
394
|
-
|
395
|
-
if not booking_uuid:
|
396
|
-
raise ValueError("booking_uuid is required")
|
422
|
+
booking_uuid = get_booking_uuid(booking)
|
397
423
|
|
398
424
|
try:
|
399
|
-
|
425
|
+
self.get_booking(booking_uuid)
|
400
426
|
except Exception:
|
401
|
-
raise BookingNotFoundError(f"Booking {booking_uuid} does not exist.")
|
427
|
+
raise exc.BookingNotFoundError(f"Booking {booking_uuid} does not exist.")
|
402
428
|
|
403
429
|
params = {"confirmed": "true"}
|
404
|
-
resp =
|
405
|
-
"DELETE", f"/member/members/{self.
|
430
|
+
resp = self._default_request(
|
431
|
+
"DELETE", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}", params=params
|
406
432
|
)
|
407
433
|
if resp["code"] == "NOT_AUTHORIZED" and resp["message"].startswith("This class booking has"):
|
408
|
-
raise BookingAlreadyCancelledError(
|
434
|
+
raise exc.BookingAlreadyCancelledError(
|
409
435
|
f"Booking {booking_uuid} is already cancelled.", booking_uuid=booking_uuid
|
410
436
|
)
|
411
437
|
|
412
|
-
|
413
|
-
|
414
|
-
async def get_bookings(
|
438
|
+
def get_bookings(
|
415
439
|
self,
|
416
440
|
start_date: date | str | None = None,
|
417
441
|
end_date: date | str | None = None,
|
418
442
|
status: models.BookingStatus | None = None,
|
419
|
-
limit: int | None = None,
|
420
443
|
exclude_cancelled: bool = True,
|
421
444
|
exclude_checkedin: bool = True,
|
422
|
-
):
|
445
|
+
) -> list[models.Booking]:
|
423
446
|
"""Get the member's bookings.
|
424
447
|
|
425
448
|
Args:
|
@@ -427,13 +450,11 @@ class Otf:
|
|
427
450
|
end_date (date | str | None): The end date for the bookings. Default is None.
|
428
451
|
status (BookingStatus | None): The status of the bookings to get. Default is None, which includes\
|
429
452
|
all statuses. Only a single status can be provided.
|
430
|
-
limit (int | None): The maximum number of bookings to return. Default is None, which returns all\
|
431
|
-
bookings.
|
432
453
|
exclude_cancelled (bool): Whether to exclude cancelled bookings. Default is True.
|
433
454
|
exclude_checkedin (bool): Whether to exclude checked-in bookings. Default is True.
|
434
455
|
|
435
456
|
Returns:
|
436
|
-
|
457
|
+
list[Booking]: The member's bookings.
|
437
458
|
|
438
459
|
Warning:
|
439
460
|
---
|
@@ -457,7 +478,7 @@ class Otf:
|
|
457
478
|
"""
|
458
479
|
|
459
480
|
if exclude_cancelled and status == models.BookingStatus.Cancelled:
|
460
|
-
|
481
|
+
LOGGER.warning(
|
461
482
|
"Cannot exclude cancelled bookings when status is Cancelled. Setting exclude_cancelled to False."
|
462
483
|
)
|
463
484
|
exclude_cancelled = False
|
@@ -472,30 +493,28 @@ class Otf:
|
|
472
493
|
|
473
494
|
params = {"startDate": start_date, "endDate": end_date, "statuses": status_value}
|
474
495
|
|
475
|
-
|
496
|
+
resp = self._default_request("GET", f"/member/members/{self.member_uuid}/bookings", params=params)["data"]
|
476
497
|
|
477
|
-
|
498
|
+
# add studio details for each booking, instead of using the different studio model returned by this endpoint
|
499
|
+
studio_uuids = {b["class"]["studio"]["studioUUId"] for b in resp}
|
500
|
+
studios = {studio_uuid: self.get_studio_detail(studio_uuid) for studio_uuid in studio_uuids}
|
478
501
|
|
479
|
-
|
480
|
-
|
502
|
+
for b in resp:
|
503
|
+
b["class"]["studio"] = studios[b["class"]["studio"]["studioUUId"]]
|
504
|
+
b["is_home_studio"] = b["class"]["studio"].studio_uuid == self.home_studio_uuid
|
481
505
|
|
482
|
-
for
|
483
|
-
|
484
|
-
continue
|
485
|
-
if booking.otf_class.studio.studio_uuid == self.home_studio_uuid:
|
486
|
-
booking.is_home_studio = True
|
487
|
-
else:
|
488
|
-
booking.is_home_studio = False
|
506
|
+
bookings = [models.Booking(**b) for b in resp]
|
507
|
+
bookings = sorted(bookings, key=lambda x: x.otf_class.starts_at)
|
489
508
|
|
490
509
|
if exclude_cancelled:
|
491
|
-
|
510
|
+
bookings = [b for b in bookings if b.status != models.BookingStatus.Cancelled]
|
492
511
|
|
493
512
|
if exclude_checkedin:
|
494
|
-
|
513
|
+
bookings = [b for b in bookings if b.status != models.BookingStatus.CheckedIn]
|
495
514
|
|
496
|
-
return
|
515
|
+
return bookings
|
497
516
|
|
498
|
-
|
517
|
+
def _get_bookings_old(self, status: models.BookingStatus | None = None) -> list[models.Booking]:
|
499
518
|
"""Get the member's bookings.
|
500
519
|
|
501
520
|
Args:
|
@@ -503,7 +522,7 @@ class Otf:
|
|
503
522
|
all statuses. Only a single status can be provided.
|
504
523
|
|
505
524
|
Returns:
|
506
|
-
|
525
|
+
list[Booking]: The member's bookings.
|
507
526
|
|
508
527
|
Raises:
|
509
528
|
ValueError: If an unaccepted status is provided.
|
@@ -538,72 +557,56 @@ class Otf:
|
|
538
557
|
|
539
558
|
status_value = status.value if status else None
|
540
559
|
|
541
|
-
res =
|
542
|
-
"GET", f"/member/members/{self.
|
560
|
+
res = self._default_request(
|
561
|
+
"GET", f"/member/members/{self.member_uuid}/bookings", params={"status": status_value}
|
543
562
|
)
|
544
563
|
|
545
|
-
return models.
|
564
|
+
return [models.Booking(**b) for b in res["data"]]
|
546
565
|
|
547
|
-
|
548
|
-
self, include_addresses: bool = True, include_class_summary: bool = True, include_credit_card: bool = False
|
549
|
-
):
|
566
|
+
def get_member_detail(self) -> models.MemberDetail:
|
550
567
|
"""Get the member details.
|
551
568
|
|
552
|
-
Args:
|
553
|
-
include_addresses (bool): Whether to include the member's addresses in the response.
|
554
|
-
include_class_summary (bool): Whether to include the member's class summary in the response.
|
555
|
-
include_credit_card (bool): Whether to include the member's credit card information in the response.
|
556
|
-
|
557
569
|
Returns:
|
558
570
|
MemberDetail: The member details.
|
559
|
-
|
560
|
-
|
561
|
-
Notes:
|
562
|
-
---
|
563
|
-
The include_addresses, include_class_summary, and include_credit_card parameters are optional and determine
|
564
|
-
what additional information is included in the response. By default, all additional information is included,
|
565
|
-
with the exception of the credit card information.
|
566
|
-
|
567
|
-
The base member details include the last four of a credit card regardless of the include_credit_card,
|
568
|
-
although this is not always the same details as what is in the member_credit_card field. There doesn't seem
|
569
|
-
to be a way to exclude this information, and I do not know which is which or why they differ.
|
570
571
|
"""
|
571
572
|
|
572
|
-
include:
|
573
|
-
if include_addresses:
|
574
|
-
include.append("memberAddresses")
|
575
|
-
|
576
|
-
if include_class_summary:
|
577
|
-
include.append("memberClassSummary")
|
573
|
+
params = {"include": "memberAddresses,memberClassSummary"}
|
578
574
|
|
579
|
-
|
580
|
-
|
575
|
+
resp = self._default_request("GET", f"/member/members/{self.member_uuid}", params=params)
|
576
|
+
data = resp["data"]
|
581
577
|
|
582
|
-
|
578
|
+
# use standard StudioDetail model instead of the one returned by this endpoint
|
579
|
+
home_studio_uuid = data["homeStudio"]["studioUUId"]
|
580
|
+
data["home_studio"] = self.get_studio_detail(home_studio_uuid)
|
583
581
|
|
584
|
-
|
585
|
-
return models.MemberDetail(**data["data"])
|
582
|
+
return models.MemberDetail(**data)
|
586
583
|
|
587
|
-
|
584
|
+
def get_member_membership(self) -> models.MemberMembership:
|
588
585
|
"""Get the member's membership details.
|
589
586
|
|
590
587
|
Returns:
|
591
588
|
MemberMembership: The member's membership details.
|
592
589
|
"""
|
593
590
|
|
594
|
-
data =
|
591
|
+
data = self._default_request("GET", f"/member/members/{self.member_uuid}/memberships")
|
595
592
|
return models.MemberMembership(**data["data"])
|
596
593
|
|
597
|
-
|
594
|
+
def get_member_purchases(self) -> list[models.MemberPurchase]:
|
598
595
|
"""Get the member's purchases, including monthly subscriptions and class packs.
|
599
596
|
|
600
597
|
Returns:
|
601
|
-
|
598
|
+
list[MemberPurchase]: The member's purchases.
|
602
599
|
"""
|
603
|
-
data =
|
604
|
-
return models.MemberPurchaseList(data=data["data"])
|
600
|
+
data = self._default_request("GET", f"/member/members/{self.member_uuid}/purchases")
|
605
601
|
|
606
|
-
|
602
|
+
purchases = data["data"]
|
603
|
+
|
604
|
+
for p in purchases:
|
605
|
+
p["studio"] = self.get_studio_detail(p["studio"]["studioUUId"])
|
606
|
+
|
607
|
+
return [models.MemberPurchase(**purchase) for purchase in purchases]
|
608
|
+
|
609
|
+
def _get_member_lifetime_stats(
|
607
610
|
self, select_time: models.StatsTime = models.StatsTime.AllTime
|
608
611
|
) -> models.StatsResponse:
|
609
612
|
"""Get the member's lifetime stats.
|
@@ -620,67 +623,129 @@ class Otf:
|
|
620
623
|
Any: The member's lifetime stats.
|
621
624
|
"""
|
622
625
|
|
623
|
-
data =
|
626
|
+
data = self._default_request("GET", f"/performance/v2/{self.member_uuid}/over-time/{select_time}")
|
624
627
|
|
625
628
|
stats = models.StatsResponse(**data["data"])
|
629
|
+
|
626
630
|
return stats
|
627
631
|
|
628
|
-
|
629
|
-
|
632
|
+
def get_member_lifetime_stats_in_studio(
|
633
|
+
self, select_time: models.StatsTime = models.StatsTime.AllTime
|
634
|
+
) -> models.TimeStats:
|
635
|
+
"""Get the member's lifetime stats in studio.
|
636
|
+
|
637
|
+
Args:
|
638
|
+
select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
|
630
639
|
|
631
640
|
Returns:
|
632
|
-
|
641
|
+
Any: The member's lifetime stats in studio.
|
642
|
+
"""
|
633
643
|
|
634
|
-
|
635
|
-
|
636
|
-
|
637
|
-
|
644
|
+
data = self._get_member_lifetime_stats(select_time)
|
645
|
+
|
646
|
+
return data.in_studio.get_by_time(select_time)
|
647
|
+
|
648
|
+
def get_member_lifetime_stats_out_of_studio(
|
649
|
+
self, select_time: models.StatsTime = models.StatsTime.AllTime
|
650
|
+
) -> models.TimeStats:
|
651
|
+
"""Get the member's lifetime stats out of studio.
|
652
|
+
|
653
|
+
Args:
|
654
|
+
select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
|
655
|
+
|
656
|
+
Returns:
|
657
|
+
Any: The member's lifetime stats out of studio.
|
638
658
|
"""
|
639
|
-
data = await self._default_request("GET", "/member/agreements/9d98fb27-0f00-4598-ad08-5b1655a59af6")
|
640
|
-
return models.LatestAgreement(**data["data"])
|
641
659
|
|
642
|
-
|
660
|
+
data = self._get_member_lifetime_stats(select_time)
|
661
|
+
|
662
|
+
return data.out_studio.get_by_time(select_time)
|
663
|
+
|
664
|
+
def get_out_of_studio_workout_history(self) -> list[models.OutOfStudioWorkoutHistory]:
|
643
665
|
"""Get the member's out of studio workout history.
|
644
666
|
|
645
667
|
Returns:
|
646
|
-
|
668
|
+
list[OutOfStudioWorkoutHistory]: The member's out of studio workout history.
|
647
669
|
"""
|
648
|
-
data =
|
670
|
+
data = self._default_request("GET", f"/member/members/{self.member_uuid}/out-of-studio-workout")
|
649
671
|
|
650
|
-
return models.
|
672
|
+
return [models.OutOfStudioWorkoutHistory(**workout) for workout in data["data"]]
|
651
673
|
|
652
|
-
|
674
|
+
def get_favorite_studios(self) -> list[models.StudioDetail]:
|
653
675
|
"""Get the member's favorite studios.
|
654
676
|
|
655
677
|
Returns:
|
656
|
-
|
678
|
+
list[StudioDetail]: The member's favorite studios.
|
657
679
|
"""
|
658
|
-
data =
|
680
|
+
data = self._default_request("GET", f"/member/members/{self.member_uuid}/favorite-studios")
|
681
|
+
studio_uuids = [studio["studioUUId"] for studio in data["data"]]
|
682
|
+
return [self.get_studio_detail(studio_uuid) for studio_uuid in studio_uuids]
|
683
|
+
|
684
|
+
def add_favorite_studio(self, studio_uuids: list[str] | str) -> list[models.StudioDetail]:
|
685
|
+
"""Add a studio to the member's favorite studios.
|
686
|
+
|
687
|
+
Args:
|
688
|
+
studio_uuids (list[str] | str): The studio UUID or list of studio UUIDs to add to the member's favorite\
|
689
|
+
studios. If a string is provided, it will be converted to a list.
|
690
|
+
|
691
|
+
Returns:
|
692
|
+
list[StudioDetail]: The new favorite studios.
|
693
|
+
"""
|
694
|
+
studio_uuids = ensure_list(studio_uuids)
|
695
|
+
|
696
|
+
if not studio_uuids:
|
697
|
+
raise ValueError("studio_uuids is required")
|
698
|
+
|
699
|
+
body = {"studioUUIds": studio_uuids}
|
700
|
+
resp = self._default_request("POST", "/mobile/v1/members/favorite-studios", json=body)
|
659
701
|
|
660
|
-
|
702
|
+
new_faves = resp.get("data", {}).get("studios", [])
|
661
703
|
|
662
|
-
|
704
|
+
return [models.StudioDetail(**studio) for studio in new_faves]
|
705
|
+
|
706
|
+
def remove_favorite_studio(self, studio_uuids: list[str] | str) -> None:
|
707
|
+
"""Remove a studio from the member's favorite studios.
|
708
|
+
|
709
|
+
Args:
|
710
|
+
studio_uuids (list[str] | str): The studio UUID or list of studio UUIDs to remove from the member's\
|
711
|
+
favorite studios. If a string is provided, it will be converted to a list.
|
712
|
+
|
713
|
+
Returns:
|
714
|
+
None
|
715
|
+
"""
|
716
|
+
studio_uuids = ensure_list(studio_uuids)
|
717
|
+
|
718
|
+
if not studio_uuids:
|
719
|
+
raise ValueError("studio_uuids is required")
|
720
|
+
|
721
|
+
body = {"studioUUIds": studio_uuids}
|
722
|
+
self._default_request("DELETE", "/mobile/v1/members/favorite-studios", json=body)
|
723
|
+
|
724
|
+
def get_studio_services(self, studio_uuid: str | None = None) -> list[models.StudioService]:
|
663
725
|
"""Get the services available at a specific studio. If no studio UUID is provided, the member's home studio
|
664
726
|
will be used.
|
665
727
|
|
666
728
|
Args:
|
667
|
-
studio_uuid (str): The studio UUID to get services for.
|
668
|
-
studio.
|
729
|
+
studio_uuid (str, optional): The studio UUID to get services for.
|
669
730
|
|
670
731
|
Returns:
|
671
|
-
|
732
|
+
list[StudioService]: The services available at the studio.
|
672
733
|
"""
|
673
734
|
studio_uuid = studio_uuid or self.home_studio_uuid
|
674
|
-
data =
|
675
|
-
|
735
|
+
data = self._default_request("GET", f"/member/studios/{studio_uuid}/services")
|
736
|
+
|
737
|
+
for d in data["data"]:
|
738
|
+
d["studio"] = self.get_studio_detail(studio_uuid)
|
739
|
+
|
740
|
+
return [models.StudioService(**d) for d in data["data"]]
|
676
741
|
|
677
|
-
|
742
|
+
@functools.cache
|
743
|
+
def get_studio_detail(self, studio_uuid: str | None = None) -> models.StudioDetail:
|
678
744
|
"""Get detailed information about a specific studio. If no studio UUID is provided, it will default to the
|
679
745
|
user's home studio.
|
680
746
|
|
681
747
|
Args:
|
682
|
-
studio_uuid (str):
|
683
|
-
studio.
|
748
|
+
studio_uuid (str, optional): The studio UUID to get detailed information about.
|
684
749
|
|
685
750
|
Returns:
|
686
751
|
StudioDetail: Detailed information about the studio.
|
@@ -688,166 +753,195 @@ class Otf:
|
|
688
753
|
studio_uuid = studio_uuid or self.home_studio_uuid
|
689
754
|
|
690
755
|
path = f"/mobile/v1/studios/{studio_uuid}"
|
691
|
-
params = {"include": "locations"}
|
692
756
|
|
693
|
-
res =
|
757
|
+
res = self._default_request("GET", path)
|
758
|
+
|
694
759
|
return models.StudioDetail(**res["data"])
|
695
760
|
|
696
|
-
|
697
|
-
self,
|
698
|
-
|
699
|
-
|
700
|
-
|
701
|
-
|
702
|
-
|
703
|
-
|
761
|
+
def get_studios_by_geo(
|
762
|
+
self, latitude: float | None = None, longitude: float | None = None
|
763
|
+
) -> list[models.StudioDetail]:
|
764
|
+
"""Alias for search_studios_by_geo."""
|
765
|
+
|
766
|
+
return self.search_studios_by_geo(latitude, longitude)
|
767
|
+
|
768
|
+
def search_studios_by_geo(
|
769
|
+
self, latitude: float | None = None, longitude: float | None = None, distance: int = 50
|
770
|
+
) -> list[models.StudioDetail]:
|
704
771
|
"""Search for studios by geographic location.
|
705
772
|
|
706
773
|
Args:
|
707
774
|
latitude (float, optional): Latitude of the location to search around, if None uses home studio latitude.
|
708
775
|
longitude (float, optional): Longitude of the location to search around, if None uses home studio longitude.
|
709
|
-
distance (
|
710
|
-
page_index (int, optional): Page index to start at. Defaults to 1.
|
711
|
-
page_size (int, optional): Number of results per page. Defaults to 50.
|
776
|
+
distance (int, optional): The distance in miles to search around the location. Default is 50.
|
712
777
|
|
713
778
|
Returns:
|
714
|
-
|
779
|
+
list[StudioDetail]: List of studios that match the search criteria.
|
780
|
+
"""
|
781
|
+
latitude = latitude or self.home_studio.location.latitude
|
782
|
+
longitude = longitude or self.home_studio.location.longitude
|
715
783
|
|
716
|
-
|
717
|
-
|
718
|
-
|
719
|
-
|
784
|
+
return self._get_studios_by_geo(latitude, longitude, distance)
|
785
|
+
|
786
|
+
def _get_all_studios(self) -> list[models.StudioDetail]:
|
787
|
+
"""Gets all studios. Marked as private to avoid random users calling it. Useful for testing and validating
|
788
|
+
models.
|
720
789
|
|
790
|
+
Returns:
|
791
|
+
list[StudioDetail]: List of studios that match the search criteria.
|
721
792
|
"""
|
722
|
-
|
793
|
+
# long/lat being None will cause the endpoint to return all studios
|
794
|
+
return self._get_studios_by_geo(None, None)
|
723
795
|
|
724
|
-
|
725
|
-
|
796
|
+
def _get_studios_by_geo(
|
797
|
+
self, latitude: float | None, longitude: float | None, distance: int = 50
|
798
|
+
) -> list[models.StudioDetail]:
|
799
|
+
"""
|
800
|
+
Searches for studios by geographic location.
|
726
801
|
|
727
|
-
|
728
|
-
|
802
|
+
Args:
|
803
|
+
latitude (float | None): Latitude of the location.
|
804
|
+
longitude (float | None): Longitude of the location.
|
729
805
|
|
730
|
-
|
731
|
-
|
732
|
-
|
806
|
+
Returns:
|
807
|
+
list[models.StudioDetail]: List of studios matching the search criteria.
|
808
|
+
"""
|
809
|
+
path = "/mobile/v1/studios"
|
733
810
|
|
734
|
-
|
735
|
-
self.logger.warning("Page index must be greater than 0, setting to 1.")
|
736
|
-
page_index = 1
|
811
|
+
distance = min(distance, 250) # max distance is 250 miles
|
737
812
|
|
738
|
-
params = {
|
739
|
-
|
740
|
-
|
741
|
-
"latitude": latitude,
|
742
|
-
"longitude": longitude,
|
743
|
-
"distance": distance,
|
744
|
-
}
|
813
|
+
params = {"latitude": latitude, "longitude": longitude, "distance": distance, "pageIndex": 1, "pageSize": 100}
|
814
|
+
|
815
|
+
LOGGER.debug("Starting studio search", extra={"params": params})
|
745
816
|
|
746
|
-
all_results:
|
817
|
+
all_results: dict[str, dict[str, Any]] = {}
|
747
818
|
|
748
819
|
while True:
|
749
|
-
res =
|
750
|
-
|
751
|
-
|
820
|
+
res = self._default_request("GET", path, params=params)
|
821
|
+
studios = res["data"].get("studios", [])
|
822
|
+
total_count = res["data"].get("pagination", {}).get("totalCount", 0)
|
752
823
|
|
753
|
-
|
824
|
+
all_results.update({studio["studioUUId"]: studio for studio in studios})
|
825
|
+
if len(all_results) >= total_count or not studios:
|
754
826
|
break
|
755
827
|
|
756
828
|
params["pageIndex"] += 1
|
757
829
|
|
758
|
-
|
830
|
+
LOGGER.info("Studio search completed, fetched %d of %d studios", len(all_results), total_count, stacklevel=2)
|
759
831
|
|
760
|
-
|
761
|
-
"""Get the member's total classes. This is a simple object reflecting the total number of classes attended,
|
762
|
-
both in-studio and OT Live.
|
832
|
+
return [models.StudioDetail(**studio) for studio in all_results.values()]
|
763
833
|
|
764
|
-
|
765
|
-
TotalClasses: The member's total classes.
|
766
|
-
"""
|
767
|
-
data = await self._default_request("GET", "/mobile/v1/members/classes/summary")
|
768
|
-
return models.TotalClasses(**data["data"])
|
769
|
-
|
770
|
-
async def get_body_composition_list(self) -> models.BodyCompositionList:
|
834
|
+
def get_body_composition_list(self) -> list[models.BodyCompositionData]:
|
771
835
|
"""Get the member's body composition list.
|
772
836
|
|
773
837
|
Returns:
|
774
|
-
|
838
|
+
list[BodyCompositionData]: The member's body composition list.
|
775
839
|
"""
|
776
|
-
data =
|
840
|
+
data = self._default_request("GET", f"/member/members/{self.user.cognito_id}/body-composition")
|
841
|
+
return [models.BodyCompositionData(**item) for item in data["data"]]
|
777
842
|
|
778
|
-
|
779
|
-
|
780
|
-
async def get_challenge_tracker_content(self) -> models.ChallengeTrackerContent:
|
843
|
+
def get_challenge_tracker(self) -> models.ChallengeTracker:
|
781
844
|
"""Get the member's challenge tracker content.
|
782
845
|
|
783
846
|
Returns:
|
784
|
-
|
847
|
+
ChallengeTracker: The member's challenge tracker content.
|
785
848
|
"""
|
786
|
-
data =
|
787
|
-
return models.
|
849
|
+
data = self._default_request("GET", f"/challenges/v3.1/member/{self.member_uuid}")
|
850
|
+
return models.ChallengeTracker(**data["Dto"])
|
788
851
|
|
789
|
-
|
852
|
+
def get_benchmarks(
|
790
853
|
self,
|
791
|
-
|
792
|
-
|
793
|
-
|
794
|
-
):
|
795
|
-
"""Get the member's challenge tracker details.
|
854
|
+
challenge_category_id: models.ChallengeCategory | Literal[0] = 0,
|
855
|
+
equipment_id: models.EquipmentType | Literal[0] = 0,
|
856
|
+
challenge_subcategory_id: int = 0,
|
857
|
+
) -> list[models.FitnessBenchmark]:
|
858
|
+
"""Get the member's challenge tracker participation details.
|
796
859
|
|
797
860
|
Args:
|
798
|
-
|
799
|
-
|
800
|
-
|
861
|
+
challenge_category_id (ChallengeType): The challenge type ID.
|
862
|
+
equipment_id (EquipmentType | Literal[0]): The equipment ID, default is 0 - this doesn't seem\
|
863
|
+
to be have any impact on the results.
|
864
|
+
challenge_subcategory_id (int): The challenge sub type ID. Default is 0 - this doesn't seem\
|
865
|
+
to be have any impact on the results.
|
801
866
|
|
802
867
|
Returns:
|
803
|
-
|
804
|
-
|
805
|
-
Notes:
|
806
|
-
---
|
807
|
-
I'm not sure what the challenge_sub_type_id is supposed to be, so it defaults to 0.
|
808
|
-
|
868
|
+
list[FitnessBenchmark]: The member's challenge tracker details.
|
809
869
|
"""
|
810
870
|
params = {
|
811
|
-
"equipmentId": equipment_id
|
812
|
-
"challengeTypeId":
|
813
|
-
"challengeSubTypeId":
|
871
|
+
"equipmentId": int(equipment_id),
|
872
|
+
"challengeTypeId": int(challenge_category_id),
|
873
|
+
"challengeSubTypeId": challenge_subcategory_id,
|
814
874
|
}
|
815
875
|
|
816
|
-
data =
|
876
|
+
data = self._default_request("GET", f"/challenges/v3/member/{self.member_uuid}/benchmarks", params=params)
|
877
|
+
return [models.FitnessBenchmark(**item) for item in data["Dto"]]
|
817
878
|
|
818
|
-
|
879
|
+
def get_benchmarks_by_equipment(self, equipment_id: models.EquipmentType) -> list[models.FitnessBenchmark]:
|
880
|
+
"""Get the member's challenge tracker participation details by equipment.
|
819
881
|
|
820
|
-
|
821
|
-
|
882
|
+
Args:
|
883
|
+
equipment_id (EquipmentType): The equipment type ID.
|
884
|
+
|
885
|
+
Returns:
|
886
|
+
list[FitnessBenchmark]: The member's challenge tracker details.
|
887
|
+
"""
|
888
|
+
benchmarks = self.get_benchmarks(equipment_id=equipment_id)
|
889
|
+
|
890
|
+
benchmarks = [b for b in benchmarks if b.equipment_id == equipment_id]
|
891
|
+
|
892
|
+
return benchmarks
|
893
|
+
|
894
|
+
def get_benchmarks_by_challenge_category(
|
895
|
+
self, challenge_category_id: models.ChallengeCategory
|
896
|
+
) -> list[models.FitnessBenchmark]:
|
897
|
+
"""Get the member's challenge tracker participation details by challenge.
|
822
898
|
|
823
899
|
Args:
|
824
|
-
|
900
|
+
challenge_category_id (ChallengeType): The challenge type ID.
|
825
901
|
|
826
902
|
Returns:
|
827
|
-
|
903
|
+
list[FitnessBenchmark]: The member's challenge tracker details.
|
904
|
+
"""
|
905
|
+
benchmarks = self.get_benchmarks(challenge_category_id=challenge_category_id)
|
828
906
|
|
829
|
-
|
830
|
-
|
831
|
-
|
832
|
-
|
907
|
+
benchmarks = [b for b in benchmarks if b.challenge_category_id == challenge_category_id]
|
908
|
+
|
909
|
+
return benchmarks
|
910
|
+
|
911
|
+
def get_challenge_tracker_detail(self, challenge_category_id: models.ChallengeCategory) -> models.FitnessBenchmark:
|
912
|
+
"""Get details about a challenge. This endpoint does not (usually) return member participation, but rather
|
913
|
+
details about the challenge itself.
|
833
914
|
|
915
|
+
Args:
|
916
|
+
challenge_category_id (ChallengeType): The challenge type ID.
|
917
|
+
|
918
|
+
Returns:
|
919
|
+
FitnessBenchmark: Details about the challenge.
|
834
920
|
"""
|
835
921
|
|
836
|
-
data =
|
922
|
+
data = self._default_request(
|
837
923
|
"GET",
|
838
|
-
f"/challenges/v1/member/{self.
|
839
|
-
params={"challengeTypeId":
|
924
|
+
f"/challenges/v1/member/{self.member_uuid}/participation",
|
925
|
+
params={"challengeTypeId": int(challenge_category_id)},
|
840
926
|
)
|
841
|
-
return data
|
842
927
|
|
843
|
-
|
928
|
+
if len(data["Dto"]) > 1:
|
929
|
+
LOGGER.warning("Multiple challenge participations found, returning the first one.")
|
930
|
+
|
931
|
+
if len(data["Dto"]) == 0:
|
932
|
+
raise exc.ResourceNotFoundError(f"Challenge {challenge_category_id} not found")
|
933
|
+
|
934
|
+
return models.FitnessBenchmark(**data["Dto"][0])
|
935
|
+
|
936
|
+
def get_performance_summaries(self, limit: int = 5) -> list[models.PerformanceSummaryEntry]:
|
844
937
|
"""Get a list of performance summaries for the authenticated user.
|
845
938
|
|
846
939
|
Args:
|
847
|
-
limit (int): The maximum number of performance summaries to return. Defaults to
|
940
|
+
limit (int): The maximum number of performance summaries to return. Defaults to 5.
|
941
|
+
only_include_rateable (bool): Whether to only include rateable performance summaries. Defaults to True.
|
848
942
|
|
849
943
|
Returns:
|
850
|
-
|
944
|
+
list[PerformanceSummaryEntry]: A list of performance summaries.
|
851
945
|
|
852
946
|
Developer Notes:
|
853
947
|
---
|
@@ -855,15 +949,12 @@ class Otf:
|
|
855
949
|
|
856
950
|
"""
|
857
951
|
|
858
|
-
res =
|
859
|
-
|
860
|
-
|
861
|
-
|
862
|
-
params={"limit": limit},
|
863
|
-
)
|
864
|
-
return models.PerformanceSummaryList(summaries=res["items"])
|
952
|
+
res = self._performance_summary_request("GET", "/v1/performance-summaries", params={"limit": limit})
|
953
|
+
entries = [models.PerformanceSummaryEntry(**item) for item in res["items"]]
|
954
|
+
|
955
|
+
return entries
|
865
956
|
|
866
|
-
|
957
|
+
def get_performance_summary(self, performance_summary_id: str) -> models.PerformanceSummaryDetail:
|
867
958
|
"""Get a detailed performance summary for a given workout.
|
868
959
|
|
869
960
|
Args:
|
@@ -874,41 +965,29 @@ class Otf:
|
|
874
965
|
"""
|
875
966
|
|
876
967
|
path = f"/v1/performance-summaries/{performance_summary_id}"
|
877
|
-
res =
|
968
|
+
res = self._performance_summary_request("GET", path)
|
969
|
+
if res is None:
|
970
|
+
raise exc.ResourceNotFoundError(f"Performance summary {performance_summary_id} not found")
|
971
|
+
|
878
972
|
return models.PerformanceSummaryDetail(**res)
|
879
973
|
|
880
|
-
|
974
|
+
def get_hr_history(self) -> list[models.TelemetryHistoryItem]:
|
881
975
|
"""Get the heartrate history for the user.
|
882
976
|
|
883
977
|
Returns a list of history items that contain the max heartrate, start/end bpm for each zone,
|
884
978
|
the change from the previous, the change bucket, and the assigned at time.
|
885
979
|
|
886
980
|
Returns:
|
887
|
-
|
981
|
+
list[HistoryItem]: The heartrate history for the user.
|
888
982
|
|
889
983
|
"""
|
890
984
|
path = "/v1/physVars/maxHr/history"
|
891
985
|
|
892
|
-
params = {"memberUuid": self.
|
893
|
-
|
894
|
-
return models.
|
986
|
+
params = {"memberUuid": self.member_uuid}
|
987
|
+
resp = self._telemetry_request("GET", path, params=params)
|
988
|
+
return [models.TelemetryHistoryItem(**item) for item in resp["history"]]
|
895
989
|
|
896
|
-
|
897
|
-
"""Get the max heartrate for the user.
|
898
|
-
|
899
|
-
Returns a simple object that has the member_uuid and the max_hr.
|
900
|
-
|
901
|
-
Returns:
|
902
|
-
TelemetryMaxHr: The max heartrate for the user.
|
903
|
-
"""
|
904
|
-
path = "/v1/physVars/maxHr"
|
905
|
-
|
906
|
-
params = {"memberUuid": self._member_id}
|
907
|
-
|
908
|
-
res = await self._telemetry_request("GET", path, params=params)
|
909
|
-
return models.TelemetryMaxHr(**res)
|
910
|
-
|
911
|
-
async def get_telemetry(self, performance_summary_id: str, max_data_points: int = 120) -> models.Telemetry:
|
990
|
+
def get_telemetry(self, performance_summary_id: str, max_data_points: int = 120) -> models.Telemetry:
|
912
991
|
"""Get the telemetry for a performance summary.
|
913
992
|
|
914
993
|
This returns an object that contains the max heartrate, start/end bpm for each zone,
|
@@ -925,27 +1004,167 @@ class Otf:
|
|
925
1004
|
path = "/v1/performance/summary"
|
926
1005
|
|
927
1006
|
params = {"classHistoryUuid": performance_summary_id, "maxDataPoints": max_data_points}
|
928
|
-
res =
|
1007
|
+
res = self._telemetry_request("GET", path, params=params)
|
929
1008
|
return models.Telemetry(**res)
|
930
1009
|
|
1010
|
+
def get_sms_notification_settings(self) -> models.SmsNotificationSettings:
|
1011
|
+
"""Get the member's SMS notification settings.
|
1012
|
+
|
1013
|
+
Returns:
|
1014
|
+
SmsNotificationSettings: The member's SMS notification settings.
|
1015
|
+
"""
|
1016
|
+
res = self._default_request("GET", url="/sms/v1/preferences", params={"phoneNumber": self.member.phone_number})
|
1017
|
+
|
1018
|
+
return models.SmsNotificationSettings(**res["data"])
|
1019
|
+
|
1020
|
+
def update_sms_notification_settings(
|
1021
|
+
self, promotional_enabled: bool | None = None, transactional_enabled: bool | None = None
|
1022
|
+
) -> models.SmsNotificationSettings:
|
1023
|
+
"""Update the member's SMS notification settings. Arguments not provided will be left unchanged.
|
1024
|
+
|
1025
|
+
Args:
|
1026
|
+
promotional_enabled (bool | None): Whether to enable promotional SMS notifications.
|
1027
|
+
transactional_enabled (bool | None): Whether to enable transactional SMS notifications.
|
1028
|
+
|
1029
|
+
Returns:
|
1030
|
+
SmsNotificationSettings: The updated SMS notification settings.
|
1031
|
+
|
1032
|
+
Warning:
|
1033
|
+
---
|
1034
|
+
This endpoint seems to accept almost anything, converting values to truthy/falsey and
|
1035
|
+
updating the settings accordingly. The one error I've gotten is with -1
|
1036
|
+
|
1037
|
+
```
|
1038
|
+
ERROR - Response:
|
1039
|
+
{
|
1040
|
+
"code": "ER_WARN_DATA_OUT_OF_RANGE",
|
1041
|
+
"message": "An unexpected server error occurred, please try again.",
|
1042
|
+
"details": [
|
1043
|
+
{
|
1044
|
+
"message": "ER_WARN_DATA_OUT_OF_RANGE: Out of range value for column 'IsPromotionalSMSOptIn' at row 1",
|
1045
|
+
"additionalInfo": ""
|
1046
|
+
}
|
1047
|
+
]
|
1048
|
+
}
|
1049
|
+
```
|
1050
|
+
"""
|
1051
|
+
url = "/sms/v1/preferences"
|
1052
|
+
|
1053
|
+
current_settings = self.get_sms_notification_settings()
|
1054
|
+
|
1055
|
+
promotional_enabled = (
|
1056
|
+
promotional_enabled if promotional_enabled is not None else current_settings.is_promotional_sms_opt_in
|
1057
|
+
)
|
1058
|
+
transactional_enabled = (
|
1059
|
+
transactional_enabled if transactional_enabled is not None else current_settings.is_transactional_sms_opt_in
|
1060
|
+
)
|
1061
|
+
|
1062
|
+
body = {
|
1063
|
+
"promosms": promotional_enabled,
|
1064
|
+
"source": "OTF",
|
1065
|
+
"transactionalsms": transactional_enabled,
|
1066
|
+
"phoneNumber": self.member.phone_number,
|
1067
|
+
}
|
1068
|
+
|
1069
|
+
self._default_request("POST", url, json=body)
|
1070
|
+
|
1071
|
+
# the response returns nothing useful, so we just query the settings again
|
1072
|
+
new_settings = self.get_sms_notification_settings()
|
1073
|
+
return new_settings
|
1074
|
+
|
1075
|
+
def get_email_notification_settings(self) -> models.EmailNotificationSettings:
|
1076
|
+
"""Get the member's email notification settings.
|
1077
|
+
|
1078
|
+
Returns:
|
1079
|
+
EmailNotificationSettings: The member's email notification settings.
|
1080
|
+
"""
|
1081
|
+
res = self._default_request("GET", url="/otfmailing/v2/preferences", params={"email": self.member.email})
|
1082
|
+
|
1083
|
+
return models.EmailNotificationSettings(**res["data"])
|
1084
|
+
|
1085
|
+
def update_email_notification_settings(
|
1086
|
+
self, promotional_enabled: bool | None = None, transactional_enabled: bool | None = None
|
1087
|
+
) -> models.EmailNotificationSettings:
|
1088
|
+
"""Update the member's email notification settings. Arguments not provided will be left unchanged.
|
1089
|
+
|
1090
|
+
Args:
|
1091
|
+
promotional_enabled (bool | None): Whether to enable promotional email notifications.
|
1092
|
+
transactional_enabled (bool | None): Whether to enable transactional email notifications.
|
1093
|
+
|
1094
|
+
Returns:
|
1095
|
+
EmailNotificationSettings: The updated email notification settings.
|
1096
|
+
"""
|
1097
|
+
current_settings = self.get_email_notification_settings()
|
1098
|
+
|
1099
|
+
promotional_enabled = (
|
1100
|
+
promotional_enabled if promotional_enabled is not None else current_settings.is_promotional_email_opt_in
|
1101
|
+
)
|
1102
|
+
transactional_enabled = (
|
1103
|
+
transactional_enabled
|
1104
|
+
if transactional_enabled is not None
|
1105
|
+
else current_settings.is_transactional_email_opt_in
|
1106
|
+
)
|
1107
|
+
|
1108
|
+
body = {
|
1109
|
+
"promotionalEmail": promotional_enabled,
|
1110
|
+
"source": "OTF",
|
1111
|
+
"transactionalEmail": transactional_enabled,
|
1112
|
+
"email": self.member.email,
|
1113
|
+
}
|
1114
|
+
|
1115
|
+
self._default_request("POST", "/otfmailing/v2/preferences", json=body)
|
1116
|
+
|
1117
|
+
# the response returns nothing useful, so we just query the settings again
|
1118
|
+
new_settings = self.get_email_notification_settings()
|
1119
|
+
return new_settings
|
1120
|
+
|
1121
|
+
def update_member_name(self, first_name: str | None = None, last_name: str | None = None) -> models.MemberDetail:
|
1122
|
+
"""Update the member's name. Will return the original member details if no names are provided.
|
1123
|
+
|
1124
|
+
Args:
|
1125
|
+
first_name (str | None): The first name to update to. Default is None.
|
1126
|
+
last_name (str | None): The last name to update to. Default is None.
|
1127
|
+
|
1128
|
+
Returns:
|
1129
|
+
MemberDetail: The updated member details or the original member details if no changes were made.
|
1130
|
+
"""
|
1131
|
+
|
1132
|
+
if not first_name and not last_name:
|
1133
|
+
LOGGER.warning("No names provided, nothing to update.")
|
1134
|
+
return self.member
|
1135
|
+
|
1136
|
+
first_name = first_name or self.member.first_name
|
1137
|
+
last_name = last_name or self.member.last_name
|
1138
|
+
|
1139
|
+
if first_name == self.member.first_name and last_name == self.member.last_name:
|
1140
|
+
LOGGER.warning("No changes to names, nothing to update.")
|
1141
|
+
return self.member
|
1142
|
+
|
1143
|
+
path = f"/member/members/{self.member_uuid}"
|
1144
|
+
body = {"firstName": first_name, "lastName": last_name}
|
1145
|
+
|
1146
|
+
res = self._default_request("PUT", path, json=body)
|
1147
|
+
|
1148
|
+
return models.MemberDetail(**res["data"])
|
1149
|
+
|
931
1150
|
# the below do not return any data for me, so I can't test them
|
932
1151
|
|
933
|
-
|
1152
|
+
def _get_member_services(self, active_only: bool = True) -> Any:
|
934
1153
|
"""Get the member's services.
|
935
1154
|
|
936
1155
|
Args:
|
937
1156
|
active_only (bool): Whether to only include active services. Default is True.
|
938
1157
|
|
939
1158
|
Returns:
|
940
|
-
Any: The member's
|
941
|
-
|
1159
|
+
Any: The member's services.
|
1160
|
+
"""
|
942
1161
|
active_only_str = "true" if active_only else "false"
|
943
|
-
data =
|
944
|
-
"GET", f"/member/members/{self.
|
1162
|
+
data = self._default_request(
|
1163
|
+
"GET", f"/member/members/{self.member_uuid}/services", params={"activeOnly": active_only_str}
|
945
1164
|
)
|
946
1165
|
return data
|
947
1166
|
|
948
|
-
|
1167
|
+
def _get_aspire_data(self, datetime: str | None = None, unit: str | None = None) -> Any:
|
949
1168
|
"""Get data from the member's aspire wearable.
|
950
1169
|
|
951
1170
|
Note: I don't have an aspire wearable, so I can't test this.
|
@@ -959,5 +1178,5 @@ class Otf:
|
|
959
1178
|
"""
|
960
1179
|
params = {"datetime": datetime, "unit": unit}
|
961
1180
|
|
962
|
-
data = self._default_request("GET", f"/member/wearables/{self.
|
1181
|
+
data = self._default_request("GET", f"/member/wearables/{self.member_uuid}/wearable-daily", params=params)
|
963
1182
|
return data
|