diff --git a/.coverage b/.coverage index ef820ab..d009bbe 100644 Binary files a/.coverage and b/.coverage differ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d10e39d..7d41d78 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,32 @@ +default_install_hook_types: [pre-commit, pre-push] + repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.13 hooks: - id: ruff-check args: [--fix] - - id: ruff-format \ No newline at end of file + - id: ruff-format + + - repo: local + hooks: + - id: mypy + name: mypy (strict) + entry: python -m mypy glpi_python_client + language: system + types: [python] + pass_filenames: false + + - id: pytest-coverage + name: pytest with coverage (>=95%) + entry: python -m pytest -m "not integration" --cov=glpi_python_client --cov-fail-under=95 -q + language: system + pass_filenames: false + stages: [pre-push] + + - id: sphinx-build + name: sphinx build (warnings as errors) + entry: python -m sphinx -b html -W --keep-going docs docs/_build/html_check + language: system + pass_filenames: false + stages: [pre-push] diff --git a/docs/user_guide.rst b/docs/user_guide.rst index a92e78b..844121e 100644 --- a/docs/user_guide.rst +++ b/docs/user_guide.rst @@ -186,6 +186,20 @@ need real concurrency: GLPI calls out concurrently with :func:`asyncio.gather`. * :meth:`AsyncGlpiClient.get_task_statistics` fans the per-ticket task list calls out concurrently with :func:`asyncio.gather`. +* :meth:`AsyncGlpiClient.get_task_durations` fans the per-ticket task + fetches out concurrently with :func:`asyncio.gather` when + ``return_task_details=True``. + +Pagination helpers (``iter_search_tickets``, ``iter_search_users``, +``iter_search_entities``) are exposed as **async generators** on the +async client. Iterate them with ``async for`` to walk every page +without blocking the event loop: + +.. code-block:: python + + async for batch in client.iter_search_tickets("status==1", batch_size=200): + for ticket in batch: + ... The synchronous versions of the same helpers issue the calls sequentially. @@ -710,25 +724,79 @@ Example — description and timeline only, no metadata fields: Reporting helpers ~~~~~~~~~~~~~~~~~ -The custom statistics mixin exposes two helpers that aggregate the +The custom statistics mixin exposes several helpers that aggregate the ticket and ticket-task records returned by the contract-aligned mixins. -Both return plain Python dictionaries so they can be serialised or +They all return plain Python dictionaries so they can be serialised or forwarded as-is. +Streaming pagination with ``iter_search_*`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``search_*`` helpers return one page at a time and require the +caller to manage the ``start`` cursor. The companion ``iter_search_*`` +generators handle pagination automatically by yielding successive +batches until the API returns fewer rows than the requested +``batch_size`` (the natural end-of-stream signal): + +* :meth:`GlpiClient.iter_search_tickets` +* :meth:`GlpiClient.iter_search_users` +* :meth:`GlpiClient.iter_search_entities` + +.. code-block:: python + + # Walk every "open" ticket without loading the full result set in memory. + total = 0 + for batch in client.iter_search_tickets("status==1", batch_size=200): + total += len(batch) + for ticket in batch: + print(ticket.id, ticket.name) + print(f"processed {total} tickets") + +On the asynchronous client the same helpers are exposed as **async +generators** through the bridge, so each ``next()`` call runs off the +event loop and the consumer uses ``async for``: + +.. code-block:: python + + async for batch in async_client.iter_search_users("", batch_size=100): + for user in batch: + print(user.id, user.username) + ``get_ticket_statistics`` ^^^^^^^^^^^^^^^^^^^^^^^^^ Counts tickets created within an ISO date window and groups them by -entity, status, priority, and type. +entity, status, priority, and type. Optional filters restrict the +result set on the server side: + +* ``entity_id`` — restrict to a single entity by numeric identifier. +* ``entity_name`` — substring match against the entity ``name`` column; + the helper resolves matching IDs via ``search_entities`` and ORs + them together. Ignored when ``entity_id`` is provided. +* ``extra_filter`` — raw RSQL fragment AND-joined with the date window. .. code-block:: python + # Tickets created in January 2026 on a specific entity, restricted to + # priority "HIGH" (5) via an extra raw RSQL fragment. stats = client.get_ticket_statistics( start_date="2026-01-01", end_date="2026-01-31", + entity_id=3, + extra_filter="priority==5", ) print(stats) + # Resolve the entity by (partial) name instead of by ID: + stats = client.get_ticket_statistics( + start_date="2026-01-01", + end_date="2026-01-31", + entity_name="Helpdesk", + ) + +When ``entity_name`` matches no entity the helper short-circuits and +returns ``{"entities": {}}`` without issuing any ticket search. + Returned shape (the outer key is always ``"entities"``; entity keys are the GLPI numeric identifier as a string, ``"unknown"`` when missing):: @@ -790,6 +858,115 @@ needed (for example ``client.get_user(22)`` to turn user key ``"22"`` into a full :class:`GetUser` model). +``get_task_durations`` +^^^^^^^^^^^^^^^^^^^^^^ + +Aggregates task durations over a date window with rich server-side +filters and an optional per-task detail list. Internally the helper +iterates :meth:`iter_search_tickets` to collect every matching ticket, +then computes per-user and per-entity totals. + +Available filters: + +* ``start_date`` / ``end_date`` / ``default_days`` — ISO ``YYYY-MM-DD`` + date window; ``default_days`` is used when ``start_date`` is omitted. +* ``entity_id`` — restrict to a single entity by identifier. +* ``entity_name`` — substring match resolved through ``search_entities``; + ignored when ``entity_id`` is given. +* ``user_id`` — tickets where the user is **either** assignee or + requester (OR semantics). +* ``user_editor_id`` — tickets last updated by this user. +* ``user_recipient_id`` — tickets where this user is the requester. +* ``extra_filter`` — raw RSQL fragment AND-joined with everything else. +* ``return_task_details`` — when ``True``, fetch every non-zero ticket's + task list and include them as ``tasks`` in the result. + +.. code-block:: python + + # Sum durations for a tech on a specific entity over the last 30 days. + summary = client.get_task_durations( + entity_id=3, + user_id=42, + ) + print(summary["total_duration"], summary["task_count"]) + print(summary["duration_by_entity"]) # {"3": 7200} + + # Same query but ask for the per-task breakdown. + detailed = client.get_task_durations( + entity_id=3, + user_id=42, + return_task_details=True, + ) + for task in detailed["tasks"] or []: + print(task["task_id"], task["ticket_id"], task["duration"]) + +Returned shape:: + + { + "start_date": "2026-01-01", + "end_date": "2026-01-31", + "total_duration": 7200, + "task_count": 4, + "duration_by_user": {"42": 7200}, + "duration_by_entity": {"3": 7200}, + "tasks": None, # or a list[dict] when return_task_details=True + } + +On the async client the same method is overridden to run the per-ticket +task fetches concurrently with :func:`asyncio.gather` when +``return_task_details=True``. + +``get_user_activity`` +^^^^^^^^^^^^^^^^^^^^^ + +Aggregates per-user activity over a date window: tickets where the +user appears as technician (``users_id_assign``), tickets where the +user appears as requester (``users_id_requester``), and the user's +task duration totals. Multiple users that resolve to the same display +key (``" "``) are merged into a single bucket. + +The helper raises ``ValueError`` when no identifier is supplied or +when the search criteria match no users in the directory. + +.. code-block:: python + + # Activity for a single user identified by username (substring match). + report = client.get_user_activity( + username="alice", + start_date="2026-01-01", + end_date="2026-01-31", + ) + for display_name, data in report["users"].items(): + print( + display_name, + data["tickets_as_technician"], + data["tickets_as_recipient"], + data["task_durations"]["total_duration"], + ) + + # Activity for every user whose last name contains "Smith". + report = client.get_user_activity(realname="Smith", default_days=90) + +Returned shape:: + + { + "users": { + "Alice Smith": { + "user_ids": [42], + "tickets_as_technician": 7, + "tickets_as_recipient": 2, + "task_durations": { + "start_date": "2026-01-01", + "end_date": "2026-01-31", + "total_duration": 7200, + "task_count": 4, + "duration_by_user": {"42": 7200}, + "duration_by_entity": {"3": 7200}, + }, + } + } + } + .. _end-to-end-examples: 6. End-to-end examples diff --git a/glpi_python_client/__init__.py b/glpi_python_client/__init__.py index ce9bf6a..dfec82e 100644 --- a/glpi_python_client/__init__.py +++ b/glpi_python_client/__init__.py @@ -72,7 +72,7 @@ TicketMarkdownOptions, ) -__version__ = "0.3.0" +__version__ = "0.3.1" __all__ = [ "AsyncGlpiClient", diff --git a/glpi_python_client/clients/api/administration/_entity.py b/glpi_python_client/clients/api/administration/_entity.py index 1dc4ead..8c9d5b7 100644 --- a/glpi_python_client/clients/api/administration/_entity.py +++ b/glpi_python_client/clients/api/administration/_entity.py @@ -7,6 +7,8 @@ from __future__ import annotations +from collections.abc import Iterator + from glpi_python_client.clients.commons._constants import ENTITY_ENDPOINT, GlpiId from glpi_python_client.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.administration._entity import ( @@ -54,6 +56,48 @@ def search_entities( ENTITY_ENDPOINT, GetEntity, params=params, skip_entity=True ) + def iter_search_entities( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + ) -> Iterator[list[GetEntity]]: + """Yield successive pages of GLPI entities until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + Entity calls bypass the ``GLPI-Entity`` header so cross-entity + lookups remain possible. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every accessible entity. + batch_size : int, optional + Number of records requested per page (default 50). + + Yields + ------ + list[GetEntity] + One page of entities per iteration. The last yielded batch may + be shorter than ``batch_size``. + """ + + start = 0 + while True: + batch = self.search_entities( + rsql_filter, + limit=batch_size, + start=start, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + def get_entity(self, entity_id: GlpiId) -> GetEntity: """Fetch one GLPI entity by identifier. diff --git a/glpi_python_client/clients/api/administration/_user.py b/glpi_python_client/clients/api/administration/_user.py index 4c73b6b..c4279b7 100644 --- a/glpi_python_client/clients/api/administration/_user.py +++ b/glpi_python_client/clients/api/administration/_user.py @@ -8,6 +8,8 @@ from __future__ import annotations +from collections.abc import Iterator + from glpi_python_client.clients.commons._constants import USER_ENDPOINT, GlpiId from glpi_python_client.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.administration._user import ( @@ -63,6 +65,51 @@ def search_users( USER_ENDPOINT, GetUser, params=params, skip_entity=skip_entity ) + def iter_search_users( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + skip_entity: bool = False, + ) -> Iterator[list[GetUser]]: + """Yield successive pages of GLPI users until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible user. + batch_size : int, optional + Number of records requested per page (default 50). + skip_entity : bool, optional + When ``True`` the ``GLPI-Entity`` header is omitted so the + search spans every entity the caller has access to. + + Yields + ------ + list[GetUser] + One page of users per iteration. The last yielded batch may + be shorter than ``batch_size``. + """ + + start = 0 + while True: + batch = self.search_users( + rsql_filter, + limit=batch_size, + start=start, + skip_entity=skip_entity, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + def get_user(self, user_id: GlpiId) -> GetUser: """Fetch one GLPI user by identifier. diff --git a/glpi_python_client/clients/api/assistance/_ticket.py b/glpi_python_client/clients/api/assistance/_ticket.py index 01a9e1d..fc3f08f 100644 --- a/glpi_python_client/clients/api/assistance/_ticket.py +++ b/glpi_python_client/clients/api/assistance/_ticket.py @@ -6,6 +6,8 @@ from __future__ import annotations +from collections.abc import Iterator + from glpi_python_client.clients.commons._constants import TICKET_ENDPOINT, GlpiId from glpi_python_client.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema.assistance._ticket import ( @@ -68,6 +70,56 @@ def search_tickets( params["fields"] = ",".join(fields) return self._resource_list(TICKET_ENDPOINT, GetTicket, params=params) + def iter_search_tickets( + self, + rsql_filter: str = "", + *, + batch_size: int = 50, + sort: str | None = None, + fields: tuple[str, ...] = (), + ) -> Iterator[list[GetTicket]]: + """Yield successive pages of GLPI tickets until exhausted. + + The generator drives pagination automatically by advancing the + ``start`` offset after each batch. Iteration stops when the server + returns fewer items than ``batch_size``, which signals the last page. + + Parameters + ---------- + rsql_filter : str, optional + Raw RSQL filter forwarded as the ``filter`` query parameter. + Empty by default, which lists every visible ticket. + batch_size : int, optional + Number of records requested per page (default 50). Acts as + the ``limit`` parameter on each underlying + :meth:`search_tickets` call. + sort : str | None, optional + ``sort`` query parameter forwarded as-is to each page request. + fields : tuple[str, ...], optional + Restricted set of contract field names to request. + + Yields + ------ + list[GetTicket] + One page of tickets per iteration. The last yielded batch may + be shorter than ``batch_size``. + """ + + start = 0 + while True: + batch = self.search_tickets( + rsql_filter, + limit=batch_size, + start=start, + sort=sort, + fields=fields, + ) + if batch: + yield batch + if len(batch) < batch_size: + break + start += batch_size + def get_ticket(self, ticket_id: GlpiId) -> GetTicket: """Fetch one GLPI ticket by identifier. diff --git a/glpi_python_client/clients/commons/_async_bridge.py b/glpi_python_client/clients/commons/_async_bridge.py index d147404..ff6f87f 100644 --- a/glpi_python_client/clients/commons/_async_bridge.py +++ b/glpi_python_client/clients/commons/_async_bridge.py @@ -33,6 +33,19 @@ from concurrent.futures import Executor from typing import Any +# Sentinel used by the async-generator bridge to signal exhaustion without +# propagating StopIteration through a coroutine (which PEP 479 forbids). +_STOPPED: object = object() + + +def _next_or_stopped(gen: Any) -> Any: + """Return the next item from *gen* or ``_STOPPED`` when exhausted.""" + + try: + return next(gen) + except StopIteration: + return _STOPPED + class AsyncBridge: """Base class that converts inherited sync methods into coroutines. @@ -78,14 +91,22 @@ def __init_subclass__(cls, **kwargs: object) -> None: continue if not callable(member) or inspect.iscoroutinefunction(member): continue + if inspect.isasyncgenfunction(member): + continue # Skip if the subclass already overrides the method with - # a coroutine function (for example async fan-outs). + # a coroutine function or async generator (for example async fan-outs). existing = getattr(cls, name, None) - if existing is not None and inspect.iscoroutinefunction(existing): + if existing is not None and ( + inspect.iscoroutinefunction(existing) + or inspect.isasyncgenfunction(existing) + ): seen.add(name) continue seen.add(name) - setattr(cls, name, _make_async_wrapper(member)) + if inspect.isgeneratorfunction(member): + setattr(cls, name, _make_async_generator_wrapper(member)) + else: + setattr(cls, name, _make_async_wrapper(member)) def _make_async_wrapper(sync_func: Callable[..., Any]) -> Callable[..., Any]: @@ -115,4 +136,39 @@ async def wrapper(self: AsyncBridge, *args: Any, **kwargs: Any) -> Any: return wrapper +def _make_async_generator_wrapper(sync_func: Callable[..., Any]) -> Callable[..., Any]: + """Return an async generator wrapper for a synchronous generator function. + + Each call to ``next()`` on the underlying sync generator is dispatched + to a worker thread so that the blocking HTTP call inside the generator + body does not block the event loop. + + Parameters + ---------- + sync_func : Callable[..., Any] + Synchronous generator function inherited from a sync mixin. + + Returns + ------- + Callable[..., Any] + Async generator function that yields the same items as the + synchronous generator, one batch at a time, off the event loop. + """ + + @functools.wraps(sync_func) + async def wrapper(self: AsyncBridge, *args: Any, **kwargs: Any) -> Any: + gen = sync_func(self, *args, **kwargs) + while True: + if self._executor is not None: + loop = asyncio.get_running_loop() + item = await loop.run_in_executor(self._executor, _next_or_stopped, gen) + else: + item = await asyncio.to_thread(_next_or_stopped, gen) + if item is _STOPPED: + return + yield item + + return wrapper + + __all__ = ["AsyncBridge"] diff --git a/glpi_python_client/clients/custom/_statistics.py b/glpi_python_client/clients/custom/_statistics.py index 3a213ce..043e506 100644 --- a/glpi_python_client/clients/custom/_statistics.py +++ b/glpi_python_client/clients/custom/_statistics.py @@ -12,8 +12,13 @@ from collections import defaultdict from datetime import date, timedelta +from typing import TypedDict -from glpi_python_client.clients.commons._filters import rsql_all_filter +from glpi_python_client.clients.commons._filters import ( + rsql_all_filter, + rsql_any_filter, + rsql_contains_filter, +) from glpi_python_client.clients.commons._transport import TransportMixin from glpi_python_client.models.api_schema._common import ( IdNameCompletenameRef, @@ -29,6 +34,43 @@ ) +class TaskStatisticsResult(TypedDict): + """Typed shape returned by :meth:`StatisticsMixin.get_task_statistics`.""" + + ticket_count: int + task_count: int + total_duration: int + duration_by_user: dict[str, int] + duration_by_ticket: dict[int, int] + + +class TaskDurationsResult(TypedDict): + """Typed shape returned by :meth:`StatisticsMixin.get_task_durations`.""" + + start_date: str + end_date: str + total_duration: int + task_count: int + duration_by_user: dict[str, int] + duration_by_entity: dict[str, int] + tasks: list[dict[str, object]] | None + + +class UserActivityEntry(TypedDict): + """One per-user activity bucket inside :class:`UserActivityResult`.""" + + user_ids: list[int] + tickets_as_technician: int + tickets_as_recipient: int + task_durations: TaskDurationsResult + + +class UserActivityResult(TypedDict): + """Typed shape returned by :meth:`StatisticsMixin.get_user_activity`.""" + + users: dict[str, UserActivityEntry] + + class StatisticsMixin(TransportMixin): """Synchronous custom statistics built on the contract API mixins.""" @@ -38,6 +80,8 @@ def get_ticket_statistics( start_date: str | None = None, end_date: str | None = None, default_days: int = 30, + entity_id: int | None = None, + entity_name: str | None = None, extra_filter: str | None = None, ) -> dict[str, object]: """Return ticket counts grouped by entity, status, priority, and type. @@ -58,6 +102,14 @@ def get_ticket_statistics( default_days : int, optional Span in days used when ``start_date`` is omitted (defaults to 30 and must be a positive integer). + entity_id : int | None, optional + When provided, restricts results to tickets belonging to the + entity with this GLPI identifier. + entity_name : str | None, optional + When provided (and ``entity_id`` is ``None``), the name is + resolved via ``search_entities`` and the matched entity IDs + are used to filter tickets. If no entity matches, + ``{"entities": {}}`` is returned immediately. extra_filter : str | None, optional Optional raw RSQL fragment to ``AND`` with the date window on the server side. @@ -80,8 +132,25 @@ def get_ticket_statistics( end_date=end_date, default_days=default_days, ) + + entity_filter: str | None = None + if entity_id is not None: + entity_filter = f"entities_id=={entity_id}" + elif entity_name is not None: + name_filter = rsql_contains_filter("name", entity_name) or "" + entities = self.search_entities( # type: ignore[attr-defined] + rsql_filter=name_filter, + limit=200, + ) + if not entities: + return {"entities": {}} + entity_filter = rsql_any_filter( + *(f"entities_id=={e.id}" for e in entities if e.id is not None) + ) + query = rsql_all_filter( f"date_creation=ge={start.isoformat()};date_creation=le={end.isoformat()}", + entity_filter, extra_filter, ) tickets: list[GetTicket] = self.search_tickets( # type: ignore[attr-defined] @@ -93,7 +162,7 @@ def get_ticket_statistics( def get_task_statistics( self, ticket_ids: list[int], - ) -> dict[str, object]: + ) -> TaskStatisticsResult: """Return task duration totals grouped by user and ticket. The helper expects a list of ticket identifiers because GLPI @@ -109,7 +178,7 @@ def get_task_statistics( Returns ------- - dict[str, object] + TaskStatisticsResult Mapping with ``ticket_count``, ``task_count``, ``total_duration``, ``duration_by_user``, and ``duration_by_ticket`` entries (durations are integer @@ -117,13 +186,13 @@ def get_task_statistics( """ if not ticket_ids: - return { - "ticket_count": 0, - "task_count": 0, - "total_duration": 0, - "duration_by_user": {}, - "duration_by_ticket": {}, - } + return TaskStatisticsResult( + ticket_count=0, + task_count=0, + total_duration=0, + duration_by_user={}, + duration_by_ticket={}, + ) results: list[list[GetTicketTask]] = [ self.list_ticket_tasks(ticket_id) # type: ignore[attr-defined] @@ -132,6 +201,350 @@ def get_task_statistics( flattened: list[GetTicketTask] = [task for batch in results for task in batch] return _summarize_tasks(ticket_ids, flattened) + def get_task_durations( + self, + *, + start_date: str | None = None, + end_date: str | None = None, + default_days: int = 30, + entity_id: int | None = None, + entity_name: str | None = None, + user_id: int | None = None, + user_editor_id: int | None = None, + user_recipient_id: int | None = None, + extra_filter: str | None = None, + return_task_details: bool = False, + ) -> TaskDurationsResult: + """Return task duration totals with optional per-task detail. + + Builds an RSQL filter from the supplied parameters, collects all + matching tickets by iterating :meth:`iter_search_tickets`, computes + ``duration_by_entity`` by grouping :meth:`get_task_statistics` + results against the per-ticket entity map, and optionally returns a + flat list of individual task records. + + Parameters + ---------- + start_date : str | None, optional + ISO ``YYYY-MM-DD`` start of the window. Defaults to + ``end_date - default_days + 1`` when omitted. + end_date : str | None, optional + ISO ``YYYY-MM-DD`` end of the window. Defaults to today. + default_days : int, optional + Span in days used when ``start_date`` is omitted (defaults + to 30 and must be a positive integer). + entity_id : int | None, optional + Restrict to tickets in this entity. + entity_name : str | None, optional + Resolve entity by name and restrict to matched entities + (ignored when ``entity_id`` is given). + user_id : int | None, optional + Restrict to tickets where the user is an assignee or + requester (OR semantics across both roles). + user_editor_id : int | None, optional + Restrict to tickets last updated by this user. + user_recipient_id : int | None, optional + Restrict to tickets where this user is the requester. + extra_filter : str | None, optional + Optional raw RSQL fragment appended as an AND clause. + return_task_details : bool, optional + When ``True``, include a ``tasks`` list of individual task + records in the returned mapping (default ``False``). + + Returns + ------- + TaskDurationsResult + Mapping with ``start_date``, ``end_date``, ``total_duration``, + ``task_count``, ``duration_by_user``, ``duration_by_entity``, + and ``tasks`` (``None`` when ``return_task_details=False``). + + Raises + ------ + ValueError + If ``default_days < 1`` or ``start_date > end_date``. + """ + + start, end = _resolve_window( + start_date=start_date, + end_date=end_date, + default_days=default_days, + ) + date_filter = ( + f"date_creation=ge={start.isoformat()};date_creation=le={end.isoformat()}" + ) + + entity_filter: str | None = None + if entity_id is not None: + entity_filter = f"entities_id=={entity_id}" + elif entity_name is not None: + name_filter = rsql_contains_filter("name", entity_name) or "" + entities = self.search_entities( # type: ignore[attr-defined] + rsql_filter=name_filter, + limit=200, + ) + if not entities: + return TaskDurationsResult( + start_date=start.isoformat(), + end_date=end.isoformat(), + total_duration=0, + task_count=0, + duration_by_user={}, + duration_by_entity={}, + tasks=None, + ) + entity_filter = rsql_any_filter( + *(f"entities_id=={e.id}" for e in entities if e.id is not None) + ) + + user_filter: str | None = None + if user_id is not None: + user_filter = rsql_any_filter( + f"users_id_assign=={user_id}", + f"users_id_requester=={user_id}", + ) + + editor_filter: str | None = None + if user_editor_id is not None: + editor_filter = f"users_id_lastupdater=={user_editor_id}" + + recipient_filter: str | None = None + if user_recipient_id is not None: + recipient_filter = f"users_id_requester=={user_recipient_id}" + + rsql_filter = ( + rsql_all_filter( + date_filter, + entity_filter, + user_filter, + editor_filter, + recipient_filter, + extra_filter, + ) + or "" + ) + + ticket_ids: list[int] = [] + ticket_entity_map: dict[int, str] = {} + for batch in self.iter_search_tickets( # type: ignore[attr-defined] + rsql_filter, + batch_size=200, + ): + for ticket in batch: + if ticket.id is not None: + ticket_ids.append(ticket.id) + ticket_entity_map[ticket.id] = _entity_key(ticket.entity) + + result = self.get_task_statistics(ticket_ids) + duration_by_ticket = result["duration_by_ticket"] + + duration_by_entity: defaultdict[str, int] = defaultdict(int) + for tid, dur in duration_by_ticket.items(): + entity_key = ticket_entity_map.get(int(tid), "unknown") + duration_by_entity[entity_key] += int(dur) + + task_details: list[dict[str, object]] | None = None + if return_task_details: + task_details = [] + for tid, dur in duration_by_ticket.items(): + if int(dur) == 0: + continue + for task in self.list_ticket_tasks(int(tid)): # type: ignore[attr-defined] + task_details.append( + { + "task_id": task.id, + "ticket_id": int(tid), + "duration": int(task.duration or 0), + "user_id": task.user.id if task.user else None, + "user_name": task.user.name if task.user else None, + "date": str(task.date_creation or ""), + } + ) + + return TaskDurationsResult( + start_date=start.isoformat(), + end_date=end.isoformat(), + total_duration=int(result["total_duration"]), + task_count=int(result["task_count"]), + duration_by_user=result["duration_by_user"], + duration_by_entity=dict(duration_by_entity), + tasks=task_details, + ) + + def get_user_activity( + self, + *, + user_id: int | None = None, + username: str | None = None, + realname: str | None = None, + firstname: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + default_days: int = 30, + ) -> UserActivityResult: + """Return per-user GLPI activity aggregated across tickets and tasks. + + Aggregates tickets where each matched user is an assignee, tickets + where the user is a requester, and task durations over the requested + date window. When multiple users resolve to the same display key + their results are merged. + + Parameters + ---------- + user_id : int | None, optional + Identify the user by GLPI numeric identifier. + username : str | None, optional + Filter by username (substring match). + realname : str | None, optional + Filter by family name (substring match). + firstname : str | None, optional + Filter by given name (substring match). + start_date : str | None, optional + ISO ``YYYY-MM-DD`` start of the activity window. + end_date : str | None, optional + ISO ``YYYY-MM-DD`` end of the activity window. Defaults to today. + default_days : int, optional + Span in days used when ``start_date`` is omitted (default 30). + + Returns + ------- + UserActivityResult + Mapping with one ``users`` key. Each user key maps to a + :class:`UserActivityEntry` with ``user_ids``, + ``tickets_as_technician``, ``tickets_as_recipient``, and + ``task_durations``. + + Raises + ------ + ValueError + If none of ``user_id``, ``username``, ``realname``, or + ``firstname`` are supplied, or if the supplied criteria match + no GLPI users. + """ + + if all(v is None for v in (user_id, username, realname, firstname)): + raise ValueError( + "At least one of user_id, username, realname, or " + "firstname must be supplied" + ) + + start, end = _resolve_window( + start_date=start_date, + end_date=end_date, + default_days=default_days, + ) + + if user_id is not None: + resolved_user_ids: list[int] = [user_id] + user_display_map: dict[int, str] = {user_id: str(user_id)} + else: + name_parts = [ + rsql_contains_filter("username", username) if username else None, + rsql_contains_filter("realname", realname) if realname else None, + rsql_contains_filter("firstname", firstname) if firstname else None, + ] + user_rsql = rsql_all_filter(*name_parts) or "" + matched_users = self.search_users( # type: ignore[attr-defined] + rsql_filter=user_rsql, + limit=200, + ) + if not matched_users: + raise ValueError("No users matched the supplied criteria") + resolved_user_ids = [u.id for u in matched_users if u.id is not None] + user_display_map = { + u.id: ( + f"{u.firstname or ''} {u.realname or ''}".strip() + or u.username + or str(u.id) + ) + for u in matched_users + if u.id is not None + } + + date_range = ( + f"date_creation=ge={start.isoformat()};date_creation=le={end.isoformat()}" + ) + + users_output: dict[str, UserActivityEntry] = {} + for uid in resolved_user_ids: + display_key = user_display_map.get(uid, str(uid)) + tech_count = 0 + for batch in self.iter_search_tickets( # type: ignore[attr-defined] + f"users_id_assign=={uid};{date_range}", + batch_size=200, + ): + tech_count += len(batch) + recipient_count = 0 + for batch in self.iter_search_tickets( # type: ignore[attr-defined] + f"users_id_requester=={uid};{date_range}", + batch_size=200, + ): + recipient_count += len(batch) + task_dur = self.get_task_durations( + start_date=start_date, + end_date=end_date, + default_days=default_days, + user_id=uid, + ) + # Drop the optional ``tasks`` payload before storing on the + # per-user entry; the activity summary keeps only aggregated + # counters per user. + task_dur_clean: TaskDurationsResult = TaskDurationsResult( + start_date=task_dur["start_date"], + end_date=task_dur["end_date"], + total_duration=task_dur["total_duration"], + task_count=task_dur["task_count"], + duration_by_user=dict(task_dur["duration_by_user"]), + duration_by_entity=dict(task_dur["duration_by_entity"]), + tasks=None, + ) + + if display_key in users_output: + existing = users_output[display_key] + existing["user_ids"] = [*existing["user_ids"], uid] + existing["tickets_as_technician"] += tech_count + existing["tickets_as_recipient"] += recipient_count + existing["task_durations"] = _merge_task_durations( + existing["task_durations"], task_dur_clean + ) + else: + users_output[display_key] = UserActivityEntry( + user_ids=[uid], + tickets_as_technician=tech_count, + tickets_as_recipient=recipient_count, + task_durations=task_dur_clean, + ) + + return UserActivityResult(users=users_output) + + +def _merge_task_durations( + prev: TaskDurationsResult, new: TaskDurationsResult +) -> TaskDurationsResult: + """Merge two task-duration aggregates summing every counter. + + The returned ``start_date`` / ``end_date`` are inherited from + ``prev`` since the helper is only used to fold per-user results that + were computed over the same window. The ``tasks`` payload is dropped + because the merged aggregate is part of a user activity report and + not a detail listing. + """ + + merged_by_user: dict[str, int] = dict(prev["duration_by_user"]) + for k, v in new["duration_by_user"].items(): + merged_by_user[k] = merged_by_user.get(k, 0) + int(v) + merged_by_entity: dict[str, int] = dict(prev["duration_by_entity"]) + for k, v in new["duration_by_entity"].items(): + merged_by_entity[k] = merged_by_entity.get(k, 0) + int(v) + return TaskDurationsResult( + start_date=prev["start_date"], + end_date=prev["end_date"], + total_duration=prev["total_duration"] + new["total_duration"], + task_count=prev["task_count"] + new["task_count"], + duration_by_user=merged_by_user, + duration_by_entity=merged_by_entity, + tasks=None, + ) + def _resolve_window( *, @@ -181,7 +594,7 @@ def _summarize_tickets(tickets: list[GetTicket]) -> dict[str, object]: def _summarize_tasks( ticket_ids: list[int], tasks: list[GetTicketTask] -) -> dict[str, object]: +) -> TaskStatisticsResult: """Aggregate one task list by user and parent ticket identifier.""" duration_by_user: defaultdict[str, int] = defaultdict(int) @@ -193,13 +606,13 @@ def _summarize_tasks( duration_by_user[_user_key(task.user)] += duration if task.tickets_id is not None: duration_by_ticket[task.tickets_id] += duration - return { - "ticket_count": len(ticket_ids), - "task_count": len(tasks), - "total_duration": total_duration, - "duration_by_user": dict(duration_by_user), - "duration_by_ticket": dict(duration_by_ticket), - } + return TaskStatisticsResult( + ticket_count=len(ticket_ids), + task_count=len(tasks), + total_duration=total_duration, + duration_by_user=dict(duration_by_user), + duration_by_ticket=dict(duration_by_ticket), + ) def _entity_key(entity: IdNameCompletenameRef | None) -> str: diff --git a/glpi_python_client/clients/custom/_statistics_async.py b/glpi_python_client/clients/custom/_statistics_async.py index d97fcf7..290b36c 100644 --- a/glpi_python_client/clients/custom/_statistics_async.py +++ b/glpi_python_client/clients/custom/_statistics_async.py @@ -1,17 +1,24 @@ """Asynchronous override for the statistics aggregation helper. -The async mixin overrides :meth:`get_task_statistics` so the per-ticket -task-listing calls are dispatched concurrently using -:func:`asyncio.gather`. ``get_ticket_statistics`` does not need a custom -override because it issues a single GLPI request and therefore behaves -correctly when wrapped by the bridge into a coroutine. +The async mixin overrides :meth:`get_task_statistics` and +:meth:`get_task_durations` so the per-ticket task-listing calls are +dispatched concurrently using :func:`asyncio.gather`. +``get_ticket_statistics`` does not need a custom override because it issues +a single GLPI request and therefore behaves correctly when wrapped by the +bridge into a coroutine. """ from __future__ import annotations import asyncio -from glpi_python_client.clients.custom._statistics import StatisticsMixin +from glpi_python_client.clients.custom._statistics import ( + StatisticsMixin, + TaskDurationsResult, + TaskStatisticsResult, + _entity_key, + _summarize_tasks, +) from glpi_python_client.models.api_schema.assistance.timeline._task import ( GetTicketTask, ) @@ -29,7 +36,7 @@ class AsyncStatisticsMixin(StatisticsMixin): async def get_task_statistics( # type: ignore[override] self, ticket_ids: list[int], - ) -> dict[str, object]: + ) -> TaskStatisticsResult: """Return task duration totals with concurrent per-ticket fetches. Parameters @@ -40,22 +47,20 @@ async def get_task_statistics( # type: ignore[override] Returns ------- - dict[str, object] + TaskStatisticsResult Mapping with ``ticket_count``, ``task_count``, ``total_duration``, ``duration_by_user``, and ``duration_by_ticket`` entries. """ - from glpi_python_client.clients.custom._statistics import _summarize_tasks - if not ticket_ids: - return { - "ticket_count": 0, - "task_count": 0, - "total_duration": 0, - "duration_by_user": {}, - "duration_by_ticket": {}, - } + return TaskStatisticsResult( + ticket_count=0, + task_count=0, + total_duration=0, + duration_by_user={}, + duration_by_ticket={}, + ) results = await asyncio.gather( *( self.list_ticket_tasks(ticket_id) # type: ignore[attr-defined] @@ -65,5 +70,181 @@ async def get_task_statistics( # type: ignore[override] flattened: list[GetTicketTask] = [task for batch in results for task in batch] return _summarize_tasks(ticket_ids, flattened) + async def get_task_durations( # type: ignore[override] + self, + *, + start_date: str | None = None, + end_date: str | None = None, + default_days: int = 30, + entity_id: int | None = None, + entity_name: str | None = None, + user_id: int | None = None, + user_editor_id: int | None = None, + user_recipient_id: int | None = None, + extra_filter: str | None = None, + return_task_details: bool = False, + ) -> TaskDurationsResult: + """Return task duration totals with concurrent per-ticket fetches. + + Overrides the synchronous implementation so that when + ``return_task_details=True`` the per-ticket + :meth:`list_ticket_tasks` calls are dispatched concurrently using + :func:`asyncio.gather`. The date-window, entity, and user filter + logic is identical to the synchronous version. + + Parameters + ---------- + start_date : str | None, optional + ISO ``YYYY-MM-DD`` start of the window. + end_date : str | None, optional + ISO ``YYYY-MM-DD`` end of the window. + default_days : int, optional + Span in days used when ``start_date`` is omitted (default 30). + entity_id : int | None, optional + Restrict to tickets in this entity. + entity_name : str | None, optional + Resolve entity by name and restrict to matched entities. + user_id : int | None, optional + Restrict to tickets where the user is assignee or requester. + user_editor_id : int | None, optional + Restrict to tickets last updated by this user. + user_recipient_id : int | None, optional + Restrict to tickets where this user is the requester. + extra_filter : str | None, optional + Optional raw RSQL fragment appended as an AND clause. + return_task_details : bool, optional + When ``True``, fan-out per-ticket task fetches concurrently + and include a ``tasks`` list in the result. + + Returns + ------- + TaskDurationsResult + Same shape as the synchronous :meth:`get_task_durations`. + """ + + from collections import defaultdict + + from glpi_python_client.clients.commons._filters import ( + rsql_all_filter, + rsql_any_filter, + rsql_contains_filter, + ) + from glpi_python_client.clients.custom._statistics import _resolve_window + + start, end = _resolve_window( + start_date=start_date, + end_date=end_date, + default_days=default_days, + ) + date_filter = ( + f"date_creation=ge={start.isoformat()};date_creation=le={end.isoformat()}" + ) + + entity_filter: str | None = None + if entity_id is not None: + entity_filter = f"entities_id=={entity_id}" + elif entity_name is not None: + name_filter = rsql_contains_filter("name", entity_name) or "" + entities = await self.search_entities( # type: ignore[attr-defined] + rsql_filter=name_filter, + limit=200, + ) + if not entities: + return TaskDurationsResult( + start_date=start.isoformat(), + end_date=end.isoformat(), + total_duration=0, + task_count=0, + duration_by_user={}, + duration_by_entity={}, + tasks=None, + ) + entity_filter = rsql_any_filter( + *(f"entities_id=={e.id}" for e in entities if e.id is not None) + ) + + user_filter: str | None = None + if user_id is not None: + user_filter = rsql_any_filter( + f"users_id_assign=={user_id}", + f"users_id_requester=={user_id}", + ) + + editor_filter: str | None = None + if user_editor_id is not None: + editor_filter = f"users_id_lastupdater=={user_editor_id}" + + recipient_filter: str | None = None + if user_recipient_id is not None: + recipient_filter = f"users_id_requester=={user_recipient_id}" + + rsql_filter = ( + rsql_all_filter( + date_filter, + entity_filter, + user_filter, + editor_filter, + recipient_filter, + extra_filter, + ) + or "" + ) + + ticket_ids: list[int] = [] + ticket_entity_map: dict[int, str] = {} + async for batch in self.iter_search_tickets( # type: ignore[attr-defined] + rsql_filter, + batch_size=200, + ): + for ticket in batch: + if ticket.id is not None: + ticket_ids.append(ticket.id) + ticket_entity_map[ticket.id] = _entity_key(ticket.entity) + + result = await self.get_task_statistics(ticket_ids) + + duration_by_entity: defaultdict[str, int] = defaultdict(int) + for tid, dur in result["duration_by_ticket"].items(): + entity_key = ticket_entity_map.get(int(tid), "unknown") + duration_by_entity[entity_key] += int(dur) + + task_details: list[dict[str, object]] | None = None + if return_task_details: + tasks_per_ticket: list[list[GetTicketTask]] = await asyncio.gather( + *( + self.list_ticket_tasks(int(tid)) # type: ignore[attr-defined] + for tid, dur in result["duration_by_ticket"].items() + if int(dur) > 0 + ) + ) + non_zero_tids = [ + int(tid) + for tid, dur in result["duration_by_ticket"].items() + if int(dur) > 0 + ] + task_details = [] + for tid, tasks in zip(non_zero_tids, tasks_per_ticket, strict=True): + for task in tasks: + task_details.append( + { + "task_id": task.id, + "ticket_id": tid, + "duration": int(task.duration or 0), + "user_id": task.user.id if task.user else None, + "user_name": task.user.name if task.user else None, + "date": str(task.date_creation or ""), + } + ) + + return TaskDurationsResult( + start_date=start.isoformat(), + end_date=end.isoformat(), + total_duration=result["total_duration"], + task_count=result["task_count"], + duration_by_user=result["duration_by_user"], + duration_by_entity=dict(duration_by_entity), + tasks=task_details, + ) + __all__ = ["AsyncStatisticsMixin"] diff --git a/glpi_python_client/clients/custom/tests/test_statistics.py b/glpi_python_client/clients/custom/tests/test_statistics.py index 1fb7d79..1e52fd9 100644 --- a/glpi_python_client/clients/custom/tests/test_statistics.py +++ b/glpi_python_client/clients/custom/tests/test_statistics.py @@ -192,3 +192,371 @@ def fake_list(ticket_id: int) -> list[GetTicketTask]: assert result["total_duration"] == 900 assert result["duration_by_user"] == {"42": 600, "unknown": 300} assert result["duration_by_ticket"] == {1: 900} + + +# --------------------------------------------------------------------------- +# get_ticket_statistics — extended filters (Change 2) +# --------------------------------------------------------------------------- + + +def test_get_ticket_statistics_entity_id_filter(client: GlpiClient) -> None: + """When entity_id is given its RSQL clause is appended to the filter.""" + + captured: dict[str, Any] = {} + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetTicket]: + captured["filter"] = rsql_filter + return [] + + client.search_tickets = fake_search # type: ignore[method-assign] + client.get_ticket_statistics( + start_date="2026-01-01", + end_date="2026-01-31", + entity_id=7, + ) + assert "entities_id==7" in captured["filter"] + + +def test_get_ticket_statistics_entity_name_resolution(client: GlpiClient) -> None: + """When entity_name is given entities are resolved and IDs ORed.""" + + from glpi_python_client.models.api_schema.administration._entity import GetEntity + + captured_tickets: dict[str, Any] = {} + + def fake_search_entities( + rsql_filter: str = "", *, limit: int | None = 200, start: int = 0 + ) -> list[GetEntity]: + return [GetEntity(id=3, name="Acme"), GetEntity(id=4, name="Acme Sub")] + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetTicket]: + captured_tickets["filter"] = rsql_filter + return [] + + client.search_entities = fake_search_entities # type: ignore[method-assign] + client.search_tickets = fake_search # type: ignore[method-assign] + client.get_ticket_statistics( + start_date="2026-01-01", + end_date="2026-01-31", + entity_name="Acme", + ) + assert "entities_id==3" in captured_tickets["filter"] + assert "entities_id==4" in captured_tickets["filter"] + + +def test_get_ticket_statistics_entity_name_no_match(client: GlpiClient) -> None: + """When entity_name matches nothing the fast-path returns empty entities.""" + + from glpi_python_client.models.api_schema.administration._entity import GetEntity + + def fake_search_entities( + rsql_filter: str = "", *, limit: int | None = 200, start: int = 0 + ) -> list[GetEntity]: + return [] + + client.search_entities = fake_search_entities # type: ignore[method-assign] + result = client.get_ticket_statistics( + start_date="2026-01-01", + end_date="2026-01-31", + entity_name="NonExistent", + ) + assert result == {"entities": {}} + + +def test_get_ticket_statistics_extra_filter_appended(client: GlpiClient) -> None: + """extra_filter is AND-joined with the date window.""" + + captured: dict[str, Any] = {} + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetTicket]: + captured["filter"] = rsql_filter + return [] + + client.search_tickets = fake_search # type: ignore[method-assign] + client.get_ticket_statistics( + start_date="2026-01-01", + end_date="2026-01-31", + extra_filter="priority==5", + ) + assert "date_creation=ge=2026-01-01" in captured["filter"] + assert "priority==5" in captured["filter"] + + +def test_get_ticket_statistics_default_days_window(client: GlpiClient) -> None: + """default_days shifts the start of the window without other params.""" + + from datetime import date, timedelta + + captured: dict[str, Any] = {} + + def fake_search( + rsql_filter: str = "", *, limit: int = 50, start: int = 0 + ) -> list[GetTicket]: + captured["filter"] = rsql_filter + return [] + + client.search_tickets = fake_search # type: ignore[method-assign] + client.get_ticket_statistics(default_days=14) + end = date.today() + start = end - timedelta(days=13) + assert f"date_creation=ge={start.isoformat()}" in captured["filter"] + assert f"date_creation=le={end.isoformat()}" in captured["filter"] + + +# --------------------------------------------------------------------------- +# get_task_durations (Change 3) +# --------------------------------------------------------------------------- + + +def _make_ticket(ticket_id: int, entity_id: int | None = 1) -> GetTicket: + """Build a minimal GetTicket for task-duration tests.""" + + payload: dict[str, Any] = {"id": ticket_id, "name": f"t{ticket_id}", "content": "c"} + if entity_id is not None: + payload["entity"] = IdNameCompletenameRef( + id=entity_id, name=f"E{entity_id}", completename=f"E{entity_id}" + ) + return GetTicket(**payload) + + +def test_get_task_durations_empty_ticket_list(client: GlpiClient) -> None: + """When no tickets match, all durations are zero.""" + + def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): + return iter([]) + + client.iter_search_tickets = fake_iter # type: ignore[method-assign] + result = client.get_task_durations(start_date="2026-01-01", end_date="2026-01-31") + assert result["total_duration"] == 0 + assert result["task_count"] == 0 + assert result["duration_by_entity"] == {} + assert result["tasks"] is None + + +def test_get_task_durations_entity_grouping(client: GlpiClient) -> None: + """duration_by_entity groups ticket-task durations by entity key.""" + + tickets = [_make_ticket(1, entity_id=10), _make_ticket(2, entity_id=20)] + + def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): + yield tickets + + def fake_task_stats(ticket_ids: list[int]) -> dict[str, Any]: + return { + "ticket_count": 2, + "task_count": 2, + "total_duration": 1200, + "duration_by_user": {"42": 1200}, + "duration_by_ticket": {1: 600, 2: 600}, + } + + client.iter_search_tickets = fake_iter # type: ignore[method-assign] + client.get_task_statistics = fake_task_stats # type: ignore[method-assign] + result = client.get_task_durations(start_date="2026-01-01", end_date="2026-01-31") + assert result["duration_by_entity"] == {"10": 600, "20": 600} + + +def test_get_task_durations_return_task_details_true(client: GlpiClient) -> None: + """When return_task_details=True a tasks list with correct shape is returned.""" + + tickets = [_make_ticket(1, entity_id=10)] + + def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): + yield tickets + + def fake_task_stats(ticket_ids: list[int]) -> dict[str, Any]: + return { + "ticket_count": 1, + "task_count": 1, + "total_duration": 300, + "duration_by_user": {"7": 300}, + "duration_by_ticket": {1: 300}, + } + + def fake_list_tasks(ticket_id: int) -> list[GetTicketTask]: + return [ + GetTicketTask( + id=99, + tickets_id=ticket_id, + duration=300, + user=IdNameRef(id=7, name="alice"), + ) + ] + + client.iter_search_tickets = fake_iter # type: ignore[method-assign] + client.get_task_statistics = fake_task_stats # type: ignore[method-assign] + client.list_ticket_tasks = fake_list_tasks # type: ignore[method-assign] + result = client.get_task_durations( + start_date="2026-01-01", end_date="2026-01-31", return_task_details=True + ) + assert isinstance(result["tasks"], list) + assert len(result["tasks"]) == 1 + task = result["tasks"][0] + assert task["task_id"] == 99 + assert task["ticket_id"] == 1 + assert task["duration"] == 300 + assert task["user_id"] == 7 + + +def test_get_task_durations_return_task_details_false(client: GlpiClient) -> None: + """When return_task_details=False the tasks key is None.""" + + def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): + return iter([]) + + client.iter_search_tickets = fake_iter # type: ignore[method-assign] + result = client.get_task_durations(start_date="2026-01-01", end_date="2026-01-31") + assert result["tasks"] is None + + +# --------------------------------------------------------------------------- +# get_user_activity (Change 4) +# --------------------------------------------------------------------------- + + +def test_get_user_activity_raises_without_identifier(client: GlpiClient) -> None: + """Calling without any identifier raises ValueError.""" + + with pytest.raises(ValueError, match="user_id"): + client.get_user_activity() + + +def test_get_user_activity_single_user_happy_path(client: GlpiClient) -> None: + """A single matched user populates all activity keys.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + def fake_search_users( + rsql_filter: str = "", + *, + limit: int = 200, + start: int = 0, + skip_entity: bool = False, + ) -> list[GetUser]: + return [GetUser(id=42, username="alice", firstname="Alice", realname="Smith")] + + tech_calls: list[str] = [] + recip_calls: list[str] = [] + + def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): + if "users_id_assign" in rsql_filter: + tech_calls.append(rsql_filter) + yield [_make_ticket(1)] + else: + recip_calls.append(rsql_filter) + yield [] + + def fake_task_durations( + *, + start_date: str | None = None, + end_date: str | None = None, + default_days: int = 30, + user_id: int | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + return { + "start_date": "2026-01-01", + "end_date": "2026-01-31", + "total_duration": 600, + "task_count": 1, + "duration_by_user": {"42": 600}, + "duration_by_entity": {"10": 600}, + } + + client.search_users = fake_search_users # type: ignore[method-assign] + client.iter_search_tickets = fake_iter # type: ignore[method-assign] + client.get_task_durations = fake_task_durations # type: ignore[method-assign] + + result = client.get_user_activity( + username="alice", start_date="2026-01-01", end_date="2026-01-31" + ) + users = result["users"] + assert len(users) == 1 + key = next(iter(users)) + data = users[key] + assert data["user_ids"] == [42] + assert data["tickets_as_technician"] == 1 + assert data["tickets_as_recipient"] == 0 + assert "total_duration" in data["task_durations"] + + +def test_get_user_activity_raises_when_no_users_matched(client: GlpiClient) -> None: + """When search_users returns empty a ValueError is raised.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + def fake_search_users( + rsql_filter: str = "", + *, + limit: int = 200, + start: int = 0, + skip_entity: bool = False, + ) -> list[GetUser]: + return [] + + client.search_users = fake_search_users # type: ignore[method-assign] + with pytest.raises(ValueError, match="No users matched"): + client.get_user_activity(username="ghost") + + +def test_get_user_activity_multi_user_merge(client: GlpiClient) -> None: + """Multiple users under the same display key have their counts merged.""" + + from glpi_python_client.models.api_schema.administration._user import GetUser + + def fake_search_users( + rsql_filter: str = "", + *, + limit: int = 200, + start: int = 0, + skip_entity: bool = False, + ) -> list[GetUser]: + return [ + GetUser(id=1, username="a1", firstname="Alice", realname="Smith"), + GetUser(id=2, username="a2", firstname="Alice", realname="Smith"), + ] + + def fake_iter(rsql_filter: str = "", *, batch_size: int = 200): + if "users_id_assign" in rsql_filter: + yield [_make_ticket(1)] + else: + yield [] + + def fake_task_durations( + *, + start_date: str | None = None, + end_date: str | None = None, + default_days: int = 30, + user_id: int | None = None, + **kwargs: Any, + ) -> dict[str, Any]: + return { + "start_date": "2026-01-01", + "end_date": "2026-01-31", + "total_duration": 300, + "task_count": 1, + "duration_by_user": {str(user_id): 300}, + "duration_by_entity": {"10": 300}, + } + + client.search_users = fake_search_users # type: ignore[method-assign] + client.iter_search_tickets = fake_iter # type: ignore[method-assign] + client.get_task_durations = fake_task_durations # type: ignore[method-assign] + + result = client.get_user_activity( + realname="Smith", start_date="2026-01-01", end_date="2026-01-31" + ) + users = result["users"] + # Both users share the same display key → merged into one entry + assert len(users) == 1 + key = next(iter(users)) + data = users[key] + assert sorted(data["user_ids"]) == [1, 2] + assert data["tickets_as_technician"] == 2 # 1 per user + assert data["task_durations"]["total_duration"] == 600 # 300 per user diff --git a/glpi_python_client/clients/custom/tests/test_statistics_async.py b/glpi_python_client/clients/custom/tests/test_statistics_async.py new file mode 100644 index 0000000..4a93bb8 --- /dev/null +++ b/glpi_python_client/clients/custom/tests/test_statistics_async.py @@ -0,0 +1,232 @@ +"""Unit tests for the asynchronous statistics mixin. + +These tests stub :meth:`iter_search_tickets`, :meth:`list_ticket_tasks`, +:meth:`search_entities`, and :meth:`get_task_statistics` directly on an +:class:`AsyncGlpiClient` instance so the async aggregations exercise +their real summarization logic without any HTTP call. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from typing import Any + +import pytest + +from glpi_python_client import AsyncGlpiClient +from glpi_python_client.models.api_schema._common import ( + IdNameCompletenameRef, + IdNameRef, +) +from glpi_python_client.models.api_schema.administration._entity import GetEntity +from glpi_python_client.models.api_schema.assistance._ticket import GetTicket +from glpi_python_client.models.api_schema.assistance.timeline._task import ( + GetTicketTask, +) +from glpi_python_client.testing.utils import make_async_client + + +@pytest.fixture +def aclient() -> AsyncGlpiClient: + """Return one in-memory asynchronous test client.""" + + return make_async_client() + + +def _make_ticket(ticket_id: int, entity_id: int | None = 1) -> GetTicket: + """Build a minimal ``GetTicket`` for the duration aggregations.""" + + payload: dict[str, Any] = { + "id": ticket_id, + "name": f"t{ticket_id}", + "content": "c", + } + if entity_id is not None: + payload["entity"] = IdNameCompletenameRef( + id=entity_id, name=f"E{entity_id}", completename=f"E{entity_id}" + ) + return GetTicket(**payload) + + +async def _aiter_batches( + batches: list[list[GetTicket]], +) -> AsyncIterator[list[GetTicket]]: + """Yield ticket batches as an async iterator (mirrors the bridge wrapper).""" + + for batch in batches: + yield batch + + +async def test_async_get_task_durations_empty_iterator( + aclient: AsyncGlpiClient, +) -> None: + """An empty ticket iterator returns zeroed totals without task fetches.""" + + def fake_iter( + rsql_filter: str = "", *, batch_size: int = 200 + ) -> AsyncIterator[list[GetTicket]]: + return _aiter_batches([]) + + aclient.iter_search_tickets = fake_iter # type: ignore[method-assign] + result = await aclient.get_task_durations( + start_date="2026-01-01", end_date="2026-01-31" + ) + assert result["total_duration"] == 0 + assert result["task_count"] == 0 + assert result["duration_by_entity"] == {} + assert result["tasks"] is None + await aclient.close() + + +async def test_async_get_task_durations_entity_grouping( + aclient: AsyncGlpiClient, +) -> None: + """``duration_by_entity`` is grouped from the per-ticket statistics.""" + + tickets = [_make_ticket(1, entity_id=10), _make_ticket(2, entity_id=20)] + + def fake_iter( + rsql_filter: str = "", *, batch_size: int = 200 + ) -> AsyncIterator[list[GetTicket]]: + return _aiter_batches([tickets]) + + async def fake_stats(ticket_ids: list[int]) -> dict[str, Any]: + return { + "ticket_count": 2, + "task_count": 2, + "total_duration": 1200, + "duration_by_user": {"42": 1200}, + "duration_by_ticket": {1: 600, 2: 600}, + } + + aclient.iter_search_tickets = fake_iter # type: ignore[method-assign] + aclient.get_task_statistics = fake_stats # type: ignore[method-assign] + result = await aclient.get_task_durations( + start_date="2026-01-01", end_date="2026-01-31" + ) + assert result["duration_by_entity"] == {"10": 600, "20": 600} + assert result["tasks"] is None + await aclient.close() + + +async def test_async_get_task_durations_return_task_details( + aclient: AsyncGlpiClient, +) -> None: + """``return_task_details=True`` returns a flat task list with metadata.""" + + tickets = [_make_ticket(1, entity_id=10)] + + def fake_iter( + rsql_filter: str = "", *, batch_size: int = 200 + ) -> AsyncIterator[list[GetTicket]]: + return _aiter_batches([tickets]) + + async def fake_stats(ticket_ids: list[int]) -> dict[str, Any]: + return { + "ticket_count": 1, + "task_count": 1, + "total_duration": 300, + "duration_by_user": {"7": 300}, + "duration_by_ticket": {1: 300}, + } + + async def fake_list_tasks(ticket_id: int) -> list[GetTicketTask]: + return [ + GetTicketTask( + id=99, + tickets_id=ticket_id, + duration=300, + user=IdNameRef(id=7, name="alice"), + ) + ] + + aclient.iter_search_tickets = fake_iter # type: ignore[method-assign] + aclient.get_task_statistics = fake_stats # type: ignore[method-assign] + aclient.list_ticket_tasks = fake_list_tasks # type: ignore[method-assign] + result = await aclient.get_task_durations( + start_date="2026-01-01", + end_date="2026-01-31", + return_task_details=True, + ) + tasks = result["tasks"] + assert isinstance(tasks, list) + assert len(tasks) == 1 + assert tasks[0]["task_id"] == 99 + assert tasks[0]["ticket_id"] == 1 + assert tasks[0]["duration"] == 300 + assert tasks[0]["user_id"] == 7 + await aclient.close() + + +async def test_async_get_task_durations_entity_name_no_match( + aclient: AsyncGlpiClient, +) -> None: + """When ``entity_name`` matches nothing the helper short-circuits.""" + + async def fake_search_entities( + rsql_filter: str = "", *, limit: int = 50 + ) -> list[GetEntity]: + return [] + + aclient.search_entities = fake_search_entities # type: ignore[method-assign] + result = await aclient.get_task_durations( + start_date="2026-01-01", + end_date="2026-01-31", + entity_name="nope", + ) + assert result["total_duration"] == 0 + assert result["task_count"] == 0 + assert result["duration_by_entity"] == {} + assert result["tasks"] is None + await aclient.close() + + +async def test_async_get_task_durations_entity_name_match( + aclient: AsyncGlpiClient, +) -> None: + """When ``entity_name`` matches entities the helper combines RSQL filters.""" + + tickets = [_make_ticket(1, entity_id=42)] + + async def fake_search_entities( + rsql_filter: str = "", *, limit: int = 50 + ) -> list[GetEntity]: + return [GetEntity(id=42, name="acme", completename="root > acme")] + + captured: dict[str, str] = {} + + def fake_iter( + rsql_filter: str = "", *, batch_size: int = 200 + ) -> AsyncIterator[list[GetTicket]]: + captured["filter"] = rsql_filter + return _aiter_batches([tickets]) + + async def fake_stats(ticket_ids: list[int]) -> dict[str, Any]: + return { + "ticket_count": 1, + "task_count": 0, + "total_duration": 0, + "duration_by_user": {}, + "duration_by_ticket": {1: 0}, + } + + aclient.search_entities = fake_search_entities # type: ignore[method-assign] + aclient.iter_search_tickets = fake_iter # type: ignore[method-assign] + aclient.get_task_statistics = fake_stats # type: ignore[method-assign] + + result = await aclient.get_task_durations( + start_date="2026-01-01", + end_date="2026-01-31", + entity_name="acme", + user_id=7, + user_editor_id=8, + user_recipient_id=9, + extra_filter="status==1", + ) + assert result["task_count"] == 0 + assert "entities_id==42" in captured["filter"] + assert "users_id_assign==7" in captured["filter"] + assert "users_id_lastupdater==8" in captured["filter"] + assert "users_id_requester==9" in captured["filter"] + assert "status==1" in captured["filter"] + await aclient.close() diff --git a/glpi_python_client/clients/tests/test_api_coverage.py b/glpi_python_client/clients/tests/test_api_coverage.py index 7735687..c237324 100644 --- a/glpi_python_client/clients/tests/test_api_coverage.py +++ b/glpi_python_client/clients/tests/test_api_coverage.py @@ -705,3 +705,178 @@ def test_delete_helpers_raise_on_failure_status( rec.install(client) with pytest.raises(ValueError): call(client) + + +# --------------------------------------------------------------------------- +# iter_search_tickets +# --------------------------------------------------------------------------- + + +def test_iter_search_tickets_single_page(client: GlpiClient) -> None: + """A response shorter than batch_size yields one batch then stops.""" + + pages: list[list[Any]] = [[{"id": 1, "name": "t1", "content": "c"}]] + call_count = 0 + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + fields: tuple[str, ...] = (), + ) -> list[Any]: + nonlocal call_count + call_count += 1 + return pages[0] + + client.search_tickets = fake_search # type: ignore[method-assign] + batches = list(client.iter_search_tickets("status==1", batch_size=50)) + assert call_count == 1 + assert len(batches) == 1 + assert len(batches[0]) == 1 + + +def test_iter_search_tickets_multi_page_stops_on_short_batch( + client: GlpiClient, +) -> None: + """Iteration stops after the first batch shorter than batch_size.""" + + ticket_a = {"id": 1, "name": "a", "content": "c"} + ticket_b = {"id": 2, "name": "b", "content": "c"} + ticket_c = {"id": 3, "name": "c", "content": "c"} + responses = [ + [ticket_a, ticket_b], # full page → continue + [ticket_c], # short page → last + ] + call_count = 0 + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + sort: str | None = None, + fields: tuple[str, ...] = (), + ) -> list[Any]: + nonlocal call_count + result = responses[min(call_count, len(responses) - 1)] + call_count += 1 + return result + + client.search_tickets = fake_search # type: ignore[method-assign] + batches = list(client.iter_search_tickets("", batch_size=2)) + assert call_count == 2 + assert len(batches) == 2 + assert len(batches[0]) == 2 + assert len(batches[1]) == 1 + + +# --------------------------------------------------------------------------- +# iter_search_users +# --------------------------------------------------------------------------- + + +def test_iter_search_users_single_page(client: GlpiClient) -> None: + """A response shorter than batch_size yields one batch then stops.""" + + call_count = 0 + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + skip_entity: bool = False, + ) -> list[Any]: + nonlocal call_count + call_count += 1 + return [{"id": 1, "username": "alice"}] + + client.search_users = fake_search # type: ignore[method-assign] + batches = list(client.iter_search_users("username==alice", batch_size=50)) + assert call_count == 1 + assert len(batches) == 1 + + +def test_iter_search_users_multi_page_stops_on_short_batch( + client: GlpiClient, +) -> None: + """Iteration stops after the first short user batch.""" + + responses = [ + [{"id": 1, "username": "alice"}, {"id": 2, "username": "bob"}], + [{"id": 3, "username": "carol"}], + ] + call_count = 0 + + def fake_search( + rsql_filter: str = "", + *, + limit: int = 50, + start: int = 0, + skip_entity: bool = False, + ) -> list[Any]: + nonlocal call_count + result = responses[min(call_count, len(responses) - 1)] + call_count += 1 + return result + + client.search_users = fake_search # type: ignore[method-assign] + batches = list(client.iter_search_users("", batch_size=2)) + assert call_count == 2 + assert sum(len(b) for b in batches) == 3 + + +# --------------------------------------------------------------------------- +# iter_search_entities +# --------------------------------------------------------------------------- + + +def test_iter_search_entities_single_page(client: GlpiClient) -> None: + """A response shorter than batch_size yields one batch then stops.""" + + call_count = 0 + + def fake_search( + rsql_filter: str = "", + *, + limit: int | None = 50, + start: int = 0, + ) -> list[Any]: + nonlocal call_count + call_count += 1 + return [{"id": 1, "name": "root"}] + + client.search_entities = fake_search # type: ignore[method-assign] + batches = list(client.iter_search_entities("", batch_size=50)) + assert call_count == 1 + assert len(batches) == 1 + + +def test_iter_search_entities_multi_page_stops_on_short_batch( + client: GlpiClient, +) -> None: + """Iteration stops after the first short entity batch.""" + + responses = [ + [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}], + [{"id": 3, "name": "c"}], + ] + call_count = 0 + + def fake_search( + rsql_filter: str = "", + *, + limit: int | None = 50, + start: int = 0, + ) -> list[Any]: + nonlocal call_count + result = responses[min(call_count, len(responses) - 1)] + call_count += 1 + return result + + client.search_entities = fake_search # type: ignore[method-assign] + batches = list(client.iter_search_entities("", batch_size=2)) + assert call_count == 2 + assert sum(len(b) for b in batches) == 3 diff --git a/glpi_python_client/clients/tests/test_async_branches.py b/glpi_python_client/clients/tests/test_async_branches.py index c48c4fb..7f81af6 100644 --- a/glpi_python_client/clients/tests/test_async_branches.py +++ b/glpi_python_client/clients/tests/test_async_branches.py @@ -135,3 +135,61 @@ async def test_async_from_env_accepts_executor() -> None: assert client._executor is pool finally: await client.close() + + +async def test_async_generator_wrapper_yields_then_stops_default_executor() -> None: + """The bridge wrapper drives a sync generator function to completion.""" + + from glpi_python_client.clients.commons._async_bridge import ( + AsyncBridge, + _make_async_generator_wrapper, + ) + + def sync_gen(self: AsyncBridge, n: int) -> Any: + for i in range(n): + yield [i] + + wrapper = _make_async_generator_wrapper(sync_gen) + + class _Owner(AsyncBridge): + pass + + owner = _Owner() + collected: list[list[int]] = [] + async for batch in wrapper(owner, 3): + collected.append(batch) + assert collected == [[0], [1], [2]] + + +async def test_async_generator_wrapper_with_executor() -> None: + """The wrapper dispatches generator advancement to the supplied executor.""" + + from glpi_python_client.clients.commons._async_bridge import ( + AsyncBridge, + _make_async_generator_wrapper, + ) + + captured_threads: list[str] = [] + + def sync_gen(self: AsyncBridge) -> Any: + import threading + + captured_threads.append(threading.current_thread().name) + yield ["one"] + captured_threads.append(threading.current_thread().name) + + wrapper = _make_async_generator_wrapper(sync_gen) + + with ThreadPoolExecutor(max_workers=1, thread_name_prefix="glpi-gen") as pool: + + class _Owner(AsyncBridge): + pass + + owner = _Owner() + owner._executor = pool + batches: list[list[str]] = [] + async for batch in wrapper(owner): + batches.append(batch) + assert batches == [["one"]] + assert captured_threads + assert all(name.startswith("glpi-gen") for name in captured_threads) diff --git a/glpi_python_client/clients/tests/test_parity.py b/glpi_python_client/clients/tests/test_parity.py index c95adde..0e91c29 100644 --- a/glpi_python_client/clients/tests/test_parity.py +++ b/glpi_python_client/clients/tests/test_parity.py @@ -38,22 +38,28 @@ def test_sync_and_async_clients_expose_the_same_public_methods() -> None: def test_sync_endpoint_methods_are_not_coroutine_functions() -> None: - """Every public sync method is a plain function, not a coroutine.""" + """Every public sync method is a plain or generator function, not a coroutine.""" for name in _public_callable_names(GlpiClient): member = getattr(GlpiClient, name) assert not inspect.iscoroutinefunction(member), ( f"GlpiClient.{name} should be synchronous" ) + assert not inspect.isasyncgenfunction(member), ( + f"GlpiClient.{name} should be synchronous" + ) def test_async_endpoint_methods_are_coroutine_functions() -> None: - """Every public async method is a coroutine function.""" + """Every public async method is a coroutine or async generator function.""" for name in _public_callable_names(AsyncGlpiClient): member = getattr(AsyncGlpiClient, name) - assert inspect.iscoroutinefunction(member), ( - f"AsyncGlpiClient.{name} should be a coroutine" + is_async = inspect.iscoroutinefunction(member) or inspect.isasyncgenfunction( + member + ) + assert is_async, ( + f"AsyncGlpiClient.{name} should be a coroutine or async generator" ) diff --git a/pyproject.toml b/pyproject.toml index 4bbf408..1084088 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ exclude = [ [project] name = "glpi-python-client" -version = "0.3.0" +version = "0.3.1" description = "A typed Python client for GLPI ITSM APIs." readme = "README.md" requires-python = ">=3.10" diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md index c1f7e96..2a61408 100644 --- a/skills/glpi-reporting-and-context/SKILL.md +++ b/skills/glpi-reporting-and-context/SKILL.md @@ -1,6 +1,6 @@ --- name: glpi-reporting-and-context -description: "Aggregate GLPI ticket and task statistics and load grouped ticket contexts with the asynchronous glpi_python_client.GlpiClient. Use for operational reporting, ticket counts grouped by entity/status/priority/type, task duration totals grouped by user and ticket, or one-call ticket context retrieval bundling tickets with timeline records." +description: "Aggregate GLPI ticket and task statistics and load grouped ticket contexts with the asynchronous glpi_python_client.GlpiClient. Use for operational reporting, ticket counts grouped by entity/status/priority/type, task duration totals grouped by user/entity/ticket, per-user activity reports, batch-streamed pagination of search results, or one-call ticket context retrieval bundling tickets with timeline records." license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read tickets, tasks, users, entities, and timeline records." metadata: @@ -9,13 +9,16 @@ metadata: --- # GLPI Reporting And Context -> The snippets below use `AsyncGlpiClient` (`async with` + `await`). Every method shown also exists on the synchronous `GlpiClient` with the same signature -- replace `async with` with `with`, drop the `await` keyword, and skip the surrounding `async def`/`asyncio.run` scaffolding. +> The snippets below use `AsyncGlpiClient` (`async with` + `await`). Every method shown also exists on the synchronous `GlpiClient` with the same signature -- replace `async with` with `with`, drop the `await` keyword, and skip the surrounding `async def`/`asyncio.run` scaffolding. The `iter_search_*` helpers are async **generators** on the async client: iterate them with `async for` instead of `await`. -Three custom helpers on `GlpiClient` build on top of the contract-aligned API mixins: +Custom helpers on `GlpiClient` build on top of the contract-aligned API mixins: - `get_ticket_context(ticket_id)` returns one `GlpiTicketContext` bundling the primary ticket together with its tasks, followups, solutions, and timeline document links. The five underlying calls run concurrently via `asyncio.gather`. -- `get_ticket_statistics(...)` returns ticket counts grouped by entity, status, priority, and type over an ISO date window applied to GLPI `date_creation`. +- `get_ticket_statistics(...)` returns ticket counts grouped by entity, status, priority, and type over an ISO date window applied to GLPI `date_creation`. Accepts `entity_id`, `entity_name` (substring match resolved via `search_entities`), and `extra_filter` (raw RSQL AND-joined with the window). - `get_task_statistics(ticket_ids)` returns task duration totals grouped by user and ticket for a caller-supplied list of ticket IDs. +- `get_task_durations(...)` is a higher-level helper that internally iterates `iter_search_tickets` with a date/entity/user filter, computes per-user and per-entity duration totals, and optionally returns a flat per-task list when `return_task_details=True`. +- `get_user_activity(...)` aggregates per-user activity (tickets as technician, tickets as recipient, task durations) over a date window; resolves users by `user_id`, `username`, `realname`, or `firstname` and merges users that share the same display key. +- `iter_search_tickets`, `iter_search_users`, `iter_search_entities` yield successive `list[...]` batches of contract models and stop on the first short batch. They handle pagination so callers do not manage `start` cursors manually. Returned identifiers are raw GLPI numeric values; resolve them with the appropriate `search_*` helpers when human-readable labels are needed. @@ -23,9 +26,11 @@ Returned identifiers are raw GLPI numeric values; resolve them with the appropri 1. Create a `GlpiClient` with the correct entity/profile scope. 2. For one ticket, call `await client.get_ticket_context(ticket_id)` and read `bundle.ticket`, `bundle.tasks`, `bundle.followups`, `bundle.solutions`, and `bundle.documents`. -3. For ticket counts, call `await client.get_ticket_statistics(start_date=..., end_date=..., default_days=..., extra_filter=...)`. All keyword arguments are optional; the default window is the last 30 days ending today. -4. For task duration totals, first gather the relevant ticket identifiers (typically via `search_tickets`), then call `await client.get_task_statistics(ticket_ids)`. -5. Use the public enums (`GlpiTicketStatus`, `GlpiTicketType`, `GlpiPriority`, ...) when composing additional RSQL filters. +3. For ticket counts, call `await client.get_ticket_statistics(start_date=..., end_date=..., default_days=..., entity_id=..., entity_name=..., extra_filter=...)`. All keyword arguments are optional; the default window is the last 30 days ending today. +4. For task duration totals on a known ticket list, call `await client.get_task_statistics(ticket_ids)`. For an end-to-end "duration over a window with filters" report, call `await client.get_task_durations(...)` instead; it gathers the ticket IDs internally. +5. For a per-user activity report, call `await client.get_user_activity(username=..., start_date=..., end_date=...)`. Supply at least one of `user_id`, `username`, `realname`, `firstname`. +6. For memory-bounded pagination over large result sets, iterate `iter_search_tickets` / `iter_search_users` / `iter_search_entities` with `async for batch in client.iter_search_*(...): ...`. +7. Use the public enums (`GlpiTicketStatus`, `GlpiTicketType`, `GlpiPriority`, ...) when composing additional RSQL filters. ## Examples @@ -38,7 +43,7 @@ print(len(context.followups), len(context.tasks)) print(len(context.solutions), len(context.documents)) ``` -Aggregate ticket statistics for one month, narrowed by status: +Aggregate ticket statistics for one month, narrowed by status and entity: ```python from glpi_python_client import GlpiTicketStatus @@ -46,27 +51,60 @@ from glpi_python_client import GlpiTicketStatus stats = await client.get_ticket_statistics( start_date="2026-01-01", end_date="2026-01-31", + entity_id=3, extra_filter=f"status=={int(GlpiTicketStatus.NEW)}", ) print(stats["entities"]) ``` -Aggregate task durations across the open tickets of an entity: +Aggregate task durations across the open tickets of an entity using +`get_task_durations` (no manual ticket-list gathering): ```python -open_tickets = await client.search_tickets("status==1", limit=200) -ticket_ids = [t.id for t in open_tickets] +durations = await client.get_task_durations( + entity_id=3, + user_id=42, + return_task_details=True, +) +print(durations["total_duration"], durations["task_count"]) +print(durations["duration_by_entity"]) +for task in durations["tasks"] or []: + print(task["task_id"], task["ticket_id"], task["duration"]) +``` + +Build a per-user activity report: -task_stats = await client.get_task_statistics(ticket_ids) -print(task_stats["task_count"], task_stats["total_duration"]) -print(task_stats["duration_by_user"]) -print(task_stats["duration_by_ticket"]) +```python +report = await client.get_user_activity( + username="alice", + start_date="2026-01-01", + end_date="2026-01-31", +) +for display_name, data in report["users"].items(): + print( + display_name, + data["tickets_as_technician"], + data["tickets_as_recipient"], + data["task_durations"]["total_duration"], + ) +``` + +Stream all open tickets without loading the full result set in memory: + +```python +total = 0 +async for batch in client.iter_search_tickets("status==1", batch_size=200): + total += len(batch) + for ticket in batch: + ... # process each ticket +print(f"processed {total} tickets") ``` ## Gotchas -- All three helpers are async; always `await` them. -- `get_ticket_statistics` validates its date window locally and raises `ValueError` when `default_days < 1` or `start_date > end_date`. The window is applied to `date_creation` server-side. -- `get_task_statistics(ticket_ids=[])` returns zeroed totals without any HTTP call. -- Returned counter keys are raw GLPI numeric identifiers (entity IDs, status numbers, ...) for stable behaviour. Resolve to labels with `search_entities` or the appropriate enum. +- All helpers shown above are async on `AsyncGlpiClient`; always `await` them. The `iter_search_*` helpers are async **generators** -- use `async for`, not `await`. +- `get_ticket_statistics`, `get_task_durations`, and `get_user_activity` validate their date window locally and raise `ValueError` when `default_days < 1` or `start_date > end_date`. The window is applied to `date_creation` server-side. +- `get_task_statistics(ticket_ids=[])` returns zeroed totals without any HTTP call. `get_task_durations` likewise returns zeroed totals when no tickets match the filter, and short-circuits with zeros when `entity_name` resolves to no entities. +- `get_user_activity` raises `ValueError` when no identifier is supplied and when the criteria match no users. Multiple users with the same `f"{firstname} {realname}"` display key are merged into one bucket. +- Returned counter keys are raw GLPI numeric identifiers (entity IDs, status numbers, user IDs as strings) for stable behaviour. Resolve to labels with `search_entities` / `get_user` or the appropriate enum. - Extra ticket fields (plugin keys, custom dropdowns) flow through `ticket.extra_payload` and are visible on `context.ticket` as well. \ No newline at end of file