glpi-python-client 0.4.1__py3-none-any.whl → 0.4.3__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 (42) hide show
  1. glpi_python_client/__init__.py +9 -1
  2. glpi_python_client/_async/clients/_base_client.py +25 -1
  3. glpi_python_client/_async/clients/api/administration/_user.py +76 -0
  4. glpi_python_client/_async/clients/api/assistance/_team.py +2 -3
  5. glpi_python_client/_async/clients/api/assistance/_ticket.py +8 -2
  6. glpi_python_client/_async/clients/api/dropdowns/_location.py +44 -0
  7. glpi_python_client/_async/clients/api/knowledgebase/_article.py +59 -2
  8. glpi_python_client/_async/clients/api/knowledgebase/_category.py +60 -1
  9. glpi_python_client/_async/clients/api/management/_document.py +92 -0
  10. glpi_python_client/_async/clients/api/plugins/_fields.py +19 -3
  11. glpi_python_client/_async/clients/commons/_config.py +61 -0
  12. glpi_python_client/_async/clients/commons/_filters.py +24 -0
  13. glpi_python_client/_async/clients/commons/_http.py +64 -5
  14. glpi_python_client/_async/clients/commons/_payloads.py +60 -7
  15. glpi_python_client/_async/clients/commons/_transport.py +99 -15
  16. glpi_python_client/_async/clients/custom/_statistics.py +32 -22
  17. glpi_python_client/_sync/clients/_base_client.py +25 -1
  18. glpi_python_client/_sync/clients/api/administration/_user.py +76 -0
  19. glpi_python_client/_sync/clients/api/assistance/_team.py +2 -3
  20. glpi_python_client/_sync/clients/api/assistance/_ticket.py +8 -2
  21. glpi_python_client/_sync/clients/api/dropdowns/_location.py +44 -0
  22. glpi_python_client/_sync/clients/api/knowledgebase/_article.py +59 -2
  23. glpi_python_client/_sync/clients/api/knowledgebase/_category.py +60 -1
  24. glpi_python_client/_sync/clients/api/management/_document.py +92 -0
  25. glpi_python_client/_sync/clients/api/plugins/_fields.py +19 -3
  26. glpi_python_client/_sync/clients/commons/_config.py +61 -0
  27. glpi_python_client/_sync/clients/commons/_filters.py +24 -0
  28. glpi_python_client/_sync/clients/commons/_http.py +64 -5
  29. glpi_python_client/_sync/clients/commons/_payloads.py +60 -7
  30. glpi_python_client/_sync/clients/commons/_transport.py +99 -15
  31. glpi_python_client/_sync/clients/custom/_statistics.py +32 -22
  32. glpi_python_client/content/conversion.py +74 -2
  33. glpi_python_client/models/_base.py +116 -1
  34. glpi_python_client/models/api_schema/_content.py +13 -4
  35. glpi_python_client/models/api_schema/assistance/_ticket.py +42 -6
  36. glpi_python_client/models/custom_schema/_ticket_context.py +18 -3
  37. glpi_python_client/rsql.py +188 -0
  38. glpi_python_client/testing/utils.py +1 -0
  39. {glpi_python_client-0.4.1.dist-info → glpi_python_client-0.4.3.dist-info}/METADATA +6 -2
  40. {glpi_python_client-0.4.1.dist-info → glpi_python_client-0.4.3.dist-info}/RECORD +42 -41
  41. {glpi_python_client-0.4.1.dist-info → glpi_python_client-0.4.3.dist-info}/WHEEL +1 -1
  42. {glpi_python_client-0.4.1.dist-info → glpi_python_client-0.4.3.dist-info}/licenses/LICENSE +0 -0
@@ -103,8 +103,13 @@ from glpi_python_client.models import (
103
103
  PostUser,
104
104
  TicketMarkdownOptions,
105
105
  )
106
+ from glpi_python_client.rsql import (
107
+ changed_since,
108
+ created_between,
109
+ date_window,
110
+ )
106
111
 
107
- __version__ = "0.4.1"
112
+ __version__ = "0.4.3"
108
113
 
