otf-api 0.8.2__py3-none-any.whl → 0.9.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.
Files changed (43) hide show
  1. otf_api/__init__.py +7 -4
  2. otf_api/api.py +699 -480
  3. otf_api/auth/__init__.py +4 -0
  4. otf_api/auth/auth.py +234 -0
  5. otf_api/auth/user.py +66 -0
  6. otf_api/auth/utils.py +129 -0
  7. otf_api/exceptions.py +38 -5
  8. otf_api/filters.py +97 -0
  9. otf_api/logging.py +19 -0
  10. otf_api/models/__init__.py +27 -38
  11. otf_api/models/body_composition_list.py +47 -50
  12. otf_api/models/bookings.py +63 -87
  13. otf_api/models/challenge_tracker_content.py +42 -21
  14. otf_api/models/challenge_tracker_detail.py +68 -48
  15. otf_api/models/classes.py +53 -62
  16. otf_api/models/enums.py +108 -30
  17. otf_api/models/lifetime_stats.py +59 -45
  18. otf_api/models/member_detail.py +95 -115
  19. otf_api/models/member_membership.py +18 -17
  20. otf_api/models/member_purchases.py +21 -127
  21. otf_api/models/mixins.py +37 -33
  22. otf_api/models/notifications.py +17 -0
  23. otf_api/models/out_of_studio_workout_history.py +22 -31
  24. otf_api/models/performance_summary_detail.py +47 -42
  25. otf_api/models/performance_summary_list.py +19 -37
  26. otf_api/models/studio_detail.py +51 -98
  27. otf_api/models/studio_services.py +27 -48
  28. otf_api/models/telemetry.py +14 -5
  29. otf_api/utils.py +134 -0
  30. {otf_api-0.8.2.dist-info → otf_api-0.9.1.dist-info}/METADATA +21 -10
  31. otf_api-0.9.1.dist-info/RECORD +35 -0
  32. {otf_api-0.8.2.dist-info → otf_api-0.9.1.dist-info}/WHEEL +1 -1
  33. otf_api/auth.py +0 -316
  34. otf_api/models/book_class.py +0 -89
  35. otf_api/models/cancel_booking.py +0 -49
  36. otf_api/models/favorite_studios.py +0 -106
  37. otf_api/models/latest_agreement.py +0 -21
  38. otf_api/models/telemetry_hr_history.py +0 -34
  39. otf_api/models/telemetry_max_hr.py +0 -13
  40. otf_api/models/total_classes.py +0 -8
  41. otf_api-0.8.2.dist-info/AUTHORS.md +0 -9
  42. otf_api-0.8.2.dist-info/RECORD +0 -36
  43. {otf_api-0.8.2.dist-info → otf_api-0.9.1.dist-info}/LICENSE +0 -0
otf_api/api.py CHANGED
@@ -1,138 +1,64 @@
1
- import asyncio
1
+ import atexit
2
2
  import contextlib
3
- import json
3
+ import functools
4
4
  from datetime import date, datetime, timedelta
5
- from logging import Logger, getLogger
6
- from typing import Any
5
+ from json import JSONDecodeError
6
+ from logging import getLogger
7
+ from typing import Any, Literal
7
8
 
8
- import aiohttp
9
- import requests
9
+ import attrs
10
+ import httpx
10
11
  from yarl import URL
11
12
 
12
- from otf_api import models
13
+ from otf_api import exceptions as exc
14
+ from otf_api import filters, models
13
15
  from otf_api.auth import OtfUser
14
- from otf_api.exceptions import (
15
- AlreadyBookedError,
16
- BookingAlreadyCancelledError,
17
- BookingNotFoundError,
18
- OutsideSchedulingWindowError,
19
- )
16
+ from otf_api.utils import ensure_date, ensure_list, get_booking_uuid, get_class_uuid
20
17
 
21
18
  API_BASE_URL = "api.orangetheory.co"
22
19
  API_IO_BASE_URL = "api.orangetheory.io"
23
20
  API_TELEMETRY_BASE_URL = "api.yuzu.orangetheory.com"
24
- REQUEST_HEADERS = {"Authorization": None, "Content-Type": "application/json", "Accept": "application/json"}
21
+ JSON_HEADERS = {"Content-Type": "application/json", "Accept": "application/json"}
22
+ LOGGER = getLogger(__name__)
25
23
 
26
24
 
25
+ @attrs.define(init=False)
27
26
  class Otf:
28
- logger: "Logger" = getLogger(__file__)
27
+ member: models.MemberDetail
28
+ member_uuid: str
29
+ home_studio: models.StudioDetail
30
+ home_studio_uuid: str
29
31
  user: OtfUser
30
- _session: aiohttp.ClientSession
32
+ session: httpx.Client
31
33
 
