otf-api 0.9.4__py3-none-any.whl → 0.10.0__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,6 +1,9 @@
1
1
  import atexit
2
2
  import contextlib
3
3
  import functools
4
+ import warnings
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ from copy import deepcopy
4
7
  from datetime import date, datetime, timedelta
5
8
  from json import JSONDecodeError
6
9
  from logging import getLogger
@@ -8,11 +11,14 @@ from typing import Any, Literal
8
11
 
9
12
  import attrs
10
13
  import httpx
14
+ from cachetools import TTLCache, cached
15
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
11
16
  from yarl import URL
12
17
 
13
18
  from otf_api import exceptions as exc
14
19
  from otf_api import filters, models
15
20
  from otf_api.auth import OtfUser
21
+ from otf_api.models.enums import HISTORICAL_BOOKING_STATUSES
16
22
  from otf_api.utils import ensure_date, ensure_list, get_booking_uuid, get_class_uuid
17
23
 
18
24
  API_BASE_URL = "api.orangetheory.co"
@@ -58,6 +64,11 @@ class Otf:
58
64
  # Combine immutable attributes into a single hash value
59
65
  return hash(self.member_uuid)
60
66
 
67
+ @retry(
68
+ retry=retry_if_exception_type(exc.OtfRequestError),
69
+ stop=stop_after_attempt(3),
70
+ wait=wait_exponential(multiplier=1, min=4, max=10),
71
+ )
61
72
  def _do(
62
73
  self,
63
74
  method: str,
@@ -87,13 +98,20 @@ class Otf:
87
98
  LOGGER.exception(f"Response: {response.text}")
88
99
  raise
89
100
  except httpx.HTTPStatusError as e:
101
+ if e.response.status_code == 404:
102
+ raise exc.ResourceNotFoundError("Resource not found")
103
+
104
+ if e.response.status_code == 403:
105
+ raise
106
+
90
107
  raise exc.OtfRequestError("Error making request", e, response=response, request=request)
91
108
  except Exception as e:
92
109
  LOGGER.exception(f"Error making request: {e}")
93
110
  raise
94
111
 
95
112
  if not response.text:
96
- return None
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)
97
115
 
98
116
  try:
99
117
  resp = response.json()
@@ -129,6 +147,201 @@ class Otf:
129
147
  perf_api_headers = {"koji-member-id": self.member_uuid, "koji-member-email": self.user.email_address}
130
148
  return self._do(method, API_IO_BASE_URL, url, params, perf_api_headers)
131
149
 
