Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .coverage
Binary file not shown.
27 changes: 26 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
- 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]
183 changes: 180 additions & 3 deletions docs/user_guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)::

Expand Down Expand Up @@ -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 (``"<firstname> <realname>"``) 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
Expand Down
2 changes: 1 addition & 1 deletion glpi_python_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
TicketMarkdownOptions,
)

__version__ = "0.3.0"
__version__ = "0.3.1"

__all__ = [
"AsyncGlpiClient",
Expand Down
44 changes: 44 additions & 0 deletions glpi_python_client/clients/api/administration/_entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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.

Expand Down
47 changes: 47 additions & 0 deletions glpi_python_client/clients/api/administration/_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading