glpi-python-client 0.6.3__py3-none-any.whl → 0.6.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -50,12 +50,14 @@ from glpi_python_client.models import (
50
50
  DeleteTicketTask,
51
51
  DeleteTimelineDocument,
52
52
  DeleteUser,
53
+ FollowupDocuments,
53
54
  GetComputer,
54
55
  GetContract,
55
56
  GetContractCost,
56
57
  GetContractItem,
57
58
  GetContractType,
58
59
  GetDocument,
60
+ GetDocumentItem,
59
61
  GetEntity,
60
62
  GetFollowup,
61
63
  GetKBArticle,
@@ -131,7 +133,7 @@ from glpi_python_client.rsql import (
131
133
  date_window,
132
134
  )
133
135
 
134
- __version__ = "0.6.3"
136
+ __version__ = "0.6.5"
135
137
 
136
138
  __all__ = [
137
139
  "AsyncGlpiClient",
@@ -153,12 +155,14 @@ __all__ = [
153
155
  "DeleteTicketTask",
154
156
  "DeleteTimelineDocument",
155
157
  "DeleteUser",
158
+ "FollowupDocuments",
156
159
  "GetComputer",
157
160
  "GetContract",
158
161
  "GetContractCost",
159
162
  "GetContractItem",
160
163
  "GetContractType",
161
164
  "GetDocument",
165
+ "GetDocumentItem",
162
166
  "GetEntity",
163
167
  "GetFollowup",
164
168
  "GetKBArticle",
@@ -17,18 +17,36 @@ as :class:`GetDocument`.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import re
21
+
22
+ from glpi_python_client._async._concurrency import gather
20
23
  from glpi_python_client._async.clients.commons._constants import (
21
24
  TICKET_ENDPOINT,
22
25
  TIMELINE_DOCUMENT_SUFFIX,
23
26
  GlpiId,
24
27
  )
28
+ from glpi_python_client._async.clients.commons._payloads import model_from_payload
25
29
  from glpi_python_client._async.clients.commons._transport import TransportMixin
30
+ from glpi_python_client._errors import GlpiValidationError
26
31
  from glpi_python_client.models.api_schema.assistance.timeline._document import (
27
32
  DeleteTimelineDocument,
28
33
  PatchTimelineDocument,
29
34
  PostTimelineDocument,
30
35
  )
36
+ from glpi_python_client.models.api_schema.assistance.timeline._followup import (
37
+ GetFollowup,
38
+ )
31
39
  from glpi_python_client.models.api_schema.management._document import GetDocument
40
+ from glpi_python_client.models.api_schema.management._document_item import (
41
+ FollowupDocuments,
42
+ GetDocumentItem,
43
+ )
44
+
45
+ _ITEM_DOCUMENT_RANGE = "0-999"
46
+ _ITEM_DOCUMENT_PAGE_SIZE = 1000
47
+ _ITEMTYPE_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
48
+ _V1_FEATURE_LABEL = "item document link listing"
49
+ _FOLLOWUP_ITEMTYPE = "ITILFollowup"
32
50
 
33
51
 
34
52
  class TimelineDocumentMixin(TransportMixin):
@@ -62,6 +80,173 @@ class TimelineDocumentMixin(TransportMixin):
62
80
  unwrap_envelope=True,
63
81
  )
64
82
 
83
+ async def list_item_documents(
84
+ self, itemtype: str, items_id: GlpiId
85
+ ) -> list[GetDocumentItem]:
86
+ """List the document links of one item, over the legacy v1 API.
87
+
88
+ Only the followup route (``itemtype="ITILFollowup"``) has been
89
+ checked against a live server. Other itemtypes are sent to the same
90
+ v1 sub-route unchanged.
91
+
92
+ Unlike :meth:`list_ticket_timeline_documents`, this call needs the
93
+ legacy v1 session (``v1_base_url`` and ``v1_user_token``). Without
94
+ one it raises ``RuntimeError``.
95
+
96
+ It returns link rows, not documents. A caller resolves each
97
+ ``documents_id`` with ``get_document``.
98
+
99
+ The library does not filter private followups, tasks or solutions
100
+ for the caller: every link the authenticated user can read is
101
+ returned.
102
+
103
+ GLPI's editor links an image pasted into a followup to the followup,
104
+ not to the ticket. A caller that wants such images therefore walks
105
+ the ticket's followups and lists each one.
106
+
107
+ Parameters
108
+ ----------
109
+ itemtype : str
110
+ GLPI itemtype owning the links, for example ``"ITILFollowup"``.
111
+ It must start with a letter and contain only letters, digits and
112
+ underscores, because it is placed in the URL path.
113
+ items_id : GlpiId
114
+ Positive numeric identifier of the item.
115
+
116
+ Returns
117
+ -------
118
+ list[GetDocumentItem]
119
+ Link rows, with naive timestamps localised to the server
120
+ timezone. An empty answer gives an empty list.
121
+
122
+ Raises
123
+ ------
124
+ RuntimeError
125
+ If the client was built without a v1 session.
126
+ GlpiValidationError
127
+ If ``itemtype`` or ``items_id`` is invalid, if GLPI answers
128
+ something other than a list or an empty body, or if it answers a
129
+ full page of 1000 rows or more, which may be truncated. A
130
+ partial list is never returned.
131
+ GlpiStatusError
132
+ If the GLPI server returns a non-success HTTP status.
133
+ """
134
+
135
+ v1 = self._require_v1_session(_V1_FEATURE_LABEL)
136
+ if not _ITEMTYPE_PATTERN.fullmatch(itemtype):
137
+ raise GlpiValidationError(
138
+ f"itemtype must match {_ITEMTYPE_PATTERN.pattern!r}, got {itemtype!r}"
139
+ )
140
+ if items_id <= 0:
141
+ raise GlpiValidationError(
142
+ f"items_id must be a positive integer, got {items_id!r}"
143
+ )
144
+ payload = await v1.request_json(
145
+ "GET",
146
+ f"{itemtype}/{items_id}/Document_Item",
147
+ params={"range": _ITEM_DOCUMENT_RANGE},
148
+ failure_message=(f"Failed to list document links of {itemtype} {items_id}"),
149
+ )
150
+ if isinstance(payload, dict) and not payload:
151
+ return []
152
+ if not isinstance(payload, list):
153
+ raise GlpiValidationError(
154
+ f"Unexpected document link answer for {itemtype} {items_id}: "
155
+ f"{type(payload).__name__}"
156
+ )
157
+ if len(payload) >= _ITEM_DOCUMENT_PAGE_SIZE:
158
+ raise GlpiValidationError(
159
+ f"{itemtype} {items_id} returned {len(payload)} document links, "
160
+ "a full page that may be truncated"
161
+ )
162
+ return [
163
+ model_from_payload(
164
+ GetDocumentItem, row, server_timezone=self.server_timezone
165
+ )
166
+ for row in payload
167
+ ]
168
+
169
+ async def list_ticket_followup_documents(
170
+ self, ticket_id: GlpiId, *, include_private: bool = False
171
+ ) -> list[FollowupDocuments]:
172
+ """List the document links of each followup of one ticket.
173
+
174
+ GLPI's editor links an image pasted into a followup to the followup,
175
+ not to the ticket. :meth:`list_ticket_timeline_documents` therefore
176
+ never returns such an image, and this method does.
177
+
178
+ Each entry holds the followup and its links, so a caller reads
179
+ ``followup.is_private`` to classify every link without a second
180
+ read. Only followups are walked: tasks and solutions are not.
181
+
182
+ This call needs the legacy v1 session (``v1_base_url`` and
183
+ ``v1_user_token``). Without one it raises ``RuntimeError``. It issues
184
+ one v1 request per kept followup, all at once on the asynchronous
185
+ surface and one after the other on the synchronous one.
186
+
187
+ Parameters
188
+ ----------
189
+ ticket_id : GlpiId
190
+ Numeric identifier of the parent ticket.
191
+ include_private : bool, optional
192
+ When false (the default), private followups and followups whose
193
+ ``is_private`` flag is missing are dropped and not queried, so
194
+ only what a requester can see is returned. A missing flag counts
195
+ as private. When true, every followup is kept.
196
+
197
+ Returns
198
+ -------
199
+ list[FollowupDocuments]
200
+ One entry per kept followup, in ascending followup id, including
201
+ a followup with no link (an empty ``documents`` list). A link row
202
+ that does not name that followup as its item is dropped.
203
+
204
+ Raises
205
+ ------
206
+ RuntimeError
207
+ If the client was built without a v1 session.
208
+ GlpiValidationError
209
+ If a kept followup has no identifier, or if reading the links of
210
+ a followup fails validation. A partial list is never returned.
211
+ GlpiStatusError
212
+ If the GLPI server returns a non-success HTTP status.
213
+ """
214
+
215
+ self._require_v1_session(_V1_FEATURE_LABEL)
216
+ followups = await self.list_ticket_followups(ticket_id) # type: ignore[attr-defined]
217
+ kept = [
218
+ followup
219
+ for followup in followups
220
+ if include_private or followup.is_private is False
221
+ ]
222
+ keyed: list[tuple[int, GetFollowup]] = []
223
+ for followup in kept:
224
+ if followup.id is None:
225
+ raise GlpiValidationError(
226
+ f"A followup of ticket {ticket_id} has no id, "
227
+ "so its document links cannot be read"
228
+ )
229
+ keyed.append((followup.id, followup))
230
+ keyed.sort(key=lambda pair: pair[0])
231
+ link_lists = await gather(
232
+ *(
233
+ self.list_item_documents(_FOLLOWUP_ITEMTYPE, followup_id)
234
+ for followup_id, _ in keyed
235
+ )
236
+ )
237
+ return [
238
+ FollowupDocuments(
239
+ followup=followup,
240
+ documents=[
241
+ row
242
+ for row in rows
243
+ if row.itemtype == _FOLLOWUP_ITEMTYPE
244
+ and row.items_id == followup_id
245
+ ],
246
+ )
247
+ for (followup_id, followup), rows in zip(keyed, link_lists, strict=True)
248
+ ]
249
+
65
250
  async def get_ticket_timeline_document(
66
251
  self, ticket_id: GlpiId, document_link_id: GlpiId
67
252
  ) -> GetDocument:
@@ -17,18 +17,36 @@ as :class:`GetDocument`.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import re
21
+
22
+ from glpi_python_client._sync._concurrency import gather
20
23
  from glpi_python_client._sync.clients.commons._constants import (
21
24
  TICKET_ENDPOINT,
22
25
  TIMELINE_DOCUMENT_SUFFIX,
23
26
  GlpiId,
24
27
  )
28
+ from glpi_python_client._sync.clients.commons._payloads import model_from_payload
25
29
  from glpi_python_client._sync.clients.commons._transport import TransportMixin
30
+ from glpi_python_client._errors import GlpiValidationError
26
31
  from glpi_python_client.models.api_schema.assistance.timeline._document import (
27
32
  DeleteTimelineDocument,
28
33
  PatchTimelineDocument,
29
34
  PostTimelineDocument,
30
35
  )
36
+ from glpi_python_client.models.api_schema.assistance.timeline._followup import (
37
+ GetFollowup,
38
+ )
31
39
  from glpi_python_client.models.api_schema.management._document import GetDocument
40
+ from glpi_python_client.models.api_schema.management._document_item import (
41
+ FollowupDocuments,
42
+ GetDocumentItem,
43
+ )
44
+
45
+ _ITEM_DOCUMENT_RANGE = "0-999"
46
+ _ITEM_DOCUMENT_PAGE_SIZE = 1000
47
+ _ITEMTYPE_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
48
+ _V1_FEATURE_LABEL = "item document link listing"
49
+ _FOLLOWUP_ITEMTYPE = "ITILFollowup"
32
50
 
33
51
 
34
52
  class TimelineDocumentMixin(TransportMixin):
@@ -62,6 +80,173 @@ class TimelineDocumentMixin(TransportMixin):
62
80
  unwrap_envelope=True,
63
81
  )
64
82
 
83
+ def list_item_documents(
84
+ self, itemtype: str, items_id: GlpiId
85
+ ) -> list[GetDocumentItem]:
86
+ """List the document links of one item, over the legacy v1 API.
87
+
88
+ Only the followup route (``itemtype="ITILFollowup"``) has been
89
+ checked against a live server. Other itemtypes are sent to the same
90
+ v1 sub-route unchanged.
91
+
92
+ Unlike :meth:`list_ticket_timeline_documents`, this call needs the
93
+ legacy v1 session (``v1_base_url`` and ``v1_user_token``). Without
94
+ one it raises ``RuntimeError``.
95
+
96
+ It returns link rows, not documents. A caller resolves each
97
+ ``documents_id`` with ``get_document``.
98
+
99
+ The library does not filter private followups, tasks or solutions
100
+ for the caller: every link the authenticated user can read is
101
+ returned.
102
+
103
+ GLPI's editor links an image pasted into a followup to the followup,
104
+ not to the ticket. A caller that wants such images therefore walks
105
+ the ticket's followups and lists each one.
106
+
107
+ Parameters
108
+ ----------
109
+ itemtype : str
110
+ GLPI itemtype owning the links, for example ``"ITILFollowup"``.
111
+ It must start with a letter and contain only letters, digits and
112
+ underscores, because it is placed in the URL path.
113
+ items_id : GlpiId
114
+ Positive numeric identifier of the item.
115
+
116
+ Returns
117
+ -------
118
+ list[GetDocumentItem]
119
+ Link rows, with naive timestamps localised to the server
120
+ timezone. An empty answer gives an empty list.
121
+
122
+ Raises
123
+ ------
124
+ RuntimeError
125
+ If the client was built without a v1 session.
126
+ GlpiValidationError
127
+ If ``itemtype`` or ``items_id`` is invalid, if GLPI answers
128
+ something other than a list or an empty body, or if it answers a
129
+ full page of 1000 rows or more, which may be truncated. A
130
+ partial list is never returned.
131
+ GlpiStatusError
132
+ If the GLPI server returns a non-success HTTP status.
133
+ """
134
+
135
+ v1 = self._require_v1_session(_V1_FEATURE_LABEL)
136
+ if not _ITEMTYPE_PATTERN.fullmatch(itemtype):
137
+ raise GlpiValidationError(
138
+ f"itemtype must match {_ITEMTYPE_PATTERN.pattern!r}, got {itemtype!r}"
139
+ )
140
+ if items_id <= 0:
141
+ raise GlpiValidationError(
142
+ f"items_id must be a positive integer, got {items_id!r}"
143
+ )
144
+ payload = v1.request_json(
145
+ "GET",
146
+ f"{itemtype}/{items_id}/Document_Item",
147
+ params={"range": _ITEM_DOCUMENT_RANGE},
148
+ failure_message=(f"Failed to list document links of {itemtype} {items_id}"),
149
+ )
150
+ if isinstance(payload, dict) and not payload:
151
+ return []
152
+ if not isinstance(payload, list):
153
+ raise GlpiValidationError(
154
+ f"Unexpected document link answer for {itemtype} {items_id}: "
155
+ f"{type(payload).__name__}"
156
+ )
157
+ if len(payload) >= _ITEM_DOCUMENT_PAGE_SIZE:
158
+ raise GlpiValidationError(
159
+ f"{itemtype} {items_id} returned {len(payload)} document links, "
160
+ "a full page that may be truncated"
161
+ )
162
+ return [
163
+ model_from_payload(
164
+ GetDocumentItem, row, server_timezone=self.server_timezone
165
+ )
166
+ for row in payload
167
+ ]
168
+
169
+ def list_ticket_followup_documents(
170
+ self, ticket_id: GlpiId, *, include_private: bool = False
171
+ ) -> list[FollowupDocuments]:
172
+ """List the document links of each followup of one ticket.
173
+
174
+ GLPI's editor links an image pasted into a followup to the followup,
175
+ not to the ticket. :meth:`list_ticket_timeline_documents` therefore
176
+ never returns such an image, and this method does.
177
+
178
+ Each entry holds the followup and its links, so a caller reads
179
+ ``followup.is_private`` to classify every link without a second
180
+ read. Only followups are walked: tasks and solutions are not.
181
+
182
+ This call needs the legacy v1 session (``v1_base_url`` and
183
+ ``v1_user_token``). Without one it raises ``RuntimeError``. It issues
184
+ one v1 request per kept followup, all at once on the asynchronous
185
+ surface and one after the other on the synchronous one.
186
+
187
+ Parameters
188
+ ----------
189
+ ticket_id : GlpiId
190
+ Numeric identifier of the parent ticket.
191
+ include_private : bool, optional
192
+ When false (the default), private followups and followups whose
193
+ ``is_private`` flag is missing are dropped and not queried, so
194
+ only what a requester can see is returned. A missing flag counts
195
+ as private. When true, every followup is kept.
196
+
197
+ Returns
198
+ -------
199
+ list[FollowupDocuments]
200
+ One entry per kept followup, in ascending followup id, including
201
+ a followup with no link (an empty ``documents`` list). A link row
202
+ that does not name that followup as its item is dropped.
203
+
204
+ Raises
205
+ ------
206
+ RuntimeError
207
+ If the client was built without a v1 session.
208
+ GlpiValidationError
209
+ If a kept followup has no identifier, or if reading the links of
210
+ a followup fails validation. A partial list is never returned.
211
+ GlpiStatusError
212
+ If the GLPI server returns a non-success HTTP status.
213
+ """
214
+
215
+ self._require_v1_session(_V1_FEATURE_LABEL)
216
+ followups = self.list_ticket_followups(ticket_id) # type: ignore[attr-defined]
217
+ kept = [
218
+ followup
219
+ for followup in followups
220
+ if include_private or followup.is_private is False
221
+ ]
222
+ keyed: list[tuple[int, GetFollowup]] = []
223
+ for followup in kept:
224
+ if followup.id is None:
225
+ raise GlpiValidationError(
226
+ f"A followup of ticket {ticket_id} has no id, "
227
+ "so its document links cannot be read"
228
+ )
229
+ keyed.append((followup.id, followup))
230
+ keyed.sort(key=lambda pair: pair[0])
231
+ link_lists = gather(
232
+ *(
233
+ self.list_item_documents(_FOLLOWUP_ITEMTYPE, followup_id)
234
+ for followup_id, _ in keyed
235
+ )
236
+ )
237
+ return [
238
+ FollowupDocuments(
239
+ followup=followup,
240
+ documents=[
241
+ row
242
+ for row in rows
243
+ if row.itemtype == _FOLLOWUP_ITEMTYPE
244
+ and row.items_id == followup_id
245
+ ],
246
+ )
247
+ for (followup_id, followup), rows in zip(keyed, link_lists, strict=True)
248
+ ]
249
+
65
250
  def get_ticket_timeline_document(
66
251
  self, ticket_id: GlpiId, document_link_id: GlpiId
67
252
  ) -> GetDocument:
@@ -179,6 +179,30 @@ def _plain_text_html(text: str) -> str:
179
179
  return "<p>" + "<br>".join(lines) + "</p>"
180
180
 
181
181
 
182
+ #: What a browser strips from the front of a URL, and drops from inside one.
183
+ _URL_LEADING = "".join(chr(c) for c in range(0x21))
184
+ _URL_INNER = str.maketrans("", "", "\t\r\n")
185
+
186
+ #: The elements inside which the reader writes no image, so offers none.
187
+ _UNFORMATTED = ("pre", "code", "kbd", "samp")
188
+
189
+
190
+ def _is_data_uri(src: str) -> bool:
191
+ """Whether a browser reads ``src`` as a ``data:`` URI.
192
+
193
+ The scheme is case-insensitive, leading C0 controls and spaces are
194
+ stripped, and tabs and line breaks are dropped wherever they are.
195
+ """
196
+
197
+ return src.lstrip(_URL_LEADING).translate(_URL_INNER).casefold().startswith("data:")
198
+
199
+
200
+ def _image_id(el: Tag) -> str | None:
201
+ """The trimmed ``id`` of an element, or ``None`` when it has none."""
202
+
203
+ return str(el.get("id") or "").strip() or None
204
+
205
+
182
206
  def _soup(html: str) -> BeautifulSoup:
183
207
  """Parse ``html`` and drop what a browser does not display."""
184
208
 
@@ -402,6 +426,12 @@ class Link:
402
426
  enclosing_href : str or None
403
427
  For an ``<img>`` inside a link, that link's ``href``; ``None``
404
428
  otherwise. The image is read before the link around it.
429
+ image_id : str or None
430
+ For an ``<img>``, the value of its ``id`` attribute, trimmed, or
431
+ ``None`` when it has none or the element is a link. GLPI sets it to
432
+ the tag of the Document it created for an image it has not rewritten
433
+ into a document link, and that Document carries the same value as its
434
+ tag, so a caller can find it.
405
435
  """
406
436
 
407
437
  href: str
@@ -410,6 +440,7 @@ class Link:
410
440
  image: bool = False
411
441
  document_id: int | None = None
412
442
  enclosing_href: str | None = None
443
+ image_id: str | None = None
413
444
 
414
445
 
415
446
  #: A ``rewrite_link`` callback: called with each link and image a read meets,
@@ -664,6 +695,7 @@ class _Converter(MarkdownConverter):
664
695
  enclosing_href=(
665
696
  None if around is None else str(around.get("href") or "")
666
697
  ),
698
+ image_id=_image_id(el),
667
699
  )
668
700
  )
669
701
  if isinstance(answer, str):
@@ -1021,6 +1053,33 @@ class GlpiContentConverter:
1021
1053
  document_id = int(values[0])
1022
1054
  return document_id if document_id > 0 else None
1023
1055
 
1056
+ @staticmethod
1057
+ def data_image_ids(html: str) -> tuple[str, ...]:
1058
+ """Return the ``id`` of every image a body keeps as a ``data:`` URI.
1059
+
1060
+ Each ``<img>`` whose ``src`` a browser reads as a ``data:`` URI and
1061
+ that has a non-empty ``id`` contributes it, in document order and
1062
+ without repeats. The base64 is not decoded. An image inside ``<pre>``,
1063
+ ``<code>``, ``<kbd>`` or ``<samp>`` is left out: the reader shows it
1064
+ as no image, so it never offers it to ``rewrite_link`` either.
1065
+
1066
+ GLPI sets a tag on the Documents it creates from an upload or an
1067
+ editor paste, images or not. For an image it has not rewritten into a
1068
+ document link, that tag is the ``id`` of the ``<img>``
1069
+ (:attr:`Link.image_id`, :attr:`GetDocument.tag`). A caller calls this
1070
+ first, to decide whether it needs to read any Document at all.
1071
+ """
1072
+
1073
+ if "<" not in html:
1074
+ return ()
1075
+ ids = (
1076
+ _image_id(img)
1077
+ for img in _soup(html).find_all("img")
1078
+ if _is_data_uri(str(img.get("src") or ""))
1079
+ and img.find_parent(_UNFORMATTED) is None
1080
+ )
1081
+ return tuple(dict.fromkeys(i for i in ids if i is not None))
1082
+
1024
1083
  @staticmethod
1025
1084
  def document_image(
1026
1085
  document_id: int,
@@ -109,9 +109,11 @@ from glpi_python_client.models.api_schema.management import (
109
109
  DeleteContract,
110
110
  DeleteContractCost,
111
111
  DeleteDocument,
112
+ FollowupDocuments,
112
113
  GetContract,
113
114
  GetContractCost,
114
115
  GetDocument,
116
+ GetDocumentItem,
115
117
  PatchContract,
116
118
  PatchContractCost,
117
119
  PatchDocument,
@@ -149,12 +151,14 @@ __all__ = [
149
151
  "DeleteTicketTask",
150
152
  "DeleteTimelineDocument",
151
153
  "DeleteUser",
154
+ "FollowupDocuments",
152
155
  "GetComputer",
153
156
  "GetContract",
154
157
  "GetContractCost",
155
158
  "GetContractItem",
156
159
  "GetContractType",
157
160
  "GetDocument",
161
+ "GetDocumentItem",
158
162
  "GetEntity",
159
163
  "GetFollowup",
160
164
  "GetKBArticle",
@@ -135,9 +135,14 @@ class GlpiTimelinePosition(GlpiEnum):
135
135
  The contract advertises ``[-1, 0, 1, 2, 3, 4]`` on
136
136
  ``timeline_position``. ``LEFT`` and ``RIGHT`` follow the GLPI UI
137
137
  conventions.
138
+
139
+ ``NO_TIMELINE`` is GLPI's own name for ``-1`` and an alias of
140
+ ``INVALID``. GLPI's editor writes it on the link of an image it embeds
141
+ in a text, and such a link is hidden from the ticket's Documents tab.
138
142
  """
139
143
 
140
144
  INVALID = -1
145
+ NO_TIMELINE = -1
141
146
  NONE = 0
142
147
  LEFT = 1
143
148
  RIGHT = 2
@@ -18,14 +18,20 @@ from glpi_python_client.models.api_schema.management._document import (
18
18
  PatchDocument,
19
19
  PostDocument,
20
20
  )
21
+ from glpi_python_client.models.api_schema.management._document_item import (
22
+ FollowupDocuments,
23
+ GetDocumentItem,
24
+ )
21
25
 
22
26
  __all__ = [
23
27
  "DeleteContract",
24
28
  "DeleteContractCost",
25
29
  "DeleteDocument",
30
+ "FollowupDocuments",
26
31
  "GetContract",
27
32
  "GetContractCost",
28
33
  "GetDocument",
34
+ "GetDocumentItem",
29
35
  "PatchContract",
30
36
  "PatchContractCost",
31
37
  "PatchDocument",
@@ -61,6 +61,20 @@ class GetDocument(GlpiModel):
61
61
  mime: str | None = None
62
62
  sha1sum: str | None = None
63
63
 
64
+ @property
65
+ def tag(self) -> str | None:
66
+ """The tag GLPI gave the Document, or ``None``.
67
+
68
+ GLPI sets a tag on the Documents it creates from an upload or an
69
+ editor paste, images or not. For an image it has not rewritten into
70
+ a document link, it is the ``id`` of the ``<img>`` left in the body
71
+ (see ``GlpiContentConverter.data_image_ids``). Read from
72
+ ``extra_payload``; ``None`` when absent, empty or not a string.
73
+ """
74
+
75
+ value = self.extra_payload.get("tag")
76
+ return value if isinstance(value, str) and value else None
77
+
64
78
 
65
79
  class PostDocument(GlpiModel):
66
80
  """Request body for ``POST /Management/Document``.
@@ -0,0 +1,88 @@
1
+ """GLPI ``Document_Item`` link rows, as the legacy v1 API returns them.
2
+
3
+ A ``Document_Item`` row links one document to one item: a ticket, a followup,
4
+ a task or a solution. The v1 sub-item route ``<Itemtype>/<id>/Document_Item``
5
+ answers a list of these rows. They are not the ``GetTimelineDocument`` shape
6
+ of the v2 timeline, which carries no entity and no dates.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime
12
+
13
+ from pydantic import ConfigDict
14
+
15
+ from glpi_python_client.models._base import GlpiModel
16
+ from glpi_python_client.models.api_schema.assistance.timeline._followup import (
17
+ GetFollowup,
18
+ )
19
+ from glpi_python_client.models.api_schema.enums import GlpiTimelinePosition
20
+
21
+
22
+ class GetDocumentItem(GlpiModel):
23
+ """One ``Document_Item`` row linking a document to an item.
24
+
25
+ Keys the model does not declare (``users_id``, ``is_recursive``,
26
+ ``links`` and any plugin column) are kept in ``extra_payload``.
27
+
28
+ Parameters
29
+ ----------
30
+ id : int
31
+ Native identifier of the link row.
32
+ documents_id : int
33
+ Identifier of the linked document.
34
+ itemtype : str | None, optional
35
+ GLPI type of the item the document is linked to, for example
36
+ ``"Ticket"`` or ``"ITILFollowup"``.
37
+ items_id : int | None, optional
38
+ Identifier of the item the document is linked to.
39
+ entities_id : int | None, optional
40
+ Identifier of the entity owning the link.
41
+ date_creation : datetime | None, optional
42
+ Creation timestamp of the link row.
43
+ date_mod : datetime | None, optional
44
+ Last modification timestamp of the link row.
45
+ date : datetime | None, optional
46
+ Timestamp GLPI records on the link.
47
+ timeline_position : GlpiTimelinePosition | None, optional
48
+ Position of the link in the ticket timeline. A link at
49
+ :attr:`GlpiTimelinePosition.NO_TIMELINE` (``-1``) is written by
50
+ GLPI's editor for an image it embeds in a text. Such a link is
51
+ hidden from the ticket's Documents tab. A document uploaded as an
52
+ attachment is linked at ``1``.
53
+ """
54
+
55
+ id: int
56
+ documents_id: int
57
+ itemtype: str | None = None
58
+ items_id: int | None = None
59
+ entities_id: int | None = None
60
+ date_creation: datetime | None = None
61
+ date_mod: datetime | None = None
62
+ date: datetime | None = None
63
+ timeline_position: GlpiTimelinePosition | None = None
64
+
65
+
66
+ class FollowupDocuments(GlpiModel):
67
+ """One ticket followup together with its document links.
68
+
69
+ Returned by ``list_ticket_followup_documents``. The followup is kept
70
+ whole, so a caller reads ``followup.is_private`` to classify every link
71
+ without a second read.
72
+
73
+ Parameters
74
+ ----------
75
+ followup : GetFollowup
76
+ The followup, as the ticket's followup listing returned it.
77
+ documents : list[GetDocumentItem]
78
+ The ``Document_Item`` rows linked to this followup. Empty when the
79
+ followup has no document.
80
+ """
81
+
82
+ model_config = ConfigDict(extra="allow", frozen=True)
83
+
84
+ followup: GetFollowup
85
+ documents: list[GetDocumentItem]
86
+
87
+
88
+ __all__ = ["FollowupDocuments", "GetDocumentItem"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: glpi-python-client
3
- Version: 0.6.3
3
+ Version: 0.6.5
4
4
  Summary: A typed Python client for GLPI ITSM APIs.
5
5
  Project-URL: Homepage, https://github.com/baraline/glpi_python_client
6
6
  Project-URL: Documentation, https://glpi-python-client.readthedocs.io/en/latest/
@@ -1,4 +1,4 @@
1
- glpi_python_client/__init__.py,sha256=IMYtZ6FcQdH6IPopjnPwGlKf-nb6QjjTb1JLgq0AXYY,5538
1
+ glpi_python_client/__init__.py,sha256=goD0YVtmpFX2_3vXrg-qOCLUgtCYwEj-wFJeP0-UfLk,5630
2
2
  glpi_python_client/_errors.py,sha256=Bc9UvlxulbdcGvzZIKXnJCTL78sW78R6EFg50P-1dpY,7151
3
3
  glpi_python_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  glpi_python_client/rsql.py,sha256=3u3lH3_-F1bvHbVzxALXTDPoHyoMATP1f4QPTar_sEU,8974
@@ -21,7 +21,7 @@ glpi_python_client/_async/clients/api/assistance/__init__.py,sha256=nL77QpQ4Yzfd
21
21
  glpi_python_client/_async/clients/api/assistance/_team.py,sha256=QEwFTXE8W_MURi_zgfDTLoqYP__NF1SwBd_7dLJZg5U,3824
22
22
  glpi_python_client/_async/clients/api/assistance/_ticket.py,sha256=z1-kKjkSwvePZl65CXpDay50uJpirjlA1ww4_3eVyJU,7990
23
23
  glpi_python_client/_async/clients/api/assistance/timeline/__init__.py,sha256=AqB_UpcRMHjfvZyLBjkwfsnEBwz77xBbGJzXzWmS7os,619
24
- glpi_python_client/_async/clients/api/assistance/timeline/_document.py,sha256=3vTbAxh8ZoVmx45xId5jjcapm4124dVnhXoF84Du_8w,7334
24
+ glpi_python_client/_async/clients/api/assistance/timeline/_document.py,sha256=BYCScaSyfrPzAh-znObYh1yEeL4dwYLYV8F0hKn6YcE,14803
25
25
  glpi_python_client/_async/clients/api/assistance/timeline/_followup.py,sha256=jgE_kpJxMa3v1Tt4Jm2PahiRNX7v3vKh6xjbX27sp7w,6221
26
26
  glpi_python_client/_async/clients/api/assistance/timeline/_solution.py,sha256=3DpbDeHDQ7FJdevhcpAcgXyB9FZu7_S2kVblFXsZUq0,6156
27
27
  glpi_python_client/_async/clients/api/assistance/timeline/_task.py,sha256=EdkrRJnkHWaZdxgyqwL1rhUygGSJScR2jIS6CFJL4S4,5869
@@ -67,7 +67,7 @@ glpi_python_client/_sync/clients/api/assistance/__init__.py,sha256=dAXVQPgCmPhCL
67
67
  glpi_python_client/_sync/clients/api/assistance/_team.py,sha256=Nk0FXUHyZZOkg62Os4FBee3Jv4YzMJoOqsvsWM0yqI0,3785
68
68
  glpi_python_client/_sync/clients/api/assistance/_ticket.py,sha256=Xuk0-8geUdII-uFdgIbkhG_QMEfOytcHp5n-Hq2D-U8,7906
69
69
  glpi_python_client/_sync/clients/api/assistance/timeline/__init__.py,sha256=d_YAwgv3r1sNrRVlPC5r1yEhbUZQKzLtuNzHD9eBF8Y,615
70
- glpi_python_client/_sync/clients/api/assistance/timeline/_document.py,sha256=Tqy4HCzZiZ5keXQEHt8gbA8OGAkdMqlvJ9yX0tVnknA,7272
70
+ glpi_python_client/_sync/clients/api/assistance/timeline/_document.py,sha256=0T7wEyVNoECI8c0LUZ1WQtXgvpRBKyl-GTsB4jdJ2xA,14709
71
71
  glpi_python_client/_sync/clients/api/assistance/timeline/_followup.py,sha256=trQJJThA8jk2jVK-jTW8uPZyYEvR5Zi18mqGT6436VM,6158
72
72
  glpi_python_client/_sync/clients/api/assistance/timeline/_solution.py,sha256=KVyrjKFS35lJzvjwEromOEL2Q2OfvLP7MvhV7QirtVs,6093
73
73
  glpi_python_client/_sync/clients/api/assistance/timeline/_task.py,sha256=peSQmKYoHc9IVdQltBaDqFFHZKdBzWJwt_0GXng2odU,5806
@@ -95,13 +95,13 @@ glpi_python_client/_sync/clients/custom/__init__.py,sha256=sxfGlHVulucP7bacWhUMi
95
95
  glpi_python_client/_sync/clients/custom/_statistics.py,sha256=xrD5C99ieROJx60iUomBHacQ_49HpgQL4XjnfREUQvs,38616
96
96
  glpi_python_client/_sync/clients/custom/_ticket_context.py,sha256=LDUvKNr8VpZonF5bDo8QYYwMEsWmayVU28Ra3GonNmE,3004
97
97
  glpi_python_client/content/__init__.py,sha256=TaZPo8WSvVj4U-hSnxhUcqU8JnCKNZYpfQf4pH45aN4,424
98
- glpi_python_client/content/conversion.py,sha256=bglYY5IgoTv2SdS0Gton7WgsyzwwQ5GdU_2dLloL2l0,42860
99
- glpi_python_client/models/__init__.py,sha256=b6DPZtH0CxyBUnVB0x9vP9AMBIjgubDFm_1TOQuCiVA,5362
98
+ glpi_python_client/content/conversion.py,sha256=0dxQW0ojSo1KC8zcTFa476KQa1i3sLWzQ8gTEEVslys,45341
99
+ glpi_python_client/models/__init__.py,sha256=CLvk-1A-88114X7HaS9P6KyfovQjCPRX08J56rJaGls,5454
100
100
  glpi_python_client/models/_base.py,sha256=pYg69KHuR5TDED5utr4Knr7HncBnLTxXHJZYf6vEUKA,12183
101
101
  glpi_python_client/models/api_schema/__init__.py,sha256=yTxUYih1Xy7NUogzD7UKp2b8HyE4wdeC_FCKS6ZRVVM,1176
102
102
  glpi_python_client/models/api_schema/_common.py,sha256=wVtaIkUM9iS3jyDb0bpFopEkP_3vP1ax7D8rwHe9QkI,2004
103
103
  glpi_python_client/models/api_schema/_content.py,sha256=DlsEmlXIK_0jyXh3j5PgU6vkwch2o0I8UpUkLq2fqYw,12826
104
- glpi_python_client/models/api_schema/enums.py,sha256=WDdEhnB_-ZggvY0RTnAX6C6bN8extGzpE9An-Nk5QuQ,4834
104
+ glpi_python_client/models/api_schema/enums.py,sha256=bHNCuumOXIZm80n5RqIhCzFqizII-3er41mn2aktF5s,5071
105
105
  glpi_python_client/models/api_schema/administration/__init__.py,sha256=uTr2rEMYq47T5foUMHm1rtsBmEQvdD1EQw5CZOUN38Y,706
106
106
  glpi_python_client/models/api_schema/administration/_entity.py,sha256=KdbxMyPXumGZKmmabDIciF3nFS61pLRp5SBOTFa8slk,3297
107
107
  glpi_python_client/models/api_schema/administration/_user.py,sha256=MQ6cHKdni98sjm-Whf0GKdGpNjPgGGJpUBgVjlGa19Q,15872
@@ -124,10 +124,11 @@ glpi_python_client/models/api_schema/knowledgebase/_article.py,sha256=zNLnkkdfGj
124
124
  glpi_python_client/models/api_schema/knowledgebase/_category.py,sha256=bokNV56PTULFCAzV8dxLOAl8Nk2yCI5Lmb3RLGm0zTM,2260
125
125
  glpi_python_client/models/api_schema/knowledgebase/_comment.py,sha256=mouYQE2AigGpJY0jTu9KoqOa4xT0_ckj0h6XDfY7mqc,2325
126
126
  glpi_python_client/models/api_schema/knowledgebase/_revision.py,sha256=KuTb_F85mc63RHpQ1VFsmM-eDBjUrxAEdLM48-WmWYs,1985
127
- glpi_python_client/models/api_schema/management/__init__.py,sha256=mI39635e4_IgpaYNOXZotZ_xIXPH8oE1FXno6cvr-pY,816
127
+ glpi_python_client/models/api_schema/management/__init__.py,sha256=z5ntUAXiXXhZCEIsTVaTgJdXbg0ZW24TZctarfAqjr0,987
128
128
  glpi_python_client/models/api_schema/management/_contract.py,sha256=m6g__AKWvDQ09xKyUe8RhRS8xE1d1pOBjqOOG-8u6Ck,14045
129
129
  glpi_python_client/models/api_schema/management/_contract_cost.py,sha256=Bp2B3bA5u8CE_oBYY70QIUot8jFLCrX_icXZ4kpYqSE,4966
130
- glpi_python_client/models/api_schema/management/_document.py,sha256=RzKF-rNlz8CsAMa2BAaiqHNEE3-m1mci7vtdARls4uw,4607
130
+ glpi_python_client/models/api_schema/management/_document.py,sha256=kiEkTnOydGHjOyAwTbo020Kei4ffkBDj-9m775gLyfw,5200
131
+ glpi_python_client/models/api_schema/management/_document_item.py,sha256=Bu9c9YdhSs5hGZ8dRfRxqS4g7VZ1A3EJtNbDLulKpWA,3113
131
132
  glpi_python_client/models/api_schema/plugins/__init__.py,sha256=GA0pr0PeoPOqwjUilrBujh0OqjUfZhzPWgIlgDJcZog,544
132
133
  glpi_python_client/models/api_schema/plugins/_fields.py,sha256=Se-12ZDqk_-on_37YF_hRD5S0e0H6zyha3SXI9xb_9Y,6576
133
134
  glpi_python_client/models/custom_schema/__init__.py,sha256=Usp592x9OWws30vU1rEXbkkRBhcgytM5nxRPTB1gqhY,500
@@ -135,7 +136,7 @@ glpi_python_client/models/custom_schema/_ticket_context.py,sha256=jnlfgho-aDbmA9
135
136
  glpi_python_client/testing/__init__.py,sha256=iLtUyJjyv3fEhy3PN25NxNTbtqDl3SoHLbNium0Z80U,568
136
137
  glpi_python_client/testing/fixtures.py,sha256=HKt6MXHgcmLn4L98pSwp-oGlc9C--_vrABNe5G_iBeg,1286
137
138
  glpi_python_client/testing/utils.py,sha256=JyO3w2d-0kYdtC1hEZ2Tu_YjnCz8nUQ_Ck05-1EhsX4,4404
138
- glpi_python_client-0.6.3.dist-info/METADATA,sha256=4RZb8YHJdpzGHRZpengz0BnNOWHWaTzEDFgmc1WyDss,8234
139
- glpi_python_client-0.6.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
140
- glpi_python_client-0.6.3.dist-info/licenses/LICENSE,sha256=nL8re-TIKkxCoL7OkSBja6EB4NsQR1HoOJgxv_MEhtc,1088
141
- glpi_python_client-0.6.3.dist-info/RECORD,,
139
+ glpi_python_client-0.6.5.dist-info/METADATA,sha256=xKVBulZ8pipAYJLr8FuRcAM1M21XFiM9PGGq3PNMeZE,8234
140
+ glpi_python_client-0.6.5.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
141
+ glpi_python_client-0.6.5.dist-info/licenses/LICENSE,sha256=nL8re-TIKkxCoL7OkSBja6EB4NsQR1HoOJgxv_MEhtc,1088
142
+ glpi_python_client-0.6.5.dist-info/RECORD,,