otf-api 0.6.4__tar.gz → 0.7.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {otf_api-0.6.4 → otf_api-0.7.1}/PKG-INFO +3 -3
- {otf_api-0.6.4 → otf_api-0.7.1}/README.md +2 -0
- {otf_api-0.6.4 → otf_api-0.7.1}/pyproject.toml +1 -4
- otf_api-0.7.1/src/otf_api/__init__.py +7 -0
- {otf_api-0.6.4 → otf_api-0.7.1}/src/otf_api/api.py +76 -111
- {otf_api-0.6.4 → otf_api-0.7.1}/src/otf_api/auth.py +11 -10
- otf_api-0.7.1/src/otf_api/exceptions.py +18 -0
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/__init__.py +6 -4
- otf_api-0.7.1/src/otf_api/models/base.py +7 -0
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/body_composition_list.py +3 -14
- otf_api-0.7.1/src/otf_api/models/book_class.py +89 -0
- otf_api-0.7.1/src/otf_api/models/bookings.py +119 -0
- otf_api-0.7.1/src/otf_api/models/cancel_booking.py +49 -0
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/challenge_tracker_detail.py +12 -13
- otf_api-0.7.1/src/otf_api/models/classes.py +80 -0
- otf_api-0.7.1/src/otf_api/models/enums.py +87 -0
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/favorite_studios.py +17 -19
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/lifetime_stats.py +0 -18
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/member_detail.py +16 -19
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/member_purchases.py +10 -10
- otf_api-0.7.1/src/otf_api/models/mixins.py +44 -0
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/out_of_studio_workout_history.py +3 -3
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/performance_summary_list.py +2 -5
- otf_api-0.7.1/src/otf_api/models/studio_detail.py +109 -0
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/studio_services.py +2 -2
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/telemetry.py +1 -1
- otf_api-0.6.4/src/otf_api/__init__.py +0 -15
- otf_api-0.6.4/src/otf_api/models/__init__.py +0 -66
- otf_api-0.6.4/src/otf_api/models/base.py +0 -22
- otf_api-0.6.4/src/otf_api/models/responses/book_class.py +0 -407
- otf_api-0.6.4/src/otf_api/models/responses/bookings.py +0 -160
- otf_api-0.6.4/src/otf_api/models/responses/cancel_booking.py +0 -95
- otf_api-0.6.4/src/otf_api/models/responses/classes.py +0 -148
- otf_api-0.6.4/src/otf_api/models/responses/enums.py +0 -23
- otf_api-0.6.4/src/otf_api/models/responses/studio_detail.py +0 -113
- {otf_api-0.6.4 → otf_api-0.7.1}/AUTHORS.md +0 -0
- {otf_api-0.6.4 → otf_api-0.7.1}/LICENSE +0 -0
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/challenge_tracker_content.py +0 -0
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/latest_agreement.py +0 -0
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/member_membership.py +0 -0
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/performance_summary_detail.py +0 -0
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/telemetry_hr_history.py +0 -0
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/telemetry_max_hr.py +0 -0
- {otf_api-0.6.4/src/otf_api/models/responses → otf_api-0.7.1/src/otf_api/models}/total_classes.py +0 -0
- {otf_api-0.6.4 → otf_api-0.7.1}/src/otf_api/py.typed +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: otf-api
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.7.1
|
4
4
|
Summary: Python OrangeTheory Fitness API Client
|
5
5
|
License: MIT
|
6
6
|
Author: Jessica Smith
|
@@ -21,8 +21,6 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
21
|
Requires-Dist: aiohttp (==3.10.*)
|
22
22
|
Requires-Dist: humanize (>=4.9.0,<5.0.0)
|
23
23
|
Requires-Dist: inflection (==0.5.*)
|
24
|
-
Requires-Dist: loguru (==0.7.2)
|
25
|
-
Requires-Dist: pendulum (>=3.0.0,<4.0.0)
|
26
24
|
Requires-Dist: pint (==0.24.*)
|
27
25
|
Requires-Dist: pycognito (==2024.5.1)
|
28
26
|
Requires-Dist: pydantic (==2.7.3)
|
@@ -31,6 +29,8 @@ Description-Content-Type: text/markdown
|
|
31
29
|
|
32
30
|
Simple API client for interacting with the OrangeTheory Fitness APIs.
|
33
31
|
|
32
|
+
Review the [documentation](https://otf-api.readthedocs.io/en/stable/).
|
33
|
+
|
34
34
|
|
35
35
|
This library allows access to the OrangeTheory API to retrieve workouts and performance data, class schedules, studio information, and bookings.
|
36
36
|
|
@@ -1,5 +1,7 @@
|
|
1
1
|
Simple API client for interacting with the OrangeTheory Fitness APIs.
|
2
2
|
|
3
|
+
Review the [documentation](https://otf-api.readthedocs.io/en/stable/).
|
4
|
+
|
3
5
|
|
4
6
|
This library allows access to the OrangeTheory API to retrieve workouts and performance data, class schedules, studio information, and bookings.
|
5
7
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[tool.poetry]
|
2
2
|
name = "otf-api"
|
3
|
-
version = "0.
|
3
|
+
version = "0.7.1"
|
4
4
|
description = "Python OrangeTheory Fitness API Client"
|
5
5
|
authors = ["Jessica Smith <j.smith.git1@gmail.com>"]
|
6
6
|
license = "MIT"
|
@@ -25,8 +25,6 @@ python = "^3.10"
|
|
25
25
|
aiohttp = "3.10.*"
|
26
26
|
humanize = "^4.9.0"
|
27
27
|
inflection = "0.5.*"
|
28
|
-
loguru = "0.7.2"
|
29
|
-
pendulum = "^3.0.0"
|
30
28
|
pint = "0.24.*"
|
31
29
|
pycognito = "2024.5.1"
|
32
30
|
pydantic = "2.7.3"
|
@@ -43,7 +41,6 @@ pytest = "8.2.2"
|
|
43
41
|
pytest-asyncio = "0.23.7"
|
44
42
|
pytest-cov = "5.0.0"
|
45
43
|
pytest-loguru = "0.4.0"
|
46
|
-
ruff = "0.4.9"
|
47
44
|
tox = "4.15.1"
|
48
45
|
twine = "5.1.1"
|
49
46
|
|
@@ -10,44 +10,9 @@ import requests
|
|
10
10
|
from loguru import logger
|
11
11
|
from yarl import URL
|
12
12
|
|
13
|
+
from otf_api import models
|
13
14
|
from otf_api.auth import OtfUser
|
14
|
-
from otf_api.
|
15
|
-
BodyCompositionList,
|
16
|
-
BookClass,
|
17
|
-
BookingList,
|
18
|
-
BookingStatus,
|
19
|
-
CancelBooking,
|
20
|
-
ChallengeTrackerContent,
|
21
|
-
ChallengeTrackerDetailList,
|
22
|
-
ChallengeType,
|
23
|
-
ClassType,
|
24
|
-
DoW,
|
25
|
-
EquipmentType,
|
26
|
-
FavoriteStudioList,
|
27
|
-
LatestAgreement,
|
28
|
-
MemberDetail,
|
29
|
-
MemberMembership,
|
30
|
-
MemberPurchaseList,
|
31
|
-
OtfClassList,
|
32
|
-
OutOfStudioWorkoutHistoryList,
|
33
|
-
Pagination,
|
34
|
-
PerformanceSummaryDetail,
|
35
|
-
PerformanceSummaryList,
|
36
|
-
StatsResponse,
|
37
|
-
StatsTime,
|
38
|
-
StudioDetail,
|
39
|
-
StudioDetailList,
|
40
|
-
StudioServiceList,
|
41
|
-
Telemetry,
|
42
|
-
TelemetryHrHistory,
|
43
|
-
TelemetryMaxHr,
|
44
|
-
TotalClasses,
|
45
|
-
)
|
46
|
-
|
47
|
-
|
48
|
-
class AlreadyBookedError(Exception):
|
49
|
-
pass
|
50
|
-
|
15
|
+
from otf_api.exceptions import AlreadyBookedError
|
51
16
|
|
52
17
|
if typing.TYPE_CHECKING:
|
53
18
|
from loguru import Logger
|
@@ -92,7 +57,7 @@ class Otf:
|
|
92
57
|
user (OtfUser, optional): A user object. Default is None.
|
93
58
|
"""
|
94
59
|
|
95
|
-
self.member: MemberDetail
|
60
|
+
self.member: models.MemberDetail
|
96
61
|
self.home_studio_uuid: str
|
97
62
|
|
98
63
|
if user:
|
@@ -119,7 +84,7 @@ class Otf:
|
|
119
84
|
self.member = self._get_member_details_sync()
|
120
85
|
self.home_studio_uuid = self.member.home_studio.studio_uuid
|
121
86
|
|
122
|
-
def _get_member_details_sync(self)
|
87
|
+
def _get_member_details_sync(self):
|
123
88
|
"""Get the member details synchronously.
|
124
89
|
|
125
90
|
This is used to get the member details when the API is first initialized, to let use initialize
|
@@ -130,10 +95,10 @@ class Otf:
|
|
130
95
|
"""
|
131
96
|
url = f"https://{API_BASE_URL}/member/members/{self._member_id}"
|
132
97
|
resp = requests.get(url, headers=self.headers)
|
133
|
-
return MemberDetail(**resp.json()["data"])
|
98
|
+
return models.MemberDetail(**resp.json()["data"])
|
134
99
|
|
135
100
|
@property
|
136
|
-
def headers(self)
|
101
|
+
def headers(self):
|
137
102
|
"""Get the headers for the API request."""
|
138
103
|
|
139
104
|
# check the token before making a request in case it has expired
|
@@ -146,7 +111,7 @@ class Otf:
|
|
146
111
|
}
|
147
112
|
|
148
113
|
@property
|
149
|
-
def session(self)
|
114
|
+
def session(self):
|
150
115
|
"""Get the aiohttp session."""
|
151
116
|
if not getattr(self, "_session", None):
|
152
117
|
self._session = aiohttp.ClientSession(headers=self.headers)
|
@@ -224,7 +189,7 @@ class Otf:
|
|
224
189
|
"""Perform an API request to the performance summary API."""
|
225
190
|
return await self._do(method, API_IO_BASE_URL, url, params, headers)
|
226
191
|
|
227
|
-
async def get_body_composition_list(self)
|
192
|
+
async def get_body_composition_list(self):
|
228
193
|
"""Get the member's body composition list.
|
229
194
|
|
230
195
|
Returns:
|
@@ -232,7 +197,7 @@ class Otf:
|
|
232
197
|
"""
|
233
198
|
data = await self._default_request("GET", f"/member/members/{self._member_uuid}/body-composition")
|
234
199
|
|
235
|
-
return BodyCompositionList(data=data["data"])
|
200
|
+
return models.BodyCompositionList(data=data["data"])
|
236
201
|
|
237
202
|
async def get_classes(
|
238
203
|
self,
|
@@ -241,11 +206,11 @@ class Otf:
|
|
241
206
|
start_date: str | None = None,
|
242
207
|
end_date: str | None = None,
|
243
208
|
limit: int | None = None,
|
244
|
-
class_type: ClassType | list[ClassType] | None = None,
|
209
|
+
class_type: models.ClassType | list[models.ClassType] | None = None,
|
245
210
|
exclude_cancelled: bool = False,
|
246
|
-
day_of_week: list[DoW] | None = None,
|
211
|
+
day_of_week: list[models.DoW] | None = None,
|
247
212
|
start_time: list[str] | None = None,
|
248
|
-
)
|
213
|
+
):
|
249
214
|
"""Get the classes for the user.
|
250
215
|
|
251
216
|
Returns a list of classes that are available for the user, based on the studio UUIDs provided. If no studio
|
@@ -278,7 +243,7 @@ class Otf:
|
|
278
243
|
params = {"studio_ids": studio_uuids}
|
279
244
|
|
280
245
|
classes_resp = await self._classes_request("GET", path, params=params)
|
281
|
-
classes_list = OtfClassList(classes=classes_resp["items"])
|
246
|
+
classes_list = models.OtfClassList(classes=classes_resp["items"])
|
282
247
|
|
283
248
|
if start_date:
|
284
249
|
start_dtme = datetime.strptime(start_date, "%Y-%m-%d") # noqa
|
@@ -319,7 +284,7 @@ class Otf:
|
|
319
284
|
|
320
285
|
classes_list.classes = list(filter(lambda c: not c.canceled, classes_list.classes))
|
321
286
|
|
322
|
-
booking_resp = await self.get_bookings(start_date, end_date, status=BookingStatus.Booked)
|
287
|
+
booking_resp = await self.get_bookings(start_date, end_date, status=models.BookingStatus.Booked)
|
323
288
|
booked_classes = {b.otf_class.class_uuid for b in booking_resp.bookings}
|
324
289
|
|
325
290
|
for otf_class in classes_list.classes:
|
@@ -327,7 +292,7 @@ class Otf:
|
|
327
292
|
|
328
293
|
return classes_list
|
329
294
|
|
330
|
-
async def get_total_classes(self)
|
295
|
+
async def get_total_classes(self):
|
331
296
|
"""Get the member's total classes. This is a simple object reflecting the total number of classes attended,
|
332
297
|
both in-studio and OT Live.
|
333
298
|
|
@@ -335,9 +300,9 @@ class Otf:
|
|
335
300
|
TotalClasses: The member's total classes.
|
336
301
|
"""
|
337
302
|
data = await self._default_request("GET", "/mobile/v1/members/classes/summary")
|
338
|
-
return TotalClasses(**data["data"])
|
303
|
+
return models.TotalClasses(**data["data"])
|
339
304
|
|
340
|
-
async def book_class(self, class_uuid: str)
|
305
|
+
async def book_class(self, class_uuid: str):
|
341
306
|
"""Book a class by class_uuid.
|
342
307
|
|
343
308
|
Args:
|
@@ -362,10 +327,10 @@ class Otf:
|
|
362
327
|
raise AlreadyBookedError(f"Class {class_uuid} is already booked.")
|
363
328
|
raise Exception(f"Error booking class {class_uuid}: {json.dumps(resp)}")
|
364
329
|
|
365
|
-
data = BookClass(**resp["data"])
|
330
|
+
data = models.BookClass(**resp["data"])
|
366
331
|
return data
|
367
332
|
|
368
|
-
async def cancel_booking(self, booking_uuid: str)
|
333
|
+
async def cancel_booking(self, booking_uuid: str):
|
369
334
|
"""Cancel a class by booking_uuid.
|
370
335
|
|
371
336
|
Args:
|
@@ -379,17 +344,17 @@ class Otf:
|
|
379
344
|
resp = await self._default_request(
|
380
345
|
"DELETE", f"/member/members/{self._member_id}/bookings/{booking_uuid}", params=params
|
381
346
|
)
|
382
|
-
return CancelBooking(**resp["data"])
|
347
|
+
return models.CancelBooking(**resp["data"])
|
383
348
|
|
384
349
|
async def get_bookings(
|
385
350
|
self,
|
386
351
|
start_date: date | str | None = None,
|
387
352
|
end_date: date | str | None = None,
|
388
|
-
status: BookingStatus | None = None,
|
353
|
+
status: models.BookingStatus | None = None,
|
389
354
|
limit: int | None = None,
|
390
355
|
exclude_cancelled: bool = True,
|
391
356
|
exclude_checkedin: bool = True,
|
392
|
-
)
|
357
|
+
):
|
393
358
|
"""Get the member's bookings.
|
394
359
|
|
395
360
|
Args:
|
@@ -426,7 +391,7 @@ class Otf:
|
|
426
391
|
used. I'm not sure if this is a bug or if the API is supposed to work this way.
|
427
392
|
"""
|
428
393
|
|
429
|
-
if exclude_cancelled and status == BookingStatus.Cancelled:
|
394
|
+
if exclude_cancelled and status == models.BookingStatus.Cancelled:
|
430
395
|
logger.warning(
|
431
396
|
"Cannot exclude cancelled bookings when status is Cancelled. Setting exclude_cancelled to False."
|
432
397
|
)
|
@@ -446,7 +411,7 @@ class Otf:
|
|
446
411
|
|
447
412
|
bookings = res["data"][:limit] if limit else res["data"]
|
448
413
|
|
449
|
-
data = BookingList(bookings=bookings)
|
414
|
+
data = models.BookingList(bookings=bookings)
|
450
415
|
data.bookings = sorted(data.bookings, key=lambda x: x.otf_class.starts_at_local)
|
451
416
|
|
452
417
|
for booking in data.bookings:
|
@@ -458,14 +423,14 @@ class Otf:
|
|
458
423
|
booking.is_home_studio = False
|
459
424
|
|
460
425
|
if exclude_cancelled:
|
461
|
-
data.bookings = [b for b in data.bookings if b.status != BookingStatus.Cancelled]
|
426
|
+
data.bookings = [b for b in data.bookings if b.status != models.BookingStatus.Cancelled]
|
462
427
|
|
463
428
|
if exclude_checkedin:
|
464
|
-
data.bookings = [b for b in data.bookings if b.status != BookingStatus.CheckedIn]
|
429
|
+
data.bookings = [b for b in data.bookings if b.status != models.BookingStatus.CheckedIn]
|
465
430
|
|
466
431
|
return data
|
467
432
|
|
468
|
-
async def _get_bookings_old(self, status: BookingStatus | None = None)
|
433
|
+
async def _get_bookings_old(self, status: models.BookingStatus | None = None):
|
469
434
|
"""Get the member's bookings.
|
470
435
|
|
471
436
|
Args:
|
@@ -497,10 +462,10 @@ class Otf:
|
|
497
462
|
"""
|
498
463
|
|
499
464
|
if status and status not in [
|
500
|
-
BookingStatus.Cancelled,
|
501
|
-
BookingStatus.Booked,
|
502
|
-
BookingStatus.CheckedIn,
|
503
|
-
BookingStatus.Waitlisted,
|
465
|
+
models.BookingStatus.Cancelled,
|
466
|
+
models.BookingStatus.Booked,
|
467
|
+
models.BookingStatus.CheckedIn,
|
468
|
+
models.BookingStatus.Waitlisted,
|
504
469
|
]:
|
505
470
|
raise ValueError(
|
506
471
|
"Invalid status provided. Only Cancelled, Booked, CheckedIn, Waitlisted, and None are supported."
|
@@ -512,20 +477,23 @@ class Otf:
|
|
512
477
|
|
513
478
|
res = await self._default_request("GET", f"/member/members/{self._member_id}/bookings", params=params)
|
514
479
|
|
515
|
-
return BookingList(bookings=res["data"])
|
480
|
+
return models.BookingList(bookings=res["data"])
|
516
481
|
|
517
|
-
async def get_challenge_tracker_content(self)
|
482
|
+
async def get_challenge_tracker_content(self):
|
518
483
|
"""Get the member's challenge tracker content.
|
519
484
|
|
520
485
|
Returns:
|
521
486
|
ChallengeTrackerContent: The member's challenge tracker content.
|
522
487
|
"""
|
523
488
|
data = await self._default_request("GET", f"/challenges/v3.1/member/{self._member_id}")
|
524
|
-
return ChallengeTrackerContent(**data["Dto"])
|
489
|
+
return models.ChallengeTrackerContent(**data["Dto"])
|
525
490
|
|
526
491
|
async def get_challenge_tracker_detail(
|
527
|
-
self,
|
528
|
-
|
492
|
+
self,
|
493
|
+
equipment_id: models.EquipmentType,
|
494
|
+
challenge_type_id: models.ChallengeType,
|
495
|
+
challenge_sub_type_id: int = 0,
|
496
|
+
):
|
529
497
|
"""Get the member's challenge tracker details.
|
530
498
|
|
531
499
|
Args:
|
@@ -549,9 +517,9 @@ class Otf:
|
|
549
517
|
|
550
518
|
data = await self._default_request("GET", f"/challenges/v3/member/{self._member_id}/benchmarks", params=params)
|
551
519
|
|
552
|
-
return ChallengeTrackerDetailList(details=data["Dto"])
|
520
|
+
return models.ChallengeTrackerDetailList(details=data["Dto"])
|
553
521
|
|
554
|
-
async def get_challenge_tracker_participation(self, challenge_type_id: ChallengeType)
|
522
|
+
async def get_challenge_tracker_participation(self, challenge_type_id: models.ChallengeType):
|
555
523
|
"""Get the member's participation in a challenge.
|
556
524
|
|
557
525
|
Args:
|
@@ -575,7 +543,7 @@ class Otf:
|
|
575
543
|
|
576
544
|
async def get_member_detail(
|
577
545
|
self, include_addresses: bool = True, include_class_summary: bool = True, include_credit_card: bool = False
|
578
|
-
)
|
546
|
+
):
|
579
547
|
"""Get the member details.
|
580
548
|
|
581
549
|
Args:
|
@@ -611,9 +579,9 @@ class Otf:
|
|
611
579
|
params = {"include": ",".join(include)} if include else None
|
612
580
|
|
613
581
|
data = await self._default_request("GET", f"/member/members/{self._member_id}", params=params)
|
614
|
-
return MemberDetail(**data["data"])
|
582
|
+
return models.MemberDetail(**data["data"])
|
615
583
|
|
616
|
-
async def get_member_membership(self)
|
584
|
+
async def get_member_membership(self):
|
617
585
|
"""Get the member's membership details.
|
618
586
|
|
619
587
|
Returns:
|
@@ -621,18 +589,18 @@ class Otf:
|
|
621
589
|
"""
|
622
590
|
|
623
591
|
data = await self._default_request("GET", f"/member/members/{self._member_id}/memberships")
|
624
|
-
return MemberMembership(**data["data"])
|
592
|
+
return models.MemberMembership(**data["data"])
|
625
593
|
|
626
|
-
async def get_member_purchases(self)
|
594
|
+
async def get_member_purchases(self):
|
627
595
|
"""Get the member's purchases, including monthly subscriptions and class packs.
|
628
596
|
|
629
597
|
Returns:
|
630
598
|
MemberPurchaseList: The member's purchases.
|
631
599
|
"""
|
632
600
|
data = await self._default_request("GET", f"/member/members/{self._member_id}/purchases")
|
633
|
-
return MemberPurchaseList(data=data["data"])
|
601
|
+
return models.MemberPurchaseList(data=data["data"])
|
634
602
|
|
635
|
-
async def get_member_lifetime_stats(self, select_time: StatsTime = StatsTime.AllTime)
|
603
|
+
async def get_member_lifetime_stats(self, select_time: models.StatsTime = models.StatsTime.AllTime):
|
636
604
|
"""Get the member's lifetime stats.
|
637
605
|
|
638
606
|
Args:
|
@@ -649,10 +617,9 @@ class Otf:
|
|
649
617
|
|
650
618
|
data = await self._default_request("GET", f"/performance/v2/{self._member_id}/over-time/{select_time.value}")
|
651
619
|
|
652
|
-
|
653
|
-
return stats
|
620
|
+
return models.StatsResponse(**data["data"])
|
654
621
|
|
655
|
-
async def get_out_of_studio_workout_history(self)
|
622
|
+
async def get_out_of_studio_workout_history(self):
|
656
623
|
"""Get the member's out of studio workout history.
|
657
624
|
|
658
625
|
Returns:
|
@@ -660,9 +627,9 @@ class Otf:
|
|
660
627
|
"""
|
661
628
|
data = await self._default_request("GET", f"/member/members/{self._member_id}/out-of-studio-workout")
|
662
629
|
|
663
|
-
return OutOfStudioWorkoutHistoryList(data=data["data"])
|
630
|
+
return models.OutOfStudioWorkoutHistoryList(data=data["data"])
|
664
631
|
|
665
|
-
async def get_favorite_studios(self)
|
632
|
+
async def get_favorite_studios(self):
|
666
633
|
"""Get the member's favorite studios.
|
667
634
|
|
668
635
|
Returns:
|
@@ -670,9 +637,9 @@ class Otf:
|
|
670
637
|
"""
|
671
638
|
data = await self._default_request("GET", f"/member/members/{self._member_id}/favorite-studios")
|
672
639
|
|
673
|
-
return FavoriteStudioList(studios=data["data"])
|
640
|
+
return models.FavoriteStudioList(studios=data["data"])
|
674
641
|
|
675
|
-
async def get_latest_agreement(self)
|
642
|
+
async def get_latest_agreement(self):
|
676
643
|
"""Get the latest agreement for the member.
|
677
644
|
|
678
645
|
Returns:
|
@@ -684,9 +651,9 @@ class Otf:
|
|
684
651
|
in general. The agreement ID is hardcoded in the endpoint, so it will always return the same agreement.
|
685
652
|
"""
|
686
653
|
data = await self._default_request("GET", "/member/agreements/9d98fb27-0f00-4598-ad08-5b1655a59af6")
|
687
|
-
return LatestAgreement(**data["data"])
|
654
|
+
return models.LatestAgreement(**data["data"])
|
688
655
|
|
689
|
-
async def get_studio_services(self, studio_uuid: str | None = None)
|
656
|
+
async def get_studio_services(self, studio_uuid: str | None = None):
|
690
657
|
"""Get the services available at a specific studio. If no studio UUID is provided, the member's home studio
|
691
658
|
will be used.
|
692
659
|
|
@@ -699,9 +666,9 @@ class Otf:
|
|
699
666
|
"""
|
700
667
|
studio_uuid = studio_uuid or self.home_studio_uuid
|
701
668
|
data = await self._default_request("GET", f"/member/studios/{studio_uuid}/services")
|
702
|
-
return StudioServiceList(data=data["data"])
|
669
|
+
return models.StudioServiceList(data=data["data"])
|
703
670
|
|
704
|
-
async def get_performance_summaries(self, limit: int = 30)
|
671
|
+
async def get_performance_summaries(self, limit: int = 30):
|
705
672
|
"""Get a list of performance summaries for the authenticated user.
|
706
673
|
|
707
674
|
Args:
|
@@ -719,10 +686,9 @@ class Otf:
|
|
719
686
|
path = "/v1/performance-summaries"
|
720
687
|
params = {"limit": limit}
|
721
688
|
res = await self._performance_summary_request("GET", path, headers=self._perf_api_headers, params=params)
|
722
|
-
|
723
|
-
return retval
|
689
|
+
return models.PerformanceSummaryList(summaries=res["items"])
|
724
690
|
|
725
|
-
async def get_performance_summary(self, performance_summary_id: str)
|
691
|
+
async def get_performance_summary(self, performance_summary_id: str):
|
726
692
|
"""Get a detailed performance summary for a given workout.
|
727
693
|
|
728
694
|
Args:
|
@@ -734,10 +700,9 @@ class Otf:
|
|
734
700
|
|
735
701
|
path = f"/v1/performance-summaries/{performance_summary_id}"
|
736
702
|
res = await self._performance_summary_request("GET", path, headers=self._perf_api_headers)
|
737
|
-
|
738
|
-
return retval
|
703
|
+
return models.PerformanceSummaryDetail(**res)
|
739
704
|
|
740
|
-
async def get_studio_detail(self, studio_uuid: str | None = None)
|
705
|
+
async def get_studio_detail(self, studio_uuid: str | None = None):
|
741
706
|
"""Get detailed information about a specific studio. If no studio UUID is provided, it will default to the
|
742
707
|
user's home studio.
|
743
708
|
|
@@ -754,7 +719,7 @@ class Otf:
|
|
754
719
|
params = {"include": "locations"}
|
755
720
|
|
756
721
|
res = await self._default_request("GET", path, params=params)
|
757
|
-
return StudioDetail(**res["data"])
|
722
|
+
return models.StudioDetail(**res["data"])
|
758
723
|
|
759
724
|
async def search_studios_by_geo(
|
760
725
|
self,
|
@@ -763,7 +728,7 @@ class Otf:
|
|
763
728
|
distance: float = 50,
|
764
729
|
page_index: int = 1,
|
765
730
|
page_size: int = 50,
|
766
|
-
)
|
731
|
+
):
|
767
732
|
"""Search for studios by geographic location.
|
768
733
|
|
769
734
|
Args:
|
@@ -806,21 +771,21 @@ class Otf:
|
|
806
771
|
"distance": distance,
|
807
772
|
}
|
808
773
|
|
809
|
-
all_results: list[StudioDetail] = []
|
774
|
+
all_results: list[models.StudioDetail] = []
|
810
775
|
|
811
776
|
while True:
|
812
777
|
res = await self._default_request("GET", path, params=params)
|
813
|
-
pagination = Pagination(**res["data"].pop("pagination"))
|
814
|
-
all_results.extend([StudioDetail(**studio) for studio in res["data"]["studios"]])
|
778
|
+
pagination = models.Pagination(**res["data"].pop("pagination"))
|
779
|
+
all_results.extend([models.StudioDetail(**studio) for studio in res["data"]["studios"]])
|
815
780
|
|
816
781
|
if len(all_results) == pagination.total_count:
|
817
782
|
break
|
818
783
|
|
819
784
|
params["pageIndex"] += 1
|
820
785
|
|
821
|
-
return StudioDetailList(studios=all_results)
|
786
|
+
return models.StudioDetailList(studios=all_results)
|
822
787
|
|
823
|
-
async def get_hr_history(self)
|
788
|
+
async def get_hr_history(self):
|
824
789
|
"""Get the heartrate history for the user.
|
825
790
|
|
826
791
|
Returns a list of history items that contain the max heartrate, start/end bpm for each zone,
|
@@ -834,9 +799,9 @@ class Otf:
|
|
834
799
|
|
835
800
|
params = {"memberUuid": self._member_id}
|
836
801
|
res = await self._telemetry_request("GET", path, params=params)
|
837
|
-
return TelemetryHrHistory(**res)
|
802
|
+
return models.TelemetryHrHistory(**res)
|
838
803
|
|
839
|
-
async def get_max_hr(self)
|
804
|
+
async def get_max_hr(self):
|
840
805
|
"""Get the max heartrate for the user.
|
841
806
|
|
842
807
|
Returns a simple object that has the member_uuid and the max_hr.
|
@@ -849,9 +814,9 @@ class Otf:
|
|
849
814
|
params = {"memberUuid": self._member_id}
|
850
815
|
|
851
816
|
res = await self._telemetry_request("GET", path, params=params)
|
852
|
-
return TelemetryMaxHr(**res)
|
817
|
+
return models.TelemetryMaxHr(**res)
|
853
818
|
|
854
|
-
async def get_telemetry(self, performance_summary_id: str, max_data_points: int = 120)
|
819
|
+
async def get_telemetry(self, performance_summary_id: str, max_data_points: int = 120):
|
855
820
|
"""Get the telemetry for a performance summary.
|
856
821
|
|
857
822
|
This returns an object that contains the max heartrate, start/end bpm for each zone,
|
@@ -869,11 +834,11 @@ class Otf:
|
|
869
834
|
|
870
835
|
params = {"classHistoryUuid": performance_summary_id, "maxDataPoints": max_data_points}
|
871
836
|
res = await self._telemetry_request("GET", path, params=params)
|
872
|
-
return Telemetry(**res)
|
837
|
+
return models.Telemetry(**res)
|
873
838
|
|
874
839
|
# the below do not return any data for me, so I can't test them
|
875
840
|
|
876
|
-
async def _get_member_services(self, active_only: bool = True)
|
841
|
+
async def _get_member_services(self, active_only: bool = True):
|
877
842
|
"""Get the member's services.
|
878
843
|
|
879
844
|
Args:
|
@@ -888,7 +853,7 @@ class Otf:
|
|
888
853
|
)
|
889
854
|
return data
|
890
855
|
|
891
|
-
async def _get_aspire_data(self, datetime: str | None = None, unit: str | None = None)
|
856
|
+
async def _get_aspire_data(self, datetime: str | None = None, unit: str | None = None):
|
892
857
|
"""Get data from the member's aspire wearable.
|
893
858
|
|
894
859
|
Note: I don't have an aspire wearable, so I can't test this.
|
@@ -1,9 +1,9 @@
|
|
1
1
|
import typing
|
2
|
+
from datetime import datetime, timedelta
|
3
|
+
from logging import getLogger
|
2
4
|
from typing import Any
|
3
5
|
|
4
6
|
import jwt
|
5
|
-
import pendulum
|
6
|
-
from loguru import logger
|
7
7
|
from pycognito import AWSSRP, Cognito, MFAChallengeException
|
8
8
|
from pycognito.exceptions import TokenVerificationException
|
9
9
|
from pydantic import Field
|
@@ -15,6 +15,7 @@ if typing.TYPE_CHECKING:
|
|
15
15
|
from boto3.session import Session
|
16
16
|
from botocore.config import Config
|
17
17
|
|
18
|
+
LOGGER = getLogger(__name__)
|
18
19
|
CLIENT_ID = "65knvqta6p37efc2l3eh26pl5o" # from otlive
|
19
20
|
USER_POOL_ID = "us-east-1_dYDxUeyL1"
|
20
21
|
|
@@ -64,12 +65,12 @@ class OtfCognito(Cognito):
|
|
64
65
|
def device_key(self, value: str | None):
|
65
66
|
if not value:
|
66
67
|
if self._device_key:
|
67
|
-
|
68
|
+
LOGGER.debug("Clearing device key")
|
68
69
|
self._device_key = value
|
69
70
|
return
|
70
71
|
|
71
72
|
redacted_value = value[:4] + "*" * (len(value) - 8) + value[-4:]
|
72
|
-
|
73
|
+
LOGGER.debug(f"Setting device key: {redacted_value}")
|
73
74
|
self._device_key = value
|
74
75
|
|
75
76
|
def _set_tokens(self, tokens: dict[str, Any]):
|
@@ -116,7 +117,7 @@ class OtfCognito(Cognito):
|
|
116
117
|
try:
|
117
118
|
self.renew_access_token()
|
118
119
|
except TokenVerificationException:
|
119
|
-
|
120
|
+
LOGGER.error("Failed to renew access token. Confirming device.")
|
120
121
|
self.device_key = None
|
121
122
|
aws.confirm_device(tokens)
|
122
123
|
|
@@ -129,11 +130,11 @@ class OtfCognito(Cognito):
|
|
129
130
|
"""
|
130
131
|
if not self.access_token:
|
131
132
|
raise AttributeError("Access Token Required to Check Token")
|
132
|
-
now =
|
133
|
+
now = datetime.now() # noqa
|
133
134
|
dec_access_token = jwt.decode(self.access_token, options={"verify_signature": False})
|
134
135
|
|
135
|
-
exp =
|
136
|
-
if now > exp
|
136
|
+
exp = datetime.fromtimestamp(dec_access_token["exp"]) # noqa
|
137
|
+
if now > exp - timedelta(minutes=15):
|
137
138
|
expired = True
|
138
139
|
if renew:
|
139
140
|
self.renew_access_token()
|
@@ -147,7 +148,7 @@ class OtfCognito(Cognito):
|
|
147
148
|
self._add_secret_hash(auth_params, "SECRET_HASH")
|
148
149
|
|
149
150
|
if self.device_key:
|
150
|
-
|
151
|
+
LOGGER.debug("Using device key for refresh token")
|
151
152
|
auth_params["DEVICE_KEY"] = self.device_key
|
152
153
|
|
153
154
|
refresh_response = self.client.initiate_auth(
|
@@ -311,5 +312,5 @@ class OtfUser(OtfItemBase):
|
|
311
312
|
}
|
312
313
|
|
313
314
|
@property
|
314
|
-
def device_key(self)
|
315
|
+
def device_key(self):
|
315
316
|
return self.cognito.device_key
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class BookingError(Exception):
|
2
|
+
booking_uuid: str | None
|
3
|
+
|
4
|
+
def __init__(self, message: str, booking_uuid: str | None = None):
|
5
|
+
super().__init__(message)
|
6
|
+
self.booking_uuid = booking_uuid
|
7
|
+
|
8
|
+
|
9
|
+
class AlreadyBookedError(BookingError): ...
|
10
|
+
|
11
|
+
|
12
|
+
class BookingAlreadyCancelledError(BookingError): ...
|
13
|
+
|
14
|
+
|
15
|
+
class OutsideSchedulingWindowError(Exception): ...
|
16
|
+
|
17
|
+
|
18
|
+
class BookingNotFoundError(Exception): ...
|