109
114
  __all__ = [
110
115
  "AsyncGlpiClient",
@@ -190,4 +195,7 @@ __all__ = [
190
195
  "PostUser",
191
196
  "TicketMarkdownOptions",
192
197
  "__version__",
198
+ "changed_since",
199
+ "created_between",
200
+ "date_window",
193
201
  ]
@@ -25,10 +25,12 @@ from glpi_python_client._async._concurrency import Lock
25
25
  from glpi_python_client._async.clients.commons._config import (
26
26
  build_client_env_config,
27
27
  build_client_resources,
28
+ resolve_server_timezone,
28
29
  )
29
30
 
30
31
  if TYPE_CHECKING:
31
32
  from collections.abc import Mapping
33
+ from datetime import tzinfo
32
34
 
33
35
  logger = logging.getLogger(__name__)
34
36
 
@@ -45,6 +47,7 @@ class _BaseGlpiClient:
45
47
  self,
46
48
  *,
47
49
  glpi_api_url: str,
50
+ server_timezone: str | tzinfo,
48
51
  client_id: str | None = None,
49
52
  client_secret: str | None = None,
50
53
  username: str | None = None,
@@ -66,6 +69,25 @@ class _BaseGlpiClient:
66
69
  glpi_api_url : str
67
70
  Base URL of the GLPI v2 REST API, e.g.
68
71
  ``https://glpi.example.com/api.php/v2``.
72
+ server_timezone : str | tzinfo
73
+ IANA name of the timezone the GLPI server runs in (e.g.
74
+ ``"Europe/Paris"``), or a ``tzinfo``. **Required**: GLPI does
75
+ not advertise it, and it governs both directions of every
76
+ timestamp the client exchanges.
77
+
78
+ Reading, it interprets the timestamps the server sends without
79
+ an offset. Writing, it is what makes an aware ``datetime``
80
+ arrive as the moment it names: GLPI reads the naive prefix of a
81
+ timestamp and discards the offset, so the value has to be
82
+ converted onto the server's clock before it is sent. Measured on
83
+ a live instance, offsets from ``-08:00`` to ``+14:00`` written
84
+ to one field all stored the same wall clock.
85
+
86
+ There is no default because every candidate is wrong somewhere
87
+ -- guessing UTC against a Europe/Paris instance shifts those
88
+ timestamps by an hour or two and never raises. Prefer a name
89
+ over a fixed offset: a name follows DST, and one instance emits
90
+ both ``+01:00`` and ``+02:00``.
69
91
  client_id : str | None, optional
70
92
  OAuth client identifier used to obtain access tokens.
71
93
  client_secret : str | None, optional
@@ -103,6 +125,7 @@ class _BaseGlpiClient:
103
125
  missing OAuth credentials together with no v1 fallback).
