otf-api 0.10.1__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/auth/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
+ from .auth import HttpxCognitoAuth
1
2
  from .user import OtfUser
2
- from .utils import HttpxCognitoAuth
3
3
 
4
4
  __all__ = ["OTF_AUTH_TYPE", "HttpxCognitoAuth", "OtfAuth", "OtfUser"]
otf_api/auth/auth.py CHANGED
@@ -1,13 +1,19 @@
1
+ # pyright: reportTypedDictNotRequiredAccess=none
1
2
  import platform
2
3
  import typing
4
+ from collections.abc import Generator
3
5
  from logging import getLogger
4
6
  from pathlib import Path
5
7
  from time import sleep
6
8
  from typing import Any, ClassVar
7
9
 
10
+ import httpx
8
11
  from boto3 import Session
9
12
  from botocore import UNSIGNED
13
+ from botocore.auth import SigV4Auth
14
+ from botocore.awsrequest import AWSRequest
10
15
  from botocore.config import Config
16
+ from botocore.credentials import Credentials
11
17
  from botocore.exceptions import ClientError
12
18
  from pycognito import AWSSRP, Cognito
13
19
  from pycognito.aws_srp import generate_hash_device
@@ -15,6 +21,7 @@ from pycognito.aws_srp import generate_hash_device
15
21
  from otf_api.utils import CacheableData
16
22
 
17
23
  if typing.TYPE_CHECKING:
24
+ from mypy_boto3_cognito_identity import CognitoIdentityClient
18
25
  from mypy_boto3_cognito_idp import CognitoIdentityProviderClient
19
26
  from mypy_boto3_cognito_idp.type_defs import InitiateAuthResponseTypeDef
20
27
 
@@ -24,6 +31,9 @@ USER_POOL_ID = "us-east-1_dYDxUeyL1"
24
31
  REGION = "us-east-1"
25
32
  COGNITO_IDP_URL = f"https://cognito-idp.{REGION}.amazonaws.com/"
26
33
 
34
+ ID_POOL_ID = "us-east-1:4943c880-fb02-4fd7-bc37-2f4c32ecb2a3"
35
+ PROVIDER_KEY = f"cognito-idp.{REGION}.amazonaws.com/{USER_POOL_ID}"
36
+
27
37
  BOTO_CONFIG = Config(region_name=REGION, signature_version=UNSIGNED)
28
38
  CRED_CACHE = CacheableData("creds", Path("~/.otf-api"))
29
39
 
@@ -82,6 +92,11 @@ class OtfCognito(Cognito):
82
92
  self.mfa_tokens: dict[str, Any] = {}
83
93
  self.pool_domain_url: str | None = None
84
94
 
95
+ self.handle_login(username, password, id_token, access_token)
96
+
97
+ def handle_login(
98
+ self, username: str | None, password: str | None, id_token: str | None = None, access_token: str | None = None
99
+ ) -> None:
85
100
  try:
86
101
  dd = CRED_CACHE.get_cached_data(DEVICE_KEYS)
87
102
  except Exception:
@@ -93,9 +108,13 @@ class OtfCognito(Cognito):
93
108
  self.device_group_key = dd.get("device_group_key") # type: ignore
94
109
  self.device_password = dd.get("device_password") # type: ignore
95
110
 
96
- self.client: CognitoIdentityProviderClient = Session().client(
111
+ self.idp_client: CognitoIdentityProviderClient = Session().client(
97
112
  "cognito-idp", config=BOTO_CONFIG, region_name=REGION
98
- )
113
+ ) # type: ignore
114
+
115
+ self.id_client: CognitoIdentityClient = Session().client(
116
+ "cognito-identity", config=BOTO_CONFIG, region_name=REGION
117
+ ) # type: ignore
99
118
 
100
119
  try:
101
120
  token_cache = CRED_CACHE.get_cached_data(TOKEN_KEYS)
@@ -114,10 +133,28 @@ class OtfCognito(Cognito):
114
133
  self.access_token = token_cache["access_token"]
115
134
  self.refresh_token = token_cache["refresh_token"]
116
135
 
