From 0807d2e35913e7a0c16338bfbb21c0c0e6bc31b9 Mon Sep 17 00:00:00 2001 From: baraline Date: Mon, 12 Oct 2026 11:36:38 +0200 Subject: [PATCH 1/5] feat(models): GetDocumentItem for v1 Document_Item link rows, and NO_TIMELINE for position -1 Co-Authored-By: Claude Opus 5.5 --- docs/api_reference.rst | 5 + docs/user_guide.rst | 2 +- glpi_python_client/__init__.py | 2 + glpi_python_client/models/__init__.py | 2 + glpi_python_client/models/api_schema/enums.py | 5 + .../models/api_schema/management/__init__.py | 4 + .../api_schema/management/_document_item.py | 61 +++++++++ .../management/tests/test_document_item.py | 120 ++++++++++++++++++ 8 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 glpi_python_client/models/api_schema/management/_document_item.py create mode 100644 glpi_python_client/models/api_schema/management/tests/test_document_item.py diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 2716eb5..7edf1f3 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -315,6 +315,11 @@ Documents :undoc-members: :show-inheritance: +.. autoclass:: GetDocumentItem + :members: + :undoc-members: + :show-inheritance: + .. autoclass:: PostDocument :members: :undoc-members: diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 600ac92..a1e07b9 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -379,7 +379,7 @@ The full set is re-exported from the package root, including ``GetLocation`` / ``PostLocation`` / ``PatchLocation`` / ``DeleteLocation``, ``GetEntity`` / ``PostEntity`` / ``PatchEntity`` / ``DeleteEntity``, ``GetFollowup``, ``GetTicketTask``, ``GetSolution``, ``GetTimelineDocument``, -``GetTeamMember``, and ``GetDocument`` together with their post / patch / +``GetTeamMember``, ``GetDocumentItem``, and ``GetDocument`` together with their post / patch / delete variants. All models inherit from a permissive base: the GLPI server is the diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index 0f3a045..640fa62 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -56,6 +56,7 @@ GetContractItem, GetContractType, GetDocument, + GetDocumentItem, GetEntity, GetFollowup, GetKBArticle, @@ -159,6 +160,7 @@ "GetContractItem", "GetContractType", "GetDocument", + "GetDocumentItem", "GetEntity", "GetFollowup", "GetKBArticle", diff --git a/glpi_python_client/models/__init__.py b/glpi_python_client/models/__init__.py index 93581c5..fdc742c 100644 --- a/glpi_python_client/models/__init__.py +++ b/glpi_python_client/models/__init__.py @@ -112,6 +112,7 @@ GetContract, GetContractCost, GetDocument, + GetDocumentItem, PatchContract, PatchContractCost, PatchDocument, @@ -155,6 +156,7 @@ "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..f50ec58 100644 --- a/glpi_python_client/models/api_schema/management/__init__.py +++ b/glpi_python_client/models/api_schema/management/__init__.py @@ -18,6 +18,9 @@ PatchDocument, PostDocument, ) +from glpi_python_client.models.api_schema.management._document_item import ( + GetDocumentItem, +) __all__ = [ "DeleteContract", @@ -26,6 +29,7 @@ "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..a2ae8f5 --- /dev/null +++ b/glpi_python_client/models/api_schema/management/_document_item.py @@ -0,0 +1,61 @@ +"""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 glpi_python_client.models._base import GlpiModel +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 + + +__all__ = ["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..66a5415 --- /dev/null +++ b/glpi_python_client/models/api_schema/management/tests/test_document_item.py @@ -0,0 +1,120 @@ +"""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.enums import GlpiTimelinePosition +from glpi_python_client.models.api_schema.management import 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 + + +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 From 6800d22cf6f469c623e3d3583884d159b4f118d9 Mon Sep 17 00:00:00 2001 From: baraline Date: Mon, 12 Oct 2026 11:46:37 +0200 Subject: [PATCH 2/5] feat(timeline): list_item_documents lists an item's v1 Document_Item links Reads //Document_Item over the legacy v1 session and returns GetDocumentItem rows. The itemtype and id are validated before any call, the range 0-999 is sent, an empty body reads as no rows, any other non-list answer raises, and a full page of 1000 rows or more raises rather than returning a possibly truncated list. The followup route is the one measured live. Co-Authored-By: Claude Opus 5.5 --- .../api/assistance/timeline/_document.py | 98 +++++++++++ .../timeline/tests/test_document.py | 162 +++++++++++++++++- .../api/assistance/timeline/_document.py | 98 +++++++++++ .../timeline/tests/test_document.py | 162 +++++++++++++++++- skills/glpi-document-workflow/SKILL.md | 3 +- 5 files changed, 520 insertions(+), 3 deletions(-) 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..9ae3f6c 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,30 @@ from __future__ import annotations +import re + 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.management._document import GetDocument +from glpi_python_client.models.api_schema.management._document_item import ( + 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" class TimelineDocumentMixin(TransportMixin): @@ -62,6 +74,92 @@ 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 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..b41c366 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,18 @@ 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 ( + GetDocumentItem, + GlpiStatusError, + GlpiValidationError, + PatchTimelineDocument, + PostTimelineDocument, +) from glpi_python_client._async._testing import ( FailingTransportRecorder, TransportRecorder, @@ -108,3 +115,156 @@ 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) 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..faa31e0 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,30 @@ from __future__ import annotations +import re + 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.management._document import GetDocument +from glpi_python_client.models.api_schema.management._document_item import ( + 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" class TimelineDocumentMixin(TransportMixin): @@ -62,6 +74,92 @@ 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 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..2408956 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,18 @@ 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 ( + GetDocumentItem, + GlpiStatusError, + GlpiValidationError, + PatchTimelineDocument, + PostTimelineDocument, +) from glpi_python_client._sync._testing import ( FailingTransportRecorder, TransportRecorder, @@ -108,3 +115,156 @@ 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) diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index 66a2f02..7b69677 100644 --- a/skills/glpi-document-workflow/SKILL.md +++ b/skills/glpi-document-workflow/SKILL.md @@ -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 library does not filter private followups. Only the followup route is measured live, and an answer of 1000 rows or more raises `GlpiValidationError` rather than returning a partial list. From cd8b0972d69ce0e0a2a1a913b7c8230edd768dc9 Mon Sep 17 00:00:00 2001 From: baraline Date: Mon, 12 Oct 2026 11:56:54 +0200 Subject: [PATCH 3/5] feat(timeline): list_ticket_followup_documents walks a ticket's followups for their document links Lists each followup's Document_Item links through list_item_documents and returns one FollowupDocuments (the followup and its links) per kept followup, in ascending followup id. By default private and flag-less followups are dropped and never queried; include_private=True keeps them, so a caller can classify every link from followup.is_private. Link rows that do not name the followup are dropped, a followup without an id raises, and a failed read propagates rather than returning a partial list. Co-Authored-By: Claude Opus 5.5 --- docs/api_reference.rst | 5 + docs/user_guide.rst | 2 +- glpi_python_client/__init__.py | 2 + .../api/assistance/timeline/_document.py | 79 ++++++++ .../timeline/tests/test_document.py | 171 ++++++++++++++++++ .../api/assistance/timeline/_document.py | 79 ++++++++ .../timeline/tests/test_document.py | 171 ++++++++++++++++++ glpi_python_client/models/__init__.py | 2 + .../models/api_schema/management/__init__.py | 2 + .../api_schema/management/_document_item.py | 29 ++- .../management/tests/test_document_item.py | 19 +- skills/glpi-document-workflow/SKILL.md | 2 +- 12 files changed, 559 insertions(+), 4 deletions(-) diff --git a/docs/api_reference.rst b/docs/api_reference.rst index 7edf1f3..9fe7a36 100644 --- a/docs/api_reference.rst +++ b/docs/api_reference.rst @@ -320,6 +320,11 @@ Documents :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 a1e07b9..a7cffe7 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -379,7 +379,7 @@ The full set is re-exported from the package root, including ``GetLocation`` / ``PostLocation`` / ``PatchLocation`` / ``DeleteLocation``, ``GetEntity`` / ``PostEntity`` / ``PatchEntity`` / ``DeleteEntity``, ``GetFollowup``, ``GetTicketTask``, ``GetSolution``, ``GetTimelineDocument``, -``GetTeamMember``, ``GetDocumentItem``, and ``GetDocument`` together with their post / patch / +``GetTeamMember``, ``GetDocumentItem``, ``FollowupDocuments``, and ``GetDocument`` together with their post / patch / delete variants. All models inherit from a permissive base: the GLPI server is the diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index 640fa62..524f183 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -50,6 +50,7 @@ DeleteTicketTask, DeleteTimelineDocument, DeleteUser, + FollowupDocuments, GetComputer, GetContract, GetContractCost, @@ -154,6 +155,7 @@ "DeleteTicketTask", "DeleteTimelineDocument", "DeleteUser", + "FollowupDocuments", "GetComputer", "GetContract", "GetContractCost", 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 9ae3f6c..93f8492 100644 --- a/glpi_python_client/_async/clients/api/assistance/timeline/_document.py +++ b/glpi_python_client/_async/clients/api/assistance/timeline/_document.py @@ -19,6 +19,7 @@ import re +from glpi_python_client._async._concurrency import gather from glpi_python_client._async.clients.commons._constants import ( TICKET_ENDPOINT, TIMELINE_DOCUMENT_SUFFIX, @@ -34,6 +35,7 @@ ) from glpi_python_client.models.api_schema.management._document import GetDocument from glpi_python_client.models.api_schema.management._document_item import ( + FollowupDocuments, GetDocumentItem, ) @@ -41,6 +43,7 @@ _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): @@ -160,6 +163,82 @@ async def list_item_documents( 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``. + + 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 + ] + if any(followup.id is None for followup in kept): + raise GlpiValidationError( + f"A followup of ticket {ticket_id} has no id, " + "so its document links cannot be read" + ) + kept.sort(key=lambda followup: followup.id) + link_lists = await gather( + *( + self.list_item_documents(_FOLLOWUP_ITEMTYPE, followup.id) + for followup in kept + ) + ) + return [ + FollowupDocuments( + followup=followup, + documents=[ + row + for row in rows + if row.itemtype == _FOLLOWUP_ITEMTYPE + and row.items_id == followup.id + ], + ) + for followup, rows in zip(kept, 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 b41c366..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 @@ -14,6 +14,7 @@ import pytest from glpi_python_client import ( + FollowupDocuments, GetDocumentItem, GlpiStatusError, GlpiValidationError, @@ -268,3 +269,173 @@ async def test_list_item_documents_refuses_a_full_page(client: Any) -> None: 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 faa31e0..cfdd551 100644 --- a/glpi_python_client/_sync/clients/api/assistance/timeline/_document.py +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/_document.py @@ -19,6 +19,7 @@ import re +from glpi_python_client._sync._concurrency import gather from glpi_python_client._sync.clients.commons._constants import ( TICKET_ENDPOINT, TIMELINE_DOCUMENT_SUFFIX, @@ -34,6 +35,7 @@ ) from glpi_python_client.models.api_schema.management._document import GetDocument from glpi_python_client.models.api_schema.management._document_item import ( + FollowupDocuments, GetDocumentItem, ) @@ -41,6 +43,7 @@ _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): @@ -160,6 +163,82 @@ def list_item_documents( 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``. + + 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 + ] + if any(followup.id is None for followup in kept): + raise GlpiValidationError( + f"A followup of ticket {ticket_id} has no id, " + "so its document links cannot be read" + ) + kept.sort(key=lambda followup: followup.id) + link_lists = gather( + *( + self.list_item_documents(_FOLLOWUP_ITEMTYPE, followup.id) + for followup in kept + ) + ) + return [ + FollowupDocuments( + followup=followup, + documents=[ + row + for row in rows + if row.itemtype == _FOLLOWUP_ITEMTYPE + and row.items_id == followup.id + ], + ) + for followup, rows in zip(kept, 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 2408956..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 @@ -14,6 +14,7 @@ import pytest from glpi_python_client import ( + FollowupDocuments, GetDocumentItem, GlpiStatusError, GlpiValidationError, @@ -268,3 +269,173 @@ def test_list_item_documents_refuses_a_full_page(client: Any) -> None: 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 fdc742c..ddf4cf3 100644 --- a/glpi_python_client/models/__init__.py +++ b/glpi_python_client/models/__init__.py @@ -109,6 +109,7 @@ DeleteContract, DeleteContractCost, DeleteDocument, + FollowupDocuments, GetContract, GetContractCost, GetDocument, @@ -150,6 +151,7 @@ "DeleteTicketTask", "DeleteTimelineDocument", "DeleteUser", + "FollowupDocuments", "GetComputer", "GetContract", "GetContractCost", diff --git a/glpi_python_client/models/api_schema/management/__init__.py b/glpi_python_client/models/api_schema/management/__init__.py index f50ec58..deefad9 100644 --- a/glpi_python_client/models/api_schema/management/__init__.py +++ b/glpi_python_client/models/api_schema/management/__init__.py @@ -19,6 +19,7 @@ PostDocument, ) from glpi_python_client.models.api_schema.management._document_item import ( + FollowupDocuments, GetDocumentItem, ) @@ -26,6 +27,7 @@ "DeleteContract", "DeleteContractCost", "DeleteDocument", + "FollowupDocuments", "GetContract", "GetContractCost", "GetDocument", diff --git a/glpi_python_client/models/api_schema/management/_document_item.py b/glpi_python_client/models/api_schema/management/_document_item.py index a2ae8f5..df1f171 100644 --- a/glpi_python_client/models/api_schema/management/_document_item.py +++ b/glpi_python_client/models/api_schema/management/_document_item.py @@ -10,7 +10,12 @@ 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 @@ -58,4 +63,26 @@ class GetDocumentItem(GlpiModel): timeline_position: GlpiTimelinePosition | None = None -__all__ = ["GetDocumentItem"] +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 index 66a5415..e152dcf 100644 --- 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 @@ -7,8 +7,12 @@ 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 GetDocumentItem +from glpi_python_client.models.api_schema.management import ( + FollowupDocuments, + GetDocumentItem, +) _PARIS_SUMMER = timezone(timedelta(hours=2)) @@ -118,3 +122,16 @@ def test_naive_datetimes_are_localised_to_the_server_timezone() -> 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/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index 7b69677..cda62cb 100644 --- a/skills/glpi-document-workflow/SKILL.md +++ b/skills/glpi-document-workflow/SKILL.md @@ -71,4 +71,4 @@ document_id = await client.create_document(PostDocument(name="Diagnostic notes") - `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. -- `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 library does not filter private followups. Only the followup route is measured live, and an answer of 1000 rows or more raises `GlpiValidationError` rather than returning a partial list. +- `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. From 8543f09826f13781668695c112a944b1a4435a02 Mon Sep 17 00:00:00 2001 From: baraline Date: Mon, 12 Oct 2026 12:01:17 +0200 Subject: [PATCH 4/5] feat(timeline): document 0.6.4, which reads the documents linked to a followup (0.6.4) Bumps the version in pyproject.toml, the package and every skill, adds the CHANGELOG entry, and describes list_item_documents, list_ticket_followup_documents and NO_TIMELINE in the user guide. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 29 +++++++++++++++ docs/user_guide.rst | 37 +++++++++++++++++++ glpi_python_client/__init__.py | 2 +- pyproject.toml | 2 +- skills/glpi-asset-workflow/SKILL.md | 2 +- skills/glpi-client-setup/SKILL.md | 2 +- skills/glpi-contract-workflow/SKILL.md | 2 +- skills/glpi-document-workflow/SKILL.md | 2 +- skills/glpi-knowledge-base/SKILL.md | 2 +- skills/glpi-plugin-fields/SKILL.md | 2 +- skills/glpi-reporting-and-context/SKILL.md | 2 +- skills/glpi-team-members/SKILL.md | 2 +- skills/glpi-ticket-timeline/SKILL.md | 2 +- skills/glpi-ticket-workflow/SKILL.md | 2 +- .../glpi-user-location-provisioning/SKILL.md | 2 +- 15 files changed, 79 insertions(+), 13 deletions(-) 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/user_guide.rst b/docs/user_guide.rst index a7cffe7..6f61d3b 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -580,6 +580,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 524f183..7b8a525 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -133,7 +133,7 @@ date_window, ) -__version__ = "0.6.3" +__version__ = "0.6.4" __all__ = [ "AsyncGlpiClient", 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 cda62cb..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 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..5e57459 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 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 From 6ca37f8ed5558f45f47e7549aad2e16e645b7697 Mon Sep 17 00:00:00 2001 From: baraline Date: Mon, 12 Oct 2026 12:11:01 +0200 Subject: [PATCH 5/5] fix(timeline): narrow followup ids for real, document the fan-out, and reflow the model list list_ticket_followup_documents builds (id, followup) pairs with an explicit None check, so mypy narrows the id instead of passing through Any. Its docstring states one v1 request per kept followup. The user guide lists GetDocumentItem and FollowupDocuments apart from the models with post / patch / delete variants, the timeline skill points to the new method, and a test pins that GlpiTimelinePosition(-1) still names INVALID. Co-Authored-By: Claude Opus 5.5 --- docs/user_guide.rst | 5 ++-- .../api/assistance/timeline/_document.py | 30 ++++++++++++------- .../api/assistance/timeline/_document.py | 30 ++++++++++++------- .../management/tests/test_document_item.py | 1 + skills/glpi-ticket-timeline/SKILL.md | 1 + 5 files changed, 43 insertions(+), 24 deletions(-) diff --git a/docs/user_guide.rst b/docs/user_guide.rst index 6f61d3b..f27c045 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -379,8 +379,9 @@ The full set is re-exported from the package root, including ``GetLocation`` / ``PostLocation`` / ``PatchLocation`` / ``DeleteLocation``, ``GetEntity`` / ``PostEntity`` / ``PatchEntity`` / ``DeleteEntity``, ``GetFollowup``, ``GetTicketTask``, ``GetSolution``, ``GetTimelineDocument``, -``GetTeamMember``, ``GetDocumentItem``, ``FollowupDocuments``, and ``GetDocument`` together with their post / patch / -delete variants. +``GetTeamMember``, and ``GetDocument`` together with their post / patch / +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 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 93f8492..2f3aa81 100644 --- a/glpi_python_client/_async/clients/api/assistance/timeline/_document.py +++ b/glpi_python_client/_async/clients/api/assistance/timeline/_document.py @@ -33,6 +33,9 @@ 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, @@ -177,7 +180,9 @@ async def list_ticket_followup_documents( 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``. + ``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 ---------- @@ -214,16 +219,19 @@ async def list_ticket_followup_documents( for followup in followups if include_private or followup.is_private is False ] - if any(followup.id is None for followup in kept): - raise GlpiValidationError( - f"A followup of ticket {ticket_id} has no id, " - "so its document links cannot be read" - ) - kept.sort(key=lambda followup: followup.id) + 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 in kept + self.list_item_documents(_FOLLOWUP_ITEMTYPE, followup_id) + for followup_id, _ in keyed ) ) return [ @@ -233,10 +241,10 @@ async def list_ticket_followup_documents( row for row in rows if row.itemtype == _FOLLOWUP_ITEMTYPE - and row.items_id == followup.id + and row.items_id == followup_id ], ) - for followup, rows in zip(kept, link_lists, strict=True) + for (followup_id, followup), rows in zip(keyed, link_lists, strict=True) ] async def get_ticket_timeline_document( 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 cfdd551..b8d3549 100644 --- a/glpi_python_client/_sync/clients/api/assistance/timeline/_document.py +++ b/glpi_python_client/_sync/clients/api/assistance/timeline/_document.py @@ -33,6 +33,9 @@ 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, @@ -177,7 +180,9 @@ def list_ticket_followup_documents( 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``. + ``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 ---------- @@ -214,16 +219,19 @@ def list_ticket_followup_documents( for followup in followups if include_private or followup.is_private is False ] - if any(followup.id is None for followup in kept): - raise GlpiValidationError( - f"A followup of ticket {ticket_id} has no id, " - "so its document links cannot be read" - ) - kept.sort(key=lambda followup: followup.id) + 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 in kept + self.list_item_documents(_FOLLOWUP_ITEMTYPE, followup_id) + for followup_id, _ in keyed ) ) return [ @@ -233,10 +241,10 @@ def list_ticket_followup_documents( row for row in rows if row.itemtype == _FOLLOWUP_ITEMTYPE - and row.items_id == followup.id + and row.items_id == followup_id ], ) - for followup, rows in zip(kept, link_lists, strict=True) + for (followup_id, followup), rows in zip(keyed, link_lists, strict=True) ] def get_ticket_timeline_document( 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 index e152dcf..82526a6 100644 --- 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 @@ -84,6 +84,7 @@ def test_no_timeline_is_an_alias_of_invalid() -> None: 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: diff --git a/skills/glpi-ticket-timeline/SKILL.md b/skills/glpi-ticket-timeline/SKILL.md index 5e57459..f0aa959 100644 --- a/skills/glpi-ticket-timeline/SKILL.md +++ b/skills/glpi-ticket-timeline/SKILL.md @@ -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: