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.
Files changed (74) hide show
  1. otf_api/__init__.py +35 -3
  2. otf_api/api/__init__.py +3 -0
  3. otf_api/api/_compat.py +77 -0
  4. otf_api/api/api.py +80 -0
  5. otf_api/api/bookings/__init__.py +3 -0
  6. otf_api/api/bookings/booking_api.py +541 -0
  7. otf_api/api/bookings/booking_client.py +112 -0
  8. otf_api/api/client.py +203 -0
  9. otf_api/api/members/__init__.py +3 -0
  10. otf_api/api/members/member_api.py +187 -0
  11. otf_api/api/members/member_client.py +112 -0
  12. otf_api/api/studios/__init__.py +3 -0
  13. otf_api/api/studios/studio_api.py +173 -0
  14. otf_api/api/studios/studio_client.py +120 -0
  15. otf_api/api/utils.py +307 -0
  16. otf_api/api/workouts/__init__.py +3 -0
  17. otf_api/api/workouts/workout_api.py +333 -0
  18. otf_api/api/workouts/workout_client.py +140 -0
  19. otf_api/auth/__init__.py +1 -1
  20. otf_api/auth/auth.py +155 -89
  21. otf_api/auth/user.py +5 -17
  22. otf_api/auth/utils.py +27 -2
  23. otf_api/cache.py +132 -0
  24. otf_api/exceptions.py +18 -6
  25. otf_api/models/__init__.py +25 -21
  26. otf_api/models/bookings/__init__.py +23 -0
  27. otf_api/models/bookings/bookings.py +134 -0
  28. otf_api/models/{bookings_v2.py → bookings/bookings_v2.py} +72 -31
  29. otf_api/models/bookings/classes.py +124 -0
  30. otf_api/models/{enums.py → bookings/enums.py} +7 -81
  31. otf_api/{filters.py → models/bookings/filters.py} +39 -11
  32. otf_api/models/{ratings.py → bookings/ratings.py} +2 -6
  33. otf_api/models/members/__init__.py +5 -0
  34. otf_api/models/members/member_detail.py +149 -0
  35. otf_api/models/members/member_membership.py +26 -0
  36. otf_api/models/members/member_purchases.py +29 -0
  37. otf_api/models/members/notifications.py +17 -0
  38. otf_api/models/mixins.py +48 -1
  39. otf_api/models/studios/__init__.py +5 -0
  40. otf_api/models/studios/enums.py +11 -0
  41. otf_api/models/studios/studio_detail.py +93 -0
  42. otf_api/models/studios/studio_services.py +36 -0
  43. otf_api/models/workouts/__init__.py +31 -0
  44. otf_api/models/{body_composition_list.py → workouts/body_composition_list.py} +140 -71
  45. otf_api/models/workouts/challenge_tracker_content.py +50 -0
  46. otf_api/models/workouts/challenge_tracker_detail.py +99 -0
  47. otf_api/models/workouts/enums.py +70 -0
  48. otf_api/models/workouts/lifetime_stats.py +96 -0
  49. otf_api/models/workouts/out_of_studio_workout_history.py +32 -0
  50. otf_api/models/{performance_summary.py → workouts/performance_summary.py} +19 -5
  51. otf_api/models/workouts/telemetry.py +88 -0
  52. otf_api/models/{workout.py → workouts/workout.py} +34 -20
  53. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/METADATA +4 -2
  54. otf_api-0.13.0.dist-info/RECORD +59 -0
  55. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/WHEEL +1 -1
  56. otf_api/api.py +0 -1682
  57. otf_api/logging.py +0 -19
  58. otf_api/models/bookings.py +0 -109
  59. otf_api/models/challenge_tracker_content.py +0 -59
  60. otf_api/models/challenge_tracker_detail.py +0 -88
  61. otf_api/models/classes.py +0 -70
  62. otf_api/models/lifetime_stats.py +0 -78
  63. otf_api/models/member_detail.py +0 -121
  64. otf_api/models/member_membership.py +0 -26
  65. otf_api/models/member_purchases.py +0 -29
  66. otf_api/models/notifications.py +0 -17
  67. otf_api/models/out_of_studio_workout_history.py +0 -32
  68. otf_api/models/studio_detail.py +0 -71
  69. otf_api/models/studio_services.py +0 -36
  70. otf_api/models/telemetry.py +0 -84
  71. otf_api/utils.py +0 -164
  72. otf_api-0.12.0.dist-info/RECORD +0 -38
  73. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/licenses/LICENSE +0 -0
  74. {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,3 @@
1
+ from .member_api import MemberApi
2
+
3
+ __all__ = ["MemberApi"]
@@ -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,3 @@
1
+ from .studio_api import StudioApi
2
+
3
+ __all__ = ["StudioApi"]
@@ -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
+ }