32
- def __init__(
33
- self,
34
- username: str | None = None,
35
- password: str | None = None,
36
- access_token: str | None = None,
37
- id_token: str | None = None,
38
- refresh_token: str | None = None,
39
- device_key: str | None = None,
40
- user: OtfUser | None = None,
41
- ):
42
- """Create a new Otf instance.
43
-
44
- Authentication methods:
45
- ---
46
- - Provide a username and password.
47
- - Provide an access token and id token.
48
- - Provide a user object.
49
-
50
- Args:
51
- username (str, optional): The username of the user. Default is None.
52
- password (str, optional): The password of the user. Default is None.
53
- access_token (str, optional): The access token. Default is None.
54
- id_token (str, optional): The id token. Default is None.
55
- refresh_token (str, optional): The refresh token. Default is None.
56
- device_key (str, optional): The device key. Default is None.
57
- user (OtfUser, optional): A user object. Default is None.
58
- """
59
-
60
- self.member: models.MemberDetail
61
- self.home_studio_uuid: str
62
-
63
- if user:
64
- self.user = user
65
- elif username and password or (access_token and id_token):
66
- self.user = OtfUser(
67
- username=username,
68
- password=password,
69
- access_token=access_token,
70
- id_token=id_token,
71
- refresh_token=refresh_token,
72
- device_key=device_key,
73
- )
74
- else:
75
- raise ValueError("No valid authentication method provided")
76
-
77
- # simplify access to member_id and member_uuid
78
- self._member_id = self.user.member_id
79
- self._member_uuid = self.user.member_uuid
80
- self._perf_api_headers = {
81
- "koji-member-id": self._member_id,
82
- "koji-member-email": self.user.id_claims_data.email,
83
- }
84
- self.member = self._get_member_details_sync()
85
- self.home_studio_uuid = self.member.home_studio.studio_uuid
34
+ def __init__(self, user: OtfUser | None = None):
35
+ """Initialize the OTF API client.
86
36
 
87
- def _get_member_details_sync(self):
88
- """Get the member details synchronously.
89
-
90
- This is used to get the member details when the API is first initialized, to let use initialize
91
- without needing to await the member details.
92
-
93
- Returns:
94
- MemberDetail: The member details.
37
+ Args:
38
+ user (OtfUser): The user to authenticate as.
95
39
  """
96
- url = f"https://{API_BASE_URL}/member/members/{self._member_id}"
97
- resp = requests.get(url, headers=self.headers)
98
- return models.MemberDetail(**resp.json()["data"])
99
-
100
- @property
101
- def headers(self):
102
- """Get the headers for the API request."""
103
-
104
- # check the token before making a request in case it has expired
105
-
106
- self.user.cognito.check_token()
107
- return {
108
- "Authorization": f"Bearer {self.user.cognito.id_token}",
109
- "Content-Type": "application/json",
110
- "Accept": "application/json",
111
- }
40
+ self.user = user or OtfUser()
41
+ self.member_uuid = self.user.member_uuid
112
42
 
113
- @property
114
- def session(self):
115
- """Get the aiohttp session."""
116
- if not getattr(self, "_session", None):
117
- self._session = aiohttp.ClientSession(headers=self.headers)
43
+ self.session = httpx.Client(
44
+ headers=JSON_HEADERS, auth=self.user.httpx_auth, timeout=httpx.Timeout(20.0, connect=60.0)
45
+ )
46
+ atexit.register(self.session.close)
118
47
 
119
- return self._session
48
+ self.member = self.get_member_detail()
49
+ self.home_studio = self.member.home_studio
50
+ self.home_studio_uuid = self.home_studio.studio_uuid
120
51
 
121
- def __del__(self) -> None:
122
- if not hasattr(self, "session"):
123
- return
52
+ def __eq__(self, other):
53
+ if not isinstance(other, Otf):
54
+ return False
55
+ return self.member_uuid == other.member_uuid
124
56
 
125
- try:
126
- asyncio.create_task(self._close_session()) # noqa
127
- except RuntimeError:
128
- loop = asyncio.new_event_loop()
129
- loop.run_until_complete(self._close_session())
57
+ def __hash__(self):
58
+ # Combine immutable attributes into a single hash value
59
+ return hash(self.member_uuid)
130
60
 
131
- async def _close_session(self) -> None:
132
- if not self.session.closed:
133
- await self.session.close()
134
-
135
- async def _do(
61
+ def _do(
136
62
  self,
137
63
  method: str,
138
64
  base_url: str,
@@ -143,150 +69,214 @@ class Otf:
143
69
  ) -> Any:
144
70
  """Perform an API request."""
145
71
 
72
+ headers = headers or {}
146
73
  params = params or {}
147
74
  params = {k: v for k, v in params.items() if v is not None}
148
75
 
149
76
  full_url = str(URL.build(scheme="https", host=base_url, path=url))
150
77
 
151
- self.logger.debug(f"Making {method!r} request to {full_url}, params: {params}")
152
-
153
- # ensure we have headers that contain the most up-to-date token
154
- if not headers:
155
- headers = self.headers
156
- else:
157
- headers.update(self.headers)
78
+ LOGGER.debug(f"Making {method!r} request to {full_url}, params: {params}")
158
79
 
159
- text = None
160
- async with self.session.request(method, full_url, headers=headers, params=params, **kwargs) as response:
161
- with contextlib.suppress(Exception):
162
- text = await response.text()
80
+ request = self.session.build_request(method, full_url, headers=headers, params=params, **kwargs)
81
+ response = self.session.send(request)
163
82
 
164
- try:
165
- response.raise_for_status()
166
- except aiohttp.ClientResponseError as e:
167
- self.logger.exception(f"Error making request: {e}")
168
- self.logger.exception(f"Response: {text}")
169
- except Exception as e:
170
- self.logger.exception(f"Error making request: {e}")
171
-
172
- return await response.json()
83
+ try:
84
+ response.raise_for_status()
85
+ except httpx.RequestError as e:
86
+ LOGGER.exception(f"Error making request: {e}")
87
+ LOGGER.exception(f"Response: {response.text}")
88
+ raise
89
+ except httpx.HTTPStatusError as e:
90
+ LOGGER.exception(f"Error making request: {e}")
91
+ LOGGER.exception(f"Response: {response.text}")
92
+ raise exc.OtfRequestError("Error making request", response=response, request=request)
93
+ except Exception as e:
94
+ LOGGER.exception(f"Error making request: {e}")
95
+ raise
96
+
97
+ if not response.text:
98
+ return None
173
99
 
174
- async def _classes_request(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
100
+ try:
101
+ resp = response.json()
102
+ except JSONDecodeError as e:
103
+ LOGGER.error(f"Error decoding JSON: {e}")
104
+ LOGGER.error(f"Response: {response.text}")
105
+ raise
106
+
107
+ if (
108
+ "Status" in resp
109
+ and isinstance(resp["Status"], int)
110
+ and not (resp["Status"] >= 200 and resp["Status"] <= 299)
111
+ ):
112
+ LOGGER.error(f"Error making request: {resp}")
113
+ raise exc.OtfRequestError("Error making request", response=response, request=request)
114
+
115
+ return resp
116
+
117
+ def _classes_request(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
175
118
  """Perform an API request to the classes API."""
176
- return await self._do(method, API_IO_BASE_URL, url, params)
119
+ return self._do(method, API_IO_BASE_URL, url, params)
177
120
 
178
- async def _default_request(self, method: str, url: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
121
+ def _default_request(self, method: str, url: str, params: dict[str, Any] | None = None, **kwargs: Any) -> Any:
179
122
  """Perform an API request to the default API."""
180
- return await self._do(method, API_BASE_URL, url, params, **kwargs)
123
+ return self._do(method, API_BASE_URL, url, params, **kwargs)
181
124
 
182
- async def _telemetry_request(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
125
+ def _telemetry_request(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
183
126
  """Perform an API request to the Telemetry API."""
184
- return await self._do(method, API_TELEMETRY_BASE_URL, url, params)
127
+ return self._do(method, API_TELEMETRY_BASE_URL, url, params)
185
128
 
186
- async def _performance_summary_request(
187
- self, method: str, url: str, headers: dict[str, str], params: dict[str, Any] | None = None
188
- ) -> Any:
129
+ def _performance_summary_request(self, method: str, url: str, params: dict[str, Any] | None = None) -> Any:
189
130
  """Perform an API request to the performance summary API."""
190
- return await self._do(method, API_IO_BASE_URL, url, params, headers)
131
+ perf_api_headers = {"koji-member-id": self.member_uuid, "koji-member-email": self.user.email_address}
132
+ return self._do(method, API_IO_BASE_URL, url, params, perf_api_headers)
191
133
 
192
- async def get_classes(
134
+ def get_classes(
193
135
  self,
136
+ start_date: date | None = None,
137
+ end_date: date | None = None,
194
138
  studio_uuids: list[str] | None = None,
195
- include_home_studio: bool = True,
196
- start_date: str | None = None,
197
- end_date: str | None = None,
198
- limit: int | None = None,
199
- class_type: models.ClassType | list[models.ClassType] | None = None,
200
- exclude_cancelled: bool = False,
201
- day_of_week: list[models.DoW] | None = None,
202
- start_time: list[str] | None = None,
203
- exclude_unbookable: bool = True,
204
- ) -> models.OtfClassList:
139
+ include_home_studio: bool | None = None,
140
+ filters: list[filters.ClassFilter] | filters.ClassFilter | None = None,
141
+ ) -> list[models.OtfClass]:
205
142
  """Get the classes for the user.
206
143
 
207
144
  Returns a list of classes that are available for the user, based on the studio UUIDs provided. If no studio
208
145
  UUIDs are provided, it will default to the user's home studio.
209
146
 
210
147
  Args:
148
+ start_date (date | None): The start date for the classes. Default is None.
149
+ end_date (date | None): The end date for the classes. Default is None.
211
150
  studio_uuids (list[str] | None): The studio UUIDs to get the classes for. Default is None, which will\
212
151
  default to the user's home studio only.
213
152
  include_home_studio (bool): Whether to include the home studio in the classes. Default is True.
214
- start_date (str | None): The start date to get classes for, in the format "YYYY-MM-DD". Default is None.
215
- end_date (str | None): The end date to get classes for, in the format "YYYY-MM-DD". Default is None.
216
- limit (int | None): Limit the number of classes returned. Default is None.
217
- class_type (ClassType | list[ClassType] | None): The class type to filter by. Default is None. Multiple\
218
- class types can be provided, if there are multiple there will be a call per class type.
219
- exclude_cancelled (bool): Whether to exclude cancelled classes. Default is False.
220
- day_of_week (list[DoW] | None): The days of the week to filter by. Default is None.
221
- start_time (list[str] | None): The start time to filter by. Default is None.
222
- exclude_unbookable (bool): Whether to exclude classes that are outside the scheduling window. Default is\
223
- True.
153
+ filters (list[ClassFilter] | ClassFilter | None): A list of filters to apply to the classes, or a single\
154
+ filter. Filters are applied as an OR operation. Default is None.
224
155
 
225
156
  Returns:
226
- OtfClassList: The classes for the user.
157
+ list[OtfClass]: The classes for the user.
227
158
  """
228
159
 
229
- if not studio_uuids:
230
- studio_uuids = [self.home_studio_uuid]
231
- elif include_home_studio and self.home_studio_uuid not in studio_uuids:
232
- studio_uuids.append(self.home_studio_uuid)
160
+ classes = self._get_classes(studio_uuids, include_home_studio)
233
161
 
234
- classes_resp = await self._classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})
235
- classes_list = models.OtfClassList(classes=classes_resp["items"])
162
+ # remove those that are cancelled *by the studio*
163
+ classes = [c for c in classes if not c.is_cancelled]
236
164
 
237
- if start_date:
238
- start_dtme = datetime.strptime(start_date, "%Y-%m-%d") # noqa
239
- classes_list.classes = [c for c in classes_list.classes if c.starts_at_local >= start_dtme]
165
+ bookings = self.get_bookings(status=models.BookingStatus.Booked)
166
+ booked_classes = {b.class_uuid for b in bookings}
240
167
 
241
- if end_date:
242
- end_dtme = datetime.strptime(end_date, "%Y-%m-%d") # noqa
243
- classes_list.classes = [c for c in classes_list.classes if c.ends_at_local <= end_dtme]
168
+ for otf_class in classes:
169
+ otf_class.is_booked = otf_class.class_uuid in booked_classes
244
170
 
245
- if limit:
246
- classes_list.classes = classes_list.classes[:limit]
171
+ # filter by provided start_date/end_date, if provided
172
+ classes = self._filter_classes_by_date(classes, start_date, end_date)
247
173
 
248
- if class_type and isinstance(class_type, str):
249
- class_type = [class_type]
174
+ # filter by provided filters, if provided
175
+ classes = self._filter_classes_by_filters(classes, filters)
250
176
 
251
- if day_of_week and not isinstance(day_of_week, list):
252
- day_of_week = [day_of_week]
177
+ # sort by start time, then by name
178
+ classes = sorted(classes, key=lambda x: (x.starts_at, x.name))
253
179
 
254
- if start_time and not isinstance(start_time, list):
255
- start_time = [start_time]
180
+ return classes
256
181
 
257
- if class_type:
258
- classes_list.classes = [c for c in classes_list.classes if c.class_type in class_type]
182
+ def _get_classes(
183
+ self, studio_uuids: list[str] | None = None, include_home_studio: bool | None = None
184
+ ) -> list[models.OtfClass]:
185
+ """Handles the actual request to get classes.
259
186
 
