otf-api 0.9.4__py3-none-any.whl → 0.10.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
otf_api/api.py CHANGED
@@ -1,6 +1,8 @@
1
1
  import atexit
2
2
  import contextlib
3
3
  import functools
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from copy import deepcopy
4
6
  from datetime import date, datetime, timedelta
5
7
  from json import JSONDecodeError
6
8
  from logging import getLogger
@@ -8,11 +10,14 @@ from typing import Any, Literal
8
10
 
9
11
  import attrs
10
12
  import httpx
13
+ from cachetools import TTLCache, cached
14
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
11
15
  from yarl import URL
12
16
 
13
17
  from otf_api import exceptions as exc
14
18
  from otf_api import filters, models
15
19
  from otf_api.auth import OtfUser
20
+ from otf_api.models.enums import HISTORICAL_BOOKING_STATUSES
16
21
  from otf_api.utils import ensure_date, ensure_list, get_booking_uuid, get_class_uuid
17
22
 
18
23
  API_BASE_URL = "api.orangetheory.co"
@@ -58,6 +63,11 @@ class Otf:
58
63
  # Combine immutable attributes into a single hash value
59
64
  return hash(self.member_uuid)
60
65
 
66
+ @retry(
67
+ retry=retry_if_exception_type(exc.OtfRequestError),
68
+ stop=stop_after_attempt(3),
69
+ wait=wait_exponential(multiplier=1, min=4, max=10),
70
+ )
61
71
  def _do(
62
72
  self,
63
73
  method: str,
@@ -87,13 +97,20 @@ class Otf:
87
97
  LOGGER.exception(f"Response: {response.text}")
88
98
  raise
89
99
  except httpx.HTTPStatusError as e:
100
+ if e.response.status_code == 404:
101
+ raise exc.ResourceNotFoundError("Resource not found")
102
+
103
+ if e.response.status_code == 403:
104
+ raise
105
+
90
106
  raise exc.OtfRequestError("Error making request", e, response=response, request=request)
91
107
  except Exception as e:
92
108
  LOGGER.exception(f"Error making request: {e}")
93
109
  raise
94
110
 
95
111
  if not response.text:
96
- return None
112
+ # insanely enough, at least one endpoint (get perf summary) returns None without error instead of 404
113
+ raise exc.OtfRequestError("Empty response", None, response=response, request=request)
97
114
 
98
115
  try:
99
116
  resp = response.json()
@@ -129,6 +146,204 @@ class Otf:
129
146
  perf_api_headers = {"koji-member-id": self.member_uuid, "koji-member-email": self.user.email_address}
130
147
  return self._do(method, API_IO_BASE_URL, url, params, perf_api_headers)
131
148
 
149
+ def _get_classes_raw(self, studio_uuids: list[str] | None) -> dict:
150
+ """Retrieve raw class data."""
151
+ return self._classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})
152
+
153
+ def _get_booking_raw(self, booking_uuid: str) -> dict:
154
+ """Retrieve raw booking data."""
155
+ return self._default_request("GET", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}")
156
+
157
+ def _get_bookings_raw(self, start_date: str | None, end_date: str | None, status: str | list[str] | None) -> dict:
158
+ """Retrieve raw bookings data."""
159
+
160
+ if isinstance(status, list):
161
+ status = ",".join(status)
162
+
163
+ return self._default_request(
164
+ "GET",
165
+ f"/member/members/{self.member_uuid}/bookings",
166
+ params={"startDate": start_date, "endDate": end_date, "statuses": status},
167
+ )
168
+
169
+ def _get_member_detail_raw(self) -> dict:
170
+ """Retrieve raw member details."""
171
+ return self._default_request(
172
+ "GET", f"/member/members/{self.member_uuid}", params={"include": "memberAddresses,memberClassSummary"}
173
+ )
174
+
175
+ def _get_member_membership_raw(self) -> dict:
176
+ """Retrieve raw member membership details."""
177
+ return self._default_request("GET", f"/member/members/{self.member_uuid}/memberships")
178
+
179
+ def _get_performance_summaries_raw(self, limit: int | None = None) -> dict:
180
+ """Retrieve raw performance summaries data."""
181
+ params = {"limit": limit} if limit else {}
182
+ return self._performance_summary_request("GET", "/v1/performance-summaries", params=params)
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(
325
+ self, class_uuid: str, performance_summary_id: str, class_rating: int, coach_rating: int
326
+ ) -> dict:
327
+ """Retrieve raw response from rating a class and coach."""
328
+ return self._default_request(
329
+ "POST",
330
+ "/mobile/v1/members/classes/ratings",
331
+ json={
332
+ "classUUId": class_uuid,
333
+ "otBeatClassHistoryUUId": performance_summary_id,
334
+ "classRating": class_rating,
335
+ "coachRating": coach_rating,
336
+ },
337
+ )
338
+
339
+ def _update_member_name_raw(self, first_name: str, last_name: str) -> dict:
340
+ """Retrieve raw response from updating member name."""
341
+ return self._default_request(
342
+ "PUT",
343
+ f"/member/members/{self.member_uuid}",
344
+ json={"firstName": first_name, "lastName": last_name},
345
+ )
346
+
132
347
  def get_classes(
133
348
  self,
134
349
  start_date: date | None = None,
@@ -204,7 +419,7 @@ class Otf:
204
419
  else:
205
420
  studio_uuids.append(self.home_studio_uuid)
206
421
 
207
- classes_resp = self._classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})
422
+ classes_resp = self._get_classes_raw(studio_uuids)
208
423
 
