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.
Files changed (98) hide show
  1. glpi_python_client/__init__.py +36 -0
  2. glpi_python_client/auth/__init__.py +11 -0
  3. glpi_python_client/auth/auth.py +310 -0
  4. glpi_python_client/auth/tests/test_auth.py +189 -0
  5. glpi_python_client/clients/__init__.py +18 -0
  6. glpi_python_client/clients/api_v1_session.py +460 -0
  7. glpi_python_client/clients/api_v2_client.py +317 -0
  8. glpi_python_client/clients/async_api_v2_client.py +236 -0
  9. glpi_python_client/clients/tests/__init__.py +5 -0
  10. glpi_python_client/clients/tests/test_api_v1_session.py +85 -0
  11. glpi_python_client/clients/tests/test_api_v2_client.py +349 -0
  12. glpi_python_client/clients/tests/test_async_api_v2_client.py +257 -0
  13. glpi_python_client/clients/v2/__init__.py +8 -0
  14. glpi_python_client/clients/v2/async_/__init__.py +12 -0
  15. glpi_python_client/clients/v2/async_/api.py +29 -0
  16. glpi_python_client/clients/v2/async_/directory.py +88 -0
  17. glpi_python_client/clients/v2/async_/documents.py +144 -0
  18. glpi_python_client/clients/v2/async_/team.py +125 -0
  19. glpi_python_client/clients/v2/async_/tests/__init__.py +5 -0
  20. glpi_python_client/clients/v2/async_/tests/test_directory.py +43 -0
  21. glpi_python_client/clients/v2/async_/tests/test_documents.py +41 -0
  22. glpi_python_client/clients/v2/async_/tests/test_team.py +44 -0
  23. glpi_python_client/clients/v2/async_/tests/test_tickets.py +174 -0
  24. glpi_python_client/clients/v2/async_/tests/test_timeline.py +126 -0
  25. glpi_python_client/clients/v2/async_/tickets.py +312 -0
  26. glpi_python_client/clients/v2/async_/timeline.py +312 -0
  27. glpi_python_client/clients/v2/async_/transport.py +251 -0
  28. glpi_python_client/clients/v2/common/__init__.py +6 -0
  29. glpi_python_client/clients/v2/common/client_config.py +219 -0
  30. glpi_python_client/clients/v2/common/constants.py +45 -0
  31. glpi_python_client/clients/v2/common/errors.py +23 -0
  32. glpi_python_client/clients/v2/common/filters.py +30 -0
  33. glpi_python_client/clients/v2/common/payloads.py +57 -0
  34. glpi_python_client/clients/v2/common/request_http.py +195 -0
  35. glpi_python_client/clients/v2/common/response_payloads.py +76 -0
  36. glpi_python_client/clients/v2/common/ticket_search.py +113 -0
  37. glpi_python_client/clients/v2/sync/__init__.py +12 -0
  38. glpi_python_client/clients/v2/sync/api.py +29 -0
  39. glpi_python_client/clients/v2/sync/directory.py +90 -0
  40. glpi_python_client/clients/v2/sync/documents.py +144 -0
  41. glpi_python_client/clients/v2/sync/team.py +125 -0
  42. glpi_python_client/clients/v2/sync/tests/__init__.py +5 -0
  43. glpi_python_client/clients/v2/sync/tests/test_directory.py +57 -0
  44. glpi_python_client/clients/v2/sync/tests/test_documents.py +99 -0
  45. glpi_python_client/clients/v2/sync/tests/test_team.py +64 -0
  46. glpi_python_client/clients/v2/sync/tests/test_tickets.py +430 -0
  47. glpi_python_client/clients/v2/sync/tests/test_timeline.py +77 -0
  48. glpi_python_client/clients/v2/sync/tests/test_transport.py +89 -0
  49. glpi_python_client/clients/v2/sync/tickets.py +312 -0
  50. glpi_python_client/clients/v2/sync/timeline.py +308 -0
  51. glpi_python_client/clients/v2/sync/transport.py +246 -0
  52. glpi_python_client/content/__init__.py +11 -0
  53. glpi_python_client/content/conversion.py +58 -0
  54. glpi_python_client/content/records/__init__.py +84 -0
  55. glpi_python_client/content/records/core/__init__.py +6 -0
  56. glpi_python_client/content/records/core/document_links.py +100 -0
  57. glpi_python_client/content/records/core/normalization.py +53 -0
  58. glpi_python_client/content/records/core/references.py +98 -0
  59. glpi_python_client/content/records/core/scalars.py +83 -0
  60. glpi_python_client/content/records/parsers/__init__.py +6 -0
  61. glpi_python_client/content/records/parsers/directory.py +62 -0
  62. glpi_python_client/content/records/parsers/documents.py +49 -0
  63. glpi_python_client/content/records/parsers/team.py +58 -0
  64. glpi_python_client/content/records/parsers/tests/__init__.py +5 -0
  65. glpi_python_client/content/records/parsers/tests/test_tickets.py +35 -0
  66. glpi_python_client/content/records/parsers/tests/test_timeline.py +20 -0
  67. glpi_python_client/content/records/parsers/tickets.py +96 -0
  68. glpi_python_client/content/records/parsers/timeline.py +119 -0
  69. glpi_python_client/content/tests/__init__.py +5 -0
  70. glpi_python_client/content/tests/test_conversion.py +13 -0
  71. glpi_python_client/models/__init__.py +30 -0
  72. glpi_python_client/models/_base.py +22 -0
  73. glpi_python_client/models/_payload.py +79 -0
  74. glpi_python_client/models/_shared.py +37 -0
  75. glpi_python_client/models/glpi/__init__.py +27 -0
  76. glpi_python_client/models/glpi/_document.py +59 -0
  77. glpi_python_client/models/glpi/_followup.py +77 -0
  78. glpi_python_client/models/glpi/_location.py +53 -0
  79. glpi_python_client/models/glpi/_solution.py +57 -0
  80. glpi_python_client/models/glpi/_task.py +41 -0
  81. glpi_python_client/models/glpi/_team_member.py +33 -0
  82. glpi_python_client/models/glpi/_ticket.py +303 -0
  83. glpi_python_client/models/glpi/_user.py +92 -0
  84. glpi_python_client/models/glpi/tests/__init__.py +5 -0
  85. glpi_python_client/models/glpi/tests/test__document.py +12 -0
  86. glpi_python_client/models/glpi/tests/test__followup.py +31 -0
  87. glpi_python_client/models/glpi/tests/test__location.py +12 -0
  88. glpi_python_client/models/glpi/tests/test__solution.py +11 -0
  89. glpi_python_client/models/glpi/tests/test__ticket.py +59 -0
  90. glpi_python_client/models/glpi/tests/test__user.py +29 -0
  91. glpi_python_client/py.typed +0 -0
  92. glpi_python_client/testing/__init__.py +27 -0
  93. glpi_python_client/testing/fixtures.py +52 -0
  94. glpi_python_client/testing/utils.py +149 -0
  95. glpi_python_client-0.1.0.dist-info/METADATA +144 -0
  96. glpi_python_client-0.1.0.dist-info/RECORD +98 -0
  97. glpi_python_client-0.1.0.dist-info/WHEEL +4 -0
  98. 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 "")