diff --git a/.gitignore b/.gitignore index cb85322..7f56013 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,7 @@ CLAUDE.md docs/superpowers/* # Large generated GLPI API contract (kept locally, not tracked) -docs/glpi_api_contract.json +docs/api_contract/ # Coverage data: rewritten by every test run (and by the venv .pth hook). .coverage .coverage.* diff --git a/CHANGELOG.md b/CHANGELOG.md index b1dc065..b184fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,33 @@ 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/). +## Unreleased + +### Added + +- `Assets/Computer` endpoint support: `search_computers`, + `iter_search_computers`, `get_computer`, `create_computer`, + `update_computer`, `delete_computer`, and the `GetComputer` / + `PostComputer` / `PatchComputer` / `DeleteComputer` models. +- Computer-to-contract links: `list_computer_contracts`, + `get_computer_contract`, `link_computer_contract`, + `update_computer_contract`, `unlink_computer_contract`. The client sets + the link's `itemtype` itself, because the GLPI contract types it as a + free string. +- `Management/Contract` endpoint support, including the cost sub-resource + and the `Dropdowns/ContractType` dropdown. +- `GlpiContractRenewalType` for the contract's documented `renewal_type` + enum (no renewal, tacit, explicit). +- Two agent skills: `glpi-asset-workflow` and `glpi-contract-workflow`. + +### Notes + +- `Contract.date_begin` is modelled as `datetime.date`, not `datetime`. + The GLPI contract declares `format: date`, and keeping it a plain date + keeps it out of the server-clock conversion that rewrites aware + timestamps — which on a date-only field could roll the value to the + previous or next day. + ## 0.5.0 — 2026-09-08 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15be8e0..54192c6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,3 +63,19 @@ python -m sphinx -W --keep-going -b html docs docs/_build/html library core. - Add tests for payload serialization and response normalization when adding endpoints. + +### The GLPI OpenAPI contract + +The models under `glpi_python_client/models/api_schema/` mirror +`components.schemas.*` from the GLPI High-Level REST API document, +including which fields are `readOnly`. That document is not committed — +it is ~14 MB and instance-specific. Fetch your instance's copy to the +path the models cite: + +```bash +curl -sk "/api.php/v2.3/doc.json" -o docs/api_contract/api.json +``` + +`docs/api_contract/` is gitignored. Nothing in CI checks the models +against it, so when you add or change a model, diff it against the +contract by hand. diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 0c62a1b..244031e 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -499,6 +499,121 @@ The companion mixin methods are exposed on :class:`GlpiClient` / :undoc-members: :show-inheritance: +Computers +--------- + +.. autoclass:: GetComputer + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: PostComputer + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: PatchComputer + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: DeleteComputer + :members: + :undoc-members: + :show-inheritance: + +Computer Contract Links +----------------------- + +.. autoclass:: GetContractItem + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: PostContractItem + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: PatchContractItem + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: DeleteContractItem + :members: + :undoc-members: + :show-inheritance: + +Contracts +--------- + +.. autoclass:: GetContract + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: PostContract + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: PatchContract + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: DeleteContract + :members: + :undoc-members: + :show-inheritance: + +Contract Costs +-------------- + +.. autoclass:: GetContractCost + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: PostContractCost + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: PatchContractCost + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: DeleteContractCost + :members: + :undoc-members: + :show-inheritance: + +Contract Types +-------------- + +.. autoclass:: GetContractType + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: PostContractType + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: PatchContractType + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: DeleteContractType + :members: + :undoc-members: + :show-inheritance: + Enums ----- @@ -547,6 +662,11 @@ Enums :undoc-members: :show-inheritance: +.. autoclass:: GlpiContractRenewalType + :members: + :undoc-members: + :show-inheritance: + Package Metadata ---------------- diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 9fb1b13..7ee9d10 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -38,7 +38,7 @@ The guide is split into the following sections: throwaway GLPI instance to follow along. 4. **GLPI API interface** — the contract-aligned helpers that map one-to-one to GLPI v2 endpoints (tickets, timeline, team members, - users, locations, entities, documents). + users, locations, entities, documents, computers, contracts). 5. **Added functionalities** — helpers built on top of the API mixins: the ``Fields`` plugin custom-field helpers, the aggregated ticket context view, and the reporting helpers. @@ -580,6 +580,211 @@ Example output:: the client (``v1_base_url`` and ``v1_user_token``) because the GLPI v2 contract does not advertise a binary upload endpoint. +Assets +~~~~~~ + +The asset mixins map to ``/Assets/*``. GLPI models roughly two dozen +asset itemtypes (computers, monitors, printers, network equipment, and +so on), and this client currently implements only ``Computer`` -- there +is no ``search_monitors`` or ``get_printer``. Treat ``Computer`` as the +one supported asset type rather than a stand-in for the rest of the +family. + +``search_ / get_ / create_ / update_ / delete_`` follow the same shape +as the other resources, with ``iter_search_computers`` for streaming +pagination and the same ``rsql_filter`` / ``limit`` / ``start`` / +``sort`` arguments as ``search_tickets``: + +.. code-block:: python + + from glpi_python_client import PatchComputer, PostComputer + + computer_id = client.create_computer( + PostComputer(name="ws-1042", serial="PF3KL9QJ") + ) + client.update_computer( + computer_id, PatchComputer(comment="Reimaged for the finance team") + ) + computer = client.get_computer(computer_id) + print(computer.id, computer.name, computer.serial) + + results = client.search_computers("name==ws-1042", limit=5) + for c in results: + print(c.id, c.name) + +Example output:: + + 1042 ws-1042 PF3KL9QJ + 1042 ws-1042 + +Linking a computer to a contract +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +A computer's coverage contracts are tracked as links under +``/Assets/Computer/{id}/Contract``, exposed as +``list_computer_contracts``, ``get_computer_contract``, +``link_computer_contract``, ``update_computer_contract``, and +``unlink_computer_contract``: + +.. code-block:: python + + from glpi_python_client import IdNameRef, PostContract, PostContractItem + + contract_id = client.create_contract(PostContract(name="Dell ProSupport 2026")) + + link_id = client.link_computer_contract( + computer_id, PostContractItem(contract=IdNameRef(id=contract_id)) + ) + for link in client.list_computer_contracts(computer_id): + print(link.id, link.itemtype, link.items_id) + + client.unlink_computer_contract(computer_id, link_id, force=True) + +Example output:: + + 17 Computer 1042 + +The underlying ``Contract_Item`` GLPI resource is shared by every asset +type, so ``PostContractItem`` and ``PatchContractItem`` both carry an +``itemtype`` field typed as a free string rather than an enum -- +nothing stops a caller from writing ``"Computre"``. Because of that, +``link_computer_contract`` and ``update_computer_contract`` ignore +whatever ``itemtype`` and ``items_id`` are set on the body passed in +and set both fields themselves from ``computer_id``. Pass only +``contract`` (and ``comment``, if the contract ever adds one); a typo +in the two identifying fields would otherwise attach the link to the +wrong kind of object with no error from either side. + +Contracts +~~~~~~~~~ + +The contract mixin maps to ``/Management/Contract`` with the usual +``search_ / get_ / create_ / update_ / delete_`` shape and +``iter_search_contracts`` for streaming pagination: + +.. code-block:: python + + from glpi_python_client import PatchContract, PostContract + + contract_id = client.create_contract( + PostContract(name="Dell ProSupport 2026", number="CTR-2026-001") + ) + client.update_contract( + contract_id, PatchContract(comment="Renewed for another year") + ) + contract = client.get_contract(contract_id) + print(contract.id, contract.name, contract.date_begin) + + results = client.search_contracts("name==Dell ProSupport 2026", limit=5) + for c in results: + print(c.id, c.name) + +Example output:: + + 501 Dell ProSupport 2026 2026-01-15 + 501 Dell ProSupport 2026 + +.. warning:: + + ``date_begin`` on :class:`~glpi_python_client.GetContract` and + :class:`~glpi_python_client.PostContract` is a plain + :class:`datetime.date`, not a :class:`~datetime.datetime`. The GLPI + contract declares this field with ``format: date`` -- a contract has + no time-of-day for its start. That choice is load-bearing, not + cosmetic: the server-clock conversion in ``models/_base.py`` that + rewrites timestamps into the configured ``server_timezone`` only + touches values that are instances of :class:`datetime.datetime`, and + a plain :class:`~datetime.date` is not one, so ``date_begin`` never + enters that conversion. Had it been modelled as ``datetime`` it would + have been eligible, and converting a midnight, offset-naive value + between timezones can roll it onto the previous or next calendar + day. Note the asymmetry: ``date_begin`` and ``date_end`` on + ``ContractCost`` (below) *are* ``datetime`` fields, because the + contract declares those two with ``format: date-time``. That + difference comes from the GLPI contract itself and is not an + inconsistency to reconcile. + +Contract costs +^^^^^^^^^^^^^^ + +Cost lines live under ``/Management/Contract/{id}/Cost`` as a genuine +sub-resource with their own create, update, and delete endpoints: +``list_contract_costs``, ``get_contract_cost``, +``create_contract_cost``, ``update_contract_cost``, and +``delete_contract_cost``. + +.. code-block:: python + + from glpi_python_client import PatchContractCost, PostContractCost + + cost_id = client.create_contract_cost( + contract_id, PostContractCost(name="Year 1", cost=4200.0) + ) + client.update_contract_cost( + contract_id, cost_id, PatchContractCost(comment="Paid on invoice #88") + ) + cost = client.get_contract_cost(contract_id, cost_id) + print(cost.id, cost.name, cost.cost) + + print(len(client.list_contract_costs(contract_id))) + + client.delete_contract_cost(contract_id, cost_id, force=True) + +Example output:: + + 9 Year 1 4200.0 + 1 + +.. note:: + + ``costs`` on :class:`~glpi_python_client.GetContract` is read-only: + it comes back populated with references to the contract's cost + lines, but the field does not exist at all on ``PostContract`` or + ``PatchContract``. Write cost lines through + ``create_contract_cost``, ``update_contract_cost``, and + ``delete_contract_cost`` instead of trying to assign ``costs`` on + the parent contract. + +Contract types +^^^^^^^^^^^^^^ + +``/Dropdowns/ContractType`` is a plain dropdown: ``search_ / get_ / +create_ / update_ / delete_`` plus ``iter_search_contract_types``. +Unlike ``search_computers`` and ``search_contracts``, the contract-type +search helpers do not accept a ``sort`` argument. + +.. code-block:: python + + from glpi_python_client import PostContractType + + type_id = client.create_contract_type(PostContractType(name="Maintenance")) + contract_type = client.get_contract_type(type_id) + print(contract_type.id, contract_type.name) + +Example output:: + + 6 Maintenance + +Assign the type -- and a renewal behaviour -- through the parent +contract: + +.. code-block:: python + + from glpi_python_client import GlpiContractRenewalType, IdNameRef, PatchContract + + client.update_contract( + contract_id, + PatchContract( + type=IdNameRef(id=type_id), + renewal_type=GlpiContractRenewalType.TACIT, + ), + ) + +:class:`glpi_python_client.GlpiContractRenewalType` mirrors the three +values GLPI documents for ``Contract.renewal_type``: ``NONE`` (no +renewal), ``TACIT`` (automatic renewal), and ``EXPLICIT`` (manual +renewal). + Knowledge base ~~~~~~~~~~~~~~ @@ -685,8 +890,9 @@ the package root for easy use in RSQL filters: :class:`glpi_python_client.GlpiTaskState`, :class:`glpi_python_client.GlpiSolutionStatus`, :class:`glpi_python_client.GlpiTimelinePosition`, -:class:`glpi_python_client.GlpiUserAuthType`, and -:class:`glpi_python_client.GlpiGlobalValidation`. +:class:`glpi_python_client.GlpiUserAuthType`, +:class:`glpi_python_client.GlpiGlobalValidation`, and +:class:`glpi_python_client.GlpiContractRenewalType`. .. code-block:: python diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index 508acad..218c529 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -32,6 +32,11 @@ ) from glpi_python_client._sync.clients import GlpiClient from glpi_python_client.models import ( + DeleteComputer, + DeleteContract, + DeleteContractCost, + DeleteContractItem, + DeleteContractType, DeleteDocument, DeleteEntity, DeleteFollowup, @@ -45,6 +50,11 @@ DeleteTicketTask, DeleteTimelineDocument, DeleteUser, + GetComputer, + GetContract, + GetContractCost, + GetContractItem, + GetContractType, GetDocument, GetEntity, GetFollowup, @@ -62,6 +72,7 @@ GetTicketTask, GetTimelineDocument, GetUser, + GlpiContractRenewalType, GlpiEnum, GlpiGlobalValidation, GlpiPriority, @@ -75,6 +86,11 @@ IdNameCompletenameRef, IdNameRef, IdRef, + PatchComputer, + PatchContract, + PatchContractCost, + PatchContractItem, + PatchContractType, PatchDocument, PatchEntity, PatchFollowup, @@ -88,6 +104,11 @@ PatchTicketTask, PatchTimelineDocument, PatchUser, + PostComputer, + PostContract, + PostContractCost, + PostContractItem, + PostContractType, PostDocument, PostEntity, PostFollowup, @@ -114,6 +135,11 @@ __all__ = [ "AsyncGlpiClient", + "DeleteComputer", + "DeleteContract", + "DeleteContractCost", + "DeleteContractItem", + "DeleteContractType", "DeleteDocument", "DeleteEntity", "DeleteFollowup", @@ -127,6 +153,11 @@ "DeleteTicketTask", "DeleteTimelineDocument", "DeleteUser", + "GetComputer", + "GetContract", + "GetContractCost", + "GetContractItem", + "GetContractType", "GetDocument", "GetEntity", "GetFollowup", @@ -147,6 +178,7 @@ "GlpiAuthError", "GlpiClient", "GlpiContentError", + "GlpiContractRenewalType", "GlpiEnum", "GlpiError", "GlpiGlobalValidation", @@ -168,6 +200,11 @@ "IdNameCompletenameRef", "IdNameRef", "IdRef", + "PatchComputer", + "PatchContract", + "PatchContractCost", + "PatchContractItem", + "PatchContractType", "PatchDocument", "PatchEntity", "PatchFollowup", @@ -181,6 +218,11 @@ "PatchTicketTask", "PatchTimelineDocument", "PatchUser", + "PostComputer", + "PostContract", + "PostContractCost", + "PostContractItem", + "PostContractType", "PostDocument", "PostEntity", "PostFollowup", diff --git a/glpi_python_client/_async/clients/api/__init__.py b/glpi_python_client/_async/clients/api/__init__.py index 7bd032e..ecb861b 100644 --- a/glpi_python_client/_async/clients/api/__init__.py +++ b/glpi_python_client/_async/clients/api/__init__.py @@ -1,7 +1,7 @@ """Per-endpoint API mixins backed by the ``api_schema`` Pydantic models. The mixins under this package mirror the endpoints documented in -``docs/glpi_api_contract.json`` one for one. They wrap the +``docs/api_contract/api.json`` one for one. They wrap the transport helpers from :mod:`glpi_python_client._async.clients.commons` and exchange typed ``Get``, ``Post``, ``Patch``, and ``Delete`` models with the GLPI API. @@ -13,6 +13,7 @@ EntityMixin, UserMixin, ) +from glpi_python_client._async.clients.api.assets import ComputerMixin from glpi_python_client._async.clients.api.assistance import ( TeamMemberMixin, TicketMixin, @@ -23,19 +24,28 @@ TicketTaskMixin, TimelineDocumentMixin, ) -from glpi_python_client._async.clients.api.dropdowns import LocationMixin +from glpi_python_client._async.clients.api.dropdowns import ( + ContractTypeMixin, + LocationMixin, +) from glpi_python_client._async.clients.api.knowledgebase import ( KBArticleCommentMixin, KBArticleMixin, KBArticleRevisionMixin, KBCategoryMixin, ) -from glpi_python_client._async.clients.api.management import DocumentMixin +from glpi_python_client._async.clients.api.management import ( + ContractMixin, + DocumentMixin, +) from glpi_python_client._async.clients.api.plugins import ( PluginFieldsMixin, ) __all__ = [ + "ComputerMixin", + "ContractMixin", + "ContractTypeMixin", "DocumentMixin", "EntityMixin", "FollowupMixin", diff --git a/glpi_python_client/_async/clients/api/assets/__init__.py b/glpi_python_client/_async/clients/api/assets/__init__.py new file mode 100644 index 0000000..d03cc4d --- /dev/null +++ b/glpi_python_client/_async/clients/api/assets/__init__.py @@ -0,0 +1,7 @@ +"""GLPI ``/Assets`` mixins for the GLPI client.""" + +from __future__ import annotations + +from glpi_python_client._async.clients.api.assets._computer import ComputerMixin + +__all__ = ["ComputerMixin"] diff --git a/glpi_python_client/_async/clients/api/assets/_computer.py b/glpi_python_client/_async/clients/api/assets/_computer.py new file mode 100644 index 0000000..c446667 --- /dev/null +++ b/glpi_python_client/_async/clients/api/assets/_computer.py @@ -0,0 +1,427 @@ +"""GLPI ``/Assets/Computer`` mixin. + +The mixin exposes search, fetch, create, update, and delete helpers for the +GLPI computer resource using the contract-aligned ``api_schema`` models. It +also exposes CRUD helpers for the ``/Assets/Computer/{id}/Contract`` +sub-resource, the join answering which contracts cover a given computer. + +``link_computer_contract`` and ``update_computer_contract`` set ``itemtype`` +and ``items_id`` themselves rather than trusting the caller-supplied values +on the request body; see ``models/api_schema/assets/_contract_item.py`` for +why. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +from glpi_python_client._async.clients.commons._constants import ( + COMPUTER_ENDPOINT, + GlpiId, +) +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.assets._computer import ( + DeleteComputer, + GetComputer, + PatchComputer, + PostComputer, +) +from glpi_python_client.models.api_schema.assets._contract_item import ( + DeleteContractItem, + GetContractItem, + PatchContractItem, + PostContractItem, +) + + +class ComputerMixin(TransportMixin): + """CRUD helpers for ``/Assets/Computer``.""" + + async def search_computers( + self, + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + ) -> list[GetComputer]: + """Search GLPI computers with an optional RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + sort : str | None, optional + Server-side ordering expressed as ``":"``, + for example ``"date_mod:desc"``. Omitted when :data:`None`, + leaving the server default ordering in place. + + Returns + ------- + list[GetComputer] + Computers matching the filter. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + if rsql_filter: + params["filter"] = rsql_filter + if sort is not None: + params["sort"] = sort + return await self._resource_list(COMPUTER_ENDPOINT, GetComputer, params=params) + + async def iter_search_computers( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + sort: str | None = None, + ) -> AsyncIterator[list[GetComputer]]: + """Yield successive pages of GLPI computers until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible record. + batch_size : int, optional + Number of records requested per page (default 50). Acts as the + ``limit`` parameter on each underlying :meth:`search_computers` + call. + sort : str | None, optional + Server-side ordering expressed as ``":"``, + for example ``"date_mod:desc"``. Forwarded to each page + request; omitted when :data:`None`, leaving the server default + ordering in place. + + Yields + ------ + list[GetComputer] + One page per iteration. The last yielded batch may be shorter + than ``batch_size``. + """ + + start = 0 + while True: + batch = await self.search_computers( + rsql_filter, + limit=batch_size, + start=start, + sort=sort, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + + async def get_computer(self, computer_id: GlpiId) -> GetComputer: + """Fetch one GLPI computer by identifier. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the computer to retrieve. + + Returns + ------- + GetComputer + Validated computer payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{COMPUTER_ENDPOINT}/{computer_id}", + GetComputer, + failure_message=f"Failed to get computer {computer_id}", + ) + + async def create_computer(self, computer: PostComputer) -> int: + """Create one GLPI computer. + + Parameters + ---------- + computer : PostComputer + Request body describing the computer 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( + COMPUTER_ENDPOINT, + computer, + failure_message="Failed to create computer", + missing_message="GLPI computer create response did not include an ID", + log_message_factory=(lambda new_id: f"GLPI API created computer {new_id}"), + ) + + async def update_computer( + self, computer_id: GlpiId, computer: PatchComputer + ) -> None: + """Update one GLPI computer with a partial body. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the computer to update. + computer : PatchComputer + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_update( + f"{COMPUTER_ENDPOINT}/{computer_id}", + computer, + failure_message=f"Failed to update computer {computer_id}", + log_message=f"GLPI API updated computer {computer_id}", + ) + + async def delete_computer( + self, computer_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one GLPI computer by identifier. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the computer to delete. + force : bool | None, optional + When ``True`` the computer 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"{COMPUTER_ENDPOINT}/{computer_id}", + failure_message=f"Failed to delete computer {computer_id}", + log_message=f"GLPI API deleted computer {computer_id}", + force=force, + delete_model_cls=DeleteComputer, + ) + + async def list_computer_contracts( + self, + computer_id: GlpiId, + *, + limit: int = 50, + start: int = 0, + ) -> list[GetContractItem]: + """List the contracts linked to one GLPI computer. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the owning computer. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + + Returns + ------- + list[GetContractItem] + Contract links belonging to the computer. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + return await self._resource_list( + f"{COMPUTER_ENDPOINT}/{computer_id}/Contract", + GetContractItem, + params=params, + ) + + async def get_computer_contract( + self, computer_id: GlpiId, link_id: GlpiId + ) -> GetContractItem: + """Fetch one contract link recorded against a GLPI computer. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the owning computer. + link_id : GlpiId + Numeric identifier of the contract link to retrieve. + + Returns + ------- + GetContractItem + Validated contract link payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{COMPUTER_ENDPOINT}/{computer_id}/Contract/{link_id}", + GetContractItem, + failure_message=( + f"Failed to get contract link {link_id} for computer {computer_id}" + ), + ) + + async def link_computer_contract( + self, computer_id: GlpiId, link: PostContractItem + ) -> int: + """Link one GLPI contract to one computer. + + The ``itemtype`` and ``items_id`` fields are set from + ``computer_id`` rather than taken from ``link``. The GLPI contract + types ``itemtype`` as a free string, so a caller-supplied value is + a silent mis-link waiting to happen; this helper already knows + which asset it is working on. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the computer the contract covers. + link : PostContractItem + Request body naming the contract to link. Any ``itemtype`` or + ``items_id`` set on it is replaced. + + Returns + ------- + int + Identifier assigned to the new link 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. + """ + + body = link.model_copy(update={"itemtype": "Computer", "items_id": computer_id}) + return await self._resource_create( + f"{COMPUTER_ENDPOINT}/{computer_id}/Contract", + body, + failure_message=f"Failed to link a contract to computer {computer_id}", + missing_message=( + "GLPI contract link create response did not include an ID" + ), + log_message_factory=( + lambda new_id: ( + f"GLPI API linked contract item {new_id} to computer {computer_id}" + ) + ), + ) + + async def update_computer_contract( + self, computer_id: GlpiId, link_id: GlpiId, link: PatchContractItem + ) -> None: + """Update one computer-contract link with a partial body. + + The ``itemtype`` and ``items_id`` fields are set from + ``computer_id`` rather than taken from ``link``, for the same + reason as :meth:`link_computer_contract`. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the owning computer. + link_id : GlpiId + Numeric identifier of the contract link to update. + link : PatchContractItem + Partial request body. Any ``itemtype`` or ``items_id`` set on + it is replaced. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + body = link.model_copy(update={"itemtype": "Computer", "items_id": computer_id}) + await self._resource_update( + f"{COMPUTER_ENDPOINT}/{computer_id}/Contract/{link_id}", + body, + failure_message=( + f"Failed to update contract link {link_id} for computer {computer_id}" + ), + log_message=( + f"GLPI API updated contract link {link_id} for computer {computer_id}" + ), + ) + + async def unlink_computer_contract( + self, computer_id: GlpiId, link_id: GlpiId, *, force: bool | None = None + ) -> None: + """Remove one contract link from a GLPI computer. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the owning computer. + link_id : GlpiId + Numeric identifier of the contract link to remove. + force : bool | None, optional + When ``True`` the link 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"{COMPUTER_ENDPOINT}/{computer_id}/Contract/{link_id}", + failure_message=( + f"Failed to unlink contract link {link_id} from computer {computer_id}" + ), + log_message=( + f"GLPI API unlinked contract link {link_id} from computer {computer_id}" + ), + force=force, + delete_model_cls=DeleteContractItem, + ) + + +__all__ = ["ComputerMixin"] diff --git a/glpi_python_client/_async/clients/api/assets/tests/__init__.py b/glpi_python_client/_async/clients/api/assets/tests/__init__.py new file mode 100644 index 0000000..15a176a --- /dev/null +++ b/glpi_python_client/_async/clients/api/assets/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the ``/Assets`` endpoint mixins.""" diff --git a/glpi_python_client/_async/clients/api/assets/tests/test_computer.py b/glpi_python_client/_async/clients/api/assets/tests/test_computer.py new file mode 100644 index 0000000..81bc1c2 --- /dev/null +++ b/glpi_python_client/_async/clients/api/assets/tests/test_computer.py @@ -0,0 +1,271 @@ +"""Unit tests for the ``Assets/Computer`` endpoint mixin. + +The tests cover search, fetch, create, update, and delete for GLPI +computers, using the shared transport recorders to stub the four +transport helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from typing import Any + +from glpi_python_client import ( + GetComputer, + IdNameRef, + PatchComputer, + PatchContractItem, + PostComputer, + PostContractItem, +) +from glpi_python_client._async._testing import TransportRecorder + + +async def test_search_computers_passes_filter(client: Any) -> None: + """``search_computers`` forwards the RSQL filter through ``filter``.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "LAPTOP-01"}]) + rec.install(client) + computers = await client.search_computers("name==LAPTOP-01") + assert computers[0].id == 1 + assert rec.calls[0]["endpoint"] == "Assets/Computer" + assert rec.calls[0]["params"]["filter"] == "name==LAPTOP-01" + + +async def test_search_computers_forwards_sort(client: Any) -> None: + """``sort`` reaches the server when given, and is absent otherwise.""" + + rec = TransportRecorder(get_payload=[]) + rec.install(client) + await client.search_computers(sort="date_mod:desc") + assert rec.calls[0]["params"]["sort"] == "date_mod:desc" + + rec2 = TransportRecorder(get_payload=[]) + rec2.install(client) + await client.search_computers() + assert "sort" not in rec2.calls[0]["params"] + + +async def test_iter_search_computers_stops_on_short_page(client: Any) -> None: + """Pagination stops once the server returns fewer rows than requested. + + The recorder answers every GET with the same payload, so a generator + that did not stop on a short page would loop forever here rather than + fail an assertion. + """ + + rec = TransportRecorder(get_payload=[{"id": 1}, {"id": 2}]) + rec.install(client) + pages = [page async for page in client.iter_search_computers(batch_size=3)] + assert len(pages) == 1 + assert len(pages[0]) == 2 + assert len(rec.calls) == 1 + assert rec.calls[0]["params"] == {"limit": 3, "start": 0} + + +async def test_iter_search_computers_yields_every_page(client: Any) -> None: + """The generator advances ``start`` until a short page ends the walk. + + ``TransportRecorder`` replays one payload forever, so it cannot drive a + multi-page walk. Replace ``search_computers`` itself, as + ``test_location.py`` does. The stub is a named function rather than a + lambda: this module's twin is generated from it by a token rewriter, + which can transform a ``def`` but cannot build one out of a lambda. + """ + + pages = [[GetComputer(id=i) for i in range(3)], [GetComputer(id=99)]] + starts: list[int] = [] + sorts: list[str | None] = [] + + async def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + ) -> list[GetComputer]: + starts.append(start) + sorts.append(sort) + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_computers = fake_search # type: ignore[method-assign] + + batches = [ + batch + async for batch in client.iter_search_computers( + "name==x", batch_size=3, sort="date_mod:desc" + ) + ] + + assert starts == [0, 3] + assert sorts == ["date_mod:desc", "date_mod:desc"] + assert [len(b) for b in batches] == [3, 1] + + +async def test_get_computer_endpoint(client: Any) -> None: + """``get_computer`` hits the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 9, "name": "LAPTOP-01"}) + rec.install(client) + computer = await client.get_computer(9) + assert computer.id == 9 + assert rec.calls[0]["endpoint"] == "Assets/Computer/9" + + +async def test_create_computer_returns_new_id(client: Any) -> None: + """``create_computer`` posts the body and returns the server id.""" + + rec = TransportRecorder(post_payload={"id": 31}) + rec.install(client) + new_id = await client.create_computer(PostComputer(name="LAPTOP-02")) + assert new_id == 31 + assert rec.calls[0]["endpoint"] == "Assets/Computer" + assert rec.calls[0]["json"]["name"] == "LAPTOP-02" + + +async def test_update_computer(client: Any) -> None: + """``update_computer`` patches the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.update_computer(9, PatchComputer(serial="SN-123")) + assert rec.calls[0]["endpoint"] == "Assets/Computer/9" + assert rec.calls[0]["json"]["serial"] == "SN-123" + + +async def test_delete_computer_with_force(client: Any) -> None: + """``delete_computer(force=True)`` ships the force flag.""" + + rec = TransportRecorder() + rec.install(client) + await client.delete_computer(9, force=True) + assert rec.calls[0]["json"]["force"] is True + + +async def test_group_fields_parse_as_lists_of_references(client: Any) -> None: + """``group`` and ``group_tech`` are arrays in the contract, not scalars.""" + + computer = GetComputer.model_validate( + { + "id": 1, + "group": [{"id": 4, "name": "Support"}], + "group_tech": [{"id": 5, "name": "Techs"}], + } + ) + assert computer.group is not None + assert computer.group[0].name == "Support" + assert computer.group_tech is not None + assert computer.group_tech[0].id == 5 + + +async def test_post_computer_excludes_every_readonly_field() -> None: + """The four contract ``readOnly`` fields never reach a write body.""" + + for field in ("id", "uuid", "last_inventory_update", "last_boot"): + assert field not in PostComputer.model_fields, field + assert field not in PatchComputer.model_fields, field + + +# --------------------------------------------------------------------------- +# Computer <-> contract links +# --------------------------------------------------------------------------- + + +async def test_list_computer_contracts_endpoint(client: Any) -> None: + """``list_computer_contracts`` hits the contract sub-resource.""" + + rec = TransportRecorder( + get_payload=[{"id": 2, "contract": {"id": 7, "name": "Support"}}] + ) + rec.install(client) + links = await client.list_computer_contracts(9) + assert links[0].contract is not None + assert links[0].contract.name == "Support" + assert rec.calls[0]["endpoint"] == "Assets/Computer/9/Contract" + + +async def test_get_computer_contract_endpoint(client: Any) -> None: + """``get_computer_contract`` hits the per-link endpoint.""" + + rec = TransportRecorder(get_payload={"id": 2}) + rec.install(client) + link = await client.get_computer_contract(9, 2) + assert link.id == 2 + assert rec.calls[0]["endpoint"] == "Assets/Computer/9/Contract/2" + + +async def test_link_computer_contract_sets_itemtype_itself(client: Any) -> None: + """The client fills ``itemtype`` and ``items_id``, not the caller. + + ``Contract_Item.itemtype`` is a free string in the contract, so a typo + there is a silently wrong link. The mixin knows it is working on a + computer and says so. + """ + + rec = TransportRecorder(post_payload={"id": 4}) + rec.install(client) + new_id = await client.link_computer_contract( + 9, PostContractItem(contract=IdNameRef(id=7)) + ) + assert new_id == 4 + assert rec.calls[0]["endpoint"] == "Assets/Computer/9/Contract" + assert rec.calls[0]["json"]["itemtype"] == "Computer" + assert rec.calls[0]["json"]["items_id"] == 9 + + +async def test_link_computer_contract_overrides_a_caller_itemtype( + client: Any, +) -> None: + """A caller-supplied itemtype cannot point the link at another type.""" + + rec = TransportRecorder(post_payload={"id": 4}) + rec.install(client) + await client.link_computer_contract( + 9, PostContractItem(contract=IdNameRef(id=7), itemtype="Monitor", items_id=1) + ) + assert rec.calls[0]["json"]["itemtype"] == "Computer" + assert rec.calls[0]["json"]["items_id"] == 9 + + +async def test_update_computer_contract(client: Any) -> None: + """``update_computer_contract`` patches the per-link endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.update_computer_contract( + 9, 2, PatchContractItem(contract=IdNameRef(id=8)) + ) + assert rec.calls[0]["endpoint"] == "Assets/Computer/9/Contract/2" + assert rec.calls[0]["json"]["itemtype"] == "Computer" + assert rec.calls[0]["json"]["items_id"] == 9 + + +async def test_update_computer_contract_overrides_a_caller_itemtype( + client: Any, +) -> None: + """A caller-supplied itemtype cannot repoint an existing link either. + + The update path stamps the same two fields as the create path. Without + this test, dropping the stamp from one of the two would leave the suite + green. + """ + + rec = TransportRecorder() + rec.install(client) + await client.update_computer_contract( + 9, + 2, + PatchContractItem(contract=IdNameRef(id=8), itemtype="Monitor", items_id=1), + ) + assert rec.calls[0]["json"]["itemtype"] == "Computer" + assert rec.calls[0]["json"]["items_id"] == 9 + + +async def test_unlink_computer_contract_with_force(client: Any) -> None: + """``unlink_computer_contract(force=True)`` ships the force flag.""" + + rec = TransportRecorder() + rec.install(client) + await client.unlink_computer_contract(9, 2, force=True) + assert rec.calls[0]["endpoint"] == "Assets/Computer/9/Contract/2" + assert rec.calls[0]["json"]["force"] is True diff --git a/glpi_python_client/_async/clients/api/dropdowns/__init__.py b/glpi_python_client/_async/clients/api/dropdowns/__init__.py index 60704f7..0979bf3 100644 --- a/glpi_python_client/_async/clients/api/dropdowns/__init__.py +++ b/glpi_python_client/_async/clients/api/dropdowns/__init__.py @@ -2,6 +2,9 @@ from __future__ import annotations +from glpi_python_client._async.clients.api.dropdowns._contract_type import ( + ContractTypeMixin, +) from glpi_python_client._async.clients.api.dropdowns._location import LocationMixin -__all__ = ["LocationMixin"] +__all__ = ["ContractTypeMixin", "LocationMixin"] diff --git a/glpi_python_client/_async/clients/api/dropdowns/_contract_type.py b/glpi_python_client/_async/clients/api/dropdowns/_contract_type.py new file mode 100644 index 0000000..7b86a01 --- /dev/null +++ b/glpi_python_client/_async/clients/api/dropdowns/_contract_type.py @@ -0,0 +1,220 @@ +"""GLPI ``/Dropdowns/ContractType`` mixin. + +The mixin exposes search, fetch, create, update, and delete helpers for the +GLPI contract type dropdown resource using the contract-aligned +``api_schema`` models. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +from glpi_python_client._async.clients.commons._constants import ( + CONTRACT_TYPE_ENDPOINT, + GlpiId, +) +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.dropdowns._contract_type import ( + DeleteContractType, + GetContractType, + PatchContractType, + PostContractType, +) + + +class ContractTypeMixin(TransportMixin): + """CRUD helpers for ``/Dropdowns/ContractType``.""" + + async def search_contract_types( + self, + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + ) -> list[GetContractType]: + """Search GLPI contract types with an optional RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + + Returns + ------- + list[GetContractType] + Contract types matching the filter. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + if rsql_filter: + params["filter"] = rsql_filter + return await self._resource_list( + CONTRACT_TYPE_ENDPOINT, GetContractType, params=params + ) + + async def iter_search_contract_types( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + ) -> AsyncIterator[list[GetContractType]]: + """Yield successive pages of GLPI contract types until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible record. + batch_size : int, optional + Number of records requested per page (default 50). Acts as the + ``limit`` parameter on each underlying + :meth:`search_contract_types` call. + + Yields + ------ + list[GetContractType] + One page per iteration. The last yielded batch may be shorter + than ``batch_size``. + """ + + start = 0 + while True: + batch = await self.search_contract_types( + rsql_filter, + limit=batch_size, + start=start, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + + async def get_contract_type(self, contract_type_id: GlpiId) -> GetContractType: + """Fetch one GLPI contract type by identifier. + + Parameters + ---------- + contract_type_id : GlpiId + Numeric identifier of the contract type to retrieve. + + Returns + ------- + GetContractType + Validated contract type payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{CONTRACT_TYPE_ENDPOINT}/{contract_type_id}", + GetContractType, + failure_message=f"Failed to get contract type {contract_type_id}", + ) + + async def create_contract_type(self, contract_type: PostContractType) -> int: + """Create one GLPI contract type. + + Parameters + ---------- + contract_type : PostContractType + Request body describing the contract type 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( + CONTRACT_TYPE_ENDPOINT, + contract_type, + failure_message="Failed to create contract type", + missing_message=( + "GLPI contract type create response did not include an ID" + ), + log_message_factory=( + lambda new_id: f"GLPI API created contract type {new_id}" + ), + ) + + async def update_contract_type( + self, contract_type_id: GlpiId, contract_type: PatchContractType + ) -> None: + """Update one GLPI contract type with a partial body. + + Parameters + ---------- + contract_type_id : GlpiId + Numeric identifier of the contract type to update. + contract_type : PatchContractType + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_update( + f"{CONTRACT_TYPE_ENDPOINT}/{contract_type_id}", + contract_type, + failure_message=(f"Failed to update contract type {contract_type_id}"), + log_message=f"GLPI API updated contract type {contract_type_id}", + ) + + async def delete_contract_type( + self, contract_type_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one GLPI contract type by identifier. + + Parameters + ---------- + contract_type_id : GlpiId + Numeric identifier of the contract type to delete. + force : bool | None, optional + When ``True`` the contract type 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"{CONTRACT_TYPE_ENDPOINT}/{contract_type_id}", + failure_message=(f"Failed to delete contract type {contract_type_id}"), + log_message=f"GLPI API deleted contract type {contract_type_id}", + force=force, + delete_model_cls=DeleteContractType, + ) + + +__all__ = ["ContractTypeMixin"] diff --git a/glpi_python_client/_async/clients/api/dropdowns/tests/test_contract_type.py b/glpi_python_client/_async/clients/api/dropdowns/tests/test_contract_type.py new file mode 100644 index 0000000..9f4b189 --- /dev/null +++ b/glpi_python_client/_async/clients/api/dropdowns/tests/test_contract_type.py @@ -0,0 +1,126 @@ +"""Unit tests for the ``Dropdowns/ContractType`` endpoint mixin. + +The tests cover search, fetch, create, update, and delete for GLPI +contract types, using the shared transport recorders to stub the four +transport helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from typing import Any + +from glpi_python_client import GetContractType, PatchContractType, PostContractType +from glpi_python_client._async._testing import TransportRecorder + + +async def test_search_contract_types_passes_filter(client: Any) -> None: + """``search_contract_types`` forwards the RSQL filter through ``filter``.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "Maintenance"}]) + rec.install(client) + types = await client.search_contract_types("name==Maintenance") + assert types[0].id == 1 + assert rec.calls[0]["endpoint"] == "Dropdowns/ContractType" + assert rec.calls[0]["params"]["filter"] == "name==Maintenance" + + +async def test_search_contract_types_omits_empty_filter(client: Any) -> None: + """An empty filter is not sent, so the server lists everything visible.""" + + rec = TransportRecorder(get_payload=[]) + rec.install(client) + await client.search_contract_types() + assert "filter" not in rec.calls[0]["params"] + + +async def test_iter_search_contract_types_stops_on_short_page(client: Any) -> None: + """Pagination stops once the server returns fewer rows than requested.""" + + rec = TransportRecorder(get_payload=[{"id": 1}]) + rec.install(client) + pages = [page async for page in client.iter_search_contract_types(batch_size=2)] + assert len(pages) == 1 + assert len(pages[0]) == 1 + assert len(rec.calls) == 1 + assert rec.calls[0]["params"] == {"limit": 2, "start": 0} + + +async def test_iter_search_contract_types_yields_every_page(client: Any) -> None: + """The generator advances ``start`` until a short page ends the walk. + + ``TransportRecorder`` replays one payload forever, so it cannot drive a + multi-page walk. Replace ``search_contract_types`` itself, as + ``test_location.py`` does. The stub is a named function rather than a + lambda: this module's twin is generated from it by a token rewriter, + which can transform a ``def`` but cannot build one out of a lambda. + """ + + pages = [ + [GetContractType(id=i) for i in range(3)], + [GetContractType(id=99)], + ] + starts: list[int] = [] + + async def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetContractType]: + starts.append(start) + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_contract_types = fake_search # type: ignore[method-assign] + + batches = [ + batch + async for batch in client.iter_search_contract_types("name==x", batch_size=3) + ] + + assert starts == [0, 3] + assert [len(b) for b in batches] == [3, 1] + + +async def test_get_contract_type_endpoint(client: Any) -> None: + """``get_contract_type`` hits the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 9, "name": "Lease"}) + rec.install(client) + contract_type = await client.get_contract_type(9) + assert contract_type.id == 9 + assert rec.calls[0]["endpoint"] == "Dropdowns/ContractType/9" + + +async def test_create_contract_type_returns_new_id(client: Any) -> None: + """``create_contract_type`` posts the body and returns the server id.""" + + rec = TransportRecorder(post_payload={"id": 42}) + rec.install(client) + new_id = await client.create_contract_type(PostContractType(name="Lease")) + assert new_id == 42 + assert rec.calls[0]["endpoint"] == "Dropdowns/ContractType" + assert rec.calls[0]["json"]["name"] == "Lease" + + +async def test_update_contract_type(client: Any) -> None: + """``update_contract_type`` patches the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.update_contract_type(9, PatchContractType(name="Lease 2")) + assert rec.calls[0]["endpoint"] == "Dropdowns/ContractType/9" + assert rec.calls[0]["json"]["name"] == "Lease 2" + + +async def test_delete_contract_type_with_force(client: Any) -> None: + """``delete_contract_type(force=True)`` ships the force flag.""" + + rec = TransportRecorder() + rec.install(client) + await client.delete_contract_type(9, force=True) + assert rec.calls[0]["endpoint"] == "Dropdowns/ContractType/9" + assert rec.calls[0]["json"]["force"] is True + + +async def test_post_contract_type_excludes_readonly_id() -> None: + """``PostContractType`` has no ``id``; the server assigns it.""" + + assert "id" not in PostContractType.model_fields diff --git a/glpi_python_client/_async/clients/api/management/__init__.py b/glpi_python_client/_async/clients/api/management/__init__.py index 4116a74..3dfacd2 100644 --- a/glpi_python_client/_async/clients/api/management/__init__.py +++ b/glpi_python_client/_async/clients/api/management/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from glpi_python_client._async.clients.api.management._contract import ContractMixin from glpi_python_client._async.clients.api.management._document import DocumentMixin -__all__ = ["DocumentMixin"] +__all__ = ["ContractMixin", "DocumentMixin"] diff --git a/glpi_python_client/_async/clients/api/management/_contract.py b/glpi_python_client/_async/clients/api/management/_contract.py new file mode 100644 index 0000000..6d470a3 --- /dev/null +++ b/glpi_python_client/_async/clients/api/management/_contract.py @@ -0,0 +1,411 @@ +"""GLPI ``/Management/Contract`` mixin. + +The mixin exposes search, fetch, create, update, and delete helpers for the +GLPI contract resource using the contract-aligned ``api_schema`` models. It +also exposes CRUD helpers for the ``/Management/Contract/{id}/Cost`` +sub-resource, built off the parent contract id. + +``ContractCost.date_begin`` and ``date_end`` are ``datetime``, not +``date``. This is the opposite choice from ``Contract.date_begin``: the +contract declares ``Contract.date_begin`` with ``format: date`` and +``ContractCost``'s two date fields with ``format: date-time``. See +``models/api_schema/management/_contract_cost.py`` for the full +explanation; the asymmetry is real and comes from the contract itself. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator + +from glpi_python_client._async.clients.commons._constants import ( + CONTRACT_ENDPOINT, + GlpiId, +) +from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.management._contract import ( + DeleteContract, + GetContract, + PatchContract, + PostContract, +) +from glpi_python_client.models.api_schema.management._contract_cost import ( + DeleteContractCost, + GetContractCost, + PatchContractCost, + PostContractCost, +) + + +class ContractMixin(TransportMixin): + """CRUD helpers for ``/Management/Contract``.""" + + async def search_contracts( + self, + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + ) -> list[GetContract]: + """Search GLPI contracts with an optional RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + sort : str | None, optional + Server-side ordering expressed as ``":"``, + for example ``"date_begin:desc"``. Omitted when :data:`None`, + leaving the server default ordering in place. + + Returns + ------- + list[GetContract] + Contracts matching the filter. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + if rsql_filter: + params["filter"] = rsql_filter + if sort is not None: + params["sort"] = sort + return await self._resource_list(CONTRACT_ENDPOINT, GetContract, params=params) + + async def iter_search_contracts( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + sort: str | None = None, + ) -> AsyncIterator[list[GetContract]]: + """Yield successive pages of GLPI contracts until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible record. + batch_size : int, optional + Number of records requested per page (default 50). Acts as the + ``limit`` parameter on each underlying :meth:`search_contracts` + call. + sort : str | None, optional + Server-side ordering expressed as ``":"``, + for example ``"date_begin:desc"``. Forwarded to each page + request; omitted when :data:`None`, leaving the server default + ordering in place. + + Yields + ------ + list[GetContract] + One page per iteration. The last yielded batch may be shorter + than ``batch_size``. + """ + + start = 0 + while True: + batch = await self.search_contracts( + rsql_filter, + limit=batch_size, + start=start, + sort=sort, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + + async def get_contract(self, contract_id: GlpiId) -> GetContract: + """Fetch one GLPI contract by identifier. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the contract to retrieve. + + Returns + ------- + GetContract + Validated contract payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{CONTRACT_ENDPOINT}/{contract_id}", + GetContract, + failure_message=f"Failed to get contract {contract_id}", + ) + + async def create_contract(self, contract: PostContract) -> int: + """Create one GLPI contract. + + Parameters + ---------- + contract : PostContract + Request body describing the contract 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( + CONTRACT_ENDPOINT, + contract, + failure_message="Failed to create contract", + missing_message="GLPI contract create response did not include an ID", + log_message_factory=(lambda new_id: f"GLPI API created contract {new_id}"), + ) + + async def update_contract( + self, contract_id: GlpiId, contract: PatchContract + ) -> None: + """Update one GLPI contract with a partial body. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the contract to update. + contract : PatchContract + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_update( + f"{CONTRACT_ENDPOINT}/{contract_id}", + contract, + failure_message=f"Failed to update contract {contract_id}", + log_message=f"GLPI API updated contract {contract_id}", + ) + + async def delete_contract( + self, contract_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one GLPI contract by identifier. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the contract to delete. + force : bool | None, optional + When ``True`` the contract 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"{CONTRACT_ENDPOINT}/{contract_id}", + failure_message=f"Failed to delete contract {contract_id}", + log_message=f"GLPI API deleted contract {contract_id}", + force=force, + delete_model_cls=DeleteContract, + ) + + async def list_contract_costs( + self, + contract_id: GlpiId, + *, + limit: int = 50, + start: int = 0, + ) -> list[GetContractCost]: + """List the cost lines recorded against one GLPI contract. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the owning contract. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + + Returns + ------- + list[GetContractCost] + Cost lines belonging to the contract. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + return await self._resource_list( + f"{CONTRACT_ENDPOINT}/{contract_id}/Cost", + GetContractCost, + params=params, + ) + + async def get_contract_cost( + self, contract_id: GlpiId, cost_id: GlpiId + ) -> GetContractCost: + """Fetch one cost line recorded against a GLPI contract. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the owning contract. + cost_id : GlpiId + Numeric identifier of the cost line to retrieve. + + Returns + ------- + GetContractCost + Validated cost line payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return await self._resource_get( + f"{CONTRACT_ENDPOINT}/{contract_id}/Cost/{cost_id}", + GetContractCost, + failure_message=( + f"Failed to get cost {cost_id} for contract {contract_id}" + ), + ) + + async def create_contract_cost( + self, contract_id: GlpiId, cost: PostContractCost + ) -> int: + """Create one cost line against a GLPI contract. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the owning contract. + cost : PostContractCost + Request body describing the cost line 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( + f"{CONTRACT_ENDPOINT}/{contract_id}/Cost", + cost, + failure_message=f"Failed to create cost for contract {contract_id}", + missing_message=( + "GLPI contract cost create response did not include an ID" + ), + log_message_factory=( + lambda new_id: ( + f"GLPI API created cost {new_id} for contract {contract_id}" + ) + ), + ) + + async def update_contract_cost( + self, contract_id: GlpiId, cost_id: GlpiId, cost: PatchContractCost + ) -> None: + """Update one contract cost line with a partial body. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the owning contract. + cost_id : GlpiId + Numeric identifier of the cost line to update. + cost : PatchContractCost + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + await self._resource_update( + f"{CONTRACT_ENDPOINT}/{contract_id}/Cost/{cost_id}", + cost, + failure_message=( + f"Failed to update cost {cost_id} for contract {contract_id}" + ), + log_message=(f"GLPI API updated cost {cost_id} for contract {contract_id}"), + ) + + async def delete_contract_cost( + self, contract_id: GlpiId, cost_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one cost line from a GLPI contract. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the owning contract. + cost_id : GlpiId + Numeric identifier of the cost line to delete. + force : bool | None, optional + When ``True`` the cost line 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"{CONTRACT_ENDPOINT}/{contract_id}/Cost/{cost_id}", + failure_message=( + f"Failed to delete cost {cost_id} for contract {contract_id}" + ), + log_message=(f"GLPI API deleted cost {cost_id} for contract {contract_id}"), + force=force, + delete_model_cls=DeleteContractCost, + ) + + +__all__ = ["ContractMixin"] diff --git a/glpi_python_client/_async/clients/api/management/tests/test_contract.py b/glpi_python_client/_async/clients/api/management/tests/test_contract.py new file mode 100644 index 0000000..4468089 --- /dev/null +++ b/glpi_python_client/_async/clients/api/management/tests/test_contract.py @@ -0,0 +1,257 @@ +"""Unit tests for the ``Management/Contract`` endpoint mixin. + +The tests cover search, fetch, create, update, and delete for GLPI +contracts, and pin the two modelling decisions the contract forced: a +``date``-typed ``date_begin`` and an enum-typed ``renewal_type``. +""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Any + +from glpi_python_client import ( + GetContract, + GetContractCost, + GlpiContractRenewalType, + PatchContract, + PatchContractCost, + PostContract, + PostContractCost, +) +from glpi_python_client._async._testing import TransportRecorder + + +async def test_search_contracts_passes_filter(client: Any) -> None: + """``search_contracts`` forwards the RSQL filter through ``filter``.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "Support 2026"}]) + rec.install(client) + contracts = await client.search_contracts("name==Support 2026") + assert contracts[0].id == 1 + assert rec.calls[0]["endpoint"] == "Management/Contract" + assert rec.calls[0]["params"]["filter"] == "name==Support 2026" + + +async def test_search_contracts_forwards_sort(client: Any) -> None: + """``sort`` reaches the server when given, and is absent otherwise.""" + + rec = TransportRecorder(get_payload=[]) + rec.install(client) + await client.search_contracts(sort="date_begin:desc") + assert rec.calls[0]["params"]["sort"] == "date_begin:desc" + + rec2 = TransportRecorder(get_payload=[]) + rec2.install(client) + await client.search_contracts() + assert "sort" not in rec2.calls[0]["params"] + + +async def test_iter_search_contracts_stops_on_short_page(client: Any) -> None: + """Pagination stops once the server returns fewer rows than requested.""" + + rec = TransportRecorder(get_payload=[{"id": 1}]) + rec.install(client) + pages = [page async for page in client.iter_search_contracts(batch_size=2)] + assert len(pages) == 1 + assert len(pages[0]) == 1 + assert len(rec.calls) == 1 + assert rec.calls[0]["params"] == {"limit": 2, "start": 0} + + +async def test_iter_search_contracts_yields_every_page(client: Any) -> None: + """The generator advances ``start`` until a short page ends the walk. + + ``TransportRecorder`` replays one payload forever, so it cannot drive a + multi-page walk. Replace ``search_contracts`` itself, as + ``test_location.py`` does. The stub is a named function rather than a + lambda: this module's twin is generated from it by a token rewriter, + which can transform a ``def`` but cannot build one out of a lambda. + """ + + pages = [[GetContract(id=i) for i in range(3)], [GetContract(id=99)]] + starts: list[int] = [] + sorts: list[str | None] = [] + + async def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + ) -> list[GetContract]: + starts.append(start) + sorts.append(sort) + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_contracts = fake_search # type: ignore[method-assign] + + batches = [ + batch + async for batch in client.iter_search_contracts( + "name==x", batch_size=3, sort="date_mod:desc" + ) + ] + + assert starts == [0, 3] + assert sorts == ["date_mod:desc", "date_mod:desc"] + assert [len(b) for b in batches] == [3, 1] + + +async def test_get_contract_endpoint(client: Any) -> None: + """``get_contract`` hits the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 9, "name": "Support"}) + rec.install(client) + contract = await client.get_contract(9) + assert contract.id == 9 + assert rec.calls[0]["endpoint"] == "Management/Contract/9" + + +async def test_create_contract_returns_new_id(client: Any) -> None: + """``create_contract`` posts the body and returns the server id.""" + + rec = TransportRecorder(post_payload={"id": 77}) + rec.install(client) + new_id = await client.create_contract(PostContract(name="Support")) + assert new_id == 77 + assert rec.calls[0]["endpoint"] == "Management/Contract" + + +async def test_update_contract(client: Any) -> None: + """``update_contract`` patches the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.update_contract(9, PatchContract(name="Support 2027")) + assert rec.calls[0]["endpoint"] == "Management/Contract/9" + + +async def test_delete_contract_with_force(client: Any) -> None: + """``delete_contract(force=True)`` ships the force flag.""" + + rec = TransportRecorder() + rec.install(client) + await client.delete_contract(9, force=True) + assert rec.calls[0]["json"]["force"] is True + + +async def test_date_begin_is_a_plain_date_not_a_datetime(client: Any) -> None: + """``date_begin`` parses to ``date``. + + The contract declares ``format: date``. Modelling it as ``datetime`` + would make an aware value eligible for the server-clock conversion in + ``models/_base.py``, which could roll the start date to the previous + or next day. + """ + + contract = GetContract.model_validate({"id": 1, "date_begin": "2026-01-15"}) + assert contract.date_begin == date(2026, 1, 15) + assert not isinstance(contract.date_begin, datetime) + + +async def test_date_begin_survives_a_write_unshifted(client: Any) -> None: + """A ``date`` is serialised as-is, with no timezone conversion.""" + + rec = TransportRecorder() + rec.install(client) + await client.update_contract(9, PatchContract(date_begin=date(2026, 1, 15))) + assert rec.calls[0]["json"]["date_begin"] == "2026-01-15" + + +async def test_renewal_type_round_trips_as_an_enum(client: Any) -> None: + """``renewal_type`` parses into ``GlpiContractRenewalType``.""" + + contract = GetContract.model_validate({"id": 1, "renewal_type": 1}) + assert contract.renewal_type is GlpiContractRenewalType.TACIT + + +async def test_renewal_type_serialises_as_its_integer(client: Any) -> None: + """The enum is numeric on the wire, as GLPI expects.""" + + rec = TransportRecorder() + rec.install(client) + await client.update_contract( + 9, PatchContract(renewal_type=GlpiContractRenewalType.EXPLICIT) + ) + assert rec.calls[0]["json"]["renewal_type"] == 2 + + +async def test_costs_is_read_only_on_the_client_side() -> None: + """``costs`` is returned but never written; cost lines have own endpoints.""" + + assert "costs" in GetContract.model_fields + assert "costs" not in PostContract.model_fields + assert "costs" not in PatchContract.model_fields + + +async def test_post_contract_excludes_readonly_id() -> None: + """``PostContract`` has no ``id``; the server assigns it.""" + + assert "id" not in PostContract.model_fields + + +# --------------------------------------------------------------------------- +# Contract costs +# --------------------------------------------------------------------------- + + +async def test_list_contract_costs_endpoint(client: Any) -> None: + """``list_contract_costs`` hits the cost sub-resource of one contract.""" + + rec = TransportRecorder(get_payload=[{"id": 3, "cost": 1200.0}]) + rec.install(client) + costs = await client.list_contract_costs(9) + assert costs[0].cost == 1200.0 + assert rec.calls[0]["endpoint"] == "Management/Contract/9/Cost" + + +async def test_get_contract_cost_endpoint(client: Any) -> None: + """``get_contract_cost`` hits the per-cost endpoint.""" + + rec = TransportRecorder(get_payload={"id": 3, "cost": 1200.0}) + rec.install(client) + cost = await client.get_contract_cost(9, 3) + assert cost.id == 3 + assert rec.calls[0]["endpoint"] == "Management/Contract/9/Cost/3" + + +async def test_create_contract_cost_returns_new_id(client: Any) -> None: + """``create_contract_cost`` posts to the sub-resource and returns the id.""" + + rec = TransportRecorder(post_payload={"id": 5}) + rec.install(client) + new_id = await client.create_contract_cost( + 9, PostContractCost(name="Year 1", cost=1200.0) + ) + assert new_id == 5 + assert rec.calls[0]["endpoint"] == "Management/Contract/9/Cost" + assert rec.calls[0]["json"]["cost"] == 1200.0 + + +async def test_update_contract_cost(client: Any) -> None: + """``update_contract_cost`` patches the per-cost endpoint.""" + + rec = TransportRecorder() + rec.install(client) + await client.update_contract_cost(9, 3, PatchContractCost(cost=1500.0)) + assert rec.calls[0]["endpoint"] == "Management/Contract/9/Cost/3" + + +async def test_delete_contract_cost_with_force(client: Any) -> None: + """``delete_contract_cost(force=True)`` ships the force flag.""" + + rec = TransportRecorder() + rec.install(client) + await client.delete_contract_cost(9, 3, force=True) + assert rec.calls[0]["endpoint"] == "Management/Contract/9/Cost/3" + assert rec.calls[0]["json"]["force"] is True + + +async def test_contract_cost_id_is_the_only_readonly_field() -> None: + """``id`` is readable but never written; every other field is shared.""" + + assert "id" in GetContractCost.model_fields + assert "id" not in PostContractCost.model_fields + assert "id" not in PatchContractCost.model_fields diff --git a/glpi_python_client/_async/clients/client.py b/glpi_python_client/_async/clients/client.py index bca6b81..a605d51 100644 --- a/glpi_python_client/_async/clients/client.py +++ b/glpi_python_client/_async/clients/client.py @@ -24,6 +24,9 @@ from glpi_python_client._async.clients._base_client import _BaseGlpiClient from glpi_python_client._async.clients.api import ( + ComputerMixin, + ContractMixin, + ContractTypeMixin, DocumentMixin, EntityMixin, FollowupMixin, @@ -57,9 +60,12 @@ class AsyncGlpiClient( TimelineDocumentMixin, TeamMemberMixin, DocumentMixin, + ContractMixin, + ComputerMixin, UserMixin, EntityMixin, LocationMixin, + ContractTypeMixin, KBCategoryMixin, KBArticleMixin, KBArticleCommentMixin, diff --git a/glpi_python_client/_async/clients/commons/_constants.py b/glpi_python_client/_async/clients/commons/_constants.py index a066759..1f8be0a 100644 --- a/glpi_python_client/_async/clients/commons/_constants.py +++ b/glpi_python_client/_async/clients/commons/_constants.py @@ -1,7 +1,7 @@ """GLPI v2 endpoint paths and shared transport-layer type aliases. The constants here mirror the resource paths defined in the GLPI v2 API -contract under ``docs/glpi_api_contract.json``. Endpoint paths are kept in +contract under ``docs/api_contract/api.json``. Endpoint paths are kept in one place so the API mixins all use the same resource locations and the shared HTTP helpers can rely on stable parameter types. """ @@ -13,14 +13,19 @@ GlpiId: TypeAlias = int RequestParamValue: TypeAlias = str | int | float | bytes | None +# assets/ +COMPUTER_ENDPOINT = "Assets/Computer" + # administration/ USER_ENDPOINT = "Administration/User" ENTITY_ENDPOINT = "Administration/Entity" # dropdowns/ +CONTRACT_TYPE_ENDPOINT = "Dropdowns/ContractType" LOCATION_ENDPOINT = "Dropdowns/Location" # management/ +CONTRACT_ENDPOINT = "Management/Contract" DOCUMENT_ENDPOINT = "Management/Document" # assistance/ @@ -41,6 +46,9 @@ __all__ = [ + "COMPUTER_ENDPOINT", + "CONTRACT_ENDPOINT", + "CONTRACT_TYPE_ENDPOINT", "DOCUMENT_ENDPOINT", "ENTITY_ENDPOINT", "FOLLOWUP_SUFFIX", diff --git a/glpi_python_client/_sync/clients/api/__init__.py b/glpi_python_client/_sync/clients/api/__init__.py index 577f705..5444f6b 100644 --- a/glpi_python_client/_sync/clients/api/__init__.py +++ b/glpi_python_client/_sync/clients/api/__init__.py @@ -1,7 +1,7 @@ """Per-endpoint API mixins backed by the ``api_schema`` Pydantic models. The mixins under this package mirror the endpoints documented in -``docs/glpi_api_contract.json`` one for one. They wrap the +``docs/api_contract/api.json`` one for one. They wrap the transport helpers from :mod:`glpi_python_client._sync.clients.commons` and exchange typed ``Get``, ``Post``, ``Patch``, and ``Delete`` models with the GLPI API. @@ -13,6 +13,7 @@ EntityMixin, UserMixin, ) +from glpi_python_client._sync.clients.api.assets import ComputerMixin from glpi_python_client._sync.clients.api.assistance import ( TeamMemberMixin, TicketMixin, @@ -23,19 +24,28 @@ TicketTaskMixin, TimelineDocumentMixin, ) -from glpi_python_client._sync.clients.api.dropdowns import LocationMixin +from glpi_python_client._sync.clients.api.dropdowns import ( + ContractTypeMixin, + LocationMixin, +) from glpi_python_client._sync.clients.api.knowledgebase import ( KBArticleCommentMixin, KBArticleMixin, KBArticleRevisionMixin, KBCategoryMixin, ) -from glpi_python_client._sync.clients.api.management import DocumentMixin +from glpi_python_client._sync.clients.api.management import ( + ContractMixin, + DocumentMixin, +) from glpi_python_client._sync.clients.api.plugins import ( PluginFieldsMixin, ) __all__ = [ + "ComputerMixin", + "ContractMixin", + "ContractTypeMixin", "DocumentMixin", "EntityMixin", "FollowupMixin", diff --git a/glpi_python_client/_sync/clients/api/assets/__init__.py b/glpi_python_client/_sync/clients/api/assets/__init__.py new file mode 100644 index 0000000..28d8b74 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/assets/__init__.py @@ -0,0 +1,7 @@ +"""GLPI ``/Assets`` mixins for the GLPI client.""" + +from __future__ import annotations + +from glpi_python_client._sync.clients.api.assets._computer import ComputerMixin + +__all__ = ["ComputerMixin"] diff --git a/glpi_python_client/_sync/clients/api/assets/_computer.py b/glpi_python_client/_sync/clients/api/assets/_computer.py new file mode 100644 index 0000000..4d82beb --- /dev/null +++ b/glpi_python_client/_sync/clients/api/assets/_computer.py @@ -0,0 +1,427 @@ +"""GLPI ``/Assets/Computer`` mixin. + +The mixin exposes search, fetch, create, update, and delete helpers for the +GLPI computer resource using the contract-aligned ``api_schema`` models. It +also exposes CRUD helpers for the ``/Assets/Computer/{id}/Contract`` +sub-resource, the join answering which contracts cover a given computer. + +``link_computer_contract`` and ``update_computer_contract`` set ``itemtype`` +and ``items_id`` themselves rather than trusting the caller-supplied values +on the request body; see ``models/api_schema/assets/_contract_item.py`` for +why. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +from glpi_python_client._sync.clients.commons._constants import ( + COMPUTER_ENDPOINT, + GlpiId, +) +from glpi_python_client._sync.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.assets._computer import ( + DeleteComputer, + GetComputer, + PatchComputer, + PostComputer, +) +from glpi_python_client.models.api_schema.assets._contract_item import ( + DeleteContractItem, + GetContractItem, + PatchContractItem, + PostContractItem, +) + + +class ComputerMixin(TransportMixin): + """CRUD helpers for ``/Assets/Computer``.""" + + def search_computers( + self, + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + ) -> list[GetComputer]: + """Search GLPI computers with an optional RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + sort : str | None, optional + Server-side ordering expressed as ``":"``, + for example ``"date_mod:desc"``. Omitted when :data:`None`, + leaving the server default ordering in place. + + Returns + ------- + list[GetComputer] + Computers matching the filter. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + if rsql_filter: + params["filter"] = rsql_filter + if sort is not None: + params["sort"] = sort + return self._resource_list(COMPUTER_ENDPOINT, GetComputer, params=params) + + def iter_search_computers( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + sort: str | None = None, + ) -> Iterator[list[GetComputer]]: + """Yield successive pages of GLPI computers until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible record. + batch_size : int, optional + Number of records requested per page (default 50). Acts as the + ``limit`` parameter on each underlying :meth:`search_computers` + call. + sort : str | None, optional + Server-side ordering expressed as ``":"``, + for example ``"date_mod:desc"``. Forwarded to each page + request; omitted when :data:`None`, leaving the server default + ordering in place. + + Yields + ------ + list[GetComputer] + One page per iteration. The last yielded batch may be shorter + than ``batch_size``. + """ + + start = 0 + while True: + batch = self.search_computers( + rsql_filter, + limit=batch_size, + start=start, + sort=sort, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + + def get_computer(self, computer_id: GlpiId) -> GetComputer: + """Fetch one GLPI computer by identifier. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the computer to retrieve. + + Returns + ------- + GetComputer + Validated computer payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return self._resource_get( + f"{COMPUTER_ENDPOINT}/{computer_id}", + GetComputer, + failure_message=f"Failed to get computer {computer_id}", + ) + + def create_computer(self, computer: PostComputer) -> int: + """Create one GLPI computer. + + Parameters + ---------- + computer : PostComputer + Request body describing the computer 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( + COMPUTER_ENDPOINT, + computer, + failure_message="Failed to create computer", + missing_message="GLPI computer create response did not include an ID", + log_message_factory=(lambda new_id: f"GLPI API created computer {new_id}"), + ) + + def update_computer( + self, computer_id: GlpiId, computer: PatchComputer + ) -> None: + """Update one GLPI computer with a partial body. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the computer to update. + computer : PatchComputer + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + self._resource_update( + f"{COMPUTER_ENDPOINT}/{computer_id}", + computer, + failure_message=f"Failed to update computer {computer_id}", + log_message=f"GLPI API updated computer {computer_id}", + ) + + def delete_computer( + self, computer_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one GLPI computer by identifier. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the computer to delete. + force : bool | None, optional + When ``True`` the computer 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"{COMPUTER_ENDPOINT}/{computer_id}", + failure_message=f"Failed to delete computer {computer_id}", + log_message=f"GLPI API deleted computer {computer_id}", + force=force, + delete_model_cls=DeleteComputer, + ) + + def list_computer_contracts( + self, + computer_id: GlpiId, + *, + limit: int = 50, + start: int = 0, + ) -> list[GetContractItem]: + """List the contracts linked to one GLPI computer. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the owning computer. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + + Returns + ------- + list[GetContractItem] + Contract links belonging to the computer. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + return self._resource_list( + f"{COMPUTER_ENDPOINT}/{computer_id}/Contract", + GetContractItem, + params=params, + ) + + def get_computer_contract( + self, computer_id: GlpiId, link_id: GlpiId + ) -> GetContractItem: + """Fetch one contract link recorded against a GLPI computer. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the owning computer. + link_id : GlpiId + Numeric identifier of the contract link to retrieve. + + Returns + ------- + GetContractItem + Validated contract link payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return self._resource_get( + f"{COMPUTER_ENDPOINT}/{computer_id}/Contract/{link_id}", + GetContractItem, + failure_message=( + f"Failed to get contract link {link_id} for computer {computer_id}" + ), + ) + + def link_computer_contract( + self, computer_id: GlpiId, link: PostContractItem + ) -> int: + """Link one GLPI contract to one computer. + + The ``itemtype`` and ``items_id`` fields are set from + ``computer_id`` rather than taken from ``link``. The GLPI contract + types ``itemtype`` as a free string, so a caller-supplied value is + a silent mis-link waiting to happen; this helper already knows + which asset it is working on. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the computer the contract covers. + link : PostContractItem + Request body naming the contract to link. Any ``itemtype`` or + ``items_id`` set on it is replaced. + + Returns + ------- + int + Identifier assigned to the new link 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. + """ + + body = link.model_copy(update={"itemtype": "Computer", "items_id": computer_id}) + return self._resource_create( + f"{COMPUTER_ENDPOINT}/{computer_id}/Contract", + body, + failure_message=f"Failed to link a contract to computer {computer_id}", + missing_message=( + "GLPI contract link create response did not include an ID" + ), + log_message_factory=( + lambda new_id: ( + f"GLPI API linked contract item {new_id} to computer {computer_id}" + ) + ), + ) + + def update_computer_contract( + self, computer_id: GlpiId, link_id: GlpiId, link: PatchContractItem + ) -> None: + """Update one computer-contract link with a partial body. + + The ``itemtype`` and ``items_id`` fields are set from + ``computer_id`` rather than taken from ``link``, for the same + reason as :meth:`link_computer_contract`. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the owning computer. + link_id : GlpiId + Numeric identifier of the contract link to update. + link : PatchContractItem + Partial request body. Any ``itemtype`` or ``items_id`` set on + it is replaced. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + body = link.model_copy(update={"itemtype": "Computer", "items_id": computer_id}) + self._resource_update( + f"{COMPUTER_ENDPOINT}/{computer_id}/Contract/{link_id}", + body, + failure_message=( + f"Failed to update contract link {link_id} for computer {computer_id}" + ), + log_message=( + f"GLPI API updated contract link {link_id} for computer {computer_id}" + ), + ) + + def unlink_computer_contract( + self, computer_id: GlpiId, link_id: GlpiId, *, force: bool | None = None + ) -> None: + """Remove one contract link from a GLPI computer. + + Parameters + ---------- + computer_id : GlpiId + Numeric identifier of the owning computer. + link_id : GlpiId + Numeric identifier of the contract link to remove. + force : bool | None, optional + When ``True`` the link 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"{COMPUTER_ENDPOINT}/{computer_id}/Contract/{link_id}", + failure_message=( + f"Failed to unlink contract link {link_id} from computer {computer_id}" + ), + log_message=( + f"GLPI API unlinked contract link {link_id} from computer {computer_id}" + ), + force=force, + delete_model_cls=DeleteContractItem, + ) + + +__all__ = ["ComputerMixin"] diff --git a/glpi_python_client/_sync/clients/api/assets/tests/__init__.py b/glpi_python_client/_sync/clients/api/assets/tests/__init__.py new file mode 100644 index 0000000..15a176a --- /dev/null +++ b/glpi_python_client/_sync/clients/api/assets/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the ``/Assets`` endpoint mixins.""" diff --git a/glpi_python_client/_sync/clients/api/assets/tests/test_computer.py b/glpi_python_client/_sync/clients/api/assets/tests/test_computer.py new file mode 100644 index 0000000..371f27c --- /dev/null +++ b/glpi_python_client/_sync/clients/api/assets/tests/test_computer.py @@ -0,0 +1,271 @@ +"""Unit tests for the ``Assets/Computer`` endpoint mixin. + +The tests cover search, fetch, create, update, and delete for GLPI +computers, using the shared transport recorders to stub the four +transport helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from typing import Any + +from glpi_python_client import ( + GetComputer, + IdNameRef, + PatchComputer, + PatchContractItem, + PostComputer, + PostContractItem, +) +from glpi_python_client._sync._testing import TransportRecorder + + +def test_search_computers_passes_filter(client: Any) -> None: + """``search_computers`` forwards the RSQL filter through ``filter``.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "LAPTOP-01"}]) + rec.install(client) + computers = client.search_computers("name==LAPTOP-01") + assert computers[0].id == 1 + assert rec.calls[0]["endpoint"] == "Assets/Computer" + assert rec.calls[0]["params"]["filter"] == "name==LAPTOP-01" + + +def test_search_computers_forwards_sort(client: Any) -> None: + """``sort`` reaches the server when given, and is absent otherwise.""" + + rec = TransportRecorder(get_payload=[]) + rec.install(client) + client.search_computers(sort="date_mod:desc") + assert rec.calls[0]["params"]["sort"] == "date_mod:desc" + + rec2 = TransportRecorder(get_payload=[]) + rec2.install(client) + client.search_computers() + assert "sort" not in rec2.calls[0]["params"] + + +def test_iter_search_computers_stops_on_short_page(client: Any) -> None: + """Pagination stops once the server returns fewer rows than requested. + + The recorder answers every GET with the same payload, so a generator + that did not stop on a short page would loop forever here rather than + fail an assertion. + """ + + rec = TransportRecorder(get_payload=[{"id": 1}, {"id": 2}]) + rec.install(client) + pages = [page for page in client.iter_search_computers(batch_size=3)] + assert len(pages) == 1 + assert len(pages[0]) == 2 + assert len(rec.calls) == 1 + assert rec.calls[0]["params"] == {"limit": 3, "start": 0} + + +def test_iter_search_computers_yields_every_page(client: Any) -> None: + """The generator advances ``start`` until a short page ends the walk. + + ``TransportRecorder`` replays one payload forever, so it cannot drive a + multi-page walk. Replace ``search_computers`` itself, as + ``test_location.py`` does. The stub is a named function rather than a + lambda: this module's twin is generated from it by a token rewriter, + which can transform a ``def`` but cannot build one out of a lambda. + """ + + pages = [[GetComputer(id=i) for i in range(3)], [GetComputer(id=99)]] + starts: list[int] = [] + sorts: list[str | None] = [] + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + ) -> list[GetComputer]: + starts.append(start) + sorts.append(sort) + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_computers = fake_search # type: ignore[method-assign] + + batches = [ + batch + for batch in client.iter_search_computers( + "name==x", batch_size=3, sort="date_mod:desc" + ) + ] + + assert starts == [0, 3] + assert sorts == ["date_mod:desc", "date_mod:desc"] + assert [len(b) for b in batches] == [3, 1] + + +def test_get_computer_endpoint(client: Any) -> None: + """``get_computer`` hits the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 9, "name": "LAPTOP-01"}) + rec.install(client) + computer = client.get_computer(9) + assert computer.id == 9 + assert rec.calls[0]["endpoint"] == "Assets/Computer/9" + + +def test_create_computer_returns_new_id(client: Any) -> None: + """``create_computer`` posts the body and returns the server id.""" + + rec = TransportRecorder(post_payload={"id": 31}) + rec.install(client) + new_id = client.create_computer(PostComputer(name="LAPTOP-02")) + assert new_id == 31 + assert rec.calls[0]["endpoint"] == "Assets/Computer" + assert rec.calls[0]["json"]["name"] == "LAPTOP-02" + + +def test_update_computer(client: Any) -> None: + """``update_computer`` patches the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.update_computer(9, PatchComputer(serial="SN-123")) + assert rec.calls[0]["endpoint"] == "Assets/Computer/9" + assert rec.calls[0]["json"]["serial"] == "SN-123" + + +def test_delete_computer_with_force(client: Any) -> None: + """``delete_computer(force=True)`` ships the force flag.""" + + rec = TransportRecorder() + rec.install(client) + client.delete_computer(9, force=True) + assert rec.calls[0]["json"]["force"] is True + + +def test_group_fields_parse_as_lists_of_references(client: Any) -> None: + """``group`` and ``group_tech`` are arrays in the contract, not scalars.""" + + computer = GetComputer.model_validate( + { + "id": 1, + "group": [{"id": 4, "name": "Support"}], + "group_tech": [{"id": 5, "name": "Techs"}], + } + ) + assert computer.group is not None + assert computer.group[0].name == "Support" + assert computer.group_tech is not None + assert computer.group_tech[0].id == 5 + + +def test_post_computer_excludes_every_readonly_field() -> None: + """The four contract ``readOnly`` fields never reach a write body.""" + + for field in ("id", "uuid", "last_inventory_update", "last_boot"): + assert field not in PostComputer.model_fields, field + assert field not in PatchComputer.model_fields, field + + +# --------------------------------------------------------------------------- +# Computer <-> contract links +# --------------------------------------------------------------------------- + + +def test_list_computer_contracts_endpoint(client: Any) -> None: + """``list_computer_contracts`` hits the contract sub-resource.""" + + rec = TransportRecorder( + get_payload=[{"id": 2, "contract": {"id": 7, "name": "Support"}}] + ) + rec.install(client) + links = client.list_computer_contracts(9) + assert links[0].contract is not None + assert links[0].contract.name == "Support" + assert rec.calls[0]["endpoint"] == "Assets/Computer/9/Contract" + + +def test_get_computer_contract_endpoint(client: Any) -> None: + """``get_computer_contract`` hits the per-link endpoint.""" + + rec = TransportRecorder(get_payload={"id": 2}) + rec.install(client) + link = client.get_computer_contract(9, 2) + assert link.id == 2 + assert rec.calls[0]["endpoint"] == "Assets/Computer/9/Contract/2" + + +def test_link_computer_contract_sets_itemtype_itself(client: Any) -> None: + """The client fills ``itemtype`` and ``items_id``, not the caller. + + ``Contract_Item.itemtype`` is a free string in the contract, so a typo + there is a silently wrong link. The mixin knows it is working on a + computer and says so. + """ + + rec = TransportRecorder(post_payload={"id": 4}) + rec.install(client) + new_id = client.link_computer_contract( + 9, PostContractItem(contract=IdNameRef(id=7)) + ) + assert new_id == 4 + assert rec.calls[0]["endpoint"] == "Assets/Computer/9/Contract" + assert rec.calls[0]["json"]["itemtype"] == "Computer" + assert rec.calls[0]["json"]["items_id"] == 9 + + +def test_link_computer_contract_overrides_a_caller_itemtype( + client: Any, +) -> None: + """A caller-supplied itemtype cannot point the link at another type.""" + + rec = TransportRecorder(post_payload={"id": 4}) + rec.install(client) + client.link_computer_contract( + 9, PostContractItem(contract=IdNameRef(id=7), itemtype="Monitor", items_id=1) + ) + assert rec.calls[0]["json"]["itemtype"] == "Computer" + assert rec.calls[0]["json"]["items_id"] == 9 + + +def test_update_computer_contract(client: Any) -> None: + """``update_computer_contract`` patches the per-link endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.update_computer_contract( + 9, 2, PatchContractItem(contract=IdNameRef(id=8)) + ) + assert rec.calls[0]["endpoint"] == "Assets/Computer/9/Contract/2" + assert rec.calls[0]["json"]["itemtype"] == "Computer" + assert rec.calls[0]["json"]["items_id"] == 9 + + +def test_update_computer_contract_overrides_a_caller_itemtype( + client: Any, +) -> None: + """A caller-supplied itemtype cannot repoint an existing link either. + + The update path stamps the same two fields as the create path. Without + this test, dropping the stamp from one of the two would leave the suite + green. + """ + + rec = TransportRecorder() + rec.install(client) + client.update_computer_contract( + 9, + 2, + PatchContractItem(contract=IdNameRef(id=8), itemtype="Monitor", items_id=1), + ) + assert rec.calls[0]["json"]["itemtype"] == "Computer" + assert rec.calls[0]["json"]["items_id"] == 9 + + +def test_unlink_computer_contract_with_force(client: Any) -> None: + """``unlink_computer_contract(force=True)`` ships the force flag.""" + + rec = TransportRecorder() + rec.install(client) + client.unlink_computer_contract(9, 2, force=True) + assert rec.calls[0]["endpoint"] == "Assets/Computer/9/Contract/2" + assert rec.calls[0]["json"]["force"] is True diff --git a/glpi_python_client/_sync/clients/api/dropdowns/__init__.py b/glpi_python_client/_sync/clients/api/dropdowns/__init__.py index c7f1cb4..817ad59 100644 --- a/glpi_python_client/_sync/clients/api/dropdowns/__init__.py +++ b/glpi_python_client/_sync/clients/api/dropdowns/__init__.py @@ -2,6 +2,9 @@ from __future__ import annotations +from glpi_python_client._sync.clients.api.dropdowns._contract_type import ( + ContractTypeMixin, +) from glpi_python_client._sync.clients.api.dropdowns._location import LocationMixin -__all__ = ["LocationMixin"] +__all__ = ["ContractTypeMixin", "LocationMixin"] diff --git a/glpi_python_client/_sync/clients/api/dropdowns/_contract_type.py b/glpi_python_client/_sync/clients/api/dropdowns/_contract_type.py new file mode 100644 index 0000000..ff25575 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/dropdowns/_contract_type.py @@ -0,0 +1,220 @@ +"""GLPI ``/Dropdowns/ContractType`` mixin. + +The mixin exposes search, fetch, create, update, and delete helpers for the +GLPI contract type dropdown resource using the contract-aligned +``api_schema`` models. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +from glpi_python_client._sync.clients.commons._constants import ( + CONTRACT_TYPE_ENDPOINT, + GlpiId, +) +from glpi_python_client._sync.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.dropdowns._contract_type import ( + DeleteContractType, + GetContractType, + PatchContractType, + PostContractType, +) + + +class ContractTypeMixin(TransportMixin): + """CRUD helpers for ``/Dropdowns/ContractType``.""" + + def search_contract_types( + self, + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + ) -> list[GetContractType]: + """Search GLPI contract types with an optional RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + + Returns + ------- + list[GetContractType] + Contract types matching the filter. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + if rsql_filter: + params["filter"] = rsql_filter + return self._resource_list( + CONTRACT_TYPE_ENDPOINT, GetContractType, params=params + ) + + def iter_search_contract_types( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + ) -> Iterator[list[GetContractType]]: + """Yield successive pages of GLPI contract types until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible record. + batch_size : int, optional + Number of records requested per page (default 50). Acts as the + ``limit`` parameter on each underlying + :meth:`search_contract_types` call. + + Yields + ------ + list[GetContractType] + One page per iteration. The last yielded batch may be shorter + than ``batch_size``. + """ + + start = 0 + while True: + batch = self.search_contract_types( + rsql_filter, + limit=batch_size, + start=start, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + + def get_contract_type(self, contract_type_id: GlpiId) -> GetContractType: + """Fetch one GLPI contract type by identifier. + + Parameters + ---------- + contract_type_id : GlpiId + Numeric identifier of the contract type to retrieve. + + Returns + ------- + GetContractType + Validated contract type payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return self._resource_get( + f"{CONTRACT_TYPE_ENDPOINT}/{contract_type_id}", + GetContractType, + failure_message=f"Failed to get contract type {contract_type_id}", + ) + + def create_contract_type(self, contract_type: PostContractType) -> int: + """Create one GLPI contract type. + + Parameters + ---------- + contract_type : PostContractType + Request body describing the contract type 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( + CONTRACT_TYPE_ENDPOINT, + contract_type, + failure_message="Failed to create contract type", + missing_message=( + "GLPI contract type create response did not include an ID" + ), + log_message_factory=( + lambda new_id: f"GLPI API created contract type {new_id}" + ), + ) + + def update_contract_type( + self, contract_type_id: GlpiId, contract_type: PatchContractType + ) -> None: + """Update one GLPI contract type with a partial body. + + Parameters + ---------- + contract_type_id : GlpiId + Numeric identifier of the contract type to update. + contract_type : PatchContractType + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + self._resource_update( + f"{CONTRACT_TYPE_ENDPOINT}/{contract_type_id}", + contract_type, + failure_message=(f"Failed to update contract type {contract_type_id}"), + log_message=f"GLPI API updated contract type {contract_type_id}", + ) + + def delete_contract_type( + self, contract_type_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one GLPI contract type by identifier. + + Parameters + ---------- + contract_type_id : GlpiId + Numeric identifier of the contract type to delete. + force : bool | None, optional + When ``True`` the contract type 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"{CONTRACT_TYPE_ENDPOINT}/{contract_type_id}", + failure_message=(f"Failed to delete contract type {contract_type_id}"), + log_message=f"GLPI API deleted contract type {contract_type_id}", + force=force, + delete_model_cls=DeleteContractType, + ) + + +__all__ = ["ContractTypeMixin"] diff --git a/glpi_python_client/_sync/clients/api/dropdowns/tests/test_contract_type.py b/glpi_python_client/_sync/clients/api/dropdowns/tests/test_contract_type.py new file mode 100644 index 0000000..d1df162 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/dropdowns/tests/test_contract_type.py @@ -0,0 +1,126 @@ +"""Unit tests for the ``Dropdowns/ContractType`` endpoint mixin. + +The tests cover search, fetch, create, update, and delete for GLPI +contract types, using the shared transport recorders to stub the four +transport helpers without any HTTP plumbing. +""" + +from __future__ import annotations + +from typing import Any + +from glpi_python_client import GetContractType, PatchContractType, PostContractType +from glpi_python_client._sync._testing import TransportRecorder + + +def test_search_contract_types_passes_filter(client: Any) -> None: + """``search_contract_types`` forwards the RSQL filter through ``filter``.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "Maintenance"}]) + rec.install(client) + types = client.search_contract_types("name==Maintenance") + assert types[0].id == 1 + assert rec.calls[0]["endpoint"] == "Dropdowns/ContractType" + assert rec.calls[0]["params"]["filter"] == "name==Maintenance" + + +def test_search_contract_types_omits_empty_filter(client: Any) -> None: + """An empty filter is not sent, so the server lists everything visible.""" + + rec = TransportRecorder(get_payload=[]) + rec.install(client) + client.search_contract_types() + assert "filter" not in rec.calls[0]["params"] + + +def test_iter_search_contract_types_stops_on_short_page(client: Any) -> None: + """Pagination stops once the server returns fewer rows than requested.""" + + rec = TransportRecorder(get_payload=[{"id": 1}]) + rec.install(client) + pages = [page for page in client.iter_search_contract_types(batch_size=2)] + assert len(pages) == 1 + assert len(pages[0]) == 1 + assert len(rec.calls) == 1 + assert rec.calls[0]["params"] == {"limit": 2, "start": 0} + + +def test_iter_search_contract_types_yields_every_page(client: Any) -> None: + """The generator advances ``start`` until a short page ends the walk. + + ``TransportRecorder`` replays one payload forever, so it cannot drive a + multi-page walk. Replace ``search_contract_types`` itself, as + ``test_location.py`` does. The stub is a named function rather than a + lambda: this module's twin is generated from it by a token rewriter, + which can transform a ``def`` but cannot build one out of a lambda. + """ + + pages = [ + [GetContractType(id=i) for i in range(3)], + [GetContractType(id=99)], + ] + starts: list[int] = [] + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetContractType]: + starts.append(start) + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_contract_types = fake_search # type: ignore[method-assign] + + batches = [ + batch + for batch in client.iter_search_contract_types("name==x", batch_size=3) + ] + + assert starts == [0, 3] + assert [len(b) for b in batches] == [3, 1] + + +def test_get_contract_type_endpoint(client: Any) -> None: + """``get_contract_type`` hits the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 9, "name": "Lease"}) + rec.install(client) + contract_type = client.get_contract_type(9) + assert contract_type.id == 9 + assert rec.calls[0]["endpoint"] == "Dropdowns/ContractType/9" + + +def test_create_contract_type_returns_new_id(client: Any) -> None: + """``create_contract_type`` posts the body and returns the server id.""" + + rec = TransportRecorder(post_payload={"id": 42}) + rec.install(client) + new_id = client.create_contract_type(PostContractType(name="Lease")) + assert new_id == 42 + assert rec.calls[0]["endpoint"] == "Dropdowns/ContractType" + assert rec.calls[0]["json"]["name"] == "Lease" + + +def test_update_contract_type(client: Any) -> None: + """``update_contract_type`` patches the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.update_contract_type(9, PatchContractType(name="Lease 2")) + assert rec.calls[0]["endpoint"] == "Dropdowns/ContractType/9" + assert rec.calls[0]["json"]["name"] == "Lease 2" + + +def test_delete_contract_type_with_force(client: Any) -> None: + """``delete_contract_type(force=True)`` ships the force flag.""" + + rec = TransportRecorder() + rec.install(client) + client.delete_contract_type(9, force=True) + assert rec.calls[0]["endpoint"] == "Dropdowns/ContractType/9" + assert rec.calls[0]["json"]["force"] is True + + +def test_post_contract_type_excludes_readonly_id() -> None: + """``PostContractType`` has no ``id``; the server assigns it.""" + + assert "id" not in PostContractType.model_fields diff --git a/glpi_python_client/_sync/clients/api/management/__init__.py b/glpi_python_client/_sync/clients/api/management/__init__.py index 18a180c..cd9ad9b 100644 --- a/glpi_python_client/_sync/clients/api/management/__init__.py +++ b/glpi_python_client/_sync/clients/api/management/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from glpi_python_client._sync.clients.api.management._contract import ContractMixin from glpi_python_client._sync.clients.api.management._document import DocumentMixin -__all__ = ["DocumentMixin"] +__all__ = ["ContractMixin", "DocumentMixin"] diff --git a/glpi_python_client/_sync/clients/api/management/_contract.py b/glpi_python_client/_sync/clients/api/management/_contract.py new file mode 100644 index 0000000..cf4fcb9 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/management/_contract.py @@ -0,0 +1,411 @@ +"""GLPI ``/Management/Contract`` mixin. + +The mixin exposes search, fetch, create, update, and delete helpers for the +GLPI contract resource using the contract-aligned ``api_schema`` models. It +also exposes CRUD helpers for the ``/Management/Contract/{id}/Cost`` +sub-resource, built off the parent contract id. + +``ContractCost.date_begin`` and ``date_end`` are ``datetime``, not +``date``. This is the opposite choice from ``Contract.date_begin``: the +contract declares ``Contract.date_begin`` with ``format: date`` and +``ContractCost``'s two date fields with ``format: date-time``. See +``models/api_schema/management/_contract_cost.py`` for the full +explanation; the asymmetry is real and comes from the contract itself. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +from glpi_python_client._sync.clients.commons._constants import ( + CONTRACT_ENDPOINT, + GlpiId, +) +from glpi_python_client._sync.clients.commons._transport import TransportMixin +from glpi_python_client.models.api_schema.management._contract import ( + DeleteContract, + GetContract, + PatchContract, + PostContract, +) +from glpi_python_client.models.api_schema.management._contract_cost import ( + DeleteContractCost, + GetContractCost, + PatchContractCost, + PostContractCost, +) + + +class ContractMixin(TransportMixin): + """CRUD helpers for ``/Management/Contract``.""" + + def search_contracts( + self, + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + ) -> list[GetContract]: + """Search GLPI contracts with an optional RSQL filter. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + sort : str | None, optional + Server-side ordering expressed as ``":"``, + for example ``"date_begin:desc"``. Omitted when :data:`None`, + leaving the server default ordering in place. + + Returns + ------- + list[GetContract] + Contracts matching the filter. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + if rsql_filter: + params["filter"] = rsql_filter + if sort is not None: + params["sort"] = sort + return self._resource_list(CONTRACT_ENDPOINT, GetContract, params=params) + + def iter_search_contracts( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + sort: str | None = None, + ) -> Iterator[list[GetContract]]: + """Yield successive pages of GLPI contracts until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible record. + batch_size : int, optional + Number of records requested per page (default 50). Acts as the + ``limit`` parameter on each underlying :meth:`search_contracts` + call. + sort : str | None, optional + Server-side ordering expressed as ``":"``, + for example ``"date_begin:desc"``. Forwarded to each page + request; omitted when :data:`None`, leaving the server default + ordering in place. + + Yields + ------ + list[GetContract] + One page per iteration. The last yielded batch may be shorter + than ``batch_size``. + """ + + start = 0 + while True: + batch = self.search_contracts( + rsql_filter, + limit=batch_size, + start=start, + sort=sort, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + + def get_contract(self, contract_id: GlpiId) -> GetContract: + """Fetch one GLPI contract by identifier. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the contract to retrieve. + + Returns + ------- + GetContract + Validated contract payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return self._resource_get( + f"{CONTRACT_ENDPOINT}/{contract_id}", + GetContract, + failure_message=f"Failed to get contract {contract_id}", + ) + + def create_contract(self, contract: PostContract) -> int: + """Create one GLPI contract. + + Parameters + ---------- + contract : PostContract + Request body describing the contract 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( + CONTRACT_ENDPOINT, + contract, + failure_message="Failed to create contract", + missing_message="GLPI contract create response did not include an ID", + log_message_factory=(lambda new_id: f"GLPI API created contract {new_id}"), + ) + + def update_contract( + self, contract_id: GlpiId, contract: PatchContract + ) -> None: + """Update one GLPI contract with a partial body. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the contract to update. + contract : PatchContract + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + self._resource_update( + f"{CONTRACT_ENDPOINT}/{contract_id}", + contract, + failure_message=f"Failed to update contract {contract_id}", + log_message=f"GLPI API updated contract {contract_id}", + ) + + def delete_contract( + self, contract_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one GLPI contract by identifier. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the contract to delete. + force : bool | None, optional + When ``True`` the contract 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"{CONTRACT_ENDPOINT}/{contract_id}", + failure_message=f"Failed to delete contract {contract_id}", + log_message=f"GLPI API deleted contract {contract_id}", + force=force, + delete_model_cls=DeleteContract, + ) + + def list_contract_costs( + self, + contract_id: GlpiId, + *, + limit: int = 50, + start: int = 0, + ) -> list[GetContractCost]: + """List the cost lines recorded against one GLPI contract. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the owning contract. + limit : int, optional + Maximum number of records returned by the GLPI server. + start : int, optional + Zero-based offset of the first record returned. + + Returns + ------- + list[GetContractCost] + Cost lines belonging to the contract. + """ + + params: dict[str, object] = {"limit": limit, "start": start} + return self._resource_list( + f"{CONTRACT_ENDPOINT}/{contract_id}/Cost", + GetContractCost, + params=params, + ) + + def get_contract_cost( + self, contract_id: GlpiId, cost_id: GlpiId + ) -> GetContractCost: + """Fetch one cost line recorded against a GLPI contract. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the owning contract. + cost_id : GlpiId + Numeric identifier of the cost line to retrieve. + + Returns + ------- + GetContractCost + Validated cost line payload. + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + return self._resource_get( + f"{CONTRACT_ENDPOINT}/{contract_id}/Cost/{cost_id}", + GetContractCost, + failure_message=( + f"Failed to get cost {cost_id} for contract {contract_id}" + ), + ) + + def create_contract_cost( + self, contract_id: GlpiId, cost: PostContractCost + ) -> int: + """Create one cost line against a GLPI contract. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the owning contract. + cost : PostContractCost + Request body describing the cost line 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( + f"{CONTRACT_ENDPOINT}/{contract_id}/Cost", + cost, + failure_message=f"Failed to create cost for contract {contract_id}", + missing_message=( + "GLPI contract cost create response did not include an ID" + ), + log_message_factory=( + lambda new_id: ( + f"GLPI API created cost {new_id} for contract {contract_id}" + ) + ), + ) + + def update_contract_cost( + self, contract_id: GlpiId, cost_id: GlpiId, cost: PatchContractCost + ) -> None: + """Update one contract cost line with a partial body. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the owning contract. + cost_id : GlpiId + Numeric identifier of the cost line to update. + cost : PatchContractCost + Partial request body. + + Returns + ------- + None + + Raises + ------ + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + self._resource_update( + f"{CONTRACT_ENDPOINT}/{contract_id}/Cost/{cost_id}", + cost, + failure_message=( + f"Failed to update cost {cost_id} for contract {contract_id}" + ), + log_message=(f"GLPI API updated cost {cost_id} for contract {contract_id}"), + ) + + def delete_contract_cost( + self, contract_id: GlpiId, cost_id: GlpiId, *, force: bool | None = None + ) -> None: + """Delete one cost line from a GLPI contract. + + Parameters + ---------- + contract_id : GlpiId + Numeric identifier of the owning contract. + cost_id : GlpiId + Numeric identifier of the cost line to delete. + force : bool | None, optional + When ``True`` the cost line 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"{CONTRACT_ENDPOINT}/{contract_id}/Cost/{cost_id}", + failure_message=( + f"Failed to delete cost {cost_id} for contract {contract_id}" + ), + log_message=(f"GLPI API deleted cost {cost_id} for contract {contract_id}"), + force=force, + delete_model_cls=DeleteContractCost, + ) + + +__all__ = ["ContractMixin"] diff --git a/glpi_python_client/_sync/clients/api/management/tests/test_contract.py b/glpi_python_client/_sync/clients/api/management/tests/test_contract.py new file mode 100644 index 0000000..89caf58 --- /dev/null +++ b/glpi_python_client/_sync/clients/api/management/tests/test_contract.py @@ -0,0 +1,257 @@ +"""Unit tests for the ``Management/Contract`` endpoint mixin. + +The tests cover search, fetch, create, update, and delete for GLPI +contracts, and pin the two modelling decisions the contract forced: a +``date``-typed ``date_begin`` and an enum-typed ``renewal_type``. +""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Any + +from glpi_python_client import ( + GetContract, + GetContractCost, + GlpiContractRenewalType, + PatchContract, + PatchContractCost, + PostContract, + PostContractCost, +) +from glpi_python_client._sync._testing import TransportRecorder + + +def test_search_contracts_passes_filter(client: Any) -> None: + """``search_contracts`` forwards the RSQL filter through ``filter``.""" + + rec = TransportRecorder(get_payload=[{"id": 1, "name": "Support 2026"}]) + rec.install(client) + contracts = client.search_contracts("name==Support 2026") + assert contracts[0].id == 1 + assert rec.calls[0]["endpoint"] == "Management/Contract" + assert rec.calls[0]["params"]["filter"] == "name==Support 2026" + + +def test_search_contracts_forwards_sort(client: Any) -> None: + """``sort`` reaches the server when given, and is absent otherwise.""" + + rec = TransportRecorder(get_payload=[]) + rec.install(client) + client.search_contracts(sort="date_begin:desc") + assert rec.calls[0]["params"]["sort"] == "date_begin:desc" + + rec2 = TransportRecorder(get_payload=[]) + rec2.install(client) + client.search_contracts() + assert "sort" not in rec2.calls[0]["params"] + + +def test_iter_search_contracts_stops_on_short_page(client: Any) -> None: + """Pagination stops once the server returns fewer rows than requested.""" + + rec = TransportRecorder(get_payload=[{"id": 1}]) + rec.install(client) + pages = [page for page in client.iter_search_contracts(batch_size=2)] + assert len(pages) == 1 + assert len(pages[0]) == 1 + assert len(rec.calls) == 1 + assert rec.calls[0]["params"] == {"limit": 2, "start": 0} + + +def test_iter_search_contracts_yields_every_page(client: Any) -> None: + """The generator advances ``start`` until a short page ends the walk. + + ``TransportRecorder`` replays one payload forever, so it cannot drive a + multi-page walk. Replace ``search_contracts`` itself, as + ``test_location.py`` does. The stub is a named function rather than a + lambda: this module's twin is generated from it by a token rewriter, + which can transform a ``def`` but cannot build one out of a lambda. + """ + + pages = [[GetContract(id=i) for i in range(3)], [GetContract(id=99)]] + starts: list[int] = [] + sorts: list[str | None] = [] + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + ) -> list[GetContract]: + starts.append(start) + sorts.append(sort) + index = start // limit + return pages[index] if index < len(pages) else [] + + client.search_contracts = fake_search # type: ignore[method-assign] + + batches = [ + batch + for batch in client.iter_search_contracts( + "name==x", batch_size=3, sort="date_mod:desc" + ) + ] + + assert starts == [0, 3] + assert sorts == ["date_mod:desc", "date_mod:desc"] + assert [len(b) for b in batches] == [3, 1] + + +def test_get_contract_endpoint(client: Any) -> None: + """``get_contract`` hits the per-id endpoint.""" + + rec = TransportRecorder(get_payload={"id": 9, "name": "Support"}) + rec.install(client) + contract = client.get_contract(9) + assert contract.id == 9 + assert rec.calls[0]["endpoint"] == "Management/Contract/9" + + +def test_create_contract_returns_new_id(client: Any) -> None: + """``create_contract`` posts the body and returns the server id.""" + + rec = TransportRecorder(post_payload={"id": 77}) + rec.install(client) + new_id = client.create_contract(PostContract(name="Support")) + assert new_id == 77 + assert rec.calls[0]["endpoint"] == "Management/Contract" + + +def test_update_contract(client: Any) -> None: + """``update_contract`` patches the per-id endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.update_contract(9, PatchContract(name="Support 2027")) + assert rec.calls[0]["endpoint"] == "Management/Contract/9" + + +def test_delete_contract_with_force(client: Any) -> None: + """``delete_contract(force=True)`` ships the force flag.""" + + rec = TransportRecorder() + rec.install(client) + client.delete_contract(9, force=True) + assert rec.calls[0]["json"]["force"] is True + + +def test_date_begin_is_a_plain_date_not_a_datetime(client: Any) -> None: + """``date_begin`` parses to ``date``. + + The contract declares ``format: date``. Modelling it as ``datetime`` + would make an aware value eligible for the server-clock conversion in + ``models/_base.py``, which could roll the start date to the previous + or next day. + """ + + contract = GetContract.model_validate({"id": 1, "date_begin": "2026-01-15"}) + assert contract.date_begin == date(2026, 1, 15) + assert not isinstance(contract.date_begin, datetime) + + +def test_date_begin_survives_a_write_unshifted(client: Any) -> None: + """A ``date`` is serialised as-is, with no timezone conversion.""" + + rec = TransportRecorder() + rec.install(client) + client.update_contract(9, PatchContract(date_begin=date(2026, 1, 15))) + assert rec.calls[0]["json"]["date_begin"] == "2026-01-15" + + +def test_renewal_type_round_trips_as_an_enum(client: Any) -> None: + """``renewal_type`` parses into ``GlpiContractRenewalType``.""" + + contract = GetContract.model_validate({"id": 1, "renewal_type": 1}) + assert contract.renewal_type is GlpiContractRenewalType.TACIT + + +def test_renewal_type_serialises_as_its_integer(client: Any) -> None: + """The enum is numeric on the wire, as GLPI expects.""" + + rec = TransportRecorder() + rec.install(client) + client.update_contract( + 9, PatchContract(renewal_type=GlpiContractRenewalType.EXPLICIT) + ) + assert rec.calls[0]["json"]["renewal_type"] == 2 + + +def test_costs_is_read_only_on_the_client_side() -> None: + """``costs`` is returned but never written; cost lines have own endpoints.""" + + assert "costs" in GetContract.model_fields + assert "costs" not in PostContract.model_fields + assert "costs" not in PatchContract.model_fields + + +def test_post_contract_excludes_readonly_id() -> None: + """``PostContract`` has no ``id``; the server assigns it.""" + + assert "id" not in PostContract.model_fields + + +# --------------------------------------------------------------------------- +# Contract costs +# --------------------------------------------------------------------------- + + +def test_list_contract_costs_endpoint(client: Any) -> None: + """``list_contract_costs`` hits the cost sub-resource of one contract.""" + + rec = TransportRecorder(get_payload=[{"id": 3, "cost": 1200.0}]) + rec.install(client) + costs = client.list_contract_costs(9) + assert costs[0].cost == 1200.0 + assert rec.calls[0]["endpoint"] == "Management/Contract/9/Cost" + + +def test_get_contract_cost_endpoint(client: Any) -> None: + """``get_contract_cost`` hits the per-cost endpoint.""" + + rec = TransportRecorder(get_payload={"id": 3, "cost": 1200.0}) + rec.install(client) + cost = client.get_contract_cost(9, 3) + assert cost.id == 3 + assert rec.calls[0]["endpoint"] == "Management/Contract/9/Cost/3" + + +def test_create_contract_cost_returns_new_id(client: Any) -> None: + """``create_contract_cost`` posts to the sub-resource and returns the id.""" + + rec = TransportRecorder(post_payload={"id": 5}) + rec.install(client) + new_id = client.create_contract_cost( + 9, PostContractCost(name="Year 1", cost=1200.0) + ) + assert new_id == 5 + assert rec.calls[0]["endpoint"] == "Management/Contract/9/Cost" + assert rec.calls[0]["json"]["cost"] == 1200.0 + + +def test_update_contract_cost(client: Any) -> None: + """``update_contract_cost`` patches the per-cost endpoint.""" + + rec = TransportRecorder() + rec.install(client) + client.update_contract_cost(9, 3, PatchContractCost(cost=1500.0)) + assert rec.calls[0]["endpoint"] == "Management/Contract/9/Cost/3" + + +def test_delete_contract_cost_with_force(client: Any) -> None: + """``delete_contract_cost(force=True)`` ships the force flag.""" + + rec = TransportRecorder() + rec.install(client) + client.delete_contract_cost(9, 3, force=True) + assert rec.calls[0]["endpoint"] == "Management/Contract/9/Cost/3" + assert rec.calls[0]["json"]["force"] is True + + +def test_contract_cost_id_is_the_only_readonly_field() -> None: + """``id`` is readable but never written; every other field is shared.""" + + assert "id" in GetContractCost.model_fields + assert "id" not in PostContractCost.model_fields + assert "id" not in PatchContractCost.model_fields diff --git a/glpi_python_client/_sync/clients/client.py b/glpi_python_client/_sync/clients/client.py index fdee02c..7a2bcdc 100644 --- a/glpi_python_client/_sync/clients/client.py +++ b/glpi_python_client/_sync/clients/client.py @@ -24,6 +24,9 @@ from glpi_python_client._sync.clients._base_client import _BaseGlpiClient from glpi_python_client._sync.clients.api import ( + ComputerMixin, + ContractMixin, + ContractTypeMixin, DocumentMixin, EntityMixin, FollowupMixin, @@ -57,9 +60,12 @@ class GlpiClient( TimelineDocumentMixin, TeamMemberMixin, DocumentMixin, + ContractMixin, + ComputerMixin, UserMixin, EntityMixin, LocationMixin, + ContractTypeMixin, KBCategoryMixin, KBArticleMixin, KBArticleCommentMixin, diff --git a/glpi_python_client/_sync/clients/commons/_constants.py b/glpi_python_client/_sync/clients/commons/_constants.py index a066759..1f8be0a 100644 --- a/glpi_python_client/_sync/clients/commons/_constants.py +++ b/glpi_python_client/_sync/clients/commons/_constants.py @@ -1,7 +1,7 @@ """GLPI v2 endpoint paths and shared transport-layer type aliases. The constants here mirror the resource paths defined in the GLPI v2 API -contract under ``docs/glpi_api_contract.json``. Endpoint paths are kept in +contract under ``docs/api_contract/api.json``. Endpoint paths are kept in one place so the API mixins all use the same resource locations and the shared HTTP helpers can rely on stable parameter types. """ @@ -13,14 +13,19 @@ GlpiId: TypeAlias = int RequestParamValue: TypeAlias = str | int | float | bytes | None +# assets/ +COMPUTER_ENDPOINT = "Assets/Computer" + # administration/ USER_ENDPOINT = "Administration/User" ENTITY_ENDPOINT = "Administration/Entity" # dropdowns/ +CONTRACT_TYPE_ENDPOINT = "Dropdowns/ContractType" LOCATION_ENDPOINT = "Dropdowns/Location" # management/ +CONTRACT_ENDPOINT = "Management/Contract" DOCUMENT_ENDPOINT = "Management/Document" # assistance/ @@ -41,6 +46,9 @@ __all__ = [ + "COMPUTER_ENDPOINT", + "CONTRACT_ENDPOINT", + "CONTRACT_TYPE_ENDPOINT", "DOCUMENT_ENDPOINT", "ENTITY_ENDPOINT", "FOLLOWUP_SUFFIX", diff --git a/glpi_python_client/models/__init__.py b/glpi_python_client/models/__init__.py index afd90d8..93581c5 100644 --- a/glpi_python_client/models/__init__.py +++ b/glpi_python_client/models/__init__.py @@ -3,7 +3,7 @@ The models are organised in two layers: * :mod:`glpi_python_client.models.api_schema` -- raw Pydantic shapes that - mirror ``docs/glpi_api_contract.json`` one for one, apart from the + mirror ``docs/api_contract/api.json`` one for one, apart from the rich-text slots documented there. One model per HTTP verb is exposed under the ``Get``, ``Post``, ``Patch`` and ``Delete`` naming convention. Use these models from new client @@ -30,6 +30,16 @@ PostEntity, PostUser, ) +from glpi_python_client.models.api_schema.assets import ( + DeleteComputer, + DeleteContractItem, + GetComputer, + GetContractItem, + PatchComputer, + PatchContractItem, + PostComputer, + PostContractItem, +) from glpi_python_client.models.api_schema.assistance import ( DeleteTeamMember, DeleteTicket, @@ -59,12 +69,17 @@ PostTimelineDocument, ) from glpi_python_client.models.api_schema.dropdowns import ( + DeleteContractType, DeleteLocation, + GetContractType, GetLocation, + PatchContractType, PatchLocation, + PostContractType, PostLocation, ) from glpi_python_client.models.api_schema.enums import ( + GlpiContractRenewalType, GlpiEnum, GlpiGlobalValidation, GlpiPriority, @@ -91,9 +106,17 @@ PostKBCategory, ) from glpi_python_client.models.api_schema.management import ( + DeleteContract, + DeleteContractCost, DeleteDocument, + GetContract, + GetContractCost, GetDocument, + PatchContract, + PatchContractCost, PatchDocument, + PostContract, + PostContractCost, PostDocument, ) from glpi_python_client.models.api_schema.plugins import ( @@ -108,6 +131,11 @@ ) __all__ = [ + "DeleteComputer", + "DeleteContract", + "DeleteContractCost", + "DeleteContractItem", + "DeleteContractType", "DeleteDocument", "DeleteEntity", "DeleteFollowup", @@ -121,6 +149,11 @@ "DeleteTicketTask", "DeleteTimelineDocument", "DeleteUser", + "GetComputer", + "GetContract", + "GetContractCost", + "GetContractItem", + "GetContractType", "GetDocument", "GetEntity", "GetFollowup", @@ -138,6 +171,7 @@ "GetTicketTask", "GetTimelineDocument", "GetUser", + "GlpiContractRenewalType", "GlpiEnum", "GlpiGlobalValidation", "GlpiPriority", @@ -151,6 +185,11 @@ "IdNameCompletenameRef", "IdNameRef", "IdRef", + "PatchComputer", + "PatchContract", + "PatchContractCost", + "PatchContractItem", + "PatchContractType", "PatchDocument", "PatchEntity", "PatchFollowup", @@ -164,6 +203,11 @@ "PatchTicketTask", "PatchTimelineDocument", "PatchUser", + "PostComputer", + "PostContract", + "PostContractCost", + "PostContractItem", + "PostContractType", "PostDocument", "PostEntity", "PostFollowup", diff --git a/glpi_python_client/models/api_schema/__init__.py b/glpi_python_client/models/api_schema/__init__.py index e9a4116..691b634 100644 --- a/glpi_python_client/models/api_schema/__init__.py +++ b/glpi_python_client/models/api_schema/__init__.py @@ -10,7 +10,7 @@ the contract exposes any. Only the field names, types, and read-only flags advertised by -``docs/glpi_api_contract.json`` are honoured. Mandatory and optional behaviour +``docs/api_contract/api.json`` are honoured. Mandatory and optional behaviour is left to GLPI: every field is declared optional in Python because the contract does not advertise ``required`` arrays. diff --git a/glpi_python_client/models/api_schema/assets/__init__.py b/glpi_python_client/models/api_schema/assets/__init__.py new file mode 100644 index 0000000..f1282b4 --- /dev/null +++ b/glpi_python_client/models/api_schema/assets/__init__.py @@ -0,0 +1,25 @@ +"""Asset schemas mirroring the ``/Assets`` endpoints.""" + +from glpi_python_client.models.api_schema.assets._computer import ( + DeleteComputer, + GetComputer, + PatchComputer, + PostComputer, +) +from glpi_python_client.models.api_schema.assets._contract_item import ( + DeleteContractItem, + GetContractItem, + PatchContractItem, + PostContractItem, +) + +__all__ = [ + "DeleteComputer", + "DeleteContractItem", + "GetComputer", + "GetContractItem", + "PatchComputer", + "PatchContractItem", + "PostComputer", + "PostContractItem", +] diff --git a/glpi_python_client/models/api_schema/assets/_computer.py b/glpi_python_client/models/api_schema/assets/_computer.py new file mode 100644 index 0000000..98d9ac5 --- /dev/null +++ b/glpi_python_client/models/api_schema/assets/_computer.py @@ -0,0 +1,257 @@ +"""GLPI ``Computer`` schemas for the ``/Assets/Computer`` endpoints. + +The field layout mirrors ``components.schemas.Computer`` from the GLPI +OpenAPI contract. Four read-only contract fields (``id``, ``uuid``, +``last_inventory_update``, ``last_boot``) are excluded from request +models. +""" + +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 ( + IdNameCompletenameRef, + IdNameRef, +) + + +class GetComputer(GlpiModel): + """Response shape returned by ``GET /Assets/Computer`` endpoints. + + Mirrors ``components.schemas.Computer``. No field carries a + ``description`` in the OpenAPI contract; the parameter notes below + reflect the field names, types and ``readOnly`` flags as advertised. + + Parameters + ---------- + id : int | None, optional + Native GLPI identifier (``readOnly``). + name : str | None, optional + Short display name of the computer. + comment : str | None, optional + Free-form comment associated with the computer. + status : IdNameRef | None, optional + Related status reference, see ``Dropdowns/State``. + entity : IdNameCompletenameRef | None, optional + Owning GLPI entity reference, including its completename. + is_recursive : bool | None, optional + Whether the computer is visible to child entities. + manufacturer : IdNameRef | None, optional + Related manufacturer reference. + user : IdNameRef | None, optional + Related user reference for the person the computer is assigned to. + user_tech : IdNameRef | None, optional + Related user reference for the technician in charge of the + computer. + contact : str | None, optional + Free-form contact name associated with the computer. + contact_num : str | None, optional + Free-form contact phone number associated with the computer. + serial : str | None, optional + Manufacturer serial number. + otherserial : str | None, optional + Secondary inventory or asset-tag number. + is_deleted : bool | None, optional + Whether the computer has been moved to the GLPI trash. + date_creation : datetime | None, optional + Creation timestamp of the computer record (``format: date-time``). + date_mod : datetime | None, optional + Last modification timestamp of the computer record + (``format: date-time``). + location : IdNameRef | None, optional + Related location reference. + type : IdNameRef | None, optional + Related computer type reference. + model : IdNameRef | None, optional + Related computer model reference. + group : list[IdNameRef] | None, optional + Related groups the computer belongs to. Unlike most foreign-key + fields on this model, the contract declares this as an array + rather than a single reference. + group_tech : list[IdNameRef] | None, optional + Related groups in charge of the computer. Also an array, for the + same reason as ``group``. + uuid : str | None, optional + Hardware UUID reported by automatic inventory (``readOnly``). + network : IdNameRef | None, optional + Related network reference. + autoupdatesystem : IdNameRef | None, optional + Related reference to the inventory or synchronisation source that + manages automatic updates for this computer. + is_template : bool | None, optional + Whether this record is a computer template rather than a live + computer. + template_name : str | None, optional + Name of the computer template used to create new computers from + this record. + is_dynamic : bool | None, optional + Whether this record is kept in sync by automatic inventory. + ticket_tco : float | None, optional + Total cost of ownership tracked against the computer. + last_inventory_update : datetime | None, optional + Timestamp of the last automatic inventory update (``readOnly``, + ``format: date-time``). + last_boot : datetime | None, optional + Timestamp of the last reported boot (``readOnly``, + ``format: date-time``). + """ + + id: int | None = None + name: str | None = None + comment: str | None = None + status: IdNameRef | None = None + entity: IdNameCompletenameRef | None = None + is_recursive: bool | None = None + manufacturer: IdNameRef | None = None + user: IdNameRef | None = None + user_tech: IdNameRef | None = None + contact: str | None = None + contact_num: str | None = None + serial: str | None = None + otherserial: str | None = None + is_deleted: bool | None = None + date_creation: datetime | None = None + date_mod: datetime | None = None + location: IdNameRef | None = None + type: IdNameRef | None = None + model: IdNameRef | None = None + group: list[IdNameRef] | None = None + group_tech: list[IdNameRef] | None = None + uuid: str | None = None + network: IdNameRef | None = None + autoupdatesystem: IdNameRef | None = None + is_template: bool | None = None + template_name: str | None = None + is_dynamic: bool | None = None + ticket_tco: float | None = None + last_inventory_update: datetime | None = None + last_boot: datetime | None = None + + +class PostComputer(GlpiModel): + """Request body for ``POST /Assets/Computer``. + + Four read-only contract fields (``id``, ``uuid``, + ``last_inventory_update``, ``last_boot``) are intentionally excluded + because the server rejects them on input. + + Parameters + ---------- + name : str | None, optional + Short display name of the computer. + comment : str | None, optional + Free-form comment associated with the computer. + status : IdNameRef | None, optional + Related status reference, see ``Dropdowns/State``. + entity : IdNameCompletenameRef | None, optional + Owning GLPI entity reference, including its completename. + is_recursive : bool | None, optional + Whether the computer is visible to child entities. + manufacturer : IdNameRef | None, optional + Related manufacturer reference. + user : IdNameRef | None, optional + Related user reference for the person the computer is assigned to. + user_tech : IdNameRef | None, optional + Related user reference for the technician in charge of the + computer. + contact : str | None, optional + Free-form contact name associated with the computer. + contact_num : str | None, optional + Free-form contact phone number associated with the computer. + serial : str | None, optional + Manufacturer serial number. + otherserial : str | None, optional + Secondary inventory or asset-tag number. + is_deleted : bool | None, optional + Whether the computer should be moved to the GLPI trash. + date_creation : datetime | None, optional + Creation timestamp to set on the computer record + (``format: date-time``). + date_mod : datetime | None, optional + Last modification timestamp to set on the computer record + (``format: date-time``). + location : IdNameRef | None, optional + Related location reference. + type : IdNameRef | None, optional + Related computer type reference. + model : IdNameRef | None, optional + Related computer model reference. + group : list[IdNameRef] | None, optional + Related groups the computer belongs to. Unlike most foreign-key + fields on this model, the contract declares this as an array + rather than a single reference. + group_tech : list[IdNameRef] | None, optional + Related groups in charge of the computer. Also an array, for the + same reason as ``group``. + network : IdNameRef | None, optional + Related network reference. + autoupdatesystem : IdNameRef | None, optional + Related reference to the inventory or synchronisation source that + manages automatic updates for this computer. + is_template : bool | None, optional + Whether this record is a computer template rather than a live + computer. + template_name : str | None, optional + Name of the computer template used to create new computers from + this record. + is_dynamic : bool | None, optional + Whether this record is kept in sync by automatic inventory. + ticket_tco : float | None, optional + Total cost of ownership tracked against the computer. + """ + + name: str | None = None + comment: str | None = None + status: IdNameRef | None = None + entity: IdNameCompletenameRef | None = None + is_recursive: bool | None = None + manufacturer: IdNameRef | None = None + user: IdNameRef | None = None + user_tech: IdNameRef | None = None + contact: str | None = None + contact_num: str | None = None + serial: str | None = None + otherserial: str | None = None + is_deleted: bool | None = None + date_creation: datetime | None = None + date_mod: datetime | None = None + location: IdNameRef | None = None + type: IdNameRef | None = None + model: IdNameRef | None = None + group: list[IdNameRef] | None = None + group_tech: list[IdNameRef] | None = None + network: IdNameRef | None = None + autoupdatesystem: IdNameRef | None = None + is_template: bool | None = None + template_name: str | None = None + is_dynamic: bool | None = None + ticket_tco: float | None = None + + +class PatchComputer(PostComputer): + """Request body for ``PATCH /Assets/Computer/{id}``. + + The contract uses the same ``Computer`` schema for create and + partial-update bodies; ``PatchComputer`` is kept distinct so client + mixins can express the intent of the operation explicitly. + """ + + +class DeleteComputer(GlpiModel): + """Query parameters for ``DELETE /Assets/Computer/{id}``. + + Parameters + ---------- + force : bool | None, optional + When ``True``, permanently delete the computer instead of moving + the record to the GLPI trash. When ``False`` or :data:`None`, + the server applies its default soft-delete behaviour and the + computer can still be restored. + """ + + force: bool | None = None + + +__all__ = ["DeleteComputer", "GetComputer", "PatchComputer", "PostComputer"] diff --git a/glpi_python_client/models/api_schema/assets/_contract_item.py b/glpi_python_client/models/api_schema/assets/_contract_item.py new file mode 100644 index 0000000..a557a61 --- /dev/null +++ b/glpi_python_client/models/api_schema/assets/_contract_item.py @@ -0,0 +1,102 @@ +"""GLPI ``Contract_Item`` schemas for the ``/Assets/Computer/{id}/Contract`` +sub-resource. + +The field layout mirrors ``components.schemas.Contract_Item`` from the GLPI +OpenAPI contract. The read-only contract field (``id``) is excluded from +request models. + +``itemtype`` is declared in the contract as a free string (``maxLength: +100``) rather than an enum, so nothing here validates that it names an +actual GLPI asset type. Client mixins that link a contract to a specific +asset -- see ``ComputerMixin.link_computer_contract`` -- set ``itemtype`` +and ``items_id`` themselves rather than trusting the caller-supplied +values on ``PostContractItem``/``PatchContractItem``, because a typo in a +free-string field would otherwise silently attach the link to the wrong +kind of object. +""" + +from __future__ import annotations + +from glpi_python_client.models._base import GlpiModel +from glpi_python_client.models.api_schema._common import IdNameRef + + +class GetContractItem(GlpiModel): + """Response shape for ``GET /Assets/Computer/{id}/Contract`` endpoints. + + Mirrors ``components.schemas.Contract_Item``. + + Parameters + ---------- + id : int | None, optional + Native GLPI identifier (``readOnly``). + contract : IdNameRef | None, optional + Related contract reference. + itemtype : str | None, optional + GLPI itemtype of the linked asset (free string, ``maxLength: + 100``, not an enum in the contract). + items_id : int | None, optional + Native GLPI identifier of the linked asset. + """ + + id: int | None = None + contract: IdNameRef | None = None + itemtype: str | None = None + items_id: int | None = None + + +class PostContractItem(GlpiModel): + """Request body for ``POST /Assets/Computer/{id}/Contract``. + + The read-only contract field (``id``) is intentionally excluded + because the server rejects it on input. Client mixins that link a + contract to a specific asset overwrite ``itemtype`` and ``items_id`` + on the body they send, regardless of what is set here; see the module + docstring. + + Parameters + ---------- + contract : IdNameRef | None, optional + Related contract reference. + itemtype : str | None, optional + GLPI itemtype of the linked asset (free string, ``maxLength: + 100``, not an enum in the contract). + items_id : int | None, optional + Native GLPI identifier of the linked asset. + """ + + contract: IdNameRef | None = None + itemtype: str | None = None + items_id: int | None = None + + +class PatchContractItem(PostContractItem): + """Request body for ``PATCH /Assets/Computer/{id}/Contract/{link_id}``. + + The contract uses the same ``Contract_Item`` schema for create and + partial-update bodies; ``PatchContractItem`` is kept distinct so + client mixins can express the intent of the operation explicitly. + """ + + +class DeleteContractItem(GlpiModel): + """Query parameters for ``DELETE .../Contract/{link_id}``. + + Parameters + ---------- + force : bool | None, optional + When ``True``, permanently delete the link instead of moving the + record to the GLPI trash. When ``False`` or :data:`None`, the + server applies its default soft-delete behaviour and the link can + still be restored. + """ + + force: bool | None = None + + +__all__ = [ + "DeleteContractItem", + "GetContractItem", + "PatchContractItem", + "PostContractItem", +] diff --git a/glpi_python_client/models/api_schema/dropdowns/__init__.py b/glpi_python_client/models/api_schema/dropdowns/__init__.py index 49f8864..59e645e 100644 --- a/glpi_python_client/models/api_schema/dropdowns/__init__.py +++ b/glpi_python_client/models/api_schema/dropdowns/__init__.py @@ -1,5 +1,11 @@ """Dropdowns entity schemas mirroring the ``/Dropdowns`` endpoints.""" +from glpi_python_client.models.api_schema.dropdowns._contract_type import ( + DeleteContractType, + GetContractType, + PatchContractType, + PostContractType, +) from glpi_python_client.models.api_schema.dropdowns._location import ( DeleteLocation, GetLocation, @@ -8,8 +14,12 @@ ) __all__ = [ + "DeleteContractType", "DeleteLocation", + "GetContractType", "GetLocation", + "PatchContractType", "PatchLocation", + "PostContractType", "PostLocation", ] diff --git a/glpi_python_client/models/api_schema/dropdowns/_contract_type.py b/glpi_python_client/models/api_schema/dropdowns/_contract_type.py new file mode 100644 index 0000000..540dff9 --- /dev/null +++ b/glpi_python_client/models/api_schema/dropdowns/_contract_type.py @@ -0,0 +1,100 @@ +"""GLPI ``ContractType`` schemas for the ``/Dropdowns/ContractType`` endpoints. + +The field layout mirrors ``components.schemas.ContractType`` from the GLPI +OpenAPI contract. The read-only contract field (``id``) is excluded from +request models. +""" + +from __future__ import annotations + +from datetime import datetime + +from glpi_python_client.models._base import GlpiModel + + +class GetContractType(GlpiModel): + """Response shape returned by ``GET /Dropdowns/ContractType`` endpoints. + + Mirrors ``components.schemas.ContractType``. No field carries a + ``description`` in the OpenAPI contract; the parameter notes below + reflect the field names, types and ``readOnly`` flags as advertised. + + Parameters + ---------- + id : int | None, optional + Native GLPI identifier (``readOnly``). + name : str | None, optional + Short display name of the contract type. + comment : str | None, optional + Free-form comment associated with the contract type. + date_creation : datetime | None, optional + Creation timestamp of the contract type record + (``format: date-time``). + date_mod : datetime | None, optional + Last modification timestamp of the contract type record + (``format: date-time``). + """ + + id: int | None = None + name: str | None = None + comment: str | None = None + date_creation: datetime | None = None + date_mod: datetime | None = None + + +class PostContractType(GlpiModel): + """Request body for ``POST /Dropdowns/ContractType``. + + The read-only contract field (``id``) is intentionally excluded + because the server rejects it on input. + + Parameters + ---------- + name : str | None, optional + Short display name of the contract type. + comment : str | None, optional + Free-form comment associated with the contract type. + date_creation : datetime | None, optional + Creation timestamp to set on the contract type record + (``format: date-time``). + date_mod : datetime | None, optional + Last modification timestamp to set on the contract type record + (``format: date-time``). + """ + + name: str | None = None + comment: str | None = None + date_creation: datetime | None = None + date_mod: datetime | None = None + + +class PatchContractType(PostContractType): + """Request body for ``PATCH /Dropdowns/ContractType/{id}``. + + The contract uses the same ``ContractType`` schema for create and + partial-update bodies; ``PatchContractType`` is kept distinct so client + mixins can express the intent of the operation explicitly. + """ + + +class DeleteContractType(GlpiModel): + """Query parameters for ``DELETE /Dropdowns/ContractType/{id}``. + + Parameters + ---------- + force : bool | None, optional + When ``True``, permanently delete the contract type instead of + moving the record to the GLPI trash. When ``False`` or + :data:`None`, the server applies its default soft-delete behaviour + and the contract type can still be restored. + """ + + force: bool | None = None + + +__all__ = [ + "DeleteContractType", + "GetContractType", + "PatchContractType", + "PostContractType", +] diff --git a/glpi_python_client/models/api_schema/enums.py b/glpi_python_client/models/api_schema/enums.py index 475e8d3..db2e7ee 100644 --- a/glpi_python_client/models/api_schema/enums.py +++ b/glpi_python_client/models/api_schema/enums.py @@ -160,7 +160,21 @@ class GlpiUserAuthType(GlpiEnum): EXTERNAL = 6 +class GlpiContractRenewalType(GlpiEnum): + """GLPI contract renewal types as advertised by the contract. + + The contract enum on ``Contract.renewal_type`` is ``[0, 1, 2]``, with + the meanings documented inline on that field: no renewal, tacit + (automatic) renewal, and explicit (manual) renewal. + """ + + NONE = 0 + TACIT = 1 + EXPLICIT = 2 + + __all__ = [ + "GlpiContractRenewalType", "GlpiEnum", "GlpiGlobalValidation", "GlpiPriority", diff --git a/glpi_python_client/models/api_schema/management/__init__.py b/glpi_python_client/models/api_schema/management/__init__.py index 7250308..0edb266 100644 --- a/glpi_python_client/models/api_schema/management/__init__.py +++ b/glpi_python_client/models/api_schema/management/__init__.py @@ -1,5 +1,17 @@ """Management entity schemas mirroring the ``/Management`` endpoints.""" +from glpi_python_client.models.api_schema.management._contract import ( + DeleteContract, + GetContract, + PatchContract, + PostContract, +) +from glpi_python_client.models.api_schema.management._contract_cost import ( + DeleteContractCost, + GetContractCost, + PatchContractCost, + PostContractCost, +) from glpi_python_client.models.api_schema.management._document import ( DeleteDocument, GetDocument, @@ -8,8 +20,16 @@ ) __all__ = [ + "DeleteContract", + "DeleteContractCost", "DeleteDocument", + "GetContract", + "GetContractCost", "GetDocument", + "PatchContract", + "PatchContractCost", "PatchDocument", + "PostContract", + "PostContractCost", "PostDocument", ] diff --git a/glpi_python_client/models/api_schema/management/_contract.py b/glpi_python_client/models/api_schema/management/_contract.py new file mode 100644 index 0000000..44fbf24 --- /dev/null +++ b/glpi_python_client/models/api_schema/management/_contract.py @@ -0,0 +1,328 @@ +"""GLPI ``Contract`` schemas for the ``/Management/Contract`` endpoints. + +The field layout mirrors ``components.schemas.Contract`` from the GLPI +OpenAPI contract. The read-only contract field (``id``) is excluded from +request models, and ``costs`` is excluded from both request models as well: +cost lines are written through their own ``ContractCost`` endpoints, so +this class only ever reads them back. + +``date_begin`` is modelled as ``datetime.date`` rather than +``datetime.datetime``, because the contract declares it with +``format: date`` -- GLPI stores no time-of-day for a contract's start. +This is the opposite choice from ``ContractCost``'s own date fields, which +the contract declares with ``format: date-time`` and which are therefore +modelled as ``datetime``. The asymmetry is real +and comes from the contract, not from an inconsistency in this client: a +plain ``date`` also falls outside the server-clock conversion that +``models/_base.py`` applies to aware ``datetime`` values, so keeping +``date_begin`` a ``date`` protects it from a shift that could roll the +start date to the previous or next day. +""" + +from __future__ import annotations + +from datetime import date, datetime + +from glpi_python_client.models._base import GlpiModel +from glpi_python_client.models.api_schema._common import IdNameRef, IdRef +from glpi_python_client.models.api_schema.enums import GlpiContractRenewalType + + +class GetContract(GlpiModel): + """Response shape returned by ``GET /Management/Contract`` endpoints. + + Mirrors ``components.schemas.Contract``. + + Parameters + ---------- + id : int | None, optional + Native GLPI identifier (``readOnly``). + name : str | None, optional + Short display name of the contract. + comment : str | None, optional + Free-form comment associated with the contract. + status : IdNameRef | None, optional + Related contract status reference. + entity : IdNameRef | None, optional + Owning GLPI entity reference. + date_creation : datetime | None, optional + Creation timestamp of the contract record (``format: date-time``). + date_mod : datetime | None, optional + Last modification timestamp of the contract record + (``format: date-time``). + type : IdNameRef | None, optional + Related contract type reference, see ``Dropdowns/ContractType``. + is_deleted : bool | None, optional + Whether the contract has been moved to the GLPI trash. + costs : list[IdRef] | None, optional + Related contract cost line references. Read-only on this client: + cost lines are created and updated through their own + ``ContractCost`` endpoints, never through this model. + number : str | None, optional + Contract reference number. + location : IdNameRef | None, optional + Related location reference. + date_begin : date | None, optional + Contract start date (``format: date``). A plain calendar date, not + a timestamp: GLPI stores no time-of-day here, and keeping it a + ``date`` also keeps it out of the server-clock conversion applied + to aware timestamps. + duration : int | None, optional + Contract duration, in months. + notice_period : int | None, optional + Notice period, in months. + renewal_period : int | None, optional + Renewal period, in months. + invoice_period : int | None, optional + Invoice period, in months. + accounting_number : str | None, optional + Accounting reference number. + week_begin_hour : str | None, optional + Weekday coverage start time, in ``HH:MM:SS`` format (RFC 3339 + partial-time). + week_end_hour : str | None, optional + Weekday coverage end time, in ``HH:MM:SS`` format (RFC 3339 + partial-time). + saturday_begin_hour : str | None, optional + Saturday coverage start time, in ``HH:MM:SS`` format (RFC 3339 + partial-time). + saturday_end_hour : str | None, optional + Saturday coverage end time, in ``HH:MM:SS`` format (RFC 3339 + partial-time). + sunday_begin_hour : str | None, optional + Sunday coverage start time, in ``HH:MM:SS`` format (RFC 3339 + partial-time). + sunday_end_hour : str | None, optional + Sunday coverage end time, in ``HH:MM:SS`` format (RFC 3339 + partial-time). + use_saturday : bool | None, optional + Whether the Saturday coverage hours apply to this contract. + use_sunday : bool | None, optional + Whether the Sunday coverage hours apply to this contract. + max_links_allowed : int | None, optional + Maximum number of items that can be linked to this contract + (``0`` means unlimited). + alert : int | None, optional + Selects which expiration alerts are active. The contract is + self-inconsistent about the last two values: the field's + ``enum`` lists ``[0, 4, 8, 12, 64, 72]``, but its accompanying + description numbers the same six meanings as ``0`` no alert, + ``4`` alert on end date, ``8`` alert on notice date, ``12`` + both, ``16`` periodic alert, ``24`` periodic alert and alert on + notice date. The first four values agree between the two + listings; the last two do not. + + The server settles neither reading: writing each of ``0``, ``4``, + ``8``, ``12``, ``16``, ``24``, ``64`` and ``72`` to a live GLPI 11 + contract stores the value unchanged and rejects none of them, so + the endpoint validates this field not at all and the ``enum`` in + the contract document does not describe an enforced set. Typed as + a plain ``int`` for that reason: an enum would reject values the + server itself accepts, and would have to pick one of the two + numberings to name them by. + renewal_type : GlpiContractRenewalType | None, optional + Renewal behaviour of the contract: no renewal, tacit (automatic) + renewal, or explicit (manual) renewal. + template_name : str | None, optional + Name of the contract template used to create new contracts from + this record. + is_template : bool | None, optional + Whether this record is a contract template rather than a live + contract. + """ + + id: int | None = None + name: str | None = None + comment: str | None = None + status: IdNameRef | None = None + entity: IdNameRef | None = None + date_creation: datetime | None = None + date_mod: datetime | None = None + type: IdNameRef | None = None + is_deleted: bool | None = None + costs: list[IdRef] | None = None + number: str | None = None + location: IdNameRef | None = None + date_begin: date | None = None + duration: int | None = None + notice_period: int | None = None + renewal_period: int | None = None + invoice_period: int | None = None + accounting_number: str | None = None + week_begin_hour: str | None = None + week_end_hour: str | None = None + saturday_begin_hour: str | None = None + saturday_end_hour: str | None = None + sunday_begin_hour: str | None = None + sunday_end_hour: str | None = None + use_saturday: bool | None = None + use_sunday: bool | None = None + max_links_allowed: int | None = None + alert: int | None = None + renewal_type: GlpiContractRenewalType | None = None + template_name: str | None = None + is_template: bool | None = None + + +class PostContract(GlpiModel): + """Request body for ``POST /Management/Contract``. + + The read-only contract field (``id``) is intentionally excluded + because the server rejects it on input. ``costs`` is also excluded: + cost lines are written through their own ``ContractCost`` endpoints, + never through this model. + + Parameters + ---------- + name : str | None, optional + Short display name of the contract. + comment : str | None, optional + Free-form comment associated with the contract. + status : IdNameRef | None, optional + Related contract status reference. + entity : IdNameRef | None, optional + Owning GLPI entity reference. + date_creation : datetime | None, optional + Creation timestamp to set on the contract record + (``format: date-time``). + date_mod : datetime | None, optional + Last modification timestamp to set on the contract record + (``format: date-time``). + type : IdNameRef | None, optional + Related contract type reference, see ``Dropdowns/ContractType``. + is_deleted : bool | None, optional + Whether the contract should be moved to the GLPI trash. + number : str | None, optional + Contract reference number. + location : IdNameRef | None, optional + Related location reference. + date_begin : date | None, optional + Contract start date (``format: date``). A plain calendar date, not + a timestamp: GLPI stores no time-of-day here, and keeping it a + ``date`` also keeps it out of the server-clock conversion applied + to aware timestamps. + duration : int | None, optional + Contract duration, in months. + notice_period : int | None, optional + Notice period, in months. + renewal_period : int | None, optional + Renewal period, in months. + invoice_period : int | None, optional + Invoice period, in months. + accounting_number : str | None, optional + Accounting reference number. + week_begin_hour : str | None, optional + Weekday coverage start time, in ``HH:MM:SS`` format (RFC 3339 + partial-time). + week_end_hour : str | None, optional + Weekday coverage end time, in ``HH:MM:SS`` format (RFC 3339 + partial-time). + saturday_begin_hour : str | None, optional + Saturday coverage start time, in ``HH:MM:SS`` format (RFC 3339 + partial-time). + saturday_end_hour : str | None, optional + Saturday coverage end time, in ``HH:MM:SS`` format (RFC 3339 + partial-time). + sunday_begin_hour : str | None, optional + Sunday coverage start time, in ``HH:MM:SS`` format (RFC 3339 + partial-time). + sunday_end_hour : str | None, optional + Sunday coverage end time, in ``HH:MM:SS`` format (RFC 3339 + partial-time). + use_saturday : bool | None, optional + Whether the Saturday coverage hours apply to this contract. + use_sunday : bool | None, optional + Whether the Sunday coverage hours apply to this contract. + max_links_allowed : int | None, optional + Maximum number of items that can be linked to this contract + (``0`` means unlimited). + alert : int | None, optional + Selects which expiration alerts are active. The contract is + self-inconsistent about the last two values: the field's + ``enum`` lists ``[0, 4, 8, 12, 64, 72]``, but its accompanying + description numbers the same six meanings as ``0`` no alert, + ``4`` alert on end date, ``8`` alert on notice date, ``12`` + both, ``16`` periodic alert, ``24`` periodic alert and alert on + notice date. The first four values agree between the two + listings; the last two do not. + + The server settles neither reading: writing each of ``0``, ``4``, + ``8``, ``12``, ``16``, ``24``, ``64`` and ``72`` to a live GLPI 11 + contract stores the value unchanged and rejects none of them, so + the endpoint validates this field not at all and the ``enum`` in + the contract document does not describe an enforced set. Typed as + a plain ``int`` for that reason: an enum would reject values the + server itself accepts, and would have to pick one of the two + numberings to name them by. + renewal_type : GlpiContractRenewalType | None, optional + Renewal behaviour of the contract: no renewal, tacit (automatic) + renewal, or explicit (manual) renewal. + template_name : str | None, optional + Name of the contract template used to create new contracts from + this record. + is_template : bool | None, optional + Whether this record is a contract template rather than a live + contract. + """ + + name: str | None = None + comment: str | None = None + status: IdNameRef | None = None + entity: IdNameRef | None = None + date_creation: datetime | None = None + date_mod: datetime | None = None + type: IdNameRef | None = None + is_deleted: bool | None = None + number: str | None = None + location: IdNameRef | None = None + date_begin: date | None = None + duration: int | None = None + notice_period: int | None = None + renewal_period: int | None = None + invoice_period: int | None = None + accounting_number: str | None = None + week_begin_hour: str | None = None + week_end_hour: str | None = None + saturday_begin_hour: str | None = None + saturday_end_hour: str | None = None + sunday_begin_hour: str | None = None + sunday_end_hour: str | None = None + use_saturday: bool | None = None + use_sunday: bool | None = None + max_links_allowed: int | None = None + alert: int | None = None + renewal_type: GlpiContractRenewalType | None = None + template_name: str | None = None + is_template: bool | None = None + + +class PatchContract(PostContract): + """Request body for ``PATCH /Management/Contract/{id}``. + + The contract uses the same ``Contract`` schema for create and + partial-update bodies; ``PatchContract`` is kept distinct so client + mixins can express the intent of the operation explicitly. + """ + + +class DeleteContract(GlpiModel): + """Query parameters for ``DELETE /Management/Contract/{id}``. + + Parameters + ---------- + force : bool | None, optional + When ``True``, permanently delete the contract instead of moving + the record to the GLPI trash. When ``False`` or :data:`None`, the + server applies its default soft-delete behaviour and the contract + can still be restored. + """ + + force: bool | None = None + + +__all__ = [ + "DeleteContract", + "GetContract", + "PatchContract", + "PostContract", +] diff --git a/glpi_python_client/models/api_schema/management/_contract_cost.py b/glpi_python_client/models/api_schema/management/_contract_cost.py new file mode 100644 index 0000000..f96929b --- /dev/null +++ b/glpi_python_client/models/api_schema/management/_contract_cost.py @@ -0,0 +1,139 @@ +"""GLPI ``ContractCost`` schemas for the ``/Management/Contract/{id}/Cost`` +sub-resource. + +The field layout mirrors ``components.schemas.ContractCost`` from the GLPI +OpenAPI contract. The read-only contract field (``id``) is excluded from +request models. + +``date_begin`` and ``date_end`` are modelled as ``datetime.datetime``, not +``datetime.date``. This is the opposite choice from ``Contract.date_begin`` +(the parent resource): the contract declares ``ContractCost``'s two date +fields with ``format: date-time``, whereas ``Contract.date_begin`` is +``format: date``. The asymmetry is real and comes from the contract itself, +not from an inconsistency in this client -- do not "fix" one to match the +other. +""" + +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 GetContractCost(GlpiModel): + """Response shape for ``GET /Management/Contract/{id}/Cost`` endpoints. + + Mirrors ``components.schemas.ContractCost``. + + Parameters + ---------- + id : int | None, optional + Native GLPI identifier (``readOnly``). + contract : IdNameRef | None, optional + Related contract reference, the parent of this cost line. + name : str | None, optional + Short display name of the cost line. + comment : str | None, optional + Free-form comment associated with the cost line. + date_begin : datetime | None, optional + Cost line start timestamp (``format: date-time``). Unlike + ``Contract.date_begin``, which the contract declares with + ``format: date``, this field carries a time-of-day component. + date_end : datetime | None, optional + Cost line end timestamp (``format: date-time``). + cost : float | None, optional + Monetary amount of the cost line. + budget : IdNameRef | None, optional + Related budget reference. + entity : IdNameRef | None, optional + Owning GLPI entity reference. + is_recursive : bool | None, optional + Whether the cost line is visible in sub-entities of ``entity``. + """ + + id: int | None = None + contract: IdNameRef | None = None + name: str | None = None + comment: str | None = None + date_begin: datetime | None = None + date_end: datetime | None = None + cost: float | None = None + budget: IdNameRef | None = None + entity: IdNameRef | None = None + is_recursive: bool | None = None + + +class PostContractCost(GlpiModel): + """Request body for ``POST /Management/Contract/{id}/Cost``. + + The read-only contract field (``id``) is intentionally excluded + because the server rejects it on input. + + Parameters + ---------- + contract : IdNameRef | None, optional + Related contract reference, the parent of this cost line. + name : str | None, optional + Short display name of the cost line. + comment : str | None, optional + Free-form comment associated with the cost line. + date_begin : datetime | None, optional + Cost line start timestamp to set (``format: date-time``). Unlike + ``Contract.date_begin``, which the contract declares with + ``format: date``, this field carries a time-of-day component. + date_end : datetime | None, optional + Cost line end timestamp to set (``format: date-time``). + cost : float | None, optional + Monetary amount of the cost line. + budget : IdNameRef | None, optional + Related budget reference. + entity : IdNameRef | None, optional + Owning GLPI entity reference. + is_recursive : bool | None, optional + Whether the cost line should be visible in sub-entities of + ``entity``. + """ + + contract: IdNameRef | None = None + name: str | None = None + comment: str | None = None + date_begin: datetime | None = None + date_end: datetime | None = None + cost: float | None = None + budget: IdNameRef | None = None + entity: IdNameRef | None = None + is_recursive: bool | None = None + + +class PatchContractCost(PostContractCost): + """Request body for ``PATCH /Management/Contract/{id}/Cost/{cost_id}``. + + The contract uses the same ``ContractCost`` schema for create and + partial-update bodies; ``PatchContractCost`` is kept distinct so client + mixins can express the intent of the operation explicitly. + """ + + +class DeleteContractCost(GlpiModel): + """Query parameters for ``DELETE /Management/Contract/{id}/Cost/{cost_id}``. + + Parameters + ---------- + force : bool | None, optional + When ``True``, permanently delete the cost line instead of moving + the record to the GLPI trash. When ``False`` or :data:`None`, the + server applies its default soft-delete behaviour and the cost line + can still be restored. + """ + + force: bool | None = None + + +__all__ = [ + "DeleteContractCost", + "GetContractCost", + "PatchContractCost", + "PostContractCost", +] diff --git a/glpi_python_client/testing/tests/test_exports.py b/glpi_python_client/testing/tests/test_exports.py index 6c3e6e7..116bbbd 100644 --- a/glpi_python_client/testing/tests/test_exports.py +++ b/glpi_python_client/testing/tests/test_exports.py @@ -36,3 +36,44 @@ def test_kb_models_exported_from_models_package() -> None: for name in _KB_MODELS: assert hasattr(models, name), name assert name in models.__all__, name + + +_ASSET_AND_CONTRACT_MODELS = ( + "GetComputer", + "PostComputer", + "PatchComputer", + "DeleteComputer", + "GetContractItem", + "PostContractItem", + "PatchContractItem", + "DeleteContractItem", + "GetContract", + "PostContract", + "PatchContract", + "DeleteContract", + "GetContractCost", + "PostContractCost", + "PatchContractCost", + "DeleteContractCost", + "GetContractType", + "PostContractType", + "PatchContractType", + "DeleteContractType", + "GlpiContractRenewalType", +) + + +def test_asset_and_contract_models_exported_from_top_level() -> None: + """Every asset and contract model is importable from the package root.""" + + for name in _ASSET_AND_CONTRACT_MODELS: + assert hasattr(glpi_python_client, name), name + assert name in glpi_python_client.__all__, name + + +def test_asset_and_contract_models_exported_from_models() -> None: + """Every asset and contract model is importable from ``models``.""" + + for name in _ASSET_AND_CONTRACT_MODELS: + assert hasattr(models, name), name + assert name in models.__all__, name diff --git a/integration_tests/test_integration.py b/integration_tests/test_integration.py index d8df5d5..4fa1c81 100644 --- a/integration_tests/test_integration.py +++ b/integration_tests/test_integration.py @@ -10,6 +10,7 @@ import os from collections.abc import Iterator from dataclasses import dataclass +from datetime import date from pathlib import Path from uuid import uuid4 @@ -20,7 +21,12 @@ GlpiStatusError, GlpiTicketContext, GlpiTicketStatus, + IdNameRef, PatchTicket, + PostComputer, + PostContract, + PostContractCost, + PostContractItem, PostFollowup, PostLocation, PostSolution, @@ -859,3 +865,125 @@ def test_set_ticket_custom_fields_rejects_unknown_container( 1, {"does-not-exist-xyz": {"any_field": "value"}}, ) + + +# --------------------------------------------------------------------------- +# Assets/Computer, Management/Contract, ContractCost, and the computer <-> +# contract join (computer-and-contract-endpoints) +# --------------------------------------------------------------------------- + + +def test_computer_contract_round_trip(client: GlpiClient) -> None: + """Create a computer and a contract, link them, read back, clean up. + + Everything modelled for these endpoints was derived from the OpenAPI + document rather than from the server, and this project's history is + largely a record of the two disagreeing. This is where that gets + checked. + """ + + marker = uuid4().hex[:8] + computer_id = client.create_computer(PostComputer(name=f"pytest-{marker}")) + try: + contract_id = client.create_contract(PostContract(name=f"pytest-{marker}")) + try: + computer = client.get_computer(computer_id) + assert computer.name == f"pytest-{marker}" + # Computer.entity carries completename, unlike Contract.entity. + # Typing it as a plain id/name reference silently drops the + # entity's full path, and nothing offline would notice. + assert computer.entity is not None + assert computer.entity.completename is not None + + link_id = client.link_computer_contract( + computer_id, PostContractItem(contract=IdNameRef(id=contract_id)) + ) + links = client.list_computer_contracts(computer_id) + assert any(link.id == link_id for link in links) + assert all(link.itemtype == "Computer" for link in links) + + client.unlink_computer_contract(computer_id, link_id, force=True) + finally: + client.delete_contract(contract_id, force=True) + finally: + client.delete_computer(computer_id, force=True) + + +def test_contract_date_begin_is_stored_as_sent(client: GlpiClient) -> None: + """A ``date`` written to ``date_begin`` reads back as the same day. + + The failure this guards is a timezone shift rolling the date to its + neighbour, which a ``datetime``-typed field would allow. + """ + + marker = uuid4().hex[:8] + start = date(2026, 1, 15) + contract_id = client.create_contract( + PostContract(name=f"pytest-{marker}", date_begin=start) + ) + try: + assert client.get_contract(contract_id).date_begin == start + finally: + client.delete_contract(contract_id, force=True) + + +def test_contract_cost_round_trip(client: GlpiClient) -> None: + """Cost lines create, list and delete against a real contract.""" + + marker = uuid4().hex[:8] + contract_id = client.create_contract(PostContract(name=f"pytest-{marker}")) + try: + cost_id = client.create_contract_cost( + contract_id, PostContractCost(name="year 1", cost=1200.0) + ) + try: + costs = client.list_contract_costs(contract_id) + assert any(cost.id == cost_id for cost in costs) + finally: + client.delete_contract_cost(contract_id, cost_id, force=True) + finally: + client.delete_contract(contract_id, force=True) + + +def test_contract_alert_accepts_values_outside_the_documented_enum( + client: GlpiClient, +) -> None: + """The server stores any ``alert`` int, which is why it is not an enum. + + The API contract's ``enum`` for this field lists ``64``/``72`` where + its own description numbers the same two meanings ``16``/``24``. The + server enforces neither list, so promoting ``alert`` to an enum would + reject values the server itself accepts. + """ + + marker = uuid4().hex[:8] + for value in (16, 24, 64, 72): + contract_id = client.create_contract( + PostContract(name=f"pytest-{marker}-{value}", alert=value) + ) + try: + assert client.get_contract(contract_id).alert == value + finally: + client.delete_contract(contract_id, force=True) + + +def test_force_delete_removes_the_record_outright(client: GlpiClient) -> None: + """``force=True`` hard-deletes instead of filling the trashcan. + + Every teardown in this module depends on it. The API contract + declares ``force`` a query parameter while this client sends it in + the request body, so a server that ignored the body would leave + these tests quietly accumulating soft-deleted records on a shared + instance while still reporting success. + """ + + marker = uuid4().hex[:8] + contract_id = client.create_contract(PostContract(name=f"pytest-{marker}")) + + client.delete_contract(contract_id, force=False) + assert client.get_contract(contract_id).is_deleted is True + + client.delete_contract(contract_id, force=True) + with pytest.raises(GlpiStatusError) as excinfo: + client.get_contract(contract_id) + assert excinfo.value.status_code == 404 diff --git a/pyproject.toml b/pyproject.toml index b43c7a3..da9fa8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ exclude = [ "*.egg-info/", "docs/_build/", "docs/superpowers/", - "docs/glpi_api_contract.json", + "docs/api_contract/", ".venv/", "venv/", ".env", diff --git a/skills/README.md b/skills/README.md index 44e4170..0f801f4 100644 --- a/skills/README.md +++ b/skills/README.md @@ -17,6 +17,8 @@ These skills are source-tree project material. They are included in source distr | `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` | | `glpi-plugin-fields` | Discover and read/write Fields-plugin custom fields | `GetPluginFieldsContainer`, `GetPluginFieldsField`, `GetPluginFieldsValueRow` | +| `glpi-asset-workflow` | Search and provision computers, and read or write the contracts covering them | `GetComputer`, `PostComputer`, `GetContractItem`, `PostContractItem` | +| `glpi-contract-workflow` | Read or write contracts, cost lines, and contract types | `GetContract`, `PostContract`, `GetContractCost`, `GetContractType` | ## Sync and async @@ -25,6 +27,6 @@ The package ships two clients with identical endpoint surfaces: - `GlpiClient` — synchronous. `with GlpiClient(...) as client`, no `await`. - `AsyncGlpiClient` — asynchronous, performing real non-blocking I/O. `async with AsyncGlpiClient(...) as client`, `await` every method. -Neither wraps the other: the async tree is hand-written and the synchronous one is generated from it by `unasync_build.py`, so the two cannot drift apart. Every skill opens with a note telling you how to read its snippets across the two surfaces. For eight of the nine that note says the same thing -- the snippets are written against `AsyncGlpiClient`, so drop the `await` and the `async` for `GlpiClient`. `glpi-client-setup` is the exception and says so in its own note: choosing between the two clients is what that skill is *for*, so it shows both directly, side by side, and neither surface is a translation of the other. +Neither wraps the other: the async tree is hand-written and the synchronous one is generated from it by `unasync_build.py`, so the two cannot drift apart. Every skill opens with a note telling you how to read its snippets across the two surfaces. For ten of the eleven that note says the same thing -- the snippets are written against `AsyncGlpiClient`, so drop the `await` and the `async` for `GlpiClient`. `glpi-client-setup` is the exception and says so in its own note: choosing between the two clients is what that skill is *for*, so it shows both directly, side by side, and neither surface is a translation of the other. When fanning out concurrently on the async client, bound the fan-out with an `asyncio.Semaphore` — see `glpi-client-setup`. An unbounded fan-out is slower, not faster. diff --git a/skills/glpi-asset-workflow/SKILL.md b/skills/glpi-asset-workflow/SKILL.md new file mode 100644 index 0000000..a88b819 --- /dev/null +++ b/skills/glpi-asset-workflow/SKILL.md @@ -0,0 +1,89 @@ +--- +name: glpi-asset-workflow +description: "Search, fetch, create, update, and delete GLPI computers, and read or write the contracts covering them, with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetComputer/PostComputer/PatchComputer/DeleteComputer and GetContractItem/PostContractItem models. Use for GLPI asset inventory, computer records, asset serial numbers, asset locations, or finding which contracts cover a machine." +license: MIT +compatibility: "Requires Python 3.10+, 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.5.0" +--- + +# GLPI Asset Workflow +> 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. + +The asset mixin maps to `/Assets/Computer`. GLPI models roughly two dozen asset itemtypes (computers, monitors, printers, network equipment, and so on); **this client implements only `Computer`**. There is no `search_monitors`, `get_printer`, or any other asset-family method -- do not invent one, and do not assume a technique shown here (e.g. `sort`) extends to an asset type that has no methods at all. Treat `Computer` as the one supported asset type, not a stand-in for the rest of the family. + +A computer's coverage is tracked separately as `Contract_Item` links under `/Assets/Computer/{id}/Contract`. This skill covers reading and writing those links from the computer side; for the contract record itself (dates, cost lines, renewal type, contract type) see `glpi-contract-workflow`. + +## Procedure + +1. Create a `GlpiClient` from the `glpi-client-setup` skill. +2. Search before creating duplicates: `search_computers(rsql_filter, limit=..., start=..., sort=...)`. `sort` takes `":"`, e.g. `"date_mod:desc"`; omit it to leave server ordering in place. `iter_search_computers(rsql_filter, batch_size=..., sort=...)` pages automatically and stops on the first short page -- prefer it over hand-rolling `start` increments. +3. Fetch one record with `get_computer(computer_id)`. +4. Create with `create_computer(PostComputer(...))`. Returns the new `int` id. +5. Update with `update_computer(computer_id, PatchComputer(...))`. Returns `None`. +6. Delete with `delete_computer(computer_id, force=True|False|None)`. `force=True` permanently deletes; omitted or `False`/`None` moves the record to the trash. +7. To find or record which contracts cover a computer, use the five link helpers below rather than searching `Contract` by computer -- there is no such filter, only the join. + +## Examples + +Search, create, and update a computer: + +```python +from glpi_python_client import PatchComputer, PostComputer + +computer_id = await client.create_computer( + PostComputer(name="ws-1042", serial="PF3KL9QJ") +) +await client.update_computer( + computer_id, PatchComputer(comment="Reimaged for the finance team") +) +computer = await client.get_computer(computer_id) +print(computer.id, computer.name, computer.serial) + +matches = await client.search_computers("name==ws-1042", limit=5, sort="date_mod:desc") +for c in matches: + print(c.id, c.name) +``` + +Page through every computer in an entity: + +```python +async for batch in client.iter_search_computers("", batch_size=100): + for c in batch: + print(c.id, c.name, c.serial) +``` + +Link a computer to a contract, list its links, update one, then unlink it. The +update call repoints the link at a different contract -- `itemtype`/`items_id` +are still set from `computer_id` regardless of what the body carries -- and +runs before the teardown, since a destroyed link has nothing left to update: + +```python +from glpi_python_client import IdNameRef, PatchContractItem, PostContractItem + +link_id = await client.link_computer_contract( + computer_id, PostContractItem(contract=IdNameRef(id=contract_id)) +) + +for link in await client.list_computer_contracts(computer_id): + print(link.id, link.itemtype, link.items_id) + +link = await client.get_computer_contract(computer_id, link_id) +print(link.contract) + +await client.update_computer_contract( + computer_id, link_id, PatchContractItem(contract=IdNameRef(id=other_contract_id)) +) + +await client.unlink_computer_contract(computer_id, link_id, force=True) +``` + +## Gotchas + +- **`link_computer_contract` and `update_computer_contract` set `itemtype` and `items_id` themselves**, from the `computer_id` argument, overwriting whatever is set on the `PostContractItem`/`PatchContractItem` body passed in. `Contract_Item.itemtype` is a free string in the GLPI contract (`maxLength: 100`, not an enum) -- nothing server-side stops `"Computre"` from silently creating a link to a typo'd itemtype that matches no real asset. Pass only `contract` (and `comment`, if the resource ever adds one); do not bother setting `itemtype`/`items_id` on the body, they will be discarded. +- `create_computer` and `link_computer_contract` return the new identifier as plain `int`. `update_computer`, `update_computer_contract`, `delete_computer`, and `unlink_computer_contract` return `None`. +- `search_computers` and `iter_search_computers` raise `GlpiStatusError` on a 4xx rather than returning `[]` -- an empty list means the server said the result set is empty, not that the filter was rejected. The usual v2 caveat still applies on the *other* side: an RSQL field the server does not recognise is silently dropped and the call answers 200 with the whole unfiltered table, so a non-empty result is not proof the filter took effect. +- `list_computer_contracts` returns `GetContractItem` records, not `GetContract`. Each carries `contract` (an `IdNameRef` pointing at the actual contract), `itemtype`, and `items_id` -- to read the contract's own fields (dates, type, renewal), call `get_contract(link.contract.id)` from `glpi-contract-workflow`. +- Extra fields returned by the live server flow into `record.extra_payload` rather than raising. +- If the caller provides a name or serial rather than an id, search first (`search_computers('serial=="PF3KL9QJ"')`) and confirm the id before updating or deleting. diff --git a/skills/glpi-contract-workflow/SKILL.md b/skills/glpi-contract-workflow/SKILL.md new file mode 100644 index 0000000..6f0ff9c --- /dev/null +++ b/skills/glpi-contract-workflow/SKILL.md @@ -0,0 +1,123 @@ +--- +name: glpi-contract-workflow +description: "Search, fetch, create, update, and delete GLPI contracts, their cost lines, and the contract-type dropdown, with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetContract/PostContract/PatchContract/DeleteContract, GetContractCost/PostContractCost, and GetContractType/PostContractType models. Use for GLPI contract coverage, maintenance agreements, contract cost/budget lines, contract renewal type, or the contract-type dropdown." +license: MIT +compatibility: "Requires Python 3.10+, 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.5.0" +--- + +# GLPI Contract Workflow +> 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. + +Contracts live under `/Management/Contract`, cost lines under the `/Management/Contract/{id}/Cost` sub-resource, and the type dropdown under `/Dropdowns/ContractType`. This skill owns the "what does this contract cover, for how much, and when does it renew" narrative. For *which assets* a contract covers, go through the asset side -- `list_computer_contracts` / `link_computer_contract` / `unlink_computer_contract` in `glpi-asset-workflow` -- there is no reverse lookup here that lists a contract's linked assets. + +## Procedure + +1. Create a `GlpiClient` from the `glpi-client-setup` skill. +2. Search contracts before creating duplicates: `search_contracts(rsql_filter, limit=..., start=..., sort=...)`. `sort` takes `":"`, e.g. `"date_begin:desc"`; omit it to leave server ordering in place. `iter_search_contracts(rsql_filter, batch_size=..., sort=...)` pages automatically. +3. Fetch one contract with `get_contract(contract_id)`. +4. Create with `create_contract(PostContract(...))`; returns the new `int` id. Update with `update_contract(contract_id, PatchContract(...))`; returns `None`. Delete with `delete_contract(contract_id, force=True|False|None)`. +5. For cost lines, use the `_contract_cost` family scoped by `contract_id`: `list_contract_costs(contract_id)`, `get_contract_cost(contract_id, cost_id)`, `create_contract_cost(contract_id, PostContractCost(...))`, `update_contract_cost(contract_id, cost_id, PatchContractCost(...))`, `delete_contract_cost(contract_id, cost_id, force=...)`. **Do not** try to write `costs` on `PostContract`/`PatchContract` -- see the gotcha below. +6. For the type dropdown, use `search_contract_types(rsql_filter, limit=..., start=...)`, `iter_search_contract_types(rsql_filter, batch_size=...)`, `get_contract_type(contract_type_id)`, `create_contract_type(PostContractType(...))`, `update_contract_type(contract_type_id, PatchContractType(...))`, `delete_contract_type(contract_type_id, force=...)`. **These two search helpers take no `sort` argument** -- unlike `search_contracts`/`search_computers`, passing `sort=` here is a `TypeError`, not a silent no-op. +7. Assign a contract's type and renewal behaviour through the parent contract, not through the type dropdown: `update_contract(contract_id, PatchContract(type=IdNameRef(id=type_id), renewal_type=GlpiContractRenewalType.TACIT))`. + +## Examples + +Create a contract, update it, and read it back: + +```python +from glpi_python_client import PatchContract, PostContract +from datetime import date + +contract_id = await client.create_contract( + PostContract( + name="Dell ProSupport 2026", + number="CTR-2026-001", + date_begin=date(2026, 1, 15), # a date.date, not datetime -- see gotcha + ) +) +await client.update_contract( + contract_id, PatchContract(comment="Renewed for another year") +) +contract = await client.get_contract(contract_id) +print(contract.id, contract.name, contract.date_begin) + +matches = await client.search_contracts( + "name==Dell ProSupport 2026", limit=5, sort="date_begin:desc" +) +for c in matches: + print(c.id, c.name) +``` + +Add and update a cost line, then list and delete it: + +```python +from datetime import datetime + +from glpi_python_client import PatchContractCost, PostContractCost + +cost_id = await client.create_contract_cost( + contract_id, + PostContractCost( + name="Year 1", + cost=4200.0, + date_begin=datetime( + 2026, 1, 15, 0, 0 + ), # a datetime here, unlike Contract.date_begin + ), +) +await client.update_contract_cost( + contract_id, cost_id, PatchContractCost(comment="Paid on invoice #88") +) +cost = await client.get_contract_cost(contract_id, cost_id) +print(cost.id, cost.name, cost.cost) + +for line in await client.list_contract_costs(contract_id): + print(line.id, line.name, line.cost) + +await client.delete_contract_cost(contract_id, cost_id, force=True) +``` + +Create a contract type and assign it, with a renewal behaviour, to a contract: + +```python +from glpi_python_client import ( + GlpiContractRenewalType, + IdNameRef, + PatchContract, + PostContractType, +) + +type_id = await client.create_contract_type(PostContractType(name="Maintenance")) +contract_type = await client.get_contract_type(type_id) +print(contract_type.id, contract_type.name) + +await client.update_contract( + contract_id, + PatchContract( + type=IdNameRef(id=type_id), + renewal_type=GlpiContractRenewalType.TACIT, + ), +) +``` + +Page through every contract type on the instance: + +```python +async for batch in client.iter_search_contract_types("", batch_size=100): + for t in batch: + print(t.id, t.name) +``` + +## Gotchas + +- **`Contract.date_begin` is a plain `datetime.date`, not `datetime.datetime`.** The GLPI contract declares it with `format: date` -- a contract has no time-of-day for its start -- and the server-clock conversion in `models/_base.py` only rewrites `datetime` instances, so a `date` never enters that conversion (which matters because converting a midnight, offset-naive `datetime` between timezones can roll it onto the previous or next calendar day). `ContractCost.date_begin` and `ContractCost.date_end` are the opposite: the contract declares those with `format: date-time`, so they are `datetime`. This asymmetry is real and comes from the GLPI contract itself, not an inconsistency to "fix" -- passing a `datetime` where `Contract.date_begin` expects a `date` (or vice versa for `ContractCost`) is the easy mistake here. +- **`costs` on `GetContract` is read-only.** It comes back populated with `IdRef` references to the contract's cost lines, but the field does not exist at all on `PostContract` or `PatchContract` -- there is nothing to assign there. Write cost lines through `create_contract_cost`, `update_contract_cost`, and `delete_contract_cost` instead. +- `GlpiContractRenewalType` (exported from `glpi_python_client`, an `IntEnum` subclass of `GlpiEnum`) has three members for `Contract.renewal_type`: `NONE = 0` (no renewal), `TACIT = 1` (automatic renewal), `EXPLICIT = 2` (manual renewal). Pass the member, not a bare integer. +- `search_contract_types` and `iter_search_contract_types` have **no `sort` parameter**, unlike `search_contracts`/`iter_search_contracts` and `search_computers`/`iter_search_computers`. Passing `sort=` to a contract-type search raises `TypeError` at the call site. +- `create_contract`, `create_contract_cost`, and `create_contract_type` return the new identifier as plain `int`. Every `update_*` and `delete_*` in this family returns `None`. +- `search_contracts`, `iter_search_contracts`, `search_contract_types`, and `iter_search_contract_types` raise `GlpiStatusError` on a 4xx rather than returning `[]` -- an empty list means the result set is genuinely empty. The v2 filter engine still silently drops an RSQL field it does not recognise and answers 200 with the whole table, so a non-empty result is not proof a filter was honoured. +- To find which assets a contract covers, do not search here -- there is no `items` field or reverse filter on `Contract`. Go through the owning asset's link helpers instead, e.g. `list_computer_contracts(computer_id)` in `glpi-asset-workflow`. +- Extra fields returned by the live server (on contracts, cost lines, or types) flow into `record.extra_payload` rather than raising.