209
424
  studio_dict = {s: self.get_studio_detail(s) for s in studio_uuids}
210
425
  classes: list[models.OtfClass] = []
@@ -289,7 +504,7 @@ class Otf:
289
504
  if not booking_uuid:
290
505
  raise ValueError("booking_uuid is required")
291
506
 
292
- data = self._default_request("GET", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}")
507
+ data = self._get_booking_raw(booking_uuid)
293
508
  return models.Booking(**data["data"])
294
509
 
295
510
  def get_booking_from_class(self, otf_class: str | models.OtfClass) -> models.Booking:
@@ -437,7 +652,7 @@ class Otf:
437
652
  self,
438
653
  start_date: date | str | None = None,
439
654
  end_date: date | str | None = None,
440
- status: models.BookingStatus | None = None,
655
+ status: models.BookingStatus | list[models.BookingStatus] | None = None,
441
656
  exclude_cancelled: bool = True,
442
657
  exclude_checkedin: bool = True,
443
658
  ) -> list[models.Booking]:
@@ -446,8 +661,7 @@ class Otf:
446
661
  Args:
447
662
  start_date (date | str | None): The start date for the bookings. Default is None.
448
663
  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.
664
+ status (BookingStatus | list[BookingStatus] | None): The status(es) to filter by. Default is None.
451
665
  exclude_cancelled (bool): Whether to exclude cancelled bookings. Default is True.
452
666
  exclude_checkedin (bool): Whether to exclude checked-in bookings. Default is True.
453
667
 
@@ -467,12 +681,6 @@ class Otf:
467
681
  ---
468
682
  If dates are provided, the endpoint will return bookings where the class date is within the provided
469
683
  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
684
  """
477
685
 
478
686
  if exclude_cancelled and status == models.BookingStatus.Cancelled:
@@ -487,11 +695,16 @@ class Otf:
487
695
  if isinstance(end_date, date):
488
696
  end_date = end_date.isoformat()
489
697
 
490
- status_value = status.value if status else None
698
+ if isinstance(status, list):
699
+ status_value = ",".join(status)
700
+ elif isinstance(status, models.BookingStatus):
701
+ status_value = status.value
702
+ elif isinstance(status, str):
703
+ status_value = status
704
+ else:
705
+ status_value = None
491
706
 
492
- params = {"startDate": start_date, "endDate": end_date, "statuses": status_value}
493
-
494
- resp = self._default_request("GET", f"/member/members/{self.member_uuid}/bookings", params=params)["data"]
707
+ resp = self._get_bookings_raw(start_date, end_date, status_value)["data"]
495
708
 
496
709
  # add studio details for each booking, instead of using the different studio model returned by this endpoint
497
710
  studio_uuids = {b["class"]["studio"]["studioUUId"] for b in resp}
@@ -512,55 +725,25 @@ class Otf:
512
725
 
513
726
  return bookings
514
727
 
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.
728
+ def get_historical_bookings(self) -> list[models.Booking]:
729
+ """Get the member's historical bookings. This will go back 45 days and return all bookings
730
+ for that time period.
521
731
 
