From e3cb63ad02c79dec83f8cc22be67a469139a414c Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 21 Jan 2025 18:44:47 -0600 Subject: [PATCH 1/4] refactor(api.py): simplify exception handling by passing original exception to OtfRequestError feat(exceptions.py): add original_exception attribute to OtfRequestError for better error context --- src/otf_api/api.py | 7 +++---- src/otf_api/exceptions.py | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index b36cc6e..56ad08a 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -87,9 +87,7 @@ def _do( LOGGER.exception(f"Response: {response.text}") raise except httpx.HTTPStatusError as e: - LOGGER.exception(f"Error making request: {e}") - LOGGER.exception(f"Response: {response.text}") - raise exc.OtfRequestError("Error making request", response=response, request=request) + raise exc.OtfRequestError("Error making request", e, response=response, request=request) except Exception as e: LOGGER.exception(f"Error making request: {e}") raise @@ -110,7 +108,7 @@ def _do( and not (resp["Status"] >= 200 and resp["Status"] <= 299) ): LOGGER.error(f"Error making request: {resp}") - raise exc.OtfRequestError("Error making request", response=response, request=request) + raise exc.OtfRequestError("Error making request", None, response=response, request=request) return resp @@ -966,6 +964,7 @@ def get_performance_summary(self, performance_summary_id: str) -> models.Perform path = f"/v1/performance-summaries/{performance_summary_id}" res = self._performance_summary_request("GET", path) + if res is None: raise exc.ResourceNotFoundError(f"Performance summary {performance_summary_id} not found") diff --git a/src/otf_api/exceptions.py b/src/otf_api/exceptions.py index c70a2b9..5ede8ce 100644 --- a/src/otf_api/exceptions.py +++ b/src/otf_api/exceptions.py @@ -8,11 +8,13 @@ class OtfException(Exception): class OtfRequestError(OtfException): """Raised when an error occurs while making a request to the OTF API.""" + original_exception: Exception response: Response request: Request - def __init__(self, message: str, response: Response, request: Request): + def __init__(self, message: str, original_exception: Exception | None, response: Response, request: Request): super().__init__(message) + self.original_exception = original_exception self.response = response self.request = request From e48b8b57afa6e4b32090cfb529dde2920853139d Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 21 Jan 2025 18:45:35 -0600 Subject: [PATCH 2/4] refactor(workout_examples.py): update method calls to use class_history_uuid instead of id for clarity refactor(performance_summary_detail.py): rename id to class_history_uuid for better semantic meaning and consistency refactor(performance_summary_list.py): rename id to class_history_uuid for improved clarity and consistency fix(performance_summary_detail.py): exclude and hide ratable field due to inaccuracy in reflecting data from PerformanceSummaryEntry --- examples/workout_examples.py | 4 ++-- src/otf_api/models/performance_summary_detail.py | 9 +++++++-- src/otf_api/models/performance_summary_list.py | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/examples/workout_examples.py b/examples/workout_examples.py index 3364c64..f796cd8 100644 --- a/examples/workout_examples.py +++ b/examples/workout_examples.py @@ -80,7 +80,7 @@ def main(): # you can get detailed information about a specific performance summary by calling `get_performance_summary` # which takes a performance_summary_id as an argument - data = otf.get_performance_summary(data_list[0].id) + data = otf.get_performance_summary(data_list[0].class_history_uuid) print(data.model_dump_json(indent=4)) """ @@ -207,7 +207,7 @@ def main(): # telemetry is a detailed record of a specific workout - minute by minute, or more granular if desired # this endpoint takes a class_history_uuid, as well as a number of max data points (default 120) - telemetry = otf.get_telemetry(performance_summary_id=data_list[1].id) + telemetry = otf.get_telemetry(performance_summary_id=data_list[1].class_history_uuid) telemetry.telemetry = telemetry.telemetry[:2] print(telemetry.model_dump_json(indent=4)) diff --git a/src/otf_api/models/performance_summary_detail.py b/src/otf_api/models/performance_summary_detail.py index f9c76ea..f7113e5 100644 --- a/src/otf_api/models/performance_summary_detail.py +++ b/src/otf_api/models/performance_summary_detail.py @@ -68,11 +68,16 @@ class Rower(BaseEquipment): class PerformanceSummaryDetail(OtfItemBase): - id: str + class_history_uuid: str = Field(..., alias="id") class_name: str | None = Field(None, alias=AliasPath("class", "name")) class_starts_at: datetime | None = Field(None, alias=AliasPath("class", "starts_at_local")) - ratable: bool | None = None + ratable: bool | None = Field( + None, + exclude=True, + repr=False, + description="Seems to be inaccurate, not reflecting ratable from `PerformanceSummaryEntry`", + ) calories_burned: int | None = Field(None, alias=AliasPath("details", "calories_burned")) splat_points: int | None = Field(None, alias=AliasPath("details", "splat_points")) step_count: int | None = Field(None, alias=AliasPath("details", "step_count")) diff --git a/src/otf_api/models/performance_summary_list.py b/src/otf_api/models/performance_summary_list.py index 34dfd04..6a17d6b 100644 --- a/src/otf_api/models/performance_summary_list.py +++ b/src/otf_api/models/performance_summary_list.py @@ -33,7 +33,7 @@ class ClassRating(OtfItemBase): class PerformanceSummaryEntry(OtfItemBase): - id: str = Field(..., alias="id") + class_history_uuid: str = Field(..., alias="id") calories_burned: int | None = Field(None, alias=AliasPath("details", "calories_burned")) splat_points: int | None = Field(None, alias=AliasPath("details", "splat_points")) step_count: int | None = Field(None, alias=AliasPath("details", "step_count")) From 1032df1ca0e050617361a9bc1d427fc60a100499 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 21 Jan 2025 18:45:55 -0600 Subject: [PATCH 3/4] feat(api): add class and coach rating functionality Introduce methods to rate classes and coaches based on performance summaries. This includes handling exceptions for already rated or non-ratable classes, enhancing user interaction with the app. docs(examples): add example for rating a class Provide a usage example for the new class rating feature, demonstrating how to rate a class and handle the response. fix(exceptions): add specific exceptions for rating errors Introduce AlreadyRatedError and ClassNotRatableError to handle specific cases when rating classes, improving error handling and clarity. --- examples/workout_examples.py | 46 ++++++++++++ src/otf_api/api.py | 131 +++++++++++++++++++++++++++++++++++ src/otf_api/exceptions.py | 8 +++ 3 files changed, 185 insertions(+) diff --git a/examples/workout_examples.py b/examples/workout_examples.py index f796cd8..9c013ee 100644 --- a/examples/workout_examples.py +++ b/examples/workout_examples.py @@ -78,6 +78,52 @@ def main(): } """ + # if you want to rate a class you can do that with the `rate_class_from_performance_summary` method + # this method takes a performance_summary object, as well as a coach_rating and class_rating + # the ratings are integers from 1 - 3 + # the method returns an updated PerformanceSummaryEntry object + + # if you already rated the class it will return an exception + # likewise if the class is not ratable (seems to be an age cutoff) or if the class is not found + + res = otf.rate_class_from_performance_summary(data_list[0], 3, 3) + print(res.model_dump_json(indent=4)) + + """ + { + "id": "c39e7cde-5e02-4e1a-89e2-d41e8a4653b3", + "calories_burned": 250, + "splat_points": 0, + "step_count": 0, + "active_time_seconds": 2687, + "zone_time_minutes": { + "gray": 17, + "blue": 24, + "green": 4, + "orange": 0, + "red": 0 + }, + "ratable": true, + "otf_class": { + "class_uuid": "23c8ad3e-4257-431c-b5f0-8313d8d82434", + "starts_at": "2025-01-18T10:30:00", + "name": "Tread 50 / Strength 50", + "type": "STRENGTH_50" + }, + "coach": "Bobby", + "coach_rating": { + "id": "18", + "description": "Double Thumbs Up", + "value": 3 + }, + "class_rating": { + "id": "21", + "description": "Double Thumbs Up", + "value": 3 + } + } + """ + # you can get detailed information about a specific performance summary by calling `get_performance_summary` # which takes a performance_summary_id as an argument data = otf.get_performance_summary(data_list[0].class_history_uuid) diff --git a/src/otf_api/api.py b/src/otf_api/api.py index 56ad08a..91faab6 100644 --- a/src/otf_api/api.py +++ b/src/otf_api/api.py @@ -1146,6 +1146,137 @@ def update_member_name(self, first_name: str | None = None, last_name: str | Non return models.MemberDetail(**res["data"]) + def _rate_class( + self, + class_uuid: str, + class_history_uuid: str, + class_rating: Literal[0, 1, 2, 3], + coach_rating: Literal[0, 1, 2, 3], + ) -> models.PerformanceSummaryEntry: + """Rate a class and coach. A simpler method is provided in `rate_class_from_performance_summary`. + + + The class rating must be between 0 and 4. + 0 is the same as dismissing the prompt to rate the class/coach in the app. + 1 through 3 is a range from bad to good. + + Args: + class_uuid (str): The class UUID. + class_history_uuid (str): The performance summary ID. + class_rating (int): The class rating. Must be 0, 1, 2, or 3. + coach_rating (int): The coach rating. Must be 0, 1, 2, or 3. + + Returns: + PerformanceSummaryEntry: The updated performance summary entry. + """ + + # com/orangetheoryfitness/fragment/rating/RateStatus.java + + # we convert these to the new values that the app uses + # mainly because we don't want to cause any issues with the API and/or with OTF corporate + # wondering where the old values are coming from + + COACH_RATING_MAP = {0: 0, 1: 16, 2: 17, 3: 18} + CLASS_RATING_MAP = {0: 0, 1: 19, 2: 20, 3: 21} + + if class_rating not in CLASS_RATING_MAP: + raise ValueError(f"Invalid class rating {class_rating}") + + if coach_rating not in COACH_RATING_MAP: + raise ValueError(f"Invalid coach rating {coach_rating}") + + body_class_rating = CLASS_RATING_MAP[class_rating] + body_coach_rating = COACH_RATING_MAP[coach_rating] + + body = { + "classUUId": class_uuid, + "otBeatClassHistoryUUId": class_history_uuid, + "classRating": body_class_rating, + "coachRating": body_coach_rating, + } + + try: + self._default_request("POST", "/mobile/v1/members/classes/ratings", json=body) + except exc.OtfRequestError as e: + if e.response.status_code == 403: + raise exc.AlreadyRatedError(f"Performance summary {class_history_uuid} is already rated.") from None + raise + + return self._get_performance_summary_entry_from_id(class_history_uuid) + + def _get_performance_summary_entry_from_id(self, class_history_uuid: str) -> models.PerformanceSummaryEntry: + """Get a performance summary entry from the ID. + + This is a helper function to compensate for the fact that a PerformanceSummaryDetail object does not contain + the class UUID, which is required to rate the class. It will also be used to return an updated performance + summary entry after rating a class. + + Args: + class_history_uuid (str): The performance summary ID. + + Returns: + PerformanceSummaryEntry: The performance summary entry. + + Raises: + ResourceNotFoundError: If the performance summary is not found. + """ + + # try going in as small of increments as possible, assuming that the rating request + # will be for a recent class + for limit in [5, 20, 60, 100]: + summaries = self.get_performance_summaries(limit) + summary = next((s for s in summaries if s.class_history_uuid == class_history_uuid), None) + + if summary: + return summary + + raise exc.ResourceNotFoundError(f"Performance summary {class_history_uuid} not found.") + + def rate_class_from_performance_summary( + self, + perf_summary: models.PerformanceSummaryEntry | models.PerformanceSummaryDetail, + class_rating: Literal[0, 1, 2, 3], + coach_rating: Literal[0, 1, 2, 3], + ) -> models.PerformanceSummaryEntry: + """Rate a class and coach. The class rating must be 0, 1, 2, or 3. 0 is the same as dismissing the prompt to + rate the class/coach. 1 - 3 is a range from bad to good. + + Args: + perf_summary (PerformanceSummaryEntry): The performance summary entry to rate. + class_rating (int): The class rating. Must be 0, 1, 2, or 3. + coach_rating (int): The coach rating. Must be 0, 1, 2, or 3. + + Returns: + PerformanceSummaryEntry: The updated performance summary entry. + + Raises: + ValueError: If `perf_summary` is not a PerformanceSummaryEntry. + AlreadyRatedError: If the performance summary is already rated. + ClassNotRatableError: If the performance summary is not rateable. + ValueError: If the performance summary does not have an associated class. + """ + + if isinstance(perf_summary, models.PerformanceSummaryDetail): + perf_summary = self._get_performance_summary_entry_from_id(perf_summary.class_history_uuid) + + if not isinstance(perf_summary, models.PerformanceSummaryEntry): + raise ValueError(f"`perf_summary` must be a PerformanceSummaryEntry, got {type(perf_summary)}") + + if perf_summary.is_rated: + raise exc.AlreadyRatedError(f"Performance summary {perf_summary.class_history_uuid} is already rated.") + + if not perf_summary.ratable: + raise exc.ClassNotRatableError(f"Performance summary {perf_summary.class_history_uuid} is not rateable.") + + if not perf_summary.otf_class or not perf_summary.otf_class.class_uuid: + raise ValueError( + f"Performance summary {perf_summary.class_history_uuid} does not have an associated class." + ) + + return self._rate_class( + perf_summary.otf_class.class_uuid, perf_summary.class_history_uuid, class_rating, coach_rating + ) + # the below do not return any data for me, so I can't test them def _get_member_services(self, active_only: bool = True) -> Any: diff --git a/src/otf_api/exceptions.py b/src/otf_api/exceptions.py index 5ede8ce..72e82b9 100644 --- a/src/otf_api/exceptions.py +++ b/src/otf_api/exceptions.py @@ -51,3 +51,11 @@ class BookingNotFoundError(OtfException): class ResourceNotFoundError(OtfException): """Raised when a resource is not found.""" + + +class AlreadyRatedError(OtfException): + """Raised when attempting to rate a class that is already rated.""" + + +class ClassNotRatableError(OtfException): + """Raised when attempting to rate a class that is not ratable.""" From d53852214d81112542d391a6fd54dca097071c23 Mon Sep 17 00:00:00 2001 From: Jessica Smith <12jessicasmith34@gmail.com> Date: Tue, 21 Jan 2025 18:47:38 -0600 Subject: [PATCH 4/4] bump for class/coach rating --- .bumpversion.toml | 2 +- pyproject.toml | 2 +- src/otf_api/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.toml b/.bumpversion.toml index 9b16d9b..6e7a19c 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "0.9.1" +current_version = "0.9.2" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(?:-(?Pdev)(?P0|[1-9]\\d*))?" diff --git a/pyproject.toml b/pyproject.toml index 44d7d9c..95ecfeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "otf-api" -version = "0.9.1" +version = "0.9.2" description = "Python OrangeTheory Fitness API Client" authors = ["Jessica Smith "] license = "MIT" diff --git a/src/otf_api/__init__.py b/src/otf_api/__init__.py index fcca50f..9b74572 100644 --- a/src/otf_api/__init__.py +++ b/src/otf_api/__init__.py @@ -4,7 +4,7 @@ from otf_api import models from otf_api.auth import OtfUser -__version__ = "0.9.1" +__version__ = "0.9.2" __all__ = ["Otf", "OtfUser", "models"]