otf-api 0.10.2__py3-none-any.whl → 0.11.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- otf_api/__init__.py +1 -1
- otf_api/api.py +372 -201
- otf_api/auth/__init__.py +1 -1
- otf_api/auth/auth.py +113 -7
- otf_api/auth/user.py +14 -7
- otf_api/auth/utils.py +21 -33
- otf_api/models/__init__.py +11 -1
- otf_api/models/bookings_v2.py +171 -0
- otf_api/models/challenge_tracker_content.py +2 -2
- otf_api/models/challenge_tracker_detail.py +2 -2
- otf_api/models/classes.py +4 -7
- otf_api/models/enums.py +1 -0
- otf_api/models/lifetime_stats.py +7 -19
- otf_api/models/member_detail.py +1 -1
- otf_api/models/mixins.py +23 -17
- otf_api/models/out_of_studio_workout_history.py +1 -1
- otf_api/models/performance_summary.py +11 -93
- otf_api/models/ratings.py +28 -0
- otf_api/models/studio_detail.py +14 -7
- otf_api/models/telemetry.py +13 -3
- otf_api/models/workout.py +81 -0
- otf_api/utils.py +36 -8
- {otf_api-0.10.2.dist-info → otf_api-0.11.0rc1.dist-info}/METADATA +23 -27
- otf_api-0.11.0rc1.dist-info/RECORD +38 -0
- {otf_api-0.10.2.dist-info → otf_api-0.11.0rc1.dist-info}/WHEEL +2 -1
- otf_api-0.11.0rc1.dist-info/top_level.txt +1 -0
- otf_api-0.10.2.dist-info/RECORD +0 -34
- {otf_api-0.10.2.dist-info → otf_api-0.11.0rc1.dist-info/licenses}/LICENSE +0 -0
otf_api/models/lifetime_stats.py
CHANGED
@@ -1,20 +1,20 @@
|
|
1
1
|
from typing import Generic, TypeVar
|
2
2
|
|
3
|
-
from pydantic import
|
3
|
+
from pydantic import Field
|
4
4
|
|
5
5
|
from otf_api.models.base import OtfItemBase
|
6
6
|
from otf_api.models.enums import StatsTime
|
7
7
|
|
8
|
-
T = TypeVar("T", bound=
|
8
|
+
T = TypeVar("T", bound=OtfItemBase)
|
9
9
|
|
10
10
|
|
11
|
-
class OutStudioMixin
|
11
|
+
class OutStudioMixin:
|
12
12
|
walking_distance: float | None = Field(None, alias="walkingDistance")
|
13
13
|
running_distance: float | None = Field(None, alias="runningDistance")
|
14
14
|
cycling_distance: float | None = Field(None, alias="cyclingDistance")
|
15
15
|
|
16
16
|
|
17
|
-
class InStudioMixin
|
17
|
+
class InStudioMixin:
|
18
18
|
treadmill_distance: float | None = Field(None, alias="treadmillDistance")
|
19
19
|
treadmill_elevation_gained: float | None = Field(None, alias="treadmillElevationGained")
|
20
20
|
rower_distance: float | None = Field(None, alias="rowerDistance")
|
@@ -72,19 +72,7 @@ class TimeStats(OtfItemBase, Generic[T]):
|
|
72
72
|
return self.all_time
|
73
73
|
|
74
74
|
|
75
|
-
class OutStudioTimeStats(TimeStats[OutStudioStatsData]):
|
76
|
-
pass
|
77
|
-
|
78
|
-
|
79
|
-
class InStudioTimeStats(TimeStats[InStudioStatsData]):
|
80
|
-
pass
|
81
|
-
|
82
|
-
|
83
|
-
class AllStatsTimeStats(TimeStats[AllStatsData]):
|
84
|
-
pass
|
85
|
-
|
86
|
-
|
87
75
|
class StatsResponse(OtfItemBase):
|
88
|
-
all_stats:
|
89
|
-
in_studio:
|
90
|
-
out_studio:
|
76
|
+
all_stats: TimeStats[AllStatsData] = Field(..., alias="allStats")
|
77
|
+
in_studio: TimeStats[InStudioStatsData] = Field(..., alias="inStudio")
|
78
|
+
out_studio: TimeStats[OutStudioStatsData] = Field(..., alias="outStudio")
|
otf_api/models/member_detail.py
CHANGED
@@ -7,7 +7,7 @@ from otf_api.models.mixins import AddressMixin
|
|
7
7
|
from otf_api.models.studio_detail import StudioDetail
|
8
8
|
|
9
9
|
|
10
|
-
class Address(AddressMixin):
|
10
|
+
class Address(AddressMixin, OtfItemBase):
|
11
11
|
member_address_uuid: str | None = Field(None, alias="memberAddressUUId", exclude=True, repr=False)
|
12
12
|
type: str | None = None
|
13
13
|
|
otf_api/models/mixins.py
CHANGED
@@ -1,23 +1,29 @@
|
|
1
1
|
from pydantic import AliasChoices, Field, field_validator, model_validator
|
2
2
|
|
3
|
-
from otf_api.models.base import OtfItemBase
|
4
3
|
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
4
|
+
class PhoneLongitudeLatitudeMixin:
|
5
|
+
phone_number: str | None = Field(None, validation_alias=AliasChoices("phone", "phoneNumber"))
|
6
|
+
latitude: float | None = Field(None, validation_alias=AliasChoices("latitude"))
|
7
|
+
longitude: float | None = Field(None, validation_alias=AliasChoices("longitude"))
|
8
|
+
|
9
|
+
|
10
|
+
class AddressMixin:
|
11
|
+
address_line1: str | None = Field(
|
12
|
+
None, validation_alias=AliasChoices("line1", "address1", "address", "physicalAddress")
|
13
|
+
)
|
14
|
+
address_line2: str | None = Field(None, validation_alias=AliasChoices("line2", "address2", "physicalAddress2"))
|
15
|
+
city: str | None = Field(None, validation_alias=AliasChoices("city", "physicalCity", "suburb"))
|
16
|
+
postal_code: str | None = Field(
|
17
|
+
None, validation_alias=AliasChoices("postal_code", "postalCode", "physicalPostalCode")
|
18
|
+
)
|
19
|
+
state: str | None = Field(None, validation_alias=AliasChoices("state", "physicalState", "territory"))
|
20
|
+
country: str | None = Field(None, validation_alias=AliasChoices("country", "physicalCountry"))
|
21
|
+
region: str | None = Field(
|
22
|
+
None, exclude=True, repr=False, validation_alias=AliasChoices("physicalRegion", "region")
|
23
|
+
)
|
24
|
+
country_id: int | None = Field(
|
25
|
+
None, exclude=True, repr=False, validation_alias=AliasChoices("physicalCountryId", "countryId")
|
26
|
+
)
|
21
27
|
|
22
28
|
@model_validator(mode="before")
|
23
29
|
@classmethod
|
@@ -24,7 +24,7 @@ class OutOfStudioWorkoutHistory(OtfItemBase):
|
|
24
24
|
has_detailed_data: bool | None = Field(None, alias="hasDetailedData")
|
25
25
|
avg_heartrate: int | None = Field(None, alias="avgHeartrate")
|
26
26
|
max_heartrate: int | None = Field(None, alias="maxHeartrate")
|
27
|
-
workout_type: str | None = Field(None,
|
27
|
+
workout_type: str | None = Field(None, validation_alias=AliasPath("workoutType", "displayName"))
|
28
28
|
red_zone_seconds: int | None = Field(None, alias="redZoneSeconds")
|
29
29
|
orange_zone_seconds: int | None = Field(None, alias="orangeZoneSeconds")
|
30
30
|
green_zone_seconds: int | None = Field(None, alias="greenZoneSeconds")
|
@@ -1,11 +1,8 @@
|
|
1
|
-
from datetime import
|
2
|
-
from typing import Any
|
1
|
+
from datetime import time
|
3
2
|
|
4
3
|
from pydantic import AliasPath, Field, field_validator
|
5
4
|
|
6
5
|
from otf_api.models.base import OtfItemBase
|
7
|
-
from otf_api.models.enums import ClassType
|
8
|
-
from otf_api.models.studio_detail import StudioDetail
|
9
6
|
|
10
7
|
|
11
8
|
class ZoneTimeMinutes(OtfItemBase):
|
@@ -16,18 +13,6 @@ class ZoneTimeMinutes(OtfItemBase):
|
|
16
13
|
red: int
|
17
14
|
|
18
15
|
|
19
|
-
class CoachRating(OtfItemBase):
|
20
|
-
id: str
|
21
|
-
description: str
|
22
|
-
value: int
|
23
|
-
|
24
|
-
|
25
|
-
class ClassRating(OtfItemBase):
|
26
|
-
id: str
|
27
|
-
description: str
|
28
|
-
value: int
|
29
|
-
|
30
|
-
|
31
16
|
class HeartRate(OtfItemBase):
|
32
17
|
max_hr: int
|
33
18
|
peak_hr: int
|
@@ -36,14 +21,6 @@ class HeartRate(OtfItemBase):
|
|
36
21
|
avg_hr_percent: int
|
37
22
|
|
38
23
|
|
39
|
-
class Class(OtfItemBase):
|
40
|
-
class_uuid: str | None = Field(None, description="Only populated if class is ratable", alias="ot_base_class_uuid")
|
41
|
-
starts_at: datetime | None = Field(None, alias="starts_at_local")
|
42
|
-
type: ClassType | None = None
|
43
|
-
studio: StudioDetail | None = None
|
44
|
-
name: str | None = None
|
45
|
-
|
46
|
-
|
47
24
|
class PerformanceMetric(OtfItemBase):
|
48
25
|
display_value: time | float
|
49
26
|
display_unit: str
|
@@ -66,7 +43,7 @@ class PerformanceMetric(OtfItemBase):
|
|
66
43
|
hours, minutes, seconds = value.split(":")
|
67
44
|
return time(hour=int(hours), minute=int(minutes), second=int(seconds))
|
68
45
|
|
69
|
-
return value
|
46
|
+
return value # type: ignore
|
70
47
|
|
71
48
|
|
72
49
|
class BaseEquipment(OtfItemBase):
|
@@ -91,79 +68,20 @@ class Rower(BaseEquipment):
|
|
91
68
|
|
92
69
|
|
93
70
|
class PerformanceSummary(OtfItemBase):
|
94
|
-
"""Represents a workout performance summary - the same data
|
95
|
-
|
96
|
-
This data comes from two different endpoints that do not actually match, because of course not.
|
97
|
-
The summary endpoint returns one distinct set of data plus some detailed data - this is the only place we can get
|
98
|
-
the studio information. The detail endpoint returns more performance data but does not have much class data and does
|
99
|
-
not have the studio.
|
100
|
-
|
101
|
-
* The summary endpoint data is missing step_count, the value is always 0.
|
102
|
-
* The detail endpoint data is missing active_time_seconds, the value is always 0.
|
103
|
-
* The detail endpoint class name is more generic than the summary endpoint class name.
|
104
|
-
|
71
|
+
"""Represents a workout performance summary - much of the same data as in the app, but not all.
|
105
72
|
|
73
|
+
You likely want to use the `Workout` model and `get_workouts` method instead.
|
106
74
|
"""
|
107
75
|
|
108
76
|
performance_summary_id: str = Field(..., alias="id", description="Unique identifier for this performance summary")
|
109
77
|
class_history_uuid: str = Field(..., alias="id", description="Same as performance_summary_id")
|
110
78
|
ratable: bool | None = None
|
111
|
-
otf_class: Class | None = Field(None, alias="class")
|
112
|
-
coach: str | None = Field(None, alias=AliasPath("class", "coach", "first_name"))
|
113
|
-
coach_rating: CoachRating | None = Field(None, alias=AliasPath("ratings", "coach"))
|
114
|
-
class_rating: ClassRating | None = Field(None, alias=AliasPath("ratings", "class"))
|
115
|
-
|
116
|
-
active_time_seconds: int | None = Field(None, alias=AliasPath("details", "active_time_seconds"))
|
117
|
-
calories_burned: int | None = Field(None, alias=AliasPath("details", "calories_burned"))
|
118
|
-
splat_points: int | None = Field(None, alias=AliasPath("details", "splat_points"))
|
119
|
-
step_count: int | None = Field(None, alias=AliasPath("details", "step_count"))
|
120
|
-
zone_time_minutes: ZoneTimeMinutes | None = Field(None, alias=AliasPath("details", "zone_time_minutes"))
|
121
|
-
heart_rate: HeartRate | None = Field(None, alias=AliasPath("details", "heart_rate"))
|
122
|
-
|
123
|
-
rower_data: Rower | None = Field(None, alias=AliasPath("details", "equipment_data", "rower"))
|
124
|
-
treadmill_data: Treadmill | None = Field(None, alias=AliasPath("details", "equipment_data", "treadmill"))
|
125
|
-
|
126
|
-
@property
|
127
|
-
def is_rated(self) -> bool:
|
128
|
-
return self.coach_rating is not None or self.class_rating is not None
|
129
|
-
|
130
|
-
@property
|
131
|
-
def studio(self) -> StudioDetail | None:
|
132
|
-
return self.otf_class.studio if self.otf_class else None
|
133
|
-
|
134
|
-
def __init__(self, **kwargs) -> None:
|
135
|
-
summary_detail = kwargs.pop("details", {}) or {}
|
136
|
-
true_detail = kwargs.pop("detail", {}) or {}
|
137
|
-
|
138
|
-
summary_class = kwargs.pop("class", {})
|
139
|
-
detail_class = true_detail.pop("class", {})
|
140
|
-
|
141
|
-
kwargs["class"] = combine_class_data(summary_class, detail_class)
|
142
|
-
kwargs["details"] = combine_detail_data(summary_detail, true_detail.pop("details", {}))
|
143
|
-
|
144
|
-
super().__init__(**kwargs)
|
145
|
-
|
146
|
-
|
147
|
-
def combine_class_data(summary_class: dict[str, str], detail_class: dict[str, str]) -> dict[str, str]:
|
148
|
-
class_data = {}
|
149
|
-
|
150
|
-
class_data["ot_base_class_uuid"] = summary_class.get("ot_base_class_uuid")
|
151
|
-
class_data["starts_at_local"] = summary_class.get("starts_at_local") or detail_class.get("starts_at_local")
|
152
|
-
class_data["type"] = summary_class.get("type")
|
153
|
-
class_data["studio"] = summary_class.get("studio")
|
154
|
-
class_data["name"] = detail_class.get("name") or summary_class.get("name")
|
155
|
-
class_data["coach"] = summary_class.get("coach")
|
156
|
-
|
157
|
-
return class_data
|
158
|
-
|
159
|
-
|
160
|
-
def combine_detail_data(summary_detail: dict[str, Any], true_detail: dict[str, Any]) -> dict[str, Any]:
|
161
|
-
# active time seconds always 0 in detail
|
162
|
-
del true_detail["active_time_seconds"]
|
163
|
-
|
164
|
-
# step count always 0 in summary
|
165
|
-
summary_detail["step_count"] = true_detail["step_count"]
|
166
79
|
|
167
|
-
|
80
|
+
calories_burned: int | None = Field(None, validation_alias=AliasPath("details", "calories_burned"))
|
81
|
+
splat_points: int | None = Field(None, validation_alias=AliasPath("details", "splat_points"))
|
82
|
+
step_count: int | None = Field(None, validation_alias=AliasPath("details", "step_count"))
|
83
|
+
zone_time_minutes: ZoneTimeMinutes | None = Field(None, validation_alias=AliasPath("details", "zone_time_minutes"))
|
84
|
+
heart_rate: HeartRate | None = Field(None, validation_alias=AliasPath("details", "heart_rate"))
|
168
85
|
|
169
|
-
|
86
|
+
rower_data: Rower | None = Field(None, validation_alias=AliasPath("details", "equipment_data", "rower"))
|
87
|
+
treadmill_data: Treadmill | None = Field(None, validation_alias=AliasPath("details", "equipment_data", "treadmill"))
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# com/orangetheoryfitness/fragment/rating/RateStatus.java
|
2
|
+
|
3
|
+
# we convert these to the new values that the app uses
|
4
|
+
# mainly because we don't want to cause any issues with the API and/or with OTF corporate
|
5
|
+
# wondering where the old values are coming from
|
6
|
+
|
7
|
+
COACH_RATING_MAP = {0: 0, 1: 16, 2: 17, 3: 18}
|
8
|
+
CLASS_RATING_MAP = {0: 0, 1: 19, 2: 20, 3: 21}
|
9
|
+
|
10
|
+
|
11
|
+
def get_class_rating_value(class_rating: int) -> int:
|
12
|
+
"""
|
13
|
+
Convert the class rating from the old values to the new values.
|
14
|
+
"""
|
15
|
+
if class_rating not in CLASS_RATING_MAP:
|
16
|
+
raise ValueError(f"Invalid class rating {class_rating}")
|
17
|
+
|
18
|
+
return CLASS_RATING_MAP[class_rating]
|
19
|
+
|
20
|
+
|
21
|
+
def get_coach_rating_value(coach_rating: int) -> int:
|
22
|
+
"""
|
23
|
+
Convert the coach rating from the old values to the new values.
|
24
|
+
"""
|
25
|
+
if coach_rating not in COACH_RATING_MAP:
|
26
|
+
raise ValueError(f"Invalid coach rating {coach_rating}")
|
27
|
+
|
28
|
+
return COACH_RATING_MAP[coach_rating]
|
otf_api/models/studio_detail.py
CHANGED
@@ -7,10 +7,10 @@ from otf_api.models.enums import StudioStatus
|
|
7
7
|
from otf_api.models.mixins import AddressMixin
|
8
8
|
|
9
9
|
|
10
|
-
class StudioLocation(AddressMixin):
|
11
|
-
phone_number: str | None = Field(None,
|
12
|
-
latitude: float | None = Field(None,
|
13
|
-
longitude: float | None = Field(None,
|
10
|
+
class StudioLocation(AddressMixin, OtfItemBase):
|
11
|
+
phone_number: str | None = Field(None, validation_alias=AliasChoices("phone", "phoneNumber"))
|
12
|
+
latitude: float | None = Field(None, validation_alias=AliasChoices("latitude"))
|
13
|
+
longitude: float | None = Field(None, validation_alias=AliasChoices("longitude"))
|
14
14
|
|
15
15
|
physical_region: str | None = Field(None, alias="physicalRegion", exclude=True, repr=False)
|
16
16
|
physical_country_id: int | None = Field(None, alias="physicalCountryId", exclude=True, repr=False)
|
@@ -27,7 +27,7 @@ class StudioDetail(OtfItemBase):
|
|
27
27
|
exclude=True,
|
28
28
|
repr=False,
|
29
29
|
)
|
30
|
-
location: StudioLocation = Field(..., alias="studioLocation", default_factory=StudioLocation)
|
30
|
+
location: StudioLocation = Field(..., alias="studioLocation", default_factory=StudioLocation) # type: ignore
|
31
31
|
name: str | None = Field(None, alias="studioName")
|
32
32
|
status: StudioStatus | None = Field(
|
33
33
|
None, alias="studioStatus", description="Active, Temporarily Closed, Coming Soon"
|
@@ -41,13 +41,13 @@ class StudioDetail(OtfItemBase):
|
|
41
41
|
accepts_visa_master_card: bool | None = Field(None, alias="acceptsVisaMasterCard", exclude=True, repr=False)
|
42
42
|
allows_cr_waitlist: bool | None = Field(None, alias="allowsCrWaitlist", exclude=True, repr=False)
|
43
43
|
allows_dashboard_access: bool | None = Field(None, alias="allowsDashboardAccess", exclude=True, repr=False)
|
44
|
-
is_crm: bool | None = Field(None,
|
44
|
+
is_crm: bool | None = Field(None, validation_alias=AliasPath("studioProfiles", "isCrm"), exclude=True, repr=False)
|
45
45
|
is_integrated: bool | None = Field(
|
46
46
|
None, alias="isIntegrated", exclude=True, repr=False, description="Always 'True'"
|
47
47
|
)
|
48
48
|
is_mobile: bool | None = Field(None, alias="isMobile", exclude=True, repr=False)
|
49
49
|
is_otbeat: bool | None = Field(None, alias="isOtbeat", exclude=True, repr=False)
|
50
|
-
is_web: bool | None = Field(None,
|
50
|
+
is_web: bool | None = Field(None, validation_alias=AliasPath("studioProfiles", "isWeb"), exclude=True, repr=False)
|
51
51
|
sms_package_enabled: bool | None = Field(None, alias="smsPackageEnabled", exclude=True, repr=False)
|
52
52
|
|
53
53
|
# misc
|
@@ -62,3 +62,10 @@ class StudioDetail(OtfItemBase):
|
|
62
62
|
studio_physical_location_id: int | None = Field(None, alias="studioPhysicalLocationId", exclude=True, repr=False)
|
63
63
|
studio_token: str | None = Field(None, alias="studioToken", exclude=True, repr=False)
|
64
64
|
studio_type_id: int | None = Field(None, alias="studioTypeId", exclude=True, repr=False)
|
65
|
+
|
66
|
+
@classmethod
|
67
|
+
def create_empty_model(cls, studio_uuid: str) -> "StudioDetail":
|
68
|
+
"""Create an empty model with the given studio_uuid."""
|
69
|
+
|
70
|
+
# pylance doesn't know that the rest of the fields default to None, so we use type: ignore
|
71
|
+
return StudioDetail(studioUUId=studio_uuid, studioName="Studio Not Found", studioStatus="Unknown") # type: ignore
|
otf_api/models/telemetry.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
from datetime import datetime, timedelta
|
2
2
|
from typing import Any
|
3
3
|
|
4
|
-
from pydantic import AliasPath, Field
|
4
|
+
from pydantic import AliasPath, Field, field_serializer
|
5
5
|
|
6
6
|
from otf_api.models.base import OtfItemBase
|
7
7
|
|
@@ -62,12 +62,22 @@ class Telemetry(OtfItemBase):
|
|
62
62
|
def __init__(self, **data: dict[str, Any]):
|
63
63
|
super().__init__(**data)
|
64
64
|
for telem in self.telemetry:
|
65
|
+
if self.class_start_time is None:
|
66
|
+
continue
|
67
|
+
|
65
68
|
telem.timestamp = self.class_start_time + timedelta(seconds=telem.relative_timestamp)
|
66
69
|
|
70
|
+
@field_serializer("telemetry", when_used="json")
|
71
|
+
def reduce_telemetry_list(self, value: list[TelemetryItem]) -> list[TelemetryItem]:
|
72
|
+
"""Reduces the telemetry list to only include the first 10 items."""
|
73
|
+
if len(value) > 10:
|
74
|
+
return value[:5] + value[-5:]
|
75
|
+
return value
|
76
|
+
|
67
77
|
|
68
78
|
class TelemetryHistoryItem(OtfItemBase):
|
69
|
-
max_hr_type: str | None = Field(None,
|
70
|
-
max_hr_value: int | None = Field(None,
|
79
|
+
max_hr_type: str | None = Field(None, validation_alias=AliasPath("maxHr", "type"))
|
80
|
+
max_hr_value: int | None = Field(None, validation_alias=AliasPath("maxHr", "value"))
|
71
81
|
zones: Zones | None = None
|
72
82
|
change_from_previous: int | None = Field(None, alias="changeFromPrevious")
|
73
83
|
change_bucket: str | None = Field(None, alias="changeBucket")
|
@@ -0,0 +1,81 @@
|
|
1
|
+
from pydantic import AliasPath, Field
|
2
|
+
|
3
|
+
from otf_api.models.base import OtfItemBase
|
4
|
+
from otf_api.models.bookings_v2 import (
|
5
|
+
BookingV2,
|
6
|
+
BookingV2Class,
|
7
|
+
BookingV2Studio,
|
8
|
+
BookingV2Workout,
|
9
|
+
Rating,
|
10
|
+
ZoneTimeMinutes,
|
11
|
+
)
|
12
|
+
from otf_api.models.performance_summary import HeartRate, Rower, Treadmill
|
13
|
+
from otf_api.models.telemetry import Telemetry
|
14
|
+
|
15
|
+
|
16
|
+
class Workout(OtfItemBase):
|
17
|
+
"""Represents a workout - this combines the performance summary, data from the new bookings endpoint, and
|
18
|
+
telemetry data.
|
19
|
+
|
20
|
+
The final product contains all the performance summary data, the detailed data over time, as well as the class,
|
21
|
+
coach, studio, and rating data from the new endpoint.
|
22
|
+
|
23
|
+
This should match the data that is shown in the OTF app after a workout.
|
24
|
+
"""
|
25
|
+
|
26
|
+
performance_summary_id: str = Field(..., alias="id", description="Unique identifier for this performance summary")
|
27
|
+
class_history_uuid: str = Field(..., alias="id", description="Same as performance_summary_id")
|
28
|
+
booking_id: str = Field(..., description="The booking id for the new bookings endpoint.")
|
29
|
+
class_uuid: str | None = Field(
|
30
|
+
None, description="Used by the ratings endpoint - seems to fall off after a few months"
|
31
|
+
)
|
32
|
+
coach: str | None = Field(None, description="First name of the coach")
|
33
|
+
|
34
|
+
ratable: bool | None = None
|
35
|
+
|
36
|
+
calories_burned: int | None = Field(None, validation_alias=AliasPath("details", "calories_burned"))
|
37
|
+
splat_points: int | None = Field(None, validation_alias=AliasPath("details", "splat_points"))
|
38
|
+
step_count: int | None = Field(None, validation_alias=AliasPath("details", "step_count"))
|
39
|
+
zone_time_minutes: ZoneTimeMinutes | None = Field(None, validation_alias=AliasPath("details", "zone_time_minutes"))
|
40
|
+
heart_rate: HeartRate | None = Field(None, validation_alias=AliasPath("details", "heart_rate"))
|
41
|
+
active_time_seconds: int | None = None
|
42
|
+
|
43
|
+
rower_data: Rower | None = Field(None, validation_alias=AliasPath("details", "equipment_data", "rower"))
|
44
|
+
treadmill_data: Treadmill | None = Field(None, validation_alias=AliasPath("details", "equipment_data", "treadmill"))
|
45
|
+
|
46
|
+
class_rating: Rating | None = None
|
47
|
+
coach_rating: Rating | None = None
|
48
|
+
|
49
|
+
otf_class: BookingV2Class
|
50
|
+
studio: BookingV2Studio
|
51
|
+
telemetry: Telemetry | None = None
|
52
|
+
|
53
|
+
def __init__(self, **data):
|
54
|
+
v2_booking = data.get("v2_booking")
|
55
|
+
if not v2_booking:
|
56
|
+
raise ValueError("v2_booking is required")
|
57
|
+
|
58
|
+
assert isinstance(v2_booking, BookingV2), "v2_booking must be an instance of BookingV2"
|
59
|
+
|
60
|
+
otf_class = v2_booking.otf_class
|
61
|
+
v2_workout = v2_booking.workout
|
62
|
+
assert isinstance(otf_class, BookingV2Class), "otf_class must be an instance of BookingV2Class"
|
63
|
+
assert isinstance(v2_workout, BookingV2Workout), "v2_workout must be an instance of BookingV2Workout"
|
64
|
+
|
65
|
+
data["otf_class"] = otf_class
|
66
|
+
data["studio"] = otf_class.studio
|
67
|
+
data["coach"] = otf_class.coach
|
68
|
+
data["ratable"] = v2_booking.ratable # this seems to be more accurate
|
69
|
+
|
70
|
+
data["booking_id"] = v2_booking.booking_id
|
71
|
+
data["active_time_seconds"] = v2_workout.active_time_seconds
|
72
|
+
data["class_rating"] = v2_booking.class_rating
|
73
|
+
data["coach_rating"] = v2_booking.coach_rating
|
74
|
+
|
75
|
+
telemetry = data.get("telemetry")
|
76
|
+
if telemetry and isinstance(telemetry, Telemetry):
|
77
|
+
# max_hr seems to be left out of the heart rate data - it has peak_hr but they do not match
|
78
|
+
# so if we have telemetry data, we can get the max_hr from there
|
79
|
+
data["details"]["heart_rate"]["max_hr"] = telemetry.max_hr
|
80
|
+
|
81
|
+
super().__init__(**data)
|
otf_api/utils.py
CHANGED
@@ -8,13 +8,12 @@ from typing import Any
|
|
8
8
|
import attrs
|
9
9
|
|
10
10
|
if typing.TYPE_CHECKING:
|
11
|
-
from otf_api
|
12
|
-
from otf_api.models.classes import OtfClass
|
11
|
+
from otf_api import models
|
13
12
|
|
14
13
|
LOGGER = getLogger(__name__)
|
15
14
|
|
16
15
|
|
17
|
-
def get_booking_uuid(booking_or_uuid: "str | Booking") -> str:
|
16
|
+
def get_booking_uuid(booking_or_uuid: "str | models.Booking") -> str:
|
18
17
|
from otf_api.models.bookings import Booking
|
19
18
|
|
20
19
|
if isinstance(booking_or_uuid, str):
|
@@ -26,16 +25,29 @@ def get_booking_uuid(booking_or_uuid: "str | Booking") -> str:
|
|
26
25
|
raise ValueError(f"Expected Booking or str, got {type(booking_or_uuid)}")
|
27
26
|
|
28
27
|
|
29
|
-
def
|
30
|
-
from otf_api.models.
|
28
|
+
def get_booking_id(booking_or_id: "str | models.BookingV2") -> str:
|
29
|
+
from otf_api.models.bookings_v2 import BookingV2
|
30
|
+
|
31
|
+
if isinstance(booking_or_id, str):
|
32
|
+
return booking_or_id
|
33
|
+
|
34
|
+
if isinstance(booking_or_id, BookingV2):
|
35
|
+
return booking_or_id.booking_id
|
36
|
+
|
37
|
+
raise ValueError(f"Expected BookingV2 or str, got {type(booking_or_id)}")
|
31
38
|
|
39
|
+
|
40
|
+
def get_class_uuid(class_or_uuid: "str | models.OtfClass | models.BookingV2Class") -> str:
|
32
41
|
if isinstance(class_or_uuid, str):
|
33
42
|
return class_or_uuid
|
34
43
|
|
35
|
-
if
|
36
|
-
|
44
|
+
if hasattr(class_or_uuid, "class_uuid"):
|
45
|
+
class_uuid = getattr(class_or_uuid, "class_uuid", None)
|
46
|
+
if class_uuid:
|
47
|
+
return class_uuid
|
48
|
+
raise ValueError("Class does not have a class_uuid")
|
37
49
|
|
38
|
-
raise ValueError(f"Expected OtfClass or str, got {type(class_or_uuid)}")
|
50
|
+
raise ValueError(f"Expected OtfClass, BookingV2Class, or str, got {type(class_or_uuid)}")
|
39
51
|
|
40
52
|
|
41
53
|
def ensure_list(obj: list | Any | None) -> list:
|
@@ -46,6 +58,22 @@ def ensure_list(obj: list | Any | None) -> list:
|
|
46
58
|
return obj
|
47
59
|
|
48
60
|
|
61
|
+
def ensure_datetime(date_str: str | datetime | None) -> datetime | None:
|
62
|
+
if not date_str:
|
63
|
+
return None
|
64
|
+
|
65
|
+
if isinstance(date_str, str):
|
66
|
+
return datetime.fromisoformat(date_str)
|
67
|
+
|
68
|
+
if isinstance(date_str, datetime):
|
69
|
+
return date_str
|
70
|
+
|
71
|
+
if isinstance(date_str, date):
|
72
|
+
return datetime.combine(date_str, datetime.min.time())
|
73
|
+
|
74
|
+
raise ValueError(f"Expected str or datetime, got {type(date_str)}")
|
75
|
+
|
76
|
+
|
49
77
|
def ensure_date(date_str: str | date | None) -> date | None:
|
50
78
|
if not date_str:
|
51
79
|
return None
|
@@ -1,36 +1,33 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: otf-api
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.11.0rc1
|
4
4
|
Summary: Python OrangeTheory Fitness API Client
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
Requires-Python: >=3.11,<4.0
|
5
|
+
Author-email: Jessica Smith <j.smith.git1@gmail.com>
|
6
|
+
License-Expression: MIT
|
7
|
+
Project-URL: Documentation, https://otf-api.readthedocs.io/en/stable/
|
9
8
|
Classifier: Development Status :: 4 - Beta
|
10
9
|
Classifier: Intended Audience :: Developers
|
11
|
-
Classifier: License :: OSI Approved :: MIT License
|
12
|
-
Classifier: Operating System :: OS Independent
|
13
|
-
Classifier: Programming Language :: Python :: 3
|
14
10
|
Classifier: Programming Language :: Python :: 3.11
|
15
|
-
Classifier: Programming Language :: Python :: 3.12
|
16
|
-
Classifier: Programming Language :: Python :: 3.13
|
17
|
-
Classifier: Topic :: Internet :: WWW/HTTP
|
18
|
-
Classifier: Topic :: Software Development :: Libraries
|
19
|
-
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
20
11
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
Requires-
|
26
|
-
Requires-Dist: mypy-boto3-cognito-idp (>=1.35.93,<2.0.0)
|
27
|
-
Requires-Dist: pint (==0.24.*)
|
28
|
-
Requires-Dist: pycognito (==2024.5.1)
|
29
|
-
Requires-Dist: pydantic (>=2.7.3)
|
30
|
-
Requires-Dist: tenacity (>=9.0.0,<10.0.0)
|
31
|
-
Requires-Dist: yarl (>=1.18.3,<2.0.0)
|
32
|
-
Project-URL: Documentation, https://otf-api.readthedocs.io/en/stable/
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
15
|
+
Classifier: Operating System :: OS Independent
|
16
|
+
Requires-Python: >=3.11
|
33
17
|
Description-Content-Type: text/markdown
|
18
|
+
License-File: LICENSE
|
19
|
+
Requires-Dist: attrs<25,>=24.3.0
|
20
|
+
Requires-Dist: httpx>=0.27.2
|
21
|
+
Requires-Dist: humanize<5,>=4.9.0
|
22
|
+
Requires-Dist: inflection==0.5.*
|
23
|
+
Requires-Dist: pint==0.24.*
|
24
|
+
Requires-Dist: pycognito==2024.5.1
|
25
|
+
Requires-Dist: pydantic>=2.7.3
|
26
|
+
Requires-Dist: yarl<2,>=1.18.3
|
27
|
+
Requires-Dist: tenacity<10,>=9.0.0
|
28
|
+
Requires-Dist: cachetools>=5.5.0
|
29
|
+
Requires-Dist: pendulum>=3.1.0
|
30
|
+
Dynamic: license-file
|
34
31
|
|
35
32
|
Simple API client for interacting with the OrangeTheory Fitness APIs.
|
36
33
|
|
@@ -62,4 +59,3 @@ otf = Otf()
|
|
62
59
|
otf = Otf(user=OtfUser(<email_address>,<password>))
|
63
60
|
|
64
61
|
```
|
65
|
-
|
@@ -0,0 +1,38 @@
|
|
1
|
+
otf_api/__init__.py,sha256=9EKuuZV9IGzs0IR8i13YCi-tO5qYQ9rFVKqvxyleKuo,209
|
2
|
+
otf_api/api.py,sha256=XbPj4MR0spEoN_1rfh7TjiTI9j5HjSVgqX3bhQGtxns,67643
|
3
|
+
otf_api/exceptions.py,sha256=GISekwF5dPt0Ol0WCU55kE5ODc5VxicNEEhmlguuE0U,1815
|
4
|
+
otf_api/filters.py,sha256=fk2bFGi3srjS96qZlaDx-ARZRaj93NUTUdMJ01TX420,3702
|
5
|
+
otf_api/logging.py,sha256=PRZpCaJ1F1Xya3L9Efkt3mKS5_QNr3sXjEUERSxYjvE,563
|
6
|
+
otf_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
7
|
+
otf_api/utils.py,sha256=TATPgb5-t3nEAqo_iGY2p2PKzJA564alN1skb23OCMQ,4817
|
8
|
+
otf_api/auth/__init__.py,sha256=PuhtiZ02GVP8zVSn1O-fhaYm7JM_tq5yUFATk_-8upk,132
|
9
|
+
otf_api/auth/auth.py,sha256=69iZIGFn9l470PrUfLvwdMTIL0tHYJ3tvuKOxy7NSyU,13185
|
10
|
+
otf_api/auth/user.py,sha256=XlK3nbqJA4fF5UFmw2tt0eAle4QOQd6trnW72QrBsx4,2681
|
11
|
+
otf_api/auth/utils.py,sha256=vc2pEyU-3yKdv0mR6r_zSHZyMw92j5UeIN_kzcb6TeE,2909
|
12
|
+
otf_api/models/__init__.py,sha256=MSinaMQaBTGscL-YRKJ3axFiItQ1HoH62wC2xdaBMgk,1876
|
13
|
+
otf_api/models/base.py,sha256=KJlIxl_sRj6f-g5vKYPw4yV6fGDk-fwZ93EO0JGPYMw,202
|
14
|
+
otf_api/models/body_composition_list.py,sha256=jGdR-9ScvIOtULJNB99aYh2INk2ihoHAnTWtbQCIea4,12202
|
15
|
+
otf_api/models/bookings.py,sha256=Lj-IHN1k-w4vF-5aIKbsiQ6Uq-I5_ELoPUKXPGkfbgM,4440
|
16
|
+
otf_api/models/bookings_v2.py,sha256=9HyXGnDNDmIqmr4r0_3pR1mFtN_Lfp9nP-4bCiwe5QA,5865
|
17
|
+
otf_api/models/challenge_tracker_content.py,sha256=5Ucu1n4W15v1rzhoXNvAD9tCSg3JTUiR92HHiDAxRec,2597
|
18
|
+
otf_api/models/challenge_tracker_detail.py,sha256=c2Ds7Kv2-VaPtxoXSUTI5zrmU1A1dcSaM1UIolwSVxU,4323
|
19
|
+
otf_api/models/classes.py,sha256=aKV6LGEh0YiPxyOaoMD0gaQOSHqs69cYHhP9H_2p_jY,3051
|
20
|
+
otf_api/models/enums.py,sha256=6P7wOYwvZkBLfNckKaNWv6Po94yg6lq0qyNNNOSqEFg,4633
|
21
|
+
otf_api/models/lifetime_stats.py,sha256=qpPCJuL68KhEbsIZ6cTx1dhh7RzDH5xWEAnLIsq8mZE,2853
|
22
|
+
otf_api/models/member_detail.py,sha256=UzUttKuF_P3bvzEy_ZXBwDVwj52JtpDhGuPuAtQqT0I,6611
|
23
|
+
otf_api/models/member_membership.py,sha256=jZwHzwtVyMUr8dWGlFbMYj9qveCbiOblWW5szXDUFFo,1338
|
24
|
+
otf_api/models/member_purchases.py,sha256=Ne7ByEbGTqTJhuEyCgywWe8I3nc-D46qw09up7ys38s,1627
|
25
|
+
otf_api/models/mixins.py,sha256=RBuAvAN1lYQpas0brACBof-6R4EqwGJL8HWHt41eQNg,2368
|
26
|
+
otf_api/models/notifications.py,sha256=AkmIfiIiU6wox_7puyenbhCX10SFvBD5eBAovcurRgY,833
|
27
|
+
otf_api/models/out_of_studio_workout_history.py,sha256=Kjkb8HW7k0qGMW3rAKXxZQju4MYglmmSRUdRx6FW_MQ,1714
|
28
|
+
otf_api/models/performance_summary.py,sha256=vUQJum2lW6zHnYMOvdWwClWwrIwef6WojfKqyXUxTno,2914
|
29
|
+
otf_api/models/ratings.py,sha256=RVsOGqx_eaB5i60pMRNR4xqYkQZhwRrLeSvmcFEDTgw,934
|
30
|
+
otf_api/models/studio_detail.py,sha256=2gq0A27NOZGz_PTBvsB-dkzm01nYc9FHmx1NON6xp6U,4187
|
31
|
+
otf_api/models/studio_services.py,sha256=aGLQMQmjGVpI6YxzAl-mcp3Y9cHPXuH9dIqrl6E-78E,1665
|
32
|
+
otf_api/models/telemetry.py,sha256=PQ_CbADW5-t-U2iEQJGugNy-c4rD0q76TfyIqeFnTho,3170
|
33
|
+
otf_api/models/workout.py,sha256=P3xVTvcYrm_RdU6qi3Xm2BXTxxvhvF0dgoEcODY41AA,3678
|
34
|
+
otf_api-0.11.0rc1.dist-info/licenses/LICENSE,sha256=UaPT9ynYigC3nX8n22_rC37n-qmTRKLFaHrtUwF9ktE,1071
|
35
|
+
otf_api-0.11.0rc1.dist-info/METADATA,sha256=au0J0vMB2Edln-n_Swag6fh2Zneyw1nnle6jdTcJu9k,2148
|
36
|
+
otf_api-0.11.0rc1.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
|
37
|
+
otf_api-0.11.0rc1.dist-info/top_level.txt,sha256=KAhYg1X2YG0LkTuVRhUV1I_AReNZUVNdEan7cp0pEE4,8
|
38
|
+
otf_api-0.11.0rc1.dist-info/RECORD,,
|
@@ -0,0 +1 @@
|
|
1
|
+
otf_api
|