otf-api 0.9.4__py3-none-any.whl → 0.10.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 +1 -1
- otf_api/api.py +391 -210
- otf_api/models/__init__.py +13 -7
- otf_api/models/enums.py +19 -4
- otf_api/models/performance_summary.py +169 -0
- otf_api/models/studio_detail.py +5 -3
- otf_api/models/telemetry.py +4 -1
- {otf_api-0.9.4.dist-info → otf_api-0.10.1.dist-info}/METADATA +3 -1
- {otf_api-0.9.4.dist-info → otf_api-0.10.1.dist-info}/RECORD +11 -12
- otf_api/models/performance_summary_detail.py +0 -89
- otf_api/models/performance_summary_list.py +0 -50
- {otf_api-0.9.4.dist-info → otf_api-0.10.1.dist-info}/LICENSE +0 -0
- {otf_api-0.9.4.dist-info → otf_api-0.10.1.dist-info}/WHEEL +0 -0
otf_api/api.py
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
import atexit
|
2
2
|
import contextlib
|
3
3
|
import functools
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
5
|
+
from copy import deepcopy
|
4
6
|
from datetime import date, datetime, timedelta
|
5
7
|
from json import JSONDecodeError
|
6
8
|
from logging import getLogger
|
@@ -8,11 +10,14 @@ from typing import Any, Literal
|
|
8
10
|
|
9
11
|
import attrs
|
10
12
|
import httpx
|
13
|
+
from cachetools import TTLCache, cached
|
14
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
11
15
|
from yarl import URL
|
12
16
|
|
13
17
|
from otf_api import exceptions as exc
|
14
18
|
from otf_api import filters, models
|
15
19
|
from otf_api.auth import OtfUser
|
20
|
+
from otf_api.models.enums import HISTORICAL_BOOKING_STATUSES
|
16
21
|
from otf_api.utils import ensure_date, ensure_list, get_booking_uuid, get_class_uuid
|
17
22
|
|
18
23
|
API_BASE_URL = "api.orangetheory.co"
|
@@ -58,6 +63,11 @@ class Otf:
|
|
58
63
|
# Combine immutable attributes into a single hash value
|
59
64
|
return hash(self.member_uuid)
|
60
65
|
|
66
|
+
@retry(
|
67
|
+
retry=retry_if_exception_type(exc.OtfRequestError),
|
68
|
+
stop=stop_after_attempt(3),
|
69
|
+
wait=wait_exponential(multiplier=1, min=4, max=10),
|
70
|
+
)
|
61
71
|
def _do(
|
62
72
|
self,
|
63
73
|
method: str,
|
@@ -87,13 +97,20 @@ class Otf:
|
|
87
97
|
LOGGER.exception(f"Response: {response.text}")
|
88
98
|
raise
|
89
99
|
except httpx.HTTPStatusError as e:
|
100
|
+
if e.response.status_code == 404:
|
101
|
+
raise exc.ResourceNotFoundError("Resource not found")
|
102
|
+
|
103
|
+
if e.response.status_code == 403:
|
104
|
+
raise
|
105
|
+
|
90
106
|
raise exc.OtfRequestError("Error making request", e, response=response, request=request)
|
91
107
|
except Exception as e:
|
92
108
|
LOGGER.exception(f"Error making request: {e}")
|
93
109
|
raise
|
94
110
|
|
95
111
|
if not response.text:
|
96
|
-
|
112
|
+
# insanely enough, at least one endpoint (get perf summary) returns None without error instead of 404
|
113
|
+
raise exc.OtfRequestError("Empty response", None, response=response, request=request)
|
97
114
|
|
98
115
|
try:
|
99
116
|
resp = response.json()
|
@@ -129,6 +146,204 @@ class Otf:
|
|
129
146
|
perf_api_headers = {"koji-member-id": self.member_uuid, "koji-member-email": self.user.email_address}
|
130
147
|
return self._do(method, API_IO_BASE_URL, url, params, perf_api_headers)
|
131
148
|
|
149
|
+
def _get_classes_raw(self, studio_uuids: list[str] | None) -> dict:
|
150
|
+
"""Retrieve raw class data."""
|
151
|
+
return self._classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})
|
152
|
+
|
153
|
+
def _get_booking_raw(self, booking_uuid: str) -> dict:
|
154
|
+
"""Retrieve raw booking data."""
|
155
|
+
return self._default_request("GET", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}")
|
156
|
+
|
157
|
+
def _get_bookings_raw(self, start_date: str | None, end_date: str | None, status: str | list[str] | None) -> dict:
|
158
|
+
"""Retrieve raw bookings data."""
|
159
|
+
|
160
|
+
if isinstance(status, list):
|
161
|
+
status = ",".join(status)
|
162
|
+
|
163
|
+
return self._default_request(
|
164
|
+
"GET",
|
165
|
+
f"/member/members/{self.member_uuid}/bookings",
|
166
|
+
params={"startDate": start_date, "endDate": end_date, "statuses": status},
|
167
|
+
)
|
168
|
+
|
169
|
+
def _get_member_detail_raw(self) -> dict:
|
170
|
+
"""Retrieve raw member details."""
|
171
|
+
return self._default_request(
|
172
|
+
"GET", f"/member/members/{self.member_uuid}", params={"include": "memberAddresses,memberClassSummary"}
|
173
|
+
)
|
174
|
+
|
175
|
+
def _get_member_membership_raw(self) -> dict:
|
176
|
+
"""Retrieve raw member membership details."""
|
177
|
+
return self._default_request("GET", f"/member/members/{self.member_uuid}/memberships")
|
178
|
+
|
179
|
+
def _get_performance_summaries_raw(self, limit: int | None = None) -> dict:
|
180
|
+
"""Retrieve raw performance summaries data."""
|
181
|
+
params = {"limit": limit} if limit else {}
|
182
|
+
return self._performance_summary_request("GET", "/v1/performance-summaries", params=params)
|
183
|
+
|
184
|
+
def _get_performance_summary_raw(self, performance_summary_id: str) -> dict:
|
185
|
+
"""Retrieve raw performance summary data."""
|
186
|
+
return self._performance_summary_request("GET", f"/v1/performance-summaries/{performance_summary_id}")
|
187
|
+
|
188
|
+
def _get_hr_history_raw(self) -> dict:
|
189
|
+
"""Retrieve raw heart rate history."""
|
190
|
+
return self._telemetry_request("GET", "/v1/physVars/maxHr/history", params={"memberUuid": self.member_uuid})
|
191
|
+
|
192
|
+
def _get_telemetry_raw(self, performance_summary_id: str, max_data_points: int) -> dict:
|
193
|
+
"""Retrieve raw telemetry data."""
|
194
|
+
return self._telemetry_request(
|
195
|
+
"GET",
|
196
|
+
"/v1/performance/summary",
|
197
|
+
params={"classHistoryUuid": performance_summary_id, "maxDataPoints": max_data_points},
|
198
|
+
)
|
199
|
+
|
200
|
+
def _get_studio_detail_raw(self, studio_uuid: str) -> dict:
|
201
|
+
"""Retrieve raw studio details."""
|
202
|
+
return self._default_request("GET", f"/mobile/v1/studios/{studio_uuid}")
|
203
|
+
|
204
|
+
def _get_studios_by_geo_raw(
|
205
|
+
self, latitude: float | None, longitude: float | None, distance: int, page_index: int, page_size: int
|
206
|
+
) -> dict:
|
207
|
+
"""Retrieve raw studios by geo data."""
|
208
|
+
return self._default_request(
|
209
|
+
"GET",
|
210
|
+
"/mobile/v1/studios",
|
211
|
+
params={
|
212
|
+
"latitude": latitude,
|
213
|
+
"longitude": longitude,
|
214
|
+
"distance": distance,
|
215
|
+
"pageIndex": page_index,
|
216
|
+
"pageSize": page_size,
|
217
|
+
},
|
218
|
+
)
|
219
|
+
|
220
|
+
def _get_body_composition_list_raw(self) -> dict:
|
221
|
+
"""Retrieve raw body composition list."""
|
222
|
+
return self._default_request("GET", f"/member/members/{self.user.cognito_id}/body-composition")
|
223
|
+
|
224
|
+
def _get_challenge_tracker_raw(self) -> dict:
|
225
|
+
"""Retrieve raw challenge tracker data."""
|
226
|
+
return self._default_request("GET", f"/challenges/v3.1/member/{self.member_uuid}")
|
227
|
+
|
228
|
+
def _get_benchmarks_raw(self, challenge_category_id: int, equipment_id: int, challenge_subcategory_id: int) -> dict:
|
229
|
+
"""Retrieve raw fitness benchmark data."""
|
230
|
+
return self._default_request(
|
231
|
+
"GET",
|
232
|
+
f"/challenges/v3/member/{self.member_uuid}/benchmarks",
|
233
|
+
params={
|
234
|
+
"equipmentId": equipment_id,
|
235
|
+
"challengeTypeId": challenge_category_id,
|
236
|
+
"challengeSubTypeId": challenge_subcategory_id,
|
237
|
+
},
|
238
|
+
)
|
239
|
+
|
240
|
+
def _get_sms_notification_settings_raw(self) -> dict:
|
241
|
+
"""Retrieve raw SMS notification settings."""
|
242
|
+
return self._default_request("GET", url="/sms/v1/preferences", params={"phoneNumber": self.member.phone_number})
|
243
|
+
|
244
|
+
def _get_email_notification_settings_raw(self) -> dict:
|
245
|
+
"""Retrieve raw email notification settings."""
|
246
|
+
return self._default_request("GET", url="/otfmailing/v2/preferences", params={"email": self.member.email})
|
247
|
+
|
248
|
+
def _get_member_lifetime_stats_raw(self, select_time: str) -> dict:
|
249
|
+
"""Retrieve raw lifetime stats data."""
|
250
|
+
return self._default_request("GET", f"/performance/v2/{self.member_uuid}/over-time/{select_time}")
|
251
|
+
|
252
|
+
def _get_member_services_raw(self, active_only: bool) -> dict:
|
253
|
+
"""Retrieve raw member services data."""
|
254
|
+
return self._default_request(
|
255
|
+
"GET", f"/member/members/{self.member_uuid}/services", params={"activeOnly": str(active_only).lower()}
|
256
|
+
)
|
257
|
+
|
258
|
+
def _get_aspire_data_raw(self, datetime: str | None, unit: str | None) -> dict:
|
259
|
+
"""Retrieve raw aspire wearable data."""
|
260
|
+
return self._default_request(
|
261
|
+
"GET", f"/member/wearables/{self.member_uuid}/wearable-daily", params={"datetime": datetime, "unit": unit}
|
262
|
+
)
|
263
|
+
|
264
|
+
def _get_member_purchases_raw(self) -> dict:
|
265
|
+
"""Retrieve raw member purchases data."""
|
266
|
+
return self._default_request("GET", f"/member/members/{self.member_uuid}/purchases")
|
267
|
+
|
268
|
+
def _get_favorite_studios_raw(self) -> dict:
|
269
|
+
"""Retrieve raw favorite studios data."""
|
270
|
+
return self._default_request("GET", f"/member/members/{self.member_uuid}/favorite-studios")
|
271
|
+
|
272
|
+
def _get_studio_services_raw(self, studio_uuid: str) -> dict:
|
273
|
+
"""Retrieve raw studio services data."""
|
274
|
+
return self._default_request("GET", f"/member/studios/{studio_uuid}/services")
|
275
|
+
|
276
|
+
def _get_out_of_studio_workout_history_raw(self) -> dict:
|
277
|
+
"""Retrieve raw out-of-studio workout history data."""
|
278
|
+
return self._default_request("GET", f"/member/members/{self.member_uuid}/out-of-studio-workout")
|
279
|
+
|
280
|
+
def _add_favorite_studio_raw(self, studio_uuids: list[str]) -> dict:
|
281
|
+
"""Retrieve raw response from adding a studio to favorite studios."""
|
282
|
+
return self._default_request("POST", "/mobile/v1/members/favorite-studios", json={"studioUUIds": studio_uuids})
|
283
|
+
|
284
|
+
def _remove_favorite_studio_raw(self, studio_uuids: list[str]) -> dict:
|
285
|
+
"""Retrieve raw response from removing a studio from favorite studios."""
|
286
|
+
return self._default_request(
|
287
|
+
"DELETE", "/mobile/v1/members/favorite-studios", json={"studioUUIds": studio_uuids}
|
288
|
+
)
|
289
|
+
|
290
|
+
def _get_challenge_tracker_detail_raw(self, challenge_category_id: int) -> dict:
|
291
|
+
"""Retrieve raw challenge tracker detail data."""
|
292
|
+
return self._default_request(
|
293
|
+
"GET",
|
294
|
+
f"/challenges/v1/member/{self.member_uuid}/participation",
|
295
|
+
params={"challengeTypeId": challenge_category_id},
|
296
|
+
)
|
297
|
+
|
298
|
+
def _update_sms_notification_settings_raw(self, promotional_enabled: bool, transactional_enabled: bool) -> dict:
|
299
|
+
"""Retrieve raw response from updating SMS notification settings."""
|
300
|
+
return self._default_request(
|
301
|
+
"POST",
|
302
|
+
"/sms/v1/preferences",
|
303
|
+
json={
|
304
|
+
"promosms": promotional_enabled,
|
305
|
+
"source": "OTF",
|
306
|
+
"transactionalsms": transactional_enabled,
|
307
|
+
"phoneNumber": self.member.phone_number,
|
308
|
+
},
|
309
|
+
)
|
310
|
+
|
311
|
+
def _update_email_notification_settings_raw(self, promotional_enabled: bool, transactional_enabled: bool) -> dict:
|
312
|
+
"""Retrieve raw response from updating email notification settings."""
|
313
|
+
return self._default_request(
|
314
|
+
"POST",
|
315
|
+
"/otfmailing/v2/preferences",
|
316
|
+
json={
|
317
|
+
"promotionalEmail": promotional_enabled,
|
318
|
+
"source": "OTF",
|
319
|
+
"transactionalEmail": transactional_enabled,
|
320
|
+
"email": self.member.email,
|
321
|
+
},
|
322
|
+
)
|
323
|
+
|
324
|
+
def _rate_class_raw(
|
325
|
+
self, class_uuid: str, performance_summary_id: str, class_rating: int, coach_rating: int
|
326
|
+
) -> dict:
|
327
|
+
"""Retrieve raw response from rating a class and coach."""
|
328
|
+
return self._default_request(
|
329
|
+
"POST",
|
330
|
+
"/mobile/v1/members/classes/ratings",
|
331
|
+
json={
|
332
|
+
"classUUId": class_uuid,
|
333
|
+
"otBeatClassHistoryUUId": performance_summary_id,
|
334
|
+
"classRating": class_rating,
|
335
|
+
"coachRating": coach_rating,
|
336
|
+
},
|
337
|
+
)
|
338
|
+
|
339
|
+
def _update_member_name_raw(self, first_name: str, last_name: str) -> dict:
|
340
|
+
"""Retrieve raw response from updating member name."""
|
341
|
+
return self._default_request(
|
342
|
+
"PUT",
|
343
|
+
f"/member/members/{self.member_uuid}",
|
344
|
+
json={"firstName": first_name, "lastName": last_name},
|
345
|
+
)
|
346
|
+
|
132
347
|
def get_classes(
|
133
348
|
self,
|
134
349
|
start_date: date | None = None,
|
@@ -204,7 +419,7 @@ class Otf:
|
|
204
419
|
else:
|
205
420
|
studio_uuids.append(self.home_studio_uuid)
|
206
421
|
|
207
|
-
classes_resp = self.
|
422
|
+
classes_resp = self._get_classes_raw(studio_uuids)
|
208
423
|
|
209
424
|
studio_dict = {s: self.get_studio_detail(s) for s in studio_uuids}
|
210
425
|
classes: list[models.OtfClass] = []
|
@@ -289,7 +504,7 @@ class Otf:
|
|
289
504
|
if not booking_uuid:
|
290
505
|
raise ValueError("booking_uuid is required")
|
291
506
|
|
292
|
-
data = self.
|
507
|
+
data = self._get_booking_raw(booking_uuid)
|
293
508
|
return models.Booking(**data["data"])
|
294
509
|
|
295
510
|
def get_booking_from_class(self, otf_class: str | models.OtfClass) -> models.Booking:
|
@@ -437,7 +652,7 @@ class Otf:
|
|
437
652
|
self,
|
438
653
|
start_date: date | str | None = None,
|
439
654
|
end_date: date | str | None = None,
|
440
|
-
status: models.BookingStatus | None = None,
|
655
|
+
status: models.BookingStatus | list[models.BookingStatus] | None = None,
|
441
656
|
exclude_cancelled: bool = True,
|
442
657
|
exclude_checkedin: bool = True,
|
443
658
|
) -> list[models.Booking]:
|
@@ -446,8 +661,7 @@ class Otf:
|
|
446
661
|
Args:
|
447
662
|
start_date (date | str | None): The start date for the bookings. Default is None.
|
448
663
|
end_date (date | str | None): The end date for the bookings. Default is None.
|
449
|
-
status (BookingStatus | None): The status
|
450
|
-
all statuses. Only a single status can be provided.
|
664
|
+
status (BookingStatus | list[BookingStatus] | None): The status(es) to filter by. Default is None.
|
451
665
|
exclude_cancelled (bool): Whether to exclude cancelled bookings. Default is True.
|
452
666
|
exclude_checkedin (bool): Whether to exclude checked-in bookings. Default is True.
|
453
667
|
|
@@ -467,12 +681,6 @@ class Otf:
|
|
467
681
|
---
|
468
682
|
If dates are provided, the endpoint will return bookings where the class date is within the provided
|
469
683
|
date range. If no dates are provided, it will go back 45 days and forward about 30 days.
|
470
|
-
|
471
|
-
Developer Notes:
|
472
|
-
---
|
473
|
-
Looking at the code in the app, it appears that this endpoint accepts multiple statuses. Indeed,
|
474
|
-
it does not throw an error if you include a list of statuses. However, only the last status in the list is
|
475
|
-
used. I'm not sure if this is a bug or if the API is supposed to work this way.
|
476
684
|
"""
|
477
685
|
|
478
686
|
if exclude_cancelled and status == models.BookingStatus.Cancelled:
|
@@ -487,11 +695,16 @@ class Otf:
|
|
487
695
|
if isinstance(end_date, date):
|
488
696
|
end_date = end_date.isoformat()
|
489
697
|
|
490
|
-
|
698
|
+
if isinstance(status, list):
|
699
|
+
status_value = ",".join(status)
|
700
|
+
elif isinstance(status, models.BookingStatus):
|
701
|
+
status_value = status.value
|
702
|
+
elif isinstance(status, str):
|
703
|
+
status_value = status
|
704
|
+
else:
|
705
|
+
status_value = None
|
491
706
|
|
492
|
-
|
493
|
-
|
494
|
-
resp = self._default_request("GET", f"/member/members/{self.member_uuid}/bookings", params=params)["data"]
|
707
|
+
resp = self._get_bookings_raw(start_date, end_date, status_value)["data"]
|
495
708
|
|
496
709
|
# add studio details for each booking, instead of using the different studio model returned by this endpoint
|
497
710
|
studio_uuids = {b["class"]["studio"]["studioUUId"] for b in resp}
|
@@ -512,55 +725,25 @@ class Otf:
|
|
512
725
|
|
513
726
|
return bookings
|
514
727
|
|
515
|
-
def
|
516
|
-
"""Get the member's bookings.
|
517
|
-
|
518
|
-
Args:
|
519
|
-
status (BookingStatus | None): The status of the bookings to get. Default is None, which includes
|
520
|
-
all statuses. Only a single status can be provided.
|
728
|
+
def get_historical_bookings(self) -> list[models.Booking]:
|
729
|
+
"""Get the member's historical bookings. This will go back 45 days and return all bookings
|
730
|
+
for that time period.
|
521
731
|
|
522
732
|
Returns:
|
523
|
-
list[Booking]: The member's bookings.
|
524
|
-
|
525
|
-
Raises:
|
526
|
-
ValueError: If an unaccepted status is provided.
|
527
|
-
|
528
|
-
Notes:
|
529
|
-
---
|
530
|
-
This one is called with the param named 'status'. Dates cannot be provided, because if the endpoint
|
531
|
-
receives a date, it will return as if the param name was 'statuses'.
|
532
|
-
|
533
|
-
Note: This seems to only work for Cancelled, Booked, CheckedIn, and Waitlisted statuses. If you provide
|
534
|
-
a different status, it will return all bookings, not filtered by status. The results in this scenario do
|
535
|
-
not line up with the `get_bookings` with no status provided, as that returns fewer records. Likely the
|
536
|
-
filtered dates are different on the backend.
|
537
|
-
|
538
|
-
My guess: the endpoint called with dates and 'statuses' is a "v2" kind of thing, where they upgraded without
|
539
|
-
changing the version of the api. Calling it with no dates and a singular (limited) status is probably v1.
|
540
|
-
|
541
|
-
I'm leaving this in here for reference, but marking it private. I just don't want to have to puzzle over
|
542
|
-
this again if I remove it and forget about it.
|
543
|
-
|
733
|
+
list[Booking]: The member's historical bookings.
|
544
734
|
"""
|
545
|
-
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
|
556
|
-
status_value = status.value if status else None
|
557
|
-
|
558
|
-
res = self._default_request(
|
559
|
-
"GET", f"/member/members/{self.member_uuid}/bookings", params={"status": status_value}
|
735
|
+
# api goes back 45 days but we'll go back 47 to be safe
|
736
|
+
start_date = datetime.today().date() - timedelta(days=47)
|
737
|
+
end_date = datetime.today().date()
|
738
|
+
|
739
|
+
return self.get_bookings(
|
740
|
+
start_date=start_date,
|
741
|
+
end_date=end_date,
|
742
|
+
status=HISTORICAL_BOOKING_STATUSES,
|
743
|
+
exclude_cancelled=False,
|
744
|
+
exclude_checkedin=False,
|
560
745
|
)
|
561
746
|
|
562
|
-
return [models.Booking(**b) for b in res["data"]]
|
563
|
-
|
564
747
|
def get_member_detail(self) -> models.MemberDetail:
|
565
748
|
"""Get the member details.
|
566
749
|
|
@@ -568,9 +751,7 @@ class Otf:
|
|
568
751
|
MemberDetail: The member details.
|
569
752
|
"""
|
570
753
|
|
571
|
-
|
572
|
-
|
573
|
-
resp = self._default_request("GET", f"/member/members/{self.member_uuid}", params=params)
|
754
|
+
resp = self._get_member_detail_raw()
|
574
755
|
data = resp["data"]
|
575
756
|
|
576
757
|
# use standard StudioDetail model instead of the one returned by this endpoint
|
@@ -586,7 +767,7 @@ class Otf:
|
|
586
767
|
MemberMembership: The member's membership details.
|
587
768
|
"""
|
588
769
|
|
589
|
-
data = self.
|
770
|
+
data = self._get_member_membership_raw()
|
590
771
|
return models.MemberMembership(**data["data"])
|
591
772
|
|
592
773
|
def get_member_purchases(self) -> list[models.MemberPurchase]:
|
@@ -595,9 +776,7 @@ class Otf:
|
|
595
776
|
Returns:
|
596
777
|
list[MemberPurchase]: The member's purchases.
|
597
778
|
"""
|
598
|
-
|
599
|
-
|
600
|
-
purchases = data["data"]
|
779
|
+
purchases = self._get_member_purchases_raw()["data"]
|
601
780
|
|
602
781
|
for p in purchases:
|
603
782
|
p["studio"] = self.get_studio_detail(p["studio"]["studioUUId"])
|
@@ -621,7 +800,7 @@ class Otf:
|
|
621
800
|
Any: The member's lifetime stats.
|
622
801
|
"""
|
623
802
|
|
624
|
-
data = self.
|
803
|
+
data = self._get_member_lifetime_stats_raw(select_time.value)
|
625
804
|
|
626
805
|
stats = models.StatsResponse(**data["data"])
|
627
806
|
|
@@ -665,7 +844,7 @@ class Otf:
|
|
665
844
|
Returns:
|
666
845
|
list[OutOfStudioWorkoutHistory]: The member's out of studio workout history.
|
667
846
|
"""
|
668
|
-
data = self.
|
847
|
+
data = self._get_out_of_studio_workout_history_raw()
|
669
848
|
|
670
849
|
return [models.OutOfStudioWorkoutHistory(**workout) for workout in data["data"]]
|
671
850
|
|
@@ -675,7 +854,7 @@ class Otf:
|
|
675
854
|
Returns:
|
676
855
|
list[StudioDetail]: The member's favorite studios.
|
677
856
|
"""
|
678
|
-
data = self.
|
857
|
+
data = self._get_favorite_studios_raw()
|
679
858
|
studio_uuids = [studio["studioUUId"] for studio in data["data"]]
|
680
859
|
return [self.get_studio_detail(studio_uuid) for studio_uuid in studio_uuids]
|
681
860
|
|
@@ -694,8 +873,7 @@ class Otf:
|
|
694
873
|
if not studio_uuids:
|
695
874
|
raise ValueError("studio_uuids is required")
|
696
875
|
|
697
|
-
|
698
|
-
resp = self._default_request("POST", "/mobile/v1/members/favorite-studios", json=body)
|
876
|
+
resp = self._add_favorite_studio_raw(studio_uuids)
|
699
877
|
|
700
878
|
new_faves = resp.get("data", {}).get("studios", [])
|
701
879
|
|
@@ -716,8 +894,9 @@ class Otf:
|
|
716
894
|
if not studio_uuids:
|
717
895
|
raise ValueError("studio_uuids is required")
|
718
896
|
|
719
|
-
|
720
|
-
|
897
|
+
# keeping the convention of regular/raw methods even though this method doesn't return anything
|
898
|
+
# in case that changes in the future
|
899
|
+
self._remove_favorite_studio_raw(studio_uuids)
|
721
900
|
|
722
901
|
def get_studio_services(self, studio_uuid: str | None = None) -> list[models.StudioService]:
|
723
902
|
"""Get the services available at a specific studio. If no studio UUID is provided, the member's home studio
|
@@ -730,18 +909,20 @@ class Otf:
|
|
730
909
|
list[StudioService]: The services available at the studio.
|
731
910
|
"""
|
732
911
|
studio_uuid = studio_uuid or self.home_studio_uuid
|
733
|
-
data = self.
|
912
|
+
data = self._get_studio_services_raw(studio_uuid)
|
734
913
|
|
735
914
|
for d in data["data"]:
|
736
915
|
d["studio"] = self.get_studio_detail(studio_uuid)
|
737
916
|
|
738
917
|
return [models.StudioService(**d) for d in data["data"]]
|
739
918
|
|
740
|
-
@
|
919
|
+
@cached(cache=TTLCache(maxsize=1024, ttl=600))
|
741
920
|
def get_studio_detail(self, studio_uuid: str | None = None) -> models.StudioDetail:
|
742
921
|
"""Get detailed information about a specific studio. If no studio UUID is provided, it will default to the
|
743
922
|
user's home studio.
|
744
923
|
|
924
|
+
If the studio is not found, it will return a StudioDetail object with default values.
|
925
|
+
|
745
926
|
Args:
|
746
927
|
studio_uuid (str, optional): The studio UUID to get detailed information about.
|
747
928
|
|
@@ -750,9 +931,10 @@ class Otf:
|
|
750
931
|
"""
|
751
932
|
studio_uuid = studio_uuid or self.home_studio_uuid
|
752
933
|
|
753
|
-
|
754
|
-
|
755
|
-
|
934
|
+
try:
|
935
|
+
res = self._get_studio_detail_raw(studio_uuid)
|
936
|
+
except exc.ResourceNotFoundError:
|
937
|
+
return models.StudioDetail(studioUUId=studio_uuid, studioName="Studio Not Found", studioStatus="Unknown")
|
756
938
|
|
757
939
|
return models.StudioDetail(**res["data"])
|
758
940
|
|
@@ -804,18 +986,25 @@ class Otf:
|
|
804
986
|
Returns:
|
805
987
|
list[models.StudioDetail]: List of studios matching the search criteria.
|
806
988
|
"""
|
807
|
-
path = "/mobile/v1/studios"
|
808
|
-
|
809
989
|
distance = min(distance, 250) # max distance is 250 miles
|
810
|
-
|
811
|
-
|
812
|
-
|
813
|
-
|
990
|
+
page_size = 100
|
991
|
+
page_index = 1
|
992
|
+
LOGGER.debug(
|
993
|
+
"Starting studio search",
|
994
|
+
extra={
|
995
|
+
"latitude": latitude,
|
996
|
+
"longitude": longitude,
|
997
|
+
"distance": distance,
|
998
|
+
"page_index": page_index,
|
999
|
+
"page_size": page_size,
|
1000
|
+
},
|
1001
|
+
)
|
814
1002
|
|
815
1003
|
all_results: dict[str, dict[str, Any]] = {}
|
816
1004
|
|
817
1005
|
while True:
|
818
|
-
res = self.
|
1006
|
+
res = self._get_studios_by_geo_raw(latitude, longitude, distance, page_index, page_size)
|
1007
|
+
|
819
1008
|
studios = res["data"].get("studios", [])
|
820
1009
|
total_count = res["data"].get("pagination", {}).get("totalCount", 0)
|
821
1010
|
|
@@ -823,7 +1012,7 @@ class Otf:
|
|
823
1012
|
if len(all_results) >= total_count or not studios:
|
824
1013
|
break
|
825
1014
|
|
826
|
-
|
1015
|
+
page_index += 1
|
827
1016
|
|
828
1017
|
LOGGER.info("Studio search completed, fetched %d of %d studios", len(all_results), total_count, stacklevel=2)
|
829
1018
|
|
@@ -835,7 +1024,7 @@ class Otf:
|
|
835
1024
|
Returns:
|
836
1025
|
list[BodyCompositionData]: The member's body composition list.
|
837
1026
|
"""
|
838
|
-
data = self.
|
1027
|
+
data = self._get_body_composition_list_raw()
|
839
1028
|
return [models.BodyCompositionData(**item) for item in data["data"]]
|
840
1029
|
|
841
1030
|
def get_challenge_tracker(self) -> models.ChallengeTracker:
|
@@ -844,7 +1033,7 @@ class Otf:
|
|
844
1033
|
Returns:
|
845
1034
|
ChallengeTracker: The member's challenge tracker content.
|
846
1035
|
"""
|
847
|
-
data = self.
|
1036
|
+
data = self._get_challenge_tracker_raw()
|
848
1037
|
return models.ChallengeTracker(**data["Dto"])
|
849
1038
|
|
850
1039
|
def get_benchmarks(
|
@@ -865,13 +1054,7 @@ class Otf:
|
|
865
1054
|
Returns:
|
866
1055
|
list[FitnessBenchmark]: The member's challenge tracker details.
|
867
1056
|
"""
|
868
|
-
|
869
|
-
"equipmentId": int(equipment_id),
|
870
|
-
"challengeTypeId": int(challenge_category_id),
|
871
|
-
"challengeSubTypeId": challenge_subcategory_id,
|
872
|
-
}
|
873
|
-
|
874
|
-
data = self._default_request("GET", f"/challenges/v3/member/{self.member_uuid}/benchmarks", params=params)
|
1057
|
+
data = self._get_benchmarks_raw(int(challenge_category_id), int(equipment_id), challenge_subcategory_id)
|
875
1058
|
return [models.FitnessBenchmark(**item) for item in data["Dto"]]
|
876
1059
|
|
877
1060
|
def get_benchmarks_by_equipment(self, equipment_id: models.EquipmentType) -> list[models.FitnessBenchmark]:
|
@@ -917,11 +1100,7 @@ class Otf:
|
|
917
1100
|
FitnessBenchmark: Details about the challenge.
|
918
1101
|
"""
|
919
1102
|
|
920
|
-
data = self.
|
921
|
-
"GET",
|
922
|
-
f"/challenges/v1/member/{self.member_uuid}/participation",
|
923
|
-
params={"challengeTypeId": int(challenge_category_id)},
|
924
|
-
)
|
1103
|
+
data = self._get_challenge_tracker_detail_raw(int(challenge_category_id))
|
925
1104
|
|
926
1105
|
if len(data["Dto"]) > 1:
|
927
1106
|
LOGGER.warning("Multiple challenge participations found, returning the first one.")
|
@@ -931,15 +1110,52 @@ class Otf:
|
|
931
1110
|
|
932
1111
|
return models.FitnessBenchmark(**data["Dto"][0])
|
933
1112
|
|
934
|
-
|
935
|
-
|
1113
|
+
@cached(cache=TTLCache(maxsize=1024, ttl=600))
|
1114
|
+
def get_performance_summaries_dict(self, limit: int | None = None) -> dict[str, models.PerformanceSummary]:
|
1115
|
+
"""Get a dictionary of performance summaries for the authenticated user.
|
1116
|
+
|
1117
|
+
Args:
|
1118
|
+
limit (int | None): The maximum number of entries to return. Default is None.
|
1119
|
+
|
1120
|
+
Returns:
|
1121
|
+
dict[str, PerformanceSummary]: A dictionary of performance summaries, keyed by class history UUID.
|
1122
|
+
|
1123
|
+
Developer Notes:
|
1124
|
+
---
|
1125
|
+
In the app, this is referred to as 'getInStudioWorkoutHistory'.
|
1126
|
+
|
1127
|
+
"""
|
1128
|
+
|
1129
|
+
items = self._get_performance_summaries_raw(limit=limit)["items"]
|
1130
|
+
|
1131
|
+
distinct_studio_ids = set([rec["class"]["studio"]["id"] for rec in items])
|
1132
|
+
perf_summary_ids = set([rec["id"] for rec in items])
|
1133
|
+
|
1134
|
+
with ThreadPoolExecutor() as pool:
|
1135
|
+
studio_futures = {s: pool.submit(self.get_studio_detail, s) for s in distinct_studio_ids}
|
1136
|
+
perf_summary_futures = {s: pool.submit(self._get_performancy_summary_detail, s) for s in perf_summary_ids}
|
1137
|
+
|
1138
|
+
studio_dict = {k: v.result() for k, v in studio_futures.items()}
|
1139
|
+
# deepcopy these so that mutating them in PerformanceSummary doesn't affect the cache
|
1140
|
+
perf_summary_dict = {k: deepcopy(v.result()) for k, v in perf_summary_futures.items()}
|
1141
|
+
|
1142
|
+
for item in items:
|
1143
|
+
item["class"]["studio"] = studio_dict[item["class"]["studio"]["id"]]
|
1144
|
+
item["detail"] = perf_summary_dict[item["id"]]
|
1145
|
+
|
1146
|
+
entries = [models.PerformanceSummary(**item) for item in items]
|
1147
|
+
entries_dict = {entry.performance_summary_id: entry for entry in entries}
|
1148
|
+
|
1149
|
+
return entries_dict
|
1150
|
+
|
1151
|
+
def get_performance_summaries(self, limit: int | None = None) -> list[models.PerformanceSummary]:
|
1152
|
+
"""Get a list of all performance summaries for the authenticated user.
|
936
1153
|
|
937
1154
|
Args:
|
938
|
-
limit (int): The maximum number of
|
939
|
-
only_include_rateable (bool): Whether to only include rateable performance summaries. Defaults to True.
|
1155
|
+
limit (int | None): The maximum number of entries to return. Default is None.
|
940
1156
|
|
941
1157
|
Returns:
|
942
|
-
list[
|
1158
|
+
list[PerformanceSummary]: A list of performance summaries.
|
943
1159
|
|
944
1160
|
Developer Notes:
|
945
1161
|
---
|
@@ -947,28 +1163,55 @@ class Otf:
|
|
947
1163
|
|
948
1164
|
"""
|
949
1165
|
|
950
|
-
|
951
|
-
entries = [models.PerformanceSummaryEntry(**item) for item in res["items"]]
|
1166
|
+
records = list(self.get_performance_summaries_dict(limit=limit).values())
|
952
1167
|
|
953
|
-
|
1168
|
+
sorted_records = sorted(records, key=lambda x: x.otf_class.starts_at, reverse=True)
|
954
1169
|
|
955
|
-
|
956
|
-
|
1170
|
+
return sorted_records
|
1171
|
+
|
1172
|
+
def get_performance_summary(
|
1173
|
+
self, performance_summary_id: str, limit: int | None = None
|
1174
|
+
) -> models.PerformanceSummary:
|
1175
|
+
"""Get performance summary for a given workout.
|
1176
|
+
|
1177
|
+
Note: Due to the way the OTF API is set up, we have to call both the list and the get endpoints. By
|
1178
|
+
default this will call the list endpoint with no limit, in order to ensure that the performance summary
|
1179
|
+
is returned if it exists. This could result in a lot of requests, so you also have the option to provide
|
1180
|
+
a limit to only fetch a certain number of performance summaries.
|
957
1181
|
|
958
1182
|
Args:
|
959
1183
|
performance_summary_id (str): The ID of the performance summary to retrieve.
|
960
1184
|
|
961
1185
|
Returns:
|
962
|
-
|
1186
|
+
PerformanceSummary: The performance summary.
|
1187
|
+
|
1188
|
+
Raises:
|
1189
|
+
ResourceNotFoundError: If the performance_summary_id is not in the list of performance summaries.
|
963
1190
|
"""
|
964
1191
|
|
965
|
-
|
966
|
-
res = self._performance_summary_request("GET", path)
|
1192
|
+
perf_summary = self.get_performance_summaries_dict(limit=limit).get(performance_summary_id)
|
967
1193
|
|
968
|
-
if
|
1194
|
+
if perf_summary is None:
|
969
1195
|
raise exc.ResourceNotFoundError(f"Performance summary {performance_summary_id} not found")
|
970
1196
|
|
971
|
-
return
|
1197
|
+
return perf_summary
|
1198
|
+
|
1199
|
+
@functools.lru_cache(maxsize=1024)
|
1200
|
+
def _get_performancy_summary_detail(self, performance_summary_id: str) -> dict[str, Any]:
|
1201
|
+
"""Get the details for a performance summary. Generally should not be called directly. This
|
1202
|
+
|
1203
|
+
Args:
|
1204
|
+
performance_summary_id (str): The performance summary ID.
|
1205
|
+
|
1206
|
+
Returns:
|
1207
|
+
dict[str, Any]: The performance summary details.
|
1208
|
+
|
1209
|
+
Developer Notes:
|
1210
|
+
---
|
1211
|
+
This is mostly here to cache the results of the raw method.
|
1212
|
+
"""
|
1213
|
+
|
1214
|
+
return self._get_performance_summary_raw(performance_summary_id)
|
972
1215
|
|
973
1216
|
def get_hr_history(self) -> list[models.TelemetryHistoryItem]:
|
974
1217
|
"""Get the heartrate history for the user.
|
@@ -980,10 +1223,7 @@ class Otf:
|
|
980
1223
|
list[HistoryItem]: The heartrate history for the user.
|
981
1224
|
|
982
1225
|
"""
|
983
|
-
|
984
|
-
|
985
|
-
params = {"memberUuid": self.member_uuid}
|
986
|
-
resp = self._telemetry_request("GET", path, params=params)
|
1226
|
+
resp = self._get_hr_history_raw()
|
987
1227
|
return [models.TelemetryHistoryItem(**item) for item in resp["history"]]
|
988
1228
|
|
989
1229
|
def get_telemetry(self, performance_summary_id: str, max_data_points: int = 120) -> models.Telemetry:
|
@@ -998,12 +1238,9 @@ class Otf:
|
|
998
1238
|
|
999
1239
|
Returns:
|
1000
1240
|
TelemetryItem: The telemetry for the class history.
|
1001
|
-
|
1002
1241
|
"""
|
1003
|
-
path = "/v1/performance/summary"
|
1004
1242
|
|
1005
|
-
|
1006
|
-
res = self._telemetry_request("GET", path, params=params)
|
1243
|
+
res = self._get_telemetry_raw(performance_summary_id, max_data_points)
|
1007
1244
|
return models.Telemetry(**res)
|
1008
1245
|
|
1009
1246
|
def get_sms_notification_settings(self) -> models.SmsNotificationSettings:
|
@@ -1012,7 +1249,7 @@ class Otf:
|
|
1012
1249
|
Returns:
|
1013
1250
|
SmsNotificationSettings: The member's SMS notification settings.
|
1014
1251
|
"""
|
1015
|
-
res = self.
|
1252
|
+
res = self._get_sms_notification_settings_raw()
|
1016
1253
|
|
1017
1254
|
return models.SmsNotificationSettings(**res["data"])
|
1018
1255
|
|
@@ -1047,7 +1284,6 @@ class Otf:
|
|
1047
1284
|
}
|
1048
1285
|
```
|
1049
1286
|
"""
|
1050
|
-
url = "/sms/v1/preferences"
|
1051
1287
|
|
1052
1288
|
current_settings = self.get_sms_notification_settings()
|
1053
1289
|
|
@@ -1058,14 +1294,7 @@ class Otf:
|
|
1058
1294
|
transactional_enabled if transactional_enabled is not None else current_settings.is_transactional_sms_opt_in
|
1059
1295
|
)
|
1060
1296
|
|
1061
|
-
|
1062
|
-
"promosms": promotional_enabled,
|
1063
|
-
"source": "OTF",
|
1064
|
-
"transactionalsms": transactional_enabled,
|
1065
|
-
"phoneNumber": self.member.phone_number,
|
1066
|
-
}
|
1067
|
-
|
1068
|
-
self._default_request("POST", url, json=body)
|
1297
|
+
self._update_sms_notification_settings_raw(promotional_enabled, transactional_enabled)
|
1069
1298
|
|
1070
1299
|
# the response returns nothing useful, so we just query the settings again
|
1071
1300
|
new_settings = self.get_sms_notification_settings()
|
@@ -1077,7 +1306,7 @@ class Otf:
|
|
1077
1306
|
Returns:
|
1078
1307
|
EmailNotificationSettings: The member's email notification settings.
|
1079
1308
|
"""
|
1080
|
-
res = self.
|
1309
|
+
res = self._get_email_notification_settings_raw()
|
1081
1310
|
|
1082
1311
|
return models.EmailNotificationSettings(**res["data"])
|
1083
1312
|
|
@@ -1104,14 +1333,7 @@ class Otf:
|
|
1104
1333
|
else current_settings.is_transactional_email_opt_in
|
1105
1334
|
)
|
1106
1335
|
|
1107
|
-
|
1108
|
-
"promotionalEmail": promotional_enabled,
|
1109
|
-
"source": "OTF",
|
1110
|
-
"transactionalEmail": transactional_enabled,
|
1111
|
-
"email": self.member.email,
|
1112
|
-
}
|
1113
|
-
|
1114
|
-
self._default_request("POST", "/otfmailing/v2/preferences", json=body)
|
1336
|
+
self._update_email_notification_settings_raw(promotional_enabled, transactional_enabled)
|
1115
1337
|
|
1116
1338
|
# the response returns nothing useful, so we just query the settings again
|
1117
1339
|
new_settings = self.get_email_notification_settings()
|
@@ -1139,20 +1361,17 @@ class Otf:
|
|
1139
1361
|
LOGGER.warning("No changes to names, nothing to update.")
|
1140
1362
|
return self.member
|
1141
1363
|
|
1142
|
-
|
1143
|
-
body = {"firstName": first_name, "lastName": last_name}
|
1144
|
-
|
1145
|
-
res = self._default_request("PUT", path, json=body)
|
1364
|
+
res = self._update_member_name_raw(first_name, last_name)
|
1146
1365
|
|
1147
1366
|
return models.MemberDetail(**res["data"])
|
1148
1367
|
|
1149
1368
|
def _rate_class(
|
1150
1369
|
self,
|
1151
1370
|
class_uuid: str,
|
1152
|
-
|
1371
|
+
performance_summary_id: str,
|
1153
1372
|
class_rating: Literal[0, 1, 2, 3],
|
1154
1373
|
coach_rating: Literal[0, 1, 2, 3],
|
1155
|
-
) -> models.
|
1374
|
+
) -> models.PerformanceSummary:
|
1156
1375
|
"""Rate a class and coach. A simpler method is provided in `rate_class_from_performance_summary`.
|
1157
1376
|
|
1158
1377
|
|
@@ -1162,12 +1381,12 @@ class Otf:
|
|
1162
1381
|
|
1163
1382
|
Args:
|
1164
1383
|
class_uuid (str): The class UUID.
|
1165
|
-
|
1384
|
+
performance_summary_id (str): The performance summary ID.
|
1166
1385
|
class_rating (int): The class rating. Must be 0, 1, 2, or 3.
|
1167
1386
|
coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
|
1168
1387
|
|
1169
1388
|
Returns:
|
1170
|
-
|
1389
|
+
PerformanceSummary: The updated performance summary.
|
1171
1390
|
"""
|
1172
1391
|
|
1173
1392
|
# com/orangetheoryfitness/fragment/rating/RateStatus.java
|
@@ -1188,93 +1407,60 @@ class Otf:
|
|
1188
1407
|
body_class_rating = CLASS_RATING_MAP[class_rating]
|
1189
1408
|
body_coach_rating = COACH_RATING_MAP[coach_rating]
|
1190
1409
|
|
1191
|
-
body = {
|
1192
|
-
"classUUId": class_uuid,
|
1193
|
-
"otBeatClassHistoryUUId": class_history_uuid,
|
1194
|
-
"classRating": body_class_rating,
|
1195
|
-
"coachRating": body_coach_rating,
|
1196
|
-
}
|
1197
|
-
|
1198
1410
|
try:
|
1199
|
-
self.
|
1411
|
+
self._rate_class_raw(class_uuid, performance_summary_id, body_class_rating, body_coach_rating)
|
1200
1412
|
except exc.OtfRequestError as e:
|
1201
1413
|
if e.response.status_code == 403:
|
1202
|
-
raise exc.AlreadyRatedError(f"Performance summary {
|
1414
|
+
raise exc.AlreadyRatedError(f"Performance summary {performance_summary_id} is already rated.") from None
|
1203
1415
|
raise
|
1204
1416
|
|
1205
|
-
|
1206
|
-
|
1207
|
-
|
1208
|
-
|
1209
|
-
|
1210
|
-
|
1211
|
-
the class UUID, which is required to rate the class. It will also be used to return an updated performance
|
1212
|
-
summary entry after rating a class.
|
1213
|
-
|
1214
|
-
Args:
|
1215
|
-
class_history_uuid (str): The performance summary ID.
|
1216
|
-
|
1217
|
-
Returns:
|
1218
|
-
PerformanceSummaryEntry: The performance summary entry.
|
1219
|
-
|
1220
|
-
Raises:
|
1221
|
-
ResourceNotFoundError: If the performance summary is not found.
|
1222
|
-
"""
|
1223
|
-
|
1224
|
-
# try going in as small of increments as possible, assuming that the rating request
|
1225
|
-
# will be for a recent class
|
1226
|
-
for limit in [5, 20, 60, 100]:
|
1227
|
-
summaries = self.get_performance_summaries(limit)
|
1228
|
-
summary = next((s for s in summaries if s.class_history_uuid == class_history_uuid), None)
|
1417
|
+
# we have to clear the cache after rating a class, otherwise we will get back the same data
|
1418
|
+
# showing it not rated. that would be incorrect, confusing, and would cause errors if the user
|
1419
|
+
# then attempted to rate the class again.
|
1420
|
+
# we could attempt to only refresh the one record but with these endpoints that's not simple
|
1421
|
+
# NOTE: the individual perf summary endpoint does not have rating data, so it's cache is not cleared
|
1422
|
+
self.get_performance_summaries_dict.cache_clear()
|
1229
1423
|
|
1230
|
-
|
1231
|
-
return summary
|
1232
|
-
|
1233
|
-
raise exc.ResourceNotFoundError(f"Performance summary {class_history_uuid} not found.")
|
1424
|
+
return self.get_performance_summary(performance_summary_id)
|
1234
1425
|
|
1235
1426
|
def rate_class_from_performance_summary(
|
1236
1427
|
self,
|
1237
|
-
perf_summary: models.
|
1428
|
+
perf_summary: models.PerformanceSummary,
|
1238
1429
|
class_rating: Literal[0, 1, 2, 3],
|
1239
1430
|
coach_rating: Literal[0, 1, 2, 3],
|
1240
|
-
) -> models.
|
1431
|
+
) -> models.PerformanceSummary:
|
1241
1432
|
"""Rate a class and coach. The class rating must be 0, 1, 2, or 3. 0 is the same as dismissing the prompt to
|
1242
1433
|
rate the class/coach. 1 - 3 is a range from bad to good.
|
1243
1434
|
|
1244
1435
|
Args:
|
1245
|
-
perf_summary (
|
1436
|
+
perf_summary (PerformanceSummary): The performance summary to rate.
|
1246
1437
|
class_rating (int): The class rating. Must be 0, 1, 2, or 3.
|
1247
1438
|
coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
|
1248
1439
|
|
1249
1440
|
Returns:
|
1250
|
-
|
1441
|
+
PerformanceSummary: The updated performance summary.
|
1251
1442
|
|
1252
1443
|
Raises:
|
1253
|
-
ValueError: If `perf_summary` is not a PerformanceSummaryEntry.
|
1254
1444
|
AlreadyRatedError: If the performance summary is already rated.
|
1255
1445
|
ClassNotRatableError: If the performance summary is not rateable.
|
1256
1446
|
ValueError: If the performance summary does not have an associated class.
|
1257
1447
|
"""
|
1258
1448
|
|
1259
|
-
if isinstance(perf_summary, models.PerformanceSummaryDetail):
|
1260
|
-
perf_summary = self._get_performance_summary_entry_from_id(perf_summary.class_history_uuid)
|
1261
|
-
|
1262
|
-
if not isinstance(perf_summary, models.PerformanceSummaryEntry):
|
1263
|
-
raise ValueError(f"`perf_summary` must be a PerformanceSummaryEntry, got {type(perf_summary)}")
|
1264
|
-
|
1265
1449
|
if perf_summary.is_rated:
|
1266
|
-
raise exc.AlreadyRatedError(f"Performance summary {perf_summary.
|
1450
|
+
raise exc.AlreadyRatedError(f"Performance summary {perf_summary.performance_summary_id} is already rated.")
|
1267
1451
|
|
1268
1452
|
if not perf_summary.ratable:
|
1269
|
-
raise exc.ClassNotRatableError(
|
1453
|
+
raise exc.ClassNotRatableError(
|
1454
|
+
f"Performance summary {perf_summary.performance_summary_id} is not rateable."
|
1455
|
+
)
|
1270
1456
|
|
1271
1457
|
if not perf_summary.otf_class or not perf_summary.otf_class.class_uuid:
|
1272
1458
|
raise ValueError(
|
1273
|
-
f"Performance summary {perf_summary.
|
1459
|
+
f"Performance summary {perf_summary.performance_summary_id} does not have an associated class."
|
1274
1460
|
)
|
1275
1461
|
|
1276
1462
|
return self._rate_class(
|
1277
|
-
perf_summary.otf_class.class_uuid, perf_summary.
|
1463
|
+
perf_summary.otf_class.class_uuid, perf_summary.performance_summary_id, class_rating, coach_rating
|
1278
1464
|
)
|
1279
1465
|
|
1280
1466
|
# the below do not return any data for me, so I can't test them
|
@@ -1288,10 +1474,7 @@ class Otf:
|
|
1288
1474
|
Returns:
|
1289
1475
|
Any: The member's services.
|
1290
1476
|
"""
|
1291
|
-
|
1292
|
-
data = self._default_request(
|
1293
|
-
"GET", f"/member/members/{self.member_uuid}/services", params={"activeOnly": active_only_str}
|
1294
|
-
)
|
1477
|
+
data = self._get_member_services_raw(active_only)
|
1295
1478
|
return data
|
1296
1479
|
|
1297
1480
|
def _get_aspire_data(self, datetime: str | None = None, unit: str | None = None) -> Any:
|
@@ -1306,7 +1489,5 @@ class Otf:
|
|
1306
1489
|
Returns:
|
1307
1490
|
Any: The member's aspire data.
|
1308
1491
|
"""
|
1309
|
-
|
1310
|
-
|
1311
|
-
data = self._default_request("GET", f"/member/wearables/{self.member_uuid}/wearable-daily", params=params)
|
1492
|
+
data = self._get_aspire_data_raw(datetime, unit)
|
1312
1493
|
return data
|