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,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
+ )