522
732
  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
-
733
+ list[Booking]: The member's historical bookings.
544
734
  """
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}
735
+ # api goes back 45 days but we'll go back 47 to be safe
736
+ start_date = datetime.today().date() - timedelta(days=47)
737
+ end_date = datetime.today().date()
738
+
739
+ return self.get_bookings(
740
+ start_date=start_date,
741
+ end_date=end_date,
742
+ status=HISTORICAL_BOOKING_STATUSES,
743
+ exclude_cancelled=False,
744
+ exclude_checkedin=False,
560
745
  )
561
746
 
562
- return [models.Booking(**b) for b in res["data"]]
563
-
564
747
  def get_member_detail(self) -> models.MemberDetail:
565
748
  """Get the member details.
566
749
 
@@ -568,9 +751,7 @@ class Otf:
568
751
  MemberDetail: The member details.
569
752
  """
570
753
 
571
- params = {"include": "memberAddresses,memberClassSummary"}
572
-
573
- resp = self._default_request("GET", f"/member/members/{self.member_uuid}", params=params)
754
+ resp = self._get_member_detail_raw()
574
755
  data = resp["data"]
575
756
 
576
757
  # use standard StudioDetail model instead of the one returned by this endpoint
@@ -586,7 +767,7 @@ class Otf:
586
767
  MemberMembership: The member's membership details.
587
768
  """
588
769
 
589
- data = self._default_request("GET", f"/member/members/{self.member_uuid}/memberships")
770
+ data = self._get_member_membership_raw()
590
771
  return models.MemberMembership(**data["data"])
591
772
 
592
773
  def get_member_purchases(self) -> list[models.MemberPurchase]:
@@ -595,9 +776,7 @@ class Otf:
595
776
  Returns:
596
777
  list[MemberPurchase]: The member's purchases.
597
778
  """
598
- data = self._default_request("GET", f"/member/members/{self.member_uuid}/purchases")
599
-
600
- purchases = data["data"]
779
+ purchases = self._get_member_purchases_raw()["data"]
601
780
 
602
781
  for p in purchases:
603
782
  p["studio"] = self.get_studio_detail(p["studio"]["studioUUId"])
@@ -621,7 +800,7 @@ class Otf:
621
800
  Any: The member's lifetime stats.
622
801
  """
623
802
 
624
- data = self._default_request("GET", f"/performance/v2/{self.member_uuid}/over-time/{select_time}")
803
+ data = self._get_member_lifetime_stats_raw(select_time.value)
625
804
 
626
805
  stats = models.StatsResponse(**data["data"])
627
806
 
@@ -665,7 +844,7 @@ class Otf:
665
844
  Returns:
666
845
  list[OutOfStudioWorkoutHistory]: The member's out of studio workout history.
667
846
  """
668
- data = self._default_request("GET", f"/member/members/{self.member_uuid}/out-of-studio-workout")
847
+ data = self._get_out_of_studio_workout_history_raw()
669
848
 
670
849
  return [models.OutOfStudioWorkoutHistory(**workout) for workout in data["data"]]
671
850
 
@@ -675,7 +854,7 @@ class Otf:
675
854
  Returns:
676
855
  list[StudioDetail]: The member's favorite studios.
677
856
  """
