otf-api 0.10.2__py3-none-any.whl → 0.11.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
otf_api/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
 
@@ -101,10 +106,18 @@ class Otf:
101
106
  if e.response.status_code == 404:
102
107
  raise exc.ResourceNotFoundError("Resource not found")
103
108
 
104
- 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
@@ -130,27 +143,66 @@ class Otf:
130
143
 
131
144
  return resp
132
145
 
133
- 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:
134
149
  """Perform an API request to the classes API."""
135
- return self._do(method, API_IO_BASE_URL, url, params)
150
+ return self._do(method, API_IO_BASE_URL, url, params, headers=headers)
136
151
 
137
- def _default_request(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:
138
160
  """Perform an API request to the default API."""
139
- return self._do(method, API_BASE_URL, url, params, **kwargs)
161
+ return self._do(method, API_BASE_URL, url, params, headers=headers, **kwargs)
140
162
 
141
- def _telemetry_request(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:
142
166
  """Perform an API request to the Telemetry API."""
143
- return self._do(method, API_TELEMETRY_BASE_URL, url, params)
167
+ return self._do(method, API_TELEMETRY_BASE_URL, url, params, headers=headers)
144
168
 
145
- def _performance_summary_request(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:
146
172
  """Perform an API request to the performance summary API."""
147
173
  perf_api_headers = {"koji-member-id": self.member_uuid, "koji-member-email": self.user.email_address}
148
- 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)
149
177
 
150
178
  def _get_classes_raw(self, studio_uuids: list[str] | None) -> dict:
151
179
  """Retrieve raw class data."""
152
180
  return self._classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})
153
181
 
182
+ def _cancel_booking_raw(self, booking_uuid: str) -> dict:
183
+ """Cancel a booking by booking_uuid."""
184
+ return self._default_request(
185
+ "DELETE", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}", params={"confirmed": "true"}
186
+ )
187
+
188
+ def _book_class_raw(self, class_uuid, body):
189
+ try:
190
+ resp = self._default_request("PUT", f"/member/members/{self.member_uuid}/bookings", json=body)
191
+ except exc.OtfRequestError as e:
192
+ resp_obj = e.response.json()
193
+
194
+ if resp_obj["code"] == "ERROR":
195
+ err_code = resp_obj["data"]["errorCode"]
196
+ if err_code == "603":
197
+ raise exc.AlreadyBookedError(f"Class {class_uuid} is already booked.")
198
+ if err_code == "602":
199
+ raise exc.OutsideSchedulingWindowError(f"Class {class_uuid} is outside the scheduling window.")
200
+
201
+ raise
202
+ except Exception as e:
203
+ raise exc.OtfException(f"Error booking class {class_uuid}: {e}")
204
+ return resp
205
+
154
206
  def _get_booking_raw(self, booking_uuid: str) -> dict:
155
207
  """Retrieve raw booking data."""
156
208
  return self._default_request("GET", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}")
@@ -167,6 +219,37 @@ class Otf:
167
219
  params={"startDate": start_date, "endDate": end_date, "statuses": status},
168
220
  )
169
221
 
222
+ def _get_bookings_new_raw(
223
+ self,
224
+ ends_before: datetime,
225
+ starts_after: datetime,
226
+ include_canceled: bool = True,
227
+ expand: bool = False,
228
+ ) -> dict:
229
+ """Retrieve raw bookings data."""
230
+
231
+ params: dict[str, bool | str] = {
232
+ "ends_before": pendulum.instance(ends_before).strftime("%Y-%m-%dT%H:%M:%SZ"),
233
+ "starts_after": pendulum.instance(starts_after).strftime("%Y-%m-%dT%H:%M:%SZ"),
234
+ }
235
+
236
+ params["include_canceled"] = include_canceled if include_canceled is not None else True
237
+ params["expand"] = expand if expand is not None else False
238
+
239
+ return self._classes_request("GET", "/v1/bookings/me", params=params)
240
+
241
+ def _cancel_booking_new_raw(self, booking_id: str) -> dict:
242
+ """Cancel a booking by booking_id."""
243
+ return self._classes_request("DELETE", f"/v1/bookings/me/{booking_id}", headers={"SIGV4AUTH_REQUIRED": "true"})
244
+
245
+ def _get_booking_new_raw(self, booking_id: str) -> dict:
246
+ """Retrieve raw booking data."""
247
+ return self._classes_request("GET", f"/v1/bookings/me/{booking_id}", headers={"SIGV4AUTH_REQUIRED": "true"})
248
+
249
+ def _book_class_new_raw(self, class_id: str) -> dict:
250
+ """Book a class by class_id."""
251
+ return self._classes_request("POST", f"/v1/bookings/me/{class_id}", headers={"SIGV4AUTH_REQUIRED": "true"})
252
+
170
253
  def _get_member_detail_raw(self) -> dict:
