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,312 @@
|
|
|
1
|
+
"""Synchronous ticket operations for GLPI v2 clients.
|
|
2
|
+
|
|
3
|
+
This module contains the ticket search, fetch, create, update, and delete
|
|
4
|
+
helpers used by the synchronous high-level client.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from typing import Any, cast, overload
|
|
12
|
+
|
|
13
|
+
from glpi_python_client.clients.v2.common.constants import TICKET_ENDPOINT, GlpiId
|
|
14
|
+
from glpi_python_client.clients.v2.common.request_http import (
|
|
15
|
+
ensure_response_status,
|
|
16
|
+
require_non_empty_text,
|
|
17
|
+
require_response_text,
|
|
18
|
+
)
|
|
19
|
+
from glpi_python_client.clients.v2.common.ticket_search import (
|
|
20
|
+
advance_ticket_search_pagination,
|
|
21
|
+
build_ticket_search_params,
|
|
22
|
+
filter_ticket_search_batch,
|
|
23
|
+
is_deleted_ticket,
|
|
24
|
+
merge_list_ticket_fields,
|
|
25
|
+
)
|
|
26
|
+
from glpi_python_client.content.records.core.normalization import (
|
|
27
|
+
_normalize_ticket_record,
|
|
28
|
+
)
|
|
29
|
+
from glpi_python_client.content.records.parsers.tickets import (
|
|
30
|
+
_glpi_ticket_record,
|
|
31
|
+
)
|
|
32
|
+
from glpi_python_client.models import GlpiTicket
|
|
33
|
+
|
|
34
|
+
from .transport import SyncTransportMixin
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SyncTicketMixin(SyncTransportMixin):
|
|
40
|
+
"""Synchronous GLPI ticket search and mutation helpers.
|
|
41
|
+
|
|
42
|
+
The mixin exposes the typed ticket operations while delegating shared field
|
|
43
|
+
and pagination rules to the common helper modules.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
@overload
|
|
47
|
+
def search_ticket_records(
|
|
48
|
+
self,
|
|
49
|
+
query: str | None = None,
|
|
50
|
+
*,
|
|
51
|
+
fields: tuple[str, ...] = (),
|
|
52
|
+
sort: str | None = None,
|
|
53
|
+
batch_size: None = None,
|
|
54
|
+
include_deleted_ticket: bool = False,
|
|
55
|
+
) -> list[GlpiTicket]: ...
|
|
56
|
+
|
|
57
|
+
@overload
|
|
58
|
+
def search_ticket_records(
|
|
59
|
+
self,
|
|
60
|
+
query: str | None = None,
|
|
61
|
+
*,
|
|
62
|
+
fields: tuple[str, ...] = (),
|
|
63
|
+
sort: str | None = None,
|
|
64
|
+
batch_size: int,
|
|
65
|
+
include_deleted_ticket: bool = False,
|
|
66
|
+
) -> Iterator[list[GlpiTicket]]: ...
|
|
67
|
+
|
|
68
|
+
def search_ticket_records(
|
|
69
|
+
self,
|
|
70
|
+
query: str | None = None,
|
|
71
|
+
*,
|
|
72
|
+
fields: tuple[str, ...] = (),
|
|
73
|
+
sort: str | None = None,
|
|
74
|
+
batch_size: int | None = None,
|
|
75
|
+
include_deleted_ticket: bool = False,
|
|
76
|
+
) -> list[GlpiTicket] | Iterator[list[GlpiTicket]]:
|
|
77
|
+
"""Search GLPI tickets and return either a full list or record batches.
|
|
78
|
+
|
|
79
|
+
Passing ``batch_size`` switches the method into streaming mode and
|
|
80
|
+
returns an iterator of typed ticket batches instead of materializing the
|
|
81
|
+
full result set.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
if batch_size is not None and batch_size < 1:
|
|
85
|
+
raise ValueError("batch_size must be a positive integer or None")
|
|
86
|
+
|
|
87
|
+
merged_fields = merge_list_ticket_fields(list(fields) or None)
|
|
88
|
+
batches = self._iter_ticket_record_batches(
|
|
89
|
+
query=query,
|
|
90
|
+
fields=merged_fields,
|
|
91
|
+
sort=sort,
|
|
92
|
+
batch_size=batch_size,
|
|
93
|
+
include_deleted_ticket=include_deleted_ticket,
|
|
94
|
+
)
|
|
95
|
+
if batch_size is not None:
|
|
96
|
+
return batches
|
|
97
|
+
|
|
98
|
+
records: list[GlpiTicket] = []
|
|
99
|
+
for page in batches:
|
|
100
|
+
records.extend(page)
|
|
101
|
+
return records
|
|
102
|
+
|
|
103
|
+
def _iter_ticket_record_batches(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
query: str | None = None,
|
|
107
|
+
fields: list[str] | None = None,
|
|
108
|
+
sort: str | None = None,
|
|
109
|
+
batch_size: int | None = None,
|
|
110
|
+
include_deleted_ticket: bool = False,
|
|
111
|
+
) -> Iterator[list[GlpiTicket]]:
|
|
112
|
+
"""Yield typed ticket batches produced from paginated raw payloads.
|
|
113
|
+
|
|
114
|
+
This helper sits between raw payload pagination and public return types,
|
|
115
|
+
ensuring each yielded batch already contains parsed ``GlpiTicket``
|
|
116
|
+
objects.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
for page in self._yield_ticket_payloads(
|
|
120
|
+
query=query,
|
|
121
|
+
fields=fields,
|
|
122
|
+
sort=sort,
|
|
123
|
+
batch_size=batch_size,
|
|
124
|
+
include_deleted_ticket=include_deleted_ticket,
|
|
125
|
+
):
|
|
126
|
+
yield [
|
|
127
|
+
_glpi_ticket_record(raw_ticket)
|
|
128
|
+
for raw_ticket in page
|
|
129
|
+
if isinstance(raw_ticket, dict)
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
def get_ticket_record(
|
|
133
|
+
self,
|
|
134
|
+
ticket_id: GlpiId,
|
|
135
|
+
*,
|
|
136
|
+
include_deleted_ticket: bool = False,
|
|
137
|
+
) -> GlpiTicket:
|
|
138
|
+
"""Fetch one GLPI ticket by identifier.
|
|
139
|
+
|
|
140
|
+
Deleted tickets are rejected by default so single-record fetch behavior
|
|
141
|
+
stays aligned with the default search behavior.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
response = self._get_request(f"{TICKET_ENDPOINT}/{ticket_id}")
|
|
145
|
+
if response.status_code not in (200, 206):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"Failed to get ticket {ticket_id}: {response.status_code}"
|
|
148
|
+
)
|
|
149
|
+
payload = _normalize_ticket_record(response.json())
|
|
150
|
+
if not isinstance(payload, dict):
|
|
151
|
+
raise ValueError(f"Unexpected GLPI ticket payload for {ticket_id}")
|
|
152
|
+
if not include_deleted_ticket and is_deleted_ticket(payload):
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"Ticket {ticket_id} is deleted and excluded from fetch results"
|
|
155
|
+
)
|
|
156
|
+
return _glpi_ticket_record(payload)
|
|
157
|
+
|
|
158
|
+
def create_ticket(self, ticket: GlpiTicket) -> str:
|
|
159
|
+
"""Create a GLPI ticket and return the identifier assigned by GLPI.
|
|
160
|
+
|
|
161
|
+
The ticket name precondition is enforced locally before the request is
|
|
162
|
+
sent, and the response must contain a created ticket ID.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
require_non_empty_text(
|
|
166
|
+
ticket.name,
|
|
167
|
+
error_message="GLPI ticket creation requires a name",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
payload_data = ticket.to_api_payload(
|
|
171
|
+
entity_id=self.glpi_entity,
|
|
172
|
+
include_entity=True,
|
|
173
|
+
)
|
|
174
|
+
response = self._post_request(TICKET_ENDPOINT, payload_data)
|
|
175
|
+
ensure_response_status(
|
|
176
|
+
response,
|
|
177
|
+
success_statuses=(200, 201),
|
|
178
|
+
failure_message="Failed to create ticket",
|
|
179
|
+
)
|
|
180
|
+
ticket_id = require_response_text(
|
|
181
|
+
response,
|
|
182
|
+
keys=("id",),
|
|
183
|
+
missing_message="GLPI create response did not include a ticket ID",
|
|
184
|
+
)
|
|
185
|
+
logger.info(
|
|
186
|
+
"GLPI API created ticket %s fields=%s",
|
|
187
|
+
ticket_id,
|
|
188
|
+
sorted(payload_data),
|
|
189
|
+
)
|
|
190
|
+
return ticket_id
|
|
191
|
+
|
|
192
|
+
def update_ticket(
|
|
193
|
+
self,
|
|
194
|
+
ticket_id: GlpiId,
|
|
195
|
+
ticket: GlpiTicket,
|
|
196
|
+
*,
|
|
197
|
+
field_mask: tuple[str, ...] = (),
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Update one GLPI ticket with the provided field changes.
|
|
200
|
+
|
|
201
|
+
Optional field masks are forwarded to the model serializer so callers
|
|
202
|
+
can restrict the update payload to a specific subset of fields.
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
payload_data = ticket.to_api_payload(
|
|
206
|
+
entity_id=self.glpi_entity,
|
|
207
|
+
include_entity=False,
|
|
208
|
+
field_mask=field_mask,
|
|
209
|
+
)
|
|
210
|
+
response = self._update_request(
|
|
211
|
+
f"{TICKET_ENDPOINT}/{ticket_id}",
|
|
212
|
+
payload_data,
|
|
213
|
+
)
|
|
214
|
+
ensure_response_status(
|
|
215
|
+
response,
|
|
216
|
+
success_statuses=(200, 204),
|
|
217
|
+
failure_message=f"Failed to update ticket {ticket_id}",
|
|
218
|
+
)
|
|
219
|
+
logger.info(
|
|
220
|
+
"GLPI API updated ticket %s fields=%s",
|
|
221
|
+
ticket_id,
|
|
222
|
+
sorted(payload_data),
|
|
223
|
+
)
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
def delete_ticket(self, ticket_id: GlpiId) -> None:
|
|
227
|
+
"""Delete one GLPI ticket by identifier.
|
|
228
|
+
|
|
229
|
+
The method logs successful deletion and returns ``None`` to match the
|
|
230
|
+
package's mutation-helper conventions.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
response = self._delete_request(f"{TICKET_ENDPOINT}/{ticket_id}")
|
|
234
|
+
ensure_response_status(
|
|
235
|
+
response,
|
|
236
|
+
success_statuses=(200, 204),
|
|
237
|
+
failure_message=f"Failed to delete ticket {ticket_id}",
|
|
238
|
+
)
|
|
239
|
+
logger.info("GLPI API deleted ticket %s", ticket_id)
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
def _yield_ticket_payloads(
|
|
243
|
+
self,
|
|
244
|
+
*,
|
|
245
|
+
query: str | None = None,
|
|
246
|
+
fields: list[str] | None = None,
|
|
247
|
+
sort: str | None = None,
|
|
248
|
+
batch_size: int | None = None,
|
|
249
|
+
include_deleted_ticket: bool = False,
|
|
250
|
+
) -> Iterator[list[dict[str, Any]]]:
|
|
251
|
+
"""Yield paginated raw ticket payload batches from the GLPI API.
|
|
252
|
+
|
|
253
|
+
Pagination continues until the server indicates no more content or the
|
|
254
|
+
observed page-size heuristic shows that iteration is complete.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
params = build_ticket_search_params(
|
|
258
|
+
query=query,
|
|
259
|
+
fields=fields,
|
|
260
|
+
sort=sort,
|
|
261
|
+
batch_size=batch_size,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
observed_page_size = batch_size
|
|
265
|
+
while True:
|
|
266
|
+
current_start = cast(int, params["start"])
|
|
267
|
+
response = self._get_request(TICKET_ENDPOINT, params)
|
|
268
|
+
if response.status_code not in (200, 206):
|
|
269
|
+
logger.info(
|
|
270
|
+
"GLPI ticket search returned status %s (start=%d)",
|
|
271
|
+
response.status_code,
|
|
272
|
+
current_start,
|
|
273
|
+
)
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
batch = response.json()
|
|
277
|
+
if not isinstance(batch, list) or not batch:
|
|
278
|
+
logger.info(
|
|
279
|
+
"GLPI ticket search returned empty batch (start=%d)",
|
|
280
|
+
current_start,
|
|
281
|
+
)
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
logger.info(
|
|
285
|
+
"GLPI ticket search: batch of %d tickets (start=%d)",
|
|
286
|
+
len(batch),
|
|
287
|
+
current_start,
|
|
288
|
+
)
|
|
289
|
+
result_batch, deleted_count = filter_ticket_search_batch(
|
|
290
|
+
batch,
|
|
291
|
+
include_deleted_ticket=include_deleted_ticket,
|
|
292
|
+
)
|
|
293
|
+
if deleted_count:
|
|
294
|
+
logger.info(
|
|
295
|
+
"GLPI ticket search: excluded %d deleted tickets (start=%d)",
|
|
296
|
+
deleted_count,
|
|
297
|
+
current_start,
|
|
298
|
+
)
|
|
299
|
+
if result_batch:
|
|
300
|
+
yield result_batch
|
|
301
|
+
|
|
302
|
+
next_start, observed_page_size, should_continue = (
|
|
303
|
+
advance_ticket_search_pagination(
|
|
304
|
+
current_start=current_start,
|
|
305
|
+
page_size=len(batch),
|
|
306
|
+
content_range=response.headers.get("Content-Range", ""),
|
|
307
|
+
observed_page_size=observed_page_size,
|
|
308
|
+
)
|
|
309
|
+
)
|
|
310
|
+
params["start"] = next_start
|
|
311
|
+
if not should_continue:
|
|
312
|
+
return
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Synchronous timeline operations for GLPI v2 clients.
|
|
2
|
+
|
|
3
|
+
This module groups followup, task, solution, and attachment-lookup helpers for
|
|
4
|
+
ticket timeline data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from tenacity import RetryError
|
|
12
|
+
|
|
13
|
+
from glpi_python_client.clients.v2.common.constants import (
|
|
14
|
+
FOLLOWUP_SUFFIX,
|
|
15
|
+
SOLUTION_SUFFIX,
|
|
16
|
+
TASK_SUFFIX,
|
|
17
|
+
TICKET_ENDPOINT,
|
|
18
|
+
GlpiId,
|
|
19
|
+
)
|
|
20
|
+
from glpi_python_client.clients.v2.common.errors import remote_error_message
|
|
21
|
+
from glpi_python_client.clients.v2.common.request_http import (
|
|
22
|
+
ensure_response_status,
|
|
23
|
+
require_response_text,
|
|
24
|
+
)
|
|
25
|
+
from glpi_python_client.clients.v2.common.response_payloads import (
|
|
26
|
+
timeline_records_from_response,
|
|
27
|
+
)
|
|
28
|
+
from glpi_python_client.content.records.core.scalars import _optional_text
|
|
29
|
+
from glpi_python_client.content.records.parsers.timeline import (
|
|
30
|
+
_glpi_followup_record,
|
|
31
|
+
_glpi_solution_record,
|
|
32
|
+
_glpi_task_record,
|
|
33
|
+
)
|
|
34
|
+
from glpi_python_client.models import GlpiFollowup, GlpiSolution, GlpiTask
|
|
35
|
+
|
|
36
|
+
from .transport import SyncTransportMixin
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SyncTimelineMixin(SyncTransportMixin):
|
|
42
|
+
"""Synchronous followup, task, and solution helpers.
|
|
43
|
+
|
|
44
|
+
The mixin keeps ticket timeline behavior together because these endpoints
|
|
45
|
+
share parsing, logging, and optional legacy attachment lookup rules.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def get_followup_records(self, ticket_id: GlpiId) -> list[GlpiFollowup]:
|
|
49
|
+
"""Fetch the followups associated with one ticket.
|
|
50
|
+
|
|
51
|
+
Non-success responses are logged and normalized to an empty list so the
|
|
52
|
+
helper behaves like the other timeline list operations.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}"
|
|
56
|
+
response = self._get_request(endpoint)
|
|
57
|
+
if response.status_code not in (200, 206):
|
|
58
|
+
logger.warning(
|
|
59
|
+
"Failed to get followups for ticket %s: %s",
|
|
60
|
+
ticket_id,
|
|
61
|
+
response.status_code,
|
|
62
|
+
)
|
|
63
|
+
return []
|
|
64
|
+
return timeline_records_from_response(
|
|
65
|
+
response,
|
|
66
|
+
record_factory=_glpi_followup_record,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def get_task_records(self, ticket_id: GlpiId) -> list[GlpiTask]:
|
|
70
|
+
"""Fetch the tasks associated with one ticket.
|
|
71
|
+
|
|
72
|
+
Returned records are parsed into typed ``GlpiTask`` instances using the
|
|
73
|
+
shared timeline response handling helpers.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{TASK_SUFFIX}"
|
|
77
|
+
response = self._get_request(endpoint)
|
|
78
|
+
if response.status_code not in (200, 206):
|
|
79
|
+
logger.warning(
|
|
80
|
+
"Failed to get tasks for ticket %s: %s",
|
|
81
|
+
ticket_id,
|
|
82
|
+
response.status_code,
|
|
83
|
+
)
|
|
84
|
+
return []
|
|
85
|
+
return timeline_records_from_response(
|
|
86
|
+
response,
|
|
87
|
+
record_factory=_glpi_task_record,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def get_followup_attachment_document_ids(
|
|
91
|
+
self, followup_id: GlpiId
|
|
92
|
+
) -> tuple[str, ...]:
|
|
93
|
+
"""Fetch document IDs linked directly to one followup through v1.
|
|
94
|
+
|
|
95
|
+
Attachment lookup is best-effort because it depends on the optional
|
|
96
|
+
legacy v1 session; failures are logged and normalized to an empty tuple.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
v1_session = self._v1
|
|
100
|
+
if v1_session is None:
|
|
101
|
+
return ()
|
|
102
|
+
|
|
103
|
+
document_ids: list[str] = []
|
|
104
|
+
seen_document_ids: set[str] = set()
|
|
105
|
+
try:
|
|
106
|
+
relations = v1_session.get_sub_items(
|
|
107
|
+
"ITILFollowup",
|
|
108
|
+
followup_id,
|
|
109
|
+
"Document_Item",
|
|
110
|
+
)
|
|
111
|
+
except (RetryError, ValueError) as exc:
|
|
112
|
+
logger.warning(
|
|
113
|
+
"Skipping GLPI followup %s attachment lookup after v1 API failure: %s",
|
|
114
|
+
followup_id,
|
|
115
|
+
remote_error_message(exc),
|
|
116
|
+
)
|
|
117
|
+
return ()
|
|
118
|
+
for relation in relations:
|
|
119
|
+
document_id = _optional_text(relation.get("documents_id"))
|
|
120
|
+
if document_id is None or document_id in seen_document_ids:
|
|
121
|
+
continue
|
|
122
|
+
seen_document_ids.add(document_id)
|
|
123
|
+
document_ids.append(document_id)
|
|
124
|
+
return tuple(document_ids)
|
|
125
|
+
|
|
126
|
+
def get_solution_attachment_document_ids(
|
|
127
|
+
self, solution_id: GlpiId
|
|
128
|
+
) -> tuple[str, ...]:
|
|
129
|
+
"""Fetch document IDs linked directly to one solution through v1.
|
|
130
|
+
|
|
131
|
+
This mirrors followup attachment lookup and preserves first-seen order
|
|
132
|
+
while removing duplicate document identifiers.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
v1_session = self._v1
|
|
136
|
+
if v1_session is None:
|
|
137
|
+
return ()
|
|
138
|
+
|
|
139
|
+
document_ids: list[str] = []
|
|
140
|
+
seen_document_ids: set[str] = set()
|
|
141
|
+
try:
|
|
142
|
+
relations = v1_session.get_sub_items(
|
|
143
|
+
"ITILSolution",
|
|
144
|
+
solution_id,
|
|
145
|
+
"Document_Item",
|
|
146
|
+
)
|
|
147
|
+
except (RetryError, ValueError) as exc:
|
|
148
|
+
logger.warning(
|
|
149
|
+
"Skipping GLPI solution %s attachment lookup after v1 API failure: %s",
|
|
150
|
+
solution_id,
|
|
151
|
+
remote_error_message(exc),
|
|
152
|
+
)
|
|
153
|
+
return ()
|
|
154
|
+
for relation in relations:
|
|
155
|
+
document_id = _optional_text(relation.get("documents_id"))
|
|
156
|
+
if document_id is None or document_id in seen_document_ids:
|
|
157
|
+
continue
|
|
158
|
+
seen_document_ids.add(document_id)
|
|
159
|
+
document_ids.append(document_id)
|
|
160
|
+
return tuple(document_ids)
|
|
161
|
+
|
|
162
|
+
def get_solution_records(self, ticket_id: GlpiId) -> list[GlpiSolution]:
|
|
163
|
+
"""Fetch the solutions associated with one ticket.
|
|
164
|
+
|
|
165
|
+
Each returned item is parsed into a typed ``GlpiSolution`` record using
|
|
166
|
+
the shared timeline parsing helpers.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}"
|
|
170
|
+
response = self._get_request(endpoint)
|
|
171
|
+
if response.status_code not in (200, 206):
|
|
172
|
+
logger.warning(
|
|
173
|
+
"Failed to get solutions for ticket %s: %s",
|
|
174
|
+
ticket_id,
|
|
175
|
+
response.status_code,
|
|
176
|
+
)
|
|
177
|
+
return []
|
|
178
|
+
return timeline_records_from_response(
|
|
179
|
+
response,
|
|
180
|
+
record_factory=_glpi_solution_record,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def create_followup(
|
|
184
|
+
self,
|
|
185
|
+
ticket_id: GlpiId,
|
|
186
|
+
followup: GlpiFollowup,
|
|
187
|
+
) -> str:
|
|
188
|
+
"""Create a GLPI followup and return the identifier assigned by GLPI.
|
|
189
|
+
|
|
190
|
+
The response may expose the created ID under different keys, so the
|
|
191
|
+
helper normalizes that lookup before returning to callers.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}"
|
|
195
|
+
payload_data = followup.to_api_payload()
|
|
196
|
+
response = self._post_request(endpoint, payload_data)
|
|
197
|
+
ensure_response_status(
|
|
198
|
+
response,
|
|
199
|
+
success_statuses=(200, 201),
|
|
200
|
+
failure_message=f"Failed to post followup on ticket {ticket_id}",
|
|
201
|
+
)
|
|
202
|
+
followup_id = require_response_text(
|
|
203
|
+
response,
|
|
204
|
+
keys=("id", "followup_id"),
|
|
205
|
+
missing_message="GLPI followup create response did not include an ID",
|
|
206
|
+
)
|
|
207
|
+
logger.info(
|
|
208
|
+
"GLPI API created %s followup %s on ticket %s",
|
|
209
|
+
"private" if payload_data.get("is_private") else "public",
|
|
210
|
+
followup_id,
|
|
211
|
+
ticket_id,
|
|
212
|
+
)
|
|
213
|
+
return followup_id
|
|
214
|
+
|
|
215
|
+
def update_followup(
|
|
216
|
+
self,
|
|
217
|
+
ticket_id: GlpiId,
|
|
218
|
+
followup_id: GlpiId,
|
|
219
|
+
followup: GlpiFollowup,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Update one GLPI followup on a ticket.
|
|
222
|
+
|
|
223
|
+
The followup model is serialized as-is and the method returns ``None``
|
|
224
|
+
once GLPI accepts the update.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}/{followup_id}"
|
|
228
|
+
payload_data = followup.to_api_payload()
|
|
229
|
+
response = self._update_request(endpoint, payload_data)
|
|
230
|
+
ensure_response_status(
|
|
231
|
+
response,
|
|
232
|
+
success_statuses=(200, 204),
|
|
233
|
+
failure_message=(
|
|
234
|
+
f"Failed to patch followup {followup_id} on ticket {ticket_id}"
|
|
235
|
+
),
|
|
236
|
+
)
|
|
237
|
+
logger.info(
|
|
238
|
+
"GLPI API updated followup %s on ticket %s fields=%s",
|
|
239
|
+
followup_id,
|
|
240
|
+
ticket_id,
|
|
241
|
+
sorted(payload_data),
|
|
242
|
+
)
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
def delete_followup(self, ticket_id: GlpiId, followup_id: GlpiId) -> None:
|
|
246
|
+
"""Delete one GLPI ticket followup by identifier.
|
|
247
|
+
|
|
248
|
+
Successful deletes are logged with both ticket and followup context to
|
|
249
|
+
help trace timeline mutations.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{FOLLOWUP_SUFFIX}/{followup_id}"
|
|
253
|
+
response = self._delete_request(endpoint)
|
|
254
|
+
ensure_response_status(
|
|
255
|
+
response,
|
|
256
|
+
success_statuses=(200, 204),
|
|
257
|
+
failure_message=(
|
|
258
|
+
f"Failed to delete followup {followup_id} on ticket {ticket_id}"
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
logger.info("GLPI API deleted followup %s on ticket %s", followup_id, ticket_id)
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
def create_solution(
|
|
265
|
+
self,
|
|
266
|
+
ticket_id: GlpiId,
|
|
267
|
+
solution: GlpiSolution,
|
|
268
|
+
) -> str:
|
|
269
|
+
"""Create a GLPI solution and return the identifier assigned by GLPI.
|
|
270
|
+
|
|
271
|
+
As with followups, the created solution identifier is normalized from
|
|
272
|
+
the response payload before being returned.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}"
|
|
276
|
+
payload_data = solution.to_api_payload()
|
|
277
|
+
response = self._post_request(endpoint, payload_data)
|
|
278
|
+
ensure_response_status(
|
|
279
|
+
response,
|
|
280
|
+
success_statuses=(200, 201),
|
|
281
|
+
failure_message=f"Failed to post solution on ticket {ticket_id}",
|
|
282
|
+
)
|
|
283
|
+
solution_id = require_response_text(
|
|
284
|
+
response,
|
|
285
|
+
keys=("id", "solution_id"),
|
|
286
|
+
missing_message="GLPI solution create response did not include an ID",
|
|
287
|
+
)
|
|
288
|
+
logger.info("GLPI API created solution %s on ticket %s", solution_id, ticket_id)
|
|
289
|
+
return solution_id
|
|
290
|
+
|
|
291
|
+
def delete_solution(self, ticket_id: GlpiId, solution_id: GlpiId) -> None:
|
|
292
|
+
"""Delete one GLPI ticket solution by identifier.
|
|
293
|
+
|
|
294
|
+
The helper returns ``None`` after a successful delete response and logs
|
|
295
|
+
the mutation for troubleshooting and auditability.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{SOLUTION_SUFFIX}/{solution_id}"
|
|
299
|
+
response = self._delete_request(endpoint)
|
|
300
|
+
ensure_response_status(
|
|
301
|
+
response,
|
|
302
|
+
success_statuses=(200, 204),
|
|
303
|
+
failure_message=(
|
|
304
|
+
f"Failed to delete solution {solution_id} on ticket {ticket_id}"
|
|
305
|
+
),
|
|
306
|
+
)
|
|
307
|
+
logger.info("GLPI API deleted solution %s on ticket %s", solution_id, ticket_id)
|
|
308
|
+
return None
|