104
126
  """
105
127
 
128
+ self.server_timezone = resolve_server_timezone(server_timezone)
106
129
  resources = build_client_resources(
107
130
  glpi_api_url=glpi_api_url,
108
131
  client_name=type(self).__name__,
@@ -142,7 +165,8 @@ class _BaseGlpiClient:
142
165
  ``GLPI_USERNAME``, ``GLPI_PASSWORD``, ``GLPI_VERIFY_SSL``,
143
166
  ``GLPI_V1_BASE_URL``, ``GLPI_V1_USER_TOKEN``, ``GLPI_V1_APP_TOKEN``,
144
167
  ``GLPI_ENTITY``, ``GLPI_PROFILE``, ``GLPI_ENTITY_RECURSIVE``,
145
- ``GLPI_LANGUAGE``, ``GLPI_AUTH_TOKEN_REFRESH``).
168
+ ``GLPI_LANGUAGE``, ``GLPI_AUTH_TOKEN_REFRESH``,
169
+ ``GLPI_SERVER_TIMEZONE``).
146
170
 
147
171
  Parameters
148
172
  ----------
@@ -12,6 +12,7 @@ from collections.abc import AsyncIterator
12
12
 
13
13
  from glpi_python_client._async.clients.commons._constants import USER_ENDPOINT, GlpiId
14
14
  from glpi_python_client._async.clients.commons._transport import TransportMixin
15
+ from glpi_python_client._errors import GlpiValidationError
15
16
  from glpi_python_client.models.api_schema.administration._user import (
16
17
  DeleteUser,
17
18
  GetUser,
@@ -110,6 +111,81 @@ class UserMixin(TransportMixin):
110
111
  break
111
112
  start += batch_size
112
113
 
114
+ async def find_user_by_email(
115
+ self,
116
+ email: str,
117
+ *,
118
+ rsql_filter: str = "",
119
+ batch_size: int = 100,
120
+ skip_entity: bool = True,
121
+ ) -> GetUser | None:
122
+ """Return the first user holding ``email``, or ``None``.
123
+
124
+ **This scans.** GLPI exposes e-mail addresses as ``User.emails``, a
125
+ nested *array*, and the v2 filter engine cannot join a nested array
126
+ -- the structurally identical ``Ticket.team`` answers HTTP 500 for
127
+ its declared subfields and is silently ignored for every other
128
+ spelling. So there is no server-side e-mail filter to use, and the
129
+ addresses have to be compared client-side.
130
+
131
+ Nothing about that is cheap: the scan costs one request per
132
+ ``batch_size`` users until it matches, so it is meant for
133
+ occasional resolution, not for a per-request lookup. Narrow it with
134
+ ``rsql_filter`` when you can (``"is_active==true"`` is the usual
135
+ one), and cache the resulting id rather than calling this again.
136
+
137
+ A server-side fast path is deliberately *not* attempted. GLPI v2
138
+ ignores a filter field it does not recognise and answers with the
139
+ whole unfiltered table, so a guessed e-mail filter would not fail
140
+ -- it would return a plausible non-empty page whose first row is
141
+ the wrong person. Guessing is the one thing this helper exists to
142
+ stop each caller doing separately.
143
+
144
+ Parameters
145
+ ----------
146
+ email : str
147
+ Address to look for. Compared case-insensitively after
148
+ trimming surrounding whitespace, the way mail systems treat it.
149
+ rsql_filter : str, optional
150
+ Raw RSQL filter narrowing the population scanned. Empty by
151
+ default, which scans every visible user.
152
+ batch_size : int, optional
153
+ Users fetched per request while scanning (defaults to 100).
154
+ skip_entity : bool, optional
155
+ When ``True`` (the default) the ``GLPI-Entity`` header is
156
+ omitted so the scan spans every entity the caller can see. A
157
+ user whose account lives outside the client's configured entity
158
+ is invisible otherwise, and the helper would answer ``None``
159
+ for somebody who exists.
160
+
161
+ Returns
162
+ -------
163
+ GetUser | None
164
+ The first user with a matching address, or ``None`` when the
165
+ scanned population holds none.
166
+
167
+ Raises
168
+ ------
169
+ GlpiValidationError
170
+ If ``email`` is blank -- which would otherwise scan the whole
171
+ directory and match nothing.
172
+ """
173
+
174
+ needle = email.strip().casefold()
175
+ if not needle:
176
+ raise GlpiValidationError("find_user_by_email requires a non-empty address")
177
+
178
+ async for batch in self.iter_search_users(
179
+ rsql_filter,
180
+ batch_size=batch_size,
181
+ skip_entity=skip_entity,
182
+ ):
183
+ for user in batch:
184
+ for entry in user.emails or ():
185
+ if entry.email and entry.email.strip().casefold() == needle:
186
+ return user
187
+ return None
188
+
113
189
  async def get_user(self, user_id: GlpiId) -> GetUser:
114
190
  """Fetch one GLPI user by identifier.
115
191
 
@@ -15,7 +15,6 @@ from glpi_python_client._async.clients.commons._constants import (
15
15
  GlpiId,
16
16
  )
17
17
  from glpi_python_client._async.clients.commons._http import ensure_response_status
18
- from glpi_python_client._async.clients.commons._payloads import model_to_payload
19
18
  from glpi_python_client._async.clients.commons._transport import TransportMixin
