glpi-python-client 0.1.0__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.
- glpi_python_client/__init__.py +36 -0
- glpi_python_client/auth/__init__.py +11 -0
- glpi_python_client/auth/auth.py +310 -0
- glpi_python_client/auth/tests/test_auth.py +189 -0
- glpi_python_client/clients/__init__.py +18 -0
- glpi_python_client/clients/api_v1_session.py +460 -0
- glpi_python_client/clients/api_v2_client.py +317 -0
- glpi_python_client/clients/async_api_v2_client.py +236 -0
- glpi_python_client/clients/tests/__init__.py +5 -0
- glpi_python_client/clients/tests/test_api_v1_session.py +85 -0
- glpi_python_client/clients/tests/test_api_v2_client.py +349 -0
- glpi_python_client/clients/tests/test_async_api_v2_client.py +257 -0
- glpi_python_client/clients/v2/__init__.py +8 -0
- glpi_python_client/clients/v2/async_/__init__.py +12 -0
- glpi_python_client/clients/v2/async_/api.py +29 -0
- glpi_python_client/clients/v2/async_/directory.py +88 -0
- glpi_python_client/clients/v2/async_/documents.py +144 -0
- glpi_python_client/clients/v2/async_/team.py +125 -0
- glpi_python_client/clients/v2/async_/tests/__init__.py +5 -0
- glpi_python_client/clients/v2/async_/tests/test_directory.py +43 -0
- glpi_python_client/clients/v2/async_/tests/test_documents.py +41 -0
- glpi_python_client/clients/v2/async_/tests/test_team.py +44 -0
- glpi_python_client/clients/v2/async_/tests/test_tickets.py +174 -0
- glpi_python_client/clients/v2/async_/tests/test_timeline.py +126 -0
- glpi_python_client/clients/v2/async_/tickets.py +312 -0
- glpi_python_client/clients/v2/async_/timeline.py +312 -0
- glpi_python_client/clients/v2/async_/transport.py +251 -0
- glpi_python_client/clients/v2/common/__init__.py +6 -0
- glpi_python_client/clients/v2/common/client_config.py +219 -0
- glpi_python_client/clients/v2/common/constants.py +45 -0
- glpi_python_client/clients/v2/common/errors.py +23 -0
- glpi_python_client/clients/v2/common/filters.py +30 -0
- glpi_python_client/clients/v2/common/payloads.py +57 -0
- glpi_python_client/clients/v2/common/request_http.py +195 -0
- glpi_python_client/clients/v2/common/response_payloads.py +76 -0
- glpi_python_client/clients/v2/common/ticket_search.py +113 -0
- glpi_python_client/clients/v2/sync/__init__.py +12 -0
- glpi_python_client/clients/v2/sync/api.py +29 -0
- glpi_python_client/clients/v2/sync/directory.py +90 -0
- glpi_python_client/clients/v2/sync/documents.py +144 -0
- glpi_python_client/clients/v2/sync/team.py +125 -0
- glpi_python_client/clients/v2/sync/tests/__init__.py +5 -0
- glpi_python_client/clients/v2/sync/tests/test_directory.py +57 -0
- glpi_python_client/clients/v2/sync/tests/test_documents.py +99 -0
- glpi_python_client/clients/v2/sync/tests/test_team.py +64 -0
- glpi_python_client/clients/v2/sync/tests/test_tickets.py +430 -0
- glpi_python_client/clients/v2/sync/tests/test_timeline.py +77 -0
- glpi_python_client/clients/v2/sync/tests/test_transport.py +89 -0
- glpi_python_client/clients/v2/sync/tickets.py +312 -0
- glpi_python_client/clients/v2/sync/timeline.py +308 -0
- glpi_python_client/clients/v2/sync/transport.py +246 -0
- glpi_python_client/content/__init__.py +11 -0
- glpi_python_client/content/conversion.py +58 -0
- glpi_python_client/content/records/__init__.py +84 -0
- glpi_python_client/content/records/core/__init__.py +6 -0
- glpi_python_client/content/records/core/document_links.py +100 -0
- glpi_python_client/content/records/core/normalization.py +53 -0
- glpi_python_client/content/records/core/references.py +98 -0
- glpi_python_client/content/records/core/scalars.py +83 -0
- glpi_python_client/content/records/parsers/__init__.py +6 -0
- glpi_python_client/content/records/parsers/directory.py +62 -0
- glpi_python_client/content/records/parsers/documents.py +49 -0
- glpi_python_client/content/records/parsers/team.py +58 -0
- glpi_python_client/content/records/parsers/tests/__init__.py +5 -0
- glpi_python_client/content/records/parsers/tests/test_tickets.py +35 -0
- glpi_python_client/content/records/parsers/tests/test_timeline.py +20 -0
- glpi_python_client/content/records/parsers/tickets.py +96 -0
- glpi_python_client/content/records/parsers/timeline.py +119 -0
- glpi_python_client/content/tests/__init__.py +5 -0
- glpi_python_client/content/tests/test_conversion.py +13 -0
- glpi_python_client/models/__init__.py +30 -0
- glpi_python_client/models/_base.py +22 -0
- glpi_python_client/models/_payload.py +79 -0
- glpi_python_client/models/_shared.py +37 -0
- glpi_python_client/models/glpi/__init__.py +27 -0
- glpi_python_client/models/glpi/_document.py +59 -0
- glpi_python_client/models/glpi/_followup.py +77 -0
- glpi_python_client/models/glpi/_location.py +53 -0
- glpi_python_client/models/glpi/_solution.py +57 -0
- glpi_python_client/models/glpi/_task.py +41 -0
- glpi_python_client/models/glpi/_team_member.py +33 -0
- glpi_python_client/models/glpi/_ticket.py +303 -0
- glpi_python_client/models/glpi/_user.py +92 -0
- glpi_python_client/models/glpi/tests/__init__.py +5 -0
- glpi_python_client/models/glpi/tests/test__document.py +12 -0
- glpi_python_client/models/glpi/tests/test__followup.py +31 -0
- glpi_python_client/models/glpi/tests/test__location.py +12 -0
- glpi_python_client/models/glpi/tests/test__solution.py +11 -0
- glpi_python_client/models/glpi/tests/test__ticket.py +59 -0
- glpi_python_client/models/glpi/tests/test__user.py +29 -0
- glpi_python_client/py.typed +0 -0
- glpi_python_client/testing/__init__.py +27 -0
- glpi_python_client/testing/fixtures.py +52 -0
- glpi_python_client/testing/utils.py +149 -0
- glpi_python_client-0.1.0.dist-info/METADATA +144 -0
- glpi_python_client-0.1.0.dist-info/RECORD +98 -0
- glpi_python_client-0.1.0.dist-info/WHEEL +4 -0
- glpi_python_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""Legacy GLPI v1 session management and document operations.
|
|
2
|
+
|
|
3
|
+
The high-level package still relies on selected v1 endpoints for document
|
|
4
|
+
upload and linking workflows, so this module owns the authenticated v1 HTTP
|
|
5
|
+
session and the retry logic around it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from typing import cast
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
from tenacity import retry, stop_after_attempt, wait_fixed
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_DEFAULT_SESSION_REFRESH_INTERVAL_SECONDS = 15 * 60
|
|
21
|
+
_AUTH_FAILURE_STATUS_CODES = frozenset({401, 403})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GLPIV1Session:
|
|
25
|
+
"""Session manager for the GLPI v1 REST API.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
base_url : str
|
|
30
|
+
The v1 API base URL.
|
|
31
|
+
user_token : str
|
|
32
|
+
The ``user_token`` credential for v1 authentication.
|
|
33
|
+
app_token : str
|
|
34
|
+
The ``App-Token`` value.
|
|
35
|
+
verify_ssl : bool, optional
|
|
36
|
+
Whether to verify SSL certificates.
|
|
37
|
+
session_refresh_interval_seconds : int, optional
|
|
38
|
+
Maximum age of a v1 session token before it is renewed.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
base_url: str,
|
|
45
|
+
user_token: str,
|
|
46
|
+
app_token: str,
|
|
47
|
+
verify_ssl: bool = True,
|
|
48
|
+
session_refresh_interval_seconds: int = (
|
|
49
|
+
_DEFAULT_SESSION_REFRESH_INTERVAL_SECONDS
|
|
50
|
+
),
|
|
51
|
+
) -> None:
|
|
52
|
+
self._base_url = base_url.rstrip("/")
|
|
53
|
+
self._user_token = user_token
|
|
54
|
+
self._app_token = app_token
|
|
55
|
+
if session_refresh_interval_seconds < 1:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
"session_refresh_interval_seconds must be a positive integer"
|
|
58
|
+
)
|
|
59
|
+
self._session_refresh_interval = timedelta(
|
|
60
|
+
seconds=session_refresh_interval_seconds
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
self._http = requests.Session()
|
|
64
|
+
self._http.verify = verify_ssl
|
|
65
|
+
|
|
66
|
+
self._session_token: str | None = None
|
|
67
|
+
self._session_started_at: datetime | None = None
|
|
68
|
+
|
|
69
|
+
@retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
|
|
70
|
+
def _init_session(self) -> None:
|
|
71
|
+
"""Call ``GET /initSession`` to obtain a session token.
|
|
72
|
+
|
|
73
|
+
Returns
|
|
74
|
+
-------
|
|
75
|
+
None
|
|
76
|
+
Stores the session token.
|
|
77
|
+
|
|
78
|
+
Raises
|
|
79
|
+
------
|
|
80
|
+
ValueError
|
|
81
|
+
If the server does not return a valid session token.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
headers: dict[str, str] = {
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
"Accept": "application/json",
|
|
87
|
+
"Authorization": f"user_token {self._user_token}",
|
|
88
|
+
}
|
|
89
|
+
if self._app_token:
|
|
90
|
+
headers["App-Token"] = self._app_token
|
|
91
|
+
|
|
92
|
+
response = self._http.get(
|
|
93
|
+
f"{self._base_url}/initSession",
|
|
94
|
+
headers=headers,
|
|
95
|
+
timeout=30,
|
|
96
|
+
)
|
|
97
|
+
if response.status_code != 200:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"GLPI v1 initSession failed: "
|
|
100
|
+
f"{response.status_code} {response.text[:300]}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
token = response.json().get("session_token")
|
|
104
|
+
if not token:
|
|
105
|
+
raise ValueError("GLPI v1 initSession returned no session_token")
|
|
106
|
+
|
|
107
|
+
self._session_token = str(token)
|
|
108
|
+
self._session_started_at = datetime.now(tz=timezone.utc)
|
|
109
|
+
logger.info("GLPI v1 session initialised.")
|
|
110
|
+
|
|
111
|
+
def _ensure_session(self) -> None:
|
|
112
|
+
"""Lazily initialise or renew the v1 session if needed.
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
None
|
|
117
|
+
Ensures the session token exists.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
if self._session_token is None:
|
|
121
|
+
self._init_session()
|
|
122
|
+
return
|
|
123
|
+
if self._is_session_stale():
|
|
124
|
+
logger.info("GLPI v1 session reached refresh interval; renewing session.")
|
|
125
|
+
self._renew_session()
|
|
126
|
+
|
|
127
|
+
def _is_session_stale(self) -> bool:
|
|
128
|
+
"""Return whether the current v1 session should be renewed.
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
bool
|
|
133
|
+
``True`` when the session age is beyond the configured refresh
|
|
134
|
+
interval.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
if self._session_started_at is None:
|
|
138
|
+
return True
|
|
139
|
+
return datetime.now(tz=timezone.utc) >= (
|
|
140
|
+
self._session_started_at + self._session_refresh_interval
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _session_headers(self) -> dict[str, str]:
|
|
144
|
+
"""Return headers for the current v1 session token.
|
|
145
|
+
|
|
146
|
+
Returns
|
|
147
|
+
-------
|
|
148
|
+
dict[str, str]
|
|
149
|
+
Headers including the current session token.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
headers: dict[str, str] = {
|
|
153
|
+
"Session-Token": str(self._session_token),
|
|
154
|
+
"Accept": "application/json",
|
|
155
|
+
}
|
|
156
|
+
if self._app_token:
|
|
157
|
+
headers["App-Token"] = self._app_token
|
|
158
|
+
return headers
|
|
159
|
+
|
|
160
|
+
def _renew_session(self) -> None:
|
|
161
|
+
"""Close the current v1 session token and initialise a new one.
|
|
162
|
+
|
|
163
|
+
Returns
|
|
164
|
+
-------
|
|
165
|
+
None
|
|
166
|
+
Mutates the stored session token.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
old_token = self._session_token
|
|
170
|
+
if old_token is not None:
|
|
171
|
+
try:
|
|
172
|
+
self._http.get(
|
|
173
|
+
f"{self._base_url}/killSession",
|
|
174
|
+
headers=self._session_headers(),
|
|
175
|
+
timeout=10,
|
|
176
|
+
)
|
|
177
|
+
except Exception:
|
|
178
|
+
logger.warning("Failed to kill stale GLPI v1 session.", exc_info=True)
|
|
179
|
+
self._session_token = None
|
|
180
|
+
self._session_started_at = None
|
|
181
|
+
self._init_session()
|
|
182
|
+
|
|
183
|
+
def _headers(self) -> dict[str, str]:
|
|
184
|
+
"""Return headers for an authenticated v1 request.
|
|
185
|
+
|
|
186
|
+
Returns
|
|
187
|
+
-------
|
|
188
|
+
dict[str, str]
|
|
189
|
+
Headers including the session token.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
self._ensure_session()
|
|
193
|
+
return self._session_headers()
|
|
194
|
+
|
|
195
|
+
def _authenticated_request(
|
|
196
|
+
self,
|
|
197
|
+
method: str,
|
|
198
|
+
url: str,
|
|
199
|
+
*,
|
|
200
|
+
headers: dict[str, str] | None = None,
|
|
201
|
+
**kwargs: object,
|
|
202
|
+
) -> requests.Response:
|
|
203
|
+
"""Execute one v1 request and retry once after auth rejection.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
method : str
|
|
208
|
+
HTTP method.
|
|
209
|
+
url : str
|
|
210
|
+
Absolute request URL.
|
|
211
|
+
headers : dict[str, str] | None, optional
|
|
212
|
+
Additional request headers.
|
|
213
|
+
**kwargs : object
|
|
214
|
+
Additional ``requests`` keyword arguments.
|
|
215
|
+
|
|
216
|
+
Returns
|
|
217
|
+
-------
|
|
218
|
+
requests.Response
|
|
219
|
+
Raw response from GLPI.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
request_headers = {**self._headers(), **(headers or {})}
|
|
223
|
+
request_method = getattr(self._http, method.lower())
|
|
224
|
+
response = cast(
|
|
225
|
+
requests.Response,
|
|
226
|
+
request_method(url, headers=request_headers, **kwargs),
|
|
227
|
+
)
|
|
228
|
+
if not _is_auth_failure_response(response):
|
|
229
|
+
return response
|
|
230
|
+
|
|
231
|
+
logger.warning(
|
|
232
|
+
"GLPI v1 session token was rejected; refreshing session and retrying "
|
|
233
|
+
"request once."
|
|
234
|
+
)
|
|
235
|
+
self._renew_session()
|
|
236
|
+
request_headers = {**self._headers(), **(headers or {})}
|
|
237
|
+
return cast(
|
|
238
|
+
requests.Response,
|
|
239
|
+
request_method(url, headers=request_headers, **kwargs),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
@retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
|
|
243
|
+
def get_sub_items(
|
|
244
|
+
self,
|
|
245
|
+
itemtype: str,
|
|
246
|
+
item_id: str | int,
|
|
247
|
+
sub_itemtype: str,
|
|
248
|
+
) -> list[dict[str, object]]:
|
|
249
|
+
"""Fetch legacy GLPI sub-items for one parent item.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
itemtype : str
|
|
254
|
+
Parent GLPI itemtype.
|
|
255
|
+
item_id : str | int
|
|
256
|
+
Parent item identifier.
|
|
257
|
+
sub_itemtype : str
|
|
258
|
+
Child itemtype to fetch.
|
|
259
|
+
|
|
260
|
+
Returns
|
|
261
|
+
-------
|
|
262
|
+
list[dict[str, object]]
|
|
263
|
+
Raw sub-item payloads.
|
|
264
|
+
|
|
265
|
+
Raises
|
|
266
|
+
------
|
|
267
|
+
ValueError
|
|
268
|
+
If the request fails or returns an unexpected payload shape.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
response = self._authenticated_request(
|
|
272
|
+
"GET",
|
|
273
|
+
f"{self._base_url}/{itemtype}/{item_id}/{sub_itemtype}",
|
|
274
|
+
timeout=30,
|
|
275
|
+
)
|
|
276
|
+
if response.status_code not in (200, 206):
|
|
277
|
+
raise ValueError(
|
|
278
|
+
"GLPI v1 sub-item fetch failed: "
|
|
279
|
+
f"{response.status_code} {response.text[:300]}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
payload = response.json()
|
|
283
|
+
if isinstance(payload, list):
|
|
284
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
285
|
+
if isinstance(payload, dict):
|
|
286
|
+
return [payload]
|
|
287
|
+
raise ValueError(
|
|
288
|
+
"GLPI v1 sub-item fetch returned unexpected payload: "
|
|
289
|
+
f"{type(payload).__name__}"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def close(self) -> None:
|
|
293
|
+
"""Kill the v1 session and release resources.
|
|
294
|
+
|
|
295
|
+
Returns
|
|
296
|
+
-------
|
|
297
|
+
None
|
|
298
|
+
Best-effort cleanup.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
if self._session_token is not None:
|
|
303
|
+
self._http.get(
|
|
304
|
+
f"{self._base_url}/killSession",
|
|
305
|
+
headers=self._session_headers(),
|
|
306
|
+
timeout=10,
|
|
307
|
+
)
|
|
308
|
+
logger.info("GLPI v1 session killed.")
|
|
309
|
+
except Exception:
|
|
310
|
+
logger.warning("Failed to kill GLPI v1 session.", exc_info=True)
|
|
311
|
+
finally:
|
|
312
|
+
self._session_token = None
|
|
313
|
+
self._session_started_at = None
|
|
314
|
+
self._http.close()
|
|
315
|
+
|
|
316
|
+
@retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
|
|
317
|
+
def upload_document(
|
|
318
|
+
self,
|
|
319
|
+
filename: str,
|
|
320
|
+
content: bytes,
|
|
321
|
+
mime_type: str,
|
|
322
|
+
*,
|
|
323
|
+
document_name: str | None = None,
|
|
324
|
+
ticket_id: int | None = None,
|
|
325
|
+
entity_id: int | None = None,
|
|
326
|
+
) -> dict[str, object]:
|
|
327
|
+
"""Upload a document via ``POST /Document``.
|
|
328
|
+
|
|
329
|
+
Parameters
|
|
330
|
+
----------
|
|
331
|
+
filename : str
|
|
332
|
+
File name.
|
|
333
|
+
content : bytes
|
|
334
|
+
Raw file bytes.
|
|
335
|
+
mime_type : str
|
|
336
|
+
MIME type.
|
|
337
|
+
document_name : str | None, optional
|
|
338
|
+
GLPI display name.
|
|
339
|
+
ticket_id : int | None, optional
|
|
340
|
+
GLPI ticket ID used to link the document during creation.
|
|
341
|
+
entity_id : int | None, optional
|
|
342
|
+
GLPI entity ID assigned to the created document.
|
|
343
|
+
|
|
344
|
+
Returns
|
|
345
|
+
-------
|
|
346
|
+
dict[str, object]
|
|
347
|
+
GLPI response payload.
|
|
348
|
+
|
|
349
|
+
Raises
|
|
350
|
+
------
|
|
351
|
+
ValueError
|
|
352
|
+
If upload fails.
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
manifest_input: dict[str, object] = {
|
|
356
|
+
"name": document_name or filename,
|
|
357
|
+
"_filename": [filename],
|
|
358
|
+
}
|
|
359
|
+
if entity_id is not None:
|
|
360
|
+
manifest_input["entities_id"] = int(entity_id)
|
|
361
|
+
if ticket_id is not None:
|
|
362
|
+
manifest_input["itemtype"] = "Ticket"
|
|
363
|
+
manifest_input["items_id"] = int(ticket_id)
|
|
364
|
+
manifest_input["tickets_id"] = int(ticket_id)
|
|
365
|
+
manifest = json.dumps({"input": manifest_input})
|
|
366
|
+
response = self._authenticated_request(
|
|
367
|
+
"POST",
|
|
368
|
+
f"{self._base_url}/Document",
|
|
369
|
+
files=[
|
|
370
|
+
("uploadManifest", (None, manifest, "application/json")),
|
|
371
|
+
("filename[]", (filename, content, mime_type)),
|
|
372
|
+
],
|
|
373
|
+
timeout=60,
|
|
374
|
+
)
|
|
375
|
+
if response.status_code not in (200, 201):
|
|
376
|
+
raise ValueError(
|
|
377
|
+
"GLPI v1 document upload failed: "
|
|
378
|
+
f"{response.status_code} {response.text[:300]}"
|
|
379
|
+
)
|
|
380
|
+
payload = response.json()
|
|
381
|
+
if not isinstance(payload, dict):
|
|
382
|
+
raise ValueError(
|
|
383
|
+
"GLPI v1 document upload returned unexpected payload: "
|
|
384
|
+
f"{type(payload).__name__}"
|
|
385
|
+
)
|
|
386
|
+
logger.info("GLPI v1 document uploaded: id=%s", payload.get("id"))
|
|
387
|
+
return cast(dict[str, object], payload)
|
|
388
|
+
|
|
389
|
+
@retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
|
|
390
|
+
def link_document_to_ticket(
|
|
391
|
+
self, document_id: int, ticket_id: int
|
|
392
|
+
) -> dict[str, object]:
|
|
393
|
+
"""Link an existing document to a ticket.
|
|
394
|
+
|
|
395
|
+
Parameters
|
|
396
|
+
----------
|
|
397
|
+
document_id : int
|
|
398
|
+
GLPI document ID.
|
|
399
|
+
ticket_id : int
|
|
400
|
+
GLPI ticket ID.
|
|
401
|
+
|
|
402
|
+
Returns
|
|
403
|
+
-------
|
|
404
|
+
dict[str, object]
|
|
405
|
+
GLPI response payload.
|
|
406
|
+
|
|
407
|
+
Raises
|
|
408
|
+
------
|
|
409
|
+
ValueError
|
|
410
|
+
If the link creation fails.
|
|
411
|
+
"""
|
|
412
|
+
|
|
413
|
+
payload = json.dumps(
|
|
414
|
+
{
|
|
415
|
+
"input": {
|
|
416
|
+
"documents_id": document_id,
|
|
417
|
+
"itemtype": "Ticket",
|
|
418
|
+
"items_id": ticket_id,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
)
|
|
422
|
+
response = self._authenticated_request(
|
|
423
|
+
"POST",
|
|
424
|
+
f"{self._base_url}/Document_Item",
|
|
425
|
+
headers={"Content-Type": "application/json"},
|
|
426
|
+
data=payload,
|
|
427
|
+
timeout=30,
|
|
428
|
+
)
|
|
429
|
+
if response.status_code not in (200, 201):
|
|
430
|
+
raise ValueError(
|
|
431
|
+
"GLPI v1 Document_Item link failed: "
|
|
432
|
+
f"{response.status_code} {response.text[:300]}"
|
|
433
|
+
)
|
|
434
|
+
result = response.json()
|
|
435
|
+
if not isinstance(result, dict):
|
|
436
|
+
raise ValueError(
|
|
437
|
+
"GLPI v1 Document_Item link returned unexpected payload: "
|
|
438
|
+
f"{type(result).__name__}"
|
|
439
|
+
)
|
|
440
|
+
logger.info("GLPI v1 document %d linked to ticket %d", document_id, ticket_id)
|
|
441
|
+
return cast(dict[str, object], result)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _is_auth_failure_response(response: requests.Response) -> bool:
|
|
445
|
+
"""Return whether one GLPI v1 response means the session is invalid.
|
|
446
|
+
|
|
447
|
+
Parameters
|
|
448
|
+
----------
|
|
449
|
+
response : requests.Response
|
|
450
|
+
Raw GLPI response.
|
|
451
|
+
|
|
452
|
+
Returns
|
|
453
|
+
-------
|
|
454
|
+
bool
|
|
455
|
+
``True`` when the response indicates an expired or rejected v1 session.
|
|
456
|
+
"""
|
|
457
|
+
|
|
458
|
+
if response.status_code in _AUTH_FAILURE_STATUS_CODES:
|
|
459
|
+
return True
|
|
460
|
+
return "ERROR_SESSION_TOKEN_INVALID" in str(response.text or "")
|