otf-api 0.12.0__py3-none-any.whl → 0.13.0__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.
Files changed (74) hide show
  1. otf_api/__init__.py +35 -3
  2. otf_api/api/__init__.py +3 -0
  3. otf_api/api/_compat.py +77 -0
  4. otf_api/api/api.py +80 -0
  5. otf_api/api/bookings/__init__.py +3 -0
  6. otf_api/api/bookings/booking_api.py +541 -0
  7. otf_api/api/bookings/booking_client.py +112 -0
  8. otf_api/api/client.py +203 -0
  9. otf_api/api/members/__init__.py +3 -0
  10. otf_api/api/members/member_api.py +187 -0
  11. otf_api/api/members/member_client.py +112 -0
  12. otf_api/api/studios/__init__.py +3 -0
  13. otf_api/api/studios/studio_api.py +173 -0
  14. otf_api/api/studios/studio_client.py +120 -0
  15. otf_api/api/utils.py +307 -0
  16. otf_api/api/workouts/__init__.py +3 -0
  17. otf_api/api/workouts/workout_api.py +333 -0
  18. otf_api/api/workouts/workout_client.py +140 -0
  19. otf_api/auth/__init__.py +1 -1
  20. otf_api/auth/auth.py +155 -89
  21. otf_api/auth/user.py +5 -17
  22. otf_api/auth/utils.py +27 -2
  23. otf_api/cache.py +132 -0
  24. otf_api/exceptions.py +18 -6
  25. otf_api/models/__init__.py +25 -21
  26. otf_api/models/bookings/__init__.py +23 -0
  27. otf_api/models/bookings/bookings.py +134 -0
  28. otf_api/models/{bookings_v2.py → bookings/bookings_v2.py} +72 -31
  29. otf_api/models/bookings/classes.py +124 -0
  30. otf_api/models/{enums.py → bookings/enums.py} +7 -81
  31. otf_api/{filters.py → models/bookings/filters.py} +39 -11
  32. otf_api/models/{ratings.py → bookings/ratings.py} +2 -6
  33. otf_api/models/members/__init__.py +5 -0
  34. otf_api/models/members/member_detail.py +149 -0
  35. otf_api/models/members/member_membership.py +26 -0
  36. otf_api/models/members/member_purchases.py +29 -0
  37. otf_api/models/members/notifications.py +17 -0
  38. otf_api/models/mixins.py +48 -1
  39. otf_api/models/studios/__init__.py +5 -0
  40. otf_api/models/studios/enums.py +11 -0
  41. otf_api/models/studios/studio_detail.py +93 -0
  42. otf_api/models/studios/studio_services.py +36 -0
  43. otf_api/models/workouts/__init__.py +31 -0
  44. otf_api/models/{body_composition_list.py → workouts/body_composition_list.py} +140 -71
  45. otf_api/models/workouts/challenge_tracker_content.py +50 -0
  46. otf_api/models/workouts/challenge_tracker_detail.py +99 -0
  47. otf_api/models/workouts/enums.py +70 -0
  48. otf_api/models/workouts/lifetime_stats.py +96 -0
  49. otf_api/models/workouts/out_of_studio_workout_history.py +32 -0
  50. otf_api/models/{performance_summary.py → workouts/performance_summary.py} +19 -5
  51. otf_api/models/workouts/telemetry.py +88 -0
  52. otf_api/models/{workout.py → workouts/workout.py} +34 -20
  53. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/METADATA +4 -2
  54. otf_api-0.13.0.dist-info/RECORD +59 -0
  55. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/WHEEL +1 -1
  56. otf_api/api.py +0 -1682
  57. otf_api/logging.py +0 -19
  58. otf_api/models/bookings.py +0 -109
  59. otf_api/models/challenge_tracker_content.py +0 -59
  60. otf_api/models/challenge_tracker_detail.py +0 -88
  61. otf_api/models/classes.py +0 -70
  62. otf_api/models/lifetime_stats.py +0 -78
  63. otf_api/models/member_detail.py +0 -121
  64. otf_api/models/member_membership.py +0 -26
  65. otf_api/models/member_purchases.py +0 -29
  66. otf_api/models/notifications.py +0 -17
  67. otf_api/models/out_of_studio_workout_history.py +0 -32
  68. otf_api/models/studio_detail.py +0 -71
  69. otf_api/models/studio_services.py +0 -36
  70. otf_api/models/telemetry.py +0 -84
  71. otf_api/utils.py +0 -164
  72. otf_api-0.12.0.dist-info/RECORD +0 -38
  73. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/licenses/LICENSE +0 -0
  74. {otf_api-0.12.0.dist-info → otf_api-0.13.0.dist-info}/top_level.txt +0 -0
