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/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
- 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
 
@@ -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
- if e.response.status_code == 403:
104
- 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
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(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
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(self, method: str, url: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
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(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
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(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
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
- return self._do(method, API_IO_BASE_URL, url, params, perf_api_headers)
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
- try:
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
- try:
638
- self.get_booking(booking_uuid)
639
- except Exception:
640
- raise exc.BookingNotFoundError(f"Booking {booking_uuid} does not exist.")
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
- params = {"confirmed": "true"}
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
- Any: The member's lifetime stats.
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.TimeStats:
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
- Any: The member's lifetime stats in studio.
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.TimeStats:
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
- Any: The member's lifetime stats out of studio.
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(studioUUId=studio_uuid, studioName="Studio Not Found", studioStatus="Unknown")
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: models.ChallengeCategory | Literal[0] = 0,
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 (ChallengeType): The challenge type 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 (ChallengeType): The challenge type 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: models.ChallengeCategory) -> models.FitnessBenchmark:
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 (ChallengeType): The challenge type 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
- @cached(cache=TTLCache(maxsize=1024, ttl=600))
1114
- def get_performance_summaries_dict(self, limit: int | None = None) -> dict[str, models.PerformanceSummary]:
1115
- """Get a dictionary of performance summaries for the authenticated user.
1116
-
1117
- Args:
1118
- limit (int | None): The maximum number of entries to return. Default is None.
1119
-
1120
- Returns:
1121
- dict[str, PerformanceSummary]: A dictionary of performance summaries, keyed by class history UUID.
1122
-
1123
- Developer Notes:
1124
- ---
1125
- In the app, this is referred to as 'getInStudioWorkoutHistory'.
1126
-
1127
- """
1128
-
1129
- items = self._get_performance_summaries_raw(limit=limit)["items"]
1130
-
1131
- distinct_studio_ids = set([rec["class"]["studio"]["id"] for rec in items])
1132
- perf_summary_ids = set([rec["id"] for rec in items])
1133
-
1134
- with ThreadPoolExecutor() as pool:
1135
- studio_futures = {s: pool.submit(self.get_studio_detail, s) for s in distinct_studio_ids}
1136
- perf_summary_futures = {s: pool.submit(self._get_performancy_summary_detail, s) for s in perf_summary_ids}
1137
-
1138
- studio_dict = {k: v.result() for k, v in studio_futures.items()}
1139
- # deepcopy these so that mutating them in PerformanceSummary doesn't affect the cache
1140
- perf_summary_dict = {k: deepcopy(v.result()) for k, v in perf_summary_futures.items()}
1141
-
1142
- for item in items:
1143
- item["class"]["studio"] = studio_dict[item["class"]["studio"]["id"]]
1144
- item["detail"] = perf_summary_dict[item["id"]]
1145
-
1146
- entries = [models.PerformanceSummary(**item) for item in items]
1147
- entries_dict = {entry.performance_summary_id: entry for entry in entries}
1148
-
1149
- return entries_dict
1150
-
1151
- def get_performance_summaries(self, limit: int | None = None) -> list[models.PerformanceSummary]:
1152
- """Get a list of all performance summaries for the authenticated user.
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 self._get_performance_summary_raw(performance_summary_id)
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 = 120) -> models.Telemetry:
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 120.
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 _rate_class(
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
- ) -> models.PerformanceSummary:
1375
- """Rate a class and coach. A simpler method is provided in `rate_class_from_performance_summary`.
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
- PerformanceSummary: The updated performance summary.
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
- if coach_rating not in COACH_RATING_MAP:
1405
- raise ValueError(f"Invalid coach rating {coach_rating}")
1474
+ """
1406
1475
 
1407
- body_class_rating = CLASS_RATING_MAP[class_rating]
1408
- body_coach_rating = COACH_RATING_MAP[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"Performance summary {performance_summary_id} is already rated.") from None
1483
+ raise exc.AlreadyRatedError(f"Workout {performance_summary_id} is already rated.") from None
1415
1484
  raise
1416
1485
 
1417
- # we have to clear the cache after rating a class, otherwise we will get back the same data
1418
- # showing it not rated. that would be incorrect, confusing, and would cause errors if the user
1419
- # then attempted to rate the class again.
1420
- # we could attempt to only refresh the one record but with these endpoints that's not simple
1421
- # NOTE: the individual perf summary endpoint does not have rating data, so it's cache is not cleared
1422
- self.get_performance_summaries_dict.cache_clear()
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
- return self.get_performance_summary(performance_summary_id)
1573
+ perf_summaries_dict = {perf_summary["id"]: perf_summary for perf_summary in perf_summaries}
1574
+ return perf_summaries_dict
1425
1575
 
1426
- def rate_class_from_performance_summary(
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
- perf_summary: models.PerformanceSummary,
1596
+ workout: models.Workout,
1429
1597
  class_rating: Literal[0, 1, 2, 3],
1430
1598
  coach_rating: Literal[0, 1, 2, 3],
1431
- ) -> models.PerformanceSummary:
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
- perf_summary (PerformanceSummary): The performance summary to rate.
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
- PerformanceSummary: The updated performance summary.
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 perf_summary.is_rated:
1450
- raise exc.AlreadyRatedError(f"Performance summary {perf_summary.performance_summary_id} is already rated.")
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 perf_summary.ratable:
1453
- raise exc.ClassNotRatableError(
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
- if not perf_summary.otf_class or not perf_summary.otf_class.class_uuid:
1458
- raise ValueError(
1459
- f"Performance summary {perf_summary.performance_summary_id} does not have an associated class."
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
- return self._rate_class(
1463
- perf_summary.otf_class.class_uuid, perf_summary.performance_summary_id, class_rating, coach_rating
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: