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,219 @@
|
|
|
1
|
+
"""Configuration and resource setup for GLPI v2 clients.
|
|
2
|
+
|
|
3
|
+
This module centralizes environment parsing, URL normalization, SSL warning
|
|
4
|
+
behavior, and the construction of shared runtime resources used by both the
|
|
5
|
+
sync and async high-level clients.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
import urllib3
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from glpi_python_client.auth.auth import GLPITokenManager
|
|
19
|
+
from glpi_python_client.clients.api_v1_session import GLPIV1Session
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class ClientResources:
|
|
24
|
+
"""Runtime resources shared by sync and async v2 clients.
|
|
25
|
+
|
|
26
|
+
The concrete client classes use this immutable bundle to keep setup logic
|
|
27
|
+
centralized while still owning the resource lifecycle themselves.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
glpi_api_url: str
|
|
31
|
+
session: requests.Session
|
|
32
|
+
auth: GLPITokenManager
|
|
33
|
+
v1: GLPIV1Session | None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def configure_ssl_warning_policy(*, verify_ssl: bool) -> None:
|
|
37
|
+
"""Adjust insecure-request warning behavior for the configured SSL policy.
|
|
38
|
+
|
|
39
|
+
When certificate verification is disabled, urllib3 warnings are muted so
|
|
40
|
+
callers do not get repeated noise from every request made by the client.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
if verify_ssl:
|
|
44
|
+
return
|
|
45
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_v2_client_resources(
|
|
49
|
+
*,
|
|
50
|
+
glpi_api_url: object,
|
|
51
|
+
client_name: str,
|
|
52
|
+
client_id: str | None,
|
|
53
|
+
client_secret: str | None,
|
|
54
|
+
username: str | None,
|
|
55
|
+
password: str | None,
|
|
56
|
+
verify_ssl: bool,
|
|
57
|
+
auth_token_refresh: int | None,
|
|
58
|
+
v1_base_url: str | None,
|
|
59
|
+
v1_user_token: str | None,
|
|
60
|
+
v1_app_token: str | None,
|
|
61
|
+
) -> ClientResources:
|
|
62
|
+
"""Build the shared resources required by a v2 client instance.
|
|
63
|
+
|
|
64
|
+
This includes the normalized API URL, the shared ``requests`` session, the
|
|
65
|
+
OAuth token manager, and the optional legacy v1 session used for document
|
|
66
|
+
uploads.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
from glpi_python_client.auth.auth import GLPITokenManager
|
|
70
|
+
from glpi_python_client.clients.api_v1_session import GLPIV1Session
|
|
71
|
+
|
|
72
|
+
normalized_api_url = normalize_client_api_url(
|
|
73
|
+
glpi_api_url,
|
|
74
|
+
client_name=client_name,
|
|
75
|
+
)
|
|
76
|
+
validate_v1_document_config(
|
|
77
|
+
v1_base_url=v1_base_url,
|
|
78
|
+
v1_user_token=v1_user_token,
|
|
79
|
+
)
|
|
80
|
+
configure_ssl_warning_policy(verify_ssl=verify_ssl)
|
|
81
|
+
|
|
82
|
+
session = requests.Session()
|
|
83
|
+
session.verify = verify_ssl
|
|
84
|
+
try:
|
|
85
|
+
auth = GLPITokenManager(
|
|
86
|
+
token_url=f"{normalized_api_url}/token",
|
|
87
|
+
client_id=client_id,
|
|
88
|
+
client_secret=client_secret,
|
|
89
|
+
username=username,
|
|
90
|
+
password=password,
|
|
91
|
+
session=session,
|
|
92
|
+
auth_token_refresh=auth_token_refresh,
|
|
93
|
+
)
|
|
94
|
+
except Exception:
|
|
95
|
+
session.close()
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
v1: GLPIV1Session | None = None
|
|
99
|
+
if v1_base_url and v1_user_token:
|
|
100
|
+
v1 = GLPIV1Session(
|
|
101
|
+
base_url=v1_base_url,
|
|
102
|
+
user_token=v1_user_token,
|
|
103
|
+
app_token=v1_app_token or "",
|
|
104
|
+
verify_ssl=verify_ssl,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return ClientResources(
|
|
108
|
+
glpi_api_url=normalized_api_url,
|
|
109
|
+
session=session,
|
|
110
|
+
auth=auth,
|
|
111
|
+
v1=v1,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def parse_optional_env_int(value: object) -> int | None:
|
|
116
|
+
"""Parse one optional integer from an environment-style value.
|
|
117
|
+
|
|
118
|
+
``None`` is preserved, native integers are accepted as-is, and strings are
|
|
119
|
+
converted through ``int()`` so explicit overrides and environment values
|
|
120
|
+
follow the same normalization path.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
if value is None:
|
|
124
|
+
return None
|
|
125
|
+
if isinstance(value, int):
|
|
126
|
+
return value
|
|
127
|
+
if isinstance(value, str):
|
|
128
|
+
return int(value)
|
|
129
|
+
raise TypeError("Integer environment values must be strings or integers")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def parse_optional_env_bool(value: object, *, default: bool) -> bool:
|
|
133
|
+
"""Parse one optional boolean from an environment-style value.
|
|
134
|
+
|
|
135
|
+
String values follow the conventional true and false spellings accepted by
|
|
136
|
+
the package configuration helpers, while ``None`` falls back to the caller
|
|
137
|
+
provided default.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
if value is None:
|
|
141
|
+
return default
|
|
142
|
+
if isinstance(value, bool):
|
|
143
|
+
return value
|
|
144
|
+
if not isinstance(value, str):
|
|
145
|
+
raise TypeError("Boolean environment values must be strings or booleans")
|
|
146
|
+
if value.casefold() in {"1", "true", "yes", "on"}:
|
|
147
|
+
return True
|
|
148
|
+
if value.casefold() in {"0", "false", "no", "off"}:
|
|
149
|
+
return False
|
|
150
|
+
raise ValueError(f"Invalid boolean environment value: {value!r}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def build_client_env_config(
|
|
154
|
+
*,
|
|
155
|
+
prefix: str,
|
|
156
|
+
env: Mapping[str, str],
|
|
157
|
+
overrides: Mapping[str, object],
|
|
158
|
+
) -> dict[str, object]:
|
|
159
|
+
"""Build common GLPI client config values from environment variables.
|
|
160
|
+
|
|
161
|
+
The returned mapping matches the constructor keyword arguments accepted by
|
|
162
|
+
both public client classes, making it suitable for direct unpacking.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
config: dict[str, object] = {
|
|
166
|
+
"glpi_api_url": env.get(f"{prefix}API_URL"),
|
|
167
|
+
"client_id": env.get(f"{prefix}CLIENT_ID"),
|
|
168
|
+
"client_secret": env.get(f"{prefix}CLIENT_SECRET"),
|
|
169
|
+
"username": env.get(f"{prefix}USERNAME"),
|
|
170
|
+
"password": env.get(f"{prefix}PASSWORD"),
|
|
171
|
+
"glpi_entity": parse_optional_env_int(env.get(f"{prefix}ENTITY")),
|
|
172
|
+
"glpi_profile": parse_optional_env_int(env.get(f"{prefix}PROFILE")),
|
|
173
|
+
"entity_recursive": parse_optional_env_bool(
|
|
174
|
+
env.get(f"{prefix}ENTITY_RECURSIVE"),
|
|
175
|
+
default=False,
|
|
176
|
+
),
|
|
177
|
+
"language": env.get(f"{prefix}LANGUAGE") or "en_GB",
|
|
178
|
+
"verify_ssl": parse_optional_env_bool(
|
|
179
|
+
env.get(f"{prefix}VERIFY_SSL"),
|
|
180
|
+
default=True,
|
|
181
|
+
),
|
|
182
|
+
"auth_token_refresh": parse_optional_env_int(
|
|
183
|
+
env.get(f"{prefix}AUTH_TOKEN_REFRESH")
|
|
184
|
+
),
|
|
185
|
+
"v1_base_url": env.get(f"{prefix}V1_BASE_URL"),
|
|
186
|
+
"v1_user_token": env.get(f"{prefix}V1_USER_TOKEN"),
|
|
187
|
+
"v1_app_token": env.get(f"{prefix}V1_APP_TOKEN"),
|
|
188
|
+
}
|
|
189
|
+
config.update(overrides)
|
|
190
|
+
return config
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def normalize_client_api_url(glpi_api_url: object, *, client_name: str) -> str:
|
|
194
|
+
"""Validate and normalize the configured GLPI API base URL.
|
|
195
|
+
|
|
196
|
+
The helper rejects missing or non-string values early and strips a trailing
|
|
197
|
+
slash so endpoint assembly remains consistent across the client codebase.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
if not isinstance(glpi_api_url, str) or not glpi_api_url:
|
|
201
|
+
raise ValueError(f"{client_name} requires glpi_api_url")
|
|
202
|
+
return glpi_api_url.rstrip("/")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def validate_v1_document_config(
|
|
206
|
+
*,
|
|
207
|
+
v1_base_url: str | None,
|
|
208
|
+
v1_user_token: str | None,
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Validate the paired legacy v1 document configuration values.
|
|
211
|
+
|
|
212
|
+
Document uploads require both the legacy base URL and the user token. This
|
|
213
|
+
helper rejects partial configuration before a client is constructed.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
if bool(v1_base_url) != bool(v1_user_token):
|
|
217
|
+
raise ValueError(
|
|
218
|
+
"GLPI v1 document support requires both v1_base_url and v1_user_token."
|
|
219
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""GLPI v2 endpoint names and shared type aliases.
|
|
2
|
+
|
|
3
|
+
This module keeps string constants and lightweight aliases in one place so the
|
|
4
|
+
client layers can share endpoint paths and request parameter types without
|
|
5
|
+
repeating literals.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TypeAlias
|
|
11
|
+
|
|
12
|
+
GlpiId: TypeAlias = str | int
|
|
13
|
+
RequestParamValue: TypeAlias = str | int | float | bytes | None
|
|
14
|
+
|
|
15
|
+
TICKET_ENDPOINT = "Assistance/Ticket"
|
|
16
|
+
FOLLOWUP_SUFFIX = "Timeline/Followup"
|
|
17
|
+
TASK_SUFFIX = "Timeline/Task"
|
|
18
|
+
SOLUTION_SUFFIX = "Timeline/Solution"
|
|
19
|
+
DOCUMENT_SUFFIX = "Timeline/Document"
|
|
20
|
+
TEAM_MEMBER_SUFFIX = "TeamMember"
|
|
21
|
+
USER_ENDPOINT = "Administration/User"
|
|
22
|
+
LOCATION_ENDPOINT = "Dropdowns/Location"
|
|
23
|
+
|
|
24
|
+
LIST_TICKET_CORE_FIELDS = [
|
|
25
|
+
"id",
|
|
26
|
+
"name",
|
|
27
|
+
"content",
|
|
28
|
+
"is_deleted",
|
|
29
|
+
"status",
|
|
30
|
+
"urgency",
|
|
31
|
+
"impact",
|
|
32
|
+
"priority",
|
|
33
|
+
"type",
|
|
34
|
+
"external_id",
|
|
35
|
+
"date_creation",
|
|
36
|
+
"date_mod",
|
|
37
|
+
"date_close",
|
|
38
|
+
"category",
|
|
39
|
+
"entity",
|
|
40
|
+
"location",
|
|
41
|
+
"request_type",
|
|
42
|
+
"team",
|
|
43
|
+
"user_recipient",
|
|
44
|
+
"user_editor",
|
|
45
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Error formatting helpers for GLPI v2 client operations.
|
|
2
|
+
|
|
3
|
+
These helpers normalize exception messages so retry wrappers and direct remote
|
|
4
|
+
call failures surface a readable error string to higher-level client methods.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from tenacity import RetryError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def remote_error_message(exc: Exception) -> str:
|
|
13
|
+
"""Return a readable message for one remote-call exception.
|
|
14
|
+
|
|
15
|
+
``tenacity.RetryError`` instances are unwrapped to expose the underlying
|
|
16
|
+
failure message instead of the retry wrapper representation.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
if isinstance(exc, RetryError):
|
|
20
|
+
inner_exception = exc.last_attempt.exception()
|
|
21
|
+
if isinstance(inner_exception, Exception):
|
|
22
|
+
return str(inner_exception)
|
|
23
|
+
return str(exc)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""RSQL filter helpers for GLPI v2 search endpoints.
|
|
2
|
+
|
|
3
|
+
The high-level client uses these helpers to build safe text-search filters for
|
|
4
|
+
GLPI endpoints that accept RSQL-like query expressions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def rsql_contains_filter(field: str, value: str) -> str | None:
|
|
11
|
+
"""Build a contains-style RSQL filter for one text field.
|
|
12
|
+
|
|
13
|
+
Blank search input returns ``None`` so callers can skip adding the filter,
|
|
14
|
+
while non-empty input is escaped before being wrapped in wildcard syntax.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
text = value.strip()
|
|
18
|
+
if not text:
|
|
19
|
+
return None
|
|
20
|
+
return f'{field}=like="*{escape_rsql_like_value(text)}*"'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def escape_rsql_like_value(value: str) -> str:
|
|
24
|
+
"""Escape user text embedded in a quoted RSQL ``like`` value.
|
|
25
|
+
|
|
26
|
+
The helper protects backslashes, quotes, and wildcard characters so caller
|
|
27
|
+
input is treated as text instead of modifying the filter expression.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
return value.replace("\\", "\\\\").replace('"', '\\"').replace("*", "\\*")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Request payload builders for GLPI v2 client operations.
|
|
2
|
+
|
|
3
|
+
These helpers keep small but repeated mutation payload rules out of the public
|
|
4
|
+
client methods so sync and async implementations can share them directly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .constants import GlpiId
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_team_member_payload(
|
|
13
|
+
*,
|
|
14
|
+
member_type: str,
|
|
15
|
+
member_id: int,
|
|
16
|
+
role: str,
|
|
17
|
+
) -> dict[str, object]:
|
|
18
|
+
"""Build the API payload used to add or remove one team member.
|
|
19
|
+
|
|
20
|
+
The returned mapping matches the shape expected by the GLPI team-member
|
|
21
|
+
endpoint for both creation and removal workflows.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
"type": member_type,
|
|
26
|
+
"id": member_id,
|
|
27
|
+
"role": role,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def prepare_document_upload(
|
|
32
|
+
*,
|
|
33
|
+
ticket_id: GlpiId | None,
|
|
34
|
+
filename: str | None,
|
|
35
|
+
content: bytes | None,
|
|
36
|
+
mime_type: str | None,
|
|
37
|
+
) -> tuple[int, str, bytes, str, str]:
|
|
38
|
+
"""Validate a document upload request and return normalized upload data.
|
|
39
|
+
|
|
40
|
+
This helper enforces the package-level upload prerequisites and converts the
|
|
41
|
+
mixed model fields into the concrete values required by the legacy v1 upload
|
|
42
|
+
API.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
if ticket_id is None:
|
|
46
|
+
raise ValueError("GLPI document upload requires a ticket_id")
|
|
47
|
+
if filename is None:
|
|
48
|
+
raise ValueError("GLPI document upload requires a filename")
|
|
49
|
+
if content is None:
|
|
50
|
+
raise ValueError("GLPI document upload requires file content")
|
|
51
|
+
|
|
52
|
+
parsed_ticket_id = int(ticket_id)
|
|
53
|
+
document_name = f"Document ticket {parsed_ticket_id}"
|
|
54
|
+
effective_mime_type = (
|
|
55
|
+
mime_type if mime_type is not None else "application/octet-stream"
|
|
56
|
+
)
|
|
57
|
+
return parsed_ticket_id, filename, content, effective_mime_type, document_name
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""HTTP request and response helpers for GLPI v2 clients.
|
|
2
|
+
|
|
3
|
+
This module contains the small transport-agnostic helpers used by both sync
|
|
4
|
+
and async request layers to normalize parameters, assemble headers, and handle
|
|
5
|
+
common response validation rules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
from glpi_python_client.content.records.core.scalars import _optional_text
|
|
16
|
+
|
|
17
|
+
from .constants import RequestParamValue
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def request_params(
|
|
21
|
+
params: dict[str, object] | None,
|
|
22
|
+
) -> dict[str, RequestParamValue] | None:
|
|
23
|
+
"""Normalize query parameters into ``requests``-compatible values.
|
|
24
|
+
|
|
25
|
+
Each value is converted through ``request_param_value`` so callers can pass
|
|
26
|
+
richer Python objects without repeating serialization logic.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
if params is None:
|
|
30
|
+
return None
|
|
31
|
+
return {key: request_param_value(value) for key, value in params.items()}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def request_param_value(value: object) -> RequestParamValue:
|
|
35
|
+
"""Normalize one query parameter value for ``requests``.
|
|
36
|
+
|
|
37
|
+
Native scalar values are preserved and any other object is stringified so
|
|
38
|
+
higher-level client code can pass enums and IDs without special handling.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
if value is None or isinstance(value, str | int | float | bytes):
|
|
42
|
+
return value
|
|
43
|
+
return str(value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def require_access_token(access_token: str | None) -> str:
|
|
47
|
+
"""Return a usable access token or raise when it is missing.
|
|
48
|
+
|
|
49
|
+
Transport helpers call this right before request dispatch so missing token
|
|
50
|
+
state turns into a clear local error instead of a malformed API call.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
if not access_token:
|
|
54
|
+
raise ValueError("Failed to acquire access token for API request")
|
|
55
|
+
return access_token
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_request_headers(
|
|
59
|
+
*,
|
|
60
|
+
access_token: str | None,
|
|
61
|
+
language: str,
|
|
62
|
+
glpi_entity: int | None,
|
|
63
|
+
glpi_profile: int | None,
|
|
64
|
+
entity_recursive: bool,
|
|
65
|
+
include_content_type: bool = False,
|
|
66
|
+
skip_entity: bool = False,
|
|
67
|
+
) -> dict[str, str]:
|
|
68
|
+
"""Build GLPI request headers from one client state snapshot.
|
|
69
|
+
|
|
70
|
+
The header set includes authorization and language settings, with optional
|
|
71
|
+
entity, profile, recursion, and content-type headers derived from the
|
|
72
|
+
current client configuration.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
headers = {
|
|
76
|
+
"Authorization": f"Bearer {access_token}",
|
|
77
|
+
"Accept": "application/json",
|
|
78
|
+
"Accept-Language": language,
|
|
79
|
+
}
|
|
80
|
+
if include_content_type:
|
|
81
|
+
headers["Content-Type"] = "application/json"
|
|
82
|
+
if not skip_entity:
|
|
83
|
+
if glpi_entity is not None:
|
|
84
|
+
headers["GLPI-Entity"] = str(glpi_entity)
|
|
85
|
+
if glpi_profile is not None:
|
|
86
|
+
headers["GLPI-Profile"] = str(glpi_profile)
|
|
87
|
+
if entity_recursive:
|
|
88
|
+
headers["GLPI-Entity-Recursive"] = "true"
|
|
89
|
+
return headers
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_request_url(glpi_api_url: str, endpoint: str) -> str:
|
|
93
|
+
"""Return the absolute URL for one GLPI endpoint path.
|
|
94
|
+
|
|
95
|
+
Callers provide the normalized API base URL and the endpoint suffix that is
|
|
96
|
+
already specific to the requested resource.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
return f"{glpi_api_url}/{endpoint}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def finalize_request_response(
|
|
103
|
+
response: requests.Response,
|
|
104
|
+
*,
|
|
105
|
+
method: str,
|
|
106
|
+
url: str,
|
|
107
|
+
success_statuses: tuple[int, ...],
|
|
108
|
+
logger: logging.Logger,
|
|
109
|
+
) -> requests.Response:
|
|
110
|
+
"""Validate one GLPI transport response and preserve warning behavior.
|
|
111
|
+
|
|
112
|
+
Server errors are raised immediately while non-success statuses outside the
|
|
113
|
+
accepted set are logged for higher-level mutation and lookup helpers to
|
|
114
|
+
interpret consistently.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
method_name = method.upper()
|
|
118
|
+
if 500 <= response.status_code < 600:
|
|
119
|
+
message = (
|
|
120
|
+
f"GLPI {method_name} {url} failed with "
|
|
121
|
+
f"{response.status_code} {response.reason}"
|
|
122
|
+
)
|
|
123
|
+
logger.warning(message)
|
|
124
|
+
raise requests.HTTPError(message)
|
|
125
|
+
if response.status_code not in success_statuses:
|
|
126
|
+
logger.warning(
|
|
127
|
+
"GLPI %s %s returned %s: %s",
|
|
128
|
+
method_name,
|
|
129
|
+
url,
|
|
130
|
+
response.status_code,
|
|
131
|
+
response.text[:200],
|
|
132
|
+
)
|
|
133
|
+
return response
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def ensure_response_status(
|
|
137
|
+
response: requests.Response,
|
|
138
|
+
*,
|
|
139
|
+
success_statuses: tuple[int, ...],
|
|
140
|
+
failure_message: str,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Raise a consistent ``ValueError`` for an unexpected response status.
|
|
143
|
+
|
|
144
|
+
Higher-level client methods use this helper to keep their mutation and fetch
|
|
145
|
+
failure messages aligned across sync and async call sites.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
if response.status_code not in success_statuses:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"{failure_message}: {response.status_code} {response.text[:200]}"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def response_json_mapping(response: requests.Response) -> Mapping[str, object]:
|
|
155
|
+
"""Return the JSON response payload as a mapping when possible.
|
|
156
|
+
|
|
157
|
+
Empty response bodies become an empty mapping and non-mapping JSON payloads
|
|
158
|
+
are intentionally ignored so callers can safely probe expected keys.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
result = response.json() if response.content else {}
|
|
162
|
+
return result if isinstance(result, Mapping) else {}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def require_response_text(
|
|
166
|
+
response: requests.Response,
|
|
167
|
+
*,
|
|
168
|
+
keys: tuple[str, ...],
|
|
169
|
+
missing_message: str,
|
|
170
|
+
) -> str:
|
|
171
|
+
"""Return the first non-empty text field from a JSON response mapping.
|
|
172
|
+
|
|
173
|
+
This is primarily used for create responses that may expose the created ID
|
|
174
|
+
under one of several field names.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
result = response_json_mapping(response)
|
|
178
|
+
for key in keys:
|
|
179
|
+
value = _optional_text(result.get(key))
|
|
180
|
+
if value is not None:
|
|
181
|
+
return value
|
|
182
|
+
raise ValueError(missing_message)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def require_non_empty_text(value: object, *, error_message: str) -> str:
|
|
186
|
+
"""Return stripped text or raise when the value is empty.
|
|
187
|
+
|
|
188
|
+
Validation stays centralized here so operation-level preconditions use the
|
|
189
|
+
same message and whitespace-trimming behavior throughout the package.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
text = _optional_text(value)
|
|
193
|
+
if text is None:
|
|
194
|
+
raise ValueError(error_message)
|
|
195
|
+
return text
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Response payload extraction helpers for GLPI v2 clients.
|
|
2
|
+
|
|
3
|
+
These functions turn raw JSON and timeline payloads into predictable lists of
|
|
4
|
+
mapping items before higher-level parsers convert them into typed models.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from glpi_python_client.content.records.core.normalization import (
|
|
15
|
+
_normalize_timeline_records,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
RecordT = TypeVar("RecordT")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def list_payload_items(payload: object) -> list[dict[str, Any]]:
|
|
22
|
+
"""Return dictionary items from one plain JSON list payload.
|
|
23
|
+
|
|
24
|
+
Non-list payloads are treated as empty so callers can safely use this on
|
|
25
|
+
API responses that may vary or fail validation upstream.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
if not isinstance(payload, list):
|
|
29
|
+
return []
|
|
30
|
+
return [item for item in payload if isinstance(item, dict)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def timeline_payload_items(payload: object) -> list[dict[str, Any]]:
|
|
34
|
+
"""Return dictionary items from one GLPI timeline payload.
|
|
35
|
+
|
|
36
|
+
Timeline payloads go through the shared normalization step first because
|
|
37
|
+
GLPI can nest timeline items in multiple container shapes.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
return [
|
|
41
|
+
item for item in _normalize_timeline_records(payload) if isinstance(item, dict)
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def list_payload_records(
|
|
46
|
+
payload: object,
|
|
47
|
+
*,
|
|
48
|
+
record_factory: Callable[[dict[str, Any]], RecordT | None],
|
|
49
|
+
) -> list[RecordT]:
|
|
50
|
+
"""Build typed records from one plain JSON list payload.
|
|
51
|
+
|
|
52
|
+
The provided factory may return ``None`` to skip individual raw items while
|
|
53
|
+
preserving the rest of the batch.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
records: list[RecordT] = []
|
|
57
|
+
for item in list_payload_items(payload):
|
|
58
|
+
record = record_factory(item)
|
|
59
|
+
if record is not None:
|
|
60
|
+
records.append(record)
|
|
61
|
+
return records
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def timeline_records_from_response(
|
|
65
|
+
response: requests.Response,
|
|
66
|
+
*,
|
|
67
|
+
record_factory: Callable[[dict[str, Any]], RecordT],
|
|
68
|
+
) -> list[RecordT]:
|
|
69
|
+
"""Build typed records from one successful GLPI timeline response.
|
|
70
|
+
|
|
71
|
+
Unlike plain list payload handling, timeline parsing assumes each normalized
|
|
72
|
+
item should produce a record and lets the record factory raise on invalid
|
|
73
|
+
data.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
return [record_factory(item) for item in timeline_payload_items(response.json())]
|