171
254
  """Retrieve raw member details."""
172
255
  return self._default_request(
@@ -190,7 +273,7 @@ class Otf:
190
273
  """Retrieve raw heart rate history."""
191
274
  return self._telemetry_request("GET", "/v1/physVars/maxHr/history", params={"memberUuid": self.member_uuid})
192
275
 
193
- def _get_telemetry_raw(self, performance_summary_id: str, max_data_points: int) -> dict:
276
+ def _get_telemetry_raw(self, performance_summary_id: str, max_data_points: int = 150) -> dict:
194
277
  """Retrieve raw telemetry data."""
195
278
  return self._telemetry_request(
196
279
  "GET",
@@ -345,10 +428,61 @@ class Otf:
345
428
  json={"firstName": first_name, "lastName": last_name},
346
429
  )
347
430
 
431
+ def _get_all_bookings_new(self) -> list[models.BookingV2]:
432
+ """Get bookings from the new endpoint with no date filters."""
433
+ start_date = pendulum.datetime(1970, 1, 1)
434
+ end_date = pendulum.today().start_of("day").add(days=45)
435
+ return self.get_bookings_new(start_date, end_date, exclude_canceled=False)
436
+
437
+ def get_bookings_new(
438
+ self,
439
+ start_dtme: datetime | str | None = None,
440
+ end_dtme: datetime | str | None = None,
441
+ exclude_canceled: bool = True,
442
+ ) -> list[models.BookingV2]:
443
+ """Get the bookings for the user. If no dates are provided, it will return all bookings
444
+ between today and 45 days from now.
445
+
446
+ Warning:
447
+ ---
448
+ If you do not exclude cancelled bookings, you may receive multiple bookings for the same workout, such
449
+ as when a class changes from a 2G to a 3G. Apparently the system actually creates a new booking for the
450
+ new class, which is normally transparent to the user.
451
+
452
+ Args:
453
+ start_dtme (datetime | str | None): The start date for the bookings. Default is None.
454
+ end_dtme (datetime | str | None): The end date for the bookings. Default is None.
455
+ exclude_canceled (bool): Whether to exclude canceled bookings. Default is True.
456
+ Returns:
457
+ list[BookingV2]: The bookings for the user.
458
+ """
459
+
460
+ expand = True # this doesn't seem to have an effect? so leaving it out of the argument list
461
+
462
+ # leaving the parameter as `exclude_canceled` for backwards compatibility
463
+ include_canceled = not exclude_canceled
464
+
465
+ end_dtme = ensure_datetime(end_dtme)
466
+ start_dtme = ensure_datetime(start_dtme)
467
+
468
+ end_dtme = end_dtme or pendulum.today().start_of("day").add(days=45)
469
+ start_dtme = start_dtme or pendulum.datetime(1970, 1, 1).start_of("day")
470
+
471
+ bookings_resp = self._get_bookings_new_raw(
472
+ ends_before=end_dtme, starts_after=start_dtme, include_canceled=include_canceled, expand=expand
473
+ )
474
+
475
+ return [models.BookingV2(**b) for b in bookings_resp["items"]]
476
+
477
+ # def get_booking_new(self, booking_id: str) -> models.BookingV2:
478
+ # """Get a booking by ID."""
479
+ # booking_resp = self._get_booking_new_raw(booking_id)
480
+ # return models.BookingV2(**booking_resp)
481
+
348
482
  def get_classes(
349
483
  self,
350
- start_date: date | None = None,
351
- end_date: date | None = None,
484
+ start_date: date | str | None = None,
485
+ end_date: date | str | None = None,
352
486
  studio_uuids: list[str] | None = None,
353
487
  include_home_studio: bool | None = None,
354
488
  filters: list[filters.ClassFilter] | filters.ClassFilter | None = None,
@@ -371,6 +505,9 @@ class Otf:
371
505
  list[OtfClass]: The classes for the user.
372
506
  """
373
507
 
508
+ start_date = ensure_date(start_date)
509
+ end_date = ensure_date(end_date)
510
+
374
511
  classes = self._get_classes(studio_uuids, include_home_studio)
375
512
 
376
513
  # remove those that are cancelled *by the studio*
@@ -531,6 +668,29 @@ class Otf:
531
668
 
532
669
  raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
533
670
 
671
+ def get_booking_from_class_new(self, otf_class: str | models.OtfClass | models.BookingV2Class) -> models.BookingV2:
672
+ """Get a specific booking by class_uuid or OtfClass object.
673
+
674
+ Args:
675
+ otf_class (str | OtfClass | BookingV2Class): The class UUID or the OtfClass object to get the booking for.
676
+
677
+ Returns:
678
+ BookingV2: The booking.
679
+
680
+ Raises:
681
+ BookingNotFoundError: If the booking does not exist.
682
+ ValueError: If class_uuid is None or empty string.
683
+ """
684
+
685
+ class_uuid = get_class_uuid(otf_class)
686
+
687
+ all_bookings = self._get_all_bookings_new()
688
+
689
+ if booking := next((b for b in all_bookings if b.class_uuid == class_uuid), None):
690
+ return booking
691
+
692
+ raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
693
+
534
694
  def book_class(self, otf_class: str | models.OtfClass) -> models.Booking:
535
695
  """Book a class by providing either the class_uuid or the OtfClass object.
536
696
 
@@ -556,21 +716,7 @@ class Otf:
556
716
 
557
717
  body = {"classUUId": class_uuid, "confirmed": False, "waitlist": False}
558
718
 
559
- 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}")
719
+ resp = self._book_class_raw(class_uuid, body)
574
720
 
575
721
  # get the booking uuid - we will only use this to return a Booking object using `get_booking`
576
722
  # this is an attempt to improve on OTF's terrible data model
@@ -633,22 +779,44 @@ class Otf:
633
779
  ValueError: If booking_uuid is None or empty string
634
780
  BookingNotFoundError: If the booking does not exist.
635
781
  """
782
+ # if isinstance(booking, models.BookingV2):
783
+ # LOGGER.warning("BookingV2 object provided, using the new cancel booking endpoint (`cancel_booking_new`)")
784
+ # self.cancel_booking_new(booking)
785
+
636
786
  booking_uuid = get_booking_uuid(booking)
637
787
 
638
- try:
639
- self.get_booking(booking_uuid)
640
- except Exception:
641
- 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.")
642
793
 
643
- params = {"confirmed": "true"}
644
- resp = self._default_request(
645
- "DELETE", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}", params=params
646
- )
794
+ resp = self._cancel_booking_raw(booking_uuid)
647
795
  if resp["code"] == "NOT_AUTHORIZED" and resp["message"].startswith("This class booking has"):
648
796
  raise exc.BookingAlreadyCancelledError(
649
797
  f"Booking {booking_uuid} is already cancelled.", booking_uuid=booking_uuid
650
798
  )
651
799
 
800
+ # def cancel_booking_new(self, booking: str | models.BookingV2) -> None:
801
+ # """Cancel a booking by providing either the booking_id or the BookingV2 object.
802
+ # Args:
803
+ # booking (str | BookingV2): The booking ID or the BookingV2 object to cancel.
804
+ # Raises:
805
+ # ValueError: If booking_id is None or empty string
806
+ # BookingNotFoundError: If the booking does not exist.
807
+ # """
808
+
809
+ # if isinstance(booking, models.Booking):
810
+ # LOGGER.warning("Booking object provided, using the old cancel booking endpoint (`cancel_booking`)")
811
+ # self.cancel_booking(booking)
812
+
813
+ # booking_id = get_booking_id(booking)
814
+
815
+ # if booking == booking_id: # ensure this booking exists by calling the booking endpoint
816
+ # self.get_booking_new(booking_id)
817
+
818
+ # self._cancel_booking_new_raw(booking_id)
819
+
652
820
  def get_bookings(
653
821
  self,
654
822
  start_date: date | str | None = None,
@@ -798,7 +966,7 @@ class Otf:
798
966
  It is being provided anyway, in case this changes in the future.
799
967
 
800
968
  Returns:
801
- Any: The member's lifetime stats.
969
+ StatsResponse: The member's lifetime stats.
802
970
  """
803
971
 
804
972
  data = self._get_member_lifetime_stats_raw(select_time.value)
@@ -809,14 +977,14 @@ class Otf:
809
977
 
810
978
  def get_member_lifetime_stats_in_studio(
811
979
  self, select_time: models.StatsTime = models.StatsTime.AllTime
812
- ) -> models.TimeStats:
980
+ ) -> models.InStudioStatsData:
813
981
  """Get the member's lifetime stats in studio.
814
982
 
815
983
  Args:
816
984
  select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
817
985
 
818
986
  Returns:
819
- Any: The member's lifetime stats in studio.
987
+ InStudioStatsData: The member's lifetime stats in studio.
820
988
  """
821
989
 
822
990
  data = self._get_member_lifetime_stats(select_time)
@@ -825,14 +993,14 @@ class Otf:
825
993
 
826
994
  def get_member_lifetime_stats_out_of_studio(
827
995
  self, select_time: models.StatsTime = models.StatsTime.AllTime
828
- ) -> models.TimeStats:
996
+ ) -> models.OutStudioStatsData:
829
997
  """Get the member's lifetime stats out of studio.
830
998
 
831
999
  Args:
832
1000
  select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
833
1001
 
834
1002
  Returns:
835
- Any: The member's lifetime stats out of studio.
1003
+ OutStudioStatsData: The member's lifetime stats out of studio.
836
1004
  """
837
1005
 
838
1006
  data = self._get_member_lifetime_stats(select_time)
@@ -935,7 +1103,7 @@ class Otf:
935
1103
  try:
936
1104
  res = self._get_studio_detail_raw(studio_uuid)
937
1105
  except exc.ResourceNotFoundError:
938
- return models.StudioDetail(studioUUId=studio_uuid, studioName="Studio Not Found", studioStatus="Unknown")
1106
+ return models.StudioDetail.create_empty_model(studio_uuid)
939
1107
 
940
1108
  return models.StudioDetail(**res["data"])
941
1109
 
@@ -1039,14 +1207,14 @@ class Otf:
1039
1207
 
1040
1208
  def get_benchmarks(
1041
1209
  self,
1042
- challenge_category_id: models.ChallengeCategory | Literal[0] = 0,
1210
+ challenge_category_id: int = 0,
1043
1211
  equipment_id: models.EquipmentType | Literal[0] = 0,
1044
1212
  challenge_subcategory_id: int = 0,
1045
1213
  ) -> list[models.FitnessBenchmark]:
1046
1214
  """Get the member's challenge tracker participation details.
1047
1215
 
1048
1216
  Args:
1049
- challenge_category_id (ChallengeType): The challenge type ID.
1217
+ challenge_category_id (int): The challenge type ID.
1050
1218
  equipment_id (EquipmentType | Literal[0]): The equipment ID, default is 0 - this doesn't seem\
1051
1219
  to be have any impact on the results.
1052
1220
  challenge_subcategory_id (int): The challenge sub type ID. Default is 0 - this doesn't seem\
@@ -1073,13 +1241,11 @@ class Otf:
1073
1241
 
1074
1242
  return benchmarks
1075
1243
 
1076
- def get_benchmarks_by_challenge_category(
1077
- self, challenge_category_id: models.ChallengeCategory
1078
- ) -> list[models.FitnessBenchmark]:
1244
+ def get_benchmarks_by_challenge_category(self, challenge_category_id: int) -> list[models.FitnessBenchmark]:
1079
1245
  """Get the member's challenge tracker participation details by challenge.
1080
1246
 
1081
1247
  Args:
1082
- challenge_category_id (ChallengeType): The challenge type ID.
1248
+ challenge_category_id (int): The challenge type ID.
1083
1249
 
1084
1250
  Returns:
1085
1251
  list[FitnessBenchmark]: The member's challenge tracker details.
@@ -1090,12 +1256,12 @@ class Otf:
1090
1256
 
1091
1257
  return benchmarks
1092
1258
 
1093
- def get_challenge_tracker_detail(self, challenge_category_id: models.ChallengeCategory) -> models.FitnessBenchmark:
1259
+ def get_challenge_tracker_detail(self, challenge_category_id: int) -> models.FitnessBenchmark:
1094
1260
  """Get details about a challenge. This endpoint does not (usually) return member participation, but rather
1095
1261
  details about the challenge itself.
1096
1262
 
1097
1263
  Args:
1098
- challenge_category_id (ChallengeType): The challenge type ID.
1264
+ challenge_category_id (int): The challenge type ID.
1099
1265
 
1100
1266
  Returns:
1101
1267
  FitnessBenchmark: Details about the challenge.
@@ -1111,94 +1277,7 @@ class Otf:
1111
1277
 
1112
1278
  return models.FitnessBenchmark(**data["Dto"][0])
1113
1279
 
1114
- @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]:
1280
+ def get_performance_summary(self, performance_summary_id: str) -> models.PerformanceSummary:
1202
1281
  """Get the details for a performance summary. Generally should not be called directly. This
1203
1282
 
1204
1283
  Args:
@@ -1206,13 +1285,14 @@ class Otf:
1206
1285
 
1207
1286
  Returns:
1208
1287
  dict[str, Any]: The performance summary details.
1209
-
1210
- Developer Notes:
1211
- ---
1212
- This is mostly here to cache the results of the raw method.
1213
1288
  """
1214
1289
 
1215
- return 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)
1216
1296
 
