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/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
- JSON_HEADERS = {"Content-Type": "application/json", "Accept": "application/json"}
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=JSON_HEADERS, auth=self.user.httpx_auth, timeout=httpx.Timeout(20.0, connect=60.0)
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
- if e.response.status_code == 403:
105
- raise
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
- # insanely enough, at least one endpoint (get perf summary) returns None without error instead of 404
114
- raise exc.OtfRequestError("Empty response", None, response=response, request=request)
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(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
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(self, method: str, url: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
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(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
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(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
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
- return self._do(method, API_IO_BASE_URL, url, params, perf_api_headers)
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] | None) -> dict:
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
- try:
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
- try:
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
- params = {"confirmed": "true"}
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
- Any: The member's lifetime stats.
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.TimeStats:
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
- Any: The member's lifetime stats in studio.
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.TimeStats:
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
- Any: The member's lifetime stats out of studio.
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(studioUUId=studio_uuid, studioName="Studio Not Found", studioStatus="Unknown")
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: models.ChallengeCategory | Literal[0] = 0,
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 (ChallengeType): The challenge type 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 (ChallengeType): The challenge type 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: models.ChallengeCategory) -> models.FitnessBenchmark:
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 (ChallengeType): The challenge type 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
- @cached(cache=TTLCache(maxsize=1024, ttl=600))
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 self._get_performance_summary_raw(performance_summary_id)
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 = 120) -> models.Telemetry:
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 120.
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 _rate_class(
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
- ) -> models.PerformanceSummary:
1376
- """Rate a class and coach. A simpler method is provided in `rate_class_from_performance_summary`.
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
- PerformanceSummary: The updated performance summary.
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
- if coach_rating not in COACH_RATING_MAP:
1406
- raise ValueError(f"Invalid coach rating {coach_rating}")
1504
+ """
1407
1505
 
1408
- body_class_rating = CLASS_RATING_MAP[class_rating]
1409
- body_coach_rating = COACH_RATING_MAP[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"Performance summary {performance_summary_id} is already rated.") from None
1513
+ raise exc.AlreadyRatedError(f"Workout {performance_summary_id} is already rated.") from None
1416
1514
  raise
1417
1515
 
1418
- # we have to clear the cache after rating a class, otherwise we will get back the same data
1419
- # showing it not rated. that would be incorrect, confusing, and would cause errors if the user
1420
- # then attempted to rate the class again.
1421
- # we could attempt to only refresh the one record but with these endpoints that's not simple
1422
- # NOTE: the individual perf summary endpoint does not have rating data, so it's cache is not cleared
1423
- self.get_performance_summaries_dict.cache_clear()
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
- return self.get_performance_summary(performance_summary_id)
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
- def rate_class_from_performance_summary(
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
- perf_summary: models.PerformanceSummary,
1625
+ workout: models.Workout,
1430
1626
  class_rating: Literal[0, 1, 2, 3],
1431
1627
  coach_rating: Literal[0, 1, 2, 3],
1432
- ) -> models.PerformanceSummary:
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
- perf_summary (PerformanceSummary): The performance summary to rate.
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
- PerformanceSummary: The updated performance summary.
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 perf_summary.is_rated:
1451
- raise exc.AlreadyRatedError(f"Performance summary {perf_summary.performance_summary_id} is already rated.")
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 perf_summary.ratable:
1454
- raise exc.ClassNotRatableError(
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
- if not perf_summary.otf_class or not perf_summary.otf_class.class_uuid:
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._rate_class(
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