otf-api 0.10.2__py3-none-any.whl → 0.11.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 +397 -209
- otf_api/auth/__init__.py +1 -1
- otf_api/auth/auth.py +115 -8
- 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.1.dist-info}/METADATA +23 -27
- otf_api-0.11.1.dist-info/RECORD +38 -0
- {otf_api-0.10.2.dist-info → otf_api-0.11.1.dist-info}/WHEEL +2 -1
- otf_api-0.11.1.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.1.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_id, get_booking_uuid, get_class_uuid
|
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
|
|
@@ -64,7 +69,7 @@ class Otf:
|
|
64
69
|
return hash(self.member_uuid)
|
65
70
|
|
66
71
|
@retry(
|
67
|
-
retry=retry_if_exception_type(exc.OtfRequestError),
|
72
|
+
retry=retry_if_exception_type((exc.OtfRequestError, httpx.HTTPStatusError)),
|
68
73
|
stop=stop_after_attempt(3),
|
69
74
|
wait=wait_exponential(multiplier=1, min=4, max=10),
|
70
75
|
reraise=True,
|
@@ -101,17 +106,28 @@ 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
|
111
124
|
|
112
125
|
if not response.text:
|
113
|
-
|
114
|
-
|
126
|
+
if method == "GET":
|
127
|
+
raise exc.OtfRequestError("Empty response", None, response=response, request=request)
|
128
|
+
|
129
|
+
LOGGER.debug(f"Request {method!r} to {full_url!r} returned no content")
|
130
|
+
return None
|
115
131
|
|
116
132
|
try:
|
117
133
|
resp = response.json()
|
@@ -130,27 +146,76 @@ class Otf:
|
|
130
146
|
|
131
147
|
return resp
|
132
148
|
|
133
|
-
def _classes_request(
|
149
|
+
def _classes_request(
|
150
|
+
self,
|
151
|
+
method: str,
|
152
|
+
url: str,
|
153
|
+
params: dict[str, Any] | None = None,
|
154
|
+
headers: dict[str, Any] | None = None,
|
155
|
+
**kwargs: Any,
|
156
|
+
) -> Any:
|
134
157
|
"""Perform an API request to the classes API."""
|
135
|
-
return self._do(method, API_IO_BASE_URL, url, params)
|
158
|
+
return self._do(method, API_IO_BASE_URL, url, params, headers=headers, **kwargs)
|
136
159
|
|
137
|
-
def _default_request(
|
160
|
+
def _default_request(
|
161
|
+
self,
|
162
|
+
method: str,
|
163
|
+
url: str,
|
164
|
+
params: dict[str, Any] | None = None,
|
165
|
+
headers: dict[str, Any] | None = None,
|
166
|
+
**kwargs: Any,
|
167
|
+
) -> Any:
|
138
168
|
"""Perform an API request to the default API."""
|
139
|
-
return self._do(method, API_BASE_URL, url, params, **kwargs)
|
169
|
+
return self._do(method, API_BASE_URL, url, params, headers=headers, **kwargs)
|
140
170
|
|
141
|
-
def _telemetry_request(
|
171
|
+
def _telemetry_request(
|
172
|
+
self, method: str, url: str, params: dict[str, Any] | None = None, headers: dict[str, Any] | None = None
|
173
|
+
) -> Any:
|
142
174
|
"""Perform an API request to the Telemetry API."""
|
143
|
-
return self._do(method, API_TELEMETRY_BASE_URL, url, params)
|
175
|
+
return self._do(method, API_TELEMETRY_BASE_URL, url, params, headers=headers)
|
144
176
|
|
145
|
-
def _performance_summary_request(
|
177
|
+
def _performance_summary_request(
|
178
|
+
self, method: str, url: str, params: dict[str, Any] | None = None, headers: dict[str, Any] | None = None
|
179
|
+
) -> Any:
|
146
180
|
"""Perform an API request to the performance summary API."""
|
147
181
|
perf_api_headers = {"koji-member-id": self.member_uuid, "koji-member-email": self.user.email_address}
|
148
|
-
|
182
|
+
headers = perf_api_headers | (headers or {})
|
183
|
+
|
184
|
+
return self._do(method, API_IO_BASE_URL, url, params, headers=headers)
|
149
185
|
|
150
|
-
def _get_classes_raw(self, studio_uuids: list[str]
|
186
|
+
def _get_classes_raw(self, studio_uuids: list[str]) -> dict:
|
151
187
|
"""Retrieve raw class data."""
|
152
188
|
return self._classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})
|
153
189
|
|
190
|
+
def _cancel_booking_raw(self, booking_uuid: str) -> dict:
|
191
|
+
"""Cancel a booking by booking_uuid."""
|
192
|
+
return self._default_request(
|
193
|
+
"DELETE", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}", params={"confirmed": "true"}
|
194
|
+
)
|
195
|
+
|
196
|
+
def _book_class_raw(self, class_uuid, body):
|
197
|
+
try:
|
198
|
+
resp = self._default_request("PUT", f"/member/members/{self.member_uuid}/bookings", json=body)
|
199
|
+
except exc.OtfRequestError as e:
|
200
|
+
resp_obj = e.response.json()
|
201
|
+
|
202
|
+
if resp_obj["code"] == "ERROR":
|
203
|
+
err_code = resp_obj["data"]["errorCode"]
|
204
|
+
if err_code == "603":
|
205
|
+
raise exc.AlreadyBookedError(f"Class {class_uuid} is already booked.")
|
206
|
+
if err_code == "602":
|
207
|
+
raise exc.OutsideSchedulingWindowError(f"Class {class_uuid} is outside the scheduling window.")
|
208
|
+
|
209
|
+
raise
|
210
|
+
except Exception as e:
|
211
|
+
raise exc.OtfException(f"Error booking class {class_uuid}: {e}")
|
212
|
+
return resp
|
213
|
+
|
214
|
+
def _book_class_new_raw(self, body: dict[str, str | bool]) -> dict:
|
215
|
+
"""Book a class by class_id."""
|
216
|
+
|
217
|
+
return self._classes_request("POST", "/v1/bookings/me", json=body)
|
218
|
+
|
154
219
|
def _get_booking_raw(self, booking_uuid: str) -> dict:
|
155
220
|
"""Retrieve raw booking data."""
|
156
221
|
return self._default_request("GET", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}")
|
@@ -167,6 +232,29 @@ class Otf:
|
|
167
232
|
params={"startDate": start_date, "endDate": end_date, "statuses": status},
|
168
233
|
)
|
169
234
|
|
235
|
+
def _get_bookings_new_raw(
|
236
|
+
self,
|
237
|
+
ends_before: datetime,
|
238
|
+
starts_after: datetime,
|
239
|
+
include_canceled: bool = True,
|
240
|
+
expand: bool = False,
|
241
|
+
) -> dict:
|
242
|
+
"""Retrieve raw bookings data."""
|
243
|
+
|
244
|
+
params: dict[str, bool | str] = {
|
245
|
+
"ends_before": pendulum.instance(ends_before).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
246
|
+
"starts_after": pendulum.instance(starts_after).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
247
|
+
}
|
248
|
+
|
249
|
+
params["include_canceled"] = include_canceled if include_canceled is not None else True
|
250
|
+
params["expand"] = expand if expand is not None else False
|
251
|
+
|
252
|
+
return self._classes_request("GET", "/v1/bookings/me", params=params)
|
253
|
+
|
254
|
+
def _cancel_booking_new_raw(self, booking_id: str) -> dict:
|
255
|
+
"""Cancel a booking by booking_id."""
|
256
|
+
return self._classes_request("DELETE", f"/v1/bookings/me/{booking_id}")
|
257
|
+
|
170
258
|
def _get_member_detail_raw(self) -> dict:
|
171
259
|
"""Retrieve raw member details."""
|
172
260
|
return self._default_request(
|
@@ -190,7 +278,7 @@ class Otf:
|
|
190
278
|
"""Retrieve raw heart rate history."""
|
191
279
|
return self._telemetry_request("GET", "/v1/physVars/maxHr/history", params={"memberUuid": self.member_uuid})
|
192
280
|
|
193
|
-
def _get_telemetry_raw(self, performance_summary_id: str, max_data_points: int) -> dict:
|
281
|
+
def _get_telemetry_raw(self, performance_summary_id: str, max_data_points: int = 150) -> dict:
|
194
282
|
"""Retrieve raw telemetry data."""
|
195
283
|
return self._telemetry_request(
|
196
284
|
"GET",
|
@@ -345,10 +433,67 @@ class Otf:
|
|
345
433
|
json={"firstName": first_name, "lastName": last_name},
|
346
434
|
)
|
347
435
|
|
436
|
+
def _get_all_bookings_new(self) -> list[models.BookingV2]:
|
437
|
+
"""Get bookings from the new endpoint with no date filters."""
|
438
|
+
start_date = pendulum.datetime(1970, 1, 1)
|
439
|
+
end_date = pendulum.today().start_of("day").add(days=45)
|
440
|
+
return self.get_bookings_new(start_date, end_date, exclude_canceled=False)
|
441
|
+
|
442
|
+
def _get_app_config_raw(self) -> dict[str, Any]:
|
443
|
+
return self._default_request("GET", "/member/app-configurations", headers={"SIGV4AUTH_REQUIRED": "true"})
|
444
|
+
|
445
|
+
def get_bookings_new(
|
446
|
+
self,
|
447
|
+
start_dtme: datetime | str | None = None,
|
448
|
+
end_dtme: datetime | str | None = None,
|
449
|
+
exclude_canceled: bool = True,
|
450
|
+
) -> list[models.BookingV2]:
|
451
|
+
"""Get the bookings for the user. If no dates are provided, it will return all bookings
|
452
|
+
between today and 45 days from now.
|
453
|
+
|
454
|
+
Warning:
|
455
|
+
---
|
456
|
+
If you do not exclude cancelled bookings, you may receive multiple bookings for the same workout, such
|
457
|
+
as when a class changes from a 2G to a 3G. Apparently the system actually creates a new booking for the
|
458
|
+
new class, which is normally transparent to the user.
|
459
|
+
|
460
|
+
Args:
|
461
|
+
start_dtme (datetime | str | None): The start date for the bookings. Default is None.
|
462
|
+
end_dtme (datetime | str | None): The end date for the bookings. Default is None.
|
463
|
+
exclude_canceled (bool): Whether to exclude canceled bookings. Default is True.
|
464
|
+
Returns:
|
465
|
+
list[BookingV2]: The bookings for the user.
|
466
|
+
"""
|
467
|
+
|
468
|
+
expand = True # this doesn't seem to have an effect? so leaving it out of the argument list
|
469
|
+
|
470
|
+
# leaving the parameter as `exclude_canceled` for backwards compatibility
|
471
|
+
include_canceled = not exclude_canceled
|
472
|
+
|
473
|
+
end_dtme = ensure_datetime(end_dtme)
|
474
|
+
start_dtme = ensure_datetime(start_dtme)
|
475
|
+
|
476
|
+
end_dtme = end_dtme or pendulum.today().start_of("day").add(days=45)
|
477
|
+
start_dtme = start_dtme or pendulum.datetime(1970, 1, 1).start_of("day")
|
478
|
+
|
479
|
+
bookings_resp = self._get_bookings_new_raw(
|
480
|
+
ends_before=end_dtme, starts_after=start_dtme, include_canceled=include_canceled, expand=expand
|
481
|
+
)
|
482
|
+
|
483
|
+
return [models.BookingV2(**b) for b in bookings_resp["items"]]
|
484
|
+
|
485
|
+
def get_booking_new(self, booking_id: str) -> models.BookingV2:
|
486
|
+
"""Get a booking by ID."""
|
487
|
+
all_bookings = self._get_all_bookings_new()
|
488
|
+
booking = next((b for b in all_bookings if b.booking_id == booking_id), None)
|
489
|
+
if not booking:
|
490
|
+
raise exc.ResourceNotFoundError(f"Booking with ID {booking_id} not found")
|
491
|
+
return booking
|
492
|
+
|
348
493
|
def get_classes(
|
349
494
|
self,
|
350
|
-
start_date: date | None = None,
|
351
|
-
end_date: date | None = None,
|
495
|
+
start_date: date | str | None = None,
|
496
|
+
end_date: date | str | None = None,
|
352
497
|
studio_uuids: list[str] | None = None,
|
353
498
|
include_home_studio: bool | None = None,
|
354
499
|
filters: list[filters.ClassFilter] | filters.ClassFilter | None = None,
|
@@ -359,11 +504,11 @@ class Otf:
|
|
359
504
|
UUIDs are provided, it will default to the user's home studio.
|
360
505
|
|
361
506
|
Args:
|
362
|
-
start_date (date | None): The start date for the classes. Default is None.
|
363
|
-
end_date (date | None): The end date for the classes. Default is None.
|
507
|
+
start_date (date | str | None): The start date for the classes. Default is None.
|
508
|
+
end_date (date | str | None): The end date for the classes. Default is None.
|
364
509
|
studio_uuids (list[str] | None): The studio UUIDs to get the classes for. Default is None, which will\
|
365
510
|
default to the user's home studio only.
|
366
|
-
include_home_studio (bool): Whether to include the home studio in the classes. Default is True.
|
511
|
+
include_home_studio (bool | None): Whether to include the home studio in the classes. Default is True.
|
367
512
|
filters (list[ClassFilter] | ClassFilter | None): A list of filters to apply to the classes, or a single\
|
368
513
|
filter. Filters are applied as an OR operation. Default is None.
|
369
514
|
|
@@ -371,6 +516,9 @@ class Otf:
|
|
371
516
|
list[OtfClass]: The classes for the user.
|
372
517
|
"""
|
373
518
|
|
519
|
+
start_date = ensure_date(start_date)
|
520
|
+
end_date = ensure_date(end_date)
|
521
|
+
|
374
522
|
classes = self._get_classes(studio_uuids, include_home_studio)
|
375
523
|
|
376
524
|
# remove those that are cancelled *by the studio*
|
@@ -531,6 +679,29 @@ class Otf:
|
|
531
679
|
|
532
680
|
raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
|
533
681
|
|
682
|
+
def get_booking_from_class_new(self, otf_class: str | models.OtfClass | models.BookingV2Class) -> models.BookingV2:
|
683
|
+
"""Get a specific booking by class_uuid or OtfClass object.
|
684
|
+
|
685
|
+
Args:
|
686
|
+
otf_class (str | OtfClass | BookingV2Class): The class UUID or the OtfClass object to get the booking for.
|
687
|
+
|
688
|
+
Returns:
|
689
|
+
BookingV2: The booking.
|
690
|
+
|
691
|
+
Raises:
|
692
|
+
BookingNotFoundError: If the booking does not exist.
|
693
|
+
ValueError: If class_uuid is None or empty string.
|
694
|
+
"""
|
695
|
+
|
696
|
+
class_uuid = get_class_uuid(otf_class)
|
697
|
+
|
698
|
+
all_bookings = self._get_all_bookings_new()
|
699
|
+
|
700
|
+
if booking := next((b for b in all_bookings if b.class_uuid == class_uuid), None):
|
701
|
+
return booking
|
702
|
+
|
703
|
+
raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
|
704
|
+
|
534
705
|
def book_class(self, otf_class: str | models.OtfClass) -> models.Booking:
|
535
706
|
"""Book a class by providing either the class_uuid or the OtfClass object.
|
536
707
|
|
@@ -556,21 +727,7 @@ class Otf:
|
|
556
727
|
|
557
728
|
body = {"classUUId": class_uuid, "confirmed": False, "waitlist": False}
|
558
729
|
|
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}")
|
730
|
+
resp = self._book_class_raw(class_uuid, body)
|
574
731
|
|
575
732
|
# get the booking uuid - we will only use this to return a Booking object using `get_booking`
|
576
733
|
# this is an attempt to improve on OTF's terrible data model
|
@@ -580,6 +737,26 @@ class Otf:
|
|
580
737
|
|
581
738
|
return booking
|
582
739
|
|
740
|
+
def book_class_new(self, class_id: str) -> models.BookingV2:
|
741
|
+
"""Book a class by providing the class_id.
|
742
|
+
|
743
|
+
Args:
|
744
|
+
class_id (str): The class ID to book.
|
745
|
+
|
746
|
+
Returns:
|
747
|
+
BookingV2: The booking.
|
748
|
+
"""
|
749
|
+
if not class_id:
|
750
|
+
raise ValueError("class_id is required")
|
751
|
+
|
752
|
+
body = {"class_id": class_id, "confirmed": False, "waitlist": False}
|
753
|
+
|
754
|
+
resp = self._book_class_new_raw(body)
|
755
|
+
|
756
|
+
new_booking = models.BookingV2(**resp)
|
757
|
+
|
758
|
+
return new_booking
|
759
|
+
|
583
760
|
def _check_class_already_booked(self, class_uuid: str) -> None:
|
584
761
|
"""Check if the class is already booked.
|
585
762
|
|
@@ -633,22 +810,43 @@ class Otf:
|
|
633
810
|
ValueError: If booking_uuid is None or empty string
|
634
811
|
BookingNotFoundError: If the booking does not exist.
|
635
812
|
"""
|
813
|
+
if isinstance(booking, models.BookingV2):
|
814
|
+
LOGGER.warning("BookingV2 object provided, using the new cancel booking endpoint (`cancel_booking_new`)")
|
815
|
+
self.cancel_booking_new(booking)
|
816
|
+
|
636
817
|
booking_uuid = get_booking_uuid(booking)
|
637
818
|
|
638
|
-
|
639
|
-
self.get_booking(booking_uuid)
|
640
|
-
except Exception:
|
641
|
-
raise exc.BookingNotFoundError(f"Booking {booking_uuid} does not exist.")
|
819
|
+
if booking == booking_uuid: # ensure this booking exists by calling the booking endpoint
|
820
|
+
_ = self.get_booking(booking_uuid) # allow the exception to be raised if it doesn't exist
|
642
821
|
|
643
|
-
|
644
|
-
resp = self._default_request(
|
645
|
-
"DELETE", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}", params=params
|
646
|
-
)
|
822
|
+
resp = self._cancel_booking_raw(booking_uuid)
|
647
823
|
if resp["code"] == "NOT_AUTHORIZED" and resp["message"].startswith("This class booking has"):
|
648
824
|
raise exc.BookingAlreadyCancelledError(
|
649
825
|
f"Booking {booking_uuid} is already cancelled.", booking_uuid=booking_uuid
|
650
826
|
)
|
651
827
|
|
828
|
+
def cancel_booking_new(self, booking: str | models.BookingV2) -> None:
|
829
|
+
"""Cancel a booking by providing either the booking_id or the BookingV2 object.
|
830
|
+
|
831
|
+
Args:
|
832
|
+
booking (str | BookingV2): The booking ID or the BookingV2 object to cancel.
|
833
|
+
|
834
|
+
Raises:
|
835
|
+
ValueError: If booking_id is None or empty string
|
836
|
+
BookingNotFoundError: If the booking does not exist.
|
837
|
+
"""
|
838
|
+
|
839
|
+
if isinstance(booking, models.Booking):
|
840
|
+
LOGGER.warning("Booking object provided, using the old cancel booking endpoint (`cancel_booking`)")
|
841
|
+
self.cancel_booking(booking)
|
842
|
+
|
843
|
+
booking_id = get_booking_id(booking)
|
844
|
+
|
845
|
+
if booking == booking_id:
|
846
|
+
_ = self.get_booking_new(booking_id) # allow the exception to be raised if it doesn't exist
|
847
|
+
|
848
|
+
self._cancel_booking_new_raw(booking_id)
|
849
|
+
|
652
850
|
def get_bookings(
|
653
851
|
self,
|
654
852
|
start_date: date | str | None = None,
|
@@ -798,7 +996,7 @@ class Otf:
|
|
798
996
|
It is being provided anyway, in case this changes in the future.
|
799
997
|
|
800
998
|
Returns:
|
801
|
-
|
999
|
+
StatsResponse: The member's lifetime stats.
|
802
1000
|
"""
|
803
1001
|
|
804
1002
|
data = self._get_member_lifetime_stats_raw(select_time.value)
|
@@ -809,14 +1007,14 @@ class Otf:
|
|
809
1007
|
|
810
1008
|
def get_member_lifetime_stats_in_studio(
|
811
1009
|
self, select_time: models.StatsTime = models.StatsTime.AllTime
|
812
|
-
) -> models.
|
1010
|
+
) -> models.InStudioStatsData:
|
813
1011
|
"""Get the member's lifetime stats in studio.
|
814
1012
|
|
815
1013
|
Args:
|
816
1014
|
select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
|
817
1015
|
|
818
1016
|
Returns:
|
819
|
-
|
1017
|
+
InStudioStatsData: The member's lifetime stats in studio.
|
820
1018
|
"""
|
821
1019
|
|
822
1020
|
data = self._get_member_lifetime_stats(select_time)
|
@@ -825,14 +1023,14 @@ class Otf:
|
|
825
1023
|
|
826
1024
|
def get_member_lifetime_stats_out_of_studio(
|
827
1025
|
self, select_time: models.StatsTime = models.StatsTime.AllTime
|
828
|
-
) -> models.
|
1026
|
+
) -> models.OutStudioStatsData:
|
829
1027
|
"""Get the member's lifetime stats out of studio.
|
830
1028
|
|
831
1029
|
Args:
|
832
1030
|
select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
|
833
1031
|
|
834
1032
|
Returns:
|
835
|
-
|
1033
|
+
OutStudioStatsData: The member's lifetime stats out of studio.
|
836
1034
|
"""
|
837
1035
|
|
838
1036
|
data = self._get_member_lifetime_stats(select_time)
|
@@ -935,7 +1133,7 @@ class Otf:
|
|
935
1133
|
try:
|
936
1134
|
res = self._get_studio_detail_raw(studio_uuid)
|
937
1135
|
except exc.ResourceNotFoundError:
|
938
|
-
return models.StudioDetail(
|
1136
|
+
return models.StudioDetail.create_empty_model(studio_uuid)
|
939
1137
|
|
940
1138
|
return models.StudioDetail(**res["data"])
|
941
1139
|
|
@@ -1039,14 +1237,14 @@ class Otf:
|
|
1039
1237
|
|
1040
1238
|
def get_benchmarks(
|
1041
1239
|
self,
|
1042
|
-
challenge_category_id:
|
1240
|
+
challenge_category_id: int = 0,
|
1043
1241
|
equipment_id: models.EquipmentType | Literal[0] = 0,
|
1044
1242
|
challenge_subcategory_id: int = 0,
|
1045
1243
|
) -> list[models.FitnessBenchmark]:
|
1046
1244
|
"""Get the member's challenge tracker participation details.
|
1047
1245
|
|
1048
1246
|
Args:
|
1049
|
-
challenge_category_id (
|
1247
|
+
challenge_category_id (int): The challenge type ID.
|
1050
1248
|
equipment_id (EquipmentType | Literal[0]): The equipment ID, default is 0 - this doesn't seem\
|
1051
1249
|
to be have any impact on the results.
|
1052
1250
|
challenge_subcategory_id (int): The challenge sub type ID. Default is 0 - this doesn't seem\
|
@@ -1073,13 +1271,11 @@ class Otf:
|
|
1073
1271
|
|
1074
1272
|
return benchmarks
|
1075
1273
|
|
1076
|
-
def get_benchmarks_by_challenge_category(
|
1077
|
-
self, challenge_category_id: models.ChallengeCategory
|
1078
|
-
) -> list[models.FitnessBenchmark]:
|
1274
|
+
def get_benchmarks_by_challenge_category(self, challenge_category_id: int) -> list[models.FitnessBenchmark]:
|
1079
1275
|
"""Get the member's challenge tracker participation details by challenge.
|
1080
1276
|
|
1081
1277
|
Args:
|
1082
|
-
challenge_category_id (
|
1278
|
+
challenge_category_id (int): The challenge type ID.
|
1083
1279
|
|
1084
1280
|
Returns:
|
1085
1281
|
list[FitnessBenchmark]: The member's challenge tracker details.
|
@@ -1090,12 +1286,12 @@ class Otf:
|
|
1090
1286
|
|
1091
1287
|
return benchmarks
|
1092
1288
|
|
1093
|
-
def get_challenge_tracker_detail(self, challenge_category_id:
|
1289
|
+
def get_challenge_tracker_detail(self, challenge_category_id: int) -> models.FitnessBenchmark:
|
1094
1290
|
"""Get details about a challenge. This endpoint does not (usually) return member participation, but rather
|
1095
1291
|
details about the challenge itself.
|
1096
1292
|
|
1097
1293
|
Args:
|
1098
|
-
challenge_category_id (
|
1294
|
+
challenge_category_id (int): The challenge type ID.
|
1099
1295
|
|
1100
1296
|
Returns:
|
1101
1297
|
FitnessBenchmark: Details about the challenge.
|
@@ -1111,94 +1307,7 @@ class Otf:
|
|
1111
1307
|
|
1112
1308
|
return models.FitnessBenchmark(**data["Dto"][0])
|
1113
1309
|
|
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]:
|
1310
|
+
def get_performance_summary(self, performance_summary_id: str) -> models.PerformanceSummary:
|
1202
1311
|
"""Get the details for a performance summary. Generally should not be called directly. This
|
1203
1312
|
|
1204
1313
|
Args:
|
@@ -1206,13 +1315,14 @@ class Otf:
|
|
1206
1315
|
|
1207
1316
|
Returns:
|
1208
1317
|
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
1318
|
"""
|
1214
1319
|
|
1215
|
-
return
|
1320
|
+
warning_msg = "This endpoint does not return all data, consider using `get_workouts` instead."
|
1321
|
+
if warning_msg not in LOGGED_ONCE:
|
1322
|
+
LOGGER.warning(warning_msg)
|
1323
|
+
|
1324
|
+
resp = self._get_performance_summary_raw(performance_summary_id)
|
1325
|
+
return models.PerformanceSummary(**resp)
|
1216
1326
|
|
1217
1327
|
def get_hr_history(self) -> list[models.TelemetryHistoryItem]:
|
1218
1328
|
"""Get the heartrate history for the user.
|
@@ -1227,7 +1337,7 @@ class Otf:
|
|
1227
1337
|
resp = self._get_hr_history_raw()
|
1228
1338
|
return [models.TelemetryHistoryItem(**item) for item in resp["history"]]
|
1229
1339
|
|
1230
|
-
def get_telemetry(self, performance_summary_id: str, max_data_points: int =
|
1340
|
+
def get_telemetry(self, performance_summary_id: str, max_data_points: int = 150) -> models.Telemetry:
|
1231
1341
|
"""Get the telemetry for a performance summary.
|
1232
1342
|
|
1233
1343
|
This returns an object that contains the max heartrate, start/end bpm for each zone,
|
@@ -1235,7 +1345,7 @@ class Otf:
|
|
1235
1345
|
|
1236
1346
|
Args:
|
1237
1347
|
performance_summary_id (str): The performance summary id.
|
1238
|
-
max_data_points (int): The max data points to use for the telemetry. Default is
|
1348
|
+
max_data_points (int): The max data points to use for the telemetry. Default is 150, to match the app.
|
1239
1349
|
|
1240
1350
|
Returns:
|
1241
1351
|
TelemetryItem: The telemetry for the class history.
|
@@ -1295,7 +1405,7 @@ class Otf:
|
|
1295
1405
|
transactional_enabled if transactional_enabled is not None else current_settings.is_transactional_sms_opt_in
|
1296
1406
|
)
|
1297
1407
|
|
1298
|
-
self._update_sms_notification_settings_raw(promotional_enabled, transactional_enabled)
|
1408
|
+
self._update_sms_notification_settings_raw(promotional_enabled, transactional_enabled) # type: ignore
|
1299
1409
|
|
1300
1410
|
# the response returns nothing useful, so we just query the settings again
|
1301
1411
|
new_settings = self.get_sms_notification_settings()
|
@@ -1334,7 +1444,7 @@ class Otf:
|
|
1334
1444
|
else current_settings.is_transactional_email_opt_in
|
1335
1445
|
)
|
1336
1446
|
|
1337
|
-
self._update_email_notification_settings_raw(promotional_enabled, transactional_enabled)
|
1447
|
+
self._update_email_notification_settings_raw(promotional_enabled, transactional_enabled) # type: ignore
|
1338
1448
|
|
1339
1449
|
# the response returns nothing useful, so we just query the settings again
|
1340
1450
|
new_settings = self.get_email_notification_settings()
|
@@ -1362,19 +1472,21 @@ class Otf:
|
|
1362
1472
|
LOGGER.warning("No changes to names, nothing to update.")
|
1363
1473
|
return self.member
|
1364
1474
|
|
1475
|
+
assert first_name is not None, "First name is required"
|
1476
|
+
assert last_name is not None, "Last name is required"
|
1477
|
+
|
1365
1478
|
res = self._update_member_name_raw(first_name, last_name)
|
1366
1479
|
|
1367
1480
|
return models.MemberDetail(**res["data"])
|
1368
1481
|
|
1369
|
-
def
|
1482
|
+
def rate_class(
|
1370
1483
|
self,
|
1371
1484
|
class_uuid: str,
|
1372
1485
|
performance_summary_id: str,
|
1373
1486
|
class_rating: Literal[0, 1, 2, 3],
|
1374
1487
|
coach_rating: Literal[0, 1, 2, 3],
|
1375
|
-
)
|
1376
|
-
"""Rate a class and coach. A simpler method is provided in `
|
1377
|
-
|
1488
|
+
):
|
1489
|
+
"""Rate a class and coach. A simpler method is provided in `rate_class_from_workout`.
|
1378
1490
|
|
1379
1491
|
The class rating must be between 0 and 4.
|
1380
1492
|
0 is the same as dismissing the prompt to rate the class/coach in the app.
|
@@ -1387,82 +1499,158 @@ class Otf:
|
|
1387
1499
|
coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
|
1388
1500
|
|
1389
1501
|
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}")
|
1502
|
+
None
|
1404
1503
|
|
1405
|
-
|
1406
|
-
raise ValueError(f"Invalid coach rating {coach_rating}")
|
1504
|
+
"""
|
1407
1505
|
|
1408
|
-
body_class_rating =
|
1409
|
-
body_coach_rating =
|
1506
|
+
body_class_rating = models.get_class_rating_value(class_rating)
|
1507
|
+
body_coach_rating = models.get_coach_rating_value(coach_rating)
|
1410
1508
|
|
1411
1509
|
try:
|
1412
1510
|
self._rate_class_raw(class_uuid, performance_summary_id, body_class_rating, body_coach_rating)
|
1413
1511
|
except exc.OtfRequestError as e:
|
1414
1512
|
if e.response.status_code == 403:
|
1415
|
-
raise exc.AlreadyRatedError(f"
|
1513
|
+
raise exc.AlreadyRatedError(f"Workout {performance_summary_id} is already rated.") from None
|
1416
1514
|
raise
|
1417
1515
|
|
1418
|
-
|
1419
|
-
|
1420
|
-
|
1421
|
-
|
1422
|
-
|
1423
|
-
|
1516
|
+
def get_workout_from_booking(self, booking: str | models.BookingV2) -> models.Workout:
|
1517
|
+
"""Get a workout for a specific booking.
|
1518
|
+
|
1519
|
+
Args:
|
1520
|
+
booking (str | Booking): The booking ID or BookingV2 object to get the workout for.
|
1521
|
+
|
1522
|
+
Returns:
|
1523
|
+
Workout: The member's workout.
|
1524
|
+
|
1525
|
+
Raises:
|
1526
|
+
BookingNotFoundError: If the booking does not exist.
|
1527
|
+
ResourceNotFoundError: If the workout does not exist.
|
1528
|
+
"""
|
1529
|
+
booking_id = get_booking_id(booking)
|
1530
|
+
|
1531
|
+
booking = self.get_booking_new(booking_id)
|
1532
|
+
|
1533
|
+
if not booking.workout or not booking.workout.performance_summary_id:
|
1534
|
+
raise exc.ResourceNotFoundError(f"Workout for booking {booking_id} not found.")
|
1535
|
+
|
1536
|
+
perf_summary = self._get_performance_summary_raw(booking.workout.performance_summary_id)
|
1537
|
+
telemetry = self.get_telemetry(booking.workout.performance_summary_id)
|
1538
|
+
workout = models.Workout(**perf_summary, v2_booking=booking, telemetry=telemetry)
|
1539
|
+
|
1540
|
+
return workout
|
1424
1541
|
|
1425
|
-
|
1542
|
+
def get_workouts(
|
1543
|
+
self, start_date: date | str | None = None, end_date: date | str | None = None
|
1544
|
+
) -> list[models.Workout]:
|
1545
|
+
"""Get the member's workouts, using the new bookings endpoint and the performance summary endpoint.
|
1426
1546
|
|
1427
|
-
|
1547
|
+
Args:
|
1548
|
+
start_date (date | str | None): The start date for the workouts. If None, defaults to 30 days ago.
|
1549
|
+
end_date (date | str | None): The end date for the workouts. If None, defaults to today.
|
1550
|
+
|
1551
|
+
Returns:
|
1552
|
+
list[Workout]: The member's workouts.
|
1553
|
+
"""
|
1554
|
+
start_date = ensure_date(start_date) or pendulum.today().subtract(days=30).date()
|
1555
|
+
end_date = ensure_date(end_date) or datetime.today().date()
|
1556
|
+
|
1557
|
+
start_dtme = pendulum.datetime(start_date.year, start_date.month, start_date.day, 0, 0, 0)
|
1558
|
+
end_dtme = pendulum.datetime(end_date.year, end_date.month, end_date.day, 23, 59, 59)
|
1559
|
+
|
1560
|
+
bookings = self.get_bookings_new(start_dtme, end_dtme, exclude_canceled=False)
|
1561
|
+
bookings_dict = {b.workout.id: b for b in bookings if b.workout}
|
1562
|
+
|
1563
|
+
perf_summaries_dict = self._get_perf_summaries_threaded(list(bookings_dict.keys()))
|
1564
|
+
telemetry_dict = self._get_telemetry_threaded(list(perf_summaries_dict.keys()))
|
1565
|
+
perf_summary_to_class_uuid_map = self._get_perf_summary_to_class_uuid_mapping()
|
1566
|
+
|
1567
|
+
workouts: list[models.Workout] = []
|
1568
|
+
for perf_id, perf_summary in perf_summaries_dict.items():
|
1569
|
+
workout = models.Workout(
|
1570
|
+
**perf_summary,
|
1571
|
+
v2_booking=bookings_dict[perf_id],
|
1572
|
+
telemetry=telemetry_dict.get(perf_id),
|
1573
|
+
class_uuid=perf_summary_to_class_uuid_map.get(perf_id),
|
1574
|
+
)
|
1575
|
+
workouts.append(workout)
|
1576
|
+
|
1577
|
+
return workouts
|
1578
|
+
|
1579
|
+
def _get_perf_summary_to_class_uuid_mapping(self) -> dict[str, str | None]:
|
1580
|
+
"""Get a mapping of performance summary IDs to class UUIDs. These will be used
|
1581
|
+
when rating a class.
|
1582
|
+
|
1583
|
+
Returns:
|
1584
|
+
dict[str, str | None]: A dictionary mapping performance summary IDs to class UUIDs.
|
1585
|
+
"""
|
1586
|
+
perf_summaries = self._get_performance_summaries_raw()["items"]
|
1587
|
+
return {item["id"]: item["class"].get("ot_base_class_uuid") for item in perf_summaries}
|
1588
|
+
|
1589
|
+
def _get_perf_summaries_threaded(self, performance_summary_ids: list[str]) -> dict[str, dict[str, Any]]:
|
1590
|
+
"""Get performance summaries in a ThreadPoolExecutor, to speed up the process.
|
1591
|
+
|
1592
|
+
Args:
|
1593
|
+
performance_summary_ids (list[str]): The performance summary IDs to get.
|
1594
|
+
|
1595
|
+
Returns:
|
1596
|
+
dict[str, dict[str, Any]]: A dictionary of performance summaries, keyed by performance summary ID.
|
1597
|
+
"""
|
1598
|
+
|
1599
|
+
with ThreadPoolExecutor(max_workers=10) as pool:
|
1600
|
+
perf_summaries = pool.map(self._get_performance_summary_raw, performance_summary_ids)
|
1601
|
+
|
1602
|
+
perf_summaries_dict = {perf_summary["id"]: perf_summary for perf_summary in perf_summaries}
|
1603
|
+
return perf_summaries_dict
|
1604
|
+
|
1605
|
+
def _get_telemetry_threaded(
|
1606
|
+
self, performance_summary_ids: list[str], max_data_points: int = 150
|
1607
|
+
) -> dict[str, models.Telemetry]:
|
1608
|
+
"""Get telemetry in a ThreadPoolExecutor, to speed up the process.
|
1609
|
+
|
1610
|
+
Args:
|
1611
|
+
performance_summary_ids (list[str]): The performance summary IDs to get.
|
1612
|
+
max_data_points (int): The max data points to use for the telemetry. Default is 150.
|
1613
|
+
|
1614
|
+
Returns:
|
1615
|
+
dict[str, Telemetry]: A dictionary of telemetry, keyed by performance summary ID.
|
1616
|
+
"""
|
1617
|
+
partial_fn = partial(self.get_telemetry, max_data_points=max_data_points)
|
1618
|
+
with ThreadPoolExecutor(max_workers=10) as pool:
|
1619
|
+
telemetry = pool.map(partial_fn, performance_summary_ids)
|
1620
|
+
telemetry_dict = {perf_summary.performance_summary_id: perf_summary for perf_summary in telemetry}
|
1621
|
+
return telemetry_dict
|
1622
|
+
|
1623
|
+
def rate_class_from_workout(
|
1428
1624
|
self,
|
1429
|
-
|
1625
|
+
workout: models.Workout,
|
1430
1626
|
class_rating: Literal[0, 1, 2, 3],
|
1431
1627
|
coach_rating: Literal[0, 1, 2, 3],
|
1432
|
-
) -> models.
|
1628
|
+
) -> models.Workout:
|
1433
1629
|
"""Rate a class and coach. The class rating must be 0, 1, 2, or 3. 0 is the same as dismissing the prompt to
|
1434
1630
|
rate the class/coach. 1 - 3 is a range from bad to good.
|
1435
1631
|
|
1436
1632
|
Args:
|
1437
|
-
|
1633
|
+
workout (Workout): The workout to rate.
|
1438
1634
|
class_rating (int): The class rating. Must be 0, 1, 2, or 3.
|
1439
1635
|
coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
|
1440
1636
|
|
1441
1637
|
Returns:
|
1442
|
-
|
1638
|
+
Workout: The updated workout with the new ratings.
|
1443
1639
|
|
1444
1640
|
Raises:
|
1445
1641
|
AlreadyRatedError: If the performance summary is already rated.
|
1446
1642
|
ClassNotRatableError: If the performance summary is not rateable.
|
1447
|
-
ValueError: If the performance summary does not have an associated class.
|
1448
1643
|
"""
|
1449
1644
|
|
1450
|
-
if
|
1451
|
-
raise exc.
|
1645
|
+
if not workout.ratable or not workout.class_uuid:
|
1646
|
+
raise exc.ClassNotRatableError(f"Workout {workout.performance_summary_id} is not rateable.")
|
1452
1647
|
|
1453
|
-
if not
|
1454
|
-
raise exc.
|
1455
|
-
f"Performance summary {perf_summary.performance_summary_id} is not rateable."
|
1456
|
-
)
|
1648
|
+
if workout.class_rating is not None or workout.coach_rating is not None:
|
1649
|
+
raise exc.AlreadyRatedError(f"Workout {workout.performance_summary_id} already rated.")
|
1457
1650
|
|
1458
|
-
|
1459
|
-
raise ValueError(
|
1460
|
-
f"Performance summary {perf_summary.performance_summary_id} does not have an associated class."
|
1461
|
-
)
|
1651
|
+
self.rate_class(workout.class_uuid, workout.performance_summary_id, class_rating, coach_rating)
|
1462
1652
|
|
1463
|
-
return self.
|
1464
|
-
perf_summary.otf_class.class_uuid, perf_summary.performance_summary_id, class_rating, coach_rating
|
1465
|
-
)
|
1653
|
+
return self.get_workout_from_booking(workout.booking_id)
|
1466
1654
|
|
1467
1655
|
# the below do not return any data for me, so I can't test them
|
1468
1656
|
|