tlnt-cli 1.5.1__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.
- tlnt/__init__.py +3 -0
- tlnt/__main__.py +4 -0
- tlnt/browser/__init__.py +0 -0
- tlnt/browser/login.py +414 -0
- tlnt/browser/oauth.py +50 -0
- tlnt/browser/scripted.py +183 -0
- tlnt/cli/__init__.py +0 -0
- tlnt/cli/avatars.py +51 -0
- tlnt/cli/commands/__init__.py +0 -0
- tlnt/cli/commands/approvals.py +98 -0
- tlnt/cli/commands/attendance.py +283 -0
- tlnt/cli/commands/calendar.py +108 -0
- tlnt/cli/commands/login.py +225 -0
- tlnt/cli/commands/logout.py +12 -0
- tlnt/cli/commands/mcp.py +8 -0
- tlnt/cli/commands/notifications.py +102 -0
- tlnt/cli/commands/profile.py +13 -0
- tlnt/cli/commands/setup.py +12 -0
- tlnt/cli/commands/status.py +12 -0
- tlnt/cli/commands/timeoff.py +491 -0
- tlnt/cli/commands/update.py +86 -0
- tlnt/cli/commands/version.py +15 -0
- tlnt/cli/main.py +163 -0
- tlnt/cli/options.py +56 -0
- tlnt/cli/output.py +321 -0
- tlnt/cli/prompts.py +76 -0
- tlnt/core/__init__.py +0 -0
- tlnt/core/clients/__init__.py +0 -0
- tlnt/core/clients/mekari/__init__.py +0 -0
- tlnt/core/clients/mekari/client.py +826 -0
- tlnt/core/clients/mekari/constants.py +174 -0
- tlnt/core/config.py +53 -0
- tlnt/core/http_client.py +110 -0
- tlnt/core/logs.py +34 -0
- tlnt/core/models.py +84 -0
- tlnt/core/play_store.py +85 -0
- tlnt/core/services/__init__.py +46 -0
- tlnt/core/services/attendance.py +213 -0
- tlnt/core/services/calendar.py +54 -0
- tlnt/core/services/login.py +100 -0
- tlnt/core/services/logout.py +38 -0
- tlnt/core/services/notifications.py +97 -0
- tlnt/core/services/profile.py +11 -0
- tlnt/core/services/setup.py +85 -0
- tlnt/core/services/status.py +35 -0
- tlnt/core/services/timeoff.py +146 -0
- tlnt/core/session.py +189 -0
- tlnt/core/user_agent.py +71 -0
- tlnt/mcp/__init__.py +0 -0
- tlnt/mcp/main.py +176 -0
- tlnt_cli-1.5.1.dist-info/METADATA +315 -0
- tlnt_cli-1.5.1.dist-info/RECORD +55 -0
- tlnt_cli-1.5.1.dist-info/WHEEL +4 -0
- tlnt_cli-1.5.1.dist-info/entry_points.txt +3 -0
- tlnt_cli-1.5.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date as date_type
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.prompt import Prompt
|
|
9
|
+
|
|
10
|
+
from tlnt.cli import prompts
|
|
11
|
+
from tlnt.cli.commands.approvals import run_approval
|
|
12
|
+
from tlnt.cli.options import InteractiveOption, JsonOption, TableOption, VerboseOption
|
|
13
|
+
from tlnt.cli.output import action_banner, confirm, console, dig, emit, run
|
|
14
|
+
from tlnt.core.models import Result
|
|
15
|
+
from tlnt.core.services import positive_id
|
|
16
|
+
from tlnt.core.services import timeoff as timeoff_service
|
|
17
|
+
from tlnt.core.services.notifications import (
|
|
18
|
+
notification_detail as notification_detail_action,
|
|
19
|
+
notification_timeoff_list as notification_timeoff_list_action,
|
|
20
|
+
timeoff_approve as timeoff_approve_action,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def timeoff_types_list(
|
|
25
|
+
json_output: JsonOption = False,
|
|
26
|
+
table: TableOption = False,
|
|
27
|
+
verbose: VerboseOption = False,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Read available time-off types."""
|
|
30
|
+
run(
|
|
31
|
+
"Loading time-off policies...",
|
|
32
|
+
timeoff_service.timeoff_types_list,
|
|
33
|
+
json_output,
|
|
34
|
+
verbose,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def timeoff_balance(
|
|
39
|
+
date: Annotated[
|
|
40
|
+
str,
|
|
41
|
+
typer.Option("--date", help="Balance date."),
|
|
42
|
+
] = "",
|
|
43
|
+
keyword: Annotated[
|
|
44
|
+
str,
|
|
45
|
+
typer.Option("--keyword", help="Filter balances by keyword."),
|
|
46
|
+
] = "",
|
|
47
|
+
show_unlimited_balance: Annotated[
|
|
48
|
+
bool,
|
|
49
|
+
typer.Option("--show-unlimited-balance", help="Include unlimited balances."),
|
|
50
|
+
] = False,
|
|
51
|
+
policy_id: Annotated[
|
|
52
|
+
str,
|
|
53
|
+
typer.Option("--policy-id", help="Filter by time-off policy ID."),
|
|
54
|
+
] = "",
|
|
55
|
+
limited_by_date: Annotated[
|
|
56
|
+
str,
|
|
57
|
+
typer.Option("--limited-by-date", help="Limit balances by date."),
|
|
58
|
+
] = "",
|
|
59
|
+
refresh: Annotated[
|
|
60
|
+
bool,
|
|
61
|
+
typer.Option("--refresh/--no-refresh", help="Refresh balances before reading."),
|
|
62
|
+
] = True,
|
|
63
|
+
show_special_leave: Annotated[
|
|
64
|
+
bool,
|
|
65
|
+
typer.Option("--show-special-leave", help="Include special leave balances."),
|
|
66
|
+
] = False,
|
|
67
|
+
json_output: JsonOption = False,
|
|
68
|
+
table: TableOption = False,
|
|
69
|
+
verbose: VerboseOption = False,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Read the signed-in user's time-off balances."""
|
|
72
|
+
run(
|
|
73
|
+
"Loading time-off balance...",
|
|
74
|
+
lambda: timeoff_service.timeoff_balance(
|
|
75
|
+
date=date,
|
|
76
|
+
keyword=keyword,
|
|
77
|
+
show_unlimited_balance=show_unlimited_balance,
|
|
78
|
+
policy_id=policy_id,
|
|
79
|
+
limited_by_date=limited_by_date,
|
|
80
|
+
refresh=refresh,
|
|
81
|
+
show_special_leave=show_special_leave,
|
|
82
|
+
),
|
|
83
|
+
json_output,
|
|
84
|
+
verbose,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def timeoff_delegation_list(
|
|
89
|
+
page: Annotated[
|
|
90
|
+
int,
|
|
91
|
+
typer.Option("--page", help="Delegation page number."),
|
|
92
|
+
] = 1,
|
|
93
|
+
search: Annotated[
|
|
94
|
+
str,
|
|
95
|
+
typer.Option("--search", help="Search for a delegate."),
|
|
96
|
+
] = "",
|
|
97
|
+
json_output: JsonOption = False,
|
|
98
|
+
table: TableOption = False,
|
|
99
|
+
verbose: VerboseOption = False,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Search employees available for time-off delegation."""
|
|
102
|
+
if page < 1:
|
|
103
|
+
raise typer.BadParameter("page must be at least 1")
|
|
104
|
+
run(
|
|
105
|
+
"Loading delegates...",
|
|
106
|
+
lambda: timeoff_service.timeoff_delegation_list(page=page, search=search),
|
|
107
|
+
json_output,
|
|
108
|
+
verbose,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def timeoff_list(
|
|
113
|
+
page: Annotated[
|
|
114
|
+
int,
|
|
115
|
+
typer.Option("--page", help="Request page number."),
|
|
116
|
+
] = 1,
|
|
117
|
+
limit: Annotated[
|
|
118
|
+
int,
|
|
119
|
+
typer.Option("--limit", help="Maximum requests per page."),
|
|
120
|
+
] = 30,
|
|
121
|
+
date: Annotated[
|
|
122
|
+
str,
|
|
123
|
+
typer.Option("--date", help="Start date filter."),
|
|
124
|
+
] = "",
|
|
125
|
+
end_date: Annotated[
|
|
126
|
+
str,
|
|
127
|
+
typer.Option("--end-date", help="End date filter."),
|
|
128
|
+
] = "",
|
|
129
|
+
status: Annotated[
|
|
130
|
+
int,
|
|
131
|
+
typer.Option("--status", help="Time-off request status."),
|
|
132
|
+
] = 0,
|
|
133
|
+
policy_id: Annotated[
|
|
134
|
+
str,
|
|
135
|
+
typer.Option("--policy-id", help="Filter by time-off policy ID."),
|
|
136
|
+
] = "",
|
|
137
|
+
all_period: Annotated[
|
|
138
|
+
bool,
|
|
139
|
+
typer.Option("--all-period/--no-all-period", help="Include all periods."),
|
|
140
|
+
] = True,
|
|
141
|
+
show_metrics: Annotated[
|
|
142
|
+
bool,
|
|
143
|
+
typer.Option("--show-metrics", help="Include request metrics."),
|
|
144
|
+
] = False,
|
|
145
|
+
json_output: JsonOption = False,
|
|
146
|
+
table: TableOption = False,
|
|
147
|
+
verbose: VerboseOption = False,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Read time-off requests."""
|
|
150
|
+
if page < 1:
|
|
151
|
+
raise typer.BadParameter("page must be at least 1")
|
|
152
|
+
if limit < 1:
|
|
153
|
+
raise typer.BadParameter("limit must be at least 1")
|
|
154
|
+
run(
|
|
155
|
+
"Loading time-off requests...",
|
|
156
|
+
lambda: timeoff_service.timeoff_list(
|
|
157
|
+
page=page,
|
|
158
|
+
limit=limit,
|
|
159
|
+
date=date,
|
|
160
|
+
end_date=end_date,
|
|
161
|
+
status=status,
|
|
162
|
+
policy_id=policy_id,
|
|
163
|
+
all_period=all_period,
|
|
164
|
+
show_metrics=show_metrics,
|
|
165
|
+
),
|
|
166
|
+
json_output,
|
|
167
|
+
verbose,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def timeoff_detail(
|
|
172
|
+
request_id: Annotated[
|
|
173
|
+
int,
|
|
174
|
+
typer.Argument(help="Time-off request ID."),
|
|
175
|
+
],
|
|
176
|
+
json_output: JsonOption = False,
|
|
177
|
+
table: TableOption = False,
|
|
178
|
+
verbose: VerboseOption = False,
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Read one time-off request."""
|
|
181
|
+
if request_id < 1:
|
|
182
|
+
raise typer.BadParameter("request_id must be at least 1")
|
|
183
|
+
run(
|
|
184
|
+
"Loading time-off request...",
|
|
185
|
+
lambda: timeoff_service.timeoff_detail(request_id),
|
|
186
|
+
json_output,
|
|
187
|
+
verbose,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _valid_schedule_time(value: str) -> bool:
|
|
193
|
+
return (
|
|
194
|
+
len(value) == 5
|
|
195
|
+
and value[2] == ":"
|
|
196
|
+
and value[:2].isdigit()
|
|
197
|
+
and value[3:].isdigit()
|
|
198
|
+
and 0 <= int(value[:2]) < 24
|
|
199
|
+
and 0 <= int(value[3:]) < 60
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def timeoff_request(
|
|
204
|
+
date: Annotated[
|
|
205
|
+
str | None,
|
|
206
|
+
typer.Option("--date", help="Time-off date in YYYY-MM-DD format."),
|
|
207
|
+
] = None,
|
|
208
|
+
policy_id: Annotated[
|
|
209
|
+
str | None,
|
|
210
|
+
typer.Option("--policy-id", help="Time-off policy ID."),
|
|
211
|
+
] = None,
|
|
212
|
+
reason: Annotated[
|
|
213
|
+
str | None,
|
|
214
|
+
typer.Option("--reason", help="Reason for the time-off request."),
|
|
215
|
+
] = None,
|
|
216
|
+
half_day: Annotated[
|
|
217
|
+
str | None,
|
|
218
|
+
typer.Option(
|
|
219
|
+
"--half-day",
|
|
220
|
+
help="Choose before or after break for a half-day leave.",
|
|
221
|
+
),
|
|
222
|
+
] = None,
|
|
223
|
+
schedule_in: Annotated[
|
|
224
|
+
str,
|
|
225
|
+
typer.Option("--schedule-in", help="Schedule start time (HH:MM)."),
|
|
226
|
+
] = "",
|
|
227
|
+
schedule_out: Annotated[
|
|
228
|
+
str,
|
|
229
|
+
typer.Option("--schedule-out", help="Schedule end time (HH:MM)."),
|
|
230
|
+
] = "",
|
|
231
|
+
delegate_to: Annotated[
|
|
232
|
+
str,
|
|
233
|
+
typer.Option("--delegate-to", help="Delegate the request to an employee ID."),
|
|
234
|
+
] = "",
|
|
235
|
+
file_path: Annotated[
|
|
236
|
+
Path | None,
|
|
237
|
+
typer.Option(
|
|
238
|
+
"--file",
|
|
239
|
+
exists=True,
|
|
240
|
+
dir_okay=False,
|
|
241
|
+
readable=True,
|
|
242
|
+
help="Attach one file to the request.",
|
|
243
|
+
),
|
|
244
|
+
] = None,
|
|
245
|
+
interactive: InteractiveOption = False,
|
|
246
|
+
json_output: JsonOption = False,
|
|
247
|
+
table: TableOption = False,
|
|
248
|
+
verbose: VerboseOption = False,
|
|
249
|
+
) -> None:
|
|
250
|
+
"""Request a full-day or half-day time off."""
|
|
251
|
+
if interactive:
|
|
252
|
+
unavailable = prompts.guard(json_output, title="Time-off request")
|
|
253
|
+
if unavailable is not None:
|
|
254
|
+
emit(unavailable, json_output)
|
|
255
|
+
return
|
|
256
|
+
if policy_id is None:
|
|
257
|
+
policies = timeoff_service.timeoff_types_list()
|
|
258
|
+
if not policies.ok:
|
|
259
|
+
emit(policies, json_output)
|
|
260
|
+
return
|
|
261
|
+
rows = dig(policies.data, "timeOffPolicies")
|
|
262
|
+
if not rows:
|
|
263
|
+
raise typer.BadParameter("No time-off policies available.")
|
|
264
|
+
selected = prompts.pick(rows, ("id", "policy_name"), "Select a policy")
|
|
265
|
+
policy_id = str(selected["id"])
|
|
266
|
+
date = prompts.ask_required(
|
|
267
|
+
"Date (YYYY-MM-DD)", date or "", default=date_type.today().isoformat()
|
|
268
|
+
)
|
|
269
|
+
reason = prompts.ask_required("Reason", reason or "")
|
|
270
|
+
if half_day is None:
|
|
271
|
+
leave_type = Prompt.ask(
|
|
272
|
+
"Leave type",
|
|
273
|
+
choices=["full", "before", "after"],
|
|
274
|
+
default="full",
|
|
275
|
+
console=console,
|
|
276
|
+
)
|
|
277
|
+
half_day = None if leave_type == "full" else leave_type
|
|
278
|
+
if half_day:
|
|
279
|
+
schedule_in = prompts.ask_required("Schedule start (HH:MM)", schedule_in)
|
|
280
|
+
schedule_out = prompts.ask_required("Schedule end (HH:MM)", schedule_out)
|
|
281
|
+
file_path = prompts.ask_path("Attachment path (optional)", file_path)
|
|
282
|
+
|
|
283
|
+
if date is None:
|
|
284
|
+
raise typer.BadParameter("Missing option '--date'.", param_hint="--date")
|
|
285
|
+
if policy_id is None:
|
|
286
|
+
raise typer.BadParameter("Missing option '--policy-id'.", param_hint="--policy-id")
|
|
287
|
+
if reason is None:
|
|
288
|
+
raise typer.BadParameter("Missing option '--reason'.", param_hint="--reason")
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
parsed_date = date_type.fromisoformat(date)
|
|
292
|
+
except ValueError as error:
|
|
293
|
+
raise typer.BadParameter(
|
|
294
|
+
"date must use YYYY-MM-DD format",
|
|
295
|
+
param_hint="--date",
|
|
296
|
+
) from error
|
|
297
|
+
if parsed_date.isoformat() != date:
|
|
298
|
+
raise typer.BadParameter("date must use YYYY-MM-DD format", param_hint="--date")
|
|
299
|
+
if not policy_id.strip():
|
|
300
|
+
raise typer.BadParameter("policy_id must not be empty", param_hint="--policy-id")
|
|
301
|
+
if not reason.strip():
|
|
302
|
+
raise typer.BadParameter("reason must not be empty", param_hint="--reason")
|
|
303
|
+
if half_day not in {None, "before", "after"}:
|
|
304
|
+
raise typer.BadParameter(
|
|
305
|
+
"half-day must be before or after",
|
|
306
|
+
param_hint="--half-day",
|
|
307
|
+
)
|
|
308
|
+
if half_day:
|
|
309
|
+
if not schedule_in or not schedule_out:
|
|
310
|
+
raise typer.BadParameter(
|
|
311
|
+
"both schedule times are required for half-day leave",
|
|
312
|
+
param_hint="--schedule-in and --schedule-out",
|
|
313
|
+
)
|
|
314
|
+
if not _valid_schedule_time(schedule_in):
|
|
315
|
+
raise typer.BadParameter(
|
|
316
|
+
"schedule-in must use 24-hour HH:MM format",
|
|
317
|
+
param_hint="--schedule-in",
|
|
318
|
+
)
|
|
319
|
+
if not _valid_schedule_time(schedule_out):
|
|
320
|
+
raise typer.BadParameter(
|
|
321
|
+
"schedule-out must use 24-hour HH:MM format",
|
|
322
|
+
param_hint="--schedule-out",
|
|
323
|
+
)
|
|
324
|
+
elif schedule_in or schedule_out:
|
|
325
|
+
raise typer.BadParameter(
|
|
326
|
+
"schedule times require --half-day",
|
|
327
|
+
param_hint="--half-day",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
request_period = (
|
|
331
|
+
f"{half_day}-break half-day from {schedule_in} to {schedule_out}"
|
|
332
|
+
if half_day
|
|
333
|
+
else "full-day"
|
|
334
|
+
)
|
|
335
|
+
action_banner("Time-off request", enabled=not json_output)
|
|
336
|
+
if not confirm(
|
|
337
|
+
f"Request {request_period} time off on {date} with policy {policy_id} "
|
|
338
|
+
f"for {reason!r}?",
|
|
339
|
+
default=False,
|
|
340
|
+
err=json_output,
|
|
341
|
+
):
|
|
342
|
+
emit(
|
|
343
|
+
Result(False, "timeoff_action_cancelled", "Time-off request cancelled"),
|
|
344
|
+
json_output,
|
|
345
|
+
)
|
|
346
|
+
return
|
|
347
|
+
emit(
|
|
348
|
+
timeoff_service.timeoff_request(
|
|
349
|
+
date=date,
|
|
350
|
+
policy_id=policy_id,
|
|
351
|
+
reason=reason,
|
|
352
|
+
half_day_after_break=half_day == "after",
|
|
353
|
+
half_day_before_break=half_day == "before",
|
|
354
|
+
schedule_in=schedule_in,
|
|
355
|
+
delegate_to=delegate_to,
|
|
356
|
+
file_path=str(file_path) if file_path is not None else None,
|
|
357
|
+
schedule_out=schedule_out,
|
|
358
|
+
),
|
|
359
|
+
json_output,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def timeoff_cancel(
|
|
364
|
+
request_id: Annotated[
|
|
365
|
+
int | None,
|
|
366
|
+
typer.Argument(help="Time-off request ID."),
|
|
367
|
+
] = None,
|
|
368
|
+
request_type: Annotated[
|
|
369
|
+
str,
|
|
370
|
+
typer.Option("--type", help="Cancellation request type."),
|
|
371
|
+
] = "1002",
|
|
372
|
+
interactive: InteractiveOption = False,
|
|
373
|
+
json_output: JsonOption = False,
|
|
374
|
+
table: TableOption = False,
|
|
375
|
+
verbose: VerboseOption = False,
|
|
376
|
+
) -> None:
|
|
377
|
+
"""Cancel a time-off request after confirmation."""
|
|
378
|
+
if interactive:
|
|
379
|
+
unavailable = prompts.guard(json_output, title="Cancel time-off")
|
|
380
|
+
if unavailable is not None:
|
|
381
|
+
emit(unavailable, json_output)
|
|
382
|
+
return
|
|
383
|
+
if request_id is None:
|
|
384
|
+
listing = timeoff_service.timeoff_list(
|
|
385
|
+
page=1,
|
|
386
|
+
limit=30,
|
|
387
|
+
date="",
|
|
388
|
+
end_date="",
|
|
389
|
+
status=0,
|
|
390
|
+
policy_id="",
|
|
391
|
+
all_period=True,
|
|
392
|
+
show_metrics=False,
|
|
393
|
+
)
|
|
394
|
+
if not listing.ok:
|
|
395
|
+
emit(listing, json_output)
|
|
396
|
+
return
|
|
397
|
+
rows = dig(listing.data, "time_off_request.data")
|
|
398
|
+
if not rows:
|
|
399
|
+
raise typer.BadParameter("No time-off requests available to cancel.")
|
|
400
|
+
last_page = dig(listing.data, "time_off_request.last_page")
|
|
401
|
+
if isinstance(last_page, int) and last_page > 1:
|
|
402
|
+
console.print(
|
|
403
|
+
"More time-off requests may exist — see `tlnt timeoff list --page`.",
|
|
404
|
+
style="yellow",
|
|
405
|
+
)
|
|
406
|
+
selected = prompts.pick(rows, ("id", "request_id"), "Select a request to cancel")
|
|
407
|
+
request_id = int(selected["id"])
|
|
408
|
+
|
|
409
|
+
if request_id is None:
|
|
410
|
+
raise typer.BadParameter("Missing argument 'REQUEST_ID'.")
|
|
411
|
+
if request_id < 1:
|
|
412
|
+
raise typer.BadParameter("request_id must be at least 1")
|
|
413
|
+
action_banner("Cancel time-off", enabled=not json_output)
|
|
414
|
+
if not confirm(
|
|
415
|
+
f"Cancel time-off request {request_id} (type {request_type})?",
|
|
416
|
+
default=False,
|
|
417
|
+
err=json_output,
|
|
418
|
+
):
|
|
419
|
+
emit(
|
|
420
|
+
Result(False, "timeoff_action_cancelled", "Time-off action cancelled"),
|
|
421
|
+
json_output,
|
|
422
|
+
)
|
|
423
|
+
return
|
|
424
|
+
emit(
|
|
425
|
+
timeoff_service.timeoff_cancel(
|
|
426
|
+
request_id=request_id,
|
|
427
|
+
request_type=request_type,
|
|
428
|
+
),
|
|
429
|
+
json_output,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _timeoff_approval_fields(detail: Result) -> tuple[int, str] | Result:
|
|
434
|
+
inbox = dig(detail.data, "inbox")
|
|
435
|
+
if isinstance(dig(inbox, "live_attendance_request"), dict):
|
|
436
|
+
return Result(
|
|
437
|
+
False,
|
|
438
|
+
"invalid_input",
|
|
439
|
+
"This inbox item is an attendance request; approve it with "
|
|
440
|
+
"`tlnt attendance approve`",
|
|
441
|
+
)
|
|
442
|
+
message = inbox.get("message") if isinstance(inbox, dict) else None
|
|
443
|
+
# fk_ref and timeoff_request.id name the same request; when both are
|
|
444
|
+
# present they must agree.
|
|
445
|
+
ids = {
|
|
446
|
+
positive_id(raw_id)
|
|
447
|
+
for raw_id in (dig(inbox, "fk_ref"), dig(inbox, "timeoff_request.id"))
|
|
448
|
+
if raw_id is not None
|
|
449
|
+
}
|
|
450
|
+
request_id = ids.pop() if len(ids) == 1 else None
|
|
451
|
+
if request_id is None or not isinstance(message, str):
|
|
452
|
+
return Result(
|
|
453
|
+
False,
|
|
454
|
+
"remote_error",
|
|
455
|
+
"Mekari returned an invalid time-off approval response",
|
|
456
|
+
detail.status_code,
|
|
457
|
+
detail.data,
|
|
458
|
+
)
|
|
459
|
+
return int(request_id), message
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def timeoff_approve(
|
|
463
|
+
inbox_id: Annotated[
|
|
464
|
+
int | None,
|
|
465
|
+
typer.Argument(help="Talenta inbox ID."),
|
|
466
|
+
] = None,
|
|
467
|
+
reason: Annotated[
|
|
468
|
+
str,
|
|
469
|
+
typer.Option("--reason", help="Optional approval reason."),
|
|
470
|
+
] = "",
|
|
471
|
+
interactive: InteractiveOption = False,
|
|
472
|
+
json_output: JsonOption = False,
|
|
473
|
+
table: TableOption = False,
|
|
474
|
+
verbose: VerboseOption = False,
|
|
475
|
+
) -> None:
|
|
476
|
+
"""Approve a time-off request after confirmation."""
|
|
477
|
+
run_approval(
|
|
478
|
+
inbox_id,
|
|
479
|
+
reason,
|
|
480
|
+
interactive,
|
|
481
|
+
json_output,
|
|
482
|
+
verbose,
|
|
483
|
+
noun="time-off",
|
|
484
|
+
id_label="Time-off request ID",
|
|
485
|
+
list_command="tlnt notification timeoff-list",
|
|
486
|
+
cancelled_code="timeoff_approval_cancelled",
|
|
487
|
+
list_page=lambda: notification_timeoff_list_action(page=1),
|
|
488
|
+
load_detail=notification_detail_action,
|
|
489
|
+
fields=_timeoff_approval_fields,
|
|
490
|
+
submit=timeoff_approve_action,
|
|
491
|
+
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Self-update command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from importlib import metadata
|
|
7
|
+
from typing import NoReturn
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from tlnt import DISTRIBUTION
|
|
13
|
+
from tlnt.core import play_store
|
|
14
|
+
|
|
15
|
+
PYPI_URL = f"https://pypi.org/pypi/{DISTRIBUTION}/json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _fail(message: str) -> NoReturn:
|
|
19
|
+
typer.echo(message, err=True)
|
|
20
|
+
raise typer.Exit(1)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _version_key(version: str) -> tuple[int, ...]:
|
|
24
|
+
return tuple(int(part) for part in version.split("."))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def latest_release_version(timeout: float = 10) -> str:
|
|
28
|
+
"""Return the newest tlnt version published on PyPI."""
|
|
29
|
+
response = httpx.get(PYPI_URL, timeout=timeout, follow_redirects=True)
|
|
30
|
+
response.raise_for_status()
|
|
31
|
+
return response.json()["info"]["version"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fetch_latest_version() -> str | None:
|
|
35
|
+
"""Like latest_release_version, but quick and silent: None on any failure."""
|
|
36
|
+
try:
|
|
37
|
+
version = latest_release_version(timeout=3)
|
|
38
|
+
except (httpx.HTTPError, ValueError, KeyError, TypeError):
|
|
39
|
+
return None
|
|
40
|
+
return version if isinstance(version, str) else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def update_warning() -> str | None:
|
|
44
|
+
"""Return a notice when PyPI has a newer tlnt than the installed one."""
|
|
45
|
+
installed = metadata.version(DISTRIBUTION)
|
|
46
|
+
latest = play_store.cached_version("pypi_version.json", fetch_latest_version)
|
|
47
|
+
try:
|
|
48
|
+
if not latest or _version_key(latest) <= _version_key(installed):
|
|
49
|
+
return None
|
|
50
|
+
except ValueError:
|
|
51
|
+
return None # pre-release or dev version: stay silent
|
|
52
|
+
return (
|
|
53
|
+
f"⚠️ tlnt v{installed} is installed, but v{latest} is available. "
|
|
54
|
+
"Run `tlnt update` to update."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _install_release(installed: str, latest: str) -> int:
|
|
59
|
+
typer.echo(f"Installing tlnt {latest} (current {installed})")
|
|
60
|
+
try:
|
|
61
|
+
return subprocess.run(
|
|
62
|
+
["uv", "tool", "install", f"{DISTRIBUTION}=={latest}", "--force"]
|
|
63
|
+
).returncode
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
_fail("uv not found; install uv first: https://docs.astral.sh/uv/")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def update() -> None:
|
|
69
|
+
"""Update tlnt to the latest PyPI release."""
|
|
70
|
+
installed = metadata.version(DISTRIBUTION)
|
|
71
|
+
try:
|
|
72
|
+
latest = latest_release_version()
|
|
73
|
+
except (httpx.HTTPError, ValueError, KeyError, TypeError):
|
|
74
|
+
_fail("Could not read the latest tlnt release from PyPI")
|
|
75
|
+
try:
|
|
76
|
+
latest_key = _version_key(latest)
|
|
77
|
+
except ValueError:
|
|
78
|
+
_fail(f"Unexpected release version: {latest}")
|
|
79
|
+
installed_key = _version_key(installed)
|
|
80
|
+
if latest_key == installed_key:
|
|
81
|
+
typer.echo(f"tlnt {installed} is the latest release")
|
|
82
|
+
raise typer.Exit(0)
|
|
83
|
+
if latest_key < installed_key:
|
|
84
|
+
typer.echo(f"tlnt {installed} is newer than the latest release ({latest})")
|
|
85
|
+
raise typer.Exit(0)
|
|
86
|
+
raise typer.Exit(_install_release(installed, latest))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Version command callback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import metadata
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from tlnt import DISTRIBUTION
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def print_version(show: bool) -> None:
|
|
13
|
+
if show:
|
|
14
|
+
typer.echo(metadata.version(DISTRIBUTION))
|
|
15
|
+
raise typer.Exit()
|