glpi-python-client 0.4.2__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.
- glpi_python_client/__init__.py +9 -1
- glpi_python_client/_async/clients/_base_client.py +25 -1
- glpi_python_client/_async/clients/api/administration/_user.py +76 -0
- glpi_python_client/_async/clients/api/assistance/_team.py +2 -3
- glpi_python_client/_async/clients/api/assistance/_ticket.py +8 -2
- glpi_python_client/_async/clients/api/dropdowns/_location.py +44 -0
- glpi_python_client/_async/clients/api/knowledgebase/_article.py +59 -2
- glpi_python_client/_async/clients/api/knowledgebase/_category.py +60 -1
- glpi_python_client/_async/clients/api/management/_document.py +92 -0
- glpi_python_client/_async/clients/api/plugins/_fields.py +19 -3
- glpi_python_client/_async/clients/commons/_config.py +61 -0
- glpi_python_client/_async/clients/commons/_filters.py +24 -0
- glpi_python_client/_async/clients/commons/_http.py +64 -5
- glpi_python_client/_async/clients/commons/_payloads.py +60 -7
- glpi_python_client/_async/clients/commons/_transport.py +99 -15
- glpi_python_client/_async/clients/custom/_statistics.py +32 -22
- glpi_python_client/_sync/clients/_base_client.py +25 -1
- glpi_python_client/_sync/clients/api/administration/_user.py +76 -0
- glpi_python_client/_sync/clients/api/assistance/_team.py +2 -3
- glpi_python_client/_sync/clients/api/assistance/_ticket.py +8 -2
- glpi_python_client/_sync/clients/api/dropdowns/_location.py +44 -0
- glpi_python_client/_sync/clients/api/knowledgebase/_article.py +59 -2
- glpi_python_client/_sync/clients/api/knowledgebase/_category.py +60 -1
- glpi_python_client/_sync/clients/api/management/_document.py +92 -0
- glpi_python_client/_sync/clients/api/plugins/_fields.py +19 -3
- glpi_python_client/_sync/clients/commons/_config.py +61 -0
- glpi_python_client/_sync/clients/commons/_filters.py +24 -0
- glpi_python_client/_sync/clients/commons/_http.py +64 -5
- glpi_python_client/_sync/clients/commons/_payloads.py +60 -7
- glpi_python_client/_sync/clients/commons/_transport.py +99 -15
- glpi_python_client/_sync/clients/custom/_statistics.py +32 -22
- glpi_python_client/content/conversion.py +74 -2
- glpi_python_client/models/_base.py +116 -1
- glpi_python_client/models/api_schema/_content.py +13 -4
- glpi_python_client/models/api_schema/assistance/_ticket.py +42 -6
- glpi_python_client/models/custom_schema/_ticket_context.py +18 -3
- glpi_python_client/rsql.py +188 -0
- glpi_python_client/testing/utils.py +1 -0
- {glpi_python_client-0.4.2.dist-info → glpi_python_client-0.4.3.dist-info}/METADATA +5 -2
- {glpi_python_client-0.4.2.dist-info → glpi_python_client-0.4.3.dist-info}/RECORD +42 -41
- {glpi_python_client-0.4.2.dist-info → glpi_python_client-0.4.3.dist-info}/WHEEL +1 -1
- {glpi_python_client-0.4.2.dist-info → glpi_python_client-0.4.3.dist-info}/licenses/LICENSE +0 -0
glpi_python_client/__init__.py
CHANGED
|
@@ -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.
|
|
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,
|
|
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=
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 = [
|
|
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 = [
|
|
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 [
|
|
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,
|