otf-api 0.8.2__py3-none-any.whl → 0.9.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 +7 -4
- otf_api/api.py +699 -480
- otf_api/auth/__init__.py +4 -0
- otf_api/auth/auth.py +234 -0
- otf_api/auth/user.py +66 -0
- otf_api/auth/utils.py +129 -0
- otf_api/exceptions.py +38 -5
- otf_api/filters.py +97 -0
- otf_api/logging.py +19 -0
- otf_api/models/__init__.py +27 -38
- otf_api/models/body_composition_list.py +47 -50
- otf_api/models/bookings.py +63 -87
- otf_api/models/challenge_tracker_content.py +42 -21
- otf_api/models/challenge_tracker_detail.py +68 -48
- otf_api/models/classes.py +53 -62
- otf_api/models/enums.py +108 -30
- otf_api/models/lifetime_stats.py +59 -45
- otf_api/models/member_detail.py +95 -115
- otf_api/models/member_membership.py +18 -17
- otf_api/models/member_purchases.py +21 -127
- otf_api/models/mixins.py +37 -33
- otf_api/models/notifications.py +17 -0
- otf_api/models/out_of_studio_workout_history.py +22 -31
- otf_api/models/performance_summary_detail.py +47 -42
- otf_api/models/performance_summary_list.py +19 -37
- otf_api/models/studio_detail.py +51 -98
- otf_api/models/studio_services.py +27 -48
- otf_api/models/telemetry.py +14 -5
- otf_api/utils.py +134 -0
- {otf_api-0.8.2.dist-info → otf_api-0.9.1.dist-info}/METADATA +21 -10
- otf_api-0.9.1.dist-info/RECORD +35 -0
- {otf_api-0.8.2.dist-info → otf_api-0.9.1.dist-info}/WHEEL +1 -1
- otf_api/auth.py +0 -316
- otf_api/models/book_class.py +0 -89
- otf_api/models/cancel_booking.py +0 -49
- otf_api/models/favorite_studios.py +0 -106
- otf_api/models/latest_agreement.py +0 -21
- otf_api/models/telemetry_hr_history.py +0 -34
- otf_api/models/telemetry_max_hr.py +0 -13
- otf_api/models/total_classes.py +0 -8
- otf_api-0.8.2.dist-info/AUTHORS.md +0 -9
- otf_api-0.8.2.dist-info/RECORD +0 -36
- {otf_api-0.8.2.dist-info → otf_api-0.9.1.dist-info}/LICENSE +0 -0
otf_api/auth/__init__.py
ADDED
otf_api/auth/auth.py
ADDED
@@ -0,0 +1,234 @@
|
|
1
|
+
import platform
|
2
|
+
import typing
|
3
|
+
from logging import getLogger
|
4
|
+
from pathlib import Path
|
5
|
+
from time import sleep
|
6
|
+
from typing import Any, ClassVar
|
7
|
+
|
8
|
+
from boto3 import Session
|
9
|
+
from botocore import UNSIGNED
|
10
|
+
from botocore.config import Config
|
11
|
+
from botocore.exceptions import ClientError
|
12
|
+
from pycognito import AWSSRP, Cognito
|
13
|
+
from pycognito.aws_srp import generate_hash_device
|
14
|
+
|
15
|
+
from otf_api.utils import CacheableData
|
16
|
+
|
17
|
+
if typing.TYPE_CHECKING:
|
18
|
+
from mypy_boto3_cognito_idp import CognitoIdentityProviderClient
|
19
|
+
from mypy_boto3_cognito_idp.type_defs import InitiateAuthResponseTypeDef
|
20
|
+
|
21
|
+
LOGGER = getLogger(__name__)
|
22
|
+
CLIENT_ID = "1457d19r0pcjgmp5agooi0rb1b" # from android app
|
23
|
+
USER_POOL_ID = "us-east-1_dYDxUeyL1"
|
24
|
+
REGION = "us-east-1"
|
25
|
+
COGNITO_IDP_URL = f"https://cognito-idp.{REGION}.amazonaws.com/"
|
26
|
+
|
27
|
+
BOTO_CONFIG = Config(region_name=REGION, signature_version=UNSIGNED)
|
28
|
+
CRED_CACHE = CacheableData("creds", Path("~/.otf-api"))
|
29
|
+
|
30
|
+
DEVICE_KEYS = ["device_key", "device_group_key", "device_password"]
|
31
|
+
TOKEN_KEYS = ["access_token", "id_token", "refresh_token"]
|
32
|
+
|
33
|
+
|
34
|
+
class NoCredentialsError(Exception):
|
35
|
+
"""Raised when no credentials are found."""
|
36
|
+
|
37
|
+
|
38
|
+
class OtfCognito(Cognito):
|
39
|
+
"""A subclass of the pycognito Cognito class that adds the device_key to the auth_params. Without this
|
40
|
+
being set the renew_access_token call will always fail with NOT_AUTHORIZED."""
|
41
|
+
|
42
|
+
user_pool_id: ClassVar[str] = USER_POOL_ID
|
43
|
+
client_id: ClassVar[str] = CLIENT_ID
|
44
|
+
user_pool_region: ClassVar[str] = REGION
|
45
|
+
client_secret: ClassVar[str] = ""
|
46
|
+
|
47
|
+
id_token: str
|
48
|
+
access_token: str
|
49
|
+
device_key: str
|
50
|
+
device_group_key: str
|
51
|
+
device_password: str
|
52
|
+
device_name: str
|
53
|
+
|
54
|
+
def __init__(
|
55
|
+
self,
|
56
|
+
username: str | None = None,
|
57
|
+
password: str | None = None,
|
58
|
+
id_token: str | None = None,
|
59
|
+
access_token: str | None = None,
|
60
|
+
refresh_token: str | None = None,
|
61
|
+
):
|
62
|
+
"""
|
63
|
+
|
64
|
+
Args:
|
65
|
+
username (str, optional): User Pool username
|
66
|
+
password (str, optional): User Pool password
|
67
|
+
id_token (str, optional): ID Token returned by authentication
|
68
|
+
access_token (str, optional): Access Token returned by authentication
|
69
|
+
refresh_token (str, optional): Refresh Token returned by authentication
|
70
|
+
"""
|
71
|
+
|
72
|
+
self.username = username
|
73
|
+
self.id_token = id_token # type: ignore
|
74
|
+
self.access_token = access_token # type: ignore
|
75
|
+
self.refresh_token = refresh_token
|
76
|
+
|
77
|
+
self.id_claims: dict[str, Any] = {}
|
78
|
+
self.access_claims: dict[str, Any] = {}
|
79
|
+
self.custom_attributes: dict[str, Any] = {}
|
80
|
+
self.base_attributes: dict[str, Any] = {}
|
81
|
+
self.pool_jwk: dict[str, Any] = {}
|
82
|
+
self.mfa_tokens: dict[str, Any] = {}
|
83
|
+
self.pool_domain_url: str | None = None
|
84
|
+
|
85
|
+
try:
|
86
|
+
dd = CRED_CACHE.get_cached_data(DEVICE_KEYS)
|
87
|
+
except Exception:
|
88
|
+
LOGGER.exception("Failed to read device key cache")
|
89
|
+
dd = {}
|
90
|
+
|
91
|
+
self.device_name = platform.node()
|
92
|
+
self.device_key = dd.get("device_key") # type: ignore
|
93
|
+
self.device_group_key = dd.get("device_group_key") # type: ignore
|
94
|
+
self.device_password = dd.get("device_password") # type: ignore
|
95
|
+
|
96
|
+
self.client: CognitoIdentityProviderClient = Session().client(
|
97
|
+
"cognito-idp", config=BOTO_CONFIG, region_name=REGION
|
98
|
+
)
|
99
|
+
|
100
|
+
try:
|
101
|
+
token_cache = CRED_CACHE.get_cached_data(TOKEN_KEYS)
|
102
|
+
except Exception:
|
103
|
+
LOGGER.exception("Failed to read token cache")
|
104
|
+
token_cache = {}
|
105
|
+
|
106
|
+
if not (username and password) and not (id_token and access_token) and not token_cache:
|
107
|
+
raise NoCredentialsError("No credentials provided and no tokens cached, cannot authenticate")
|
108
|
+
|
109
|
+
if username and password:
|
110
|
+
self.login(password)
|
111
|
+
elif token_cache and not (id_token and access_token):
|
112
|
+
LOGGER.debug("Using cached tokens")
|
113
|
+
self.id_token = token_cache["id_token"]
|
114
|
+
self.access_token = token_cache["access_token"]
|
115
|
+
self.refresh_token = token_cache["refresh_token"]
|
116
|
+
|
117
|
+
self.check_token()
|
118
|
+
self.verify_tokens()
|
119
|
+
CRED_CACHE.write_to_cache(self.tokens)
|
120
|
+
|
121
|
+
@property
|
122
|
+
def tokens(self) -> dict[str, str]:
|
123
|
+
tokens = {
|
124
|
+
"access_token": self.access_token,
|
125
|
+
"id_token": self.id_token,
|
126
|
+
"refresh_token": self.refresh_token,
|
127
|
+
}
|
128
|
+
return {k: v for k, v in tokens.items() if v}
|
129
|
+
|
130
|
+
@property
|
131
|
+
def device_metadata(self) -> dict[str, str]:
|
132
|
+
dm = {
|
133
|
+
"device_key": self.device_key,
|
134
|
+
"device_group_key": self.device_group_key,
|
135
|
+
"device_password": self.device_password,
|
136
|
+
}
|
137
|
+
return {k: v for k, v in dm.items() if v}
|
138
|
+
|
139
|
+
def login(self, password: str) -> None:
|
140
|
+
"""Called when logging in with a username and password. Will set the tokens and device metadata."""
|
141
|
+
|
142
|
+
LOGGER.debug("Logging in with username and password...")
|
143
|
+
|
144
|
+
aws = AWSSRP(
|
145
|
+
username=self.username,
|
146
|
+
password=password,
|
147
|
+
pool_id=USER_POOL_ID,
|
148
|
+
client_id=CLIENT_ID,
|
149
|
+
client=self.client,
|
150
|
+
)
|
151
|
+
try:
|
152
|
+
tokens: InitiateAuthResponseTypeDef = aws.authenticate_user()
|
153
|
+
except ClientError as e:
|
154
|
+
code = e.response["Error"]["Code"]
|
155
|
+
msg = e.response["Error"]["Message"]
|
156
|
+
if "UserLambdaValidationException" in msg or "UserLambdaValidation" in code:
|
157
|
+
sleep(5)
|
158
|
+
tokens = aws.authenticate_user()
|
159
|
+
else:
|
160
|
+
raise
|
161
|
+
|
162
|
+
self._set_tokens(tokens)
|
163
|
+
self._handle_device_setup()
|
164
|
+
|
165
|
+
def _handle_device_setup(self) -> None:
|
166
|
+
"""Confirms the device with Cognito and caches the device metadata.
|
167
|
+
|
168
|
+
Devices are not remembered at this time, as OTF does not have MFA set up currently. Without MFA setup, there
|
169
|
+
is no benefit to remembering the device. Additionally, it does not appear that the OTF app remembers devices,
|
170
|
+
so this matches the behavior of the app.
|
171
|
+
"""
|
172
|
+
|
173
|
+
if not self.device_key:
|
174
|
+
raise ValueError("Device key not set - device key is required by this Cognito pool")
|
175
|
+
|
176
|
+
self.device_password, device_secret_verifier_config = generate_hash_device(
|
177
|
+
self.device_group_key, self.device_key
|
178
|
+
)
|
179
|
+
|
180
|
+
self.client.confirm_device(
|
181
|
+
AccessToken=self.access_token,
|
182
|
+
DeviceKey=self.device_key,
|
183
|
+
DeviceSecretVerifierConfig=device_secret_verifier_config,
|
184
|
+
DeviceName=self.device_name,
|
185
|
+
)
|
186
|
+
|
187
|
+
try:
|
188
|
+
CRED_CACHE.write_to_cache(self.device_metadata)
|
189
|
+
except Exception:
|
190
|
+
LOGGER.exception("Failed to write device key cache")
|
191
|
+
|
192
|
+
##### OVERRIDDEN METHODS #####
|
193
|
+
|
194
|
+
def renew_access_token(self) -> None:
|
195
|
+
"""Sets a new access token on the User using the cached refresh token and device metadata.
|
196
|
+
|
197
|
+
Overridden to add the device key to the auth_params if it is set. Without this all calls to renew_access_token
|
198
|
+
will fail with NOT_AUTHORIZED. Also skips the call to _add_secret_hash since we don't have a client secret.
|
199
|
+
|
200
|
+
# https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_InitiateAuth.html#CognitoUserPools-InitiateAuth-request-AuthFlow
|
201
|
+
|
202
|
+
"""
|
203
|
+
if not self.device_key:
|
204
|
+
raise ValueError("Device key not set - device key is required by this Cognito pool")
|
205
|
+
|
206
|
+
if not self.refresh_token:
|
207
|
+
raise ValueError("No refresh token set - cannot renew access token")
|
208
|
+
|
209
|
+
refresh_response = self.client.initiate_auth(
|
210
|
+
ClientId=self.client_id,
|
211
|
+
AuthFlow="REFRESH_TOKEN_AUTH",
|
212
|
+
AuthParameters={"REFRESH_TOKEN": self.refresh_token, "DEVICE_KEY": self.device_key},
|
213
|
+
)
|
214
|
+
self._set_tokens(refresh_response)
|
215
|
+
|
216
|
+
def _set_tokens(self, tokens: "InitiateAuthResponseTypeDef") -> None:
|
217
|
+
"""Helper function to verify and set token attributes based on a Cognito AuthenticationResult.
|
218
|
+
|
219
|
+
Overridden to cache the tokens and set/cache the device metadata.
|
220
|
+
"""
|
221
|
+
auth_result = tokens["AuthenticationResult"]
|
222
|
+
device_metadata = auth_result.get("NewDeviceMetadata", {})
|
223
|
+
|
224
|
+
# tokens - refresh token defaults to existing value if not present
|
225
|
+
# note: verify_token also sets the token attribute
|
226
|
+
self.verify_token(auth_result["AccessToken"], "access_token", "access")
|
227
|
+
self.verify_token(auth_result["IdToken"], "id_token", "id")
|
228
|
+
self.refresh_token = auth_result.get("RefreshToken", self.refresh_token)
|
229
|
+
CRED_CACHE.write_to_cache(self.tokens)
|
230
|
+
|
231
|
+
# device metadata - default to existing values if not present
|
232
|
+
self.device_key = device_metadata.get("DeviceKey", self.device_key)
|
233
|
+
self.device_group_key = device_metadata.get("DeviceGroupKey", self.device_group_key)
|
234
|
+
CRED_CACHE.write_to_cache(self.device_metadata)
|
otf_api/auth/user.py
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
from logging import getLogger
|
2
|
+
|
3
|
+
import attrs
|
4
|
+
|
5
|
+
from otf_api.auth.auth import CRED_CACHE, NoCredentialsError, OtfCognito
|
6
|
+
from otf_api.auth.utils import HttpxCognitoAuth, can_provide_input, prompt_for_username_and_password
|
7
|
+
|
8
|
+
LOGGER = getLogger(__name__)
|
9
|
+
|
10
|
+
|
11
|
+
@attrs.define(init=False)
|
12
|
+
class OtfUser:
|
13
|
+
"""OtfUser is a thin wrapper around OtfCognito, meant to hide all of the gory details from end users."""
|
14
|
+
|
15
|
+
cognito_id: str
|
16
|
+
member_uuid: str
|
17
|
+
email_address: str
|
18
|
+
cognito: OtfCognito
|
19
|
+
httpx_auth: HttpxCognitoAuth
|
20
|
+
|
21
|
+
def __init__(
|
22
|
+
self,
|
23
|
+
username: str | None = None,
|
24
|
+
password: str | None = None,
|
25
|
+
id_token: str | None = None,
|
26
|
+
access_token: str | None = None,
|
27
|
+
refresh_token: str | None = None,
|
28
|
+
):
|
29
|
+
"""Create a User instance.
|
30
|
+
|
31
|
+
Args:
|
32
|
+
username (str, optional): User Pool username
|
33
|
+
password (str, optional): User Pool password
|
34
|
+
id_token (str, optional): ID Token returned by authentication
|
35
|
+
access_token (str, optional): Access Token returned by authentication
|
36
|
+
refresh_token (str, optional): Refresh Token returned by authentication
|
37
|
+
|
38
|
+
Raises:
|
39
|
+
NoCredentialsError: If neither username/password nor id/access tokens are provided.
|
40
|
+
"""
|
41
|
+
try:
|
42
|
+
self.cognito = OtfCognito(
|
43
|
+
username=username,
|
44
|
+
password=password,
|
45
|
+
id_token=id_token,
|
46
|
+
access_token=access_token,
|
47
|
+
refresh_token=refresh_token,
|
48
|
+
)
|
49
|
+
except NoCredentialsError:
|
50
|
+
if not can_provide_input():
|
51
|
+
LOGGER.error("Unable to prompt for credentials in a non-interactive shell")
|
52
|
+
raise
|
53
|
+
|
54
|
+
username, password = prompt_for_username_and_password()
|
55
|
+
self.cognito = OtfCognito(username=username, password=password)
|
56
|
+
|
57
|
+
self.cognito_id = self.cognito.access_claims["sub"]
|
58
|
+
self.member_uuid = self.cognito.id_claims["cognito:username"]
|
59
|
+
self.email_address = self.cognito.id_claims["email"]
|
60
|
+
|
61
|
+
self.httpx_auth = HttpxCognitoAuth(cognito=self.cognito)
|
62
|
+
|
63
|
+
@staticmethod
|
64
|
+
def clear_cache():
|
65
|
+
"""Clear the cached credentials."""
|
66
|
+
CRED_CACHE.clear_cache()
|
otf_api/auth/utils.py
ADDED
@@ -0,0 +1,129 @@
|
|
1
|
+
import os
|
2
|
+
import sys
|
3
|
+
from collections.abc import Generator
|
4
|
+
from functools import partial
|
5
|
+
from getpass import getpass
|
6
|
+
from logging import getLogger
|
7
|
+
from typing import Any
|
8
|
+
|
9
|
+
import httpx
|
10
|
+
from httpx import Request
|
11
|
+
from pycognito import Cognito
|
12
|
+
|
13
|
+
LOGGER = getLogger(__name__)
|
14
|
+
USERNAME_PROMPT = "Enter your Orangetheory Fitness username/email: "
|
15
|
+
PASSWORD_PROMPT = "Enter your Orangetheory Fitness password: "
|
16
|
+
|
17
|
+
|
18
|
+
def _show_error_message(message: str) -> None:
|
19
|
+
try:
|
20
|
+
from rich import print # type: ignore
|
21
|
+
|
22
|
+
print(message, style="bold red")
|
23
|
+
except ImportError:
|
24
|
+
print(message)
|
25
|
+
|
26
|
+
|
27
|
+
def _get_password_input(prompt: str) -> str:
|
28
|
+
try:
|
29
|
+
from rich import get_console # type: ignore
|
30
|
+
|
31
|
+
otf_input = partial(get_console().input, password=True)
|
32
|
+
prompt = f"[bold blue]{prompt}[/bold blue]"
|
33
|
+
except ImportError:
|
34
|
+
otf_input = getpass
|
35
|
+
|
36
|
+
return otf_input(prompt)
|
37
|
+
|
38
|
+
|
39
|
+
def _get_input(prompt: str) -> str:
|
40
|
+
try:
|
41
|
+
from rich import get_console # type: ignore
|
42
|
+
|
43
|
+
otf_input = get_console().input
|
44
|
+
prompt = f"[bold blue]{prompt}[/bold blue]"
|
45
|
+
except ImportError:
|
46
|
+
otf_input = input
|
47
|
+
|
48
|
+
return otf_input(prompt)
|
49
|
+
|
50
|
+
|
51
|
+
def _prompt_for_username() -> str:
|
52
|
+
def get_username() -> str:
|
53
|
+
username = _get_input(USERNAME_PROMPT)
|
54
|
+
|
55
|
+
if not username:
|
56
|
+
_show_error_message("Username is required")
|
57
|
+
return ""
|
58
|
+
|
59
|
+
if "@" not in username or username.endswith("@"):
|
60
|
+
_show_error_message("Username should be a valid email address")
|
61
|
+
return ""
|
62
|
+
|
63
|
+
return username
|
64
|
+
|
65
|
+
while not (username := get_username()):
|
66
|
+
pass
|
67
|
+
|
68
|
+
return username
|
69
|
+
|
70
|
+
|
71
|
+
def _prompt_for_password() -> str:
|
72
|
+
def get_password() -> str:
|
73
|
+
password = _get_password_input(PASSWORD_PROMPT)
|
74
|
+
|
75
|
+
if not password:
|
76
|
+
_show_error_message("Password is required")
|
77
|
+
return ""
|
78
|
+
|
79
|
+
return password
|
80
|
+
|
81
|
+
while not (password := get_password()):
|
82
|
+
pass
|
83
|
+
|
84
|
+
return password
|
85
|
+
|
86
|
+
|
87
|
+
def prompt_for_username_and_password() -> tuple[str, str]:
|
88
|
+
"""Prompt for a username and password.
|
89
|
+
|
90
|
+
Returns:
|
91
|
+
tuple[str, str]: A tuple containing the username and password.
|
92
|
+
"""
|
93
|
+
|
94
|
+
username = os.getenv("OTF_EMAIL") or _prompt_for_username()
|
95
|
+
password = os.getenv("OTF_PASSWORD") or _prompt_for_password()
|
96
|
+
|
97
|
+
return username, password
|
98
|
+
|
99
|
+
|
100
|
+
def can_provide_input() -> bool:
|
101
|
+
"""Check if the script is running in an interactive shell.
|
102
|
+
|
103
|
+
Returns:
|
104
|
+
bool: True if the script is running in an interactive shell.
|
105
|
+
"""
|
106
|
+
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/exceptions.py
CHANGED
@@ -1,4 +1,25 @@
|
|
1
|
-
|
1
|
+
from httpx import Request, Response
|
2
|
+
|
3
|
+
|
4
|
+
class OtfException(Exception):
|
5
|
+
"""Base class for all exceptions in this package."""
|
6
|
+
|
7
|
+
|
8
|
+
class OtfRequestError(OtfException):
|
9
|
+
"""Raised when an error occurs while making a request to the OTF API."""
|
10
|
+
|
11
|
+
response: Response
|
12
|
+
request: Request
|
13
|
+
|
14
|
+
def __init__(self, message: str, response: Response, request: Request):
|
15
|
+
super().__init__(message)
|
16
|
+
self.response = response
|
17
|
+
self.request = request
|
18
|
+
|
19
|
+
|
20
|
+
class BookingError(OtfException):
|
21
|
+
"""Base class for booking-related errors, with an optional booking UUID attribute."""
|
22
|
+
|
2
23
|
booking_uuid: str | None
|
3
24
|
|
4
25
|
def __init__(self, message: str, booking_uuid: str | None = None):
|
@@ -6,13 +27,25 @@ class BookingError(Exception):
|
|
6
27
|
self.booking_uuid = booking_uuid
|
7
28
|
|
8
29
|
|
9
|
-
class AlreadyBookedError(BookingError):
|
30
|
+
class AlreadyBookedError(BookingError):
|
31
|
+
"""Raised when attempting to book a class that is already booked."""
|
32
|
+
|
33
|
+
|
34
|
+
class ConflictingBookingError(BookingError):
|
35
|
+
"""Raised when attempting to book a class that conflicts with an existing booking."""
|
36
|
+
|
37
|
+
|
38
|
+
class BookingAlreadyCancelledError(BookingError):
|
39
|
+
"""Raised when attempting to cancel a booking that is already cancelled."""
|
10
40
|
|
11
41
|
|
12
|
-
class
|
42
|
+
class OutsideSchedulingWindowError(OtfException):
|
43
|
+
"""Raised when attempting to book a class outside the scheduling window."""
|
13
44
|
|
14
45
|
|
15
|
-
class
|
46
|
+
class BookingNotFoundError(OtfException):
|
47
|
+
"""Raised when a booking is not found."""
|
16
48
|
|
17
49
|
|
18
|
-
class
|
50
|
+
class ResourceNotFoundError(OtfException):
|
51
|
+
"""Raised when a resource is not found."""
|
otf_api/filters.py
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
from datetime import date, datetime, time
|
2
|
+
|
3
|
+
from pydantic import BaseModel, field_validator
|
4
|
+
|
5
|
+
from otf_api.models import ClassType, DoW, OtfClass
|
6
|
+
|
7
|
+
|
8
|
+
class ClassFilter(BaseModel):
|
9
|
+
"""ClassFilter is used to filter classes, to separate the filtering logic from the API client.
|
10
|
+
|
11
|
+
The `class_type`, `day_of_week`, and `start_time` fields can either be a single value or a list of values.
|
12
|
+
If a single value is provided, it will be converted to a list.
|
13
|
+
|
14
|
+
The `class_type` and `day_of_week` fields can be provided as strings or as the corresponding Enum values. The
|
15
|
+
class will attempt to match the string to the Enum value, regardless of case.
|
16
|
+
|
17
|
+
The arguments are applied as an AND filter, meaning that all filters must match for a class to be included. If
|
18
|
+
a filter is not provided, it is not applied. If multiple values are provided for a filter the values are treated
|
19
|
+
as an OR filter, meaning that a class will be included if it matches any of the values.
|
20
|
+
|
21
|
+
All arguments are optional and default to None.
|
22
|
+
|
23
|
+
Args:
|
24
|
+
start_date (date): Filter classes that start on or after this date.
|
25
|
+
end_date (date): Filter classes that start on or before this date.
|
26
|
+
class_type (list[ClassType]): Filter classes by class type.
|
27
|
+
day_of_week (list[DoW]): Filter classes by day of the week.
|
28
|
+
start_time (list[time]): Filter classes by start time.
|
29
|
+
"""
|
30
|
+
|
31
|
+
start_date: date | None = None
|
32
|
+
end_date: date | None = None
|
33
|
+
class_type: list[ClassType] | None = None
|
34
|
+
day_of_week: list[DoW] | None = None
|
35
|
+
start_time: list[time] | None = None
|
36
|
+
|
37
|
+
def filter_classes(self, classes: list[OtfClass]) -> list[OtfClass]:
|
38
|
+
"""Filters a list of classes based on the filter arguments.
|
39
|
+
|
40
|
+
Args:
|
41
|
+
classes (list[OtfClass]): A list of classes to filter.
|
42
|
+
|
43
|
+
Returns:
|
44
|
+
list[OtfClass]: The filtered list of classes.
|
45
|
+
"""
|
46
|
+
# in case these are set after the class is created
|
47
|
+
if self.start_date and isinstance(self.start_date, datetime):
|
48
|
+
self.start_date = self.start_date.date()
|
49
|
+
|
50
|
+
if self.end_date and isinstance(self.end_date, datetime):
|
51
|
+
self.end_date = self.end_date.date()
|
52
|
+
|
53
|
+
if self.start_date:
|
54
|
+
classes = [c for c in classes if c.starts_at.date() >= self.start_date]
|
55
|
+
|
56
|
+
if self.end_date:
|
57
|
+
classes = [c for c in classes if c.starts_at.date() <= self.end_date]
|
58
|
+
|
59
|
+
if self.class_type:
|
60
|
+
classes = [c for c in classes if c.class_type in self.class_type]
|
61
|
+
|
62
|
+
if self.day_of_week:
|
63
|
+
classes = [c for c in classes if c.day_of_week in self.day_of_week]
|
64
|
+
|
65
|
+
if self.start_time:
|
66
|
+
classes = [c for c in classes if c.starts_at.time() in self.start_time]
|
67
|
+
|
68
|
+
return classes
|
69
|
+
|
70
|
+
@field_validator("class_type", "day_of_week", "start_time", mode="before")
|
71
|
+
@classmethod
|
72
|
+
def _single_item_to_list(cls, v):
|
73
|
+
if v and not isinstance(v, list):
|
74
|
+
return [v]
|
75
|
+
return v
|
76
|
+
|
77
|
+
@field_validator("day_of_week", mode="before")
|
78
|
+
@classmethod
|
79
|
+
def _day_of_week_str_to_enum(cls, v):
|
80
|
+
if v and isinstance(v, str):
|
81
|
+
return [DoW(v.title())]
|
82
|
+
|
83
|
+
if v and isinstance(v, list) and not all(isinstance(i, DoW) for i in v):
|
84
|
+
return [DoW(i.title()) for i in v]
|
85
|
+
|
86
|
+
return v
|
87
|
+
|
88
|
+
@field_validator("class_type", mode="before")
|
89
|
+
@classmethod
|
90
|
+
def _class_type_str_to_enum(cls, v):
|
91
|
+
if v and isinstance(v, str):
|
92
|
+
return [ClassType.get_case_insensitive(v)]
|
93
|
+
|
94
|
+
if v and isinstance(v, list) and not all(isinstance(i, ClassType) for i in v):
|
95
|
+
return [ClassType.get_case_insensitive(i) for i in v]
|
96
|
+
|
97
|
+
return v
|
otf_api/logging.py
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
import logging
|
2
|
+
import os
|
3
|
+
|
4
|
+
LOG_LEVEL = os.getenv("OTF_LOG_LEVEL", "INFO")
|
5
|
+
|
6
|
+
LOG_FMT = "{asctime} - {module}.{funcName}:{lineno} - {levelname} - {message}"
|
7
|
+
DATE_FMT = "%Y-%m-%d %H:%M:%S%z"
|
8
|
+
|
9
|
+
logger = logging.getLogger("otf_api")
|
10
|
+
|
11
|
+
# 2) Set the logger level to INFO (or whatever you need).
|
12
|
+
logger.setLevel(LOG_LEVEL)
|
13
|
+
|
14
|
+
# 3) Create a handler (e.g., console) and set its formatter.
|
15
|
+
handler = logging.StreamHandler()
|
16
|
+
handler.setFormatter(logging.Formatter(fmt=LOG_FMT, datefmt=DATE_FMT, style="{"))
|
17
|
+
|
18
|
+
# 4) Add this handler to your package logger.
|
19
|
+
logger.addHandler(handler)
|