otf-api 0.2.2__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. otf_api/__init__.py +14 -69
  2. otf_api/api.py +873 -66
  3. otf_api/auth.py +314 -0
  4. otf_api/cli/__init__.py +4 -0
  5. otf_api/cli/_utilities.py +60 -0
  6. otf_api/cli/app.py +172 -0
  7. otf_api/cli/bookings.py +231 -0
  8. otf_api/cli/prompts.py +162 -0
  9. otf_api/models/__init__.py +30 -23
  10. otf_api/models/base.py +205 -2
  11. otf_api/models/responses/__init__.py +29 -29
  12. otf_api/models/responses/body_composition_list.py +304 -0
  13. otf_api/models/responses/book_class.py +405 -0
  14. otf_api/models/responses/bookings.py +211 -37
  15. otf_api/models/responses/cancel_booking.py +93 -0
  16. otf_api/models/responses/challenge_tracker_content.py +6 -6
  17. otf_api/models/responses/challenge_tracker_detail.py +6 -6
  18. otf_api/models/responses/classes.py +205 -7
  19. otf_api/models/responses/enums.py +0 -35
  20. otf_api/models/responses/favorite_studios.py +5 -5
  21. otf_api/models/responses/latest_agreement.py +2 -2
  22. otf_api/models/responses/lifetime_stats.py +92 -0
  23. otf_api/models/responses/member_detail.py +17 -12
  24. otf_api/models/responses/member_membership.py +2 -2
  25. otf_api/models/responses/member_purchases.py +9 -9
  26. otf_api/models/responses/out_of_studio_workout_history.py +4 -4
  27. otf_api/models/responses/performance_summary_detail.py +1 -1
  28. otf_api/models/responses/performance_summary_list.py +13 -13
  29. otf_api/models/responses/studio_detail.py +10 -10
  30. otf_api/models/responses/studio_services.py +8 -8
  31. otf_api/models/responses/telemetry.py +6 -6
  32. otf_api/models/responses/telemetry_hr_history.py +6 -6
  33. otf_api/models/responses/telemetry_max_hr.py +3 -3
  34. otf_api/models/responses/total_classes.py +2 -2
  35. otf_api/models/responses/workouts.py +4 -4
  36. otf_api-0.4.0.dist-info/METADATA +54 -0
  37. otf_api-0.4.0.dist-info/RECORD +42 -0
  38. otf_api-0.4.0.dist-info/entry_points.txt +3 -0
  39. otf_api/__version__.py +0 -1
  40. otf_api/classes_api.py +0 -44
  41. otf_api/member_api.py +0 -380
  42. otf_api/models/auth.py +0 -141
  43. otf_api/performance_api.py +0 -54
  44. otf_api/studios_api.py +0 -96
  45. otf_api/telemetry_api.py +0 -95
  46. otf_api-0.2.2.dist-info/METADATA +0 -284
  47. otf_api-0.2.2.dist-info/RECORD +0 -38
  48. {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/AUTHORS.md +0 -0
  49. {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/LICENSE +0 -0
  50. {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/WHEEL +0 -0
otf_api/member_api.py DELETED
@@ -1,380 +0,0 @@
1
- import typing
2
- from datetime import date
3
-
4
- from otf_api.models.responses.enums import BookingStatus
5
- from otf_api.models.responses.favorite_studios import FavoriteStudioList
6
-
7
- from .models import (
8
- BookingList,
9
- ChallengeTrackerContent,
10
- ChallengeTrackerDetailList,
11
- ChallengeType,
12
- EquipmentType,
13
- LatestAgreement,
14
- MemberDetail,
15
- MemberMembership,
16
- MemberPurchaseList,
17
- OutOfStudioWorkoutHistoryList,
18
- StudioServiceList,
19
- TotalClasses,
20
- WorkoutList,
21
- )
22
-
23
- if typing.TYPE_CHECKING:
24
- from otf_api import Api
25
-
26
-
27
- class MemberApi:
28
- def __init__(self, api: "Api"):
29
- self._api = api
30
- self.logger = api.logger
31
-
32
- # simplify access to member_id and member_uuid
33
- self._member_id = self._api.user.member_id
34
- self._member_uuid = self._api.user.member_uuid
35
-
36
- async def get_workouts(self) -> WorkoutList:
37
- """Get the list of workouts from OT Live.
38
-
39
- Returns:
40
- WorkoutList: The list of workouts.
41
-
42
- Info:
43
- ---
44
- This returns data from the same api the [OT Live website](https://otlive.orangetheory.com/) uses.
45
- It is quite a bit of data, and all workouts going back to ~2019. The data includes the class history
46
- UUID, which can be used to get telemetry data for a specific workout.
47
- """
48
-
49
- res = await self._api._default_request("GET", "/virtual-class/in-studio-workouts")
50
-
51
- return WorkoutList(workouts=res["data"])
52
-
53
- async def get_total_classes(self) -> TotalClasses:
54
- """Get the member's total classes. This is a simple object reflecting the total number of classes attended,
55
- both in-studio and OT Live.
56
-
57
- Returns:
58
- TotalClasses: The member's total classes.
59
- """
60
- data = await self._api._default_request("GET", "/mobile/v1/members/classes/summary")
61
- return TotalClasses(**data["data"])
62
-
63
- async def get_bookings(
64
- self,
65
- start_date: date | str | None = None,
66
- end_date: date | str | None = None,
67
- status: BookingStatus | None = None,
68
- ) -> BookingList:
69
- """Get the member's bookings.
70
-
71
- Args:
72
- start_date (date | str | None): The start date for the bookings. Default is None.
73
- end_date (date | str | None): The end date for the bookings. Default is None.
74
- status (BookingStatus | None): The status of the bookings to get. Default is None, which includes\
75
- all statuses. Only a single status can be provided.
76
-
77
- Returns:
78
- BookingList: The member's bookings.
79
-
80
- Warning:
81
- ---
82
- Incorrect statuses do not cause any bad status code, they just return no results.
83
-
84
- Tip:
85
- ---
86
- `CheckedIn` - you must provide dates if you want to get bookings with a status of CheckedIn. If you do not
87
- provide dates, the endpoint will return no results for this status.
88
-
89
- Dates Notes:
90
- ---
91
- If dates are provided, the endpoint will return bookings where the class date is within the provided
92
- date range. If no dates are provided, it seems to default to the current month.
93
-
94
- In general, this endpoint does not seem to be able to access bookings older than a certain point. It seems
95
- to be able to go back about 45 days or a month. For current/future dates, it seems to be able to go forward
96
- to as far as you can book classes in the app, which is usually 30 days from today's date.
97
-
98
- Developer Notes:
99
- ---
100
- Looking at the code in the app, it appears that this endpoint accepts multiple statuses. Indeed,
101
- it does not throw an error if you include a list of statuses. However, only the last status in the list is
102
- used. I'm not sure if this is a bug or if the API is supposed to work this way.
103
- """
104
-
105
- if isinstance(start_date, date):
106
- start_date = start_date.isoformat()
107
-
108
- if isinstance(end_date, date):
109
- end_date = end_date.isoformat()
110
-
111
- status_value = status.value if status else None
112
-
113
- params = {"startDate": start_date, "endDate": end_date, "statuses": status_value}
114
-
115
- res = await self._api._default_request("GET", f"/member/members/{self._member_id}/bookings", params=params)
116
-
117
- return BookingList(bookings=res["data"])
118
-
119
- async def _get_bookings_old(self, status: BookingStatus | None = None) -> BookingList:
120
- """Get the member's bookings.
121
-
122
- Args:
123
- status (BookingStatus | None): The status of the bookings to get. Default is None, which includes
124
- all statuses. Only a single status can be provided.
125
-
126
- Returns:
127
- BookingList: The member's bookings.
128
-
129
- Raises:
130
- ValueError: If an unaccepted status is provided.
131
-
132
- Notes:
133
- ---
134
- This one is called with the param named 'status'. Dates cannot be provided, because if the endpoint
135
- receives a date, it will return as if the param name was 'statuses'.
136
-
137
- Note: This seems to only work for Cancelled, Booked, CheckedIn, and Waitlisted statuses. If you provide
138
- a different status, it will return all bookings, not filtered by status. The results in this scenario do
139
- not line up with the `get_bookings` with no status provided, as that returns fewer records. Likely the
140
- filtered dates are different on the backend.
141
-
142
- My guess: the endpoint called with dates and 'statuses' is a "v2" kind of thing, where they upgraded without
143
- changing the version of the api. Calling it with no dates and a singular (limited) status is probably v1.
144
-
145
- I'm leaving this in here for reference, but marking it private. I just don't want to have to puzzle over
146
- this again if I remove it and forget about it.
147
-
148
- """
149
-
150
- if status and status not in [
151
- BookingStatus.Cancelled,
152
- BookingStatus.Booked,
153
- BookingStatus.CheckedIn,
154
- BookingStatus.Waitlisted,
155
- ]:
156
- raise ValueError(
157
- "Invalid status provided. Only Cancelled, Booked, CheckedIn, Waitlisted, and None are supported."
158
- )
159
-
160
- status_value = status.value if status else None
161
-
162
- params = {"status": status_value}
163
-
164
- res = await self._api._default_request("GET", f"/member/members/{self._member_id}/bookings", params=params)
165
-
166
- return BookingList(bookings=res["data"])
167
-
168
- async def get_challenge_tracker_content(self) -> ChallengeTrackerContent:
169
- """Get the member's challenge tracker content.
170
-
171
- Returns:
172
- ChallengeTrackerContent: The member's challenge tracker content.
173
- """
174
- data = await self._api._default_request("GET", f"/challenges/v3.1/member/{self._member_id}")
175
- return ChallengeTrackerContent(**data["Dto"])
176
-
177
- async def get_challenge_tracker_detail(
178
- self, equipment_id: EquipmentType, challenge_type_id: ChallengeType, challenge_sub_type_id: int = 0
179
- ) -> ChallengeTrackerDetailList:
180
- """Get the member's challenge tracker details.
181
-
182
- Args:
183
- equipment_id (EquipmentType): The equipment ID.
184
- challenge_type_id (ChallengeType): The challenge type ID.
185
- challenge_sub_type_id (int): The challenge sub type ID. Default is 0.
186
-
187
- Returns:
188
- ChallengeTrackerDetailList: The member's challenge tracker details.
189
-
190
- Notes:
191
- ---
192
- I'm not sure what the challenge_sub_type_id is supposed to be, so it defaults to 0.
193
-
194
- """
195
- params = {
196
- "equipmentId": equipment_id.value,
197
- "challengeTypeId": challenge_type_id.value,
198
- "challengeSubTypeId": challenge_sub_type_id,
199
- }
200
-
201
- data = await self._api._default_request(
202
- "GET", f"/challenges/v3/member/{self._member_id}/benchmarks", params=params
203
- )
204
-
205
- return ChallengeTrackerDetailList(details=data["Dto"])
206
-
207
- async def get_challenge_tracker_participation(self, challenge_type_id: ChallengeType) -> typing.Any:
208
- """Get the member's participation in a challenge.
209
-
210
- Args:
211
- challenge_type_id (ChallengeType): The challenge type ID.
212
-
213
- Returns:
214
- Any: The member's participation in the challenge.
215
-
216
- Notes:
217
- ---
218
- I've never gotten this to return anything other than invalid response. I'm not sure if it's a bug
219
- in my code or the API.
220
-
221
- """
222
- params = {"challengeTypeId": challenge_type_id.value}
223
-
224
- data = await self._api._default_request(
225
- "GET", f"/challenges/v1/member/{self._member_id}/participation", params=params
226
- )
227
- return data
228
-
229
- async def get_member_detail(
230
- self, include_addresses: bool = True, include_class_summary: bool = True, include_credit_card: bool = False
231
- ) -> MemberDetail:
232
- """Get the member details.
233
-
234
- Args:
235
- include_addresses (bool): Whether to include the member's addresses in the response.
236
- include_class_summary (bool): Whether to include the member's class summary in the response.
237
- include_credit_card (bool): Whether to include the member's credit card information in the response.
238
-
239
- Returns:
240
- MemberDetail: The member details.
241
-
242
-
243
- Notes:
244
- ---
245
- The include_addresses, include_class_summary, and include_credit_card parameters are optional and determine
246
- what additional information is included in the response. By default, all additional information is included,
247
- with the exception of the credit card information.
248
-
249
- The base member details include the last four of a credit card regardless of the include_credit_card,
250
- although this is not always the same details as what is in the member_credit_card field. There doesn't seem
251
- to be a way to exclude this information, and I do not know which is which or why they differ.
252
- """
253
-
254
- include: list[str] = []
255
- if include_addresses:
256
- include.append("memberAddresses")
257
-
258
- if include_class_summary:
259
- include.append("memberClassSummary")
260
-
261
- if include_credit_card:
262
- include.append("memberCreditCard")
263
-
264
- params = {"include": ",".join(include)} if include else None
265
-
266
- data = await self._api._default_request("GET", f"/member/members/{self._member_id}", params=params)
267
- return MemberDetail(**data["data"])
268
-
269
- async def get_member_membership(self) -> MemberMembership:
270
- """Get the member's membership details.
271
-
272
- Returns:
273
- MemberMembership: The member's membership details.
274
- """
275
-
276
- data = await self._api._default_request("GET", f"/member/members/{self._member_id}/memberships")
277
- return MemberMembership(**data["data"])
278
-
279
- async def get_member_purchases(self) -> MemberPurchaseList:
280
- """Get the member's purchases, including monthly subscriptions and class packs.
281
-
282
- Returns:
283
- MemberPurchaseList: The member's purchases.
284
- """
285
- data = await self._api._default_request("GET", f"/member/members/{self._member_id}/purchases")
286
- return MemberPurchaseList(data=data["data"])
287
-
288
- async def get_out_of_studio_workout_history(self) -> OutOfStudioWorkoutHistoryList:
289
- """Get the member's out of studio workout history.
290
-
291
- Returns:
292
- OutOfStudioWorkoutHistoryList: The member's out of studio workout history.
293
- """
294
- data = await self._api._default_request("GET", f"/member/members/{self._member_id}/out-of-studio-workout")
295
-
296
- return OutOfStudioWorkoutHistoryList(data=data["data"])
297
-
298
- async def get_favorite_studios(self) -> FavoriteStudioList:
299
- """Get the member's favorite studios.
300
-
301
- Returns:
302
- FavoriteStudioList: The member's favorite studios.
303
- """
304
- data = await self._api._default_request("GET", f"/member/members/{self._member_id}/favorite-studios")
305
-
306
- return FavoriteStudioList(studios=data["data"])
307
-
308
- async def get_latest_agreement(self) -> LatestAgreement:
309
- """Get the latest agreement for the member.
310
-
311
- Returns:
312
- LatestAgreement: The agreement.
313
-
314
- Notes:
315
- ---
316
- In this context, "latest" means the most recent agreement with a specific ID, not the most recent agreement
317
- in general. The agreement ID is hardcoded in the endpoint, so it will always return the same agreement.
318
- """
319
- data = await self._api._default_request("GET", "/member/agreements/9d98fb27-0f00-4598-ad08-5b1655a59af6")
320
- return LatestAgreement(**data["data"])
321
-
322
- async def get_studio_services(self, studio_uuid: str | None = None) -> StudioServiceList:
323
- """Get the services available at a specific studio. If no studio UUID is provided, the member's home studio
324
- will be used.
325
-
326
- Args:
327
- studio_uuid (str): The studio UUID to get services for. Default is None, which will use the member's home\
328
- studio.
329
-
330
- Returns:
331
- StudioServiceList: The services available at the studio.
332
- """
333
- studio_uuid = studio_uuid or self._api.home_studio.studio_uuid
334
- data = await self._api._default_request("GET", f"/member/studios/{studio_uuid}/services")
335
- return StudioServiceList(data=data["data"])
336
-
337
- # the below do not return any data for me, so I can't test them
338
-
339
- async def _get_member_services(self, active_only: bool = True) -> typing.Any:
340
- """Get the member's services.
341
-
342
- Args:
343
- active_only (bool): Whether to only include active services. Default is True.
344
-
345
- Returns:
346
- Any: The member's service
347
- ."""
348
- active_only_str = "true" if active_only else "false"
349
- data = await self._api._default_request(
350
- "GET", f"/member/members/{self._member_id}/services", params={"activeOnly": active_only_str}
351
- )
352
- return data
353
-
354
- async def _get_aspire_data(self, datetime: str | None = None, unit: str | None = None) -> typing.Any:
355
- """Get data from the member's aspire wearable.
356
-
357
- Note: I don't have an aspire wearable, so I can't test this.
358
-
359
- Args:
360
- datetime (str | None): The date and time to get data for. Default is None.
361
- unit (str | None): The measurement unit. Default is None.
362
-
363
- Returns:
364
- Any: The member's aspire data.
365
- """
366
- params = {"datetime": datetime, "unit": unit}
367
-
368
- data = self._api._default_request("GET", f"/member/wearables/{self._member_id}/wearable-daily", params=params)
369
- return data
370
-
371
- async def _get_body_composition_list(self) -> typing.Any:
372
- """Get the member's body composition list.
373
-
374
- Note: I don't have body composition data, so I can't test this.
375
-
376
- Returns:
377
- Any: The member's body composition list.
378
- """
379
- data = await self._api._default_request("GET", f"/member/members/{self._member_uuid}/body-composition")
380
- return data
otf_api/models/auth.py DELETED
@@ -1,141 +0,0 @@
1
- import json
2
- from pathlib import Path
3
- from typing import ClassVar
4
-
5
- from pycognito import Cognito, TokenVerificationException
6
- from pydantic import Field
7
-
8
- from otf_api.models.base import OtfBaseModel
9
-
10
- CLIENT_ID = "65knvqta6p37efc2l3eh26pl5o" # from otlive
11
- USER_POOL_ID = "us-east-1_dYDxUeyL1"
12
-
13
-
14
- class IdClaimsData(OtfBaseModel):
15
- sub: str
16
- email_verified: bool
17
- iss: str
18
- cognito_username: str = Field(alias="cognito:username")
19
- given_name: str
20
- locale: str
21
- home_studio_id: str = Field(alias="custom:home_studio_id")
22
- aud: str
23
- event_id: str
24
- token_use: str
25
- auth_time: int
26
- exp: int
27
- is_migration: str = Field(alias="custom:isMigration")
28
- iat: int
29
- family_name: str
30
- email: str
31
-
32
- @property
33
- def member_uuid(self) -> str:
34
- return self.cognito_username
35
-
36
- @property
37
- def full_name(self) -> str:
38
- return f"{self.given_name} {self.family_name}"
39
-
40
-
41
- class AccessClaimsData(OtfBaseModel):
42
- sub: str
43
- device_key: str
44
- iss: str
45
- client_id: str
46
- event_id: str
47
- token_use: str
48
- scope: str
49
- auth_time: int
50
- exp: int
51
- iat: int
52
- jti: str
53
- username: str
54
-
55
- @property
56
- def member_uuid(self) -> str:
57
- return self.username
58
-
59
-
60
- class User:
61
- token_path: ClassVar[Path] = Path("~/.otf/.tokens").expanduser()
62
- cognito: Cognito
63
-
64
- def __init__(self, cognito: Cognito):
65
- self.cognito = cognito
66
-
67
- @property
68
- def member_id(self) -> str:
69
- return self.id_claims_data.cognito_username
70
-
71
- @property
72
- def member_uuid(self) -> str:
73
- return self.access_claims_data.sub
74
-
75
- @property
76
- def access_claims_data(self) -> AccessClaimsData:
77
- return AccessClaimsData(**self.cognito.access_claims)
78
-
79
- @property
80
- def id_claims_data(self) -> IdClaimsData:
81
- return IdClaimsData(**self.cognito.id_claims)
82
-
83
- def save_to_disk(self) -> None:
84
- self.token_path.parent.mkdir(parents=True, exist_ok=True)
85
- data = {
86
- "username": self.cognito.username,
87
- "id_token": self.cognito.id_token,
88
- "access_token": self.cognito.access_token,
89
- "refresh_token": self.cognito.refresh_token,
90
- }
91
- self.token_path.write_text(json.dumps(data))
92
-
93
- @classmethod
94
- def load_from_disk(cls, username: str | None = None, password: str | None = None) -> "User":
95
- """Load a User instance from disk. If the token is invalid, reauthenticate with the provided credentials.
96
-
97
- Args:
98
- username (str | None): The username to reauthenticate with.
99
- password (str | None): The password to reauthenticate with.
100
-
101
- Returns:
102
- User: The loaded user.
103
-
104
- Raises:
105
- ValueError: If the token is invalid and no username and password are provided.
106
- """
107
- attr_dict = json.loads(cls.token_path.read_text())
108
-
109
- cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, **attr_dict)
110
- try:
111
- cognito_user.verify_tokens()
112
- return cls(cognito=cognito_user)
113
- except TokenVerificationException:
114
- if username and password:
115
- user = cls.login(username, password)
116
- return user
117
- raise
118
-
119
- @classmethod
120
- def login(cls, username: str, password: str) -> "User":
121
- """Login and return a User instance. After a successful login, the user is saved to disk.
122
-
123
- Args:
124
- username (str): The username to login with.
125
- password (str): The password to login with.
126
-
127
- Returns:
128
- User: The logged in user.
129
- """
130
- cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, username=username)
131
- cognito_user.authenticate(password)
132
- cognito_user.check_token()
133
- user = cls(cognito=cognito_user)
134
- user.save_to_disk()
135
- return user
136
-
137
- def refresh_token(self) -> "User":
138
- """Refresh the user's access token."""
139
- self.cognito.check_token()
140
- self.save_to_disk()
141
- return self
@@ -1,54 +0,0 @@
1
- import typing
2
-
3
- from otf_api.models.responses.performance_summary_detail import PerformanceSummaryDetail
4
- from otf_api.models.responses.performance_summary_list import PerformanceSummaryList
5
-
6
- if typing.TYPE_CHECKING:
7
- from otf_api import Api
8
-
9
-
10
- class PerformanceApi:
11
- def __init__(self, api: "Api"):
12
- self._api = api
13
- self.logger = api.logger
14
-
15
- # simplify access to member_id and member_uuid
16
- self._member_id = self._api.user.member_id
17
- self._member_uuid = self._api.user.member_uuid
18
- self._headers = {"koji-member-id": self._member_id, "koji-member-email": self._api.user.id_claims_data.email}
19
-
20
- async def get_performance_summaries(self, limit: int = 30) -> PerformanceSummaryList:
21
- """Get a list of performance summaries for the authenticated user.
22
-
23
- Args:
24
- limit (int): The maximum number of performance summaries to return. Defaults to 30.
25
-
26
- Returns:
27
- PerformanceSummaryList: A list of performance summaries.
28
-
29
- Developer Notes:
30
- ---
31
- In the app, this is referred to as 'getInStudioWorkoutHistory'.
32
-
33
- """
34
-
35
- path = "/v1/performance-summaries"
36
- params = {"limit": limit}
37
- res = await self._api._performance_summary_request("GET", path, headers=self._headers, params=params)
38
- retval = PerformanceSummaryList(summaries=res["items"])
39
- return retval
40
-
41
- async def get_performance_summary(self, performance_summary_id: str) -> PerformanceSummaryDetail:
42
- """Get a detailed performance summary for a given workout.
43
-
44
- Args:
45
- performance_summary_id (str): The ID of the performance summary to retrieve.
46
-
47
- Returns:
48
- PerformanceSummaryDetail: A detailed performance summary.
49
- """
50
-
51
- path = f"/v1/performance-summaries/{performance_summary_id}"
52
- res = await self._api._performance_summary_request("GET", path, headers=self._headers)
53
- retval = PerformanceSummaryDetail(**res)
54
- return retval
otf_api/studios_api.py DELETED
@@ -1,96 +0,0 @@
1
- import typing
2
-
3
- from otf_api.models.responses.studio_detail import Pagination, StudioDetail, StudioDetailList
4
-
5
- if typing.TYPE_CHECKING:
6
- from otf_api import Api
7
-
8
-
9
- class StudiosApi:
10
- def __init__(self, api: "Api"):
11
- self._api = api
12
- self.logger = api.logger
13
-
14
- # simplify access to member_id and member_uuid
15
- self._member_id = self._api.user.member_id
16
- self._member_uuid = self._api.user.member_uuid
17
-
18
- async def get_studio_detail(self, studio_uuid: str | None = None) -> StudioDetail:
19
- """Get detailed information about a specific studio. If no studio UUID is provided, it will default to the
20
- user's home studio.
21
-
22
- Args:
23
- studio_uuid (str): Studio UUID to get details for. Defaults to None, which will default to the user's home\
24
- studio.
25
-
26
- Returns:
27
- StudioDetail: Detailed information about the studio.
28
- """
29
- studio_uuid = studio_uuid or self._api.home_studio.studio_uuid
30
-
31
- path = f"/mobile/v1/studios/{studio_uuid}"
32
- params = {"include": "locations"}
33
-
34
- res = await self._api._default_request("GET", path, params=params)
35
- return StudioDetail(**res["data"])
36
-
37
- async def search_studios_by_geo(
38
- self,
39
- latitude: float | None = None,
40
- longitude: float | None = None,
41
- distance: float = 50,
42
- page_index: int = 1,
43
- page_size: int = 50,
44
- ) -> StudioDetailList:
45
- """Search for studios by geographic location.
46
-
47
- Args:
48
- latitude (float, optional): Latitude of the location to search around, if None uses home studio latitude.
49
- longitude (float, optional): Longitude of the location to search around, if None uses home studio longitude.
50
- distance (float, optional): Distance in miles to search around the location. Defaults to 50.
51
- page_index (int, optional): Page index to start at. Defaults to 1.
52
- page_size (int, optional): Number of results per page. Defaults to 50.
53
-
54
- Returns:
55
- StudioDetailList: List of studios that match the search criteria.
56
-
57
- Notes:
58
- ---
59
- There does not seem to be a limit to the number of results that can be requested total or per page, the
60
- library enforces a limit of 50 results per page to avoid potential rate limiting issues.
61
-
62
- """
63
- path = "/mobile/v1/studios"
64
-
65
- latitude = latitude or self._api.home_studio.studio_location.latitude
66
- longitude = longitude or self._api.home_studio.studio_location.longitude
67
-
68
- if page_size > 50:
69
- self.logger.warning("The API does not support more than 50 results per page, limiting to 50.")
70
- page_size = 50
71
-
72
- if page_index < 1:
73
- self.logger.warning("Page index must be greater than 0, setting to 1.")
74
- page_index = 1
75
-
76
- params = {
77
- "pageIndex": page_index,
78
- "pageSize": page_size,
79
- "latitude": latitude,
80
- "longitude": longitude,
81
- "distance": distance,
82
- }
83
-
84
- all_results: list[StudioDetail] = []
85
-
86
- while True:
87
- res = await self._api._default_request("GET", path, params=params)
88
- pagination = Pagination(**res["data"].pop("pagination"))
89
- all_results.extend([StudioDetail(**studio) for studio in res["data"]["studios"]])
90
-
91
- if len(all_results) == pagination.total_count:
92
- break
93
-
94
- params["pageIndex"] += 1
95
-
96
- return StudioDetailList(studios=all_results)