Skip to content

Commit

Permalink
Merge pull request #73 from NodeJSmith/feature/add_coach_class_rating
Browse files Browse the repository at this point in the history
Feature/add coach class rating
  • Loading branch information
NodeJSmith authored Jan 22, 2025
2 parents 7da1d58 + d538522 commit 287d45f
Show file tree
Hide file tree
Showing 8 changed files with 204 additions and 13 deletions.
2 changes: 1 addition & 1 deletion .bumpversion.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tool.bumpversion]
current_version = "0.9.1"
current_version = "0.9.2"

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

Expand Down
50 changes: 48 additions & 2 deletions examples/workout_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,55 @@ 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].id)
data = otf.get_performance_summary(data_list[0].class_history_uuid)
print(data.model_dump_json(indent=4))

"""
Expand Down Expand Up @@ -207,7 +253,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))

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>"]
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/otf_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
138 changes: 134 additions & 4 deletions src/otf_api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -1147,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
12 changes: 11 additions & 1 deletion src/otf_api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -49,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."""
9 changes: 7 additions & 2 deletions src/otf_api/models/performance_summary_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
2 changes: 1 addition & 1 deletion src/otf_api/models/performance_summary_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down

0 comments on commit 287d45f

Please sign in to comment.