otf-api 0.4.0__py3-none-any.whl → 0.6.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.
- otf_api/__init__.py +1 -1
- otf_api/api.py +20 -64
- otf_api/auth.py +2 -1
- otf_api/models/__init__.py +0 -2
- otf_api/models/base.py +1 -62
- otf_api/models/responses/__init__.py +0 -2
- otf_api/models/responses/bookings.py +0 -78
- otf_api/models/responses/classes.py +0 -77
- otf_api/models/responses/performance_summary_list.py +2 -2
- {otf_api-0.4.0.dist-info → otf_api-0.6.0.dist-info}/METADATA +3 -5
- {otf_api-0.4.0.dist-info → otf_api-0.6.0.dist-info}/RECORD +14 -21
- otf_api/cli/__init__.py +0 -4
- otf_api/cli/_utilities.py +0 -60
- otf_api/cli/app.py +0 -172
- otf_api/cli/bookings.py +0 -231
- otf_api/cli/prompts.py +0 -162
- otf_api/models/responses/workouts.py +0 -78
- otf_api-0.4.0.dist-info/entry_points.txt +0 -3
- {otf_api-0.4.0.dist-info → otf_api-0.6.0.dist-info}/AUTHORS.md +0 -0
- {otf_api-0.4.0.dist-info → otf_api-0.6.0.dist-info}/LICENSE +0 -0
- {otf_api-0.4.0.dist-info → otf_api-0.6.0.dist-info}/WHEEL +0 -0
otf_api/__init__.py
CHANGED
otf_api/api.py
CHANGED
@@ -3,7 +3,6 @@ import contextlib
|
|
3
3
|
import json
|
4
4
|
import typing
|
5
5
|
from datetime import date, datetime
|
6
|
-
from math import ceil
|
7
6
|
from typing import Any
|
8
7
|
|
9
8
|
import aiohttp
|
@@ -43,7 +42,6 @@ from otf_api.models import (
|
|
43
42
|
TelemetryHrHistory,
|
44
43
|
TelemetryMaxHr,
|
45
44
|
TotalClasses,
|
46
|
-
WorkoutList,
|
47
45
|
)
|
48
46
|
|
49
47
|
|
@@ -226,32 +224,15 @@ class Otf:
|
|
226
224
|
"""Perform an API request to the performance summary API."""
|
227
225
|
return await self._do(method, API_IO_BASE_URL, url, params, headers)
|
228
226
|
|
229
|
-
async def
|
230
|
-
"""Get the
|
227
|
+
async def get_body_composition_list(self) -> BodyCompositionList:
|
228
|
+
"""Get the member's body composition list.
|
231
229
|
|
232
230
|
Returns:
|
233
|
-
|
234
|
-
|
235
|
-
Info:
|
236
|
-
---
|
237
|
-
This returns data from the same api the [OT Live website](https://otlive.orangetheory.com/) uses.
|
238
|
-
It is quite a bit of data, and all workouts going back to ~2019. The data includes the class history
|
239
|
-
UUID, which can be used to get telemetry data for a specific workout.
|
231
|
+
Any: The member's body composition list.
|
240
232
|
"""
|
233
|
+
data = await self._default_request("GET", f"/member/members/{self._member_uuid}/body-composition")
|
241
234
|
|
242
|
-
|
243
|
-
|
244
|
-
return WorkoutList(workouts=res["data"])
|
245
|
-
|
246
|
-
async def get_total_classes(self) -> TotalClasses:
|
247
|
-
"""Get the member's total classes. This is a simple object reflecting the total number of classes attended,
|
248
|
-
both in-studio and OT Live.
|
249
|
-
|
250
|
-
Returns:
|
251
|
-
TotalClasses: The member's total classes.
|
252
|
-
"""
|
253
|
-
data = await self._default_request("GET", "/mobile/v1/members/classes/summary")
|
254
|
-
return TotalClasses(**data["data"])
|
235
|
+
return BodyCompositionList(data=data["data"])
|
255
236
|
|
256
237
|
async def get_classes(
|
257
238
|
self,
|
@@ -346,6 +327,16 @@ class Otf:
|
|
346
327
|
|
347
328
|
return classes_list
|
348
329
|
|
330
|
+
async def get_total_classes(self) -> TotalClasses:
|
331
|
+
"""Get the member's total classes. This is a simple object reflecting the total number of classes attended,
|
332
|
+
both in-studio and OT Live.
|
333
|
+
|
334
|
+
Returns:
|
335
|
+
TotalClasses: The member's total classes.
|
336
|
+
"""
|
337
|
+
data = await self._default_request("GET", "/mobile/v1/members/classes/summary")
|
338
|
+
return TotalClasses(**data["data"])
|
339
|
+
|
349
340
|
async def book_class(self, class_uuid: str) -> BookClass | typing.Any:
|
350
341
|
"""Book a class by class_uuid.
|
351
342
|
|
@@ -860,16 +851,15 @@ class Otf:
|
|
860
851
|
res = await self._telemetry_request("GET", path, params=params)
|
861
852
|
return TelemetryMaxHr(**res)
|
862
853
|
|
863
|
-
async def get_telemetry(self,
|
864
|
-
"""Get the telemetry for a
|
854
|
+
async def get_telemetry(self, performance_summary_id: str, max_data_points: int = 120) -> Telemetry:
|
855
|
+
"""Get the telemetry for a performance summary.
|
865
856
|
|
866
857
|
This returns an object that contains the max heartrate, start/end bpm for each zone,
|
867
858
|
and a list of telemetry items that contain the heartrate, splat points, calories, and timestamp.
|
868
859
|
|
869
860
|
Args:
|
870
|
-
|
871
|
-
max_data_points (int): The max data points to use for the telemetry. Default is
|
872
|
-
get the max data points from the workout. If the workout is not found, it will default to 120 data points.
|
861
|
+
performance_summary_id (str): The performance summary id.
|
862
|
+
max_data_points (int): The max data points to use for the telemetry. Default is 120.
|
873
863
|
|
874
864
|
Returns:
|
875
865
|
TelemetryItem: The telemetry for the class history.
|
@@ -877,30 +867,10 @@ class Otf:
|
|
877
867
|
"""
|
878
868
|
path = "/v1/performance/summary"
|
879
869
|
|
880
|
-
|
881
|
-
|
882
|
-
params = {"classHistoryUuid": class_history_uuid, "maxDataPoints": max_data_points}
|
870
|
+
params = {"classHistoryUuid": performance_summary_id, "maxDataPoints": max_data_points}
|
883
871
|
res = await self._telemetry_request("GET", path, params=params)
|
884
872
|
return Telemetry(**res)
|
885
873
|
|
886
|
-
async def _get_max_data_points(self, class_history_uuid: str) -> int:
|
887
|
-
"""Get the max data points to use for the telemetry.
|
888
|
-
|
889
|
-
Attempts to get the amount of active time for the workout from the OT Live API. If the workout is not found,
|
890
|
-
it will default to 120 data points. If it is found, it will calculate the amount of data points needed based on
|
891
|
-
the active time. This should amount to a data point per 30 seconds, roughly.
|
892
|
-
|
893
|
-
Args:
|
894
|
-
class_history_uuid (str): The class history UUID.
|
895
|
-
|
896
|
-
Returns:
|
897
|
-
int: The max data points to use.
|
898
|
-
"""
|
899
|
-
workouts = await self.get_workouts()
|
900
|
-
workout = workouts.by_class_history_uuid.get(class_history_uuid)
|
901
|
-
max_data_points = 120 if workout is None else ceil(active_time_to_data_points(workout.active_time))
|
902
|
-
return max_data_points
|
903
|
-
|
904
874
|
# the below do not return any data for me, so I can't test them
|
905
875
|
|
906
876
|
async def _get_member_services(self, active_only: bool = True) -> typing.Any:
|
@@ -934,17 +904,3 @@ class Otf:
|
|
934
904
|
|
935
905
|
data = self._default_request("GET", f"/member/wearables/{self._member_id}/wearable-daily", params=params)
|
936
906
|
return data
|
937
|
-
|
938
|
-
async def get_body_composition_list(self) -> BodyCompositionList:
|
939
|
-
"""Get the member's body composition list.
|
940
|
-
|
941
|
-
Returns:
|
942
|
-
Any: The member's body composition list.
|
943
|
-
"""
|
944
|
-
data = await self._default_request("GET", f"/member/members/{self._member_uuid}/body-composition")
|
945
|
-
|
946
|
-
return BodyCompositionList(data=data["data"])
|
947
|
-
|
948
|
-
|
949
|
-
def active_time_to_data_points(active_time: int) -> float:
|
950
|
-
return active_time / 60 * 2
|
otf_api/auth.py
CHANGED
otf_api/models/__init__.py
CHANGED
otf_api/models/base.py
CHANGED
@@ -1,14 +1,9 @@
|
|
1
1
|
import inspect
|
2
2
|
import typing
|
3
|
-
from
|
4
|
-
from typing import Any, ClassVar, TypeVar
|
3
|
+
from typing import ClassVar, TypeVar
|
5
4
|
|
6
5
|
from box import Box
|
7
|
-
from inflection import humanize
|
8
6
|
from pydantic import BaseModel, ConfigDict
|
9
|
-
from rich.style import Style
|
10
|
-
from rich.styled import Styled
|
11
|
-
from rich.table import Table
|
12
7
|
|
13
8
|
if typing.TYPE_CHECKING:
|
14
9
|
from pydantic.main import IncEx
|
@@ -138,50 +133,6 @@ class BetterDumperMixin:
|
|
138
133
|
class OtfItemBase(BetterDumperMixin, BaseModel):
|
139
134
|
model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
|
140
135
|
|
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
136
|
|
186
137
|
class OtfListBase(BaseModel):
|
187
138
|
model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
|
@@ -191,18 +142,6 @@ class OtfListBase(BaseModel):
|
|
191
142
|
def collection(self) -> list[OtfItemBase]:
|
192
143
|
return getattr(self, self.collection_field)
|
193
144
|
|
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
145
|
def to_json(self, **kwargs) -> str:
|
207
146
|
kwargs.setdefault("indent", 4)
|
208
147
|
kwargs.setdefault("exclude_none", True)
|
@@ -21,7 +21,6 @@ from .telemetry import Telemetry
|
|
21
21
|
from .telemetry_hr_history import TelemetryHrHistory
|
22
22
|
from .telemetry_max_hr import TelemetryMaxHr
|
23
23
|
from .total_classes import TotalClasses
|
24
|
-
from .workouts import WorkoutList
|
25
24
|
|
26
25
|
__all__ = [
|
27
26
|
"BodyCompositionList",
|
@@ -56,5 +55,4 @@ __all__ = [
|
|
56
55
|
"TelemetryHrHistory",
|
57
56
|
"TelemetryMaxHr",
|
58
57
|
"TotalClasses",
|
59
|
-
"WorkoutList",
|
60
58
|
]
|
@@ -4,9 +4,6 @@ from typing import ClassVar
|
|
4
4
|
|
5
5
|
from inflection import humanize
|
6
6
|
from pydantic import Field
|
7
|
-
from rich.style import Style
|
8
|
-
from rich.styled import Styled
|
9
|
-
from rich.table import Table
|
10
7
|
|
11
8
|
from otf_api.models.base import OtfItemBase, OtfListBase
|
12
9
|
from otf_api.models.responses.classes import OtfClassTimeMixin
|
@@ -223,82 +220,7 @@ class Booking(OtfItemBase):
|
|
223
220
|
def id_val(self) -> str:
|
224
221
|
return self.class_booking_id
|
225
222
|
|
226
|
-
@property
|
227
|
-
def sidebar_data(self) -> Table:
|
228
|
-
data = {
|
229
|
-
"date": self.otf_class.date,
|
230
|
-
"class_name": self.otf_class.name,
|
231
|
-
"description": self.otf_class.description,
|
232
|
-
"class_id": self.id_val,
|
233
|
-
"studio_address": self.otf_class.studio.studio_location.physical_address,
|
234
|
-
"coach_name": self.otf_class.coach.name,
|
235
|
-
}
|
236
|
-
|
237
|
-
table = Table(expand=True, show_header=False, show_footer=False)
|
238
|
-
table.add_column("Key", style="cyan", ratio=1)
|
239
|
-
table.add_column("Value", style="magenta", ratio=2)
|
240
|
-
|
241
|
-
for key, value in data.items():
|
242
|
-
if value is False:
|
243
|
-
table.add_row(key, Styled(str(value), style="red"))
|
244
|
-
else:
|
245
|
-
table.add_row(key, str(value))
|
246
|
-
|
247
|
-
return table
|
248
|
-
|
249
|
-
def get_style(self, is_selected: bool = False) -> Style:
|
250
|
-
style = super().get_style(is_selected)
|
251
|
-
if self.status == BookingStatus.Cancelled:
|
252
|
-
style = Style(color="red")
|
253
|
-
elif self.status == BookingStatus.Waitlisted:
|
254
|
-
style = Style(color="yellow")
|
255
|
-
elif self.status == BookingStatus.CheckedIn and is_selected:
|
256
|
-
style = Style(color="blue", strike=True)
|
257
|
-
elif self.status == BookingStatus.CheckedIn:
|
258
|
-
style = Style(color="grey58")
|
259
|
-
|
260
|
-
return style
|
261
|
-
|
262
|
-
@classmethod
|
263
|
-
def attr_to_column_header(cls, attr: str) -> str:
|
264
|
-
if attr.startswith("otf_class"):
|
265
|
-
return OtfClass.attr_to_column_header(attr.split(".")[-1])
|
266
|
-
|
267
|
-
attr_map = {k: humanize(k) for k in cls.model_fields}
|
268
|
-
overrides = {
|
269
|
-
"day_of_week": "Class DoW",
|
270
|
-
"date": "Class Date",
|
271
|
-
"time": "Class Time",
|
272
|
-
"duration": "Class Duration",
|
273
|
-
"name": "Class Name",
|
274
|
-
"is_home_studio": "Home Studio",
|
275
|
-
"is_booked": "Booked",
|
276
|
-
}
|
277
|
-
|
278
|
-
attr_map.update(overrides)
|
279
|
-
|
280
|
-
return attr_map.get(attr, attr)
|
281
|
-
|
282
223
|
|
283
224
|
class BookingList(OtfListBase):
|
284
225
|
collection_field: ClassVar[str] = "bookings"
|
285
226
|
bookings: list[Booking]
|
286
|
-
|
287
|
-
@staticmethod
|
288
|
-
def show_bookings_columns() -> list[str]:
|
289
|
-
return [
|
290
|
-
"otf_class.day_of_week",
|
291
|
-
"otf_class.date",
|
292
|
-
"otf_class.time",
|
293
|
-
"otf_class.duration",
|
294
|
-
"otf_class.name",
|
295
|
-
"status",
|
296
|
-
"otf_class.studio.studio_name",
|
297
|
-
"is_home_studio",
|
298
|
-
]
|
299
|
-
|
300
|
-
def to_table(self, columns: list[str] | None = None) -> Table:
|
301
|
-
if not columns:
|
302
|
-
columns = self.show_bookings_columns()
|
303
|
-
|
304
|
-
return super().to_table(columns)
|
@@ -3,11 +3,7 @@ from enum import Enum
|
|
3
3
|
from typing import ClassVar
|
4
4
|
|
5
5
|
from humanize import precisedelta
|
6
|
-
from inflection import humanize
|
7
6
|
from pydantic import Field
|
8
|
-
from rich.style import Style
|
9
|
-
from rich.styled import Styled
|
10
|
-
from rich.table import Table
|
11
7
|
|
12
8
|
from otf_api.models.base import OtfItemBase, OtfListBase
|
13
9
|
|
@@ -176,80 +172,7 @@ class OtfClass(OtfItemBase, OtfClassTimeMixin):
|
|
176
172
|
dow = self.starts_at_local.strftime("%A")
|
177
173
|
return DoW.get_case_insensitive(dow)
|
178
174
|
|
179
|
-
@property
|
180
|
-
def sidebar_data(self) -> Table:
|
181
|
-
data = {
|
182
|
-
"class_date": self.date,
|
183
|
-
"class_time": self.time.strip(),
|
184
|
-
"class_name": self.name,
|
185
|
-
"class_id": self.id_val,
|
186
|
-
"available": self.has_availability,
|
187
|
-
"waitlist_available": self.waitlist_available,
|
188
|
-
"studio_address": self.studio.address.line1,
|
189
|
-
"coach_name": self.coach.first_name,
|
190
|
-
"waitlist_size": self.waitlist_size,
|
191
|
-
"max_capacity": self.max_capacity,
|
192
|
-
}
|
193
|
-
|
194
|
-
if not self.full:
|
195
|
-
del data["waitlist_available"]
|
196
|
-
del data["waitlist_size"]
|
197
|
-
|
198
|
-
table = Table(expand=True, show_header=False, show_footer=False)
|
199
|
-
table.add_column("Key", style="cyan", ratio=1)
|
200
|
-
table.add_column("Value", style="magenta", ratio=2)
|
201
|
-
|
202
|
-
for key, value in data.items():
|
203
|
-
if value is False:
|
204
|
-
table.add_row(key, Styled(str(value), style="red"))
|
205
|
-
else:
|
206
|
-
table.add_row(key, str(value))
|
207
|
-
|
208
|
-
return table
|
209
|
-
|
210
|
-
def get_style(self, is_selected: bool = False) -> Style:
|
211
|
-
style = super().get_style(is_selected)
|
212
|
-
if self.is_booked:
|
213
|
-
style = Style(color="grey58")
|
214
|
-
return style
|
215
|
-
|
216
|
-
@classmethod
|
217
|
-
def attr_to_column_header(cls, attr: str) -> str:
|
218
|
-
attr_map = {k: humanize(k) for k in cls.model_fields}
|
219
|
-
overrides = {
|
220
|
-
"day_of_week": "Class DoW",
|
221
|
-
"date": "Class Date",
|
222
|
-
"time": "Class Time",
|
223
|
-
"duration": "Class Duration",
|
224
|
-
"name": "Class Name",
|
225
|
-
"is_home_studio": "Home Studio",
|
226
|
-
"is_booked": "Booked",
|
227
|
-
}
|
228
|
-
|
229
|
-
attr_map.update(overrides)
|
230
|
-
|
231
|
-
return attr_map.get(attr, attr)
|
232
|
-
|
233
175
|
|
234
176
|
class OtfClassList(OtfListBase):
|
235
177
|
collection_field: ClassVar[str] = "classes"
|
236
178
|
classes: list[OtfClass]
|
237
|
-
|
238
|
-
@staticmethod
|
239
|
-
def book_class_columns() -> list[str]:
|
240
|
-
return [
|
241
|
-
"day_of_week",
|
242
|
-
"date",
|
243
|
-
"time",
|
244
|
-
"duration",
|
245
|
-
"name",
|
246
|
-
"studio.name",
|
247
|
-
"is_home_studio",
|
248
|
-
"is_booked",
|
249
|
-
]
|
250
|
-
|
251
|
-
def to_table(self, columns: list[str] | None = None) -> Table:
|
252
|
-
if not columns:
|
253
|
-
columns = self.book_class_columns()
|
254
|
-
|
255
|
-
return super().to_table(columns)
|
@@ -33,7 +33,7 @@ class Studio(OtfItemBase):
|
|
33
33
|
class Class(OtfItemBase):
|
34
34
|
ot_base_class_uuid: str | None = None
|
35
35
|
starts_at_local: str
|
36
|
-
name: str
|
36
|
+
name: str | None = None
|
37
37
|
coach: Coach
|
38
38
|
studio: Studio
|
39
39
|
|
@@ -56,7 +56,7 @@ class Ratings(OtfItemBase):
|
|
56
56
|
|
57
57
|
|
58
58
|
class PerformanceSummaryEntry(OtfItemBase):
|
59
|
-
|
59
|
+
id: str = Field(..., alias="id")
|
60
60
|
details: Details
|
61
61
|
ratable: bool
|
62
62
|
otf_class: Class = Field(..., alias="class")
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: otf-api
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.6.0
|
4
4
|
Summary: Python OrangeTheory Fitness API Client
|
5
5
|
License: MIT
|
6
6
|
Author: Jessica Smith
|
@@ -18,7 +18,7 @@ Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
18
|
Classifier: Topic :: Software Development :: Libraries
|
19
19
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
20
20
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
21
|
-
Requires-Dist: aiohttp (==3.
|
21
|
+
Requires-Dist: aiohttp (==3.8.*)
|
22
22
|
Requires-Dist: humanize (>=4.9.0,<5.0.0)
|
23
23
|
Requires-Dist: inflection (==0.5.*)
|
24
24
|
Requires-Dist: loguru (==0.7.2)
|
@@ -27,15 +27,13 @@ Requires-Dist: pint (==0.24.*)
|
|
27
27
|
Requires-Dist: pycognito (==2024.5.1)
|
28
28
|
Requires-Dist: pydantic (==2.7.3)
|
29
29
|
Requires-Dist: python-box (>=7.2.0,<8.0.0)
|
30
|
-
Requires-Dist: readchar (>=4.1.0,<5.0.0)
|
31
|
-
Requires-Dist: typer (>=0.12.3,<0.13.0)
|
32
30
|
Project-URL: Documentation, https://otf-api.readthedocs.io/en/stable/
|
33
31
|
Description-Content-Type: text/markdown
|
34
32
|
|
35
33
|
Simple API client for interacting with the OrangeTheory Fitness APIs.
|
36
34
|
|
37
35
|
|
38
|
-
This library allows access to the OrangeTheory API to retrieve workouts and performance data, class schedules, studio information, and bookings.
|
36
|
+
This library allows access to the OrangeTheory API to retrieve workouts and performance data, class schedules, studio information, and bookings.
|
39
37
|
|
40
38
|
## Installation
|
41
39
|
```bash
|
@@ -1,21 +1,16 @@
|
|
1
|
-
otf_api/__init__.py,sha256=
|
2
|
-
otf_api/api.py,sha256=
|
3
|
-
otf_api/auth.py,sha256=
|
4
|
-
otf_api/
|
5
|
-
otf_api/
|
6
|
-
otf_api/
|
7
|
-
otf_api/cli/bookings.py,sha256=wSmZA-03etcL6Tvb1vDSvHZW8EA9CZUgKX6W1pps3Yw,8161
|
8
|
-
otf_api/cli/prompts.py,sha256=iyodQXVa5v9VsrMxw0ob1okGRBDbWCSxhrNEylsOTEQ,5358
|
9
|
-
otf_api/models/__init__.py,sha256=2Zvf7u1Z3qguDd4PsWeoP_Lma3bk-A7RmYQ4LbPJ9Kg,1464
|
10
|
-
otf_api/models/base.py,sha256=oTDxyliK64GyTNx1bGTd-b9dfVn0r3YPpSycs2qEuIw,7285
|
11
|
-
otf_api/models/responses/__init__.py,sha256=UdJhkzREux-5DnHE5VSYN0KNKxyDkUkYMPWQpa9Y9qs,2000
|
1
|
+
otf_api/__init__.py,sha256=eRrcOgzcFV7KIhf6KwQ5AnvOaGfAam3AKbP01hAFOys,237
|
2
|
+
otf_api/api.py,sha256=QlRHXDwzbB97aCfacb_9FVox-5AuPLMweRQAZkz-EE0,34885
|
3
|
+
otf_api/auth.py,sha256=XzwLSi5M3DyG7bE7DmWAzXF2y6fkJyAZxHUA9lpW25M,10231
|
4
|
+
otf_api/models/__init__.py,sha256=3GHBOirQA4yu06cgD9pYmCU8u8_F9nxNHeSXDuFpe5A,1428
|
5
|
+
otf_api/models/base.py,sha256=FrYzMVA4-EWhaKwzem8bEAzRu_jK-RTCkDj5ihTSkZM,5442
|
6
|
+
otf_api/models/responses/__init__.py,sha256=xxwz-JwRd0upmI0VNdvInbAm2FOQvPo3pS0SEhWfkI4,1947
|
12
7
|
otf_api/models/responses/body_composition_list.py,sha256=RTC5bQpmMDUKqFl0nGFExdDxfnbOAGoBLWunjpOym80,12193
|
13
8
|
otf_api/models/responses/book_class.py,sha256=bWURKEjLZWPzwu3HNP2zUmHWo7q7h6_z43a9KTST0Ec,15413
|
14
|
-
otf_api/models/responses/bookings.py,sha256=
|
9
|
+
otf_api/models/responses/bookings.py,sha256=XwW6blHpw9imtXRnQJYahUgfrAeOcGZqfYE-oJZ8510,8467
|
15
10
|
otf_api/models/responses/cancel_booking.py,sha256=dmC5OP97Dy4qYT0l1KHYIitqSCo6M6Yqa0QztjgG_xQ,3859
|
16
11
|
otf_api/models/responses/challenge_tracker_content.py,sha256=KKpSWyyg3viN0vf1Sg2zTMlMZExLe3I6wowmUPWvRCA,1423
|
17
12
|
otf_api/models/responses/challenge_tracker_detail.py,sha256=o0y_ETfHmR1QhoOmvd83P6lfMZUPIwPlnS1V_po0dkE,3048
|
18
|
-
otf_api/models/responses/classes.py,sha256=
|
13
|
+
otf_api/models/responses/classes.py,sha256=VxesbFyfRhohwkjiclqTtMPl8bNc5PJajveTHtDBQ2A,4731
|
19
14
|
otf_api/models/responses/enums.py,sha256=Au8XhD-4T8ljiueUykFDc6Qz7kOoTlJ_kiDEx7nLVLM,1191
|
20
15
|
otf_api/models/responses/favorite_studios.py,sha256=C5JSyiNijm6HQEBVrV9vPfZexSWQ1IlN0E3Ag0GeP_0,4982
|
21
16
|
otf_api/models/responses/latest_agreement.py,sha256=aE8hbWE4Pgguw4Itah7a1SqwOLpJ6t9oODFwLQ8Wzo0,774
|
@@ -25,18 +20,16 @@ otf_api/models/responses/member_membership.py,sha256=_z301T9DrdQW9vIgnx_LeZmkRhv
|
|
25
20
|
otf_api/models/responses/member_purchases.py,sha256=JoTk3hYjsq4rXogVivZxeFaM-j3gIChmIAGVldOU7rE,6085
|
26
21
|
otf_api/models/responses/out_of_studio_workout_history.py,sha256=FwdnmTgFrMtQ8PngsmCv3UroWj3kDnQg6KfGLievoaU,1709
|
27
22
|
otf_api/models/responses/performance_summary_detail.py,sha256=H5yWxGShR4uiXvY2OaniENburTGM7DKQjN7gvF3MG6g,1585
|
28
|
-
otf_api/models/responses/performance_summary_list.py,sha256=
|
23
|
+
otf_api/models/responses/performance_summary_list.py,sha256=R__tsXGz5tVX5gfoRoVUNK4UP2pXRoK5jdSyHABsDXs,1234
|
29
24
|
otf_api/models/responses/studio_detail.py,sha256=CJBCsi4SMs_W5nrWE4hfCs1ugJ5t7GrH80hTv7Ie3eg,5007
|
30
25
|
otf_api/models/responses/studio_services.py,sha256=mFDClPtU0HCk5fb19gjGKpt2F8n8kto7sj1pE_l4RdQ,1836
|
31
26
|
otf_api/models/responses/telemetry.py,sha256=8dl8FKLeyb6jtqsZT7XD4JzXBMLlami448-Jt0tFbSY,1663
|
32
27
|
otf_api/models/responses/telemetry_hr_history.py,sha256=vDcLb4wTHVBw8O0mGblUujHfJegkflOCWW-bnTXNCI0,763
|
33
28
|
otf_api/models/responses/telemetry_max_hr.py,sha256=xKxH0fIlOqFyZv8UW98XsxF-GMoIs9gnCTAbu88ZQtg,266
|
34
29
|
otf_api/models/responses/total_classes.py,sha256=WrKkWbq0eK8J0RC4qhZ5kmXnv_ZTDbyzsoRm7XKGlss,288
|
35
|
-
otf_api/models/responses/workouts.py,sha256=4r6wQVY-yUsI83JYBpSCYhd7I5u-5OLvy1Vd1_gra88,3177
|
36
30
|
otf_api/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
37
|
-
otf_api-0.
|
38
|
-
otf_api-0.
|
39
|
-
otf_api-0.
|
40
|
-
otf_api-0.
|
41
|
-
otf_api-0.
|
42
|
-
otf_api-0.4.0.dist-info/RECORD,,
|
31
|
+
otf_api-0.6.0.dist-info/AUTHORS.md,sha256=FcNWMxpe8KDuTq4Qau0SUXsabQwGs9TGnMp1WkXRnj8,123
|
32
|
+
otf_api-0.6.0.dist-info/LICENSE,sha256=UaPT9ynYigC3nX8n22_rC37n-qmTRKLFaHrtUwF9ktE,1071
|
33
|
+
otf_api-0.6.0.dist-info/METADATA,sha256=lzG11-AQbcrEr5whF8QiBWSeJljHCG3V2OL_I1hDl6M,2031
|
34
|
+
otf_api-0.6.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
35
|
+
otf_api-0.6.0.dist-info/RECORD,,
|
otf_api/cli/__init__.py
DELETED
otf_api/cli/_utilities.py
DELETED
@@ -1,60 +0,0 @@
|
|
1
|
-
import functools
|
2
|
-
import inspect
|
3
|
-
import traceback
|
4
|
-
from collections.abc import Awaitable, Callable
|
5
|
-
from typing import Any, NoReturn, ParamSpec, TypeGuard, TypeVar
|
6
|
-
|
7
|
-
import typer
|
8
|
-
from click.exceptions import ClickException
|
9
|
-
|
10
|
-
T = TypeVar("T")
|
11
|
-
P = ParamSpec("P")
|
12
|
-
R = TypeVar("R")
|
13
|
-
|
14
|
-
|
15
|
-
def is_async_fn(func: Callable[P, R] | Callable[P, Awaitable[R]]) -> TypeGuard[Callable[P, Awaitable[R]]]:
|
16
|
-
"""
|
17
|
-
Returns `True` if a function returns a coroutine.
|
18
|
-
|
19
|
-
See https://github.com/microsoft/pyright/issues/2142 for an example use
|
20
|
-
"""
|
21
|
-
while hasattr(func, "__wrapped__"):
|
22
|
-
func = func.__wrapped__
|
23
|
-
|
24
|
-
return inspect.iscoroutinefunction(func)
|
25
|
-
|
26
|
-
|
27
|
-
def exit_with_error(message: Any, code: int = 1, **kwargs: Any) -> NoReturn:
|
28
|
-
"""
|
29
|
-
Utility to print a stylized error message and exit with a non-zero code
|
30
|
-
"""
|
31
|
-
from otf_api.cli.app import base_app
|
32
|
-
|
33
|
-
kwargs.setdefault("style", "red")
|
34
|
-
base_app.console.print(message, **kwargs)
|
35
|
-
raise typer.Exit(code)
|
36
|
-
|
37
|
-
|
38
|
-
def exit_with_success(message: Any, **kwargs: Any) -> NoReturn:
|
39
|
-
"""
|
40
|
-
Utility to print a stylized success message and exit with a zero code
|
41
|
-
"""
|
42
|
-
from otf_api.cli.app import base_app
|
43
|
-
|
44
|
-
kwargs.setdefault("style", "green")
|
45
|
-
base_app.console.print(message, **kwargs)
|
46
|
-
raise typer.Exit(0)
|
47
|
-
|
48
|
-
|
49
|
-
def with_cli_exception_handling(fn: Callable[[Any], Any]) -> Callable[[Any], Any]:
|
50
|
-
@functools.wraps(fn)
|
51
|
-
def wrapper(*args: Any, **kwargs: Any) -> Any | None:
|
52
|
-
try:
|
53
|
-
return fn(*args, **kwargs)
|
54
|
-
except (typer.Exit, typer.Abort, ClickException):
|
55
|
-
raise # Do not capture click or typer exceptions
|
56
|
-
except Exception:
|
57
|
-
traceback.print_exc()
|
58
|
-
exit_with_error("An exception occurred.")
|
59
|
-
|
60
|
-
return wrapper
|
otf_api/cli/app.py
DELETED
@@ -1,172 +0,0 @@
|
|
1
|
-
import asyncio
|
2
|
-
import functools
|
3
|
-
import sys
|
4
|
-
import typing
|
5
|
-
from collections.abc import Callable
|
6
|
-
from enum import Enum
|
7
|
-
|
8
|
-
import typer
|
9
|
-
from loguru import logger
|
10
|
-
from rich.console import Console
|
11
|
-
from rich.theme import Theme
|
12
|
-
|
13
|
-
import otf_api
|
14
|
-
from otf_api.cli._utilities import is_async_fn, with_cli_exception_handling
|
15
|
-
|
16
|
-
if typing.TYPE_CHECKING:
|
17
|
-
from otf_api import Otf
|
18
|
-
|
19
|
-
|
20
|
-
class OutputType(str, Enum):
|
21
|
-
json = "json"
|
22
|
-
table = "table"
|
23
|
-
interactive = "interactive"
|
24
|
-
|
25
|
-
|
26
|
-
def version_callback(value: bool) -> None:
|
27
|
-
if value:
|
28
|
-
print(otf_api.__version__)
|
29
|
-
raise typer.Exit()
|
30
|
-
|
31
|
-
|
32
|
-
OPT_USERNAME: str = typer.Option(None, envvar=["OTF_EMAIL", "OTF_USERNAME"], help="Username for the OTF API")
|
33
|
-
OPT_PASSWORD: str = typer.Option(envvar="OTF_PASSWORD", help="Password for the OTF API", hide_input=True)
|
34
|
-
OPT_OUTPUT: OutputType = typer.Option(None, envvar="OTF_OUTPUT", show_default=False, help="Output format")
|
35
|
-
OPT_LOG_LEVEL: str = typer.Option("CRITICAL", help="Log level", envvar="OTF_LOG_LEVEL")
|
36
|
-
OPT_VERSION: bool = typer.Option(
|
37
|
-
None, "--version", callback=version_callback, help="Display the current version.", is_eager=True
|
38
|
-
)
|
39
|
-
|
40
|
-
|
41
|
-
def register_main_callback(app: "AsyncTyper") -> None:
|
42
|
-
@app.callback() # type: ignore
|
43
|
-
@with_cli_exception_handling
|
44
|
-
def main_callback(
|
45
|
-
ctx: typer.Context, # noqa
|
46
|
-
version: bool = OPT_VERSION, # noqa
|
47
|
-
username: str = OPT_USERNAME,
|
48
|
-
password: str = OPT_PASSWORD,
|
49
|
-
output: OutputType = OPT_OUTPUT,
|
50
|
-
log_level: str = OPT_LOG_LEVEL,
|
51
|
-
) -> None:
|
52
|
-
app.setup_console()
|
53
|
-
app.set_username(username)
|
54
|
-
app.password = password
|
55
|
-
app.output = output or OutputType.table
|
56
|
-
app.set_log_level(log_level)
|
57
|
-
|
58
|
-
# When running on Windows we need to ensure that the correct event loop policy is
|
59
|
-
# in place or we will not be able to spawn subprocesses. Sometimes this policy is
|
60
|
-
# changed by other libraries, but here in our CLI we should have ownership of the
|
61
|
-
# process and be able to safely force it to be the correct policy.
|
62
|
-
# https://github.com/PrefectHQ/prefect/issues/8206
|
63
|
-
if sys.platform == "win32":
|
64
|
-
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
65
|
-
|
66
|
-
|
67
|
-
class AsyncTyper(typer.Typer):
|
68
|
-
"""
|
69
|
-
Wraps commands created by `Typer` to support async functions and handle errors.
|
70
|
-
"""
|
71
|
-
|
72
|
-
console: Console
|
73
|
-
|
74
|
-
def __init__(self, *args: typing.Any, **kwargs: typing.Any):
|
75
|
-
super().__init__(*args, **kwargs)
|
76
|
-
|
77
|
-
theme = Theme({"prompt.choices": "bold blue"})
|
78
|
-
self.console = Console(highlight=False, theme=theme, color_system="auto")
|
79
|
-
|
80
|
-
# TODO: clean these up later, just don't want warnings everywhere that these could be None
|
81
|
-
self.api: Otf = None # type: ignore
|
82
|
-
self.username: str = None # type: ignore
|
83
|
-
self.password: str = None # type: ignore
|
84
|
-
self.output: OutputType = None # type: ignore
|
85
|
-
self.logger = logger
|
86
|
-
self.log_level = "CRITICAL"
|
87
|
-
|
88
|
-
def set_username(self, username: str | None = None) -> None:
|
89
|
-
if username:
|
90
|
-
self.username = username
|
91
|
-
return
|
92
|
-
|
93
|
-
raise ValueError("Username not provided and not found in cache")
|
94
|
-
|
95
|
-
def set_log_level(self, level: str) -> None:
|
96
|
-
self.log_level = level
|
97
|
-
logger.remove()
|
98
|
-
logger.add(sys.stderr, level=self.log_level.upper())
|
99
|
-
|
100
|
-
def print(self, *args: typing.Any, **kwargs: typing.Any) -> None:
|
101
|
-
if self.output == "json":
|
102
|
-
self.console.print_json(*args, **kwargs)
|
103
|
-
else:
|
104
|
-
self.console.print(*args, **kwargs)
|
105
|
-
|
106
|
-
def add_typer(
|
107
|
-
self, typer_instance: "AsyncTyper", *args: typing.Any, aliases: list[str] | None = None, **kwargs: typing.Any
|
108
|
-
) -> typing.Any:
|
109
|
-
aliases = aliases or []
|
110
|
-
for alias in aliases:
|
111
|
-
super().add_typer(typer_instance, *args, name=alias, no_args_is_help=True, hidden=True, **kwargs)
|
112
|
-
|
113
|
-
return super().add_typer(typer_instance, *args, no_args_is_help=True, **kwargs)
|
114
|
-
|
115
|
-
def command(
|
116
|
-
self, name: str | None = None, *args: typing.Any, aliases: list[str] | None = None, **kwargs: typing.Any
|
117
|
-
) -> Callable[[typing.Any], typing.Any]:
|
118
|
-
"""
|
119
|
-
Create a new command. If aliases are provided, the same command function
|
120
|
-
will be registered with multiple names.
|
121
|
-
"""
|
122
|
-
|
123
|
-
aliases = aliases or []
|
124
|
-
|
125
|
-
def wrapper(fn: Callable[[typing.Any], typing.Any]) -> Callable[[typing.Any], typing.Any]:
|
126
|
-
# click doesn't support async functions, so we wrap them in
|
127
|
-
# asyncio.run(). This has the advantage of keeping the function in
|
128
|
-
# the main thread, which means signal handling works for e.g. the
|
129
|
-
# server and workers. However, it means that async CLI commands can
|
130
|
-
# not directly call other async CLI commands (because asyncio.run()
|
131
|
-
# can not be called nested). In that (rare) circumstance, refactor
|
132
|
-
# the CLI command so its business logic can be invoked separately
|
133
|
-
# from its entrypoint.
|
134
|
-
if is_async_fn(fn):
|
135
|
-
_fn = fn
|
136
|
-
|
137
|
-
@functools.wraps(fn)
|
138
|
-
def fn(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
|
139
|
-
return asyncio.run(_fn(*args, **kwargs)) # type: ignore
|
140
|
-
|
141
|
-
fn.aio = _fn # type: ignore
|
142
|
-
|
143
|
-
fn = with_cli_exception_handling(fn)
|
144
|
-
|
145
|
-
# register fn with its original name
|
146
|
-
command_decorator = super(AsyncTyper, self).command(name=name, *args, **kwargs)
|
147
|
-
original_command = command_decorator(fn)
|
148
|
-
|
149
|
-
# register fn for each alias, e.g. @marvin_app.command(aliases=["r"])
|
150
|
-
for alias in aliases:
|
151
|
-
super(AsyncTyper, self).command(
|
152
|
-
name=alias,
|
153
|
-
*args,
|
154
|
-
**{k: v for k, v in kwargs.items() if k != "aliases"},
|
155
|
-
)(fn)
|
156
|
-
|
157
|
-
return typing.cast(Callable[[typing.Any], typing.Any], original_command)
|
158
|
-
|
159
|
-
return wrapper
|
160
|
-
|
161
|
-
def setup_console(self, soft_wrap: bool = True, prompt: bool = True) -> None:
|
162
|
-
self.console = Console(
|
163
|
-
highlight=False,
|
164
|
-
color_system="auto",
|
165
|
-
theme=Theme({"prompt.choices": "bold blue"}),
|
166
|
-
soft_wrap=not soft_wrap,
|
167
|
-
force_interactive=prompt,
|
168
|
-
)
|
169
|
-
|
170
|
-
|
171
|
-
base_app = AsyncTyper(add_completion=True, no_args_is_help=True)
|
172
|
-
register_main_callback(base_app)
|
otf_api/cli/bookings.py
DELETED
@@ -1,231 +0,0 @@
|
|
1
|
-
from enum import Enum
|
2
|
-
|
3
|
-
import pendulum
|
4
|
-
import typer
|
5
|
-
from loguru import logger
|
6
|
-
|
7
|
-
import otf_api
|
8
|
-
from otf_api.cli.app import OPT_OUTPUT, AsyncTyper, OutputType, base_app
|
9
|
-
from otf_api.cli.prompts import prompt_select_from_table
|
10
|
-
from otf_api.models.responses.bookings import BookingStatus
|
11
|
-
from otf_api.models.responses.classes import ClassType, ClassTypeCli, DoW
|
12
|
-
|
13
|
-
flipped_status = {item.value: item.name for item in BookingStatus}
|
14
|
-
FlippedEnum = Enum("CliBookingStatus", flipped_status) # type: ignore
|
15
|
-
|
16
|
-
|
17
|
-
bookings_app = AsyncTyper(name="bookings", help="Get bookings data")
|
18
|
-
classes_app = AsyncTyper(name="classes", help="Get classes data")
|
19
|
-
base_app.add_typer(bookings_app, aliases=["booking"])
|
20
|
-
base_app.add_typer(classes_app, aliases=["class"])
|
21
|
-
|
22
|
-
|
23
|
-
def today() -> str:
|
24
|
-
val: str = pendulum.yesterday().date().to_date_string()
|
25
|
-
return val
|
26
|
-
|
27
|
-
|
28
|
-
def next_month() -> str:
|
29
|
-
val: str = pendulum.now().add(months=1).date().to_date_string()
|
30
|
-
return val
|
31
|
-
|
32
|
-
|
33
|
-
@bookings_app.command(name="list")
|
34
|
-
async def list_bookings(
|
35
|
-
start_date: str = typer.Option(default_factory=today, help="Start date for bookings"),
|
36
|
-
end_date: str = typer.Option(default_factory=next_month, help="End date for bookings"),
|
37
|
-
status: FlippedEnum = typer.Option(None, case_sensitive=False, help="Booking status"),
|
38
|
-
limit: int = typer.Option(None, help="Limit the number of bookings returned"),
|
39
|
-
exclude_cancelled: bool = typer.Option(
|
40
|
-
True, "--exclude-cancelled/--include-cancelled", help="Exclude cancelled bookings", show_default=True
|
41
|
-
),
|
42
|
-
exclude_none: bool = typer.Option(
|
43
|
-
True, "--exclude-none/--allow-none", help="Exclude fields with a value of None", show_default=True
|
44
|
-
),
|
45
|
-
output: OutputType = OPT_OUTPUT,
|
46
|
-
) -> None:
|
47
|
-
"""
|
48
|
-
List bookings data
|
49
|
-
"""
|
50
|
-
|
51
|
-
logger.info("Listing bookings data")
|
52
|
-
|
53
|
-
if output:
|
54
|
-
base_app.output = output
|
55
|
-
|
56
|
-
bk_status = BookingStatus.get_from_key_insensitive(status.value) if status else None
|
57
|
-
|
58
|
-
if not base_app.api:
|
59
|
-
base_app.api = await otf_api.Otf.create(base_app.username, base_app.password)
|
60
|
-
bookings = await base_app.api.get_bookings(start_date, end_date, bk_status, limit, exclude_cancelled)
|
61
|
-
|
62
|
-
if base_app.output == "json":
|
63
|
-
base_app.print(bookings.to_json(exclude_none=exclude_none))
|
64
|
-
elif base_app.output == "table":
|
65
|
-
base_app.print(bookings.to_table())
|
66
|
-
elif base_app.output == "interactive":
|
67
|
-
result = prompt_select_from_table(
|
68
|
-
console=base_app.console,
|
69
|
-
prompt="Select a booking",
|
70
|
-
columns=bookings.show_bookings_columns(),
|
71
|
-
data=bookings.bookings,
|
72
|
-
)
|
73
|
-
print(result)
|
74
|
-
|
75
|
-
|
76
|
-
@bookings_app.command()
|
77
|
-
async def book(class_uuid: str = typer.Option(help="Class UUID to cancel")) -> None:
|
78
|
-
"""
|
79
|
-
Book a class
|
80
|
-
"""
|
81
|
-
|
82
|
-
logger.info(f"Booking class {class_uuid}")
|
83
|
-
|
84
|
-
if not base_app.api:
|
85
|
-
base_app.api = await otf_api.Otf.create(base_app.username, base_app.password)
|
86
|
-
booking = await base_app.api.book_class(class_uuid)
|
87
|
-
|
88
|
-
base_app.console.print(booking)
|
89
|
-
|
90
|
-
|
91
|
-
@bookings_app.command()
|
92
|
-
async def book_interactive(
|
93
|
-
studio_uuids: list[str] = typer.Option(None, help="Studio UUIDs to get classes for"),
|
94
|
-
include_home_studio: bool = typer.Option(True, help="Include the home studio in the classes"),
|
95
|
-
start_date: str = typer.Option(default_factory=today, help="Start date for classes"),
|
96
|
-
end_date: str = typer.Option(None, help="End date for classes"),
|
97
|
-
limit: int = typer.Option(None, help="Limit the number of classes returned"),
|
98
|
-
class_type: list[ClassTypeCli] = typer.Option(None, help="Class type to filter by"),
|
99
|
-
day_of_week: list[DoW] = typer.Option(None, help="Days of the week to filter by"),
|
100
|
-
exclude_cancelled: bool = typer.Option(
|
101
|
-
True, "--exclude-cancelled/--allow-cancelled", help="Exclude cancelled classes", show_default=True
|
102
|
-
),
|
103
|
-
start_time: list[str] = typer.Option(None, help="Start time for classes"),
|
104
|
-
) -> None:
|
105
|
-
"""
|
106
|
-
Book a class interactively
|
107
|
-
"""
|
108
|
-
|
109
|
-
logger.info("Booking class interactively")
|
110
|
-
|
111
|
-
with base_app.console.status("Getting classes...", spinner="arc"):
|
112
|
-
if class_type:
|
113
|
-
class_type_enums = [ClassType.get_from_key_insensitive(class_type.value) for class_type in class_type]
|
114
|
-
else:
|
115
|
-
class_type_enums = None
|
116
|
-
|
117
|
-
if not base_app.api:
|
118
|
-
base_app.api = await otf_api.Otf.create(base_app.username, base_app.password)
|
119
|
-
|
120
|
-
classes = await base_app.api.get_classes(
|
121
|
-
studio_uuids,
|
122
|
-
include_home_studio,
|
123
|
-
start_date,
|
124
|
-
end_date,
|
125
|
-
limit,
|
126
|
-
class_type_enums,
|
127
|
-
exclude_cancelled,
|
128
|
-
day_of_week,
|
129
|
-
start_time,
|
130
|
-
)
|
131
|
-
|
132
|
-
result = prompt_select_from_table(
|
133
|
-
console=base_app.console,
|
134
|
-
prompt="Book a class, any class",
|
135
|
-
columns=classes.book_class_columns(),
|
136
|
-
data=classes.classes,
|
137
|
-
)
|
138
|
-
|
139
|
-
print(result["ot_class_uuid"])
|
140
|
-
booking = await base_app.api.book_class(result["ot_class_uuid"])
|
141
|
-
|
142
|
-
base_app.console.print(booking)
|
143
|
-
|
144
|
-
|
145
|
-
@bookings_app.command()
|
146
|
-
async def cancel_interactive() -> None:
|
147
|
-
"""
|
148
|
-
Cancel a booking interactively
|
149
|
-
"""
|
150
|
-
|
151
|
-
logger.info("Cancelling booking interactively")
|
152
|
-
|
153
|
-
with base_app.console.status("Getting bookings...", spinner="arc"):
|
154
|
-
if not base_app.api:
|
155
|
-
base_app.api = await otf_api.Otf.create(base_app.username, base_app.password)
|
156
|
-
bookings = await base_app.api.get_bookings()
|
157
|
-
|
158
|
-
result = prompt_select_from_table(
|
159
|
-
console=base_app.console,
|
160
|
-
prompt="Cancel a booking, any booking",
|
161
|
-
columns=bookings.show_bookings_columns(),
|
162
|
-
data=bookings.bookings,
|
163
|
-
)
|
164
|
-
|
165
|
-
print(result["class_booking_uuid"])
|
166
|
-
booking = await base_app.api.cancel_booking(result["class_booking_uuid"])
|
167
|
-
|
168
|
-
base_app.console.print(booking)
|
169
|
-
|
170
|
-
|
171
|
-
@bookings_app.command()
|
172
|
-
async def cancel(booking_uuid: str = typer.Option(help="Booking UUID to cancel")) -> None:
|
173
|
-
"""
|
174
|
-
Cancel a booking
|
175
|
-
"""
|
176
|
-
|
177
|
-
logger.info(f"Cancelling booking {booking_uuid}")
|
178
|
-
|
179
|
-
if not base_app.api:
|
180
|
-
base_app.api = await otf_api.Otf.create(base_app.username, base_app.password)
|
181
|
-
booking = await base_app.api.cancel_booking(booking_uuid)
|
182
|
-
|
183
|
-
base_app.console.print(booking)
|
184
|
-
|
185
|
-
|
186
|
-
@classes_app.command(name="list")
|
187
|
-
async def list_classes(
|
188
|
-
studio_uuids: list[str] = typer.Option(None, help="Studio UUIDs to get classes for"),
|
189
|
-
include_home_studio: bool = typer.Option(True, help="Include the home studio in the classes"),
|
190
|
-
start_date: str = typer.Option(default_factory=today, help="Start date for classes"),
|
191
|
-
end_date: str = typer.Option(default_factory=next_month, help="End date for classes"),
|
192
|
-
limit: int = typer.Option(None, help="Limit the number of classes returned"),
|
193
|
-
class_type: ClassTypeCli = typer.Option(None, help="Class type to filter by"),
|
194
|
-
exclude_cancelled: bool = typer.Option(
|
195
|
-
True, "--exclude-cancelled/--allow-cancelled", help="Exclude cancelled classes", show_default=True
|
196
|
-
),
|
197
|
-
exclude_none: bool = typer.Option(
|
198
|
-
True, "--exclude-none/--allow-none", help="Exclude fields with a value of None", show_default=True
|
199
|
-
),
|
200
|
-
output: OutputType = OPT_OUTPUT,
|
201
|
-
) -> None:
|
202
|
-
"""
|
203
|
-
List classes data
|
204
|
-
"""
|
205
|
-
|
206
|
-
logger.info("Listing classes")
|
207
|
-
|
208
|
-
if output:
|
209
|
-
base_app.output = output
|
210
|
-
|
211
|
-
class_type_enum = ClassType.get_from_key_insensitive(class_type.value) if class_type else None
|
212
|
-
|
213
|
-
if not base_app.api:
|
214
|
-
base_app.api = await otf_api.Otf.create(base_app.username, base_app.password)
|
215
|
-
classes = await base_app.api.get_classes(
|
216
|
-
studio_uuids, include_home_studio, start_date, end_date, limit, class_type_enum, exclude_cancelled
|
217
|
-
)
|
218
|
-
|
219
|
-
if base_app.output == "json":
|
220
|
-
base_app.print(classes.to_json(exclude_none=exclude_none))
|
221
|
-
elif base_app.output == "table":
|
222
|
-
base_app.print(classes.to_table())
|
223
|
-
else:
|
224
|
-
result = prompt_select_from_table(
|
225
|
-
console=base_app.console,
|
226
|
-
prompt="Book a class, any class",
|
227
|
-
columns=classes.book_class_columns(),
|
228
|
-
data=classes.classes,
|
229
|
-
)
|
230
|
-
print(type(result))
|
231
|
-
print(result)
|
otf_api/cli/prompts.py
DELETED
@@ -1,162 +0,0 @@
|
|
1
|
-
import os
|
2
|
-
import typing
|
3
|
-
|
4
|
-
import readchar
|
5
|
-
from rich.layout import Layout
|
6
|
-
from rich.live import Live
|
7
|
-
from rich.panel import Panel
|
8
|
-
from rich.prompt import Confirm, Prompt
|
9
|
-
from rich.table import Table
|
10
|
-
|
11
|
-
from otf_api.cli._utilities import exit_with_error
|
12
|
-
from otf_api.models.base import T
|
13
|
-
|
14
|
-
if typing.TYPE_CHECKING:
|
15
|
-
from rich.console import Console
|
16
|
-
|
17
|
-
|
18
|
-
def prompt(message, **kwargs):
|
19
|
-
"""Utility to prompt the user for input with consistent styling"""
|
20
|
-
return Prompt.ask(f"[bold][green]?[/] {message}[/]", **kwargs)
|
21
|
-
|
22
|
-
|
23
|
-
def confirm(message, **kwargs):
|
24
|
-
"""Utility to prompt the user for confirmation with consistent styling"""
|
25
|
-
return Confirm.ask(f"[bold][green]?[/] {message}[/]", **kwargs)
|
26
|
-
|
27
|
-
|
28
|
-
def prompt_select_from_table(
|
29
|
-
console: "Console",
|
30
|
-
prompt: str,
|
31
|
-
columns: list[str],
|
32
|
-
data: list[T],
|
33
|
-
table_kwargs: dict | None = None,
|
34
|
-
) -> dict:
|
35
|
-
"""
|
36
|
-
Given a list of columns and some data, display options to user in a table
|
37
|
-
and prompt them to select one.
|
38
|
-
|
39
|
-
Args:
|
40
|
-
prompt: A prompt to display to the user before the table.
|
41
|
-
columns: A list of strings that represent the attributes of the data to display.
|
42
|
-
data: A list of dicts with keys corresponding to the `key` values in
|
43
|
-
the `columns` argument.
|
44
|
-
table_kwargs: Additional kwargs to pass to the `rich.Table` constructor.
|
45
|
-
Returns:
|
46
|
-
dict: Data representation of the selected row
|
47
|
-
"""
|
48
|
-
current_idx = 0
|
49
|
-
selected_row = None
|
50
|
-
table_kwargs = table_kwargs or {}
|
51
|
-
layout = Layout()
|
52
|
-
|
53
|
-
if not data:
|
54
|
-
exit_with_error("No data to display")
|
55
|
-
|
56
|
-
MODEL_TYPE = type(data[0])
|
57
|
-
|
58
|
-
TABLE_PANEL = Layout(name="left")
|
59
|
-
DATA_PANEL = Layout(name="right")
|
60
|
-
|
61
|
-
layout.split_row(TABLE_PANEL, DATA_PANEL)
|
62
|
-
|
63
|
-
TABLE_PANEL.ratio = 7
|
64
|
-
DATA_PANEL.ratio = 3
|
65
|
-
DATA_PANEL.minimum_size = 50
|
66
|
-
|
67
|
-
n_rows = os.get_terminal_size()[1] - 5
|
68
|
-
|
69
|
-
def build_table() -> Layout:
|
70
|
-
"""
|
71
|
-
Generate a table of options. The `current_idx` will be highlighted.
|
72
|
-
"""
|
73
|
-
|
74
|
-
table = initialize_table()
|
75
|
-
rows = data.copy()
|
76
|
-
rows, offset = paginate_rows(rows)
|
77
|
-
selected_item = add_rows_to_table(table, rows, offset)
|
78
|
-
|
79
|
-
finalize_table(table, prompt, selected_item)
|
80
|
-
|
81
|
-
return layout
|
82
|
-
|
83
|
-
def initialize_table() -> Table:
|
84
|
-
table_kwargs.setdefault("expand", True)
|
85
|
-
table = Table(**table_kwargs)
|
86
|
-
table.add_column()
|
87
|
-
for column in columns:
|
88
|
-
table.add_column(MODEL_TYPE.attr_to_column_header(column))
|
89
|
-
return table
|
90
|
-
|
91
|
-
def paginate_rows(rows: list[T]) -> tuple[list[T], int]:
|
92
|
-
if len(rows) > n_rows:
|
93
|
-
start = max(0, current_idx - n_rows + 1)
|
94
|
-
end = min(len(rows), start + n_rows)
|
95
|
-
rows = rows[start:end]
|
96
|
-
offset = start
|
97
|
-
else:
|
98
|
-
offset = 0
|
99
|
-
return rows, offset
|
100
|
-
|
101
|
-
def add_rows_to_table(table: Table, rows: list[T], offset: int) -> T:
|
102
|
-
selected_item: T = None
|
103
|
-
for i, item in enumerate(rows):
|
104
|
-
idx_with_offset = i + offset
|
105
|
-
is_selected_row = idx_with_offset == current_idx
|
106
|
-
if is_selected_row:
|
107
|
-
selected_item = item
|
108
|
-
table.add_row(*item.to_row(columns, is_selected_row))
|
109
|
-
return selected_item
|
110
|
-
|
111
|
-
def finalize_table(table: Table, prompt: str, selected_item: T) -> None:
|
112
|
-
if table.row_count < n_rows:
|
113
|
-
for _ in range(n_rows - table.row_count):
|
114
|
-
table.add_row()
|
115
|
-
|
116
|
-
TABLE_PANEL.update(Panel(table, title=prompt))
|
117
|
-
DATA_PANEL.update(Panel("", title="Selected Data"))
|
118
|
-
if not selected_item:
|
119
|
-
DATA_PANEL.visible = False
|
120
|
-
elif selected_item.sidebar_data is not None:
|
121
|
-
sidebar_data = selected_item.sidebar_data
|
122
|
-
DATA_PANEL.update(sidebar_data)
|
123
|
-
DATA_PANEL.visible = True
|
124
|
-
|
125
|
-
with Live(build_table(), console=console, transient=True) as live:
|
126
|
-
instructions_message = f"[bold][green]?[/] {prompt} [bright_blue][Use arrows to move; enter to select]"
|
127
|
-
live.console.print(instructions_message)
|
128
|
-
while selected_row is None:
|
129
|
-
key = readchar.readkey()
|
130
|
-
|
131
|
-
start_val = 0
|
132
|
-
offset = 0
|
133
|
-
|
134
|
-
match key:
|
135
|
-
case readchar.key.UP:
|
136
|
-
offset = -1
|
137
|
-
start_val = len(data) - 1 if current_idx < 0 else current_idx
|
138
|
-
case readchar.key.PAGE_UP:
|
139
|
-
offset = -5
|
140
|
-
start_val = len(data) - 1 if current_idx < 0 else current_idx
|
141
|
-
case readchar.key.DOWN:
|
142
|
-
offset = 1
|
143
|
-
start_val = 0 if current_idx >= len(data) else current_idx
|
144
|
-
case readchar.key.PAGE_DOWN:
|
145
|
-
offset = 5
|
146
|
-
start_val = 0 if current_idx >= len(data) else current_idx
|
147
|
-
case readchar.key.CTRL_C:
|
148
|
-
# gracefully exit with no message
|
149
|
-
exit_with_error("")
|
150
|
-
case readchar.key.ENTER | readchar.key.CR:
|
151
|
-
selected_row = data[current_idx]
|
152
|
-
|
153
|
-
current_idx = start_val + offset
|
154
|
-
|
155
|
-
if current_idx < 0:
|
156
|
-
current_idx = len(data) - 1
|
157
|
-
elif current_idx >= len(data):
|
158
|
-
current_idx = 0
|
159
|
-
|
160
|
-
live.update(build_table(), refresh=True)
|
161
|
-
|
162
|
-
return selected_row
|
@@ -1,78 +0,0 @@
|
|
1
|
-
from ast import literal_eval
|
2
|
-
from datetime import datetime
|
3
|
-
from typing import Any
|
4
|
-
|
5
|
-
from pydantic import Field, PrivateAttr
|
6
|
-
|
7
|
-
from otf_api.models.base import OtfItemBase
|
8
|
-
|
9
|
-
|
10
|
-
class WorkoutType(OtfItemBase):
|
11
|
-
id: int
|
12
|
-
display_name: str = Field(..., alias="displayName")
|
13
|
-
icon: str
|
14
|
-
|
15
|
-
|
16
|
-
class Workout(OtfItemBase):
|
17
|
-
studio_number: str = Field(..., alias="studioNumber")
|
18
|
-
studio_name: str = Field(..., alias="studioName")
|
19
|
-
class_type: str = Field(..., alias="classType")
|
20
|
-
active_time: int = Field(..., alias="activeTime")
|
21
|
-
coach: str
|
22
|
-
member_uuid: str = Field(..., alias="memberUuId")
|
23
|
-
class_date: datetime = Field(..., alias="classDate")
|
24
|
-
total_calories: int = Field(..., alias="totalCalories")
|
25
|
-
avg_hr: int = Field(..., alias="avgHr")
|
26
|
-
max_hr: int = Field(..., alias="maxHr")
|
27
|
-
avg_percent_hr: int = Field(..., alias="avgPercentHr")
|
28
|
-
max_percent_hr: int = Field(..., alias="maxPercentHr")
|
29
|
-
total_splat_points: int = Field(..., alias="totalSplatPoints")
|
30
|
-
red_zone_time_second: int = Field(..., alias="redZoneTimeSecond")
|
31
|
-
orange_zone_time_second: int = Field(..., alias="orangeZoneTimeSecond")
|
32
|
-
green_zone_time_second: int = Field(..., alias="greenZoneTimeSecond")
|
33
|
-
blue_zone_time_second: int = Field(..., alias="blueZoneTimeSecond")
|
34
|
-
black_zone_time_second: int = Field(..., alias="blackZoneTimeSecond")
|
35
|
-
step_count: int = Field(..., alias="stepCount")
|
36
|
-
class_history_uuid: str = Field(..., alias="classHistoryUuId")
|
37
|
-
class_id: str = Field(..., alias="classId")
|
38
|
-
date_created: datetime = Field(..., alias="dateCreated")
|
39
|
-
date_updated: datetime = Field(..., alias="dateUpdated")
|
40
|
-
is_intro: bool = Field(..., alias="isIntro")
|
41
|
-
is_leader: bool = Field(..., alias="isLeader")
|
42
|
-
member_email: None = Field(..., alias="memberEmail")
|
43
|
-
member_name: str = Field(..., alias="memberName")
|
44
|
-
member_performance_id: str = Field(..., alias="memberPerformanceId")
|
45
|
-
minute_by_minute_hr: list[int] = Field(
|
46
|
-
...,
|
47
|
-
alias="minuteByMinuteHr",
|
48
|
-
description="HR data for each minute of the workout. It is returned as a string literal, so it needs to be "
|
49
|
-
"evaluated to a list. If can't be parsed, it will return an empty list.",
|
50
|
-
)
|
51
|
-
source: str
|
52
|
-
studio_account_uuid: str = Field(..., alias="studioAccountUuId")
|
53
|
-
version: str
|
54
|
-
workout_type: WorkoutType = Field(..., alias="workoutType")
|
55
|
-
_minute_by_minute_raw: str | None = PrivateAttr(None)
|
56
|
-
|
57
|
-
@property
|
58
|
-
def active_time_minutes(self) -> int:
|
59
|
-
"""Get the active time in minutes."""
|
60
|
-
return self.active_time // 60
|
61
|
-
|
62
|
-
def __init__(self, **data: Any):
|
63
|
-
if "minuteByMinuteHr" in data:
|
64
|
-
try:
|
65
|
-
data["minuteByMinuteHr"] = literal_eval(data["minuteByMinuteHr"])
|
66
|
-
except (ValueError, SyntaxError):
|
67
|
-
data["minuteByMinuteHr"] = []
|
68
|
-
|
69
|
-
super().__init__(**data)
|
70
|
-
self._minute_by_minute_raw = data.get("minuteByMinuteHr")
|
71
|
-
|
72
|
-
|
73
|
-
class WorkoutList(OtfItemBase):
|
74
|
-
workouts: list[Workout]
|
75
|
-
|
76
|
-
@property
|
77
|
-
def by_class_history_uuid(self) -> dict[str, Workout]:
|
78
|
-
return {workout.class_history_uuid: workout for workout in self.workouts}
|
File without changes
|
File without changes
|
File without changes
|