diff --git a/CHANGELOG.md b/CHANGELOG.md index 220c30d..b7d23ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## 0.6.4 — 2026-10-12 + +A patch release that reads the documents GLPI links to a followup. GLPI's +editor links an image pasted into a followup to the followup and not to the +ticket, so the ticket-level listing never returned it. Existing methods are +unchanged. + +### Added + +- **`list_item_documents(itemtype, items_id)`** lists the v1 `Document_Item` + links of one item as `GetDocumentItem` rows. It needs the v1 session + (`v1_base_url` and `v1_user_token`) and raises `RuntimeError` without one. + An invalid `itemtype` or id, a non-list answer, or a full page of 1000 rows + or more raises `GlpiValidationError`; a partial list is never returned. +- **`GetDocumentItem`**, exported from the package root, models a link row: + `id`, `documents_id`, `itemtype`, `items_id`, `entities_id`, the dates and + `timeline_position`. Other keys stay in `extra_payload`. +- **`list_ticket_followup_documents(ticket_id, *, include_private=False)`** + returns one `FollowupDocuments` (the followup and its `GetDocumentItem` + links) per followup, in ascending followup id, empty ones included. Private + followups, and followups with no `is_private` flag, are dropped by default; + `include_private=True` keeps every followup. +- **`FollowupDocuments`**, exported from the package root. +- **`GlpiTimelinePosition.NO_TIMELINE`** (-1) is an alias of `INVALID`, so + existing uses of `INVALID` are unaffected. +- The followup route was measured live. The primitive `list_item_documents` + does not filter private followups; the convenience filters them by default. + Tasks and solutions are not measured, and no helper walks them. + ## 0.6.3 — 2026-10-08 A patch release of the converter's reading: a caller can recognise the diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 2716eb5..9fe7a36 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -315,6 +315,16 @@ Documents :undoc-members: :show-inheritance: +.. autoclass:: GetDocumentItem + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: FollowupDocuments + :members: + :undoc-members: + :show-inheritance: + .. autoclass:: PostDocument :members: :undoc-members: diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 600ac92..f27c045 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -380,7 +380,8 @@ The full set is re-exported from the package root, including ``GetEntity`` / ``PostEntity`` / ``PatchEntity`` / ``DeleteEntity``, ``GetFollowup``, ``GetTicketTask``, ``GetSolution``, ``GetTimelineDocument``, ``GetTeamMember``, and ``GetDocument`` together with their post / patch / -delete variants. +delete variants. The read-only ``GetDocumentItem`` and ``FollowupDocuments`` +have no post / patch / delete variants. All models inherit from a permissive base: the GLPI server is the authoritative validator, so any extra keys returned by the live server @@ -580,6 +581,43 @@ Example output:: the client (``v1_base_url`` and ``v1_user_token``) because the GLPI v2 contract does not advertise a binary upload endpoint. +Documents linked to a followup +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +GLPI's editor links an image pasted into a followup to the followup, not to +the ticket, so ``list_ticket_timeline_documents`` never returns it. Two +helpers read those links through the legacy v1 session (``v1_base_url`` and +``v1_user_token``); without one they raise ``RuntimeError``. + +``list_item_documents(itemtype, items_id)`` returns the document links of any +item as :class:`~glpi_python_client.GetDocumentItem` rows: the link ``id``, the +``documents_id``, the ``itemtype`` and ``items_id`` it names, and a +``timeline_position``. It does not filter anything, private followups +included. A page of 1000 rows or more raises ``GlpiValidationError`` rather +than return a possibly truncated list. + +``list_ticket_followup_documents(ticket_id, *, include_private=False)`` walks +the ticket's followups and returns one +:class:`~glpi_python_client.FollowupDocuments` per followup, in ascending +followup id, holding the followup and its links (an empty list for a followup +without any). By default private followups, and followups whose ``is_private`` +flag is missing, are dropped and not queried; pass ``include_private=True`` to +keep them all. + +.. code-block:: python + + for entry in client.list_ticket_followup_documents(ticket_id): + for link in entry.documents: + print(entry.followup.id, link.documents_id, link.timeline_position) + +``timeline_position`` is a :class:`~glpi_python_client.GlpiTimelinePosition`. +``GlpiTimelinePosition.NO_TIMELINE`` (-1) names the value GLPI gives a link +that sits outside the ticket timeline, such as an image pasted into a +followup. It is an alias of ``INVALID``, which keeps its meaning. + +The followup route was measured against a live server. Tasks and solutions +were not measured, so no helper walks them. + Assets ~~~~~~ diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index 0f3a045..7b8a525 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -50,12 +50,14 @@ DeleteTicketTask, DeleteTimelineDocument, DeleteUser, + FollowupDocuments, GetComputer, GetContract, GetContractCost, GetContractItem, GetContractType, GetDocument, + GetDocumentItem, GetEntity, GetFollowup, GetKBArticle, @@ -131,7 +133,7 @@ date_window, ) -__version__ = "0.6.3" +__version__ = "0.6.4" __all__ = [ "AsyncGlpiClient", @@ -153,12 +155,14 @@ "DeleteTicketTask", "DeleteTimelineDocument", "DeleteUser", + "FollowupDocuments", "GetComputer", "GetContract", "GetContractCost", "GetContractItem", "GetContractType", "GetDocument", + "GetDocumentItem", "GetEntity", "GetFollowup", "GetKBArticle", diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/_document.py b/glpi_python_client/_async/clients/api/assistance/timeline/_document.py index d832174..2f3aa81 100644 --- a/glpi_python_client/_async/clients/api/assistance/timeline/_document.py +++ b/glpi_python_client/_async/clients/api/assistance/timeline/_document.py @@ -17,18 +17,36 @@ from __future__ import annotations +import re + +from glpi_python_client._async._concurrency import gather from glpi_python_client._async.clients.commons._constants import ( TICKET_ENDPOINT, TIMELINE_DOCUMENT_SUFFIX, GlpiId, ) +from glpi_python_client._async.clients.commons._payloads import model_from_payload from glpi_python_client._async.clients.commons._transport import TransportMixin +from glpi_python_client._errors import GlpiValidationError from glpi_python_client.models.api_schema.assistance.timeline._document import ( DeleteTimelineDocument, PatchTimelineDocument, PostTimelineDocument, ) +from glpi_python_client.models.api_schema.assistance.timeline._followup import ( + GetFollowup, +) from glpi_python_client.models.api_schema.management._document import GetDocument +from glpi_python_client.models.api_schema.management._document_item import ( + FollowupDocuments, + GetDocumentItem, +) + +_ITEM_DOCUMENT_RANGE = "0-999" +_ITEM_DOCUMENT_PAGE_SIZE = 1000 +_ITEMTYPE_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$") +_V1_FEATURE_LABEL = "item document link listing" +_FOLLOWUP_ITEMTYPE = "ITILFollowup" class TimelineDocumentMixin(TransportMixin): @@ -62,6 +80,173 @@ async def list_ticket_timeline_documents( unwrap_envelope=True, ) + async def list_item_documents( + self, itemtype: str, items_id: GlpiId + ) -> list[GetDocumentItem]: + """List the document links of one item, over the legacy v1 API. + + Only the followup route (``itemtype="ITILFollowup"``) has been + checked against a live server. Other itemtypes are sent to the same + v1 sub-route unchanged. + + Unlike :meth:`list_ticket_timeline_documents`, this call needs the + legacy v1 session (``v1_base_url`` and ``v1_user_token``). Without + one it raises ``RuntimeError``. + + It returns link rows, not documents. A caller resolves each + ``documents_id`` with ``get_document``. + + The library does not filter private followups, tasks or solutions + for the caller: every link the authenticated user can read is + returned. + + GLPI's editor links an image pasted into a followup to the followup, + not to the ticket. A caller that wants such images therefore walks + the ticket's followups and lists each one. + + Parameters + ---------- + itemtype : str + GLPI itemtype owning the links, for example ``"ITILFollowup"``. + It must start with a letter and contain only letters, digits and + underscores, because it is placed in the URL path. + items_id : GlpiId + Positive numeric identifier of the item. + + Returns + ------- + list[GetDocumentItem] + Link rows, with naive timestamps localised to the server + timezone. An empty answer gives an empty list. + + Raises + ------ + RuntimeError + If the client was built without a v1 session. + GlpiValidationError + If ``itemtype`` or ``items_id`` is invalid, if GLPI answers + something other than a list or an empty body, or if it answers a + full page of 1000 rows or more, which may be truncated. A + partial list is never returned. + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + v1 = self._require_v1_session(_V1_FEATURE_LABEL) + if not _ITEMTYPE_PATTERN.fullmatch(itemtype): + raise GlpiValidationError( + f"itemtype must match {_ITEMTYPE_PATTERN.pattern!r}, got {itemtype!r}" + ) + if items_id <= 0: + raise GlpiValidationError( + f"items_id must be a positive integer, got {items_id!r}" + ) + payload = await v1.request_json( + "GET", + f"{itemtype}/{items_id}/Document_Item", + params={"range": _ITEM_DOCUMENT_RANGE}, + failure_message=(f"Failed to list document links of {itemtype} {items_id}"), + ) + if isinstance(payload, dict) and not payload: + return [] + if not isinstance(payload, list): + raise GlpiValidationError( + f"Unexpected document link answer for {itemtype} {items_id}: " + f"{type(payload).__name__}" + ) + if len(payload) >= _ITEM_DOCUMENT_PAGE_SIZE: + raise GlpiValidationError( + f"{itemtype} {items_id} returned {len(payload)} document links, " + "a full page that may be truncated" + ) + return [ + model_from_payload( + GetDocumentItem, row, server_timezone=self.server_timezone + ) + for row in payload + ] + + async def list_ticket_followup_documents( + self, ticket_id: GlpiId, *, include_private: bool = False + ) -> list[FollowupDocuments]: + """List the document links of each followup of one ticket. + + GLPI's editor links an image pasted into a followup to the followup, + not to the ticket. :meth:`list_ticket_timeline_documents` therefore + never returns such an image, and this method does. + + Each entry holds the followup and its links, so a caller reads + ``followup.is_private`` to classify every link without a second + read. Only followups are walked: tasks and solutions are not. + + This call needs the legacy v1 session (``v1_base_url`` and + ``v1_user_token``). Without one it raises ``RuntimeError``. It issues + one v1 request per kept followup, all at once on the asynchronous + surface and one after the other on the synchronous one. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + include_private : bool, optional + When false (the default), private followups and followups whose + ``is_private`` flag is missing are dropped and not queried, so + only what a requester can see is returned. A missing flag counts + as private. When true, every followup is kept. + + Returns + ------- + list[FollowupDocuments] + One entry per kept followup, in ascending followup id, including + a followup with no link (an empty ``documents`` list). A link row + that does not name that followup as its item is dropped. + + Raises + ------ + RuntimeError + If the client was built without a v1 session. + GlpiValidationError + If a kept followup has no identifier, or if reading the links of + a followup fails validation. A partial list is never returned. + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + self._require_v1_session(_V1_FEATURE_LABEL) + followups = await self.list_ticket_followups(ticket_id) # type: ignore[attr-defined] + kept = [ + followup + for followup in followups + if include_private or followup.is_private is False + ] + keyed: list[tuple[int, GetFollowup]] = [] + for followup in kept: + if followup.id is None: + raise GlpiValidationError( + f"A followup of ticket {ticket_id} has no id, " + "so its document links cannot be read" + ) + keyed.append((followup.id, followup)) + keyed.sort(key=lambda pair: pair[0]) + link_lists = await gather( + *( + self.list_item_documents(_FOLLOWUP_ITEMTYPE, followup_id) + for followup_id, _ in keyed + ) + ) + return [ + FollowupDocuments( + followup=followup, + documents=[ + row + for row in rows + if row.itemtype == _FOLLOWUP_ITEMTYPE + and row.items_id == followup_id + ], + ) + for (followup_id, followup), rows in zip(keyed, link_lists, strict=True) + ] + async def get_ticket_timeline_document( self, ticket_id: GlpiId, document_link_id: GlpiId ) -> GetDocument: diff --git a/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_document.py b/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_document.py index 5a0f465..9ab36cb 100644 --- a/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_document.py +++ b/glpi_python_client/_async/clients/api/assistance/timeline/tests/test_document.py @@ -8,11 +8,19 @@ from __future__ import annotations from collections.abc import Callable +from datetime import timedelta, timezone from typing import Any import pytest -from glpi_python_client import PatchTimelineDocument, PostTimelineDocument +from glpi_python_client import ( + FollowupDocuments, + GetDocumentItem, + GlpiStatusError, + GlpiValidationError, + PatchTimelineDocument, + PostTimelineDocument, +) from glpi_python_client._async._testing import ( FailingTransportRecorder, TransportRecorder, @@ -108,3 +116,326 @@ async def test_delete_helpers_raise_on_failure_status( FailingTransportRecorder(500).install(client) with pytest.raises(ValueError): await call(client) + + +class _FakeV1: + """Stand-in for the v1 session that records ``request_json`` calls.""" + + def __init__(self, response: object, error: Exception | None = None) -> None: + self.response = response + self.error = error + self.calls: list[dict[str, Any]] = [] + + async def request_json( + self, + method: str, + path: str, + *, + params: dict[str, object] | None = None, + failure_message: str | None = None, + ) -> object: + self.calls.append({"method": method, "path": path, "params": params}) + if self.error is not None: + raise self.error + return self.response + + async def close(self) -> None: + """No-op; the real session is closed with the client.""" + + +def _link_row(row_id: int = 1, documents_id: int = 11) -> dict[str, Any]: + return { + "id": row_id, + "documents_id": documents_id, + "items_id": 7, + "itemtype": "ITILFollowup", + "entities_id": 0, + "is_recursive": 0, + "date": "2026-01-02 03:04:05", + "timeline_position": -1, + } + + +async def test_list_item_documents_reads_the_followup_route(client: Any) -> None: + """The call is a v1 GET on the item's Document_Item sub-route.""" + + fake = _FakeV1([_link_row()]) + client._v1 = fake + rows = await client.list_item_documents("ITILFollowup", 7) + assert len(fake.calls) == 1 + assert fake.calls[0]["method"] == "GET" + assert fake.calls[0]["path"] == "ITILFollowup/7/Document_Item" + assert fake.calls[0]["params"] == {"range": "0-999"} + assert isinstance(rows[0], GetDocumentItem) + assert rows[0].documents_id == 11 + assert rows[0].itemtype == "ITILFollowup" + assert rows[0].items_id == 7 + + +async def test_list_item_documents_localises_dates(client: Any) -> None: + """Naive link dates gain the client's server timezone.""" + + zone = timezone(timedelta(hours=2)) + client.server_timezone = zone + client._v1 = _FakeV1([_link_row()]) + rows = await client.list_item_documents("ITILFollowup", 7) + assert rows[0].date is not None + assert rows[0].date.utcoffset() == timedelta(hours=2) + + +async def test_list_item_documents_uses_the_given_itemtype(client: Any) -> None: + """A task call puts its own itemtype and id in the path.""" + + fake = _FakeV1([]) + client._v1 = fake + await client.list_item_documents("TicketTask", 42) + assert fake.calls[0]["path"] == "TicketTask/42/Document_Item" + + +async def test_list_item_documents_empty_answers(client: Any) -> None: + """An empty list and the empty-body ``{}`` both give no rows.""" + + client._v1 = _FakeV1([]) + assert await client.list_item_documents("ITILFollowup", 7) == [] + client._v1 = _FakeV1({}) + assert await client.list_item_documents("ITILFollowup", 7) == [] + + +@pytest.mark.parametrize("answer", [{"a": 1}, "text", None]) +async def test_list_item_documents_rejects_other_payloads( + client: Any, answer: object +) -> None: + """Anything but a list or ``{}`` raises instead of reading as empty.""" + + client._v1 = _FakeV1(answer) + with pytest.raises(GlpiValidationError): + await client.list_item_documents("ITILFollowup", 7) + + +async def test_list_item_documents_needs_a_v1_session(client: Any) -> None: + """Without a v1 session the call raises ``RuntimeError``.""" + + assert client._v1 is None + with pytest.raises(RuntimeError, match="v1 session"): + await client.list_item_documents("ITILFollowup", 7) + + +@pytest.mark.parametrize( + ("itemtype", "items_id"), + [ + ("ITIL/Followup", 7), + ("ITIL Followup", 7), + ("..", 7), + ("", 7), + ("1Followup", 7), + ("ITILFollowup", 0), + ("ITILFollowup", -3), + ], +) +async def test_list_item_documents_validates_before_any_call( + client: Any, itemtype: str, items_id: int +) -> None: + """A bad itemtype or id raises before the session is touched.""" + + fake = _FakeV1([]) + client._v1 = fake + with pytest.raises(GlpiValidationError): + await client.list_item_documents(itemtype, items_id) + assert fake.calls == [] + + +async def test_list_item_documents_propagates_status_errors(client: Any) -> None: + """A 4xx raised by the session reaches the caller.""" + + client._v1 = _FakeV1( + [], + error=GlpiStatusError("denied", status_code=403, url="https://glpi.example/x"), + ) + with pytest.raises(GlpiStatusError): + await client.list_item_documents("ITILFollowup", 7) + + +async def test_list_item_documents_returns_999_rows_whole(client: Any) -> None: + """One row under the page size is a complete answer.""" + + client._v1 = _FakeV1([_link_row(i, i) for i in range(1, 1000)]) + rows = await client.list_item_documents("ITILFollowup", 7) + assert len(rows) == 999 + + +async def test_list_item_documents_refuses_a_full_page(client: Any) -> None: + """A full page may be truncated, so it raises and returns no partial list.""" + + client._v1 = _FakeV1([_link_row(i, i) for i in range(1, 1001)]) + with pytest.raises(GlpiValidationError): + await client.list_item_documents("ITILFollowup", 7) + + +class _RoutedV1: + """V1 stand-in that answers per path and records every path asked.""" + + def __init__(self, routes: dict[str, object]) -> None: + self.routes = routes + self.paths: list[str] = [] + + async def request_json( + self, + method: str, + path: str, + *, + params: dict[str, object] | None = None, + failure_message: str | None = None, + ) -> object: + self.paths.append(path) + answer = self.routes[path] + if isinstance(answer, Exception): + raise answer + return answer + + async def close(self) -> None: + """No-op; the real session is closed with the client.""" + + +def _followup(followup_id: int | None, is_private: bool | None) -> dict[str, Any]: + row: dict[str, Any] = {"content": "text"} + if followup_id is not None: + row["id"] = followup_id + if is_private is not None: + row["is_private"] = is_private + return row + + +def _followup_link( + followup_id: int, documents_id: int, **overrides: Any +) -> dict[str, Any]: + row = _link_row(row_id=documents_id + 100, documents_id=documents_id) + row["items_id"] = followup_id + row.update(overrides) + return row + + +def _install_followups(client: Any, followups: list[dict[str, Any]]) -> None: + TransportRecorder(get_payload=followups).install(client) + + +async def test_followup_documents_lists_public_followups_in_id_order( + client: Any, +) -> None: + """Public followups come back by ascending id, links attached, empties kept.""" + + _install_followups(client, [_followup(12, False), _followup(11, False)]) + fake = _RoutedV1( + { + "ITILFollowup/11/Document_Item": [ + _followup_link(11, 21), + _followup_link(11, 22), + ], + "ITILFollowup/12/Document_Item": [], + } + ) + client._v1 = fake + entries = await client.list_ticket_followup_documents(7) + assert all(isinstance(entry, FollowupDocuments) for entry in entries) + assert [entry.followup.id for entry in entries] == [11, 12] + assert [d.documents_id for d in entries[0].documents] == [21, 22] + assert entries[1].documents == [] + assert sorted(fake.paths) == [ + "ITILFollowup/11/Document_Item", + "ITILFollowup/12/Document_Item", + ] + + +async def test_followup_documents_skips_private_and_flagless_by_default( + client: Any, +) -> None: + """Private and flag-less followups are absent and never queried.""" + + _install_followups( + client, [_followup(11, False), _followup(12, True), _followup(13, None)] + ) + fake = _RoutedV1({"ITILFollowup/11/Document_Item": [_followup_link(11, 21)]}) + client._v1 = fake + entries = await client.list_ticket_followup_documents(7) + assert [entry.followup.id for entry in entries] == [11] + assert fake.paths == ["ITILFollowup/11/Document_Item"] + + +async def test_followup_documents_include_private_keeps_every_followup( + client: Any, +) -> None: + """With ``include_private`` every followup is listed, flags intact.""" + + _install_followups( + client, [_followup(13, None), _followup(11, False), _followup(12, True)] + ) + client._v1 = _RoutedV1( + { + "ITILFollowup/11/Document_Item": [_followup_link(11, 21)], + "ITILFollowup/12/Document_Item": [_followup_link(12, 22)], + "ITILFollowup/13/Document_Item": [], + } + ) + entries = await client.list_ticket_followup_documents(7, include_private=True) + assert [entry.followup.id for entry in entries] == [11, 12, 13] + assert [entry.followup.is_private for entry in entries] == [False, True, None] + assert [len(entry.documents) for entry in entries] == [1, 1, 0] + + +async def test_followup_documents_drops_foreign_link_rows(client: Any) -> None: + """A row naming another followup or another itemtype is dropped.""" + + _install_followups(client, [_followup(11, False)]) + client._v1 = _RoutedV1( + { + "ITILFollowup/11/Document_Item": [ + _followup_link(11, 21), + _followup_link(12, 22), + _followup_link(11, 23, itemtype="Ticket"), + _followup_link(11, 24, items_id=None), + ] + } + ) + entries = await client.list_ticket_followup_documents(7) + assert [d.documents_id for d in entries[0].documents] == [21] + + +async def test_followup_documents_propagates_a_failed_read(client: Any) -> None: + """One failing followup read fails the call, with no partial list.""" + + _install_followups(client, [_followup(11, False), _followup(12, False)]) + client._v1 = _RoutedV1( + { + "ITILFollowup/11/Document_Item": [_followup_link(11, 21)], + "ITILFollowup/12/Document_Item": GlpiValidationError("boom"), + } + ) + with pytest.raises(GlpiValidationError, match="boom"): + await client.list_ticket_followup_documents(7) + + +async def test_followup_documents_rejects_a_followup_without_an_id( + client: Any, +) -> None: + """A followup that cannot be queried raises rather than being skipped.""" + + _install_followups(client, [_followup(11, False), _followup(None, False)]) + fake = _RoutedV1({"ITILFollowup/11/Document_Item": []}) + client._v1 = fake + with pytest.raises(GlpiValidationError): + await client.list_ticket_followup_documents(7) + + +async def test_followup_documents_without_a_v1_session_raises(client: Any) -> None: + """The v1 session is required even when no followup exists.""" + + _install_followups(client, []) + with pytest.raises(RuntimeError): + await client.list_ticket_followup_documents(7) + + +async def test_followup_documents_no_followups_gives_empty_list(client: Any) -> None: + """A ticket without followups gives an empty list.""" + + _install_followups(client, []) + client._v1 = _RoutedV1({}) + assert await client.list_ticket_followup_documents(7) == [] diff --git a/glpi_python_client/_sync/clients/api/assistance/timeline/_document.py b/glpi_python_client/_sync/clients/api/assistance/timeline/_document.py index 7d93352..b8d3549 100644 --- a/glpi_python_client/_sync/clients/api/assistance/timeline/_document.py +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/_document.py @@ -17,18 +17,36 @@ from __future__ import annotations +import re + +from glpi_python_client._sync._concurrency import gather from glpi_python_client._sync.clients.commons._constants import ( TICKET_ENDPOINT, TIMELINE_DOCUMENT_SUFFIX, GlpiId, ) +from glpi_python_client._sync.clients.commons._payloads import model_from_payload from glpi_python_client._sync.clients.commons._transport import TransportMixin +from glpi_python_client._errors import GlpiValidationError from glpi_python_client.models.api_schema.assistance.timeline._document import ( DeleteTimelineDocument, PatchTimelineDocument, PostTimelineDocument, ) +from glpi_python_client.models.api_schema.assistance.timeline._followup import ( + GetFollowup, +) from glpi_python_client.models.api_schema.management._document import GetDocument +from glpi_python_client.models.api_schema.management._document_item import ( + FollowupDocuments, + GetDocumentItem, +) + +_ITEM_DOCUMENT_RANGE = "0-999" +_ITEM_DOCUMENT_PAGE_SIZE = 1000 +_ITEMTYPE_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$") +_V1_FEATURE_LABEL = "item document link listing" +_FOLLOWUP_ITEMTYPE = "ITILFollowup" class TimelineDocumentMixin(TransportMixin): @@ -62,6 +80,173 @@ def list_ticket_timeline_documents( unwrap_envelope=True, ) + def list_item_documents( + self, itemtype: str, items_id: GlpiId + ) -> list[GetDocumentItem]: + """List the document links of one item, over the legacy v1 API. + + Only the followup route (``itemtype="ITILFollowup"``) has been + checked against a live server. Other itemtypes are sent to the same + v1 sub-route unchanged. + + Unlike :meth:`list_ticket_timeline_documents`, this call needs the + legacy v1 session (``v1_base_url`` and ``v1_user_token``). Without + one it raises ``RuntimeError``. + + It returns link rows, not documents. A caller resolves each + ``documents_id`` with ``get_document``. + + The library does not filter private followups, tasks or solutions + for the caller: every link the authenticated user can read is + returned. + + GLPI's editor links an image pasted into a followup to the followup, + not to the ticket. A caller that wants such images therefore walks + the ticket's followups and lists each one. + + Parameters + ---------- + itemtype : str + GLPI itemtype owning the links, for example ``"ITILFollowup"``. + It must start with a letter and contain only letters, digits and + underscores, because it is placed in the URL path. + items_id : GlpiId + Positive numeric identifier of the item. + + Returns + ------- + list[GetDocumentItem] + Link rows, with naive timestamps localised to the server + timezone. An empty answer gives an empty list. + + Raises + ------ + RuntimeError + If the client was built without a v1 session. + GlpiValidationError + If ``itemtype`` or ``items_id`` is invalid, if GLPI answers + something other than a list or an empty body, or if it answers a + full page of 1000 rows or more, which may be truncated. A + partial list is never returned. + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + v1 = self._require_v1_session(_V1_FEATURE_LABEL) + if not _ITEMTYPE_PATTERN.fullmatch(itemtype): + raise GlpiValidationError( + f"itemtype must match {_ITEMTYPE_PATTERN.pattern!r}, got {itemtype!r}" + ) + if items_id <= 0: + raise GlpiValidationError( + f"items_id must be a positive integer, got {items_id!r}" + ) + payload = v1.request_json( + "GET", + f"{itemtype}/{items_id}/Document_Item", + params={"range": _ITEM_DOCUMENT_RANGE}, + failure_message=(f"Failed to list document links of {itemtype} {items_id}"), + ) + if isinstance(payload, dict) and not payload: + return [] + if not isinstance(payload, list): + raise GlpiValidationError( + f"Unexpected document link answer for {itemtype} {items_id}: " + f"{type(payload).__name__}" + ) + if len(payload) >= _ITEM_DOCUMENT_PAGE_SIZE: + raise GlpiValidationError( + f"{itemtype} {items_id} returned {len(payload)} document links, " + "a full page that may be truncated" + ) + return [ + model_from_payload( + GetDocumentItem, row, server_timezone=self.server_timezone + ) + for row in payload + ] + + def list_ticket_followup_documents( + self, ticket_id: GlpiId, *, include_private: bool = False + ) -> list[FollowupDocuments]: + """List the document links of each followup of one ticket. + + GLPI's editor links an image pasted into a followup to the followup, + not to the ticket. :meth:`list_ticket_timeline_documents` therefore + never returns such an image, and this method does. + + Each entry holds the followup and its links, so a caller reads + ``followup.is_private`` to classify every link without a second + read. Only followups are walked: tasks and solutions are not. + + This call needs the legacy v1 session (``v1_base_url`` and + ``v1_user_token``). Without one it raises ``RuntimeError``. It issues + one v1 request per kept followup, all at once on the asynchronous + surface and one after the other on the synchronous one. + + Parameters + ---------- + ticket_id : GlpiId + Numeric identifier of the parent ticket. + include_private : bool, optional + When false (the default), private followups and followups whose + ``is_private`` flag is missing are dropped and not queried, so + only what a requester can see is returned. A missing flag counts + as private. When true, every followup is kept. + + Returns + ------- + list[FollowupDocuments] + One entry per kept followup, in ascending followup id, including + a followup with no link (an empty ``documents`` list). A link row + that does not name that followup as its item is dropped. + + Raises + ------ + RuntimeError + If the client was built without a v1 session. + GlpiValidationError + If a kept followup has no identifier, or if reading the links of + a followup fails validation. A partial list is never returned. + GlpiStatusError + If the GLPI server returns a non-success HTTP status. + """ + + self._require_v1_session(_V1_FEATURE_LABEL) + followups = self.list_ticket_followups(ticket_id) # type: ignore[attr-defined] + kept = [ + followup + for followup in followups + if include_private or followup.is_private is False + ] + keyed: list[tuple[int, GetFollowup]] = [] + for followup in kept: + if followup.id is None: + raise GlpiValidationError( + f"A followup of ticket {ticket_id} has no id, " + "so its document links cannot be read" + ) + keyed.append((followup.id, followup)) + keyed.sort(key=lambda pair: pair[0]) + link_lists = gather( + *( + self.list_item_documents(_FOLLOWUP_ITEMTYPE, followup_id) + for followup_id, _ in keyed + ) + ) + return [ + FollowupDocuments( + followup=followup, + documents=[ + row + for row in rows + if row.itemtype == _FOLLOWUP_ITEMTYPE + and row.items_id == followup_id + ], + ) + for (followup_id, followup), rows in zip(keyed, link_lists, strict=True) + ] + def get_ticket_timeline_document( self, ticket_id: GlpiId, document_link_id: GlpiId ) -> GetDocument: diff --git a/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_document.py b/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_document.py index c603359..ad49ea5 100644 --- a/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_document.py +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/tests/test_document.py @@ -8,11 +8,19 @@ from __future__ import annotations from collections.abc import Callable +from datetime import timedelta, timezone from typing import Any import pytest -from glpi_python_client import PatchTimelineDocument, PostTimelineDocument +from glpi_python_client import ( + FollowupDocuments, + GetDocumentItem, + GlpiStatusError, + GlpiValidationError, + PatchTimelineDocument, + PostTimelineDocument, +) from glpi_python_client._sync._testing import ( FailingTransportRecorder, TransportRecorder, @@ -108,3 +116,326 @@ def test_delete_helpers_raise_on_failure_status( FailingTransportRecorder(500).install(client) with pytest.raises(ValueError): call(client) + + +class _FakeV1: + """Stand-in for the v1 session that records ``request_json`` calls.""" + + def __init__(self, response: object, error: Exception | None = None) -> None: + self.response = response + self.error = error + self.calls: list[dict[str, Any]] = [] + + def request_json( + self, + method: str, + path: str, + *, + params: dict[str, object] | None = None, + failure_message: str | None = None, + ) -> object: + self.calls.append({"method": method, "path": path, "params": params}) + if self.error is not None: + raise self.error + return self.response + + def close(self) -> None: + """No-op; the real session is closed with the client.""" + + +def _link_row(row_id: int = 1, documents_id: int = 11) -> dict[str, Any]: + return { + "id": row_id, + "documents_id": documents_id, + "items_id": 7, + "itemtype": "ITILFollowup", + "entities_id": 0, + "is_recursive": 0, + "date": "2026-01-02 03:04:05", + "timeline_position": -1, + } + + +def test_list_item_documents_reads_the_followup_route(client: Any) -> None: + """The call is a v1 GET on the item's Document_Item sub-route.""" + + fake = _FakeV1([_link_row()]) + client._v1 = fake + rows = client.list_item_documents("ITILFollowup", 7) + assert len(fake.calls) == 1 + assert fake.calls[0]["method"] == "GET" + assert fake.calls[0]["path"] == "ITILFollowup/7/Document_Item" + assert fake.calls[0]["params"] == {"range": "0-999"} + assert isinstance(rows[0], GetDocumentItem) + assert rows[0].documents_id == 11 + assert rows[0].itemtype == "ITILFollowup" + assert rows[0].items_id == 7 + + +def test_list_item_documents_localises_dates(client: Any) -> None: + """Naive link dates gain the client's server timezone.""" + + zone = timezone(timedelta(hours=2)) + client.server_timezone = zone + client._v1 = _FakeV1([_link_row()]) + rows = client.list_item_documents("ITILFollowup", 7) + assert rows[0].date is not None + assert rows[0].date.utcoffset() == timedelta(hours=2) + + +def test_list_item_documents_uses_the_given_itemtype(client: Any) -> None: + """A task call puts its own itemtype and id in the path.""" + + fake = _FakeV1([]) + client._v1 = fake + client.list_item_documents("TicketTask", 42) + assert fake.calls[0]["path"] == "TicketTask/42/Document_Item" + + +def test_list_item_documents_empty_answers(client: Any) -> None: + """An empty list and the empty-body ``{}`` both give no rows.""" + + client._v1 = _FakeV1([]) + assert client.list_item_documents("ITILFollowup", 7) == [] + client._v1 = _FakeV1({}) + assert client.list_item_documents("ITILFollowup", 7) == [] + + +@pytest.mark.parametrize("answer", [{"a": 1}, "text", None]) +def test_list_item_documents_rejects_other_payloads( + client: Any, answer: object +) -> None: + """Anything but a list or ``{}`` raises instead of reading as empty.""" + + client._v1 = _FakeV1(answer) + with pytest.raises(GlpiValidationError): + client.list_item_documents("ITILFollowup", 7) + + +def test_list_item_documents_needs_a_v1_session(client: Any) -> None: + """Without a v1 session the call raises ``RuntimeError``.""" + + assert client._v1 is None + with pytest.raises(RuntimeError, match="v1 session"): + client.list_item_documents("ITILFollowup", 7) + + +@pytest.mark.parametrize( + ("itemtype", "items_id"), + [ + ("ITIL/Followup", 7), + ("ITIL Followup", 7), + ("..", 7), + ("", 7), + ("1Followup", 7), + ("ITILFollowup", 0), + ("ITILFollowup", -3), + ], +) +def test_list_item_documents_validates_before_any_call( + client: Any, itemtype: str, items_id: int +) -> None: + """A bad itemtype or id raises before the session is touched.""" + + fake = _FakeV1([]) + client._v1 = fake + with pytest.raises(GlpiValidationError): + client.list_item_documents(itemtype, items_id) + assert fake.calls == [] + + +def test_list_item_documents_propagates_status_errors(client: Any) -> None: + """A 4xx raised by the session reaches the caller.""" + + client._v1 = _FakeV1( + [], + error=GlpiStatusError("denied", status_code=403, url="https://glpi.example/x"), + ) + with pytest.raises(GlpiStatusError): + client.list_item_documents("ITILFollowup", 7) + + +def test_list_item_documents_returns_999_rows_whole(client: Any) -> None: + """One row under the page size is a complete answer.""" + + client._v1 = _FakeV1([_link_row(i, i) for i in range(1, 1000)]) + rows = client.list_item_documents("ITILFollowup", 7) + assert len(rows) == 999 + + +def test_list_item_documents_refuses_a_full_page(client: Any) -> None: + """A full page may be truncated, so it raises and returns no partial list.""" + + client._v1 = _FakeV1([_link_row(i, i) for i in range(1, 1001)]) + with pytest.raises(GlpiValidationError): + client.list_item_documents("ITILFollowup", 7) + + +class _RoutedV1: + """V1 stand-in that answers per path and records every path asked.""" + + def __init__(self, routes: dict[str, object]) -> None: + self.routes = routes + self.paths: list[str] = [] + + def request_json( + self, + method: str, + path: str, + *, + params: dict[str, object] | None = None, + failure_message: str | None = None, + ) -> object: + self.paths.append(path) + answer = self.routes[path] + if isinstance(answer, Exception): + raise answer + return answer + + def close(self) -> None: + """No-op; the real session is closed with the client.""" + + +def _followup(followup_id: int | None, is_private: bool | None) -> dict[str, Any]: + row: dict[str, Any] = {"content": "text"} + if followup_id is not None: + row["id"] = followup_id + if is_private is not None: + row["is_private"] = is_private + return row + + +def _followup_link( + followup_id: int, documents_id: int, **overrides: Any +) -> dict[str, Any]: + row = _link_row(row_id=documents_id + 100, documents_id=documents_id) + row["items_id"] = followup_id + row.update(overrides) + return row + + +def _install_followups(client: Any, followups: list[dict[str, Any]]) -> None: + TransportRecorder(get_payload=followups).install(client) + + +def test_followup_documents_lists_public_followups_in_id_order( + client: Any, +) -> None: + """Public followups come back by ascending id, links attached, empties kept.""" + + _install_followups(client, [_followup(12, False), _followup(11, False)]) + fake = _RoutedV1( + { + "ITILFollowup/11/Document_Item": [ + _followup_link(11, 21), + _followup_link(11, 22), + ], + "ITILFollowup/12/Document_Item": [], + } + ) + client._v1 = fake + entries = client.list_ticket_followup_documents(7) + assert all(isinstance(entry, FollowupDocuments) for entry in entries) + assert [entry.followup.id for entry in entries] == [11, 12] + assert [d.documents_id for d in entries[0].documents] == [21, 22] + assert entries[1].documents == [] + assert sorted(fake.paths) == [ + "ITILFollowup/11/Document_Item", + "ITILFollowup/12/Document_Item", + ] + + +def test_followup_documents_skips_private_and_flagless_by_default( + client: Any, +) -> None: + """Private and flag-less followups are absent and never queried.""" + + _install_followups( + client, [_followup(11, False), _followup(12, True), _followup(13, None)] + ) + fake = _RoutedV1({"ITILFollowup/11/Document_Item": [_followup_link(11, 21)]}) + client._v1 = fake + entries = client.list_ticket_followup_documents(7) + assert [entry.followup.id for entry in entries] == [11] + assert fake.paths == ["ITILFollowup/11/Document_Item"] + + +def test_followup_documents_include_private_keeps_every_followup( + client: Any, +) -> None: + """With ``include_private`` every followup is listed, flags intact.""" + + _install_followups( + client, [_followup(13, None), _followup(11, False), _followup(12, True)] + ) + client._v1 = _RoutedV1( + { + "ITILFollowup/11/Document_Item": [_followup_link(11, 21)], + "ITILFollowup/12/Document_Item": [_followup_link(12, 22)], + "ITILFollowup/13/Document_Item": [], + } + ) + entries = client.list_ticket_followup_documents(7, include_private=True) + assert [entry.followup.id for entry in entries] == [11, 12, 13] + assert [entry.followup.is_private for entry in entries] == [False, True, None] + assert [len(entry.documents) for entry in entries] == [1, 1, 0] + + +def test_followup_documents_drops_foreign_link_rows(client: Any) -> None: + """A row naming another followup or another itemtype is dropped.""" + + _install_followups(client, [_followup(11, False)]) + client._v1 = _RoutedV1( + { + "ITILFollowup/11/Document_Item": [ + _followup_link(11, 21), + _followup_link(12, 22), + _followup_link(11, 23, itemtype="Ticket"), + _followup_link(11, 24, items_id=None), + ] + } + ) + entries = client.list_ticket_followup_documents(7) + assert [d.documents_id for d in entries[0].documents] == [21] + + +def test_followup_documents_propagates_a_failed_read(client: Any) -> None: + """One failing followup read fails the call, with no partial list.""" + + _install_followups(client, [_followup(11, False), _followup(12, False)]) + client._v1 = _RoutedV1( + { + "ITILFollowup/11/Document_Item": [_followup_link(11, 21)], + "ITILFollowup/12/Document_Item": GlpiValidationError("boom"), + } + ) + with pytest.raises(GlpiValidationError, match="boom"): + client.list_ticket_followup_documents(7) + + +def test_followup_documents_rejects_a_followup_without_an_id( + client: Any, +) -> None: + """A followup that cannot be queried raises rather than being skipped.""" + + _install_followups(client, [_followup(11, False), _followup(None, False)]) + fake = _RoutedV1({"ITILFollowup/11/Document_Item": []}) + client._v1 = fake + with pytest.raises(GlpiValidationError): + client.list_ticket_followup_documents(7) + + +def test_followup_documents_without_a_v1_session_raises(client: Any) -> None: + """The v1 session is required even when no followup exists.""" + + _install_followups(client, []) + with pytest.raises(RuntimeError): + client.list_ticket_followup_documents(7) + + +def test_followup_documents_no_followups_gives_empty_list(client: Any) -> None: + """A ticket without followups gives an empty list.""" + + _install_followups(client, []) + client._v1 = _RoutedV1({}) + assert client.list_ticket_followup_documents(7) == [] diff --git a/glpi_python_client/models/__init__.py b/glpi_python_client/models/__init__.py index 93581c5..ddf4cf3 100644 --- a/glpi_python_client/models/__init__.py +++ b/glpi_python_client/models/__init__.py @@ -109,9 +109,11 @@ DeleteContract, DeleteContractCost, DeleteDocument, + FollowupDocuments, GetContract, GetContractCost, GetDocument, + GetDocumentItem, PatchContract, PatchContractCost, PatchDocument, @@ -149,12 +151,14 @@ "DeleteTicketTask", "DeleteTimelineDocument", "DeleteUser", + "FollowupDocuments", "GetComputer", "GetContract", "GetContractCost", "GetContractItem", "GetContractType", "GetDocument", + "GetDocumentItem", "GetEntity", "GetFollowup", "GetKBArticle", diff --git a/glpi_python_client/models/api_schema/enums.py b/glpi_python_client/models/api_schema/enums.py index db2e7ee..065fd5d 100644 --- a/glpi_python_client/models/api_schema/enums.py +++ b/glpi_python_client/models/api_schema/enums.py @@ -135,9 +135,14 @@ class GlpiTimelinePosition(GlpiEnum): The contract advertises ``[-1, 0, 1, 2, 3, 4]`` on ``timeline_position``. ``LEFT`` and ``RIGHT`` follow the GLPI UI conventions. + + ``NO_TIMELINE`` is GLPI's own name for ``-1`` and an alias of + ``INVALID``. GLPI's editor writes it on the link of an image it embeds + in a text, and such a link is hidden from the ticket's Documents tab. """ INVALID = -1 + NO_TIMELINE = -1 NONE = 0 LEFT = 1 RIGHT = 2 diff --git a/glpi_python_client/models/api_schema/management/__init__.py b/glpi_python_client/models/api_schema/management/__init__.py index 0edb266..deefad9 100644 --- a/glpi_python_client/models/api_schema/management/__init__.py +++ b/glpi_python_client/models/api_schema/management/__init__.py @@ -18,14 +18,20 @@ PatchDocument, PostDocument, ) +from glpi_python_client.models.api_schema.management._document_item import ( + FollowupDocuments, + GetDocumentItem, +) __all__ = [ "DeleteContract", "DeleteContractCost", "DeleteDocument", + "FollowupDocuments", "GetContract", "GetContractCost", "GetDocument", + "GetDocumentItem", "PatchContract", "PatchContractCost", "PatchDocument", diff --git a/glpi_python_client/models/api_schema/management/_document_item.py b/glpi_python_client/models/api_schema/management/_document_item.py new file mode 100644 index 0000000..df1f171 --- /dev/null +++ b/glpi_python_client/models/api_schema/management/_document_item.py @@ -0,0 +1,88 @@ +"""GLPI ``Document_Item`` link rows, as the legacy v1 API returns them. + +A ``Document_Item`` row links one document to one item: a ticket, a followup, +a task or a solution. The v1 sub-item route ``//Document_Item`` +answers a list of these rows. They are not the ``GetTimelineDocument`` shape +of the v2 timeline, which carries no entity and no dates. +""" + +from __future__ import annotations + +from datetime import datetime + +from pydantic import ConfigDict + +from glpi_python_client.models._base import GlpiModel +from glpi_python_client.models.api_schema.assistance.timeline._followup import ( + GetFollowup, +) +from glpi_python_client.models.api_schema.enums import GlpiTimelinePosition + + +class GetDocumentItem(GlpiModel): + """One ``Document_Item`` row linking a document to an item. + + Keys the model does not declare (``users_id``, ``is_recursive``, + ``links`` and any plugin column) are kept in ``extra_payload``. + + Parameters + ---------- + id : int + Native identifier of the link row. + documents_id : int + Identifier of the linked document. + itemtype : str | None, optional + GLPI type of the item the document is linked to, for example + ``"Ticket"`` or ``"ITILFollowup"``. + items_id : int | None, optional + Identifier of the item the document is linked to. + entities_id : int | None, optional + Identifier of the entity owning the link. + date_creation : datetime | None, optional + Creation timestamp of the link row. + date_mod : datetime | None, optional + Last modification timestamp of the link row. + date : datetime | None, optional + Timestamp GLPI records on the link. + timeline_position : GlpiTimelinePosition | None, optional + Position of the link in the ticket timeline. A link at + :attr:`GlpiTimelinePosition.NO_TIMELINE` (``-1``) is written by + GLPI's editor for an image it embeds in a text. Such a link is + hidden from the ticket's Documents tab. A document uploaded as an + attachment is linked at ``1``. + """ + + id: int + documents_id: int + itemtype: str | None = None + items_id: int | None = None + entities_id: int | None = None + date_creation: datetime | None = None + date_mod: datetime | None = None + date: datetime | None = None + timeline_position: GlpiTimelinePosition | None = None + + +class FollowupDocuments(GlpiModel): + """One ticket followup together with its document links. + + Returned by ``list_ticket_followup_documents``. The followup is kept + whole, so a caller reads ``followup.is_private`` to classify every link + without a second read. + + Parameters + ---------- + followup : GetFollowup + The followup, as the ticket's followup listing returned it. + documents : list[GetDocumentItem] + The ``Document_Item`` rows linked to this followup. Empty when the + followup has no document. + """ + + model_config = ConfigDict(extra="allow", frozen=True) + + followup: GetFollowup + documents: list[GetDocumentItem] + + +__all__ = ["FollowupDocuments", "GetDocumentItem"] diff --git a/glpi_python_client/models/api_schema/management/tests/test_document_item.py b/glpi_python_client/models/api_schema/management/tests/test_document_item.py new file mode 100644 index 0000000..82526a6 --- /dev/null +++ b/glpi_python_client/models/api_schema/management/tests/test_document_item.py @@ -0,0 +1,138 @@ +"""Tests for the ``GetDocumentItem`` link-row model.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest +from pydantic import ValidationError + +from glpi_python_client.models.api_schema.assistance.timeline import GetFollowup +from glpi_python_client.models.api_schema.enums import GlpiTimelinePosition +from glpi_python_client.models.api_schema.management import ( + FollowupDocuments, + GetDocumentItem, +) + +_PARIS_SUMMER = timezone(timedelta(hours=2)) + + +def _row(**overrides: object) -> dict[str, object]: + """Return one v1 ``Document_Item`` row with synthetic values.""" + + row: dict[str, object] = { + "id": 7, + "documents_id": 11, + "items_id": 42, + "itemtype": "ITILFollowup", + "entities_id": 3, + "is_recursive": 0, + "date_mod": "2024-01-02 03:04:05", + "date_creation": "2024-01-02 03:04:06", + "date": "2024-01-02 03:04:07", + "users_id": 5, + "timeline_position": 1, + "links": [{"rel": "Document", "href": "https://example.invalid/Document/11"}], + } + row.update(overrides) + return row + + +def test_full_v1_row_builds_the_model() -> None: + """Every declared field is read and the rest lands in ``extra_payload``.""" + + item = GetDocumentItem.model_validate(_row()) + assert (item.id, item.documents_id, item.items_id) == (7, 11, 42) + assert item.itemtype == "ITILFollowup" + assert item.entities_id == 3 + assert item.date_creation == datetime(2024, 1, 2, 3, 4, 6) + assert item.date_mod == datetime(2024, 1, 2, 3, 4, 5) + assert item.date == datetime(2024, 1, 2, 3, 4, 7) + assert item.extra_payload["users_id"] == 5 + assert item.extra_payload["is_recursive"] == 0 + + +def test_row_without_documents_id_is_rejected() -> None: + """The document id is the one field a link row cannot do without.""" + + row = _row() + del row["documents_id"] + with pytest.raises(ValidationError): + GetDocumentItem.model_validate(row) + + +def test_row_without_id_is_rejected() -> None: + """The row id is required.""" + + row = _row() + del row["id"] + with pytest.raises(ValidationError): + GetDocumentItem.model_validate(row) + + +def test_minus_one_reads_no_timeline() -> None: + """A link at -1 is the editor's embedded-image marker.""" + + item = GetDocumentItem.model_validate(_row(timeline_position=-1)) + assert item.timeline_position is GlpiTimelinePosition.NO_TIMELINE + assert "timeline_position" not in item.extra_payload + + +def test_no_timeline_is_an_alias_of_invalid() -> None: + """``NO_TIMELINE`` is GLPI's own name for the value ``INVALID`` already holds.""" + + assert GlpiTimelinePosition.NO_TIMELINE is GlpiTimelinePosition.INVALID + assert GlpiTimelinePosition.NO_TIMELINE == -1 + assert GlpiTimelinePosition(-1) is GlpiTimelinePosition.INVALID + assert GlpiTimelinePosition(-1).name == "INVALID" + + +def test_one_reads_left() -> None: + """A link at 1 is an uploaded document.""" + + item = GetDocumentItem.model_validate(_row(timeline_position=1)) + assert item.timeline_position is GlpiTimelinePosition.LEFT + + +def test_missing_timeline_position_reads_none() -> None: + """A row without the field leaves it unset.""" + + row = _row() + del row["timeline_position"] + assert GetDocumentItem.model_validate(row).timeline_position is None + + +def test_links_land_in_extra_payload() -> None: + """The HATEOAS ``links`` list is kept untouched in ``extra_payload``.""" + + item = GetDocumentItem.model_validate(_row()) + assert item.extra_payload["links"] == [ + {"rel": "Document", "href": "https://example.invalid/Document/11"} + ] + + +def test_naive_datetimes_are_localised_to_the_server_timezone() -> None: + """Naive timestamps gain the server timezone from the validation context.""" + + item = GetDocumentItem.model_validate( + _row(), context={"server_timezone": _PARIS_SUMMER} + ) + assert item.date_creation is not None + assert item.date_creation.tzinfo == _PARIS_SUMMER + assert item.date_mod is not None + assert item.date_mod.tzinfo == _PARIS_SUMMER + assert item.date is not None + assert item.date.tzinfo == _PARIS_SUMMER + + +def test_followup_documents_holds_a_followup_and_its_links() -> None: + """The result model carries the followup whole and is frozen.""" + + entry = FollowupDocuments( + followup=GetFollowup.model_validate({"id": 42, "is_private": False}), + documents=[GetDocumentItem.model_validate(_row())], + ) + assert entry.followup.is_private is False + assert entry.documents[0].documents_id == 11 + with pytest.raises(ValidationError): + entry.documents = [] diff --git a/pyproject.toml b/pyproject.toml index b07267c..802d8a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ exclude = [ [project] name = "glpi-python-client" -version = "0.6.3" +version = "0.6.4" description = "A typed Python client for GLPI ITSM APIs." readme = "README.md" requires-python = ">=3.11" diff --git a/skills/glpi-asset-workflow/SKILL.md b/skills/glpi-asset-workflow/SKILL.md index 1cee0bf..883a92d 100644 --- a/skills/glpi-asset-workflow/SKILL.md +++ b/skills/glpi-asset-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write assets." metadata: package: glpi-python-client - version: "0.6.3" + version: "0.6.4" --- # GLPI Asset Workflow diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md index 6be697c..1de40c9 100644 --- a/skills/glpi-client-setup/SKILL.md +++ b/skills/glpi-client-setup/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to a GLPI v2 API, and valid GLPI credentials." metadata: package: glpi-python-client - version: "0.6.3" + version: "0.6.4" --- # GLPI Client Setup diff --git a/skills/glpi-contract-workflow/SKILL.md b/skills/glpi-contract-workflow/SKILL.md index f7fcf38..46ae603 100644 --- a/skills/glpi-contract-workflow/SKILL.md +++ b/skills/glpi-contract-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write contracts." metadata: package: glpi-python-client - version: "0.6.3" + version: "0.6.4" --- # GLPI Contract Workflow diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index 66a2f02..d73761b 100644 --- a/skills/glpi-document-workflow/SKILL.md +++ b/skills/glpi-document-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and v1 credentials configured on the client for binary uploads." metadata: package: glpi-python-client - version: "0.6.3" + version: "0.6.4" --- # GLPI Document Workflow @@ -70,4 +70,5 @@ document_id = await client.create_document(PostDocument(name="Diagnostic notes") - `download_document_content` returns `bytes` and raises on non-200 responses. It holds the entire file in memory, so a 500 MB attachment costs 500 MB of process memory even when the caller only writes it straight to disk -- prefer `stream_document_content(document_id, chunk_size=65536)`, an async generator on `AsyncGlpiClient` (`async for`) and a plain generator on `GlpiClient` (`for`), which yields the body in chunks and raises the same `GlpiStatusError` on a non-200. Uploads still buffer. - `mime_type` defaults to `application/octet-stream` when omitted on `upload_document`. - The snippets above use `AsyncGlpiClient`, so every call is awaited. The same methods on the synchronous `GlpiClient` are plain blocking calls -- drop the `await`. -- The `delete_document(force=True)` flag permanently deletes; omit (or `False`) to move to the trash. \ No newline at end of file +- The `delete_document(force=True)` flag permanently deletes; omit (or `False`) to move to the trash. +- `list_item_documents(itemtype, items_id)` lists the `GetDocumentItem` link rows of one item over the v1 session, and raises `RuntimeError` without one. It returns links, so resolve each `documents_id` with `get_document`. GLPI's editor links an image pasted into a followup to the followup, not the ticket, so walk the ticket's followups with `list_item_documents("ITILFollowup", followup_id)`. The primitive does not filter private followups. `list_ticket_followup_documents(ticket_id, *, include_private=False)` does that walk for you: it returns one `FollowupDocuments` (the followup and its links) per followup in id order, and by default drops private and flag-less followups without querying them. Only the followup route is measured live, and an answer of 1000 rows or more raises `GlpiValidationError` rather than returning a partial list. diff --git a/skills/glpi-knowledge-base/SKILL.md b/skills/glpi-knowledge-base/SKILL.md index 6085d94..2c78d7e 100644 --- a/skills/glpi-knowledge-base/SKILL.md +++ b/skills/glpi-knowledge-base/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and — for category writes only — a legacy v1 session (v1_base_url + v1_user_token)." metadata: package: glpi-python-client - version: "0.6.3" + version: "0.6.4" --- # GLPI Knowledge Base diff --git a/skills/glpi-plugin-fields/SKILL.md b/skills/glpi-plugin-fields/SKILL.md index 2a4b739..56a1532 100644 --- a/skills/glpi-plugin-fields/SKILL.md +++ b/skills/glpi-plugin-fields/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, the GLPI Fields plugin installed server-side, and a legacy v1 session (v1_base_url + v1_user_token) — every method in this family goes over the v1 API." metadata: package: glpi-python-client - version: "0.6.3" + version: "0.6.4" --- # GLPI Plugin Fields diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md index 23f9547..1eb2fe7 100644 --- a/skills/glpi-reporting-and-context/SKILL.md +++ b/skills/glpi-reporting-and-context/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read tickets, tasks, users, entities, and timeline records." metadata: package: glpi-python-client - version: "0.6.3" + version: "0.6.4" --- # GLPI Reporting And Context diff --git a/skills/glpi-team-members/SKILL.md b/skills/glpi-team-members/SKILL.md index 25bd868..9fb9513 100644 --- a/skills/glpi-team-members/SKILL.md +++ b/skills/glpi-team-members/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to manage ticket teams." metadata: package: glpi-python-client - version: "0.6.3" + version: "0.6.4" --- # GLPI Team Members diff --git a/skills/glpi-ticket-timeline/SKILL.md b/skills/glpi-ticket-timeline/SKILL.md index bdc0775..f0aa959 100644 --- a/skills/glpi-ticket-timeline/SKILL.md +++ b/skills/glpi-ticket-timeline/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, and network access to the GLPI v2 API." metadata: package: glpi-python-client - version: "0.6.3" + version: "0.6.4" --- # GLPI Ticket Timeline @@ -17,6 +17,7 @@ The ticket timeline is exposed by four resource families under `/Assistance/Tick 1. Create a `GlpiClient` from the `glpi-client-setup` skill. 2. Read collections with `list_ticket_followups`, `list_ticket_tasks`, `list_ticket_solutions`, and `list_ticket_timeline_documents`. + An image pasted into a followup is linked to the followup, not the ticket, so `list_ticket_timeline_documents` omits it; read those with `list_ticket_followup_documents` (needs the v1 session). 3. Read individual records with `get_ticket_followup`, `get_ticket_task`, `get_ticket_solution`, `get_ticket_timeline_document`. 4. Create entries with `create_ticket_followup(ticket_id, followup: PostFollowup)`, `create_ticket_task(ticket_id, task: PostTicketTask)` or `create_ticket_solution(ticket_id, solution: PostSolution)`. Each returns the new identifier as `int`. `link_ticket_timeline_document(ticket_id, document_link: PostTimelineDocument)` also returns an `int`, but it cannot be told *which* existing document to link -- see the note under "Attach a document" and prefer `upload_document(..., ticket_id=...)`. 5. Update entries with, exactly: diff --git a/skills/glpi-ticket-workflow/SKILL.md b/skills/glpi-ticket-workflow/SKILL.md index aed54c0..219bd52 100644 --- a/skills/glpi-ticket-workflow/SKILL.md +++ b/skills/glpi-ticket-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials accepted by GlpiClient." metadata: package: glpi-python-client - version: "0.6.3" + version: "0.6.4" --- # GLPI Ticket Workflow diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md index dc78226..0e7d941 100644 --- a/skills/glpi-user-location-provisioning/SKILL.md +++ b/skills/glpi-user-location-provisioning/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.11+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write users, locations, and entities." metadata: package: glpi-python-client - version: "0.6.3" + version: "0.6.4" --- # GLPI User, Location, And Entity Provisioning