otf-api 0.15.1__tar.gz → 0.15.2__tar.gz
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-0.15.1/src/otf_api.egg-info → otf_api-0.15.2}/PKG-INFO +2 -1
- {otf_api-0.15.1 → otf_api-0.15.2}/pyproject.toml +2 -1
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/__init__.py +16 -5
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/bookings/booking_api.py +49 -36
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/client.py +19 -9
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/members/member_api.py +2 -2
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/studios/studio_api.py +47 -47
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/workouts/workout_api.py +68 -6
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/workouts/workout_client.py +25 -3
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/performance_summary.py +10 -31
- {otf_api-0.15.1 → otf_api-0.15.2/src/otf_api.egg-info}/PKG-INFO +2 -1
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api.egg-info/requires.txt +1 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/LICENSE +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/README.md +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/setup.cfg +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/__init__.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/_compat.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/api.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/bookings/__init__.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/bookings/booking_client.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/members/__init__.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/members/member_client.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/studios/__init__.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/studios/studio_client.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/utils.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/api/workouts/__init__.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/auth/__init__.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/auth/auth.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/auth/user.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/auth/utils.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/cache.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/exceptions.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/__init__.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/base.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/bookings/__init__.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/bookings/bookings.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/bookings/bookings_v2.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/bookings/classes.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/bookings/enums.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/bookings/filters.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/bookings/ratings.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/members/__init__.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/members/member_detail.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/members/member_membership.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/members/member_purchases.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/members/notifications.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/mixins.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/studios/__init__.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/studios/enums.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/studios/studio_detail.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/studios/studio_services.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/__init__.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/body_composition_list.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/challenge_tracker_content.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/challenge_tracker_detail.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/enums.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/lifetime_stats.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/out_of_studio_workout_history.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/telemetry.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/workout.py +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/py.typed +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api.egg-info/SOURCES.txt +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api.egg-info/dependency_links.txt +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api.egg-info/top_level.txt +0 -0
- {otf_api-0.15.1 → otf_api-0.15.2}/tests/test_filters.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: otf-api
|
3
|
-
Version: 0.15.
|
3
|
+
Version: 0.15.2
|
4
4
|
Summary: Python OrangeTheory Fitness API Client
|
5
5
|
Author-email: Jessica Smith <j.smith.git1@gmail.com>
|
6
6
|
License-Expression: MIT
|
@@ -29,6 +29,7 @@ Requires-Dist: pendulum>=3.1.0
|
|
29
29
|
Requires-Dist: diskcache>=5.6.3
|
30
30
|
Requires-Dist: platformdirs>=4.3.6
|
31
31
|
Requires-Dist: packaging>=24.2
|
32
|
+
Requires-Dist: coloredlogs>=15.0.1
|
32
33
|
Dynamic: license-file
|
33
34
|
|
34
35
|
Simple API client for interacting with the OrangeTheory Fitness APIs.
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "otf-api"
|
3
|
-
version = "0.15.
|
3
|
+
version = "0.15.2"
|
4
4
|
description = "Python OrangeTheory Fitness API Client"
|
5
5
|
authors = [{ name = "Jessica Smith", email = "j.smith.git1@gmail.com" }]
|
6
6
|
requires-python = ">=3.11"
|
@@ -30,6 +30,7 @@ dependencies = [
|
|
30
30
|
"diskcache>=5.6.3",
|
31
31
|
"platformdirs>=4.3.6",
|
32
32
|
"packaging>=24.2",
|
33
|
+
"coloredlogs>=15.0.1",
|
33
34
|
]
|
34
35
|
|
35
36
|
[project.urls]
|
@@ -7,13 +7,15 @@ Use it at your own risk. It may break at any time if Orangetheory changes their
|
|
7
7
|
import logging
|
8
8
|
import os
|
9
9
|
|
10
|
+
import coloredlogs
|
11
|
+
|
10
12
|
from otf_api import models
|
11
13
|
from otf_api.api import Otf
|
12
14
|
from otf_api.auth import OtfUser
|
13
15
|
|
14
16
|
LOG_LEVEL = os.getenv("OTF_LOG_LEVEL", "INFO").upper()
|
15
|
-
|
16
|
-
LOG_FMT = "
|
17
|
+
|
18
|
+
LOG_FMT = "%(asctime)s - %(module)s.%(funcName)s:%(lineno)d - %(levelname)s - %(message)s"
|
17
19
|
DATE_FMT = "%Y-%m-%d %H:%M:%S%z"
|
18
20
|
|
19
21
|
|
@@ -24,19 +26,28 @@ def _setup_logging() -> None:
|
|
24
26
|
return # Already set up
|
25
27
|
|
26
28
|
# 2) Set the logger level to INFO (or whatever you need).
|
27
|
-
logger.setLevel(
|
29
|
+
logger.setLevel(LOG_LEVEL)
|
28
30
|
|
29
31
|
# 3) Create a handler (e.g., console) and set its formatter.
|
30
32
|
handler = logging.StreamHandler()
|
31
|
-
handler.setFormatter(logging.Formatter(fmt=LOG_FMT, datefmt=DATE_FMT, style="
|
33
|
+
handler.setFormatter(logging.Formatter(fmt=LOG_FMT, datefmt=DATE_FMT, style="%"))
|
32
34
|
|
33
35
|
# 4) Add this handler to your package logger.
|
34
36
|
logger.addHandler(handler)
|
35
37
|
|
38
|
+
coloredlogs.install(
|
39
|
+
level=LOG_LEVEL,
|
40
|
+
logger=logger,
|
41
|
+
fmt=LOG_FMT,
|
42
|
+
datefmt=DATE_FMT,
|
43
|
+
style="%",
|
44
|
+
isatty=True, # Use colored output only if the output is a terminal
|
45
|
+
)
|
46
|
+
|
36
47
|
|
37
48
|
_setup_logging()
|
38
49
|
|
39
|
-
__version__ = "0.15.
|
50
|
+
__version__ = "0.15.2"
|
40
51
|
|
41
52
|
|
42
53
|
__all__ = ["Otf", "OtfUser", "models"]
|
@@ -8,19 +8,19 @@ import pendulum
|
|
8
8
|
from otf_api import exceptions as exc
|
9
9
|
from otf_api import models
|
10
10
|
from otf_api.api import utils
|
11
|
-
from otf_api.api.client import OtfClient
|
12
11
|
from otf_api.models.bookings import HISTORICAL_BOOKING_STATUSES, ClassFilter
|
13
12
|
|
14
13
|
from .booking_client import BookingClient
|
15
14
|
|
16
15
|
if typing.TYPE_CHECKING:
|
17
16
|
from otf_api import Otf
|
17
|
+
from otf_api.api.client import OtfClient
|
18
18
|
|
19
19
|
LOGGER = getLogger(__name__)
|
20
20
|
|
21
21
|
|
22
22
|
class BookingApi:
|
23
|
-
def __init__(self, otf: "Otf", otf_client: OtfClient):
|
23
|
+
def __init__(self, otf: "Otf", otf_client: "OtfClient"):
|
24
24
|
"""Initialize the Booking API client.
|
25
25
|
|
26
26
|
Args:
|
@@ -30,6 +30,39 @@ class BookingApi:
|
|
30
30
|
self.otf = otf
|
31
31
|
self.client = BookingClient(otf_client)
|
32
32
|
|
33
|
+
def _get_all_bookings_new(
|
34
|
+
self, exclude_cancelled: bool = True, remove_duplicates: bool = True
|
35
|
+
) -> list[models.BookingV2]:
|
36
|
+
"""Get bookings from the new endpoint with no date filters.
|
37
|
+
|
38
|
+
This is marked as private to avoid random users calling it.
|
39
|
+
Useful for testing and validating models.
|
40
|
+
|
41
|
+
Args:
|
42
|
+
exclude_cancelled (bool): Whether to exclude cancelled bookings. Default is True.
|
43
|
+
remove_duplicates (bool): Whether to remove duplicate bookings. Default is True.
|
44
|
+
|
45
|
+
Returns:
|
46
|
+
list[BookingV2]: List of bookings that match the search criteria.
|
47
|
+
"""
|
48
|
+
start_date = pendulum.datetime(1970, 1, 1)
|
49
|
+
end_date = pendulum.today().start_of("day").add(days=45)
|
50
|
+
return self.get_bookings_new(start_date, end_date, exclude_cancelled, remove_duplicates)
|
51
|
+
|
52
|
+
def _get_all_bookings_new_by_date(self) -> dict[datetime, models.BookingV2]:
|
53
|
+
"""Get all bookings from the new endpoint by date.
|
54
|
+
|
55
|
+
This is marked as private to avoid random users calling it.
|
56
|
+
Useful for testing and validating models.
|
57
|
+
|
58
|
+
Returns:
|
59
|
+
dict[datetime, BookingV2]: Dictionary of bookings by date.
|
60
|
+
"""
|
61
|
+
start_date = pendulum.datetime(1970, 1, 1)
|
62
|
+
end_date = pendulum.today().start_of("day").add(days=45)
|
63
|
+
bookings = self.get_bookings_new_by_date(start_date, end_date)
|
64
|
+
return bookings
|
65
|
+
|
33
66
|
def get_bookings_new(
|
34
67
|
self,
|
35
68
|
start_date: datetime | date | str | None = None,
|
@@ -79,6 +112,7 @@ class BookingApi:
|
|
79
112
|
bookings_resp = self.client.get_bookings_new(
|
80
113
|
ends_before=end_date, starts_after=start_date, include_canceled=include_canceled, expand=expand
|
81
114
|
)
|
115
|
+
LOGGER.debug("Found %d bookings between %s and %s", len(bookings_resp), start_date, end_date)
|
82
116
|
|
83
117
|
# filter out bookings with ids that start with "no-booking-id"
|
84
118
|
# no idea what these are, but I am praying for the poor sap stuck with maintaining OTF's data model
|
@@ -89,7 +123,7 @@ class BookingApi:
|
|
89
123
|
try:
|
90
124
|
results.append(models.BookingV2.create(**b, api=self.otf))
|
91
125
|
except ValueError as e:
|
92
|
-
LOGGER.
|
126
|
+
LOGGER.error("Failed to create BookingV2 from response: %s. Booking data:\n%s", e, b)
|
93
127
|
continue
|
94
128
|
|
95
129
|
if not remove_duplicates:
|
@@ -112,6 +146,9 @@ class BookingApi:
|
|
112
146
|
list[BookingV2]: The deduplicated list of bookings.
|
113
147
|
"""
|
114
148
|
# remove duplicates by class_id, keeping the one with the most recent updated_at timestamp
|
149
|
+
|
150
|
+
orig_count = len(results)
|
151
|
+
|
115
152
|
seen_classes: dict[str, models.BookingV2] = {}
|
116
153
|
|
117
154
|
for booking in results:
|
@@ -127,11 +164,20 @@ class BookingApi:
|
|
127
164
|
"this is unexpected behavior."
|
128
165
|
)
|
129
166
|
if booking.updated_at > existing_booking.updated_at:
|
167
|
+
LOGGER.debug(
|
168
|
+
"Replacing existing booking for class_id %s with more recent booking %s", class_id, booking
|
169
|
+
)
|
130
170
|
seen_classes[class_id] = booking
|
131
171
|
|
132
172
|
results = list(seen_classes.values())
|
133
173
|
results = sorted(results, key=lambda x: x.starts_at)
|
134
174
|
|
175
|
+
new_count = len(results)
|
176
|
+
diff = orig_count - new_count
|
177
|
+
|
178
|
+
if diff:
|
179
|
+
LOGGER.debug("Removed %d duplicate bookings, returning %d unique bookings", diff, new_count)
|
180
|
+
|
135
181
|
return results
|
136
182
|
|
137
183
|
def get_bookings_new_by_date(
|
@@ -615,36 +661,3 @@ class BookingApi:
|
|
615
661
|
if e.response.status_code == 403:
|
616
662
|
raise exc.AlreadyRatedError(f"Workout {performance_summary_id} is already rated.") from None
|
617
663
|
raise
|
618
|
-
|
619
|
-
def _get_all_bookings_new(
|
620
|
-
self, exclude_cancelled: bool = True, remove_duplicates: bool = True
|
621
|
-
) -> list[models.BookingV2]:
|
622
|
-
"""Get bookings from the new endpoint with no date filters.
|
623
|
-
|
624
|
-
This is marked as private to avoid random users calling it.
|
625
|
-
Useful for testing and validating models.
|
626
|
-
|
627
|
-
Args:
|
628
|
-
exclude_cancelled (bool): Whether to exclude cancelled bookings. Default is True.
|
629
|
-
remove_duplicates (bool): Whether to remove duplicate bookings. Default is True.
|
630
|
-
|
631
|
-
Returns:
|
632
|
-
list[BookingV2]: List of bookings that match the search criteria.
|
633
|
-
"""
|
634
|
-
start_date = pendulum.datetime(1970, 1, 1)
|
635
|
-
end_date = pendulum.today().start_of("day").add(days=45)
|
636
|
-
return self.get_bookings_new(start_date, end_date, exclude_cancelled, remove_duplicates)
|
637
|
-
|
638
|
-
def _get_all_bookings_new_by_date(self) -> dict[datetime, models.BookingV2]:
|
639
|
-
"""Get all bookings from the new endpoint by date.
|
640
|
-
|
641
|
-
This is marked as private to avoid random users calling it.
|
642
|
-
Useful for testing and validating models.
|
643
|
-
|
644
|
-
Returns:
|
645
|
-
dict[datetime, BookingV2]: Dictionary of bookings by date.
|
646
|
-
"""
|
647
|
-
start_date = pendulum.datetime(1970, 1, 1)
|
648
|
-
end_date = pendulum.today().start_of("day").add(days=45)
|
649
|
-
bookings = self.get_bookings_new_by_date(start_date, end_date)
|
650
|
-
return bookings
|
@@ -1,4 +1,6 @@
|
|
1
1
|
import atexit
|
2
|
+
import json
|
3
|
+
import os
|
2
4
|
import re
|
3
5
|
from json import JSONDecodeError
|
4
6
|
from logging import getLogger
|
@@ -47,6 +49,7 @@ class OtfClient:
|
|
47
49
|
self.session = httpx.Client(
|
48
50
|
headers=HEADERS, auth=self.user.httpx_auth, timeout=httpx.Timeout(20.0, connect=60.0)
|
49
51
|
)
|
52
|
+
self.log_raw_response = os.getenv("OTF_LOG_RAW_RESPONSE", "false").lower() == "true"
|
50
53
|
atexit.register(self.session.close)
|
51
54
|
|
52
55
|
def __getstate__(self):
|
@@ -110,7 +113,7 @@ class OtfClient:
|
|
110
113
|
"""
|
111
114
|
full_url = str(URL.build(scheme="https", host=base_url, path=path))
|
112
115
|
request = self._build_request(method, full_url, params, headers, **kwargs)
|
113
|
-
LOGGER.debug(
|
116
|
+
LOGGER.debug("Making %r request to '%s'", method, str(request.url))
|
114
117
|
|
115
118
|
try:
|
116
119
|
response = self.session.send(request)
|
@@ -158,10 +161,14 @@ class OtfClient:
|
|
158
161
|
if error_code == "602":
|
159
162
|
raise exc.OutsideSchedulingWindowError("Class is outside scheduling window")
|
160
163
|
|
161
|
-
|
162
|
-
LOGGER.error(msg)
|
164
|
+
LOGGER.error("HTTP error %s for %s %s", response.status_code, request.method, request.url)
|
163
165
|
error_cls = exc.RetryableOtfRequestError if response.status_code >= 500 else exc.OtfRequestError
|
164
|
-
raise error_cls(
|
166
|
+
raise error_cls(
|
167
|
+
message=f"HTTP error {response.status_code} for {request.method} {request.url}",
|
168
|
+
original_exception=error,
|
169
|
+
request=request,
|
170
|
+
response=response,
|
171
|
+
)
|
165
172
|
|
166
173
|
def _handle_transport_error(self, error: Exception, request: httpx.Request) -> None:
|
167
174
|
"""Handle transport errors during API requests.
|
@@ -177,7 +184,7 @@ class OtfClient:
|
|
177
184
|
url = request.url
|
178
185
|
|
179
186
|
if not isinstance(error, httpx.HTTPStatusError):
|
180
|
-
LOGGER.exception(
|
187
|
+
LOGGER.exception("Unexpected error during %r %r: %s - %s", method, url, type(error).__name__, error)
|
181
188
|
return
|
182
189
|
|
183
190
|
json_data = get_json_from_response(error.response)
|
@@ -190,7 +197,7 @@ class OtfClient:
|
|
190
197
|
data_status: int | None = data.get("Status") or data.get("status") or None
|
191
198
|
|
192
199
|
if isinstance(data, dict) and isinstance(data_status, int) and not 200 <= data_status <= 299:
|
193
|
-
LOGGER.error(
|
200
|
+
LOGGER.error("API returned error: %s", data)
|
194
201
|
raise exc.OtfRequestError("Bad API response", None, response=response, request=request)
|
195
202
|
|
196
203
|
raise exc.OtfRequestError(
|
@@ -202,17 +209,20 @@ class OtfClient:
|
|
202
209
|
if method == "GET":
|
203
210
|
raise exc.OtfRequestError("Empty response", None, response=response, request=request)
|
204
211
|
|
205
|
-
LOGGER.debug(
|
212
|
+
LOGGER.debug("No content returned from %s %s", method, response.url)
|
206
213
|
return None
|
207
214
|
|
208
215
|
try:
|
209
216
|
json_data = response.json()
|
210
217
|
except JSONDecodeError as e:
|
211
|
-
LOGGER.error(
|
212
|
-
LOGGER.error(
|
218
|
+
LOGGER.error("Invalid JSON: %s", e)
|
219
|
+
LOGGER.error("Response content: %s", response.text)
|
213
220
|
raise
|
214
221
|
|
215
222
|
if is_error_response(json_data):
|
216
223
|
self._map_logical_error(json_data, response, request)
|
217
224
|
|
225
|
+
if self.log_raw_response:
|
226
|
+
LOGGER.debug("Response from %s %s: %s", method, response.url, json.dumps(json_data, indent=4))
|
227
|
+
|
218
228
|
return json_data
|
@@ -3,18 +3,18 @@ from logging import getLogger
|
|
3
3
|
from typing import Any
|
4
4
|
|
5
5
|
from otf_api import models
|
6
|
-
from otf_api.api.client import OtfClient
|
7
6
|
|
8
7
|
from .member_client import MemberClient
|
9
8
|
|
10
9
|
if typing.TYPE_CHECKING:
|
11
10
|
from otf_api import Otf
|
11
|
+
from otf_api.api.client import OtfClient
|
12
12
|
|
13
13
|
LOGGER = getLogger(__name__)
|
14
14
|
|
15
15
|
|
16
16
|
class MemberApi:
|
17
|
-
def __init__(self, otf: "Otf", otf_client: OtfClient):
|
17
|
+
def __init__(self, otf: "Otf", otf_client: "OtfClient"):
|
18
18
|
"""Initialize the Member API client.
|
19
19
|
|
20
20
|
Args:
|
@@ -4,18 +4,18 @@ from logging import getLogger
|
|
4
4
|
from otf_api import exceptions as exc
|
5
5
|
from otf_api import models
|
6
6
|
from otf_api.api import utils
|
7
|
-
from otf_api.api.client import OtfClient
|
8
7
|
|
9
8
|
from .studio_client import StudioClient
|
10
9
|
|
11
10
|
if typing.TYPE_CHECKING:
|
12
11
|
from otf_api import Otf
|
12
|
+
from otf_api.api.client import OtfClient
|
13
13
|
|
14
14
|
LOGGER = getLogger(__name__)
|
15
15
|
|
16
16
|
|
17
17
|
class StudioApi:
|
18
|
-
def __init__(self, otf: "Otf", otf_client: OtfClient):
|
18
|
+
def __init__(self, otf: "Otf", otf_client: "OtfClient"):
|
19
19
|
"""Initialize the Studio API client.
|
20
20
|
|
21
21
|
Args:
|
@@ -25,6 +25,51 @@ class StudioApi:
|
|
25
25
|
self.otf = otf
|
26
26
|
self.client = StudioClient(otf_client)
|
27
27
|
|
28
|
+
def _get_all_studios(self) -> list[models.StudioDetail]:
|
29
|
+
"""Gets all studios. Marked as private to avoid random users calling it.
|
30
|
+
|
31
|
+
Useful for testing and validating models.
|
32
|
+
|
33
|
+
Returns:
|
34
|
+
list[StudioDetail]: List of studios that match the search criteria.
|
35
|
+
"""
|
36
|
+
# long/lat being None will cause the endpoint to return all studios
|
37
|
+
results = self.client.get_studios_by_geo(None, None)
|
38
|
+
|
39
|
+
studios: list[models.StudioDetail] = []
|
40
|
+
for studio in results:
|
41
|
+
try:
|
42
|
+
studios.append(models.StudioDetail.create(**studio, api=self.otf))
|
43
|
+
except ValueError as e:
|
44
|
+
LOGGER.error(f"Failed to create StudioDetail for studio {studio}: {e}")
|
45
|
+
continue
|
46
|
+
|
47
|
+
return studios
|
48
|
+
|
49
|
+
def _get_studio_detail_threaded(self, studio_uuids: list[str]) -> dict[str, models.StudioDetail]:
|
50
|
+
"""Get detailed information about multiple studios in a threaded manner.
|
51
|
+
|
52
|
+
This is used to improve performance when fetching details for multiple studios at once.
|
53
|
+
This method is on the Otf class because StudioDetail is a model that requires the API instance.
|
54
|
+
|
55
|
+
Args:
|
56
|
+
studio_uuids (list[str]): List of studio UUIDs to get details for.
|
57
|
+
|
58
|
+
Returns:
|
59
|
+
dict[str, StudioDetail]: A dictionary mapping studio UUIDs to their detailed information.
|
60
|
+
"""
|
61
|
+
studio_dicts = self.client.get_studio_detail_threaded(studio_uuids)
|
62
|
+
|
63
|
+
studios: dict[str, models.StudioDetail] = {}
|
64
|
+
for studio_uuid, studio in studio_dicts.items():
|
65
|
+
try:
|
66
|
+
studios[studio_uuid] = models.StudioDetail.create(**studio, api=self.otf)
|
67
|
+
except ValueError as e:
|
68
|
+
LOGGER.error(f"Failed to create StudioDetail for studio {studio_uuid}: {e}")
|
69
|
+
continue
|
70
|
+
|
71
|
+
return studios
|
72
|
+
|
28
73
|
def get_favorite_studios(self) -> list[models.StudioDetail]:
|
29
74
|
"""Get the member's favorite studios.
|
30
75
|
|
@@ -159,48 +204,3 @@ class StudioApi:
|
|
159
204
|
continue
|
160
205
|
|
161
206
|
return studios
|
162
|
-
|
163
|
-
def _get_all_studios(self) -> list[models.StudioDetail]:
|
164
|
-
"""Gets all studios. Marked as private to avoid random users calling it.
|
165
|
-
|
166
|
-
Useful for testing and validating models.
|
167
|
-
|
168
|
-
Returns:
|
169
|
-
list[StudioDetail]: List of studios that match the search criteria.
|
170
|
-
"""
|
171
|
-
# long/lat being None will cause the endpoint to return all studios
|
172
|
-
results = self.client.get_studios_by_geo(None, None)
|
173
|
-
|
174
|
-
studios: list[models.StudioDetail] = []
|
175
|
-
for studio in results:
|
176
|
-
try:
|
177
|
-
studios.append(models.StudioDetail.create(**studio, api=self.otf))
|
178
|
-
except ValueError as e:
|
179
|
-
LOGGER.error(f"Failed to create StudioDetail for studio {studio}: {e}")
|
180
|
-
continue
|
181
|
-
|
182
|
-
return studios
|
183
|
-
|
184
|
-
def _get_studio_detail_threaded(self, studio_uuids: list[str]) -> dict[str, models.StudioDetail]:
|
185
|
-
"""Get detailed information about multiple studios in a threaded manner.
|
186
|
-
|
187
|
-
This is used to improve performance when fetching details for multiple studios at once.
|
188
|
-
This method is on the Otf class because StudioDetail is a model that requires the API instance.
|
189
|
-
|
190
|
-
Args:
|
191
|
-
studio_uuids (list[str]): List of studio UUIDs to get details for.
|
192
|
-
|
193
|
-
Returns:
|
194
|
-
dict[str, StudioDetail]: A dictionary mapping studio UUIDs to their detailed information.
|
195
|
-
"""
|
196
|
-
studio_dicts = self.client.get_studio_detail_threaded(studio_uuids)
|
197
|
-
|
198
|
-
studios: dict[str, models.StudioDetail] = {}
|
199
|
-
for studio_uuid, studio in studio_dicts.items():
|
200
|
-
try:
|
201
|
-
studios[studio_uuid] = models.StudioDetail.create(**studio, api=self.otf)
|
202
|
-
except ValueError as e:
|
203
|
-
LOGGER.error(f"Failed to create StudioDetail for studio {studio_uuid}: {e}")
|
204
|
-
continue
|
205
|
-
|
206
|
-
return studios
|
@@ -9,18 +9,18 @@ import pendulum
|
|
9
9
|
from otf_api import exceptions as exc
|
10
10
|
from otf_api import models
|
11
11
|
from otf_api.api import utils
|
12
|
-
from otf_api.api.client import OtfClient
|
13
12
|
|
14
13
|
from .workout_client import WorkoutClient
|
15
14
|
|
16
15
|
if typing.TYPE_CHECKING:
|
17
16
|
from otf_api import Otf
|
17
|
+
from otf_api.api.client import OtfClient
|
18
18
|
|
19
19
|
LOGGER = getLogger(__name__)
|
20
20
|
|
21
21
|
|
22
22
|
class WorkoutApi:
|
23
|
-
def __init__(self, otf: "Otf", otf_client: OtfClient):
|
23
|
+
def __init__(self, otf: "Otf", otf_client: "OtfClient"):
|
24
24
|
"""Initialize the Workout API client.
|
25
25
|
|
26
26
|
Args:
|
@@ -262,7 +262,7 @@ class WorkoutApi:
|
|
262
262
|
bookings = self.otf.bookings.get_bookings_new(
|
263
263
|
start_dtme, end_dtme, exclude_cancelled=True, remove_duplicates=True
|
264
264
|
)
|
265
|
-
bookings_dict =
|
265
|
+
bookings_dict = self._filter_bookings_for_workouts(bookings)
|
266
266
|
|
267
267
|
perf_summaries_dict = self.client.get_perf_summaries_threaded(list(bookings_dict.keys()))
|
268
268
|
telemetry_dict = self.client.get_telemetry_threaded(list(perf_summaries_dict.keys()), max_data_points)
|
@@ -279,12 +279,74 @@ class WorkoutApi:
|
|
279
279
|
api=self.otf,
|
280
280
|
)
|
281
281
|
workouts.append(workout)
|
282
|
-
except ValueError
|
283
|
-
LOGGER.
|
284
|
-
|
282
|
+
except ValueError:
|
283
|
+
LOGGER.exception("Failed to create Workout for performance summary %s", perf_id)
|
284
|
+
|
285
|
+
LOGGER.debug("Returning %d workouts", len(workouts))
|
285
286
|
|
286
287
|
return workouts
|
287
288
|
|
289
|
+
def _filter_bookings_for_workouts(self, bookings: list[models.BookingV2]) -> dict[str, models.BookingV2]:
|
290
|
+
"""Filter bookings to only those that have a workout and are not in the future.
|
291
|
+
|
292
|
+
This is being pulled out of `get_workouts` to add more robust logging and error handling.
|
293
|
+
|
294
|
+
Args:
|
295
|
+
bookings (list[BookingV2]): The list of bookings to filter.
|
296
|
+
|
297
|
+
Returns:
|
298
|
+
dict[str, BookingV2]: A dictionary mapping workout IDs to bookings that have workouts.
|
299
|
+
"""
|
300
|
+
future_bookings = [b for b in bookings if b.starts_at and b.starts_at > pendulum.now().naive()]
|
301
|
+
missing_workouts = [b for b in bookings if not b.workout and b not in future_bookings]
|
302
|
+
LOGGER.debug("Found %d future bookings and %d missing workouts", len(future_bookings), len(missing_workouts))
|
303
|
+
|
304
|
+
if future_bookings:
|
305
|
+
for booking in future_bookings:
|
306
|
+
LOGGER.warning(
|
307
|
+
"Booking %s for class '%s' (class_uuid=%s) is in the future, filtering out.",
|
308
|
+
booking.booking_id,
|
309
|
+
booking.otf_class,
|
310
|
+
booking.class_uuid or "Unknown",
|
311
|
+
)
|
312
|
+
|
313
|
+
if missing_workouts:
|
314
|
+
for booking in missing_workouts:
|
315
|
+
LOGGER.warning(
|
316
|
+
"Booking %s for class '%s' (class_uuid=%s) is missing a workout, filtering out.",
|
317
|
+
booking.booking_id,
|
318
|
+
booking.otf_class,
|
319
|
+
booking.class_uuid or "Unknown",
|
320
|
+
)
|
321
|
+
|
322
|
+
bookings_dict = {
|
323
|
+
b.workout.id: b for b in bookings if b.workout and b not in future_bookings and b not in missing_workouts
|
324
|
+
}
|
325
|
+
|
326
|
+
LOGGER.debug("Filtered bookings to %d valid bookings for workouts mapping", len(bookings_dict))
|
327
|
+
|
328
|
+
return bookings_dict
|
329
|
+
|
330
|
+
def get_lifetime_workouts(self) -> list[models.Workout]:
|
331
|
+
"""Get the member's lifetime workouts.
|
332
|
+
|
333
|
+
This is a convenience method that calls `get_workouts` with no date range.
|
334
|
+
|
335
|
+
Returns:
|
336
|
+
list[Workout]: The member's lifetime workouts.
|
337
|
+
|
338
|
+
Raises:
|
339
|
+
ResourceNotFoundError: If the member's created date is not set, as we cannot determine the start date for
|
340
|
+
the workouts.
|
341
|
+
"""
|
342
|
+
if not self.otf.member.created_date:
|
343
|
+
raise exc.ResourceNotFoundError("Member created date not found, cannot get lifetime workouts.")
|
344
|
+
|
345
|
+
start_date = self.otf.member.created_date.date()
|
346
|
+
end_date = pendulum.tomorrow().date()
|
347
|
+
|
348
|
+
return self.get_workouts(start_date=start_date, end_date=end_date)
|
349
|
+
|
288
350
|
def rate_class_from_workout(
|
289
351
|
self,
|
290
352
|
workout: models.Workout,
|
@@ -1,9 +1,12 @@
|
|
1
1
|
from concurrent.futures import ThreadPoolExecutor
|
2
2
|
from functools import partial
|
3
|
+
from logging import getLogger
|
3
4
|
from typing import Any
|
4
5
|
|
5
6
|
from otf_api.api.client import API_IO_BASE_URL, API_TELEMETRY_BASE_URL, CACHE, OtfClient
|
6
7
|
|
8
|
+
LOGGER = getLogger(__name__)
|
9
|
+
|
7
10
|
|
8
11
|
class WorkoutClient:
|
9
12
|
"""Client for retrieving workout and performance data from the OTF API.
|
@@ -98,7 +101,12 @@ class WorkoutClient:
|
|
98
101
|
dict[str, str | None]: A dictionary mapping performance summary IDs to class UUIDs.
|
99
102
|
"""
|
100
103
|
perf_summaries = self.get_performance_summaries()["items"]
|
101
|
-
|
104
|
+
LOGGER.debug("Retrieved %d performance summaries for mapping", len(perf_summaries))
|
105
|
+
|
106
|
+
perf_summary_dict = {item["id"]: item["class"].get("ot_base_class_uuid") for item in perf_summaries}
|
107
|
+
|
108
|
+
LOGGER.debug("Created performance summary to class UUID mapping with %d entries", len(perf_summary_dict))
|
109
|
+
return perf_summary_dict
|
102
110
|
|
103
111
|
def get_perf_summaries_threaded(self, performance_summary_ids: list[str]) -> dict[str, dict[str, Any]]:
|
104
112
|
"""Get performance summaries in a ThreadPoolExecutor, to speed up the process.
|
@@ -112,7 +120,13 @@ class WorkoutClient:
|
|
112
120
|
with ThreadPoolExecutor(max_workers=10) as pool:
|
113
121
|
perf_summaries = pool.map(self.get_performance_summary, performance_summary_ids)
|
114
122
|
|
115
|
-
|
123
|
+
perf_summaries_list = list(perf_summaries)
|
124
|
+
LOGGER.debug("Retrieved %d performance summaries in threaded mode", len(perf_summaries_list))
|
125
|
+
|
126
|
+
perf_summaries_dict = {perf_summary["id"]: perf_summary for perf_summary in perf_summaries_list}
|
127
|
+
|
128
|
+
LOGGER.debug("Returning %d performance summaries", len(perf_summaries_dict))
|
129
|
+
|
116
130
|
return perf_summaries_dict
|
117
131
|
|
118
132
|
def get_telemetry_threaded(
|
@@ -130,7 +144,15 @@ class WorkoutClient:
|
|
130
144
|
partial_fn = partial(self.get_telemetry, max_data_points=max_data_points)
|
131
145
|
with ThreadPoolExecutor(max_workers=10) as pool:
|
132
146
|
telemetry = pool.map(partial_fn, performance_summary_ids)
|
133
|
-
|
147
|
+
|
148
|
+
telemetry_list = list(telemetry)
|
149
|
+
|
150
|
+
LOGGER.debug("Retrieved %d telemetry records in threaded mode", len(telemetry_list))
|
151
|
+
|
152
|
+
telemetry_dict = {perf_summary["classHistoryUuid"]: perf_summary for perf_summary in telemetry_list}
|
153
|
+
|
154
|
+
LOGGER.debug("Returning %d telemetry records", len(telemetry_dict))
|
155
|
+
|
134
156
|
return telemetry_dict
|
135
157
|
|
136
158
|
def get_aspire_data(self, datetime: str | None, unit: str | None) -> dict:
|
@@ -1,9 +1,12 @@
|
|
1
|
-
from
|
1
|
+
from logging import getLogger
|
2
|
+
from typing import Any
|
2
3
|
|
3
|
-
from pydantic import AliasPath, Field
|
4
|
+
from pydantic import AliasPath, Field
|
4
5
|
|
5
6
|
from otf_api.models.base import OtfItemBase
|
6
7
|
|
8
|
+
LOGGER = getLogger(__name__)
|
9
|
+
|
7
10
|
|
8
11
|
class ZoneTimeMinutes(OtfItemBase):
|
9
12
|
gray: int
|
@@ -22,41 +25,17 @@ class HeartRate(OtfItemBase):
|
|
22
25
|
|
23
26
|
|
24
27
|
class PerformanceMetric(OtfItemBase):
|
25
|
-
display_value:
|
28
|
+
display_value: Any
|
26
29
|
display_unit: str
|
27
|
-
metric_value: float
|
30
|
+
metric_value: float | int = Field(
|
31
|
+
coerce_numbers_to_str=True,
|
32
|
+
description="The raw value of the metric, as a float or int. When time this reflects seconds.",
|
33
|
+
)
|
28
34
|
|
29
35
|
def __str__(self) -> str:
|
30
36
|
"""Return a string representation of the PerformanceMetric."""
|
31
37
|
return f"{self.display_value} {self.display_unit}"
|
32
38
|
|
33
|
-
@field_validator("display_value", mode="before")
|
34
|
-
@classmethod
|
35
|
-
def convert_to_time_format(cls, value: str | None | float | int) -> time | float | None:
|
36
|
-
"""Convert display_value to a time object if it is in the format of HH:MM:SS or MM:SS.
|
37
|
-
|
38
|
-
Args:
|
39
|
-
value (str | None | float | int): The value to convert.
|
40
|
-
|
41
|
-
Returns:
|
42
|
-
time | float: The converted value, or the original value if it is not in the expected format.
|
43
|
-
"""
|
44
|
-
if not value:
|
45
|
-
return None
|
46
|
-
|
47
|
-
if isinstance(value, float | int):
|
48
|
-
return value
|
49
|
-
|
50
|
-
if isinstance(value, str) and ":" in value:
|
51
|
-
if value.count(":") == 1:
|
52
|
-
minutes, seconds = value.split(":")
|
53
|
-
return time(minute=int(minutes), second=int(seconds))
|
54
|
-
if value.count(":") == 2:
|
55
|
-
hours, minutes, seconds = value.split(":")
|
56
|
-
return time(hour=int(hours), minute=int(minutes), second=int(seconds))
|
57
|
-
|
58
|
-
return value # type: ignore
|
59
|
-
|
60
39
|
|
61
40
|
class BaseEquipment(OtfItemBase):
|
62
41
|
avg_pace: PerformanceMetric
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: otf-api
|
3
|
-
Version: 0.15.
|
3
|
+
Version: 0.15.2
|
4
4
|
Summary: Python OrangeTheory Fitness API Client
|
5
5
|
Author-email: Jessica Smith <j.smith.git1@gmail.com>
|
6
6
|
License-Expression: MIT
|
@@ -29,6 +29,7 @@ Requires-Dist: pendulum>=3.1.0
|
|
29
29
|
Requires-Dist: diskcache>=5.6.3
|
30
30
|
Requires-Dist: platformdirs>=4.3.6
|
31
31
|
Requires-Dist: packaging>=24.2
|
32
|
+
Requires-Dist: coloredlogs>=15.0.1
|
32
33
|
Dynamic: license-file
|
33
34
|
|
34
35
|
Simple API client for interacting with the OrangeTheory Fitness APIs.
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
{otf_api-0.15.1 → otf_api-0.15.2}/src/otf_api/models/workouts/out_of_studio_workout_history.py
RENAMED
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|