From 89b6853fbd17ef286fed710415998fad5291a0e2 Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 30 Oct 2026 20:51:37 +0100 Subject: [PATCH 1/3] Add group recursion and user authorizations through the legacy v1 API: set_group_recursive, list/add/update/delete_user_authorization and the UserAuthorization model The GLPI v2 API has no is_recursive on Group and no route for a user's profile and entity authorizations, so these helpers use the v1 session. A v1 write answered false is a refusal and raises GlpiProtocolError with the server message. Co-Authored-By: Claude Opus 5.5 --- glpi_python_client/__init__.py | 2 + .../clients/api/administration/_group.py | 43 ++++ .../clients/api/administration/_user.py | 215 +++++++++++++++++- .../api/administration/tests/test_group.py | 70 +++++- .../api/administration/tests/test_user.py | 194 +++++++++++++++- .../_async/clients/commons/_payloads.py | 34 +++ .../clients/commons/tests/test_payloads.py | 30 +++ .../clients/api/administration/_group.py | 43 ++++ .../_sync/clients/api/administration/_user.py | 215 +++++++++++++++++- .../api/administration/tests/test_group.py | 70 +++++- .../api/administration/tests/test_user.py | 194 +++++++++++++++- .../_sync/clients/commons/_payloads.py | 34 +++ .../clients/commons/tests/test_payloads.py | 30 +++ glpi_python_client/models/__init__.py | 2 + .../api_schema/administration/__init__.py | 4 + .../administration/_user_authorization.py | 48 ++++ .../tests/test_administration_schemas.py | 38 ++++ .../testing/tests/test_exports.py | 8 + .../testing/tests/test_method_invocation.py | 3 + 19 files changed, 1271 insertions(+), 6 deletions(-) create mode 100644 glpi_python_client/models/api_schema/administration/_user_authorization.py diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index 2093e20..f3adcdd 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -130,6 +130,7 @@ PostTimelineDocument, PostUser, TicketMarkdownOptions, + UserAuthorization, ) from glpi_python_client.rsql import ( changed_since, @@ -250,6 +251,7 @@ "PostTimelineDocument", "PostUser", "TicketMarkdownOptions", + "UserAuthorization", "__version__", "changed_since", "created_between", diff --git a/glpi_python_client/_async/clients/api/administration/_group.py b/glpi_python_client/_async/clients/api/administration/_group.py index 1aa8442..509cb16 100644 --- a/glpi_python_client/_async/clients/api/administration/_group.py +++ b/glpi_python_client/_async/clients/api/administration/_group.py @@ -11,6 +11,9 @@ from collections.abc import AsyncIterator from glpi_python_client._async.clients.commons._constants import GROUP_ENDPOINT, GlpiId +from glpi_python_client._async.clients.commons._payloads import ( + ensure_v1_write_accepted, +) from glpi_python_client._async.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.administration._group import ( DeleteGroup, @@ -19,6 +22,8 @@ PostGroup, ) +_V1_GROUP_FEATURE_LABEL = "group recursion" + class GroupMixin(TransportMixin): """CRUD helpers for ``/Administration/Group``.""" @@ -214,5 +219,43 @@ async def delete_group( delete_model_cls=DeleteGroup, ) + async def set_group_recursive( + self, group_id: GlpiId, recursive: bool = True + ) -> None: + """Set whether one group is visible in the sub-entities of its entity. + + The GLPI v2 ``Group`` schema has no ``is_recursive`` property (a v2 + create or update silently drops the key), so this helper writes the + flag through the legacy v1 API, which requires + ``v1_base_url``/``v1_user_token`` to be configured (pointing at the + legacy ``apirest.php``). + + Parameters + ---------- + group_id : int | str + Identifier of the group. + recursive : bool, optional + ``True`` (the default) makes the group apply to the sub-entities + of its entity; ``False`` restricts it to its own entity. + + Raises + ------ + RuntimeError + When no legacy v1 session is configured on the client. + GlpiStatusError + When the legacy API returns a non-success status. + GlpiProtocolError + When the legacy API answers that the update was refused. + """ + + v1 = self._require_v1_session(_V1_GROUP_FEATURE_LABEL) + payload = await v1.request_json( + "PUT", + f"Group/{group_id}", + json_body={"input": {"id": int(group_id), "is_recursive": int(recursive)}}, + failure_message=f"Failed to set is_recursive on group {group_id}", + ) + ensure_v1_write_accepted(payload, f"Setting is_recursive on group {group_id}") + __all__ = ["GroupMixin"] diff --git a/glpi_python_client/_async/clients/api/administration/_user.py b/glpi_python_client/_async/clients/api/administration/_user.py index 5cf5fc4..d850590 100644 --- a/glpi_python_client/_async/clients/api/administration/_user.py +++ b/glpi_python_client/_async/clients/api/administration/_user.py @@ -11,14 +11,24 @@ from collections.abc import AsyncIterator from glpi_python_client._async.clients.commons._constants import USER_ENDPOINT, GlpiId +from glpi_python_client._async.clients.commons._payloads import ( + ensure_v1_write_accepted, + model_from_payload, +) from glpi_python_client._async.clients.commons._transport import TransportMixin -from glpi_python_client._errors import GlpiValidationError +from glpi_python_client._errors import GlpiProtocolError, GlpiValidationError from glpi_python_client.models.api_schema.administration._user import ( DeleteUser, GetUser, PatchUser, PostUser, ) +from glpi_python_client.models.api_schema.administration._user_authorization import ( + UserAuthorization, +) + +_V1_AUTHORIZATION_FEATURE_LABEL = "user authorizations" +_AUTHORIZATION_LIST_RANGE = "0-999" class UserMixin(TransportMixin): @@ -296,5 +306,208 @@ async def delete_user(self, user_id: GlpiId, *, force: bool | None = None) -> No delete_model_cls=DeleteUser, ) + async def list_user_authorizations( + self, user_id: GlpiId + ) -> list[UserAuthorization]: + """List the profile/entity authorizations of one user. + + The GLPI v2 API has no route for a user's authorizations + (``Profile_User`` rows), so this helper reads them through the legacy + v1 API, which requires ``v1_base_url``/``v1_user_token`` to be + configured (pointing at the legacy ``apirest.php``). + + Parameters + ---------- + user_id : int | str + Identifier of the user. + + Returns + ------- + list[UserAuthorization] + One entry per profile the user holds, with the entity it applies + to and whether it is recursive. + + Raises + ------ + RuntimeError + When no legacy v1 session is configured on the client. + GlpiStatusError + When the legacy API returns a non-success status. + """ + + v1 = self._require_v1_session(_V1_AUTHORIZATION_FEATURE_LABEL) + payload = await v1.request_json( + "GET", + f"User/{user_id}/Profile_User", + params={"range": _AUTHORIZATION_LIST_RANGE}, + failure_message=f"Failed to list the authorizations of user {user_id}", + ) + rows = payload if isinstance(payload, list) else [] + return [ + model_from_payload( + UserAuthorization, row, server_timezone=self.server_timezone + ) + for row in rows + ] + + async def add_user_authorization( + self, + user_id: GlpiId, + *, + profile_id: GlpiId, + entity_id: GlpiId, + recursive: bool = False, + ) -> int: + """Give one user a profile in an entity. + + The GLPI v2 API has no route for a user's authorizations, so this + helper creates the ``Profile_User`` row through the legacy v1 API, + which requires ``v1_base_url``/``v1_user_token`` to be configured. + + Parameters + ---------- + user_id : int | str + Identifier of the user. + profile_id : int | str + Identifier of the profile to grant. + entity_id : int | str + Identifier of the entity the profile applies to. + recursive : bool, optional + Whether the profile also applies to the sub-entities of + ``entity_id``. ``False`` by default. + + Returns + ------- + int + Identifier of the new authorization. + + Raises + ------ + RuntimeError + When no legacy v1 session is configured on the client. + GlpiStatusError + When the legacy API returns a non-success status. + GlpiProtocolError + When the answer carries no authorization id. + """ + + v1 = self._require_v1_session(_V1_AUTHORIZATION_FEATURE_LABEL) + payload = await v1.request_json( + "POST", + "Profile_User", + json_body={ + "input": { + "users_id": int(user_id), + "profiles_id": int(profile_id), + "entities_id": int(entity_id), + "is_recursive": int(recursive), + } + }, + failure_message=( + f"Failed to add profile {profile_id} in entity {entity_id} " + f"to user {user_id}" + ), + ) + new_id = payload.get("id") if isinstance(payload, dict) else None + if new_id is None or isinstance(new_id, bool): + raise GlpiProtocolError( + f"GLPI user authorization response missing id: {payload!r}" + ) + try: + return int(new_id) + except (TypeError, ValueError) as exc: + raise GlpiProtocolError( + f"GLPI user authorization response has a non-numeric id: {payload!r}" + ) from exc + + async def update_user_authorization( + self, + authorization_id: GlpiId, + *, + entity_id: GlpiId | None = None, + recursive: bool | None = None, + ) -> None: + """Move or re-scope one user authorization. + + The GLPI v2 API has no route for a user's authorizations, so this + helper updates the ``Profile_User`` row through the legacy v1 API, + which requires ``v1_base_url``/``v1_user_token`` to be configured. + Only the supplied arguments are changed. + + Parameters + ---------- + authorization_id : int | str + Identifier of the authorization (see + :meth:`list_user_authorizations`). + entity_id : int | str | None, optional + New entity for the authorization. + recursive : bool | None, optional + New recursion flag. + + Raises + ------ + GlpiValidationError + When neither ``entity_id`` nor ``recursive`` is given; no request + is sent. + RuntimeError + When no legacy v1 session is configured on the client. + GlpiStatusError + When the legacy API returns a non-success status. + GlpiProtocolError + When the legacy API answers that the update was refused. + """ + + if entity_id is None and recursive is None: + raise GlpiValidationError( + "update_user_authorization needs entity_id, recursive or both." + ) + v1 = self._require_v1_session(_V1_AUTHORIZATION_FEATURE_LABEL) + fields: dict[str, object] = {"id": int(authorization_id)} + if entity_id is not None: + fields["entities_id"] = int(entity_id) + if recursive is not None: + fields["is_recursive"] = int(recursive) + payload = await v1.request_json( + "PUT", + f"Profile_User/{authorization_id}", + json_body={"input": fields}, + failure_message=f"Failed to update user authorization {authorization_id}", + ) + ensure_v1_write_accepted( + payload, f"Updating user authorization {authorization_id}" + ) + + async def delete_user_authorization(self, authorization_id: GlpiId) -> None: + """Remove one user authorization. + + The GLPI v2 API has no route for a user's authorizations, so this + helper deletes the ``Profile_User`` row through the legacy v1 API, + which requires ``v1_base_url``/``v1_user_token`` to be configured. + + Parameters + ---------- + authorization_id : int | str + Identifier of the authorization to remove. + + Raises + ------ + RuntimeError + When no legacy v1 session is configured on the client. + GlpiStatusError + When the legacy API returns a non-success status. + GlpiProtocolError + When the legacy API answers that the deletion was refused. + """ + + v1 = self._require_v1_session(_V1_AUTHORIZATION_FEATURE_LABEL) + payload = await v1.request_json( + "DELETE", + f"Profile_User/{authorization_id}", + failure_message=f"Failed to delete user authorization {authorization_id}", + ) + ensure_v1_write_accepted( + payload, f"Deleting user authorization {authorization_id}" + ) + __all__ = ["UserMixin"] diff --git a/glpi_python_client/_async/clients/api/administration/tests/test_group.py b/glpi_python_client/_async/clients/api/administration/tests/test_group.py index 7cffa67..2f19897 100644 --- a/glpi_python_client/_async/clients/api/administration/tests/test_group.py +++ b/glpi_python_client/_async/clients/api/administration/tests/test_group.py @@ -7,7 +7,7 @@ import pytest -from glpi_python_client import PatchGroup, PostGroup +from glpi_python_client import GlpiProtocolError, PatchGroup, PostGroup from glpi_python_client._async._testing import ( FailingTransportRecorder, TransportRecorder, @@ -155,3 +155,71 @@ async def fake_search( ] assert seen == [(0, True), (2, True)] assert sum(len(b) for b in batches) == 3 + + +class _FakeV1: + """Stand-in for the v1 session that records ``request_json`` calls.""" + + def __init__(self, *responses: object) -> None: + self.responses = list(responses) + self.calls: list[dict[str, Any]] = [] + + async def request_json( + self, + method: str, + path: str, + *, + params: dict[str, object] | None = None, + json_body: dict[str, object] | None = None, + success_statuses: tuple[int, ...] = (200, 201, 204, 206), + failure_message: str | None = None, + ) -> object: + self.calls.append( + { + "method": method, + "path": path, + "params": params, + "json_body": json_body, + "failure_message": failure_message, + } + ) + return self.responses.pop(0) + + async def close(self) -> None: + """No-op; the real session is closed with the client.""" + + +async def test_set_group_recursive_puts_the_flag_through_v1(client: Any) -> None: + """``set_group_recursive`` sends ``is_recursive: 1`` to the v1 Group route.""" + + fake = _FakeV1([{"7": True, "message": ""}]) + client._v1 = fake + await client.set_group_recursive(7) + call = fake.calls[0] + assert call["method"] == "PUT" + assert call["path"] == "Group/7" + assert call["json_body"] == {"input": {"id": 7, "is_recursive": 1}} + + +async def test_set_group_recursive_false_sends_zero(client: Any) -> None: + """``recursive=False`` sends ``is_recursive: 0``.""" + + fake = _FakeV1([{"7": True, "message": ""}]) + client._v1 = fake + await client.set_group_recursive("7", recursive=False) + assert fake.calls[0]["json_body"] == {"input": {"id": 7, "is_recursive": 0}} + + +async def test_set_group_recursive_raises_on_a_refused_answer(client: Any) -> None: + """A ``false`` answer raises with the server message, never passes.""" + + client._v1 = _FakeV1([{"7": False, "message": "Right missing"}]) + with pytest.raises(GlpiProtocolError, match="Right missing"): + await client.set_group_recursive(7) + + +async def test_set_group_recursive_requires_the_v1_session(client: Any) -> None: + """Without a v1 session the usual configuration error is raised.""" + + with pytest.raises(RuntimeError, match="group recursion"): + await client.set_group_recursive(7) diff --git a/glpi_python_client/_async/clients/api/administration/tests/test_user.py b/glpi_python_client/_async/clients/api/administration/tests/test_user.py index a3bc1f6..ce27201 100644 --- a/glpi_python_client/_async/clients/api/administration/tests/test_user.py +++ b/glpi_python_client/_async/clients/api/administration/tests/test_user.py @@ -12,7 +12,12 @@ import pytest -from glpi_python_client import PatchUser, PostUser +from glpi_python_client import ( + GlpiProtocolError, + GlpiValidationError, + PatchUser, + PostUser, +) from glpi_python_client._async._testing import ( FailingTransportRecorder, TransportRecorder, @@ -347,3 +352,190 @@ async def test_find_user_by_email_rejects_a_blank_address(client: Any) -> None: with pytest.raises(GlpiValidationError): await client.find_user_by_email(" ") + + +class _FakeV1: + """Stand-in for the v1 session that records ``request_json`` calls.""" + + def __init__(self, *responses: object) -> None: + self.responses = list(responses) + self.calls: list[dict[str, Any]] = [] + + async def request_json( + self, + method: str, + path: str, + *, + params: dict[str, object] | None = None, + json_body: dict[str, object] | None = None, + success_statuses: tuple[int, ...] = (200, 201, 204, 206), + failure_message: str | None = None, + ) -> object: + self.calls.append( + { + "method": method, + "path": path, + "params": params, + "json_body": json_body, + "failure_message": failure_message, + } + ) + return self.responses.pop(0) + + async def close(self) -> None: + """No-op; the real session is closed with the client.""" + + +_ROW = { + "id": 5, + "users_id": 2568, + "profiles_id": 1, + "entities_id": 0, + "is_recursive": 0, + "is_dynamic": 0, + "is_default_profile": 1, +} + + +async def test_list_user_authorizations_parses_v1_rows(client: Any) -> None: + """Rows of ``GET User//Profile_User`` become ``UserAuthorization``.""" + + fake = _FakeV1([_ROW, {**_ROW, "id": 6, "entities_id": 493, "is_recursive": 1}]) + client._v1 = fake + rows = await client.list_user_authorizations(2568) + assert fake.calls[0]["method"] == "GET" + assert fake.calls[0]["path"] == "User/2568/Profile_User" + assert fake.calls[0]["params"] == {"range": "0-999"} + assert [(r.id, r.user_id, r.profile_id, r.entity_id) for r in rows] == [ + (5, 2568, 1, 0), + (6, 2568, 1, 493), + ] + assert [r.is_recursive for r in rows] == [False, True] + assert rows[0].is_default_profile is True + assert rows[0].is_dynamic is False + + +async def test_list_user_authorizations_ignores_a_non_list_answer( + client: Any, +) -> None: + """An empty body yields an empty list.""" + + client._v1 = _FakeV1({}) + assert await client.list_user_authorizations(1) == [] + + +async def test_add_user_authorization_posts_the_row(client: Any) -> None: + """``add_user_authorization`` posts a Profile_User row and returns its id.""" + + fake = _FakeV1({"id": 31, "message": ""}) + client._v1 = fake + new_id = await client.add_user_authorization( + 2568, profile_id=1, entity_id=492, recursive=True + ) + assert new_id == 31 + call = fake.calls[0] + assert call["method"] == "POST" + assert call["path"] == "Profile_User" + assert call["json_body"] == { + "input": { + "users_id": 2568, + "profiles_id": 1, + "entities_id": 492, + "is_recursive": 1, + } + } + + +async def test_add_user_authorization_defaults_to_non_recursive(client: Any) -> None: + """``recursive`` defaults to ``False``.""" + + fake = _FakeV1({"id": "32"}) + client._v1 = fake + assert await client.add_user_authorization(1, profile_id=2, entity_id=3) == 32 + assert fake.calls[0]["json_body"]["input"]["is_recursive"] == 0 + + +@pytest.mark.parametrize( + "answer", [{"message": "x"}, [], {"id": True}, {"id": "abc"}, None] +) +async def test_add_user_authorization_requires_an_id( + client: Any, answer: object +) -> None: + """A success answer without a usable id raises a protocol error.""" + + client._v1 = _FakeV1(answer) + with pytest.raises(GlpiProtocolError): + await client.add_user_authorization(1, profile_id=2, entity_id=3) + + +async def test_update_user_authorization_sends_only_given_fields( + client: Any, +) -> None: + """Only the supplied fields travel in the PUT body.""" + + fake = _FakeV1([{"5": True, "message": ""}], [{"5": True, "message": ""}]) + client._v1 = fake + await client.update_user_authorization(5, entity_id=493) + await client.update_user_authorization("5", recursive=False) + assert fake.calls[0]["method"] == "PUT" + assert fake.calls[0]["path"] == "Profile_User/5" + assert fake.calls[0]["json_body"] == {"input": {"id": 5, "entities_id": 493}} + assert fake.calls[1]["json_body"] == {"input": {"id": 5, "is_recursive": 0}} + + +async def test_update_user_authorization_needs_a_change(client: Any) -> None: + """With neither argument the call fails before any request.""" + + fake = _FakeV1() + client._v1 = fake + with pytest.raises(GlpiValidationError): + await client.update_user_authorization(5) + assert fake.calls == [] + + +async def test_update_user_authorization_raises_on_a_refused_answer( + client: Any, +) -> None: + """A ``false`` answer raises with the server message.""" + + client._v1 = _FakeV1([{"5": False, "message": "Not allowed"}]) + with pytest.raises(GlpiProtocolError, match="Not allowed"): + await client.update_user_authorization(5, recursive=True) + + +async def test_delete_user_authorization_sends_delete(client: Any) -> None: + """``delete_user_authorization`` deletes the Profile_User row.""" + + fake = _FakeV1([{"5": True, "message": ""}]) + client._v1 = fake + await client.delete_user_authorization(5) + assert fake.calls[0]["method"] == "DELETE" + assert fake.calls[0]["path"] == "Profile_User/5" + + +async def test_delete_user_authorization_raises_on_a_refused_answer( + client: Any, +) -> None: + """A ``false`` answer raises on delete too.""" + + client._v1 = _FakeV1([{"5": False, "message": ""}]) + with pytest.raises(GlpiProtocolError, match="refused"): + await client.delete_user_authorization(5) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.list_user_authorizations(1), + lambda c: c.add_user_authorization(1, profile_id=2, entity_id=3), + lambda c: c.update_user_authorization(1, recursive=True), + lambda c: c.delete_user_authorization(1), + ], +) +async def test_authorization_methods_require_the_v1_session( + client: Any, call: Callable[[Any], Any] +) -> None: + """Without a v1 session each method raises the configuration error.""" + + with pytest.raises(RuntimeError, match="user authorizations"): + await call(client) diff --git a/glpi_python_client/_async/clients/commons/_payloads.py b/glpi_python_client/_async/clients/commons/_payloads.py index f1792b4..05172f3 100644 --- a/glpi_python_client/_async/clients/commons/_payloads.py +++ b/glpi_python_client/_async/clients/commons/_payloads.py @@ -10,6 +10,7 @@ from datetime import tzinfo from typing import TypeVar +from glpi_python_client._errors import GlpiProtocolError from glpi_python_client.models._base import ( SERVER_TIMEZONE_CONTEXT_KEY, GlpiModel, @@ -95,3 +96,36 @@ def model_from_payload( __all__ = ["model_from_payload", "model_to_payload"] + + +def ensure_v1_write_accepted(payload: object, description: str) -> None: + """Raise when a legacy v1 write answered ``false`` for an item. + + The v1 API reports a refused write with a success HTTP status and a + body such as ``[{"": false, "message": "..."}]``, so the status + check alone lets it pass. An empty body, or a body without any + ``false`` entry, is accepted. + + Parameters + ---------- + payload : object + Parsed JSON body returned by the v1 session. + description : str + What was attempted, used as the prefix of the error message. + + Raises + ------ + GlpiProtocolError + When any item of the answer is ``false``; the message carries the + server's own explanation when it gave one. + """ + + if not isinstance(payload, list): + return + for entry in payload: + if not isinstance(entry, dict): + continue + if any(value is False for key, value in entry.items() if key != "message"): + reason = entry.get("message") + suffix = f": {reason}" if reason else "" + raise GlpiProtocolError(f"{description} was refused by GLPI{suffix}") diff --git a/glpi_python_client/_async/clients/commons/tests/test_payloads.py b/glpi_python_client/_async/clients/commons/tests/test_payloads.py index 6b6611a..193721d 100644 --- a/glpi_python_client/_async/clients/commons/tests/test_payloads.py +++ b/glpi_python_client/_async/clients/commons/tests/test_payloads.py @@ -5,6 +5,8 @@ import json from datetime import UTC, datetime, timedelta, timezone +import pytest + from glpi_python_client._async.clients.commons._payloads import ( model_from_payload, model_to_payload, @@ -119,3 +121,31 @@ def test_model_to_payload_leaves_an_aware_datetime_alone_without_a_timezone() -> body = model_to_payload(PostTicketTask(planned_begin=aware)) assert body["planned_begin"] == "2024-01-01T12:00:00Z" + + +@pytest.mark.parametrize( + "payload", + [[{"5": True, "message": ""}], {}, [], None, [{"message": "ok"}], ["x"]], +) +def test_ensure_v1_write_accepted_passes_accepted_answers(payload: object) -> None: + """Accepted, empty or unrecognised answers do not raise.""" + + from glpi_python_client._async.clients.commons._payloads import ( + ensure_v1_write_accepted, + ) + + ensure_v1_write_accepted(payload, "Writing") + + +def test_ensure_v1_write_accepted_raises_with_the_server_message() -> None: + """A ``false`` entry raises and carries the server's message.""" + + from glpi_python_client import GlpiProtocolError + from glpi_python_client._async.clients.commons._payloads import ( + ensure_v1_write_accepted, + ) + + with pytest.raises(GlpiProtocolError, match=r"Writing was refused.*Nope"): + ensure_v1_write_accepted([{"5": False, "message": "Nope"}], "Writing") + with pytest.raises(GlpiProtocolError, match=r"refused by GLPI$"): + ensure_v1_write_accepted([{"5": False, "message": ""}], "Writing") diff --git a/glpi_python_client/_sync/clients/api/administration/_group.py b/glpi_python_client/_sync/clients/api/administration/_group.py index cb54b96..f29d213 100644 --- a/glpi_python_client/_sync/clients/api/administration/_group.py +++ b/glpi_python_client/_sync/clients/api/administration/_group.py @@ -11,6 +11,9 @@ from collections.abc import Iterator from glpi_python_client._sync.clients.commons._constants import GROUP_ENDPOINT, GlpiId +from glpi_python_client._sync.clients.commons._payloads import ( + ensure_v1_write_accepted, +) from glpi_python_client._sync.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.administration._group import ( DeleteGroup, @@ -19,6 +22,8 @@ PostGroup, ) +_V1_GROUP_FEATURE_LABEL = "group recursion" + class GroupMixin(TransportMixin): """CRUD helpers for ``/Administration/Group``.""" @@ -214,5 +219,43 @@ def delete_group( delete_model_cls=DeleteGroup, ) + def set_group_recursive( + self, group_id: GlpiId, recursive: bool = True + ) -> None: + """Set whether one group is visible in the sub-entities of its entity. + + The GLPI v2 ``Group`` schema has no ``is_recursive`` property (a v2 + create or update silently drops the key), so this helper writes the + flag through the legacy v1 API, which requires + ``v1_base_url``/``v1_user_token`` to be configured (pointing at the + legacy ``apirest.php``). + + Parameters + ---------- + group_id : int | str + Identifier of the group. + recursive : bool, optional + ``True`` (the default) makes the group apply to the sub-entities + of its entity; ``False`` restricts it to its own entity. + + Raises + ------ + RuntimeError + When no legacy v1 session is configured on the client. + GlpiStatusError + When the legacy API returns a non-success status. + GlpiProtocolError + When the legacy API answers that the update was refused. + """ + + v1 = self._require_v1_session(_V1_GROUP_FEATURE_LABEL) + payload = v1.request_json( + "PUT", + f"Group/{group_id}", + json_body={"input": {"id": int(group_id), "is_recursive": int(recursive)}}, + failure_message=f"Failed to set is_recursive on group {group_id}", + ) + ensure_v1_write_accepted(payload, f"Setting is_recursive on group {group_id}") + __all__ = ["GroupMixin"] diff --git a/glpi_python_client/_sync/clients/api/administration/_user.py b/glpi_python_client/_sync/clients/api/administration/_user.py index 11eb40a..42c322c 100644 --- a/glpi_python_client/_sync/clients/api/administration/_user.py +++ b/glpi_python_client/_sync/clients/api/administration/_user.py @@ -11,14 +11,24 @@ from collections.abc import Iterator from glpi_python_client._sync.clients.commons._constants import USER_ENDPOINT, GlpiId +from glpi_python_client._sync.clients.commons._payloads import ( + ensure_v1_write_accepted, + model_from_payload, +) from glpi_python_client._sync.clients.commons._transport import TransportMixin -from glpi_python_client._errors import GlpiValidationError +from glpi_python_client._errors import GlpiProtocolError, GlpiValidationError from glpi_python_client.models.api_schema.administration._user import ( DeleteUser, GetUser, PatchUser, PostUser, ) +from glpi_python_client.models.api_schema.administration._user_authorization import ( + UserAuthorization, +) + +_V1_AUTHORIZATION_FEATURE_LABEL = "user authorizations" +_AUTHORIZATION_LIST_RANGE = "0-999" class UserMixin(TransportMixin): @@ -296,5 +306,208 @@ def delete_user(self, user_id: GlpiId, *, force: bool | None = None) -> None: delete_model_cls=DeleteUser, ) + def list_user_authorizations( + self, user_id: GlpiId + ) -> list[UserAuthorization]: + """List the profile/entity authorizations of one user. + + The GLPI v2 API has no route for a user's authorizations + (``Profile_User`` rows), so this helper reads them through the legacy + v1 API, which requires ``v1_base_url``/``v1_user_token`` to be + configured (pointing at the legacy ``apirest.php``). + + Parameters + ---------- + user_id : int | str + Identifier of the user. + + Returns + ------- + list[UserAuthorization] + One entry per profile the user holds, with the entity it applies + to and whether it is recursive. + + Raises + ------ + RuntimeError + When no legacy v1 session is configured on the client. + GlpiStatusError + When the legacy API returns a non-success status. + """ + + v1 = self._require_v1_session(_V1_AUTHORIZATION_FEATURE_LABEL) + payload = v1.request_json( + "GET", + f"User/{user_id}/Profile_User", + params={"range": _AUTHORIZATION_LIST_RANGE}, + failure_message=f"Failed to list the authorizations of user {user_id}", + ) + rows = payload if isinstance(payload, list) else [] + return [ + model_from_payload( + UserAuthorization, row, server_timezone=self.server_timezone + ) + for row in rows + ] + + def add_user_authorization( + self, + user_id: GlpiId, + *, + profile_id: GlpiId, + entity_id: GlpiId, + recursive: bool = False, + ) -> int: + """Give one user a profile in an entity. + + The GLPI v2 API has no route for a user's authorizations, so this + helper creates the ``Profile_User`` row through the legacy v1 API, + which requires ``v1_base_url``/``v1_user_token`` to be configured. + + Parameters + ---------- + user_id : int | str + Identifier of the user. + profile_id : int | str + Identifier of the profile to grant. + entity_id : int | str + Identifier of the entity the profile applies to. + recursive : bool, optional + Whether the profile also applies to the sub-entities of + ``entity_id``. ``False`` by default. + + Returns + ------- + int + Identifier of the new authorization. + + Raises + ------ + RuntimeError + When no legacy v1 session is configured on the client. + GlpiStatusError + When the legacy API returns a non-success status. + GlpiProtocolError + When the answer carries no authorization id. + """ + + v1 = self._require_v1_session(_V1_AUTHORIZATION_FEATURE_LABEL) + payload = v1.request_json( + "POST", + "Profile_User", + json_body={ + "input": { + "users_id": int(user_id), + "profiles_id": int(profile_id), + "entities_id": int(entity_id), + "is_recursive": int(recursive), + } + }, + failure_message=( + f"Failed to add profile {profile_id} in entity {entity_id} " + f"to user {user_id}" + ), + ) + new_id = payload.get("id") if isinstance(payload, dict) else None + if new_id is None or isinstance(new_id, bool): + raise GlpiProtocolError( + f"GLPI user authorization response missing id: {payload!r}" + ) + try: + return int(new_id) + except (TypeError, ValueError) as exc: + raise GlpiProtocolError( + f"GLPI user authorization response has a non-numeric id: {payload!r}" + ) from exc + + def update_user_authorization( + self, + authorization_id: GlpiId, + *, + entity_id: GlpiId | None = None, + recursive: bool | None = None, + ) -> None: + """Move or re-scope one user authorization. + + The GLPI v2 API has no route for a user's authorizations, so this + helper updates the ``Profile_User`` row through the legacy v1 API, + which requires ``v1_base_url``/``v1_user_token`` to be configured. + Only the supplied arguments are changed. + + Parameters + ---------- + authorization_id : int | str + Identifier of the authorization (see + :meth:`list_user_authorizations`). + entity_id : int | str | None, optional + New entity for the authorization. + recursive : bool | None, optional + New recursion flag. + + Raises + ------ + GlpiValidationError + When neither ``entity_id`` nor ``recursive`` is given; no request + is sent. + RuntimeError + When no legacy v1 session is configured on the client. + GlpiStatusError + When the legacy API returns a non-success status. + GlpiProtocolError + When the legacy API answers that the update was refused. + """ + + if entity_id is None and recursive is None: + raise GlpiValidationError( + "update_user_authorization needs entity_id, recursive or both." + ) + v1 = self._require_v1_session(_V1_AUTHORIZATION_FEATURE_LABEL) + fields: dict[str, object] = {"id": int(authorization_id)} + if entity_id is not None: + fields["entities_id"] = int(entity_id) + if recursive is not None: + fields["is_recursive"] = int(recursive) + payload = v1.request_json( + "PUT", + f"Profile_User/{authorization_id}", + json_body={"input": fields}, + failure_message=f"Failed to update user authorization {authorization_id}", + ) + ensure_v1_write_accepted( + payload, f"Updating user authorization {authorization_id}" + ) + + def delete_user_authorization(self, authorization_id: GlpiId) -> None: + """Remove one user authorization. + + The GLPI v2 API has no route for a user's authorizations, so this + helper deletes the ``Profile_User`` row through the legacy v1 API, + which requires ``v1_base_url``/``v1_user_token`` to be configured. + + Parameters + ---------- + authorization_id : int | str + Identifier of the authorization to remove. + + Raises + ------ + RuntimeError + When no legacy v1 session is configured on the client. + GlpiStatusError + When the legacy API returns a non-success status. + GlpiProtocolError + When the legacy API answers that the deletion was refused. + """ + + v1 = self._require_v1_session(_V1_AUTHORIZATION_FEATURE_LABEL) + payload = v1.request_json( + "DELETE", + f"Profile_User/{authorization_id}", + failure_message=f"Failed to delete user authorization {authorization_id}", + ) + ensure_v1_write_accepted( + payload, f"Deleting user authorization {authorization_id}" + ) + __all__ = ["UserMixin"] diff --git a/glpi_python_client/_sync/clients/api/administration/tests/test_group.py b/glpi_python_client/_sync/clients/api/administration/tests/test_group.py index 77e1917..63f8e6f 100644 --- a/glpi_python_client/_sync/clients/api/administration/tests/test_group.py +++ b/glpi_python_client/_sync/clients/api/administration/tests/test_group.py @@ -7,7 +7,7 @@ import pytest -from glpi_python_client import PatchGroup, PostGroup +from glpi_python_client import GlpiProtocolError, PatchGroup, PostGroup from glpi_python_client._sync._testing import ( FailingTransportRecorder, TransportRecorder, @@ -155,3 +155,71 @@ def fake_search( ] assert seen == [(0, True), (2, True)] assert sum(len(b) for b in batches) == 3 + + +class _FakeV1: + """Stand-in for the v1 session that records ``request_json`` calls.""" + + def __init__(self, *responses: object) -> None: + self.responses = list(responses) + self.calls: list[dict[str, Any]] = [] + + def request_json( + self, + method: str, + path: str, + *, + params: dict[str, object] | None = None, + json_body: dict[str, object] | None = None, + success_statuses: tuple[int, ...] = (200, 201, 204, 206), + failure_message: str | None = None, + ) -> object: + self.calls.append( + { + "method": method, + "path": path, + "params": params, + "json_body": json_body, + "failure_message": failure_message, + } + ) + return self.responses.pop(0) + + def close(self) -> None: + """No-op; the real session is closed with the client.""" + + +def test_set_group_recursive_puts_the_flag_through_v1(client: Any) -> None: + """``set_group_recursive`` sends ``is_recursive: 1`` to the v1 Group route.""" + + fake = _FakeV1([{"7": True, "message": ""}]) + client._v1 = fake + client.set_group_recursive(7) + call = fake.calls[0] + assert call["method"] == "PUT" + assert call["path"] == "Group/7" + assert call["json_body"] == {"input": {"id": 7, "is_recursive": 1}} + + +def test_set_group_recursive_false_sends_zero(client: Any) -> None: + """``recursive=False`` sends ``is_recursive: 0``.""" + + fake = _FakeV1([{"7": True, "message": ""}]) + client._v1 = fake + client.set_group_recursive("7", recursive=False) + assert fake.calls[0]["json_body"] == {"input": {"id": 7, "is_recursive": 0}} + + +def test_set_group_recursive_raises_on_a_refused_answer(client: Any) -> None: + """A ``false`` answer raises with the server message, never passes.""" + + client._v1 = _FakeV1([{"7": False, "message": "Right missing"}]) + with pytest.raises(GlpiProtocolError, match="Right missing"): + client.set_group_recursive(7) + + +def test_set_group_recursive_requires_the_v1_session(client: Any) -> None: + """Without a v1 session the usual configuration error is raised.""" + + with pytest.raises(RuntimeError, match="group recursion"): + client.set_group_recursive(7) diff --git a/glpi_python_client/_sync/clients/api/administration/tests/test_user.py b/glpi_python_client/_sync/clients/api/administration/tests/test_user.py index af25eef..2712ac1 100644 --- a/glpi_python_client/_sync/clients/api/administration/tests/test_user.py +++ b/glpi_python_client/_sync/clients/api/administration/tests/test_user.py @@ -12,7 +12,12 @@ import pytest -from glpi_python_client import PatchUser, PostUser +from glpi_python_client import ( + GlpiProtocolError, + GlpiValidationError, + PatchUser, + PostUser, +) from glpi_python_client._sync._testing import ( FailingTransportRecorder, TransportRecorder, @@ -347,3 +352,190 @@ def test_find_user_by_email_rejects_a_blank_address(client: Any) -> None: with pytest.raises(GlpiValidationError): client.find_user_by_email(" ") + + +class _FakeV1: + """Stand-in for the v1 session that records ``request_json`` calls.""" + + def __init__(self, *responses: object) -> None: + self.responses = list(responses) + self.calls: list[dict[str, Any]] = [] + + def request_json( + self, + method: str, + path: str, + *, + params: dict[str, object] | None = None, + json_body: dict[str, object] | None = None, + success_statuses: tuple[int, ...] = (200, 201, 204, 206), + failure_message: str | None = None, + ) -> object: + self.calls.append( + { + "method": method, + "path": path, + "params": params, + "json_body": json_body, + "failure_message": failure_message, + } + ) + return self.responses.pop(0) + + def close(self) -> None: + """No-op; the real session is closed with the client.""" + + +_ROW = { + "id": 5, + "users_id": 2568, + "profiles_id": 1, + "entities_id": 0, + "is_recursive": 0, + "is_dynamic": 0, + "is_default_profile": 1, +} + + +def test_list_user_authorizations_parses_v1_rows(client: Any) -> None: + """Rows of ``GET User//Profile_User`` become ``UserAuthorization``.""" + + fake = _FakeV1([_ROW, {**_ROW, "id": 6, "entities_id": 493, "is_recursive": 1}]) + client._v1 = fake + rows = client.list_user_authorizations(2568) + assert fake.calls[0]["method"] == "GET" + assert fake.calls[0]["path"] == "User/2568/Profile_User" + assert fake.calls[0]["params"] == {"range": "0-999"} + assert [(r.id, r.user_id, r.profile_id, r.entity_id) for r in rows] == [ + (5, 2568, 1, 0), + (6, 2568, 1, 493), + ] + assert [r.is_recursive for r in rows] == [False, True] + assert rows[0].is_default_profile is True + assert rows[0].is_dynamic is False + + +def test_list_user_authorizations_ignores_a_non_list_answer( + client: Any, +) -> None: + """An empty body yields an empty list.""" + + client._v1 = _FakeV1({}) + assert client.list_user_authorizations(1) == [] + + +def test_add_user_authorization_posts_the_row(client: Any) -> None: + """``add_user_authorization`` posts a Profile_User row and returns its id.""" + + fake = _FakeV1({"id": 31, "message": ""}) + client._v1 = fake + new_id = client.add_user_authorization( + 2568, profile_id=1, entity_id=492, recursive=True + ) + assert new_id == 31 + call = fake.calls[0] + assert call["method"] == "POST" + assert call["path"] == "Profile_User" + assert call["json_body"] == { + "input": { + "users_id": 2568, + "profiles_id": 1, + "entities_id": 492, + "is_recursive": 1, + } + } + + +def test_add_user_authorization_defaults_to_non_recursive(client: Any) -> None: + """``recursive`` defaults to ``False``.""" + + fake = _FakeV1({"id": "32"}) + client._v1 = fake + assert client.add_user_authorization(1, profile_id=2, entity_id=3) == 32 + assert fake.calls[0]["json_body"]["input"]["is_recursive"] == 0 + + +@pytest.mark.parametrize( + "answer", [{"message": "x"}, [], {"id": True}, {"id": "abc"}, None] +) +def test_add_user_authorization_requires_an_id( + client: Any, answer: object +) -> None: + """A success answer without a usable id raises a protocol error.""" + + client._v1 = _FakeV1(answer) + with pytest.raises(GlpiProtocolError): + client.add_user_authorization(1, profile_id=2, entity_id=3) + + +def test_update_user_authorization_sends_only_given_fields( + client: Any, +) -> None: + """Only the supplied fields travel in the PUT body.""" + + fake = _FakeV1([{"5": True, "message": ""}], [{"5": True, "message": ""}]) + client._v1 = fake + client.update_user_authorization(5, entity_id=493) + client.update_user_authorization("5", recursive=False) + assert fake.calls[0]["method"] == "PUT" + assert fake.calls[0]["path"] == "Profile_User/5" + assert fake.calls[0]["json_body"] == {"input": {"id": 5, "entities_id": 493}} + assert fake.calls[1]["json_body"] == {"input": {"id": 5, "is_recursive": 0}} + + +def test_update_user_authorization_needs_a_change(client: Any) -> None: + """With neither argument the call fails before any request.""" + + fake = _FakeV1() + client._v1 = fake + with pytest.raises(GlpiValidationError): + client.update_user_authorization(5) + assert fake.calls == [] + + +def test_update_user_authorization_raises_on_a_refused_answer( + client: Any, +) -> None: + """A ``false`` answer raises with the server message.""" + + client._v1 = _FakeV1([{"5": False, "message": "Not allowed"}]) + with pytest.raises(GlpiProtocolError, match="Not allowed"): + client.update_user_authorization(5, recursive=True) + + +def test_delete_user_authorization_sends_delete(client: Any) -> None: + """``delete_user_authorization`` deletes the Profile_User row.""" + + fake = _FakeV1([{"5": True, "message": ""}]) + client._v1 = fake + client.delete_user_authorization(5) + assert fake.calls[0]["method"] == "DELETE" + assert fake.calls[0]["path"] == "Profile_User/5" + + +def test_delete_user_authorization_raises_on_a_refused_answer( + client: Any, +) -> None: + """A ``false`` answer raises on delete too.""" + + client._v1 = _FakeV1([{"5": False, "message": ""}]) + with pytest.raises(GlpiProtocolError, match="refused"): + client.delete_user_authorization(5) + + +@pytest.mark.parametrize( + "call", + [ + lambda c: c.list_user_authorizations(1), + lambda c: c.add_user_authorization(1, profile_id=2, entity_id=3), + lambda c: c.update_user_authorization(1, recursive=True), + lambda c: c.delete_user_authorization(1), + ], +) +def test_authorization_methods_require_the_v1_session( + client: Any, call: Callable[[Any], Any] +) -> None: + """Without a v1 session each method raises the configuration error.""" + + with pytest.raises(RuntimeError, match="user authorizations"): + call(client) diff --git a/glpi_python_client/_sync/clients/commons/_payloads.py b/glpi_python_client/_sync/clients/commons/_payloads.py index f1792b4..05172f3 100644 --- a/glpi_python_client/_sync/clients/commons/_payloads.py +++ b/glpi_python_client/_sync/clients/commons/_payloads.py @@ -10,6 +10,7 @@ from datetime import tzinfo from typing import TypeVar +from glpi_python_client._errors import GlpiProtocolError from glpi_python_client.models._base import ( SERVER_TIMEZONE_CONTEXT_KEY, GlpiModel, @@ -95,3 +96,36 @@ def model_from_payload( __all__ = ["model_from_payload", "model_to_payload"] + + +def ensure_v1_write_accepted(payload: object, description: str) -> None: + """Raise when a legacy v1 write answered ``false`` for an item. + + The v1 API reports a refused write with a success HTTP status and a + body such as ``[{"": false, "message": "..."}]``, so the status + check alone lets it pass. An empty body, or a body without any + ``false`` entry, is accepted. + + Parameters + ---------- + payload : object + Parsed JSON body returned by the v1 session. + description : str + What was attempted, used as the prefix of the error message. + + Raises + ------ + GlpiProtocolError + When any item of the answer is ``false``; the message carries the + server's own explanation when it gave one. + """ + + if not isinstance(payload, list): + return + for entry in payload: + if not isinstance(entry, dict): + continue + if any(value is False for key, value in entry.items() if key != "message"): + reason = entry.get("message") + suffix = f": {reason}" if reason else "" + raise GlpiProtocolError(f"{description} was refused by GLPI{suffix}") diff --git a/glpi_python_client/_sync/clients/commons/tests/test_payloads.py b/glpi_python_client/_sync/clients/commons/tests/test_payloads.py index 6520921..b2193b2 100644 --- a/glpi_python_client/_sync/clients/commons/tests/test_payloads.py +++ b/glpi_python_client/_sync/clients/commons/tests/test_payloads.py @@ -5,6 +5,8 @@ import json from datetime import UTC, datetime, timedelta, timezone +import pytest + from glpi_python_client._sync.clients.commons._payloads import ( model_from_payload, model_to_payload, @@ -119,3 +121,31 @@ def test_model_to_payload_leaves_an_aware_datetime_alone_without_a_timezone() -> body = model_to_payload(PostTicketTask(planned_begin=aware)) assert body["planned_begin"] == "2024-01-01T12:00:00Z" + + +@pytest.mark.parametrize( + "payload", + [[{"5": True, "message": ""}], {}, [], None, [{"message": "ok"}], ["x"]], +) +def test_ensure_v1_write_accepted_passes_accepted_answers(payload: object) -> None: + """Accepted, empty or unrecognised answers do not raise.""" + + from glpi_python_client._sync.clients.commons._payloads import ( + ensure_v1_write_accepted, + ) + + ensure_v1_write_accepted(payload, "Writing") + + +def test_ensure_v1_write_accepted_raises_with_the_server_message() -> None: + """A ``false`` entry raises and carries the server's message.""" + + from glpi_python_client import GlpiProtocolError + from glpi_python_client._sync.clients.commons._payloads import ( + ensure_v1_write_accepted, + ) + + with pytest.raises(GlpiProtocolError, match=r"Writing was refused.*Nope"): + ensure_v1_write_accepted([{"5": False, "message": "Nope"}], "Writing") + with pytest.raises(GlpiProtocolError, match=r"refused by GLPI$"): + ensure_v1_write_accepted([{"5": False, "message": ""}], "Writing") diff --git a/glpi_python_client/models/__init__.py b/glpi_python_client/models/__init__.py index 92043e3..5038f31 100644 --- a/glpi_python_client/models/__init__.py +++ b/glpi_python_client/models/__init__.py @@ -33,6 +33,7 @@ PostEntity, PostGroup, PostUser, + UserAuthorization, ) from glpi_python_client.models.api_schema.assets import ( DeleteComputer, @@ -235,4 +236,5 @@ "PostTimelineDocument", "PostUser", "TicketMarkdownOptions", + "UserAuthorization", ] diff --git a/glpi_python_client/models/api_schema/administration/__init__.py b/glpi_python_client/models/api_schema/administration/__init__.py index b152d38..3ff7ecb 100644 --- a/glpi_python_client/models/api_schema/administration/__init__.py +++ b/glpi_python_client/models/api_schema/administration/__init__.py @@ -24,6 +24,9 @@ PatchUser, PostUser, ) +from glpi_python_client.models.api_schema.administration._user_authorization import ( + UserAuthorization, +) __all__ = [ "DeleteEntity", @@ -38,4 +41,5 @@ "PostEntity", "PostGroup", "PostUser", + "UserAuthorization", ] diff --git a/glpi_python_client/models/api_schema/administration/_user_authorization.py b/glpi_python_client/models/api_schema/administration/_user_authorization.py new file mode 100644 index 0000000..7b6f75f --- /dev/null +++ b/glpi_python_client/models/api_schema/administration/_user_authorization.py @@ -0,0 +1,48 @@ +"""Schema of one GLPI user authorization (``Profile_User`` row). + +A user authorization says that a user holds a profile in an entity, and +whether that profile also applies to the entity's sub-entities. The GLPI v2 +API has no route for these rows, so they are read and written through the +legacy v1 REST API, which names the columns ``users_id``, ``profiles_id`` and +``entities_id`` and encodes flags as ``0``/``1``. +""" + +from __future__ import annotations + +from pydantic import AliasChoices, Field + +from glpi_python_client.models._base import GlpiModel + + +class UserAuthorization(GlpiModel): + """One profile held by a user in an entity. + + Both the v1 column names (``users_id``, ``profiles_id``, ``entities_id``) + and the Python field names are accepted on input; the ``0``/``1`` flags + of the v1 API become booleans. + + Parameters + ---------- + id : int + Identifier of the authorization row. + user_id : int + Identifier of the user holding the profile. + profile_id : int + Identifier of the profile. + entity_id : int + Identifier of the entity the profile applies to. + is_recursive : bool, optional + Whether the profile also applies to the sub-entities of the entity. + is_dynamic : bool, optional + Whether the row was added by a directory rule rather than by hand. + is_default_profile : bool, optional + Whether this is the user's default profile. + """ + + id: int + user_id: int = Field(validation_alias=AliasChoices("user_id", "users_id")) + profile_id: int = Field(validation_alias=AliasChoices("profile_id", "profiles_id")) + entity_id: int = Field(validation_alias=AliasChoices("entity_id", "entities_id")) + is_recursive: bool = False + is_dynamic: bool = False + is_default_profile: bool = False diff --git a/glpi_python_client/models/api_schema/administration/tests/test_administration_schemas.py b/glpi_python_client/models/api_schema/administration/tests/test_administration_schemas.py index 4b8cd33..8784ebf 100644 --- a/glpi_python_client/models/api_schema/administration/tests/test_administration_schemas.py +++ b/glpi_python_client/models/api_schema/administration/tests/test_administration_schemas.py @@ -188,3 +188,41 @@ def test_patch_group_accepts_subset_and_delete_group_defaults() -> None: PatchGroup.model_validate({"comment": "changed"}) assert DeleteGroup().force is None + + +def test_user_authorization_reads_a_v1_row() -> None: + """The v1 column names and 0/1 flags map onto the model fields.""" + + from glpi_python_client.models.api_schema.administration import ( + UserAuthorization, + ) + + row = UserAuthorization.model_validate( + { + "id": 5, + "users_id": 2568, + "profiles_id": 1, + "entities_id": 493, + "is_recursive": 1, + "is_dynamic": 0, + "is_default_profile": 1, + "extra_column": "kept", + } + ) + assert (row.id, row.user_id, row.profile_id, row.entity_id) == (5, 2568, 1, 493) + assert row.is_recursive is True + assert row.is_dynamic is False + assert row.is_default_profile is True + assert row.extra_payload == {"extra_column": "kept"} + + +def test_user_authorization_accepts_python_field_names() -> None: + """The Python field names are accepted on input as well.""" + + from glpi_python_client.models.api_schema.administration import ( + UserAuthorization, + ) + + row = UserAuthorization(id=1, user_id=2, profile_id=3, entity_id=4) + assert (row.user_id, row.profile_id, row.entity_id) == (2, 3, 4) + assert row.is_recursive is False diff --git a/glpi_python_client/testing/tests/test_exports.py b/glpi_python_client/testing/tests/test_exports.py index a50e049..8f947db 100644 --- a/glpi_python_client/testing/tests/test_exports.py +++ b/glpi_python_client/testing/tests/test_exports.py @@ -96,3 +96,11 @@ def test_group_models_exported_from_models_package() -> None: for name in _GROUP_MODELS: assert hasattr(models, name), name assert name in models.__all__, name + + +def test_user_authorization_exported_from_top_level_and_models() -> None: + """``UserAuthorization`` is importable from both public packages.""" + + for module in (glpi_python_client, models): + assert hasattr(module, "UserAuthorization") + assert "UserAuthorization" in module.__all__ diff --git a/glpi_python_client/testing/tests/test_method_invocation.py b/glpi_python_client/testing/tests/test_method_invocation.py index 9111401..eca6749 100644 --- a/glpi_python_client/testing/tests/test_method_invocation.py +++ b/glpi_python_client/testing/tests/test_method_invocation.py @@ -42,6 +42,9 @@ # documented "no criteria" case: it raises GlpiValidationError before # any request leaves the process. "get_user_activity": "validates that at least one selector is given first", + # Both changes are optional keywords, so a no-argument call is the + # documented "nothing to change" case, rejected before any request. + "update_user_authorization": "validates that a change is given first", } From a9e76dfa80a0cfe75682893379f442cd11e00902 Mon Sep 17 00:00:00 2001 From: baraline Date: Fri, 30 Oct 2026 21:58:00 +0100 Subject: [PATCH 2/3] Document group recursion and user authorizations and release 0.6.7: API reference, user guide, provisioning skill and CHANGELOG, version bumped in pyproject, __version__ and every skill Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 28 +++++++++++++++ docs/api_reference.rst | 8 +++++ docs/user_guide.rst | 35 +++++++++++++++++++ glpi_python_client/__init__.py | 2 +- pyproject.toml | 2 +- skills/README.md | 2 +- skills/glpi-asset-workflow/SKILL.md | 2 +- skills/glpi-client-setup/SKILL.md | 2 +- skills/glpi-contract-workflow/SKILL.md | 2 +- skills/glpi-document-workflow/SKILL.md | 2 +- skills/glpi-knowledge-base/SKILL.md | 2 +- skills/glpi-plugin-fields/SKILL.md | 2 +- skills/glpi-reporting-and-context/SKILL.md | 2 +- skills/glpi-team-members/SKILL.md | 2 +- skills/glpi-ticket-timeline/SKILL.md | 2 +- skills/glpi-ticket-workflow/SKILL.md | 2 +- .../glpi-user-location-provisioning/SKILL.md | 3 +- 17 files changed, 86 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b14c396..e790452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## 0.6.7 — 2026-10-30 + +A minor addition: group recursion and a user's profile/entity authorizations, +through the legacy v1 API. The GLPI v2 `Group` schema has no `is_recursive` +(a v2 create silently drops it) and v2 has no route for a user's +authorizations, so these helpers need `v1_base_url` / `v1_user_token` and +raise `RuntimeError` without them. Existing methods are unchanged. + +### Added + +- **`set_group_recursive(group_id, recursive=True)`** on both clients. +- **`list_user_authorizations`, `add_user_authorization`, + `update_user_authorization` and `delete_user_authorization`** on both + clients, over GLPI's `Profile_User` rows. `update_user_authorization` with + neither `entity_id` nor `recursive` raises `GlpiValidationError` without a + request. +- **`UserAuthorization`**, one authorization row (`id`, `user_id`, + `profile_id`, `entity_id`, `is_recursive`, `is_dynamic`, + `is_default_profile`), exported from the package root and from + `glpi_python_client.models`. + +### Changed + +- A v1 write that GLPI answers with `false` for an item (a success status and + a body such as `[{"7": false, "message": "..."}]`) raises + `GlpiProtocolError` with the server's message. The new helpers do this; the + existing v1 writes are unchanged. + ## 0.6.6 — 2026-10-28 A minor addition: the GLPI v2 `/Administration/Group` routes, so a caller can diff --git a/docs/api_reference.rst b/docs/api_reference.rst index edaf0c1..9af0dda 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -509,6 +509,14 @@ Groups :undoc-members: :show-inheritance: +User authorizations +------------------- + +.. autoclass:: UserAuthorization + :members: + :undoc-members: + :show-inheritance: + Plugin: Fields (custom fields) ------------------------------ diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 635a76d..0f23290 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -379,6 +379,7 @@ The full set is re-exported from the package root, including ``GetLocation`` / ``PostLocation`` / ``PatchLocation`` / ``DeleteLocation``, ``GetEntity`` / ``PostEntity`` / ``PatchEntity`` / ``DeleteEntity``, ``GetGroup`` / ``PostGroup`` / ``PatchGroup`` / ``DeleteGroup``, +``UserAuthorization``, ``GetFollowup``, ``GetTicketTask``, ``GetSolution``, ``GetTimelineDocument``, ``GetTeamMember``, and ``GetDocument`` together with their post / patch / delete variants. The read-only ``GetDocumentItem`` and ``FollowupDocuments`` @@ -582,6 +583,40 @@ Example output:: the client (``v1_base_url`` and ``v1_user_token``) because the GLPI v2 contract does not advertise a binary upload endpoint. +Group recursion and user authorizations +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +GLPI lets a group, and a user's profile in an entity, apply to the +sub-entities of that entity too. The v2 API cannot set either: its ``Group`` +schema has no ``is_recursive`` property (a v2 create silently drops the key) +and it has no route at all for a user's profile and entity authorizations. +Five helpers go through the legacy v1 session instead (``v1_base_url`` and +``v1_user_token``); without one they raise ``RuntimeError``. + +* ``set_group_recursive(group_id, recursive=True)`` sets the flag on one + group. +* ``list_user_authorizations(user_id)`` returns the user's profiles as + :class:`~glpi_python_client.UserAuthorization` rows: the row ``id``, the + ``profile_id`` and ``entity_id``, and the ``is_recursive``, ``is_dynamic`` + and ``is_default_profile`` flags. +* ``add_user_authorization(user_id, profile_id=..., entity_id=..., + recursive=False)`` grants a profile in an entity and returns the new row id. +* ``update_user_authorization(authorization_id, entity_id=None, + recursive=None)`` moves a row to another entity or changes its recursion; + with neither argument it raises ``GlpiValidationError`` without a request. +* ``delete_user_authorization(authorization_id)`` removes a row. + +The v1 API answers a refused write with a success status and a body such as +``[{"7": false, "message": "..."}]``. These helpers raise +``GlpiProtocolError`` with that message instead of returning as if it had +worked. + +.. code-block:: python + + await client.set_group_recursive(119) + rows = await client.list_user_authorizations(2568) + await client.update_user_authorization(rows[0].id, entity_id=493, recursive=True) + Documents linked to a followup ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index f3adcdd..13cfcb0 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -138,7 +138,7 @@ date_window, ) -__version__ = "0.6.6" +__version__ = "0.6.7" __all__ = [ "AsyncGlpiClient", diff --git a/pyproject.toml b/pyproject.toml index 5cef98b..b5b7749 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ exclude = [ [project] name = "glpi-python-client" -version = "0.6.6" +version = "0.6.7" description = "A typed Python client for GLPI ITSM APIs." readme = "README.md" requires-python = ">=3.11" diff --git a/skills/README.md b/skills/README.md index 637bb80..25e835c 100644 --- a/skills/README.md +++ b/skills/README.md @@ -12,7 +12,7 @@ These skills are source-tree project material. They are included in source distr | `glpi-ticket-workflow` | Search, fetch, create, update, or delete tickets | `GetTicket`, `PostTicket`, `PatchTicket`, `DeleteTicket` | | `glpi-ticket-timeline` | Read timeline records or write followups, tasks, solutions, and document links | `PostFollowup`, `PostTicketTask`, `PostSolution`, `PostTimelineDocument` (plus matching Get/Patch/Delete for followups/tasks/solutions; document reads return `GetDocument`, not a `GetTimelineDocument`) | | `glpi-document-workflow` | Manage document metadata, upload binary content, download binaries | `GetDocument`, `PostDocument`, `PatchDocument`, `DeleteDocument` | -| `glpi-user-location-provisioning` | Search and provision users, locations, entities, and groups | `GetUser`, `PostUser`, `GetLocation`, `PostLocation`, `GetEntity`, `PostEntity`, `GetGroup`, `PostGroup` | +| `glpi-user-location-provisioning` | Search and provision users, locations, entities, and groups; group recursion and user authorizations | `GetUser`, `PostUser`, `GetLocation`, `PostLocation`, `GetEntity`, `PostEntity`, `GetGroup`, `PostGroup`, `UserAuthorization` | | `glpi-reporting-and-context` | Aggregate ticket statistics, aggregate task durations, or load one ticket context bundle | `GlpiClient`, `GlpiTicketContext`, public enums | | `glpi-team-members` | List, add, or remove ticket team members | `GetTeamMember`, `PostTeamMember` | | `glpi-knowledge-base` | Search, read, or write KB articles, categories, comments, and revisions | `GetKBArticle`, `PostKBArticle`, `GetKBCategory`, `GetKBArticleComment`, `GetKBArticleRevision` | diff --git a/skills/glpi-asset-workflow/SKILL.md b/skills/glpi-asset-workflow/SKILL.md index e43ca0f..d0b8e23 100644 --- a/skills/glpi-asset-workflow/SKILL.md +++ b/skills/glpi-asset-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write assets." metadata: package: glpi-python-client - version: "0.6.6" + version: "0.6.7" --- # GLPI Asset Workflow diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md index 5ae3388..90d75c6 100644 --- a/skills/glpi-client-setup/SKILL.md +++ b/skills/glpi-client-setup/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to a GLPI v2 API, and valid GLPI credentials." metadata: package: glpi-python-client - version: "0.6.6" + version: "0.6.7" --- # GLPI Client Setup diff --git a/skills/glpi-contract-workflow/SKILL.md b/skills/glpi-contract-workflow/SKILL.md index d0a5797..63aed62 100644 --- a/skills/glpi-contract-workflow/SKILL.md +++ b/skills/glpi-contract-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write contracts." metadata: package: glpi-python-client - version: "0.6.6" + version: "0.6.7" --- # GLPI Contract Workflow diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index b734116..3083c56 100644 --- a/skills/glpi-document-workflow/SKILL.md +++ b/skills/glpi-document-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and v1 credentials configured on the client for binary uploads." metadata: package: glpi-python-client - version: "0.6.6" + version: "0.6.7" --- # GLPI Document Workflow diff --git a/skills/glpi-knowledge-base/SKILL.md b/skills/glpi-knowledge-base/SKILL.md index 0cefa96..4daf65d 100644 --- a/skills/glpi-knowledge-base/SKILL.md +++ b/skills/glpi-knowledge-base/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and — for category writes only — a legacy v1 session (v1_base_url + v1_user_token)." metadata: package: glpi-python-client - version: "0.6.6" + version: "0.6.7" --- # GLPI Knowledge Base diff --git a/skills/glpi-plugin-fields/SKILL.md b/skills/glpi-plugin-fields/SKILL.md index 6c87ba6..04a756a 100644 --- a/skills/glpi-plugin-fields/SKILL.md +++ b/skills/glpi-plugin-fields/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, the GLPI Fields plugin installed server-side, and a legacy v1 session (v1_base_url + v1_user_token) — every method in this family goes over the v1 API." metadata: package: glpi-python-client - version: "0.6.6" + version: "0.6.7" --- # GLPI Plugin Fields diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md index 3805a12..fe7d28d 100644 --- a/skills/glpi-reporting-and-context/SKILL.md +++ b/skills/glpi-reporting-and-context/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read tickets, tasks, users, entities, and timeline records." metadata: package: glpi-python-client - version: "0.6.6" + version: "0.6.7" --- # GLPI Reporting And Context diff --git a/skills/glpi-team-members/SKILL.md b/skills/glpi-team-members/SKILL.md index 994dde8..397c955 100644 --- a/skills/glpi-team-members/SKILL.md +++ b/skills/glpi-team-members/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to manage ticket teams." metadata: package: glpi-python-client - version: "0.6.6" + version: "0.6.7" --- # GLPI Team Members diff --git a/skills/glpi-ticket-timeline/SKILL.md b/skills/glpi-ticket-timeline/SKILL.md index 1a148f4..28fec8f 100644 --- a/skills/glpi-ticket-timeline/SKILL.md +++ b/skills/glpi-ticket-timeline/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, and network access to the GLPI v2 API." metadata: package: glpi-python-client - version: "0.6.6" + version: "0.6.7" --- # GLPI Ticket Timeline diff --git a/skills/glpi-ticket-workflow/SKILL.md b/skills/glpi-ticket-workflow/SKILL.md index 671534b..fb8c588 100644 --- a/skills/glpi-ticket-workflow/SKILL.md +++ b/skills/glpi-ticket-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials accepted by GlpiClient." metadata: package: glpi-python-client - version: "0.6.6" + version: "0.6.7" --- # GLPI Ticket Workflow diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md index 20532c5..1657b3d 100644 --- a/skills/glpi-user-location-provisioning/SKILL.md +++ b/skills/glpi-user-location-provisioning/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write users, locations, and entities." metadata: package: glpi-python-client - version: "0.6.6" + version: "0.6.7" --- # GLPI User, Location, And Entity Provisioning @@ -22,6 +22,7 @@ Users live under `/Administration/User`, entities under `/Administration/Entity` 4. Create with `create_user(PostUser(...))`, `create_location(PostLocation(...))`, or `create_entity(PostEntity(...))`. Each returns the new ID. 5. Update with `update_user(user_id, PatchUser(...))`, `update_location(location_id, PatchLocation(...))`, or `update_entity(entity_id, PatchEntity(...))`. 6. Delete with `delete_user(user_id, force=True|False|None)` and the matching `delete_location` / `delete_entity` helpers. +7. Group recursion and user authorizations are not in the v2 API (its `Group` schema has no `is_recursive`, and there is no route for a user's profile/entity rows), so they go through the legacy v1 session and need `v1_base_url` / `v1_user_token`; without them each raises `RuntimeError`. `set_group_recursive(group_id, recursive=True)` sets the flag on a group. `list_user_authorizations(user_id)` returns `UserAuthorization` rows (`id`, `user_id`, `profile_id`, `entity_id`, `is_recursive`, `is_dynamic`, `is_default_profile`); `add_user_authorization(user_id, profile_id=..., entity_id=..., recursive=False)` returns the new row id; `update_user_authorization(authorization_id, entity_id=None, recursive=None)` changes only what you pass and raises `GlpiValidationError` with neither; `delete_user_authorization(authorization_id)` removes a row. A v1 write answered `false` raises `GlpiProtocolError` with the server's message. ## Examples From e63678872be2eb83c0478b9b7fdf2f11c1068667 Mon Sep 17 00:00:00 2001 From: baraline Date: Sat, 31 Oct 2026 00:30:33 +0100 Subject: [PATCH 3/3] List ensure_v1_write_accepted among the payload module's exports Co-Authored-By: Claude Opus 5.5 --- glpi_python_client/_async/clients/commons/_payloads.py | 2 +- glpi_python_client/_sync/clients/commons/_payloads.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/glpi_python_client/_async/clients/commons/_payloads.py b/glpi_python_client/_async/clients/commons/_payloads.py index 05172f3..bf70d93 100644 --- a/glpi_python_client/_async/clients/commons/_payloads.py +++ b/glpi_python_client/_async/clients/commons/_payloads.py @@ -95,7 +95,7 @@ def model_from_payload( return model_class.model_validate(payload, context=context) -__all__ = ["model_from_payload", "model_to_payload"] +__all__ = ["ensure_v1_write_accepted", "model_from_payload", "model_to_payload"] def ensure_v1_write_accepted(payload: object, description: str) -> None: diff --git a/glpi_python_client/_sync/clients/commons/_payloads.py b/glpi_python_client/_sync/clients/commons/_payloads.py index 05172f3..bf70d93 100644 --- a/glpi_python_client/_sync/clients/commons/_payloads.py +++ b/glpi_python_client/_sync/clients/commons/_payloads.py @@ -95,7 +95,7 @@ def model_from_payload( return model_class.model_validate(payload, context=context) -__all__ = ["model_from_payload", "model_to_payload"] +__all__ = ["ensure_v1_write_accepted", "model_from_payload", "model_to_payload"] def ensure_v1_write_accepted(payload: object, description: str) -> None: