otf-api 0.2.2__py3-none-any.whl → 0.3.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 (49) hide show
  1. otf_api/__init__.py +12 -67
  2. otf_api/api.py +794 -36
  3. otf_api/cli/__init__.py +4 -0
  4. otf_api/cli/_utilities.py +60 -0
  5. otf_api/cli/app.py +177 -0
  6. otf_api/cli/bookings.py +231 -0
  7. otf_api/cli/prompts.py +162 -0
  8. otf_api/models/__init__.py +4 -8
  9. otf_api/models/auth.py +18 -12
  10. otf_api/models/base.py +205 -2
  11. otf_api/models/responses/__init__.py +6 -14
  12. otf_api/models/responses/body_composition_list.py +304 -0
  13. otf_api/models/responses/book_class.py +405 -0
  14. otf_api/models/responses/bookings.py +211 -37
  15. otf_api/models/responses/cancel_booking.py +93 -0
  16. otf_api/models/responses/challenge_tracker_content.py +6 -6
  17. otf_api/models/responses/challenge_tracker_detail.py +6 -6
  18. otf_api/models/responses/classes.py +205 -7
  19. otf_api/models/responses/enums.py +0 -35
  20. otf_api/models/responses/favorite_studios.py +5 -5
  21. otf_api/models/responses/latest_agreement.py +2 -2
  22. otf_api/models/responses/lifetime_stats.py +92 -0
  23. otf_api/models/responses/member_detail.py +17 -12
  24. otf_api/models/responses/member_membership.py +2 -2
  25. otf_api/models/responses/member_purchases.py +9 -9
  26. otf_api/models/responses/out_of_studio_workout_history.py +4 -4
  27. otf_api/models/responses/performance_summary_detail.py +1 -1
  28. otf_api/models/responses/performance_summary_list.py +13 -13
  29. otf_api/models/responses/studio_detail.py +10 -10
  30. otf_api/models/responses/studio_services.py +8 -8
  31. otf_api/models/responses/telemetry.py +6 -6
  32. otf_api/models/responses/telemetry_hr_history.py +6 -6
  33. otf_api/models/responses/telemetry_max_hr.py +3 -3
  34. otf_api/models/responses/total_classes.py +2 -2
  35. otf_api/models/responses/workouts.py +4 -4
  36. otf_api-0.3.0.dist-info/METADATA +55 -0
  37. otf_api-0.3.0.dist-info/RECORD +42 -0
  38. otf_api-0.3.0.dist-info/entry_points.txt +3 -0
  39. otf_api/__version__.py +0 -1
  40. otf_api/classes_api.py +0 -44
  41. otf_api/member_api.py +0 -380
  42. otf_api/performance_api.py +0 -54
  43. otf_api/studios_api.py +0 -96
  44. otf_api/telemetry_api.py +0 -95
  45. otf_api-0.2.2.dist-info/METADATA +0 -284
  46. otf_api-0.2.2.dist-info/RECORD +0 -38
  47. {otf_api-0.2.2.dist-info → otf_api-0.3.0.dist-info}/AUTHORS.md +0 -0
  48. {otf_api-0.2.2.dist-info → otf_api-0.3.0.dist-info}/LICENSE +0 -0
  49. {otf_api-0.2.2.dist-info → otf_api-0.3.0.dist-info}/WHEEL +0 -0
otf_api/models/auth.py CHANGED
@@ -5,13 +5,13 @@ from typing import ClassVar
5
5
  from pycognito import Cognito, TokenVerificationException
6
6
  from pydantic import Field
7
7
 
8
- from otf_api.models.base import OtfBaseModel
8
+ from otf_api.models.base import OtfItemBase
9
9
 
10
10
  CLIENT_ID = "65knvqta6p37efc2l3eh26pl5o" # from otlive
11
11
  USER_POOL_ID = "us-east-1_dYDxUeyL1"
12
12
 
13
13
 
14
- class IdClaimsData(OtfBaseModel):
14
+ class IdClaimsData(OtfItemBase):
15
15
  sub: str
16
16
  email_verified: bool
17
17
  iss: str
