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
@@ -0,0 +1,826 @@
1
+ """Concrete Mekari mobile API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable, Mapping
7
+ from datetime import date, datetime, timedelta, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from tlnt.core.clients.mekari import constants as mekari_constants
14
+ from tlnt.core.clients.mekari.constants import (
15
+ ATTENDANCE_APPROVE_ENDPOINT,
16
+ CALENDAR_ACTIVITY_ENDPOINT,
17
+ CALENDAR_BIRTHDAY_ENDPOINT,
18
+ CALENDAR_HOLIDAY_ENDPOINT,
19
+ CALENDAR_INDEX_ENDPOINT,
20
+ CALENDAR_MY_BIRTHDAY_ENDPOINT,
21
+ CALENDAR_TIMEOFF_ENDPOINT,
22
+ DASHBOARD_USER_ENDPOINT,
23
+ GPS_ACCURACY,
24
+ INBOX_NOTIFICATION_ENDPOINT,
25
+ LOGOUT_ENDPOINT,
26
+ OAUTH2_TOKEN_URL,
27
+ TIMEOFF_APPROVE_ENDPOINT,
28
+ TIMEOFF_CANCEL_REQUEST_ENDPOINT,
29
+ TIMEOFF_DELEGATION_LIST_ENDPOINT,
30
+ TIMEOFF_HISTORY_ENDPOINT,
31
+ TIMEOFF_REQUEST_ENDPOINT,
32
+ clock_endpoint,
33
+ inbox_endpoint,
34
+ notification_detail_endpoint,
35
+ schedule_endpoint,
36
+ summary_attendance_clocks_endpoint,
37
+ summary_attendance_metrics_endpoint,
38
+ timeoff_balance_endpoint,
39
+ timeoff_detail_endpoint,
40
+ )
41
+ from tlnt.core.http_client import send_request
42
+ from tlnt.core.logs import logger
43
+ from tlnt.core.models import ActiveSchedule, MobileToken, Result, dig
44
+
45
+ _TOKEN_SHAPE = re.compile(
46
+ r"^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}_\S+$",
47
+ re.IGNORECASE,
48
+ )
49
+ _SENSITIVE_KEYS = ("token", "cookie", "authorization", "password", "secret", "csrf")
50
+ _log = logger.bind(component="mekari")
51
+
52
+
53
+ def build_clock_fields(
54
+ schedule: ActiveSchedule,
55
+ event_type: str,
56
+ notes: str,
57
+ *,
58
+ latitude: str,
59
+ longitude: str,
60
+ location_id: str | None = None,
61
+ ) -> dict[str, str]:
62
+ fields = {
63
+ "notes": notes,
64
+ "latitude": latitude,
65
+ "longitude": longitude,
66
+ "source": "mobileapp",
67
+ "schedule_date": schedule.schedule_date,
68
+ "attendance_clock_type": schedule.clock_type,
69
+ "attendance_office_hour_id": schedule.office_hour_id,
70
+ "attendance_office_hour_setting_id": schedule.office_hour_setting_id,
71
+ "on_live_tracking": "false",
72
+ "end_live_tracking": "true",
73
+ "event_type": event_type,
74
+ "mixpanel[Entry point]": "Index",
75
+ "gps_accuracy": GPS_ACCURACY,
76
+ }
77
+ if location_id is not None:
78
+ fields["location_id"] = location_id
79
+ return fields
80
+
81
+
82
+ def _approval_body(
83
+ request_id: int, inbox_id: int, inbox_message: str, reason: str
84
+ ) -> dict[str, Any]:
85
+ """The approve payload shared by attendance and time-off approvals."""
86
+ return {
87
+ "id": str(request_id),
88
+ "approve": "approve",
89
+ "reason": reason,
90
+ "inboxID": str(inbox_id),
91
+ "inboxMessage": inbox_message,
92
+ "inboxReimbursementBenefit": [],
93
+ "submissions": [],
94
+ }
95
+
96
+
97
+ def _is_dict(data: Any) -> bool:
98
+ return isinstance(data, dict)
99
+
100
+
101
+ def _shape(path: str, kind: type) -> Callable[[Any], bool]:
102
+ """Valid when the value at the dotted path inside `data` has this type."""
103
+ return lambda data: isinstance(dig(data, path), kind)
104
+
105
+
106
+ def _is_iso_date(value: Any) -> bool:
107
+ if not isinstance(value, str):
108
+ return False
109
+ try:
110
+ return date.fromisoformat(value).isoformat() == value
111
+ except ValueError:
112
+ return False
113
+
114
+
115
+ def _is_iso_date_list(data: Any) -> bool:
116
+ return isinstance(data, list) and all(_is_iso_date(value) for value in data)
117
+
118
+
119
+ _has_events = _shape("events", list)
120
+
121
+
122
+ class MekariClient:
123
+ def __init__(
124
+ self,
125
+ *,
126
+ transport: httpx.BaseTransport | None = None,
127
+ timeout: float = 20.0,
128
+ ) -> None:
129
+ self._http = httpx.Client(
130
+ follow_redirects=True,
131
+ timeout=timeout,
132
+ transport=transport,
133
+ headers=mekari_constants.request_headers(),
134
+ )
135
+ self._closed = False
136
+
137
+ def exchange_code(self, code: str, verifier: str) -> MobileToken | None:
138
+ """Trade an authorization code for the mobile token; None on any failure."""
139
+ body = {
140
+ "grant_type": "authorization_code",
141
+ "code": code,
142
+ "code_verifier": verifier,
143
+ "client_id": mekari_constants.client_id(),
144
+ }
145
+ try:
146
+ response = send_request(
147
+ self._http,
148
+ "POST",
149
+ OAUTH2_TOKEN_URL,
150
+ headers=mekari_constants.token_exchange_headers(),
151
+ json_body=body,
152
+ )
153
+ payload = response.json()
154
+ except (httpx.HTTPError, ValueError) as error:
155
+ _exchange_failed(type(error).__name__)
156
+ return None
157
+ token = payload.get("access_token") if isinstance(payload, dict) else None
158
+ if not isinstance(token, str) or not _TOKEN_SHAPE.fullmatch(token):
159
+ _exchange_failed("invalid_token")
160
+ return None
161
+ captured = datetime.now().astimezone()
162
+ expires_in = payload.get("expires_in")
163
+ expires_at = (
164
+ (captured + timedelta(seconds=expires_in)).isoformat()
165
+ if isinstance(expires_in, int)
166
+ and not isinstance(expires_in, bool)
167
+ and expires_in > 0
168
+ else None
169
+ )
170
+ return MobileToken(token, captured.isoformat(), expires_at)
171
+
172
+ def logout(self, token: str) -> Result:
173
+ requested = self._request(token, "POST", LOGOUT_ENDPOINT)
174
+ if isinstance(requested, Result):
175
+ return requested
176
+ response, _payload = requested
177
+ if not response.is_success:
178
+ return self._remote_error(response)
179
+ return Result(True, "logout_succeeded", "Logout successful", response.status_code)
180
+
181
+ def employment_info(self, token: str) -> Result:
182
+ return self._get_data(
183
+ token,
184
+ "GET",
185
+ DASHBOARD_USER_ENDPOINT,
186
+ code="profile_loaded",
187
+ message="Profile loaded",
188
+ what="dashboard user",
189
+ )
190
+
191
+ def notification_count(self, token: str) -> Result:
192
+ return self._get_data(
193
+ token,
194
+ "GET",
195
+ INBOX_NOTIFICATION_ENDPOINT,
196
+ code="notification_count_loaded",
197
+ message="Notification count loaded",
198
+ what="notification count",
199
+ )
200
+
201
+ def notification_list(
202
+ self,
203
+ token: str,
204
+ page: int = 1,
205
+ query: str = "",
206
+ status: int = 3,
207
+ ) -> Result:
208
+ return self._get_data(
209
+ token,
210
+ "GET",
211
+ inbox_endpoint(0),
212
+ code="notification_list_loaded",
213
+ message="Notification list loaded",
214
+ what="notification list",
215
+ valid=_shape("inbox.data", list),
216
+ params={"page": page, "query": query, "status": status},
217
+ )
218
+
219
+ def notification_attendance_list(
220
+ self,
221
+ token: str,
222
+ page: int = 1,
223
+ query: str = "",
224
+ status: int = 3,
225
+ ) -> Result:
226
+ return self._get_data(
227
+ token,
228
+ "GET",
229
+ inbox_endpoint(8),
230
+ code="notification_attendance_list_loaded",
231
+ message="Attendance notification list loaded",
232
+ what="attendance notification list",
233
+ valid=_shape("inbox.data", list),
234
+ params={"page": page, "query": query, "status": status},
235
+ )
236
+
237
+ def notification_timeoff_list(
238
+ self,
239
+ token: str,
240
+ page: int = 1,
241
+ query: str = "",
242
+ status: int = 3,
243
+ ) -> Result:
244
+ return self._get_data(
245
+ token,
246
+ "GET",
247
+ inbox_endpoint(3),
248
+ code="notification_timeoff_list_loaded",
249
+ message="Time-off notification list loaded",
250
+ what="time-off notification list",
251
+ valid=_shape("inbox.data", list),
252
+ params={"page": page, "query": query, "status": status},
253
+ )
254
+
255
+ def notification_detail(self, token: str, inbox_id: int) -> Result:
256
+ return self._get_data(
257
+ token,
258
+ "GET",
259
+ notification_detail_endpoint(inbox_id),
260
+ code="notification_detail_loaded",
261
+ message="Notification detail loaded",
262
+ what="notification detail",
263
+ valid=_shape("inbox", dict),
264
+ )
265
+
266
+ def attendance_approve(
267
+ self,
268
+ token: str,
269
+ inbox_id: int,
270
+ attendance_id: int,
271
+ inbox_message: str,
272
+ reason: str = "",
273
+ ) -> Result:
274
+ return self._get_data(
275
+ token,
276
+ "POST",
277
+ ATTENDANCE_APPROVE_ENDPOINT,
278
+ code="attendance_approved",
279
+ message="Live attendance request approved",
280
+ what="attendance approval",
281
+ json_body=_approval_body(attendance_id, inbox_id, inbox_message, reason),
282
+ )
283
+
284
+ def timeoff_approve(
285
+ self,
286
+ token: str,
287
+ inbox_id: int,
288
+ timeoff_id: int,
289
+ inbox_message: str,
290
+ reason: str = "",
291
+ ) -> Result:
292
+ requested = self._request(
293
+ token,
294
+ "POST",
295
+ TIMEOFF_APPROVE_ENDPOINT,
296
+ json_body=_approval_body(timeoff_id, inbox_id, inbox_message, reason),
297
+ )
298
+ if isinstance(requested, Result):
299
+ return requested
300
+ response, payload = requested
301
+ data = payload.get("data") if isinstance(payload, dict) else None
302
+ # This endpoint can answer HTTP 200 with a failing status in the body.
303
+ if not isinstance(data, dict) or payload.get("status") != response.status_code:
304
+ return self._invalid(response, payload, "time-off approval")
305
+ return Result(
306
+ True,
307
+ "timeoff_approved",
308
+ "Time-off request approved",
309
+ response.status_code,
310
+ data,
311
+ )
312
+
313
+ def summary_attendance_clocks(
314
+ self,
315
+ token: str,
316
+ company_id: str,
317
+ selected_date: str,
318
+ ) -> Result:
319
+ return self._get_data(
320
+ token,
321
+ "GET",
322
+ summary_attendance_clocks_endpoint(company_id),
323
+ code="attendance_summary_list_loaded",
324
+ message="Attendance summary list loaded",
325
+ what="attendance summary list",
326
+ valid=_shape("attendance", list),
327
+ params={"selected_date": selected_date, "include_on_call_log": False},
328
+ )
329
+
330
+ def summary_attendance_metrics(
331
+ self,
332
+ token: str,
333
+ company_id: str,
334
+ organisation_user_id: str,
335
+ selected_date: str,
336
+ ) -> Result:
337
+ return self._get_data(
338
+ token,
339
+ "GET",
340
+ summary_attendance_metrics_endpoint(company_id),
341
+ code="attendance_summary_metrics_loaded",
342
+ message="Attendance summary metrics loaded",
343
+ what="attendance summary metrics",
344
+ valid=_shape("attributes", dict),
345
+ params={
346
+ "organisation_user_id": organisation_user_id,
347
+ "selected_date": selected_date,
348
+ },
349
+ )
350
+
351
+ def timeoff_types_list(self, token: str) -> Result:
352
+ return self._get_data(
353
+ token,
354
+ "GET",
355
+ TIMEOFF_REQUEST_ENDPOINT,
356
+ code="timeoff_types_list_loaded",
357
+ message="Time-off types loaded",
358
+ what="time-off types",
359
+ valid=_shape("timeOffPolicies", list),
360
+ )
361
+
362
+ def timeoff_balance(
363
+ self,
364
+ token: str,
365
+ company_id: str,
366
+ user_id: str,
367
+ *,
368
+ date: str = "",
369
+ keyword: str = "",
370
+ show_unlimited_balance: bool = False,
371
+ policy_id: str = "",
372
+ limited_by_date: str = "",
373
+ refresh: bool = True,
374
+ show_special_leave: bool = False,
375
+ ) -> Result:
376
+ return self._get_data(
377
+ token,
378
+ "GET",
379
+ timeoff_balance_endpoint(company_id, user_id),
380
+ code="timeoff_balance_loaded",
381
+ message="Time-off balance loaded",
382
+ what="time-off balance",
383
+ valid=_shape("", list),
384
+ params={
385
+ "company_id": company_id,
386
+ "user_id": user_id,
387
+ "date": date,
388
+ "keyword": keyword,
389
+ "show_unlimited_balance": show_unlimited_balance,
390
+ "policy_id": policy_id,
391
+ "limited_by_date": limited_by_date,
392
+ "refresh": refresh,
393
+ "show_special_leave": show_special_leave,
394
+ },
395
+ )
396
+
397
+ def timeoff_delegation_list(
398
+ self,
399
+ token: str,
400
+ page: int = 1,
401
+ search: str = "",
402
+ ) -> Result:
403
+ return self._get_data(
404
+ token,
405
+ "GET",
406
+ TIMEOFF_DELEGATION_LIST_ENDPOINT,
407
+ code="timeoff_delegation_list_loaded",
408
+ message="Time-off delegation list loaded",
409
+ what="time-off delegation list",
410
+ valid=_shape("data", list),
411
+ params={"page": page, "search": search},
412
+ )
413
+
414
+ def timeoff_list(
415
+ self,
416
+ token: str,
417
+ page: int = 1,
418
+ limit: int = 30,
419
+ date: str = "",
420
+ end_date: str = "",
421
+ status: int = 0,
422
+ policy_id: str = "",
423
+ all_period: bool = True,
424
+ show_metrics: bool = False,
425
+ ) -> Result:
426
+ return self._get_data(
427
+ token,
428
+ "GET",
429
+ TIMEOFF_HISTORY_ENDPOINT,
430
+ code="timeoff_list_loaded",
431
+ message="Time-off request list loaded",
432
+ what="time-off request list",
433
+ valid=_shape("time_off_request.data", list),
434
+ params={
435
+ "type": "request",
436
+ "page": page,
437
+ "limit": limit,
438
+ "date": date,
439
+ "endDate": end_date,
440
+ "status": status,
441
+ "policy_id": policy_id,
442
+ "all_period": all_period,
443
+ "show_metrics": show_metrics,
444
+ },
445
+ )
446
+
447
+ def timeoff_detail(self, token: str, request_id: int) -> Result:
448
+ return self._get_data(
449
+ token,
450
+ "GET",
451
+ timeoff_detail_endpoint(request_id),
452
+ code="timeoff_detail_loaded",
453
+ message="Time-off request detail loaded",
454
+ what="time-off detail",
455
+ valid=_shape("timeoff_detail", dict),
456
+ )
457
+
458
+ def timeoff_request(
459
+ self,
460
+ token: str,
461
+ date: str,
462
+ policy_id: str,
463
+ reason: str,
464
+ *,
465
+ half_day_after_break: bool = False,
466
+ half_day_before_break: bool = False,
467
+ schedule_in: str = "",
468
+ schedule_out: str = "",
469
+ delegate_to: str = "",
470
+ file_path: str | Path | None = None,
471
+ ) -> Result:
472
+ use_half_day = half_day_after_break or half_day_before_break
473
+ half_day_type = "1" if half_day_before_break else "2" if half_day_after_break else "0"
474
+ fields = {
475
+ "end_date": date,
476
+ "hourly_start": "",
477
+ "policy_id": policy_id,
478
+ "hourly_duration": "",
479
+ "hourly_start_date": date,
480
+ "delegate_to": delegate_to,
481
+ "schedule_out": schedule_out if use_half_day else "",
482
+ "useHalfDay": "1" if use_half_day else "0",
483
+ "is_try_again": "false",
484
+ "halfDayType": half_day_type,
485
+ "is_block_leave": "0",
486
+ "schedule_in": schedule_in if use_half_day else "",
487
+ "user_id": "",
488
+ "shift_id": "",
489
+ "comment": reason,
490
+ "start_date": date,
491
+ }
492
+ attachment: list[tuple[str, tuple[str, bytes]]] = []
493
+ if file_path is not None:
494
+ path = Path(file_path)
495
+ try:
496
+ attachment = [("file[]", (path.name, path.read_bytes()))]
497
+ except OSError:
498
+ return Result(
499
+ False,
500
+ "invalid_input",
501
+ f"Attachment could not be read: {path}",
502
+ )
503
+ requested = self._request(
504
+ token,
505
+ "POST",
506
+ TIMEOFF_REQUEST_ENDPOINT,
507
+ files=[(name, (None, value)) for name, value in fields.items()] + attachment,
508
+ )
509
+ if isinstance(requested, Result):
510
+ return requested
511
+ response, payload = requested
512
+ data = payload.get("data") if isinstance(payload, dict) else None
513
+ if not isinstance(data, dict) or payload.get("status") != response.status_code:
514
+ return self._invalid(response, payload, "time-off request")
515
+ return Result(
516
+ True,
517
+ "timeoff_request_submitted",
518
+ "Time-off request submitted",
519
+ response.status_code,
520
+ data,
521
+ )
522
+
523
+ def timeoff_cancel(
524
+ self,
525
+ token: str,
526
+ request_id: int,
527
+ request_type: str = "1002",
528
+ ) -> Result:
529
+ requested = self._request(
530
+ token,
531
+ "POST",
532
+ TIMEOFF_CANCEL_REQUEST_ENDPOINT,
533
+ data={"id": str(request_id), "type": request_type},
534
+ )
535
+ if isinstance(requested, Result):
536
+ return requested
537
+ response, payload = requested
538
+ if (
539
+ not isinstance(payload, dict)
540
+ or not isinstance(payload.get("message"), str)
541
+ or payload.get("status") != response.status_code
542
+ ):
543
+ return self._invalid(response, payload, "time-off cancellation")
544
+ return Result(
545
+ True,
546
+ "timeoff_request_cancelled",
547
+ "Time-off request cancelled",
548
+ response.status_code,
549
+ )
550
+
551
+ def calendar_holiday(
552
+ self,
553
+ token: str,
554
+ params: Mapping[str, Any] | None = None,
555
+ ) -> Result:
556
+ return self._get_data(
557
+ token,
558
+ "GET",
559
+ CALENDAR_HOLIDAY_ENDPOINT,
560
+ code="calendar_holiday_loaded",
561
+ message="Calendar holidays loaded",
562
+ what="calendar holiday",
563
+ valid=_has_events,
564
+ params=params,
565
+ )
566
+
567
+ def calendar_timeoff(
568
+ self,
569
+ token: str,
570
+ params: Mapping[str, Any] | None = None,
571
+ ) -> Result:
572
+ return self._get_data(
573
+ token,
574
+ "GET",
575
+ CALENDAR_TIMEOFF_ENDPOINT,
576
+ code="calendar_timeoff_loaded",
577
+ message="Calendar time-off loaded",
578
+ what="calendar timeoff",
579
+ valid=_has_events,
580
+ params=params,
581
+ )
582
+
583
+ def calendar_index(
584
+ self,
585
+ token: str,
586
+ params: Mapping[str, Any] | None = None,
587
+ ) -> Result:
588
+ return self._get_data(
589
+ token,
590
+ "GET",
591
+ CALENDAR_INDEX_ENDPOINT,
592
+ code="calendar_index_loaded",
593
+ message="Calendar index loaded",
594
+ what="calendar index",
595
+ valid=_is_iso_date_list,
596
+ params=params,
597
+ )
598
+
599
+ def calendar_my_birthday(self, token: str) -> Result:
600
+ return self._get_data(
601
+ token,
602
+ "GET",
603
+ CALENDAR_MY_BIRTHDAY_ENDPOINT,
604
+ code="calendar_my_birthday_loaded",
605
+ message="My birthday loaded",
606
+ what="calendar my birthday",
607
+ valid=_is_iso_date,
608
+ )
609
+
610
+ def calendar_birthday(
611
+ self,
612
+ token: str,
613
+ params: Mapping[str, Any] | None = None,
614
+ ) -> Result:
615
+ return self._get_data(
616
+ token,
617
+ "GET",
618
+ CALENDAR_BIRTHDAY_ENDPOINT,
619
+ code="calendar_birthday_loaded",
620
+ message="Calendar birthdays loaded",
621
+ what="calendar birthday",
622
+ valid=_has_events,
623
+ params=params,
624
+ )
625
+
626
+ def calendar_activity(
627
+ self,
628
+ token: str,
629
+ params: Mapping[str, Any] | None = None,
630
+ ) -> Result:
631
+ return self._get_data(
632
+ token,
633
+ "GET",
634
+ CALENDAR_ACTIVITY_ENDPOINT,
635
+ code="calendar_activity_loaded",
636
+ message="Calendar activity loaded",
637
+ what="calendar activity",
638
+ valid=_has_events,
639
+ params=params,
640
+ )
641
+
642
+ def active_schedule(self, token: str, company_id: str) -> ActiveSchedule | Result:
643
+ requested = self._request(
644
+ token,
645
+ "GET",
646
+ schedule_endpoint(company_id),
647
+ params={
648
+ "device_time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
649
+ "show_multiple": "true",
650
+ },
651
+ )
652
+ if isinstance(requested, Result):
653
+ return requested
654
+ _response, payload = requested
655
+ entries = payload.get("data") if isinstance(payload, dict) else None
656
+ entry = entries[0] if isinstance(entries, list) and entries else None
657
+ office_hour_id = entry.get("current_shift_id") if isinstance(entry, dict) else None
658
+ setting_id = (
659
+ entry.get("attendance_office_hour_setting_id") if isinstance(entry, dict) else None
660
+ )
661
+ if not str(office_hour_id or "").strip() or not str(setting_id or "").strip():
662
+ return Result(False, "schedule_unavailable", "No active schedule found")
663
+ return ActiveSchedule(
664
+ schedule_date=str(entry.get("current_shift_date") or date.today().isoformat()),
665
+ office_hour_id=str(office_hour_id),
666
+ office_hour_setting_id=str(setting_id),
667
+ clock_type=str(entry.get("attendance_clock_type") or "attendance"),
668
+ )
669
+
670
+ def clock(
671
+ self,
672
+ token: str,
673
+ company_id: str,
674
+ event_type: str,
675
+ notes: str,
676
+ selfie: Path | None = None,
677
+ *,
678
+ schedule: ActiveSchedule,
679
+ latitude: str,
680
+ longitude: str,
681
+ location_id: str | None = None,
682
+ ) -> Result:
683
+ parts: list[tuple[str, tuple[Any, Any] | tuple[str, bytes, str]]] = [
684
+ (name, (None, value))
685
+ for name, value in build_clock_fields(
686
+ schedule,
687
+ event_type,
688
+ notes,
689
+ latitude=latitude,
690
+ longitude=longitude,
691
+ location_id=location_id,
692
+ ).items()
693
+ ]
694
+ if selfie is not None:
695
+ parts.append(
696
+ ("selfie_photo", (selfie.name, selfie.read_bytes(), "image/jpeg"))
697
+ )
698
+ requested = self._request(token, "POST", clock_endpoint(company_id), files=parts)
699
+ if isinstance(requested, Result):
700
+ return requested
701
+ response, payload = requested
702
+ if not response.is_success:
703
+ return self._remote_error(response, payload)
704
+ return Result(True, "clock_succeeded", "Clock recorded", response.status_code, payload)
705
+
706
+ def _request(
707
+ self,
708
+ token: str,
709
+ method: str,
710
+ url: str,
711
+ **request: Any,
712
+ ) -> tuple[httpx.Response, Any] | Result:
713
+ """Send one authorized request; failures become Results.
714
+
715
+ send_request already logs every failed request (network error or
716
+ HTTP status), so nothing is logged here.
717
+ """
718
+ try:
719
+ response = send_request(
720
+ self._http,
721
+ method,
722
+ url,
723
+ headers=self._auth_headers(token),
724
+ **request,
725
+ )
726
+ except httpx.RequestError:
727
+ return Result(False, "network_error", "Could not reach Mekari")
728
+ except httpx.HTTPStatusError as error:
729
+ if error.response.status_code == 401:
730
+ return self._expired()
731
+ return self._remote_error(error.response)
732
+ return response, self._response_data(response)
733
+
734
+ def _get_data(
735
+ self,
736
+ token: str,
737
+ method: str,
738
+ url: str,
739
+ *,
740
+ code: str,
741
+ message: str,
742
+ what: str,
743
+ valid: Callable[[Any], bool] = _is_dict,
744
+ **request: Any,
745
+ ) -> Result:
746
+ """Return the payload's `data` when `valid` accepts it."""
747
+ requested = self._request(token, method, url, **request)
748
+ if isinstance(requested, Result):
749
+ return requested
750
+ response, payload = requested
751
+ data = payload.get("data") if isinstance(payload, dict) else None
752
+ if not valid(data):
753
+ return self._invalid(response, payload, what)
754
+ return Result(True, code, message, response.status_code, data)
755
+
756
+ @staticmethod
757
+ def _invalid(response: httpx.Response, payload: Any, what: str) -> Result:
758
+ return Result(
759
+ False,
760
+ "remote_error",
761
+ f"Mekari returned an invalid {what} response",
762
+ response.status_code,
763
+ payload,
764
+ )
765
+
766
+ @staticmethod
767
+ def _auth_headers(token: str) -> dict[str, str]:
768
+ return {
769
+ "Authorization": f"Bearer {token}",
770
+ "Accept": "application/json, text/plain, */*",
771
+ }
772
+
773
+ def close(self) -> None:
774
+ if not self._closed:
775
+ self._http.close()
776
+ self._closed = True
777
+
778
+ def __enter__(self) -> MekariClient:
779
+ return self
780
+
781
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
782
+ self.close()
783
+
784
+ @staticmethod
785
+ def _expired() -> Result:
786
+ return Result(
787
+ False,
788
+ "mobile_token_expired",
789
+ "Mobile token expired; run `tlnt login`",
790
+ )
791
+
792
+ def _remote_error(self, response: httpx.Response, data: Any = None) -> Result:
793
+ safe_data = self._response_data(response) if data is None else data
794
+ message = f"Mekari returned HTTP {response.status_code}"
795
+ if isinstance(safe_data, dict) and safe_data.get("message"):
796
+ message = str(safe_data["message"])
797
+ return Result(False, "remote_error", message, response.status_code, safe_data)
798
+
799
+ @classmethod
800
+ def _response_data(cls, response: httpx.Response) -> Any:
801
+ if not response.content:
802
+ return None
803
+ try:
804
+ return cls._sanitize(response.json())
805
+ except ValueError:
806
+ return None
807
+
808
+ @classmethod
809
+ def _sanitize(cls, value: Any) -> Any:
810
+ if isinstance(value, dict):
811
+ return {
812
+ key: cls._sanitize(item)
813
+ for key, item in value.items()
814
+ if not any(part in str(key).casefold() for part in _SENSITIVE_KEYS)
815
+ }
816
+ if isinstance(value, list):
817
+ return [cls._sanitize(item) for item in value]
818
+ return value
819
+
820
+
821
+ def _exchange_failed(error_type: str) -> None:
822
+ _log.bind(
823
+ event="failure",
824
+ operation="exchange_code",
825
+ error_type=error_type,
826
+ ).warning("operation failed")