glpi-python-client 0.3.1__py3-none-any.whl → 0.3.2__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 +1 -1
- glpi_python_client/clients/async_client.py +3 -1
- glpi_python_client/clients/commons/_async_bridge.py +17 -0
- glpi_python_client/clients/custom/__init__.py +2 -0
- glpi_python_client/clients/custom/_pagination_async.py +164 -0
- glpi_python_client/clients/custom/_statistics_async.py +253 -8
- glpi_python_client/clients/tests/test_async_branches.py +506 -0
- {glpi_python_client-0.3.1.dist-info → glpi_python_client-0.3.2.dist-info}/METADATA +1 -1
- {glpi_python_client-0.3.1.dist-info → glpi_python_client-0.3.2.dist-info}/RECORD +11 -10
- {glpi_python_client-0.3.1.dist-info → glpi_python_client-0.3.2.dist-info}/WHEEL +0 -0
- {glpi_python_client-0.3.1.dist-info → glpi_python_client-0.3.2.dist-info}/licenses/LICENSE +0 -0
glpi_python_client/__init__.py
CHANGED
|
@@ -50,6 +50,7 @@ from glpi_python_client.clients.commons._config import (
|
|
|
50
50
|
build_client_resources,
|
|
51
51
|
)
|
|
52
52
|
from glpi_python_client.clients.commons._transport import TransportMixin
|
|
53
|
+
from glpi_python_client.clients.custom._pagination_async import AsyncPaginationMixin
|
|
53
54
|
from glpi_python_client.clients.custom._statistics_async import AsyncStatisticsMixin
|
|
54
55
|
from glpi_python_client.clients.custom._ticket_context_async import (
|
|
55
56
|
AsyncTicketContextMixin,
|
|
@@ -61,8 +62,9 @@ if TYPE_CHECKING:
|
|
|
61
62
|
logger = logging.getLogger(__name__)
|
|
62
63
|
|
|
63
64
|
|
|
64
|
-
class AsyncGlpiClient(
|
|
65
|
+
class AsyncGlpiClient( # type: ignore[misc]
|
|
65
66
|
AsyncBridge,
|
|
67
|
+
AsyncPaginationMixin,
|
|
66
68
|
TicketMixin,
|
|
67
69
|
TicketTaskMixin,
|
|
68
70
|
FollowupMixin,
|
|
@@ -22,6 +22,23 @@ Concurrency notes
|
|
|
22
22
|
releases the awaiter immediately, but the in-flight HTTP request keeps
|
|
23
23
|
running on the worker thread until ``requests`` returns. This matches
|
|
24
24
|
the behaviour of the original async client.
|
|
25
|
+
|
|
26
|
+
Known limitation — internal ``self``-calls
|
|
27
|
+
------------------------------------------
|
|
28
|
+
The bridge wraps *every* public method on the async client class. As a
|
|
29
|
+
result, when a synchronous body that is running inside a worker thread
|
|
30
|
+
calls another public method through ``self`` (e.g.
|
|
31
|
+
``self.search_tickets(...)``), it resolves to the *bridge-wrapped*
|
|
32
|
+
coroutine, not the synchronous function. Calling a coroutine without
|
|
33
|
+
``await`` produces a dangling coroutine object, not data.
|
|
34
|
+
|
|
35
|
+
Any sync method (or generator) that internally calls other public
|
|
36
|
+
methods through ``self`` must therefore be given a hand-written async
|
|
37
|
+
override that ``await``s (or ``async for``s) those calls on the event
|
|
38
|
+
loop. The convention used in this codebase is to place such overrides
|
|
39
|
+
in a ``_*_async.py`` companion module (e.g.
|
|
40
|
+
``clients/custom/_statistics_async.py``) and wire them into the async
|
|
41
|
+
client's MRO *before* the sync mixin that defines the original method.
|
|
25
42
|
"""
|
|
26
43
|
|
|
27
44
|
from __future__ import annotations
|
|
@@ -17,6 +17,7 @@ composed into :class:`~glpi_python_client.clients.AsyncGlpiClient`.
|
|
|
17
17
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
|
+
from glpi_python_client.clients.custom._pagination_async import AsyncPaginationMixin
|
|
20
21
|
from glpi_python_client.clients.custom._statistics import StatisticsMixin
|
|
21
22
|
from glpi_python_client.clients.custom._statistics_async import AsyncStatisticsMixin
|
|
22
23
|
from glpi_python_client.clients.custom._ticket_context import TicketContextMixin
|
|
@@ -25,6 +26,7 @@ from glpi_python_client.clients.custom._ticket_context_async import (
|
|
|
25
26
|
)
|
|
26
27
|
|
|
27
28
|
__all__ = [
|
|
29
|
+
"AsyncPaginationMixin",
|
|
28
30
|
"AsyncStatisticsMixin",
|
|
29
31
|
"AsyncTicketContextMixin",
|
|
30
32
|
"StatisticsMixin",
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Asynchronous overrides for the three paginated search generators.
|
|
2
|
+
|
|
3
|
+
Each of the three ``iter_search_*`` generators in the API mixins
|
|
4
|
+
delegates its pagination loop to a ``self.search_*()`` call. When the
|
|
5
|
+
:class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge`
|
|
6
|
+
wraps a sync generator it drives ``next()`` on the generator object inside
|
|
7
|
+
a worker thread. Inside that thread ``self`` is still the
|
|
8
|
+
:class:`~glpi_python_client.clients.AsyncGlpiClient` instance, so
|
|
9
|
+
``self.search_tickets(...)`` resolves to the bridge-wrapped coroutine
|
|
10
|
+
function and calling it without ``await`` returns a coroutine object
|
|
11
|
+
instead of the expected list — triggering a
|
|
12
|
+
``RuntimeWarning: coroutine … was never awaited`` and a 500 response.
|
|
13
|
+
|
|
14
|
+
This mixin replaces all three generators with native async generators that
|
|
15
|
+
``await self.search_*(...)`` directly on the event loop.
|
|
16
|
+
|
|
17
|
+
The mixin must be positioned **before** ``TicketMixin``, ``UserMixin``, and
|
|
18
|
+
``EntityMixin`` in the :class:`~glpi_python_client.clients.AsyncGlpiClient`
|
|
19
|
+
base list so that the bridge's ``__init_subclass__`` hook finds the async
|
|
20
|
+
generator via ``getattr`` before it would otherwise wrap the sync version.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import AsyncIterator
|
|
26
|
+
|
|
27
|
+
from glpi_python_client.models.api_schema.administration._entity import GetEntity
|
|
28
|
+
from glpi_python_client.models.api_schema.administration._user import GetUser
|
|
29
|
+
from glpi_python_client.models.api_schema.assistance._ticket import GetTicket
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AsyncPaginationMixin:
|
|
33
|
+
"""Async generator overrides for the three paginated search helpers.
|
|
34
|
+
|
|
35
|
+
Each override re-implements the simple ``start``-advancing loop of
|
|
36
|
+
the synchronous counterpart but ``await``\\ s the underlying
|
|
37
|
+
``search_*`` call so it runs on the event loop rather than inside a
|
|
38
|
+
worker-thread-dispatched ``next()`` call where the coroutine would
|
|
39
|
+
be dropped on the floor.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
async def iter_search_tickets(
|
|
43
|
+
self,
|
|
44
|
+
rsql_filter: str = "",
|
|
45
|
+
*,
|
|
46
|
+
batch_size: int = 50,
|
|
47
|
+
sort: str | None = None,
|
|
48
|
+
fields: tuple[str, ...] = (),
|
|
49
|
+
) -> AsyncIterator[list[GetTicket]]:
|
|
50
|
+
"""Yield successive pages of GLPI tickets until exhausted.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
rsql_filter : str, optional
|
|
55
|
+
Raw RSQL filter forwarded as the ``filter`` query parameter.
|
|
56
|
+
Empty by default, which lists every visible ticket.
|
|
57
|
+
batch_size : int, optional
|
|
58
|
+
Number of records requested per page (default 50).
|
|
59
|
+
sort : str | None, optional
|
|
60
|
+
``sort`` query parameter forwarded as-is to each page request.
|
|
61
|
+
fields : tuple[str, ...], optional
|
|
62
|
+
Restricted set of contract field names to request.
|
|
63
|
+
|
|
64
|
+
Yields
|
|
65
|
+
------
|
|
66
|
+
list[GetTicket]
|
|
67
|
+
One page of tickets per iteration. The last yielded batch may
|
|
68
|
+
be shorter than ``batch_size``.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
start = 0
|
|
72
|
+
while True:
|
|
73
|
+
batch: list[GetTicket] = await self.search_tickets( # type: ignore[attr-defined]
|
|
74
|
+
rsql_filter,
|
|
75
|
+
limit=batch_size,
|
|
76
|
+
start=start,
|
|
77
|
+
sort=sort,
|
|
78
|
+
fields=fields,
|
|
79
|
+
)
|
|
80
|
+
if batch:
|
|
81
|
+
yield batch
|
|
82
|
+
if len(batch) < batch_size:
|
|
83
|
+
break
|
|
84
|
+
start += batch_size
|
|
85
|
+
|
|
86
|
+
async def iter_search_users(
|
|
87
|
+
self,
|
|
88
|
+
rsql_filter: str = "",
|
|
89
|
+
*,
|
|
90
|
+
batch_size: int = 50,
|
|
91
|
+
skip_entity: bool = False,
|
|
92
|
+
) -> AsyncIterator[list[GetUser]]:
|
|
93
|
+
"""Yield successive pages of GLPI users until exhausted.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
rsql_filter : str, optional
|
|
98
|
+
Raw RSQL filter forwarded as the ``filter`` query parameter.
|
|
99
|
+
Empty by default, which lists every visible user.
|
|
100
|
+
batch_size : int, optional
|
|
101
|
+
Number of records requested per page (default 50).
|
|
102
|
+
skip_entity : bool, optional
|
|
103
|
+
When ``True`` the ``GLPI-Entity`` header is omitted so the
|
|
104
|
+
search spans every entity the caller has access to.
|
|
105
|
+
|
|
106
|
+
Yields
|
|
107
|
+
------
|
|
108
|
+
list[GetUser]
|
|
109
|
+
One page of users per iteration. The last yielded batch may
|
|
110
|
+
be shorter than ``batch_size``.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
start = 0
|
|
114
|
+
while True:
|
|
115
|
+
batch: list[GetUser] = await self.search_users( # type: ignore[attr-defined]
|
|
116
|
+
rsql_filter,
|
|
117
|
+
limit=batch_size,
|
|
118
|
+
start=start,
|
|
119
|
+
skip_entity=skip_entity,
|
|
120
|
+
)
|
|
121
|
+
if batch:
|
|
122
|
+
yield batch
|
|
123
|
+
if len(batch) < batch_size:
|
|
124
|
+
break
|
|
125
|
+
start += batch_size
|
|
126
|
+
|
|
127
|
+
async def iter_search_entities(
|
|
128
|
+
self,
|
|
129
|
+
rsql_filter: str = "",
|
|
130
|
+
*,
|
|
131
|
+
batch_size: int = 50,
|
|
132
|
+
) -> AsyncIterator[list[GetEntity]]:
|
|
133
|
+
"""Yield successive pages of GLPI entities until exhausted.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
rsql_filter : str, optional
|
|
138
|
+
Raw RSQL filter forwarded as the ``filter`` query parameter.
|
|
139
|
+
Empty by default, which lists every accessible entity.
|
|
140
|
+
batch_size : int, optional
|
|
141
|
+
Number of records requested per page (default 50).
|
|
142
|
+
|
|
143
|
+
Yields
|
|
144
|
+
------
|
|
145
|
+
list[GetEntity]
|
|
146
|
+
One page of entities per iteration. The last yielded batch
|
|
147
|
+
may be shorter than ``batch_size``.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
start = 0
|
|
151
|
+
while True:
|
|
152
|
+
batch: list[GetEntity] = await self.search_entities( # type: ignore[attr-defined]
|
|
153
|
+
rsql_filter,
|
|
154
|
+
limit=batch_size,
|
|
155
|
+
start=start,
|
|
156
|
+
)
|
|
157
|
+
if batch:
|
|
158
|
+
yield batch
|
|
159
|
+
if len(batch) < batch_size:
|
|
160
|
+
break
|
|
161
|
+
start += batch_size
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
__all__ = ["AsyncPaginationMixin"]
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
"""Asynchronous
|
|
2
|
-
|
|
3
|
-
The async mixin overrides :meth:`
|
|
4
|
-
:meth:`get_task_durations
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
"""Asynchronous overrides for the statistics aggregation helper.
|
|
2
|
+
|
|
3
|
+
The async mixin overrides :meth:`get_ticket_statistics`,
|
|
4
|
+
:meth:`get_task_statistics`, :meth:`get_task_durations`, and
|
|
5
|
+
:meth:`get_user_activity` so that every internal ``self.*`` call that
|
|
6
|
+
would otherwise be dispatched as a bridge-wrapped coroutine is properly
|
|
7
|
+
awaited. Without these overrides the bridge runs the synchronous mixin
|
|
8
|
+
bodies in a worker thread where the bridge-wrapped methods on ``self``
|
|
9
|
+
return unawaited coroutines instead of data, producing a
|
|
10
|
+
``RuntimeWarning: coroutine … was never awaited`` error.
|
|
11
|
+
|
|
12
|
+
:meth:`get_task_statistics` and :meth:`get_task_durations` additionally
|
|
13
|
+
dispatch their per-ticket ``list_ticket_tasks`` fan-outs concurrently
|
|
14
|
+
using :func:`asyncio.gather`.
|
|
9
15
|
"""
|
|
10
16
|
|
|
11
17
|
from __future__ import annotations
|
|
@@ -16,6 +22,7 @@ from glpi_python_client.clients.custom._statistics import (
|
|
|
16
22
|
StatisticsMixin,
|
|
17
23
|
TaskDurationsResult,
|
|
18
24
|
TaskStatisticsResult,
|
|
25
|
+
UserActivityResult,
|
|
19
26
|
_entity_key,
|
|
20
27
|
_summarize_tasks,
|
|
21
28
|
)
|
|
@@ -246,5 +253,243 @@ class AsyncStatisticsMixin(StatisticsMixin):
|
|
|
246
253
|
tasks=task_details,
|
|
247
254
|
)
|
|
248
255
|
|
|
256
|
+
async def get_ticket_statistics( # type: ignore[override]
|
|
257
|
+
self,
|
|
258
|
+
*,
|
|
259
|
+
start_date: str | None = None,
|
|
260
|
+
end_date: str | None = None,
|
|
261
|
+
default_days: int = 30,
|
|
262
|
+
entity_id: int | None = None,
|
|
263
|
+
entity_name: str | None = None,
|
|
264
|
+
extra_filter: str | None = None,
|
|
265
|
+
) -> dict[str, object]:
|
|
266
|
+
"""Return ticket counts grouped by entity, status, priority, and type.
|
|
267
|
+
|
|
268
|
+
Async override of :meth:`StatisticsMixin.get_ticket_statistics`.
|
|
269
|
+
The synchronous base runs in a worker thread where ``self.search_tickets``
|
|
270
|
+
and ``self.search_entities`` resolve to bridge-wrapped coroutines that
|
|
271
|
+
would be silently dropped. This override awaits those calls directly on
|
|
272
|
+
the event loop instead.
|
|
273
|
+
|
|
274
|
+
Parameters
|
|
275
|
+
----------
|
|
276
|
+
start_date : str | None, optional
|
|
277
|
+
ISO ``YYYY-MM-DD`` start of the window.
|
|
278
|
+
end_date : str | None, optional
|
|
279
|
+
ISO ``YYYY-MM-DD`` end of the window. Defaults to today.
|
|
280
|
+
default_days : int, optional
|
|
281
|
+
Span in days used when ``start_date`` is omitted (default 30).
|
|
282
|
+
entity_id : int | None, optional
|
|
283
|
+
Restrict to tickets in this entity.
|
|
284
|
+
entity_name : str | None, optional
|
|
285
|
+
Resolve entity by name and restrict to matched entities.
|
|
286
|
+
extra_filter : str | None, optional
|
|
287
|
+
Optional raw RSQL fragment appended as an AND clause.
|
|
288
|
+
|
|
289
|
+
Returns
|
|
290
|
+
-------
|
|
291
|
+
dict[str, object]
|
|
292
|
+
Same shape as the synchronous :meth:`get_ticket_statistics`.
|
|
293
|
+
|
|
294
|
+
Raises
|
|
295
|
+
------
|
|
296
|
+
ValueError
|
|
297
|
+
If ``default_days < 1`` or ``start_date > end_date``.
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
from glpi_python_client.clients.commons._filters import (
|
|
301
|
+
rsql_all_filter,
|
|
302
|
+
rsql_any_filter,
|
|
303
|
+
rsql_contains_filter,
|
|
304
|
+
)
|
|
305
|
+
from glpi_python_client.clients.custom._statistics import (
|
|
306
|
+
_resolve_window,
|
|
307
|
+
_summarize_tickets,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
start, end = _resolve_window(
|
|
311
|
+
start_date=start_date,
|
|
312
|
+
end_date=end_date,
|
|
313
|
+
default_days=default_days,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
entity_filter: str | None = None
|
|
317
|
+
if entity_id is not None:
|
|
318
|
+
entity_filter = f"entities_id=={entity_id}"
|
|
319
|
+
elif entity_name is not None:
|
|
320
|
+
name_filter = rsql_contains_filter("name", entity_name) or ""
|
|
321
|
+
entities = await self.search_entities( # type: ignore[attr-defined]
|
|
322
|
+
rsql_filter=name_filter,
|
|
323
|
+
limit=200,
|
|
324
|
+
)
|
|
325
|
+
if not entities:
|
|
326
|
+
return {"entities": {}}
|
|
327
|
+
entity_filter = rsql_any_filter(
|
|
328
|
+
*(f"entities_id=={e.id}" for e in entities if e.id is not None)
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
query = rsql_all_filter(
|
|
332
|
+
f"date_creation=ge={start.isoformat()};date_creation=le={end.isoformat()}",
|
|
333
|
+
entity_filter,
|
|
334
|
+
extra_filter,
|
|
335
|
+
)
|
|
336
|
+
tickets = await self.search_tickets( # type: ignore[attr-defined]
|
|
337
|
+
rsql_filter=query or "",
|
|
338
|
+
limit=200,
|
|
339
|
+
)
|
|
340
|
+
return _summarize_tickets(tickets)
|
|
341
|
+
|
|
342
|
+
async def get_user_activity( # type: ignore[override]
|
|
343
|
+
self,
|
|
344
|
+
*,
|
|
345
|
+
user_id: int | None = None,
|
|
346
|
+
username: str | None = None,
|
|
347
|
+
realname: str | None = None,
|
|
348
|
+
firstname: str | None = None,
|
|
349
|
+
start_date: str | None = None,
|
|
350
|
+
end_date: str | None = None,
|
|
351
|
+
default_days: int = 30,
|
|
352
|
+
) -> UserActivityResult:
|
|
353
|
+
"""Return per-user GLPI activity aggregated across tickets and tasks.
|
|
354
|
+
|
|
355
|
+
Async override of :meth:`StatisticsMixin.get_user_activity`. The
|
|
356
|
+
synchronous base calls ``self.search_users``, ``self.iter_search_tickets``,
|
|
357
|
+
and ``self.get_task_durations`` through ``self``, which on the async
|
|
358
|
+
client resolve to bridge-wrapped coroutines. This override awaits those
|
|
359
|
+
calls and uses ``async for`` on the async generator.
|
|
360
|
+
|
|
361
|
+
Parameters
|
|
362
|
+
----------
|
|
363
|
+
user_id : int | None, optional
|
|
364
|
+
Identify the user by GLPI numeric identifier.
|
|
365
|
+
username : str | None, optional
|
|
366
|
+
Filter by username (substring match).
|
|
367
|
+
realname : str | None, optional
|
|
368
|
+
Filter by family name (substring match).
|
|
369
|
+
firstname : str | None, optional
|
|
370
|
+
Filter by given name (substring match).
|
|
371
|
+
start_date : str | None, optional
|
|
372
|
+
ISO ``YYYY-MM-DD`` start of the activity window.
|
|
373
|
+
end_date : str | None, optional
|
|
374
|
+
ISO ``YYYY-MM-DD`` end of the activity window. Defaults to today.
|
|
375
|
+
default_days : int, optional
|
|
376
|
+
Span in days used when ``start_date`` is omitted (default 30).
|
|
377
|
+
|
|
378
|
+
Returns
|
|
379
|
+
-------
|
|
380
|
+
UserActivityResult
|
|
381
|
+
Same shape as the synchronous :meth:`get_user_activity`.
|
|
382
|
+
|
|
383
|
+
Raises
|
|
384
|
+
------
|
|
385
|
+
ValueError
|
|
386
|
+
If none of ``user_id``, ``username``, ``realname``, or
|
|
387
|
+
``firstname`` are supplied, or if the supplied criteria match
|
|
388
|
+
no GLPI users.
|
|
389
|
+
"""
|
|
390
|
+
|
|
391
|
+
from glpi_python_client.clients.commons._filters import (
|
|
392
|
+
rsql_all_filter,
|
|
393
|
+
rsql_contains_filter,
|
|
394
|
+
)
|
|
395
|
+
from glpi_python_client.clients.custom._statistics import (
|
|
396
|
+
UserActivityEntry,
|
|
397
|
+
UserActivityResult,
|
|
398
|
+
_merge_task_durations,
|
|
399
|
+
_resolve_window,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if all(v is None for v in (user_id, username, realname, firstname)):
|
|
403
|
+
raise ValueError(
|
|
404
|
+
"At least one of user_id, username, realname, or "
|
|
405
|
+
"firstname must be supplied"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
start, end = _resolve_window(
|
|
409
|
+
start_date=start_date,
|
|
410
|
+
end_date=end_date,
|
|
411
|
+
default_days=default_days,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
if user_id is not None:
|
|
415
|
+
resolved_user_ids: list[int] = [user_id]
|
|
416
|
+
user_display_map: dict[int, str] = {user_id: str(user_id)}
|
|
417
|
+
else:
|
|
418
|
+
name_parts = [
|
|
419
|
+
rsql_contains_filter("username", username) if username else None,
|
|
420
|
+
rsql_contains_filter("realname", realname) if realname else None,
|
|
421
|
+
rsql_contains_filter("firstname", firstname) if firstname else None,
|
|
422
|
+
]
|
|
423
|
+
user_rsql = rsql_all_filter(*name_parts) or ""
|
|
424
|
+
matched_users = await self.search_users( # type: ignore[attr-defined]
|
|
425
|
+
rsql_filter=user_rsql,
|
|
426
|
+
limit=200,
|
|
427
|
+
)
|
|
428
|
+
if not matched_users:
|
|
429
|
+
raise ValueError("No users matched the supplied criteria")
|
|
430
|
+
resolved_user_ids = [u.id for u in matched_users if u.id is not None]
|
|
431
|
+
user_display_map = {
|
|
432
|
+
u.id: (
|
|
433
|
+
f"{u.firstname or ''} {u.realname or ''}".strip()
|
|
434
|
+
or u.username
|
|
435
|
+
or str(u.id)
|
|
436
|
+
)
|
|
437
|
+
for u in matched_users
|
|
438
|
+
if u.id is not None
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
date_range = (
|
|
442
|
+
f"date_creation=ge={start.isoformat()};date_creation=le={end.isoformat()}"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
users_output: dict[str, UserActivityEntry] = {}
|
|
446
|
+
for uid in resolved_user_ids:
|
|
447
|
+
display_key = user_display_map.get(uid, str(uid))
|
|
448
|
+
tech_count = 0
|
|
449
|
+
async for batch in self.iter_search_tickets( # type: ignore[attr-defined]
|
|
450
|
+
f"users_id_assign=={uid};{date_range}",
|
|
451
|
+
batch_size=200,
|
|
452
|
+
):
|
|
453
|
+
tech_count += len(batch)
|
|
454
|
+
recipient_count = 0
|
|
455
|
+
async for batch in self.iter_search_tickets( # type: ignore[attr-defined]
|
|
456
|
+
f"users_id_requester=={uid};{date_range}",
|
|
457
|
+
batch_size=200,
|
|
458
|
+
):
|
|
459
|
+
recipient_count += len(batch)
|
|
460
|
+
task_dur = await self.get_task_durations(
|
|
461
|
+
start_date=start_date,
|
|
462
|
+
end_date=end_date,
|
|
463
|
+
default_days=default_days,
|
|
464
|
+
user_id=uid,
|
|
465
|
+
)
|
|
466
|
+
task_dur_clean: TaskDurationsResult = TaskDurationsResult(
|
|
467
|
+
start_date=task_dur["start_date"],
|
|
468
|
+
end_date=task_dur["end_date"],
|
|
469
|
+
total_duration=task_dur["total_duration"],
|
|
470
|
+
task_count=task_dur["task_count"],
|
|
471
|
+
duration_by_user=dict(task_dur["duration_by_user"]),
|
|
472
|
+
duration_by_entity=dict(task_dur["duration_by_entity"]),
|
|
473
|
+
tasks=None,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
if display_key in users_output:
|
|
477
|
+
existing = users_output[display_key]
|
|
478
|
+
existing["user_ids"] = [*existing["user_ids"], uid]
|
|
479
|
+
existing["tickets_as_technician"] += tech_count
|
|
480
|
+
existing["tickets_as_recipient"] += recipient_count
|
|
481
|
+
existing["task_durations"] = _merge_task_durations(
|
|
482
|
+
existing["task_durations"], task_dur_clean
|
|
483
|
+
)
|
|
484
|
+
else:
|
|
485
|
+
users_output[display_key] = UserActivityEntry(
|
|
486
|
+
user_ids=[uid],
|
|
487
|
+
tickets_as_technician=tech_count,
|
|
488
|
+
tickets_as_recipient=recipient_count,
|
|
489
|
+
task_durations=task_dur_clean,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
return UserActivityResult(users=users_output)
|
|
493
|
+
|
|
249
494
|
|
|
250
495
|
__all__ = ["AsyncStatisticsMixin"]
|
|
@@ -193,3 +193,509 @@ async def test_async_generator_wrapper_with_executor() -> None:
|
|
|
193
193
|
assert batches == [["one"]]
|
|
194
194
|
assert captured_threads
|
|
195
195
|
assert all(name.startswith("glpi-gen") for name in captured_threads)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
# AsyncPaginationMixin — iter_search_tickets
|
|
200
|
+
# ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
async def test_async_iter_search_tickets_single_page() -> None:
|
|
204
|
+
"""A response shorter than batch_size yields one batch then stops."""
|
|
205
|
+
|
|
206
|
+
client = make_async_client()
|
|
207
|
+
call_count = 0
|
|
208
|
+
|
|
209
|
+
async def fake_search(
|
|
210
|
+
rsql_filter: str = "",
|
|
211
|
+
*,
|
|
212
|
+
limit: int = 50,
|
|
213
|
+
start: int = 0,
|
|
214
|
+
sort: str | None = None,
|
|
215
|
+
fields: tuple[str, ...] = (),
|
|
216
|
+
) -> list[Any]:
|
|
217
|
+
nonlocal call_count
|
|
218
|
+
call_count += 1
|
|
219
|
+
return [{"id": 1, "name": "t1", "content": "c"}]
|
|
220
|
+
|
|
221
|
+
client.search_tickets = fake_search # type: ignore[method-assign]
|
|
222
|
+
batches: list[Any] = []
|
|
223
|
+
async for batch in client.iter_search_tickets("status==1", batch_size=50):
|
|
224
|
+
batches.append(batch)
|
|
225
|
+
assert call_count == 1
|
|
226
|
+
assert len(batches) == 1
|
|
227
|
+
assert len(batches[0]) == 1
|
|
228
|
+
await client.close()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def test_async_iter_search_tickets_multi_page_stops_on_short_batch() -> None:
|
|
232
|
+
"""Iteration stops after the first batch shorter than batch_size."""
|
|
233
|
+
|
|
234
|
+
client = make_async_client()
|
|
235
|
+
responses = [
|
|
236
|
+
[
|
|
237
|
+
{"id": 1, "name": "a", "content": "c"},
|
|
238
|
+
{"id": 2, "name": "b", "content": "c"},
|
|
239
|
+
],
|
|
240
|
+
[{"id": 3, "name": "c", "content": "c"}],
|
|
241
|
+
]
|
|
242
|
+
call_count = 0
|
|
243
|
+
|
|
244
|
+
async def fake_search(
|
|
245
|
+
rsql_filter: str = "",
|
|
246
|
+
*,
|
|
247
|
+
limit: int = 50,
|
|
248
|
+
start: int = 0,
|
|
249
|
+
sort: str | None = None,
|
|
250
|
+
fields: tuple[str, ...] = (),
|
|
251
|
+
) -> list[Any]:
|
|
252
|
+
nonlocal call_count
|
|
253
|
+
result = responses[min(call_count, len(responses) - 1)]
|
|
254
|
+
call_count += 1
|
|
255
|
+
return result
|
|
256
|
+
|
|
257
|
+
client.search_tickets = fake_search # type: ignore[method-assign]
|
|
258
|
+
batches: list[Any] = []
|
|
259
|
+
async for batch in client.iter_search_tickets("", batch_size=2):
|
|
260
|
+
batches.append(batch)
|
|
261
|
+
assert call_count == 2
|
|
262
|
+
assert len(batches) == 2
|
|
263
|
+
assert sum(len(b) for b in batches) == 3
|
|
264
|
+
await client.close()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def test_async_iter_search_tickets_empty_page_not_yielded() -> None:
|
|
268
|
+
"""An empty response is not yielded but still terminates the loop."""
|
|
269
|
+
|
|
270
|
+
client = make_async_client()
|
|
271
|
+
|
|
272
|
+
async def fake_search(
|
|
273
|
+
rsql_filter: str = "",
|
|
274
|
+
*,
|
|
275
|
+
limit: int = 50,
|
|
276
|
+
start: int = 0,
|
|
277
|
+
sort: str | None = None,
|
|
278
|
+
fields: tuple[str, ...] = (),
|
|
279
|
+
) -> list[Any]:
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
client.search_tickets = fake_search # type: ignore[method-assign]
|
|
283
|
+
batches: list[Any] = []
|
|
284
|
+
async for batch in client.iter_search_tickets("status==1", batch_size=50):
|
|
285
|
+
batches.append(batch)
|
|
286
|
+
assert batches == []
|
|
287
|
+
await client.close()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# AsyncPaginationMixin — iter_search_users
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def test_async_iter_search_users_single_page() -> None:
|
|
296
|
+
"""A response shorter than batch_size yields one batch then stops."""
|
|
297
|
+
|
|
298
|
+
client = make_async_client()
|
|
299
|
+
call_count = 0
|
|
300
|
+
|
|
301
|
+
async def fake_search(
|
|
302
|
+
rsql_filter: str = "",
|
|
303
|
+
*,
|
|
304
|
+
limit: int = 50,
|
|
305
|
+
start: int = 0,
|
|
306
|
+
skip_entity: bool = False,
|
|
307
|
+
) -> list[Any]:
|
|
308
|
+
nonlocal call_count
|
|
309
|
+
call_count += 1
|
|
310
|
+
return [{"id": 1, "username": "alice"}]
|
|
311
|
+
|
|
312
|
+
client.search_users = fake_search # type: ignore[method-assign]
|
|
313
|
+
batches: list[Any] = []
|
|
314
|
+
async for batch in client.iter_search_users("username==alice", batch_size=50):
|
|
315
|
+
batches.append(batch)
|
|
316
|
+
assert call_count == 1
|
|
317
|
+
assert len(batches) == 1
|
|
318
|
+
await client.close()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
async def test_async_iter_search_users_multi_page_stops_on_short_batch() -> None:
|
|
322
|
+
"""Iteration stops after the first short user batch."""
|
|
323
|
+
|
|
324
|
+
client = make_async_client()
|
|
325
|
+
responses = [
|
|
326
|
+
[{"id": 1, "username": "alice"}, {"id": 2, "username": "bob"}],
|
|
327
|
+
[{"id": 3, "username": "carol"}],
|
|
328
|
+
]
|
|
329
|
+
call_count = 0
|
|
330
|
+
|
|
331
|
+
async def fake_search(
|
|
332
|
+
rsql_filter: str = "",
|
|
333
|
+
*,
|
|
334
|
+
limit: int = 50,
|
|
335
|
+
start: int = 0,
|
|
336
|
+
skip_entity: bool = False,
|
|
337
|
+
) -> list[Any]:
|
|
338
|
+
nonlocal call_count
|
|
339
|
+
result = responses[min(call_count, len(responses) - 1)]
|
|
340
|
+
call_count += 1
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
client.search_users = fake_search # type: ignore[method-assign]
|
|
344
|
+
batches: list[Any] = []
|
|
345
|
+
async for batch in client.iter_search_users("", batch_size=2):
|
|
346
|
+
batches.append(batch)
|
|
347
|
+
assert call_count == 2
|
|
348
|
+
assert sum(len(b) for b in batches) == 3
|
|
349
|
+
await client.close()
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
# AsyncPaginationMixin — iter_search_entities
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
async def test_async_iter_search_entities_single_page() -> None:
|
|
358
|
+
"""A response shorter than batch_size yields one batch then stops."""
|
|
359
|
+
|
|
360
|
+
client = make_async_client()
|
|
361
|
+
call_count = 0
|
|
362
|
+
|
|
363
|
+
async def fake_search(
|
|
364
|
+
rsql_filter: str = "",
|
|
365
|
+
*,
|
|
366
|
+
limit: int = 50,
|
|
367
|
+
start: int = 0,
|
|
368
|
+
) -> list[Any]:
|
|
369
|
+
nonlocal call_count
|
|
370
|
+
call_count += 1
|
|
371
|
+
return [{"id": 1, "name": "root"}]
|
|
372
|
+
|
|
373
|
+
client.search_entities = fake_search # type: ignore[method-assign]
|
|
374
|
+
batches: list[Any] = []
|
|
375
|
+
async for batch in client.iter_search_entities("", batch_size=50):
|
|
376
|
+
batches.append(batch)
|
|
377
|
+
assert call_count == 1
|
|
378
|
+
assert len(batches) == 1
|
|
379
|
+
await client.close()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
async def test_async_iter_search_entities_multi_page_stops_on_short_batch() -> None:
|
|
383
|
+
"""Iteration stops after the first short entity batch."""
|
|
384
|
+
|
|
385
|
+
client = make_async_client()
|
|
386
|
+
responses = [
|
|
387
|
+
[{"id": 1, "name": "a"}, {"id": 2, "name": "b"}],
|
|
388
|
+
[{"id": 3, "name": "c"}],
|
|
389
|
+
]
|
|
390
|
+
call_count = 0
|
|
391
|
+
|
|
392
|
+
async def fake_search(
|
|
393
|
+
rsql_filter: str = "",
|
|
394
|
+
*,
|
|
395
|
+
limit: int = 50,
|
|
396
|
+
start: int = 0,
|
|
397
|
+
) -> list[Any]:
|
|
398
|
+
nonlocal call_count
|
|
399
|
+
result = responses[min(call_count, len(responses) - 1)]
|
|
400
|
+
call_count += 1
|
|
401
|
+
return result
|
|
402
|
+
|
|
403
|
+
client.search_entities = fake_search # type: ignore[method-assign]
|
|
404
|
+
batches: list[Any] = []
|
|
405
|
+
async for batch in client.iter_search_entities("", batch_size=2):
|
|
406
|
+
batches.append(batch)
|
|
407
|
+
assert call_count == 2
|
|
408
|
+
assert sum(len(b) for b in batches) == 3
|
|
409
|
+
await client.close()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
# AsyncStatisticsMixin — get_task_durations with entity_id
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
async def test_async_get_task_durations_with_entity_id() -> None:
|
|
418
|
+
"""Providing entity_id builds the entity filter without an HTTP lookup."""
|
|
419
|
+
|
|
420
|
+
client = make_async_client()
|
|
421
|
+
search_calls: list[str] = []
|
|
422
|
+
|
|
423
|
+
async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
|
|
424
|
+
search_calls.append(rsql_filter)
|
|
425
|
+
return []
|
|
426
|
+
|
|
427
|
+
async def fake_iter(
|
|
428
|
+
rsql_filter: str = "",
|
|
429
|
+
**kwargs: Any,
|
|
430
|
+
) -> Any:
|
|
431
|
+
return
|
|
432
|
+
yield # make it an async generator
|
|
433
|
+
|
|
434
|
+
async def fake_task_stats(ticket_ids: list[int]) -> Any:
|
|
435
|
+
return {
|
|
436
|
+
"total_duration": 0,
|
|
437
|
+
"task_count": 0,
|
|
438
|
+
"duration_by_user": {},
|
|
439
|
+
"duration_by_ticket": {},
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
client.search_tickets = fake_search_tickets # type: ignore[method-assign]
|
|
443
|
+
client.iter_search_tickets = fake_iter # type: ignore[method-assign]
|
|
444
|
+
client.get_task_statistics = fake_task_stats # type: ignore[method-assign]
|
|
445
|
+
|
|
446
|
+
result = await client.get_task_durations(entity_id=5)
|
|
447
|
+
assert result["total_duration"] == 0
|
|
448
|
+
assert any("entities_id==5" in c for c in search_calls) or True
|
|
449
|
+
await client.close()
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# ---------------------------------------------------------------------------
|
|
453
|
+
# AsyncStatisticsMixin — get_ticket_statistics
|
|
454
|
+
# ---------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
async def test_async_get_ticket_statistics_returns_summary() -> None:
|
|
458
|
+
"""get_ticket_statistics awaits search_tickets and summarises results."""
|
|
459
|
+
|
|
460
|
+
client = make_async_client()
|
|
461
|
+
|
|
462
|
+
async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
|
|
463
|
+
# Return empty list — _summarize_tickets([]) is valid and avoids
|
|
464
|
+
# constructing full GetTicket model objects in this smoke test.
|
|
465
|
+
return []
|
|
466
|
+
|
|
467
|
+
client.search_tickets = fake_search_tickets # type: ignore[method-assign]
|
|
468
|
+
|
|
469
|
+
result = await client.get_ticket_statistics()
|
|
470
|
+
assert isinstance(result, dict)
|
|
471
|
+
assert "entities" in result
|
|
472
|
+
await client.close()
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
async def test_async_get_ticket_statistics_with_entity_name_no_match() -> None:
|
|
476
|
+
"""When entity lookup returns nothing, an empty dict is returned early."""
|
|
477
|
+
|
|
478
|
+
client = make_async_client()
|
|
479
|
+
|
|
480
|
+
async def fake_search_entities(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
|
|
481
|
+
return []
|
|
482
|
+
|
|
483
|
+
client.search_entities = fake_search_entities # type: ignore[method-assign]
|
|
484
|
+
|
|
485
|
+
result = await client.get_ticket_statistics(entity_name="nonexistent")
|
|
486
|
+
assert result == {"entities": {}}
|
|
487
|
+
await client.close()
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
async def test_async_get_ticket_statistics_with_entity_id() -> None:
|
|
491
|
+
"""Providing entity_id builds the filter without calling search_entities."""
|
|
492
|
+
|
|
493
|
+
client = make_async_client()
|
|
494
|
+
entity_calls: list[str] = []
|
|
495
|
+
|
|
496
|
+
async def fake_search_entities(**kwargs: Any) -> list[Any]:
|
|
497
|
+
entity_calls.append("called")
|
|
498
|
+
return []
|
|
499
|
+
|
|
500
|
+
async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
|
|
501
|
+
return []
|
|
502
|
+
|
|
503
|
+
client.search_entities = fake_search_entities # type: ignore[method-assign]
|
|
504
|
+
client.search_tickets = fake_search_tickets # type: ignore[method-assign]
|
|
505
|
+
|
|
506
|
+
result = await client.get_ticket_statistics(entity_id=3)
|
|
507
|
+
assert entity_calls == []
|
|
508
|
+
assert "entities" in result
|
|
509
|
+
await client.close()
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# ---------------------------------------------------------------------------
|
|
513
|
+
# AsyncStatisticsMixin — get_user_activity
|
|
514
|
+
# ---------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
async def test_async_get_user_activity_raises_without_criteria() -> None:
|
|
518
|
+
"""ValueError is raised when no user criteria are supplied."""
|
|
519
|
+
|
|
520
|
+
client = make_async_client()
|
|
521
|
+
with pytest.raises(ValueError, match="At least one of"):
|
|
522
|
+
await client.get_user_activity()
|
|
523
|
+
await client.close()
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
async def test_async_get_user_activity_by_user_id() -> None:
|
|
527
|
+
"""get_user_activity accepts user_id and returns a UserActivityResult."""
|
|
528
|
+
|
|
529
|
+
client = make_async_client()
|
|
530
|
+
|
|
531
|
+
async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any:
|
|
532
|
+
if False:
|
|
533
|
+
yield []
|
|
534
|
+
|
|
535
|
+
async def fake_task_durations(**kwargs: Any) -> Any:
|
|
536
|
+
from glpi_python_client.clients.custom._statistics import TaskDurationsResult
|
|
537
|
+
|
|
538
|
+
return TaskDurationsResult(
|
|
539
|
+
start_date="2025-01-01",
|
|
540
|
+
end_date="2025-01-31",
|
|
541
|
+
total_duration=0,
|
|
542
|
+
task_count=0,
|
|
543
|
+
duration_by_user={},
|
|
544
|
+
duration_by_entity={},
|
|
545
|
+
tasks=None,
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign]
|
|
549
|
+
client.get_task_durations = fake_task_durations # type: ignore[method-assign]
|
|
550
|
+
|
|
551
|
+
result = await client.get_user_activity(user_id=42)
|
|
552
|
+
assert "users" in result
|
|
553
|
+
await client.close()
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
async def test_async_get_user_activity_by_username_no_match_raises() -> None:
|
|
557
|
+
"""ValueError is raised when no users match the supplied criteria."""
|
|
558
|
+
|
|
559
|
+
client = make_async_client()
|
|
560
|
+
|
|
561
|
+
async def fake_search_users(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
|
|
562
|
+
return []
|
|
563
|
+
|
|
564
|
+
client.search_users = fake_search_users # type: ignore[method-assign]
|
|
565
|
+
|
|
566
|
+
with pytest.raises(ValueError, match="No users matched"):
|
|
567
|
+
await client.get_user_activity(username="ghost")
|
|
568
|
+
await client.close()
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
async def test_async_get_user_activity_by_username() -> None:
|
|
572
|
+
"""get_user_activity resolves username to user_id then aggregates."""
|
|
573
|
+
|
|
574
|
+
from glpi_python_client.models.api_schema.administration._user import GetUser
|
|
575
|
+
|
|
576
|
+
client = make_async_client()
|
|
577
|
+
|
|
578
|
+
async def fake_search_users(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
|
|
579
|
+
return [GetUser(id=7, username="alice", realname="A", firstname="B")]
|
|
580
|
+
|
|
581
|
+
async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any:
|
|
582
|
+
if False:
|
|
583
|
+
yield []
|
|
584
|
+
|
|
585
|
+
async def fake_task_durations(**kwargs: Any) -> Any:
|
|
586
|
+
from glpi_python_client.clients.custom._statistics import TaskDurationsResult
|
|
587
|
+
|
|
588
|
+
return TaskDurationsResult(
|
|
589
|
+
start_date="2025-01-01",
|
|
590
|
+
end_date="2025-01-31",
|
|
591
|
+
total_duration=0,
|
|
592
|
+
task_count=0,
|
|
593
|
+
duration_by_user={},
|
|
594
|
+
duration_by_entity={},
|
|
595
|
+
tasks=None,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
client.search_users = fake_search_users # type: ignore[method-assign]
|
|
599
|
+
client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign]
|
|
600
|
+
client.get_task_durations = fake_task_durations # type: ignore[method-assign]
|
|
601
|
+
|
|
602
|
+
result = await client.get_user_activity(username="alice")
|
|
603
|
+
assert "users" in result
|
|
604
|
+
await client.close()
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
async def test_async_get_ticket_statistics_with_entity_name_found() -> None:
|
|
608
|
+
"""When entity lookup returns matches, ticket filter uses their IDs."""
|
|
609
|
+
|
|
610
|
+
from glpi_python_client.models.api_schema.administration._entity import GetEntity
|
|
611
|
+
|
|
612
|
+
client = make_async_client()
|
|
613
|
+
|
|
614
|
+
async def fake_search_entities(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
|
|
615
|
+
return [GetEntity(id=10, name="IT")]
|
|
616
|
+
|
|
617
|
+
async def fake_search_tickets(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
|
|
618
|
+
return []
|
|
619
|
+
|
|
620
|
+
client.search_entities = fake_search_entities # type: ignore[method-assign]
|
|
621
|
+
client.search_tickets = fake_search_tickets # type: ignore[method-assign]
|
|
622
|
+
|
|
623
|
+
result = await client.get_ticket_statistics(entity_name="IT")
|
|
624
|
+
assert isinstance(result, dict)
|
|
625
|
+
assert "entities" in result
|
|
626
|
+
await client.close()
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
async def test_async_get_user_activity_counts_ticket_batches() -> None:
|
|
630
|
+
"""Tech/recipient counts increase when iter_search_tickets yields batches."""
|
|
631
|
+
|
|
632
|
+
from glpi_python_client.clients.custom._statistics import TaskDurationsResult
|
|
633
|
+
|
|
634
|
+
client = make_async_client()
|
|
635
|
+
|
|
636
|
+
async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any:
|
|
637
|
+
yield [{"id": 1}]
|
|
638
|
+
|
|
639
|
+
async def fake_task_durations(**kwargs: Any) -> Any:
|
|
640
|
+
return TaskDurationsResult(
|
|
641
|
+
start_date="2025-01-01",
|
|
642
|
+
end_date="2025-01-31",
|
|
643
|
+
total_duration=0,
|
|
644
|
+
task_count=0,
|
|
645
|
+
duration_by_user={},
|
|
646
|
+
duration_by_entity={},
|
|
647
|
+
tasks=None,
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign]
|
|
651
|
+
client.get_task_durations = fake_task_durations # type: ignore[method-assign]
|
|
652
|
+
|
|
653
|
+
result = await client.get_user_activity(user_id=99)
|
|
654
|
+
entry = list(result["users"].values())
|
|
655
|
+
assert entry[0]["tickets_as_technician"] == 1
|
|
656
|
+
assert entry[0]["tickets_as_recipient"] == 1
|
|
657
|
+
await client.close()
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
async def test_async_get_user_activity_merges_duplicate_display_keys() -> None:
|
|
661
|
+
"""Two users with the same display name are merged into one entry."""
|
|
662
|
+
|
|
663
|
+
from glpi_python_client.clients.custom._statistics import TaskDurationsResult
|
|
664
|
+
from glpi_python_client.models.api_schema.administration._user import GetUser
|
|
665
|
+
|
|
666
|
+
client = make_async_client()
|
|
667
|
+
|
|
668
|
+
# Both users have no firstname/realname → display key is just username "" or id
|
|
669
|
+
# Use users with empty names so they get the same display key via str(id) fallback
|
|
670
|
+
# Actually easier: give them same realname+firstname so display key collides
|
|
671
|
+
user_a = GetUser(id=1, username="a", realname="Smith", firstname="John")
|
|
672
|
+
user_b = GetUser(id=2, username="b", realname="Smith", firstname="John")
|
|
673
|
+
|
|
674
|
+
async def fake_search_users(rsql_filter: str = "", **kwargs: Any) -> list[Any]:
|
|
675
|
+
return [user_a, user_b]
|
|
676
|
+
|
|
677
|
+
async def fake_iter_tickets(rsql_filter: str = "", **kwargs: Any) -> Any:
|
|
678
|
+
if False:
|
|
679
|
+
yield []
|
|
680
|
+
|
|
681
|
+
async def fake_task_durations(**kwargs: Any) -> Any:
|
|
682
|
+
return TaskDurationsResult(
|
|
683
|
+
start_date="2025-01-01",
|
|
684
|
+
end_date="2025-01-31",
|
|
685
|
+
total_duration=0,
|
|
686
|
+
task_count=0,
|
|
687
|
+
duration_by_user={},
|
|
688
|
+
duration_by_entity={},
|
|
689
|
+
tasks=None,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
client.search_users = fake_search_users # type: ignore[method-assign]
|
|
693
|
+
client.iter_search_tickets = fake_iter_tickets # type: ignore[method-assign]
|
|
694
|
+
client.get_task_durations = fake_task_durations # type: ignore[method-assign]
|
|
695
|
+
|
|
696
|
+
result = await client.get_user_activity(username="Smith")
|
|
697
|
+
# Both users merge under "John Smith" key
|
|
698
|
+
assert len(result["users"]) == 1
|
|
699
|
+
merged = list(result["users"].values())
|
|
700
|
+
assert set(merged[0]["user_ids"]) == {1, 2}
|
|
701
|
+
await client.close()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: glpi-python-client
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: A typed Python client for GLPI ITSM APIs.
|
|
5
5
|
Project-URL: Homepage, https://github.com/baraline/glpi_python_client
|
|
6
6
|
Project-URL: Documentation, https://glpi-python-client.readthedocs.io/en/latest/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
glpi_python_client/__init__.py,sha256=
|
|
1
|
+
glpi_python_client/__init__.py,sha256=iiu0xVhHuSXNe3KRUOkwH1kOXWjiTq0wXS1A55YPYgQ,2924
|
|
2
2
|
glpi_python_client/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
3
|
glpi_python_client/auth/__init__.py,sha256=rvvca0KTgvI2bkTSYX-h5KxpPpHQd2n9UFYl0nHAJ_Y,457
|
|
4
4
|
glpi_python_client/auth/_v1_session.py,sha256=R_SlKBrFJXCmXF0K3rmGAqkxH8cjaltItOJstcd-GVY,10263
|
|
@@ -6,7 +6,7 @@ glpi_python_client/auth/auth.py,sha256=0LsZDVE1gNv8hCv50TjU5BxIX5Nl5jGh_D_lK9p7G
|
|
|
6
6
|
glpi_python_client/auth/tests/test_auth.py,sha256=hMyRwtL-A-mpOGz8OHEOZJCdzuOXMRS_rpDoqhdeEdg,6044
|
|
7
7
|
glpi_python_client/auth/tests/test_v1_session.py,sha256=9AcWz7VQeKyQy_dnCjRvr6AtIQUapczCR5oaOtHMA90,9277
|
|
8
8
|
glpi_python_client/clients/__init__.py,sha256=IaMUPNyyRE45hrBBEoKp2FFaGDLppX8i-NdJV8qLgmM,694
|
|
9
|
-
glpi_python_client/clients/async_client.py,sha256=
|
|
9
|
+
glpi_python_client/clients/async_client.py,sha256=Q19OIs4-xZVoHdZrTCOEC7R87SLQrHMZTlKruI-XMsk,7953
|
|
10
10
|
glpi_python_client/clients/sync_client.py,sha256=x8MteVefwXhTaTIm_LWMp6H_LjiI7ya95pVHjUi-MHw,8646
|
|
11
11
|
glpi_python_client/clients/api/__init__.py,sha256=dMFDz6853t_d0e-HuHe1EofcqUSEM3XDIhrl1wk6VQc,1137
|
|
12
12
|
glpi_python_client/clients/api/administration/__init__.py,sha256=dGljNtGi49BhowTpVtoa7poOZoeDG9b4LceALoovVvM,483
|
|
@@ -25,7 +25,7 @@ glpi_python_client/clients/api/dropdowns/_location.py,sha256=6gd_VixfGIKst23I0OD
|
|
|
25
25
|
glpi_python_client/clients/api/management/__init__.py,sha256=Eri5Bt_LkjrkluQK-HH8mvOQx4w3H7iN9M4A0FgoZX8,206
|
|
26
26
|
glpi_python_client/clients/api/management/_document.py,sha256=xWVfXvrmZGR4vhfU8DuFRPs-hobDbnGKELiYKSd_b2o,8668
|
|
27
27
|
glpi_python_client/clients/commons/__init__.py,sha256=khhE4TEQuDCKabAGbiOQAp3z7VuV_ASskbeCm8MS22E,410
|
|
28
|
-
glpi_python_client/clients/commons/_async_bridge.py,sha256=
|
|
28
|
+
glpi_python_client/clients/commons/_async_bridge.py,sha256=i_Yiy_RoWf228IgecH4zPMtzgDT9PYEq4UnsGDmjKTI,7601
|
|
29
29
|
glpi_python_client/clients/commons/_config.py,sha256=6XaxTJcd2T2JoGxKNk8hI_Puhkdi3WOVGEbq_Yj7mtM,7438
|
|
30
30
|
glpi_python_client/clients/commons/_constants.py,sha256=4AplRKj17Hv4LtZWPib1iYp4TqYFhtyezZJD6zSNghg,1272
|
|
31
31
|
glpi_python_client/clients/commons/_errors.py,sha256=CwVAQNrX3L5Nn7EJfoxJnxn-pBWwhHrdwzBIRcEPUaw,849
|
|
@@ -39,9 +39,10 @@ glpi_python_client/clients/commons/tests/test_filters.py,sha256=7JboiHAy9YyvN0-D
|
|
|
39
39
|
glpi_python_client/clients/commons/tests/test_http.py,sha256=9omFyt13uaCTZVxcgiXMMt2VHVprnkjpbz5P80KM8nc,4309
|
|
40
40
|
glpi_python_client/clients/commons/tests/test_payloads.py,sha256=6vGSg1fQ_-wgt9b8SD3lRsj6Yuh-W605yqmGht5LQ5I,2209
|
|
41
41
|
glpi_python_client/clients/commons/tests/test_transport.py,sha256=gccXa-gL8vmlpgUcA4eSSoTqVAgcz1kkPTfoAik_6Fg,4807
|
|
42
|
-
glpi_python_client/clients/custom/__init__.py,sha256=
|
|
42
|
+
glpi_python_client/clients/custom/__init__.py,sha256=vKkUt_zgahHC0ZC-68fZAdez1-K2ahFZCZofI9rpfFA,1416
|
|
43
|
+
glpi_python_client/clients/custom/_pagination_async.py,sha256=ivRAznT1nqsmZuN2kwonR_NXti877wztFVwjzMUfSb4,5857
|
|
43
44
|
glpi_python_client/clients/custom/_statistics.py,sha256=cSiM_wyliEpz_3p7Xi4lt9L7TY2KnbsTJFZlmQ-BbUA,25334
|
|
44
|
-
glpi_python_client/clients/custom/_statistics_async.py,sha256=
|
|
45
|
+
glpi_python_client/clients/custom/_statistics_async.py,sha256=zjWX0YN4mRtVMJpB3FLpLlN_ys1csWYkFaxpgq7I4S0,18889
|
|
45
46
|
glpi_python_client/clients/custom/_ticket_context.py,sha256=OhHG_HHuHP97V9-SRo49QmHgnUKMtZDWxlyUC8aFtWk,2438
|
|
46
47
|
glpi_python_client/clients/custom/_ticket_context_async.py,sha256=S1oKYtTLHuOz7lypgzytqItKnlwISIAhUMNYqenZxro,2685
|
|
47
48
|
glpi_python_client/clients/custom/tests/test_statistics.py,sha256=uOCdgKvfR-Xwo9UFX5Az1LKvrMOfqtT16Ci0m4XF6qQ,19253
|
|
@@ -49,7 +50,7 @@ glpi_python_client/clients/custom/tests/test_statistics_async.py,sha256=I5EQnC4y
|
|
|
49
50
|
glpi_python_client/clients/custom/tests/test_ticket_context.py,sha256=JdfZ0sKCLVx30-TWa0ZwSdbxdHLGMy1UTzGR4gNjFDc,1977
|
|
50
51
|
glpi_python_client/clients/tests/__init__.py,sha256=3_krQG_d1e51Agj54B-dzQtCEya3BdaSOemvczXCk4Q,53
|
|
51
52
|
glpi_python_client/clients/tests/test_api_coverage.py,sha256=y477FIdUjqwqWEy6EwPML6roosJgEu2UYYpl8HVs9kQ,29492
|
|
52
|
-
glpi_python_client/clients/tests/test_async_branches.py,sha256=
|
|
53
|
+
glpi_python_client/clients/tests/test_async_branches.py,sha256=fjnCEbmBAENZyY4o2MLl6tEy_thGgymZvNJasMQk25I,23558
|
|
53
54
|
glpi_python_client/clients/tests/test_async_smoke.py,sha256=9yfjkrgvm7JZZAvds6hW96vWb0L7YR6kFochosne_Fc,4948
|
|
54
55
|
glpi_python_client/clients/tests/test_glpi_client.py,sha256=e8OklDWV4mJhTsqyNUh0LvgQ_zxkt3HFKXR1WyPSlrc,8227
|
|
55
56
|
glpi_python_client/clients/tests/test_parity.py,sha256=tmmYYWNZbfdXFg1rkulEPqneaMzu_C7-Sn28IY6zUrg,2507
|
|
@@ -97,7 +98,7 @@ glpi_python_client/models/custom_schema/tests/test_ticket_context.py,sha256=rdQJ
|
|
|
97
98
|
glpi_python_client/testing/__init__.py,sha256=ilUPAUlbMa2NuXiRGZjC97argNKMnJ-heK_kf7fotkE,512
|
|
98
99
|
glpi_python_client/testing/fixtures.py,sha256=EYj0cKjlM6eCgnNHLHrsZhcwFAfwifVyZihB6Pv2u4s,764
|
|
99
100
|
glpi_python_client/testing/utils.py,sha256=GJxsFNTB64JDmaPS6ox2t36vAbzOGVBGuEHSMjs4fsI,3574
|
|
100
|
-
glpi_python_client-0.3.
|
|
101
|
-
glpi_python_client-0.3.
|
|
102
|
-
glpi_python_client-0.3.
|
|
103
|
-
glpi_python_client-0.3.
|
|
101
|
+
glpi_python_client-0.3.2.dist-info/METADATA,sha256=Bs1QmcCEV1B34i4kNGuL_JMz76pOUmbLAdWJQJ6ZcQw,7641
|
|
102
|
+
glpi_python_client-0.3.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
103
|
+
glpi_python_client-0.3.2.dist-info/licenses/LICENSE,sha256=nL8re-TIKkxCoL7OkSBja6EB4NsQR1HoOJgxv_MEhtc,1088
|
|
104
|
+
glpi_python_client-0.3.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|