117
- self.check_token()
136
+ try:
137
+ self.check_token()
138
+ except ClientError as e:
139
+ if e.response["Error"]["Code"] == "NotAuthorizedException":
140
+ LOGGER.warning("Tokens expired, attempting to login with username and password")
141
+ CRED_CACHE.clear_cache()
142
+ if not username or not password:
143
+ raise NoCredentialsError("No credentials provided and no tokens cached, cannot authenticate")
144
+ self.handle_login(username, password)
145
+
118
146
  self.verify_tokens()
119
147
  CRED_CACHE.write_to_cache(self.tokens)
120
148
 
149
+ def get_identity_credentials(self):
150
+ """Get the AWS credentials for the user using the Cognito Identity Pool.
151
+ This is used to access AWS resources using the Cognito Identity Pool."""
152
+ cognito_id = self.id_client.get_id(IdentityPoolId=ID_POOL_ID, Logins={PROVIDER_KEY: self.id_token})
153
+ creds = self.id_client.get_credentials_for_identity(
154
+ IdentityId=cognito_id["IdentityId"], Logins={PROVIDER_KEY: self.id_token}
155
+ )
156
+ return creds["Credentials"]
157
+
121
158
  @property
122
159
  def tokens(self) -> dict[str, str]:
123
160
  tokens = {
@@ -146,7 +183,7 @@ class OtfCognito(Cognito):
146
183
  password=password,
147
184
  pool_id=USER_POOL_ID,
148
185
  client_id=CLIENT_ID,
149
- client=self.client,
186
+ client=self.idp_client,
150
187
  )
151
188
  try:
152
189
  tokens: InitiateAuthResponseTypeDef = aws.authenticate_user()
@@ -177,10 +214,10 @@ class OtfCognito(Cognito):
177
214
  self.device_group_key, self.device_key
178
215
  )
179
216
 