678
- data = self._default_request("GET", f"/member/members/{self.member_uuid}/favorite-studios")
857
+ data = self._get_favorite_studios_raw()
679
858
  studio_uuids = [studio["studioUUId"] for studio in data["data"]]
680
859
  return [self.get_studio_detail(studio_uuid) for studio_uuid in studio_uuids]
681
860
 
@@ -694,8 +873,7 @@ class Otf:
694
873
  if not studio_uuids:
695
874
  raise ValueError("studio_uuids is required")
696
875
 
697
- body = {"studioUUIds": studio_uuids}
698
- resp = self._default_request("POST", "/mobile/v1/members/favorite-studios", json=body)
876
+ resp = self._add_favorite_studio_raw(studio_uuids)
699
877
 
700
878
  new_faves = resp.get("data", {}).get("studios", [])
701
879
 
@@ -716,8 +894,9 @@ class Otf:
716
894
  if not studio_uuids:
717
895
  raise ValueError("studio_uuids is required")
718
896
 
719
- body = {"studioUUIds": studio_uuids}
720
- self._default_request("DELETE", "/mobile/v1/members/favorite-studios", json=body)
897
+ # keeping the convention of regular/raw methods even though this method doesn't return anything
898
+ # in case that changes in the future
899
+ self._remove_favorite_studio_raw(studio_uuids)
721
900
 
722
901
  def get_studio_services(self, studio_uuid: str | None = None) -> list[models.StudioService]:
723
902
  """Get the services available at a specific studio. If no studio UUID is provided, the member's home studio
@@ -730,18 +909,20 @@ class Otf:
730
909
  list[StudioService]: The services available at the studio.
731
910
  """
732
911
  studio_uuid = studio_uuid or self.home_studio_uuid
733
- data = self._default_request("GET", f"/member/studios/{studio_uuid}/services")
912
+ data = self._get_studio_services_raw(studio_uuid)
734
913
 
735
914
  for d in data["data"]:
736
915
  d["studio"] = self.get_studio_detail(studio_uuid)
737
916
 
738
917
  return [models.StudioService(**d) for d in data["data"]]
739
918
 
740
- @functools.cache
919
+ @cached(cache=TTLCache(maxsize=1024, ttl=600))
741
920
  def get_studio_detail(self, studio_uuid: str | None = None) -> models.StudioDetail:
742
921
  """Get detailed information about a specific studio. If no studio UUID is provided, it will default to the
743
922
  user's home studio.
744
923
 
924
+ If the studio is not found, it will return a StudioDetail object with default values.
925
+
745
926
  Args:
746
927
  studio_uuid (str, optional): The studio UUID to get detailed information about.
747
928
 
@@ -750,9 +931,10 @@ class Otf:
750
931
  """
751
932
  studio_uuid = studio_uuid or self.home_studio_uuid
752
933
 
753
- path = f"/mobile/v1/studios/{studio_uuid}"
754
-
755
- res = self._default_request("GET", path)
934
+ try:
935
+ res = self._get_studio_detail_raw(studio_uuid)
936
+ except exc.ResourceNotFoundError:
937
+ return models.StudioDetail(studioUUId=studio_uuid, studioName="Studio Not Found", studioStatus="Unknown")
756
938
 
757
939
  return models.StudioDetail(**res["data"])
758
940
 
@@ -804,18 +986,25 @@ class Otf:
804
986
  Returns:
805
987
  list[models.StudioDetail]: List of studios matching the search criteria.
