otf-api 0.2.2__py3-none-any.whl → 0.4.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 +14 -69
- otf_api/api.py +873 -66
- otf_api/auth.py +314 -0
- otf_api/cli/__init__.py +4 -0
- otf_api/cli/_utilities.py +60 -0
- otf_api/cli/app.py +172 -0
- otf_api/cli/bookings.py +231 -0
- otf_api/cli/prompts.py +162 -0
- otf_api/models/__init__.py +30 -23
- otf_api/models/base.py +205 -2
- otf_api/models/responses/__init__.py +29 -29
- otf_api/models/responses/body_composition_list.py +304 -0
- otf_api/models/responses/book_class.py +405 -0
- otf_api/models/responses/bookings.py +211 -37
- otf_api/models/responses/cancel_booking.py +93 -0
- otf_api/models/responses/challenge_tracker_content.py +6 -6
- otf_api/models/responses/challenge_tracker_detail.py +6 -6
- otf_api/models/responses/classes.py +205 -7
- otf_api/models/responses/enums.py +0 -35
- otf_api/models/responses/favorite_studios.py +5 -5
- otf_api/models/responses/latest_agreement.py +2 -2
- otf_api/models/responses/lifetime_stats.py +92 -0
- otf_api/models/responses/member_detail.py +17 -12
- otf_api/models/responses/member_membership.py +2 -2
- otf_api/models/responses/member_purchases.py +9 -9
- otf_api/models/responses/out_of_studio_workout_history.py +4 -4
- otf_api/models/responses/performance_summary_detail.py +1 -1
- otf_api/models/responses/performance_summary_list.py +13 -13
- otf_api/models/responses/studio_detail.py +10 -10
- otf_api/models/responses/studio_services.py +8 -8
- otf_api/models/responses/telemetry.py +6 -6
- otf_api/models/responses/telemetry_hr_history.py +6 -6
- otf_api/models/responses/telemetry_max_hr.py +3 -3
- otf_api/models/responses/total_classes.py +2 -2
- otf_api/models/responses/workouts.py +4 -4
- otf_api-0.4.0.dist-info/METADATA +54 -0
- otf_api-0.4.0.dist-info/RECORD +42 -0
- otf_api-0.4.0.dist-info/entry_points.txt +3 -0
- otf_api/__version__.py +0 -1
- otf_api/classes_api.py +0 -44
- otf_api/member_api.py +0 -380
- otf_api/models/auth.py +0 -141
- otf_api/performance_api.py +0 -54
- otf_api/studios_api.py +0 -96
- otf_api/telemetry_api.py +0 -95
- otf_api-0.2.2.dist-info/METADATA +0 -284
- otf_api-0.2.2.dist-info/RECORD +0 -38
- {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/AUTHORS.md +0 -0
- {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/LICENSE +0 -0
- {otf_api-0.2.2.dist-info → otf_api-0.4.0.dist-info}/WHEEL +0 -0
otf_api/cli/bookings.py
ADDED
@@ -0,0 +1,231 @@
|
|
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
ADDED
@@ -0,0 +1,162 @@
|
|
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
|
otf_api/models/__init__.py
CHANGED
@@ -1,13 +1,14 @@
|
|
1
|
-
from .auth import User
|
2
1
|
from .responses import (
|
3
|
-
|
4
|
-
|
5
|
-
ALL_STUDIO_STATUS,
|
2
|
+
BodyCompositionList,
|
3
|
+
BookClass,
|
6
4
|
BookingList,
|
7
5
|
BookingStatus,
|
6
|
+
CancelBooking,
|
8
7
|
ChallengeTrackerContent,
|
9
8
|
ChallengeTrackerDetailList,
|
10
9
|
ChallengeType,
|
10
|
+
ClassType,
|
11
|
+
DoW,
|
11
12
|
EquipmentType,
|
12
13
|
FavoriteStudioList,
|
13
14
|
HistoryClassStatus,
|
@@ -17,12 +18,14 @@ from .responses import (
|
|
17
18
|
MemberPurchaseList,
|
18
19
|
OtfClassList,
|
19
20
|
OutOfStudioWorkoutHistoryList,
|
21
|
+
Pagination,
|
20
22
|
PerformanceSummaryDetail,
|
21
23
|
PerformanceSummaryList,
|
24
|
+
StatsResponse,
|
25
|
+
StatsTime,
|
22
26
|
StudioDetail,
|
23
27
|
StudioDetailList,
|
24
28
|
StudioServiceList,
|
25
|
-
StudioStatus,
|
26
29
|
Telemetry,
|
27
30
|
TelemetryHrHistory,
|
28
31
|
TelemetryMaxHr,
|
@@ -31,33 +34,37 @@ from .responses import (
|
|
31
34
|
)
|
32
35
|
|
33
36
|
__all__ = [
|
34
|
-
"
|
35
|
-
"
|
36
|
-
"BookingStatus",
|
37
|
-
"EquipmentType",
|
38
|
-
"HistoryClassStatus",
|
39
|
-
"StudioStatus",
|
37
|
+
"BodyCompositionList",
|
38
|
+
"BookClass",
|
40
39
|
"BookingList",
|
40
|
+
"BookingStatus",
|
41
|
+
"CancelBooking",
|
41
42
|
"ChallengeTrackerContent",
|
42
43
|
"ChallengeTrackerDetailList",
|
44
|
+
"ChallengeType",
|
45
|
+
"ClassType",
|
46
|
+
"DoW",
|
47
|
+
"EquipmentType",
|
48
|
+
"FavoriteStudioList",
|
49
|
+
"HistoryClassStatus",
|
43
50
|
"LatestAgreement",
|
44
51
|
"MemberDetail",
|
45
52
|
"MemberMembership",
|
46
53
|
"MemberPurchaseList",
|
54
|
+
"OtfClassList",
|
47
55
|
"OutOfStudioWorkoutHistoryList",
|
56
|
+
"Pagination",
|
57
|
+
"PerformanceSummaryDetail",
|
58
|
+
"PerformanceSummaryList",
|
59
|
+
"StatsResponse",
|
60
|
+
"StatsTime",
|
61
|
+
"StudioDetail",
|
62
|
+
"StudioDetailList",
|
48
63
|
"StudioServiceList",
|
49
|
-
"
|
50
|
-
"WorkoutList",
|
51
|
-
"FavoriteStudioList",
|
52
|
-
"OtfClassList",
|
53
|
-
"TelemetryHrHistory",
|
64
|
+
"StudioStatus",
|
54
65
|
"Telemetry",
|
66
|
+
"TelemetryHrHistory",
|
55
67
|
"TelemetryMaxHr",
|
56
|
-
"
|
57
|
-
"
|
58
|
-
"ALL_CLASS_STATUS",
|
59
|
-
"ALL_HISTORY_CLASS_STATUS",
|
60
|
-
"ALL_STUDIO_STATUS",
|
61
|
-
"PerformanceSummaryDetail",
|
62
|
-
"PerformanceSummaryList",
|
68
|
+
"TotalClasses",
|
69
|
+
"WorkoutList",
|
63
70
|
]
|
otf_api/models/base.py
CHANGED
@@ -1,7 +1,210 @@
|
|
1
|
-
|
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
|
-
|
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)
|