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,213 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from tlnt.core.clients.mekari.client import MekariClient, build_clock_fields
|
|
7
|
+
from tlnt.core.clients.mekari.constants import clock_endpoint
|
|
8
|
+
from tlnt.core.models import ActiveSchedule, Result, SessionState
|
|
9
|
+
from tlnt.core.services import owned_client, positive_id
|
|
10
|
+
from tlnt.core.session import SessionStore, load_session, mobile_token
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
MAX_NOTE_LENGTH = 500
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_notes(notes: str) -> Result | None:
|
|
17
|
+
if len(notes) > MAX_NOTE_LENGTH:
|
|
18
|
+
return Result(
|
|
19
|
+
False,
|
|
20
|
+
"invalid_input",
|
|
21
|
+
f"notes must be at most {MAX_NOTE_LENGTH} characters",
|
|
22
|
+
)
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _within(value: str, limit: float) -> bool:
|
|
27
|
+
try:
|
|
28
|
+
number = float(value)
|
|
29
|
+
except (TypeError, ValueError):
|
|
30
|
+
return False
|
|
31
|
+
return -limit <= number <= limit # False for nan
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_coordinates(latitude: str, longitude: str) -> Result | None:
|
|
35
|
+
"""Reject impossible coordinates; accepted strings are sent as typed."""
|
|
36
|
+
if not _within(latitude, 90):
|
|
37
|
+
return Result(False, "invalid_input", "latitude must be a number from -90 to 90")
|
|
38
|
+
if not _within(longitude, 180):
|
|
39
|
+
return Result(
|
|
40
|
+
False, "invalid_input", "longitude must be a number from -180 to 180"
|
|
41
|
+
)
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_attendance(
|
|
46
|
+
store: SessionStore | None,
|
|
47
|
+
client: MekariClient,
|
|
48
|
+
) -> tuple[SessionState, str, str, dict | None] | Result:
|
|
49
|
+
session = load_session(store)
|
|
50
|
+
if isinstance(session, Result):
|
|
51
|
+
return session
|
|
52
|
+
|
|
53
|
+
token = mobile_token(store, session=session)
|
|
54
|
+
if isinstance(token, Result):
|
|
55
|
+
return token
|
|
56
|
+
|
|
57
|
+
company_id = (
|
|
58
|
+
session.profile.company_id.strip()
|
|
59
|
+
if session.profile is not None
|
|
60
|
+
else ""
|
|
61
|
+
)
|
|
62
|
+
profile_data: dict | None = None
|
|
63
|
+
if not company_id:
|
|
64
|
+
employment = client.employment_info(token)
|
|
65
|
+
if isinstance(employment, Result):
|
|
66
|
+
if not employment.ok:
|
|
67
|
+
return employment
|
|
68
|
+
data = employment.data
|
|
69
|
+
else:
|
|
70
|
+
data = employment
|
|
71
|
+
if not isinstance(data, dict):
|
|
72
|
+
return Result(False, "profile_missing", "Profile company ID is unavailable")
|
|
73
|
+
company_id = positive_id(data.get("company_id") or data.get("companyId"))
|
|
74
|
+
if company_id is None:
|
|
75
|
+
return Result(False, "profile_missing", "Profile company ID is unavailable")
|
|
76
|
+
profile_data = data
|
|
77
|
+
|
|
78
|
+
return session, token, company_id, profile_data
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def schedule(
|
|
82
|
+
store: SessionStore | None = None,
|
|
83
|
+
client: MekariClient | None = None,
|
|
84
|
+
) -> ActiveSchedule | Result:
|
|
85
|
+
with owned_client(client) as mekari:
|
|
86
|
+
resolved = _resolve_attendance(store, mekari)
|
|
87
|
+
if isinstance(resolved, Result):
|
|
88
|
+
return resolved
|
|
89
|
+
_, token, company_id, _profile = resolved
|
|
90
|
+
return mekari.active_schedule(token, company_id)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def summary_list(
|
|
94
|
+
selected_date: date,
|
|
95
|
+
store: SessionStore | None = None,
|
|
96
|
+
client: MekariClient | None = None,
|
|
97
|
+
) -> Result:
|
|
98
|
+
with owned_client(client) as mekari:
|
|
99
|
+
resolved = _resolve_attendance(store, mekari)
|
|
100
|
+
if isinstance(resolved, Result):
|
|
101
|
+
return resolved
|
|
102
|
+
_, token, company_id, _profile = resolved
|
|
103
|
+
return mekari.summary_attendance_clocks(
|
|
104
|
+
token,
|
|
105
|
+
company_id,
|
|
106
|
+
selected_date.isoformat(),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def summary_metrics(
|
|
111
|
+
selected_date: date,
|
|
112
|
+
store: SessionStore | None = None,
|
|
113
|
+
client: MekariClient | None = None,
|
|
114
|
+
) -> Result:
|
|
115
|
+
with owned_client(client) as mekari:
|
|
116
|
+
resolved = _resolve_attendance(store, mekari)
|
|
117
|
+
if isinstance(resolved, Result):
|
|
118
|
+
return resolved
|
|
119
|
+
_, token, company_id, profile_data = resolved
|
|
120
|
+
|
|
121
|
+
if profile_data is None:
|
|
122
|
+
profile = mekari.employment_info(token)
|
|
123
|
+
if isinstance(profile, Result):
|
|
124
|
+
if not profile.ok:
|
|
125
|
+
return profile
|
|
126
|
+
profile_data = profile.data
|
|
127
|
+
else:
|
|
128
|
+
profile_data = profile
|
|
129
|
+
user_id = (
|
|
130
|
+
positive_id(profile_data.get("id")) if isinstance(profile_data, dict) else None
|
|
131
|
+
)
|
|
132
|
+
if user_id is None:
|
|
133
|
+
return Result(False, "profile_missing", "Profile user ID is unavailable")
|
|
134
|
+
|
|
135
|
+
return mekari.summary_attendance_metrics(
|
|
136
|
+
token,
|
|
137
|
+
company_id,
|
|
138
|
+
user_id,
|
|
139
|
+
selected_date.isoformat(),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def preview_clock(
|
|
144
|
+
event_type: str,
|
|
145
|
+
notes: str,
|
|
146
|
+
*,
|
|
147
|
+
latitude: str,
|
|
148
|
+
longitude: str,
|
|
149
|
+
location_id: str | None = None,
|
|
150
|
+
store: SessionStore | None = None,
|
|
151
|
+
client: MekariClient | None = None,
|
|
152
|
+
) -> Result:
|
|
153
|
+
validation = validate_notes(notes) or validate_coordinates(latitude, longitude)
|
|
154
|
+
if validation is not None:
|
|
155
|
+
return validation
|
|
156
|
+
with owned_client(client) as mekari:
|
|
157
|
+
resolved = _resolve_attendance(store, mekari)
|
|
158
|
+
if isinstance(resolved, Result):
|
|
159
|
+
return resolved
|
|
160
|
+
_, token, company_id, _profile = resolved
|
|
161
|
+
active = mekari.active_schedule(token, company_id)
|
|
162
|
+
if isinstance(active, Result):
|
|
163
|
+
return active
|
|
164
|
+
return Result(
|
|
165
|
+
True,
|
|
166
|
+
"clockin_preview",
|
|
167
|
+
"Attendance request preview",
|
|
168
|
+
data={
|
|
169
|
+
"method": "POST",
|
|
170
|
+
"url": clock_endpoint(company_id),
|
|
171
|
+
"fields": build_clock_fields(
|
|
172
|
+
active,
|
|
173
|
+
event_type,
|
|
174
|
+
notes,
|
|
175
|
+
latitude=latitude,
|
|
176
|
+
longitude=longitude,
|
|
177
|
+
location_id=location_id,
|
|
178
|
+
),
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def clock(
|
|
184
|
+
event_type: str,
|
|
185
|
+
notes: str,
|
|
186
|
+
selfie: Path | None = None,
|
|
187
|
+
*,
|
|
188
|
+
schedule: ActiveSchedule,
|
|
189
|
+
latitude: str,
|
|
190
|
+
longitude: str,
|
|
191
|
+
location_id: str | None = None,
|
|
192
|
+
store: SessionStore | None = None,
|
|
193
|
+
client: MekariClient | None = None,
|
|
194
|
+
) -> Result:
|
|
195
|
+
validation = validate_notes(notes) or validate_coordinates(latitude, longitude)
|
|
196
|
+
if validation is not None:
|
|
197
|
+
return validation
|
|
198
|
+
with owned_client(client) as mekari:
|
|
199
|
+
resolved = _resolve_attendance(store, mekari)
|
|
200
|
+
if isinstance(resolved, Result):
|
|
201
|
+
return resolved
|
|
202
|
+
_, token, company_id, _profile = resolved
|
|
203
|
+
return mekari.clock(
|
|
204
|
+
token,
|
|
205
|
+
company_id,
|
|
206
|
+
event_type,
|
|
207
|
+
notes,
|
|
208
|
+
selfie,
|
|
209
|
+
schedule=schedule,
|
|
210
|
+
latitude=latitude,
|
|
211
|
+
longitude=longitude,
|
|
212
|
+
location_id=location_id,
|
|
213
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from tlnt.core.clients.mekari.client import MekariClient
|
|
5
|
+
from tlnt.core.models import Result
|
|
6
|
+
from tlnt.core.services import with_client
|
|
7
|
+
from tlnt.core.session import SessionStore
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def calendar_holiday(
|
|
11
|
+
params: Mapping[str, Any] | None = None,
|
|
12
|
+
store: SessionStore | None = None,
|
|
13
|
+
client: MekariClient | None = None,
|
|
14
|
+
) -> Result:
|
|
15
|
+
return with_client(lambda c, token: c.calendar_holiday(token, params), store, client)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def calendar_timeoff(
|
|
19
|
+
params: Mapping[str, Any] | None = None,
|
|
20
|
+
store: SessionStore | None = None,
|
|
21
|
+
client: MekariClient | None = None,
|
|
22
|
+
) -> Result:
|
|
23
|
+
return with_client(lambda c, token: c.calendar_timeoff(token, params), store, client)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def calendar_index(
|
|
27
|
+
params: Mapping[str, Any] | None = None,
|
|
28
|
+
store: SessionStore | None = None,
|
|
29
|
+
client: MekariClient | None = None,
|
|
30
|
+
) -> Result:
|
|
31
|
+
return with_client(lambda c, token: c.calendar_index(token, params), store, client)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def calendar_my_birthday(
|
|
35
|
+
store: SessionStore | None = None,
|
|
36
|
+
client: MekariClient | None = None,
|
|
37
|
+
) -> Result:
|
|
38
|
+
return with_client(lambda c, token: c.calendar_my_birthday(token), store, client)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def calendar_birthday(
|
|
42
|
+
params: Mapping[str, Any] | None = None,
|
|
43
|
+
store: SessionStore | None = None,
|
|
44
|
+
client: MekariClient | None = None,
|
|
45
|
+
) -> Result:
|
|
46
|
+
return with_client(lambda c, token: c.calendar_birthday(token, params), store, client)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def calendar_activity(
|
|
50
|
+
params: Mapping[str, Any] | None = None,
|
|
51
|
+
store: SessionStore | None = None,
|
|
52
|
+
client: MekariClient | None = None,
|
|
53
|
+
) -> Result:
|
|
54
|
+
return with_client(lambda c, token: c.calendar_activity(token, params), store, client)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, replace
|
|
4
|
+
from functools import partial
|
|
5
|
+
|
|
6
|
+
from keyring.errors import KeyringError
|
|
7
|
+
|
|
8
|
+
from tlnt.browser.login import (
|
|
9
|
+
DEFAULT_CDP_ENDPOINT,
|
|
10
|
+
BrowserLogin,
|
|
11
|
+
Credentials,
|
|
12
|
+
LoginFailed,
|
|
13
|
+
cdp_context,
|
|
14
|
+
chrome_context,
|
|
15
|
+
)
|
|
16
|
+
from tlnt.browser.scripted import ScriptedLogin
|
|
17
|
+
from tlnt.core.models import Result, SessionState
|
|
18
|
+
from tlnt.core.logs import logger
|
|
19
|
+
from tlnt.core.session import InvalidSession, SessionMissing, SessionStore
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_log = logger.bind(component="service")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _drop_stored_password(store: SessionStore, session: SessionState) -> SessionState:
|
|
26
|
+
"""Best-effort removal of a password saved by the retired --store-password."""
|
|
27
|
+
if session.keyring_ref is None:
|
|
28
|
+
return session
|
|
29
|
+
try:
|
|
30
|
+
store.delete_password(session.keyring_ref)
|
|
31
|
+
except KeyringError:
|
|
32
|
+
# Keep the reference so a later `tlnt logout` can retry the cleanup.
|
|
33
|
+
_log.bind(
|
|
34
|
+
event="warning",
|
|
35
|
+
operation="login",
|
|
36
|
+
error_type="KeyringError",
|
|
37
|
+
).warning("stored password cleanup failed")
|
|
38
|
+
return session
|
|
39
|
+
return replace(session, credential_email=None, keyring_ref=None)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _browser_login(
|
|
43
|
+
backend: str, cdp: str, credentials: Credentials | None, headless: bool
|
|
44
|
+
) -> BrowserLogin:
|
|
45
|
+
if backend == "cdp":
|
|
46
|
+
return BrowserLogin(
|
|
47
|
+
context_factory=partial(cdp_context, cdp), credentials=credentials
|
|
48
|
+
)
|
|
49
|
+
if backend in ("ego", "yab"):
|
|
50
|
+
return ScriptedLogin(backend)
|
|
51
|
+
factory = partial(chrome_context, headless=True) if headless else chrome_context
|
|
52
|
+
return BrowserLogin(context_factory=factory, credentials=credentials)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def login(
|
|
56
|
+
store: SessionStore | None = None,
|
|
57
|
+
browser: BrowserLogin | None = None,
|
|
58
|
+
*,
|
|
59
|
+
backend: str = "chrome",
|
|
60
|
+
cdp: str = DEFAULT_CDP_ENDPOINT,
|
|
61
|
+
credentials: Credentials | None = None,
|
|
62
|
+
headless: bool = False,
|
|
63
|
+
) -> Result:
|
|
64
|
+
store = store or SessionStore()
|
|
65
|
+
browser = browser or _browser_login(backend, cdp, credentials, headless)
|
|
66
|
+
try:
|
|
67
|
+
existing = store.load()
|
|
68
|
+
except (SessionMissing, InvalidSession):
|
|
69
|
+
existing = None
|
|
70
|
+
try:
|
|
71
|
+
session = browser.login()
|
|
72
|
+
except LoginFailed as error:
|
|
73
|
+
return Result(False, "login_failed", str(error))
|
|
74
|
+
if existing is not None:
|
|
75
|
+
session = _drop_stored_password(
|
|
76
|
+
store,
|
|
77
|
+
replace(
|
|
78
|
+
session,
|
|
79
|
+
profile=session.profile or existing.profile,
|
|
80
|
+
mobile=session.mobile or existing.mobile,
|
|
81
|
+
credential_email=existing.credential_email,
|
|
82
|
+
keyring_ref=existing.keyring_ref,
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
store.save(session)
|
|
88
|
+
saved = store.load()
|
|
89
|
+
except (OSError, InvalidSession):
|
|
90
|
+
return Result(
|
|
91
|
+
False,
|
|
92
|
+
"login_failed",
|
|
93
|
+
"Login succeeded, but the session could not be saved",
|
|
94
|
+
)
|
|
95
|
+
return Result(
|
|
96
|
+
True,
|
|
97
|
+
"login_succeeded",
|
|
98
|
+
"Login saved",
|
|
99
|
+
data=asdict(saved.profile) if saved.profile is not None else None,
|
|
100
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from keyring.errors import KeyringError
|
|
2
|
+
|
|
3
|
+
from tlnt.core.clients.mekari.client import MekariClient
|
|
4
|
+
from tlnt.core.models import Result
|
|
5
|
+
from tlnt.core.services import owned_client
|
|
6
|
+
from tlnt.core.session import SessionStore, load_session, mobile_token
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def logout(
|
|
10
|
+
store: SessionStore | None = None,
|
|
11
|
+
client: MekariClient | None = None,
|
|
12
|
+
) -> Result:
|
|
13
|
+
store = store or SessionStore()
|
|
14
|
+
session = load_session(store)
|
|
15
|
+
token = mobile_token(store, session if not isinstance(session, Result) else None)
|
|
16
|
+
remote_status: int | None = None
|
|
17
|
+
|
|
18
|
+
if not isinstance(token, Result):
|
|
19
|
+
with owned_client(client) as mekari:
|
|
20
|
+
remote = mekari.logout(token)
|
|
21
|
+
# An already-expired token leaves no remote session to end.
|
|
22
|
+
if not remote.ok and remote.code != "mobile_token_expired":
|
|
23
|
+
return remote
|
|
24
|
+
remote_status = remote.status_code
|
|
25
|
+
if not isinstance(session, Result) and session.keyring_ref is not None:
|
|
26
|
+
try:
|
|
27
|
+
store.delete_password(session.keyring_ref)
|
|
28
|
+
except KeyringError:
|
|
29
|
+
return Result(
|
|
30
|
+
False,
|
|
31
|
+
"logout_failed",
|
|
32
|
+
"Password could not be removed from the system keyring",
|
|
33
|
+
)
|
|
34
|
+
try:
|
|
35
|
+
store.delete()
|
|
36
|
+
except OSError:
|
|
37
|
+
return Result(False, "logout_failed", "Session could not be removed")
|
|
38
|
+
return Result(True, "logout_succeeded", "Session removed", remote_status)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from tlnt.core.clients.mekari.client import MekariClient
|
|
2
|
+
from tlnt.core.models import Result
|
|
3
|
+
from tlnt.core.services import with_client
|
|
4
|
+
from tlnt.core.session import SessionStore
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def notification_count(
|
|
8
|
+
store: SessionStore | None = None,
|
|
9
|
+
client: MekariClient | None = None,
|
|
10
|
+
) -> Result:
|
|
11
|
+
return with_client(lambda c, token: c.notification_count(token), store, client)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def notification_list(
|
|
15
|
+
page: int = 1,
|
|
16
|
+
query: str = "",
|
|
17
|
+
status: int = 3,
|
|
18
|
+
store: SessionStore | None = None,
|
|
19
|
+
client: MekariClient | None = None,
|
|
20
|
+
) -> Result:
|
|
21
|
+
return with_client(
|
|
22
|
+
lambda c, token: c.notification_list(token, page, query, status), store, client
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def notification_attendance_list(
|
|
27
|
+
page: int = 1,
|
|
28
|
+
query: str = "",
|
|
29
|
+
status: int = 3,
|
|
30
|
+
store: SessionStore | None = None,
|
|
31
|
+
client: MekariClient | None = None,
|
|
32
|
+
) -> Result:
|
|
33
|
+
return with_client(
|
|
34
|
+
lambda c, token: c.notification_attendance_list(token, page, query, status),
|
|
35
|
+
store,
|
|
36
|
+
client,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def notification_timeoff_list(
|
|
41
|
+
page: int = 1,
|
|
42
|
+
query: str = "",
|
|
43
|
+
status: int = 3,
|
|
44
|
+
store: SessionStore | None = None,
|
|
45
|
+
client: MekariClient | None = None,
|
|
46
|
+
) -> Result:
|
|
47
|
+
return with_client(
|
|
48
|
+
lambda c, token: c.notification_timeoff_list(token, page, query, status),
|
|
49
|
+
store,
|
|
50
|
+
client,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def notification_detail(
|
|
55
|
+
inbox_id: int,
|
|
56
|
+
store: SessionStore | None = None,
|
|
57
|
+
client: MekariClient | None = None,
|
|
58
|
+
) -> Result:
|
|
59
|
+
return with_client(
|
|
60
|
+
lambda c, token: c.notification_detail(token, inbox_id), store, client
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def attendance_approve(
|
|
65
|
+
inbox_id: int,
|
|
66
|
+
attendance_id: int,
|
|
67
|
+
inbox_message: str,
|
|
68
|
+
reason: str = "",
|
|
69
|
+
*,
|
|
70
|
+
store: SessionStore | None = None,
|
|
71
|
+
client: MekariClient | None = None,
|
|
72
|
+
) -> Result:
|
|
73
|
+
return with_client(
|
|
74
|
+
lambda c, token: c.attendance_approve(
|
|
75
|
+
token, inbox_id, attendance_id, inbox_message, reason
|
|
76
|
+
),
|
|
77
|
+
store,
|
|
78
|
+
client,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def timeoff_approve(
|
|
83
|
+
inbox_id: int,
|
|
84
|
+
timeoff_id: int,
|
|
85
|
+
inbox_message: str,
|
|
86
|
+
reason: str = "",
|
|
87
|
+
*,
|
|
88
|
+
store: SessionStore | None = None,
|
|
89
|
+
client: MekariClient | None = None,
|
|
90
|
+
) -> Result:
|
|
91
|
+
return with_client(
|
|
92
|
+
lambda c, token: c.timeoff_approve(
|
|
93
|
+
token, inbox_id, timeoff_id, inbox_message, reason
|
|
94
|
+
),
|
|
95
|
+
store,
|
|
96
|
+
client,
|
|
97
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from tlnt.core.clients.mekari.client import MekariClient
|
|
2
|
+
from tlnt.core.models import Result
|
|
3
|
+
from tlnt.core.services import with_client
|
|
4
|
+
from tlnt.core.session import SessionStore
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def profile(
|
|
8
|
+
store: SessionStore | None = None,
|
|
9
|
+
client: MekariClient | None = None,
|
|
10
|
+
) -> Result:
|
|
11
|
+
return with_client(lambda c, token: c.employment_info(token), store, client)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Create config.json, or bring an existing one up to date."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from tlnt.core import config
|
|
10
|
+
from tlnt.core.clients.mekari.constants import (
|
|
11
|
+
DEFAULT_APP_VERSION,
|
|
12
|
+
DEFAULT_USER_AGENT_MOBILE,
|
|
13
|
+
)
|
|
14
|
+
from tlnt.core.models import Result
|
|
15
|
+
|
|
16
|
+
# Every key tlnt reads from config.json; an empty value means "not set".
|
|
17
|
+
DEFAULT_CONFIG: dict[str, str] = {
|
|
18
|
+
"client_id": "",
|
|
19
|
+
"app_version": DEFAULT_APP_VERSION,
|
|
20
|
+
"user_agent_mobile": DEFAULT_USER_AGENT_MOBILE,
|
|
21
|
+
"session_path": "",
|
|
22
|
+
"cdp": "",
|
|
23
|
+
"latitude": "",
|
|
24
|
+
"longitude": "",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def setup(path: Path | None = None) -> Result:
|
|
29
|
+
"""Add missing keys, drop unknown ones, and keep every value already set."""
|
|
30
|
+
path = path or config.get_config_path()
|
|
31
|
+
try:
|
|
32
|
+
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
33
|
+
current = json.loads(text) if text.strip() else {}
|
|
34
|
+
except (OSError, ValueError):
|
|
35
|
+
current = None
|
|
36
|
+
if not isinstance(current, dict):
|
|
37
|
+
return Result(
|
|
38
|
+
False,
|
|
39
|
+
"setup_failed",
|
|
40
|
+
f"{path} is not a JSON object and was left unchanged; "
|
|
41
|
+
"fix or move it, then run `tlnt setup` again",
|
|
42
|
+
)
|
|
43
|
+
# A file with none of our keys belongs to something else; never trim it.
|
|
44
|
+
if current and not current.keys() & DEFAULT_CONFIG.keys():
|
|
45
|
+
return Result(
|
|
46
|
+
False,
|
|
47
|
+
"setup_failed",
|
|
48
|
+
f"{path} has no tlnt keys and was left unchanged; pass another "
|
|
49
|
+
"file with --config, or move it, then run `tlnt setup` again",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
merged = {key: current.get(key, value) for key, value in DEFAULT_CONFIG.items()}
|
|
53
|
+
added = [key for key in DEFAULT_CONFIG if key not in current]
|
|
54
|
+
removed = [key for key in current if key not in DEFAULT_CONFIG]
|
|
55
|
+
if added or removed:
|
|
56
|
+
try:
|
|
57
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
58
|
+
path.touch(mode=0o600, exist_ok=True)
|
|
59
|
+
# ponytail: plain write, not atomic; use a temp file + os.replace
|
|
60
|
+
# if a truncated config is ever reported.
|
|
61
|
+
path.write_text(
|
|
62
|
+
json.dumps(merged, indent=2, ensure_ascii=False) + "\n",
|
|
63
|
+
encoding="utf-8",
|
|
64
|
+
)
|
|
65
|
+
except OSError as error:
|
|
66
|
+
return Result(
|
|
67
|
+
False,
|
|
68
|
+
"setup_failed",
|
|
69
|
+
f"Could not write {path}: {error.strerror or error}",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if not current:
|
|
73
|
+
message = f"Created {path}"
|
|
74
|
+
elif added or removed:
|
|
75
|
+
message = f"Updated {path}"
|
|
76
|
+
else:
|
|
77
|
+
message = f"{path} is up to date"
|
|
78
|
+
if not (merged["client_id"] or os.environ.get("TALENTA_CLIENT_ID")):
|
|
79
|
+
message += "; set client_id, then run `tlnt login`"
|
|
80
|
+
return Result(
|
|
81
|
+
True,
|
|
82
|
+
"setup_succeeded",
|
|
83
|
+
message,
|
|
84
|
+
data={"config_path": str(path), "added": added, "removed": removed},
|
|
85
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
from tlnt.core.models import Result
|
|
4
|
+
from tlnt.core.session import SessionStore, load_session, mobile_token
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def status(store: SessionStore | None = None) -> Result:
|
|
8
|
+
session = load_session(store)
|
|
9
|
+
if isinstance(session, Result):
|
|
10
|
+
return session
|
|
11
|
+
token = mobile_token(store, session)
|
|
12
|
+
if isinstance(token, Result):
|
|
13
|
+
return token
|
|
14
|
+
# Expiry applies only to the saved token, never to TALENTA_BEARER.
|
|
15
|
+
saved = (
|
|
16
|
+
session.mobile
|
|
17
|
+
if session.mobile is not None and session.mobile.access_token == token
|
|
18
|
+
else None
|
|
19
|
+
)
|
|
20
|
+
if saved is not None and saved.is_expired(datetime.now(timezone.utc)):
|
|
21
|
+
return Result(
|
|
22
|
+
False,
|
|
23
|
+
"mobile_token_expired",
|
|
24
|
+
"Mobile token expired; run `tlnt login`",
|
|
25
|
+
)
|
|
26
|
+
return Result(
|
|
27
|
+
True,
|
|
28
|
+
"session_valid",
|
|
29
|
+
"Session is valid",
|
|
30
|
+
data=(
|
|
31
|
+
{"token_expires_at": saved.expires_at}
|
|
32
|
+
if saved is not None and saved.expires_at
|
|
33
|
+
else None
|
|
34
|
+
),
|
|
35
|
+
)
|