Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/add coach class rating #73

Merged
merged 4 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 <j.smith.git1@gmail.com>"]
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
Loading