806
988
  """
807
- path = "/mobile/v1/studios"
808
-
809
989
  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})
990
+ page_size = 100
991
+ page_index = 1
992
+ LOGGER.debug(
993
+ "Starting studio search",
994
+ extra={
995
+ "latitude": latitude,
996
+ "longitude": longitude,
997
+ "distance": distance,
998
+ "page_index": page_index,
999
+ "page_size": page_size,
1000
+ },
1001
+ )
814
1002
 
815
1003
  all_results: dict[str, dict[str, Any]] = {}
816
1004
 
817
1005
  while True:
818
- res = self._default_request("GET", path, params=params)
1006
+ res = self._get_studios_by_geo_raw(latitude, longitude, distance, page_index, page_size)
1007
+
819
1008
  studios = res["data"].get("studios", [])
820
1009
  total_count = res["data"].get("pagination", {}).get("totalCount", 0)
821
1010
 
@@ -823,7 +1012,7 @@ class Otf:
823
1012
  if len(all_results) >= total_count or not studios:
824
1013
  break
825
1014
 
826
- params["pageIndex"] += 1
1015
+ page_index += 1
827
1016
 
828
1017
  LOGGER.info("Studio search completed, fetched %d of %d studios", len(all_results), total_count, stacklevel=2)
829
1018
 
@@ -835,7 +1024,7 @@ class Otf:
835
1024
  Returns:
836
1025
  list[BodyCompositionData]: The member's body composition list.
837
1026
  """
838
- data = self._default_request("GET", f"/member/members/{self.user.cognito_id}/body-composition")
1027
+ data = self._get_body_composition_list_raw()
839
1028
  return [models.BodyCompositionData(**item) for item in data["data"]]
840
1029
 
841
1030
  def get_challenge_tracker(self) -> models.ChallengeTracker:
@@ -844,7 +1033,7 @@ class Otf:
844
1033
  Returns:
845
1034
  ChallengeTracker: The member's challenge tracker content.
846
1035
  """
847
- data = self._default_request("GET", f"/challenges/v3.1/member/{self.member_uuid}")
1036
+ data = self._get_challenge_tracker_raw()
848
1037
  return models.ChallengeTracker(**data["Dto"])
849
1038
 
850
1039
  def get_benchmarks(
@@ -865,13 +1054,7 @@ class Otf:
865
1054
  Returns:
866
1055
  list[FitnessBenchmark]: The member's challenge tracker details.
867
1056
  """
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)
1057
+ data = self._get_benchmarks_raw(int(challenge_category_id), int(equipment_id), challenge_subcategory_id)
875
1058
  return [models.FitnessBenchmark(**item) for item in data["Dto"]]
876
1059
 
877
1060
  def get_benchmarks_by_equipment(self, equipment_id: models.EquipmentType) -> list[models.FitnessBenchmark]:
@@ -917,11 +1100,7 @@ class Otf:
917
1100
  FitnessBenchmark: Details about the challenge.
918
1101
  """
919
1102
 
920
- data = self._default_request(
921
- "GET",
922
- f"/challenges/v1/member/{self.member_uuid}/participation",
923
- params={"challengeTypeId": int(challenge_category_id)},
924
- )
1103
+ data = self._get_challenge_tracker_detail_raw(int(challenge_category_id))
925
1104
 
926
1105
  if len(data["Dto"]) > 1:
927
1106
  LOGGER.warning("Multiple challenge participations found, returning the first one.")
@@ -931,15 +1110,52 @@ class Otf:
931
1110
 
932
1111
  return models.FitnessBenchmark(**data["Dto"][0])
933
1112
 
934
- def get_performance_summaries(self, limit: int = 5) -> list[models.PerformanceSummaryEntry]:
935
- """Get a list of performance summaries for the authenticated user.
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.
936
1153
 
937
1154
  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.
1155
+ limit (int | None): The maximum number of entries to return. Default is None.
940
1156
 
941
1157
  Returns:
942
- list[PerformanceSummaryEntry]: A list of performance summaries.
1158
+ list[PerformanceSummary]: A list of performance summaries.
943
1159
 
944
1160
  Developer Notes:
945
1161
  ---
@@ -947,28 +1163,55 @@ class Otf:
947
1163
 
948
1164
  """
949
1165
 
950
- res = self._performance_summary_request("GET", "/v1/performance-summaries", params={"limit": limit})
951
- entries = [models.PerformanceSummaryEntry(**item) for item in res["items"]]
1166
+ records = list(self.get_performance_summaries_dict(limit=limit).values())
952
1167
 
953
- return entries
1168
+ sorted_records = sorted(records, key=lambda x: x.otf_class.starts_at, reverse=True)
954
1169
 
955
- def get_performance_summary(self, performance_summary_id: str) -> models.PerformanceSummaryDetail:
956
- """Get a detailed performance summary for a given workout.
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.
957
1181
 