@@ -1,8 +1,9 @@
1
1
  from datetime import date, datetime, time
2
2
 
3
+ import pendulum
3
4
  from pydantic import BaseModel, field_validator
4
5
 
5
- from otf_api.models import ClassType, DoW, OtfClass
6
+ from otf_api.models.bookings import ClassType, DoW, OtfClass
6
7
 
7
8
 
8
9
  class ClassFilter(BaseModel):
@@ -28,11 +29,11 @@ class ClassFilter(BaseModel):
28
29
  start_time (list[time]): Filter classes by start time.
29
30
  """
30
31
 
31
- start_date: date | None = None
32
- end_date: date | None = None
33
- class_type: list[ClassType] | None = None
34
- day_of_week: list[DoW] | None = None
35
- start_time: list[time] | None = None
32
+ start_date: date | str | None = None
33
+ end_date: date | str | None = None
34
+ class_type: list[ClassType] | ClassType | None = None
35
+ day_of_week: list[DoW] | DoW | None = None
36
+ start_time: list[time] | time | None = None
36
37
 
37
38
  def filter_classes(self, classes: list[OtfClass]) -> list[OtfClass]:
38
39
  """Filters a list of classes based on the filter arguments.
@@ -51,9 +52,11 @@ class ClassFilter(BaseModel):
51
52
  self.end_date = self.end_date.date()
52
53
 
53
54
  if self.start_date:
55
+ assert isinstance(self.start_date, date), "start_date must be a date object"
54
56
  classes = [c for c in classes if c.starts_at.date() >= self.start_date]
55
57
 
56
58
  if self.end_date:
59
+ assert isinstance(self.end_date, date), "end_date must be a date object"
57
60
  classes = [c for c in classes if c.starts_at.date() <= self.end_date]
58
61
 
59
62
  if self.class_type:
@@ -63,35 +66,60 @@ class ClassFilter(BaseModel):
63
66
  classes = [c for c in classes if c.day_of_week in self.day_of_week]
64
67
 
65
68
  if self.start_time:
69
+ assert isinstance(self.start_time, list), "start_time must be a list of time objects"
66
70
  classes = [c for c in classes if c.starts_at.time() in self.start_time]
67
71
 
68
72
  return classes
69
73
 
70
74
  @field_validator("class_type", "day_of_week", "start_time", mode="before")
71
75
  @classmethod
72
- def _single_item_to_list(cls, v):
76
+ def _single_item_to_list(cls, v: str | list | None) -> list | None:
73
77
  if v and not isinstance(v, list):
74
78
  return [v]
79
+
80
+ if not v:
81
+ return None
82
+
75
83
  return v
76
84
 
77
85
  @field_validator("day_of_week", mode="before")
78
86
  @classmethod
79
- def _day_of_week_str_to_enum(cls, v):
87
+ def _day_of_week_str_to_enum(cls, v: str | list[str] | None) -> list[DoW] | None:
80
88
  if v and isinstance(v, str):
81
89
  return [DoW(v.title())]
82
90
 
83
91
  if v and isinstance(v, list) and not all(isinstance(i, DoW) for i in v):
84
92
  return [DoW(i.title()) for i in v]
85
93
 
86
- return v
94
+ if not v:
95
+ return None
96
+
97
+ return v # type: ignore
87
98
 
88
99
  @field_validator("class_type", mode="before")
89
100
  @classmethod
90
- def _class_type_str_to_enum(cls, v):
101
+ def _class_type_str_to_enum(cls, v: str | list[str] | None) -> list[ClassType] | None:
91
102
  if v and isinstance(v, str):
92
103
  return [ClassType.get_case_insensitive(v)]
93
104
 
94
105
  if v and isinstance(v, list) and not all(isinstance(i, ClassType) for i in v):
95
106
  return [ClassType.get_case_insensitive(i) for i in v]
96
107
 
97
- return v
108
+ if not v:
109
+ return None
110
+
111
+ return v # type: ignore
112
+
113
+ @field_validator("start_date", "end_date", mode="before")
114
+ @classmethod
115
+ def _date_string_to_date(cls, v: str | date | None) -> date | None:
116
+ if v is None:
117
+ return None
118
+ if isinstance(v, date):
119
+ return v
120
+
121
+ try:
122
+ value = pendulum.parse(v)
123
+ return value.date() if isinstance(value, datetime) else value # type: ignore
124
+ except (ValueError, TypeError):
125
+ raise ValueError(f"Invalid date format: {v}. Expected format is YYYY-MM-DD or a date object.")
@@ -9,9 +9,7 @@ CLASS_RATING_MAP = {0: 0, 1: 19, 2: 20, 3: 21}
9
9
 