180
- self.client.confirm_device(
217
+ self.idp_client.confirm_device(
181
218
  AccessToken=self.access_token,
182
219
  DeviceKey=self.device_key,
183
- DeviceSecretVerifierConfig=device_secret_verifier_config,
220
+ DeviceSecretVerifierConfig=device_secret_verifier_config, # type: ignore (pycognito is untyped)
184
221
  DeviceName=self.device_name,
185
222
  )
186
223
 
@@ -206,7 +243,7 @@ class OtfCognito(Cognito):
206
243
  if not self.refresh_token:
207
244
  raise ValueError("No refresh token set - cannot renew access token")
208
245
 
209
- refresh_response = self.client.initiate_auth(
246
+ refresh_response = self.idp_client.initiate_auth(
210
247
  ClientId=self.client_id,
211
248
  AuthFlow="REFRESH_TOKEN_AUTH",
212
249
  AuthParameters={"REFRESH_TOKEN": self.refresh_token, "DEVICE_KEY": self.device_key},
@@ -221,6 +258,9 @@ class OtfCognito(Cognito):
221
258
  auth_result = tokens["AuthenticationResult"]
222
259
  device_metadata = auth_result.get("NewDeviceMetadata", {})
223
260
 
261
+ assert "AccessToken" in auth_result, "AccessToken not found in AuthenticationResult"
262
+ assert "IdToken" in auth_result, "IdToken not found in AuthenticationResult"
263
+
224
264
  # tokens - refresh token defaults to existing value if not present
225
265
  # note: verify_token also sets the token attribute
226
266
  self.verify_token(auth_result["AccessToken"], "access_token", "access")
@@ -232,3 +272,69 @@ class OtfCognito(Cognito):
232
272
  self.device_key = device_metadata.get("DeviceKey", self.device_key)
233
273
  self.device_group_key = device_metadata.get("DeviceGroupKey", self.device_group_key)
234
274
  CRED_CACHE.write_to_cache(self.device_metadata)
275
+
276
+
277
+ class HttpxCognitoAuth(httpx.Auth):
278
+ http_header: str = "Authorization"
279
+ http_header_prefix: str = "Bearer "
280
+
281
+ def __init__(self, cognito: OtfCognito):
282
+ """HTTPX Authentication extension for Cognito User Pools.
283
+
284
+ Args:
285
+ cognito (Cognito): A Cognito instance.
286
+ """
287
+
288
+ self.cognito = cognito
289
+
290
+ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, Any, None]:
291
+ self.cognito.check_token(renew=True)
292
+
293
+ token = self.cognito.id_token
294
+
295
+ assert isinstance(token, str), "Token is not a string"
296
+
297
+ request.headers[self.http_header] = self.http_header_prefix + token
298
+
299
+ if request.headers.get("SIGV4AUTH_REQUIRED"):
300
+ del request.headers["SIGV4AUTH_REQUIRED"]
301
+ yield from self.sign_httpx_request(request)
302
+ return
303
+
304
+ yield request
305
+
306
+ def sign_httpx_request(self, request: httpx.Request) -> Generator[httpx.Request, Any, None]:
307
+ """
308
+ Sign an HTTP request using AWS SigV4 for use with httpx.
309
+ """
310
+ headers = request.headers.copy()
311
+
312
+ # ensure this header is not included, it will break the signature
313
+ headers.pop("connection", None)
314
+
315
+ body = b"" if request.method in ("GET", "HEAD") else request.content or b""
316
+
317
+ if hasattr(body, "read"):
318
+ raise ValueError("Streaming bodies are not supported in signed requests")
319
+
320
+ creds = self.cognito.get_identity_credentials()
321
+
322
+ credentials = Credentials(
323
+ access_key=creds["AccessKeyId"], secret_key=creds["SecretKey"], token=creds["SessionToken"]
324
+ )
325
+
326
+ aws_request = AWSRequest(method=request.method, url=str(request.url), data=body, headers=headers)
327
+
328
+ SigV4Auth(credentials, "execute-api", REGION).add_auth(aws_request)
329
+
330
+ signed_headers = dict(aws_request.headers.items())
331
+
332
+ # Return a brand new request object with signed headers
333
+ signed_request = httpx.Request(
334
+ method=request.method,
335
+ url=request.url,
336
+ headers=signed_headers,
337
+ content=body,
338
+ )
339
+
340
+ yield signed_request
otf_api/auth/user.py CHANGED
@@ -2,8 +2,8 @@ from logging import getLogger
2
2
 
3
3
  import attrs
4
4
 
5
- from otf_api.auth.auth import CRED_CACHE, NoCredentialsError, OtfCognito
6
- from otf_api.auth.utils import HttpxCognitoAuth, can_provide_input, prompt_for_username_and_password
5
+ from otf_api.auth.auth import CRED_CACHE, HttpxCognitoAuth, NoCredentialsError, OtfCognito
6
+ from otf_api.auth.utils import can_provide_input, get_credentials_from_env, prompt_for_username_and_password
7
7
 
8
8
  LOGGER = getLogger(__name__)
9
9
 
@@ -47,12 +47,19 @@ class OtfUser:
47
47
  refresh_token=refresh_token,
48
48
  )
49
49
  except NoCredentialsError:
50
- if not can_provide_input():
51
- LOGGER.error("Unable to prompt for credentials in a non-interactive shell")
52
- raise
50
+ username, password = get_credentials_from_env()
51
+ if not username or not password:
52
+ if not can_provide_input():
53
+ LOGGER.error("Unable to prompt for credentials in a non-interactive shell")
54
+ raise
55
+ username, password = prompt_for_username_and_password()
56
+ if not username or not password:
57
+ raise NoCredentialsError("No credentials provided and no tokens cached, cannot authenticate")
58
+ except Exception as e:
59
+ LOGGER.exception("Failed to authenticate with Cognito")
60
+ raise e
53
61
 
54
- username, password = prompt_for_username_and_password()
55
- self.cognito = OtfCognito(username=username, password=password)
62
+ self.cognito = OtfCognito(username=username, password=password)
56
63
 
57
64
  self.cognito_id = self.cognito.access_claims["sub"]
58
65
  self.member_uuid = self.cognito.id_claims["cognito:username"]
otf_api/auth/utils.py CHANGED
@@ -1,14 +1,8 @@
1
1
  import os
2
2
  import sys
3
- from collections.abc import Generator
4
3
  from functools import partial
5
4
  from getpass import getpass
6
5
  from logging import getLogger
7
- from typing import Any
8
-
9
- import httpx
10
- from httpx import Request
11
- from pycognito import Cognito
12
6
 
13
7
  LOGGER = getLogger(__name__)
14
8
  USERNAME_PROMPT = "Enter your Orangetheory Fitness username/email: "
@@ -17,9 +11,9 @@ PASSWORD_PROMPT = "Enter your Orangetheory Fitness password: "
17
11
 
18
12
  def _show_error_message(message: str) -> None:
19
13
  try:
20
- from rich import print # type: ignore
14
+ from rich import get_console # type: ignore
21
15
 
22
- print(message, style="bold red")
16
+ get_console().print(message, style="bold red")
23
17
  except ImportError:
24
18
  print(message)
25
19
 
@@ -84,6 +78,23 @@ def _prompt_for_password() -> str:
84
78
  return password
85
79
 
86
80
 
81
+ def get_credentials_from_env() -> tuple[str, str]:
82
+ """Get credentials from environment variables.
83
+
84
+ Returns:
85
+ tuple[str, str]: A tuple containing the username and password.
86
+ """
87
+
88
+ username = os.getenv("OTF_EMAIL")
89
+ password = os.getenv("OTF_PASSWORD")
90
+
91
+ if not username or not password:
92
+ _show_error_message("Environment variables OTF_EMAIL and OTF_PASSWORD are required")
93
+ return "", ""
94
+
95
+ return username, password
96
+
97
+
87
98
  def prompt_for_username_and_password() -> tuple[str, str]:
88
99
  """Prompt for a username and password.
89
100
 
@@ -91,8 +102,8 @@ def prompt_for_username_and_password() -> tuple[str, str]:
91
102
  tuple[str, str]: A tuple containing the username and password.
92
103
  """
93
104
 
94
- username = os.getenv("OTF_EMAIL") or _prompt_for_username()
95
- password = os.getenv("OTF_PASSWORD") or _prompt_for_password()
105
+ username = _prompt_for_username()
106
+ password = _prompt_for_password()
96
107
 
97
108
  return username, password
98
109
 
@@ -104,26 +115,3 @@ def can_provide_input() -> bool:
104
115
  bool: True if the script is running in an interactive shell.
105
116
  """
106
117
  return os.isatty(sys.stdin.fileno()) and os.isatty(sys.stdout.fileno())
107
-
108
-
109
- class HttpxCognitoAuth(httpx.Auth):
110
- http_header: str = "Authorization"
111
- http_header_prefix: str = "Bearer "
112
-
113
- def __init__(self, cognito: Cognito):
114
- """HTTPX Authentication extension for Cognito User Pools.
115
-
116
- Args:
117
- cognito (Cognito): A Cognito instance.
118
- """
119
-
120
- self.cognito = cognito
121
-
122
- def auth_flow(self, request: Request) -> Generator[Request, Any, None]:
123
- self.cognito.check_token(renew=True)
124
-
125
- token = self.cognito.id_token
126
-
127
- request.headers[self.http_header] = self.http_header_prefix + token
128
-
129
- yield request
@@ -1,5 +1,6 @@
1
1
  from .body_composition_list import BodyCompositionData
2
2
  from .bookings import Booking
3
+ from .bookings_v2 import BookingV2, BookingV2Class
3
4
  from .challenge_tracker_content import ChallengeTracker
4
5
  from .challenge_tracker_detail import FitnessBenchmark
5
6
  from .classes import OtfClass
@@ -13,22 +14,26 @@ from .enums import (
13
14
  StatsTime,
14
15
  StudioStatus,
15
16
  )
16
- from .lifetime_stats import StatsResponse, TimeStats
17
+ from .lifetime_stats import InStudioStatsData, OutStudioStatsData, StatsResponse, TimeStats
17
18
  from .member_detail import MemberDetail
18
19
  from .member_membership import MemberMembership
19
20
  from .member_purchases import MemberPurchase
20
21
  from .notifications import EmailNotificationSettings, SmsNotificationSettings
21
22
  from .out_of_studio_workout_history import OutOfStudioWorkoutHistory
22
23
  from .performance_summary import PerformanceSummary
24
+ from .ratings import get_class_rating_value, get_coach_rating_value
23
25
  from .studio_detail import StudioDetail
24
26
  from .studio_services import StudioService
25
27
  from .telemetry import Telemetry, TelemetryHistoryItem
28
+ from .workout import Workout
26
29
 
27
30
  __all__ = [
28
31
  "HISTORICAL_BOOKING_STATUSES",
29
32
  "BodyCompositionData",
30
33
  "Booking",
31
34
  "BookingStatus",
35
+ "BookingV2",
36
+ "BookingV2Class",
32
37
  "ChallengeCategory",
33
38
  "ChallengeTracker",
34
39
  "ClassType",
@@ -36,11 +41,13 @@ __all__ = [
36
41
  "EmailNotificationSettings",
37
42
  "EquipmentType",
38
43
  "FitnessBenchmark",
44
+ "InStudioStatsData",
39
45
  "MemberDetail",
40
46
  "MemberMembership",
41
47
  "MemberPurchase",
42
48
  "OtfClass",
43
49
  "OutOfStudioWorkoutHistory",
50
+ "OutStudioStatsData",
44
51
  "PerformanceSummary",
45
52
  "SmsNotificationSettings",
46
53
  "StatsResponse",
@@ -51,4 +58,7 @@ __all__ = [
51
58
  "Telemetry",
52
59
  "TelemetryHistoryItem",
53
60
  "TimeStats",
61
+ "Workout",
62
+ "get_class_rating_value",
63
+ "get_coach_rating_value",
54
64
  ]
@@ -36,6 +36,10 @@ class OtfClass(OtfItemBase):
36
36
  program_name: str | None = Field(None, alias="programName", exclude=True, repr=False)
37
37
  virtual_class: bool | None = Field(None, alias="virtualClass", exclude=True, repr=False)
38
38
 
39
+ def __str__(self) -> str:
40
+ starts_at_str = self.starts_at.strftime("%a %b %d, %I:%M %p")
41
+ return f"Class: {starts_at_str} {self.name} - {self.coach.first_name}"
42
+
39
43
 
40
44
  class Booking(OtfItemBase):
41
45
  booking_uuid: str = Field(alias="classBookingUUId", description="ID used to cancel the booking")
@@ -0,0 +1,171 @@
1
+ from datetime import datetime
2
+ from logging import getLogger
3
+
4
+ import pendulum
5
+ from pydantic import AliasPath, Field
6
+
7
+ from otf_api.models.base import OtfItemBase
8
+ from otf_api.models.enums import BookingStatus, ClassType
9
+ from otf_api.models.mixins import AddressMixin, PhoneLongitudeLatitudeMixin
10
+ from otf_api.models.performance_summary import ZoneTimeMinutes
11
+
12
+ LOGGER = getLogger(__name__)
13
+
14
+
15
+ class Address(AddressMixin, OtfItemBase): ...
16
+
17
+
18
+ def get_end_time(start_time: datetime, class_type: ClassType) -> datetime:
19
+ """
20
+ Get the end time of a class based on the start time and class type.
21
+ """
22
+
23
+ start_time = pendulum.instance(start_time)
24
+
25
+ match class_type:
26
+ case ClassType.ORANGE_60:
27
+ return start_time.add(minutes=60)
28
+ case ClassType.ORANGE_90:
29
+ return start_time.add(minutes=90)
30
+ case ClassType.STRENGTH_50 | ClassType.TREAD_50:
31
+ return start_time.add(minutes=50)
32
+ case ClassType.OTHER:
33
+ LOGGER.warning(
34
+ f"Class type {class_type} does not have defined length, returning start time plus 60 minutes"
35
+ )
36
+ return start_time.add(minutes=60)
37
+ case _:
38
+ LOGGER.warning(f"Class type {class_type} is not recognized, returning start time plus 60 minutes")
39
+ return start_time.add(minutes=60)
40
+
41
+
42
+ class Rating(OtfItemBase):
43
+ id: str
44
+ description: str
45
+ value: int
46
+
47
+
48
+ class BookingV2Studio(PhoneLongitudeLatitudeMixin, OtfItemBase):
49
+ studio_uuid: str = Field(alias="id")
50
+ name: str | None = None
51
+ time_zone: str | None = None
52
+ email: str | None = None
53
+ address: Address | None = None
54
+
55
+ currency_code: str | None = Field(None, repr=False, exclude=True)
56
+ mbo_studio_id: str | None = Field(None, description="MindBody attr", repr=False, exclude=True)
57
+
58
+
59
+ class BookingV2Class(OtfItemBase):
60
+ class_id: str = Field(alias="id", description="Matches the `class_id` attribute of the OtfClass model")
61
+ name: str
62
+ class_type: ClassType = Field(alias="type")
63
+ starts_at: datetime = Field(
64
+ alias="starts_at_local",
65
+ description="The start time of the class. Reflects local time, but the object does not have a timezone.",
66
+ )
67
+ studio: BookingV2Studio | None = None
68
+ coach: str | None = Field(None, validation_alias=AliasPath("coach", "first_name"))
69
+
70
+ class_uuid: str | None = Field(
71
+ None, alias="ot_base_class_uuid", description="Only present when class is ratable", exclude=True, repr=False
72
+ )
73
+ starts_at_utc: datetime | None = Field(None, alias="starts_at", exclude=True, repr=False)
74
+
75
+ def __str__(self) -> str:
76
+ starts_at_str = self.starts_at.strftime("%a %b %d, %I:%M %p")
77
+ return f"Class: {starts_at_str} {self.name} - {self.coach}"
78
+
79
+
80
+ class BookingV2Workout(OtfItemBase):
81
+ id: str
82
+ performance_summary_id: str = Field(..., alias="id", description="Alias to id, to simplify the API")
83
+ calories_burned: int
84
+ splat_points: int
85
+ step_count: int
86
+ active_time_seconds: int
87
+ zone_time_minutes: ZoneTimeMinutes
88
+
89
+
90
+ class BookingV2(OtfItemBase):
91
+ booking_id: str = Field(
92
+ ..., alias="id", description="The booking ID used to cancel the booking - must be canceled through new endpoint"
93
+ )
94
+
95
+ member_uuid: str = Field(..., alias="member_id")
96
+ service_name: str | None = Field(None, description="Represents tier of member")
97
+
98
+ cross_regional: bool | None = None
99
+ intro: bool | None = None
100
+ checked_in: bool
101
+ canceled: bool
102
+ late_canceled: bool | None = None
103
+ canceled_at: datetime | None = None
104
+ ratable: bool
105
+
106
+ otf_class: BookingV2Class = Field(..., alias="class")
107
+ workout: BookingV2Workout | None = None
108
+ coach_rating: Rating | None = Field(None, validation_alias=AliasPath("ratings", "coach"))
109
+ class_rating: Rating | None = Field(None, validation_alias=AliasPath("ratings", "class"))
110
+
111
+ paying_studio_id: str | None = None
112
+ mbo_booking_id: str | None = None
113
+ mbo_unique_id: str | None = None
114
+ mbo_paying_unique_id: str | None = None
115
+ person_id: str
116
+
117
+ created_at: datetime | None = Field(
118
+ None,
119
+ description="Date the booking was created in the system, not when the booking was made",
120
+ exclude=True,
121
+ repr=False,
122
+ )
123
+ updated_at: datetime | None = Field(
124
+ None, description="Date the booking was updated, not when the booking was made", exclude=True, repr=False
125
+ )
126
+
127
+ @property
128
+ def status(self) -> BookingStatus:
129
+ """Emulates the booking status from the old API, but with less specificity"""
130
+ if self.late_canceled:
131
+ return BookingStatus.LateCancelled
132
+
133
+ if self.checked_in:
134
+ return BookingStatus.CheckedIn
135
+
136
+ if self.canceled:
137
+ return BookingStatus.Cancelled
138
+
139
+ return BookingStatus.Booked
140
+
141
+ @property
142
+ def studio_uuid(self) -> str:
143
+ """Shortcut to get the studio UUID"""
144
+ if self.otf_class.studio is None:
145
+ return ""
146
+ return self.otf_class.studio.studio_uuid
147
+
148
+ @property
149
+ def class_uuid(self) -> str:
150
+ """Shortcut to get the class UUID"""
151
+ if self.otf_class.class_uuid is None:
152
+ return ""
153
+ return self.otf_class.class_uuid
154
+
155
+ @property
156
+ def starts_at(self) -> datetime:
157
+ """Shortcut to get the class start time"""
158
+ return self.otf_class.starts_at
159
+
160
+ @property
161
+ def ends_at(self) -> datetime:
162
+ """Shortcut to get the class end time"""
163
+ return get_end_time(self.otf_class.starts_at, self.otf_class.class_type)
164
+
165
+ def __str__(self) -> str:
166
+ starts_at_str = self.otf_class.starts_at.strftime("%a %b %d, %I:%M %p")
167
+ class_name = self.otf_class.name
168
+ coach_name = self.otf_class.coach
169
+ booked_str = self.status.value
170
+
171
+ return f"Booking: {starts_at_str} {class_name} - {coach_name} ({booked_str})"
@@ -11,7 +11,7 @@ endpoint.
11
11
  from pydantic import Field
12
12
 
13
13
  from otf_api.models.base import OtfItemBase
14
- from otf_api.models.enums import ChallengeCategory, EquipmentType
14
+ from otf_api.models.enums import EquipmentType
15
15
 
16
16
 
17
17
  class Year(OtfItemBase):
@@ -39,7 +39,7 @@ class Challenge(OtfItemBase):
39
39
  # all related to the ChallengeType enums or the few SubCategory enums I've
40
40
  # been able to puzzle out. I haven't been able to link them to any code
41
41
  # in the OTF app. Due to that, they are being excluded from the model for now.
42
- challenge_category_id: ChallengeCategory | None = Field(None, alias="ChallengeCategoryId")
42
+ challenge_category_id: int | None = Field(None, alias="ChallengeCategoryId")
43
43
  challenge_sub_category_id: int | None = Field(None, alias="ChallengeSubCategoryId")
44
44
  challenge_name: str | None = Field(None, alias="ChallengeName")
45
45
  years: list[Year] = Field(default_factory=list, alias="Years")
@@ -4,7 +4,7 @@ from typing import Any
4
4
  from pydantic import Field
5
5
 
6
6
  from otf_api.models.base import OtfItemBase
7
- from otf_api.models.enums import ChallengeCategory, EquipmentType
7
+ from otf_api.models.enums import EquipmentType
8
8
 
9
9
 
10
10
  class MetricEntry(OtfItemBase):
@@ -74,7 +74,7 @@ class Goal(OtfItemBase):
74
74
 
75
75
 
76
76
  class FitnessBenchmark(OtfItemBase):
77
- challenge_category_id: ChallengeCategory | None = Field(None, alias="ChallengeCategoryId")
77
+ challenge_category_id: int | None = Field(None, alias="ChallengeCategoryId")
78
78
  challenge_sub_category_id: int | None = Field(None, alias="ChallengeSubCategoryId")
79
79
  equipment_id: EquipmentType = Field(None, alias="EquipmentId")
80
80
  equipment_name: str | None = Field(None, alias="EquipmentName")
otf_api/models/classes.py CHANGED
@@ -9,9 +9,11 @@ from otf_api.models.studio_detail import StudioDetail
9
9
 
10
10
  class OtfClass(OtfItemBase):
11
11
  class_uuid: str = Field(alias="ot_base_class_uuid", description="The OTF class UUID")
12
+ class_id: str | None = Field(None, alias="id", description="Matches new booking endpoint class id")
13
+
12
14
  name: str | None = Field(None, description="The name of the class")
13
15
  class_type: ClassType = Field(alias="type")
14
- coach: str | None = Field(None, alias=AliasPath("coach", "first_name"))
16
+ coach: str | None = Field(None, validation_alias=AliasPath("coach", "first_name"))
15
17
  ends_at: datetime = Field(
16
18
  alias="ends_at_local",
17
19
  description="The end time of the class. Reflects local time, but the object does not have a timezone.",
@@ -27,14 +29,11 @@ class OtfClass(OtfItemBase):
27
29
  full: bool | None = None
28
30
  max_capacity: int | None = None
29
31
  waitlist_available: bool | None = None
30
- waitlist_size: int | None = None
32
+ waitlist_size: int | None = Field(None, description="The number of people on the waitlist")
31
33
  is_booked: bool | None = Field(None, description="Custom helper field to determine if class is already booked")
32
34
  is_cancelled: bool | None = Field(None, alias="canceled")
33
35
  is_home_studio: bool | None = Field(None, description="Custom helper field to determine if at home studio")
34
36
 
35
- # unused fields
36
- class_id: str | None = Field(None, alias="id", exclude=True, repr=False, description="Not used by API")
37
-
38
37
  created_at: datetime | None = Field(None, exclude=True, repr=False)
39
38
  ends_at_utc: datetime | None = Field(None, alias="ends_at", exclude=True, repr=False)
40
39
  mbo_class_description_id: str | None = Field(None, exclude=True, repr=False, description="MindBody attr")
otf_api/models/enums.py CHANGED
@@ -157,6 +157,7 @@ class ChallengeCategory(IntEnum):
157
157
  RemixInSix = 65
158
158
  Push = 66
159
159
  QuarterMileTreadmill = 69
160
+ OneThousandMeterRow = 70
160
161
 
161
162
 
162
163
  class DriTriChallengeSubCategory(IntEnum):