@@ -28,6 +28,7 @@ class IdClaimsData(OtfBaseModel):
28
28
  iat: int
29
29
  family_name: str
30
30
  email: str
31
+ koji_person_id: str = Field(alias="custom:koji_person_id")
31
32
 
32
33
  @property
33
34
  def member_uuid(self) -> str:
@@ -38,7 +39,7 @@ class IdClaimsData(OtfBaseModel):
38
39
  return f"{self.given_name} {self.family_name}"
39
40
 
40
41
 
41
- class AccessClaimsData(OtfBaseModel):
42
+ class AccessClaimsData(OtfItemBase):
42
43
  sub: str
43
44
  device_key: str
44
45
  iss: str
@@ -91,18 +92,25 @@ class User:
91
92
  self.token_path.write_text(json.dumps(data))
92
93
 
93
94
  @classmethod
94
- def load_from_disk(cls, username: str | None = None, password: str | None = None) -> "User":
95
+ def cache_file_exists(cls) -> bool:
96
+ return cls.token_path.exists()
97
+
98
+ @classmethod
99
+ def username_from_disk(cls) -> str:
100
+ val: str = json.loads(cls.token_path.read_text())["username"]
101
+ return val
102
+
103
+ @classmethod
104
+ def load_from_disk(cls, username: str, password: str) -> "User":
95
105
  """Load a User instance from disk. If the token is invalid, reauthenticate with the provided credentials.
96
106
 
97
107
  Args:
98
- username (str | None): The username to reauthenticate with.
99
- password (str | None): The password to reauthenticate with.
108
+ username (str): The username to reauthenticate with.
109
+ password (str): The password to reauthenticate with.
100
110
 
101
111
  Returns:
102
112
  User: The loaded user.
103
113
 
104
- Raises:
105
- ValueError: If the token is invalid and no username and password are provided.
106
114
  """
107
115
  attr_dict = json.loads(cls.token_path.read_text())
108
116
 
@@ -111,10 +119,8 @@ class User:
111
119
  cognito_user.verify_tokens()
112
120
  return cls(cognito=cognito_user)
113
121
  except TokenVerificationException:
114
- if username and password:
115
- user = cls.login(username, password)
116
- return user
117
- raise
122
+ user = cls.login(username, password)
123
+ return user
118
124
 
119
125
  @classmethod
120
126
  def login(cls, username: str, password: str) -> "User":
otf_api/models/base.py CHANGED
@@ -1,7 +1,210 @@
1
- from typing import ClassVar
1
+ import inspect
2
+ import typing
3
+ from enum import Enum
4
+ from typing import Any, ClassVar, TypeVar
2
5
 
6
+ from box import Box
7
+ from inflection import humanize
3
8
  from pydantic import BaseModel, ConfigDict
9
+ from rich.style import Style
10
+ from rich.styled import Styled
11
+ from rich.table import Table
4
12
 
13
+ if typing.TYPE_CHECKING:
14
+ from pydantic.main import IncEx
5
15
 
