otf-api 0.10.1__py3-none-any.whl → 0.11.0rc1__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 +373 -201
- otf_api/auth/__init__.py +1 -1
- otf_api/auth/auth.py +113 -7
- 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.py +4 -0
- 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 -5
- 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.1.dist-info → otf_api-0.11.0rc1.dist-info}/METADATA +23 -27
- otf_api-0.11.0rc1.dist-info/RECORD +38 -0
- {otf_api-0.10.1.dist-info → otf_api-0.11.0rc1.dist-info}/WHEEL +2 -1
- otf_api-0.11.0rc1.dist-info/top_level.txt +1 -0
- otf_api-0.10.1.dist-info/RECORD +0 -34
- {otf_api-0.10.1.dist-info → otf_api-0.11.0rc1.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
|
|
@@ -24,6 +31,9 @@ USER_POOL_ID = "us-east-1_dYDxUeyL1"
|
|
24
31
|
REGION = "us-east-1"
|
25
32
|
COGNITO_IDP_URL = f"https://cognito-idp.{REGION}.amazonaws.com/"
|
26
33
|
|
34
|
+
ID_POOL_ID = "us-east-1:4943c880-fb02-4fd7-bc37-2f4c32ecb2a3"
|
35
|
+
PROVIDER_KEY = f"cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}"
|
36
|
+
|
27
37
|
BOTO_CONFIG = Config(region_name=REGION, signature_version=UNSIGNED)
|
28
38
|
CRED_CACHE = CacheableData("creds", Path("~/.otf-api"))
|
29
39
|
|
@@ -82,6 +92,11 @@ class OtfCognito(Cognito):
|
|
82
92
|
self.mfa_tokens: dict[str, Any] = {}
|
83
93
|
self.pool_domain_url: str | None = None
|
84
94
|
|
95
|
+
self.handle_login(username, password, id_token, access_token)
|
96
|
+
|
97
|
+
def handle_login(
|
98
|
+
self, username: str | None, password: str | None, id_token: str | None = None, access_token: str | None = None
|
99
|
+
) -> None:
|
85
100
|
try:
|
86
101
|
dd = CRED_CACHE.get_cached_data(DEVICE_KEYS)
|
87
102
|
except Exception:
|
@@ -93,9 +108,13 @@ class OtfCognito(Cognito):
|
|
93
108
|
self.device_group_key = dd.get("device_group_key") # type: ignore
|
94
109
|
self.device_password = dd.get("device_password") # type: ignore
|
95
110
|
|
96
|
-
self.
|
111
|
+
self.idp_client: CognitoIdentityProviderClient = Session().client(
|
97
112
|
"cognito-idp", config=BOTO_CONFIG, region_name=REGION
|
98
|
-
)
|
113
|
+
) # type: ignore
|
114
|
+
|
115
|
+
self.id_client: CognitoIdentityClient = Session().client(
|
116
|
+
"cognito-identity", config=BOTO_CONFIG, region_name=REGION
|
117
|
+
) # type: ignore
|
99
118
|
|
100
119
|
try:
|
101
120
|
token_cache = CRED_CACHE.get_cached_data(TOKEN_KEYS)
|
@@ -114,10 +133,28 @@ class OtfCognito(Cognito):
|
|
114
133
|
self.access_token = token_cache["access_token"]
|
115
134
|
self.refresh_token = token_cache["refresh_token"]
|
116
135
|
|
117
|
-
|
136
|
+
try:
|
137
|
+
self.check_token()
|
138
|
+
except ClientError as e:
|
139
|
+
if e.response["Error"]["Code"] == "NotAuthorizedException":
|
140
|
+
LOGGER.warning("Tokens expired, attempting to login with username and password")
|
141
|
+
CRED_CACHE.clear_cache()
|
142
|
+
if not username or not password:
|
143
|
+
raise NoCredentialsError("No credentials provided and no tokens cached, cannot authenticate")
|
144
|
+
self.handle_login(username, password)
|
145
|
+
|
118
146
|
self.verify_tokens()
|
119
147
|
CRED_CACHE.write_to_cache(self.tokens)
|
120
148
|
|
149
|
+
def get_identity_credentials(self):
|
150
|
+
"""Get the AWS credentials for the user using the Cognito Identity Pool.
|
151
|
+
This is used to access AWS resources using the Cognito Identity Pool."""
|
152
|
+
cognito_id = self.id_client.get_id(IdentityPoolId=ID_POOL_ID, Logins={PROVIDER_KEY: self.id_token})
|
153
|
+
creds = self.id_client.get_credentials_for_identity(
|
154
|
+
IdentityId=cognito_id["IdentityId"], Logins={PROVIDER_KEY: self.id_token}
|
155
|
+
)
|
156
|
+
return creds["Credentials"]
|
157
|
+
|
121
158
|
@property
|
122
159
|
def tokens(self) -> dict[str, str]:
|
123
160
|
tokens = {
|
@@ -146,7 +183,7 @@ class OtfCognito(Cognito):
|
|
146
183
|
password=password,
|
147
184
|
pool_id=USER_POOL_ID,
|
148
185
|
client_id=CLIENT_ID,
|
149
|
-
client=self.
|
186
|
+
client=self.idp_client,
|
150
187
|
)
|
151
188
|
try:
|
152
189
|
tokens: InitiateAuthResponseTypeDef = aws.authenticate_user()
|
@@ -177,10 +214,10 @@ class OtfCognito(Cognito):
|
|
177
214
|
self.device_group_key, self.device_key
|
178
215
|
)
|
179
216
|
|
180
|
-
self.
|
217
|
+
self.idp_client.confirm_device(
|
181
218
|
AccessToken=self.access_token,
|
182
219
|
DeviceKey=self.device_key,
|
183
|
-
DeviceSecretVerifierConfig=device_secret_verifier_config,
|
220
|
+
DeviceSecretVerifierConfig=device_secret_verifier_config, # type: ignore (pycognito is untyped)
|
184
221
|
DeviceName=self.device_name,
|
185
222
|
)
|
186
223
|
|
@@ -206,7 +243,7 @@ class OtfCognito(Cognito):
|
|
206
243
|
if not self.refresh_token:
|
207
244
|
raise ValueError("No refresh token set - cannot renew access token")
|
208
245
|
|
209
|
-
refresh_response = self.
|
246
|
+
refresh_response = self.idp_client.initiate_auth(
|
210
247
|
ClientId=self.client_id,
|
211
248
|
AuthFlow="REFRESH_TOKEN_AUTH",
|
212
249
|
AuthParameters={"REFRESH_TOKEN": self.refresh_token, "DEVICE_KEY": self.device_key},
|
@@ -221,6 +258,9 @@ class OtfCognito(Cognito):
|
|
221
258
|
auth_result = tokens["AuthenticationResult"]
|
222
259
|
device_metadata = auth_result.get("NewDeviceMetadata", {})
|
223
260
|
|
261
|
+
assert "AccessToken" in auth_result, "AccessToken not found in AuthenticationResult"
|
262
|
+
assert "IdToken" in auth_result, "IdToken not found in AuthenticationResult"
|
263
|
+
|
224
264
|
# tokens - refresh token defaults to existing value if not present
|
225
265
|
# note: verify_token also sets the token attribute
|
226
266
|
self.verify_token(auth_result["AccessToken"], "access_token", "access")
|
@@ -232,3 +272,69 @@ class OtfCognito(Cognito):
|
|
232
272
|
self.device_key = device_metadata.get("DeviceKey", self.device_key)
|
233
273
|
self.device_group_key = device_metadata.get("DeviceGroupKey", self.device_group_key)
|
234
274
|
CRED_CACHE.write_to_cache(self.device_metadata)
|
275
|
+
|
276
|
+
|
277
|
+
class HttpxCognitoAuth(httpx.Auth):
|
278
|
+
http_header: str = "Authorization"
|
279
|
+
http_header_prefix: str = "Bearer "
|
280
|
+
|
281
|
+
def __init__(self, cognito: OtfCognito):
|
282
|
+
"""HTTPX Authentication extension for Cognito User Pools.
|
283
|
+
|
284
|
+
Args:
|
285
|
+
cognito (Cognito): A Cognito instance.
|
286
|
+
"""
|
287
|
+
|
288
|
+
self.cognito = cognito
|
289
|
+
|
290
|
+
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, Any, None]:
|
291
|
+
self.cognito.check_token(renew=True)
|
292
|
+
|
293
|
+
token = self.cognito.id_token
|
294
|
+
|
295
|
+
assert isinstance(token, str), "Token is not a string"
|
296
|
+
|
297
|
+
request.headers[self.http_header] = self.http_header_prefix + token
|
298
|
+
|
299
|
+
if request.headers.get("SIGV4AUTH_REQUIRED"):
|
300
|
+
del request.headers["SIGV4AUTH_REQUIRED"]
|
301
|
+
yield from self.sign_httpx_request(request)
|
302
|
+
return
|
303
|
+
|
304
|
+
yield request
|
305
|
+
|
306
|
+
def sign_httpx_request(self, request: httpx.Request) -> Generator[httpx.Request, Any, None]:
|
307
|
+
"""
|
308
|
+
Sign an HTTP request using AWS SigV4 for use with httpx.
|
309
|
+
"""
|
310
|
+
headers = request.headers.copy()
|
311
|
+
|
312
|
+
# ensure this header is not included, it will break the signature
|
313
|
+
headers.pop("connection", None)
|
314
|
+
|
315
|
+
body = b"" if request.method in ("GET", "HEAD") else request.content or b""
|
316
|
+
|
317
|
+
if hasattr(body, "read"):
|
318
|
+
raise ValueError("Streaming bodies are not supported in signed requests")
|
319
|
+
|
320
|
+
creds = self.cognito.get_identity_credentials()
|
321
|
+
|
322
|
+
credentials = Credentials(
|
323
|
+
access_key=creds["AccessKeyId"], secret_key=creds["SecretKey"], token=creds["SessionToken"]
|
324
|
+
)
|
325
|
+
|
326
|
+
aws_request = AWSRequest(method=request.method, url=str(request.url), data=body, headers=headers)
|
327
|
+
|
328
|
+
SigV4Auth(credentials, "execute-api", REGION).add_auth(aws_request)
|
329
|
+
|
330
|
+
signed_headers = dict(aws_request.headers.items())
|
331
|
+
|
332
|
+
# Return a brand new request object with signed headers
|
333
|
+
signed_request = httpx.Request(
|
334
|
+
method=request.method,
|
335
|
+
url=request.url,
|
336
|
+
headers=signed_headers,
|
337
|
+
content=body,
|
338
|
+
)
|
339
|
+
|
340
|
+
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
|
]
|
otf_api/models/bookings.py
CHANGED
@@ -36,6 +36,10 @@ class OtfClass(OtfItemBase):
|
|
36
36
|
program_name: str | None = Field(None, alias="programName", exclude=True, repr=False)
|
37
37
|
virtual_class: bool | None = Field(None, alias="virtualClass", exclude=True, repr=False)
|
38
38
|
|
39
|
+
def __str__(self) -> str:
|
40
|
+
starts_at_str = self.starts_at.strftime("%a %b %d, %I:%M %p")
|
41
|
+
return f"Class: {starts_at_str} {self.name} - {self.coach.first_name}"
|
42
|
+
|
39
43
|
|
40
44
|
class Booking(OtfItemBase):
|
41
45
|
booking_uuid: str = Field(alias="classBookingUUId", description="ID used to cancel the booking")
|
@@ -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,14 +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(None, alias="id", exclude=True, repr=False, description="Not used by API")
|
37
|
-
|
38
37
|
created_at: datetime | None = Field(None, exclude=True, repr=False)
|
39
38
|
ends_at_utc: datetime | None = Field(None, alias="ends_at", exclude=True, repr=False)
|
40
39
|
mbo_class_description_id: str | None = Field(None, exclude=True, repr=False, description="MindBody attr")
|