otf-api 0.10.2__py3-none-any.whl → 0.11.0rc1__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 +372 -201
- otf_api/auth/__init__.py +1 -1
- otf_api/auth/auth.py +113 -7
- otf_api/auth/user.py +14 -7
- otf_api/auth/utils.py +21 -33
- otf_api/models/__init__.py +11 -1
- otf_api/models/bookings_v2.py +171 -0
- otf_api/models/challenge_tracker_content.py +2 -2
- otf_api/models/challenge_tracker_detail.py +2 -2
- otf_api/models/classes.py +4 -7
- otf_api/models/enums.py +1 -0
- otf_api/models/lifetime_stats.py +7 -19
- otf_api/models/member_detail.py +1 -1
- otf_api/models/mixins.py +23 -17
- otf_api/models/out_of_studio_workout_history.py +1 -1
- otf_api/models/performance_summary.py +11 -93
- otf_api/models/ratings.py +28 -0
- otf_api/models/studio_detail.py +14 -7
- otf_api/models/telemetry.py +13 -3
- otf_api/models/workout.py +81 -0
- otf_api/utils.py +36 -8
- {otf_api-0.10.2.dist-info → otf_api-0.11.0rc1.dist-info}/METADATA +23 -27
- otf_api-0.11.0rc1.dist-info/RECORD +38 -0
- {otf_api-0.10.2.dist-info → otf_api-0.11.0rc1.dist-info}/WHEEL +2 -1
- otf_api-0.11.0rc1.dist-info/top_level.txt +1 -0
- otf_api-0.10.2.dist-info/RECORD +0 -34
- {otf_api-0.10.2.dist-info → otf_api-0.11.0rc1.dist-info/licenses}/LICENSE +0 -0
otf_api/api.py
CHANGED
@@ -1,15 +1,15 @@
|
|
1
1
|
import atexit
|
2
2
|
import contextlib
|
3
|
-
import functools
|
4
3
|
from concurrent.futures import ThreadPoolExecutor
|
5
|
-
from copy import deepcopy
|
6
4
|
from datetime import date, datetime, timedelta
|
5
|
+
from functools import partial
|
7
6
|
from json import JSONDecodeError
|
8
7
|
from logging import getLogger
|
9
8
|
from typing import Any, Literal
|
10
9
|
|
11
10
|
import attrs
|
12
11
|
import httpx
|
12
|
+
import pendulum
|
13
13
|
from cachetools import TTLCache, cached
|
14
14
|
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
15
15
|
from yarl import URL
|
@@ -18,13 +18,18 @@ from otf_api import exceptions as exc
|
|
18
18
|
from otf_api import filters, models
|
19
19
|
from otf_api.auth import OtfUser
|
20
20
|
from otf_api.models.enums import HISTORICAL_BOOKING_STATUSES
|
21
|
-
from otf_api.utils import ensure_date, ensure_list, get_booking_uuid, get_class_uuid
|
21
|
+
from otf_api.utils import ensure_date, ensure_datetime, ensure_list, get_booking_uuid, get_class_uuid # get_booking_id
|
22
22
|
|
23
23
|
API_BASE_URL = "api.orangetheory.co"
|
24
24
|
API_IO_BASE_URL = "api.orangetheory.io"
|
25
25
|
API_TELEMETRY_BASE_URL = "api.yuzu.orangetheory.com"
|
26
|
-
|
26
|
+
HEADERS = {
|
27
|
+
"content-type": "application/json",
|
28
|
+
"accept": "application/json",
|
29
|
+
"user-agent": "okhttp/4.12.0",
|
30
|
+
}
|
27
31
|
LOGGER = getLogger(__name__)
|
32
|
+
LOGGED_ONCE: set[str] = set()
|
28
33
|
|
29
34
|
|
30
35
|
@attrs.define(init=False)
|
@@ -46,7 +51,7 @@ class Otf:
|
|
46
51
|
self.member_uuid = self.user.member_uuid
|
47
52
|
|
48
53
|
self.session = httpx.Client(
|
49
|
-
headers=
|
54
|
+
headers=HEADERS, auth=self.user.httpx_auth, timeout=httpx.Timeout(20.0, connect=60.0)
|
50
55
|
)
|
51
56
|
atexit.register(self.session.close)
|
52
57
|
|
@@ -101,10 +106,18 @@ class Otf:
|
|
101
106
|
if e.response.status_code == 404:
|
102
107
|
raise exc.ResourceNotFoundError("Resource not found")
|
103
108
|
|
104
|
-
|
105
|
-
|
109
|
+
try:
|
110
|
+
resp_text = e.response.json()
|
111
|
+
except JSONDecodeError:
|
112
|
+
resp_text = e.response.text
|
113
|
+
|
114
|
+
LOGGER.exception(f"Error making request - {resp_text!r}: {type(e).__name__} {e}")
|
115
|
+
|
116
|
+
LOGGER.info(f"Request details: {vars(request)}")
|
117
|
+
LOGGER.info(f"Response details: {vars(response)}")
|
118
|
+
|
119
|
+
raise
|
106
120
|
|
107
|
-
raise exc.OtfRequestError("Error making request", e, response=response, request=request)
|
108
121
|
except Exception as e:
|
109
122
|
LOGGER.exception(f"Error making request: {e}")
|
110
123
|
raise
|
@@ -130,27 +143,66 @@ class Otf:
|
|
130
143
|
|
131
144
|
return resp
|
132
145
|
|
133
|
-
def _classes_request(
|
146
|
+
def _classes_request(
|
147
|
+
self, method: str, url: str, params: dict[str, Any] | None = None, headers: dict[str, Any] | None = None
|
148
|
+
) -> Any:
|
134
149
|
"""Perform an API request to the classes API."""
|
135
|
-
return self._do(method, API_IO_BASE_URL, url, params)
|
150
|
+
return self._do(method, API_IO_BASE_URL, url, params, headers=headers)
|
136
151
|
|
137
|
-
def _default_request(
|
152
|
+
def _default_request(
|
153
|
+
self,
|
154
|
+
method: str,
|
155
|
+
url: str,
|
156
|
+
params: dict[str, Any] | None = None,
|
157
|
+
headers: dict[str, Any] | None = None,
|
158
|
+
**kwargs: Any,
|
159
|
+
) -> Any:
|
138
160
|
"""Perform an API request to the default API."""
|
139
|
-
return self._do(method, API_BASE_URL, url, params, **kwargs)
|
161
|
+
return self._do(method, API_BASE_URL, url, params, headers=headers, **kwargs)
|
140
162
|
|
141
|
-
def _telemetry_request(
|
163
|
+
def _telemetry_request(
|
164
|
+
self, method: str, url: str, params: dict[str, Any] | None = None, headers: dict[str, Any] | None = None
|
165
|
+
) -> Any:
|
142
166
|
"""Perform an API request to the Telemetry API."""
|
143
|
-
return self._do(method, API_TELEMETRY_BASE_URL, url, params)
|
167
|
+
return self._do(method, API_TELEMETRY_BASE_URL, url, params, headers=headers)
|
144
168
|
|
145
|
-
def _performance_summary_request(
|
169
|
+
def _performance_summary_request(
|
170
|
+
self, method: str, url: str, params: dict[str, Any] | None = None, headers: dict[str, Any] | None = None
|
171
|
+
) -> Any:
|
146
172
|
"""Perform an API request to the performance summary API."""
|
147
173
|
perf_api_headers = {"koji-member-id": self.member_uuid, "koji-member-email": self.user.email_address}
|
148
|
-
|
174
|
+
headers = perf_api_headers | (headers or {})
|
175
|
+
|
176
|
+
return self._do(method, API_IO_BASE_URL, url, params, headers=headers)
|
149
177
|
|
150
178
|
def _get_classes_raw(self, studio_uuids: list[str] | None) -> dict:
|
151
179
|
"""Retrieve raw class data."""
|
152
180
|
return self._classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})
|
153
181
|
|
182
|
+
def _cancel_booking_raw(self, booking_uuid: str) -> dict:
|
183
|
+
"""Cancel a booking by booking_uuid."""
|
184
|
+
return self._default_request(
|
185
|
+
"DELETE", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}", params={"confirmed": "true"}
|
186
|
+
)
|
187
|
+
|
188
|
+
def _book_class_raw(self, class_uuid, body):
|
189
|
+
try:
|
190
|
+
resp = self._default_request("PUT", f"/member/members/{self.member_uuid}/bookings", json=body)
|
191
|
+
except exc.OtfRequestError as e:
|
192
|
+
resp_obj = e.response.json()
|
193
|
+
|
194
|
+
if resp_obj["code"] == "ERROR":
|
195
|
+
err_code = resp_obj["data"]["errorCode"]
|
196
|
+
if err_code == "603":
|
197
|
+
raise exc.AlreadyBookedError(f"Class {class_uuid} is already booked.")
|
198
|
+
if err_code == "602":
|
199
|
+
raise exc.OutsideSchedulingWindowError(f"Class {class_uuid} is outside the scheduling window.")
|
200
|
+
|
201
|
+
raise
|
202
|
+
except Exception as e:
|
203
|
+
raise exc.OtfException(f"Error booking class {class_uuid}: {e}")
|
204
|
+
return resp
|
205
|
+
|
154
206
|
def _get_booking_raw(self, booking_uuid: str) -> dict:
|
155
207
|
"""Retrieve raw booking data."""
|
156
208
|
return self._default_request("GET", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}")
|
@@ -167,6 +219,37 @@ class Otf:
|
|
167
219
|
params={"startDate": start_date, "endDate": end_date, "statuses": status},
|
168
220
|
)
|
169
221
|
|
222
|
+
def _get_bookings_new_raw(
|
223
|
+
self,
|
224
|
+
ends_before: datetime,
|
225
|
+
starts_after: datetime,
|
226
|
+
include_canceled: bool = True,
|
227
|
+
expand: bool = False,
|
228
|
+
) -> dict:
|
229
|
+
"""Retrieve raw bookings data."""
|
230
|
+
|
231
|
+
params: dict[str, bool | str] = {
|
232
|
+
"ends_before": pendulum.instance(ends_before).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
233
|
+
"starts_after": pendulum.instance(starts_after).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
234
|
+
}
|
235
|
+
|
236
|
+
params["include_canceled"] = include_canceled if include_canceled is not None else True
|
237
|
+
params["expand"] = expand if expand is not None else False
|
238
|
+
|
239
|
+
return self._classes_request("GET", "/v1/bookings/me", params=params)
|
240
|
+
|
241
|
+
def _cancel_booking_new_raw(self, booking_id: str) -> dict:
|
242
|
+
"""Cancel a booking by booking_id."""
|
243
|
+
return self._classes_request("DELETE", f"/v1/bookings/me/{booking_id}", headers={"SIGV4AUTH_REQUIRED": "true"})
|
244
|
+
|
245
|
+
def _get_booking_new_raw(self, booking_id: str) -> dict:
|
246
|
+
"""Retrieve raw booking data."""
|
247
|
+
return self._classes_request("GET", f"/v1/bookings/me/{booking_id}", headers={"SIGV4AUTH_REQUIRED": "true"})
|
248
|
+
|
249
|
+
def _book_class_new_raw(self, class_id: str) -> dict:
|
250
|
+
"""Book a class by class_id."""
|
251
|
+
return self._classes_request("POST", f"/v1/bookings/me/{class_id}", headers={"SIGV4AUTH_REQUIRED": "true"})
|
252
|
+
|
170
253
|
def _get_member_detail_raw(self) -> dict:
|
171
254
|
"""Retrieve raw member details."""
|
172
255
|
return self._default_request(
|
@@ -190,7 +273,7 @@ class Otf:
|
|
190
273
|
"""Retrieve raw heart rate history."""
|
191
274
|
return self._telemetry_request("GET", "/v1/physVars/maxHr/history", params={"memberUuid": self.member_uuid})
|
192
275
|
|
193
|
-
def _get_telemetry_raw(self, performance_summary_id: str, max_data_points: int) -> dict:
|
276
|
+
def _get_telemetry_raw(self, performance_summary_id: str, max_data_points: int = 150) -> dict:
|
194
277
|
"""Retrieve raw telemetry data."""
|
195
278
|
return self._telemetry_request(
|
196
279
|
"GET",
|
@@ -345,10 +428,61 @@ class Otf:
|
|
345
428
|
json={"firstName": first_name, "lastName": last_name},
|
346
429
|
)
|
347
430
|
|
431
|
+
def _get_all_bookings_new(self) -> list[models.BookingV2]:
|
432
|
+
"""Get bookings from the new endpoint with no date filters."""
|
433
|
+
start_date = pendulum.datetime(1970, 1, 1)
|
434
|
+
end_date = pendulum.today().start_of("day").add(days=45)
|
435
|
+
return self.get_bookings_new(start_date, end_date, exclude_canceled=False)
|
436
|
+
|
437
|
+
def get_bookings_new(
|
438
|
+
self,
|
439
|
+
start_dtme: datetime | str | None = None,
|
440
|
+
end_dtme: datetime | str | None = None,
|
441
|
+
exclude_canceled: bool = True,
|
442
|
+
) -> list[models.BookingV2]:
|
443
|
+
"""Get the bookings for the user. If no dates are provided, it will return all bookings
|
444
|
+
between today and 45 days from now.
|
445
|
+
|
446
|
+
Warning:
|
447
|
+
---
|
448
|
+
If you do not exclude cancelled bookings, you may receive multiple bookings for the same workout, such
|
449
|
+
as when a class changes from a 2G to a 3G. Apparently the system actually creates a new booking for the
|
450
|
+
new class, which is normally transparent to the user.
|
451
|
+
|
452
|
+
Args:
|
453
|
+
start_dtme (datetime | str | None): The start date for the bookings. Default is None.
|
454
|
+
end_dtme (datetime | str | None): The end date for the bookings. Default is None.
|
455
|
+
exclude_canceled (bool): Whether to exclude canceled bookings. Default is True.
|
456
|
+
Returns:
|
457
|
+
list[BookingV2]: The bookings for the user.
|
458
|
+
"""
|
459
|
+
|
460
|
+
expand = True # this doesn't seem to have an effect? so leaving it out of the argument list
|
461
|
+
|
462
|
+
# leaving the parameter as `exclude_canceled` for backwards compatibility
|
463
|
+
include_canceled = not exclude_canceled
|
464
|
+
|
465
|
+
end_dtme = ensure_datetime(end_dtme)
|
466
|
+
start_dtme = ensure_datetime(start_dtme)
|
467
|
+
|
468
|
+
end_dtme = end_dtme or pendulum.today().start_of("day").add(days=45)
|
469
|
+
start_dtme = start_dtme or pendulum.datetime(1970, 1, 1).start_of("day")
|
470
|
+
|
471
|
+
bookings_resp = self._get_bookings_new_raw(
|
472
|
+
ends_before=end_dtme, starts_after=start_dtme, include_canceled=include_canceled, expand=expand
|
473
|
+
)
|
474
|
+
|
475
|
+
return [models.BookingV2(**b) for b in bookings_resp["items"]]
|
476
|
+
|
477
|
+
# def get_booking_new(self, booking_id: str) -> models.BookingV2:
|
478
|
+
# """Get a booking by ID."""
|
479
|
+
# booking_resp = self._get_booking_new_raw(booking_id)
|
480
|
+
# return models.BookingV2(**booking_resp)
|
481
|
+
|
348
482
|
def get_classes(
|
349
483
|
self,
|
350
|
-
start_date: date | None = None,
|
351
|
-
end_date: date | None = None,
|
484
|
+
start_date: date | str | None = None,
|
485
|
+
end_date: date | str | None = None,
|
352
486
|
studio_uuids: list[str] | None = None,
|
353
487
|
include_home_studio: bool | None = None,
|
354
488
|
filters: list[filters.ClassFilter] | filters.ClassFilter | None = None,
|
@@ -371,6 +505,9 @@ class Otf:
|
|
371
505
|
list[OtfClass]: The classes for the user.
|
372
506
|
"""
|
373
507
|
|
508
|
+
start_date = ensure_date(start_date)
|
509
|
+
end_date = ensure_date(end_date)
|
510
|
+
|
374
511
|
classes = self._get_classes(studio_uuids, include_home_studio)
|
375
512
|
|
376
513
|
# remove those that are cancelled *by the studio*
|
@@ -531,6 +668,29 @@ class Otf:
|
|
531
668
|
|
532
669
|
raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
|
533
670
|
|
671
|
+
def get_booking_from_class_new(self, otf_class: str | models.OtfClass | models.BookingV2Class) -> models.BookingV2:
|
672
|
+
"""Get a specific booking by class_uuid or OtfClass object.
|
673
|
+
|
674
|
+
Args:
|
675
|
+
otf_class (str | OtfClass | BookingV2Class): The class UUID or the OtfClass object to get the booking for.
|
676
|
+
|
677
|
+
Returns:
|
678
|
+
BookingV2: The booking.
|
679
|
+
|
680
|
+
Raises:
|
681
|
+
BookingNotFoundError: If the booking does not exist.
|
682
|
+
ValueError: If class_uuid is None or empty string.
|
683
|
+
"""
|
684
|
+
|
685
|
+
class_uuid = get_class_uuid(otf_class)
|
686
|
+
|
687
|
+
all_bookings = self._get_all_bookings_new()
|
688
|
+
|
689
|
+
if booking := next((b for b in all_bookings if b.class_uuid == class_uuid), None):
|
690
|
+
return booking
|
691
|
+
|
692
|
+
raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
|
693
|
+
|
534
694
|
def book_class(self, otf_class: str | models.OtfClass) -> models.Booking:
|
535
695
|
"""Book a class by providing either the class_uuid or the OtfClass object.
|
536
696
|
|
@@ -556,21 +716,7 @@ class Otf:
|
|
556
716
|
|
557
717
|
body = {"classUUId": class_uuid, "confirmed": False, "waitlist": False}
|
558
718
|
|
559
|
-
|
560
|
-
resp = self._default_request("PUT", f"/member/members/{self.member_uuid}/bookings", json=body)
|
561
|
-
except exc.OtfRequestError as e:
|
562
|
-
resp_obj = e.response.json()
|
563
|
-
|
564
|
-
if resp_obj["code"] == "ERROR":
|
565
|
-
err_code = resp_obj["data"]["errorCode"]
|
566
|
-
if err_code == "603":
|
567
|
-
raise exc.AlreadyBookedError(f"Class {class_uuid} is already booked.")
|
568
|
-
if err_code == "602":
|
569
|
-
raise exc.OutsideSchedulingWindowError(f"Class {class_uuid} is outside the scheduling window.")
|
570
|
-
|
571
|
-
raise
|
572
|
-
except Exception as e:
|
573
|
-
raise exc.OtfException(f"Error booking class {class_uuid}: {e}")
|
719
|
+
resp = self._book_class_raw(class_uuid, body)
|
574
720
|
|
575
721
|
# get the booking uuid - we will only use this to return a Booking object using `get_booking`
|
576
722
|
# this is an attempt to improve on OTF's terrible data model
|
@@ -633,22 +779,44 @@ class Otf:
|
|
633
779
|
ValueError: If booking_uuid is None or empty string
|
634
780
|
BookingNotFoundError: If the booking does not exist.
|
635
781
|
"""
|
782
|
+
# if isinstance(booking, models.BookingV2):
|
783
|
+
# LOGGER.warning("BookingV2 object provided, using the new cancel booking endpoint (`cancel_booking_new`)")
|
784
|
+
# self.cancel_booking_new(booking)
|
785
|
+
|
636
786
|
booking_uuid = get_booking_uuid(booking)
|
637
787
|
|
638
|
-
|
639
|
-
|
640
|
-
|
641
|
-
|
788
|
+
if booking == booking_uuid: # ensure this booking exists by calling the booking endpoint
|
789
|
+
try:
|
790
|
+
self.get_booking(booking_uuid)
|
791
|
+
except Exception:
|
792
|
+
raise exc.BookingNotFoundError(f"Booking {booking_uuid} does not exist.")
|
642
793
|
|
643
|
-
|
644
|
-
resp = self._default_request(
|
645
|
-
"DELETE", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}", params=params
|
646
|
-
)
|
794
|
+
resp = self._cancel_booking_raw(booking_uuid)
|
647
795
|
if resp["code"] == "NOT_AUTHORIZED" and resp["message"].startswith("This class booking has"):
|
648
796
|
raise exc.BookingAlreadyCancelledError(
|
649
797
|
f"Booking {booking_uuid} is already cancelled.", booking_uuid=booking_uuid
|
650
798
|
)
|
651
799
|
|
800
|
+
# def cancel_booking_new(self, booking: str | models.BookingV2) -> None:
|
801
|
+
# """Cancel a booking by providing either the booking_id or the BookingV2 object.
|
802
|
+
# Args:
|
803
|
+
# booking (str | BookingV2): The booking ID or the BookingV2 object to cancel.
|
804
|
+
# Raises:
|
805
|
+
# ValueError: If booking_id is None or empty string
|
806
|
+
# BookingNotFoundError: If the booking does not exist.
|
807
|
+
# """
|
808
|
+
|
809
|
+
# if isinstance(booking, models.Booking):
|
810
|
+
# LOGGER.warning("Booking object provided, using the old cancel booking endpoint (`cancel_booking`)")
|
811
|
+
# self.cancel_booking(booking)
|
812
|
+
|
813
|
+
# booking_id = get_booking_id(booking)
|
814
|
+
|
815
|
+
# if booking == booking_id: # ensure this booking exists by calling the booking endpoint
|
816
|
+
# self.get_booking_new(booking_id)
|
817
|
+
|
818
|
+
# self._cancel_booking_new_raw(booking_id)
|
819
|
+
|
652
820
|
def get_bookings(
|
653
821
|
self,
|
654
822
|
start_date: date | str | None = None,
|
@@ -798,7 +966,7 @@ class Otf:
|
|
798
966
|
It is being provided anyway, in case this changes in the future.
|
799
967
|
|
800
968
|
Returns:
|
801
|
-
|
969
|
+
StatsResponse: The member's lifetime stats.
|
802
970
|
"""
|
803
971
|
|
804
972
|
data = self._get_member_lifetime_stats_raw(select_time.value)
|
@@ -809,14 +977,14 @@ class Otf:
|
|
809
977
|
|
810
978
|
def get_member_lifetime_stats_in_studio(
|
811
979
|
self, select_time: models.StatsTime = models.StatsTime.AllTime
|
812
|
-
) -> models.
|
980
|
+
) -> models.InStudioStatsData:
|
813
981
|
"""Get the member's lifetime stats in studio.
|
814
982
|
|
815
983
|
Args:
|
816
984
|
select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
|
817
985
|
|
818
986
|
Returns:
|
819
|
-
|
987
|
+
InStudioStatsData: The member's lifetime stats in studio.
|
820
988
|
"""
|
821
989
|
|
822
990
|
data = self._get_member_lifetime_stats(select_time)
|
@@ -825,14 +993,14 @@ class Otf:
|
|
825
993
|
|
826
994
|
def get_member_lifetime_stats_out_of_studio(
|
827
995
|
self, select_time: models.StatsTime = models.StatsTime.AllTime
|
828
|
-
) -> models.
|
996
|
+
) -> models.OutStudioStatsData:
|
829
997
|
"""Get the member's lifetime stats out of studio.
|
830
998
|
|
831
999
|
Args:
|
832
1000
|
select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
|
833
1001
|
|
834
1002
|
Returns:
|
835
|
-
|
1003
|
+
OutStudioStatsData: The member's lifetime stats out of studio.
|
836
1004
|
"""
|
837
1005
|
|
838
1006
|
data = self._get_member_lifetime_stats(select_time)
|
@@ -935,7 +1103,7 @@ class Otf:
|
|
935
1103
|
try:
|
936
1104
|
res = self._get_studio_detail_raw(studio_uuid)
|
937
1105
|
except exc.ResourceNotFoundError:
|
938
|
-
return models.StudioDetail(
|
1106
|
+
return models.StudioDetail.create_empty_model(studio_uuid)
|
939
1107
|
|
940
1108
|
return models.StudioDetail(**res["data"])
|
941
1109
|
|
@@ -1039,14 +1207,14 @@ class Otf:
|
|
1039
1207
|
|
1040
1208
|
def get_benchmarks(
|
1041
1209
|
self,
|
1042
|
-
challenge_category_id:
|
1210
|
+
challenge_category_id: int = 0,
|
1043
1211
|
equipment_id: models.EquipmentType | Literal[0] = 0,
|
1044
1212
|
challenge_subcategory_id: int = 0,
|
1045
1213
|
) -> list[models.FitnessBenchmark]:
|
1046
1214
|
"""Get the member's challenge tracker participation details.
|
1047
1215
|
|
1048
1216
|
Args:
|
1049
|
-
challenge_category_id (
|
1217
|
+
challenge_category_id (int): The challenge type ID.
|
1050
1218
|
equipment_id (EquipmentType | Literal[0]): The equipment ID, default is 0 - this doesn't seem\
|
1051
1219
|
to be have any impact on the results.
|
1052
1220
|
challenge_subcategory_id (int): The challenge sub type ID. Default is 0 - this doesn't seem\
|
@@ -1073,13 +1241,11 @@ class Otf:
|
|
1073
1241
|
|
1074
1242
|
return benchmarks
|
1075
1243
|
|
1076
|
-
def get_benchmarks_by_challenge_category(
|
1077
|
-
self, challenge_category_id: models.ChallengeCategory
|
1078
|
-
) -> list[models.FitnessBenchmark]:
|
1244
|
+
def get_benchmarks_by_challenge_category(self, challenge_category_id: int) -> list[models.FitnessBenchmark]:
|
1079
1245
|
"""Get the member's challenge tracker participation details by challenge.
|
1080
1246
|
|
1081
1247
|
Args:
|
1082
|
-
challenge_category_id (
|
1248
|
+
challenge_category_id (int): The challenge type ID.
|
1083
1249
|
|
1084
1250
|
Returns:
|
1085
1251
|
list[FitnessBenchmark]: The member's challenge tracker details.
|
@@ -1090,12 +1256,12 @@ class Otf:
|
|
1090
1256
|
|
1091
1257
|
return benchmarks
|
1092
1258
|
|
1093
|
-
def get_challenge_tracker_detail(self, challenge_category_id:
|
1259
|
+
def get_challenge_tracker_detail(self, challenge_category_id: int) -> models.FitnessBenchmark:
|
1094
1260
|
"""Get details about a challenge. This endpoint does not (usually) return member participation, but rather
|
1095
1261
|
details about the challenge itself.
|
1096
1262
|
|
1097
1263
|
Args:
|
1098
|
-
challenge_category_id (
|
1264
|
+
challenge_category_id (int): The challenge type ID.
|
1099
1265
|
|
1100
1266
|
Returns:
|
1101
1267
|
FitnessBenchmark: Details about the challenge.
|
@@ -1111,94 +1277,7 @@ class Otf:
|
|
1111
1277
|
|
1112
1278
|
return models.FitnessBenchmark(**data["Dto"][0])
|
1113
1279
|
|
1114
|
-
|
1115
|
-
def get_performance_summaries_dict(self, limit: int | None = None) -> dict[str, models.PerformanceSummary]:
|
1116
|
-
"""Get a dictionary of performance summaries for the authenticated user.
|
1117
|
-
|
1118
|
-
Args:
|
1119
|
-
limit (int | None): The maximum number of entries to return. Default is None.
|
1120
|
-
|
1121
|
-
Returns:
|
1122
|
-
dict[str, PerformanceSummary]: A dictionary of performance summaries, keyed by class history UUID.
|
1123
|
-
|
1124
|
-
Developer Notes:
|
1125
|
-
---
|
1126
|
-
In the app, this is referred to as 'getInStudioWorkoutHistory'.
|
1127
|
-
|
1128
|
-
"""
|
1129
|
-
|
1130
|
-
items = self._get_performance_summaries_raw(limit=limit)["items"]
|
1131
|
-
|
1132
|
-
distinct_studio_ids = set([rec["class"]["studio"]["id"] for rec in items])
|
1133
|
-
perf_summary_ids = set([rec["id"] for rec in items])
|
1134
|
-
|
1135
|
-
with ThreadPoolExecutor() as pool:
|
1136
|
-
studio_futures = {s: pool.submit(self.get_studio_detail, s) for s in distinct_studio_ids}
|
1137
|
-
perf_summary_futures = {s: pool.submit(self._get_performancy_summary_detail, s) for s in perf_summary_ids}
|
1138
|
-
|
1139
|
-
studio_dict = {k: v.result() for k, v in studio_futures.items()}
|
1140
|
-
# deepcopy these so that mutating them in PerformanceSummary doesn't affect the cache
|
1141
|
-
perf_summary_dict = {k: deepcopy(v.result()) for k, v in perf_summary_futures.items()}
|
1142
|
-
|
1143
|
-
for item in items:
|
1144
|
-
item["class"]["studio"] = studio_dict[item["class"]["studio"]["id"]]
|
1145
|
-
item["detail"] = perf_summary_dict[item["id"]]
|
1146
|
-
|
1147
|
-
entries = [models.PerformanceSummary(**item) for item in items]
|
1148
|
-
entries_dict = {entry.performance_summary_id: entry for entry in entries}
|
1149
|
-
|
1150
|
-
return entries_dict
|
1151
|
-
|
1152
|
-
def get_performance_summaries(self, limit: int | None = None) -> list[models.PerformanceSummary]:
|
1153
|
-
"""Get a list of all performance summaries for the authenticated user.
|
1154
|
-
|
1155
|
-
Args:
|
1156
|
-
limit (int | None): The maximum number of entries to return. Default is None.
|
1157
|
-
|
1158
|
-
Returns:
|
1159
|
-
list[PerformanceSummary]: A list of performance summaries.
|
1160
|
-
|
1161
|
-
Developer Notes:
|
1162
|
-
---
|
1163
|
-
In the app, this is referred to as 'getInStudioWorkoutHistory'.
|
1164
|
-
|
1165
|
-
"""
|
1166
|
-
|
1167
|
-
records = list(self.get_performance_summaries_dict(limit=limit).values())
|
1168
|
-
|
1169
|
-
sorted_records = sorted(records, key=lambda x: x.otf_class.starts_at, reverse=True)
|
1170
|
-
|
1171
|
-
return sorted_records
|
1172
|
-
|
1173
|
-
def get_performance_summary(
|
1174
|
-
self, performance_summary_id: str, limit: int | None = None
|
1175
|
-
) -> models.PerformanceSummary:
|
1176
|
-
"""Get performance summary for a given workout.
|
1177
|
-
|
1178
|
-
Note: Due to the way the OTF API is set up, we have to call both the list and the get endpoints. By
|
1179
|
-
default this will call the list endpoint with no limit, in order to ensure that the performance summary
|
1180
|
-
is returned if it exists. This could result in a lot of requests, so you also have the option to provide
|
1181
|
-
a limit to only fetch a certain number of performance summaries.
|
1182
|
-
|
1183
|
-
Args:
|
1184
|
-
performance_summary_id (str): The ID of the performance summary to retrieve.
|
1185
|
-
|
1186
|
-
Returns:
|
1187
|
-
PerformanceSummary: The performance summary.
|
1188
|
-
|
1189
|
-
Raises:
|
1190
|
-
ResourceNotFoundError: If the performance_summary_id is not in the list of performance summaries.
|
1191
|
-
"""
|
1192
|
-
|
1193
|
-
perf_summary = self.get_performance_summaries_dict(limit=limit).get(performance_summary_id)
|
1194
|
-
|
1195
|
-
if perf_summary is None:
|
1196
|
-
raise exc.ResourceNotFoundError(f"Performance summary {performance_summary_id} not found")
|
1197
|
-
|
1198
|
-
return perf_summary
|
1199
|
-
|
1200
|
-
@functools.lru_cache(maxsize=1024)
|
1201
|
-
def _get_performancy_summary_detail(self, performance_summary_id: str) -> dict[str, Any]:
|
1280
|
+
def get_performance_summary(self, performance_summary_id: str) -> models.PerformanceSummary:
|
1202
1281
|
"""Get the details for a performance summary. Generally should not be called directly. This
|
1203
1282
|
|
1204
1283
|
Args:
|
@@ -1206,13 +1285,14 @@ class Otf:
|
|
1206
1285
|
|
1207
1286
|
Returns:
|
1208
1287
|
dict[str, Any]: The performance summary details.
|
1209
|
-
|
1210
|
-
Developer Notes:
|
1211
|
-
---
|
1212
|
-
This is mostly here to cache the results of the raw method.
|
1213
1288
|
"""
|
1214
1289
|
|
1215
|
-
return
|
1290
|
+
warning_msg = "This endpoint does not return all data, consider using `get_workouts` instead."
|
1291
|
+
if warning_msg not in LOGGED_ONCE:
|
1292
|
+
LOGGER.warning(warning_msg)
|
1293
|
+
|
1294
|
+
resp = self._get_performance_summary_raw(performance_summary_id)
|
1295
|
+
return models.PerformanceSummary(**resp)
|
1216
1296
|
|
1217
1297
|
def get_hr_history(self) -> list[models.TelemetryHistoryItem]:
|
1218
1298
|
"""Get the heartrate history for the user.
|
@@ -1227,7 +1307,7 @@ class Otf:
|
|
1227
1307
|
resp = self._get_hr_history_raw()
|
1228
1308
|
return [models.TelemetryHistoryItem(**item) for item in resp["history"]]
|
1229
1309
|
|
1230
|
-
def get_telemetry(self, performance_summary_id: str, max_data_points: int =
|
1310
|
+
def get_telemetry(self, performance_summary_id: str, max_data_points: int = 150) -> models.Telemetry:
|
1231
1311
|
"""Get the telemetry for a performance summary.
|
1232
1312
|
|
1233
1313
|
This returns an object that contains the max heartrate, start/end bpm for each zone,
|
@@ -1235,7 +1315,7 @@ class Otf:
|
|
1235
1315
|
|
1236
1316
|
Args:
|
1237
1317
|
performance_summary_id (str): The performance summary id.
|
1238
|
-
max_data_points (int): The max data points to use for the telemetry. Default is
|
1318
|
+
max_data_points (int): The max data points to use for the telemetry. Default is 150, to match the app.
|
1239
1319
|
|
1240
1320
|
Returns:
|
1241
1321
|
TelemetryItem: The telemetry for the class history.
|
@@ -1295,7 +1375,7 @@ class Otf:
|
|
1295
1375
|
transactional_enabled if transactional_enabled is not None else current_settings.is_transactional_sms_opt_in
|
1296
1376
|
)
|
1297
1377
|
|
1298
|
-
self._update_sms_notification_settings_raw(promotional_enabled, transactional_enabled)
|
1378
|
+
self._update_sms_notification_settings_raw(promotional_enabled, transactional_enabled) # type: ignore
|
1299
1379
|
|
1300
1380
|
# the response returns nothing useful, so we just query the settings again
|
1301
1381
|
new_settings = self.get_sms_notification_settings()
|
@@ -1334,7 +1414,7 @@ class Otf:
|
|
1334
1414
|
else current_settings.is_transactional_email_opt_in
|
1335
1415
|
)
|
1336
1416
|
|
1337
|
-
self._update_email_notification_settings_raw(promotional_enabled, transactional_enabled)
|
1417
|
+
self._update_email_notification_settings_raw(promotional_enabled, transactional_enabled) # type: ignore
|
1338
1418
|
|
1339
1419
|
# the response returns nothing useful, so we just query the settings again
|
1340
1420
|
new_settings = self.get_email_notification_settings()
|
@@ -1362,19 +1442,21 @@ class Otf:
|
|
1362
1442
|
LOGGER.warning("No changes to names, nothing to update.")
|
1363
1443
|
return self.member
|
1364
1444
|
|
1445
|
+
assert first_name is not None, "First name is required"
|
1446
|
+
assert last_name is not None, "Last name is required"
|
1447
|
+
|
1365
1448
|
res = self._update_member_name_raw(first_name, last_name)
|
1366
1449
|
|
1367
1450
|
return models.MemberDetail(**res["data"])
|
1368
1451
|
|
1369
|
-
def
|
1452
|
+
def rate_class(
|
1370
1453
|
self,
|
1371
1454
|
class_uuid: str,
|
1372
1455
|
performance_summary_id: str,
|
1373
1456
|
class_rating: Literal[0, 1, 2, 3],
|
1374
1457
|
coach_rating: Literal[0, 1, 2, 3],
|
1375
|
-
)
|
1376
|
-
"""Rate a class and coach. A simpler method is provided in `
|
1377
|
-
|
1458
|
+
):
|
1459
|
+
"""Rate a class and coach. A simpler method is provided in `rate_class_from_workout`.
|
1378
1460
|
|
1379
1461
|
The class rating must be between 0 and 4.
|
1380
1462
|
0 is the same as dismissing the prompt to rate the class/coach in the app.
|
@@ -1387,83 +1469,172 @@ class Otf:
|
|
1387
1469
|
coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
|
1388
1470
|
|
1389
1471
|
Returns:
|
1390
|
-
|
1391
|
-
"""
|
1392
|
-
|
1393
|
-
# com/orangetheoryfitness/fragment/rating/RateStatus.java
|
1394
|
-
|
1395
|
-
# we convert these to the new values that the app uses
|
1396
|
-
# mainly because we don't want to cause any issues with the API and/or with OTF corporate
|
1397
|
-
# wondering where the old values are coming from
|
1398
|
-
|
1399
|
-
COACH_RATING_MAP = {0: 0, 1: 16, 2: 17, 3: 18}
|
1400
|
-
CLASS_RATING_MAP = {0: 0, 1: 19, 2: 20, 3: 21}
|
1401
|
-
|
1402
|
-
if class_rating not in CLASS_RATING_MAP:
|
1403
|
-
raise ValueError(f"Invalid class rating {class_rating}")
|
1472
|
+
None
|
1404
1473
|
|
1405
|
-
|
1406
|
-
raise ValueError(f"Invalid coach rating {coach_rating}")
|
1474
|
+
"""
|
1407
1475
|
|
1408
|
-
body_class_rating =
|
1409
|
-
body_coach_rating =
|
1476
|
+
body_class_rating = models.get_class_rating_value(class_rating)
|
1477
|
+
body_coach_rating = models.get_coach_rating_value(coach_rating)
|
1410
1478
|
|
1411
1479
|
try:
|
1412
1480
|
self._rate_class_raw(class_uuid, performance_summary_id, body_class_rating, body_coach_rating)
|
1413
1481
|
except exc.OtfRequestError as e:
|
1414
1482
|
if e.response.status_code == 403:
|
1415
|
-
raise exc.AlreadyRatedError(f"
|
1483
|
+
raise exc.AlreadyRatedError(f"Workout {performance_summary_id} is already rated.") from None
|
1416
1484
|
raise
|
1417
1485
|
|
1418
|
-
|
1419
|
-
|
1420
|
-
|
1421
|
-
|
1422
|
-
|
1423
|
-
|
1486
|
+
# def get_workout_from_booking(self, booking: str | models.BookingV2) -> models.Workout:
|
1487
|
+
# """Get a workout for a specific booking.
|
1488
|
+
|
1489
|
+
# Args:
|
1490
|
+
# booking_id (str | Booking): The booking ID or Booking object to get the workout for.
|
1491
|
+
|
1492
|
+
# Returns:
|
1493
|
+
# Workout: The member's workout.
|
1494
|
+
|
1495
|
+
# Raises:
|
1496
|
+
# BookingNotFoundError: If the booking does not exist.
|
1497
|
+
# ResourceNotFoundError: If the workout does not exist.
|
1498
|
+
# """
|
1499
|
+
# booking_id = booking if isinstance(booking, str) else booking.booking_id
|
1500
|
+
|
1501
|
+
# booking = self.get_booking_new(booking_id)
|
1502
|
+
# if not booking:
|
1503
|
+
# raise exc.BookingNotFoundError(f"Booking {booking_id} not found.")
|
1504
|
+
|
1505
|
+
# if not booking.workout or not booking.workout.performance_summary_id:
|
1506
|
+
# raise exc.ResourceNotFoundError(f"Workout for booking {booking_id} not found.")
|
1507
|
+
|
1508
|
+
# perf_summary = self._get_performance_summary_raw(booking.workout.performance_summary_id)
|
1509
|
+
# telemetry = self.get_telemetry(booking.workout.performance_summary_id)
|
1510
|
+
# workout = models.Workout(**perf_summary, v2_booking=booking, telemetry=telemetry)
|
1511
|
+
# return workout
|
1512
|
+
|
1513
|
+
def get_workouts(
|
1514
|
+
self, start_date: date | str | None = None, end_date: date | str | None = None
|
1515
|
+
) -> list[models.Workout]:
|
1516
|
+
"""Get the member's workouts, using the new bookings endpoint and the performance summary endpoint.
|
1517
|
+
|
1518
|
+
Args:
|
1519
|
+
start_date (date | None): The start date for the workouts. If None, defaults to 30 days ago.
|
1520
|
+
end_date (date | None): The end date for the workouts. If None, defaults to today.
|
1521
|
+
|
1522
|
+
Returns:
|
1523
|
+
list[Workout]: The member's workouts.
|
1524
|
+
"""
|
1525
|
+
start_date = ensure_date(start_date) or pendulum.today().subtract(days=30).date()
|
1526
|
+
end_date = ensure_date(end_date) or datetime.today().date()
|
1527
|
+
|
1528
|
+
start_dtme = pendulum.datetime(start_date.year, start_date.month, start_date.day, 0, 0, 0)
|
1529
|
+
end_dtme = pendulum.datetime(end_date.year, end_date.month, end_date.day, 23, 59, 59)
|
1530
|
+
|
1531
|
+
bookings = self.get_bookings_new(start_dtme, end_dtme, exclude_canceled=False)
|
1532
|
+
bookings_dict = {b.workout.id: b for b in bookings if b.workout}
|
1533
|
+
|
1534
|
+
perf_summaries_dict = self._get_perf_summaries_threaded(list(bookings_dict.keys()))
|
1535
|
+
telemetry_dict = self._get_telemetry_threaded(list(perf_summaries_dict.keys()))
|
1536
|
+
perf_summary_to_class_uuid_map = self._get_perf_summary_to_class_uuid_mapping()
|
1537
|
+
|
1538
|
+
workouts: list[models.Workout] = []
|
1539
|
+
for perf_id, perf_summary in perf_summaries_dict.items():
|
1540
|
+
workout = models.Workout(
|
1541
|
+
**perf_summary,
|
1542
|
+
v2_booking=bookings_dict[perf_id],
|
1543
|
+
telemetry=telemetry_dict.get(perf_id),
|
1544
|
+
class_uuid=perf_summary_to_class_uuid_map.get(perf_id),
|
1545
|
+
)
|
1546
|
+
workouts.append(workout)
|
1547
|
+
|
1548
|
+
return workouts
|
1549
|
+
|
1550
|
+
def _get_perf_summary_to_class_uuid_mapping(self) -> dict[str, str | None]:
|
1551
|
+
"""Get a mapping of performance summary IDs to class UUIDs. These will be used
|
1552
|
+
when rating a class.
|
1553
|
+
|
1554
|
+
Returns:
|
1555
|
+
dict[str, str | None]: A dictionary mapping performance summary IDs to class UUIDs.
|
1556
|
+
"""
|
1557
|
+
perf_summaries = self._get_performance_summaries_raw()["items"]
|
1558
|
+
return {item["id"]: item["class"].get("ot_base_class_uuid") for item in perf_summaries}
|
1559
|
+
|
1560
|
+
def _get_perf_summaries_threaded(self, performance_summary_ids: list[str]) -> dict[str, dict[str, Any]]:
|
1561
|
+
"""Get performance summaries in a ThreadPoolExecutor, to speed up the process.
|
1562
|
+
|
1563
|
+
Args:
|
1564
|
+
performance_summary_ids (list[str]): The performance summary IDs to get.
|
1565
|
+
|
1566
|
+
Returns:
|
1567
|
+
dict[str, dict[str, Any]]: A dictionary of performance summaries, keyed by performance summary ID.
|
1568
|
+
"""
|
1569
|
+
|
1570
|
+
with ThreadPoolExecutor(max_workers=10) as pool:
|
1571
|
+
perf_summaries = pool.map(self._get_performance_summary_raw, performance_summary_ids)
|
1424
1572
|
|
1425
|
-
|
1573
|
+
perf_summaries_dict = {perf_summary["id"]: perf_summary for perf_summary in perf_summaries}
|
1574
|
+
return perf_summaries_dict
|
1426
1575
|
|
1427
|
-
def
|
1576
|
+
def _get_telemetry_threaded(
|
1577
|
+
self, performance_summary_ids: list[str], max_data_points: int = 150
|
1578
|
+
) -> dict[str, models.Telemetry]:
|
1579
|
+
"""Get telemetry in a ThreadPoolExecutor, to speed up the process.
|
1580
|
+
|
1581
|
+
Args:
|
1582
|
+
performance_summary_ids (list[str]): The performance summary IDs to get.
|
1583
|
+
max_data_points (int): The max data points to use for the telemetry. Default is 150.
|
1584
|
+
|
1585
|
+
Returns:
|
1586
|
+
dict[str, Telemetry]: A dictionary of telemetry, keyed by performance summary ID.
|
1587
|
+
"""
|
1588
|
+
partial_fn = partial(self.get_telemetry, max_data_points=max_data_points)
|
1589
|
+
with ThreadPoolExecutor(max_workers=10) as pool:
|
1590
|
+
telemetry = pool.map(partial_fn, performance_summary_ids)
|
1591
|
+
telemetry_dict = {perf_summary.performance_summary_id: perf_summary for perf_summary in telemetry}
|
1592
|
+
return telemetry_dict
|
1593
|
+
|
1594
|
+
def rate_class_from_workout(
|
1428
1595
|
self,
|
1429
|
-
|
1596
|
+
workout: models.Workout,
|
1430
1597
|
class_rating: Literal[0, 1, 2, 3],
|
1431
1598
|
coach_rating: Literal[0, 1, 2, 3],
|
1432
|
-
) -> models.
|
1599
|
+
) -> models.Workout:
|
1433
1600
|
"""Rate a class and coach. The class rating must be 0, 1, 2, or 3. 0 is the same as dismissing the prompt to
|
1434
1601
|
rate the class/coach. 1 - 3 is a range from bad to good.
|
1435
1602
|
|
1436
1603
|
Args:
|
1437
|
-
|
1604
|
+
workout (Workout): The workout to rate.
|
1438
1605
|
class_rating (int): The class rating. Must be 0, 1, 2, or 3.
|
1439
1606
|
coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
|
1440
1607
|
|
1441
1608
|
Returns:
|
1442
|
-
|
1609
|
+
Workout: The updated workout with the new ratings.
|
1443
1610
|
|
1444
1611
|
Raises:
|
1445
1612
|
AlreadyRatedError: If the performance summary is already rated.
|
1446
1613
|
ClassNotRatableError: If the performance summary is not rateable.
|
1447
|
-
ValueError: If the performance summary does not have an associated class.
|
1448
1614
|
"""
|
1449
1615
|
|
1450
|
-
if
|
1451
|
-
raise exc.
|
1616
|
+
if not workout.ratable or not workout.class_uuid:
|
1617
|
+
raise exc.ClassNotRatableError(f"Workout {workout.performance_summary_id} is not rateable.")
|
1452
1618
|
|
1453
|
-
if not
|
1454
|
-
raise exc.
|
1455
|
-
f"Performance summary {perf_summary.performance_summary_id} is not rateable."
|
1456
|
-
)
|
1619
|
+
if workout.class_rating is not None or workout.coach_rating is not None:
|
1620
|
+
raise exc.AlreadyRatedError(f"Workout {workout.performance_summary_id} already rated.")
|
1457
1621
|
|
1458
|
-
|
1459
|
-
|
1460
|
-
|
1461
|
-
|
1622
|
+
self.rate_class(workout.class_uuid, workout.performance_summary_id, class_rating, coach_rating)
|
1623
|
+
|
1624
|
+
# TODO: use this once we get it working, getting workout list is substitute for now
|
1625
|
+
# return self.get_workout_from_booking(workout.booking_id)
|
1462
1626
|
|
1463
|
-
|
1464
|
-
|
1627
|
+
workout_date = pendulum.instance(workout.otf_class.starts_at).start_of("day")
|
1628
|
+
workouts = self.get_workouts(workout_date.subtract(days=1), workout_date.add(days=1))
|
1629
|
+
|
1630
|
+
selected_workout = next(
|
1631
|
+
(w for w in workouts if w.performance_summary_id == workout.performance_summary_id), None
|
1465
1632
|
)
|
1466
1633
|
|
1634
|
+
assert selected_workout is not None, "Workout not found in the list of workouts"
|
1635
|
+
|
1636
|
+
return selected_workout
|
1637
|
+
|
1467
1638
|
# the below do not return any data for me, so I can't test them
|
1468
1639
|
|
1469
1640
|
def _get_member_services(self, active_only: bool = True) -> Any:
|