260
- if exclude_cancelled:
261
- classes_list.classes = [c for c in classes_list.classes if not c.canceled]
187
+ Args:
188
+ studio_uuids (list[str] | None): The studio UUIDs to get the classes for. Default is None, which will\
189
+ default to the user's home studio only.
190
+ include_home_studio (bool): Whether to include the home studio in the classes. Default is True.
262
191
 
263
- for otf_class in classes_list.classes:
264
- otf_class.is_home_studio = otf_class.studio.id == self.home_studio_uuid
192
+ Returns:
193
+ list[OtfClass]: The classes for the user.
194
+ """
265
195
 
266
- if day_of_week:
267
- classes_list.classes = [c for c in classes_list.classes if c.day_of_week_enum in day_of_week]
196
+ studio_uuids = ensure_list(studio_uuids) or [self.home_studio_uuid]
197
+ studio_uuids = list(set(studio_uuids)) # remove duplicates
268
198
 
269
- if start_time:
270
- classes_list.classes = [
271
- c for c in classes_list.classes if any(c.time.strip().startswith(t) for t in start_time)
272
- ]
199
+ if len(studio_uuids) > 50:
200
+ LOGGER.warning("Cannot request classes for more than 50 studios at a time.")
201
+ studio_uuids = studio_uuids[:50]
273
202
 
274
- classes_list.classes = list(filter(lambda c: not c.canceled, classes_list.classes))
203
+ if include_home_studio and self.home_studio_uuid not in studio_uuids:
204
+ if len(studio_uuids) == 50:
205
+ LOGGER.warning("Cannot include home studio, request already includes 50 studios.")
206
+ else:
207
+ studio_uuids.append(self.home_studio_uuid)
275
208
 
276
- if exclude_unbookable:
277
- # this endpoint returns classes that the `book_class` endpoint will reject, this filters them out
278
- max_date = datetime.today().date() + timedelta(days=29)
279
- classes_list.classes = [c for c in classes_list.classes if c.starts_at_local.date() <= max_date]
209
+ classes_resp = self._classes_request("GET", "/v1/classes", params={"studio_ids": studio_uuids})
280
210
 
281
- booking_resp = await self.get_bookings(start_date, end_date, status=models.BookingStatus.Booked)
282
- booked_classes = {b.otf_class.class_uuid for b in booking_resp.bookings}
211
+ studio_dict = {s: self.get_studio_detail(s) for s in studio_uuids}
212
+ classes: list[models.OtfClass] = []
283
213
 
284
- for otf_class in classes_list.classes:
285
- otf_class.is_booked = otf_class.ot_class_uuid in booked_classes
214
+ for c in classes_resp["items"]:
215
+ c["studio"] = studio_dict[c["studio"]["id"]] # the one (?) place where ID actually means UUID
216
+ c["is_home_studio"] = c["studio"].studio_uuid == self.home_studio_uuid
217
+ classes.append(models.OtfClass(**c))
286
218
 
287
- return classes_list
219
+ return classes
288
220
 
289
- async def get_booking(self, booking_uuid: str) -> models.Booking:
221
+ def _filter_classes_by_date(
222
+ self, classes: list[models.OtfClass], start_date: date | None, end_date: date | None
223
+ ) -> list[models.OtfClass]:
224
+ """Filter classes by start and end dates, as well as the max date the booking endpoint will accept.
225
+
226
+ Args:
227
+ classes (list[OtfClass]): The classes to filter.
228
+ start_date (date | None): The start date to filter by.
229
+ end_date (date | None): The end date to filter by.
230
+
231
+ Returns:
232
+ list[OtfClass]: The filtered classes.
233
+ """
234
+
235
+ # this endpoint returns classes that the `book_class` endpoint will reject, this filters them out
236
+ max_date = datetime.today().date() + timedelta(days=29)
237
+
238
+ classes = [c for c in classes if c.starts_at.date() <= max_date]
239
+
240
+ # if not start date or end date, we're done
241
+ if not start_date and not end_date:
242
+ return classes
243
+
244
+ if start_date := ensure_date(start_date):
245
+ classes = [c for c in classes if c.starts_at.date() >= start_date]
246
+
247
+ if end_date := ensure_date(end_date):
248
+ classes = [c for c in classes if c.starts_at.date() <= end_date]
249
+
250
+ return classes
251
+
252
+ def _filter_classes_by_filters(
253
+ self, classes: list[models.OtfClass], filters: list[filters.ClassFilter] | filters.ClassFilter | None
254
+ ) -> list[models.OtfClass]:
255
+ """Filter classes by the provided filters.
256
+
257
+ Args:
258
+ classes (list[OtfClass]): The classes to filter.
259
+ filters (list[ClassFilter] | ClassFilter | None): The filters to apply.
260
+
261
+ Returns:
262
+ list[OtfClass]: The filtered classes.
263
+ """
264
+ if not filters:
265
+ return classes
266
+
267
+ filters = ensure_list(filters)
268
+ filtered_classes: list[models.OtfClass] = []
269
+
270
+ # apply each filter as an OR operation
271
+ for f in filters:
272
+ filtered_classes.extend(f.filter_classes(classes))
273
+
274
+ # remove duplicates
275
+ classes = list({c.class_uuid: c for c in filtered_classes}.values())
276
+
277
+ return classes
278
+
279
+ def get_booking(self, booking_uuid: str) -> models.Booking:
290
280
  """Get a specific booking by booking_uuid.
291
281
 
292
282
  Args:
@@ -301,14 +291,14 @@ class Otf:
301
291
  if not booking_uuid:
302
292
  raise ValueError("booking_uuid is required")
303
293
 
304
- data = await self._default_request("GET", f"/member/members/{self._member_id}/bookings/{booking_uuid}")
294
+ data = self._default_request("GET", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}")
305
295
  return models.Booking(**data["data"])
306
296
 
307
- async def get_booking_by_class(self, class_: str | models.OtfClass) -> models.Booking:
297
+ def get_booking_from_class(self, otf_class: str | models.OtfClass) -> models.Booking:
308
298
  """Get a specific booking by class_uuid or OtfClass object.
309
299
 
310
300
  Args:
311
- class_ (str | OtfClass): The class UUID or the OtfClass object to get the booking for.
301
+ otf_class (str | OtfClass): The class UUID or the OtfClass object to get the booking for.
312
302
 
313
303
  Returns:
314
304
  Booking: The booking.
@@ -318,24 +308,20 @@ class Otf:
318
308
  ValueError: If class_uuid is None or empty string.
319
309
  """
320
310
 
321
- class_uuid = class_.ot_class_uuid if isinstance(class_, models.OtfClass) else class_
322
-
323
- if not class_uuid:
324
- raise ValueError("class_uuid is required")
311
+ class_uuid = get_class_uuid(otf_class)
325
312
 
326
- all_bookings = await self.get_bookings(exclude_cancelled=False, exclude_checkedin=False)
313
+ all_bookings = self.get_bookings(exclude_cancelled=False, exclude_checkedin=False)
327
314
 
328
- for booking in all_bookings.bookings:
329
- if booking.otf_class.class_uuid == class_uuid:
330
- return booking
315
+ if booking := next((b for b in all_bookings if b.class_uuid == class_uuid), None):
316
+ return booking
331
317
 
332
- raise BookingNotFoundError(f"Booking for class {class_uuid} not found.")
318
+ raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
333
319
 
334
- async def book_class(self, class_: str | models.OtfClass) -> models.Booking:
320
+ def book_class(self, otf_class: str | models.OtfClass) -> models.Booking:
335
321
  """Book a class by providing either the class_uuid or the OtfClass object.
336
322
 
337
323
  Args:
338
- class_ (str | OtfClass): The class UUID or the OtfClass object to book.
324
+ otf_class (str | OtfClass): The class UUID or the OtfClass object to book.
339
325
 
340
326
  Returns:
341
327
  Booking: The booking.
@@ -344,82 +330,119 @@ class Otf:
344
330
  AlreadyBookedError: If the class is already booked.
345
331
  OutsideSchedulingWindowError: If the class is outside the scheduling window.
346
332
  ValueError: If class_uuid is None or empty string.
347
- Exception: If there is an error booking the class.
333
+ OtfException: If there is an error booking the class.
348
334
  """
349
335
 
350
- class_uuid = class_.ot_class_uuid if isinstance(class_, models.OtfClass) else class_
351
- if not class_uuid:
352
- raise ValueError("class_uuid is required")
336
+ class_uuid = get_class_uuid(otf_class)
353
337
 
354
- with contextlib.suppress(BookingNotFoundError):
355
- existing_booking = await self.get_booking_by_class(class_uuid)
356
- if existing_booking.status != models.BookingStatus.Cancelled:
357
- raise AlreadyBookedError(
358
- f"Class {class_uuid} is already booked.", booking_uuid=existing_booking.class_booking_uuid
359
- )
338
+ self._check_class_already_booked(class_uuid)
339
+
340
+ if isinstance(otf_class, models.OtfClass):
341
+ self._check_for_booking_conflicts(otf_class)
360
342
 
361
343
  body = {"classUUId": class_uuid, "confirmed": False, "waitlist": False}
362
344
 
363
- resp = await self._default_request("PUT", f"/member/members/{self._member_id}/bookings", json=body)
345
+ try:
346
+ resp = self._default_request("PUT", f"/member/members/{self.member_uuid}/bookings", json=body)
347
+ except exc.OtfRequestError as e:
348
+ resp_obj = e.response.json()
364
349
 