150
+ def _get_classes_raw(self, studio_uuids: list[str] | None) -> dict:
151
+ """Retrieve raw class data."""
152
+ return self._classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})
153
+
154
+ def _get_booking_raw(self, booking_uuid: str) -> dict:
155
+ """Retrieve raw booking data."""
156
+ return self._default_request("GET", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}")
157
+
158
+ def _get_bookings_raw(self, start_date: str | None, end_date: str | None, status: str | list[str] | None) -> dict:
159
+ """Retrieve raw bookings data."""
160
+
161
+ if isinstance(status, list):
162
+ status = ",".join(status)
163
+
164
+ return self._default_request(
165
+ "GET",
166
+ f"/member/members/{self.member_uuid}/bookings",
167
+ params={"startDate": start_date, "endDate": end_date, "statuses": status},
168
+ )
169
+
170
+ def _get_member_detail_raw(self) -> dict:
171
+ """Retrieve raw member details."""
172
+ return self._default_request(
173
+ "GET", f"/member/members/{self.member_uuid}", params={"include": "memberAddresses,memberClassSummary"}
174
+ )
175
+
176
+ def _get_member_membership_raw(self) -> dict:
177
+ """Retrieve raw member membership details."""
178
+ return self._default_request("GET", f"/member/members/{self.member_uuid}/memberships")
179
+
180
+ def _get_performance_summaries_raw(self) -> dict:
181
+ """Retrieve raw performance summaries data."""
182
+ return self._performance_summary_request("GET", "/v1/performance-summaries")
183
+
184
+ def _get_performance_summary_raw(self, performance_summary_id: str) -> dict:
185
+ """Retrieve raw performance summary data."""
186
+ return self._performance_summary_request("GET", f"/v1/performance-summaries/{performance_summary_id}")
187
+
188
+ def _get_hr_history_raw(self) -> dict:
189
+ """Retrieve raw heart rate history."""
190
+ return self._telemetry_request("GET", "/v1/physVars/maxHr/history", params={"memberUuid": self.member_uuid})
191
+
192
+ def _get_telemetry_raw(self, performance_summary_id: str, max_data_points: int) -> dict:
193
+ """Retrieve raw telemetry data."""
194
+ return self._telemetry_request(
195
+ "GET",
196
+ "/v1/performance/summary",
197
+ params={"classHistoryUuid": performance_summary_id, "maxDataPoints": max_data_points},
198
+ )
199
+
200
+ def _get_studio_detail_raw(self, studio_uuid: str) -> dict:
201
+ """Retrieve raw studio details."""
202
+ return self._default_request("GET", f"/mobile/v1/studios/{studio_uuid}")
203
+
204
+ def _get_studios_by_geo_raw(
205
+ self, latitude: float | None, longitude: float | None, distance: int, page_index: int, page_size: int
206
+ ) -> dict:
207
+ """Retrieve raw studios by geo data."""
208
+ return self._default_request(
209
+ "GET",
210
+ "/mobile/v1/studios",
211
+ params={
212
+ "latitude": latitude,
213
+ "longitude": longitude,
214
+ "distance": distance,
215
+ "pageIndex": page_index,
216
+ "pageSize": page_size,
217
+ },
218
+ )
219
+
220
+ def _get_body_composition_list_raw(self) -> dict:
221
+ """Retrieve raw body composition list."""
222
+ return self._default_request("GET", f"/member/members/{self.user.cognito_id}/body-composition")
223
+
224
+ def _get_challenge_tracker_raw(self) -> dict:
225
+ """Retrieve raw challenge tracker data."""
226
+ return self._default_request("GET", f"/challenges/v3.1/member/{self.member_uuid}")
227
+
228
+ def _get_benchmarks_raw(self, challenge_category_id: int, equipment_id: int, challenge_subcategory_id: int) -> dict:
229
+ """Retrieve raw fitness benchmark data."""
230
+ return self._default_request(
231
+ "GET",
232
+ f"/challenges/v3/member/{self.member_uuid}/benchmarks",
233
+ params={
234
+ "equipmentId": equipment_id,
235
+ "challengeTypeId": challenge_category_id,
236
+ "challengeSubTypeId": challenge_subcategory_id,
237
+ },
238
+ )
239
+
240
+ def _get_sms_notification_settings_raw(self) -> dict:
241
+ """Retrieve raw SMS notification settings."""
242
+ return self._default_request("GET", url="/sms/v1/preferences", params={"phoneNumber": self.member.phone_number})
243
+
244
+ def _get_email_notification_settings_raw(self) -> dict:
245
+ """Retrieve raw email notification settings."""
246
+ return self._default_request("GET", url="/otfmailing/v2/preferences", params={"email": self.member.email})
247
+
248
+ def _get_member_lifetime_stats_raw(self, select_time: str) -> dict:
249
+ """Retrieve raw lifetime stats data."""
250
+ return self._default_request("GET", f"/performance/v2/{self.member_uuid}/over-time/{select_time}")
251
+
252
+ def _get_member_services_raw(self, active_only: bool) -> dict:
253
+ """Retrieve raw member services data."""
254
+ return self._default_request(
255
+ "GET", f"/member/members/{self.member_uuid}/services", params={"activeOnly": str(active_only).lower()}
256
+ )
257
+
258
+ def _get_aspire_data_raw(self, datetime: str | None, unit: str | None) -> dict:
259
+ """Retrieve raw aspire wearable data."""
260
+ return self._default_request(
261
+ "GET", f"/member/wearables/{self.member_uuid}/wearable-daily", params={"datetime": datetime, "unit": unit}
262
+ )
263
+
264
+ def _get_member_purchases_raw(self) -> dict:
265
+ """Retrieve raw member purchases data."""
266
+ return self._default_request("GET", f"/member/members/{self.member_uuid}/purchases")
267
+
268
+ def _get_favorite_studios_raw(self) -> dict:
269
+ """Retrieve raw favorite studios data."""
270
+ return self._default_request("GET", f"/member/members/{self.member_uuid}/favorite-studios")
271
+
272
+ def _get_studio_services_raw(self, studio_uuid: str) -> dict:
273
+ """Retrieve raw studio services data."""
274
+ return self._default_request("GET", f"/member/studios/{studio_uuid}/services")
275
+
276
+ def _get_out_of_studio_workout_history_raw(self) -> dict:
277
+ """Retrieve raw out-of-studio workout history data."""
278
+ return self._default_request("GET", f"/member/members/{self.member_uuid}/out-of-studio-workout")
279
+
280
+ def _add_favorite_studio_raw(self, studio_uuids: list[str]) -> dict:
281
+ """Retrieve raw response from adding a studio to favorite studios."""
282
+ return self._default_request("POST", "/mobile/v1/members/favorite-studios", json={"studioUUIds": studio_uuids})
283
+
284
+ def _remove_favorite_studio_raw(self, studio_uuids: list[str]) -> dict:
285
+ """Retrieve raw response from removing a studio from favorite studios."""
286
+ return self._default_request(
287
+ "DELETE", "/mobile/v1/members/favorite-studios", json={"studioUUIds": studio_uuids}
288
+ )
289
+
290
+ def _get_challenge_tracker_detail_raw(self, challenge_category_id: int) -> dict:
291
+ """Retrieve raw challenge tracker detail data."""
292
+ return self._default_request(
293
+ "GET",
294
+ f"/challenges/v1/member/{self.member_uuid}/participation",
295
+ params={"challengeTypeId": challenge_category_id},
296
+ )
297
+
298
+ def _update_sms_notification_settings_raw(self, promotional_enabled: bool, transactional_enabled: bool) -> dict:
299
+ """Retrieve raw response from updating SMS notification settings."""
300
+ return self._default_request(
301
+ "POST",
302
+ "/sms/v1/preferences",
303
+ json={
304
+ "promosms": promotional_enabled,
305
+ "source": "OTF",
306
+ "transactionalsms": transactional_enabled,
307
+ "phoneNumber": self.member.phone_number,
308
+ },
309
+ )
310
+
311
+ def _update_email_notification_settings_raw(self, promotional_enabled: bool, transactional_enabled: bool) -> dict:
312
+ """Retrieve raw response from updating email notification settings."""
313
+ return self._default_request(
314
+ "POST",
315
+ "/otfmailing/v2/preferences",
316
+ json={
317
+ "promotionalEmail": promotional_enabled,
318
+ "source": "OTF",
319
+ "transactionalEmail": transactional_enabled,
320
+ "email": self.member.email,
321
+ },
322
+ )
323
+
324
+ def _rate_class_raw(self, class_uuid: str, class_history_uuid: str, class_rating: int, coach_rating: int) -> dict:
325
+ """Retrieve raw response from rating a class and coach."""
326
+ return self._default_request(
327
+ "POST",
328
+ "/mobile/v1/members/classes/ratings",
329
+ json={
330
+ "classUUId": class_uuid,
331
+ "otBeatClassHistoryUUId": class_history_uuid,
332
+ "classRating": class_rating,
333
+ "coachRating": coach_rating,
334
+ },
335
+ )
336
+
337
+ def _update_member_name_raw(self, first_name: str, last_name: str) -> dict:
338
+ """Retrieve raw response from updating member name."""
339
+ return self._default_request(
340
+ "PUT",
341
+ f"/member/members/{self.member_uuid}",
342
+ json={"firstName": first_name, "lastName": last_name},
343
+ )
344
+
132
345
  def get_classes(
133
346
  self,
134
347
  start_date: date | None = None,
@@ -204,7 +417,7 @@ class Otf:
204
417
  else:
205
418
  studio_uuids.append(self.home_studio_uuid)
206
419
 
207
- classes_resp = self._classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})
420
+ classes_resp = self._get_classes_raw(studio_uuids)
208
421
 
