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] 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."""