6
- class OtfBaseModel(BaseModel):
16
+ T = TypeVar("T", bound="OtfItemBase")
17
+
18
+
19
+ class BetterDumperMixin:
20
+ """A better dumper for Pydantic models that includes properties in the dumped data. Must be mixed
21
+ into a Pydantic model, as it overrides the `model_dump` method.
22
+
23
+ Includes support for nested models, and has an option to not include properties when dumping.
24
+ """
25
+
26
+ def get_properties(self) -> list[str]:
27
+ """Get the properties of the model."""
28
+ cls = type(self)
29
+
30
+ properties: list[str] = []
31
+ methods = inspect.getmembers(self, lambda f: not (inspect.isroutine(f)))
32
+ for prop_name, _ in methods:
33
+ if hasattr(cls, prop_name) and isinstance(getattr(cls, prop_name), property):
34
+ properties.append(prop_name)
35
+
36
+ return properties
37
+
38
+ @typing.overload
39
+ def model_dump(
40
+ self,
41
+ *,
42
+ mode: typing.Literal["json", "python"] | str = "python",
43
+ include: "IncEx" = None,
44
+ exclude: "IncEx" = None,
45
+ by_alias: bool = False,
46
+ exclude_unset: bool = False,
47
+ exclude_defaults: bool = False,
48
+ exclude_none: bool = False,
49
+ round_trip: bool = False,
50
+ warnings: bool = True,
51
+ include_properties: bool = True,
52
+ ) -> Box[str, typing.Any]: ...
53
+
54
+ @typing.overload
55
+ def model_dump(
56
+ self,
57
+ *,
58
+ mode: typing.Literal["json", "python"] | str = "python",
59
+ include: "IncEx" = None,
60
+ exclude: "IncEx" = None,
61
+ by_alias: bool = False,
62
+ exclude_unset: bool = False,
63
+ exclude_defaults: bool = False,
64
+ exclude_none: bool = False,
65
+ round_trip: bool = False,
66
+ warnings: bool = True,
67
+ include_properties: bool = False,
68
+ ) -> dict[str, typing.Any]: ...
69
+
70
+ def model_dump(
71
+ self,
72
+ *,
73
+ mode: typing.Literal["json", "python"] | str = "python",
74
+ include: "IncEx" = None,
75
+ exclude: "IncEx" = None,
76
+ by_alias: bool = False,
77
+ exclude_unset: bool = False,
78
+ exclude_defaults: bool = False,
79
+ exclude_none: bool = False,
80
+ round_trip: bool = False,
81
+ warnings: bool = True,
82
+ include_properties: bool = True,
83
+ ) -> dict[str, typing.Any] | Box[str, typing.Any]:
84
+ """Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump
85
+
86
+ Generate a dictionary representation of the model, optionally specifying which fields to include or exclude.
87
+
88
+ Args:
89
+ mode: The mode in which `to_python` should run.
90
+ If mode is 'json', the dictionary will only contain JSON serializable types.
91
+ If mode is 'python', the dictionary may contain any Python objects.
92
+ include: A list of fields to include in the output.
93
+ exclude: A list of fields to exclude from the output.
94
+ by_alias: Whether to use the field's alias in the dictionary key if defined.
95
+ exclude_unset: Whether to exclude fields that are unset or None from the output.
96
+ exclude_defaults: Whether to exclude fields that are set to their default value from the output.
97
+ exclude_none: Whether to exclude fields that have a value of `None` from the output.
98
+ round_trip: Whether to enable serialization and deserialization round-trip support.
99
+ warnings: Whether to log warnings when invalid fields are encountered.
100
+ include_properties: Whether to include properties in the dumped data.
101
+
102
+ Returns:
103
+ A dictionary representation of the model. Will be a `Box` if `include_properties` is `True`, otherwise a
104
+ regular dictionary.
105
+
106
+ """
107
+ dumped_data = typing.cast(BaseModel, super()).model_dump(
108
+ mode=mode,
109
+ include=include,
110
+ exclude=exclude,
111
+ by_alias=by_alias,
112
+ exclude_unset=exclude_unset,
113
+ exclude_defaults=exclude_defaults,
114
+ exclude_none=exclude_none,
115
+ round_trip=round_trip,
116
+ warnings=warnings,
117
+ )
118
+
119
+ if not include_properties:
120
+ return dumped_data
121
+
122
+ properties = self.get_properties()
123
+
124
+ # set properties to their values
125
+ for prop_name in properties:
126
+ dumped_data[prop_name] = getattr(self, prop_name)
127
+
128
+ # if the property is a Pydantic model, dump it as well
129
+ for k, v in dumped_data.items():
130
+ if issubclass(type(getattr(self, k)), BaseModel):
131
+ dumped_data[k] = getattr(self, k).model_dump()
132
+ elif hasattr(v, "model_dump"):
133
+ dumped_data[k] = v.model_dump()
134
+
135
+ return Box(dumped_data)
136
+
137
+
138
+ class OtfItemBase(BetterDumperMixin, BaseModel):
7
139
  model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