209
422
  studio_dict = {s: self.get_studio_detail(s) for s in studio_uuids}
210
423
  classes: list[models.OtfClass] = []
@@ -289,7 +502,7 @@ class Otf:
289
502
  if not booking_uuid:
290
503
  raise ValueError("booking_uuid is required")
291
504
 
292
- data = self._default_request("GET", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}")
505
+ data = self._get_booking_raw(booking_uuid)
293
506
  return models.Booking(**data["data"])
294
507
 
295
508
  def get_booking_from_class(self, otf_class: str | models.OtfClass) -> models.Booking:
@@ -437,7 +650,7 @@ class Otf:
437
650
  self,
438
651
  start_date: date | str | None = None,
439
652
  end_date: date | str | None = None,
440
- status: models.BookingStatus | None = None,
653
+ status: models.BookingStatus | list[models.BookingStatus] | None = None,
441
654
  exclude_cancelled: bool = True,
442
655
  exclude_checkedin: bool = True,
443
656
  ) -> list[models.Booking]:
@@ -446,8 +659,7 @@ class Otf:
446
659
  Args:
447
660
  start_date (date | str | None): The start date for the bookings. Default is None.
448
661
  end_date (date | str | None): The end date for the bookings. Default is None.
449
- status (BookingStatus | None): The status of the bookings to get. Default is None, which includes\
450
- all statuses. Only a single status can be provided.
662
+ status (BookingStatus | list[BookingStatus] | None): The status(es) to filter by. Default is None.
451
663
  exclude_cancelled (bool): Whether to exclude cancelled bookings. Default is True.
452
664
  exclude_checkedin (bool): Whether to exclude checked-in bookings. Default is True.
453
665
 
@@ -467,12 +679,6 @@ class Otf:
467
679
  ---
468
680
  If dates are provided, the endpoint will return bookings where the class date is within the provided
469
681
  date range. If no dates are provided, it will go back 45 days and forward about 30 days.
470
-
471
- Developer Notes:
472
- ---
473
- Looking at the code in the app, it appears that this endpoint accepts multiple statuses. Indeed,
474
- it does not throw an error if you include a list of statuses. However, only the last status in the list is
475
- used. I'm not sure if this is a bug or if the API is supposed to work this way.
476
682
  """
477
683
 
478
684
  if exclude_cancelled and status == models.BookingStatus.Cancelled:
@@ -487,11 +693,16 @@ class Otf:
487
693
  if isinstance(end_date, date):
488
694
  end_date = end_date.isoformat()
489
695
 
490
- status_value = status.value if status else None
491
-
492
- params = {"startDate": start_date, "endDate": end_date, "statuses": status_value}
696
+ if isinstance(status, list):
697
+ status_value = ",".join(status)
698
+ elif isinstance(status, models.BookingStatus):
699
+ status_value = status.value
700
+ elif isinstance(status, str):
701
+ status_value = status
702
+ else:
703
+ status_value = None
493
704
 
494
- resp = self._default_request("GET", f"/member/members/{self.member_uuid}/bookings", params=params)["data"]
705
+ resp = self._get_bookings_raw(start_date, end_date, status_value)["data"]
495
706
 
496
707
  # add studio details for each booking, instead of using the different studio model returned by this endpoint
497
708
  studio_uuids = {b["class"]["studio"]["studioUUId"] for b in resp}
@@ -512,55 +723,25 @@ class Otf:
512
723
 
513
724
  return bookings
514
725
 
515
- def _get_bookings_old(self, status: models.BookingStatus | None = None) -> list[models.Booking]:
516
- """Get the member's bookings.
517
-
518
- Args:
519
- status (BookingStatus | None): The status of the bookings to get. Default is None, which includes
520
- all statuses. Only a single status can be provided.
726
+ def get_historical_bookings(self) -> list[models.Booking]:
727
+ """Get the member's historical bookings. This will go back 45 days and return all bookings
728
+ for that time period.
521
729
 
522
730
  Returns:
523
- list[Booking]: The member's bookings.
524
-
525
- Raises:
526
- ValueError: If an unaccepted status is provided.
527
-
528
- Notes:
529
- ---
530
- This one is called with the param named 'status'. Dates cannot be provided, because if the endpoint
531
- receives a date, it will return as if the param name was 'statuses'.
532
-
533
- Note: This seems to only work for Cancelled, Booked, CheckedIn, and Waitlisted statuses. If you provide
534
- a different status, it will return all bookings, not filtered by status. The results in this scenario do
535
- not line up with the `get_bookings` with no status provided, as that returns fewer records. Likely the
536
- filtered dates are different on the backend.
537
-
538
- My guess: the endpoint called with dates and 'statuses' is a "v2" kind of thing, where they upgraded without
539
- changing the version of the api. Calling it with no dates and a singular (limited) status is probably v1.
540
-
541
- I'm leaving this in here for reference, but marking it private. I just don't want to have to puzzle over
542
- this again if I remove it and forget about it.
543
-
731
+ list[Booking]: The member's historical bookings.
544
732
  """
