diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e354f6..b14c396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ 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.6 — 2026-10-28 + +A minor addition: the GLPI v2 `/Administration/Group` routes, so a caller can +find and create groups. Existing methods are unchanged. + +### Added + +- **`search_groups`, `iter_search_groups`, `get_group`, `create_group`, + `update_group` and `delete_group`** on both clients, from a new `GroupMixin` + and the `GROUP_ENDPOINT` constant. +- **`skip_entity: bool = False`** on `search_groups` and `iter_search_groups`, + as on `search_users`: `True` omits the `GLPI-Entity`, `GLPI-Profile` and + `GLPI-Entity-Recursive` headers so the search spans every entity the caller + can see. The other group methods always send them. +- **`GetGroup`, `PostGroup`, `PatchGroup` and `DeleteGroup`**, modelled on + `components.schemas.Group` (parent, entity, the `maybe_*` / `may_contain_*` + flags, LDAP fields and timestamps), exported from the package root and from + `glpi_python_client.models`. + ## 0.6.5 — 2026-10-14 A patch release that lets a caller find the Document behind an image GLPI left diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 9fe7a36..edaf0c1 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -486,6 +486,29 @@ Entities :undoc-members: :show-inheritance: +Groups +------ + +.. autoclass:: GetGroup + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: PostGroup + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: PatchGroup + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: DeleteGroup + :members: + :undoc-members: + :show-inheritance: + Plugin: Fields (custom fields) ------------------------------ diff --git a/docs/user_guide.rst b/docs/user_guide.rst index be755ca..635a76d 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -378,6 +378,7 @@ The full set is re-exported from the package root, including ``GetUser`` / ``PostUser`` / ``PatchUser`` / ``DeleteUser``, ``GetLocation`` / ``PostLocation`` / ``PatchLocation`` / ``DeleteLocation``, ``GetEntity`` / ``PostEntity`` / ``PatchEntity`` / ``DeleteEntity``, +``GetGroup`` / ``PostGroup`` / ``PatchGroup`` / ``DeleteGroup``, ``GetFollowup``, ``GetTicketTask``, ``GetSolution``, ``GetTimelineDocument``, ``GetTeamMember``, and ``GetDocument`` together with their post / patch / delete variants. The read-only ``GetDocumentItem`` and ``FollowupDocuments`` @@ -1152,6 +1153,7 @@ batches until the API returns fewer rows than the requested * :meth:`GlpiClient.iter_search_tickets` * :meth:`GlpiClient.iter_search_users` * :meth:`GlpiClient.iter_search_entities` +* :meth:`GlpiClient.iter_search_groups` .. code-block:: python diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index 386ed73..2093e20 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -40,6 +40,7 @@ DeleteDocument, DeleteEntity, DeleteFollowup, + DeleteGroup, DeleteKBArticle, DeleteKBArticleComment, DeleteKBCategory, @@ -60,6 +61,7 @@ GetDocumentItem, GetEntity, GetFollowup, + GetGroup, GetKBArticle, GetKBArticleComment, GetKBArticleRevision, @@ -96,6 +98,7 @@ PatchDocument, PatchEntity, PatchFollowup, + PatchGroup, PatchKBArticle, PatchKBArticleComment, PatchKBCategory, @@ -114,6 +117,7 @@ PostDocument, PostEntity, PostFollowup, + PostGroup, PostKBArticle, PostKBArticleComment, PostKBCategory, @@ -133,7 +137,7 @@ date_window, ) -__version__ = "0.6.5" +__version__ = "0.6.6" __all__ = [ "AsyncGlpiClient", @@ -145,6 +149,7 @@ "DeleteDocument", "DeleteEntity", "DeleteFollowup", + "DeleteGroup", "DeleteKBArticle", "DeleteKBArticleComment", "DeleteKBCategory", @@ -165,6 +170,7 @@ "GetDocumentItem", "GetEntity", "GetFollowup", + "GetGroup", "GetKBArticle", "GetKBArticleComment", "GetKBArticleRevision", @@ -212,6 +218,7 @@ "PatchDocument", "PatchEntity", "PatchFollowup", + "PatchGroup", "PatchKBArticle", "PatchKBArticleComment", "PatchKBCategory", @@ -230,6 +237,7 @@ "PostDocument", "PostEntity", "PostFollowup", + "PostGroup", "PostKBArticle", "PostKBArticleComment", "PostKBCategory", diff --git a/glpi_python_client/_async/clients/api/__init__.py b/glpi_python_client/_async/clients/api/__init__.py index ecb861b..d373e5c 100644 --- a/glpi_python_client/_async/clients/api/__init__.py +++ b/glpi_python_client/_async/clients/api/__init__.py @@ -11,6 +11,7 @@ from glpi_python_client._async.clients.api.administration import ( EntityMixin, + GroupMixin, UserMixin, ) from glpi_python_client._async.clients.api.assets import ComputerMixin @@ -49,6 +50,7 @@ "DocumentMixin", "EntityMixin", "FollowupMixin", + "GroupMixin", "KBArticleCommentMixin", "KBArticleMixin", "KBArticleRevisionMixin", diff --git a/glpi_python_client/_async/clients/api/administration/__init__.py b/glpi_python_client/_async/clients/api/administration/__init__.py index 7a5fa7f..7b2b7d4 100644 --- a/glpi_python_client/_async/clients/api/administration/__init__.py +++ b/glpi_python_client/_async/clients/api/administration/__init__.py @@ -1,6 +1,6 @@ """GLPI ``/Administration`` mixins for the GLPI client. -The submodules expose the user and entity mixins used by this tree's +The submodules expose the user, entity and group mixins used by this tree's client -- :class:`glpi_python_client.AsyncGlpiClient` on the async surface, :class:`glpi_python_client.GlpiClient` on the generated one. """ @@ -8,6 +8,7 @@ from __future__ import annotations from glpi_python_client._async.clients.api.administration._entity import EntityMixin +from glpi_python_client._async.clients.api.administration._group import GroupMixin from glpi_python_client._async.clients.api.administration._user import UserMixin -__all__ = ["EntityMixin", "UserMixin"] +__all__ = ["EntityMixin", "GroupMixin", "UserMixin"] diff --git a/glpi_python_client/_async/clients/api/administration/_group.py b/glpi_python_client/_async/clients/api/administration/_group.py new file mode 100644 index 0000000..1aa8442 --- /dev/null +++ b/glpi_python_client/_async/clients/api/administration/_group.py @@ -0,0 +1,218 @@ +"""GLPI ``/Administration/Group`` mixin. + +The mixin exposes search, fetch, create, update, and delete helpers for the +GLPI group resource. Every call sends the client's ``GLPI-Entity`` header +(and the profile and recursion headers that travel with it) except where a +search is told to skip it with ``skip_entity=True``. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +from glpi_python_client._async.clients.commons._constants import GROUP_ENDPOINT, GlpiId +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.administration._group import ( + DeleteGroup, + GetGroup, + PatchGroup, + PostGroup, +) + + +class GroupMixin(TransportMixin): + """CRUD helpers for ``/Administration/Group``.""" + + async def search_groups( + self, + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + skip_entity: bool = False, + ) -> list[GetGroup]: + """Search GLPI groups with an optional RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter, + for example ``"name==Support"``. Empty by default. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + skip_entity : bool, optional + When ``True`` the ``GLPI-Entity`` header is omitted so the + search spans every entity the caller has access to. + + Returns + ------- + list[GetGroup] + Groups matching the filter. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + if rsql_filter: + params["filter"] = rsql_filter + return await self._resource_list( + GROUP_ENDPOINT, GetGroup, params=params, skip_entity=skip_entity + ) + + async def iter_search_groups( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + skip_entity: bool = False, + ) -> AsyncIterator[list[GetGroup]]: + """Yield successive pages of GLPI groups until exhausted. + + Pagination advances the ``start`` offset after each batch and stops + when the server returns fewer items than ``batch_size``. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + batch_size : int, optional + Number of records requested per page (default 50). + skip_entity : bool, optional + When ``True`` the ``GLPI-Entity`` header is omitted so the + search spans every entity the caller has access to. + + Yields + ------ + list[GetGroup] + One page of groups per iteration. The last yielded batch may + be shorter than ``batch_size``. + """ + + start = 0 + while True: + batch = await self.search_groups( + rsql_filter, + limit=batch_size, + start=start, + skip_entity=skip_entity, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + + async def get_group(self, group_id: GlpiId) -> GetGroup: + """Fetch one GLPI group by identifier. + + Parameters + ---------- + group_id : GlpiId + Numeric identifier of the group to retrieve. + + Returns + ------- + GetGroup + Validated group payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{GROUP_ENDPOINT}/{group_id}", + GetGroup, + failure_message=f"Failed to get group {group_id}", + ) + + async def create_group(self, group: PostGroup) -> int: + """Create one GLPI group. + + Parameters + ---------- + group : PostGroup + Request body describing the group to create. + + Returns + ------- + int + Identifier assigned by the GLPI server. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + GlpiProtocolError + If the create response is missing the ``id`` field. + """ + + return await self._resource_create( + GROUP_ENDPOINT, + group, + failure_message="Failed to create group", + missing_message="GLPI group create response did not include an ID", + log_message_factory=lambda new_id: f"GLPI API created group {new_id}", + ) + + async def update_group(self, group_id: GlpiId, group: PatchGroup) -> None: + """Update one GLPI group with a partial body. + + Parameters + ---------- + group_id : GlpiId + Numeric identifier of the group to update. + group : PatchGroup + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_update( + f"{GROUP_ENDPOINT}/{group_id}", + group, + failure_message=f"Failed to update group {group_id}", + log_message=f"GLPI API updated group {group_id}", + ) + + async def delete_group( + self, group_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one GLPI group by identifier. + + Parameters + ---------- + group_id : GlpiId + Numeric identifier of the group to delete. + force : bool | None, optional + When ``True`` the group is permanently deleted instead of + being moved to the trash. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_delete( + f"{GROUP_ENDPOINT}/{group_id}", + failure_message=f"Failed to delete group {group_id}", + log_message=f"GLPI API deleted group {group_id}", + force=force, + delete_model_cls=DeleteGroup, + ) + + +__all__ = ["GroupMixin"] 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 new file mode 100644 index 0000000..7cffa67 --- /dev/null +++ b/glpi_python_client/_async/clients/api/administration/tests/test_group.py @@ -0,0 +1,157 @@ +"""Unit tests for the ``Administration/Group`` endpoint mixin.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchGroup, PostGroup +from glpi_python_client._async._testing import ( + FailingTransportRecorder, + TransportRecorder, +) +from glpi_python_client.models.api_schema._common import IdNameRef + + +async def test_search_groups_sends_the_filter(client: Any) -> None: + """``search_groups`` forwards filter, limit and start; header kept.""" + + rec = TransportRecorder(get_payload=[{"id": 7, "name": "Team A"}]) + rec.install(client) + groups = await client.search_groups("name==x", limit=10, start=5) + assert groups[0].id == 7 + call = rec.calls[0] + assert call["endpoint"] == "Administration/Group" + assert call["params"] == {"limit": 10, "start": 5, "filter": "name==x"} + assert call["skip_entity"] is False + + +async def test_search_groups_forwards_skip_entity(client: Any) -> None: + """``skip_entity=True`` reaches the transport.""" + + rec = TransportRecorder(get_payload=[]) + rec.install(client) + await client.search_groups("name==x", skip_entity=True) + assert rec.calls[0]["skip_entity"] is True + + +async def test_get_group_targets_group_endpoint(client: Any) -> None: + """``get_group`` reads the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 7, "name": "Team A"}) + rec.install(client) + group = await client.get_group(7) + assert group.id == 7 + assert rec.calls[0]["endpoint"] == "Administration/Group/7" + assert rec.calls[0]["skip_entity"] is False + + +async def test_create_group_posts_the_body_and_returns_the_id(client: Any) -> None: + """``create_group`` posts entity and ``maybe_assigned`` and returns the id.""" + + rec = TransportRecorder(post_payload={"id": 42}) + rec.install(client) + group_id = await client.create_group( + PostGroup(name="Team A", entity=IdNameRef(id=492), maybe_assigned=True) + ) + assert group_id == 42 + call = rec.calls[0] + assert call["endpoint"] == "Administration/Group" + assert call["json"]["entity"]["id"] == 492 + assert call["json"]["maybe_assigned"] is True + assert call["json"]["name"] == "Team A" + assert call["skip_entity"] is False + + +async def test_update_group_sends_patch(client: Any) -> None: + """``update_group`` patches the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.update_group(7, PatchGroup(name="renamed")) + assert rec.calls[0]["endpoint"] == "Administration/Group/7" + assert rec.calls[0]["json"] == {"name": "renamed"} + + +async def test_delete_group_supports_force_flag(client: Any) -> None: + """``delete_group(force=True)`` ships the force flag.""" + + rec = TransportRecorder() + rec.install(client) + await client.delete_group(7, force=True) + call = rec.calls[0] + assert call["endpoint"] == "Administration/Group/7" + assert call["json"] == {"force": True} + + +@pytest.mark.parametrize("call", [lambda c: c.get_group(1)]) +async def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize("call", [lambda c: c.create_group(PostGroup(name="x"))]) +async def test_create_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every create helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize("call", [lambda c: c.update_group(1, PatchGroup(name="x"))]) +async def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +@pytest.mark.parametrize("call", [lambda c: c.delete_group(1, force=True)]) +async def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + await call(client) + + +async def test_iter_search_groups_pages_until_short(client: Any) -> None: + """Iteration stops after the first short batch and forwards skip_entity.""" + + responses = [ + [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], + [{"id": 3, "name": "c"}], + ] + seen: list[tuple[int, bool]] = [] + + async def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + skip_entity: bool = False, + ) -> list[Any]: + seen.append((start, skip_entity)) + return responses[len(seen) - 1] + + client.search_groups = fake_search # type: ignore[method-assign] + batches = [ + b async for b in client.iter_search_groups("", batch_size=2, skip_entity=True) + ] + assert seen == [(0, True), (2, True)] + assert sum(len(b) for b in batches) == 3 diff --git a/glpi_python_client/_async/clients/client.py b/glpi_python_client/_async/clients/client.py index 15f4c3e..8f00dcc 100644 --- a/glpi_python_client/_async/clients/client.py +++ b/glpi_python_client/_async/clients/client.py @@ -25,6 +25,7 @@ DocumentMixin, EntityMixin, FollowupMixin, + GroupMixin, KBArticleCommentMixin, KBArticleMixin, KBArticleRevisionMixin, @@ -59,6 +60,7 @@ class AsyncGlpiClient( ComputerMixin, UserMixin, EntityMixin, + GroupMixin, LocationMixin, ContractTypeMixin, KBCategoryMixin, diff --git a/glpi_python_client/_async/clients/commons/_constants.py b/glpi_python_client/_async/clients/commons/_constants.py index 1f8be0a..0050153 100644 --- a/glpi_python_client/_async/clients/commons/_constants.py +++ b/glpi_python_client/_async/clients/commons/_constants.py @@ -19,6 +19,7 @@ # administration/ USER_ENDPOINT = "Administration/User" ENTITY_ENDPOINT = "Administration/Entity" +GROUP_ENDPOINT = "Administration/Group" # dropdowns/ CONTRACT_TYPE_ENDPOINT = "Dropdowns/ContractType" @@ -52,6 +53,7 @@ "DOCUMENT_ENDPOINT", "ENTITY_ENDPOINT", "FOLLOWUP_SUFFIX", + "GROUP_ENDPOINT", "KB_ARTICLE_ENDPOINT", "KB_CATEGORY_ENDPOINT", "KB_COMMENT_SUFFIX", diff --git a/glpi_python_client/_sync/clients/api/__init__.py b/glpi_python_client/_sync/clients/api/__init__.py index 5444f6b..e009660 100644 --- a/glpi_python_client/_sync/clients/api/__init__.py +++ b/glpi_python_client/_sync/clients/api/__init__.py @@ -11,6 +11,7 @@ from glpi_python_client._sync.clients.api.administration import ( EntityMixin, + GroupMixin, UserMixin, ) from glpi_python_client._sync.clients.api.assets import ComputerMixin @@ -49,6 +50,7 @@ "DocumentMixin", "EntityMixin", "FollowupMixin", + "GroupMixin", "KBArticleCommentMixin", "KBArticleMixin", "KBArticleRevisionMixin", diff --git a/glpi_python_client/_sync/clients/api/administration/__init__.py b/glpi_python_client/_sync/clients/api/administration/__init__.py index 3780e6f..2141f74 100644 --- a/glpi_python_client/_sync/clients/api/administration/__init__.py +++ b/glpi_python_client/_sync/clients/api/administration/__init__.py @@ -1,6 +1,6 @@ """GLPI ``/Administration`` mixins for the GLPI client. -The submodules expose the user and entity mixins used by this tree's +The submodules expose the user, entity and group mixins used by this tree's client -- :class:`glpi_python_client.AsyncGlpiClient` on the async surface, :class:`glpi_python_client.GlpiClient` on the generated one. """ @@ -8,6 +8,7 @@ from __future__ import annotations from glpi_python_client._sync.clients.api.administration._entity import EntityMixin +from glpi_python_client._sync.clients.api.administration._group import GroupMixin from glpi_python_client._sync.clients.api.administration._user import UserMixin -__all__ = ["EntityMixin", "UserMixin"] +__all__ = ["EntityMixin", "GroupMixin", "UserMixin"] diff --git a/glpi_python_client/_sync/clients/api/administration/_group.py b/glpi_python_client/_sync/clients/api/administration/_group.py new file mode 100644 index 0000000..cb54b96 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/administration/_group.py @@ -0,0 +1,218 @@ +"""GLPI ``/Administration/Group`` mixin. + +The mixin exposes search, fetch, create, update, and delete helpers for the +GLPI group resource. Every call sends the client's ``GLPI-Entity`` header +(and the profile and recursion headers that travel with it) except where a +search is told to skip it with ``skip_entity=True``. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +from glpi_python_client._sync.clients.commons._constants import GROUP_ENDPOINT, GlpiId +from glpi_python_client._sync.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.administration._group import ( + DeleteGroup, + GetGroup, + PatchGroup, + PostGroup, +) + + +class GroupMixin(TransportMixin): + """CRUD helpers for ``/Administration/Group``.""" + + def search_groups( + self, + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + skip_entity: bool = False, + ) -> list[GetGroup]: + """Search GLPI groups with an optional RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter, + for example ``"name==Support"``. Empty by default. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + skip_entity : bool, optional + When ``True`` the ``GLPI-Entity`` header is omitted so the + search spans every entity the caller has access to. + + Returns + ------- + list[GetGroup] + Groups matching the filter. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + if rsql_filter: + params["filter"] = rsql_filter + return self._resource_list( + GROUP_ENDPOINT, GetGroup, params=params, skip_entity=skip_entity + ) + + def iter_search_groups( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + skip_entity: bool = False, + ) -> Iterator[list[GetGroup]]: + """Yield successive pages of GLPI groups until exhausted. + + Pagination advances the ``start`` offset after each batch and stops + when the server returns fewer items than ``batch_size``. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + batch_size : int, optional + Number of records requested per page (default 50). + skip_entity : bool, optional + When ``True`` the ``GLPI-Entity`` header is omitted so the + search spans every entity the caller has access to. + + Yields + ------ + list[GetGroup] + One page of groups per iteration. The last yielded batch may + be shorter than ``batch_size``. + """ + + start = 0 + while True: + batch = self.search_groups( + rsql_filter, + limit=batch_size, + start=start, + skip_entity=skip_entity, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + + def get_group(self, group_id: GlpiId) -> GetGroup: + """Fetch one GLPI group by identifier. + + Parameters + ---------- + group_id : GlpiId + Numeric identifier of the group to retrieve. + + Returns + ------- + GetGroup + Validated group payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return self._resource_get( + f"{GROUP_ENDPOINT}/{group_id}", + GetGroup, + failure_message=f"Failed to get group {group_id}", + ) + + def create_group(self, group: PostGroup) -> int: + """Create one GLPI group. + + Parameters + ---------- + group : PostGroup + Request body describing the group to create. + + Returns + ------- + int + Identifier assigned by the GLPI server. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + GlpiProtocolError + If the create response is missing the ``id`` field. + """ + + return self._resource_create( + GROUP_ENDPOINT, + group, + failure_message="Failed to create group", + missing_message="GLPI group create response did not include an ID", + log_message_factory=lambda new_id: f"GLPI API created group {new_id}", + ) + + def update_group(self, group_id: GlpiId, group: PatchGroup) -> None: + """Update one GLPI group with a partial body. + + Parameters + ---------- + group_id : GlpiId + Numeric identifier of the group to update. + group : PatchGroup + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + self._resource_update( + f"{GROUP_ENDPOINT}/{group_id}", + group, + failure_message=f"Failed to update group {group_id}", + log_message=f"GLPI API updated group {group_id}", + ) + + def delete_group( + self, group_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one GLPI group by identifier. + + Parameters + ---------- + group_id : GlpiId + Numeric identifier of the group to delete. + force : bool | None, optional + When ``True`` the group is permanently deleted instead of + being moved to the trash. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + self._resource_delete( + f"{GROUP_ENDPOINT}/{group_id}", + failure_message=f"Failed to delete group {group_id}", + log_message=f"GLPI API deleted group {group_id}", + force=force, + delete_model_cls=DeleteGroup, + ) + + +__all__ = ["GroupMixin"] 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 new file mode 100644 index 0000000..77e1917 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/administration/tests/test_group.py @@ -0,0 +1,157 @@ +"""Unit tests for the ``Administration/Group`` endpoint mixin.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +from glpi_python_client import PatchGroup, PostGroup +from glpi_python_client._sync._testing import ( + FailingTransportRecorder, + TransportRecorder, +) +from glpi_python_client.models.api_schema._common import IdNameRef + + +def test_search_groups_sends_the_filter(client: Any) -> None: + """``search_groups`` forwards filter, limit and start; header kept.""" + + rec = TransportRecorder(get_payload=[{"id": 7, "name": "Team A"}]) + rec.install(client) + groups = client.search_groups("name==x", limit=10, start=5) + assert groups[0].id == 7 + call = rec.calls[0] + assert call["endpoint"] == "Administration/Group" + assert call["params"] == {"limit": 10, "start": 5, "filter": "name==x"} + assert call["skip_entity"] is False + + +def test_search_groups_forwards_skip_entity(client: Any) -> None: + """``skip_entity=True`` reaches the transport.""" + + rec = TransportRecorder(get_payload=[]) + rec.install(client) + client.search_groups("name==x", skip_entity=True) + assert rec.calls[0]["skip_entity"] is True + + +def test_get_group_targets_group_endpoint(client: Any) -> None: + """``get_group`` reads the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 7, "name": "Team A"}) + rec.install(client) + group = client.get_group(7) + assert group.id == 7 + assert rec.calls[0]["endpoint"] == "Administration/Group/7" + assert rec.calls[0]["skip_entity"] is False + + +def test_create_group_posts_the_body_and_returns_the_id(client: Any) -> None: + """``create_group`` posts entity and ``maybe_assigned`` and returns the id.""" + + rec = TransportRecorder(post_payload={"id": 42}) + rec.install(client) + group_id = client.create_group( + PostGroup(name="Team A", entity=IdNameRef(id=492), maybe_assigned=True) + ) + assert group_id == 42 + call = rec.calls[0] + assert call["endpoint"] == "Administration/Group" + assert call["json"]["entity"]["id"] == 492 + assert call["json"]["maybe_assigned"] is True + assert call["json"]["name"] == "Team A" + assert call["skip_entity"] is False + + +def test_update_group_sends_patch(client: Any) -> None: + """``update_group`` patches the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.update_group(7, PatchGroup(name="renamed")) + assert rec.calls[0]["endpoint"] == "Administration/Group/7" + assert rec.calls[0]["json"] == {"name": "renamed"} + + +def test_delete_group_supports_force_flag(client: Any) -> None: + """``delete_group(force=True)`` ships the force flag.""" + + rec = TransportRecorder() + rec.install(client) + client.delete_group(7, force=True) + call = rec.calls[0] + assert call["endpoint"] == "Administration/Group/7" + assert call["json"] == {"force": True} + + +@pytest.mark.parametrize("call", [lambda c: c.get_group(1)]) +def test_get_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every read helper raises on a non-success status.""" + + FailingTransportRecorder(404).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize("call", [lambda c: c.create_group(PostGroup(name="x"))]) +def test_create_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every create helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize("call", [lambda c: c.update_group(1, PatchGroup(name="x"))]) +def test_update_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every update helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +@pytest.mark.parametrize("call", [lambda c: c.delete_group(1, force=True)]) +def test_delete_helpers_raise_on_failure_status( + client: Any, call: Callable[[Any], Any] +) -> None: + """Every delete helper raises on a non-success status.""" + + FailingTransportRecorder(500).install(client) + with pytest.raises(ValueError): + call(client) + + +def test_iter_search_groups_pages_until_short(client: Any) -> None: + """Iteration stops after the first short batch and forwards skip_entity.""" + + responses = [ + [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], + [{"id": 3, "name": "c"}], + ] + seen: list[tuple[int, bool]] = [] + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + skip_entity: bool = False, + ) -> list[Any]: + seen.append((start, skip_entity)) + return responses[len(seen) - 1] + + client.search_groups = fake_search # type: ignore[method-assign] + batches = [ + b for b in client.iter_search_groups("", batch_size=2, skip_entity=True) + ] + assert seen == [(0, True), (2, True)] + assert sum(len(b) for b in batches) == 3 diff --git a/glpi_python_client/_sync/clients/client.py b/glpi_python_client/_sync/clients/client.py index 95fc0ef..55ba25a 100644 --- a/glpi_python_client/_sync/clients/client.py +++ b/glpi_python_client/_sync/clients/client.py @@ -25,6 +25,7 @@ DocumentMixin, EntityMixin, FollowupMixin, + GroupMixin, KBArticleCommentMixin, KBArticleMixin, KBArticleRevisionMixin, @@ -59,6 +60,7 @@ class GlpiClient( ComputerMixin, UserMixin, EntityMixin, + GroupMixin, LocationMixin, ContractTypeMixin, KBCategoryMixin, diff --git a/glpi_python_client/_sync/clients/commons/_constants.py b/glpi_python_client/_sync/clients/commons/_constants.py index 1f8be0a..0050153 100644 --- a/glpi_python_client/_sync/clients/commons/_constants.py +++ b/glpi_python_client/_sync/clients/commons/_constants.py @@ -19,6 +19,7 @@ # administration/ USER_ENDPOINT = "Administration/User" ENTITY_ENDPOINT = "Administration/Entity" +GROUP_ENDPOINT = "Administration/Group" # dropdowns/ CONTRACT_TYPE_ENDPOINT = "Dropdowns/ContractType" @@ -52,6 +53,7 @@ "DOCUMENT_ENDPOINT", "ENTITY_ENDPOINT", "FOLLOWUP_SUFFIX", + "GROUP_ENDPOINT", "KB_ARTICLE_ENDPOINT", "KB_CATEGORY_ENDPOINT", "KB_COMMENT_SUFFIX", diff --git a/glpi_python_client/models/__init__.py b/glpi_python_client/models/__init__.py index ddf4cf3..92043e3 100644 --- a/glpi_python_client/models/__init__.py +++ b/glpi_python_client/models/__init__.py @@ -22,12 +22,16 @@ ) from glpi_python_client.models.api_schema.administration import ( DeleteEntity, + DeleteGroup, DeleteUser, GetEntity, + GetGroup, GetUser, PatchEntity, + PatchGroup, PatchUser, PostEntity, + PostGroup, PostUser, ) from glpi_python_client.models.api_schema.assets import ( @@ -141,6 +145,7 @@ "DeleteDocument", "DeleteEntity", "DeleteFollowup", + "DeleteGroup", "DeleteKBArticle", "DeleteKBArticleComment", "DeleteKBCategory", @@ -161,6 +166,7 @@ "GetDocumentItem", "GetEntity", "GetFollowup", + "GetGroup", "GetKBArticle", "GetKBArticleComment", "GetKBArticleRevision", @@ -197,6 +203,7 @@ "PatchDocument", "PatchEntity", "PatchFollowup", + "PatchGroup", "PatchKBArticle", "PatchKBArticleComment", "PatchKBCategory", @@ -215,6 +222,7 @@ "PostDocument", "PostEntity", "PostFollowup", + "PostGroup", "PostKBArticle", "PostKBArticleComment", "PostKBCategory", diff --git a/glpi_python_client/models/api_schema/administration/__init__.py b/glpi_python_client/models/api_schema/administration/__init__.py index 0d71f00..b152d38 100644 --- a/glpi_python_client/models/api_schema/administration/__init__.py +++ b/glpi_python_client/models/api_schema/administration/__init__.py @@ -1,7 +1,8 @@ """Administration entity schemas mirroring the ``/Administration`` endpoints. -This subpackage exposes the per-verb Pydantic models for ``User`` and -``Entity``. Each module follows the same ``Get``/``Post``/ +This subpackage exposes the per-verb Pydantic models for ``User``, +``Entity`` and ``Group``. Each module follows the same +``Get``/``Post``/ ``Patch``/``Delete`` naming convention. """ @@ -11,6 +12,12 @@ PatchEntity, PostEntity, ) +from glpi_python_client.models.api_schema.administration._group import ( + DeleteGroup, + GetGroup, + PatchGroup, + PostGroup, +) from glpi_python_client.models.api_schema.administration._user import ( DeleteUser, GetUser, @@ -20,11 +27,15 @@ __all__ = [ "DeleteEntity", + "DeleteGroup", "DeleteUser", "GetEntity", + "GetGroup", "GetUser", "PatchEntity", + "PatchGroup", "PatchUser", "PostEntity", + "PostGroup", "PostUser", ] diff --git a/glpi_python_client/models/api_schema/administration/_group.py b/glpi_python_client/models/api_schema/administration/_group.py new file mode 100644 index 0000000..4edcad2 --- /dev/null +++ b/glpi_python_client/models/api_schema/administration/_group.py @@ -0,0 +1,150 @@ +"""GLPI ``Group`` schemas for the ``/Administration/Group`` endpoints. + +The field layout mirrors ``components.schemas.Group`` from the GLPI OpenAPI +contract. Read-only contract fields are not declared on the request models; +a caller that supplies one anyway sends it through ``extra_payload`` and the +server decides. +""" + +from __future__ import annotations + +from datetime import datetime + +from glpi_python_client.models._base import GlpiModel +from glpi_python_client.models.api_schema._common import IdNameRef + + +class GetGroup(GlpiModel): + """Response shape returned by ``GET /Administration/Group`` endpoints. + + Mirrors ``components.schemas.Group``. All fields are optional because + the contract does not advertise a ``required`` array. + + Parameters + ---------- + id : int | None, optional + Native GLPI identifier (``readOnly``). + name : str | None, optional + Name of the group. + comment : str | None, optional + Free-form comment. + completename : str | None, optional + Full hierarchical path of the group (``readOnly``). + parent : IdNameRef | None, optional + Parent group in the hierarchy. + level : int | None, optional + Depth of the group in the hierarchy tree (``readOnly``). + entity : IdNameRef | None, optional + Entity the group belongs to. + code : str | None, optional + Group code (at most 255 characters). + ldap_field : str | None, optional + LDAP user attribute that may contain this group (memberof). + ldap_value : str | None, optional + LDAP attribute value to match for membership, usually the DN. + ldap_group_dn : str | None, optional + DN of the group in LDAP; only populated in certain cases. + maybe_requester : bool | None, optional + Can be a requester. + maybe_observer : bool | None, optional + Can be an observer. + maybe_assigned : bool | None, optional + Can be assigned (to tickets). + maybe_assigned_task : bool | None, optional + Can be assigned to tasks. + maybe_notified : bool | None, optional + Can be notified. + may_contain_items : bool | None, optional + Can contain items. + may_contain_users : bool | None, optional + Can contain users. + maybe_manager : bool | None, optional + Can be manager. + recursive_membership : bool | None, optional + Whether users of this group are implied members of all child + groups. Unrelated to entity recursion. + mfa_enforced : bool | None, optional + Whether two-factor authentication is enforced for members. + date_creation : datetime | None, optional + Creation timestamp. + date_mod : datetime | None, optional + Last modification timestamp. + """ + + id: int | None = None + name: str | None = None + comment: str | None = None + completename: str | None = None + parent: IdNameRef | None = None + level: int | None = None + entity: IdNameRef | None = None + code: str | None = None + ldap_field: str | None = None + ldap_value: str | None = None + ldap_group_dn: str | None = None + maybe_requester: bool | None = None + maybe_observer: bool | None = None + maybe_assigned: bool | None = None + maybe_assigned_task: bool | None = None + maybe_notified: bool | None = None + may_contain_items: bool | None = None + may_contain_users: bool | None = None + maybe_manager: bool | None = None + recursive_membership: bool | None = None + mfa_enforced: bool | None = None + date_creation: datetime | None = None + date_mod: datetime | None = None + + +class PostGroup(GlpiModel): + """Request body for ``POST /Administration/Group``. + + Read-only contract fields (``id``, ``completename``, ``level``) and the + server-set timestamps are not declared; see :class:`GetGroup` for the + meaning of each field. + """ + + name: str | None = None + comment: str | None = None + parent: IdNameRef | None = None + entity: IdNameRef | None = None + code: str | None = None + ldap_field: str | None = None + ldap_value: str | None = None + ldap_group_dn: str | None = None + maybe_requester: bool | None = None + maybe_observer: bool | None = None + maybe_assigned: bool | None = None + maybe_assigned_task: bool | None = None + maybe_notified: bool | None = None + may_contain_items: bool | None = None + may_contain_users: bool | None = None + maybe_manager: bool | None = None + recursive_membership: bool | None = None + mfa_enforced: bool | None = None + + +class PatchGroup(PostGroup): + """Request body for ``PATCH /Administration/Group/{id}``. + + The contract uses the same ``Group`` schema for create and + partial-update bodies; ``PatchGroup`` is kept distinct so client + mixins can express the intent of the operation explicitly. + """ + + +class DeleteGroup(GlpiModel): + """Query parameters for ``DELETE /Administration/Group/{id}``. + + Parameters + ---------- + force : bool | None, optional + When ``True``, permanently delete the group instead of moving the + record to the GLPI trash. When ``False`` or :data:`None`, the + server applies its default soft-delete behaviour. + """ + + force: bool | None = None + + +__all__ = ["DeleteGroup", "GetGroup", "PatchGroup", "PostGroup"] 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 95c5336..4b8cd33 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 @@ -143,3 +143,48 @@ def test_post_user_secret_serializer_none_propagates() -> None: payload = user.model_dump(exclude={"extra_payload"}) assert payload.get("password") is None assert payload.get("password2") is None + + +def test_get_group_validates_contract_payload() -> None: + """``GetGroup`` accepts the contract ``Group`` shape with nested refs.""" + + from glpi_python_client.models.api_schema.administration import GetGroup + + group = GetGroup.model_validate( + { + "id": 7, + "name": "Team A", + "completename": "Parent > Team A", + "level": 2, + "entity": {"id": 492, "name": "Client"}, + "parent": {"id": 3, "name": "Parent"}, + "maybe_assigned": True, + "date_mod": "2026-10-28T10:00:00+00:00", + } + ) + assert group.entity is not None and group.entity.id == 492 + assert group.parent is not None and group.parent.id == 3 + assert group.maybe_assigned is True + assert group.date_mod is not None + + +def test_post_group_leaves_read_only_fields_to_the_server() -> None: + """Read-only fields are not declared; they travel in ``extra_payload``.""" + + from glpi_python_client.models.api_schema.administration import PostGroup + + for forbidden in ("id", "completename", "level"): + group = PostGroup.model_validate({"name": "G", forbidden: "x"}) + assert group.extra_payload == {forbidden: "x"} + + +def test_patch_group_accepts_subset_and_delete_group_defaults() -> None: + """``PatchGroup`` takes a partial body; ``DeleteGroup.force`` defaults None.""" + + from glpi_python_client.models.api_schema.administration import ( + DeleteGroup, + PatchGroup, + ) + + PatchGroup.model_validate({"comment": "changed"}) + assert DeleteGroup().force is None diff --git a/glpi_python_client/testing/tests/test_exports.py b/glpi_python_client/testing/tests/test_exports.py index 116bbbd..a50e049 100644 --- a/glpi_python_client/testing/tests/test_exports.py +++ b/glpi_python_client/testing/tests/test_exports.py @@ -77,3 +77,22 @@ def test_asset_and_contract_models_exported_from_models() -> None: for name in _ASSET_AND_CONTRACT_MODELS: assert hasattr(models, name), name assert name in models.__all__, name + + +_GROUP_MODELS = ("GetGroup", "PostGroup", "PatchGroup", "DeleteGroup") + + +def test_group_models_exported_from_top_level() -> None: + """Every Group model is importable from ``glpi_python_client``.""" + + for name in _GROUP_MODELS: + assert hasattr(glpi_python_client, name), name + assert name in glpi_python_client.__all__, name + + +def test_group_models_exported_from_models_package() -> None: + """Every Group model is importable from ``glpi_python_client.models``.""" + + for name in _GROUP_MODELS: + assert hasattr(models, name), name + assert name in models.__all__, name diff --git a/pyproject.toml b/pyproject.toml index a6fd02c..5cef98b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ exclude = [ [project] name = "glpi-python-client" -version = "0.6.5" +version = "0.6.6" 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 0f801f4..637bb80 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, and entities | `GetUser`, `PostUser`, `GetLocation`, `PostLocation`, `GetEntity`, `PostEntity` | +| `glpi-user-location-provisioning` | Search and provision users, locations, entities, and groups | `GetUser`, `PostUser`, `GetLocation`, `PostLocation`, `GetEntity`, `PostEntity`, `GetGroup`, `PostGroup` | | `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 188d137..e43ca0f 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.5" + version: "0.6.6" --- # GLPI Asset Workflow diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md index 2d0ee23..5ae3388 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.5" + version: "0.6.6" --- # GLPI Client Setup diff --git a/skills/glpi-contract-workflow/SKILL.md b/skills/glpi-contract-workflow/SKILL.md index 704189b..d0a5797 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.5" + version: "0.6.6" --- # GLPI Contract Workflow diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index 1ab3445..b734116 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.5" + version: "0.6.6" --- # GLPI Document Workflow diff --git a/skills/glpi-knowledge-base/SKILL.md b/skills/glpi-knowledge-base/SKILL.md index 3505808..0cefa96 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.5" + version: "0.6.6" --- # GLPI Knowledge Base diff --git a/skills/glpi-plugin-fields/SKILL.md b/skills/glpi-plugin-fields/SKILL.md index 5fb9156..6c87ba6 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.5" + version: "0.6.6" --- # GLPI Plugin Fields diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md index 54719b0..3805a12 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.5" + version: "0.6.6" --- # GLPI Reporting And Context diff --git a/skills/glpi-team-members/SKILL.md b/skills/glpi-team-members/SKILL.md index 3d0fdb7..994dde8 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.5" + version: "0.6.6" --- # GLPI Team Members diff --git a/skills/glpi-ticket-timeline/SKILL.md b/skills/glpi-ticket-timeline/SKILL.md index 0893a29..1a148f4 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.5" + version: "0.6.6" --- # GLPI Ticket Timeline diff --git a/skills/glpi-ticket-workflow/SKILL.md b/skills/glpi-ticket-workflow/SKILL.md index ee7e0d0..671534b 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.5" + version: "0.6.6" --- # GLPI Ticket Workflow diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md index 7763019..20532c5 100644 --- a/skills/glpi-user-location-provisioning/SKILL.md +++ b/skills/glpi-user-location-provisioning/SKILL.md @@ -5,18 +5,18 @@ 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.5" + version: "0.6.6" --- # GLPI User, Location, And Entity Provisioning > The snippets below use `AsyncGlpiClient` (`async with` + `await`). Every method shown also exists on the synchronous `GlpiClient` with the same signature -- replace `async with` with `with`, drop the `await` keyword, and skip the surrounding `async def`/`asyncio.run` scaffolding. -Users live under `/Administration/User`, entities under `/Administration/Entity`, and locations under `/Dropdowns/Location`. Each resource family is exposed by the same `search_/get_/create_/update_/delete_` shape on `GlpiClient` with matching `Get`/`Post`/`Patch`/`Delete` Pydantic models. +Users live under `/Administration/User`, entities under `/Administration/Entity`, groups under `/Administration/Group`, and locations under `/Dropdowns/Location`. Each resource family is exposed by the same `search_/get_/create_/update_/delete_` shape on `GlpiClient` with matching `Get`/`Post`/`Patch`/`Delete` Pydantic models. ## Procedure 1. Create a `GlpiClient` with the correct entity/profile scope. -2. Search before creating duplicates: `search_users(rsql_filter, limit=..., start=..., skip_entity=False)`, `search_locations(rsql_filter, limit=..., start=...)`, `search_entities(rsql_filter, limit=..., start=...)`. Scope matters here: `search_users` and `search_locations` are narrowed by the client's `GLPI-Entity` / `GLPI-Profile` headers, so pass `skip_entity=True` to `search_users` (the only one of the three `search_*` helpers that has the flag — `iter_search_users` takes it too) to look across every entity the caller can see before deciding a user does not exist; `search_entities` always bypasses those headers. +2. Search before creating duplicates: `search_users(rsql_filter, limit=..., start=..., skip_entity=False)`, `search_locations(rsql_filter, limit=..., start=...)`, `search_entities(rsql_filter, limit=..., start=...)`, `search_groups(rsql_filter, limit=..., start=..., skip_entity=False)`. Scope matters here: `search_users`, `search_groups` and `search_locations` are narrowed by the client's `GLPI-Entity` / `GLPI-Profile` headers, so pass `skip_entity=True` to `search_users` or `search_groups` (the two `search_*` helpers that have the flag — `iter_search_users` and `iter_search_groups` take it too) to look across every entity the caller can see before deciding a user or group does not exist; `search_entities` always bypasses those headers. Groups are the only one of these with entity-scoped create too: `create_group(PostGroup(name=..., entity=IdNameRef(id=...), maybe_assigned=True))` places the group in that entity and makes it assignable to tickets; read it back with `get_group`, change it with `update_group(group_id, PatchGroup(...))`, remove it with `delete_group(group_id, force=...)`. To resolve a person by e-mail use `await client.find_user_by_email("a@b.test")`, which returns `GetUser | None` and defaults to `skip_entity=True` for the reason just given. 3. Fetch one record with `get_user(user_id)`, `get_location(location_id)`, or `get_entity(entity_id)`. 4. Create with `create_user(PostUser(...))`, `create_location(PostLocation(...))`, or `create_entity(PostEntity(...))`. Each returns the new ID.