Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/api_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
40 changes: 39 additions & 1 deletion docs/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
~~~~~~

Expand Down
6 changes: 5 additions & 1 deletion glpi_python_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@
DeleteTicketTask,
DeleteTimelineDocument,
DeleteUser,
FollowupDocuments,
GetComputer,
GetContract,
GetContractCost,
GetContractItem,
GetContractType,
GetDocument,
GetDocumentItem,
GetEntity,
GetFollowup,
GetKBArticle,
Expand Down Expand Up @@ -131,7 +133,7 @@
date_window,
)

__version__ = "0.6.3"
__version__ = "0.6.4"

__all__ = [
"AsyncGlpiClient",
Expand All @@ -153,12 +155,14 @@
"DeleteTicketTask",
"DeleteTimelineDocument",
"DeleteUser",
"FollowupDocuments",
"GetComputer",
"GetContract",
"GetContractCost",
"GetContractItem",
"GetContractType",
"GetDocument",
"GetDocumentItem",
"GetEntity",
"GetFollowup",
"GetKBArticle",
Expand Down
185 changes: 185 additions & 0 deletions glpi_python_client/_async/clients/api/assistance/timeline/_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading