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.
Files changed (98) hide show
  1. glpi_python_client/__init__.py +36 -0
  2. glpi_python_client/auth/__init__.py +11 -0
  3. glpi_python_client/auth/auth.py +310 -0
  4. glpi_python_client/auth/tests/test_auth.py +189 -0
  5. glpi_python_client/clients/__init__.py +18 -0
  6. glpi_python_client/clients/api_v1_session.py +460 -0
  7. glpi_python_client/clients/api_v2_client.py +317 -0
  8. glpi_python_client/clients/async_api_v2_client.py +236 -0
  9. glpi_python_client/clients/tests/__init__.py +5 -0
  10. glpi_python_client/clients/tests/test_api_v1_session.py +85 -0
  11. glpi_python_client/clients/tests/test_api_v2_client.py +349 -0
  12. glpi_python_client/clients/tests/test_async_api_v2_client.py +257 -0
  13. glpi_python_client/clients/v2/__init__.py +8 -0
  14. glpi_python_client/clients/v2/async_/__init__.py +12 -0
  15. glpi_python_client/clients/v2/async_/api.py +29 -0
  16. glpi_python_client/clients/v2/async_/directory.py +88 -0
  17. glpi_python_client/clients/v2/async_/documents.py +144 -0
  18. glpi_python_client/clients/v2/async_/team.py +125 -0
  19. glpi_python_client/clients/v2/async_/tests/__init__.py +5 -0
  20. glpi_python_client/clients/v2/async_/tests/test_directory.py +43 -0
  21. glpi_python_client/clients/v2/async_/tests/test_documents.py +41 -0
  22. glpi_python_client/clients/v2/async_/tests/test_team.py +44 -0
  23. glpi_python_client/clients/v2/async_/tests/test_tickets.py +174 -0
  24. glpi_python_client/clients/v2/async_/tests/test_timeline.py +126 -0
  25. glpi_python_client/clients/v2/async_/tickets.py +312 -0
  26. glpi_python_client/clients/v2/async_/timeline.py +312 -0
  27. glpi_python_client/clients/v2/async_/transport.py +251 -0
  28. glpi_python_client/clients/v2/common/__init__.py +6 -0
  29. glpi_python_client/clients/v2/common/client_config.py +219 -0
  30. glpi_python_client/clients/v2/common/constants.py +45 -0
  31. glpi_python_client/clients/v2/common/errors.py +23 -0
  32. glpi_python_client/clients/v2/common/filters.py +30 -0
  33. glpi_python_client/clients/v2/common/payloads.py +57 -0
  34. glpi_python_client/clients/v2/common/request_http.py +195 -0
  35. glpi_python_client/clients/v2/common/response_payloads.py +76 -0
  36. glpi_python_client/clients/v2/common/ticket_search.py +113 -0
  37. glpi_python_client/clients/v2/sync/__init__.py +12 -0
  38. glpi_python_client/clients/v2/sync/api.py +29 -0
  39. glpi_python_client/clients/v2/sync/directory.py +90 -0
  40. glpi_python_client/clients/v2/sync/documents.py +144 -0
  41. glpi_python_client/clients/v2/sync/team.py +125 -0
  42. glpi_python_client/clients/v2/sync/tests/__init__.py +5 -0
  43. glpi_python_client/clients/v2/sync/tests/test_directory.py +57 -0
  44. glpi_python_client/clients/v2/sync/tests/test_documents.py +99 -0
  45. glpi_python_client/clients/v2/sync/tests/test_team.py +64 -0
  46. glpi_python_client/clients/v2/sync/tests/test_tickets.py +430 -0
  47. glpi_python_client/clients/v2/sync/tests/test_timeline.py +77 -0
  48. glpi_python_client/clients/v2/sync/tests/test_transport.py +89 -0
  49. glpi_python_client/clients/v2/sync/tickets.py +312 -0
  50. glpi_python_client/clients/v2/sync/timeline.py +308 -0
  51. glpi_python_client/clients/v2/sync/transport.py +246 -0
  52. glpi_python_client/content/__init__.py +11 -0
  53. glpi_python_client/content/conversion.py +58 -0
  54. glpi_python_client/content/records/__init__.py +84 -0
  55. glpi_python_client/content/records/core/__init__.py +6 -0
  56. glpi_python_client/content/records/core/document_links.py +100 -0
  57. glpi_python_client/content/records/core/normalization.py +53 -0
  58. glpi_python_client/content/records/core/references.py +98 -0
  59. glpi_python_client/content/records/core/scalars.py +83 -0
  60. glpi_python_client/content/records/parsers/__init__.py +6 -0
  61. glpi_python_client/content/records/parsers/directory.py +62 -0
  62. glpi_python_client/content/records/parsers/documents.py +49 -0
  63. glpi_python_client/content/records/parsers/team.py +58 -0
  64. glpi_python_client/content/records/parsers/tests/__init__.py +5 -0
  65. glpi_python_client/content/records/parsers/tests/test_tickets.py +35 -0
  66. glpi_python_client/content/records/parsers/tests/test_timeline.py +20 -0
  67. glpi_python_client/content/records/parsers/tickets.py +96 -0
  68. glpi_python_client/content/records/parsers/timeline.py +119 -0
  69. glpi_python_client/content/tests/__init__.py +5 -0
  70. glpi_python_client/content/tests/test_conversion.py +13 -0
  71. glpi_python_client/models/__init__.py +30 -0
  72. glpi_python_client/models/_base.py +22 -0
  73. glpi_python_client/models/_payload.py +79 -0
  74. glpi_python_client/models/_shared.py +37 -0
  75. glpi_python_client/models/glpi/__init__.py +27 -0
  76. glpi_python_client/models/glpi/_document.py +59 -0
  77. glpi_python_client/models/glpi/_followup.py +77 -0
  78. glpi_python_client/models/glpi/_location.py +53 -0
  79. glpi_python_client/models/glpi/_solution.py +57 -0
  80. glpi_python_client/models/glpi/_task.py +41 -0
  81. glpi_python_client/models/glpi/_team_member.py +33 -0
  82. glpi_python_client/models/glpi/_ticket.py +303 -0
  83. glpi_python_client/models/glpi/_user.py +92 -0
  84. glpi_python_client/models/glpi/tests/__init__.py +5 -0
  85. glpi_python_client/models/glpi/tests/test__document.py +12 -0
  86. glpi_python_client/models/glpi/tests/test__followup.py +31 -0
  87. glpi_python_client/models/glpi/tests/test__location.py +12 -0
  88. glpi_python_client/models/glpi/tests/test__solution.py +11 -0
  89. glpi_python_client/models/glpi/tests/test__ticket.py +59 -0
  90. glpi_python_client/models/glpi/tests/test__user.py +29 -0
  91. glpi_python_client/py.typed +0 -0
  92. glpi_python_client/testing/__init__.py +27 -0
  93. glpi_python_client/testing/fixtures.py +52 -0
  94. glpi_python_client/testing/utils.py +149 -0
  95. glpi_python_client-0.1.0.dist-info/METADATA +144 -0
  96. glpi_python_client-0.1.0.dist-info/RECORD +98 -0
  97. glpi_python_client-0.1.0.dist-info/WHEEL +4 -0
  98. 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,6 @@
1
+ """Model-specific GLPI record parsers.
2
+
3
+ Each parser module in this package focuses on converting one family of GLPI
4
+ payloads into the package's typed models while reusing low-level helpers from
5
+ ``content.records.core``.
6
+ """
@@ -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,5 @@
1
+ """Parser tests colocated with record parser modules.
2
+
3
+ This package keeps unit tests next to the content parser files they exercise so
4
+ the test layout mirrors the implementation layout.
5
+ """
@@ -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,5 @@
1
+ """Content tests that live beside the implementation package.
2
+
3
+ These tests stay colocated with the content modules so parser and conversion
4
+ changes can be validated close to the code they exercise.
5
+ """
@@ -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)