1217
1297
  def get_hr_history(self) -> list[models.TelemetryHistoryItem]:
1218
1298
  """Get the heartrate history for the user.
@@ -1227,7 +1307,7 @@ class Otf:
1227
1307
  resp = self._get_hr_history_raw()
1228
1308
  return [models.TelemetryHistoryItem(**item) for item in resp["history"]]
1229
1309
 
1230
- def get_telemetry(self, performance_summary_id: str, max_data_points: int = 120) -> models.Telemetry:
1310
+ def get_telemetry(self, performance_summary_id: str, max_data_points: int = 150) -> models.Telemetry:
1231
1311
  """Get the telemetry for a performance summary.
1232
1312
 
1233
1313
  This returns an object that contains the max heartrate, start/end bpm for each zone,
@@ -1235,7 +1315,7 @@ class Otf:
1235
1315
 
1236
1316
  Args:
1237
1317
  performance_summary_id (str): The performance summary id.
1238
- max_data_points (int): The max data points to use for the telemetry. Default is 120.
1318
+ max_data_points (int): The max data points to use for the telemetry. Default is 150, to match the app.
1239
1319
 
1240
1320
  Returns:
1241
1321
  TelemetryItem: The telemetry for the class history.
@@ -1295,7 +1375,7 @@ class Otf:
1295
1375
  transactional_enabled if transactional_enabled is not None else current_settings.is_transactional_sms_opt_in
