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,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,5 @@
1
+ """Sync v2 client tests colocated with sync implementation modules.
2
+
3
+ Each test module mirrors the sync client file it exercises so behavior remains
4
+ easy to find from the implementation filename.
5
+ """
@@ -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()