20
19
  from glpi_python_client.models.api_schema.assistance._team import (
21
20
  GetTeamMember,
@@ -79,7 +78,7 @@ class TeamMemberMixin(TransportMixin):
79
78
  """
80
79
 
81
80
  endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}"
82
- response = await self._post_request(endpoint, model_to_payload(member))
81
+ response = await self._post_request(endpoint, self._body(member))
83
82
  ensure_response_status(
84
83
  response,
85
84
  success_statuses=(200, 201),
@@ -114,7 +113,7 @@ class TeamMemberMixin(TransportMixin):
114
113
  f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}",
115
114
  failure_message=f"Failed to remove team member on ticket {ticket_id}",
116
115
  log_message=f"GLPI API removed team member on ticket {ticket_id}",
117
- body=model_to_payload(member),
116
+ body=self._body(member),
118
117
  )
119
118
 
120
119
 
@@ -49,7 +49,11 @@ class TicketMixin(TransportMixin):
49
49
  start : int, optional
50
50
  Zero-based offset of the first record returned.
51
51
  sort : str | None, optional
52
- ``sort`` query parameter forwarded as-is, e.g. ``"date_mod desc"``.
52
+ ``sort`` query parameter, spelled ``field`` or ``field:direction``
53
+ (e.g. ``"date_mod:desc"``). Measured against GLPI 11: a space
54
+ before the direction answers **HTTP 400** ("Invalid property for
55
+ sorting"), a bare ``field`` sorts *ascending*, and a separate
56
+ ``order`` parameter is ignored.
53
57
  fields : tuple[str, ...], optional
54
58
  Restricted set of contract field names to request. Empty
55
59
  tuple lets the GLPI server pick its default field set.
@@ -94,7 +98,9 @@ class TicketMixin(TransportMixin):
94
98
  the ``limit`` parameter on each underlying
95
99
  :meth:`search_tickets` call.
96
100
  sort : str | None, optional
97
- ``sort`` query parameter forwarded as-is to each page request.
101
+ ``sort`` query parameter forwarded to each page request,
102
+ spelled ``field`` or ``field:direction`` (e.g. ``"date_mod:desc"``).
103
+ A space before the direction answers HTTP 400.
98
104
  fields : tuple[str, ...], optional
99
105
  Restricted set of contract field names to request.
100
106
 
@@ -7,6 +7,8 @@ models.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from collections.abc import AsyncIterator
11
+
10
12
  from glpi_python_client._async.clients.commons._constants import (
11
13
  LOCATION_ENDPOINT,
12
14
  GlpiId,
@@ -52,6 +54,48 @@ class LocationMixin(TransportMixin):
52
54
  params["filter"] = rsql_filter
53
55
  return await self._resource_list(LOCATION_ENDPOINT, GetLocation, params=params)
54
56
 
57
+ async def iter_search_locations(
58
+ self,
59
+ rsql_filter: str = "",
60
+ *,
61
+ batch_size: int = 50,
62
+ ) -> AsyncIterator[list[GetLocation]]:
63
+ """Yield successive pages of GLPI locations until exhausted.
64
+
65
+ The generator drives pagination automatically by advancing the
66
+ ``start`` offset after each batch. Iteration stops when the server
67
+ returns fewer items than ``batch_size``, which signals the last page.
68
+
69
+ Parameters
70
+ ----------
71
+ rsql_filter : str, optional
72
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
73
+ Empty by default, which lists every visible record.
74
+ batch_size : int, optional
75
+ Number of records requested per page (default 50). Acts as the
76
+ ``limit`` parameter on each underlying :meth:`search_locations`
77
+ call.
78
+
79
+ Yields
80
+ ------
81
+ list[GetLocation]
82
+ One page per iteration. The last yielded batch may be shorter
83
+ than ``batch_size``.
84
+ """
85
+
86
+ start = 0
87
+ while True:
88
+ batch = await self.search_locations(
89
+ rsql_filter,
90
+ limit=batch_size,
91
+ start=start,
92
+ )
93
+ if batch:
94
+ yield batch
95
+ if len(batch) < batch_size:
96
+ break
97
+ start += batch_size
98
+
55
99
  async def get_location(self, location_id: GlpiId) -> GetLocation:
56
100
  """Fetch one GLPI location by identifier.
57
101
 
@@ -8,7 +8,7 @@ Markdown through GLPI's HTML wire format transparently.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- from collections.abc import Sequence
11
+ from collections.abc import AsyncIterator, Sequence
12
12
 
13
13
  from glpi_python_client._async.clients.commons._constants import (
14
14
  KB_ARTICLE_ENDPOINT,
@@ -50,7 +50,11 @@ class KBArticleMixin(TransportMixin):
50
50
  start : int, optional
51
51
  Zero-based offset of the first record returned.
52
52
  sort : str | None, optional
53
- ``sort`` query parameter forwarded as-is.
53
+ ``sort`` query parameter, spelled ``field`` or ``field:direction``
54
+ (e.g. ``"date_mod:desc"``). Measured against GLPI 11: a space
55
+ before the direction answers **HTTP 400** ("Invalid property for
56
+ sorting"), a bare ``field`` sorts *ascending*, and a separate
57
+ ``order`` parameter is ignored.
54
58
  language : str | None, optional
55
59
  GLPI language code forwarded as the ``language`` query
56
60
  parameter to select a translated view.
@@ -72,6 +76,59 @@ class KBArticleMixin(TransportMixin):
72
76
  KB_ARTICLE_ENDPOINT, GetKBArticle, params=params
73
77
  )
74
78
 
79
+ async def iter_search_kb_articles(
80
+ self,
81
+ rsql_filter: str = "",
82
+ *,
83
+ batch_size: int = 50,
84
+ sort: str | None = None,
85
+ language: str | None = None,
86
+ ) -> AsyncIterator[list[GetKBArticle]]:
87
+ """Yield successive pages of GLPI knowledge base articles until exhausted.
88
+
89
+ The generator drives pagination automatically by advancing the
90
+ ``start`` offset after each batch. Iteration stops when the server
91
+ returns fewer items than ``batch_size``, which signals the last page.
92
+
93
+ Parameters
94
+ ----------
95
+ rsql_filter : str, optional
96
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
97
+ Empty by default, which lists every visible record.
98
+ batch_size : int, optional
99
+ Number of records requested per page (default 50). Acts as the
100
+ ``limit`` parameter on each underlying :meth:`search_kb_articles`
101
+ call.
102
+ sort : str | None, optional
103
+ ``sort`` query parameter forwarded to each page request,
104
+ spelled ``field`` or ``field:direction`` (e.g. ``"date_mod:desc"``).
105
+ A space before the direction answers HTTP 400.
106
+ language : str | None, optional
107
+ GLPI language code forwarded to each page request to select
108
+ a translated view.
109
+
110
+ Yields
111
+ ------
112
+ list[GetKBArticle]
113
+ One page per iteration. The last yielded batch may be shorter
114
+ than ``batch_size``.
115
+ """
116
+
117
+ start = 0
118
+ while True:
119
+ batch = await self.search_kb_articles(
120
+ rsql_filter,
121
+ limit=batch_size,
122
+ start=start,
123
+ sort=sort,
124
+ language=language,
125
+ )
126
+ if batch:
127
+ yield batch
128
+ if len(batch) < batch_size:
129
+ break
130
+ start += batch_size
131
+
75
132
  async def get_kb_article(self, article_id: GlpiId) -> GetKBArticle:
76
133
  """Fetch one knowledge base article by identifier.
77
134
 
@@ -7,6 +7,8 @@ GLPI knowledge base category resource using the contract-aligned
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from collections.abc import AsyncIterator
11
+
10
12
  from glpi_python_client._async.clients.commons._constants import (
11
13
  KB_CATEGORY_ENDPOINT,
12
14
  GlpiId,
@@ -43,7 +45,11 @@ class KBCategoryMixin(TransportMixin):
43
45
  start : int, optional
44
46
  Zero-based offset of the first record returned.
45
47
  sort : str | None, optional
46
- ``sort`` query parameter forwarded as-is, e.g. ``"name asc"``.
48
+ ``sort`` query parameter, spelled ``field`` or ``field:direction``
49
+ (e.g. ``"name:asc"``). Measured against GLPI 11: a space
50
+ before the direction answers **HTTP 400** ("Invalid property for
51
+ sorting"), a bare ``field`` sorts *ascending*, and a separate
52
+ ``order`` parameter is ignored.
47
53
  language : str | None, optional
48
54
  GLPI language code forwarded as the ``language`` query
49
55
  parameter to select a translated view.
@@ -65,6 +71,59 @@ class KBCategoryMixin(TransportMixin):
65
71
  KB_CATEGORY_ENDPOINT, GetKBCategory, params=params
66
72
  )
67
73
 
74
+ async def iter_search_kb_categories(
75
+ self,
76
+ rsql_filter: str = "",
77
+ *,
78
+ batch_size: int = 50,
79
+ sort: str | None = None,
80
+ language: str | None = None,
81
+ ) -> AsyncIterator[list[GetKBCategory]]:
82
+ """Yield successive pages of GLPI knowledge base categories until exhausted.
83
+
84
+ The generator drives pagination automatically by advancing the
85
+ ``start`` offset after each batch. Iteration stops when the server
86
+ returns fewer items than ``batch_size``, which signals the last page.
87
+
88
+ Parameters
89
+ ----------
90
+ rsql_filter : str, optional
91
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
92
+ Empty by default, which lists every visible record.
93
+ batch_size : int, optional
94
+ Number of records requested per page (default 50). Acts as the
95
+ ``limit`` parameter on each underlying :meth:`search_kb_categories`
96
+ call.
97
+ sort : str | None, optional
98
+ ``sort`` query parameter forwarded to each page request,
99
+ spelled ``field`` or ``field:direction`` (e.g. ``"date_mod:desc"``).
100
+ A space before the direction answers HTTP 400.
101
+ language : str | None, optional
102
+ GLPI language code forwarded to each page request to select
103
+ a translated view.
104
+
105
+ Yields
106
+ ------
107
+ list[GetKBCategory]
108
+ One page per iteration. The last yielded batch may be shorter
109
+ than ``batch_size``.
110
+ """
111
+
112
+ start = 0
113
+ while True:
114
+ batch = await self.search_kb_categories(
115
+ rsql_filter,
116
+ limit=batch_size,
117
+ start=start,
118
+ sort=sort,
119
+ language=language,
120
+ )
121
+ if batch:
122
+ yield batch
123
+ if len(batch) < batch_size:
124
+ break
125
+ start += batch_size
126
+
68
127
  async def get_kb_category(self, category_id: GlpiId) -> GetKBCategory:
69
128
  """Fetch one knowledge base category by identifier.
70
129
 
@@ -8,6 +8,7 @@ the v2 API does not advertise a binary upload endpoint in the contract.
8
8
  from __future__ import annotations
9
9
 
10
10
  import logging
11
+ from collections.abc import AsyncIterator
11
12
 
12
13
  from glpi_python_client._async.clients.commons._constants import (
13
14
  DOCUMENT_ENDPOINT,
@@ -63,6 +64,48 @@ class DocumentMixin(TransportMixin):
63
64
  DOCUMENT_ENDPOINT, GetDocument, params=params, skip_entity=True
64
65
  )
65
66
 
67
+ async def iter_search_documents(
68
+ self,
69
+ rsql_filter: str = "",
70
+ *,
71
+ batch_size: int = 50,
72
+ ) -> AsyncIterator[list[GetDocument]]:
73
+ """Yield successive pages of GLPI documents until exhausted.
74
+
75
+ The generator drives pagination automatically by advancing the
76
+ ``start`` offset after each batch. Iteration stops when the server
77
+ returns fewer items than ``batch_size``, which signals the last page.
78
+
79
+ Parameters
80
+ ----------
81
+ rsql_filter : str, optional
82
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
83
+ Empty by default, which lists every visible record.
84
+ batch_size : int, optional
85
+ Number of records requested per page (default 50). Acts as the
86
+ ``limit`` parameter on each underlying :meth:`search_documents`
87
+ call.
88
+
89
+ Yields
90
+ ------
91
+ list[GetDocument]
92
+ One page per iteration. The last yielded batch may be shorter
93
+ than ``batch_size``.
94
+ """
95
+
96
+ start = 0
97
+ while True:
98
+ batch = await self.search_documents(
99
+ rsql_filter,
100
+ limit=batch_size,
101
+ start=start,
102
+ )
103
+ if batch:
104
+ yield batch
105
+ if len(batch) < batch_size:
106
+ break
107
+ start += batch_size
108
+
66
109
  async def get_document(self, document_id: GlpiId) -> GetDocument:
67
110
  """Fetch one GLPI document by identifier.
68
111
 
@@ -214,6 +257,55 @@ class DocumentMixin(TransportMixin):
214
257
  )
215
258
  return response.content
216
259
 
260
+ async def stream_document_content(
261
+ self,
262
+ document_id: GlpiId,
263
+ *,
264
+ chunk_size: int = 65536,
265
+ ) -> AsyncIterator[bytes]:
266
+ """Stream the binary payload of one GLPI document in chunks.
267
+
268
+ Use this instead of :meth:`download_document_content` when the file
269
+ may be large: that method holds the whole body in memory before
270
+ returning, so a 500 MB attachment costs 500 MB of process memory
271
+ even if the caller only writes it straight to disk.
272
+
273
+ Parameters
274
+ ----------
275
+ document_id : GlpiId
276
+ Numeric identifier of the document whose binary content is
277
+ requested.
278
+ chunk_size : int, optional
279
+ Bytes requested per chunk (defaults to 64 KiB).
280
+
281
+ Yields
282
+ ------
283
+ bytes
284
+ Successive chunks of the document body. The final chunk may be
285
+ shorter than ``chunk_size``.
286
+
287
+ Raises
288
+ ------
289
+ GlpiStatusError
290
+ If the GLPI server returns a non-success HTTP status.
291
+
292
+ Examples
293
+ --------
294
+ Writing a document to disk without buffering it::
295
+
296
+ with open("attachment.pdf", "wb") as handle:
297
+ async for chunk in client.stream_document_content(42):
298
+ handle.write(chunk)
299
+ """
300
+
301
+ async for chunk in self._stream_request(
302
+ f"{DOCUMENT_ENDPOINT}/{document_id}/Download",
303
+ chunk_size=chunk_size,
304
+ skip_entity=True,
305
+ failure_message=f"Failed to download document {document_id}",
306
+ ):
307
+ yield chunk
308
+
217
309
  async def upload_document(
218
310
  self,
219
311
  *,
@@ -33,6 +33,7 @@ from __future__ import annotations
33
33
  import json
34
34
  from typing import Any
35
35
 
36
+ from glpi_python_client._async.clients.commons._payloads import model_from_payload
36
37
  from glpi_python_client._async.clients.commons._transport import TransportMixin
37
38
  from glpi_python_client._errors import GlpiProtocolError, GlpiValidationError
38
39
  from glpi_python_client.models.api_schema.plugins import (
@@ -147,7 +148,12 @@ class PluginFieldsMixin(TransportMixin):
147
148
  failure_message="Failed to list PluginFieldsContainer",
148
149
  )
149
150
  rows = payload if isinstance(payload, list) else []
150
- containers = [GetPluginFieldsContainer.model_validate(row) for row in rows]
151
+ containers = [
152
+ model_from_payload(
153
+ GetPluginFieldsContainer, row, server_timezone=self.server_timezone
154
+ )
155
+ for row in rows
156
+ ]
151
157
  if itemtype is None:
152
158
  return containers
153
159
  return [c for c in containers if _container_targets_itemtype(c, itemtype)]
@@ -179,7 +185,12 @@ class PluginFieldsMixin(TransportMixin):
179
185
  failure_message="Failed to list PluginFieldsField",
180
186
  )
181
187
  rows = payload if isinstance(payload, list) else []
182
- fields = [GetPluginFieldsField.model_validate(row) for row in rows]
188
+ fields = [
189
+ model_from_payload(
190
+ GetPluginFieldsField, row, server_timezone=self.server_timezone
191
+ )
192
+ for row in rows
193
+ ]
183
194
  if container_id is None:
184
195
  return fields
185
196
  return [f for f in fields if f.plugin_fields_containers_id == container_id]
@@ -219,7 +230,12 @@ class PluginFieldsMixin(TransportMixin):
219
230
  failure_message=f"Failed to list {endpoint}",
220
231
  )
221
232
  rows = payload if isinstance(payload, list) else []
222
- return [GetPluginFieldsValueRow.model_validate(row) for row in rows]
233
+ return [
234
+ model_from_payload(
235
+ GetPluginFieldsValueRow, row, server_timezone=self.server_timezone
236
+ )
237
+ for row in rows
238
+ ]
223
239
 
224
240
  async def create_item_plugin_field_row(
225
241
  self,