otf-api 0.10.1__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 +373 -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.py +4 -0
- 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 -5
- 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.1.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.1.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.1.dist-info/RECORD +0 -34
- {otf_api-0.10.1.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
|
|
@@ -67,6 +72,7 @@ class Otf:
|
|
67
72
|
retry=retry_if_exception_type(exc.OtfRequestError),
|
68
73
|
stop=stop_after_attempt(3),
|
69
74
|
wait=wait_exponential(multiplier=1, min=4, max=10),
|
75
|
+
reraise=True,
|
70
76
|
)
|
71
77
|
def _do(
|
72
78
|
self,
|
@@ -100,10 +106,18 @@ class Otf:
|
|
100
106
|
if e.response.status_code == 404:
|
101
107
|
raise exc.ResourceNotFoundError("Resource not found")
|
102
108
|
|
103
|
-
|
104
|
-
|
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
|
105
120
|
|
106
|
-
raise exc.OtfRequestError("Error making request", e, response=response, request=request)
|
107
121
|
except Exception as e:
|
108
122
|
LOGGER.exception(f"Error making request: {e}")
|
109
123
|
raise
|
@@ -129,27 +143,66 @@ class Otf:
|
|
129
143
|
|
130
144
|
return resp
|
131
145
|
|
132
|
-
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:
|
133
149
|
"""Perform an API request to the classes API."""
|
134
|
-
return self._do(method, API_IO_BASE_URL, url, params)
|
150
|
+
return self._do(method, API_IO_BASE_URL, url, params, headers=headers)
|
135
151
|
|
136
|
-
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:
|
137
160
|
"""Perform an API request to the default API."""
|
138
|
-
return self._do(method, API_BASE_URL, url, params, **kwargs)
|
161
|
+
return self._do(method, API_BASE_URL, url, params, headers=headers, **kwargs)
|
139
162
|
|
140
|
-
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:
|
141
166
|
"""Perform an API request to the Telemetry API."""
|
142
|
-
return self._do(method, API_TELEMETRY_BASE_URL, url, params)
|
167
|
+
return self._do(method, API_TELEMETRY_BASE_URL, url, params, headers=headers)
|
143
168
|
|
144
|
-
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:
|
145
172
|
"""Perform an API request to the performance summary API."""
|
146
173
|
perf_api_headers = {"koji-member-id": self.member_uuid, "koji-member-email": self.user.email_address}
|
147
|
-
|
174
|
+
headers = perf_api_headers | (headers or {})
|
175
|
+
|
176
|
+
return self._do(method, API_IO_BASE_URL, url, params, headers=headers)
|
148
177
|
|
149
178
|
def _get_classes_raw(self, studio_uuids: list[str] | None) -> dict:
|
150
179
|
"""Retrieve raw class data."""
|
151
180
|
return self._classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})
|
152
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
|
+
|
153
206
|
def _get_booking_raw(self, booking_uuid: str) -> dict:
|
154
207
|
"""Retrieve raw booking data."""
|
155
208
|
return self._default_request("GET", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}")
|
@@ -166,6 +219,37 @@ class Otf:
|
|
166
219
|
params={"startDate": start_date, "endDate": end_date, "statuses": status},
|
167
220
|
)
|
168
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
|
+
|
169
253
|
def _get_member_detail_raw(self) -> dict:
|
170
254
|
"""Retrieve raw member details."""
|
171
255
|
return self._default_request(
|
@@ -189,7 +273,7 @@ class Otf:
|
|
189
273
|
"""Retrieve raw heart rate history."""
|
190
274
|
return self._telemetry_request("GET", "/v1/physVars/maxHr/history", params={"memberUuid": self.member_uuid})
|
191
275
|
|
192
|
-
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:
|
193
277
|
"""Retrieve raw telemetry data."""
|
194
278
|
return self._telemetry_request(
|
195
279
|
"GET",
|
@@ -344,10 +428,61 @@ class Otf:
|
|
344
428
|
json={"firstName": first_name, "lastName": last_name},
|
345
429
|
)
|
346
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
|
+
|
347
482
|
def get_classes(
|
348
483
|
self,
|
349
|
-
start_date: date | None = None,
|
350
|
-
end_date: date | None = None,
|
484
|
+
start_date: date | str | None = None,
|
485
|
+
end_date: date | str | None = None,
|
351
486
|
studio_uuids: list[str] | None = None,
|
352
487
|
include_home_studio: bool | None = None,
|
353
488
|
filters: list[filters.ClassFilter] | filters.ClassFilter | None = None,
|
@@ -370,6 +505,9 @@ class Otf:
|
|
370
505
|
list[OtfClass]: The classes for the user.
|
371
506
|
"""
|
372
507
|
|
508
|
+
start_date = ensure_date(start_date)
|
509
|
+
end_date = ensure_date(end_date)
|
510
|
+
|
373
511
|
classes = self._get_classes(studio_uuids, include_home_studio)
|
374
512
|
|
375
513
|
# remove those that are cancelled *by the studio*
|
@@ -530,6 +668,29 @@ class Otf:
|
|
530
668
|
|
531
669
|
raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
|
532
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
|
+
|
533
694
|
def book_class(self, otf_class: str | models.OtfClass) -> models.Booking:
|
534
695
|
"""Book a class by providing either the class_uuid or the OtfClass object.
|
535
696
|
|
@@ -555,21 +716,7 @@ class Otf:
|
|
555
716
|
|
556
717
|
body = {"classUUId": class_uuid, "confirmed": False, "waitlist": False}
|
557
718
|
|
558
|
-
|
559
|
-
resp = self._default_request("PUT", f"/member/members/{self.member_uuid}/bookings", json=body)
|
560
|
-
except exc.OtfRequestError as e:
|
561
|
-
resp_obj = e.response.json()
|
562
|
-
|
563
|
-
if resp_obj["code"] == "ERROR":
|
564
|
-
err_code = resp_obj["data"]["errorCode"]
|
565
|
-
if err_code == "603":
|
566
|
-
raise exc.AlreadyBookedError(f"Class {class_uuid} is already booked.")
|
567
|
-
if err_code == "602":
|
568
|
-
raise exc.OutsideSchedulingWindowError(f"Class {class_uuid} is outside the scheduling window.")
|
569
|
-
|
570
|
-
raise
|
571
|
-
except Exception as e:
|
572
|
-
raise exc.OtfException(f"Error booking class {class_uuid}: {e}")
|
719
|
+
resp = self._book_class_raw(class_uuid, body)
|
573
720
|
|
574
721
|
# get the booking uuid - we will only use this to return a Booking object using `get_booking`
|
575
722
|
# this is an attempt to improve on OTF's terrible data model
|
@@ -632,22 +779,44 @@ class Otf:
|
|
632
779
|
ValueError: If booking_uuid is None or empty string
|
633
780
|
BookingNotFoundError: If the booking does not exist.
|
634
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
|
+
|
635
786
|
booking_uuid = get_booking_uuid(booking)
|
636
787
|
|
637
|
-
|
638
|
-
|
639
|
-
|
640
|
-
|
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.")
|
641
793
|
|
642
|
-
|
643
|
-
resp = self._default_request(
|
644
|
-
"DELETE", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}", params=params
|
645
|
-
)
|
794
|
+
resp = self._cancel_booking_raw(booking_uuid)
|
646
795
|
if resp["code"] == "NOT_AUTHORIZED" and resp["message"].startswith("This class booking has"):
|
647
796
|
raise exc.BookingAlreadyCancelledError(
|
648
797
|
f"Booking {booking_uuid} is already cancelled.", booking_uuid=booking_uuid
|
649
798
|
)
|
650
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
|
+
|
651
820
|
def get_bookings(
|
652
821
|
self,
|
653
822
|
start_date: date | str | None = None,
|
@@ -797,7 +966,7 @@ class Otf:
|
|
797
966
|
It is being provided anyway, in case this changes in the future.
|
798
967
|
|
799
968
|
Returns:
|
800
|
-
|
969
|
+
StatsResponse: The member's lifetime stats.
|
801
970
|
"""
|
802
971
|
|
803
972
|
data = self._get_member_lifetime_stats_raw(select_time.value)
|
@@ -808,14 +977,14 @@ class Otf:
|
|
808
977
|
|
809
978
|
def get_member_lifetime_stats_in_studio(
|
810
979
|
self, select_time: models.StatsTime = models.StatsTime.AllTime
|
811
|
-
) -> models.
|
980
|
+
) -> models.InStudioStatsData:
|
812
981
|
"""Get the member's lifetime stats in studio.
|
813
982
|
|
814
983
|
Args:
|
815
984
|
select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
|
816
985
|
|
817
986
|
Returns:
|
818
|
-
|
987
|
+
InStudioStatsData: The member's lifetime stats in studio.
|
819
988
|
"""
|
820
989
|
|
821
990
|
data = self._get_member_lifetime_stats(select_time)
|
@@ -824,14 +993,14 @@ class Otf:
|
|
824
993
|
|
825
994
|
def get_member_lifetime_stats_out_of_studio(
|
826
995
|
self, select_time: models.StatsTime = models.StatsTime.AllTime
|
827
|
-
) -> models.
|
996
|
+
) -> models.OutStudioStatsData:
|
828
997
|
"""Get the member's lifetime stats out of studio.
|
829
998
|
|
830
999
|
Args:
|
831
1000
|
select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
|
832
1001
|
|
833
1002
|
Returns:
|
834
|
-
|
1003
|
+
OutStudioStatsData: The member's lifetime stats out of studio.
|
835
1004
|
"""
|
836
1005
|
|
837
1006
|
data = self._get_member_lifetime_stats(select_time)
|
@@ -934,7 +1103,7 @@ class Otf:
|
|
934
1103
|
try:
|
935
1104
|
res = self._get_studio_detail_raw(studio_uuid)
|
936
1105
|
except exc.ResourceNotFoundError:
|
937
|
-
return models.StudioDetail(
|
1106
|
+
return models.StudioDetail.create_empty_model(studio_uuid)
|
938
1107
|
|
939
1108
|
return models.StudioDetail(**res["data"])
|
940
1109
|
|
@@ -1038,14 +1207,14 @@ class Otf:
|
|
1038
1207
|
|
1039
1208
|
def get_benchmarks(
|
1040
1209
|
self,
|
1041
|
-
challenge_category_id:
|
1210
|
+
challenge_category_id: int = 0,
|
1042
1211
|
equipment_id: models.EquipmentType | Literal[0] = 0,
|
1043
1212
|
challenge_subcategory_id: int = 0,
|
1044
1213
|
) -> list[models.FitnessBenchmark]:
|
1045
1214
|
"""Get the member's challenge tracker participation details.
|
1046
1215
|
|
1047
1216
|
Args:
|
1048
|
-
challenge_category_id (
|
1217
|
+
challenge_category_id (int): The challenge type ID.
|
1049
1218
|
equipment_id (EquipmentType | Literal[0]): The equipment ID, default is 0 - this doesn't seem\
|
1050
1219
|
to be have any impact on the results.
|
1051
1220
|
challenge_subcategory_id (int): The challenge sub type ID. Default is 0 - this doesn't seem\
|
@@ -1072,13 +1241,11 @@ class Otf:
|
|
1072
1241
|
|
1073
1242
|
return benchmarks
|
1074
1243
|
|
1075
|
-
def get_benchmarks_by_challenge_category(
|
1076
|
-
self, challenge_category_id: models.ChallengeCategory
|
1077
|
-
) -> list[models.FitnessBenchmark]:
|
1244
|
+
def get_benchmarks_by_challenge_category(self, challenge_category_id: int) -> list[models.FitnessBenchmark]:
|
1078
1245
|
"""Get the member's challenge tracker participation details by challenge.
|
1079
1246
|
|
1080
1247
|
Args:
|
1081
|
-
challenge_category_id (
|
1248
|
+
challenge_category_id (int): The challenge type ID.
|
1082
1249
|
|
1083
1250
|
Returns:
|
1084
1251
|
list[FitnessBenchmark]: The member's challenge tracker details.
|
@@ -1089,12 +1256,12 @@ class Otf:
|
|
1089
1256
|
|
1090
1257
|
return benchmarks
|
1091
1258
|
|
1092
|
-
def get_challenge_tracker_detail(self, challenge_category_id:
|
1259
|
+
def get_challenge_tracker_detail(self, challenge_category_id: int) -> models.FitnessBenchmark:
|
1093
1260
|
"""Get details about a challenge. This endpoint does not (usually) return member participation, but rather
|
1094
1261
|
details about the challenge itself.
|
1095
1262
|
|
1096
1263
|
Args:
|
1097
|
-
challenge_category_id (
|
1264
|
+
challenge_category_id (int): The challenge type ID.
|
1098
1265
|
|
1099
1266
|
Returns:
|
1100
1267
|
FitnessBenchmark: Details about the challenge.
|
@@ -1110,94 +1277,7 @@ class Otf:
|
|
1110
1277
|
|
1111
1278
|
return models.FitnessBenchmark(**data["Dto"][0])
|
1112
1279
|
|
1113
|
-
|
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.
|
1153
|
-
|
1154
|
-
Args:
|
1155
|
-
limit (int | None): The maximum number of entries to return. Default is None.
|
1156
|
-
|
1157
|
-
Returns:
|
1158
|
-
list[PerformanceSummary]: A list of performance summaries.
|
1159
|
-
|
1160
|
-
Developer Notes:
|
1161
|
-
---
|
1162
|
-
In the app, this is referred to as 'getInStudioWorkoutHistory'.
|
1163
|
-
|
1164
|
-
"""
|
1165
|
-
|
1166
|
-
records = list(self.get_performance_summaries_dict(limit=limit).values())
|
1167
|
-
|
1168
|
-
sorted_records = sorted(records, key=lambda x: x.otf_class.starts_at, reverse=True)
|
1169
|
-
|
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.
|
1181
|
-
|
1182
|
-
Args:
|
1183
|
-
performance_summary_id (str): The ID of the performance summary to retrieve.
|
1184
|
-
|
1185
|
-
Returns:
|
1186
|
-
PerformanceSummary: The performance summary.
|
1187
|
-
|
1188
|
-
Raises:
|
1189
|
-
ResourceNotFoundError: If the performance_summary_id is not in the list of performance summaries.
|
1190
|
-
"""
|
1191
|
-
|
1192
|
-
perf_summary = self.get_performance_summaries_dict(limit=limit).get(performance_summary_id)
|
1193
|
-
|
1194
|
-
if perf_summary is None:
|
1195
|
-
raise exc.ResourceNotFoundError(f"Performance summary {performance_summary_id} not found")
|
1196
|
-
|
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]:
|
1280
|
+
def get_performance_summary(self, performance_summary_id: str) -> models.PerformanceSummary:
|
1201
1281
|
"""Get the details for a performance summary. Generally should not be called directly. This
|
1202
1282
|
|
1203
1283
|
Args:
|
@@ -1205,13 +1285,14 @@ class Otf:
|
|
1205
1285
|
|
1206
1286
|
Returns:
|
1207
1287
|
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
1288
|
"""
|
1213
1289
|
|
1214
|
-
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)
|
1215
1296
|
|
1216
1297
|
def get_hr_history(self) -> list[models.TelemetryHistoryItem]:
|
1217
1298
|
"""Get the heartrate history for the user.
|
@@ -1226,7 +1307,7 @@ class Otf:
|
|
1226
1307
|
resp = self._get_hr_history_raw()
|
1227
1308
|
return [models.TelemetryHistoryItem(**item) for item in resp["history"]]
|
1228
1309
|
|
1229
|
-
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:
|
1230
1311
|
"""Get the telemetry for a performance summary.
|
1231
1312
|
|
1232
1313
|
This returns an object that contains the max heartrate, start/end bpm for each zone,
|
@@ -1234,7 +1315,7 @@ class Otf:
|
|
1234
1315
|
|
1235
1316
|
Args:
|
1236
1317
|
performance_summary_id (str): The performance summary id.
|
1237
|
-
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.
|
1238
1319
|
|
1239
1320
|
Returns:
|
1240
1321
|
TelemetryItem: The telemetry for the class history.
|
@@ -1294,7 +1375,7 @@ class Otf:
|
|
1294
1375
|
transactional_enabled if transactional_enabled is not None else current_settings.is_transactional_sms_opt_in
|
1295
1376
|
)
|
1296
1377
|
|
1297
|
-
self._update_sms_notification_settings_raw(promotional_enabled, transactional_enabled)
|
1378
|
+
self._update_sms_notification_settings_raw(promotional_enabled, transactional_enabled) # type: ignore
|
1298
1379
|
|
1299
1380
|
# the response returns nothing useful, so we just query the settings again
|
1300
1381
|
new_settings = self.get_sms_notification_settings()
|
@@ -1333,7 +1414,7 @@ class Otf:
|
|
1333
1414
|
else current_settings.is_transactional_email_opt_in
|
1334
1415
|
)
|
1335
1416
|
|
1336
|
-
self._update_email_notification_settings_raw(promotional_enabled, transactional_enabled)
|
1417
|
+
self._update_email_notification_settings_raw(promotional_enabled, transactional_enabled) # type: ignore
|
1337
1418
|
|
1338
1419
|
# the response returns nothing useful, so we just query the settings again
|
1339
1420
|
new_settings = self.get_email_notification_settings()
|
@@ -1361,19 +1442,21 @@ class Otf:
|
|
1361
1442
|
LOGGER.warning("No changes to names, nothing to update.")
|
1362
1443
|
return self.member
|
1363
1444
|
|
1445
|
+
assert first_name is not None, "First name is required"
|
1446
|
+
assert last_name is not None, "Last name is required"
|
1447
|
+
|
1364
1448
|
res = self._update_member_name_raw(first_name, last_name)
|
1365
1449
|
|
1366
1450
|
return models.MemberDetail(**res["data"])
|
1367
1451
|
|
1368
|
-
def
|
1452
|
+
def rate_class(
|
1369
1453
|
self,
|
1370
1454
|
class_uuid: str,
|
1371
1455
|
performance_summary_id: str,
|
1372
1456
|
class_rating: Literal[0, 1, 2, 3],
|
1373
1457
|
coach_rating: Literal[0, 1, 2, 3],
|
1374
|
-
)
|
1375
|
-
"""Rate a class and coach. A simpler method is provided in `
|
1376
|
-
|
1458
|
+
):
|
1459
|
+
"""Rate a class and coach. A simpler method is provided in `rate_class_from_workout`.
|
1377
1460
|
|
1378
1461
|
The class rating must be between 0 and 4.
|
1379
1462
|
0 is the same as dismissing the prompt to rate the class/coach in the app.
|
@@ -1386,83 +1469,172 @@ class Otf:
|
|
1386
1469
|
coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
|
1387
1470
|
|
1388
1471
|
Returns:
|
1389
|
-
|
1390
|
-
"""
|
1391
|
-
|
1392
|
-
# com/orangetheoryfitness/fragment/rating/RateStatus.java
|
1393
|
-
|
1394
|
-
# we convert these to the new values that the app uses
|
1395
|
-
# mainly because we don't want to cause any issues with the API and/or with OTF corporate
|
1396
|
-
# wondering where the old values are coming from
|
1397
|
-
|
1398
|
-
COACH_RATING_MAP = {0: 0, 1: 16, 2: 17, 3: 18}
|
1399
|
-
CLASS_RATING_MAP = {0: 0, 1: 19, 2: 20, 3: 21}
|
1400
|
-
|
1401
|
-
if class_rating not in CLASS_RATING_MAP:
|
1402
|
-
raise ValueError(f"Invalid class rating {class_rating}")
|
1472
|
+
None
|
1403
1473
|
|
1404
|
-
|
1405
|
-
raise ValueError(f"Invalid coach rating {coach_rating}")
|
1474
|
+
"""
|
1406
1475
|
|
1407
|
-
body_class_rating =
|
1408
|
-
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)
|
1409
1478
|
|
1410
1479
|
try:
|
1411
1480
|
self._rate_class_raw(class_uuid, performance_summary_id, body_class_rating, body_coach_rating)
|
1412
1481
|
except exc.OtfRequestError as e:
|
1413
1482
|
if e.response.status_code == 403:
|
1414
|
-
raise exc.AlreadyRatedError(f"
|
1483
|
+
raise exc.AlreadyRatedError(f"Workout {performance_summary_id} is already rated.") from None
|
1415
1484
|
raise
|
1416
1485
|
|
1417
|
-
|
1418
|
-
|
1419
|
-
|
1420
|
-
|
1421
|
-
|
1422
|
-
|
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)
|
1423
1572
|
|
1424
|
-
|
1573
|
+
perf_summaries_dict = {perf_summary["id"]: perf_summary for perf_summary in perf_summaries}
|
1574
|
+
return perf_summaries_dict
|
1425
1575
|
|
1426
|
-
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(
|
1427
1595
|
self,
|
1428
|
-
|
1596
|
+
workout: models.Workout,
|
1429
1597
|
class_rating: Literal[0, 1, 2, 3],
|
1430
1598
|
coach_rating: Literal[0, 1, 2, 3],
|
1431
|
-
) -> models.
|
1599
|
+
) -> models.Workout:
|
1432
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
|
1433
1601
|
rate the class/coach. 1 - 3 is a range from bad to good.
|
1434
1602
|
|
1435
1603
|
Args:
|
1436
|
-
|
1604
|
+
workout (Workout): The workout to rate.
|
1437
1605
|
class_rating (int): The class rating. Must be 0, 1, 2, or 3.
|
1438
1606
|
coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
|
1439
1607
|
|
1440
1608
|
Returns:
|
1441
|
-
|
1609
|
+
Workout: The updated workout with the new ratings.
|
1442
1610
|
|
1443
1611
|
Raises:
|
1444
1612
|
AlreadyRatedError: If the performance summary is already rated.
|
1445
1613
|
ClassNotRatableError: If the performance summary is not rateable.
|
1446
|
-
ValueError: If the performance summary does not have an associated class.
|
1447
1614
|
"""
|
1448
1615
|
|
1449
|
-
if
|
1450
|
-
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.")
|
1451
1618
|
|
1452
|
-
if not
|
1453
|
-
raise exc.
|
1454
|
-
f"Performance summary {perf_summary.performance_summary_id} is not rateable."
|
1455
|
-
)
|
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.")
|
1456
1621
|
|
1457
|
-
|
1458
|
-
|
1459
|
-
|
1460
|
-
|
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)
|
1461
1626
|
|
1462
|
-
|
1463
|
-
|
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
|
1464
1632
|
)
|
1465
1633
|
|
1634
|
+
assert selected_workout is not None, "Workout not found in the list of workouts"
|
1635
|
+
|
1636
|
+
return selected_workout
|
1637
|
+
|
1466
1638
|
# the below do not return any data for me, so I can't test them
|
1467
1639
|
|
1468
1640
|
def _get_member_services(self, active_only: bool = True) -> Any:
|