958
1182
  Args:
959
1183
  performance_summary_id (str): The ID of the performance summary to retrieve.
960
1184
 
961
1185
  Returns:
962
- PerformanceSummaryDetail: A detailed performance summary.
1186
+ PerformanceSummary: The performance summary.
1187
+
1188
+ Raises:
1189
+ ResourceNotFoundError: If the performance_summary_id is not in the list of performance summaries.
963
1190
  """
964
1191
 
965
- path = f"/v1/performance-summaries/{performance_summary_id}"
966
- res = self._performance_summary_request("GET", path)
1192
+ perf_summary = self.get_performance_summaries_dict(limit=limit).get(performance_summary_id)
967
1193
 
968
- if res is None:
1194
+ if perf_summary is None:
969
1195
  raise exc.ResourceNotFoundError(f"Performance summary {performance_summary_id} not found")
970
1196
 
971
- return models.PerformanceSummaryDetail(**res)
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]:
1201
+ """Get the details for a performance summary. Generally should not be called directly. This
1202
+
1203
+ Args:
1204
+ performance_summary_id (str): The performance summary ID.
1205
+
1206
+ Returns:
1207
+ 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
+ """
1213
+
1214
+ return self._get_performance_summary_raw(performance_summary_id)
972
1215
 
973
1216
  def get_hr_history(self) -> list[models.TelemetryHistoryItem]:
974
1217
  """Get the heartrate history for the user.
@@ -980,10 +1223,7 @@ class Otf:
980
1223
  list[HistoryItem]: The heartrate history for the user.
981
1224
 
982
1225
  """
983
- path = "/v1/physVars/maxHr/history"
984
-
985
- params = {"memberUuid": self.member_uuid}
986
- resp = self._telemetry_request("GET", path, params=params)
1226
+ resp = self._get_hr_history_raw()
987
1227
  return [models.TelemetryHistoryItem(**item) for item in resp["history"]]
988
1228
 
989
1229
  def get_telemetry(self, performance_summary_id: str, max_data_points: int = 120) -> models.Telemetry:
@@ -998,12 +1238,9 @@ class Otf:
998
1238
 
999
1239
  Returns:
1000
1240
  TelemetryItem: The telemetry for the class history.
1001
-
1002
1241
  """
1003
- path = "/v1/performance/summary"
1004
1242
 
1005
- params = {"classHistoryUuid": performance_summary_id, "maxDataPoints": max_data_points}
1006
- res = self._telemetry_request("GET", path, params=params)
1243
+ res = self._get_telemetry_raw(performance_summary_id, max_data_points)
1007
1244
  return models.Telemetry(**res)
1008
1245
 
1009
1246
  def get_sms_notification_settings(self) -> models.SmsNotificationSettings:
@@ -1012,7 +1249,7 @@ class Otf:
1012
1249
  Returns:
1013
1250
  SmsNotificationSettings: The member's SMS notification settings.
1014
1251
  """
1015
- res = self._default_request("GET", url="/sms/v1/preferences", params={"phoneNumber": self.member.phone_number})
1252
+ res = self._get_sms_notification_settings_raw()
1016
1253
 
1017
1254
  return models.SmsNotificationSettings(**res["data"])
1018
1255
 
@@ -1047,7 +1284,6 @@ class Otf:
1047
1284
  }
1048
1285
  ```
1049
1286
  """
1050
- url = "/sms/v1/preferences"
1051
1287
 
1052
1288
  current_settings = self.get_sms_notification_settings()
1053
1289
 
@@ -1058,14 +1294,7 @@ class Otf:
1058
1294
  transactional_enabled if transactional_enabled is not None else current_settings.is_transactional_sms_opt_in
1059
1295
  )
