otf-api 0.12.0__py3-none-any.whl → 0.13.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 +35 -3
- otf_api/api/__init__.py +3 -0
- otf_api/api/_compat.py +77 -0
- otf_api/api/api.py +80 -0
- otf_api/api/bookings/__init__.py +3 -0
- otf_api/api/bookings/booking_api.py +541 -0
- otf_api/api/bookings/booking_client.py +112 -0
- otf_api/api/client.py +203 -0
- otf_api/api/members/__init__.py +3 -0
- otf_api/api/members/member_api.py +187 -0
- otf_api/api/members/member_client.py +112 -0
- otf_api/api/studios/__init__.py +3 -0
- otf_api/api/studios/studio_api.py +173 -0
- otf_api/api/studios/studio_client.py +120 -0
- otf_api/api/utils.py +307 -0
- otf_api/api/workouts/__init__.py +3 -0
- otf_api/api/workouts/workout_api.py +333 -0
- otf_api/api/workouts/workout_client.py +140 -0
- otf_api/auth/__init__.py +1 -1
- otf_api/auth/auth.py +155 -89
- otf_api/auth/user.py +5 -17
- otf_api/auth/utils.py +27 -2
- otf_api/cache.py +132 -0
- otf_api/exceptions.py +18 -6
- otf_api/models/__init__.py +25 -21
- otf_api/models/bookings/__init__.py +23 -0
- otf_api/models/bookings/bookings.py +134 -0
- otf_api/models/{bookings_v2.py → bookings/bookings_v2.py} +72 -31
- otf_api/models/bookings/classes.py +124 -0
- otf_api/models/{enums.py → bookings/enums.py} +7 -81
- otf_api/{filters.py → models/bookings/filters.py} +39 -11
- otf_api/models/{ratings.py → bookings/ratings.py} +2 -6
- otf_api/models/members/__init__.py +5 -0
- otf_api/models/members/member_detail.py +149 -0
- otf_api/models/members/member_membership.py +26 -0
- otf_api/models/members/member_purchases.py +29 -0
- otf_api/models/members/notifications.py +17 -0
- otf_api/models/mixins.py +48 -1
- otf_api/models/studios/__init__.py +5 -0
- otf_api/models/studios/enums.py +11 -0
- otf_api/models/studios/studio_detail.py +93 -0
- otf_api/models/studios/studio_services.py +36 -0
- otf_api/models/workouts/__init__.py +31 -0
- otf_api/models/{body_composition_list.py → workouts/body_composition_list.py} +140 -71
- otf_api/models/workouts/challenge_tracker_content.py +50 -0
- otf_api/models/workouts/challenge_tracker_detail.py +99 -0
- otf_api/models/workouts/enums.py +70 -0
- otf_api/models/workouts/lifetime_stats.py +96 -0
- otf_api/models/workouts/out_of_studio_workout_history.py +32 -0
- otf_api/models/{performance_summary.py → workouts/performance_summary.py} +19 -5
- otf_api/models/workouts/telemetry.py +88 -0
- otf_api/models/{workout.py → workouts/workout.py} +34 -20
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/METADATA +4 -2
- otf_api-0.13.0.dist-info/RECORD +59 -0
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/WHEEL +1 -1
- otf_api/api.py +0 -1682
- otf_api/logging.py +0 -19
- otf_api/models/bookings.py +0 -109
- otf_api/models/challenge_tracker_content.py +0 -59
- otf_api/models/challenge_tracker_detail.py +0 -88
- otf_api/models/classes.py +0 -70
- otf_api/models/lifetime_stats.py +0 -78
- otf_api/models/member_detail.py +0 -121
- otf_api/models/member_membership.py +0 -26
- otf_api/models/member_purchases.py +0 -29
- otf_api/models/notifications.py +0 -17
- otf_api/models/out_of_studio_workout_history.py +0 -32
- otf_api/models/studio_detail.py +0 -71
- otf_api/models/studio_services.py +0 -36
- otf_api/models/telemetry.py +0 -84
- otf_api/utils.py +0 -164
- otf_api-0.12.0.dist-info/RECORD +0 -38
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/licenses/LICENSE +0 -0
- {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/top_level.txt +0 -0
otf_api/api/client.py
ADDED
@@ -0,0 +1,203 @@
|
|
1
|
+
import atexit
|
2
|
+
import re
|
3
|
+
from json import JSONDecodeError
|
4
|
+
from logging import getLogger
|
5
|
+
from typing import Any
|
6
|
+
|
7
|
+
import httpx
|
8
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
9
|
+
from yarl import URL
|
10
|
+
|
11
|
+
from otf_api import exceptions as exc
|
12
|
+
from otf_api.api.utils import get_json_from_response, is_error_response
|
13
|
+
from otf_api.auth import OtfUser
|
14
|
+
from otf_api.cache import get_cache
|
15
|
+
|
16
|
+
API_BASE_URL = "api.orangetheory.co"
|
17
|
+
API_IO_BASE_URL = "api.orangetheory.io"
|
18
|
+
API_TELEMETRY_BASE_URL = "api.yuzu.orangetheory.com"
|
19
|
+
HEADERS = {
|
20
|
+
"content-type": "application/json",
|
21
|
+
"accept": "application/json",
|
22
|
+
"user-agent": "okhttp/4.12.0",
|
23
|
+
}
|
24
|
+
CACHE = get_cache()
|
25
|
+
LOGGER = getLogger(__name__)
|
26
|
+
|
27
|
+
|
28
|
+
class OtfClient:
|
29
|
+
"""Client for interacting with the OTF API - generally to be used by the Otf class.
|
30
|
+
|
31
|
+
This class provides methods to perform various API requests, including booking classes,
|
32
|
+
retrieving member details, and managing bookings. It handles authentication and session management
|
33
|
+
using the provided OtfUser instance or a default unauthenticated user.
|
34
|
+
|
35
|
+
It also includes retry logic for handling transient errors and caching for performance optimization.
|
36
|
+
"""
|
37
|
+
|
38
|
+
def __init__(self, user: OtfUser | None = None):
|
39
|
+
"""Initialize the OTF API client.
|
40
|
+
|
41
|
+
Args:
|
42
|
+
user (OtfUser): The user to authenticate as.
|
43
|
+
"""
|
44
|
+
self.user = user or OtfUser()
|
45
|
+
self.member_uuid = self.user.member_uuid
|
46
|
+
|
47
|
+
self.session = httpx.Client(
|
48
|
+
headers=HEADERS, auth=self.user.httpx_auth, timeout=httpx.Timeout(20.0, connect=60.0)
|
49
|
+
)
|
50
|
+
atexit.register(self.session.close)
|
51
|
+
|
52
|
+
def _build_request(
|
53
|
+
self,
|
54
|
+
method: str,
|
55
|
+
full_url: str,
|
56
|
+
params: dict[str, Any] | None,
|
57
|
+
headers: dict[str, str] | None,
|
58
|
+
**kwargs,
|
59
|
+
) -> httpx.Request:
|
60
|
+
params = {k: v for k, v in (params or {}).items() if v is not None}
|
61
|
+
headers = headers or {}
|
62
|
+
return self.session.build_request(method, full_url, headers=headers, params=params, **kwargs)
|
63
|
+
|
64
|
+
@retry(
|
65
|
+
retry=retry_if_exception_type((exc.RetryableOtfRequestError, httpx.HTTPStatusError)),
|
66
|
+
stop=stop_after_attempt(3),
|
67
|
+
wait=wait_exponential(multiplier=1, min=4, max=10),
|
68
|
+
reraise=True,
|
69
|
+
)
|
70
|
+
def do(
|
71
|
+
self,
|
72
|
+
method: str,
|
73
|
+
base_url: str,
|
74
|
+
path: str,
|
75
|
+
params: dict[str, Any] | None = None,
|
76
|
+
headers: dict[str, str] | None = None,
|
77
|
+
**kwargs,
|
78
|
+
) -> Any: # noqa: ANN401
|
79
|
+
"""Perform an API request.
|
80
|
+
|
81
|
+
Args:
|
82
|
+
method (str): The HTTP method to use (e.g., 'GET', 'POST').
|
83
|
+
base_url (str): The base URL for the API.
|
84
|
+
path (str): The specific endpoint to request.
|
85
|
+
params (dict[str, Any] | None): Query parameters to include in the request.
|
86
|
+
headers (dict[str, str] | None): Additional headers to include in the request.
|
87
|
+
**kwargs: Additional keyword arguments to pass to the request.
|
88
|
+
|
89
|
+
Returns:
|
90
|
+
Any: The response data from the API request.
|
91
|
+
|
92
|
+
Raises:
|
93
|
+
OtfRequestError: If the request fails or the response is invalid.
|
94
|
+
HTTPStatusError: If the response status code indicates an error.
|
95
|
+
"""
|
96
|
+
full_url = str(URL.build(scheme="https", host=base_url, path=path))
|
97
|
+
request = self._build_request(method, full_url, params, headers, **kwargs)
|
98
|
+
LOGGER.debug(f"Making {method!r} request to '{full_url}', params: {params}, headers: {headers}")
|
99
|
+
|
100
|
+
try:
|
101
|
+
response = self.session.send(request)
|
102
|
+
response.raise_for_status()
|
103
|
+
except Exception as e:
|
104
|
+
self._handle_transport_error(e, request)
|
105
|
+
raise
|
106
|
+
|
107
|
+
return self._handle_response(method, response, request)
|
108
|
+
|
109
|
+
def default_request(
|
110
|
+
self,
|
111
|
+
method: str,
|
112
|
+
path: str,
|
113
|
+
params: dict[str, Any] | None = None,
|
114
|
+
headers: dict[str, Any] | None = None,
|
115
|
+
**kwargs,
|
116
|
+
) -> Any: # noqa: ANN401
|
117
|
+
"""Perform an API request to the default API."""
|
118
|
+
return self.do(method, API_BASE_URL, path, params, headers=headers, **kwargs)
|
119
|
+
|
120
|
+
def _map_http_error(
|
121
|
+
self, data: dict, error: httpx.HTTPStatusError, response: httpx.Response, request: httpx.Request
|
122
|
+
) -> None:
|
123
|
+
code = data.get("code")
|
124
|
+
path = request.url.path
|
125
|
+
error_code = data.get("data", {}).get("errorCode")
|
126
|
+
error_msg = data.get("message") or data.get("data", {}).get("message", "") or ""
|
127
|
+
|
128
|
+
if response.status_code == 404:
|
129
|
+
raise exc.ResourceNotFoundError(f"Resource not found: {path}")
|
130
|
+
|
131
|
+
# Match based on error code and path
|
132
|
+
if re.match(r"^/v1/bookings/me", path):
|
133
|
+
if code == "BOOKING_CANCELED":
|
134
|
+
raise exc.BookingAlreadyCancelledError(error_msg or "Booking was already cancelled")
|
135
|
+
if code == "BOOKING_ALREADY_BOOKED":
|
136
|
+
raise exc.AlreadyBookedError("This class is already booked")
|
137
|
+
|
138
|
+
if re.match(r"^/member/members/.*?/bookings", path):
|
139
|
+
if code == "NOT_AUTHORIZED" and error_msg.startswith("This class booking has been cancelled"):
|
140
|
+
raise exc.BookingNotFoundError("Booking was already cancelled")
|
141
|
+
if error_code == "603":
|
142
|
+
raise exc.AlreadyBookedError("Class is already booked")
|
143
|
+
if error_code == "602":
|
144
|
+
raise exc.OutsideSchedulingWindowError("Class is outside scheduling window")
|
145
|
+
|
146
|
+
msg = f"HTTP error {error.response.status_code} for {request.method} {request.url}"
|
147
|
+
LOGGER.error(msg)
|
148
|
+
error_cls = exc.RetryableOtfRequestError if response.status_code >= 500 else exc.OtfRequestError
|
149
|
+
raise error_cls(message=msg, original_exception=error, request=request, response=response)
|
150
|
+
|
151
|
+
def _handle_transport_error(self, error: Exception, request: httpx.Request) -> None:
|
152
|
+
"""Handle transport errors during API requests.
|
153
|
+
|
154
|
+
Generally we let these bubble up to the caller so they get retried, but there are a few
|
155
|
+
cases where we want to log the error and raise a specific exception.
|
156
|
+
|
157
|
+
Args:
|
158
|
+
error (Exception): The exception raised during the request.
|
159
|
+
request (httpx.Request): The request that caused the error.
|
160
|
+
"""
|
161
|
+
method = request.method
|
162
|
+
url = request.url
|
163
|
+
|
164
|
+
if not isinstance(error, httpx.HTTPStatusError):
|
165
|
+
LOGGER.exception(f"Unexpected error during {method!r} {url!r}: {type(error).__name__} - {error}")
|
166
|
+
return
|
167
|
+
|
168
|
+
json_data = get_json_from_response(error.response)
|
169
|
+
self._map_http_error(json_data, error, error.response, request)
|
170
|
+
|
171
|
+
return
|
172
|
+
|
173
|
+
def _map_logical_error(self, data: dict, response: httpx.Response, request: httpx.Request) -> None:
|
174
|
+
# not actually sure this is necessary, so far all of them have been HttpStatusError
|
175
|
+
data_status: int | None = data.get("Status") or data.get("status") or None
|
176
|
+
|
177
|
+
if isinstance(data, dict) and isinstance(data_status, int) and not 200 <= data_status <= 299:
|
178
|
+
LOGGER.error(f"API returned error: {data}")
|
179
|
+
raise exc.OtfRequestError("Bad API response", None, response=response, request=request)
|
180
|
+
|
181
|
+
raise exc.OtfRequestError(
|
182
|
+
f"Logical error in API response: {data}", original_exception=None, response=response, request=request
|
183
|
+
)
|
184
|
+
|
185
|
+
def _handle_response(self, method: str, response: httpx.Response, request: httpx.Request) -> Any: # noqa: ANN401
|
186
|
+
if not response.text:
|
187
|
+
if method == "GET":
|
188
|
+
raise exc.OtfRequestError("Empty response", None, response=response, request=request)
|
189
|
+
|
190
|
+
LOGGER.debug(f"No content returned from {method} {response.url}")
|
191
|
+
return None
|
192
|
+
|
193
|
+
try:
|
194
|
+
json_data = response.json()
|
195
|
+
except JSONDecodeError as e:
|
196
|
+
LOGGER.error(f"Invalid JSON: {e}")
|
197
|
+
LOGGER.error(f"Response content: {response.text}")
|
198
|
+
raise
|
199
|
+
|
200
|
+
if is_error_response(json_data):
|
201
|
+
self._map_logical_error(json_data, response, request)
|
202
|
+
|
203
|
+
return json_data
|
@@ -0,0 +1,187 @@
|
|
1
|
+
import typing
|
2
|
+
from logging import getLogger
|
3
|
+
from typing import Any
|
4
|
+
|
5
|
+
from otf_api import models
|
6
|
+
from otf_api.api.client import OtfClient
|
7
|
+
|
8
|
+
from .member_client import MemberClient
|
9
|
+
|
10
|
+
if typing.TYPE_CHECKING:
|
11
|
+
from otf_api import Otf
|
12
|
+
|
13
|
+
LOGGER = getLogger(__name__)
|
14
|
+
|
15
|
+
|
16
|
+
class MemberApi:
|
17
|
+
def __init__(self, otf: "Otf", otf_client: OtfClient):
|
18
|
+
"""Initialize the Member API client.
|
19
|
+
|
20
|
+
Args:
|
21
|
+
otf (Otf): The OTF API client.
|
22
|
+
otf_client (OtfClient): The OTF client to use for requests.
|
23
|
+
"""
|
24
|
+
self.otf = otf
|
25
|
+
self.client = MemberClient(otf_client)
|
26
|
+
|
27
|
+
def get_sms_notification_settings(self) -> models.SmsNotificationSettings:
|
28
|
+
"""Get the member's SMS notification settings.
|
29
|
+
|
30
|
+
Returns:
|
31
|
+
SmsNotificationSettings: The member's SMS notification settings.
|
32
|
+
"""
|
33
|
+
res = self.client.get_sms_notification_settings(self.otf.member.phone_number) # type: ignore
|
34
|
+
|
35
|
+
return models.SmsNotificationSettings(**res)
|
36
|
+
|
37
|
+
def update_sms_notification_settings(
|
38
|
+
self, promotional_enabled: bool | None = None, transactional_enabled: bool | None = None
|
39
|
+
) -> models.SmsNotificationSettings:
|
40
|
+
"""Update the member's SMS notification settings. Arguments not provided will be left unchanged.
|
41
|
+
|
42
|
+
Args:
|
43
|
+
promotional_enabled (bool | None): Whether to enable promotional SMS notifications.
|
44
|
+
transactional_enabled (bool | None): Whether to enable transactional SMS notifications.
|
45
|
+
|
46
|
+
Returns:
|
47
|
+
SmsNotificationSettings: The updated SMS notification settings.
|
48
|
+
"""
|
49
|
+
current_settings = self.get_sms_notification_settings()
|
50
|
+
|
51
|
+
promotional_enabled = (
|
52
|
+
promotional_enabled if promotional_enabled is not None else current_settings.is_promotional_sms_opt_in
|
53
|
+
)
|
54
|
+
transactional_enabled = (
|
55
|
+
transactional_enabled if transactional_enabled is not None else current_settings.is_transactional_sms_opt_in
|
56
|
+
)
|
57
|
+
|
58
|
+
self.client.post_sms_notification_settings(
|
59
|
+
self.otf.member.phone_number, # type: ignore
|
60
|
+
promotional_enabled, # type: ignore
|
61
|
+
transactional_enabled, # type: ignore
|
62
|
+
)
|
63
|
+
|
64
|
+
# the response returns nothing useful, so we just query the settings again
|
65
|
+
new_settings = self.get_sms_notification_settings()
|
66
|
+
return new_settings
|
67
|
+
|
68
|
+
def get_email_notification_settings(self) -> models.EmailNotificationSettings:
|
69
|
+
"""Get the member's email notification settings.
|
70
|
+
|
71
|
+
Returns:
|
72
|
+
EmailNotificationSettings: The member's email notification settings.
|
73
|
+
"""
|
74
|
+
res = self.client.get_email_notification_settings(self.otf.member.email) # type: ignore
|
75
|
+
|
76
|
+
return models.EmailNotificationSettings(**res)
|
77
|
+
|
78
|
+
def update_email_notification_settings(
|
79
|
+
self, promotional_enabled: bool | None = None, transactional_enabled: bool | None = None
|
80
|
+
) -> models.EmailNotificationSettings:
|
81
|
+
"""Update the member's email notification settings. Arguments not provided will be left unchanged.
|
82
|
+
|
83
|
+
Args:
|
84
|
+
promotional_enabled (bool | None): Whether to enable promotional email notifications.
|
85
|
+
transactional_enabled (bool | None): Whether to enable transactional email notifications.
|
86
|
+
|
87
|
+
Returns:
|
88
|
+
EmailNotificationSettings: The updated email notification settings.
|
89
|
+
"""
|
90
|
+
current_settings = self.get_email_notification_settings()
|
91
|
+
|
92
|
+
promotional_enabled = (
|
93
|
+
promotional_enabled if promotional_enabled is not None else current_settings.is_promotional_email_opt_in
|
94
|
+
)
|
95
|
+
transactional_enabled = (
|
96
|
+
transactional_enabled
|
97
|
+
if transactional_enabled is not None
|
98
|
+
else current_settings.is_transactional_email_opt_in
|
99
|
+
)
|
100
|
+
|
101
|
+
self.client.post_email_notification_settings(
|
102
|
+
self.otf.member.email, # type: ignore
|
103
|
+
promotional_enabled, # type: ignore
|
104
|
+
transactional_enabled, # type: ignore
|
105
|
+
)
|
106
|
+
|
107
|
+
# the response returns nothing useful, so we just query the settings again
|
108
|
+
new_settings = self.get_email_notification_settings()
|
109
|
+
return new_settings
|
110
|
+
|
111
|
+
def update_member_name(self, first_name: str | None = None, last_name: str | None = None) -> models.MemberDetail:
|
112
|
+
"""Update the member's name. Will return the original member details if no names are provided.
|
113
|
+
|
114
|
+
Args:
|
115
|
+
first_name (str | None): The first name to update to. Default is None.
|
116
|
+
last_name (str | None): The last name to update to. Default is None.
|
117
|
+
|
118
|
+
Returns:
|
119
|
+
MemberDetail: The updated member details or the original member details if no changes were made.
|
120
|
+
"""
|
121
|
+
if not first_name and not last_name:
|
122
|
+
LOGGER.warning("No names provided, nothing to update.")
|
123
|
+
return self.otf.member
|
124
|
+
|
125
|
+
first_name = first_name or self.otf.member.first_name
|
126
|
+
last_name = last_name or self.otf.member.last_name
|
127
|
+
|
128
|
+
if first_name == self.otf.member.first_name and last_name == self.otf.member.last_name:
|
129
|
+
LOGGER.warning("No changes to names, nothing to update.")
|
130
|
+
return self.otf.member
|
131
|
+
|
132
|
+
assert first_name is not None, "First name is required"
|
133
|
+
assert last_name is not None, "Last name is required"
|
134
|
+
|
135
|
+
res = self.client.put_member_name(first_name, last_name)
|
136
|
+
|
137
|
+
return models.MemberDetail.create(**res, api=self)
|
138
|
+
|
139
|
+
def get_member_detail(self) -> models.MemberDetail:
|
140
|
+
"""Get the member details.
|
141
|
+
|
142
|
+
Returns:
|
143
|
+
MemberDetail: The member details.
|
144
|
+
"""
|
145
|
+
data = self.client.get_member_detail()
|
146
|
+
|
147
|
+
# use standard StudioDetail model instead of the one returned by this endpoint
|
148
|
+
home_studio_uuid = data["homeStudio"]["studioUUId"]
|
149
|
+
data["home_studio"] = self.otf.studios.get_studio_detail(home_studio_uuid)
|
150
|
+
|
151
|
+
return models.MemberDetail.create(**data, api=self)
|
152
|
+
|
153
|
+
def get_member_membership(self) -> models.MemberMembership:
|
154
|
+
"""Get the member's membership details.
|
155
|
+
|
156
|
+
Returns:
|
157
|
+
MemberMembership: The member's membership details.
|
158
|
+
"""
|
159
|
+
data = self.client.get_member_membership()
|
160
|
+
return models.MemberMembership(**data)
|
161
|
+
|
162
|
+
def get_member_purchases(self) -> list[models.MemberPurchase]:
|
163
|
+
"""Get the member's purchases, including monthly subscriptions and class packs.
|
164
|
+
|
165
|
+
Returns:
|
166
|
+
list[MemberPurchase]: The member's purchases.
|
167
|
+
"""
|
168
|
+
purchases = self.client.get_member_purchases()
|
169
|
+
|
170
|
+
for p in purchases:
|
171
|
+
p["studio"] = self.otf.studios.get_studio_detail(p["studio"]["studioUUId"])
|
172
|
+
|
173
|
+
return [models.MemberPurchase(**purchase) for purchase in purchases]
|
174
|
+
|
175
|
+
# the below do not return any data for me, so I can't test them
|
176
|
+
|
177
|
+
def _get_member_services(self, active_only: bool = True) -> Any: # noqa: ANN401
|
178
|
+
"""Get the member's services.
|
179
|
+
|
180
|
+
Args:
|
181
|
+
active_only (bool): Whether to only include active services. Default is True.
|
182
|
+
|
183
|
+
Returns:
|
184
|
+
Any: The member's services.
|
185
|
+
"""
|
186
|
+
data = self.client.get_member_services(active_only)
|
187
|
+
return data
|
@@ -0,0 +1,112 @@
|
|
1
|
+
from typing import Any
|
2
|
+
|
3
|
+
from otf_api.api.client import CACHE, OtfClient
|
4
|
+
|
5
|
+
|
6
|
+
class MemberClient:
|
7
|
+
"""Client for retrieving and managing member data in the OTF API.
|
8
|
+
|
9
|
+
This class provides methods to access member details, membership information, notification settings,
|
10
|
+
and member services. It also allows updating member information such as name and notification preferences.
|
11
|
+
"""
|
12
|
+
|
13
|
+
def __init__(self, client: OtfClient):
|
14
|
+
self.client = client
|
15
|
+
self.member_uuid = client.member_uuid
|
16
|
+
|
17
|
+
@CACHE.memoize(expire=600, tag="member_detail", ignore=(0,))
|
18
|
+
def get_member_detail(self) -> dict:
|
19
|
+
"""Retrieve raw member details."""
|
20
|
+
return self.client.default_request(
|
21
|
+
"GET", f"/member/members/{self.member_uuid}", params={"include": "memberAddresses,memberClassSummary"}
|
22
|
+
)["data"]
|
23
|
+
|
24
|
+
def get_member_membership(self) -> dict:
|
25
|
+
"""Retrieve raw member membership details."""
|
26
|
+
return self.client.default_request("GET", f"/member/members/{self.member_uuid}/memberships")["data"]
|
27
|
+
|
28
|
+
def get_sms_notification_settings(self, phone_number: str) -> dict:
|
29
|
+
"""Retrieve raw SMS notification settings."""
|
30
|
+
return self.client.default_request("GET", path="/sms/v1/preferences", params={"phoneNumber": phone_number})[
|
31
|
+
"data"
|
32
|
+
]
|
33
|
+
|
34
|
+
def get_email_notification_settings(self, email: str) -> dict:
|
35
|
+
"""Retrieve raw email notification settings."""
|
36
|
+
return self.client.default_request("GET", path="/otfmailing/v2/preferences", params={"email": email})["data"]
|
37
|
+
|
38
|
+
def get_member_services(self, active_only: bool) -> dict:
|
39
|
+
"""Retrieve raw member services data."""
|
40
|
+
return self.client.default_request(
|
41
|
+
"GET", f"/member/members/{self.member_uuid}/services", params={"activeOnly": str(active_only).lower()}
|
42
|
+
)
|
43
|
+
|
44
|
+
def get_member_purchases(self) -> dict:
|
45
|
+
"""Retrieve raw member purchases data."""
|
46
|
+
return self.client.default_request("GET", f"/member/members/{self.member_uuid}/purchases")["data"]
|
47
|
+
|
48
|
+
def post_sms_notification_settings(
|
49
|
+
self, phone_number: str, promotional_enabled: bool, transactional_enabled: bool
|
50
|
+
) -> dict:
|
51
|
+
"""Retrieve raw response from updating SMS notification settings.
|
52
|
+
|
53
|
+
Warning:
|
54
|
+
This endpoint seems to accept almost anything, converting values to truthy/falsey and
|
55
|
+
updating the settings accordingly. The one error I've gotten is with -1
|
56
|
+
|
57
|
+
```
|
58
|
+
ERROR - Response:
|
59
|
+
{
|
60
|
+
"code": "ER_WARN_DATA_OUT_OF_RANGE",
|
61
|
+
"message": "An unexpected server error occurred, please try again.",
|
62
|
+
"details": [
|
63
|
+
{
|
64
|
+
"message": "ER_WARN_DATA_OUT_OF_RANGE: Out of range value for column 'IsPromotionalSMSOptIn' at row 1",
|
65
|
+
"additionalInfo": ""
|
66
|
+
}
|
67
|
+
]
|
68
|
+
}
|
69
|
+
```
|
70
|
+
"""
|
71
|
+
return self.client.default_request(
|
72
|
+
"POST",
|
73
|
+
"/sms/v1/preferences",
|
74
|
+
json={
|
75
|
+
"promosms": promotional_enabled,
|
76
|
+
"source": "OTF",
|
77
|
+
"transactionalsms": transactional_enabled,
|
78
|
+
"phoneNumber": phone_number,
|
79
|
+
},
|
80
|
+
)
|
81
|
+
|
82
|
+
def post_email_notification_settings(
|
83
|
+
self, email: str, promotional_enabled: bool, transactional_enabled: bool
|
84
|
+
) -> dict:
|
85
|
+
"""Retrieve raw response from updating email notification settings."""
|
86
|
+
return self.client.default_request(
|
87
|
+
"POST",
|
88
|
+
"/otfmailing/v2/preferences",
|
89
|
+
json={
|
90
|
+
"promotionalEmail": promotional_enabled,
|
91
|
+
"source": "OTF",
|
92
|
+
"transactionalEmail": transactional_enabled,
|
93
|
+
"email": email,
|
94
|
+
},
|
95
|
+
)
|
96
|
+
|
97
|
+
def put_member_name(self, first_name: str, last_name: str) -> dict:
|
98
|
+
"""Retrieve raw response from updating member name."""
|
99
|
+
CACHE.evict(tag="member_detail", retry=True)
|
100
|
+
return self.client.default_request(
|
101
|
+
"PUT",
|
102
|
+
f"/member/members/{self.member_uuid}",
|
103
|
+
json={"firstName": first_name, "lastName": last_name},
|
104
|
+
)["data"]
|
105
|
+
|
106
|
+
def get_app_config(self) -> dict[str, Any]:
|
107
|
+
"""Retrieve raw app configuration data.
|
108
|
+
|
109
|
+
Returns:
|
110
|
+
dict[str, Any]: A dictionary containing app configuration data.
|
111
|
+
"""
|
112
|
+
return self.client.default_request("GET", "/member/app-configurations", headers={"SIGV4AUTH_REQUIRED": "true"})
|
@@ -0,0 +1,173 @@
|
|
1
|
+
import typing
|
2
|
+
from logging import getLogger
|
3
|
+
|
4
|
+
from otf_api import exceptions as exc
|
5
|
+
from otf_api import models
|
6
|
+
from otf_api.api import utils
|
7
|
+
from otf_api.api.client import OtfClient
|
8
|
+
|
9
|
+
from .studio_client import StudioClient
|
10
|
+
|
11
|
+
if typing.TYPE_CHECKING:
|
12
|
+
from otf_api import Otf
|
13
|
+
|
14
|
+
LOGGER = getLogger(__name__)
|
15
|
+
|
16
|
+
|
17
|
+
class StudioApi:
|
18
|
+
def __init__(self, otf: "Otf", otf_client: OtfClient):
|
19
|
+
"""Initialize the Studio API client.
|
20
|
+
|
21
|
+
Args:
|
22
|
+
otf (Otf): The OTF API client.
|
23
|
+
otf_client (OtfClient): The OTF client to use for requests.
|
24
|
+
"""
|
25
|
+
self.otf = otf
|
26
|
+
self.client = StudioClient(otf_client)
|
27
|
+
|
28
|
+
def get_favorite_studios(self) -> list[models.StudioDetail]:
|
29
|
+
"""Get the member's favorite studios.
|
30
|
+
|
31
|
+
Returns:
|
32
|
+
list[StudioDetail]: The member's favorite studios.
|
33
|
+
"""
|
34
|
+
data = self.client.get_favorite_studios()
|
35
|
+
studio_uuids = [studio["studioUUId"] for studio in data]
|
36
|
+
return [self.get_studio_detail(studio_uuid) for studio_uuid in studio_uuids]
|
37
|
+
|
38
|
+
def add_favorite_studio(self, studio_uuids: list[str] | str) -> list[models.StudioDetail]:
|
39
|
+
"""Add a studio to the member's favorite studios.
|
40
|
+
|
41
|
+
Args:
|
42
|
+
studio_uuids (list[str] | str): The studio UUID or list of studio UUIDs to add to the member's favorite\
|
43
|
+
studios. If a string is provided, it will be converted to a list.
|
44
|
+
|
45
|
+
Returns:
|
46
|
+
list[StudioDetail]: The new favorite studios.
|
47
|
+
"""
|
48
|
+
studio_uuids = utils.ensure_list(studio_uuids)
|
49
|
+
|
50
|
+
if not studio_uuids:
|
51
|
+
raise ValueError("studio_uuids is required")
|
52
|
+
|
53
|
+
resp = self.client.post_favorite_studio(studio_uuids)
|
54
|
+
if not resp:
|
55
|
+
return []
|
56
|
+
|
57
|
+
new_faves = resp.get("studios", [])
|
58
|
+
|
59
|
+
return [models.StudioDetail.create(**studio, api=self) for studio in new_faves]
|
60
|
+
|
61
|
+
def remove_favorite_studio(self, studio_uuids: list[str] | str) -> None:
|
62
|
+
"""Remove a studio from the member's favorite studios.
|
63
|
+
|
64
|
+
Args:
|
65
|
+
studio_uuids (list[str] | str): The studio UUID or list of studio UUIDs to remove from the member's\
|
66
|
+
favorite studios. If a string is provided, it will be converted to a list.
|
67
|
+
|
68
|
+
Returns:
|
69
|
+
None
|
70
|
+
"""
|
71
|
+
studio_uuids = utils.ensure_list(studio_uuids)
|
72
|
+
|
73
|
+
if not studio_uuids:
|
74
|
+
raise ValueError("studio_uuids is required")
|
75
|
+
|
76
|
+
# keeping the convention of regular/raw methods even though this method doesn't return anything
|
77
|
+
# in case that changes in the future
|
78
|
+
self.client.delete_favorite_studio(studio_uuids)
|
79
|
+
|
80
|
+
def get_studio_services(self, studio_uuid: str | None = None) -> list[models.StudioService]:
|
81
|
+
"""Get the services available at a specific studio.
|
82
|
+
|
83
|
+
If no studio UUID is provided, the member's home studio will be used.
|
84
|
+
|
85
|
+
Args:
|
86
|
+
studio_uuid (str, optional): The studio UUID to get services for.
|
87
|
+
|
88
|
+
Returns:
|
89
|
+
list[StudioService]: The services available at the studio.
|
90
|
+
"""
|
91
|
+
studio_uuid = studio_uuid or self.otf.home_studio_uuid
|
92
|
+
data = self.client.get_studio_services(studio_uuid)
|
93
|
+
|
94
|
+
for d in data:
|
95
|
+
d["studio"] = self.get_studio_detail(studio_uuid)
|
96
|
+
|
97
|
+
return [models.StudioService(**d) for d in data]
|
98
|
+
|
99
|
+
def get_studio_detail(self, studio_uuid: str | None = None) -> models.StudioDetail:
|
100
|
+
"""Get detailed information about a specific studio.
|
101
|
+
|
102
|
+
If no studio UUID is provided, it will default to the user's home studio.
|
103
|
+
|
104
|
+
If the studio is not found, it will return a StudioDetail object with default values.
|
105
|
+
|
106
|
+
Args:
|
107
|
+
studio_uuid (str, optional): The studio UUID to get detailed information about.
|
108
|
+
|
109
|
+
Returns:
|
110
|
+
StudioDetail: Detailed information about the studio.
|
111
|
+
"""
|
112
|
+
studio_uuid = studio_uuid or self.otf.home_studio_uuid
|
113
|
+
|
114
|
+
try:
|
115
|
+
res = self.client.get_studio_detail(studio_uuid)
|
116
|
+
except exc.ResourceNotFoundError:
|
117
|
+
return models.StudioDetail.create_empty_model(studio_uuid)
|
118
|
+
|
119
|
+
return models.StudioDetail.create(**res, api=self)
|
120
|
+
|
121
|
+
def get_studios_by_geo(
|
122
|
+
self, latitude: float | None = None, longitude: float | None = None
|
123
|
+
) -> list[models.StudioDetail]:
|
124
|
+
"""Alias for search_studios_by_geo."""
|
125
|
+
return self.search_studios_by_geo(latitude, longitude)
|
126
|
+
|
127
|
+
def search_studios_by_geo(
|
128
|
+
self, latitude: float | None = None, longitude: float | None = None, distance: int = 50
|
129
|
+
) -> list[models.StudioDetail]:
|
130
|
+
"""Search for studios by geographic location.
|
131
|
+
|
132
|
+
Args:
|
133
|
+
latitude (float, optional): Latitude of the location to search around, if None uses home studio latitude.
|
134
|
+
longitude (float, optional): Longitude of the location to search around, if None uses home studio longitude.
|
135
|
+
distance (int, optional): The distance in miles to search around the location. Default is 50.
|
136
|
+
|
137
|
+
Returns:
|
138
|
+
list[StudioDetail]: List of studios that match the search criteria.
|
139
|
+
"""
|
140
|
+
latitude = latitude or self.otf.home_studio.location.latitude
|
141
|
+
longitude = longitude or self.otf.home_studio.location.longitude
|
142
|
+
|
143
|
+
results = self.client.get_studios_by_geo(latitude, longitude, distance)
|
144
|
+
return [models.StudioDetail.create(**studio, api=self) for studio in results]
|
145
|
+
|
146
|
+
def _get_all_studios(self) -> list[models.StudioDetail]:
|
147
|
+
"""Gets all studios. Marked as private to avoid random users calling it.
|
148
|
+
|
149
|
+
Useful for testing and validating models.
|
150
|
+
|
151
|
+
Returns:
|
152
|
+
list[StudioDetail]: List of studios that match the search criteria.
|
153
|
+
"""
|
154
|
+
# long/lat being None will cause the endpoint to return all studios
|
155
|
+
results = self.client.get_studios_by_geo(None, None)
|
156
|
+
return [models.StudioDetail.create(**studio, api=self) for studio in results]
|
157
|
+
|
158
|
+
def _get_studio_detail_threaded(self, studio_uuids: list[str]) -> dict[str, models.StudioDetail]:
|
159
|
+
"""Get detailed information about multiple studios in a threaded manner.
|
160
|
+
|
161
|
+
This is used to improve performance when fetching details for multiple studios at once.
|
162
|
+
This method is on the Otf class because StudioDetail is a model that requires the API instance.
|
163
|
+
|
164
|
+
Args:
|
165
|
+
studio_uuids (list[str]): List of studio UUIDs to get details for.
|
166
|
+
|
167
|
+
Returns:
|
168
|
+
dict[str, StudioDetail]: A dictionary mapping studio UUIDs to their detailed information.
|
169
|
+
"""
|
170
|
+
studio_dicts = self.client.get_studio_detail_threaded(studio_uuids)
|
171
|
+
return {
|
172
|
+
studio_uuid: models.StudioDetail.create(**studio, api=self) for studio_uuid, studio in studio_dicts.items()
|
173
|
+
}
|