Skip to content

Commit

Permalink
feat(api): add class and coach rating functionality
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
NodeJSmith committed Jan 22, 2025
1 parent e48b8b5 commit 1032df1
Show file tree
Hide file tree
Showing 3 changed files with 185 additions and 0 deletions.
46 changes: 46 additions & 0 deletions examples/workout_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
131 changes: 131 additions & 0 deletions src/otf_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions src/otf_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

0 comments on commit 1032df1

Please sign in to comment.