1060
1296
 
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)
1297
+ self._update_sms_notification_settings_raw(promotional_enabled, transactional_enabled)
1069
1298
 
1070
1299
  # the response returns nothing useful, so we just query the settings again
1071
1300
  new_settings = self.get_sms_notification_settings()
@@ -1077,7 +1306,7 @@ class Otf:
1077
1306
  Returns:
1078
1307
  EmailNotificationSettings: The member's email notification settings.
1079
1308
  """
1080
- res = self._default_request("GET", url="/otfmailing/v2/preferences", params={"email": self.member.email})
1309
+ res = self._get_email_notification_settings_raw()
1081
1310
 
1082
1311
  return models.EmailNotificationSettings(**res["data"])
1083
1312
 
@@ -1104,14 +1333,7 @@ class Otf:
1104
1333
  else current_settings.is_transactional_email_opt_in
1105
1334
  )
1106
1335
 
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)
1336
+ self._update_email_notification_settings_raw(promotional_enabled, transactional_enabled)
1115
1337
 
1116
1338
  # the response returns nothing useful, so we just query the settings again
1117
1339
  new_settings = self.get_email_notification_settings()
@@ -1139,20 +1361,17 @@ class Otf:
1139
1361
  LOGGER.warning("No changes to names, nothing to update.")
1140
1362
  return self.member
1141
1363
 
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)
1364
+ res = self._update_member_name_raw(first_name, last_name)
1146
1365
 
1147
1366
  return models.MemberDetail(**res["data"])
1148
1367
 
1149
1368
  def _rate_class(
1150
1369
  self,
1151
1370
  class_uuid: str,
1152
- class_history_uuid: str,
1371
+ performance_summary_id: str,
1153
1372
  class_rating: Literal[0, 1, 2, 3],
1154
1373
  coach_rating: Literal[0, 1, 2, 3],
1155
- ) -> models.PerformanceSummaryEntry:
1374
+ ) -> models.PerformanceSummary:
1156
1375
  """Rate a class and coach. A simpler method is provided in `rate_class_from_performance_summary`.
1157
1376
 
1158
1377
 
@@ -1162,12 +1381,12 @@ class Otf:
1162
1381
 
1163
1382
  Args:
1164
1383
  class_uuid (str): The class UUID.
1165
- class_history_uuid (str): The performance summary ID.
1384
+ performance_summary_id (str): The performance summary ID.
1166
1385
  class_rating (int): The class rating. Must be 0, 1, 2, or 3.
1167
1386
  coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
1168
1387
 
1169
1388
  Returns:
1170
- PerformanceSummaryEntry: The updated performance summary entry.
1389
+ PerformanceSummary: The updated performance summary.
1171
1390
  """
1172
1391
 
1173
1392
  # com/orangetheoryfitness/fragment/rating/RateStatus.java
@@ -1188,93 +1407,60 @@ class Otf:
1188
1407
  body_class_rating = CLASS_RATING_MAP[class_rating]
1189
1408
  body_coach_rating = COACH_RATING_MAP[coach_rating]
1190
1409
 
1191
- body = {
1192
- "classUUId": class_uuid,
1193
- "otBeatClassHistoryUUId": class_history_uuid,
1194
- "classRating": body_class_rating,
1195
- "coachRating": body_coach_rating,
1196
- }
1197
-
1198
1410
  try:
1199
- self._default_request("POST", "/mobile/v1/members/classes/ratings", json=body)
1411
+ self._rate_class_raw(class_uuid, performance_summary_id, body_class_rating, body_coach_rating)
1200
1412
  except exc.OtfRequestError as e:
1201
1413
  if e.response.status_code == 403:
1202
- raise exc.AlreadyRatedError(f"Performance summary {class_history_uuid} is already rated.") from None
1414
+ raise exc.AlreadyRatedError(f"Performance summary {performance_summary_id} is already rated.") from None
1203
1415
  raise
1204
1416
 
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.
1213
-
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)
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()
1229
1423
 
1230
- if summary:
1231
- return summary
1232
-
1233
- raise exc.ResourceNotFoundError(f"Performance summary {class_history_uuid} not found.")
1424
+ return self.get_performance_summary(performance_summary_id)
1234
1425
 
1235
1426
  def rate_class_from_performance_summary(
1236
1427
  self,
1237
- perf_summary: models.PerformanceSummaryEntry | models.PerformanceSummaryDetail,
1428
+ perf_summary: models.PerformanceSummary,
1238
1429
  class_rating: Literal[0, 1, 2, 3],
1239
1430
  coach_rating: Literal[0, 1, 2, 3],
1240
- ) -> models.PerformanceSummaryEntry:
1431
+ ) -> models.PerformanceSummary:
1241
1432
  """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