1296
1376
  )
1297
1377
 
1298
- self._update_sms_notification_settings_raw(promotional_enabled, transactional_enabled)
1378
+ self._update_sms_notification_settings_raw(promotional_enabled, transactional_enabled) # type: ignore
1299
1379
 
1300
1380
  # the response returns nothing useful, so we just query the settings again
1301
1381
  new_settings = self.get_sms_notification_settings()
@@ -1334,7 +1414,7 @@ class Otf:
1334
1414
  else current_settings.is_transactional_email_opt_in
1335
1415
  )
1336
1416
 
1337
- self._update_email_notification_settings_raw(promotional_enabled, transactional_enabled)
1417
+ self._update_email_notification_settings_raw(promotional_enabled, transactional_enabled) # type: ignore
1338
1418
 
1339
1419
  # the response returns nothing useful, so we just query the settings again
1340
1420
  new_settings = self.get_email_notification_settings()
@@ -1362,19 +1442,21 @@ class Otf:
1362
1442
  LOGGER.warning("No changes to names, nothing to update.")
1363
1443
  return self.member
1364
1444
 
1445
+ assert first_name is not None, "First name is required"
1446
+ assert last_name is not None, "Last name is required"
1447
+
1365
1448
  res = self._update_member_name_raw(first_name, last_name)
