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,246 @@
|
|
|
1
|
+
"""Synchronous GLPI v2 transport methods.
|
|
2
|
+
|
|
3
|
+
This module owns the authenticated ``requests`` call helpers used by the sync
|
|
4
|
+
mixins to communicate with the GLPI high-level API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
|
|
14
|
+
|
|
15
|
+
from glpi_python_client.clients.v2.common.request_http import (
|
|
16
|
+
build_request_headers,
|
|
17
|
+
build_request_url,
|
|
18
|
+
finalize_request_response,
|
|
19
|
+
request_params,
|
|
20
|
+
require_access_token,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from glpi_python_client.auth.auth import GLPITokenManager
|
|
25
|
+
from glpi_python_client.clients.api_v1_session import GLPIV1Session
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SyncTransportMixin:
|
|
31
|
+
"""Synchronous GLPI API transport helpers.
|
|
32
|
+
|
|
33
|
+
The transport mixin keeps token handling, header construction, retries, and
|
|
34
|
+
request dispatch out of the endpoint-specific synchronous mixins.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
_auth: GLPITokenManager
|
|
38
|
+
_auth_lock: Any
|
|
39
|
+
_closed: bool = False
|
|
40
|
+
_session: requests.Session
|
|
41
|
+
_v1: GLPIV1Session | None
|
|
42
|
+
entity_recursive: bool
|
|
43
|
+
glpi_api_url: str
|
|
44
|
+
glpi_entity: int | None
|
|
45
|
+
glpi_profile: int | None
|
|
46
|
+
language: str
|
|
47
|
+
|
|
48
|
+
def _ensure_open(self) -> None:
|
|
49
|
+
"""Raise when the client has already been closed.
|
|
50
|
+
|
|
51
|
+
All synchronous transport helpers call this guard before touching the
|
|
52
|
+
shared HTTP session.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
if self._closed:
|
|
56
|
+
raise RuntimeError("GLPI client is closed")
|
|
57
|
+
|
|
58
|
+
def _ensure_token(self) -> None:
|
|
59
|
+
"""Ensure that a valid GLPI access token is available.
|
|
60
|
+
|
|
61
|
+
Token refresh is protected by the client lock so concurrent sync calls
|
|
62
|
+
do not race while updating shared authentication state.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
self._ensure_open()
|
|
66
|
+
with self._auth_lock:
|
|
67
|
+
self._auth.ensure_token()
|
|
68
|
+
|
|
69
|
+
def _get_headers(
|
|
70
|
+
self,
|
|
71
|
+
*,
|
|
72
|
+
include_content_type: bool = False,
|
|
73
|
+
skip_entity: bool = False,
|
|
74
|
+
) -> dict[str, str]:
|
|
75
|
+
"""Build GLPI request headers for the current client state.
|
|
76
|
+
|
|
77
|
+
This convenience wrapper forwards the current transport state to the
|
|
78
|
+
shared header builder used across sync and async implementations.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
return build_request_headers(
|
|
82
|
+
access_token=self._auth.access_token,
|
|
83
|
+
language=self.language,
|
|
84
|
+
glpi_entity=self.glpi_entity,
|
|
85
|
+
glpi_profile=self.glpi_profile,
|
|
86
|
+
entity_recursive=self.entity_recursive,
|
|
87
|
+
include_content_type=include_content_type,
|
|
88
|
+
skip_entity=skip_entity,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def _send_request(
|
|
92
|
+
self,
|
|
93
|
+
method: str,
|
|
94
|
+
url: str,
|
|
95
|
+
**kwargs: object,
|
|
96
|
+
) -> requests.Response:
|
|
97
|
+
request_method = getattr(self._session, method)
|
|
98
|
+
return cast(requests.Response, request_method(url, **kwargs))
|
|
99
|
+
|
|
100
|
+
def _execute_request(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
method: str,
|
|
104
|
+
endpoint: str,
|
|
105
|
+
success_statuses: tuple[int, ...],
|
|
106
|
+
params: dict[str, object] | None = None,
|
|
107
|
+
json_body: dict[str, object] | None = None,
|
|
108
|
+
skip_entity: bool = False,
|
|
109
|
+
include_content_type: bool = False,
|
|
110
|
+
) -> requests.Response:
|
|
111
|
+
"""Execute one authenticated GLPI request.
|
|
112
|
+
|
|
113
|
+
The helper normalizes the endpoint URL, headers, timeout, and payload
|
|
114
|
+
placement before handing the response to the shared validation logic.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
self._ensure_token()
|
|
118
|
+
access_token = require_access_token(self._auth.access_token)
|
|
119
|
+
url = build_request_url(self.glpi_api_url, endpoint)
|
|
120
|
+
|
|
121
|
+
request_kwargs: dict[str, object] = {
|
|
122
|
+
"headers": build_request_headers(
|
|
123
|
+
access_token=access_token,
|
|
124
|
+
language=self.language,
|
|
125
|
+
glpi_entity=self.glpi_entity,
|
|
126
|
+
glpi_profile=self.glpi_profile,
|
|
127
|
+
entity_recursive=self.entity_recursive,
|
|
128
|
+
include_content_type=include_content_type,
|
|
129
|
+
skip_entity=skip_entity,
|
|
130
|
+
),
|
|
131
|
+
"timeout": 30,
|
|
132
|
+
}
|
|
133
|
+
if method == "get":
|
|
134
|
+
request_kwargs["params"] = request_params(params)
|
|
135
|
+
else:
|
|
136
|
+
request_kwargs["json"] = json_body
|
|
137
|
+
|
|
138
|
+
response = self._send_request(method, url, **request_kwargs)
|
|
139
|
+
return finalize_request_response(
|
|
140
|
+
response,
|
|
141
|
+
method=method,
|
|
142
|
+
url=url,
|
|
143
|
+
success_statuses=success_statuses,
|
|
144
|
+
logger=logger,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@retry(
|
|
148
|
+
retry=retry_if_exception_type(requests.RequestException),
|
|
149
|
+
stop=stop_after_attempt(3),
|
|
150
|
+
wait=wait_fixed(3),
|
|
151
|
+
)
|
|
152
|
+
def _get_request(
|
|
153
|
+
self,
|
|
154
|
+
endpoint: str,
|
|
155
|
+
params: dict[str, object] | None = None,
|
|
156
|
+
skip_entity: bool = False,
|
|
157
|
+
) -> requests.Response:
|
|
158
|
+
"""Execute one authenticated GLPI ``GET`` request.
|
|
159
|
+
|
|
160
|
+
Network-level request exceptions are retried according to the transport
|
|
161
|
+
retry policy before the response is returned to the caller.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
return self._execute_request(
|
|
165
|
+
method="get",
|
|
166
|
+
endpoint=endpoint,
|
|
167
|
+
success_statuses=(200, 206),
|
|
168
|
+
params=params,
|
|
169
|
+
skip_entity=skip_entity,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
@retry(
|
|
173
|
+
retry=retry_if_exception_type(requests.RequestException),
|
|
174
|
+
stop=stop_after_attempt(3),
|
|
175
|
+
wait=wait_fixed(3),
|
|
176
|
+
)
|
|
177
|
+
def _post_request(
|
|
178
|
+
self,
|
|
179
|
+
endpoint: str,
|
|
180
|
+
json_body: dict[str, object] | None = None,
|
|
181
|
+
skip_entity: bool = False,
|
|
182
|
+
) -> requests.Response:
|
|
183
|
+
"""Execute one authenticated GLPI ``POST`` request.
|
|
184
|
+
|
|
185
|
+
JSON request bodies automatically include the content-type header needed
|
|
186
|
+
by the GLPI API.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
return self._execute_request(
|
|
190
|
+
method="post",
|
|
191
|
+
endpoint=endpoint,
|
|
192
|
+
success_statuses=(200, 201),
|
|
193
|
+
json_body=json_body,
|
|
194
|
+
skip_entity=skip_entity,
|
|
195
|
+
include_content_type=True,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
@retry(
|
|
199
|
+
retry=retry_if_exception_type(requests.RequestException),
|
|
200
|
+
stop=stop_after_attempt(3),
|
|
201
|
+
wait=wait_fixed(3),
|
|
202
|
+
)
|
|
203
|
+
def _update_request(
|
|
204
|
+
self,
|
|
205
|
+
endpoint: str,
|
|
206
|
+
json_body: dict[str, object] | None = None,
|
|
207
|
+
) -> requests.Response:
|
|
208
|
+
"""Execute one authenticated GLPI ``PATCH`` request.
|
|
209
|
+
|
|
210
|
+
The helper uses the same authenticated execution path as the other HTTP
|
|
211
|
+
verbs while targeting the success codes expected from update calls.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
return self._execute_request(
|
|
215
|
+
method="patch",
|
|
216
|
+
endpoint=endpoint,
|
|
217
|
+
success_statuses=(200, 204),
|
|
218
|
+
json_body=json_body,
|
|
219
|
+
include_content_type=True,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
@retry(
|
|
223
|
+
retry=retry_if_exception_type(requests.RequestException),
|
|
224
|
+
stop=stop_after_attempt(3),
|
|
225
|
+
wait=wait_fixed(3),
|
|
226
|
+
)
|
|
227
|
+
def _delete_request(
|
|
228
|
+
self,
|
|
229
|
+
endpoint: str,
|
|
230
|
+
json_body: dict[str, object] | None = None,
|
|
231
|
+
skip_entity: bool = False,
|
|
232
|
+
) -> requests.Response:
|
|
233
|
+
"""Execute one authenticated GLPI ``DELETE`` request.
|
|
234
|
+
|
|
235
|
+
Some delete endpoints accept a JSON body, so the content-type header is
|
|
236
|
+
enabled automatically when a body is supplied.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
return self._execute_request(
|
|
240
|
+
method="delete",
|
|
241
|
+
endpoint=endpoint,
|
|
242
|
+
success_statuses=(200, 204),
|
|
243
|
+
json_body=json_body,
|
|
244
|
+
skip_entity=skip_entity,
|
|
245
|
+
include_content_type=json_body is not None,
|
|
246
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Public content-layer exports for the GLPI client package.
|
|
2
|
+
|
|
3
|
+
The content package contains helpers that translate between GLPI transport
|
|
4
|
+
payloads and the package's canonical Markdown and typed-record representations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from glpi_python_client.content.conversion import GlpiContentConverter
|
|
10
|
+
|
|
11
|
+
__all__ = ["GlpiContentConverter"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Content conversion helpers for GLPI payloads.
|
|
2
|
+
|
|
3
|
+
This module translates between GLPI's HTML transport format and the package's
|
|
4
|
+
canonical Markdown representation used by the rich content models.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from markdown import markdown as markdown_to_html
|
|
10
|
+
from markdownify import markdownify as html_to_markdown
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GlpiContentConverter:
|
|
14
|
+
"""Convert content between GLPI HTML payloads and canonical Markdown.
|
|
15
|
+
|
|
16
|
+
The converter keeps the translation rules in one place so ticket, followup,
|
|
17
|
+
task, and solution parsing all share the same content normalization.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def from_transport(value: object) -> str:
|
|
22
|
+
"""Convert one GLPI transport value into canonical Markdown.
|
|
23
|
+
|
|
24
|
+
Empty input stays empty, plain text is preserved, and HTML content is
|
|
25
|
+
normalized through ``markdownify`` with the package's preferred options.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
content = str(value or "")
|
|
29
|
+
if not content.strip():
|
|
30
|
+
return ""
|
|
31
|
+
if "<" not in content or ">" not in content:
|
|
32
|
+
return content.strip()
|
|
33
|
+
|
|
34
|
+
markdown = html_to_markdown(
|
|
35
|
+
content,
|
|
36
|
+
heading_style="ATX",
|
|
37
|
+
bullets="-",
|
|
38
|
+
strip=["script", "style"],
|
|
39
|
+
)
|
|
40
|
+
return str(markdown).strip()
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def to_transport(value: object) -> str:
|
|
44
|
+
"""Convert one canonical Markdown value into GLPI HTML.
|
|
45
|
+
|
|
46
|
+
Empty Markdown stays empty, while non-empty content is rendered through
|
|
47
|
+
the configured Markdown extensions used by the package.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
markdown = str(value or "")
|
|
51
|
+
if not markdown.strip():
|
|
52
|
+
return ""
|
|
53
|
+
html = markdown_to_html(
|
|
54
|
+
markdown,
|
|
55
|
+
extensions=["nl2br", "sane_lists"],
|
|
56
|
+
output_format="html5",
|
|
57
|
+
)
|
|
58
|
+
return str(html).strip()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Compatibility exports for GLPI record parsing helpers.
|
|
2
|
+
|
|
3
|
+
The real parsing implementation is split between ``core`` shared helpers and
|
|
4
|
+
``parsers`` for model-specific payload conversion. This package preserves the
|
|
5
|
+
older aggregate import path for internal callers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from glpi_python_client.content.records.core.document_links import (
|
|
11
|
+
_glpi_document_id_from_url,
|
|
12
|
+
_glpi_followup_attachment_document_ids,
|
|
13
|
+
_strip_glpi_document_references,
|
|
14
|
+
)
|
|
15
|
+
from glpi_python_client.content.records.core.normalization import (
|
|
16
|
+
_normalize_ticket_record,
|
|
17
|
+
_normalize_timeline_records,
|
|
18
|
+
_unwrap_timeline_item,
|
|
19
|
+
)
|
|
20
|
+
from glpi_python_client.content.records.core.references import (
|
|
21
|
+
_glpi_id_reference,
|
|
22
|
+
_glpi_id_value,
|
|
23
|
+
_glpi_reference,
|
|
24
|
+
_glpi_text_reference,
|
|
25
|
+
_glpi_ticket_user_payload,
|
|
26
|
+
)
|
|
27
|
+
from glpi_python_client.content.records.core.scalars import (
|
|
28
|
+
_coerce_bool,
|
|
29
|
+
_first_int,
|
|
30
|
+
_optional_int,
|
|
31
|
+
_optional_text,
|
|
32
|
+
_parse_glpi_datetime,
|
|
33
|
+
)
|
|
34
|
+
from glpi_python_client.content.records.parsers.directory import (
|
|
35
|
+
_glpi_location_record,
|
|
36
|
+
_glpi_user_record,
|
|
37
|
+
)
|
|
38
|
+
from glpi_python_client.content.records.parsers.documents import _glpi_document_record
|
|
39
|
+
from glpi_python_client.content.records.parsers.team import (
|
|
40
|
+
_glpi_team_member_record,
|
|
41
|
+
_resolve_glpi_member_type,
|
|
42
|
+
)
|
|
43
|
+
from glpi_python_client.content.records.parsers.tickets import (
|
|
44
|
+
_filter_visible_ticket_batch,
|
|
45
|
+
_glpi_ticket_record,
|
|
46
|
+
_is_deleted_ticket,
|
|
47
|
+
)
|
|
48
|
+
from glpi_python_client.content.records.parsers.timeline import (
|
|
49
|
+
_glpi_author_id,
|
|
50
|
+
_glpi_followup_record,
|
|
51
|
+
_glpi_solution_record,
|
|
52
|
+
_glpi_task_record,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"_coerce_bool",
|
|
57
|
+
"_filter_visible_ticket_batch",
|
|
58
|
+
"_first_int",
|
|
59
|
+
"_glpi_author_id",
|
|
60
|
+
"_glpi_document_id_from_url",
|
|
61
|
+
"_glpi_document_record",
|
|
62
|
+
"_glpi_followup_attachment_document_ids",
|
|
63
|
+
"_glpi_followup_record",
|
|
64
|
+
"_glpi_id_reference",
|
|
65
|
+
"_glpi_id_value",
|
|
66
|
+
"_glpi_location_record",
|
|
67
|
+
"_glpi_reference",
|
|
68
|
+
"_glpi_solution_record",
|
|
69
|
+
"_glpi_task_record",
|
|
70
|
+
"_glpi_team_member_record",
|
|
71
|
+
"_glpi_text_reference",
|
|
72
|
+
"_glpi_ticket_record",
|
|
73
|
+
"_glpi_ticket_user_payload",
|
|
74
|
+
"_glpi_user_record",
|
|
75
|
+
"_is_deleted_ticket",
|
|
76
|
+
"_normalize_ticket_record",
|
|
77
|
+
"_normalize_timeline_records",
|
|
78
|
+
"_optional_int",
|
|
79
|
+
"_optional_text",
|
|
80
|
+
"_parse_glpi_datetime",
|
|
81
|
+
"_resolve_glpi_member_type",
|
|
82
|
+
"_strip_glpi_document_references",
|
|
83
|
+
"_unwrap_timeline_item",
|
|
84
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Helpers for parsing GLPI document references in timeline HTML.
|
|
2
|
+
|
|
3
|
+
GLPI followups and solutions can embed attachments as links or images pointing
|
|
4
|
+
to ``document.send.php`` with a ``docid`` query parameter. These helpers keep
|
|
5
|
+
that transport-specific HTML parsing isolated from the model parsers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from urllib.parse import parse_qs, urlsplit
|
|
11
|
+
|
|
12
|
+
from bs4 import BeautifulSoup
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _glpi_followup_attachment_document_ids(raw_content: object) -> tuple[str, ...]:
|
|
16
|
+
"""Return attachment document IDs referenced by followup HTML.
|
|
17
|
+
|
|
18
|
+
GLPI timeline content may contain one or more ``a`` or ``img`` tags that
|
|
19
|
+
point at ``document.send.php``. The parser scans both ``href`` and ``src``
|
|
20
|
+
attributes, preserves the order in which document IDs appear, and removes
|
|
21
|
+
duplicates so callers can safely request metadata once per attachment.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
content = str(raw_content or "")
|
|
25
|
+
if "document.send.php" not in content.casefold():
|
|
26
|
+
return ()
|
|
27
|
+
|
|
28
|
+
document_ids: list[str] = []
|
|
29
|
+
seen_document_ids: set[str] = set()
|
|
30
|
+
soup = BeautifulSoup(content, features="lxml")
|
|
31
|
+
root = soup.body or soup
|
|
32
|
+
|
|
33
|
+
for tag in root.find_all(["a", "img"]):
|
|
34
|
+
if getattr(tag, "attrs", None) is None:
|
|
35
|
+
continue
|
|
36
|
+
for attribute_name in ("href", "src"):
|
|
37
|
+
document_id = _glpi_document_id_from_url(tag.get(attribute_name))
|
|
38
|
+
if document_id is None or document_id in seen_document_ids:
|
|
39
|
+
continue
|
|
40
|
+
seen_document_ids.add(document_id)
|
|
41
|
+
document_ids.append(document_id)
|
|
42
|
+
|
|
43
|
+
return tuple(document_ids)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _strip_glpi_document_references(raw_content: object) -> str:
|
|
47
|
+
"""Return followup HTML with GLPI attachment references removed.
|
|
48
|
+
|
|
49
|
+
Attachment images are removed completely because their source points at the
|
|
50
|
+
GLPI document endpoint rather than meaningful inline content. Attachment
|
|
51
|
+
links are replaced by their visible text when possible so the human-written
|
|
52
|
+
part of the followup remains readable after document references are split
|
|
53
|
+
into structured attachment IDs.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
content = str(raw_content or "")
|
|
57
|
+
if "document.send.php" not in content.casefold():
|
|
58
|
+
return content
|
|
59
|
+
|
|
60
|
+
soup = BeautifulSoup(content, features="lxml")
|
|
61
|
+
root = soup.body or soup
|
|
62
|
+
|
|
63
|
+
for tag in list(root.find_all(["a", "img"])):
|
|
64
|
+
if tag.name is None or getattr(tag, "attrs", None) is None:
|
|
65
|
+
continue
|
|
66
|
+
target = tag.get("src") if tag.name == "img" else tag.get("href")
|
|
67
|
+
if _glpi_document_id_from_url(target) is None:
|
|
68
|
+
continue
|
|
69
|
+
if tag.name == "img" or tag.find("img") is not None:
|
|
70
|
+
tag.decompose()
|
|
71
|
+
continue
|
|
72
|
+
replacement = tag.get_text(" ", strip=True)
|
|
73
|
+
if replacement:
|
|
74
|
+
tag.replace_with(replacement)
|
|
75
|
+
else:
|
|
76
|
+
tag.decompose()
|
|
77
|
+
|
|
78
|
+
normalized_root = soup.body or soup
|
|
79
|
+
return "".join(str(child) for child in normalized_root.contents)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _glpi_document_id_from_url(url: object) -> str | None:
|
|
83
|
+
"""Extract one GLPI document ID from a document download URL.
|
|
84
|
+
|
|
85
|
+
Only GLPI ``document.send.php`` URLs are accepted. Missing URLs, non-string
|
|
86
|
+
values, unrelated paths, missing ``docid`` parameters, and blank document IDs
|
|
87
|
+
all return ``None`` so callers can probe arbitrary HTML attributes without
|
|
88
|
+
handling parsing exceptions.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
if not isinstance(url, str) or not url.strip():
|
|
92
|
+
return None
|
|
93
|
+
parsed = urlsplit(url)
|
|
94
|
+
if not parsed.path.casefold().endswith("document.send.php"):
|
|
95
|
+
return None
|
|
96
|
+
document_ids = parse_qs(parsed.query).get("docid")
|
|
97
|
+
if not document_ids:
|
|
98
|
+
return None
|
|
99
|
+
document_id = str(document_ids[0]).strip()
|
|
100
|
+
return document_id or None
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Raw GLPI payload normalization helpers.
|
|
2
|
+
|
|
3
|
+
These helpers smooth over the small shape differences between GLPI ticket and
|
|
4
|
+
timeline payloads before the model-specific parsers consume them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _normalize_ticket_record(data: object) -> dict[str, Any] | object:
|
|
13
|
+
"""Return a shallow mapping copy for one raw GLPI ticket payload.
|
|
14
|
+
|
|
15
|
+
Ticket payloads are usually already flat mappings, so normalization here is
|
|
16
|
+
limited to copying dictionaries before downstream mutation.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
if not isinstance(data, dict):
|
|
20
|
+
return data
|
|
21
|
+
return dict(data)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _normalize_timeline_records(data: object) -> list[dict[str, Any]]:
|
|
25
|
+
"""Normalize one GLPI timeline payload to a list of item mappings.
|
|
26
|
+
|
|
27
|
+
GLPI timeline responses may contain wrapper objects around the actual item
|
|
28
|
+
payload. This helper unwraps those entries and keeps only mapping items.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
if not isinstance(data, list):
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
records: list[dict[str, Any]] = []
|
|
35
|
+
for entry in data:
|
|
36
|
+
if not isinstance(entry, dict):
|
|
37
|
+
continue
|
|
38
|
+
item = _unwrap_timeline_item(entry)
|
|
39
|
+
if isinstance(item, dict):
|
|
40
|
+
records.append(dict(item))
|
|
41
|
+
return records
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _unwrap_timeline_item(entry: dict[str, Any]) -> dict[str, Any]:
|
|
45
|
+
"""Return the nested timeline item mapping when GLPI wraps it in ``item``.
|
|
46
|
+
|
|
47
|
+
Some timeline endpoints return an outer record with the real payload stored
|
|
48
|
+
under the ``item`` key; others already return the payload directly.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
if "item" in entry and isinstance(entry["item"], dict):
|
|
52
|
+
return entry["item"]
|
|
53
|
+
return entry
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""GLPI nested reference parsing helpers.
|
|
2
|
+
|
|
3
|
+
These helpers normalize the mixed scalar-or-mapping reference fields that GLPI
|
|
4
|
+
uses for related objects such as users, entities, and categories.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from glpi_python_client.models import GlpiUser
|
|
12
|
+
|
|
13
|
+
from .scalars import _optional_int, _optional_text
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _glpi_id_value(value: Any) -> Any:
|
|
17
|
+
"""Return the ``id`` field from one GLPI mapping payload.
|
|
18
|
+
|
|
19
|
+
Non-mapping values return ``None`` so callers can safely probe optional
|
|
20
|
+
nested references without type checks at every call site.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
if isinstance(value, dict):
|
|
24
|
+
return value.get("id")
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _glpi_reference(value: Any) -> dict[str, object] | int | str | None:
|
|
29
|
+
"""Normalize one GLPI reference field using the API field shape.
|
|
30
|
+
|
|
31
|
+
Mapping values are reduced to the supported reference keys, while scalar
|
|
32
|
+
values are preserved as integers when possible and text otherwise.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
if isinstance(value, dict):
|
|
36
|
+
reference = _reference_mapping(value)
|
|
37
|
+
return reference or None
|
|
38
|
+
integer_value = _optional_int(value)
|
|
39
|
+
if integer_value is not None:
|
|
40
|
+
return integer_value
|
|
41
|
+
return _optional_text(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _glpi_id_reference(value: Any) -> dict[str, object] | int | None:
|
|
45
|
+
"""Normalize one GLPI reference whose scalar form must be an ID.
|
|
46
|
+
|
|
47
|
+
This is used for fields that should never surface free text when GLPI sends
|
|
48
|
+
a scalar reference representation.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
if isinstance(value, dict):
|
|
52
|
+
reference = _reference_mapping(value)
|
|
53
|
+
return reference or None
|
|
54
|
+
return _optional_int(value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _glpi_text_reference(value: Any) -> dict[str, object] | str | None:
|
|
58
|
+
"""Normalize one GLPI reference whose scalar form is textual.
|
|
59
|
+
|
|
60
|
+
Mapping payloads are preserved as reduced references, while scalar values
|
|
61
|
+
are coerced to stripped text.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
if isinstance(value, dict):
|
|
65
|
+
reference = _reference_mapping(value)
|
|
66
|
+
return reference or None
|
|
67
|
+
return _optional_text(value)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _glpi_ticket_user_payload(value: Any) -> GlpiUser | None:
|
|
71
|
+
"""Build a lightweight ``GlpiUser`` from one nested ticket field.
|
|
72
|
+
|
|
73
|
+
Ticket payloads often embed partial user records. This helper keeps only the
|
|
74
|
+
fields that are meaningful for the package's ticket models.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
if not isinstance(value, dict):
|
|
78
|
+
return None
|
|
79
|
+
user_id = _optional_text(value.get("id"))
|
|
80
|
+
name = _optional_text(value.get("name"))
|
|
81
|
+
email = _optional_text(value.get("email"))
|
|
82
|
+
if user_id is None and name is None and email is None:
|
|
83
|
+
return None
|
|
84
|
+
return GlpiUser(user_id=user_id, name=name, email=email)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _reference_mapping(value: dict[str, Any]) -> dict[str, object]:
|
|
88
|
+
"""Return the supported GLPI reference fields from one nested payload.
|
|
89
|
+
|
|
90
|
+
Only the small subset of keys used by the package's models is preserved so
|
|
91
|
+
reference payloads stay predictable.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
key: field_value
|
|
96
|
+
for key, field_value in value.items()
|
|
97
|
+
if key in {"id", "name", "completename"} and field_value is not None
|
|
98
|
+
}
|