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
tlnt/cli/avatars.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Terminal thumbnails for Talenta avatar URLs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from PIL import Image
|
|
12
|
+
from rich_pixels import Pixels
|
|
13
|
+
|
|
14
|
+
from tlnt.core.logs import logger
|
|
15
|
+
|
|
16
|
+
_log = logger.bind(component="cli.avatars")
|
|
17
|
+
|
|
18
|
+
# HalfcellRenderer packs two pixels into one terminal row, so 8px -> 8 cols x 4 rows.
|
|
19
|
+
SIZE = 8
|
|
20
|
+
TIMEOUT = 3.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_avatar_url(value: Any) -> bool:
|
|
24
|
+
"""True for an http(s) URL whose path contains an /avatar/ segment."""
|
|
25
|
+
if not isinstance(value, str) or not value.startswith(("http://", "https://")):
|
|
26
|
+
return False
|
|
27
|
+
return "/avatar/" in urlparse(value).path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@lru_cache(maxsize=64)
|
|
31
|
+
def thumbnail(url: str) -> Pixels | None:
|
|
32
|
+
"""Square half-block thumbnail, or None when the image is unusable.
|
|
33
|
+
|
|
34
|
+
Anonymous on purpose: avatars live on a third-party CDN, so no session
|
|
35
|
+
cookie or bearer token is ever attached. Failures are cached too, so a
|
|
36
|
+
dead URL is not refetched once per row.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
response = httpx.get(url, timeout=TIMEOUT, follow_redirects=True)
|
|
40
|
+
response.raise_for_status()
|
|
41
|
+
with Image.open(io.BytesIO(response.content)) as image:
|
|
42
|
+
return Pixels.from_image(image, resize=(SIZE, SIZE))
|
|
43
|
+
except Exception as error: # best effort: a bad avatar must not break a table
|
|
44
|
+
# Host and error type only: the avatar path (and httpx's error text,
|
|
45
|
+
# which repeats the URL) can identify an employee.
|
|
46
|
+
_log.bind(
|
|
47
|
+
event="avatar_failed",
|
|
48
|
+
host=urlparse(url).hostname,
|
|
49
|
+
error_type=type(error).__name__,
|
|
50
|
+
).debug("avatar unavailable")
|
|
51
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""The confirm-then-submit flow shared by attendance and time-off approvals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from tlnt.cli import prompts
|
|
10
|
+
from tlnt.cli.output import action_banner, confirm, console, dig, emit, spinner
|
|
11
|
+
from tlnt.core.models import Result
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_pending(flag: object) -> bool:
|
|
15
|
+
"""Documented contract: only a numeric 0 marks a pending approval."""
|
|
16
|
+
return isinstance(flag, int) and not isinstance(flag, bool) and flag == 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_approval(
|
|
20
|
+
inbox_id: int | None,
|
|
21
|
+
reason: str,
|
|
22
|
+
interactive: bool,
|
|
23
|
+
json_output: bool,
|
|
24
|
+
verbose: bool,
|
|
25
|
+
*,
|
|
26
|
+
noun: str,
|
|
27
|
+
id_label: str,
|
|
28
|
+
list_command: str,
|
|
29
|
+
cancelled_code: str,
|
|
30
|
+
list_page: Callable[[], Result],
|
|
31
|
+
load_detail: Callable[[int], Result],
|
|
32
|
+
fields: Callable[[Result], tuple[int, str] | Result],
|
|
33
|
+
submit: Callable[[int, int, str, str], Result],
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Show one inbox request and approve it only after terminal confirmation."""
|
|
36
|
+
if interactive:
|
|
37
|
+
unavailable = prompts.guard(json_output, title=f"Approve {noun}")
|
|
38
|
+
if unavailable is not None:
|
|
39
|
+
emit(unavailable, json_output)
|
|
40
|
+
return
|
|
41
|
+
if interactive and inbox_id is None:
|
|
42
|
+
spinner_enabled = not (json_output or verbose)
|
|
43
|
+
with spinner("Loading pending approvals...", enabled=spinner_enabled):
|
|
44
|
+
listing = list_page()
|
|
45
|
+
if not listing.ok:
|
|
46
|
+
emit(listing, json_output)
|
|
47
|
+
return
|
|
48
|
+
rows = dig(listing.data, "inbox.data") or []
|
|
49
|
+
pending = [
|
|
50
|
+
row
|
|
51
|
+
for row in rows
|
|
52
|
+
if isinstance(row, dict) and _is_pending(row.get("approval_flag"))
|
|
53
|
+
]
|
|
54
|
+
if not pending:
|
|
55
|
+
emit(
|
|
56
|
+
Result(False, "no_pending_approvals", f"No pending {noun} approvals"),
|
|
57
|
+
json_output,
|
|
58
|
+
)
|
|
59
|
+
return
|
|
60
|
+
last_page = dig(listing.data, "inbox.last_page")
|
|
61
|
+
if isinstance(last_page, int) and last_page > 1:
|
|
62
|
+
console.print(
|
|
63
|
+
"More pending approvals may exist — "
|
|
64
|
+
f"see `{list_command} --page`.",
|
|
65
|
+
style="warning",
|
|
66
|
+
)
|
|
67
|
+
selected = prompts.pick(pending, ("id", "message"), "Select a request to approve")
|
|
68
|
+
inbox_id = int(selected["id"])
|
|
69
|
+
|
|
70
|
+
if inbox_id is None:
|
|
71
|
+
raise typer.BadParameter("Missing argument 'INBOX_ID'.")
|
|
72
|
+
if inbox_id < 1:
|
|
73
|
+
raise typer.BadParameter("inbox_id must be at least 1")
|
|
74
|
+
|
|
75
|
+
detail = load_detail(inbox_id)
|
|
76
|
+
if not detail.ok:
|
|
77
|
+
emit(detail, json_output)
|
|
78
|
+
return
|
|
79
|
+
checked = fields(detail)
|
|
80
|
+
if isinstance(checked, Result):
|
|
81
|
+
emit(checked, json_output)
|
|
82
|
+
return
|
|
83
|
+
request_id, message = checked
|
|
84
|
+
action_banner(f"Approve {noun}", enabled=not json_output)
|
|
85
|
+
typer.echo(f"Inbox ID: {inbox_id}", err=True)
|
|
86
|
+
typer.echo(f"{id_label}: {request_id}", err=True)
|
|
87
|
+
typer.echo(f"Message: {message}", err=True)
|
|
88
|
+
if not confirm(
|
|
89
|
+
f"Approve this {noun} request?",
|
|
90
|
+
default=False,
|
|
91
|
+
err=True,
|
|
92
|
+
):
|
|
93
|
+
emit(
|
|
94
|
+
Result(False, cancelled_code, f"{noun.capitalize()} approval cancelled"),
|
|
95
|
+
json_output,
|
|
96
|
+
)
|
|
97
|
+
return
|
|
98
|
+
emit(submit(inbox_id, request_id, message, reason), json_output)
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from tlnt.cli import prompts
|
|
11
|
+
from tlnt.cli.commands.approvals import run_approval
|
|
12
|
+
from tlnt.cli.output import action_banner, confirm, emit, spinner
|
|
13
|
+
from tlnt.cli.options import (
|
|
14
|
+
AvatarsOption,
|
|
15
|
+
InteractiveOption,
|
|
16
|
+
JsonOption,
|
|
17
|
+
TableOption,
|
|
18
|
+
VerboseOption,
|
|
19
|
+
)
|
|
20
|
+
from tlnt.core.config import get_setting
|
|
21
|
+
from tlnt.core.models import Result
|
|
22
|
+
from tlnt.core.services import positive_id
|
|
23
|
+
from tlnt.core.services.attendance import (
|
|
24
|
+
clock as clock_action,
|
|
25
|
+
preview_clock as preview_action,
|
|
26
|
+
schedule as schedule_action,
|
|
27
|
+
summary_list as summary_list_action,
|
|
28
|
+
summary_metrics as summary_metrics_action,
|
|
29
|
+
validate_coordinates,
|
|
30
|
+
validate_notes,
|
|
31
|
+
)
|
|
32
|
+
from tlnt.core.services.notifications import (
|
|
33
|
+
attendance_approve as attendance_approve_action,
|
|
34
|
+
notification_attendance_list as notification_attendance_list_action,
|
|
35
|
+
notification_detail as notification_detail_action,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def _run_attendance(
|
|
39
|
+
event_type: str,
|
|
40
|
+
action_label: str,
|
|
41
|
+
dry_run: bool,
|
|
42
|
+
notes: str,
|
|
43
|
+
selfie: Path | None,
|
|
44
|
+
json_output: bool,
|
|
45
|
+
latitude: str | None,
|
|
46
|
+
longitude: str | None,
|
|
47
|
+
location_id: str | None,
|
|
48
|
+
interactive: bool = False,
|
|
49
|
+
verbose: bool = False,
|
|
50
|
+
avatars: bool = False,
|
|
51
|
+
) -> None:
|
|
52
|
+
if interactive:
|
|
53
|
+
unavailable = prompts.guard(json_output, title=action_label)
|
|
54
|
+
if unavailable is not None:
|
|
55
|
+
emit(unavailable, json_output)
|
|
56
|
+
return
|
|
57
|
+
latitude = prompts.ask_required(
|
|
58
|
+
"Latitude",
|
|
59
|
+
latitude or "",
|
|
60
|
+
default=get_setting("TALENTA_LATITUDE", "latitude"),
|
|
61
|
+
)
|
|
62
|
+
longitude = prompts.ask_required(
|
|
63
|
+
"Longitude",
|
|
64
|
+
longitude or "",
|
|
65
|
+
default=get_setting("TALENTA_LONGITUDE", "longitude"),
|
|
66
|
+
)
|
|
67
|
+
location_id = prompts.ask_optional("Location ID", location_id or "") or None
|
|
68
|
+
notes = prompts.ask_optional("Notes", notes)
|
|
69
|
+
selfie = prompts.ask_path("Selfie photo path", selfie)
|
|
70
|
+
|
|
71
|
+
if latitude is None:
|
|
72
|
+
raise typer.BadParameter("Missing option '--latitude'.")
|
|
73
|
+
if longitude is None:
|
|
74
|
+
raise typer.BadParameter("Missing option '--longitude'.")
|
|
75
|
+
|
|
76
|
+
validation = validate_notes(notes) or validate_coordinates(latitude, longitude)
|
|
77
|
+
if validation is not None:
|
|
78
|
+
raise typer.BadParameter(validation.message)
|
|
79
|
+
|
|
80
|
+
spinner_enabled = not (json_output or verbose)
|
|
81
|
+
|
|
82
|
+
if dry_run:
|
|
83
|
+
emit(
|
|
84
|
+
preview_action(
|
|
85
|
+
event_type,
|
|
86
|
+
notes,
|
|
87
|
+
latitude=latitude,
|
|
88
|
+
longitude=longitude,
|
|
89
|
+
location_id=location_id,
|
|
90
|
+
),
|
|
91
|
+
json_output,
|
|
92
|
+
avatars=avatars,
|
|
93
|
+
)
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
with spinner("Checking active schedule...", enabled=spinner_enabled):
|
|
97
|
+
active = schedule_action()
|
|
98
|
+
if isinstance(active, Result):
|
|
99
|
+
emit(active, json_output)
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
location = f"{latitude}, {longitude}"
|
|
103
|
+
if location_id is not None:
|
|
104
|
+
location += f" [{location_id}]"
|
|
105
|
+
action_banner(action_label, enabled=not json_output)
|
|
106
|
+
if not confirm(
|
|
107
|
+
f"{action_label} on {active.schedule_date} at {location}?",
|
|
108
|
+
err=json_output,
|
|
109
|
+
):
|
|
110
|
+
cancelled_code = (
|
|
111
|
+
"clockout_cancelled" if event_type == "clock_out" else "clockin_cancelled"
|
|
112
|
+
)
|
|
113
|
+
emit(
|
|
114
|
+
Result(False, cancelled_code, f"{action_label} cancelled"),
|
|
115
|
+
json_output,
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
with spinner(f"{action_label}...", enabled=spinner_enabled):
|
|
120
|
+
result = clock_action(
|
|
121
|
+
event_type,
|
|
122
|
+
notes,
|
|
123
|
+
selfie,
|
|
124
|
+
schedule=active,
|
|
125
|
+
latitude=latitude,
|
|
126
|
+
longitude=longitude,
|
|
127
|
+
location_id=location_id,
|
|
128
|
+
)
|
|
129
|
+
emit(result, json_output, avatars=avatars)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _clock_command(event_type: str, label: str, doc: str) -> Callable[..., None]:
|
|
133
|
+
"""Clock-in and clock-out share every option and differ only in the event."""
|
|
134
|
+
|
|
135
|
+
def command(
|
|
136
|
+
latitude: Annotated[
|
|
137
|
+
str | None,
|
|
138
|
+
typer.Option("--latitude", help="Attendance latitude."),
|
|
139
|
+
] = None,
|
|
140
|
+
longitude: Annotated[
|
|
141
|
+
str | None,
|
|
142
|
+
typer.Option("--longitude", help="Attendance longitude."),
|
|
143
|
+
] = None,
|
|
144
|
+
location_id: Annotated[
|
|
145
|
+
str | None,
|
|
146
|
+
typer.Option("--location-id", help="Optional Talenta location ID."),
|
|
147
|
+
] = None,
|
|
148
|
+
dry_run: Annotated[
|
|
149
|
+
bool,
|
|
150
|
+
typer.Option("--dry-run", help="Preview without submitting attendance."),
|
|
151
|
+
] = False,
|
|
152
|
+
notes: Annotated[
|
|
153
|
+
str,
|
|
154
|
+
typer.Option("--notes", help="Optional attendance note."),
|
|
155
|
+
] = "",
|
|
156
|
+
selfie: Annotated[
|
|
157
|
+
Path | None,
|
|
158
|
+
typer.Option(
|
|
159
|
+
"--selfie",
|
|
160
|
+
help="Optional selfie photo to attach.",
|
|
161
|
+
exists=True,
|
|
162
|
+
dir_okay=False,
|
|
163
|
+
readable=True,
|
|
164
|
+
),
|
|
165
|
+
] = None,
|
|
166
|
+
interactive: InteractiveOption = False,
|
|
167
|
+
json_output: JsonOption = False,
|
|
168
|
+
table: TableOption = False,
|
|
169
|
+
avatars: AvatarsOption = False,
|
|
170
|
+
verbose: VerboseOption = False,
|
|
171
|
+
) -> None:
|
|
172
|
+
_run_attendance(
|
|
173
|
+
event_type,
|
|
174
|
+
label,
|
|
175
|
+
dry_run,
|
|
176
|
+
notes,
|
|
177
|
+
selfie,
|
|
178
|
+
json_output,
|
|
179
|
+
latitude,
|
|
180
|
+
longitude,
|
|
181
|
+
location_id,
|
|
182
|
+
interactive=interactive,
|
|
183
|
+
verbose=verbose,
|
|
184
|
+
avatars=avatars,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
command.__doc__ = doc
|
|
188
|
+
return command
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
attendance_clockin = _clock_command(
|
|
192
|
+
"clock_in", "Clock in", "Clock in now or preview a request."
|
|
193
|
+
)
|
|
194
|
+
attendance_clockout = _clock_command(
|
|
195
|
+
"clock_out", "Clock out", "Clock out now or preview a request."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _attendance_date(value: str | None) -> date:
|
|
200
|
+
if value is None:
|
|
201
|
+
return date.today()
|
|
202
|
+
try:
|
|
203
|
+
selected_date = date.fromisoformat(value)
|
|
204
|
+
except ValueError as error:
|
|
205
|
+
raise typer.BadParameter("date must be YYYY-MM-DD") from error
|
|
206
|
+
if selected_date.isoformat() != value:
|
|
207
|
+
raise typer.BadParameter("date must be YYYY-MM-DD")
|
|
208
|
+
return selected_date
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def attendance_summary_list(
|
|
212
|
+
selected_date: Annotated[
|
|
213
|
+
str | None,
|
|
214
|
+
typer.Option("--date", help="Attendance date (YYYY-MM-DD)."),
|
|
215
|
+
] = None,
|
|
216
|
+
json_output: JsonOption = False,
|
|
217
|
+
table: TableOption = False,
|
|
218
|
+
verbose: VerboseOption = False,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Read attendance summaries for a date."""
|
|
221
|
+
emit(summary_list_action(_attendance_date(selected_date)), json_output)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def attendance_summary_metrics(
|
|
225
|
+
selected_date: Annotated[
|
|
226
|
+
str | None,
|
|
227
|
+
typer.Option("--date", help="Attendance date (YYYY-MM-DD)."),
|
|
228
|
+
] = None,
|
|
229
|
+
json_output: JsonOption = False,
|
|
230
|
+
table: TableOption = False,
|
|
231
|
+
verbose: VerboseOption = False,
|
|
232
|
+
) -> None:
|
|
233
|
+
"""Read attendance summary metrics for a date."""
|
|
234
|
+
emit(summary_metrics_action(_attendance_date(selected_date)), json_output)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _approval_fields(detail: Result) -> tuple[int, str] | Result:
|
|
238
|
+
data = detail.data
|
|
239
|
+
inbox = data.get("inbox") if isinstance(data, dict) else None
|
|
240
|
+
request = inbox.get("live_attendance_request") if isinstance(inbox, dict) else None
|
|
241
|
+
message = inbox.get("message") if isinstance(inbox, dict) else None
|
|
242
|
+
attendance_id = positive_id(request.get("id")) if isinstance(request, dict) else None
|
|
243
|
+
if attendance_id is None or not isinstance(message, str):
|
|
244
|
+
return Result(
|
|
245
|
+
False,
|
|
246
|
+
"remote_error",
|
|
247
|
+
"Mekari returned an invalid live-attendance approval response",
|
|
248
|
+
detail.status_code,
|
|
249
|
+
data,
|
|
250
|
+
)
|
|
251
|
+
return int(attendance_id), message
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def attendance_approve(
|
|
255
|
+
inbox_id: Annotated[
|
|
256
|
+
int | None,
|
|
257
|
+
typer.Argument(help="Talenta inbox ID."),
|
|
258
|
+
] = None,
|
|
259
|
+
reason: Annotated[
|
|
260
|
+
str,
|
|
261
|
+
typer.Option("--reason", help="Optional approval reason."),
|
|
262
|
+
] = "",
|
|
263
|
+
interactive: InteractiveOption = False,
|
|
264
|
+
json_output: JsonOption = False,
|
|
265
|
+
table: TableOption = False,
|
|
266
|
+
verbose: VerboseOption = False,
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Approve a live attendance request after confirmation."""
|
|
269
|
+
run_approval(
|
|
270
|
+
inbox_id,
|
|
271
|
+
reason,
|
|
272
|
+
interactive,
|
|
273
|
+
json_output,
|
|
274
|
+
verbose,
|
|
275
|
+
noun="attendance",
|
|
276
|
+
id_label="Attendance ID",
|
|
277
|
+
list_command="tlnt notification attendance-list",
|
|
278
|
+
cancelled_code="attendance_approval_cancelled",
|
|
279
|
+
list_page=lambda: notification_attendance_list_action(page=1),
|
|
280
|
+
load_detail=notification_detail_action,
|
|
281
|
+
fields=_approval_fields,
|
|
282
|
+
submit=attendance_approve_action,
|
|
283
|
+
)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from tlnt.cli.options import JsonOption, TableOption, VerboseOption
|
|
9
|
+
from tlnt.cli.output import run
|
|
10
|
+
from tlnt.core.services import calendar as calendar_service
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _calendar_params(
|
|
14
|
+
start_date: str | None,
|
|
15
|
+
end_date: str | None,
|
|
16
|
+
limit: int,
|
|
17
|
+
month: int | None,
|
|
18
|
+
year: int | None,
|
|
19
|
+
page: int,
|
|
20
|
+
item_id: str | None,
|
|
21
|
+
event_type: str | None,
|
|
22
|
+
search: str | None,
|
|
23
|
+
date: str | None,
|
|
24
|
+
user_id: str | None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
if page < 1:
|
|
27
|
+
raise typer.BadParameter("page must be at least 1")
|
|
28
|
+
if limit < 1:
|
|
29
|
+
raise typer.BadParameter("limit must be at least 1")
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
key: value
|
|
33
|
+
for key, value in {
|
|
34
|
+
"start_date": start_date,
|
|
35
|
+
"end_date": end_date,
|
|
36
|
+
"limit": limit,
|
|
37
|
+
"month": month,
|
|
38
|
+
"year": year,
|
|
39
|
+
"page": page,
|
|
40
|
+
"id": item_id,
|
|
41
|
+
"type": event_type,
|
|
42
|
+
"search": search,
|
|
43
|
+
"date": date,
|
|
44
|
+
"user_id": user_id,
|
|
45
|
+
}.items()
|
|
46
|
+
if value is not None
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _filtered_command(name: str, label: str, doc: str) -> Callable[..., None]:
|
|
51
|
+
"""One calendar lookup that takes the shared filters."""
|
|
52
|
+
|
|
53
|
+
def command(
|
|
54
|
+
start_date: Annotated[str | None, typer.Option("--start-date")] = None,
|
|
55
|
+
end_date: Annotated[str | None, typer.Option("--end-date")] = None,
|
|
56
|
+
limit: Annotated[int, typer.Option("--limit")] = 10,
|
|
57
|
+
month: Annotated[int | None, typer.Option("--month")] = None,
|
|
58
|
+
year: Annotated[int | None, typer.Option("--year")] = None,
|
|
59
|
+
page: Annotated[int, typer.Option("--page")] = 1,
|
|
60
|
+
item_id: Annotated[str | None, typer.Option("--id")] = None,
|
|
61
|
+
event_type: Annotated[str | None, typer.Option("--type")] = None,
|
|
62
|
+
search: Annotated[str | None, typer.Option("--search")] = None,
|
|
63
|
+
date: Annotated[str | None, typer.Option("--date")] = None,
|
|
64
|
+
user_id: Annotated[str | None, typer.Option("--user-id")] = None,
|
|
65
|
+
json_output: JsonOption = False,
|
|
66
|
+
table: TableOption = False,
|
|
67
|
+
verbose: VerboseOption = False,
|
|
68
|
+
) -> None:
|
|
69
|
+
params = _calendar_params(
|
|
70
|
+
start_date, end_date, limit, month, year, page,
|
|
71
|
+
item_id, event_type, search, date, user_id,
|
|
72
|
+
)
|
|
73
|
+
# Looked up per call so tests can patch the service function.
|
|
74
|
+
run(label, lambda: getattr(calendar_service, name)(params), json_output, verbose)
|
|
75
|
+
|
|
76
|
+
command.__doc__ = doc
|
|
77
|
+
return command
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
calendar_holiday = _filtered_command(
|
|
81
|
+
"calendar_holiday", "Loading calendar holidays...", "Read calendar holidays."
|
|
82
|
+
)
|
|
83
|
+
calendar_timeoff = _filtered_command(
|
|
84
|
+
"calendar_timeoff", "Loading calendar time off...", "Read calendar time-off events."
|
|
85
|
+
)
|
|
86
|
+
calendar_index = _filtered_command(
|
|
87
|
+
"calendar_index", "Loading calendar index...", "Read dates with calendar events."
|
|
88
|
+
)
|
|
89
|
+
calendar_birthday = _filtered_command(
|
|
90
|
+
"calendar_birthday", "Loading calendar birthdays...", "Read employee birthday events."
|
|
91
|
+
)
|
|
92
|
+
calendar_activity = _filtered_command(
|
|
93
|
+
"calendar_activity", "Loading calendar activity...", "Read calendar activity events."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def calendar_my_birthday(
|
|
98
|
+
json_output: JsonOption = False,
|
|
99
|
+
table: TableOption = False,
|
|
100
|
+
verbose: VerboseOption = False,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Read your birthday date."""
|
|
103
|
+
run(
|
|
104
|
+
"Loading your birthday...",
|
|
105
|
+
calendar_service.calendar_my_birthday,
|
|
106
|
+
json_output,
|
|
107
|
+
verbose,
|
|
108
|
+
)
|