otf-api 0.3.0__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.
- otf_api/__init__.py +4 -4
- otf_api/api.py +117 -68
- otf_api/auth.py +314 -0
- otf_api/cli/app.py +2 -7
- otf_api/cli/bookings.py +6 -6
- otf_api/models/__init__.py +28 -17
- otf_api/models/responses/__init__.py +25 -17
- {otf_api-0.3.0.dist-info → otf_api-0.4.0.dist-info}/METADATA +5 -6
- {otf_api-0.3.0.dist-info → otf_api-0.4.0.dist-info}/RECORD +13 -13
- otf_api/models/auth.py +0 -147
- {otf_api-0.3.0.dist-info → otf_api-0.4.0.dist-info}/AUTHORS.md +0 -0
- {otf_api-0.3.0.dist-info → otf_api-0.4.0.dist-info}/LICENSE +0 -0
- {otf_api-0.3.0.dist-info → otf_api-0.4.0.dist-info}/WHEEL +0 -0
- {otf_api-0.3.0.dist-info → otf_api-0.4.0.dist-info}/entry_points.txt +0 -0
otf_api/__init__.py
CHANGED
@@ -3,13 +3,13 @@ import sys
|
|
3
3
|
|
4
4
|
from loguru import logger
|
5
5
|
|
6
|
-
from .api import
|
7
|
-
from .
|
6
|
+
from .api import Otf
|
7
|
+
from .auth import OtfUser
|
8
8
|
|
9
|
-
__version__ = "0.
|
9
|
+
__version__ = "0.4.0"
|
10
10
|
|
11
11
|
|
12
|
-
__all__ = ["
|
12
|
+
__all__ = ["Otf", "OtfUser"]
|
13
13
|
|
14
14
|
logger.remove()
|
15
15
|
logger.add(sink=sys.stdout, level=os.getenv("OTF_LOG_LEVEL", "INFO"))
|
otf_api/api.py
CHANGED
@@ -7,36 +7,41 @@ from math import ceil
|
|
7
7
|
from typing import Any
|
8
8
|
|
9
9
|
import aiohttp
|
10
|
+
import requests
|
10
11
|
from loguru import logger
|
11
12
|
from yarl import URL
|
12
13
|
|
13
|
-
from otf_api.
|
14
|
-
from otf_api.models
|
15
|
-
|
16
|
-
|
17
|
-
from otf_api.models.responses.classes import ClassType, DoW, OtfClassList
|
18
|
-
from otf_api.models.responses.favorite_studios import FavoriteStudioList
|
19
|
-
from otf_api.models.responses.lifetime_stats import StatsResponse, StatsTime
|
20
|
-
from otf_api.models.responses.performance_summary_detail import PerformanceSummaryDetail
|
21
|
-
from otf_api.models.responses.performance_summary_list import PerformanceSummaryList
|
22
|
-
from otf_api.models.responses.studio_detail import Pagination, StudioDetail, StudioDetailList
|
23
|
-
from otf_api.models.responses.telemetry import Telemetry
|
24
|
-
from otf_api.models.responses.telemetry_hr_history import TelemetryHrHistory
|
25
|
-
from otf_api.models.responses.telemetry_max_hr import TelemetryMaxHr
|
26
|
-
|
27
|
-
from .models import (
|
14
|
+
from otf_api.auth import OtfUser
|
15
|
+
from otf_api.models import (
|
16
|
+
BodyCompositionList,
|
17
|
+
BookClass,
|
28
18
|
BookingList,
|
29
19
|
BookingStatus,
|
20
|
+
CancelBooking,
|
30
21
|
ChallengeTrackerContent,
|
31
22
|
ChallengeTrackerDetailList,
|
32
23
|
ChallengeType,
|
24
|
+
ClassType,
|
25
|
+
DoW,
|
33
26
|
EquipmentType,
|
27
|
+
FavoriteStudioList,
|
34
28
|
LatestAgreement,
|
35
29
|
MemberDetail,
|
36
30
|
MemberMembership,
|
37
31
|
MemberPurchaseList,
|
32
|
+
OtfClassList,
|
38
33
|
OutOfStudioWorkoutHistoryList,
|
34
|
+
Pagination,
|
35
|
+
PerformanceSummaryDetail,
|
36
|
+
PerformanceSummaryList,
|
37
|
+
StatsResponse,
|
38
|
+
StatsTime,
|
39
|
+
StudioDetail,
|
40
|
+
StudioDetailList,
|
39
41
|
StudioServiceList,
|
42
|
+
Telemetry,
|
43
|
+
TelemetryHrHistory,
|
44
|
+
TelemetryMaxHr,
|
40
45
|
TotalClasses,
|
41
46
|
WorkoutList,
|
42
47
|
)
|
@@ -56,40 +61,55 @@ API_TELEMETRY_BASE_URL = "api.yuzu.orangetheory.com"
|
|
56
61
|
REQUEST_HEADERS = {"Authorization": None, "Content-Type": "application/json", "Accept": "application/json"}
|
57
62
|
|
58
63
|
|
59
|
-
class
|
60
|
-
""
|
64
|
+
class Otf:
|
65
|
+
logger: "Logger" = logger
|
66
|
+
user: OtfUser
|
67
|
+
_session: aiohttp.ClientSession
|
61
68
|
|
62
|
-
|
69
|
+
def __init__(
|
70
|
+
self,
|
71
|
+
username: str | None = None,
|
72
|
+
password: str | None = None,
|
73
|
+
access_token: str | None = None,
|
74
|
+
id_token: str | None = None,
|
75
|
+
refresh_token: str | None = None,
|
76
|
+
device_key: str | None = None,
|
77
|
+
user: OtfUser | None = None,
|
78
|
+
):
|
79
|
+
"""Create a new Otf instance.
|
80
|
+
|
81
|
+
Authentication methods:
|
63
82
|
---
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
async def main():
|
69
|
-
otf = await Api.create("username", "password")
|
70
|
-
print(otf.member)
|
71
|
-
|
72
|
-
if __name__ == "__main__":
|
73
|
-
asyncio.run(main())
|
74
|
-
```
|
75
|
-
"""
|
83
|
+
- Provide a username and password.
|
84
|
+
- Provide an access token and id token.
|
85
|
+
- Provide a user object.
|
76
86
|
|
77
|
-
|
78
|
-
|
79
|
-
|
87
|
+
Args:
|
88
|
+
username (str, optional): The username of the user. Default is None.
|
89
|
+
password (str, optional): The password of the user. Default is None.
|
90
|
+
access_token (str, optional): The access token. Default is None.
|
91
|
+
id_token (str, optional): The id token. Default is None.
|
92
|
+
refresh_token (str, optional): The refresh token. Default is None.
|
93
|
+
device_key (str, optional): The device key. Default is None.
|
94
|
+
user (OtfUser, optional): A user object. Default is None.
|
95
|
+
"""
|
80
96
|
|
81
|
-
def __init__(self, username: str, password: str):
|
82
97
|
self.member: MemberDetail
|
83
|
-
self.
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
98
|
+
self.home_studio_uuid: str
|
99
|
+
|
100
|
+
if user:
|
101
|
+
self.user = user
|
102
|
+
elif username and password or (access_token and id_token):
|
103
|
+
self.user = OtfUser(
|
104
|
+
username=username,
|
105
|
+
password=password,
|
106
|
+
access_token=access_token,
|
107
|
+
id_token=id_token,
|
108
|
+
refresh_token=refresh_token,
|
109
|
+
device_key=device_key,
|
110
|
+
)
|
111
|
+
else:
|
112
|
+
raise ValueError("No valid authentication method provided")
|
93
113
|
|
94
114
|
# simplify access to member_id and member_uuid
|
95
115
|
self._member_id = self.user.member_id
|
@@ -98,26 +118,48 @@ class Api:
|
|
98
118
|
"koji-member-id": self._member_id,
|
99
119
|
"koji-member-email": self.user.id_claims_data.email,
|
100
120
|
}
|
121
|
+
self.member = self._get_member_details_sync()
|
122
|
+
self.home_studio_uuid = self.member.home_studio.studio_uuid
|
101
123
|
|
102
|
-
|
103
|
-
|
104
|
-
"""Create a new API instance. The username and password are required arguments because even though
|
105
|
-
we cache the token, they expire so quickly that we usually end up needing to re-authenticate.
|
124
|
+
def _get_member_details_sync(self) -> MemberDetail:
|
125
|
+
"""Get the member details synchronously.
|
106
126
|
|
107
|
-
|
108
|
-
|
109
|
-
|
127
|
+
This is used to get the member details when the API is first initialized, to let use initialize
|
128
|
+
without needing to await the member details.
|
129
|
+
|
130
|
+
Returns:
|
131
|
+
MemberDetail: The member details.
|
110
132
|
"""
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
133
|
+
url = f"https://{API_BASE_URL}/member/members/{self._member_id}"
|
134
|
+
resp = requests.get(url, headers=self.headers)
|
135
|
+
return MemberDetail(**resp.json()["data"])
|
136
|
+
|
137
|
+
@property
|
138
|
+
def headers(self) -> dict[str, str]:
|
139
|
+
"""Get the headers for the API request."""
|
140
|
+
|
141
|
+
# check the token before making a request in case it has expired
|
142
|
+
|
143
|
+
self.user.cognito.check_token()
|
144
|
+
return {
|
145
|
+
"Authorization": f"Bearer {self.user.cognito.id_token}",
|
146
|
+
"Content-Type": "application/json",
|
147
|
+
"Accept": "application/json",
|
148
|
+
}
|
149
|
+
|
150
|
+
@property
|
151
|
+
def session(self) -> aiohttp.ClientSession:
|
152
|
+
"""Get the aiohttp session."""
|
153
|
+
if not getattr(self, "_session", None):
|
154
|
+
self._session = aiohttp.ClientSession(headers=self.headers)
|
155
|
+
|
156
|
+
return self._session
|
115
157
|
|
116
158
|
def __del__(self) -> None:
|
117
159
|
if not hasattr(self, "session"):
|
118
160
|
return
|
161
|
+
|
119
162
|
try:
|
120
|
-
loop = asyncio.get_event_loop()
|
121
163
|
asyncio.create_task(self._close_session()) # noqa
|
122
164
|
except RuntimeError:
|
123
165
|
loop = asyncio.new_event_loop()
|
@@ -145,6 +187,12 @@ class Api:
|
|
145
187
|
|
146
188
|
logger.debug(f"Making {method!r} request to {full_url}, params: {params}")
|
147
189
|
|
190
|
+
# ensure we have headers that contain the most up-to-date token
|
191
|
+
if not headers:
|
192
|
+
headers = self.headers
|
193
|
+
else:
|
194
|
+
headers.update(self.headers)
|
195
|
+
|
148
196
|
text = None
|
149
197
|
async with self.session.request(method, full_url, headers=headers, params=params, **kwargs) as response:
|
150
198
|
with contextlib.suppress(Exception):
|
@@ -155,10 +203,8 @@ class Api:
|
|
155
203
|
except aiohttp.ClientResponseError as e:
|
156
204
|
logger.exception(f"Error making request: {e}")
|
157
205
|
logger.exception(f"Response: {text}")
|
158
|
-
# raise
|
159
206
|
except Exception as e:
|
160
207
|
logger.exception(f"Error making request: {e}")
|
161
|
-
# raise
|
162
208
|
|
163
209
|
return await response.json()
|
164
210
|
|
@@ -231,7 +277,7 @@ class Api:
|
|
231
277
|
start_date (str | None): The start date to get classes for, in the format "YYYY-MM-DD". Default is None.
|
232
278
|
end_date (str | None): The end date to get classes for, in the format "YYYY-MM-DD". Default is None.
|
233
279
|
limit (int | None): Limit the number of classes returned. Default is None.
|
234
|
-
class_type (ClassType | list[ClassType] | None): The class type to filter by. Default is None. Multiple
|
280
|
+
class_type (ClassType | list[ClassType] | None): The class type to filter by. Default is None. Multiple\
|
235
281
|
class types can be provided, if there are multiple there will be a call per class type.
|
236
282
|
exclude_cancelled (bool): Whether to exclude cancelled classes. Default is False.
|
237
283
|
day_of_week (list[DoW] | None): The days of the week to filter by. Default is None.
|
@@ -242,9 +288,9 @@ class Api:
|
|
242
288
|
"""
|
243
289
|
|
244
290
|
if not studio_uuids:
|
245
|
-
studio_uuids = [self.
|
246
|
-
elif include_home_studio and self.
|
247
|
-
studio_uuids.append(self.
|
291
|
+
studio_uuids = [self.home_studio_uuid]
|
292
|
+
elif include_home_studio and self.home_studio_uuid not in studio_uuids:
|
293
|
+
studio_uuids.append(self.home_studio_uuid)
|
248
294
|
|
249
295
|
path = "/v1/classes"
|
250
296
|
|
@@ -280,7 +326,7 @@ class Api:
|
|
280
326
|
classes_list.classes = [c for c in classes_list.classes if not c.canceled]
|
281
327
|
|
282
328
|
for otf_class in classes_list.classes:
|
283
|
-
otf_class.is_home_studio = otf_class.studio.id == self.
|
329
|
+
otf_class.is_home_studio = otf_class.studio.id == self.home_studio_uuid
|
284
330
|
|
285
331
|
if day_of_week:
|
286
332
|
classes_list.classes = [c for c in classes_list.classes if c.day_of_week_enum in day_of_week]
|
@@ -415,7 +461,7 @@ class Api:
|
|
415
461
|
for booking in data.bookings:
|
416
462
|
if not booking.otf_class:
|
417
463
|
continue
|
418
|
-
if booking.otf_class.studio.studio_uuid == self.
|
464
|
+
if booking.otf_class.studio.studio_uuid == self.home_studio_uuid:
|
419
465
|
booking.is_home_studio = True
|
420
466
|
else:
|
421
467
|
booking.is_home_studio = False
|
@@ -660,7 +706,7 @@ class Api:
|
|
660
706
|
Returns:
|
661
707
|
StudioServiceList: The services available at the studio.
|
662
708
|
"""
|
663
|
-
studio_uuid = studio_uuid or self.
|
709
|
+
studio_uuid = studio_uuid or self.home_studio_uuid
|
664
710
|
data = await self._default_request("GET", f"/member/studios/{studio_uuid}/services")
|
665
711
|
return StudioServiceList(data=data["data"])
|
666
712
|
|
@@ -711,7 +757,7 @@ class Api:
|
|
711
757
|
Returns:
|
712
758
|
StudioDetail: Detailed information about the studio.
|
713
759
|
"""
|
714
|
-
studio_uuid = studio_uuid or self.
|
760
|
+
studio_uuid = studio_uuid or self.home_studio_uuid
|
715
761
|
|
716
762
|
path = f"/mobile/v1/studios/{studio_uuid}"
|
717
763
|
params = {"include": "locations"}
|
@@ -747,8 +793,11 @@ class Api:
|
|
747
793
|
"""
|
748
794
|
path = "/mobile/v1/studios"
|
749
795
|
|
750
|
-
|
751
|
-
|
796
|
+
if not latitude and not longitude:
|
797
|
+
home_studio = await self.get_studio_detail()
|
798
|
+
|
799
|
+
latitude = home_studio.studio_location.latitude
|
800
|
+
longitude = home_studio.studio_location.longitude
|
752
801
|
|
753
802
|
if page_size > 50:
|
754
803
|
self.logger.warning("The API does not support more than 50 results per page, limiting to 50.")
|
otf_api/auth.py
ADDED
@@ -0,0 +1,314 @@
|
|
1
|
+
import typing
|
2
|
+
from typing import Any
|
3
|
+
|
4
|
+
import jwt
|
5
|
+
import pendulum
|
6
|
+
from loguru import logger
|
7
|
+
from pycognito import AWSSRP, Cognito, MFAChallengeException
|
8
|
+
from pycognito.exceptions import TokenVerificationException
|
9
|
+
from pydantic import Field
|
10
|
+
from pydantic.config import ConfigDict
|
11
|
+
|
12
|
+
from otf_api.models.base import OtfItemBase
|
13
|
+
|
14
|
+
if typing.TYPE_CHECKING:
|
15
|
+
from boto3.session import Session
|
16
|
+
from botocore.config import Config
|
17
|
+
|
18
|
+
CLIENT_ID = "65knvqta6p37efc2l3eh26pl5o" # from otlive
|
19
|
+
USER_POOL_ID = "us-east-1_dYDxUeyL1"
|
20
|
+
|
21
|
+
|
22
|
+
class OtfCognito(Cognito):
|
23
|
+
_device_key: str | None = None
|
24
|
+
|
25
|
+
def __init__(
|
26
|
+
self,
|
27
|
+
user_pool_id: str,
|
28
|
+
client_id: str,
|
29
|
+
user_pool_region: str | None = None,
|
30
|
+
username: str | None = None,
|
31
|
+
id_token: str | None = None,
|
32
|
+
refresh_token: str | None = None,
|
33
|
+
access_token: str | None = None,
|
34
|
+
client_secret: str | None = None,
|
35
|
+
access_key: str | None = None,
|
36
|
+
secret_key: str | None = None,
|
37
|
+
session: "Session|None" = None,
|
38
|
+
botocore_config: "Config|None" = None,
|
39
|
+
boto3_client_kwargs: dict[str, Any] | None = None,
|
40
|
+
device_key: str | None = None,
|
41
|
+
):
|
42
|
+
super().__init__(
|
43
|
+
user_pool_id,
|
44
|
+
client_id,
|
45
|
+
user_pool_region=user_pool_region,
|
46
|
+
username=username,
|
47
|
+
id_token=id_token,
|
48
|
+
refresh_token=refresh_token,
|
49
|
+
access_token=access_token,
|
50
|
+
client_secret=client_secret,
|
51
|
+
access_key=access_key,
|
52
|
+
secret_key=secret_key,
|
53
|
+
session=session,
|
54
|
+
botocore_config=botocore_config,
|
55
|
+
boto3_client_kwargs=boto3_client_kwargs,
|
56
|
+
)
|
57
|
+
self.device_key = device_key
|
58
|
+
|
59
|
+
@property
|
60
|
+
def device_key(self) -> str | None:
|
61
|
+
return self._device_key
|
62
|
+
|
63
|
+
@device_key.setter
|
64
|
+
def device_key(self, value: str | None):
|
65
|
+
if not value:
|
66
|
+
logger.info("Clearing device key")
|
67
|
+
self._device_key = value
|
68
|
+
return
|
69
|
+
|
70
|
+
redacted_value = value[:4] + "*" * (len(value) - 8) + value[-4:]
|
71
|
+
logger.info(f"Setting device key: {redacted_value}")
|
72
|
+
self._device_key = value
|
73
|
+
|
74
|
+
def _set_tokens(self, tokens: dict[str, Any]):
|
75
|
+
"""Set the tokens and device metadata from the response.
|
76
|
+
|
77
|
+
Args:
|
78
|
+
tokens (dict): The response from the Cognito service.
|
79
|
+
"""
|
80
|
+
super()._set_tokens(tokens)
|
81
|
+
|
82
|
+
if new_metadata := tokens["AuthenticationResult"].get("NewDeviceMetadata"):
|
83
|
+
self.device_key = new_metadata["DeviceKey"]
|
84
|
+
|
85
|
+
def authenticate(self, password: str, client_metadata: dict[str, Any] | None = None, device_key: str | None = None):
|
86
|
+
"""
|
87
|
+
Authenticate the user using the SRP protocol. Overridden to add `confirm_device` call.
|
88
|
+
|
89
|
+
Args:
|
90
|
+
password (str): The user's password
|
91
|
+
client_metadata (dict, optional): Any additional client metadata to send to Cognito
|
92
|
+
"""
|
93
|
+
aws = AWSSRP(
|
94
|
+
username=self.username,
|
95
|
+
password=password,
|
96
|
+
pool_id=self.user_pool_id,
|
97
|
+
client_id=self.client_id,
|
98
|
+
client=self.client,
|
99
|
+
client_secret=self.client_secret,
|
100
|
+
)
|
101
|
+
try:
|
102
|
+
tokens = aws.authenticate_user(client_metadata=client_metadata)
|
103
|
+
except MFAChallengeException as mfa_challenge:
|
104
|
+
self.mfa_tokens = mfa_challenge.get_tokens()
|
105
|
+
raise mfa_challenge
|
106
|
+
|
107
|
+
# Set the tokens and device metadata
|
108
|
+
self._set_tokens(tokens)
|
109
|
+
|
110
|
+
if not device_key:
|
111
|
+
# Confirm the device so we can use the refresh token
|
112
|
+
aws.confirm_device(tokens)
|
113
|
+
else:
|
114
|
+
self.device_key = device_key
|
115
|
+
try:
|
116
|
+
self.renew_access_token()
|
117
|
+
except TokenVerificationException:
|
118
|
+
logger.error("Failed to renew access token. Confirming device.")
|
119
|
+
self.device_key = None
|
120
|
+
aws.confirm_device(tokens)
|
121
|
+
|
122
|
+
def check_token(self, renew: bool = True) -> bool:
|
123
|
+
"""
|
124
|
+
Checks the exp attribute of the access_token and either refreshes
|
125
|
+
the tokens by calling the renew_access_tokens method or does nothing
|
126
|
+
:param renew: bool indicating whether to refresh on expiration
|
127
|
+
:return: bool indicating whether access_token has expired
|
128
|
+
"""
|
129
|
+
if not self.access_token:
|
130
|
+
raise AttributeError("Access Token Required to Check Token")
|
131
|
+
now = pendulum.now()
|
132
|
+
dec_access_token = jwt.decode(self.access_token, options={"verify_signature": False})
|
133
|
+
|
134
|
+
exp = pendulum.DateTime.fromtimestamp(dec_access_token["exp"])
|
135
|
+
if now > exp.subtract(minutes=15):
|
136
|
+
expired = True
|
137
|
+
if renew:
|
138
|
+
self.renew_access_token()
|
139
|
+
else:
|
140
|
+
expired = False
|
141
|
+
return expired
|
142
|
+
|
143
|
+
def renew_access_token(self):
|
144
|
+
"""Sets a new access token on the User using the cached refresh token and device metadata."""
|
145
|
+
auth_params = {"REFRESH_TOKEN": self.refresh_token}
|
146
|
+
self._add_secret_hash(auth_params, "SECRET_HASH")
|
147
|
+
|
148
|
+
if self.device_key:
|
149
|
+
logger.info("Using device key for refresh token")
|
150
|
+
auth_params["DEVICE_KEY"] = self.device_key
|
151
|
+
|
152
|
+
refresh_response = self.client.initiate_auth(
|
153
|
+
ClientId=self.client_id, AuthFlow="REFRESH_TOKEN_AUTH", AuthParameters=auth_params
|
154
|
+
)
|
155
|
+
self._set_tokens(refresh_response)
|
156
|
+
|
157
|
+
@classmethod
|
158
|
+
def from_token(
|
159
|
+
cls, access_token: str, id_token: str, refresh_token: str | None = None, device_key: str | None = None
|
160
|
+
) -> "OtfCognito":
|
161
|
+
"""Create an OtfCognito instance from an id token.
|
162
|
+
|
163
|
+
Args:
|
164
|
+
access_token (str): The access token.
|
165
|
+
id_token (str): The id token.
|
166
|
+
refresh_token (str, optional): The refresh token. Defaults to None.
|
167
|
+
device_key (str, optional): The device key. Defaults
|
168
|
+
|
169
|
+
Returns:
|
170
|
+
OtfCognito: The user instance
|
171
|
+
"""
|
172
|
+
cognito = OtfCognito(
|
173
|
+
USER_POOL_ID,
|
174
|
+
CLIENT_ID,
|
175
|
+
access_token=access_token,
|
176
|
+
id_token=id_token,
|
177
|
+
refresh_token=refresh_token,
|
178
|
+
device_key=device_key,
|
179
|
+
)
|
180
|
+
cognito.verify_tokens()
|
181
|
+
cognito.check_token()
|
182
|
+
return cognito
|
183
|
+
|
184
|
+
@classmethod
|
185
|
+
def login(cls, username: str, password: str) -> "OtfCognito":
|
186
|
+
"""Create an OtfCognito instance from a username and password.
|
187
|
+
|
188
|
+
Args:
|
189
|
+
username (str): The username to login with.
|
190
|
+
password (str): The password to login with.
|
191
|
+
|
192
|
+
Returns:
|
193
|
+
OtfCognito: The logged in user.
|
194
|
+
"""
|
195
|
+
cognito_user = OtfCognito(USER_POOL_ID, CLIENT_ID, username=username)
|
196
|
+
cognito_user.authenticate(password)
|
197
|
+
cognito_user.check_token()
|
198
|
+
return cognito_user
|
199
|
+
|
200
|
+
|
201
|
+
class IdClaimsData(OtfItemBase):
|
202
|
+
sub: str
|
203
|
+
email_verified: bool
|
204
|
+
iss: str
|
205
|
+
cognito_username: str = Field(alias="cognito:username")
|
206
|
+
given_name: str
|
207
|
+
locale: str
|
208
|
+
home_studio_id: str = Field(alias="custom:home_studio_id")
|
209
|
+
aud: str
|
210
|
+
event_id: str
|
211
|
+
token_use: str
|
212
|
+
auth_time: int
|
213
|
+
exp: int
|
214
|
+
is_migration: str = Field(alias="custom:isMigration")
|
215
|
+
iat: int
|
216
|
+
family_name: str
|
217
|
+
email: str
|
218
|
+
koji_person_id: str = Field(alias="custom:koji_person_id")
|
219
|
+
|
220
|
+
@property
|
221
|
+
def member_uuid(self) -> str:
|
222
|
+
return self.cognito_username
|
223
|
+
|
224
|
+
@property
|
225
|
+
def full_name(self) -> str:
|
226
|
+
return f"{self.given_name} {self.family_name}"
|
227
|
+
|
228
|
+
|
229
|
+
class AccessClaimsData(OtfItemBase):
|
230
|
+
sub: str
|
231
|
+
device_key: str
|
232
|
+
iss: str
|
233
|
+
client_id: str
|
234
|
+
event_id: str
|
235
|
+
token_use: str
|
236
|
+
scope: str
|
237
|
+
auth_time: int
|
238
|
+
exp: int
|
239
|
+
iat: int
|
240
|
+
jti: str
|
241
|
+
username: str
|
242
|
+
|
243
|
+
@property
|
244
|
+
def member_uuid(self) -> str:
|
245
|
+
return self.username
|
246
|
+
|
247
|
+
|
248
|
+
class OtfUser(OtfItemBase):
|
249
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
250
|
+
cognito: OtfCognito
|
251
|
+
|
252
|
+
def __init__(
|
253
|
+
self,
|
254
|
+
username: str | None = None,
|
255
|
+
password: str | None = None,
|
256
|
+
id_token: str | None = None,
|
257
|
+
access_token: str | None = None,
|
258
|
+
refresh_token: str | None = None,
|
259
|
+
device_key: str | None = None,
|
260
|
+
cognito: OtfCognito | None = None,
|
261
|
+
):
|
262
|
+
"""Create a User instance.
|
263
|
+
|
264
|
+
Args:
|
265
|
+
username (str, optional): The username to login with. Defaults to None.
|
266
|
+
password (str, optional): The password to login with. Defaults to None.
|
267
|
+
id_token (str, optional): The id token. Defaults to None.
|
268
|
+
access_token (str, optional): The access token. Defaults to None.
|
269
|
+
refresh_token (str, optional): The refresh token. Defaults to None.
|
270
|
+
device_key (str, optional): The device key. Defaults to None.
|
271
|
+
cognito (OtfCognito, optional): A Cognito instance. Defaults to None.
|
272
|
+
|
273
|
+
Raises:
|
274
|
+
ValueError: Must provide either username and password or id token
|
275
|
+
|
276
|
+
|
277
|
+
"""
|
278
|
+
if cognito:
|
279
|
+
cognito = cognito
|
280
|
+
elif username and password:
|
281
|
+
cognito = OtfCognito.login(username, password)
|
282
|
+
elif access_token and id_token:
|
283
|
+
cognito = OtfCognito.from_token(access_token, id_token, refresh_token, device_key)
|
284
|
+
else:
|
285
|
+
raise ValueError("Must provide either username and password or id token.")
|
286
|
+
|
287
|
+
super().__init__(cognito=cognito)
|
288
|
+
|
289
|
+
@property
|
290
|
+
def member_id(self) -> str:
|
291
|
+
return self.id_claims_data.cognito_username
|
292
|
+
|
293
|
+
@property
|
294
|
+
def member_uuid(self) -> str:
|
295
|
+
return self.access_claims_data.sub
|
296
|
+
|
297
|
+
@property
|
298
|
+
def access_claims_data(self) -> AccessClaimsData:
|
299
|
+
return AccessClaimsData(**self.cognito.access_claims)
|
300
|
+
|
301
|
+
@property
|
302
|
+
def id_claims_data(self) -> IdClaimsData:
|
303
|
+
return IdClaimsData(**self.cognito.id_claims)
|
304
|
+
|
305
|
+
def get_tokens(self) -> dict[str, str]:
|
306
|
+
return {
|
307
|
+
"id_token": self.cognito.id_token,
|
308
|
+
"access_token": self.cognito.access_token,
|
309
|
+
"refresh_token": self.cognito.refresh_token,
|
310
|
+
}
|
311
|
+
|
312
|
+
@property
|
313
|
+
def device_key(self) -> str:
|
314
|
+
return self.cognito.device_key
|
otf_api/cli/app.py
CHANGED
@@ -12,10 +12,9 @@ from rich.theme import Theme
|
|
12
12
|
|
13
13
|
import otf_api
|
14
14
|
from otf_api.cli._utilities import is_async_fn, with_cli_exception_handling
|
15
|
-
from otf_api.models.auth import User
|
16
15
|
|
17
16
|
if typing.TYPE_CHECKING:
|
18
|
-
from otf_api
|
17
|
+
from otf_api import Otf
|
19
18
|
|
20
19
|
|
21
20
|
class OutputType(str, Enum):
|
@@ -79,7 +78,7 @@ class AsyncTyper(typer.Typer):
|
|
79
78
|
self.console = Console(highlight=False, theme=theme, color_system="auto")
|
80
79
|
|
81
80
|
# TODO: clean these up later, just don't want warnings everywhere that these could be None
|
82
|
-
self.api:
|
81
|
+
self.api: Otf = None # type: ignore
|
83
82
|
self.username: str = None # type: ignore
|
84
83
|
self.password: str = None # type: ignore
|
85
84
|
self.output: OutputType = None # type: ignore
|
@@ -91,10 +90,6 @@ class AsyncTyper(typer.Typer):
|
|
91
90
|
self.username = username
|
92
91
|
return
|
93
92
|
|
94
|
-
if User.cache_file_exists():
|
95
|
-
self.username = User.username_from_disk()
|
96
|
-
return
|
97
|
-
|
98
93
|
raise ValueError("Username not provided and not found in cache")
|
99
94
|
|
100
95
|
def set_log_level(self, level: str) -> None:
|
otf_api/cli/bookings.py
CHANGED
@@ -56,7 +56,7 @@ async def list_bookings(
|
|
56
56
|
bk_status = BookingStatus.get_from_key_insensitive(status.value) if status else None
|
57
57
|
|
58
58
|
if not base_app.api:
|
59
|
-
base_app.api = await otf_api.
|
59
|
+
base_app.api = await otf_api.Otf.create(base_app.username, base_app.password)
|
60
60
|
bookings = await base_app.api.get_bookings(start_date, end_date, bk_status, limit, exclude_cancelled)
|
61
61
|
|
62
62
|
if base_app.output == "json":
|
@@ -82,7 +82,7 @@ async def book(class_uuid: str = typer.Option(help="Class UUID to cancel")) -> N
|
|
82
82
|
logger.info(f"Booking class {class_uuid}")
|
83
83
|
|
84
84
|
if not base_app.api:
|
85
|
-
base_app.api = await otf_api.
|
85
|
+
base_app.api = await otf_api.Otf.create(base_app.username, base_app.password)
|
86
86
|
booking = await base_app.api.book_class(class_uuid)
|
87
87
|
|
88
88
|
base_app.console.print(booking)
|
@@ -115,7 +115,7 @@ async def book_interactive(
|
|
115
115
|
class_type_enums = None
|
116
116
|
|
117
117
|
if not base_app.api:
|
118
|
-
base_app.api = await otf_api.
|
118
|
+
base_app.api = await otf_api.Otf.create(base_app.username, base_app.password)
|
119
119
|
|
120
120
|
classes = await base_app.api.get_classes(
|
121
121
|
studio_uuids,
|
@@ -152,7 +152,7 @@ async def cancel_interactive() -> None:
|
|
152
152
|
|
153
153
|
with base_app.console.status("Getting bookings...", spinner="arc"):
|
154
154
|
if not base_app.api:
|
155
|
-
base_app.api = await otf_api.
|
155
|
+
base_app.api = await otf_api.Otf.create(base_app.username, base_app.password)
|
156
156
|
bookings = await base_app.api.get_bookings()
|
157
157
|
|
158
158
|
result = prompt_select_from_table(
|
@@ -177,7 +177,7 @@ async def cancel(booking_uuid: str = typer.Option(help="Booking UUID to cancel")
|
|
177
177
|
logger.info(f"Cancelling booking {booking_uuid}")
|
178
178
|
|
179
179
|
if not base_app.api:
|
180
|
-
base_app.api = await otf_api.
|
180
|
+
base_app.api = await otf_api.Otf.create(base_app.username, base_app.password)
|
181
181
|
booking = await base_app.api.cancel_booking(booking_uuid)
|
182
182
|
|
183
183
|
base_app.console.print(booking)
|
@@ -211,7 +211,7 @@ async def list_classes(
|
|
211
211
|
class_type_enum = ClassType.get_from_key_insensitive(class_type.value) if class_type else None
|
212
212
|
|
213
213
|
if not base_app.api:
|
214
|
-
base_app.api = await otf_api.
|
214
|
+
base_app.api = await otf_api.Otf.create(base_app.username, base_app.password)
|
215
215
|
classes = await base_app.api.get_classes(
|
216
216
|
studio_uuids, include_home_studio, start_date, end_date, limit, class_type_enum, exclude_cancelled
|
217
217
|
)
|
otf_api/models/__init__.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
|
-
from .auth import User
|
2
1
|
from .responses import (
|
2
|
+
BodyCompositionList,
|
3
3
|
BookClass,
|
4
4
|
BookingList,
|
5
5
|
BookingStatus,
|
@@ -7,6 +7,8 @@ from .responses import (
|
|
7
7
|
ChallengeTrackerContent,
|
8
8
|
ChallengeTrackerDetailList,
|
9
9
|
ChallengeType,
|
10
|
+
ClassType,
|
11
|
+
DoW,
|
10
12
|
EquipmentType,
|
11
13
|
FavoriteStudioList,
|
12
14
|
HistoryClassStatus,
|
@@ -16,8 +18,11 @@ from .responses import (
|
|
16
18
|
MemberPurchaseList,
|
17
19
|
OtfClassList,
|
18
20
|
OutOfStudioWorkoutHistoryList,
|
21
|
+
Pagination,
|
19
22
|
PerformanceSummaryDetail,
|
20
23
|
PerformanceSummaryList,
|
24
|
+
StatsResponse,
|
25
|
+
StatsTime,
|
21
26
|
StudioDetail,
|
22
27
|
StudioDetailList,
|
23
28
|
StudioServiceList,
|
@@ -29,31 +34,37 @@ from .responses import (
|
|
29
34
|
)
|
30
35
|
|
31
36
|
__all__ = [
|
32
|
-
"
|
33
|
-
"
|
34
|
-
"BookingStatus",
|
35
|
-
"EquipmentType",
|
36
|
-
"HistoryClassStatus",
|
37
|
+
"BodyCompositionList",
|
38
|
+
"BookClass",
|
37
39
|
"BookingList",
|
40
|
+
"BookingStatus",
|
41
|
+
"CancelBooking",
|
38
42
|
"ChallengeTrackerContent",
|
39
43
|
"ChallengeTrackerDetailList",
|
44
|
+
"ChallengeType",
|
45
|
+
"ClassType",
|
46
|
+
"DoW",
|
47
|
+
"EquipmentType",
|
48
|
+
"FavoriteStudioList",
|
49
|
+
"HistoryClassStatus",
|
40
50
|
"LatestAgreement",
|
41
51
|
"MemberDetail",
|
42
52
|
"MemberMembership",
|
43
53
|
"MemberPurchaseList",
|
54
|
+
"OtfClassList",
|
44
55
|
"OutOfStudioWorkoutHistoryList",
|
56
|
+
"Pagination",
|
57
|
+
"PerformanceSummaryDetail",
|
58
|
+
"PerformanceSummaryList",
|
59
|
+
"StatsResponse",
|
60
|
+
"StatsTime",
|
61
|
+
"StudioDetail",
|
62
|
+
"StudioDetailList",
|
45
63
|
"StudioServiceList",
|
46
|
-
"
|
47
|
-
"WorkoutList",
|
48
|
-
"FavoriteStudioList",
|
49
|
-
"OtfClassList",
|
50
|
-
"TelemetryHrHistory",
|
64
|
+
"StudioStatus",
|
51
65
|
"Telemetry",
|
66
|
+
"TelemetryHrHistory",
|
52
67
|
"TelemetryMaxHr",
|
53
|
-
"
|
54
|
-
"
|
55
|
-
"PerformanceSummaryDetail",
|
56
|
-
"PerformanceSummaryList",
|
57
|
-
"BookClass",
|
58
|
-
"CancelBooking",
|
68
|
+
"TotalClasses",
|
69
|
+
"WorkoutList",
|
59
70
|
]
|
@@ -1,19 +1,21 @@
|
|
1
|
+
from .body_composition_list import BodyCompositionList
|
1
2
|
from .book_class import BookClass
|
2
3
|
from .bookings import BookingList, BookingStatus
|
3
4
|
from .cancel_booking import CancelBooking
|
4
5
|
from .challenge_tracker_content import ChallengeTrackerContent
|
5
6
|
from .challenge_tracker_detail import ChallengeTrackerDetailList
|
6
|
-
from .classes import OtfClassList
|
7
|
+
from .classes import ClassType, DoW, OtfClassList
|
7
8
|
from .enums import ChallengeType, EquipmentType, HistoryClassStatus
|
8
9
|
from .favorite_studios import FavoriteStudioList
|
9
10
|
from .latest_agreement import LatestAgreement
|
11
|
+
from .lifetime_stats import StatsResponse, StatsTime
|
10
12
|
from .member_detail import MemberDetail
|
11
13
|
from .member_membership import MemberMembership
|
12
14
|
from .member_purchases import MemberPurchaseList
|
13
15
|
from .out_of_studio_workout_history import OutOfStudioWorkoutHistoryList
|
14
16
|
from .performance_summary_detail import PerformanceSummaryDetail
|
15
17
|
from .performance_summary_list import PerformanceSummaryList
|
16
|
-
from .studio_detail import StudioDetail, StudioDetailList
|
18
|
+
from .studio_detail import Pagination, StudioDetail, StudioDetailList
|
17
19
|
from .studio_services import StudioServiceList
|
18
20
|
from .telemetry import Telemetry
|
19
21
|
from .telemetry_hr_history import TelemetryHrHistory
|
@@ -22,31 +24,37 @@ from .total_classes import TotalClasses
|
|
22
24
|
from .workouts import WorkoutList
|
23
25
|
|
24
26
|
__all__ = [
|
27
|
+
"BodyCompositionList",
|
28
|
+
"BookClass",
|
25
29
|
"BookingList",
|
30
|
+
"BookingStatus",
|
31
|
+
"CancelBooking",
|
26
32
|
"ChallengeTrackerContent",
|
27
33
|
"ChallengeTrackerDetailList",
|
34
|
+
"ChallengeType",
|
35
|
+
"ClassType",
|
36
|
+
"DoW",
|
37
|
+
"EquipmentType",
|
38
|
+
"FavoriteStudioList",
|
39
|
+
"HistoryClassStatus",
|
28
40
|
"LatestAgreement",
|
29
41
|
"MemberDetail",
|
30
42
|
"MemberMembership",
|
31
43
|
"MemberPurchaseList",
|
44
|
+
"OtfClassList",
|
32
45
|
"OutOfStudioWorkoutHistoryList",
|
46
|
+
"Pagination",
|
47
|
+
"PerformanceSummaryDetail",
|
48
|
+
"PerformanceSummaryList",
|
49
|
+
"StatsResponse",
|
50
|
+
"StatsTime",
|
51
|
+
"StudioDetail",
|
52
|
+
"StudioDetailList",
|
33
53
|
"StudioServiceList",
|
34
|
-
"TotalClasses",
|
35
|
-
"WorkoutList",
|
36
|
-
"ChallengeType",
|
37
|
-
"BookingStatus",
|
38
|
-
"EquipmentType",
|
39
|
-
"HistoryClassStatus",
|
40
54
|
"StudioStatus",
|
41
|
-
"FavoriteStudioList",
|
42
|
-
"OtfClassList",
|
43
|
-
"TelemetryHrHistory",
|
44
55
|
"Telemetry",
|
56
|
+
"TelemetryHrHistory",
|
45
57
|
"TelemetryMaxHr",
|
46
|
-
"
|
47
|
-
"
|
48
|
-
"PerformanceSummaryDetail",
|
49
|
-
"PerformanceSummaryList",
|
50
|
-
"BookClass",
|
51
|
-
"CancelBooking",
|
58
|
+
"TotalClasses",
|
59
|
+
"WorkoutList",
|
52
60
|
]
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: otf-api
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.4.0
|
4
4
|
Summary: Python OrangeTheory Fitness API Client
|
5
5
|
License: MIT
|
6
6
|
Author: Jessica Smith
|
@@ -22,13 +22,13 @@ Requires-Dist: aiohttp (==3.9.5)
|
|
22
22
|
Requires-Dist: humanize (>=4.9.0,<5.0.0)
|
23
23
|
Requires-Dist: inflection (==0.5.*)
|
24
24
|
Requires-Dist: loguru (==0.7.2)
|
25
|
-
Requires-Dist: pendulum
|
25
|
+
Requires-Dist: pendulum (>=3.0.0,<4.0.0)
|
26
26
|
Requires-Dist: pint (==0.24.*)
|
27
27
|
Requires-Dist: pycognito (==2024.5.1)
|
28
28
|
Requires-Dist: pydantic (==2.7.3)
|
29
29
|
Requires-Dist: python-box (>=7.2.0,<8.0.0)
|
30
|
-
Requires-Dist: readchar
|
31
|
-
Requires-Dist: typer
|
30
|
+
Requires-Dist: readchar (>=4.1.0,<5.0.0)
|
31
|
+
Requires-Dist: typer (>=0.12.3,<0.13.0)
|
32
32
|
Project-URL: Documentation, https://otf-api.readthedocs.io/en/stable/
|
33
33
|
Description-Content-Type: text/markdown
|
34
34
|
|
@@ -44,12 +44,11 @@ pip install otf-api
|
|
44
44
|
|
45
45
|
## Overview
|
46
46
|
|
47
|
-
To use the API, you need to create an instance of the `
|
47
|
+
To use the API, you need to create an instance of the `Otf` class, providing your email address and password. This will authenticate you with the API and allow you to make requests. When the `Otf` object is created it automatically grabs your member details and home studio, to simplify the process of making requests.
|
48
48
|
|
49
49
|
|
50
50
|
See the [examples](./examples) for more information on how to use the API.
|
51
51
|
|
52
|
-
|
53
52
|
Disclaimer:
|
54
53
|
This project is in no way affiliated with OrangeTheory Fitness.
|
55
54
|
|
@@ -1,14 +1,14 @@
|
|
1
|
-
otf_api/__init__.py,sha256=
|
2
|
-
otf_api/api.py,sha256=
|
1
|
+
otf_api/__init__.py,sha256=r_Pbfy17O3PShjKEtmGbzMz77vKm6QIWf7nj1euogiw,237
|
2
|
+
otf_api/api.py,sha256=4yiYQb_ShBduk0E9JLRs_PzLFEBDIw7c59FFE34BuKo,36749
|
3
|
+
otf_api/auth.py,sha256=eapyHm768j402iqwgKOr2hpqzztXS8DBp021TSK0Txk,10194
|
3
4
|
otf_api/cli/__init__.py,sha256=WI-882LPH7Tj_ygDHqE5ehsas_u7m3ulsplS9vXKByk,151
|
4
5
|
otf_api/cli/_utilities.py,sha256=epjEO9S6ag4HgJLXlTpCQXfdVQkqGWyNavp7DjwPL78,1753
|
5
|
-
otf_api/cli/app.py,sha256=
|
6
|
-
otf_api/cli/bookings.py,sha256=
|
6
|
+
otf_api/cli/app.py,sha256=88TuMwq3foRr1Cui0V3h0mxNkoANqd6QQifI9CIgLvI,6469
|
7
|
+
otf_api/cli/bookings.py,sha256=wSmZA-03etcL6Tvb1vDSvHZW8EA9CZUgKX6W1pps3Yw,8161
|
7
8
|
otf_api/cli/prompts.py,sha256=iyodQXVa5v9VsrMxw0ob1okGRBDbWCSxhrNEylsOTEQ,5358
|
8
|
-
otf_api/models/__init__.py,sha256=
|
9
|
-
otf_api/models/auth.py,sha256=DyFManp5RlfQinA0rWG_PjBZXrn2TpSM1-PJ630lamo,4073
|
9
|
+
otf_api/models/__init__.py,sha256=2Zvf7u1Z3qguDd4PsWeoP_Lma3bk-A7RmYQ4LbPJ9Kg,1464
|
10
10
|
otf_api/models/base.py,sha256=oTDxyliK64GyTNx1bGTd-b9dfVn0r3YPpSycs2qEuIw,7285
|
11
|
-
otf_api/models/responses/__init__.py,sha256=
|
11
|
+
otf_api/models/responses/__init__.py,sha256=UdJhkzREux-5DnHE5VSYN0KNKxyDkUkYMPWQpa9Y9qs,2000
|
12
12
|
otf_api/models/responses/body_composition_list.py,sha256=RTC5bQpmMDUKqFl0nGFExdDxfnbOAGoBLWunjpOym80,12193
|
13
13
|
otf_api/models/responses/book_class.py,sha256=bWURKEjLZWPzwu3HNP2zUmHWo7q7h6_z43a9KTST0Ec,15413
|
14
14
|
otf_api/models/responses/bookings.py,sha256=0oQxdKTK-k30GVDKiVxTh0vvPTbrw78sqpQpYL7JnJU,11058
|
@@ -34,9 +34,9 @@ otf_api/models/responses/telemetry_max_hr.py,sha256=xKxH0fIlOqFyZv8UW98XsxF-GMoI
|
|
34
34
|
otf_api/models/responses/total_classes.py,sha256=WrKkWbq0eK8J0RC4qhZ5kmXnv_ZTDbyzsoRm7XKGlss,288
|
35
35
|
otf_api/models/responses/workouts.py,sha256=4r6wQVY-yUsI83JYBpSCYhd7I5u-5OLvy1Vd1_gra88,3177
|
36
36
|
otf_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
37
|
-
otf_api-0.
|
38
|
-
otf_api-0.
|
39
|
-
otf_api-0.
|
40
|
-
otf_api-0.
|
41
|
-
otf_api-0.
|
42
|
-
otf_api-0.
|
37
|
+
otf_api-0.4.0.dist-info/AUTHORS.md,sha256=FcNWMxpe8KDuTq4Qau0SUXsabQwGs9TGnMp1WkXRnj8,123
|
38
|
+
otf_api-0.4.0.dist-info/LICENSE,sha256=UaPT9ynYigC3nX8n22_rC37n-qmTRKLFaHrtUwF9ktE,1071
|
39
|
+
otf_api-0.4.0.dist-info/METADATA,sha256=0VCBjMyO3Wrka4Jz0ZwB38zm5KxVOlb-H1ZL6_M64WU,2259
|
40
|
+
otf_api-0.4.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
41
|
+
otf_api-0.4.0.dist-info/entry_points.txt,sha256=V2jhhfsUo3DeF0CA9HmKrMnvSoOldn9ShIzbApbeHTY,44
|
42
|
+
otf_api-0.4.0.dist-info/RECORD,,
|
otf_api/models/auth.py
DELETED
@@ -1,147 +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 OtfItemBase
|
9
|
-
|
10
|
-
CLIENT_ID = "65knvqta6p37efc2l3eh26pl5o" # from otlive
|
11
|
-
USER_POOL_ID = "us-east-1_dYDxUeyL1"
|
12
|
-
|
13
|
-
|
14
|
-
class IdClaimsData(OtfItemBase):
|
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
|
-
koji_person_id: str = Field(alias="custom:koji_person_id")
|
32
|
-
|
33
|
-
@property
|
34
|
-
def member_uuid(self) -> str:
|
35
|
-
return self.cognito_username
|
36
|
-
|
37
|
-
@property
|
38
|
-
def full_name(self) -> str:
|
39
|
-
return f"{self.given_name} {self.family_name}"
|
40
|
-
|
41
|
-
|
42
|
-
class AccessClaimsData(OtfItemBase):
|
43
|
-
sub: str
|
44
|
-
device_key: str
|
45
|
-
iss: str
|
46
|
-
client_id: str
|
47
|
-
event_id: str
|
48
|
-
token_use: str
|
49
|
-
scope: str
|
50
|
-
auth_time: int
|
51
|
-
exp: int
|
52
|
-
iat: int
|
53
|
-
jti: str
|
54
|
-
username: str
|
55
|
-
|
56
|
-
@property
|
57
|
-
def member_uuid(self) -> str:
|
58
|
-
return self.username
|
59
|
-
|
60
|
-
|
61
|
-
class User:
|
62
|
-
token_path: ClassVar[Path] = Path("~/.otf/.tokens").expanduser()
|
63
|
-
cognito: Cognito
|
64
|
-
|
65
|
-
def __init__(self, cognito: Cognito):
|
66
|
-
self.cognito = cognito
|
67
|
-
|
68
|
-
@property
|
69
|
-
def member_id(self) -> str:
|
70
|
-
return self.id_claims_data.cognito_username
|
71
|
-
|
72
|
-
@property
|
73
|
-
def member_uuid(self) -> str:
|
74
|
-
return self.access_claims_data.sub
|
75
|
-
|
76
|
-
@property
|
77
|
-
def access_claims_data(self) -> AccessClaimsData:
|
78
|
-
return AccessClaimsData(**self.cognito.access_claims)
|
79
|
-
|
80
|
-
@property
|
81
|
-
def id_claims_data(self) -> IdClaimsData:
|
82
|
-
return IdClaimsData(**self.cognito.id_claims)
|
83
|
-
|
84
|
-
def save_to_disk(self) -> None:
|
85
|
-
self.token_path.parent.mkdir(parents=True, exist_ok=True)
|
86
|
-
data = {
|
87
|
-
"username": self.cognito.username,
|
88
|
-
"id_token": self.cognito.id_token,
|
89
|
-
"access_token": self.cognito.access_token,
|
90
|
-
"refresh_token": self.cognito.refresh_token,
|
91
|
-
}
|
92
|
-
self.token_path.write_text(json.dumps(data))
|
93
|
-
|
94
|
-
@classmethod
|
95
|
-
def cache_file_exists(cls) -> bool:
|
96
|
-
return cls.token_path.exists()
|
97
|
-
|
98
|
-
@classmethod
|
99
|
-
def username_from_disk(cls) -> str:
|
100
|
-
val: str = json.loads(cls.token_path.read_text())["username"]
|
101
|
-
return val
|
102
|
-
|
103
|
-
@classmethod
|
104
|
-
def load_from_disk(cls, username: str, password: str) -> "User":
|
105
|
-
"""Load a User instance from disk. If the token is invalid, reauthenticate with the provided credentials.
|
106
|
-
|
107
|
-
Args:
|
108
|
-
username (str): The username to reauthenticate with.
|
109
|
-
password (str): The password to reauthenticate with.
|
110
|
-
|
111
|
-
Returns:
|
112
|
-
User: The loaded user.
|
113
|
-
|
114
|
-
"""
|
115
|
-
attr_dict = json.loads(cls.token_path.read_text())
|
116
|
-
|
117
|
-
cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, **attr_dict)
|
118
|
-
try:
|
119
|
-
cognito_user.verify_tokens()
|
120
|
-
return cls(cognito=cognito_user)
|
121
|
-
except TokenVerificationException:
|
122
|
-
user = cls.login(username, password)
|
123
|
-
return user
|
124
|
-
|
125
|
-
@classmethod
|
126
|
-
def login(cls, username: str, password: str) -> "User":
|
127
|
-
"""Login and return a User instance. After a successful login, the user is saved to disk.
|
128
|
-
|
129
|
-
Args:
|
130
|
-
username (str): The username to login with.
|
131
|
-
password (str): The password to login with.
|
132
|
-
|
133
|
-
Returns:
|
134
|
-
User: The logged in user.
|
135
|
-
"""
|
136
|
-
cognito_user = Cognito(USER_POOL_ID, CLIENT_ID, username=username)
|
137
|
-
cognito_user.authenticate(password)
|
138
|
-
cognito_user.check_token()
|
139
|
-
user = cls(cognito=cognito_user)
|
140
|
-
user.save_to_disk()
|
141
|
-
return user
|
142
|
-
|
143
|
-
def refresh_token(self) -> "User":
|
144
|
-
"""Refresh the user's access token."""
|
145
|
-
self.cognito.check_token()
|
146
|
-
self.save_to_disk()
|
147
|
-
return self
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|