otf-api 0.10.2__py3-none-any.whl → 0.11.1__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.
@@ -1,20 +1,20 @@
1
1
  from typing import Generic, TypeVar
2
2
 
3
- from pydantic import BaseModel, Field
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=BaseModel)
8
+ T = TypeVar("T", bound=OtfItemBase)
9
9
 
10
10
 
11
- class OutStudioMixin(OtfItemBase):
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(OtfItemBase):
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: AllStatsTimeStats = Field(..., alias="allStats")
89
- in_studio: InStudioTimeStats = Field(..., alias="inStudio")
90
- out_studio: OutStudioTimeStats = Field(..., alias="outStudio")
76
+ all_stats: TimeStats[AllStatsData] = Field(..., alias="allStats")
77
+ in_studio: TimeStats[InStudioStatsData] = Field(..., alias="inStudio")
78
+ out_studio: TimeStats[OutStudioStatsData] = Field(..., alias="outStudio")
@@ -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
- class PhoneLongitudeLatitudeMixin(OtfItemBase):
7
- phone_number: str | None = Field(None, alias=AliasChoices("phone", "phoneNumber"))
8
- latitude: float | None = Field(None, alias=AliasChoices("latitude"))
9
- longitude: float | None = Field(None, alias=AliasChoices("longitude"))
10
-
11
-
12
- class AddressMixin(OtfItemBase):
13
- address_line1: str | None = Field(None, alias=AliasChoices("line1", "address1", "address", "physicalAddress"))
14
- address_line2: str | None = Field(None, alias=AliasChoices("line2", "address2", "physicalAddress2"))
15
- city: str | None = Field(None, alias=AliasChoices("city", "physicalCity", "suburb"))
16
- postal_code: str | None = Field(None, alias=AliasChoices("postal_code", "postalCode", "physicalPostalCode"))
17
- state: str | None = Field(None, alias=AliasChoices("state", "physicalState", "territory"))
18
- country: str | None = Field(None, alias=AliasChoices("country", "physicalCountry"))
19
- region: str | None = Field(None, exclude=True, repr=False, alias=AliasChoices("physicalRegion", "region"))
20
- country_id: int | None = Field(None, exclude=True, repr=False, alias=AliasChoices("physicalCountryId", "countryId"))
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, alias=AliasPath("workoutType", "displayName"))
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 datetime, time
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 that is shown in the OTF app after a workout"
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
- combined_details = summary_detail | true_detail
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
- return combined_details
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]
@@ -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, alias=AliasChoices("phone", "phoneNumber"))
12
- latitude: float | None = Field(None, alias=AliasChoices("latitude"))
13
- longitude: float | None = Field(None, alias=AliasChoices("longitude"))
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, alias=AliasPath("studioProfiles", "isCrm"), exclude=True, repr=False)
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, alias=AliasPath("studioProfiles", "isWeb"), exclude=True, repr=False)
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
@@ -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, alias=AliasPath("maxHr", "type"))
70
- max_hr_value: int | None = Field(None, alias=AliasPath("maxHr", "value"))
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.models.bookings import Booking
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 get_class_uuid(class_or_uuid: "str | OtfClass") -> str:
30
- from otf_api.models.classes import OtfClass
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 isinstance(class_or_uuid, OtfClass):
36
- return class_or_uuid.class_uuid
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.3
1
+ Metadata-Version: 2.4
2
2
  Name: otf-api
3
- Version: 0.10.2
3
+ Version: 0.11.1
4
4
  Summary: Python OrangeTheory Fitness API Client
5
- License: MIT
6
- Author: Jessica Smith
7
- Author-email: j.smith.git1@gmail.com
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
- Requires-Dist: attrs (>=24.3.0,<25.0.0)
22
- Requires-Dist: cachetools (>=5.5.0)
23
- Requires-Dist: httpx (>=0.27.2)
24
- Requires-Dist: humanize (>=4.9.0,<5.0.0)
25
- Requires-Dist: inflection (==0.5.*)
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=VlLjNep7gdvPfnIDl1YbY8uY5eRM1vE4XJvNhI4mIB8,205
2
+ otf_api/api.py,sha256=95qIszuc2zjneEk-VhS9e-F5f2EnUs4lHGehL7rU2G4,67511
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=5cPELC9Soif5knDDHm55ii1OMEPkJlGUphAdbOEmaRo,13278
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.1.dist-info/licenses/LICENSE,sha256=UaPT9ynYigC3nX8n22_rC37n-qmTRKLFaHrtUwF9ktE,1071
35
+ otf_api-0.11.1.dist-info/METADATA,sha256=BEppb5v_YlMUirZck7aKOtVkTemE7Uvantq2Gk8_00Y,2145
36
+ otf_api-0.11.1.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
37
+ otf_api-0.11.1.dist-info/top_level.txt,sha256=KAhYg1X2YG0LkTuVRhUV1I_AReNZUVNdEan7cp0pEE4,8
38
+ otf_api-0.11.1.dist-info/RECORD,,
@@ -1,4 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.1.2
2
+ Generator: setuptools (80.7.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ otf_api