glpi-python-client 0.1.0__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 +36 -0
- glpi_python_client/auth/__init__.py +11 -0
- glpi_python_client/auth/auth.py +310 -0
- glpi_python_client/auth/tests/test_auth.py +189 -0
- glpi_python_client/clients/__init__.py +18 -0
- glpi_python_client/clients/api_v1_session.py +460 -0
- glpi_python_client/clients/api_v2_client.py +317 -0
- glpi_python_client/clients/async_api_v2_client.py +236 -0
- glpi_python_client/clients/tests/__init__.py +5 -0
- glpi_python_client/clients/tests/test_api_v1_session.py +85 -0
- glpi_python_client/clients/tests/test_api_v2_client.py +349 -0
- glpi_python_client/clients/tests/test_async_api_v2_client.py +257 -0
- glpi_python_client/clients/v2/__init__.py +8 -0
- glpi_python_client/clients/v2/async_/__init__.py +12 -0
- glpi_python_client/clients/v2/async_/api.py +29 -0
- glpi_python_client/clients/v2/async_/directory.py +88 -0
- glpi_python_client/clients/v2/async_/documents.py +144 -0
- glpi_python_client/clients/v2/async_/team.py +125 -0
- glpi_python_client/clients/v2/async_/tests/__init__.py +5 -0
- glpi_python_client/clients/v2/async_/tests/test_directory.py +43 -0
- glpi_python_client/clients/v2/async_/tests/test_documents.py +41 -0
- glpi_python_client/clients/v2/async_/tests/test_team.py +44 -0
- glpi_python_client/clients/v2/async_/tests/test_tickets.py +174 -0
- glpi_python_client/clients/v2/async_/tests/test_timeline.py +126 -0
- glpi_python_client/clients/v2/async_/tickets.py +312 -0
- glpi_python_client/clients/v2/async_/timeline.py +312 -0
- glpi_python_client/clients/v2/async_/transport.py +251 -0
- glpi_python_client/clients/v2/common/__init__.py +6 -0
- glpi_python_client/clients/v2/common/client_config.py +219 -0
- glpi_python_client/clients/v2/common/constants.py +45 -0
- glpi_python_client/clients/v2/common/errors.py +23 -0
- glpi_python_client/clients/v2/common/filters.py +30 -0
- glpi_python_client/clients/v2/common/payloads.py +57 -0
- glpi_python_client/clients/v2/common/request_http.py +195 -0
- glpi_python_client/clients/v2/common/response_payloads.py +76 -0
- glpi_python_client/clients/v2/common/ticket_search.py +113 -0
- glpi_python_client/clients/v2/sync/__init__.py +12 -0
- glpi_python_client/clients/v2/sync/api.py +29 -0
- glpi_python_client/clients/v2/sync/directory.py +90 -0
- glpi_python_client/clients/v2/sync/documents.py +144 -0
- glpi_python_client/clients/v2/sync/team.py +125 -0
- glpi_python_client/clients/v2/sync/tests/__init__.py +5 -0
- glpi_python_client/clients/v2/sync/tests/test_directory.py +57 -0
- glpi_python_client/clients/v2/sync/tests/test_documents.py +99 -0
- glpi_python_client/clients/v2/sync/tests/test_team.py +64 -0
- glpi_python_client/clients/v2/sync/tests/test_tickets.py +430 -0
- glpi_python_client/clients/v2/sync/tests/test_timeline.py +77 -0
- glpi_python_client/clients/v2/sync/tests/test_transport.py +89 -0
- glpi_python_client/clients/v2/sync/tickets.py +312 -0
- glpi_python_client/clients/v2/sync/timeline.py +308 -0
- glpi_python_client/clients/v2/sync/transport.py +246 -0
- glpi_python_client/content/__init__.py +11 -0
- glpi_python_client/content/conversion.py +58 -0
- glpi_python_client/content/records/__init__.py +84 -0
- glpi_python_client/content/records/core/__init__.py +6 -0
- glpi_python_client/content/records/core/document_links.py +100 -0
- glpi_python_client/content/records/core/normalization.py +53 -0
- glpi_python_client/content/records/core/references.py +98 -0
- glpi_python_client/content/records/core/scalars.py +83 -0
- glpi_python_client/content/records/parsers/__init__.py +6 -0
- glpi_python_client/content/records/parsers/directory.py +62 -0
- glpi_python_client/content/records/parsers/documents.py +49 -0
- glpi_python_client/content/records/parsers/team.py +58 -0
- glpi_python_client/content/records/parsers/tests/__init__.py +5 -0
- glpi_python_client/content/records/parsers/tests/test_tickets.py +35 -0
- glpi_python_client/content/records/parsers/tests/test_timeline.py +20 -0
- glpi_python_client/content/records/parsers/tickets.py +96 -0
- glpi_python_client/content/records/parsers/timeline.py +119 -0
- glpi_python_client/content/tests/__init__.py +5 -0
- glpi_python_client/content/tests/test_conversion.py +13 -0
- glpi_python_client/models/__init__.py +30 -0
- glpi_python_client/models/_base.py +22 -0
- glpi_python_client/models/_payload.py +79 -0
- glpi_python_client/models/_shared.py +37 -0
- glpi_python_client/models/glpi/__init__.py +27 -0
- glpi_python_client/models/glpi/_document.py +59 -0
- glpi_python_client/models/glpi/_followup.py +77 -0
- glpi_python_client/models/glpi/_location.py +53 -0
- glpi_python_client/models/glpi/_solution.py +57 -0
- glpi_python_client/models/glpi/_task.py +41 -0
- glpi_python_client/models/glpi/_team_member.py +33 -0
- glpi_python_client/models/glpi/_ticket.py +303 -0
- glpi_python_client/models/glpi/_user.py +92 -0
- glpi_python_client/models/glpi/tests/__init__.py +5 -0
- glpi_python_client/models/glpi/tests/test__document.py +12 -0
- glpi_python_client/models/glpi/tests/test__followup.py +31 -0
- glpi_python_client/models/glpi/tests/test__location.py +12 -0
- glpi_python_client/models/glpi/tests/test__solution.py +11 -0
- glpi_python_client/models/glpi/tests/test__ticket.py +59 -0
- glpi_python_client/models/glpi/tests/test__user.py +29 -0
- glpi_python_client/py.typed +0 -0
- glpi_python_client/testing/__init__.py +27 -0
- glpi_python_client/testing/fixtures.py +52 -0
- glpi_python_client/testing/utils.py +149 -0
- glpi_python_client-0.1.0.dist-info/METADATA +144 -0
- glpi_python_client-0.1.0.dist-info/RECORD +98 -0
- glpi_python_client-0.1.0.dist-info/WHEEL +4 -0
- glpi_python_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Ticket search field and pagination helpers for GLPI v2 clients.
|
|
2
|
+
|
|
3
|
+
These helpers keep field merging, deleted-ticket filtering, and pagination math
|
|
4
|
+
in one place so sync and async search implementations behave the same way.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from glpi_python_client.content.records.parsers.tickets import (
|
|
12
|
+
_filter_visible_ticket_batch,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .constants import LIST_TICKET_CORE_FIELDS
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def merge_list_ticket_fields(fields: list[str] | None) -> list[str] | None:
|
|
19
|
+
"""Merge caller-requested list fields with required core ticket fields.
|
|
20
|
+
|
|
21
|
+
The helper preserves caller order while ensuring the fields needed for the
|
|
22
|
+
package's ticket parsing logic are always present.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
if fields is None:
|
|
26
|
+
return list(LIST_TICKET_CORE_FIELDS)
|
|
27
|
+
return list(dict.fromkeys([*fields, *LIST_TICKET_CORE_FIELDS]))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_deleted_ticket(record: dict[str, Any]) -> bool:
|
|
31
|
+
"""Return whether one raw GLPI ticket payload represents a deleted ticket.
|
|
32
|
+
|
|
33
|
+
This delegates to the shared batch filter so the single-record check uses
|
|
34
|
+
the exact same deleted-ticket rules as paginated search results.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
return _filter_visible_ticket_batch([record])[1] == 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_ticket_search_params(
|
|
41
|
+
*,
|
|
42
|
+
query: str | None = None,
|
|
43
|
+
fields: list[str] | None = None,
|
|
44
|
+
sort: str | None = None,
|
|
45
|
+
batch_size: int | None = None,
|
|
46
|
+
) -> dict[str, object]:
|
|
47
|
+
"""Build the request parameter mapping for a ticket search call.
|
|
48
|
+
|
|
49
|
+
Search pagination always starts at zero here, and optional query, field,
|
|
50
|
+
sort, and limit arguments are included only when the caller supplies them.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
params: dict[str, object] = {"start": 0}
|
|
54
|
+
if batch_size is not None:
|
|
55
|
+
params["limit"] = batch_size
|
|
56
|
+
if query:
|
|
57
|
+
params["filter"] = query
|
|
58
|
+
if fields:
|
|
59
|
+
params["fields"] = ",".join(fields)
|
|
60
|
+
if sort:
|
|
61
|
+
params["sort"] = sort
|
|
62
|
+
return params
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def filter_ticket_search_batch(
|
|
66
|
+
batch: list[object],
|
|
67
|
+
*,
|
|
68
|
+
include_deleted_ticket: bool,
|
|
69
|
+
) -> tuple[list[dict[str, Any]], int]:
|
|
70
|
+
"""Return visible ticket payloads and the number excluded as deleted.
|
|
71
|
+
|
|
72
|
+
When deleted tickets are allowed, the helper simply normalizes dictionary
|
|
73
|
+
items. Otherwise it defers to the shared deleted-ticket filter.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
if include_deleted_ticket:
|
|
77
|
+
return [dict(ticket) for ticket in batch if isinstance(ticket, dict)], 0
|
|
78
|
+
return _filter_visible_ticket_batch(batch)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def advance_ticket_search_pagination(
|
|
82
|
+
*,
|
|
83
|
+
current_start: int,
|
|
84
|
+
page_size: int,
|
|
85
|
+
content_range: str,
|
|
86
|
+
observed_page_size: int | None,
|
|
87
|
+
) -> tuple[int, int | None, bool]:
|
|
88
|
+
"""Return the next ticket page start and whether another page is needed.
|
|
89
|
+
|
|
90
|
+
The function prefers the server-provided ``Content-Range`` total when it is
|
|
91
|
+
available and otherwise falls back to observed page sizes to decide whether
|
|
92
|
+
iteration should continue.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
total_count: int | None = None
|
|
96
|
+
if content_range:
|
|
97
|
+
try:
|
|
98
|
+
total_count = int(content_range.split("/")[-1])
|
|
99
|
+
except (ValueError, IndexError):
|
|
100
|
+
total_count = None
|
|
101
|
+
|
|
102
|
+
next_start = current_start + page_size
|
|
103
|
+
if total_count is not None:
|
|
104
|
+
return next_start, observed_page_size, next_start < total_count
|
|
105
|
+
|
|
106
|
+
next_observed_page_size = observed_page_size
|
|
107
|
+
if next_observed_page_size is None:
|
|
108
|
+
next_observed_page_size = page_size
|
|
109
|
+
return (
|
|
110
|
+
next_start,
|
|
111
|
+
next_observed_page_size,
|
|
112
|
+
page_size >= next_observed_page_size,
|
|
113
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Synchronous GLPI v2 implementation exports.
|
|
2
|
+
|
|
3
|
+
The synchronous client is assembled from mixins grouped by endpoint area.
|
|
4
|
+
This package exposes the combined synchronous API mixin used by
|
|
5
|
+
``GlpiClient``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from glpi_python_client.clients.v2.sync.api import GlpiApiClientMixin
|
|
11
|
+
|
|
12
|
+
__all__ = ["GlpiApiClientMixin"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Synchronous GLPI v2 API mixin assembly.
|
|
2
|
+
|
|
3
|
+
This module combines the scope-focused synchronous mixins into the single API
|
|
4
|
+
surface consumed by the public ``GlpiClient`` class.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .directory import SyncDirectoryMixin
|
|
10
|
+
from .documents import SyncDocumentMixin
|
|
11
|
+
from .team import SyncTeamMixin
|
|
12
|
+
from .tickets import SyncTicketMixin
|
|
13
|
+
from .timeline import SyncTimelineMixin
|
|
14
|
+
from .transport import SyncTransportMixin
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class GlpiApiClientMixin(
|
|
18
|
+
SyncTicketMixin,
|
|
19
|
+
SyncTimelineMixin,
|
|
20
|
+
SyncDocumentMixin,
|
|
21
|
+
SyncTeamMixin,
|
|
22
|
+
SyncDirectoryMixin,
|
|
23
|
+
SyncTransportMixin,
|
|
24
|
+
):
|
|
25
|
+
"""Combined synchronous GLPI v2 client behavior.
|
|
26
|
+
|
|
27
|
+
The mixin class has no state of its own. It exists to provide a readable
|
|
28
|
+
composition point for the synchronous endpoint and transport helpers.
|
|
29
|
+
"""
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Synchronous user and location lookup operations for GLPI v2 clients.
|
|
2
|
+
|
|
3
|
+
This module contains the directory-style search helpers used to query users and
|
|
4
|
+
locations through the GLPI high-level API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from glpi_python_client.clients.v2.common.constants import (
|
|
10
|
+
LOCATION_ENDPOINT,
|
|
11
|
+
USER_ENDPOINT,
|
|
12
|
+
)
|
|
13
|
+
from glpi_python_client.clients.v2.common.filters import rsql_contains_filter
|
|
14
|
+
from glpi_python_client.clients.v2.common.response_payloads import list_payload_records
|
|
15
|
+
from glpi_python_client.content.records.core.scalars import _optional_text
|
|
16
|
+
from glpi_python_client.content.records.parsers.directory import (
|
|
17
|
+
_glpi_location_record,
|
|
18
|
+
_glpi_user_record,
|
|
19
|
+
)
|
|
20
|
+
from glpi_python_client.models import GlpiLocation, GlpiUser
|
|
21
|
+
|
|
22
|
+
from .transport import SyncTransportMixin
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SyncDirectoryMixin(SyncTransportMixin):
|
|
26
|
+
"""Synchronous GLPI user and location lookup helpers.
|
|
27
|
+
|
|
28
|
+
The methods in this mixin return typed directory models and hide the
|
|
29
|
+
filtering and payload-normalization details required by the GLPI API.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def search_users(
|
|
33
|
+
self,
|
|
34
|
+
rsql_filter: str = "",
|
|
35
|
+
*,
|
|
36
|
+
limit: int = 1,
|
|
37
|
+
start: int = 0,
|
|
38
|
+
skip_entity: bool = False,
|
|
39
|
+
) -> list[GlpiUser]:
|
|
40
|
+
"""Search GLPI users with an optional raw RSQL filter.
|
|
41
|
+
|
|
42
|
+
The method returns only records that include a usable GLPI user ID and
|
|
43
|
+
silently yields an empty list when the remote endpoint does not return a
|
|
44
|
+
success status.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
params: dict[str, object] = {"limit": limit, "start": start}
|
|
48
|
+
if rsql_filter:
|
|
49
|
+
params["filter"] = rsql_filter
|
|
50
|
+
response = self._get_request(
|
|
51
|
+
USER_ENDPOINT, params=params, skip_entity=skip_entity
|
|
52
|
+
)
|
|
53
|
+
if response.status_code not in (200, 206):
|
|
54
|
+
return []
|
|
55
|
+
return list_payload_records(
|
|
56
|
+
response.json(),
|
|
57
|
+
record_factory=lambda user: (
|
|
58
|
+
_glpi_user_record(user)
|
|
59
|
+
if _optional_text(user.get("id")) is not None
|
|
60
|
+
else None
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def search_locations(self, name: str) -> list[GlpiLocation]:
|
|
65
|
+
"""Search GLPI locations by name.
|
|
66
|
+
|
|
67
|
+
Blank names are filtered out locally, and non-success API responses are
|
|
68
|
+
normalized to an empty result list so callers can treat this as a lookup
|
|
69
|
+
helper rather than a strict mutation-style operation.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
location_filter = rsql_contains_filter("name", name)
|
|
73
|
+
if location_filter is None:
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
response = self._get_request(
|
|
77
|
+
LOCATION_ENDPOINT,
|
|
78
|
+
params={"filter": location_filter},
|
|
79
|
+
)
|
|
80
|
+
if response.status_code not in (200, 206):
|
|
81
|
+
return []
|
|
82
|
+
return list_payload_records(
|
|
83
|
+
response.json(),
|
|
84
|
+
record_factory=lambda location: (
|
|
85
|
+
_glpi_location_record(location)
|
|
86
|
+
if _optional_text(location.get("id")) is not None
|
|
87
|
+
and _optional_text(location.get("name")) is not None
|
|
88
|
+
else None
|
|
89
|
+
),
|
|
90
|
+
)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Synchronous document operations for GLPI v2 clients.
|
|
2
|
+
|
|
3
|
+
This module covers document listing, metadata lookup, binary download, and
|
|
4
|
+
deletion for ticket-linked GLPI documents.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
from tenacity import RetryError
|
|
14
|
+
|
|
15
|
+
from glpi_python_client.clients.v2.common.constants import (
|
|
16
|
+
DOCUMENT_SUFFIX,
|
|
17
|
+
TICKET_ENDPOINT,
|
|
18
|
+
GlpiId,
|
|
19
|
+
)
|
|
20
|
+
from glpi_python_client.clients.v2.common.errors import remote_error_message
|
|
21
|
+
from glpi_python_client.clients.v2.common.request_http import ensure_response_status
|
|
22
|
+
from glpi_python_client.clients.v2.common.response_payloads import (
|
|
23
|
+
timeline_payload_items,
|
|
24
|
+
)
|
|
25
|
+
from glpi_python_client.content.records.core.scalars import _optional_text
|
|
26
|
+
from glpi_python_client.content.records.parsers.documents import _glpi_document_record
|
|
27
|
+
from glpi_python_client.models import GlpiDocument
|
|
28
|
+
|
|
29
|
+
from .transport import SyncTransportMixin
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SyncDocumentMixin(SyncTransportMixin):
|
|
35
|
+
"""Synchronous GLPI ticket document helpers.
|
|
36
|
+
|
|
37
|
+
These helpers translate document-related GLPI payloads into typed document
|
|
38
|
+
models and keep metadata enrichment behavior consistent across calls.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def get_document_records(
|
|
42
|
+
self,
|
|
43
|
+
ticket_id: GlpiId,
|
|
44
|
+
*,
|
|
45
|
+
enrich_metadata: bool = True,
|
|
46
|
+
) -> list[GlpiDocument]:
|
|
47
|
+
"""Fetch the documents linked to one ticket.
|
|
48
|
+
|
|
49
|
+
When metadata enrichment is enabled, the method performs per-document
|
|
50
|
+
lookups and logs skipped enrichments without failing the whole batch.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{DOCUMENT_SUFFIX}"
|
|
54
|
+
response = self._get_request(endpoint)
|
|
55
|
+
if response.status_code not in (200, 206):
|
|
56
|
+
logger.warning(
|
|
57
|
+
"Failed to get documents for ticket %s: %s",
|
|
58
|
+
ticket_id,
|
|
59
|
+
response.status_code,
|
|
60
|
+
)
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
records: list[GlpiDocument] = []
|
|
64
|
+
for relation in timeline_payload_items(response.json()):
|
|
65
|
+
document_id = _optional_text(
|
|
66
|
+
relation.get("documents_id")
|
|
67
|
+
) or _optional_text(relation.get("id"))
|
|
68
|
+
if document_id is None:
|
|
69
|
+
continue
|
|
70
|
+
metadata: dict[str, Any] = {}
|
|
71
|
+
if enrich_metadata:
|
|
72
|
+
try:
|
|
73
|
+
document_record = self.get_document_record(document_id)
|
|
74
|
+
metadata = {
|
|
75
|
+
"id": document_record.document_id,
|
|
76
|
+
"filename": document_record.filename,
|
|
77
|
+
"mime": document_record.mime_type,
|
|
78
|
+
}
|
|
79
|
+
except (RetryError, ValueError, requests.RequestException) as exc:
|
|
80
|
+
logger.warning(
|
|
81
|
+
"Skipping GLPI document %s metadata lookup: %s",
|
|
82
|
+
document_id,
|
|
83
|
+
remote_error_message(exc),
|
|
84
|
+
)
|
|
85
|
+
records.append(_glpi_document_record(relation, metadata=metadata))
|
|
86
|
+
return records
|
|
87
|
+
|
|
88
|
+
def get_document_record(self, document_id: GlpiId) -> GlpiDocument:
|
|
89
|
+
"""Fetch one GLPI document record by identifier.
|
|
90
|
+
|
|
91
|
+
The payload must be a mapping and is converted into a typed
|
|
92
|
+
``GlpiDocument`` instance before being returned.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
response = self._get_request(
|
|
96
|
+
f"Management/Document/{document_id}",
|
|
97
|
+
skip_entity=True,
|
|
98
|
+
)
|
|
99
|
+
if response.status_code not in (200, 206):
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"Failed to get document {document_id}: "
|
|
102
|
+
f"{response.status_code} {response.text[:200]}"
|
|
103
|
+
)
|
|
104
|
+
payload = response.json()
|
|
105
|
+
if not isinstance(payload, dict):
|
|
106
|
+
raise ValueError(f"Unexpected GLPI document payload for {document_id}")
|
|
107
|
+
return _glpi_document_record(payload)
|
|
108
|
+
|
|
109
|
+
def download_document_content(self, document_id: GlpiId) -> bytes:
|
|
110
|
+
"""Download the raw binary payload for one GLPI document.
|
|
111
|
+
|
|
112
|
+
This method is intended for file-content retrieval and returns the raw
|
|
113
|
+
response bytes without additional decoding.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
response = self._get_request(
|
|
117
|
+
f"Management/Document/{document_id}/Download",
|
|
118
|
+
skip_entity=True,
|
|
119
|
+
)
|
|
120
|
+
if response.status_code != 200:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
f"Failed to download document {document_id}: "
|
|
123
|
+
f"{response.status_code} {response.text[:200]}"
|
|
124
|
+
)
|
|
125
|
+
return response.content
|
|
126
|
+
|
|
127
|
+
def delete_document(self, document_id: GlpiId) -> None:
|
|
128
|
+
"""Delete one GLPI document by identifier.
|
|
129
|
+
|
|
130
|
+
Successful deletes return ``None`` and are logged so document cleanup
|
|
131
|
+
workflows can be traced in client logs.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
response = self._delete_request(
|
|
135
|
+
f"Management/Document/{document_id}",
|
|
136
|
+
skip_entity=True,
|
|
137
|
+
)
|
|
138
|
+
ensure_response_status(
|
|
139
|
+
response,
|
|
140
|
+
success_statuses=(200, 204),
|
|
141
|
+
failure_message=f"Failed to delete document {document_id}",
|
|
142
|
+
)
|
|
143
|
+
logger.info("GLPI API deleted document %s", document_id)
|
|
144
|
+
return None
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Synchronous team member operations for GLPI v2 clients.
|
|
2
|
+
|
|
3
|
+
This module provides the ticket team-member helpers that list, add, and remove
|
|
4
|
+
assigned members through the GLPI API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from glpi_python_client.clients.v2.common.constants import (
|
|
12
|
+
TEAM_MEMBER_SUFFIX,
|
|
13
|
+
TICKET_ENDPOINT,
|
|
14
|
+
GlpiId,
|
|
15
|
+
)
|
|
16
|
+
from glpi_python_client.clients.v2.common.payloads import build_team_member_payload
|
|
17
|
+
from glpi_python_client.clients.v2.common.request_http import ensure_response_status
|
|
18
|
+
from glpi_python_client.clients.v2.common.response_payloads import list_payload_records
|
|
19
|
+
from glpi_python_client.content.records.parsers.team import _glpi_team_member_record
|
|
20
|
+
from glpi_python_client.models import GlpiTeamMember
|
|
21
|
+
|
|
22
|
+
from .transport import SyncTransportMixin
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SyncTeamMixin(SyncTransportMixin):
|
|
28
|
+
"""Synchronous GLPI ticket team member helpers.
|
|
29
|
+
|
|
30
|
+
The mixin keeps team-member payload construction and response handling out
|
|
31
|
+
of the public client class.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def get_team_member_records(self, ticket_id: GlpiId) -> list[GlpiTeamMember]:
|
|
35
|
+
"""Fetch the team members currently linked to one ticket.
|
|
36
|
+
|
|
37
|
+
Non-success list responses are treated as empty results and logged as a
|
|
38
|
+
warning rather than raising immediately.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}"
|
|
42
|
+
response = self._get_request(endpoint)
|
|
43
|
+
if response.status_code not in (200, 206):
|
|
44
|
+
logger.warning(
|
|
45
|
+
"Failed to get team members for ticket %s: %s",
|
|
46
|
+
ticket_id,
|
|
47
|
+
response.status_code,
|
|
48
|
+
)
|
|
49
|
+
return []
|
|
50
|
+
return list_payload_records(
|
|
51
|
+
response.json(),
|
|
52
|
+
record_factory=_glpi_team_member_record,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def add_team_member(
|
|
56
|
+
self,
|
|
57
|
+
ticket_id: GlpiId,
|
|
58
|
+
member: GlpiTeamMember,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Add one team member to a GLPI ticket.
|
|
61
|
+
|
|
62
|
+
The method builds the API payload from the typed member object and logs
|
|
63
|
+
the resulting membership change on success.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}"
|
|
67
|
+
payload = build_team_member_payload(
|
|
68
|
+
member_type=member.member_type,
|
|
69
|
+
member_id=member.member_id,
|
|
70
|
+
role=member.role,
|
|
71
|
+
)
|
|
72
|
+
response = self._post_request(endpoint, payload)
|
|
73
|
+
ensure_response_status(
|
|
74
|
+
response,
|
|
75
|
+
success_statuses=(200, 201),
|
|
76
|
+
failure_message=(
|
|
77
|
+
"Failed to add team member "
|
|
78
|
+
f"{member.member_type}:{member.member_id} "
|
|
79
|
+
f"role={member.role} on ticket {ticket_id}"
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
logger.info(
|
|
83
|
+
"GLPI API added team member %s:%s role=%s on ticket %s",
|
|
84
|
+
member.member_type,
|
|
85
|
+
member.member_id,
|
|
86
|
+
member.role,
|
|
87
|
+
ticket_id,
|
|
88
|
+
)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def remove_team_member(
|
|
92
|
+
self,
|
|
93
|
+
ticket_id: GlpiId,
|
|
94
|
+
member: GlpiTeamMember,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Remove one team member from a GLPI ticket.
|
|
97
|
+
|
|
98
|
+
The payload shape mirrors team-member creation so the same member model
|
|
99
|
+
can be used for both add and remove workflows.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}"
|
|
103
|
+
payload = build_team_member_payload(
|
|
104
|
+
member_type=member.member_type,
|
|
105
|
+
member_id=member.member_id,
|
|
106
|
+
role=member.role,
|
|
107
|
+
)
|
|
108
|
+
response = self._delete_request(endpoint, payload)
|
|
109
|
+
ensure_response_status(
|
|
110
|
+
response,
|
|
111
|
+
success_statuses=(200, 204),
|
|
112
|
+
failure_message=(
|
|
113
|
+
"Failed to remove team member "
|
|
114
|
+
f"{member.member_type}:{member.member_id} "
|
|
115
|
+
f"role={member.role} from ticket {ticket_id}"
|
|
116
|
+
),
|
|
117
|
+
)
|
|
118
|
+
logger.info(
|
|
119
|
+
"GLPI API removed team member %s:%s role=%s from ticket %s",
|
|
120
|
+
member.member_type,
|
|
121
|
+
member.member_id,
|
|
122
|
+
member.role,
|
|
123
|
+
ticket_id,
|
|
124
|
+
)
|
|
125
|
+
return None
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from glpi_python_client import GlpiClient
|
|
8
|
+
from glpi_python_client.testing.utils import SearchResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_search_locations_escapes_query_text(
|
|
12
|
+
client_factory: Callable[..., GlpiClient],
|
|
13
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
14
|
+
) -> None:
|
|
15
|
+
client = client_factory()
|
|
16
|
+
observed_params: dict[str, object] | None = None
|
|
17
|
+
|
|
18
|
+
def fake_get_request(
|
|
19
|
+
endpoint: str,
|
|
20
|
+
params: dict[str, object] | None = None,
|
|
21
|
+
skip_entity: bool = False,
|
|
22
|
+
) -> SearchResponse:
|
|
23
|
+
nonlocal observed_params
|
|
24
|
+
assert endpoint == "Dropdowns/Location"
|
|
25
|
+
assert skip_entity is False
|
|
26
|
+
observed_params = params
|
|
27
|
+
return SearchResponse([])
|
|
28
|
+
|
|
29
|
+
monkeypatch.setattr(client, "_get_request", fake_get_request)
|
|
30
|
+
try:
|
|
31
|
+
client.search_locations('Paris "HQ" *East\\West')
|
|
32
|
+
|
|
33
|
+
assert observed_params == {
|
|
34
|
+
"filter": 'name=like="*Paris \\"HQ\\" \\*East\\\\West*"'
|
|
35
|
+
}
|
|
36
|
+
finally:
|
|
37
|
+
client.close()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_search_locations_returns_empty_for_blank_query(
|
|
41
|
+
client_factory: Callable[..., GlpiClient],
|
|
42
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
43
|
+
) -> None:
|
|
44
|
+
client = client_factory()
|
|
45
|
+
|
|
46
|
+
def fake_get_request(
|
|
47
|
+
endpoint: str,
|
|
48
|
+
params: dict[str, object] | None = None,
|
|
49
|
+
skip_entity: bool = False,
|
|
50
|
+
) -> SearchResponse:
|
|
51
|
+
raise AssertionError("blank location search should not call GLPI")
|
|
52
|
+
|
|
53
|
+
monkeypatch.setattr(client, "_get_request", fake_get_request)
|
|
54
|
+
try:
|
|
55
|
+
assert client.search_locations(" ") == []
|
|
56
|
+
finally:
|
|
57
|
+
client.close()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from glpi_python_client import GlpiClient
|
|
8
|
+
from glpi_python_client.testing.utils import FakeResponse, SearchResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _empty_response() -> FakeResponse:
|
|
12
|
+
return FakeResponse(status_code=204, payload={}, text="", content=b"")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_get_document_records_can_skip_metadata_enrichment(
|
|
16
|
+
client_factory: Callable[..., GlpiClient],
|
|
17
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
18
|
+
) -> None:
|
|
19
|
+
client = client_factory()
|
|
20
|
+
metadata_calls: list[str] = []
|
|
21
|
+
|
|
22
|
+
def fake_get_request(
|
|
23
|
+
endpoint: str,
|
|
24
|
+
params: dict[str, object] | None = None,
|
|
25
|
+
skip_entity: bool = False,
|
|
26
|
+
) -> SearchResponse:
|
|
27
|
+
assert endpoint == "Assistance/Ticket/321/Timeline/Document"
|
|
28
|
+
assert params is None
|
|
29
|
+
assert skip_entity is False
|
|
30
|
+
return SearchResponse([{"documents_id": 654, "name": "diagnostic.txt"}])
|
|
31
|
+
|
|
32
|
+
def fake_get_document_record(document_id: str | int) -> None:
|
|
33
|
+
metadata_calls.append(str(document_id))
|
|
34
|
+
|
|
35
|
+
monkeypatch.setattr(client, "_get_request", fake_get_request)
|
|
36
|
+
monkeypatch.setattr(client, "get_document_record", fake_get_document_record)
|
|
37
|
+
try:
|
|
38
|
+
documents = client.get_document_records(321, enrich_metadata=False)
|
|
39
|
+
|
|
40
|
+
assert metadata_calls == []
|
|
41
|
+
assert documents[0].document_id == "654"
|
|
42
|
+
assert documents[0].filename == "diagnostic.txt"
|
|
43
|
+
finally:
|
|
44
|
+
client.close()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_get_document_records_logs_metadata_enrichment_failures(
|
|
48
|
+
client_factory: Callable[..., GlpiClient],
|
|
49
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
50
|
+
caplog: pytest.LogCaptureFixture,
|
|
51
|
+
) -> None:
|
|
52
|
+
client = client_factory()
|
|
53
|
+
|
|
54
|
+
def fake_get_request(
|
|
55
|
+
endpoint: str,
|
|
56
|
+
params: dict[str, object] | None = None,
|
|
57
|
+
skip_entity: bool = False,
|
|
58
|
+
) -> SearchResponse:
|
|
59
|
+
assert endpoint == "Assistance/Ticket/321/Timeline/Document"
|
|
60
|
+
assert params is None
|
|
61
|
+
assert skip_entity is False
|
|
62
|
+
return SearchResponse([{"documents_id": 654, "name": "diagnostic.txt"}])
|
|
63
|
+
|
|
64
|
+
def fake_get_document_record(document_id: str | int) -> None:
|
|
65
|
+
raise ValueError(f"document {document_id} unavailable")
|
|
66
|
+
|
|
67
|
+
monkeypatch.setattr(client, "_get_request", fake_get_request)
|
|
68
|
+
monkeypatch.setattr(client, "get_document_record", fake_get_document_record)
|
|
69
|
+
caplog.set_level("WARNING", logger="glpi_python_client.clients.v2.sync")
|
|
70
|
+
try:
|
|
71
|
+
documents = client.get_document_records(321)
|
|
72
|
+
|
|
73
|
+
assert documents[0].document_id == "654"
|
|
74
|
+
assert "Skipping GLPI document 654 metadata lookup" in caplog.text
|
|
75
|
+
finally:
|
|
76
|
+
client.close()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_delete_document_returns_none(
|
|
80
|
+
client_factory: Callable[..., GlpiClient],
|
|
81
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
82
|
+
) -> None:
|
|
83
|
+
client = client_factory()
|
|
84
|
+
|
|
85
|
+
def fake_delete_request(
|
|
86
|
+
endpoint: str,
|
|
87
|
+
payload: dict[str, object] | None = None,
|
|
88
|
+
skip_entity: bool = False,
|
|
89
|
+
) -> FakeResponse:
|
|
90
|
+
assert endpoint == "Management/Document/777"
|
|
91
|
+
assert payload is None
|
|
92
|
+
assert skip_entity is True
|
|
93
|
+
return _empty_response()
|
|
94
|
+
|
|
95
|
+
monkeypatch.setattr(client, "_delete_request", fake_delete_request)
|
|
96
|
+
try:
|
|
97
|
+
client.delete_document(777)
|
|
98
|
+
finally:
|
|
99
|
+
client.close()
|