1366
1449
 
1367
1450
  return models.MemberDetail(**res["data"])
1368
1451
 
1369
- def _rate_class(
1452
+ def rate_class(
1370
1453
  self,
1371
1454
  class_uuid: str,
1372
1455
  performance_summary_id: str,
1373
1456
  class_rating: Literal[0, 1, 2, 3],
1374
1457
  coach_rating: Literal[0, 1, 2, 3],
1375
- ) -> models.PerformanceSummary:
1376
- """Rate a class and coach. A simpler method is provided in `rate_class_from_performance_summary`.
1377
-
1458
+ ):
1459
+ """Rate a class and coach. A simpler method is provided in `rate_class_from_workout`.
1378
1460
 
1379
1461
  The class rating must be between 0 and 4.
1380
1462
  0 is the same as dismissing the prompt to rate the class/coach in the app.
@@ -1387,83 +1469,172 @@ class Otf:
1387
1469
  coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
1388
1470
 
1389
1471
  Returns:
1390
- 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}")
1472
+ None
1404
1473
 
1405
- if coach_rating not in COACH_RATING_MAP:
1406
- raise ValueError(f"Invalid coach rating {coach_rating}")
1474
+ """
1407
1475
 
1408
- body_class_rating = CLASS_RATING_MAP[class_rating]
1409
- 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)
1410
1478
 
1411
1479
  try:
1412
1480
  self._rate_class_raw(class_uuid, performance_summary_id, body_class_rating, body_coach_rating)
1413
1481
  except exc.OtfRequestError as e:
1414
1482
  if e.response.status_code == 403:
1415
- raise exc.AlreadyRatedError(f"Performance summary {performance_summary_id} is already rated.") from None
1483
+ raise exc.AlreadyRatedError(f"Workout {performance_summary_id} is already rated.") from None
1416
1484
  raise
1417
1485
 
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()
1486
+ # def get_workout_from_booking(self, booking: str | models.BookingV2) -> models.Workout:
1487
+ # """Get a workout for a specific booking.
1488
+
1489
+ # Args:
1490
+ # booking_id (str | Booking): The booking ID or Booking object to get the workout for.
1491
+
1492
+ # Returns:
1493
+ # Workout: The member's workout.
1494
+
1495
+ # Raises:
1496
+ # BookingNotFoundError: If the booking does not exist.
1497
+ # ResourceNotFoundError: If the workout does not exist.
1498
+ # """
1499
+ # booking_id = booking if isinstance(booking, str) else booking.booking_id
1500
+
1501
+ # booking = self.get_booking_new(booking_id)
1502
+ # if not booking:
1503
+ # raise exc.BookingNotFoundError(f"Booking {booking_id} not found.")
1504
+
1505
+ # if not booking.workout or not booking.workout.performance_summary_id:
1506
+ # raise exc.ResourceNotFoundError(f"Workout for booking {booking_id} not found.")
1507
+
1508
+ # perf_summary = self._get_performance_summary_raw(booking.workout.performance_summary_id)
1509
+ # telemetry = self.get_telemetry(booking.workout.performance_summary_id)
1510
+ # workout = models.Workout(**perf_summary, v2_booking=booking, telemetry=telemetry)
1511
+ # return workout
1512
+
1513
+ def get_workouts(
1514
+ self, start_date: date | str | None = None, end_date: date | str | None = None
1515
+ ) -> list[models.Workout]:
1516
+ """Get the member's workouts, using the new bookings endpoint and the performance summary endpoint.
1517
+
1518
+ Args:
1519
+ start_date (date | None): The start date for the workouts. If None, defaults to 30 days ago.
1520
+ end_date (date | None): The end date for the workouts. If None, defaults to today.
1521
+
1522
+ Returns:
1523
+ list[Workout]: The member's workouts.
1524
+ """
1525
+ start_date = ensure_date(start_date) or pendulum.today().subtract(days=30).date()
1526
+ end_date = ensure_date(end_date) or datetime.today().date()
1527
+
1528
+ start_dtme = pendulum.datetime(start_date.year, start_date.month, start_date.day, 0, 0, 0)
1529
+ end_dtme = pendulum.datetime(end_date.year, end_date.month, end_date.day, 23, 59, 59)
1530
+
1531
+ bookings = self.get_bookings_new(start_dtme, end_dtme, exclude_canceled=False)
1532
+ bookings_dict = {b.workout.id: b for b in bookings if b.workout}
1533
+
1534
+ perf_summaries_dict = self._get_perf_summaries_threaded(list(bookings_dict.keys()))
1535
+ telemetry_dict = self._get_telemetry_threaded(list(perf_summaries_dict.keys()))
1536
+ perf_summary_to_class_uuid_map = self._get_perf_summary_to_class_uuid_mapping()
1537
+
1538
+ workouts: list[models.Workout] = []
1539
+ for perf_id, perf_summary in perf_summaries_dict.items():
1540
+ workout = models.Workout(
1541
+ **perf_summary,
1542
+ v2_booking=bookings_dict[perf_id],
1543
+ telemetry=telemetry_dict.get(perf_id),
1544
+ class_uuid=perf_summary_to_class_uuid_map.get(perf_id),
1545
+ )
1546
+ workouts.append(workout)
1547
+
1548
+ return workouts
1549
+
1550
+ def _get_perf_summary_to_class_uuid_mapping(self) -> dict[str, str | None]:
1551
+ """Get a mapping of performance summary IDs to class UUIDs. These will be used
1552
+ when rating a class.
1553
+
1554
+ Returns:
1555
+ dict[str, str | None]: A dictionary mapping performance summary IDs to class UUIDs.
1556
+ """
1557
+ perf_summaries = self._get_performance_summaries_raw()["items"]
1558
+ return {item["id"]: item["class"].get("ot_base_class_uuid") for item in perf_summaries}
1559
+
1560
+ def _get_perf_summaries_threaded(self, performance_summary_ids: list[str]) -> dict[str, dict[str, Any]]:
1561
+ """Get performance summaries in a ThreadPoolExecutor, to speed up the process.
1562
+
1563
+ Args:
1564
+ performance_summary_ids (list[str]): The performance summary IDs to get.
1565
+
1566
+ Returns:
1567
+ dict[str, dict[str, Any]]: A dictionary of performance summaries, keyed by performance summary ID.
1568
+ """
1569
+
1570
+ with ThreadPoolExecutor(max_workers=10) as pool:
1571
+ perf_summaries = pool.map(self._get_performance_summary_raw, performance_summary_ids)
1424
1572
 
