Skip to content

Commit 421b399

Browse files
committed
add custom field interaction through v1 API
1 parent fe060c7 commit 421b399

20 files changed

Lines changed: 1438 additions & 16 deletions

.coverage

0 Bytes
Binary file not shown.

docs/api_reference.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,36 @@ Entities
289289
:undoc-members:
290290
:show-inheritance:
291291

292+
Plugin: Fields (custom fields)
293+
------------------------------
294+
295+
Schemas returned by the GLPI ``Fields`` plugin (legacy v1 REST endpoints).
296+
The companion mixin methods are exposed on :class:`GlpiClient` /
297+
:class:`AsyncGlpiClient` as ``list_plugin_fields_containers``,
298+
``list_plugin_fields_fields``, ``list_item_plugin_field_rows``,
299+
``create_item_plugin_field_row``, ``update_item_plugin_field_row``,
300+
``get_ticket_custom_fields`` and ``set_ticket_custom_fields``.
301+
302+
.. autoclass:: GetPluginFieldsContainer
303+
:members:
304+
:undoc-members:
305+
:show-inheritance:
306+
307+
.. autoclass:: GetPluginFieldsField
308+
:members:
309+
:undoc-members:
310+
:show-inheritance:
311+
312+
.. autoclass:: GetPluginFieldsValueRow
313+
:members:
314+
:undoc-members:
315+
:show-inheritance:
316+
317+
.. autoclass:: PostPluginFieldsValueRow
318+
:members:
319+
:undoc-members:
320+
:show-inheritance:
321+
292322
Enums
293323
-----
294324

docs/user_guide.rst

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -780,9 +780,7 @@ batches until the API returns fewer rows than the requested
780780

781781
Always pass an RSQL filter to ``iter_search_tickets``. Querying
782782
without any filter can return very large result sets and may cause
783-
the GLPI server to return a 500 error on busy instances. The other
784-
two generators (``iter_search_users``, ``iter_search_entities``) are
785-
not affected because those collections are typically much smaller.
783+
the GLPI server to return a 500 errors.
786784

787785
On the asynchronous client the same helpers are exposed as **async
788786
generators** through the bridge, so each ``next()`` call runs off the

glpi_python_client/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
GetEntity,
3131
GetFollowup,
3232
GetLocation,
33+
GetPluginFieldsContainer,
34+
GetPluginFieldsField,
35+
GetPluginFieldsValueRow,
3336
GetSolution,
3437
GetTeamMember,
3538
GetTicket,
@@ -63,6 +66,7 @@
6366
PostEntity,
6467
PostFollowup,
6568
PostLocation,
69+
PostPluginFieldsValueRow,
6670
PostSolution,
6771
PostTeamMember,
6872
PostTicket,
@@ -72,7 +76,7 @@
7276
TicketMarkdownOptions,
7377
)
7478

75-
__version__ = "0.3.3"
79+
__version__ = "0.3.4"
7680

7781
__all__ = [
7882
"AsyncGlpiClient",
@@ -90,6 +94,9 @@
9094
"GetEntity",
9195
"GetFollowup",
9296
"GetLocation",
97+
"GetPluginFieldsContainer",
98+
"GetPluginFieldsField",
99+
"GetPluginFieldsValueRow",
93100
"GetSolution",
94101
"GetTeamMember",
95102
"GetTicket",
@@ -124,6 +131,7 @@
124131
"PostEntity",
125132
"PostFollowup",
126133
"PostLocation",
134+
"PostPluginFieldsValueRow",
127135
"PostSolution",
128136
"PostTeamMember",
129137
"PostTicket",

glpi_python_client/auth/_v1_session.py

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
"""GLPI v1 REST session used exclusively for document uploads.
1+
"""GLPI v1 REST session used for legacy endpoints not exposed by v2.
22
3-
The high-level async ``GlpiClient`` only relies on the legacy v1 API for the
4-
``POST /Document`` multipart upload endpoint. The session wrapper below owns
5-
the authenticated v1 lifecycle (init, refresh, kill) and exposes a single
6-
``upload_document`` operation that the management mixin calls through
7-
``asyncio.to_thread`` at the blocking HTTP boundary.
3+
Two consumers currently share this session:
4+
5+
* the management :class:`DocumentMixin` for the multipart
6+
``POST /Document`` upload (the v2 API does not advertise a binary
7+
upload route), and
8+
* the :class:`PluginFieldsMixin` for the GLPI "Fields" plugin endpoints
9+
(``PluginFieldsContainer``, ``PluginFieldsField`` and the per-item
10+
value itemtypes), which the v2 contract does not surface at all.
11+
12+
The session wrapper owns the authenticated v1 lifecycle (init, refresh,
13+
kill) and exposes the typed ``upload_document`` helper plus the generic
14+
``request_json`` JSON-only HTTP helper that newer mixins build on.
815
"""
916

1017
from __future__ import annotations
@@ -223,6 +230,73 @@ def close(self) -> None:
223230
self._session_started_at = None
224231
self._http.close()
225232

233+
def request_json(
234+
self,
235+
method: str,
236+
path: str,
237+
*,
238+
params: dict[str, object] | None = None,
239+
json_body: dict[str, object] | None = None,
240+
success_statuses: tuple[int, ...] = (200, 201, 204, 206),
241+
failure_message: str | None = None,
242+
) -> object:
243+
"""Send one JSON-only authenticated request to the GLPI v1 API.
244+
245+
The helper centralises session-token handling, the one-shot retry
246+
on token rejection, status validation and JSON parsing so callers
247+
can stay focused on their endpoint semantics.
248+
249+
Parameters
250+
----------
251+
method : str
252+
HTTP verb (``"GET"``, ``"POST"``, ``"PUT"``, ``"DELETE"``).
253+
path : str
254+
Resource path appended to the v1 base URL (without leading
255+
slash, e.g. ``"PluginFieldsContainer"``).
256+
params : dict[str, object] | None, optional
257+
Query-string parameters forwarded to ``requests``.
258+
json_body : dict[str, object] | None, optional
259+
JSON body serialised into the request when set. The
260+
``Content-Type: application/json`` header is added
261+
automatically.
262+
success_statuses : tuple[int, ...], optional
263+
HTTP status codes considered successful (default covers the
264+
CRUD codes returned by the v1 API).
265+
failure_message : str | None, optional
266+
Prefix used in the :class:`ValueError` raised on a
267+
non-success status. Defaults to ``"GLPI v1 {METHOD} {path}
268+
failed"``.
269+
270+
Returns
271+
-------
272+
object
273+
Parsed JSON body for non-empty responses; an empty ``dict``
274+
when the body is empty or contains only whitespace.
275+
276+
Raises
277+
------
278+
ValueError
279+
If the v1 server returns a non-success HTTP status.
280+
"""
281+
282+
url = f"{self._base_url}/{path.lstrip('/')}"
283+
kwargs: dict[str, object] = {"timeout": 30}
284+
if params is not None:
285+
kwargs["params"] = params
286+
headers: dict[str, str] = {}
287+
if json_body is not None:
288+
kwargs["data"] = json.dumps(json_body)
289+
headers["Content-Type"] = "application/json"
290+
response = self._authenticated_request(
291+
method, url, headers=headers or None, **kwargs
292+
)
293+
if response.status_code not in success_statuses:
294+
prefix = failure_message or f"GLPI v1 {method.upper()} {path} failed"
295+
raise ValueError(f"{prefix}: {response.status_code} {response.text[:300]}")
296+
if not response.content or not response.text.strip():
297+
return {}
298+
return response.json()
299+
226300
@retry(stop=stop_after_attempt(3), wait=wait_fixed(3))
227301
def upload_document(
228302
self,

glpi_python_client/auth/tests/test_v1_session.py

Lines changed: 129 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,35 @@ def __init__(self, responses: dict[str, list[FakeResponse]]) -> None:
3232
def _next(self, key: str) -> FakeResponse:
3333
return self._responses[key].pop(0)
3434

35-
def get(self, url: str, headers: dict[str, str], timeout: int) -> FakeResponse:
35+
def get(
36+
self,
37+
url: str,
38+
headers: dict[str, str],
39+
timeout: int,
40+
**kwargs: Any,
41+
) -> FakeResponse:
3642
self.calls.append(
37-
{"method": "GET", "url": url, "headers": headers, "timeout": timeout}
43+
{
44+
"method": "GET",
45+
"url": url,
46+
"headers": headers,
47+
"timeout": timeout,
48+
**kwargs,
49+
}
3850
)
3951
if url.endswith("/initSession"):
4052
return self._next("init")
4153
if url.endswith("/killSession"):
4254
return self._next("kill")
43-
raise AssertionError(f"Unexpected GET {url}")
55+
return self._next("json")
4456

4557
def post(
4658
self,
4759
url: str,
4860
headers: dict[str, str],
49-
files: list[Any],
5061
timeout: int,
62+
files: list[Any] | None = None,
63+
**kwargs: Any,
5164
) -> FakeResponse:
5265
self.calls.append(
5366
{
@@ -56,9 +69,48 @@ def post(
5669
"headers": headers,
5770
"files": files,
5871
"timeout": timeout,
72+
**kwargs,
5973
}
6074
)
61-
return self._next("upload")
75+
if files is not None:
76+
return self._next("upload")
77+
return self._next("json")
78+
79+
def put(
80+
self,
81+
url: str,
82+
headers: dict[str, str],
83+
timeout: int,
84+
**kwargs: Any,
85+
) -> FakeResponse:
86+
self.calls.append(
87+
{
88+
"method": "PUT",
89+
"url": url,
90+
"headers": headers,
91+
"timeout": timeout,
92+
**kwargs,
93+
}
94+
)
95+
return self._next("json")
96+
97+
def delete(
98+
self,
99+
url: str,
100+
headers: dict[str, str],
101+
timeout: int,
102+
**kwargs: Any,
103+
) -> FakeResponse:
104+
self.calls.append(
105+
{
106+
"method": "DELETE",
107+
"url": url,
108+
"headers": headers,
109+
"timeout": timeout,
110+
**kwargs,
111+
}
112+
)
113+
return self._next("json")
62114

63115
def close(self) -> None:
64116
self.closed = True
@@ -260,6 +312,78 @@ def get(self, url: str, headers: dict[str, str], timeout: int) -> FakeResponse:
260312
assert http.closed is True
261313

262314

315+
def test_request_json_sends_body_and_returns_parsed_payload() -> None:
316+
"""``request_json`` serialises the body and decodes the JSON response."""
317+
318+
http = _FakeV1Http(
319+
responses={
320+
"init": [FakeResponse(status_code=200, payload={"session_token": "tk"})],
321+
"json": [FakeResponse(status_code=200, payload={"ok": True})],
322+
"kill": [FakeResponse(status_code=200, payload={})],
323+
}
324+
)
325+
session = _make(http)
326+
result = session.request_json(
327+
"POST",
328+
"PluginFieldsContainer",
329+
json_body={"input": {"name": "x"}},
330+
)
331+
assert result == {"ok": True}
332+
post_call = next(call for call in http.calls if call["method"] == "POST")
333+
assert post_call["url"].endswith("/PluginFieldsContainer")
334+
assert post_call["data"] == jsonlib.dumps({"input": {"name": "x"}})
335+
assert post_call["headers"]["Content-Type"] == "application/json"
336+
337+
338+
def test_request_json_supports_get_with_params() -> None:
339+
"""``request_json`` forwards query params on GET calls."""
340+
341+
http = _FakeV1Http(
342+
responses={
343+
"init": [FakeResponse(status_code=200, payload={"session_token": "tk"})],
344+
"json": [FakeResponse(status_code=200, payload=[{"id": 1}])],
345+
}
346+
)
347+
session = _make(http)
348+
out = session.request_json("GET", "PluginFieldsContainer", params={"range": "0-1"})
349+
assert out == [{"id": 1}]
350+
get_call = next(
351+
call for call in http.calls if call["url"].endswith("/PluginFieldsContainer")
352+
)
353+
assert get_call["params"] == {"range": "0-1"}
354+
355+
356+
def test_request_json_returns_empty_dict_on_empty_body() -> None:
357+
"""An empty response body decodes as an empty dict instead of raising."""
358+
359+
http = _FakeV1Http(
360+
responses={
361+
"init": [FakeResponse(status_code=200, payload={"session_token": "tk"})],
362+
"json": [FakeResponse(status_code=204, payload={}, content=b"")],
363+
}
364+
)
365+
session = _make(http)
366+
assert session.request_json("DELETE", "Some/Resource/1") == {}
367+
368+
369+
def test_request_json_raises_on_non_success_status() -> None:
370+
"""Non-success statuses surface as ``ValueError`` with the body excerpt."""
371+
372+
http = _FakeV1Http(
373+
responses={
374+
"init": [FakeResponse(status_code=200, payload={"session_token": "tk"})],
375+
"json": [
376+
FakeResponse(status_code=500, payload={"err": "boom"}),
377+
FakeResponse(status_code=500, payload={"err": "boom"}),
378+
],
379+
"kill": [FakeResponse(status_code=200, payload={})],
380+
}
381+
)
382+
session = _make(http)
383+
with pytest.raises(ValueError, match="failed"):
384+
session.request_json("GET", "PluginFieldsContainer")
385+
386+
263387
def test_session_token_invalid_marker_triggers_renew() -> None:
264388
"""An ``ERROR_SESSION_TOKEN_INVALID`` body marker counts as an auth failure."""
265389

glpi_python_client/clients/api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@
2525
)
2626
from glpi_python_client.clients.api.dropdowns import LocationMixin
2727
from glpi_python_client.clients.api.management import DocumentMixin
28+
from glpi_python_client.clients.api.plugins import PluginFieldsMixin
2829

2930
__all__ = [
3031
"DocumentMixin",
3132
"EntityMixin",
3233
"FollowupMixin",
3334
"LocationMixin",
35+
"PluginFieldsMixin",
3436
"SolutionMixin",
3537
"TeamMemberMixin",
3638
"TicketMixin",
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""GLPI plugin endpoint mixins exposed via the legacy v1 REST API.
2+
3+
Plugins are not advertised in the v2 OpenAPI contract so the mixins
4+
under this package go through the v1 session helper exposed by
5+
:class:`~glpi_python_client.auth._v1_session.GLPIV1Session`.
6+
"""
7+
8+
from glpi_python_client.clients.api.plugins._fields import PluginFieldsMixin
9+
10+
__all__ = ["PluginFieldsMixin"]

0 commit comments

Comments
 (0)