140
+
141
+ def convert_row_value_types(self, row: list[Any]) -> list[str]:
142
+ for i, val in enumerate(row):
143
+ if isinstance(val, bool):
144
+ row[i] = str(val)
145
+ continue
146
+
147
+ if isinstance(val, Enum):
148
+ row[i] = val.name
149
+ continue
150
+
151
+ if val is None:
152
+ row[i] = ""
153
+ continue
154
+
155
+ row[i] = str(val)
156
+
157
+ return row
158
+
159
+ def get_style(self, is_selected: bool = False) -> Style:
160
+ return Style(color="blue", bold=True) if is_selected else Style(color="white")
161
+
162
+ def to_row(self, attributes: list[str], is_selected: bool = False) -> list[Styled]:
163
+ style = self.get_style(is_selected)
164
+
165
+ boxed_self = Box(self.model_dump(), box_dots=True)
166
+ row = [boxed_self.get(attr, "") for attr in attributes]
167
+ row = self.convert_row_value_types(row)
168
+ styled = [Styled(cell, style=style) for cell in row]
169
+
170
+ prefix = "> " if is_selected else " "
171
+ styled.insert(0, Styled(prefix, style=style))
172
+
173
+ return styled
174
+
175
+ @property
176
+ def sidebar_data(self) -> Table | None:
177
+ return None
178
+
179
+ @classmethod
180
+ def attr_to_column_header(cls, attr: str) -> str:
181
+ attr_map = {k: humanize(k) for k in cls.model_fields}
182
+
183
+ return attr_map.get(attr, attr)
184
+
185
+
186
+ class OtfListBase(BaseModel):
187
+ model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
188
+ collection_field: ClassVar[str] = "data"
189
+
190
+ @property
191
+ def collection(self) -> list[OtfItemBase]:
192
+ return getattr(self, self.collection_field)
193
+
194
+ def to_table(self, columns: list[str]) -> Table:
195
+ table = Table(expand=True, show_header=True, show_footer=False)
196
+
197
+ table.add_column()
198
+ for column in columns:
199
+ table.add_column(OtfItemBase.attr_to_column_header(column))
200
+
201
+ for item in self.collection:
202
+ table.add_row(*item.to_row(columns))
203
+
204
+ return table
205
+
206
+ def to_json(self, **kwargs) -> str:
207
+ kwargs.setdefault("indent", 4)
208
+ kwargs.setdefault("exclude_none", True)
209
+
210
+ return self.model_dump_json(**kwargs)
@@ -1,17 +1,10 @@
1
- from .bookings import BookingList
1
+ from .book_class import BookClass
2
+ from .bookings import BookingList, BookingStatus
3
+ from .cancel_booking import CancelBooking
2
4
  from .challenge_tracker_content import ChallengeTrackerContent
3
5
  from .challenge_tracker_detail import ChallengeTrackerDetailList
4
6
  from .classes import OtfClassList
5
- from .enums import (
6
- ALL_CLASS_STATUS,
7
- ALL_HISTORY_CLASS_STATUS,
8
- ALL_STUDIO_STATUS,
9
- BookingStatus,
10
- ChallengeType,
11
- EquipmentType,
12
- HistoryClassStatus,
13
- StudioStatus,
14
- )
7
+ from .enums import ChallengeType, EquipmentType, HistoryClassStatus
15
8
  from .favorite_studios import FavoriteStudioList
16
9
  from .latest_agreement import LatestAgreement
17
10
  from .member_detail import MemberDetail
@@ -52,9 +45,8 @@ __all__ = [
52
45
  "TelemetryMaxHr",
53
46
  "StudioDetail",
54
47
  "StudioDetailList",
55
- "ALL_CLASS_STATUS",
56
- "ALL_HISTORY_CLASS_STATUS",
57
- "ALL_STUDIO_STATUS",
58
48
  "PerformanceSummaryDetail",
59
49
  "PerformanceSummaryList",
50
+ "BookClass",
51
+ "CancelBooking",
60
52
  ]
