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,83 @@
|
|
|
1
|
+
"""Scalar coercion helpers for GLPI records.
|
|
2
|
+
|
|
3
|
+
These helpers centralize the small normalization rules used repeatedly while
|
|
4
|
+
parsing loosely typed GLPI payload values.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_glpi_datetime(value: Any) -> datetime | None:
|
|
14
|
+
"""Parse one GLPI datetime string.
|
|
15
|
+
|
|
16
|
+
The helper accepts ISO-like GLPI timestamps, normalizes common timezone
|
|
17
|
+
suffix variants, and returns ``None`` when parsing fails.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
text = _optional_text(value)
|
|
21
|
+
if text is None:
|
|
22
|
+
return None
|
|
23
|
+
normalized = text.replace("Z", "+00:00")
|
|
24
|
+
if len(normalized) >= 5 and normalized[-5] in "+-" and normalized[-3] != ":":
|
|
25
|
+
normalized = f"{normalized[:-2]}:{normalized[-2:]}"
|
|
26
|
+
try:
|
|
27
|
+
return datetime.fromisoformat(normalized)
|
|
28
|
+
except ValueError:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _optional_text(value: Any) -> str | None:
|
|
33
|
+
"""Coerce a value to stripped text when it is meaningfully present.
|
|
34
|
+
|
|
35
|
+
Empty strings and ``None`` normalize to ``None`` so callers can treat blank
|
|
36
|
+
text fields as missing data.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
text = str(value).strip() if value is not None else ""
|
|
40
|
+
return text or None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _optional_int(value: Any) -> int | None:
|
|
44
|
+
"""Coerce a value to ``int`` when possible.
|
|
45
|
+
|
|
46
|
+
Invalid or missing integer-like values return ``None`` instead of raising,
|
|
47
|
+
which keeps record parsing tolerant of partial payloads.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
return int(value) if value is not None else None
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _coerce_bool(value: Any) -> bool:
|
|
57
|
+
"""Coerce common GLPI truthy values to ``bool``.
|
|
58
|
+
|
|
59
|
+
The helper accepts booleans, numeric sentinels, and the most common text
|
|
60
|
+
spellings emitted by GLPI payloads.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
if isinstance(value, bool):
|
|
64
|
+
return value
|
|
65
|
+
if isinstance(value, (int, float)):
|
|
66
|
+
return value != 0
|
|
67
|
+
if isinstance(value, str):
|
|
68
|
+
return value.strip().casefold() in {"1", "true", "yes"}
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _first_int(*values: Any) -> int | None:
|
|
73
|
+
"""Return the first value that can be normalized to ``int``.
|
|
74
|
+
|
|
75
|
+
This is useful for payloads that may expose the same identifier under more
|
|
76
|
+
than one candidate key.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
for value in values:
|
|
80
|
+
candidate = _optional_int(value)
|
|
81
|
+
if candidate is not None:
|
|
82
|
+
return candidate
|
|
83
|
+
return None
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""GLPI directory record parsing helpers for users and locations.
|
|
2
|
+
|
|
3
|
+
This module converts raw user and location payloads from directory-style GLPI
|
|
4
|
+
endpoints into the package's typed models.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from glpi_python_client.content.records.core.references import _glpi_id_value
|
|
12
|
+
from glpi_python_client.content.records.core.scalars import (
|
|
13
|
+
_optional_int,
|
|
14
|
+
_optional_text,
|
|
15
|
+
)
|
|
16
|
+
from glpi_python_client.models import GlpiLocation, GlpiUser
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _glpi_user_record(raw_user: dict[str, Any]) -> GlpiUser:
|
|
20
|
+
"""Build a ``GlpiUser`` from one raw user payload.
|
|
21
|
+
|
|
22
|
+
The parser derives a display name from the available first name, real name,
|
|
23
|
+
and username fields while preserving the default entity when present.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
user_id = _optional_text(raw_user.get("id"))
|
|
27
|
+
if user_id is None:
|
|
28
|
+
raise ValueError("GLPI user payload did not include an ID")
|
|
29
|
+
default_entity = raw_user.get("default_entity")
|
|
30
|
+
firstname = _optional_text(raw_user.get("firstname"))
|
|
31
|
+
realname = _optional_text(raw_user.get("realname")) or _optional_text(
|
|
32
|
+
raw_user.get("name")
|
|
33
|
+
)
|
|
34
|
+
return GlpiUser(
|
|
35
|
+
user_id=user_id,
|
|
36
|
+
email=_optional_text(raw_user.get("email"))
|
|
37
|
+
or _optional_text(raw_user.get("username")),
|
|
38
|
+
firstname=firstname,
|
|
39
|
+
realname=realname,
|
|
40
|
+
name=" ".join(part for part in (firstname, realname) if part)
|
|
41
|
+
or realname
|
|
42
|
+
or firstname,
|
|
43
|
+
entity_id=_optional_int(_glpi_id_value(default_entity) or default_entity),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _glpi_location_record(raw_location: dict[str, Any]) -> GlpiLocation:
|
|
48
|
+
"""Build a ``GlpiLocation`` from one raw location payload.
|
|
49
|
+
|
|
50
|
+
The location parser requires both the identifier and name because those
|
|
51
|
+
fields are necessary for the high-level location model.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
location_id = _optional_text(raw_location.get("id"))
|
|
55
|
+
name = _optional_text(raw_location.get("name"))
|
|
56
|
+
if location_id is None or name is None:
|
|
57
|
+
raise ValueError("GLPI location payload did not include id and name")
|
|
58
|
+
return GlpiLocation(
|
|
59
|
+
location_id=location_id,
|
|
60
|
+
name=name,
|
|
61
|
+
entity_id=_optional_int(_glpi_id_value(raw_location.get("entity"))),
|
|
62
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""GLPI document record parsing helpers.
|
|
2
|
+
|
|
3
|
+
This module converts raw document payloads and optional metadata overlays into
|
|
4
|
+
the package's typed ``GlpiDocument`` model.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from glpi_python_client.content.records.core.scalars import _optional_text
|
|
12
|
+
from glpi_python_client.models import GlpiDocument
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _glpi_document_record(
|
|
16
|
+
raw_document: dict[str, Any],
|
|
17
|
+
*,
|
|
18
|
+
metadata: dict[str, Any] | None = None,
|
|
19
|
+
) -> GlpiDocument:
|
|
20
|
+
"""Build a ``GlpiDocument`` from one raw document payload.
|
|
21
|
+
|
|
22
|
+
The parser can merge additional metadata gathered from a separate document
|
|
23
|
+
lookup before normalizing identifiers, filenames, and MIME types.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
merged = dict(raw_document)
|
|
27
|
+
if metadata:
|
|
28
|
+
merged.update({f"metadata_{key}": value for key, value in metadata.items()})
|
|
29
|
+
document_id = (
|
|
30
|
+
_optional_text(merged.get("documents_id"))
|
|
31
|
+
or _optional_text(merged.get("metadata_id"))
|
|
32
|
+
or _optional_text(merged.get("id"))
|
|
33
|
+
)
|
|
34
|
+
filename = (
|
|
35
|
+
_optional_text(merged.get("filename"))
|
|
36
|
+
or _optional_text(merged.get("name"))
|
|
37
|
+
or _optional_text(merged.get("metadata_filename"))
|
|
38
|
+
or _optional_text(merged.get("metadata_name"))
|
|
39
|
+
or document_id
|
|
40
|
+
)
|
|
41
|
+
if document_id is None or filename is None:
|
|
42
|
+
raise ValueError("GLPI document payload did not include an ID and filename")
|
|
43
|
+
return GlpiDocument(
|
|
44
|
+
document_id=document_id,
|
|
45
|
+
filename=filename,
|
|
46
|
+
mime_type=_optional_text(merged.get("mime"))
|
|
47
|
+
or _optional_text(merged.get("metadata_mime")),
|
|
48
|
+
linked_document_id=_optional_text(merged.get("documents_id")),
|
|
49
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""GLPI team-member record parsing helpers.
|
|
2
|
+
|
|
3
|
+
This module turns raw team-association payloads into typed team-member models
|
|
4
|
+
used by the ticket team helpers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from glpi_python_client.content.records.core.scalars import _first_int, _optional_text
|
|
12
|
+
from glpi_python_client.models import GlpiTeamMember
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _glpi_team_member_record(raw_member: dict[str, Any]) -> GlpiTeamMember | None:
|
|
16
|
+
"""Build a ``GlpiTeamMember`` from one raw association payload.
|
|
17
|
+
|
|
18
|
+
Incomplete associations are skipped by returning ``None`` so list parsing
|
|
19
|
+
can continue without failing the whole team-member batch.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
member_type = _resolve_glpi_member_type(raw_member)
|
|
23
|
+
member_id = _first_int(
|
|
24
|
+
raw_member.get("id"), raw_member.get("items_id"), raw_member.get("users_id")
|
|
25
|
+
)
|
|
26
|
+
role = _optional_text(raw_member.get("role"))
|
|
27
|
+
if member_type is None or member_id is None or role is None:
|
|
28
|
+
return None
|
|
29
|
+
return GlpiTeamMember(
|
|
30
|
+
member_type=member_type,
|
|
31
|
+
member_id=member_id,
|
|
32
|
+
role=role,
|
|
33
|
+
name=_optional_text(raw_member.get("display_name"))
|
|
34
|
+
or _optional_text(raw_member.get("name")),
|
|
35
|
+
display_name=_optional_text(raw_member.get("display_name"))
|
|
36
|
+
or _optional_text(raw_member.get("name")),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_glpi_member_type(member: dict[str, Any]) -> str | None:
|
|
41
|
+
"""Resolve the GLPI team-member type from explicit fields or links.
|
|
42
|
+
|
|
43
|
+
GLPI may provide the member type directly or only imply it through the
|
|
44
|
+
linked form URL, so both sources are considered.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
member_type = member.get("itemtype") or member.get("type")
|
|
48
|
+
if isinstance(member_type, str) and member_type:
|
|
49
|
+
return member_type
|
|
50
|
+
href = member.get("href")
|
|
51
|
+
if not isinstance(href, str) or not href:
|
|
52
|
+
return None
|
|
53
|
+
lowered = href.casefold()
|
|
54
|
+
if "/group.form.php" in lowered:
|
|
55
|
+
return "Group"
|
|
56
|
+
if "/user.form.php" in lowered:
|
|
57
|
+
return "User"
|
|
58
|
+
return None
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from glpi_python_client.content.records.parsers.tickets import (
|
|
6
|
+
_filter_visible_ticket_batch,
|
|
7
|
+
_glpi_ticket_record,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_ticket_record_normalizes_nested_fields(
|
|
12
|
+
sample_ticket_record: dict[str, Any],
|
|
13
|
+
) -> None:
|
|
14
|
+
ticket = _glpi_ticket_record(sample_ticket_record)
|
|
15
|
+
|
|
16
|
+
assert ticket.id == "123"
|
|
17
|
+
assert ticket.status == {"id": 2, "name": "Processing"}
|
|
18
|
+
assert ticket.entity == {"id": 7, "name": "Root"}
|
|
19
|
+
assert ticket.location == {"id": 8, "name": "Paris"}
|
|
20
|
+
assert ticket.content == "Body with **formatting**"
|
|
21
|
+
assert ticket.user_recipient is not None
|
|
22
|
+
assert ticket.user_recipient.user_id == "9"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_filter_visible_ticket_batch_removes_deleted_records() -> None:
|
|
26
|
+
visible, deleted_count = _filter_visible_ticket_batch(
|
|
27
|
+
[
|
|
28
|
+
{"id": 1, "is_deleted": 0},
|
|
29
|
+
{"id": 2, "is_deleted": "true"},
|
|
30
|
+
{"id": 3, "is_deleted": False},
|
|
31
|
+
]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
assert [ticket["id"] for ticket in visible] == [1, 3]
|
|
35
|
+
assert deleted_count == 1
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from glpi_python_client.content.records.parsers.timeline import _glpi_followup_record
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_followup_record_extracts_and_strips_document_references(
|
|
9
|
+
sample_followup_record: dict[str, Any],
|
|
10
|
+
) -> None:
|
|
11
|
+
followup = _glpi_followup_record(sample_followup_record)
|
|
12
|
+
|
|
13
|
+
assert followup.followup_id == "12"
|
|
14
|
+
assert followup.author is not None
|
|
15
|
+
assert followup.author.user_id == "5"
|
|
16
|
+
assert followup.is_private is True
|
|
17
|
+
assert followup.attachment_document_ids == ("45", "46")
|
|
18
|
+
assert followup.content is not None
|
|
19
|
+
assert "document.send.php" not in followup.content
|
|
20
|
+
assert "Hello" in followup.content
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""GLPI ticket record parsing helpers.
|
|
2
|
+
|
|
3
|
+
This module converts raw ticket payloads and search batches into the package's
|
|
4
|
+
typed ``GlpiTicket`` model while handling deleted-ticket filtering.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from glpi_python_client.content.conversion import GlpiContentConverter
|
|
13
|
+
from glpi_python_client.content.records.core.references import (
|
|
14
|
+
_glpi_id_reference,
|
|
15
|
+
_glpi_reference,
|
|
16
|
+
_glpi_text_reference,
|
|
17
|
+
_glpi_ticket_user_payload,
|
|
18
|
+
)
|
|
19
|
+
from glpi_python_client.content.records.core.scalars import (
|
|
20
|
+
_optional_int,
|
|
21
|
+
_optional_text,
|
|
22
|
+
_parse_glpi_datetime,
|
|
23
|
+
)
|
|
24
|
+
from glpi_python_client.models import GlpiTicket
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_deleted_ticket(record: dict[str, Any]) -> bool:
|
|
28
|
+
"""Return whether one raw GLPI ticket payload represents a deleted ticket.
|
|
29
|
+
|
|
30
|
+
GLPI may encode this flag as a boolean, numeric sentinel, or text value, so
|
|
31
|
+
the helper accepts all of those shapes.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
value = record.get("is_deleted")
|
|
35
|
+
if isinstance(value, bool):
|
|
36
|
+
return value
|
|
37
|
+
if isinstance(value, (int, float)):
|
|
38
|
+
return value != 0
|
|
39
|
+
if isinstance(value, str):
|
|
40
|
+
return value.strip().casefold() in {"1", "true", "yes"}
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _filter_visible_ticket_batch(batch: object) -> tuple[list[dict[str, Any]], int]:
|
|
45
|
+
"""Filter deleted tickets out of one GLPI search batch.
|
|
46
|
+
|
|
47
|
+
The function returns both the visible ticket payloads and the count removed
|
|
48
|
+
so callers can log how many deleted records were excluded.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
if not isinstance(batch, list):
|
|
52
|
+
return [], 0
|
|
53
|
+
normalized_batch = [dict(ticket) for ticket in batch if isinstance(ticket, dict)]
|
|
54
|
+
visible_batch = [
|
|
55
|
+
ticket for ticket in normalized_batch if not _is_deleted_ticket(ticket)
|
|
56
|
+
]
|
|
57
|
+
return visible_batch, len(normalized_batch) - len(visible_batch)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _glpi_ticket_record(raw_ticket: dict[str, Any]) -> GlpiTicket:
|
|
61
|
+
"""Build a rich ``GlpiTicket`` object from one raw ticket payload.
|
|
62
|
+
|
|
63
|
+
The parser normalizes content, references, timestamps, and embedded user
|
|
64
|
+
fields while preserving the payload keys that map directly to the public
|
|
65
|
+
ticket model.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
fields = dict(raw_ticket)
|
|
69
|
+
status = fields.pop("status", None)
|
|
70
|
+
ticket_id = _optional_text(fields.pop("id", None))
|
|
71
|
+
name = _optional_text(fields.pop("name", None))
|
|
72
|
+
content = str(fields.pop("content", "") or "")
|
|
73
|
+
if ticket_id is None:
|
|
74
|
+
raise ValueError("GLPI ticket payload did not include an ID")
|
|
75
|
+
external_id = _optional_text(fields.get("external_id"))
|
|
76
|
+
return GlpiTicket(
|
|
77
|
+
id=ticket_id,
|
|
78
|
+
name=name,
|
|
79
|
+
content=GlpiContentConverter.from_transport(content),
|
|
80
|
+
status=_glpi_id_reference(status),
|
|
81
|
+
urgency=_optional_int(fields.get("urgency")),
|
|
82
|
+
impact=_optional_int(fields.get("impact")),
|
|
83
|
+
priority=_optional_int(fields.get("priority")),
|
|
84
|
+
type=_optional_int(fields.get("type")),
|
|
85
|
+
external_id=external_id,
|
|
86
|
+
category=_glpi_id_reference(fields.get("category")),
|
|
87
|
+
entity=_glpi_id_reference(fields.get("entity")),
|
|
88
|
+
location=_glpi_reference(fields.get("location")),
|
|
89
|
+
request_type=_glpi_text_reference(fields.get("request_type")),
|
|
90
|
+
date_creation=_parse_glpi_datetime(fields.get("date_creation")),
|
|
91
|
+
date_mod=_parse_glpi_datetime(fields.get("date_mod"))
|
|
92
|
+
or datetime.now().astimezone(),
|
|
93
|
+
date_close=_parse_glpi_datetime(fields.get("date_close")),
|
|
94
|
+
user_recipient=_glpi_ticket_user_payload(fields.get("user_recipient")),
|
|
95
|
+
user_editor=_glpi_ticket_user_payload(fields.get("user_editor")),
|
|
96
|
+
)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""GLPI timeline record parsing helpers.
|
|
2
|
+
|
|
3
|
+
This module converts followup, solution, and task timeline payloads into their
|
|
4
|
+
typed rich-model equivalents.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from glpi_python_client.content.conversion import GlpiContentConverter
|
|
12
|
+
from glpi_python_client.content.records.core.document_links import (
|
|
13
|
+
_glpi_followup_attachment_document_ids,
|
|
14
|
+
_strip_glpi_document_references,
|
|
15
|
+
)
|
|
16
|
+
from glpi_python_client.content.records.core.references import _glpi_id_value
|
|
17
|
+
from glpi_python_client.content.records.core.scalars import (
|
|
18
|
+
_coerce_bool,
|
|
19
|
+
_optional_text,
|
|
20
|
+
_parse_glpi_datetime,
|
|
21
|
+
)
|
|
22
|
+
from glpi_python_client.models import GlpiFollowup, GlpiSolution, GlpiTask, GlpiUser
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _glpi_followup_record(raw_followup: dict[str, Any]) -> GlpiFollowup:
|
|
26
|
+
"""Build a ``GlpiFollowup`` from one raw timeline payload.
|
|
27
|
+
|
|
28
|
+
Followup parsing strips inline document references from the human-readable
|
|
29
|
+
content while extracting attachment document IDs separately.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
followup_id = _optional_text(raw_followup.get("id"))
|
|
33
|
+
if followup_id is None:
|
|
34
|
+
raise ValueError("GLPI followup payload did not include an ID")
|
|
35
|
+
raw_content = str(raw_followup.get("content") or "")
|
|
36
|
+
author_id = _glpi_author_id(raw_followup)
|
|
37
|
+
return GlpiFollowup(
|
|
38
|
+
followup_id=followup_id,
|
|
39
|
+
content=GlpiContentConverter.from_transport(
|
|
40
|
+
_strip_glpi_document_references(raw_content)
|
|
41
|
+
),
|
|
42
|
+
created_at=_parse_glpi_datetime(raw_followup.get("date_creation")),
|
|
43
|
+
updated_at=_parse_glpi_datetime(raw_followup.get("date_mod")),
|
|
44
|
+
author=GlpiUser(user_id=author_id) if author_id is not None else None,
|
|
45
|
+
is_private=_coerce_bool(raw_followup.get("is_private")),
|
|
46
|
+
attachment_document_ids=_glpi_followup_attachment_document_ids(raw_content),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _glpi_solution_record(raw_solution: dict[str, Any]) -> GlpiSolution:
|
|
51
|
+
"""Build a ``GlpiSolution`` from one raw solution payload.
|
|
52
|
+
|
|
53
|
+
Solutions share most of the same parsing rules as followups, including
|
|
54
|
+
attachment extraction and Markdown normalization.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
solution_id = _optional_text(raw_solution.get("id"))
|
|
58
|
+
if solution_id is None:
|
|
59
|
+
raise ValueError("GLPI solution payload did not include an ID")
|
|
60
|
+
raw_content = str(raw_solution.get("content") or "")
|
|
61
|
+
author_id = _glpi_author_id(raw_solution)
|
|
62
|
+
return GlpiSolution(
|
|
63
|
+
solution_id=solution_id,
|
|
64
|
+
content=GlpiContentConverter.from_transport(
|
|
65
|
+
_strip_glpi_document_references(raw_content)
|
|
66
|
+
),
|
|
67
|
+
created_at=_parse_glpi_datetime(raw_solution.get("date_creation")),
|
|
68
|
+
updated_at=_parse_glpi_datetime(raw_solution.get("date_mod")),
|
|
69
|
+
author=GlpiUser(user_id=author_id) if author_id is not None else None,
|
|
70
|
+
attachment_document_ids=_glpi_followup_attachment_document_ids(raw_content),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _glpi_task_record(raw_task: dict[str, Any]) -> GlpiTask:
|
|
75
|
+
"""Build a ``GlpiTask`` from one raw task payload.
|
|
76
|
+
|
|
77
|
+
Tasks can encode their dates under multiple keys, so the parser checks the
|
|
78
|
+
known GLPI variants before building the typed task object.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
task_id = _optional_text(raw_task.get("id"))
|
|
82
|
+
if task_id is None:
|
|
83
|
+
raise ValueError("GLPI task payload did not include an ID")
|
|
84
|
+
author_id = _glpi_author_id(raw_task)
|
|
85
|
+
editor = raw_task.get("user_editor")
|
|
86
|
+
editor_id = _optional_text(_glpi_id_value(editor))
|
|
87
|
+
return GlpiTask(
|
|
88
|
+
task_id=task_id,
|
|
89
|
+
content=GlpiContentConverter.from_transport(raw_task.get("content")),
|
|
90
|
+
created_at=_parse_glpi_datetime(
|
|
91
|
+
raw_task.get("date_creation") or raw_task.get("date")
|
|
92
|
+
),
|
|
93
|
+
updated_at=_parse_glpi_datetime(
|
|
94
|
+
raw_task.get("date_mod") or raw_task.get("date")
|
|
95
|
+
),
|
|
96
|
+
author=GlpiUser(user_id=author_id) if author_id is not None else None,
|
|
97
|
+
editor=GlpiUser(user_id=editor_id) if editor_id is not None else None,
|
|
98
|
+
is_private=_coerce_bool(raw_task.get("is_private")),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _glpi_author_id(raw_item: dict[str, Any]) -> str | None:
|
|
103
|
+
"""Extract the most specific GLPI author identifier from one payload.
|
|
104
|
+
|
|
105
|
+
The helper prefers nested user mappings before falling back to the older
|
|
106
|
+
scalar ``users_id`` field.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
user_payload = raw_item.get("user")
|
|
110
|
+
if isinstance(user_payload, dict):
|
|
111
|
+
user_id = _optional_text(user_payload.get("id"))
|
|
112
|
+
if user_id is not None:
|
|
113
|
+
return user_id
|
|
114
|
+
editor_payload = raw_item.get("user_editor")
|
|
115
|
+
if isinstance(editor_payload, dict):
|
|
116
|
+
user_id = _optional_text(editor_payload.get("id"))
|
|
117
|
+
if user_id is not None:
|
|
118
|
+
return user_id
|
|
119
|
+
return _optional_text(raw_item.get("users_id"))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from glpi_python_client.content.conversion import GlpiContentConverter
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_content_converter_uses_markdown_in_python_and_html_for_glpi() -> None:
|
|
7
|
+
markdown = GlpiContentConverter.from_transport(
|
|
8
|
+
"<p>Hello <strong>world</strong></p>"
|
|
9
|
+
)
|
|
10
|
+
html = GlpiContentConverter.to_transport("Hello **world**")
|
|
11
|
+
|
|
12
|
+
assert markdown == "Hello **world**"
|
|
13
|
+
assert html == "<p>Hello <strong>world</strong></p>"
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Public model exports for the GLPI client package.
|
|
2
|
+
|
|
3
|
+
The models package collects the supported typed objects used for request
|
|
4
|
+
payloads and parsed GLPI responses, and re-exports them from one stable
|
|
5
|
+
import surface.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from glpi_python_client.models.glpi import (
|
|
11
|
+
GlpiDocument,
|
|
12
|
+
GlpiFollowup,
|
|
13
|
+
GlpiLocation,
|
|
14
|
+
GlpiSolution,
|
|
15
|
+
GlpiTask,
|
|
16
|
+
GlpiTeamMember,
|
|
17
|
+
GlpiTicket,
|
|
18
|
+
GlpiUser,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"GlpiDocument",
|
|
23
|
+
"GlpiFollowup",
|
|
24
|
+
"GlpiLocation",
|
|
25
|
+
"GlpiSolution",
|
|
26
|
+
"GlpiTask",
|
|
27
|
+
"GlpiTeamMember",
|
|
28
|
+
"GlpiTicket",
|
|
29
|
+
"GlpiUser",
|
|
30
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Base model contracts for GLPI data objects.
|
|
2
|
+
|
|
3
|
+
This module defines the common Pydantic base class used by the package's typed
|
|
4
|
+
GLPI models.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GlpiModel(BaseModel):
|
|
15
|
+
"""Base class for field-validated GLPI data models.
|
|
16
|
+
|
|
17
|
+
The shared base model forbids undeclared fields and exposes ``extra_payload``
|
|
18
|
+
so instance-specific API extensions can still be forwarded intentionally.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(extra="forbid")
|
|
22
|
+
extra_payload: dict[str, Any] = Field(default_factory=dict)
|