Skip to content

Commit e16682b

Browse files
authored
Merge pull request #98 from NodeJSmith/feature/add_get_class_from_booking
Feature/add get class from booking
2 parents d3f20c6 + df50e5f commit e16682b

File tree

12 files changed

+193
-53
lines changed

12 files changed

+193
-53
lines changed

.bumpversion.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[tool.bumpversion]
2-
current_version = "0.14.1"
2+
current_version = "0.15.0"
33

44
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)(?:-(?P<rc_l>rc)(?P<rc>0|[1-9]\\d*))?"
55

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "otf-api"
3-
version = "0.14.1"
3+
version = "0.15.0"
44
description = "Python OrangeTheory Fitness API Client"
55
authors = [{ name = "Jessica Smith", email = "[email protected]" }]
66
requires-python = ">=3.11"

source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
project = "OrangeTheory API"
1515
copyright = "2025, Jessica Smith"
1616
author = "Jessica Smith"
17-
release = "0.14.1"
17+
release = "0.15.0"
1818

1919
# -- General configuration ---------------------------------------------------
2020
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

src/otf_api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def _setup_logging() -> None:
3636

3737
_setup_logging()
3838

39-
__version__ = "0.14.1"
39+
__version__ = "0.15.0"
4040

4141

4242
__all__ = ["Otf", "OtfUser", "models"]

src/otf_api/api/bookings/booking_api.py

Lines changed: 121 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,37 @@ def get_bookings_new(
8080
ends_before=end_date, starts_after=start_date, include_canceled=include_canceled, expand=expand
8181
)
8282

83-
results = [models.BookingV2.create(**b, api=self.otf) for b in bookings_resp]
83+
# filter out bookings with ids that start with "no-booking-id"
84+
# no idea what these are, but I am praying for the poor sap stuck with maintaining OTF's data model
85+
results: list[models.BookingV2] = []
86+
87+
for b in bookings_resp:
88+
if not b.get("id", "").startswith("no-booking-id"):
89+
try:
90+
results.append(models.BookingV2.create(**b, api=self.otf))
91+
except ValueError as e:
92+
LOGGER.warning(f"Failed to create BookingV2 from response: {e}. Booking data:\n{b}")
93+
continue
8494

8595
if not remove_duplicates:
8696
return results
8797

98+
results = self._deduplicate_bookings(results, exclude_cancelled=exclude_cancelled)
99+
100+
return results
101+
102+
def _deduplicate_bookings(
103+
self, results: list[models.BookingV2], exclude_cancelled: bool = True
104+
) -> list[models.BookingV2]:
105+
"""Deduplicate bookings by class_id, keeping the most recent booking.
106+
107+
Args:
108+
results (list[BookingV2]): The list of bookings to deduplicate.
109+
exclude_cancelled (bool): If True, will not include cancelled bookings in the results.
110+
111+
Returns:
112+
list[BookingV2]: The deduplicated list of bookings.
113+
"""
88114
# remove duplicates by class_id, keeping the one with the most recent updated_at timestamp
89115
seen_classes: dict[str, models.BookingV2] = {}
90116

@@ -188,7 +214,11 @@ def get_classes(
188214
for c in classes_resp:
189215
c["studio"] = studio_dict[c["studio"]["id"]] # the one (?) place where ID actually means UUID
190216
c["is_home_studio"] = c["studio"].studio_uuid == self.otf.home_studio_uuid
191-
classes.append(models.OtfClass.create(**c, api=self.otf))
217+
try:
218+
classes.append(models.OtfClass.create(**c, api=self.otf))
219+
except ValueError as e:
220+
LOGGER.warning(f"Failed to create OtfClass from response: {e}. Class data:\n{c}")
221+
continue
192222

193223
# additional data filtering and enrichment
194224

@@ -240,7 +270,7 @@ def get_booking_from_class(self, otf_class: str | models.OtfClass) -> models.Boo
240270
Booking: The booking.
241271
242272
Raises:
243-
BookingNotFoundError: If the booking does not exist.
273+
ResourceNotFoundError: If the booking does not exist.
244274
ValueError: If class_uuid is None or empty string.
245275
"""
246276
class_uuid = utils.get_class_uuid(otf_class)
@@ -250,7 +280,7 @@ def get_booking_from_class(self, otf_class: str | models.OtfClass) -> models.Boo
250280
if booking := next((b for b in all_bookings if b.class_uuid == class_uuid), None):
251281
return booking
252282

253-
raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
283+
raise exc.ResourceNotFoundError(f"Booking for class {class_uuid} not found.")
254284

255285
def get_booking_from_class_new(self, otf_class: str | models.OtfClass | models.BookingV2Class) -> models.BookingV2:
256286
"""Get a specific booking by class_uuid or OtfClass object.
@@ -262,7 +292,7 @@ def get_booking_from_class_new(self, otf_class: str | models.OtfClass | models.B
262292
BookingV2: The booking.
263293
264294
Raises:
265-
BookingNotFoundError: If the booking does not exist.
295+
ResourceNotFoundError: If the booking does not exist.
266296
ValueError: If class_uuid is None or empty string.
267297
"""
268298
class_uuid = utils.get_class_uuid(otf_class)
@@ -272,7 +302,78 @@ def get_booking_from_class_new(self, otf_class: str | models.OtfClass | models.B
272302
if booking := next((b for b in all_bookings if b.class_uuid == class_uuid), None):
273303
return booking
274304

275-
raise exc.BookingNotFoundError(f"Booking for class {class_uuid} not found.")
305+
raise exc.ResourceNotFoundError(f"Booking for class {class_uuid} not found.")
306+
307+
def get_class_from_booking(self, booking: models.Booking | models.BookingV2) -> models.OtfClass:
308+
"""Get the class details from a Booking or BookingV2 object.
309+
310+
Args:
311+
booking (Booking | BookingV2): The booking to get the class details from.
312+
313+
Returns:
314+
OtfClass: The class details.
315+
316+
Raises:
317+
ValueError: If the booking does not have a class_id.
318+
"""
319+
if isinstance(booking, models.BookingV2):
320+
return self.get_class_from_booking_new(booking)
321+
322+
if not booking.otf_class.class_uuid:
323+
raise ValueError("Booking does not have a class_uuid")
324+
325+
if not booking.otf_class.studio:
326+
LOGGER.warning("Booking does not have a studio, will attempt to use the home studio to get class details.")
327+
studio_uuid = self.otf.home_studio_uuid
328+
else:
329+
studio_uuid = booking.otf_class.studio.studio_uuid
330+
331+
classes = self.otf.bookings.get_classes(
332+
start_date=booking.starts_at.date(),
333+
end_date=booking.starts_at.date(),
334+
studio_uuids=[studio_uuid],
335+
)
336+
if classes:
337+
otf_class = next((c for c in classes if c.class_uuid == booking.otf_class.class_uuid), None)
338+
if otf_class:
339+
return otf_class
340+
341+
raise exc.ResourceNotFoundError(
342+
f"Class for booking {booking.otf_class.name} ({booking.booking_uuid}) not found."
343+
)
344+
345+
def get_class_from_booking_new(self, booking: models.BookingV2) -> models.OtfClass:
346+
"""Get the class details from a BookingV2 object.
347+
348+
Args:
349+
booking (BookingV2): The booking to get the class details from.
350+
351+
Returns:
352+
OtfClass: The class details.
353+
354+
Raises:
355+
ValueError: If the booking does not have a class_id.
356+
"""
357+
if not booking.otf_class.class_id:
358+
raise ValueError("Booking does not have a class_id")
359+
360+
if not booking.otf_class.studio:
361+
LOGGER.warning("Booking does not have a studio, will attempt to use the home studio to get class details.")
362+
studio_uuid = self.otf.home_studio_uuid
363+
else:
364+
studio_uuid = booking.otf_class.studio.studio_uuid
365+
366+
classes = self.otf.bookings.get_classes(
367+
start_date=booking.starts_at.date(),
368+
end_date=booking.starts_at.date(),
369+
studio_uuids=[studio_uuid],
370+
)
371+
if classes:
372+
otf_class = next((c for c in classes if c.class_id == booking.otf_class.class_id), None)
373+
if otf_class:
374+
return otf_class
375+
376+
raise exc.ResourceNotFoundError(f"Class for booking {booking.otf_class.name} ({booking.booking_id}) not found.")
276377

277378
def book_class(self, otf_class: str | models.OtfClass) -> models.Booking:
278379
"""Book a class by providing either the class_uuid or the OtfClass object.
@@ -287,7 +388,7 @@ def book_class(self, otf_class: str | models.OtfClass) -> models.Booking:
287388
AlreadyBookedError: If the class is already booked.
288389
OutsideSchedulingWindowError: If the class is outside the scheduling window.
289390
ValueError: If class_uuid is None or empty string.
290-
OtfException: If there is an error booking the class.
391+
OtfError: If there is an error booking the class.
291392
"""
292393
class_uuid = utils.get_class_uuid(otf_class)
293394

@@ -297,7 +398,7 @@ def book_class(self, otf_class: str | models.OtfClass) -> models.Booking:
297398
raise exc.AlreadyBookedError(
298399
f"Class {class_uuid} is already booked.", booking_uuid=existing_booking.booking_uuid
299400
)
300-
except exc.BookingNotFoundError:
401+
except exc.ResourceNotFoundError:
301402
pass
302403

303404
if isinstance(otf_class, models.OtfClass):
@@ -328,7 +429,7 @@ def book_class_new(self, class_id: str | models.BookingV2Class) -> models.Bookin
328429
BookingV2: The booking.
329430
330431
Raises:
331-
OtfException: If there is an error booking the class.
432+
OtfError: If there is an error booking the class.
332433
TypeError: If the input is not a string or BookingV2Class.
333434
"""
334435
class_id = utils.get_class_id(class_id)
@@ -349,7 +450,7 @@ def cancel_booking(self, booking: str | models.Booking) -> None:
349450
350451
Raises:
351452
ValueError: If booking_uuid is None or empty string
352-
BookingNotFoundError: If the booking does not exist.
453+
ResourceNotFoundError: If the booking does not exist.
353454
"""
354455
if isinstance(booking, models.BookingV2):
355456
LOGGER.warning("BookingV2 object provided, using the new cancel booking endpoint (`cancel_booking_new`)")
@@ -370,7 +471,7 @@ def cancel_booking_new(self, booking: str | models.BookingV2) -> None:
370471
371472
Raises:
372473
ValueError: If booking_id is None or empty string
373-
BookingNotFoundError: If the booking does not exist.
474+
ResourceNotFoundError: If the booking does not exist.
374475
"""
375476
if isinstance(booking, models.Booking):
376477
LOGGER.warning("Booking object provided, using the old cancel booking endpoint (`cancel_booking`)")
@@ -443,7 +544,15 @@ def get_bookings(
443544
b["class"]["studio"] = studios[b["class"]["studio"]["studioUUId"]]
444545
b["is_home_studio"] = b["class"]["studio"].studio_uuid == self.otf.home_studio_uuid
445546

446-
bookings = [models.Booking.create(**b, api=self.otf) for b in resp]
547+
bookings: list[models.Booking] = []
548+
549+
for b in resp:
550+
try:
551+
bookings.append(models.Booking.create(**b, api=self.otf))
552+
except ValueError as e:
553+
LOGGER.warning(f"Failed to create Booking from response: {e}. Booking data:\n{b}")
554+
continue
555+
447556
bookings = sorted(bookings, key=lambda x: x.otf_class.starts_at)
448557

449558
if exclude_cancelled:

src/otf_api/api/bookings/booking_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def put_class(self, body: dict) -> dict:
5151
Raises:
5252
AlreadyBookedError: If the class is already booked.
5353
OutsideSchedulingWindowError: If the class is outside the scheduling window.
54-
OtfException: If there is an error booking the class.
54+
OtfError: If there is an error booking the class.
5555
"""
5656
return self.client.default_request("PUT", f"/member/members/{self.member_uuid}/bookings", json=body)["data"]
5757

src/otf_api/api/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ def _map_http_error(
152152

153153
if re.match(r"^/member/members/.*?/bookings", path):
154154
if code == "NOT_AUTHORIZED" and error_msg.startswith("This class booking has been cancelled"):
155-
raise exc.BookingNotFoundError("Booking was already cancelled")
155+
raise exc.ResourceNotFoundError("Booking was already cancelled")
156156
if error_code == "603":
157157
raise exc.AlreadyBookedError("Class is already booked")
158158
if error_code == "602":

src/otf_api/api/studios/studio_api.py

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,15 @@ def add_favorite_studio(self, studio_uuids: list[str] | str) -> list[models.Stud
5656

5757
new_faves = resp.get("studios", [])
5858

59-
return [models.StudioDetail.create(**studio, api=self.otf) for studio in new_faves]
59+
studios: list[models.StudioDetail] = []
60+
for studio in new_faves:
61+
try:
62+
studios.append(models.StudioDetail.create(**studio, api=self.otf))
63+
except ValueError as e:
64+
LOGGER.error(f"Failed to create StudioDetail for studio {studio}: {e}")
65+
continue
66+
67+
return studios
6068

6169
def remove_favorite_studio(self, studio_uuids: list[str] | str) -> None:
6270
"""Remove a studio from the member's favorite studios.
@@ -141,7 +149,16 @@ def search_studios_by_geo(
141149
longitude = longitude or self.otf.home_studio.location.longitude
142150

143151
results = self.client.get_studios_by_geo(latitude, longitude, distance)
144-
return [models.StudioDetail.create(**studio, api=self.otf) for studio in results]
152+
153+
studios: list[models.StudioDetail] = []
154+
for studio in results:
155+
try:
156+
studios.append(models.StudioDetail.create(**studio, api=self.otf))
157+
except ValueError as e:
158+
LOGGER.error(f"Failed to create StudioDetail for studio {studio}: {e}")
159+
continue
160+
161+
return studios
145162

146163
def _get_all_studios(self) -> list[models.StudioDetail]:
147164
"""Gets all studios. Marked as private to avoid random users calling it.
@@ -153,7 +170,16 @@ def _get_all_studios(self) -> list[models.StudioDetail]:
153170
"""
154171
# long/lat being None will cause the endpoint to return all studios
155172
results = self.client.get_studios_by_geo(None, None)
156-
return [models.StudioDetail.create(**studio, api=self.otf) for studio in results]
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
157183

158184
def _get_studio_detail_threaded(self, studio_uuids: list[str]) -> dict[str, models.StudioDetail]:
159185
"""Get detailed information about multiple studios in a threaded manner.
@@ -168,7 +194,13 @@ def _get_studio_detail_threaded(self, studio_uuids: list[str]) -> dict[str, mode
168194
dict[str, StudioDetail]: A dictionary mapping studio UUIDs to their detailed information.
169195
"""
170196
studio_dicts = self.client.get_studio_detail_threaded(studio_uuids)
171-
return {
172-
studio_uuid: models.StudioDetail.create(**studio, api=self.otf)
173-
for studio_uuid, studio in studio_dicts.items()
174-
}
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

src/otf_api/api/workouts/workout_api.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,6 @@ def get_workout_from_booking(self, booking: str | models.BookingV2) -> models.Wo
221221
Workout: The member's workout.
222222
223223
Raises:
224-
BookingNotFoundError: If the booking does not exist.
225224
ResourceNotFoundError: If the workout does not exist.
226225
TypeError: If the booking is an old Booking model, as these do not have the necessary fields.
227226
"""
@@ -271,14 +270,18 @@ def get_workouts(
271270

272271
workouts: list[models.Workout] = []
273272
for perf_id, perf_summary in perf_summaries_dict.items():
274-
workout = models.Workout.create(
275-
**perf_summary,
276-
v2_booking=bookings_dict[perf_id],
277-
telemetry=telemetry_dict.get(perf_id),
278-
class_uuid=perf_summary_to_class_uuid_map.get(perf_id),
279-
api=self.otf,
280-
)
281-
workouts.append(workout)
273+
try:
274+
workout = models.Workout.create(
275+
**perf_summary,
276+
v2_booking=bookings_dict[perf_id],
277+
telemetry=telemetry_dict.get(perf_id),
278+
class_uuid=perf_summary_to_class_uuid_map.get(perf_id),
279+
api=self.otf,
280+
)
281+
workouts.append(workout)
282+
except ValueError as e:
283+
LOGGER.error(f"Failed to create Workout for performance summary {perf_id}: {e}")
284+
continue
282285

283286
return workouts
284287

0 commit comments

Comments
 (0)