1425
- 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
1426
1575
 
1427
- 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(
1428
1595
  self,
1429
- perf_summary: models.PerformanceSummary,
1596
+ workout: models.Workout,
1430
1597
  class_rating: Literal[0, 1, 2, 3],
1431
1598
  coach_rating: Literal[0, 1, 2, 3],
1432
- ) -> models.PerformanceSummary:
1599
+ ) -> models.Workout:
1433
1600
  """Rate a class and coach. The class rating must be 0, 1, 2, or 3. 0 is the same as dismissing the prompt to
1434
1601
  rate the class/coach. 1 - 3 is a range from bad to good.
1435
1602
 
1436
1603
  Args:
1437
- perf_summary (PerformanceSummary): The performance summary to rate.
1604
+ workout (Workout): The workout to rate.
1438
1605
  class_rating (int): The class rating. Must be 0, 1, 2, or 3.
1439
1606
  coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
1440
1607
 
1441
1608
  Returns:
1442
- PerformanceSummary: The updated performance summary.
1609
+ Workout: The updated workout with the new ratings.
1443
1610
 
1444
1611
  Raises:
1445
1612
  AlreadyRatedError: If the performance summary is already rated.
1446
1613
  ClassNotRatableError: If the performance summary is not rateable.
1447
- ValueError: If the performance summary does not have an associated class.
1448
1614
  """
1449
1615
 
1450
- if perf_summary.is_rated:
1451
- 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.")
1452
1618
 
1453
- if not perf_summary.ratable:
1454
- raise exc.ClassNotRatableError(
1455
- f"Performance summary {perf_summary.performance_summary_id} is not rateable."
1456
- )
1619
+ if workout.class_rating is not None or workout.coach_rating is not None:
1620
+ raise exc.AlreadyRatedError(f"Workout {workout.performance_summary_id} already rated.")
1457
1621
 
1458
- 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
- )
1622
+ self.rate_class(workout.class_uuid, workout.performance_summary_id, class_rating, coach_rating)
1623
+
1624
+ # TODO: use this once we get it working, getting workout list is substitute for now
1625
+ # return self.get_workout_from_booking(workout.booking_id)
1462
1626
 
1463
- return self._rate_class(
1464
- 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
1465
1632
  )
1466
1633
 
1634
+ assert selected_workout is not None, "Workout not found in the list of workouts"
1635
+
1636
+ return selected_workout
1637
+
1467
1638
  # the below do not return any data for me, so I can't test them
1468
1639
 
1469
1640
  def _get_member_services(self, active_only: bool = True) -> Any: