From c4291070b53fb2d03cfdd5b908b18ed9424858b6 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 27 Jul 2025 09:24:27 -0500 Subject: [PATCH 1/3] update logic to allow returning workouts even if HR monitor data missing --- src/otf_api/api/workouts/workout_api.py | 42 ++++++++++--------------- src/otf_api/models/workouts/workout.py | 13 +++++--- 2 files changed, 24 insertions(+), 31 deletions(-) diff --git a/src/otf_api/api/workouts/workout_api.py b/src/otf_api/api/workouts/workout_api.py index e63159e..4c91b3f 100644 --- a/src/otf_api/api/workouts/workout_api.py +++ b/src/otf_api/api/workouts/workout_api.py @@ -262,31 +262,32 @@ def get_workouts( bookings = self.otf.bookings.get_bookings_new( start_dtme, end_dtme, exclude_cancelled=True, remove_duplicates=True ) - bookings_dict = self._filter_bookings_for_workouts(bookings) + filtered_bookings = self._filter_bookings_for_workouts(bookings) + bookings_list = [(b, b.workout.id if b.workout else None) for b in filtered_bookings] - perf_summaries_dict = self.client.get_perf_summaries_threaded(list(bookings_dict.keys())) + workout_ids = [b.workout.id for b in filtered_bookings if b.workout] + perf_summaries_dict = self.client.get_perf_summaries_threaded(workout_ids) telemetry_dict = self.client.get_telemetry_threaded(list(perf_summaries_dict.keys()), max_data_points) perf_summary_to_class_uuid_map = self.client.get_perf_summary_to_class_uuid_mapping() workouts: list[models.Workout] = [] - for perf_id, perf_summary in perf_summaries_dict.items(): + for booking, perf_summary_id in bookings_list: try: + perf_summary = perf_summaries_dict.get(perf_summary_id, {}) if perf_summary_id else {} + telemetry = telemetry_dict.get(perf_summary_id, None) if perf_summary_id else None + class_uuid = perf_summary_to_class_uuid_map.get(perf_summary_id, None) if perf_summary_id else None workout = models.Workout.create( - **perf_summary, - v2_booking=bookings_dict[perf_id], - telemetry=telemetry_dict.get(perf_id), - class_uuid=perf_summary_to_class_uuid_map.get(perf_id), - api=self.otf, + **perf_summary, v2_booking=booking, telemetry=telemetry, class_uuid=class_uuid, api=self.otf ) workouts.append(workout) except ValueError: - LOGGER.exception("Failed to create Workout for performance summary %s", perf_id) + LOGGER.exception("Failed to create Workout for performance summary %s", perf_summary_id) LOGGER.debug("Returning %d workouts", len(workouts)) return workouts - def _filter_bookings_for_workouts(self, bookings: list[models.BookingV2]) -> dict[str, models.BookingV2]: + def _filter_bookings_for_workouts(self, bookings: list[models.BookingV2]) -> list[models.BookingV2]: """Filter bookings to only those that have a workout and are not in the future. This is being pulled out of `get_workouts` to add more robust logging and error handling. @@ -298,8 +299,8 @@ def _filter_bookings_for_workouts(self, bookings: list[models.BookingV2]) -> dic dict[str, BookingV2]: A dictionary mapping workout IDs to bookings that have workouts. """ future_bookings = [b for b in bookings if b.starts_at and b.starts_at > pendulum.now().naive()] - missing_workouts = [b for b in bookings if not b.workout and b not in future_bookings] - LOGGER.debug("Found %d future bookings and %d missing workouts", len(future_bookings), len(missing_workouts)) + # missing_workouts = [b for b in bookings if not b.workout and b not in future_bookings] + LOGGER.debug("Found %d future bookings", len(future_bookings)) if future_bookings: for booking in future_bookings: @@ -310,22 +311,11 @@ def _filter_bookings_for_workouts(self, bookings: list[models.BookingV2]) -> dic booking.class_uuid or "Unknown", ) - if missing_workouts: - for booking in missing_workouts: - LOGGER.warning( - "Booking %s for class '%s' (class_uuid=%s) is missing a workout, filtering out.", - booking.booking_id, - booking.otf_class, - booking.class_uuid or "Unknown", - ) - - bookings_dict = { - b.workout.id: b for b in bookings if b.workout and b not in future_bookings and b not in missing_workouts - } + filtered_bookings = [b for b in bookings if b not in future_bookings] - LOGGER.debug("Filtered bookings to %d valid bookings for workouts mapping", len(bookings_dict)) + LOGGER.debug("Filtered bookings to %d valid bookings for workouts mapping", len(filtered_bookings)) - return bookings_dict + return filtered_bookings def get_lifetime_workouts(self) -> list[models.Workout]: """Get the member's lifetime workouts. diff --git a/src/otf_api/models/workouts/workout.py b/src/otf_api/models/workouts/workout.py index 00a4bc4..1d593f7 100644 --- a/src/otf_api/models/workouts/workout.py +++ b/src/otf_api/models/workouts/workout.py @@ -3,7 +3,7 @@ from pydantic import AliasPath, Field from otf_api.models.base import OtfItemBase -from otf_api.models.bookings import BookingV2, BookingV2Class, BookingV2Studio, BookingV2Workout, Rating +from otf_api.models.bookings import BookingV2, BookingV2Class, BookingV2Studio, Rating from otf_api.models.mixins import ApiMixin from otf_api.models.workouts import HeartRate, Rower, Telemetry, Treadmill, ZoneTimeMinutes @@ -18,9 +18,11 @@ class Workout(ApiMixin, OtfItemBase): """ performance_summary_id: str = Field( - ..., validation_alias="id", description="Unique identifier for this performance summary" + default="unknown", validation_alias="id", description="Unique identifier for this performance summary" + ) + class_history_uuid: str = Field( + default="unknown", validation_alias="id", description="Same as performance_summary_id" ) - class_history_uuid: str = Field(..., validation_alias="id", description="Same as performance_summary_id") booking_id: str = Field(..., description="The booking id for the new bookings endpoint.") class_uuid: str | None = Field( None, description="Used by the ratings endpoint - seems to fall off after a few months" @@ -56,7 +58,6 @@ def __init__(self, **data): otf_class = v2_booking.otf_class v2_workout = v2_booking.workout assert isinstance(otf_class, BookingV2Class), "otf_class must be an instance of BookingV2Class" - assert isinstance(v2_workout, BookingV2Workout), "v2_workout must be an instance of BookingV2Workout" data["otf_class"] = otf_class data["studio"] = otf_class.studio @@ -64,10 +65,12 @@ def __init__(self, **data): data["ratable"] = v2_booking.ratable # this seems to be more accurate data["booking_id"] = v2_booking.booking_id - data["active_time_seconds"] = v2_workout.active_time_seconds data["class_rating"] = v2_booking.class_rating data["coach_rating"] = v2_booking.coach_rating + if v2_workout: + data["active_time_seconds"] = v2_workout.active_time_seconds + telemetry: dict[str, Any] | None = data.get("telemetry") if telemetry and "maxHr" in telemetry: # max_hr seems to be left out of the heart rate data - it has peak_hr but they do not match From 23145b268eaa8252bbc086be6d8039471edf1902 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 27 Jul 2025 11:19:28 -0500 Subject: [PATCH 2/3] remove _filter_bookings_for_workouts, filter in place --- src/otf_api/api/workouts/workout_api.py | 32 +------------------------ 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/src/otf_api/api/workouts/workout_api.py b/src/otf_api/api/workouts/workout_api.py index 4c91b3f..b3a7796 100644 --- a/src/otf_api/api/workouts/workout_api.py +++ b/src/otf_api/api/workouts/workout_api.py @@ -262,7 +262,7 @@ def get_workouts( bookings = self.otf.bookings.get_bookings_new( start_dtme, end_dtme, exclude_cancelled=True, remove_duplicates=True ) - filtered_bookings = self._filter_bookings_for_workouts(bookings) + filtered_bookings = [b for b in bookings if not (b.starts_at and b.starts_at > pendulum.now().naive())] bookings_list = [(b, b.workout.id if b.workout else None) for b in filtered_bookings] workout_ids = [b.workout.id for b in filtered_bookings if b.workout] @@ -287,36 +287,6 @@ def get_workouts( return workouts - def _filter_bookings_for_workouts(self, bookings: list[models.BookingV2]) -> list[models.BookingV2]: - """Filter bookings to only those that have a workout and are not in the future. - - This is being pulled out of `get_workouts` to add more robust logging and error handling. - - Args: - bookings (list[BookingV2]): The list of bookings to filter. - - Returns: - dict[str, BookingV2]: A dictionary mapping workout IDs to bookings that have workouts. - """ - future_bookings = [b for b in bookings if b.starts_at and b.starts_at > pendulum.now().naive()] - # missing_workouts = [b for b in bookings if not b.workout and b not in future_bookings] - LOGGER.debug("Found %d future bookings", len(future_bookings)) - - if future_bookings: - for booking in future_bookings: - LOGGER.warning( - "Booking %s for class '%s' (class_uuid=%s) is in the future, filtering out.", - booking.booking_id, - booking.otf_class, - booking.class_uuid or "Unknown", - ) - - filtered_bookings = [b for b in bookings if b not in future_bookings] - - LOGGER.debug("Filtered bookings to %d valid bookings for workouts mapping", len(filtered_bookings)) - - return filtered_bookings - def get_lifetime_workouts(self) -> list[models.Workout]: """Get the member's lifetime workouts. From 42ec6ba6f28551524720ef401d69563ce859c001 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Sun, 27 Jul 2025 11:19:43 -0500 Subject: [PATCH 3/3] bump patch version --- .bumpversion.toml | 2 +- pyproject.toml | 2 +- source/conf.py | 2 +- src/otf_api/__init__.py | 2 +- uv.lock | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 3be582c..82691b4 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "0.15.2" +current_version = "0.15.3" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(?:-(?Prc)(?P0|[1-9]\\d*))?" diff --git a/pyproject.toml b/pyproject.toml index c33fe56..7ac37b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "otf-api" -version = "0.15.2" +version = "0.15.3" description = "Python OrangeTheory Fitness API Client" authors = [{ name = "Jessica Smith", email = "j.smith.git1@gmail.com" }] requires-python = ">=3.11" diff --git a/source/conf.py b/source/conf.py index c07eb49..49aff39 100644 --- a/source/conf.py +++ b/source/conf.py @@ -14,7 +14,7 @@ project = "OrangeTheory API" copyright = "2025, Jessica Smith" author = "Jessica Smith" -release = "0.15.2" +release = "0.15.3" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/src/otf_api/__init__.py b/src/otf_api/__init__.py index 8a076b3..cfa784f 100644 --- a/src/otf_api/__init__.py +++ b/src/otf_api/__init__.py @@ -47,7 +47,7 @@ def _setup_logging() -> None: _setup_logging() -__version__ = "0.15.2" +__version__ = "0.15.3" __all__ = ["Otf", "OtfUser", "models"] diff --git a/uv.lock b/uv.lock index 33e1e28..c2f69b6 100644 --- a/uv.lock +++ b/uv.lock @@ -893,7 +893,7 @@ wheels = [ [[package]] name = "otf-api" -version = "0.15.2" +version = "0.15.3" source = { editable = "." } dependencies = [ { name = "attrs" },