545
-
546
- if status and status not in [
547
- models.BookingStatus.Cancelled,
548
- models.BookingStatus.Booked,
549
- models.BookingStatus.CheckedIn,
550
- models.BookingStatus.Waitlisted,
551
- ]:
552
- raise ValueError(
553
- "Invalid status provided. Only Cancelled, Booked, CheckedIn, Waitlisted, and None are supported."
554
- )
555
-
556
- status_value = status.value if status else None
557
-
558
- res = self._default_request(
559
- "GET", f"/member/members/{self.member_uuid}/bookings", params={"status": status_value}
733
+ # api goes back 45 days but we'll go back 47 to be safe
734
+ start_date = datetime.today().date() - timedelta(days=47)
735
+ end_date = datetime.today().date()
736
+
737
+ return self.get_bookings(
738
+ start_date=start_date,
739
+ end_date=end_date,
740
+ status=HISTORICAL_BOOKING_STATUSES,
741
+ exclude_cancelled=False,
742
+ exclude_checkedin=False,
560
743
  )
561
744
 
562
- return [models.Booking(**b) for b in res["data"]]
563
-
564
745
  def get_member_detail(self) -> models.MemberDetail:
565
746
  """Get the member details.
566
747
 
@@ -568,9 +749,7 @@ class Otf:
568
749
  MemberDetail: The member details.
569
750
  """
570
751
 
571
- params = {"include": "memberAddresses,memberClassSummary"}
572
-
573
- resp = self._default_request("GET", f"/member/members/{self.member_uuid}", params=params)
752
+ resp = self._get_member_detail_raw()
574
753
  data = resp["data"]
575
754
 
576
755
  # use standard StudioDetail model instead of the one returned by this endpoint
@@ -586,7 +765,7 @@ class Otf:
586
765
  MemberMembership: The member's membership details.
587
766
  """
588
767
 
589
- data = self._default_request("GET", f"/member/members/{self.member_uuid}/memberships")
768
+ data = self._get_member_membership_raw()
590
769
  return models.MemberMembership(**data["data"])
591
770
 
592
771
  def get_member_purchases(self) -> list[models.MemberPurchase]:
@@ -595,9 +774,7 @@ class Otf:
595
774
  Returns:
596
775
  list[MemberPurchase]: The member's purchases.
597
776
  """
598
- data = self._default_request("GET", f"/member/members/{self.member_uuid}/purchases")
599
-
600
- purchases = data["data"]
777
+ purchases = self._get_member_purchases_raw()["data"]
601
778
 
602
779
  for p in purchases:
603
780
  p["studio"] = self.get_studio_detail(p["studio"]["studioUUId"])
@@ -621,7 +798,7 @@ class Otf:
621
798
  Any: The member's lifetime stats.
622
799
  """
623
800
 
624
- data = self._default_request("GET", f"/performance/v2/{self.member_uuid}/over-time/{select_time}")
801
+ data = self._get_member_lifetime_stats_raw(select_time.value)
625
802
 
626
803
  stats = models.StatsResponse(**data["data"])
627
804
 
@@ -665,7 +842,7 @@ class Otf:
665
842
  Returns:
666
843
  list[OutOfStudioWorkoutHistory]: The member's out of studio workout history.
667
844
  """
668
- data = self._default_request("GET", f"/member/members/{self.member_uuid}/out-of-studio-workout")
845
+ data = self._get_out_of_studio_workout_history_raw()
669
846
 
670
847
  return [models.OutOfStudioWorkoutHistory(**workout) for workout in data["data"]]
671
848
 
@@ -675,7 +852,7 @@ class Otf:
675
852
  Returns:
676
853
  list[StudioDetail]: The member's favorite studios.
677
854
  """
678
- data = self._default_request("GET", f"/member/members/{self.member_uuid}/favorite-studios")
855
+ data = self._get_favorite_studios_raw()
679
856
  studio_uuids = [studio["studioUUId"] for studio in data["data"]]
680
857
  return [self.get_studio_detail(studio_uuid) for studio_uuid in studio_uuids]
681
858
 
@@ -694,8 +871,7 @@ class Otf:
694
871
  if not studio_uuids:
695
872
  raise ValueError("studio_uuids is required")
696
873
 
697
- body = {"studioUUIds": studio_uuids}
698
- resp = self._default_request("POST", "/mobile/v1/members/favorite-studios", json=body)
874
+ resp = self._add_favorite_studio_raw(studio_uuids)
699
875
 
700
876
  new_faves = resp.get("data", {}).get("studios", [])
701
877
 
@@ -716,8 +892,9 @@ class Otf:
716
892
  if not studio_uuids:
717
893
  raise ValueError("studio_uuids is required")
718
894
 
719
- body = {"studioUUIds": studio_uuids}
720
- self._default_request("DELETE", "/mobile/v1/members/favorite-studios", json=body)
895
+ # keeping the convention of regular/raw methods even though this method doesn't return anything
896
+ # in case that changes in the future
897
+ self._remove_favorite_studio_raw(studio_uuids)
721
898
 
722
899
  def get_studio_services(self, studio_uuid: str | None = None) -> list[models.StudioService]:
723
900
  """Get the services available at a specific studio. If no studio UUID is provided, the member's home studio
@@ -730,14 +907,14 @@ class Otf:
730
907
  list[StudioService]: The services available at the studio.
731
908
  """
732
909
  studio_uuid = studio_uuid or self.home_studio_uuid
733
- data = self._default_request("GET", f"/member/studios/{studio_uuid}/services")
910
+ data = self._get_studio_services_raw(studio_uuid)
734
911
 
735
912
  for d in data["data"]:
736
913
  d["studio"] = self.get_studio_detail(studio_uuid)
737
914
 
738
915
  return [models.StudioService(**d) for d in data["data"]]
739
916
 
740
- @functools.cache
917
+ @cached(cache=TTLCache(maxsize=1024, ttl=600))
741
918
  def get_studio_detail(self, studio_uuid: str | None = None) -> models.StudioDetail:
742
919
  """Get detailed information about a specific studio. If no studio UUID is provided, it will default to the
743
920
  user's home studio.
@@ -749,10 +926,7 @@ class Otf:
749
926
  StudioDetail: Detailed information about the studio.
750
927
  """
751
928
  studio_uuid = studio_uuid or self.home_studio_uuid
752
-
753
- path = f"/mobile/v1/studios/{studio_uuid}"
754
-
755
- res = self._default_request("GET", path)
929
+ res = self._get_studio_detail_raw(studio_uuid)
756
930
 
757
931
  return models.StudioDetail(**res["data"])
758
932
 
@@ -804,18 +978,25 @@ class Otf:
804
978
  Returns:
805
979
  list[models.StudioDetail]: List of studios matching the search criteria.
806
980
  """
807
- path = "/mobile/v1/studios"
808
-
809
981
  distance = min(distance, 250) # max distance is 250 miles
810
-
811
- params = {"latitude": latitude, "longitude": longitude, "distance": distance, "pageIndex": 1, "pageSize": 100}
812
-
813
- LOGGER.debug("Starting studio search", extra={"params": params})
982
+ page_size = 100
983
+ page_index = 1
984
+ LOGGER.debug(
985
+ "Starting studio search",
986
+ extra={
987
+ "latitude": latitude,
988
+ "longitude": longitude,
989
+ "distance": distance,
990
+ "page_index": page_index,
991
+ "page_size": page_size,
992
+ },
993
+ )
814
994
 
815
995
  all_results: dict[str, dict[str, Any]] = {}
816
996
 
817
997
  while True:
818
- res = self._default_request("GET", path, params=params)
998
+ res = self._get_studios_by_geo_raw(latitude, longitude, distance, page_index, page_size)
999
+
819
1000
  studios = res["data"].get("studios", [])
820
1001
  total_count = res["data"].get("pagination", {}).get("totalCount", 0)
821
1002
 
@@ -823,7 +1004,7 @@ class Otf:
823
1004
  if len(all_results) >= total_count or not studios:
824
1005
  break
825
1006
 
826
- params["pageIndex"] += 1
1007
+ page_index += 1
827
1008
 
828
1009
  LOGGER.info("Studio search completed, fetched %d of %d studios", len(all_results), total_count, stacklevel=2)
829
1010
 
@@ -835,7 +1016,7 @@ class Otf:
835
1016
  Returns:
836
1017
  list[BodyCompositionData]: The member's body composition list.
837
1018
  """
838
- data = self._default_request("GET", f"/member/members/{self.user.cognito_id}/body-composition")
1019
+ data = self._get_body_composition_list_raw()
839
1020
  return [models.BodyCompositionData(**item) for item in data["data"]]
840
1021
 
841
1022
  def get_challenge_tracker(self) -> models.ChallengeTracker:
@@ -844,7 +1025,7 @@ class Otf:
844
1025
  Returns:
845
1026
  ChallengeTracker: The member's challenge tracker content.
846
1027
  """
847
- data = self._default_request("GET", f"/challenges/v3.1/member/{self.member_uuid}")
1028
+ data = self._get_challenge_tracker_raw()
848
1029
  return models.ChallengeTracker(**data["Dto"])
849
1030
 
850
1031
  def get_benchmarks(
@@ -865,13 +1046,7 @@ class Otf:
865
1046
  Returns:
866
1047
  list[FitnessBenchmark]: The member's challenge tracker details.
867
1048
  """
868
- params = {
869
- "equipmentId": int(equipment_id),
870
- "challengeTypeId": int(challenge_category_id),
871
- "challengeSubTypeId": challenge_subcategory_id,
872
- }
873
-
874
- data = self._default_request("GET", f"/challenges/v3/member/{self.member_uuid}/benchmarks", params=params)
1049
+ data = self._get_benchmarks_raw(int(challenge_category_id), int(equipment_id), challenge_subcategory_id)
875
1050
  return [models.FitnessBenchmark(**item) for item in data["Dto"]]
876
1051
 
877
1052
  def get_benchmarks_by_equipment(self, equipment_id: models.EquipmentType) -> list[models.FitnessBenchmark]:
@@ -917,11 +1092,7 @@ class Otf:
917
1092
  FitnessBenchmark: Details about the challenge.
918
1093
  """
919
1094
 
920
- data = self._default_request(
921
- "GET",
922
- f"/challenges/v1/member/{self.member_uuid}/participation",
923
- params={"challengeTypeId": int(challenge_category_id)},
924
- )
1095
+ data = self._get_challenge_tracker_detail_raw(int(challenge_category_id))
925
1096
 
926
1097
  if len(data["Dto"]) > 1:
927
1098
  LOGGER.warning("Multiple challenge participations found, returning the first one.")
@@ -931,15 +1102,49 @@ class Otf:
931
1102
 
932
1103
  return models.FitnessBenchmark(**data["Dto"][0])
933
1104
 
934
- def get_performance_summaries(self, limit: int = 5) -> list[models.PerformanceSummaryEntry]:
935
- """Get a list of performance summaries for the authenticated user.
1105
+ @cached(cache=TTLCache(maxsize=1024, ttl=600))
1106
+ def get_performance_summaries_dict(self) -> dict[str, models.PerformanceSummary]:
1107
+ """Get a dictionary of performance summaries for the authenticated user.
1108
+
1109
+ Returns:
1110
+ dict[str, PerformanceSummary]: A dictionary of performance summaries, keyed by class history UUID.
1111
+
1112
+ Developer Notes:
1113
+ ---
1114
+ In the app, this is referred to as 'getInStudioWorkoutHistory'.
1115
+
1116
+ """
1117
+
1118
+ items = self._get_performance_summaries_raw()["items"]
1119
+
1120
+ distinct_studio_ids = set([rec["class"]["studio"]["id"] for rec in items])
1121
+ perf_summary_ids = set([rec["id"] for rec in items])
1122
+
1123
+ with ThreadPoolExecutor() as pool:
1124
+ studio_futures = {s: pool.submit(self.get_studio_detail, s) for s in distinct_studio_ids}
1125
+ perf_summary_futures = {s: pool.submit(self._get_performancy_summary_detail, s) for s in perf_summary_ids}
1126
+
1127
+ studio_dict = {k: v.result() for k, v in studio_futures.items()}
1128
+ # deepcopy these so that mutating them in PerformanceSummary doesn't affect the cache
1129
+ perf_summary_dict = {k: deepcopy(v.result()) for k, v in perf_summary_futures.items()}
1130
+
1131
+ for item in items:
1132
+ item["class"]["studio"] = studio_dict[item["class"]["studio"]["id"]]
1133
+ item["detail"] = perf_summary_dict[item["id"]]
1134
+
1135
+ entries = [models.PerformanceSummary(**item) for item in items]
1136
+ entries_dict = {entry.class_history_uuid: entry for entry in entries}
1137
+
1138
+ return entries_dict
1139
+
1140
+ def get_performance_summaries(self, limit: int | None = None) -> list[models.PerformanceSummary]:
1141
+ """Get a list of all performance summaries for the authenticated user.
936
1142
 
937
1143
  Args:
938
- limit (int): The maximum number of performance summaries to return. Defaults to 5.
939
- only_include_rateable (bool): Whether to only include rateable performance summaries. Defaults to True.
1144
+ limit (int | None): The maximum number of entries to return. Default is None. Deprecated.
940
1145
 
941
1146
  Returns:
942
- list[PerformanceSummaryEntry]: A list of performance summaries.
1147
+ list[PerformanceSummary]: A list of performance summaries.
943
1148
 
944
1149
  Developer Notes:
945
1150
  ---
@@ -947,28 +1152,48 @@ class Otf:
947
1152
 
948
1153
  """
949
1154
 
950
- res = self._performance_summary_request("GET", "/v1/performance-summaries", params={"limit": limit})
951
- entries = [models.PerformanceSummaryEntry(**item) for item in res["items"]]
1155
+ if limit:
1156
+ warnings.warn("Limit is deprecated and will be removed in a future version.", DeprecationWarning)
952
1157
 
953
- return entries
1158
+ records = list(self.get_performance_summaries_dict().values())
954
1159
 
955
- def get_performance_summary(self, performance_summary_id: str) -> models.PerformanceSummaryDetail:
956
- """Get a detailed performance summary for a given workout.
1160
+ sorted_records = sorted(records, key=lambda x: x.otf_class.starts_at, reverse=True)
1161
+
1162
+ return sorted_records
1163
+
1164
+ def get_performance_summary(self, performance_summary_id: str) -> models.PerformanceSummary:
1165
+ """Get performance summary for a given workout.
957
1166
 
958
1167
  Args:
959
1168
  performance_summary_id (str): The ID of the performance summary to retrieve.
960
1169
 
961
1170
  Returns:
962
- PerformanceSummaryDetail: A detailed performance summary.
1171
+ PerformanceSummary: The performance summary.
963
1172
  """
964
1173
 
965
- path = f"/v1/performance-summaries/{performance_summary_id}"
966
- res = self._performance_summary_request("GET", path)
1174
+ perf_summary = self.get_performance_summaries_dict().get(performance_summary_id)
967
1175
 
968
- if res is None:
1176
+ if perf_summary is None:
969
1177
  raise exc.ResourceNotFoundError(f"Performance summary {performance_summary_id} not found")
970
1178
 
971
- return models.PerformanceSummaryDetail(**res)
1179
+ return perf_summary
1180
+
1181
+ @functools.lru_cache(maxsize=1024)
1182
+ def _get_performancy_summary_detail(self, performance_summary_id: str) -> dict[str, Any]:
1183
+ """Get the details for a performance summary.
1184
+
1185
+ Args:
1186
+ performance_summary_id (str): The performance summary ID.
1187
+
1188
+ Returns:
1189
+ dict[str, Any]: The performance summary details.
1190
+
1191
+ Developer Notes:
1192
+ ---
1193
+ This is mostly here to cache the results of the raw method.
1194
+ """
1195
+
1196
+ return self._get_performance_summary_raw(performance_summary_id)
972
1197
 
973
1198
  def get_hr_history(self) -> list[models.TelemetryHistoryItem]:
974
1199
  """Get the heartrate history for the user.
@@ -980,10 +1205,7 @@ class Otf:
980
1205
  list[HistoryItem]: The heartrate history for the user.
981
1206
 
982
1207
  """
983
- path = "/v1/physVars/maxHr/history"
984
-
985
- params = {"memberUuid": self.member_uuid}
986
- resp = self._telemetry_request("GET", path, params=params)
1208
+ resp = self._get_hr_history_raw()
987
1209
  return [models.TelemetryHistoryItem(**item) for item in resp["history"]]
988
1210
 
989
1211
  def get_telemetry(self, performance_summary_id: str, max_data_points: int = 120) -> models.Telemetry:
@@ -998,12 +1220,9 @@ class Otf:
998
1220
 
999
1221
  Returns:
1000
1222
  TelemetryItem: The telemetry for the class history.
1001
-
1002
1223
  """
1003
- path = "/v1/performance/summary"
1004
1224
 
1005
- params = {"classHistoryUuid": performance_summary_id, "maxDataPoints": max_data_points}
1006
- res = self._telemetry_request("GET", path, params=params)
1225
+ res = self._get_telemetry_raw(performance_summary_id, max_data_points)
1007
1226
  return models.Telemetry(**res)
1008
1227
 
1009
1228
  def get_sms_notification_settings(self) -> models.SmsNotificationSettings:
@@ -1012,7 +1231,7 @@ class Otf:
1012
1231
  Returns:
1013
1232
  SmsNotificationSettings: The member's SMS notification settings.
1014
1233
  """
1015
- res = self._default_request("GET", url="/sms/v1/preferences", params={"phoneNumber": self.member.phone_number})
1234
+ res = self._get_sms_notification_settings_raw()
1016
1235
 
1017
1236
  return models.SmsNotificationSettings(**res["data"])
1018
1237
 
@@ -1047,7 +1266,6 @@ class Otf:
1047
1266
  }
1048
1267
  ```
1049
1268
  """
1050
- url = "/sms/v1/preferences"
1051
1269
 
1052
1270
  current_settings = self.get_sms_notification_settings()
1053
1271
 
@@ -1058,14 +1276,7 @@ class Otf:
1058
1276
  transactional_enabled if transactional_enabled is not None else current_settings.is_transactional_sms_opt_in
1059
1277
  )
1060
1278
 
1061
- body = {
1062
- "promosms": promotional_enabled,
1063
- "source": "OTF",
1064
- "transactionalsms": transactional_enabled,
1065
- "phoneNumber": self.member.phone_number,
1066
- }
1067
-
1068
- self._default_request("POST", url, json=body)
1279
+ self._update_sms_notification_settings_raw(promotional_enabled, transactional_enabled)
1069
1280
 
1070
1281
  # the response returns nothing useful, so we just query the settings again
1071
1282
  new_settings = self.get_sms_notification_settings()
@@ -1077,7 +1288,7 @@ class Otf:
1077
1288
  Returns:
1078
1289
  EmailNotificationSettings: The member's email notification settings.
1079
1290
  """
1080
- res = self._default_request("GET", url="/otfmailing/v2/preferences", params={"email": self.member.email})
1291
+ res = self._get_email_notification_settings_raw()
1081
1292
 
1082
1293
  return models.EmailNotificationSettings(**res["data"])
1083
1294
 
@@ -1104,14 +1315,7 @@ class Otf:
1104
1315
  else current_settings.is_transactional_email_opt_in
1105
1316
  )
1106
1317
 
1107
- body = {
1108
- "promotionalEmail": promotional_enabled,
1109
- "source": "OTF",
1110
- "transactionalEmail": transactional_enabled,
1111
- "email": self.member.email,
1112
- }
1113
-
1114
- self._default_request("POST", "/otfmailing/v2/preferences", json=body)
1318
+ self._update_email_notification_settings_raw(promotional_enabled, transactional_enabled)
1115
1319
 
1116
1320
  # the response returns nothing useful, so we just query the settings again
1117
1321
  new_settings = self.get_email_notification_settings()
@@ -1139,10 +1343,7 @@ class Otf:
1139
1343
  LOGGER.warning("No changes to names, nothing to update.")
1140
1344
  return self.member
1141
1345
 
1142
- path = f"/member/members/{self.member_uuid}"
1143
- body = {"firstName": first_name, "lastName": last_name}
1144
-
1145
- res = self._default_request("PUT", path, json=body)
1346
+ res = self._update_member_name_raw(first_name, last_name)
1146
1347
 
1147
1348
  return models.MemberDetail(**res["data"])
1148
1349
 
@@ -1152,7 +1353,7 @@ class Otf:
1152
1353
  class_history_uuid: str,
1153
1354
  class_rating: Literal[0, 1, 2, 3],
1154
1355
  coach_rating: Literal[0, 1, 2, 3],
1155
- ) -> models.PerformanceSummaryEntry:
1356
+ ) -> models.PerformanceSummary:
1156
1357
  """Rate a class and coach. A simpler method is provided in `rate_class_from_performance_summary`.
1157
1358
 
1158
1359
 
@@ -1167,7 +1368,7 @@ class Otf:
1167
1368
  coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
1168
1369
 
1169
1370
  Returns:
1170
- PerformanceSummaryEntry: The updated performance summary entry.
1371
+ PerformanceSummary: The updated performance summary.
1171
1372
  """
1172
1373
 
1173
1374
  # com/orangetheoryfitness/fragment/rating/RateStatus.java
@@ -1188,80 +1389,45 @@ class Otf:
1188
1389
  body_class_rating = CLASS_RATING_MAP[class_rating]
1189
1390
  body_coach_rating = COACH_RATING_MAP[coach_rating]
1190
1391
 
1191
- body = {
1192
- "classUUId": class_uuid,
1193
- "otBeatClassHistoryUUId": class_history_uuid,
1194
- "classRating": body_class_rating,
1195
- "coachRating": body_coach_rating,
1196
- }
1197
-
1198
1392
  try:
1199
- self._default_request("POST", "/mobile/v1/members/classes/ratings", json=body)
1393
+ self._rate_class_raw(class_uuid, class_history_uuid, body_class_rating, body_coach_rating)
1200
1394
  except exc.OtfRequestError as e:
1201
1395
  if e.response.status_code == 403:
1202
1396
  raise exc.AlreadyRatedError(f"Performance summary {class_history_uuid} is already rated.") from None
1203
1397
  raise
1204
1398
 
1205
- return self._get_performance_summary_entry_from_id(class_history_uuid)
1206
-
1207
- def _get_performance_summary_entry_from_id(self, class_history_uuid: str) -> models.PerformanceSummaryEntry:
1208
- """Get a performance summary entry from the ID.
1209
-
1210
- This is a helper function to compensate for the fact that a PerformanceSummaryDetail object does not contain
1211
- the class UUID, which is required to rate the class. It will also be used to return an updated performance
1212
- summary entry after rating a class.
1399
+ # we have to clear the cache after rating a class, otherwise we will get back the same data
1400
+ # showing it not rated. that would be incorrect, confusing, and would cause errors if the user
1401
+ # then attempted to rate the class again.
1402
+ # we could attempt to only refresh the one record but with these endpoints that's not simple
1403
+ # NOTE: the individual perf summary endpoint does not have rating data, so it's cache is not cleared
1404
+ self.get_performance_summaries_dict.cache_clear()
1213
1405
 
1214
- Args:
1215
- class_history_uuid (str): The performance summary ID.
1216
-
1217
- Returns:
1218
- PerformanceSummaryEntry: The performance summary entry.
1219
-
1220
- Raises:
1221
- ResourceNotFoundError: If the performance summary is not found.
1222
- """
1223
-
1224
- # try going in as small of increments as possible, assuming that the rating request
1225
- # will be for a recent class
1226
- for limit in [5, 20, 60, 100]:
1227
- summaries = self.get_performance_summaries(limit)
1228
- summary = next((s for s in summaries if s.class_history_uuid == class_history_uuid), None)
1229
-
1230
- if summary:
1231
- return summary
1232
-
1233
- raise exc.ResourceNotFoundError(f"Performance summary {class_history_uuid} not found.")
1406
+ return self.get_performance_summary(class_history_uuid)
1234
1407
 
1235
1408
  def rate_class_from_performance_summary(
1236
1409
  self,
1237
- perf_summary: models.PerformanceSummaryEntry | models.PerformanceSummaryDetail,
1410
+ perf_summary: models.PerformanceSummary,
1238
1411
  class_rating: Literal[0, 1, 2, 3],
1239
1412
  coach_rating: Literal[0, 1, 2, 3],
1240
- ) -> models.PerformanceSummaryEntry:
1413
+ ) -> models.PerformanceSummary:
1241
1414
  """Rate a class and coach. The class rating must be 0, 1, 2, or 3. 0 is the same as dismissing the prompt to
1242
1415
  rate the class/coach. 1 - 3 is a range from bad to good.
1243
1416
 
1244
1417
  Args:
1245
- perf_summary (PerformanceSummaryEntry): The performance summary entry to rate.
1418
+ perf_summary (PerformanceSummary): The performance summary to rate.
1246
1419
  class_rating (int): The class rating. Must be 0, 1, 2, or 3.
1247
1420
  coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
1248
1421
 
1249
1422
  Returns:
1250
- PerformanceSummaryEntry: The updated performance summary entry.
1423
+ PerformanceSummary: The updated performance summary.
1251
1424
 
1252
1425
  Raises:
1253
- ValueError: If `perf_summary` is not a PerformanceSummaryEntry.
1254
1426
  AlreadyRatedError: If the performance summary is already rated.
1255
1427
  ClassNotRatableError: If the performance summary is not rateable.
1256
1428
  ValueError: If the performance summary does not have an associated class.
1257
1429
  """
1258
1430
 
1259
- if isinstance(perf_summary, models.PerformanceSummaryDetail):
1260
- perf_summary = self._get_performance_summary_entry_from_id(perf_summary.class_history_uuid)
1261
-
1262
- if not isinstance(perf_summary, models.PerformanceSummaryEntry):
1263
- raise ValueError(f"`perf_summary` must be a PerformanceSummaryEntry, got {type(perf_summary)}")
1264
-
1265
1431
  if perf_summary.is_rated:
1266
1432
  raise exc.AlreadyRatedError(f"Performance summary {perf_summary.class_history_uuid} is already rated.")
1267
1433
 
@@ -1288,10 +1454,7 @@ class Otf:
1288
1454
  Returns:
1289
1455
  Any: The member's services.
1290
1456
  """
1291
- active_only_str = "true" if active_only else "false"
1292
- data = self._default_request(
1293
- "GET", f"/member/members/{self.member_uuid}/services", params={"activeOnly": active_only_str}
1294
- )
1457
+ data = self._get_member_services_raw(active_only)
1295
1458
  return data
1296
1459
 
1297
1460
  def _get_aspire_data(self, datetime: str | None = None, unit: str | None = None) -> Any:
@@ -1306,7 +1469,5 @@ class Otf:
1306
1469
  Returns:
1307
1470
  Any: The member's aspire data.
1308
1471
  """
1309
- params = {"datetime": datetime, "unit": unit}
1310
-
1311
- data = self._default_request("GET", f"/member/wearables/{self.member_uuid}/wearable-daily", params=params)
1472
+ data = self._get_aspire_data_raw(datetime, unit)
1312
1473
  return data