glpi-python-client 0.6.2__py3-none-any.whl → 0.6.4__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.
- glpi_python_client/__init__.py +5 -1
- glpi_python_client/_async/clients/api/assistance/timeline/_document.py +185 -0
- glpi_python_client/_sync/clients/api/assistance/timeline/_document.py +185 -0
- glpi_python_client/content/__init__.py +6 -2
- glpi_python_client/content/conversion.py +277 -21
- glpi_python_client/models/__init__.py +4 -0
- glpi_python_client/models/api_schema/enums.py +5 -0
- glpi_python_client/models/api_schema/management/__init__.py +6 -0
- glpi_python_client/models/api_schema/management/_document_item.py +88 -0
- {glpi_python_client-0.6.2.dist-info → glpi_python_client-0.6.4.dist-info}/METADATA +1 -1
- {glpi_python_client-0.6.2.dist-info → glpi_python_client-0.6.4.dist-info}/RECORD +13 -12
- {glpi_python_client-0.6.2.dist-info → glpi_python_client-0.6.4.dist-info}/WHEEL +0 -0
- {glpi_python_client-0.6.2.dist-info → glpi_python_client-0.6.4.dist-info}/licenses/LICENSE +0 -0
glpi_python_client/__init__.py
CHANGED
|
@@ -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.
|
|
136
|
+
__version__ = "0.6.4"
|
|
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:
|
|
@@ -6,6 +6,10 @@ payloads and the package's canonical Markdown and typed-record representations.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
from glpi_python_client.content.conversion import
|
|
9
|
+
from glpi_python_client.content.conversion import (
|
|
10
|
+
GlpiContentConverter,
|
|
11
|
+
Link,
|
|
12
|
+
RewriteLink,
|
|
13
|
+
)
|
|
10
14
|
|
|
11
|
-
__all__ = ["GlpiContentConverter"]
|
|
15
|
+
__all__ = ["GlpiContentConverter", "Link", "RewriteLink"]
|
|
@@ -40,9 +40,12 @@ import re
|
|
|
40
40
|
import string
|
|
41
41
|
import unicodedata
|
|
42
42
|
from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence
|
|
43
|
+
from contextvars import ContextVar
|
|
44
|
+
from dataclasses import dataclass
|
|
43
45
|
from functools import cached_property
|
|
44
46
|
from html import escape, unescape
|
|
45
47
|
from typing import Any, cast
|
|
48
|
+
from urllib.parse import parse_qs, urlsplit
|
|
46
49
|
|
|
47
50
|
import cmarkgfm
|
|
48
51
|
import mdformat_tables
|
|
@@ -59,7 +62,7 @@ from mdformat.renderer import (
|
|
|
59
62
|
)
|
|
60
63
|
from mdformat.renderer.typing import Postprocess
|
|
61
64
|
|
|
62
|
-
from glpi_python_client._errors import GlpiContentError
|
|
65
|
+
from glpi_python_client._errors import GlpiContentError, GlpiValidationError
|
|
63
66
|
|
|
64
67
|
#: Element names that make a ``<...>`` sequence markup rather than text: the
|
|
65
68
|
#: HTML5 elements, then the obsolete ones old editors and mail clients write.
|
|
@@ -363,6 +366,97 @@ def _title(title: str) -> str:
|
|
|
363
366
|
return ' "' + re.sub(r'[\\"]', r"\\\g<0>", title) + '"' if title else ""
|
|
364
367
|
|
|
365
368
|
|
|
369
|
+
#: The page GLPI serves a document from. Its editor embeds a pasted image as
|
|
370
|
+
#: ``<img src=".../front/document.send.php?docid=N&itemtype=Ticket&items_id=T">``
|
|
371
|
+
#: inside a link to the same URL, and links an attachment the same way.
|
|
372
|
+
_DOCUMENT_PAGE = "front/document.send.php"
|
|
373
|
+
|
|
374
|
+
#: A document id, as the ``docid`` parameter carries one: ASCII digits only,
|
|
375
|
+
#: since ``int`` also reads other scripts' digits.
|
|
376
|
+
_DOCUMENT_ID = re.compile(r"[0-9]{1,18}")
|
|
377
|
+
|
|
378
|
+
#: An item type, as ``itemtype`` names one: a PHP class name.
|
|
379
|
+
_ITEMTYPE = re.compile(r"[A-Za-z_][A-Za-z0-9_\\]{0,99}")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@dataclass(frozen=True, slots=True)
|
|
383
|
+
class Link:
|
|
384
|
+
"""A link or an image the reader meets, as a ``rewrite_link`` callback sees it.
|
|
385
|
+
|
|
386
|
+
Attributes
|
|
387
|
+
----------
|
|
388
|
+
href : str
|
|
389
|
+
An ``<a>``'s ``href`` or an ``<img>``'s ``src``, character references
|
|
390
|
+
decoded.
|
|
391
|
+
text : str
|
|
392
|
+
An ``<a>``'s displayed text or an ``<img>``'s ``alt``, whitespace
|
|
393
|
+
collapsed.
|
|
394
|
+
title : str
|
|
395
|
+
The ``title``, or ``""``.
|
|
396
|
+
image : bool
|
|
397
|
+
``True`` for an ``<img>``.
|
|
398
|
+
document_id : int or None
|
|
399
|
+
For a link or an image whose URL is GLPI's document page with a
|
|
400
|
+
``docid`` (:meth:`GlpiContentConverter.document_id_of`), that id;
|
|
401
|
+
``None`` otherwise.
|
|
402
|
+
enclosing_href : str or None
|
|
403
|
+
For an ``<img>`` inside a link, that link's ``href``; ``None``
|
|
404
|
+
otherwise. The image is read before the link around it.
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
href: str
|
|
408
|
+
text: str = ""
|
|
409
|
+
title: str = ""
|
|
410
|
+
image: bool = False
|
|
411
|
+
document_id: int | None = None
|
|
412
|
+
enclosing_href: str | None = None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
#: A ``rewrite_link`` callback: called with each link and image a read meets,
|
|
416
|
+
#: outside code. ``None`` keeps what the reader writes without one. A ``str`` is
|
|
417
|
+
#: written instead, as literal text. A :class:`Link` is written in the reader's
|
|
418
|
+
#: own spelling: a link's ``href`` and ``title``, around the link's content as
|
|
419
|
+
#: read; an image's ``href`` as its ``src``, ``text`` as its ``alt`` and
|
|
420
|
+
#: ``title``. An empty ``href`` drops a link and keeps its content, and writes an
|
|
421
|
+
#: image as its ``text``.
|
|
422
|
+
RewriteLink = Callable[[Link], "Link | str | None"]
|
|
423
|
+
|
|
424
|
+
_REWRITE: ContextVar[RewriteLink | None] = ContextVar("rewrite_link", default=None)
|
|
425
|
+
"""The callback of the read in progress. A context variable rather than state
|
|
426
|
+
on the shared converter: each thread and each task reads with its own."""
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class _CallbackRaised(Exception):
|
|
430
|
+
"""What a ``rewrite_link`` callback raised, carried past the reader's fallbacks.
|
|
431
|
+
|
|
432
|
+
The reader answers a ``ValueError`` by reading the body as its text; a
|
|
433
|
+
callback's own ``ValueError`` must not pass for markdownify's and quietly
|
|
434
|
+
cost the body its formatting.
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
def __init__(self, error: Exception) -> None:
|
|
438
|
+
super().__init__(error)
|
|
439
|
+
self.error = error
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _rewritten(link: Link) -> Link | str | None:
|
|
443
|
+
"""Ask the read's callback about ``link``; ``None`` when there is none."""
|
|
444
|
+
|
|
445
|
+
rewrite = _REWRITE.get()
|
|
446
|
+
if rewrite is None:
|
|
447
|
+
return None
|
|
448
|
+
try:
|
|
449
|
+
answer: object = rewrite(link) # typed as what it is, not what it should be
|
|
450
|
+
except Exception as exc:
|
|
451
|
+
raise _CallbackRaised(exc) from exc
|
|
452
|
+
if answer is None or isinstance(answer, (Link, str)):
|
|
453
|
+
return answer
|
|
454
|
+
error = TypeError(
|
|
455
|
+
f"rewrite_link returned {type(answer).__name__}; expected Link, str or None"
|
|
456
|
+
)
|
|
457
|
+
raise _CallbackRaised(error)
|
|
458
|
+
|
|
459
|
+
|
|
366
460
|
class _Converter(MarkdownConverter):
|
|
367
461
|
"""``markdownify``, with what CommonMark and the reader's glue need on top.
|
|
368
462
|
|
|
@@ -520,15 +614,34 @@ class _Converter(MarkdownConverter):
|
|
|
520
614
|
body, _, tail = rest.rpartition("```")
|
|
521
615
|
return f"{head}{fence}{body}{fence}{tail}"
|
|
522
616
|
|
|
617
|
+
def _literal(self, text: str, parent_tags: set[str]) -> str:
|
|
618
|
+
"""``text`` escaped as the reader escapes a text node: displayed as it is."""
|
|
619
|
+
|
|
620
|
+
return self.escape(" ".join(text.split()), parent_tags)
|
|
621
|
+
|
|
523
622
|
def convert_a(self, el: Tag, text: str, parent_tags: set[str]) -> str:
|
|
524
623
|
"""A link; ``<url>`` when its text is its URL and reads back unchanged."""
|
|
525
624
|
|
|
625
|
+
if "_noformat" in parent_tags:
|
|
626
|
+
return text
|
|
526
627
|
href = str(el.get("href") or "")
|
|
628
|
+
title = str(el.get("title") or "")
|
|
629
|
+
answer = _rewritten(
|
|
630
|
+
Link(
|
|
631
|
+
href,
|
|
632
|
+
" ".join(el.get_text().split()),
|
|
633
|
+
title,
|
|
634
|
+
document_id=GlpiContentConverter.document_id_of(href),
|
|
635
|
+
)
|
|
636
|
+
)
|
|
637
|
+
if isinstance(answer, str):
|
|
638
|
+
return self._literal(answer, parent_tags)
|
|
639
|
+
if answer is not None:
|
|
640
|
+
href, title = answer.href, answer.title
|
|
527
641
|
edges = _EDGES.fullmatch(text)
|
|
528
|
-
if
|
|
642
|
+
if not href or edges is None or not edges[2]:
|
|
529
643
|
return text
|
|
530
644
|
before, inner, after = edges.groups()
|
|
531
|
-
title = str(el.get("title") or "")
|
|
532
645
|
if not title and el.get_text() == href and _AUTOLINK.fullmatch(href):
|
|
533
646
|
return f"{before}<{href}>{after}"
|
|
534
647
|
return f"{before}[{inner}]({_destination(href)}{_title(title)}){after}"
|
|
@@ -536,12 +649,37 @@ class _Converter(MarkdownConverter):
|
|
|
536
649
|
def convert_img(self, el: Tag, text: str, parent_tags: set[str]) -> str:
|
|
537
650
|
"""An image, wherever it is: a cell or a heading holds one too."""
|
|
538
651
|
|
|
652
|
+
src = str(el.get("src") or "")
|
|
539
653
|
alt = " ".join(str(el.get("alt") or "").split())
|
|
654
|
+
title = str(el.get("title") or "")
|
|
655
|
+
if "_noformat" not in parent_tags:
|
|
656
|
+
around = el.find_parent("a")
|
|
657
|
+
answer = _rewritten(
|
|
658
|
+
Link(
|
|
659
|
+
src,
|
|
660
|
+
alt,
|
|
661
|
+
title,
|
|
662
|
+
image=True,
|
|
663
|
+
document_id=GlpiContentConverter.document_id_of(src),
|
|
664
|
+
enclosing_href=(
|
|
665
|
+
None if around is None else str(around.get("href") or "")
|
|
666
|
+
),
|
|
667
|
+
)
|
|
668
|
+
)
|
|
669
|
+
if isinstance(answer, str):
|
|
670
|
+
return self._literal(answer, parent_tags)
|
|
671
|
+
if answer is not None and not answer.href:
|
|
672
|
+
return self._literal(answer.text, parent_tags)
|
|
673
|
+
if answer is not None:
|
|
674
|
+
src, alt, title = (
|
|
675
|
+
answer.href,
|
|
676
|
+
" ".join(answer.text.split()),
|
|
677
|
+
answer.title,
|
|
678
|
+
)
|
|
540
679
|
alt = str(self._inherited("escape")(alt, parent_tags))
|
|
541
680
|
if alt.startswith("^") and "_noformat" not in parent_tags:
|
|
542
681
|
alt = "\\" + alt # cmark-gfm reads " or ''))})"
|
|
682
|
+
return f"}{_title(title)})"
|
|
545
683
|
|
|
546
684
|
|
|
547
685
|
class _Node(RenderTreeNode):
|
|
@@ -752,11 +890,45 @@ def _text_of(html: str) -> str:
|
|
|
752
890
|
return "\n".join(line for line in lines if line)
|
|
753
891
|
|
|
754
892
|
|
|
893
|
+
def _converted(content: str) -> str:
|
|
894
|
+
"""Read HTML, falling back to its text where the conversion cannot."""
|
|
895
|
+
|
|
896
|
+
try:
|
|
897
|
+
try:
|
|
898
|
+
return html_to_markdown(content)
|
|
899
|
+
except (RecursionError, ValueError): # ValueError: markdownify's
|
|
900
|
+
# int() of a colspan or start such as "²" or 5,000 digits
|
|
901
|
+
return html_to_markdown(_plain_text_html(_text_of(content)))
|
|
902
|
+
except ParserRejectedMarkup:
|
|
903
|
+
# html.parser gives up on a few malformed declarations; the
|
|
904
|
+
# body's words are still worth more than an exception.
|
|
905
|
+
text = unescape(_ANY_TAG.sub(" ", content))
|
|
906
|
+
return html_to_markdown(_plain_text_html(" ".join(text.split())))
|
|
907
|
+
except _CallbackRaised:
|
|
908
|
+
raise
|
|
909
|
+
except Exception as exc:
|
|
910
|
+
raise GlpiContentError(
|
|
911
|
+
"Could not convert GLPI HTML content to Markdown "
|
|
912
|
+
f"({type(exc).__name__}: {exc})."
|
|
913
|
+
) from exc
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _positive(value: object) -> bool:
|
|
917
|
+
"""Whether ``value`` is a positive ``int`` and not a ``bool``."""
|
|
918
|
+
|
|
919
|
+
return isinstance(value, int) and not isinstance(value, bool) and value > 0
|
|
920
|
+
|
|
921
|
+
|
|
755
922
|
class GlpiContentConverter:
|
|
756
923
|
"""Convert content between GLPI HTML payloads and canonical Markdown."""
|
|
757
924
|
|
|
758
925
|
@staticmethod
|
|
759
|
-
def from_transport(
|
|
926
|
+
def from_transport(
|
|
927
|
+
value: object,
|
|
928
|
+
*,
|
|
929
|
+
plain_text_is_markdown: bool = False,
|
|
930
|
+
rewrite_link: RewriteLink | None = None,
|
|
931
|
+
) -> str:
|
|
760
932
|
"""Convert one GLPI transport value into Markdown.
|
|
761
933
|
|
|
762
934
|
Parameters
|
|
@@ -774,6 +946,12 @@ class GlpiContentConverter:
|
|
|
774
946
|
Markdown, but Markdown that opens with an autolink or other
|
|
775
947
|
angle-bracketed text and carries inline HTML further on is read
|
|
776
948
|
as HTML, and loses that autolink.
|
|
949
|
+
rewrite_link : callable, optional
|
|
950
|
+
Called with a :class:`Link` for each link and image the read
|
|
951
|
+
meets outside code, an image before the link around it; its
|
|
952
|
+
answer decides what is written (:data:`RewriteLink`). Not called
|
|
953
|
+
for a value passed through as Markdown, nor for a body read as
|
|
954
|
+
its text. Without one, the read is what it always was.
|
|
777
955
|
|
|
778
956
|
Returns
|
|
779
957
|
-------
|
|
@@ -793,6 +971,10 @@ class GlpiContentConverter:
|
|
|
793
971
|
limit, where no stack is left even to report the failure as
|
|
794
972
|
``GlpiContentError`` (the user guide gives the measured
|
|
795
973
|
depths).
|
|
974
|
+
Exception
|
|
975
|
+
Whatever ``rewrite_link`` raised, unchanged; ``TypeError`` when it
|
|
976
|
+
answered something other than a :class:`Link`, a ``str`` or
|
|
977
|
+
``None``.
|
|
796
978
|
"""
|
|
797
979
|
|
|
798
980
|
content = str(value or "").strip()
|
|
@@ -808,22 +990,96 @@ class GlpiContentConverter:
|
|
|
808
990
|
# 3.11.14/3.12.12/3.13.6 rescans to the end for each (quadratic).
|
|
809
991
|
head, end, tail = content.rpartition(">")
|
|
810
992
|
content = head + end + tail.replace("<", "<")
|
|
993
|
+
token = _REWRITE.set(rewrite_link)
|
|
811
994
|
try:
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
995
|
+
return _converted(content)
|
|
996
|
+
except _CallbackRaised as raised:
|
|
997
|
+
callback_error = raised.error
|
|
998
|
+
finally:
|
|
999
|
+
_REWRITE.reset(token)
|
|
1000
|
+
raise callback_error # outside the handler: the callback's, as it raised it
|
|
1001
|
+
|
|
1002
|
+
@staticmethod
|
|
1003
|
+
def document_id_of(url: str) -> int | None:
|
|
1004
|
+
"""Return the document id a URL to GLPI's document page names.
|
|
1005
|
+
|
|
1006
|
+
``None`` unless ``url`` -- relative, rooted or absolute, with any
|
|
1007
|
+
path in front -- is ``front/document.send.php`` with exactly one
|
|
1008
|
+
``docid`` parameter, in any position, made of digits.
|
|
1009
|
+
"""
|
|
1010
|
+
|
|
1011
|
+
try:
|
|
1012
|
+
parts = urlsplit(url)
|
|
1013
|
+
except ValueError: # an unclosed "[" in a host, among others
|
|
1014
|
+
return None
|
|
1015
|
+
path = parts.path
|
|
1016
|
+
if path != _DOCUMENT_PAGE and not path.endswith("/" + _DOCUMENT_PAGE):
|
|
1017
|
+
return None
|
|
1018
|
+
values = parse_qs(parts.query).get("docid", [])
|
|
1019
|
+
if len(values) != 1 or not _DOCUMENT_ID.fullmatch(values[0]):
|
|
1020
|
+
return None
|
|
1021
|
+
document_id = int(values[0])
|
|
1022
|
+
return document_id if document_id > 0 else None
|
|
1023
|
+
|
|
1024
|
+
@staticmethod
|
|
1025
|
+
def document_image(
|
|
1026
|
+
document_id: int,
|
|
1027
|
+
*,
|
|
1028
|
+
alt: str = "",
|
|
1029
|
+
itemtype: str | None = None,
|
|
1030
|
+
items_id: int | None = None,
|
|
1031
|
+
) -> str:
|
|
1032
|
+
"""Return the Markdown of an image embedding one of GLPI's documents.
|
|
1033
|
+
|
|
1034
|
+
:meth:`to_transport` renders it as GLPI's editor writes a pasted image
|
|
1035
|
+
-- the ``<img>`` inside a link to the same URL,
|
|
1036
|
+
``/front/document.send.php?docid=<document_id>``, then
|
|
1037
|
+
``&itemtype=...&items_id=...`` when given -- and :meth:`from_transport`
|
|
1038
|
+
reads that back as this same Markdown: it is spelled by the reader
|
|
1039
|
+
itself. The URL is rooted, as the editor wrote it on the instance
|
|
1040
|
+
measured; GLPI checks the reader's right to the document when it
|
|
1041
|
+
serves it.
|
|
1042
|
+
|
|
1043
|
+
Parameters
|
|
1044
|
+
----------
|
|
1045
|
+
document_id : int
|
|
1046
|
+
The document's id.
|
|
1047
|
+
alt : str, optional
|
|
1048
|
+
The image's alternative text.
|
|
1049
|
+
itemtype, items_id : str and int, optional
|
|
1050
|
+
The item the document is attached to, such as ``"Ticket"`` and
|
|
1051
|
+
its id. Both or neither.
|
|
1052
|
+
|
|
1053
|
+
Raises
|
|
1054
|
+
------
|
|
1055
|
+
GlpiValidationError
|
|
1056
|
+
``document_id`` or ``items_id`` is not a positive ``int``,
|
|
1057
|
+
``itemtype`` is not a class name, or only one of the two was
|
|
1058
|
+
given.
|
|
1059
|
+
"""
|
|
1060
|
+
|
|
1061
|
+
if not _positive(document_id):
|
|
1062
|
+
raise GlpiValidationError(f"not a GLPI document id: {document_id!r}")
|
|
1063
|
+
url = f"/{_DOCUMENT_PAGE}?docid={document_id}"
|
|
1064
|
+
if (itemtype is None) != (items_id is None):
|
|
1065
|
+
raise GlpiValidationError(
|
|
1066
|
+
"itemtype and items_id go together: give both or neither"
|
|
1067
|
+
)
|
|
1068
|
+
if itemtype is not None:
|
|
1069
|
+
if not _ITEMTYPE.fullmatch(itemtype):
|
|
1070
|
+
raise GlpiValidationError(f"not a GLPI item type: {itemtype!r}")
|
|
1071
|
+
if not _positive(items_id):
|
|
1072
|
+
raise GlpiValidationError(f"not a GLPI item id: {items_id!r}")
|
|
1073
|
+
url += f"&itemtype={itemtype}&items_id={items_id}"
|
|
1074
|
+
native = (
|
|
1075
|
+
f'<p><a href="{escape(url)}" target="_blank">'
|
|
1076
|
+
f'<img src="{escape(url)}" alt="{escape(" ".join(alt.split()))}" /></a></p>'
|
|
1077
|
+
)
|
|
1078
|
+
token = _REWRITE.set(None) # a callback in force for an enclosing read
|
|
1079
|
+
try:
|
|
1080
|
+
return html_to_markdown(native)
|
|
1081
|
+
finally:
|
|
1082
|
+
_REWRITE.reset(token)
|
|
827
1083
|
|
|
828
1084
|
@staticmethod
|
|
829
1085
|
def to_transport(value: object) -> str:
|
|
@@ -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",
|
|
@@ -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
|
+
Version: 0.6.4
|
|
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=
|
|
1
|
+
glpi_python_client/__init__.py,sha256=IjuloET5lqy2KL5c3sgXRYxppjxcVAv7mZUK-DERRNA,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=
|
|
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=
|
|
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
|
|
@@ -94,14 +94,14 @@ glpi_python_client/_sync/clients/commons/_transport.py,sha256=x9xRFSUrJLPyp1DrVh
|
|
|
94
94
|
glpi_python_client/_sync/clients/custom/__init__.py,sha256=sxfGlHVulucP7bacWhUMi9qGJZaiZm8lXceY0OGqWio,882
|
|
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
|
-
glpi_python_client/content/__init__.py,sha256=
|
|
98
|
-
glpi_python_client/content/conversion.py,sha256=
|
|
99
|
-
glpi_python_client/models/__init__.py,sha256=
|
|
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=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=
|
|
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=
|
|
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
130
|
glpi_python_client/models/api_schema/management/_document.py,sha256=RzKF-rNlz8CsAMa2BAaiqHNEE3-m1mci7vtdARls4uw,4607
|
|
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.
|
|
139
|
-
glpi_python_client-0.6.
|
|
140
|
-
glpi_python_client-0.6.
|
|
141
|
-
glpi_python_client-0.6.
|
|
139
|
+
glpi_python_client-0.6.4.dist-info/METADATA,sha256=EFNn15U6Eqjua7bVLPM22W9N48EEMza3gq3f0--MsJs,8234
|
|
140
|
+
glpi_python_client-0.6.4.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
141
|
+
glpi_python_client-0.6.4.dist-info/licenses/LICENSE,sha256=nL8re-TIKkxCoL7OkSBja6EB4NsQR1HoOJgxv_MEhtc,1088
|
|
142
|
+
glpi_python_client-0.6.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|