365
- if resp["code"] == "ERROR":
366
- if resp["data"]["errorCode"] == "603":
367
- raise AlreadyBookedError(f"Class {class_uuid} is already booked.")
368
- if resp["data"]["errorCode"] == "602":
369
- raise OutsideSchedulingWindowError(f"Class {class_uuid} is outside the scheduling window.")
350
+ if resp_obj["code"] == "ERROR":
351
+ err_code = resp_obj["data"]["errorCode"]
352
+ if err_code == "603":
353
+ raise exc.AlreadyBookedError(f"Class {class_uuid} is already booked.")
354
+ if err_code == "602":
355
+ raise exc.OutsideSchedulingWindowError(f"Class {class_uuid} is outside the scheduling window.")
370
356
 
371
- raise Exception(f"Error booking class {class_uuid}: {json.dumps(resp)}")
357
+ raise
358
+ except Exception as e:
359
+ raise exc.OtfException(f"Error booking class {class_uuid}: {e}")
372
360
 
373
- # get the booking details - we will only use this to get the booking_uuid
374
- book_class = models.BookClass(**resp["data"])
361
+ # get the booking uuid - we will only use this to return a Booking object using `get_booking`
362
+ # this is an attempt to improve on OTF's terrible data model
363
+ booking_uuid = resp["data"]["savedBookings"][0]["classBookingUUId"]
375
364
 
376
- booking = await self.get_booking(book_class.booking_uuid)
365
+ booking = self.get_booking(booking_uuid)
377
366
 
378
367
  return booking
379
368
 
380
- async def cancel_booking(self, booking: str | models.Booking):
369
+ def _check_class_already_booked(self, class_uuid: str) -> None:
370
+ """Check if the class is already booked.
371
+
372
+ Args:
373
+ class_uuid (str): The class UUID to check.
374
+
375
+ Raises:
376
+ AlreadyBookedError: If the class is already booked.
377
+ """
378
+ existing_booking = None
379
+
380
+ with contextlib.suppress(exc.BookingNotFoundError):
381
+ existing_booking = self.get_booking_from_class(class_uuid)
382
+
383
+ if not existing_booking:
384
+ return
385
+
386
+ if existing_booking.status != models.BookingStatus.Cancelled:
387
+ raise exc.AlreadyBookedError(
388
+ f"Class {class_uuid} is already booked.", booking_uuid=existing_booking.booking_uuid
389
+ )
390
+
391
+ def _check_for_booking_conflicts(self, otf_class: models.OtfClass) -> None:
392
+ """Check for booking conflicts with the provided class.
393
+
394
+ Checks the member's bookings to see if the provided class overlaps with any existing bookings. If a conflict is
395
+ found, a ConflictingBookingError is raised.
396
+ """
397
+
398
+ bookings = self.get_bookings(start_date=otf_class.starts_at.date(), end_date=otf_class.starts_at.date())
399
+ if not bookings:
400
+ return
401
+
402
+ for booking in bookings:
403
+ booking_start = booking.otf_class.starts_at
404
+ booking_end = booking.otf_class.ends_at
405
+ # Check for overlap
406
+ if not (otf_class.ends_at < booking_start or otf_class.starts_at > booking_end):
407
+ raise exc.ConflictingBookingError(
408
+ f"You already have a booking that conflicts with this class ({booking.otf_class.class_uuid}).",
409
+ booking_uuid=booking.booking_uuid,
410
+ )
411
+
412
+ def cancel_booking(self, booking: str | models.Booking) -> None:
381
413
  """Cancel a booking by providing either the booking_uuid or the Booking object.
382
414
 
383
415
  Args:
384
416
  booking (str | Booking): The booking UUID or the Booking object to cancel.
385
417
 
386
- Returns:
387
- CancelBooking: The cancelled booking.
388
-
389
418
  Raises:
390
419
  ValueError: If booking_uuid is None or empty string
391
420
  BookingNotFoundError: If the booking does not exist.
392
421
  """
393
- booking_uuid = booking.class_booking_uuid if isinstance(booking, models.Booking) else booking
394
-
395
- if not booking_uuid:
396
- raise ValueError("booking_uuid is required")
422
+ booking_uuid = get_booking_uuid(booking)
397
423
 
398
424
  try:
399
- await self.get_booking(booking_uuid)
425
+ self.get_booking(booking_uuid)
400
426
  except Exception:
401
- raise BookingNotFoundError(f"Booking {booking_uuid} does not exist.")
427
+ raise exc.BookingNotFoundError(f"Booking {booking_uuid} does not exist.")
402
428
 
403
429
  params = {"confirmed": "true"}
404
- resp = await self._default_request(
405
- "DELETE", f"/member/members/{self._member_id}/bookings/{booking_uuid}", params=params
430
+ resp = self._default_request(
431
+ "DELETE", f"/member/members/{self.member_uuid}/bookings/{booking_uuid}", params=params
406
432
  )
407
433
  if resp["code"] == "NOT_AUTHORIZED" and resp["message"].startswith("This class booking has"):
408
- raise BookingAlreadyCancelledError(
434
+ raise exc.BookingAlreadyCancelledError(
409
435
  f"Booking {booking_uuid} is already cancelled.", booking_uuid=booking_uuid
410
436
  )
411
437
 
412
- return models.CancelBooking(**resp["data"])
413
-
414
- async def get_bookings(
438
+ def get_bookings(
415
439
  self,
416
440
  start_date: date | str | None = None,
417
441
  end_date: date | str | None = None,
418
442
  status: models.BookingStatus | None = None,
419
- limit: int | None = None,
420
443
  exclude_cancelled: bool = True,
421
444
  exclude_checkedin: bool = True,
422
- ):
445
+ ) -> list[models.Booking]:
423
446
  """Get the member's bookings.
424
447
 
425
448
  Args:
@@ -427,13 +450,11 @@ class Otf:
427
450
  end_date (date | str | None): The end date for the bookings. Default is None.
428
451
  status (BookingStatus | None): The status of the bookings to get. Default is None, which includes\
429
452
  all statuses. Only a single status can be provided.
430
- limit (int | None): The maximum number of bookings to return. Default is None, which returns all\
431
- bookings.
432
453
  exclude_cancelled (bool): Whether to exclude cancelled bookings. Default is True.
433
454
  exclude_checkedin (bool): Whether to exclude checked-in bookings. Default is True.
434
455
 
435
456
  Returns:
436
- BookingList: The member's bookings.
457
+ list[Booking]: The member's bookings.
437
458
 
438
459
  Warning:
439
460
  ---
@@ -457,7 +478,7 @@ class Otf:
457
478
  """
458
479
 
459
480
  if exclude_cancelled and status == models.BookingStatus.Cancelled:
460
- self.logger.warning(
481
+ LOGGER.warning(
461
482
  "Cannot exclude cancelled bookings when status is Cancelled. Setting exclude_cancelled to False."
462
483
  )
463
484
  exclude_cancelled = False
@@ -472,30 +493,28 @@ class Otf:
472
493
 
473
494
  params = {"startDate": start_date, "endDate": end_date, "statuses": status_value}
474
495
 
475
- res = await self._default_request("GET", f"/member/members/{self._member_id}/bookings", params=params)
496
+ resp = self._default_request("GET", f"/member/members/{self.member_uuid}/bookings", params=params)["data"]
476
497
 
477
- bookings = res["data"][:limit] if limit else res["data"]
498
+ # add studio details for each booking, instead of using the different studio model returned by this endpoint
499
+ studio_uuids = {b["class"]["studio"]["studioUUId"] for b in resp}
500
+ studios = {studio_uuid: self.get_studio_detail(studio_uuid) for studio_uuid in studio_uuids}
478
501
 
479
- data = models.BookingList(bookings=bookings)
480
- data.bookings = sorted(data.bookings, key=lambda x: x.otf_class.starts_at_local)
502
+ for b in resp:
503
+ b["class"]["studio"] = studios[b["class"]["studio"]["studioUUId"]]
504
+ b["is_home_studio"] = b["class"]["studio"].studio_uuid == self.home_studio_uuid
481
505
 
482
- for booking in data.bookings:
483
- if not booking.otf_class:
484
- continue
485
- if booking.otf_class.studio.studio_uuid == self.home_studio_uuid:
486
- booking.is_home_studio = True
487
- else:
488
- booking.is_home_studio = False
506
+ bookings = [models.Booking(**b) for b in resp]
507
+ bookings = sorted(bookings, key=lambda x: x.otf_class.starts_at)
489
508
 
490
509
  if exclude_cancelled:
491
- data.bookings = [b for b in data.bookings if b.status != models.BookingStatus.Cancelled]
510
+ bookings = [b for b in bookings if b.status != models.BookingStatus.Cancelled]
492
511
 
493
512
  if exclude_checkedin:
494
- data.bookings = [b for b in data.bookings if b.status != models.BookingStatus.CheckedIn]
513
+ bookings = [b for b in bookings if b.status != models.BookingStatus.CheckedIn]
495
514
 
496
- return data
515
+ return bookings
497
516
 
498
- async def _get_bookings_old(self, status: models.BookingStatus | None = None) -> models.BookingList:
517
+ def _get_bookings_old(self, status: models.BookingStatus | None = None) -> list[models.Booking]:
499
518
  """Get the member's bookings.
500
519
 
501
520
  Args:
@@ -503,7 +522,7 @@ class Otf:
503
522
  all statuses. Only a single status can be provided.
504
523
 
505
524
  Returns:
506
- BookingList: The member's bookings.
525
+ list[Booking]: The member's bookings.
507
526
 
508
527
  Raises:
509
528
  ValueError: If an unaccepted status is provided.
@@ -538,72 +557,56 @@ class Otf:
538
557
 
539
558
  status_value = status.value if status else None
540
559
 
541
- res = await self._default_request(
542
- "GET", f"/member/members/{self._member_id}/bookings", params={"status": status_value}
560
+ res = self._default_request(
561
+ "GET", f"/member/members/{self.member_uuid}/bookings", params={"status": status_value}
543
562
  )
544
563
 
545
- return models.BookingList(bookings=res["data"])
564
+ return [models.Booking(**b) for b in res["data"]]
546
565
 
547
- async def get_member_detail(
548
- self, include_addresses: bool = True, include_class_summary: bool = True, include_credit_card: bool = False
549
- ):
566
+ def get_member_detail(self) -> models.MemberDetail:
550
567
  """Get the member details.
551
568
 
552
- Args:
553
- include_addresses (bool): Whether to include the member's addresses in the response.
554
- include_class_summary (bool): Whether to include the member's class summary in the response.
555
- include_credit_card (bool): Whether to include the member's credit card information in the response.
556
-
557
569
  Returns:
558
570
  MemberDetail: The member details.
559
-
560
-
561
- Notes:
562
- ---
563
- The include_addresses, include_class_summary, and include_credit_card parameters are optional and determine
564
- what additional information is included in the response. By default, all additional information is included,
565
- with the exception of the credit card information.
566
-
567
- The base member details include the last four of a credit card regardless of the include_credit_card,
568
- although this is not always the same details as what is in the member_credit_card field. There doesn't seem
569
- to be a way to exclude this information, and I do not know which is which or why they differ.
570
571
  """
571
572
 
572
- include: list[str] = []
573
- if include_addresses:
574
- include.append("memberAddresses")
575
-
576
- if include_class_summary:
577
- include.append("memberClassSummary")
573
+ params = {"include": "memberAddresses,memberClassSummary"}
578
574
 
579
- if include_credit_card:
580
- include.append("memberCreditCard")
575
+ resp = self._default_request("GET", f"/member/members/{self.member_uuid}", params=params)
576
+ data = resp["data"]
581
577
 
582
- params = {"include": ",".join(include)} if include else None
578
+ # use standard StudioDetail model instead of the one returned by this endpoint
579
+ home_studio_uuid = data["homeStudio"]["studioUUId"]
580
+ data["home_studio"] = self.get_studio_detail(home_studio_uuid)
583
581
 
584
- data = await self._default_request("GET", f"/member/members/{self._member_id}", params=params)
585
- return models.MemberDetail(**data["data"])
582
+ return models.MemberDetail(**data)
586
583
 
587
- async def get_member_membership(self) -> models.MemberMembership:
584
+ def get_member_membership(self) -> models.MemberMembership:
588
585
  """Get the member's membership details.
589
586
 
590
587
  Returns:
591
588
  MemberMembership: The member's membership details.
592
589
  """
593
590
 
594
- data = await self._default_request("GET", f"/member/members/{self._member_id}/memberships")
591
+ data = self._default_request("GET", f"/member/members/{self.member_uuid}/memberships")
595
592
  return models.MemberMembership(**data["data"])
596
593
 
597
- async def get_member_purchases(self) -> models.MemberPurchaseList:
594
+ def get_member_purchases(self) -> list[models.MemberPurchase]:
598
595
  """Get the member's purchases, including monthly subscriptions and class packs.
599
596
 
600
597
  Returns:
601
- MemberPurchaseList: The member's purchases.
598
+ list[MemberPurchase]: The member's purchases.
602
599
  """
603
- data = await self._default_request("GET", f"/member/members/{self._member_id}/purchases")
604
- return models.MemberPurchaseList(data=data["data"])
600
+ data = self._default_request("GET", f"/member/members/{self.member_uuid}/purchases")
605
601
 
606
- async def get_member_lifetime_stats(
602
+ purchases = data["data"]
603
+
604
+ for p in purchases:
605
+ p["studio"] = self.get_studio_detail(p["studio"]["studioUUId"])
606
+
607
+ return [models.MemberPurchase(**purchase) for purchase in purchases]
608
+
609
+ def _get_member_lifetime_stats(
607
610
  self, select_time: models.StatsTime = models.StatsTime.AllTime
608
611
  ) -> models.StatsResponse:
609
612
  """Get the member's lifetime stats.
@@ -620,67 +623,129 @@ class Otf:
620
623
  Any: The member's lifetime stats.
621
624
  """
622
625
 
623
- data = await self._default_request("GET", f"/performance/v2/{self._member_id}/over-time/{select_time.value}")
626
+ data = self._default_request("GET", f"/performance/v2/{self.member_uuid}/over-time/{select_time}")
624
627
 
625
628
  stats = models.StatsResponse(**data["data"])
629
+
626
630
  return stats
627
631
 
628
- async def get_latest_agreement(self) -> models.LatestAgreement:
629
- """Get the latest agreement for the member.
632
+ def get_member_lifetime_stats_in_studio(
633
+ self, select_time: models.StatsTime = models.StatsTime.AllTime
634
+ ) -> models.TimeStats:
635
+ """Get the member's lifetime stats in studio.
636
+
637
+ Args:
638
+ select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
630
639
 
631
640
  Returns:
632
- LatestAgreement: The agreement.
641
+ Any: The member's lifetime stats in studio.
642
+ """
633
643
 
634
- Notes:
635
- ---
636
- In this context, "latest" means the most recent agreement with a specific ID, not the most recent agreement
637
- in general. The agreement ID is hardcoded in the endpoint, so it will always return the same agreement.
644
+ data = self._get_member_lifetime_stats(select_time)
645
+
646
+ return data.in_studio.get_by_time(select_time)
647
+
648
+ def get_member_lifetime_stats_out_of_studio(
649
+ self, select_time: models.StatsTime = models.StatsTime.AllTime
650
+ ) -> models.TimeStats:
651
+ """Get the member's lifetime stats out of studio.
652
+
653
+ Args:
654
+ select_time (StatsTime): The time period to get stats for. Default is StatsTime.AllTime.
655
+
656
+ Returns:
657
+ Any: The member's lifetime stats out of studio.
638
658
  """
639
- data = await self._default_request("GET", "/member/agreements/9d98fb27-0f00-4598-ad08-5b1655a59af6")
640
- return models.LatestAgreement(**data["data"])
641
659
 
642
- async def get_out_of_studio_workout_history(self) -> models.OutOfStudioWorkoutHistoryList:
660
+ data = self._get_member_lifetime_stats(select_time)
661
+
662
+ return data.out_studio.get_by_time(select_time)
663
+
664
+ def get_out_of_studio_workout_history(self) -> list[models.OutOfStudioWorkoutHistory]:
643
665
  """Get the member's out of studio workout history.
644
666
 
645
667
  Returns:
646
- OutOfStudioWorkoutHistoryList: The member's out of studio workout history.
668
+ list[OutOfStudioWorkoutHistory]: The member's out of studio workout history.
647
669
  """
648
- data = await self._default_request("GET", f"/member/members/{self._member_id}/out-of-studio-workout")
670
+ data = self._default_request("GET", f"/member/members/{self.member_uuid}/out-of-studio-workout")
649
671
 
650
- return models.OutOfStudioWorkoutHistoryList(workouts=data["data"])
672
+ return [models.OutOfStudioWorkoutHistory(**workout) for workout in data["data"]]
651
673
 
652
- async def get_favorite_studios(self) -> models.FavoriteStudioList:
674
+ def get_favorite_studios(self) -> list[models.StudioDetail]:
653
675
  """Get the member's favorite studios.
654
676
 
655
677
  Returns:
656
- FavoriteStudioList: The member's favorite studios.
678
+ list[StudioDetail]: The member's favorite studios.
657
679
  """
658
- data = await self._default_request("GET", f"/member/members/{self._member_id}/favorite-studios")
680
+ data = self._default_request("GET", f"/member/members/{self.member_uuid}/favorite-studios")
681
+ studio_uuids = [studio["studioUUId"] for studio in data["data"]]
682
+ return [self.get_studio_detail(studio_uuid) for studio_uuid in studio_uuids]
683
+
684
+ def add_favorite_studio(self, studio_uuids: list[str] | str) -> list[models.StudioDetail]:
685
+ """Add a studio to the member's favorite studios.
686
+
687
+ Args:
688
+ studio_uuids (list[str] | str): The studio UUID or list of studio UUIDs to add to the member's favorite\
689
+ studios. If a string is provided, it will be converted to a list.
690
+
691
+ Returns:
692
+ list[StudioDetail]: The new favorite studios.
693
+ """
694
+ studio_uuids = ensure_list(studio_uuids)
695
+
696
+ if not studio_uuids:
697
+ raise ValueError("studio_uuids is required")
698
+
699
+ body = {"studioUUIds": studio_uuids}
700
+ resp = self._default_request("POST", "/mobile/v1/members/favorite-studios", json=body)
659
701
 
660
- return models.FavoriteStudioList(studios=data["data"])
702
+ new_faves = resp.get("data", {}).get("studios", [])
661
703
 
662
- async def get_studio_services(self, studio_uuid: str | None = None) -> models.StudioServiceList:
704
+ return [models.StudioDetail(**studio) for studio in new_faves]
705
+
706
+ def remove_favorite_studio(self, studio_uuids: list[str] | str) -> None:
707
+ """Remove a studio from the member's favorite studios.
708
+
709
+ Args:
710
+ studio_uuids (list[str] | str): The studio UUID or list of studio UUIDs to remove from the member's\
711
+ favorite studios. If a string is provided, it will be converted to a list.
712
+
713
+ Returns:
714
+ None
715
+ """
716
+ studio_uuids = ensure_list(studio_uuids)
717
+
718
+ if not studio_uuids:
719
+ raise ValueError("studio_uuids is required")
720
+
721
+ body = {"studioUUIds": studio_uuids}
722
+ self._default_request("DELETE", "/mobile/v1/members/favorite-studios", json=body)
723
+
724
+ def get_studio_services(self, studio_uuid: str | None = None) -> list[models.StudioService]:
663
725
  """Get the services available at a specific studio. If no studio UUID is provided, the member's home studio
664
726
  will be used.
665
727
 
666
728
  Args:
667
- studio_uuid (str): The studio UUID to get services for. Default is None, which will use the member's home\
668
- studio.
729
+ studio_uuid (str, optional): The studio UUID to get services for.
669
730
 
670
731
  Returns:
671
- StudioServiceList: The services available at the studio.
732
+ list[StudioService]: The services available at the studio.
672
733
  """
673
734
  studio_uuid = studio_uuid or self.home_studio_uuid
674
- data = await self._default_request("GET", f"/member/studios/{studio_uuid}/services")
675
- return models.StudioServiceList(data=data["data"])
735
+ data = self._default_request("GET", f"/member/studios/{studio_uuid}/services")
736
+
737
+ for d in data["data"]:
738
+ d["studio"] = self.get_studio_detail(studio_uuid)
739
+
740
+ return [models.StudioService(**d) for d in data["data"]]
676
741
 
677
- async def get_studio_detail(self, studio_uuid: str | None = None) -> models.StudioDetail:
742
+ @functools.cache
743
+ def get_studio_detail(self, studio_uuid: str | None = None) -> models.StudioDetail:
678
744
  """Get detailed information about a specific studio. If no studio UUID is provided, it will default to the
679
745
  user's home studio.
680
746
 
681
747
  Args:
682
- studio_uuid (str): Studio UUID to get details for. Defaults to None, which will default to the user's home\
683
- studio.
748
+ studio_uuid (str, optional): The studio UUID to get detailed information about.
684
749
 
685
750
  Returns:
686
751
  StudioDetail: Detailed information about the studio.
@@ -688,166 +753,195 @@ class Otf:
688
753
  studio_uuid = studio_uuid or self.home_studio_uuid
689
754
 
690
755
  path = f"/mobile/v1/studios/{studio_uuid}"
691
- params = {"include": "locations"}
692
756
 
693
- res = await self._default_request("GET", path, params=params)
757
+ res = self._default_request("GET", path)
758
+
694
759
  return models.StudioDetail(**res["data"])
695
760
 
696
- async def search_studios_by_geo(
697
- self,
698
- latitude: float | None = None,
699
- longitude: float | None = None,
700
- distance: float = 50,
701
- page_index: int = 1,
702
- page_size: int = 50,
703
- ) -> models.StudioDetailList:
761
+ def get_studios_by_geo(
762
+ self, latitude: float | None = None, longitude: float | None = None
763
+ ) -> list[models.StudioDetail]:
764
+ """Alias for search_studios_by_geo."""
765
+
766
+ return self.search_studios_by_geo(latitude, longitude)
767
+
768
+ def search_studios_by_geo(
769
+ self, latitude: float | None = None, longitude: float | None = None, distance: int = 50
770
+ ) -> list[models.StudioDetail]:
704
771
  """Search for studios by geographic location.
705
772
 
706
773
  Args:
707
774
  latitude (float, optional): Latitude of the location to search around, if None uses home studio latitude.
708
775
  longitude (float, optional): Longitude of the location to search around, if None uses home studio longitude.
709
- distance (float, optional): Distance in miles to search around the location. Defaults to 50.
710
- page_index (int, optional): Page index to start at. Defaults to 1.
711
- page_size (int, optional): Number of results per page. Defaults to 50.
776
+ distance (int, optional): The distance in miles to search around the location. Default is 50.
712
777
 
713
778
  Returns:
714
- StudioDetailList: List of studios that match the search criteria.
779
+ list[StudioDetail]: List of studios that match the search criteria.
780
+ """
781
+ latitude = latitude or self.home_studio.location.latitude
782
+ longitude = longitude or self.home_studio.location.longitude
715
783
 
716
- Notes:
717
- ---
718
- There does not seem to be a limit to the number of results that can be requested total or per page, the
719
- library enforces a limit of 50 results per page to avoid potential rate limiting issues.
784
+ return self._get_studios_by_geo(latitude, longitude, distance)
785
+
786
+ def _get_all_studios(self) -> list[models.StudioDetail]:
787
+ """Gets all studios. Marked as private to avoid random users calling it. Useful for testing and validating
788
+ models.
720
789
 
790
+ Returns:
791
+ list[StudioDetail]: List of studios that match the search criteria.
721
792
  """
722
- path = "/mobile/v1/studios"
793
+ # long/lat being None will cause the endpoint to return all studios
794
+ return self._get_studios_by_geo(None, None)
723
795
 
724
- if not latitude and not longitude:
725
- home_studio = await self.get_studio_detail()
796
+ def _get_studios_by_geo(
797
+ self, latitude: float | None, longitude: float | None, distance: int = 50
798
+ ) -> list[models.StudioDetail]:
799
+ """
800
+ Searches for studios by geographic location.
726
801
 
727
- latitude = home_studio.studio_location.latitude
728
- longitude = home_studio.studio_location.longitude
802
+ Args:
803
+ latitude (float | None): Latitude of the location.
804
+ longitude (float | None): Longitude of the location.
729
805
 
730
- if page_size > 50:
731
- self.logger.warning("The API does not support more than 50 results per page, limiting to 50.")
732
- page_size = 50
806
+ Returns:
807
+ list[models.StudioDetail]: List of studios matching the search criteria.
808
+ """
809
+ path = "/mobile/v1/studios"
733
810
 
734
- if page_index < 1:
735
- self.logger.warning("Page index must be greater than 0, setting to 1.")
736
- page_index = 1
811
+ distance = min(distance, 250) # max distance is 250 miles
737
812
 
738
- params = {
739
- "pageIndex": page_index,
740
- "pageSize": page_size,
741
- "latitude": latitude,
742
- "longitude": longitude,
743
- "distance": distance,
744
- }
813
+ params = {"latitude": latitude, "longitude": longitude, "distance": distance, "pageIndex": 1, "pageSize": 100}
814
+
815
+ LOGGER.debug("Starting studio search", extra={"params": params})
745
816
 
746
- all_results: list[models.StudioDetail] = []
817
+ all_results: dict[str, dict[str, Any]] = {}
747
818
 
748
819
  while True:
749
- res = await self._default_request("GET", path, params=params)
750
- pagination = models.Pagination(**res["data"].pop("pagination"))
751
- all_results.extend([models.StudioDetail(**studio) for studio in res["data"]["studios"]])
820
+ res = self._default_request("GET", path, params=params)
821
+ studios = res["data"].get("studios", [])
822
+ total_count = res["data"].get("pagination", {}).get("totalCount", 0)
752
823
 
753
- if len(all_results) == pagination.total_count:
824
+ all_results.update({studio["studioUUId"]: studio for studio in studios})
825
+ if len(all_results) >= total_count or not studios:
754
826
  break
755
827
 
756
828
  params["pageIndex"] += 1
757
829
 
758
- return models.StudioDetailList(studios=all_results)
830
+ LOGGER.info("Studio search completed, fetched %d of %d studios", len(all_results), total_count, stacklevel=2)
759
831
 
760
- async def get_total_classes(self) -> models.TotalClasses:
761
- """Get the member's total classes. This is a simple object reflecting the total number of classes attended,
762
- both in-studio and OT Live.
832
+ return [models.StudioDetail(**studio) for studio in all_results.values()]
763
833
 
764
- Returns:
765
- TotalClasses: The member's total classes.
766
- """
767
- data = await self._default_request("GET", "/mobile/v1/members/classes/summary")
768
- return models.TotalClasses(**data["data"])
769
-
770
- async def get_body_composition_list(self) -> models.BodyCompositionList:
834
+ def get_body_composition_list(self) -> list[models.BodyCompositionData]:
771
835
  """Get the member's body composition list.
772
836
 
773
837
  Returns:
774
- Any: The member's body composition list.
838
+ list[BodyCompositionData]: The member's body composition list.
775
839
  """
776
- data = await self._default_request("GET", f"/member/members/{self._member_uuid}/body-composition")
840
+ data = self._default_request("GET", f"/member/members/{self.user.cognito_id}/body-composition")
841
+ return [models.BodyCompositionData(**item) for item in data["data"]]
777
842
 
778
- return models.BodyCompositionList(data=data["data"])
779
-
780
- async def get_challenge_tracker_content(self) -> models.ChallengeTrackerContent:
843
+ def get_challenge_tracker(self) -> models.ChallengeTracker:
781
844
  """Get the member's challenge tracker content.
782
845
 
783
846
  Returns:
784
- ChallengeTrackerContent: The member's challenge tracker content.
847
+ ChallengeTracker: The member's challenge tracker content.
785
848
  """
786
- data = await self._default_request("GET", f"/challenges/v3.1/member/{self._member_id}")
787
- return models.ChallengeTrackerContent(**data["Dto"])
849
+ data = self._default_request("GET", f"/challenges/v3.1/member/{self.member_uuid}")
850
+ return models.ChallengeTracker(**data["Dto"])
788
851
 
789
- async def get_challenge_tracker_detail(
852
+ def get_benchmarks(
790
853
  self,
791
- equipment_id: models.EquipmentType,
792
- challenge_type_id: models.ChallengeType,
793
- challenge_sub_type_id: int = 0,
794
- ):
795
- """Get the member's challenge tracker details.
854
+ challenge_category_id: models.ChallengeCategory | Literal[0] = 0,
855
+ equipment_id: models.EquipmentType | Literal[0] = 0,
856
+ challenge_subcategory_id: int = 0,
857
+ ) -> list[models.FitnessBenchmark]:
858
+ """Get the member's challenge tracker participation details.
796
859
 
797
860
  Args:
798
- equipment_id (EquipmentType): The equipment ID.
799
- challenge_type_id (ChallengeType): The challenge type ID.
800
- challenge_sub_type_id (int): The challenge sub type ID. Default is 0.
861
+ challenge_category_id (ChallengeType): The challenge type ID.
862
+ equipment_id (EquipmentType | Literal[0]): The equipment ID, default is 0 - this doesn't seem\
863
+ to be have any impact on the results.
864
+ challenge_subcategory_id (int): The challenge sub type ID. Default is 0 - this doesn't seem\
865
+ to be have any impact on the results.
801
866
 
802
867
  Returns:
803
- ChallengeTrackerDetailList: The member's challenge tracker details.
804
-
805
- Notes:
806
- ---
807
- I'm not sure what the challenge_sub_type_id is supposed to be, so it defaults to 0.
808
-
868
+ list[FitnessBenchmark]: The member's challenge tracker details.
809
869
  """
810
870
  params = {
811
- "equipmentId": equipment_id.value,
812
- "challengeTypeId": challenge_type_id.value,
813
- "challengeSubTypeId": challenge_sub_type_id,
871
+ "equipmentId": int(equipment_id),
872
+ "challengeTypeId": int(challenge_category_id),
873
+ "challengeSubTypeId": challenge_subcategory_id,
814
874
  }
815
875
 
816
- data = await self._default_request("GET", f"/challenges/v3/member/{self._member_id}/benchmarks", params=params)
876
+ data = self._default_request("GET", f"/challenges/v3/member/{self.member_uuid}/benchmarks", params=params)
877
+ return [models.FitnessBenchmark(**item) for item in data["Dto"]]
817
878
 
818
- return models.ChallengeTrackerDetailList(details=data["Dto"])
879
+ def get_benchmarks_by_equipment(self, equipment_id: models.EquipmentType) -> list[models.FitnessBenchmark]:
880
+ """Get the member's challenge tracker participation details by equipment.
819
881
 
820
- async def get_challenge_tracker_participation(self, challenge_type_id: models.ChallengeType) -> Any:
821
- """Get the member's participation in a challenge.
882
+ Args:
883
+ equipment_id (EquipmentType): The equipment type ID.
884
+
885
+ Returns:
886
+ list[FitnessBenchmark]: The member's challenge tracker details.
887
+ """
888
+ benchmarks = self.get_benchmarks(equipment_id=equipment_id)
889
+
890
+ benchmarks = [b for b in benchmarks if b.equipment_id == equipment_id]
891
+
892
+ return benchmarks
893
+
894
+ def get_benchmarks_by_challenge_category(
895
+ self, challenge_category_id: models.ChallengeCategory
896
+ ) -> list[models.FitnessBenchmark]:
897
+ """Get the member's challenge tracker participation details by challenge.
822
898
 
823
899
  Args:
824
- challenge_type_id (ChallengeType): The challenge type ID.
900
+ challenge_category_id (ChallengeType): The challenge type ID.
825
901
 
826
902
  Returns:
827
- Any: The member's participation in the challenge.
903
+ list[FitnessBenchmark]: The member's challenge tracker details.
904
+ """
905
+ benchmarks = self.get_benchmarks(challenge_category_id=challenge_category_id)
828
906
 
829
- Notes:
830
- ---
831
- I've never gotten this to return anything other than invalid response. I'm not sure if it's a bug
832
- in my code or the API.
907
+ benchmarks = [b for b in benchmarks if b.challenge_category_id == challenge_category_id]
908
+
909
+ return benchmarks
910
+
911
+ def get_challenge_tracker_detail(self, challenge_category_id: models.ChallengeCategory) -> models.FitnessBenchmark:
912
+ """Get details about a challenge. This endpoint does not (usually) return member participation, but rather
913
+ details about the challenge itself.
833
914
 
915
+ Args:
916
+ challenge_category_id (ChallengeType): The challenge type ID.
917
+
918
+ Returns:
919
+ FitnessBenchmark: Details about the challenge.
834
920
  """
835
921
 
836
- data = await self._default_request(
922
+ data = self._default_request(
837
923
  "GET",
838
- f"/challenges/v1/member/{self._member_id}/participation",
839
- params={"challengeTypeId": challenge_type_id.value},
924
+ f"/challenges/v1/member/{self.member_uuid}/participation",
925
+ params={"challengeTypeId": int(challenge_category_id)},
840
926
  )
841
- return data
842
927
 
843
- async def get_performance_summaries(self, limit: int = 30) -> models.PerformanceSummaryList:
928
+ if len(data["Dto"]) > 1:
929
+ LOGGER.warning("Multiple challenge participations found, returning the first one.")
930
+
931
+ if len(data["Dto"]) == 0:
932
+ raise exc.ResourceNotFoundError(f"Challenge {challenge_category_id} not found")
933
+
934
+ return models.FitnessBenchmark(**data["Dto"][0])
935
+
936
+ def get_performance_summaries(self, limit: int = 5) -> list[models.PerformanceSummaryEntry]:
844
937
  """Get a list of performance summaries for the authenticated user.
845
938
 
846
939
  Args:
847
- limit (int): The maximum number of performance summaries to return. Defaults to 30.
940
+ limit (int): The maximum number of performance summaries to return. Defaults to 5.
941
+ only_include_rateable (bool): Whether to only include rateable performance summaries. Defaults to True.
848
942
 
849
943
  Returns:
850
- PerformanceSummaryList: A list of performance summaries.
944
+ list[PerformanceSummaryEntry]: A list of performance summaries.
851
945
 
852
946
  Developer Notes:
853
947
  ---
@@ -855,15 +949,12 @@ class Otf:
855
949
 
856
950
  """
857
951
 
858
- res = await self._performance_summary_request(
859
- "GET",
860
- "/v1/performance-summaries",
861
- headers=self._perf_api_headers,
862
- params={"limit": limit},
863
- )
864
- return models.PerformanceSummaryList(summaries=res["items"])
952
+ res = self._performance_summary_request("GET", "/v1/performance-summaries", params={"limit": limit})
953
+ entries = [models.PerformanceSummaryEntry(**item) for item in res["items"]]
954
+
955
+ return entries
865
956
 
866
- async def get_performance_summary(self, performance_summary_id: str) -> models.PerformanceSummaryDetail:
957
+ def get_performance_summary(self, performance_summary_id: str) -> models.PerformanceSummaryDetail:
867
958
  """Get a detailed performance summary for a given workout.
868
959
 
869
960
  Args:
@@ -874,41 +965,29 @@ class Otf:
874
965
  """
875
966
 
876
967
  path = f"/v1/performance-summaries/{performance_summary_id}"
877
- res = await self._performance_summary_request("GET", path, headers=self._perf_api_headers)
968
+ res = self._performance_summary_request("GET", path)
969
+ if res is None:
970
+ raise exc.ResourceNotFoundError(f"Performance summary {performance_summary_id} not found")
971
+
878
972
  return models.PerformanceSummaryDetail(**res)
879
973
 
880
- async def get_hr_history(self) -> models.TelemetryHrHistory:
974
+ def get_hr_history(self) -> list[models.TelemetryHistoryItem]:
881
975
  """Get the heartrate history for the user.
882
976
 
883
977
  Returns a list of history items that contain the max heartrate, start/end bpm for each zone,
884
978
  the change from the previous, the change bucket, and the assigned at time.
885
979
 
886
980
  Returns:
887
- TelemetryHrHistory: The heartrate history for the user.
981
+ list[HistoryItem]: The heartrate history for the user.
888
982
 
889
983
  """
890
984
  path = "/v1/physVars/maxHr/history"
891
985
 
892
- params = {"memberUuid": self._member_id}
893
- res = await self._telemetry_request("GET", path, params=params)
894
- return models.TelemetryHrHistory(**res)
986
+ params = {"memberUuid": self.member_uuid}
987
+ resp = self._telemetry_request("GET", path, params=params)
988
+ return [models.TelemetryHistoryItem(**item) for item in resp["history"]]
895
989
 
896
- async def get_max_hr(self) -> models.TelemetryMaxHr:
897
- """Get the max heartrate for the user.
898
-
899
- Returns a simple object that has the member_uuid and the max_hr.
900
-
901
- Returns:
902
- TelemetryMaxHr: The max heartrate for the user.
903
- """
904
- path = "/v1/physVars/maxHr"
905
-
906
- params = {"memberUuid": self._member_id}
907
-
908
- res = await self._telemetry_request("GET", path, params=params)
909
- return models.TelemetryMaxHr(**res)
910
-
911
- async def get_telemetry(self, performance_summary_id: str, max_data_points: int = 120) -> models.Telemetry:
990
+ def get_telemetry(self, performance_summary_id: str, max_data_points: int = 120) -> models.Telemetry:
912
991
  """Get the telemetry for a performance summary.
913
992
 
914
993
  This returns an object that contains the max heartrate, start/end bpm for each zone,
@@ -925,27 +1004,167 @@ class Otf:
925
1004
  path = "/v1/performance/summary"
926
1005
 
927
1006
  params = {"classHistoryUuid": performance_summary_id, "maxDataPoints": max_data_points}
928
- res = await self._telemetry_request("GET", path, params=params)
1007
+ res = self._telemetry_request("GET", path, params=params)
929
1008
  return models.Telemetry(**res)
930
1009
 
1010
+ def get_sms_notification_settings(self) -> models.SmsNotificationSettings:
1011
+ """Get the member's SMS notification settings.
1012
+
1013
+ Returns:
1014
+ SmsNotificationSettings: The member's SMS notification settings.
1015
+ """
1016
+ res = self._default_request("GET", url="/sms/v1/preferences", params={"phoneNumber": self.member.phone_number})
1017
+
1018
+ return models.SmsNotificationSettings(**res["data"])
1019
+
1020
+ def update_sms_notification_settings(
1021
+ self, promotional_enabled: bool | None = None, transactional_enabled: bool | None = None
1022
+ ) -> models.SmsNotificationSettings:
1023
+ """Update the member's SMS notification settings. Arguments not provided will be left unchanged.
1024
+
1025
+ Args:
1026
+ promotional_enabled (bool | None): Whether to enable promotional SMS notifications.
1027
+ transactional_enabled (bool | None): Whether to enable transactional SMS notifications.
1028
+
1029
+ Returns:
1030
+ SmsNotificationSettings: The updated SMS notification settings.
1031
+
1032
+ Warning:
1033
+ ---
1034
+ This endpoint seems to accept almost anything, converting values to truthy/falsey and
1035
+ updating the settings accordingly. The one error I've gotten is with -1
1036
+
1037
+ ```
1038
+ ERROR - Response:
1039
+ {
1040
+ "code": "ER_WARN_DATA_OUT_OF_RANGE",
1041
+ "message": "An unexpected server error occurred, please try again.",
1042
+ "details": [
1043
+ {
1044
+ "message": "ER_WARN_DATA_OUT_OF_RANGE: Out of range value for column 'IsPromotionalSMSOptIn' at row 1",
1045
+ "additionalInfo": ""
1046
+ }
1047
+ ]
1048
+ }
1049
+ ```
1050
+ """
1051
+ url = "/sms/v1/preferences"
1052
+
1053
+ current_settings = self.get_sms_notification_settings()
1054
+
1055
+ promotional_enabled = (
1056
+ promotional_enabled if promotional_enabled is not None else current_settings.is_promotional_sms_opt_in
1057
+ )
1058
+ transactional_enabled = (
1059
+ transactional_enabled if transactional_enabled is not None else current_settings.is_transactional_sms_opt_in
1060
+ )
1061
+
1062
+ body = {
1063
+ "promosms": promotional_enabled,
1064
+ "source": "OTF",
1065
+ "transactionalsms": transactional_enabled,
1066
+ "phoneNumber": self.member.phone_number,
1067
+ }
1068
+
1069
+ self._default_request("POST", url, json=body)
1070
+
1071
+ # the response returns nothing useful, so we just query the settings again
1072
+ new_settings = self.get_sms_notification_settings()
1073
+ return new_settings
1074
+
1075
+ def get_email_notification_settings(self) -> models.EmailNotificationSettings:
1076
+ """Get the member's email notification settings.
1077
+
1078
+ Returns:
1079
+ EmailNotificationSettings: The member's email notification settings.
1080
+ """
1081
+ res = self._default_request("GET", url="/otfmailing/v2/preferences", params={"email": self.member.email})
1082
+
1083
+ return models.EmailNotificationSettings(**res["data"])
1084
+
1085
+ def update_email_notification_settings(
1086
+ self, promotional_enabled: bool | None = None, transactional_enabled: bool | None = None
1087
+ ) -> models.EmailNotificationSettings:
1088
+ """Update the member's email notification settings. Arguments not provided will be left unchanged.
1089
+
1090
+ Args:
1091
+ promotional_enabled (bool | None): Whether to enable promotional email notifications.
1092
+ transactional_enabled (bool | None): Whether to enable transactional email notifications.
1093
+
1094
+ Returns:
1095
+ EmailNotificationSettings: The updated email notification settings.
1096
+ """
1097
+ current_settings = self.get_email_notification_settings()
1098
+
1099
+ promotional_enabled = (
1100
+ promotional_enabled if promotional_enabled is not None else current_settings.is_promotional_email_opt_in
1101
+ )
1102
+ transactional_enabled = (
1103
+ transactional_enabled
1104
+ if transactional_enabled is not None
1105
+ else current_settings.is_transactional_email_opt_in
1106
+ )
1107
+
1108
+ body = {
1109
+ "promotionalEmail": promotional_enabled,
1110
+ "source": "OTF",
1111
+ "transactionalEmail": transactional_enabled,
1112
+ "email": self.member.email,
1113
+ }
1114
+
1115
+ self._default_request("POST", "/otfmailing/v2/preferences", json=body)
1116
+
1117
+ # the response returns nothing useful, so we just query the settings again
1118
+ new_settings = self.get_email_notification_settings()
1119
+ return new_settings
1120
+
1121
+ def update_member_name(self, first_name: str | None = None, last_name: str | None = None) -> models.MemberDetail:
1122
+ """Update the member's name. Will return the original member details if no names are provided.
1123
+
1124
+ Args:
1125
+ first_name (str | None): The first name to update to. Default is None.
1126
+ last_name (str | None): The last name to update to. Default is None.
1127
+
1128
+ Returns:
1129
+ MemberDetail: The updated member details or the original member details if no changes were made.
1130
+ """
1131
+
1132
+ if not first_name and not last_name:
1133
+ LOGGER.warning("No names provided, nothing to update.")
1134
+ return self.member
1135
+
1136
+ first_name = first_name or self.member.first_name
1137
+ last_name = last_name or self.member.last_name
1138
+
1139
+ if first_name == self.member.first_name and last_name == self.member.last_name:
1140
+ LOGGER.warning("No changes to names, nothing to update.")
1141
+ return self.member
1142
+
1143
+ path = f"/member/members/{self.member_uuid}"
1144
+ body = {"firstName": first_name, "lastName": last_name}
1145
+
1146
+ res = self._default_request("PUT", path, json=body)
1147
+
1148
+ return models.MemberDetail(**res["data"])
1149
+
931
1150
  # the below do not return any data for me, so I can't test them
932
1151
 
933
- async def _get_member_services(self, active_only: bool = True) -> Any:
1152
+ def _get_member_services(self, active_only: bool = True) -> Any:
934
1153
  """Get the member's services.
935
1154
 
936
1155
  Args:
937
1156
  active_only (bool): Whether to only include active services. Default is True.
938
1157
 
939
1158
  Returns:
940
- Any: The member's service
941
- ."""
1159
+ Any: The member's services.
1160
+ """
942
1161
  active_only_str = "true" if active_only else "false"
943
- data = await self._default_request(
944
- "GET", f"/member/members/{self._member_id}/services", params={"activeOnly": active_only_str}
1162
+ data = self._default_request(
1163
+ "GET", f"/member/members/{self.member_uuid}/services", params={"activeOnly": active_only_str}
945
1164
  )
946
1165
  return data
947
1166
 
948
- async def _get_aspire_data(self, datetime: str | None = None, unit: str | None = None) -> Any:
1167
+ def _get_aspire_data(self, datetime: str | None = None, unit: str | None = None) -> Any:
949
1168
  """Get data from the member's aspire wearable.
950
1169
 
951
1170
  Note: I don't have an aspire wearable, so I can't test this.
@@ -959,5 +1178,5 @@ class Otf:
959
1178
  """
960
1179
  params = {"datetime": datetime, "unit": unit}
961
1180
 
962
- data = self._default_request("GET", f"/member/wearables/{self._member_id}/wearable-daily", params=params)
1181
+ data = self._default_request("GET", f"/member/wearables/{self.member_uuid}/wearable-daily", params=params)
963
1182
  return data