10
10
 
11
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
- """
12
+ """Convert the class rating from the old values to the new values."""
15
13
  if class_rating not in CLASS_RATING_MAP:
16
14
  raise ValueError(f"Invalid class rating {class_rating}")
17
15
 
@@ -19,9 +17,7 @@ def get_class_rating_value(class_rating: int) -> int:
19
17
 
20
18
 
21
19
  def get_coach_rating_value(coach_rating: int) -> int:
22
- """
23
- Convert the coach rating from the old values to the new values.
24
- """
20
+ """Convert the coach rating from the old values to the new values."""
25
21
  if coach_rating not in COACH_RATING_MAP:
26
22
  raise ValueError(f"Invalid coach rating {coach_rating}")
27
23
 
@@ -0,0 +1,5 @@
1
+ from .member_detail import MemberDetail
2
+ from .member_membership import MemberMembership
3
+ from .member_purchases import MemberPurchase
4
+
5
+ __all__ = ["MemberDetail", "MemberMembership", "MemberPurchase"]
@@ -0,0 +1,149 @@
1
+ from datetime import date, datetime
2
+
3
+ from pydantic import Field, field_validator
4
+
5
+ from otf_api.models.base import OtfItemBase
6
+ from otf_api.models.mixins import AddressMixin, ApiMixin
7
+ from otf_api.models.studios.studio_detail import StudioDetail
8
+
9
+
10
+ class Address(AddressMixin, OtfItemBase):
11
+ member_address_uuid: str | None = Field(None, validation_alias="memberAddressUUId", exclude=True, repr=False)
12
+ type: str | None = None
13
+
14
+
15
+ class MemberProfile(OtfItemBase):
16
+ unit_of_measure: str | None = Field(None, validation_alias="unitOfMeasure")
17
+ max_hr_type: str | None = Field(None, validation_alias="maxHrType")
18
+ manual_max_hr: int | None = Field(None, validation_alias="manualMaxHr")
19
+ formula_max_hr: int | None = Field(None, validation_alias="formulaMaxHr")
20
+ automated_hr: int | None = Field(None, validation_alias="automatedHr")
21
+
22
+ member_profile_uuid: str | None = Field(None, validation_alias="memberProfileUUId", exclude=True, repr=False)
23
+ member_optin_flow_type_id: int | None = Field(
24
+ None, validation_alias="memberOptinFlowTypeId", exclude=True, repr=False
25
+ )
26
+
27
+
28
+ class MemberClassSummary(OtfItemBase):
29
+ total_classes_booked: int | None = Field(None, validation_alias="totalClassesBooked")
30
+ total_classes_attended: int | None = Field(None, validation_alias="totalClassesAttended")
31
+ total_intro_classes: int | None = Field(None, validation_alias="totalIntro")
32
+ total_ot_live_classes_booked: int | None = Field(None, validation_alias="totalOTLiveClassesBooked")
33
+ total_ot_live_classes_attended: int | None = Field(None, validation_alias="totalOTLiveClassesAttended")
34
+ total_classes_used_hrm: int | None = Field(None, validation_alias="totalClassesUsedHRM")
35
+ total_studios_visited: int | None = Field(None, validation_alias="totalStudiosVisited")
36
+ first_visit_date: date | None = Field(None, validation_alias="firstVisitDate")
37
+ last_class_visited_date: date | None = Field(None, validation_alias="lastClassVisitedDate")
38
+ last_class_booked_date: date | None = Field(None, validation_alias="lastClassBookedDate")
39
+
40
+ last_class_studio_visited: int | None = Field(
41
+ None, validation_alias="lastClassStudioVisited", exclude=True, repr=False
42
+ )
43
+
44
+
45
+ class MemberDetail(ApiMixin, OtfItemBase):
46
+ member_uuid: str = Field(..., validation_alias="memberUUId")
47
+ cognito_id: str = Field(
48
+ ...,
49
+ validation_alias="cognitoId",
50
+ exclude=True,
51
+ repr=False,
52
+ description="Cognito user ID, not necessary for end users. Also on OtfUser object.",
53
+ )
54
+
55
+ home_studio: StudioDetail
56
+ profile: MemberProfile = Field(..., validation_alias="memberProfile")
57
+ class_summary: MemberClassSummary | None = Field(None, validation_alias="memberClassSummary")
58
+ addresses: list[Address] | None = Field(default_factory=list)
59
+
60
+ studio_display_name: str | None = Field(
61
+ None,
62
+ validation_alias="userName",
63
+ description="The value that is displayed on tread/rower tablets and OTBeat screens",
64
+ )
65
+ first_name: str | None = Field(None, validation_alias="firstName")
66
+ last_name: str | None = Field(None, validation_alias="lastName")
67
+ email: str | None = Field(None, validation_alias="email")
68
+ phone_number: str | None = Field(None, validation_alias="phoneNumber")
69
+ birth_day: date | None = Field(None, validation_alias="birthDay")
70
+ gender: str | None = Field(None, validation_alias="gender")
71
+ locale: str | None = Field(None, validation_alias="locale")
72
+ weight: int | None = Field(None, validation_alias="weight")
73
+ weight_units: str | None = Field(None, validation_alias="weightMeasure")
74
+ height: int | None = Field(None, validation_alias="height")
75
+ height_units: str | None = Field(None, validation_alias="heightMeasure")
76
+
77
+ # unused fields - leaving these in for now in case someone finds a purpose for them
78
+ # but they will potentially (likely?) be removed in the future
79
+
80
+ # mbo fields
81
+ mbo_id: str | None = Field(None, validation_alias="mboId", exclude=True, repr=False, description="MindBody attr")
82
+ mbo_status: str | None = Field(
83
+ None, validation_alias="mboStatus", exclude=True, repr=False, description="MindBody attr"
84
+ )
85
+ mbo_studio_id: int | None = Field(
86
+ None, validation_alias="mboStudioId", exclude=True, repr=False, description="MindBody attr"
87
+ )
88
+ mbo_unique_id: int | None = Field(
89
+ None, validation_alias="mboUniqueId", exclude=True, repr=False, description="MindBody attr"
90
+ )
91
+
92
+ # ids
93
+ created_by: str | None = Field(None, validation_alias="createdBy", exclude=True, repr=False)
94
+ home_studio_id: int | None = Field(
95
+ None, validation_alias="homeStudioId", exclude=True, repr=False, description="Not used by API"
96
+ )
97
+ member_id: int | None = Field(
98
+ None, validation_alias="memberId", exclude=True, repr=False, description="Not used by API"
99
+ )
100
+ otf_acs_id: str | None = Field(None, validation_alias="otfAcsId", exclude=True, repr=False)
101
+ updated_by: str | None = Field(None, validation_alias="updatedBy", exclude=True, repr=False)
102
+
103
+ # unused address/member detail fields
104
+ created_date: datetime | None = Field(None, validation_alias="createdDate", exclude=True, repr=False)
105
+ updated_date: datetime | None = Field(None, validation_alias="updatedDate", exclude=True, repr=False)
106
+
107
+ address_line1: str | None = Field(None, validation_alias="addressLine1", exclude=True, repr=False)
108
+ address_line2: str | None = Field(None, validation_alias="addressLine2", exclude=True, repr=False)
109
+ alternate_emails: None = Field(None, validation_alias="alternateEmails", exclude=True, repr=False)
110
+ cc_last4: str | None = Field(None, validation_alias="ccLast4", exclude=True, repr=False)
111
+ cc_type: str | None = Field(None, validation_alias="ccType", exclude=True, repr=False)
112
+ city: str | None = Field(None, exclude=True, repr=False)
113
+ home_phone: str | None = Field(None, validation_alias="homePhone", exclude=True, repr=False)
114
+ intro_neccessary: bool | None = Field(None, validation_alias="introNeccessary", exclude=True, repr=False)
115
+ is_deleted: bool | None = Field(None, validation_alias="isDeleted", exclude=True, repr=False)
116
+ is_member_verified: bool | None = Field(None, validation_alias="isMemberVerified", exclude=True, repr=False)
117
+ lead_prospect: bool | None = Field(None, validation_alias="leadProspect", exclude=True, repr=False)
118
+ max_hr: int | None = Field(
119
+ None, validation_alias="maxHr", exclude=True, repr=False, description="Also found in member_profile"
120
+ )
121
+ online_signup: None = Field(None, validation_alias="onlineSignup", exclude=True, repr=False)
122
+ phone_type: None = Field(None, validation_alias="phoneType", exclude=True, repr=False)
123
+ postal_code: str | None = Field(None, validation_alias="postalCode", exclude=True, repr=False)
124
+ state: str | None = Field(None, exclude=True, repr=False)
125
+ work_phone: str | None = Field(None, validation_alias="workPhone", exclude=True, repr=False)
126
+ year_imported: int | None = Field(None, validation_alias="yearImported", exclude=True, repr=False)
127
+
128
+ @field_validator("birth_day")
129
+ @classmethod
130
+ def validate_birth_day(cls, value: date | str | None, **_kwargs) -> date | None:
131
+ """Convert birth_day to a date object if it is in the format of YYYY-MM-DD."""
132
+ if value is None:
133
+ return value
134
+ if not isinstance(value, date):
135
+ return datetime.strptime(value, "%Y-%m-%d").date()
136
+ return value
137
+
138
+ def update_name(self, first_name: str | None = None, last_name: str | None = None) -> None:
139
+ """Update the name of the member.
140
+
141
+ Args:
142
+ first_name (str | None): The new first name of the member.
143
+ last_name (str | None): The new last name of the member.
144
+ """
145
+ self.raise_if_api_not_set()
146
+
147
+ updated_member = self._api.members.update_member_name(first_name, last_name)
148
+ self.first_name = updated_member.first_name
149
+ self.last_name = updated_member.last_name
@@ -0,0 +1,26 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import Field
4
+
5
+ from otf_api.models.base import OtfItemBase
6
+
7
+
8
+ class MemberMembership(OtfItemBase):
9
+ payment_date: datetime | None = Field(None, validation_alias="paymentDate")
10
+ active_date: datetime | None = Field(None, validation_alias="activeDate")
11
+ expiration_date: datetime | None = Field(None, validation_alias="expirationDate")
12
+ current: bool | None = None
13
+ count: int | None = None
14
+ remaining: int | None = None
15
+ name: str | None = None
16
+ updated_date: datetime | None = Field(None, validation_alias="updatedDate")
17
+ created_date: datetime | None = Field(None, validation_alias="createdDate")
18
+ is_deleted: bool | None = Field(None, validation_alias="isDeleted")
19
+
20
+ member_membership_id: int | None = Field(None, validation_alias="memberMembershipId", exclude=True, repr=False)
21
+ member_membership_uuid: str | None = Field(None, validation_alias="memberMembershipUUId", exclude=True, repr=False)
22
+ membership_id: int | None = Field(None, validation_alias="membershipId", exclude=True, repr=False)
23
+ member_id: int | None = Field(None, validation_alias="memberId", exclude=True, repr=False)
24
+ mbo_description_id: str | None = Field(None, validation_alias="mboDescriptionId", exclude=True, repr=False)
25
+ created_by: str | None = Field(None, validation_alias="createdBy", exclude=True, repr=False)
26
+ updated_by: str | None = Field(None, validation_alias="updatedBy", exclude=True, repr=False)
@@ -0,0 +1,29 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import Field
4
+
5
+ from otf_api.models.base import OtfItemBase
6
+ from otf_api.models.studios import StudioDetail
7
+
8
+
9
+ class MemberPurchase(OtfItemBase):
10
+ purchase_uuid: str = Field(..., validation_alias="memberPurchaseUUId")
11
+ name: str | None = None
12
+ price: str | None = None
13
+ purchase_date_time: datetime | None = Field(None, validation_alias="memberPurchaseDateTime")
14
+ purchase_type: str | None = Field(None, validation_alias="memberPurchaseType")
15
+ status: str | None = None
16
+ quantity: int | None = None
17
+ studio: StudioDetail = Field(..., exclude=True, repr=False)
18
+
19
+ member_fee_id: int | None = Field(None, validation_alias="memberFeeId", exclude=True, repr=False)
20
+ member_id: int | None = Field(..., validation_alias="memberId", exclude=True, repr=False)
21
+ member_membership_id: int | None = Field(None, validation_alias="memberMembershipId", exclude=True, repr=False)
22
+ member_purchase_id: int | None = Field(..., validation_alias="memberPurchaseId", exclude=True, repr=False)
23
+ member_service_id: int | None = Field(None, validation_alias="memberServiceId", exclude=True, repr=False)
24
+ pos_contract_id: int | None = Field(None, validation_alias="posContractId", exclude=True, repr=False)
25
+ pos_description_id: int | None = Field(None, validation_alias="posDescriptionId", exclude=True, repr=False)
26
+ pos_pmt_ref_no: int | None = Field(None, validation_alias="posPmtRefNo", exclude=True, repr=False)
27
+ pos_product_id: int | None = Field(..., validation_alias="posProductId", exclude=True, repr=False)
28
+ pos_sale_id: int | None = Field(..., validation_alias="posSaleId", exclude=True, repr=False)
29
+ studio_id: int | None = Field(..., validation_alias="studioId", exclude=True, repr=False)
@@ -0,0 +1,17 @@
1
+ from pydantic import Field
2
+
3
+ from otf_api.models.base import OtfItemBase
4
+
5
+
6
+ class SmsNotificationSettings(OtfItemBase):
7
+ is_promotional_sms_opt_in: bool | None = Field(None, validation_alias="isPromotionalSmsOptIn")
8
+ is_transactional_sms_opt_in: bool | None = Field(None, validation_alias="isTransactionalSmsOptIn")
9
+ is_promotional_phone_opt_in: bool | None = Field(None, validation_alias="isPromotionalPhoneOptIn")
10
+ is_transactional_phone_opt_in: bool | None = Field(None, validation_alias="isTransactionalPhoneOptIn")
11
+
12
+
13
+ class EmailNotificationSettings(OtfItemBase):
14
+ is_system_email_opt_in: bool | None = Field(None, validation_alias="isSystemEmailOptIn")
15
+ is_promotional_email_opt_in: bool | None = Field(None, validation_alias="isPromotionalEmailOptIn")
16
+ is_transactional_email_opt_in: bool | None = Field(None, validation_alias="isTransactionalEmailOptIn")
17
+ email: str | None = None
otf_api/models/mixins.py CHANGED
@@ -1,13 +1,58 @@
1
+ import typing
2
+ from typing import Any
3
+
1
4
  from pydantic import AliasChoices, Field, field_validator, model_validator
2
5
 
6
+ if typing.TYPE_CHECKING:
7
+ from otf_api.api.api import Otf
8
+
9
+
10
+ class ApiMixin:
11
+ """Mixin for models that require an API instance to be set.
12
+
13
+ This allows us to create model methods such as `cancel`, `book`, etc., that require an API instance to function.
14
+ The API instance is set using the `set_api` method, and it can be accessed via the `_api` attribute.
15
+ If the API instance is not set, calling methods that require it will raise a ValueError.
16
+ """
17
+
18
+ _api: "Otf" = None # type: ignore[assignment]
19
+
20
+ def set_api(self, api: "Otf") -> None:
21
+ """Set the API instance for this model."""
22
+ self._api = api
23
+
24
+ @classmethod
25
+ def create(cls, **kwargs) -> typing.Self:
26
+ """Creates a new instance of the model with the given keyword arguments."""
27
+ api = kwargs.pop("api", None)
28
+ instance = cls(**kwargs)
29
+ if api is not None:
30
+ instance.set_api(api)
31
+ return instance
32
+
33
+ def raise_if_api_not_set(self) -> None:
34
+ """Raises an error if the API instance is not set."""
35
+ if self._api is None:
36
+ raise ValueError("API instance is not set. Use set_api() to set it before calling this method.")
37
+
3
38
 
4
39
  class PhoneLongitudeLatitudeMixin:
40
+ """Mixin for models that require phone number, latitude, and longitude fields.
41
+
42
+ This mixin exists to make it easier to handle the various names these fields can have in different APIs.
43
+ """
44
+
5
45
  phone_number: str | None = Field(None, validation_alias=AliasChoices("phone", "phoneNumber"))
6
46
  latitude: float | None = Field(None, validation_alias=AliasChoices("latitude"))
7
47
  longitude: float | None = Field(None, validation_alias=AliasChoices("longitude"))
8
48
 
9
49
 
10
50
  class AddressMixin:
51
+ """Mixin for models that require address fields.
52
+
53
+ This mixin exists to make it easier to handle the various names these fields can have in different APIs.
54
+ """
55
+
11
56
  address_line1: str | None = Field(
12
57
  None, validation_alias=AliasChoices("line1", "address1", "address", "physicalAddress")
13
58
  )
@@ -27,7 +72,8 @@ class AddressMixin:
27
72
 
28
73
  @model_validator(mode="before")
29
74
  @classmethod
30
- def check_country(cls, values):
75
+ def validate_model(cls, values: Any) -> Any: # noqa: ANN401
76
+ """Validates address fields and country format, handling specific cases."""
31
77
  if set(values.keys()) == set(
32
78
  ["phone", "latitude", "longitude", "address1", "address2", "city", "state", "postalCode"]
33
79
  ):
@@ -44,6 +90,7 @@ class AddressMixin:
44
90
  @field_validator("address_line1", "address_line2", "city", "postal_code", "state", "country")
45
91
  @classmethod
46
92
  def clean_strings(cls, value: str | None, **_kwargs) -> str | None:
93
+ """Clean strings by stripping whitespace and returning None if empty."""
47
94
  if value is None:
48
95
  return value
49
96
  value = value.strip()
@@ -0,0 +1,5 @@
1
+ from .enums import StudioStatus
2
+ from .studio_detail import StudioDetail, StudioLocation
3
+ from .studio_services import StudioService
4
+
5
+ __all__ = ["StudioDetail", "StudioLocation", "StudioService", "StudioStatus"]
@@ -0,0 +1,11 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class StudioStatus(StrEnum):
5
+ OTHER = "OTHER"
6
+ ACTIVE = "Active"
7
+ INACTIVE = "Inactive"
8
+ COMING_SOON = "Coming Soon"
9
+ TEMP_CLOSED = "Temporarily Closed"
10
+ PERM_CLOSED = "Permanently Closed"
11
+ UNKNOWN = "Unknown"
@@ -0,0 +1,93 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import AliasChoices, AliasPath, Field
4
+
5
+ from otf_api.models.base import OtfItemBase
6
+ from otf_api.models.mixins import AddressMixin, ApiMixin
7
+
8
+ from .enums import StudioStatus
9
+
10
+
11
+ class StudioLocation(AddressMixin, OtfItemBase):
12
+ phone_number: str | None = Field(None, validation_alias=AliasChoices("phone", "phoneNumber"))
13
+ latitude: float | None = Field(None, validation_alias=AliasChoices("latitude"))
14
+ longitude: float | None = Field(None, validation_alias=AliasChoices("longitude"))
15
+
16
+ physical_region: str | None = Field(None, validation_alias="physicalRegion", exclude=True, repr=False)
17
+ physical_country_id: int | None = Field(None, validation_alias="physicalCountryId", exclude=True, repr=False)
18
+
19
+
20
+ class StudioDetail(ApiMixin, OtfItemBase):
21
+ studio_uuid: str = Field(..., validation_alias="studioUUId", description="The OTF studio UUID")
22
+
23
+ contact_email: str | None = Field(None, validation_alias="contactEmail")
24
+ distance: float | None = Field(
25
+ None,
26
+ description="Distance from latitude and longitude provided to `search_studios_by_geo` method,\
27
+ NULL if that method was not used",
28
+ exclude=True,
29
+ repr=False,
30
+ )
31
+ location: StudioLocation = Field(..., validation_alias="studioLocation", default_factory=StudioLocation) # type: ignore
32
+ name: str | None = Field(None, validation_alias="studioName")
33
+ status: StudioStatus | None = Field(
34
+ None, validation_alias="studioStatus", description="Active, Temporarily Closed, Coming Soon"
35
+ )
36
+ time_zone: str | None = Field(None, validation_alias="timeZone")
37
+
38
+ # flags
39
+ accepts_ach: bool | None = Field(None, validation_alias="acceptsAch", exclude=True, repr=False)
40
+ accepts_american_express: bool | None = Field(
41
+ None, validation_alias="acceptsAmericanExpress", exclude=True, repr=False
42
+ )
43
+ accepts_discover: bool | None = Field(None, validation_alias="acceptsDiscover", exclude=True, repr=False)
44
+ accepts_visa_master_card: bool | None = Field(
45
+ None, validation_alias="acceptsVisaMasterCard", exclude=True, repr=False
46
+ )
47
+ allows_cr_waitlist: bool | None = Field(None, validation_alias="allowsCrWaitlist", exclude=True, repr=False)
48
+ allows_dashboard_access: bool | None = Field(
49
+ None, validation_alias="allowsDashboardAccess", exclude=True, repr=False
50
+ )
51
+ is_crm: bool | None = Field(None, validation_alias=AliasPath("studioProfiles", "isCrm"), exclude=True, repr=False)
52
+ is_integrated: bool | None = Field(
53
+ None, validation_alias="isIntegrated", exclude=True, repr=False, description="Always 'True'"
54
+ )
55
+ is_mobile: bool | None = Field(None, validation_alias="isMobile", exclude=True, repr=False)
56
+ is_otbeat: bool | None = Field(None, validation_alias="isOtbeat", exclude=True, repr=False)
57
+ is_web: bool | None = Field(None, validation_alias=AliasPath("studioProfiles", "isWeb"), exclude=True, repr=False)
58
+ sms_package_enabled: bool | None = Field(None, validation_alias="smsPackageEnabled", exclude=True, repr=False)
59
+
60
+ # misc
61
+ studio_id: int | None = Field(
62
+ None, validation_alias="studioId", description="Not used by API", exclude=True, repr=False
63
+ )
64
+ mbo_studio_id: int | None = Field(
65
+ None, validation_alias="mboStudioId", exclude=True, repr=False, description="MindBody attr"
66
+ )
67
+ open_date: datetime | None = Field(None, validation_alias="openDate", exclude=True, repr=False)
68
+ pricing_level: str | None = Field(
69
+ None, validation_alias="pricingLevel", exclude=True, repr=False, description="Pro, Legacy, Accelerate, or empty"
70
+ )
71
+ re_open_date: datetime | None = Field(None, validation_alias="reOpenDate", exclude=True, repr=False)
72
+ studio_number: str | None = Field(None, validation_alias="studioNumber", exclude=True, repr=False)
73
+ studio_physical_location_id: int | None = Field(
74
+ None, validation_alias="studioPhysicalLocationId", exclude=True, repr=False
75
+ )
76
+ studio_token: str | None = Field(None, validation_alias="studioToken", exclude=True, repr=False)
77
+ studio_type_id: int | None = Field(None, validation_alias="studioTypeId", exclude=True, repr=False)
78
+
79
+ @classmethod
80
+ def create_empty_model(cls, studio_uuid: str) -> "StudioDetail":
81
+ """Create an empty model with the given studio_uuid."""
82
+ # pylance doesn't know that the rest of the fields default to None, so we use type: ignore
83
+ return StudioDetail(studioUUId=studio_uuid, studioName="Studio Not Found", studioStatus="Unknown") # type: ignore
84
+
85
+ def add_to_favorites(self) -> None:
86
+ """Adds the studio to the user's favorites."""
87
+ self.raise_if_api_not_set()
88
+ self._api.studios.add_favorite_studio(self.studio_uuid)
89
+
90
+ def remove_from_favorites(self) -> None:
91
+ """Removes the studio from the user's favorites."""
92
+ self.raise_if_api_not_set()
93
+ self._api.studios.remove_favorite_studio(self.studio_uuid)
@@ -0,0 +1,36 @@
1
+ from datetime import datetime
2
+
3
+ from pydantic import Field
4
+
5
+ from otf_api.models.base import OtfItemBase
6
+ from otf_api.models.studios import StudioDetail
7
+
8
+
9
+ class StudioService(OtfItemBase):
10
+ studio: StudioDetail = Field(..., exclude=True, repr=False)
11
+ service_uuid: str = Field(..., validation_alias="serviceUUId")
12
+ name: str | None = None
13
+ price: str | None = None
14
+ qty: int | None = None
15
+ online_price: str | None = Field(None, validation_alias="onlinePrice")
16
+ tax_rate: str | None = Field(None, validation_alias="taxRate")
17
+ current: bool | None = None
18
+ is_deleted: bool | None = Field(None, validation_alias="isDeleted")
19
+ created_date: datetime | None = Field(None, validation_alias="createdDate")
20
+ updated_date: datetime | None = Field(None, validation_alias="updatedDate")
21
+
22
+ # unused fields
23
+
24
+ # ids
25
+ mbo_program_id: int | None = Field(None, validation_alias="mboProgramId", exclude=True, repr=False)
26
+ mbo_description_id: str | None = Field(None, validation_alias="mboDescriptionId", exclude=True, repr=False)
27
+ mbo_product_id: int | None = Field(None, validation_alias="mboProductId", exclude=True, repr=False)
28
+ service_id: int | None = Field(None, validation_alias="serviceId", exclude=True, repr=False)
29
+ studio_id: int | None = Field(None, validation_alias="studioId", exclude=True, repr=False)
30
+ created_by: str | None = Field(None, validation_alias="createdBy", exclude=True, repr=False)
31
+ updated_by: str | None = Field(None, validation_alias="updatedBy", exclude=True, repr=False)
32
+
33
+ # flags
34
+ is_web: bool | None = Field(None, validation_alias="isWeb", exclude=True, repr=False)
35
+ is_crm: bool | None = Field(None, validation_alias="isCrm", exclude=True, repr=False)
36
+ is_mobile: bool | None = Field(None, validation_alias="isMobile", exclude=True, repr=False)
@@ -0,0 +1,31 @@
1
+ from .body_composition_list import BodyCompositionData
2
+ from .challenge_tracker_content import ChallengeTracker
3
+ from .challenge_tracker_detail import FitnessBenchmark
4
+ from .enums import ChallengeCategory, EquipmentType, StatsTime
5
+ from .lifetime_stats import InStudioStatsData, OutStudioStatsData, StatsResponse, TimeStats
6
+ from .out_of_studio_workout_history import OutOfStudioWorkoutHistory
7
+ from .performance_summary import HeartRate, PerformanceSummary, Rower, Treadmill, ZoneTimeMinutes
8
+ from .telemetry import Telemetry, TelemetryHistoryItem
9
+ from .workout import Workout
10
+
11
+ __all__ = [
12
+ "BodyCompositionData",
13
+ "ChallengeCategory",
14
+ "ChallengeTracker",
15
+ "EquipmentType",
16
+ "FitnessBenchmark",
17
+ "HeartRate",
18
+ "InStudioStatsData",
19
+ "OutOfStudioWorkoutHistory",
20
+ "OutStudioStatsData",
21
+ "PerformanceSummary",
22
+ "Rower",
23
+ "StatsResponse",
24
+ "StatsTime",
25
+ "Telemetry",
26
+ "TelemetryHistoryItem",
27
+ "TimeStats",
28
+ "Treadmill",
29
+ "Workout",
30
+ "ZoneTimeMinutes",
31
+ ]