@@ -0,0 +1,304 @@
1
+ import inspect
2
+ from datetime import datetime
3
+ from enum import Enum
4
+
5
+ import pint
6
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
7
+
8
+ ureg = pint.UnitRegistry()
9
+
10
+ DEFAULT_WEIGHT_DIVIDERS = [55.0, 70.0, 85.0, 100.0, 115.0, 130.0, 145.0, 160.0, 175.0, 190.0, 205.0]
11
+ DEFAULT_SKELETAL_MUSCLE_MASS_DIVIDERS = [70.0, 80.0, 90.0, 100.0, 110.0, 120.0, 130.0, 140.0, 150.0, 160.0, 170.0]
12
+ DEFAULT_BODY_FAT_MASS_DIVIDERS = [40.0, 60.0, 80.0, 100.0, 160.0, 220.0, 280.0, 340.0, 400.0, 460.0, 520.0]
13
+
14
+
15
+ class AverageType(str, Enum):
16
+ BELOW_AVERAGE = "BELOW_AVERAGE"
17
+ AVERAGE = "AVERAGE"
18
+ ABOVE_AVERAGE = "ABOVE_AVERAGE"
19
+ MINIMUM = "MINIMUM" # unused
20
+
21
+
22
+ class BodyFatPercentIndicator(str, Enum):
23
+ NO_INDICATOR = "NO_INDICATOR"
24
+ MINIMUM_BODY_FAT = "MINIMUM_BODY_FAT" # unused
25
+ LOW_BODY_FAT = "LOW_BODY_FAT" # unused
26
+ HEALTHY_BODY_FAT = "HEALTHY_BODY_FAT"
27
+ GOAL_SETTING_FAT = "GOAL_SETTING_FAT"
28
+ HIGH_BODY_FAT = "HIGH_BODY_FAT"
29
+ OBESE_BODY_FAT = "OBESE_BODY_FAT" # unused
30
+
31
+
32
+ class Gender(str, Enum):
33
+ MALE = "M"
34
+ FEMALE = "F"
35
+
36
+
37
+ def get_percent_body_fat_descriptor(
38
+ percent_body_fat: float, body_fat_percent_dividers: list[float]
39
+ ) -> BodyFatPercentIndicator:
40
+ if not percent_body_fat or not body_fat_percent_dividers[3]:
41
+ return BodyFatPercentIndicator.NO_INDICATOR
42
+
43
+ if percent_body_fat < body_fat_percent_dividers[1]:
44
+ return BodyFatPercentIndicator.HEALTHY_BODY_FAT
45
+
46
+ if percent_body_fat < body_fat_percent_dividers[2]:
47
+ return BodyFatPercentIndicator.GOAL_SETTING_FAT
48
+
49
+ return BodyFatPercentIndicator.HIGH_BODY_FAT
50
+
51
+
52
+ def get_relative_descriptor(in_body_value: float, in_body_dividers: list[float]) -> AverageType:
53
+ if in_body_value <= in_body_dividers[2]:
54
+ return AverageType.BELOW_AVERAGE
55
+
56
+ if in_body_value <= in_body_dividers[4]:
57
+ return AverageType.AVERAGE
58
+
59
+ return AverageType.ABOVE_AVERAGE
60
+
61
+
62
+ def get_body_fat_percent_dividers(age: int, gender: Gender) -> list[float]:
63
+ if gender == Gender.MALE:
64
+ return get_body_fat_percent_dividers_male(age)
65
+
66
+ return get_body_fat_percent_dividers_female(age)
67
+
68
+
69
+ def get_body_fat_percent_dividers_male(age: int) -> list[float]:
70
+ match age:
71
+ case age if 0 <= age < 30:
72
+ return [0.0, 13.1, 21.1, 100.0]
73
+ case age if 30 <= age < 40:
74
+ return [0.0, 17.1, 23.1, 100.0]
75
+ case age if 40 <= age < 50:
76
+ return [0.0, 20.1, 25.1, 100.0]
77
+ case age if 50 <= age < 60:
78
+ return [0.0, 21.1, 26.1, 100.0]
79
+ case age if 60 <= age < 70:
80
+ return [0.0, 22.1, 27.1, 100.0]
81
+ case _:
82
+ return [0.0, 0.0, 0.0, 0.0]
83
+
84
+
85
+ def get_body_fat_percent_dividers_female(age: int) -> list[float]:
86
+ match age:
87
+ case age if 0 <= age < 30:
88
+ return [0.0, 19.1, 26.1, 100.0]
89
+ case age if 30 <= age < 40:
90
+ return [0.0, 20.1, 27.1, 100.0]
91
+ case age if 40 <= age < 50:
92
+ return [0.0, 22.1, 30.1, 100.0]
93
+ case age if 50 <= age < 60:
94
+ return [0.0, 25.1, 32.1, 100.0]
95
+ case age if 60 <= age < 70:
96
+ return [0.0, 26.1, 33.1, 100.0]
97
+ case _:
98
+ return [0.0, 0.0, 0.0, 0.0]
99
+
100
+
101
+ class LeanBodyMass(BaseModel):
102
+ model_config: ConfigDict = ConfigDict(extra="ignore")
103
+ left_arm: float = Field(..., alias="lbmOfLeftArm")
104
+ left_leg: float = Field(..., alias="lbmOfLeftLeg")
105
+ right_arm: float = Field(..., alias="lbmOfRightArm")
106
+ right_leg: float = Field(..., alias="lbmOfRightLeg")
107
+ trunk: float = Field(..., alias="lbmOfTrunk")
108
+
109
+
110
+ class LeanBodyMassPercent(BaseModel):
111
+ model_config: ConfigDict = ConfigDict(extra="ignore")
112
+ left_arm: float = Field(..., alias="lbmPercentOfLeftArm")
113
+ left_leg: float = Field(..., alias="lbmPercentOfLeftLeg")
114
+ right_arm: float = Field(..., alias="lbmPercentOfRightArm")
115
+ right_leg: float = Field(..., alias="lbmPercentOfRightLeg")
116
+ trunk: float = Field(..., alias="lbmPercentOfTrunk")
117
+
118
+
119
+ class BodyFatMass(BaseModel):
120
+ model_config: ConfigDict = ConfigDict(extra="ignore")
121
+ control: float = Field(..., alias="bfmControl")
122
+ left_arm: float = Field(..., alias="bfmOfLeftArm")
123
+ left_leg: float = Field(..., alias="bfmOfLeftLeg")
124
+ right_arm: float = Field(..., alias="bfmOfRightArm")
125
+ right_leg: float = Field(..., alias="bfmOfRightLeg")
126
+ trunk: float = Field(..., alias="bfmOfTrunk")
127
+
128
+
129
+ class BodyFatMassPercent(BaseModel):
130
+ model_config: ConfigDict = ConfigDict(extra="ignore")
131
+ left_arm: float = Field(..., alias="bfmPercentOfLeftArm")
132
+ left_leg: float = Field(..., alias="bfmPercentOfLeftLeg")
133
+ right_arm: float = Field(..., alias="bfmPercentOfRightArm")
134
+ right_leg: float = Field(..., alias="bfmPercentOfRightLeg")
135
+ trunk: float = Field(..., alias="bfmPercentOfTrunk")
136
+
137
+
138
+ class TotalBodyWeight(BaseModel):
139
+ model_config: ConfigDict = ConfigDict(extra="ignore")
140
+ right_arm: float = Field(..., alias="tbwOfRightArm")
141
+ left_arm: float = Field(..., alias="tbwOfLeftArm")
142
+ trunk: float = Field(..., alias="tbwOfTrunk")
143
+ right_leg: float = Field(..., alias="tbwOfRightLeg")
144
+ left_leg: float = Field(..., alias="tbwOfLeftLeg")
145
+
146
+
147
+ class IntraCellularWater(BaseModel):
148
+ model_config: ConfigDict = ConfigDict(extra="ignore")
149
+ right_arm: float = Field(..., alias="icwOfRightArm")
150
+ left_arm: float = Field(..., alias="icwOfLeftArm")
151
+ trunk: float = Field(..., alias="icwOfTrunk")
152
+ right_leg: float = Field(..., alias="icwOfRightLeg")
153
+ left_leg: float = Field(..., alias="icwOfLeftLeg")
154
+
155
+
156
+ class ExtraCellularWater(BaseModel):
157
+ model_config: ConfigDict = ConfigDict(extra="ignore")
158
+ right_arm: float = Field(..., alias="ecwOfRightArm")
159
+ left_arm: float = Field(..., alias="ecwOfLeftArm")
160
+ trunk: float = Field(..., alias="ecwOfTrunk")
161
+ right_leg: float = Field(..., alias="ecwOfRightLeg")
162
+ left_leg: float = Field(..., alias="ecwOfLeftLeg")
163
+
164
+
165
+ class ExtraCellularWaterOverTotalBodyWater(BaseModel):
166
+ model_config: ConfigDict = ConfigDict(extra="ignore")
167
+ right_arm: float = Field(..., alias="ecwOverTBWOfRightArm")
168
+ left_arm: float = Field(..., alias="ecwOverTBWOfLeftArm")
169
+ trunk: float = Field(..., alias="ecwOverTBWOfTrunk")
170
+ right_leg: float = Field(..., alias="ecwOverTBWOfRightLeg")
171
+ left_leg: float = Field(..., alias="ecwOverTBWOfLeftLeg")
172
+
173
+
174
+ class BodyCompositionData(BaseModel):
175
+ member_uuid: str = Field(..., alias="memberUUId")
176
+ member_id: str = Field(..., alias="memberId")
177
+ scan_result_uuid: str = Field(..., alias="scanResultUUId")
178
+ inbody_id: str = Field(..., alias="id", exclude=True, description="InBody ID, same as email address")
179
+ email: str
180
+ height: str = Field(..., description="Height in cm")
181
+ gender: Gender
182
+ age: int
183
+ scan_datetime: datetime = Field(..., alias="testDatetime")
184
+ provided_weight: float = Field(
185
+ ..., alias="weight", description="Weight in pounds, provided by member at time of scan"
186
+ )
187
+
188
+ lean_body_mass_details: LeanBodyMass
189
+ lean_body_mass_percent_details: LeanBodyMassPercent
190
+
191
+ total_body_weight: float = Field(..., alias="tbw", description="Total body weight in pounds, based on scan results")
192
+ dry_lean_mass: float = Field(..., alias="dlm")
193
+ body_fat_mass: float = Field(..., alias="bfm")
194
+ lean_body_mass: float = Field(..., alias="lbm")
195
+ skeletal_muscle_mass: float = Field(..., alias="smm")
196
+ body_mass_index: float = Field(..., alias="bmi")
197
+ percent_body_fat: float = Field(..., alias="pbf")
198
+ basal_metabolic_rate: float = Field(..., alias="bmr")
199
+ in_body_type: str = Field(..., alias="inBodyType")
200
+
201
+ body_fat_mass: float = Field(..., alias="bfm")
202
+ skeletal_muscle_mass: float = Field(..., alias="smm")
203
+
204
+ # excluded because they are only useful for end result of calculations
205
+ body_fat_mass_dividers: list[float] = Field(..., alias="bfmGraphScale", exclude=True)
206
+ body_fat_mass_plot_point: float = Field(..., alias="pfatnew", exclude=True)
207
+ skeletal_muscle_mass_dividers: list[float] = Field(..., alias="smmGraphScale", exclude=True)
208
+ skeletal_muscle_mass_plot_point: float = Field(..., alias="psmm", exclude=True)
209
+ weight_dividers: list[float] = Field(..., alias="wtGraphScale", exclude=True)
210
+ weight_plot_point: float = Field(..., alias="pwt", exclude=True)
211
+
212
+ # excluded due to 0 values
213
+ body_fat_mass_details: BodyFatMass = Field(..., exclude=True)
214
+ body_fat_mass_percent_details: BodyFatMassPercent = Field(..., exclude=True)
215
+ total_body_weight_details: TotalBodyWeight = Field(..., exclude=True)
216
+ intra_cellular_water_details: IntraCellularWater = Field(..., exclude=True)
217
+ extra_cellular_water_details: ExtraCellularWater = Field(..., exclude=True)
218
+ extra_cellular_water_over_total_body_water_details: ExtraCellularWaterOverTotalBodyWater = Field(..., exclude=True)
219
+ visceral_fat_level: float = Field(..., alias="vfl", exclude=True)
220
+ visceral_fat_area: float = Field(..., alias="vfa", exclude=True)
221
+ body_comp_measurement: float = Field(..., alias="bcm", exclude=True)
222
+ total_body_weight_over_lean_body_mass: float = Field(..., alias="tbwOverLBM", exclude=True)
223
+ intracellular_water: float = Field(..., alias="icw", exclude=True)
224
+ extracellular_water: float = Field(..., alias="ecw", exclude=True)
225
+ lean_body_mass_control: float = Field(..., alias="lbmControl", exclude=True)
226
+
227
+ def __init__(self, **data):
228
+ # populate child models
229
+ child_model_dict = {
230
+ k: v.annotation
231
+ for k, v in self.model_fields.items()
232
+ if inspect.isclass(v.annotation) and issubclass(v.annotation, BaseModel)
233
+ }
234
+ for k, v in child_model_dict.items():
235
+ data[k] = v(**data)
236
+
237
+ super().__init__(**data)
238
+
239
+ @field_validator("member_id", mode="before")
240
+ @classmethod
241
+ def int_to_str(cls, v: int):
242
+ return str(v)
243
+
244
+ @field_validator("skeletal_muscle_mass_dividers", "weight_dividers", "body_fat_mass_dividers", mode="before")
245
+ @classmethod
246
+ def convert_dividers_to_float_list(cls, v: str):
247
+ return [float(i) for i in v.split(";")]
248
+
249
+ @field_validator("total_body_weight", mode="before")
250
+ @classmethod
251
+ def convert_body_weight_from_kg_to_pounds(cls, v: float):
252
+ return ureg.Quantity(v, ureg.kilogram).to(ureg.pound).magnitude
253
+
254
+ @property
255
+ def body_fat_mass_relative_descriptor(self) -> AverageType:
256
+ """Get the relative descriptor for the body fat mass plot point.
257
+
258
+ For this item, a lower value is better.
259
+
260
+ Returns:
261
+ AverageType: The relative descriptor for the body fat mass plot point
262
+ """
263
+ dividers = self.body_fat_mass_dividers or DEFAULT_BODY_FAT_MASS_DIVIDERS
264
+ return get_relative_descriptor(self.body_fat_mass_plot_point, dividers)
265
+
266
+ @property
267
+ def skeletal_muscle_mass_relative_descriptor(self) -> AverageType:
268
+ """Get the relative descriptor for the skeletal muscle mass plot point.
269
+
270
+ For this item, a higher value is better.
271
+
272
+ Returns:
273
+ AverageType: The relative descriptor for the skeletal muscle mass plot point
274
+
275
+ """
276
+ dividers = self.skeletal_muscle_mass_dividers or DEFAULT_SKELETAL_MUSCLE_MASS_DIVIDERS
277
+ return get_relative_descriptor(self.skeletal_muscle_mass_plot_point, dividers)
278
+
279
+ @property
280
+ def weight_relative_descriptor(self) -> AverageType:
281
+ """Get the relative descriptor for the weight plot point.
282
+
283
+ For this item, a lower value is better.
284
+
285
+ Returns:
286
+ AverageType: The relative descriptor for the weight
287
+ """
288
+ dividers = self.weight_dividers or DEFAULT_WEIGHT_DIVIDERS
289
+ return get_relative_descriptor(self.weight_plot_point, dividers)
290
+
291
+ @property
292
+ def body_fat_percent_relative_descriptor(self) -> BodyFatPercentIndicator:
293
+ """Get the relative descriptor for the percent body fat.
294
+
295
+ Returns:
296
+ BodyFatPercentIndicator: The relative descriptor for the percent body fat
297
+ """
298
+ return get_percent_body_fat_descriptor(
299
+ self.percent_body_fat, get_body_fat_percent_dividers(self.age, self.gender)
300
+ )
301
+
302
+
303
+ class BodyCompositionList(BaseModel):
304
+ data: list[BodyCompositionData]