otf-api 0.10.2__py3-none-any.whl → 0.11.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- otf_api/__init__.py +1 -1
- otf_api/api.py +397 -209
- otf_api/auth/__init__.py +1 -1
- otf_api/auth/auth.py +115 -8
- otf_api/auth/user.py +14 -7
- otf_api/auth/utils.py +21 -33
- otf_api/models/__init__.py +11 -1
- otf_api/models/bookings_v2.py +171 -0
- otf_api/models/challenge_tracker_content.py +2 -2
- otf_api/models/challenge_tracker_detail.py +2 -2
- otf_api/models/classes.py +4 -7
- otf_api/models/enums.py +1 -0
- otf_api/models/lifetime_stats.py +7 -19
- otf_api/models/member_detail.py +1 -1
- otf_api/models/mixins.py +23 -17
- otf_api/models/out_of_studio_workout_history.py +1 -1
- otf_api/models/performance_summary.py +11 -93
- otf_api/models/ratings.py +28 -0
- otf_api/models/studio_detail.py +14 -7
- otf_api/models/telemetry.py +13 -3
- otf_api/models/workout.py +81 -0
- otf_api/utils.py +36 -8
- {otf_api-0.10.2.dist-info → otf_api-0.11.1.dist-info}/METADATA +23 -27
- otf_api-0.11.1.dist-info/RECORD +38 -0
- {otf_api-0.10.2.dist-info → otf_api-0.11.1.dist-info}/WHEEL +2 -1
- otf_api-0.11.1.dist-info/top_level.txt +1 -0
- otf_api-0.10.2.dist-info/RECORD +0 -34
- {otf_api-0.10.2.dist-info → otf_api-0.11.1.dist-info/licenses}/LICENSE +0 -0
otf_api/auth/__init__.py
CHANGED
otf_api/auth/auth.py
CHANGED
@@ -1,13 +1,19 @@
|
|
1
|
+
# pyright: reportTypedDictNotRequiredAccess=none
|
1
2
|
import platform
|
2
3
|
import typing
|
4
|
+
from collections.abc import Generator
|
3
5
|
from logging import getLogger
|
4
6
|
from pathlib import Path
|
5
7
|
from time import sleep
|
6
8
|
from typing import Any, ClassVar
|
7
9
|
|
10
|
+
import httpx
|
8
11
|
from boto3 import Session
|
9
12
|
from botocore import UNSIGNED
|
13
|
+
from botocore.auth import SigV4Auth
|
14
|
+
from botocore.awsrequest import AWSRequest
|
10
15
|
from botocore.config import Config
|
16
|
+
from botocore.credentials import Credentials
|
11
17
|
from botocore.exceptions import ClientError
|
12
18
|
from pycognito import AWSSRP, Cognito
|
13
19
|
from pycognito.aws_srp import generate_hash_device
|
@@ -15,6 +21,7 @@ from pycognito.aws_srp import generate_hash_device
|
|
15
21
|
from otf_api.utils import CacheableData
|
16
22
|
|
17
23
|
if typing.TYPE_CHECKING:
|
24
|
+
from mypy_boto3_cognito_identity import CognitoIdentityClient
|
18
25
|
from mypy_boto3_cognito_idp import CognitoIdentityProviderClient
|
19
26
|
from mypy_boto3_cognito_idp.type_defs import InitiateAuthResponseTypeDef
|
20
27
|
|
@@ -22,7 +29,9 @@ LOGGER = getLogger(__name__)
|
|
22
29
|
CLIENT_ID = "1457d19r0pcjgmp5agooi0rb1b" # from android app
|
23
30
|
USER_POOL_ID = "us-east-1_dYDxUeyL1"
|
24
31
|
REGION = "us-east-1"
|
25
|
-
|
32
|
+
|
33
|
+
ID_POOL_ID = "us-east-1:4943c880-fb02-4fd7-bc37-2f4c32ecb2a3"
|
34
|
+
PROVIDER_KEY = f"cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}"
|
26
35
|
|
27
36
|
BOTO_CONFIG = Config(region_name=REGION, signature_version=UNSIGNED)
|
28
37
|
CRED_CACHE = CacheableData("creds", Path("~/.otf-api"))
|
@@ -82,6 +91,11 @@ class OtfCognito(Cognito):
|
|
82
91
|
self.mfa_tokens: dict[str, Any] = {}
|
83
92
|
self.pool_domain_url: str | None = None
|
84
93
|
|
94
|
+
self.handle_login(username, password, id_token, access_token)
|
95
|
+
|
96
|
+
def handle_login(
|
97
|
+
self, username: str | None, password: str | None, id_token: str | None = None, access_token: str | None = None
|
98
|
+
) -> None:
|
85
99
|
try:
|
86
100
|
dd = CRED_CACHE.get_cached_data(DEVICE_KEYS)
|
87
101
|
except Exception:
|
@@ -93,9 +107,13 @@ class OtfCognito(Cognito):
|
|
93
107
|
self.device_group_key = dd.get("device_group_key") # type: ignore
|
94
108
|
self.device_password = dd.get("device_password") # type: ignore
|
95
109
|
|
96
|
-
self.
|
110
|
+
self.idp_client: CognitoIdentityProviderClient = Session().client(
|
97
111
|
"cognito-idp", config=BOTO_CONFIG, region_name=REGION
|
98
|
-
)
|
112
|
+
) # type: ignore
|
113
|
+
|
114
|
+
self.id_client: CognitoIdentityClient = Session().client(
|
115
|
+
"cognito-identity", config=BOTO_CONFIG, region_name=REGION
|
116
|
+
) # type: ignore
|
99
117
|
|
100
118
|
try:
|
101
119
|
token_cache = CRED_CACHE.get_cached_data(TOKEN_KEYS)
|
@@ -114,10 +132,28 @@ class OtfCognito(Cognito):
|
|
114
132
|
self.access_token = token_cache["access_token"]
|
115
133
|
self.refresh_token = token_cache["refresh_token"]
|
116
134
|
|
117
|
-
|
135
|
+
try:
|
136
|
+
self.check_token()
|
137
|
+
except ClientError as e:
|
138
|
+
if e.response["Error"]["Code"] == "NotAuthorizedException":
|
139
|
+
LOGGER.warning("Tokens expired, attempting to login with username and password")
|
140
|
+
CRED_CACHE.clear_cache()
|
141
|
+
if not username or not password:
|
142
|
+
raise NoCredentialsError("No credentials provided and no tokens cached, cannot authenticate")
|
143
|
+
self.handle_login(username, password)
|
144
|
+
|
118
145
|
self.verify_tokens()
|
119
146
|
CRED_CACHE.write_to_cache(self.tokens)
|
120
147
|
|
148
|
+
def get_identity_credentials(self):
|
149
|
+
"""Get the AWS credentials for the user using the Cognito Identity Pool.
|
150
|
+
This is used to access AWS resources using the Cognito Identity Pool."""
|
151
|
+
cognito_id = self.id_client.get_id(IdentityPoolId=ID_POOL_ID, Logins={PROVIDER_KEY: self.id_token})
|
152
|
+
creds = self.id_client.get_credentials_for_identity(
|
153
|
+
IdentityId=cognito_id["IdentityId"], Logins={PROVIDER_KEY: self.id_token}
|
154
|
+
)
|
155
|
+
return creds["Credentials"]
|
156
|
+
|
121
157
|
@property
|
122
158
|
def tokens(self) -> dict[str, str]:
|
123
159
|
tokens = {
|
@@ -146,7 +182,7 @@ class OtfCognito(Cognito):
|
|
146
182
|
password=password,
|
147
183
|
pool_id=USER_POOL_ID,
|
148
184
|
client_id=CLIENT_ID,
|
149
|
-
client=self.
|
185
|
+
client=self.idp_client,
|
150
186
|
)
|
151
187
|
try:
|
152
188
|
tokens: InitiateAuthResponseTypeDef = aws.authenticate_user()
|
@@ -177,10 +213,10 @@ class OtfCognito(Cognito):
|
|
177
213
|
self.device_group_key, self.device_key
|
178
214
|
)
|
179
215
|
|
180
|
-
self.
|
216
|
+
self.idp_client.confirm_device(
|
181
217
|
AccessToken=self.access_token,
|
182
218
|
DeviceKey=self.device_key,
|
183
|
-
DeviceSecretVerifierConfig=device_secret_verifier_config,
|
219
|
+
DeviceSecretVerifierConfig=device_secret_verifier_config, # type: ignore (pycognito is untyped)
|
184
220
|
DeviceName=self.device_name,
|
185
221
|
)
|
186
222
|
|
@@ -206,7 +242,7 @@ class OtfCognito(Cognito):
|
|
206
242
|
if not self.refresh_token:
|
207
243
|
raise ValueError("No refresh token set - cannot renew access token")
|
208
244
|
|
209
|
-
refresh_response = self.
|
245
|
+
refresh_response = self.idp_client.initiate_auth(
|
210
246
|
ClientId=self.client_id,
|
211
247
|
AuthFlow="REFRESH_TOKEN_AUTH",
|
212
248
|
AuthParameters={"REFRESH_TOKEN": self.refresh_token, "DEVICE_KEY": self.device_key},
|
@@ -221,6 +257,9 @@ class OtfCognito(Cognito):
|
|
221
257
|
auth_result = tokens["AuthenticationResult"]
|
222
258
|
device_metadata = auth_result.get("NewDeviceMetadata", {})
|
223
259
|
|
260
|
+
assert "AccessToken" in auth_result, "AccessToken not found in AuthenticationResult"
|
261
|
+
assert "IdToken" in auth_result, "IdToken not found in AuthenticationResult"
|
262
|
+
|
224
263
|
# tokens - refresh token defaults to existing value if not present
|
225
264
|
# note: verify_token also sets the token attribute
|
226
265
|
self.verify_token(auth_result["AccessToken"], "access_token", "access")
|
@@ -232,3 +271,71 @@ class OtfCognito(Cognito):
|
|
232
271
|
self.device_key = device_metadata.get("DeviceKey", self.device_key)
|
233
272
|
self.device_group_key = device_metadata.get("DeviceGroupKey", self.device_group_key)
|
234
273
|
CRED_CACHE.write_to_cache(self.device_metadata)
|
274
|
+
|
275
|
+
|
276
|
+
class HttpxCognitoAuth(httpx.Auth):
|
277
|
+
http_header: str = "Authorization"
|
278
|
+
http_header_prefix: str = "Bearer "
|
279
|
+
|
280
|
+
def __init__(self, cognito: OtfCognito):
|
281
|
+
"""HTTPX Authentication extension for Cognito User Pools.
|
282
|
+
|
283
|
+
Args:
|
284
|
+
cognito (Cognito): A Cognito instance.
|
285
|
+
"""
|
286
|
+
|
287
|
+
self.cognito = cognito
|
288
|
+
|
289
|
+
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, Any, None]:
|
290
|
+
self.cognito.check_token(renew=True)
|
291
|
+
|
292
|
+
token = self.cognito.id_token
|
293
|
+
|
294
|
+
assert isinstance(token, str), "Token is not a string"
|
295
|
+
|
296
|
+
request.headers[self.http_header] = self.http_header_prefix + token
|
297
|
+
|
298
|
+
# If the request has the SIGV4AUTH_REQUIRED header, sign the request
|
299
|
+
# used by very few endpoints, but I expect that may change in the future
|
300
|
+
if request.headers.get("SIGV4AUTH_REQUIRED"):
|
301
|
+
del request.headers["SIGV4AUTH_REQUIRED"]
|
302
|
+
yield from self.sign_httpx_request(request)
|
303
|
+
return
|
304
|
+
|
305
|
+
yield request
|
306
|
+
|
307
|
+
def sign_httpx_request(self, request: httpx.Request) -> Generator[httpx.Request, Any, None]:
|
308
|
+
"""
|
309
|
+
Sign an HTTP request using AWS SigV4 for use with httpx.
|
310
|
+
"""
|
311
|
+
headers = request.headers.copy()
|
312
|
+
|
313
|
+
# ensure this header is not included, it will break the signature
|
314
|
+
headers.pop("connection", None)
|
315
|
+
|
316
|
+
body = b"" if request.method in ("GET", "HEAD") else request.content or b""
|
317
|
+
|
318
|
+
if hasattr(body, "read"):
|
319
|
+
raise ValueError("Streaming bodies are not supported in signed requests")
|
320
|
+
|
321
|
+
creds = self.cognito.get_identity_credentials()
|
322
|
+
|
323
|
+
credentials = Credentials(
|
324
|
+
access_key=creds["AccessKeyId"], secret_key=creds["SecretKey"], token=creds["SessionToken"]
|
325
|
+
)
|
326
|
+
|
327
|
+
aws_request = AWSRequest(method=request.method, url=str(request.url), data=body, headers=headers)
|
328
|
+
|
329
|
+
SigV4Auth(credentials, "execute-api", REGION).add_auth(aws_request)
|
330
|
+
|
331
|
+
signed_headers = dict(aws_request.headers.items())
|
332
|
+
|
333
|
+
# Return a brand new request object with signed headers
|
334
|
+
signed_request = httpx.Request(
|
335
|
+
method=request.method,
|
336
|
+
url=request.url,
|
337
|
+
headers=signed_headers,
|
338
|
+
content=body,
|
339
|
+
)
|
340
|
+
|
341
|
+
yield signed_request
|
otf_api/auth/user.py
CHANGED
@@ -2,8 +2,8 @@ from logging import getLogger
|
|
2
2
|
|
3
3
|
import attrs
|
4
4
|
|
5
|
-
from otf_api.auth.auth import CRED_CACHE, NoCredentialsError, OtfCognito
|
6
|
-
from otf_api.auth.utils import
|
5
|
+
from otf_api.auth.auth import CRED_CACHE, HttpxCognitoAuth, NoCredentialsError, OtfCognito
|
6
|
+
from otf_api.auth.utils import can_provide_input, get_credentials_from_env, prompt_for_username_and_password
|
7
7
|
|
8
8
|
LOGGER = getLogger(__name__)
|
9
9
|
|
@@ -47,12 +47,19 @@ class OtfUser:
|
|
47
47
|
refresh_token=refresh_token,
|
48
48
|
)
|
49
49
|
except NoCredentialsError:
|
50
|
-
|
51
|
-
|
52
|
-
|
50
|
+
username, password = get_credentials_from_env()
|
51
|
+
if not username or not password:
|
52
|
+
if not can_provide_input():
|
53
|
+
LOGGER.error("Unable to prompt for credentials in a non-interactive shell")
|
54
|
+
raise
|
55
|
+
username, password = prompt_for_username_and_password()
|
56
|
+
if not username or not password:
|
57
|
+
raise NoCredentialsError("No credentials provided and no tokens cached, cannot authenticate")
|
58
|
+
except Exception as e:
|
59
|
+
LOGGER.exception("Failed to authenticate with Cognito")
|
60
|
+
raise e
|
53
61
|
|
54
|
-
|
55
|
-
self.cognito = OtfCognito(username=username, password=password)
|
62
|
+
self.cognito = OtfCognito(username=username, password=password)
|
56
63
|
|
57
64
|
self.cognito_id = self.cognito.access_claims["sub"]
|
58
65
|
self.member_uuid = self.cognito.id_claims["cognito:username"]
|
otf_api/auth/utils.py
CHANGED
@@ -1,14 +1,8 @@
|
|
1
1
|
import os
|
2
2
|
import sys
|
3
|
-
from collections.abc import Generator
|
4
3
|
from functools import partial
|
5
4
|
from getpass import getpass
|
6
5
|
from logging import getLogger
|
7
|
-
from typing import Any
|
8
|
-
|
9
|
-
import httpx
|
10
|
-
from httpx import Request
|
11
|
-
from pycognito import Cognito
|
12
6
|
|
13
7
|
LOGGER = getLogger(__name__)
|
14
8
|
USERNAME_PROMPT = "Enter your Orangetheory Fitness username/email: "
|
@@ -17,9 +11,9 @@ PASSWORD_PROMPT = "Enter your Orangetheory Fitness password: "
|
|
17
11
|
|
18
12
|
def _show_error_message(message: str) -> None:
|
19
13
|
try:
|
20
|
-
from rich import
|
14
|
+
from rich import get_console # type: ignore
|
21
15
|
|
22
|
-
print(message, style="bold red")
|
16
|
+
get_console().print(message, style="bold red")
|
23
17
|
except ImportError:
|
24
18
|
print(message)
|
25
19
|
|
@@ -84,6 +78,23 @@ def _prompt_for_password() -> str:
|
|
84
78
|
return password
|
85
79
|
|
86
80
|
|
81
|
+
def get_credentials_from_env() -> tuple[str, str]:
|
82
|
+
"""Get credentials from environment variables.
|
83
|
+
|
84
|
+
Returns:
|
85
|
+
tuple[str, str]: A tuple containing the username and password.
|
86
|
+
"""
|
87
|
+
|
88
|
+
username = os.getenv("OTF_EMAIL")
|
89
|
+
password = os.getenv("OTF_PASSWORD")
|
90
|
+
|
91
|
+
if not username or not password:
|
92
|
+
_show_error_message("Environment variables OTF_EMAIL and OTF_PASSWORD are required")
|
93
|
+
return "", ""
|
94
|
+
|
95
|
+
return username, password
|
96
|
+
|
97
|
+
|
87
98
|
def prompt_for_username_and_password() -> tuple[str, str]:
|
88
99
|
"""Prompt for a username and password.
|
89
100
|
|
@@ -91,8 +102,8 @@ def prompt_for_username_and_password() -> tuple[str, str]:
|
|
91
102
|
tuple[str, str]: A tuple containing the username and password.
|
92
103
|
"""
|
93
104
|
|
94
|
-
username =
|
95
|
-
password =
|
105
|
+
username = _prompt_for_username()
|
106
|
+
password = _prompt_for_password()
|
96
107
|
|
97
108
|
return username, password
|
98
109
|
|
@@ -104,26 +115,3 @@ def can_provide_input() -> bool:
|
|
104
115
|
bool: True if the script is running in an interactive shell.
|
105
116
|
"""
|
106
117
|
return os.isatty(sys.stdin.fileno()) and os.isatty(sys.stdout.fileno())
|
107
|
-
|
108
|
-
|
109
|
-
class HttpxCognitoAuth(httpx.Auth):
|
110
|
-
http_header: str = "Authorization"
|
111
|
-
http_header_prefix: str = "Bearer "
|
112
|
-
|
113
|
-
def __init__(self, cognito: Cognito):
|
114
|
-
"""HTTPX Authentication extension for Cognito User Pools.
|
115
|
-
|
116
|
-
Args:
|
117
|
-
cognito (Cognito): A Cognito instance.
|
118
|
-
"""
|
119
|
-
|
120
|
-
self.cognito = cognito
|
121
|
-
|
122
|
-
def auth_flow(self, request: Request) -> Generator[Request, Any, None]:
|
123
|
-
self.cognito.check_token(renew=True)
|
124
|
-
|
125
|
-
token = self.cognito.id_token
|
126
|
-
|
127
|
-
request.headers[self.http_header] = self.http_header_prefix + token
|
128
|
-
|
129
|
-
yield request
|
otf_api/models/__init__.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
from .body_composition_list import BodyCompositionData
|
2
2
|
from .bookings import Booking
|
3
|
+
from .bookings_v2 import BookingV2, BookingV2Class
|
3
4
|
from .challenge_tracker_content import ChallengeTracker
|
4
5
|
from .challenge_tracker_detail import FitnessBenchmark
|
5
6
|
from .classes import OtfClass
|
@@ -13,22 +14,26 @@ from .enums import (
|
|
13
14
|
StatsTime,
|
14
15
|
StudioStatus,
|
15
16
|
)
|
16
|
-
from .lifetime_stats import StatsResponse, TimeStats
|
17
|
+
from .lifetime_stats import InStudioStatsData, OutStudioStatsData, StatsResponse, TimeStats
|
17
18
|
from .member_detail import MemberDetail
|
18
19
|
from .member_membership import MemberMembership
|
19
20
|
from .member_purchases import MemberPurchase
|
20
21
|
from .notifications import EmailNotificationSettings, SmsNotificationSettings
|
21
22
|
from .out_of_studio_workout_history import OutOfStudioWorkoutHistory
|
22
23
|
from .performance_summary import PerformanceSummary
|
24
|
+
from .ratings import get_class_rating_value, get_coach_rating_value
|
23
25
|
from .studio_detail import StudioDetail
|
24
26
|
from .studio_services import StudioService
|
25
27
|
from .telemetry import Telemetry, TelemetryHistoryItem
|
28
|
+
from .workout import Workout
|
26
29
|
|
27
30
|
__all__ = [
|
28
31
|
"HISTORICAL_BOOKING_STATUSES",
|
29
32
|
"BodyCompositionData",
|
30
33
|
"Booking",
|
31
34
|
"BookingStatus",
|
35
|
+
"BookingV2",
|
36
|
+
"BookingV2Class",
|
32
37
|
"ChallengeCategory",
|
33
38
|
"ChallengeTracker",
|
34
39
|
"ClassType",
|
@@ -36,11 +41,13 @@ __all__ = [
|
|
36
41
|
"EmailNotificationSettings",
|
37
42
|
"EquipmentType",
|
38
43
|
"FitnessBenchmark",
|
44
|
+
"InStudioStatsData",
|
39
45
|
"MemberDetail",
|
40
46
|
"MemberMembership",
|
41
47
|
"MemberPurchase",
|
42
48
|
"OtfClass",
|
43
49
|
"OutOfStudioWorkoutHistory",
|
50
|
+
"OutStudioStatsData",
|
44
51
|
"PerformanceSummary",
|
45
52
|
"SmsNotificationSettings",
|
46
53
|
"StatsResponse",
|
@@ -51,4 +58,7 @@ __all__ = [
|
|
51
58
|
"Telemetry",
|
52
59
|
"TelemetryHistoryItem",
|
53
60
|
"TimeStats",
|
61
|
+
"Workout",
|
62
|
+
"get_class_rating_value",
|
63
|
+
"get_coach_rating_value",
|
54
64
|
]
|
@@ -0,0 +1,171 @@
|
|
1
|
+
from datetime import datetime
|
2
|
+
from logging import getLogger
|
3
|
+
|
4
|
+
import pendulum
|
5
|
+
from pydantic import AliasPath, Field
|
6
|
+
|
7
|
+
from otf_api.models.base import OtfItemBase
|
8
|
+
from otf_api.models.enums import BookingStatus, ClassType
|
9
|
+
from otf_api.models.mixins import AddressMixin, PhoneLongitudeLatitudeMixin
|
10
|
+
from otf_api.models.performance_summary import ZoneTimeMinutes
|
11
|
+
|
12
|
+
LOGGER = getLogger(__name__)
|
13
|
+
|
14
|
+
|
15
|
+
class Address(AddressMixin, OtfItemBase): ...
|
16
|
+
|
17
|
+
|
18
|
+
def get_end_time(start_time: datetime, class_type: ClassType) -> datetime:
|
19
|
+
"""
|
20
|
+
Get the end time of a class based on the start time and class type.
|
21
|
+
"""
|
22
|
+
|
23
|
+
start_time = pendulum.instance(start_time)
|
24
|
+
|
25
|
+
match class_type:
|
26
|
+
case ClassType.ORANGE_60:
|
27
|
+
return start_time.add(minutes=60)
|
28
|
+
case ClassType.ORANGE_90:
|
29
|
+
return start_time.add(minutes=90)
|
30
|
+
case ClassType.STRENGTH_50 | ClassType.TREAD_50:
|
31
|
+
return start_time.add(minutes=50)
|
32
|
+
case ClassType.OTHER:
|
33
|
+
LOGGER.warning(
|
34
|
+
f"Class type {class_type} does not have defined length, returning start time plus 60 minutes"
|
35
|
+
)
|
36
|
+
return start_time.add(minutes=60)
|
37
|
+
case _:
|
38
|
+
LOGGER.warning(f"Class type {class_type} is not recognized, returning start time plus 60 minutes")
|
39
|
+
return start_time.add(minutes=60)
|
40
|
+
|
41
|
+
|
42
|
+
class Rating(OtfItemBase):
|
43
|
+
id: str
|
44
|
+
description: str
|
45
|
+
value: int
|
46
|
+
|
47
|
+
|
48
|
+
class BookingV2Studio(PhoneLongitudeLatitudeMixin, OtfItemBase):
|
49
|
+
studio_uuid: str = Field(alias="id")
|
50
|
+
name: str | None = None
|
51
|
+
time_zone: str | None = None
|
52
|
+
email: str | None = None
|
53
|
+
address: Address | None = None
|
54
|
+
|
55
|
+
currency_code: str | None = Field(None, repr=False, exclude=True)
|
56
|
+
mbo_studio_id: str | None = Field(None, description="MindBody attr", repr=False, exclude=True)
|
57
|
+
|
58
|
+
|
59
|
+
class BookingV2Class(OtfItemBase):
|
60
|
+
class_id: str = Field(alias="id", description="Matches the `class_id` attribute of the OtfClass model")
|
61
|
+
name: str
|
62
|
+
class_type: ClassType = Field(alias="type")
|
63
|
+
starts_at: datetime = Field(
|
64
|
+
alias="starts_at_local",
|
65
|
+
description="The start time of the class. Reflects local time, but the object does not have a timezone.",
|
66
|
+
)
|
67
|
+
studio: BookingV2Studio | None = None
|
68
|
+
coach: str | None = Field(None, validation_alias=AliasPath("coach", "first_name"))
|
69
|
+
|
70
|
+
class_uuid: str | None = Field(
|
71
|
+
None, alias="ot_base_class_uuid", description="Only present when class is ratable", exclude=True, repr=False
|
72
|
+
)
|
73
|
+
starts_at_utc: datetime | None = Field(None, alias="starts_at", exclude=True, repr=False)
|
74
|
+
|
75
|
+
def __str__(self) -> str:
|
76
|
+
starts_at_str = self.starts_at.strftime("%a %b %d, %I:%M %p")
|
77
|
+
return f"Class: {starts_at_str} {self.name} - {self.coach}"
|
78
|
+
|
79
|
+
|
80
|
+
class BookingV2Workout(OtfItemBase):
|
81
|
+
id: str
|
82
|
+
performance_summary_id: str = Field(..., alias="id", description="Alias to id, to simplify the API")
|
83
|
+
calories_burned: int
|
84
|
+
splat_points: int
|
85
|
+
step_count: int
|
86
|
+
active_time_seconds: int
|
87
|
+
zone_time_minutes: ZoneTimeMinutes
|
88
|
+
|
89
|
+
|
90
|
+
class BookingV2(OtfItemBase):
|
91
|
+
booking_id: str = Field(
|
92
|
+
..., alias="id", description="The booking ID used to cancel the booking - must be canceled through new endpoint"
|
93
|
+
)
|
94
|
+
|
95
|
+
member_uuid: str = Field(..., alias="member_id")
|
96
|
+
service_name: str | None = Field(None, description="Represents tier of member")
|
97
|
+
|
98
|
+
cross_regional: bool | None = None
|
99
|
+
intro: bool | None = None
|
100
|
+
checked_in: bool
|
101
|
+
canceled: bool
|
102
|
+
late_canceled: bool | None = None
|
103
|
+
canceled_at: datetime | None = None
|
104
|
+
ratable: bool
|
105
|
+
|
106
|
+
otf_class: BookingV2Class = Field(..., alias="class")
|
107
|
+
workout: BookingV2Workout | None = None
|
108
|
+
coach_rating: Rating | None = Field(None, validation_alias=AliasPath("ratings", "coach"))
|
109
|
+
class_rating: Rating | None = Field(None, validation_alias=AliasPath("ratings", "class"))
|
110
|
+
|
111
|
+
paying_studio_id: str | None = None
|
112
|
+
mbo_booking_id: str | None = None
|
113
|
+
mbo_unique_id: str | None = None
|
114
|
+
mbo_paying_unique_id: str | None = None
|
115
|
+
person_id: str
|
116
|
+
|
117
|
+
created_at: datetime | None = Field(
|
118
|
+
None,
|
119
|
+
description="Date the booking was created in the system, not when the booking was made",
|
120
|
+
exclude=True,
|
121
|
+
repr=False,
|
122
|
+
)
|
123
|
+
updated_at: datetime | None = Field(
|
124
|
+
None, description="Date the booking was updated, not when the booking was made", exclude=True, repr=False
|
125
|
+
)
|
126
|
+
|
127
|
+
@property
|
128
|
+
def status(self) -> BookingStatus:
|
129
|
+
"""Emulates the booking status from the old API, but with less specificity"""
|
130
|
+
if self.late_canceled:
|
131
|
+
return BookingStatus.LateCancelled
|
132
|
+
|
133
|
+
if self.checked_in:
|
134
|
+
return BookingStatus.CheckedIn
|
135
|
+
|
136
|
+
if self.canceled:
|
137
|
+
return BookingStatus.Cancelled
|
138
|
+
|
139
|
+
return BookingStatus.Booked
|
140
|
+
|
141
|
+
@property
|
142
|
+
def studio_uuid(self) -> str:
|
143
|
+
"""Shortcut to get the studio UUID"""
|
144
|
+
if self.otf_class.studio is None:
|
145
|
+
return ""
|
146
|
+
return self.otf_class.studio.studio_uuid
|
147
|
+
|
148
|
+
@property
|
149
|
+
def class_uuid(self) -> str:
|
150
|
+
"""Shortcut to get the class UUID"""
|
151
|
+
if self.otf_class.class_uuid is None:
|
152
|
+
return ""
|
153
|
+
return self.otf_class.class_uuid
|
154
|
+
|
155
|
+
@property
|
156
|
+
def starts_at(self) -> datetime:
|
157
|
+
"""Shortcut to get the class start time"""
|
158
|
+
return self.otf_class.starts_at
|
159
|
+
|
160
|
+
@property
|
161
|
+
def ends_at(self) -> datetime:
|
162
|
+
"""Shortcut to get the class end time"""
|
163
|
+
return get_end_time(self.otf_class.starts_at, self.otf_class.class_type)
|
164
|
+
|
165
|
+
def __str__(self) -> str:
|
166
|
+
starts_at_str = self.otf_class.starts_at.strftime("%a %b %d, %I:%M %p")
|
167
|
+
class_name = self.otf_class.name
|
168
|
+
coach_name = self.otf_class.coach
|
169
|
+
booked_str = self.status.value
|
170
|
+
|
171
|
+
return f"Booking: {starts_at_str} {class_name} - {coach_name} ({booked_str})"
|
@@ -11,7 +11,7 @@ endpoint.
|
|
11
11
|
from pydantic import Field
|
12
12
|
|
13
13
|
from otf_api.models.base import OtfItemBase
|
14
|
-
from otf_api.models.enums import
|
14
|
+
from otf_api.models.enums import EquipmentType
|
15
15
|
|
16
16
|
|
17
17
|
class Year(OtfItemBase):
|
@@ -39,7 +39,7 @@ class Challenge(OtfItemBase):
|
|
39
39
|
# all related to the ChallengeType enums or the few SubCategory enums I've
|
40
40
|
# been able to puzzle out. I haven't been able to link them to any code
|
41
41
|
# in the OTF app. Due to that, they are being excluded from the model for now.
|
42
|
-
challenge_category_id:
|
42
|
+
challenge_category_id: int | None = Field(None, alias="ChallengeCategoryId")
|
43
43
|
challenge_sub_category_id: int | None = Field(None, alias="ChallengeSubCategoryId")
|
44
44
|
challenge_name: str | None = Field(None, alias="ChallengeName")
|
45
45
|
years: list[Year] = Field(default_factory=list, alias="Years")
|
@@ -4,7 +4,7 @@ from typing import Any
|
|
4
4
|
from pydantic import Field
|
5
5
|
|
6
6
|
from otf_api.models.base import OtfItemBase
|
7
|
-
from otf_api.models.enums import
|
7
|
+
from otf_api.models.enums import EquipmentType
|
8
8
|
|
9
9
|
|
10
10
|
class MetricEntry(OtfItemBase):
|
@@ -74,7 +74,7 @@ class Goal(OtfItemBase):
|
|
74
74
|
|
75
75
|
|
76
76
|
class FitnessBenchmark(OtfItemBase):
|
77
|
-
challenge_category_id:
|
77
|
+
challenge_category_id: int | None = Field(None, alias="ChallengeCategoryId")
|
78
78
|
challenge_sub_category_id: int | None = Field(None, alias="ChallengeSubCategoryId")
|
79
79
|
equipment_id: EquipmentType = Field(None, alias="EquipmentId")
|
80
80
|
equipment_name: str | None = Field(None, alias="EquipmentName")
|
otf_api/models/classes.py
CHANGED
@@ -9,9 +9,11 @@ from otf_api.models.studio_detail import StudioDetail
|
|
9
9
|
|
10
10
|
class OtfClass(OtfItemBase):
|
11
11
|
class_uuid: str = Field(alias="ot_base_class_uuid", description="The OTF class UUID")
|
12
|
+
class_id: str | None = Field(None, alias="id", description="Matches new booking endpoint class id")
|
13
|
+
|
12
14
|
name: str | None = Field(None, description="The name of the class")
|
13
15
|
class_type: ClassType = Field(alias="type")
|
14
|
-
coach: str | None = Field(None,
|
16
|
+
coach: str | None = Field(None, validation_alias=AliasPath("coach", "first_name"))
|
15
17
|
ends_at: datetime = Field(
|
16
18
|
alias="ends_at_local",
|
17
19
|
description="The end time of the class. Reflects local time, but the object does not have a timezone.",
|
@@ -27,16 +29,11 @@ class OtfClass(OtfItemBase):
|
|
27
29
|
full: bool | None = None
|
28
30
|
max_capacity: int | None = None
|
29
31
|
waitlist_available: bool | None = None
|
30
|
-
waitlist_size: int | None = None
|
32
|
+
waitlist_size: int | None = Field(None, description="The number of people on the waitlist")
|
31
33
|
is_booked: bool | None = Field(None, description="Custom helper field to determine if class is already booked")
|
32
34
|
is_cancelled: bool | None = Field(None, alias="canceled")
|
33
35
|
is_home_studio: bool | None = Field(None, description="Custom helper field to determine if at home studio")
|
34
36
|
|
35
|
-
# unused fields
|
36
|
-
class_id: str | None = Field(
|
37
|
-
None, alias="id", exclude=True, repr=False, description="Matches new booking endpoint class id"
|
38
|
-
)
|
39
|
-
|
40
37
|
created_at: datetime | None = Field(None, exclude=True, repr=False)
|
41
38
|
ends_at_utc: datetime | None = Field(None, alias="ends_at", exclude=True, repr=False)
|
42
39
|
mbo_class_description_id: str | None = Field(None, exclude=True, repr=False, description="MindBody attr")
|