1433
  rate the class/coach. 1 - 3 is a range from bad to good.
1243
1434
 
1244
1435
  Args:
1245
- perf_summary (PerformanceSummaryEntry): The performance summary entry to rate.
1436
+ perf_summary (PerformanceSummary): The performance summary to rate.
1246
1437
  class_rating (int): The class rating. Must be 0, 1, 2, or 3.
1247
1438
  coach_rating (int): The coach rating. Must be 0, 1, 2, or 3.
1248
1439
 
1249
1440
  Returns:
1250
- PerformanceSummaryEntry: The updated performance summary entry.
1441
+ PerformanceSummary: The updated performance summary.
1251
1442
 
1252
1443
  Raises:
1253
- ValueError: If `perf_summary` is not a PerformanceSummaryEntry.
1254
1444
  AlreadyRatedError: If the performance summary is already rated.
1255
1445
  ClassNotRatableError: If the performance summary is not rateable.
1256
1446
  ValueError: If the performance summary does not have an associated class.
1257
1447
  """
1258
1448
 
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
1449
  if perf_summary.is_rated:
1266
- raise exc.AlreadyRatedError(f"Performance summary {perf_summary.class_history_uuid} is already rated.")
1450
+ raise exc.AlreadyRatedError(f"Performance summary {perf_summary.performance_summary_id} is already rated.")
1267
1451
 
1268
1452
  if not perf_summary.ratable:
1269
- raise exc.ClassNotRatableError(f"Performance summary {perf_summary.class_history_uuid} is not rateable.")
1453
+ raise exc.ClassNotRatableError(
1454
+ f"Performance summary {perf_summary.performance_summary_id} is not rateable."
1455
+ )
1270
1456
 
1271
1457
  if not perf_summary.otf_class or not perf_summary.otf_class.class_uuid:
1272
1458
  raise ValueError(
1273
- f"Performance summary {perf_summary.class_history_uuid} does not have an associated class."
1459
+ f"Performance summary {perf_summary.performance_summary_id} does not have an associated class."
1274
1460
  )
1275
1461
 
1276
1462
  return self._rate_class(
1277
- perf_summary.otf_class.class_uuid, perf_summary.class_history_uuid, class_rating, coach_rating
1463
+ perf_summary.otf_class.class_uuid, perf_summary.performance_summary_id, class_rating, coach_rating
1278
1464
  )
1279
1465
 
1280
1466
  # the below do not return any data for me, so I can't test them
@@ -1288,10 +1474,7 @@ class Otf:
1288
1474
  Returns:
1289
1475
  Any: The member's services.
1290
1476
  """
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
- )
1477
+ data = self._get_member_services_raw(active_only)
1295
1478
  return data
1296
1479
 
1297
1480
  def _get_aspire_data(self, datetime: str | None = None, unit: str | None = None) -> Any:
@@ -1306,7 +1489,5 @@ class Otf:
1306
1489
  Returns:
1307
1490
  Any: The member's aspire data.
1308
1491
  """
1309
- params = {"datetime": datetime, "unit": unit}
1310
-
1311
- data = self._default_request("GET", f"/member/wearables/{self.member_uuid}/wearable-daily", params=params)
1492
+ data = self._get_aspire_data_raw(datetime, unit)
1312
1493
  return data