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