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.
Files changed (55) hide show
  1. tlnt/__init__.py +3 -0
  2. tlnt/__main__.py +4 -0
  3. tlnt/browser/__init__.py +0 -0
  4. tlnt/browser/login.py +414 -0
  5. tlnt/browser/oauth.py +50 -0
  6. tlnt/browser/scripted.py +183 -0
  7. tlnt/cli/__init__.py +0 -0
  8. tlnt/cli/avatars.py +51 -0
  9. tlnt/cli/commands/__init__.py +0 -0
  10. tlnt/cli/commands/approvals.py +98 -0
  11. tlnt/cli/commands/attendance.py +283 -0
  12. tlnt/cli/commands/calendar.py +108 -0
  13. tlnt/cli/commands/login.py +225 -0
  14. tlnt/cli/commands/logout.py +12 -0
  15. tlnt/cli/commands/mcp.py +8 -0
  16. tlnt/cli/commands/notifications.py +102 -0
  17. tlnt/cli/commands/profile.py +13 -0
  18. tlnt/cli/commands/setup.py +12 -0
  19. tlnt/cli/commands/status.py +12 -0
  20. tlnt/cli/commands/timeoff.py +491 -0
  21. tlnt/cli/commands/update.py +86 -0
  22. tlnt/cli/commands/version.py +15 -0
  23. tlnt/cli/main.py +163 -0
  24. tlnt/cli/options.py +56 -0
  25. tlnt/cli/output.py +321 -0
  26. tlnt/cli/prompts.py +76 -0
  27. tlnt/core/__init__.py +0 -0
  28. tlnt/core/clients/__init__.py +0 -0
  29. tlnt/core/clients/mekari/__init__.py +0 -0
  30. tlnt/core/clients/mekari/client.py +826 -0
  31. tlnt/core/clients/mekari/constants.py +174 -0
  32. tlnt/core/config.py +53 -0
  33. tlnt/core/http_client.py +110 -0
  34. tlnt/core/logs.py +34 -0
  35. tlnt/core/models.py +84 -0
  36. tlnt/core/play_store.py +85 -0
  37. tlnt/core/services/__init__.py +46 -0
  38. tlnt/core/services/attendance.py +213 -0
  39. tlnt/core/services/calendar.py +54 -0
  40. tlnt/core/services/login.py +100 -0
  41. tlnt/core/services/logout.py +38 -0
  42. tlnt/core/services/notifications.py +97 -0
  43. tlnt/core/services/profile.py +11 -0
  44. tlnt/core/services/setup.py +85 -0
  45. tlnt/core/services/status.py +35 -0
  46. tlnt/core/services/timeoff.py +146 -0
  47. tlnt/core/session.py +189 -0
  48. tlnt/core/user_agent.py +71 -0
  49. tlnt/mcp/__init__.py +0 -0
  50. tlnt/mcp/main.py +176 -0
  51. tlnt_cli-1.5.1.dist-info/METADATA +315 -0
  52. tlnt_cli-1.5.1.dist-info/RECORD +55 -0
  53. tlnt_cli-1.5.1.dist-info/WHEEL +4 -0
  54. tlnt_cli-1.5.1.dist-info/entry_points.txt +3 -0
  55. 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
+ )