User Guide ========== The ``glpi_python_client`` package exposes two high-level clients whose surface is built from contract-aligned per-endpoint mixins: * :class:`glpi_python_client.GlpiClient` — synchronous, blocking client. The single source of truth for endpoint behaviour. * :class:`glpi_python_client.AsyncGlpiClient` — asynchronous facade that wraps every synchronous method into a coroutine and dispatches it to a worker thread via :func:`asyncio.to_thread`. Both clients speak the GLPI **v2** high-level API and fall back to the legacy v1 API for features that are not exposed by v2, currently binary document uploads and the ``Fields`` plugin custom-field helpers. They expose the exact same endpoint methods and accept the same constructor arguments. Public methods always return Pydantic models (or simple Python types) and never raw dictionaries. .. contents:: :local: :depth: 2 How this guide is organised --------------------------- The guide is split into the following sections: 1. **Creating a client** — how to instantiate either client from explicit parameters or from environment variables. 2. **Sync vs async surface** — when to pick which client and how the async facade is implemented. 3. **Seed data for the examples** — a self-contained snippet that creates the records reused by every later example. Run it once on a throwaway GLPI instance to follow along. 4. **GLPI API interface** — the contract-aligned helpers that map one-to-one to GLPI v2 endpoints (tickets, timeline, team members, users, locations, entities, documents). 5. **Added functionalities** — helpers built on top of the API mixins: the ``Fields`` plugin custom-field helpers, the aggregated ticket context view, and the reporting helpers. 6. **End-to-end examples** — full workflows that combine the previous building blocks. The sample snippets in sections 3 to 6 use the synchronous :class:`GlpiClient`. Every snippet works on the asynchronous client by replacing ``with ... as client:`` with ``async with ... as client:`` and prefixing every client method call with ``await`` — the public method names and signatures are identical. .. _create-a-client: 1. Create a client ------------------ Provide the GLPI v2 API URL and at least one complete authentication pair. The OAuth password grant accepts either ``client_id`` / ``client_secret``, ``username`` / ``password``, or both pairs at once. .. code-block:: python from glpi_python_client import GlpiClient with GlpiClient( glpi_api_url="https://glpi.example.com/api.php/v2", client_id="oauth-client-id", client_secret="oauth-client-secret", username="api-user", password="api-password", glpi_entity=1, glpi_profile=4, ) as client: tickets = client.search_tickets("status==1", limit=10) for ticket in tickets: print(ticket.id, ticket.name) The asynchronous client takes the same arguments and is used inside an ``async with`` block: .. code-block:: python import asyncio from glpi_python_client import AsyncGlpiClient async def main() -> None: async with AsyncGlpiClient( glpi_api_url="https://glpi.example.com/api.php/v2", client_id="oauth-client-id", client_secret="oauth-client-secret", username="api-user", password="api-password", ) as client: tickets = await client.search_tickets("status==1", limit=10) for ticket in tickets: print(ticket.id, ticket.name) asyncio.run(main()) Optional constructor arguments ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * ``glpi_entity`` — numeric GLPI entity ID sent as the ``GLPI-Entity`` header. * ``glpi_profile`` — numeric GLPI profile ID sent as the ``GLPI-Profile`` header. * ``entity_recursive`` — when ``True`` the request scope includes child entities. * ``language`` — value of the ``Accept-Language`` header (defaults to ``"en_GB"``). * ``verify_ssl`` — set to ``False`` only on test instances with self-signed certificates. * ``auth_token_refresh`` — number of seconds before token expiry at which the auth manager proactively refreshes the OAuth access token. * ``v1_base_url`` and ``v1_user_token`` — together enable the legacy v1 fallback used by :meth:`GlpiClient.upload_document` and the ``Fields`` plugin helpers such as :meth:`GlpiClient.get_ticket_custom_fields`. * ``executor`` (:class:`AsyncGlpiClient` only) — an explicit :class:`concurrent.futures.Executor` used to dispatch the wrapped synchronous calls. Defaults to the standard library thread pool through :func:`asyncio.to_thread`. ``from_env`` ~~~~~~~~~~~~ When the same configuration is already exposed through environment variables, :meth:`GlpiClient.from_env` (and :meth:`AsyncGlpiClient.from_env`) read the ``GLPI_``-prefixed keys and build the client for you: * ``GLPI_API_URL`` * ``GLPI_CLIENT_ID`` and ``GLPI_CLIENT_SECRET`` * ``GLPI_USERNAME`` and ``GLPI_PASSWORD`` * ``GLPI_ENTITY``, ``GLPI_PROFILE``, ``GLPI_ENTITY_RECURSIVE`` * ``GLPI_LANGUAGE``, ``GLPI_VERIFY_SSL`` * ``GLPI_V1_BASE_URL``, ``GLPI_V1_USER_TOKEN``, ``GLPI_V1_APP_TOKEN`` .. code-block:: python from glpi_python_client import AsyncGlpiClient, GlpiClient sync_client = GlpiClient.from_env() async_client = AsyncGlpiClient.from_env() .. _sync-vs-async: 2. Sync vs async surface ------------------------ Both :class:`GlpiClient` and :class:`AsyncGlpiClient` expose the same public endpoint methods. The parity is enforced by a unit test so any new sync endpoint is automatically reflected on the async client. When to pick which ~~~~~~~~~~~~~~~~~~ * Use :class:`GlpiClient` for plain Python scripts, CLI tools, cron entries, and synchronous services. No event loop, no ``await``. * Use :class:`AsyncGlpiClient` when your application already runs an event loop (for example a FastAPI or aiohttp service, an async CLI, or a Jupyter notebook cell), or when you want concurrent fan-out. How the async client is implemented ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The asynchronous surface is a thin facade over the synchronous endpoint mixins. The :class:`~glpi_python_client.clients.commons._async_bridge.AsyncBridge` base class walks the MRO of :class:`AsyncGlpiClient` at class-creation time and wraps every inherited public synchronous method into a coroutine wrapper that schedules the call on a worker thread: * by default through :func:`asyncio.to_thread`; * on a caller-supplied :class:`concurrent.futures.Executor` when one is passed to the constructor or to ``from_env``. Because the underlying HTTP layer is still backed by the blocking ``requests`` library, every concurrent worker runs on a distinct thread. A shared :class:`threading.Lock` (not :class:`asyncio.Lock`) serialises OAuth token acquisition so concurrent ``asyncio.gather`` fan-outs cannot race the auth manager, while the HTTP requests themselves execute outside the lock through the thread-safe :class:`requests.Session`. A number of helpers ship with hand-written async overrides rather than relying solely on the bridge. There are two reasons a method needs its own async variant: 1. **Concurrency** — the method benefits from fanning multiple GLPI calls out concurrently with :func:`asyncio.gather`. 2. **Internal self-calls** — the method calls another public method through ``self`` (e.g. ``self.search_tickets(...)`` inside a pagination loop). When the bridge runs the synchronous body in a worker thread, ``self.method`` resolves to the bridge-wrapped *coroutine*, which returns a coroutine object instead of data when called without ``await``. The async override replaces the body so every internal call is properly awaited on the event loop. Helpers with async overrides: * :meth:`AsyncGlpiClient.get_ticket_context` — fans the five underlying GLPI calls out concurrently (reason: concurrency). * :meth:`AsyncGlpiClient.get_task_statistics` — fans the per-ticket task-list calls out concurrently (reason: concurrency). * :meth:`AsyncGlpiClient.get_task_durations` — fans the per-ticket task fetches out concurrently when ``return_task_details=True``, and properly awaits ``iter_search_tickets`` and ``search_entities`` internally (reasons: concurrency + internal self-calls). * :meth:`AsyncGlpiClient.get_ticket_statistics` — properly awaits ``search_tickets`` and ``search_entities`` internally (reason: internal self-calls). * :meth:`AsyncGlpiClient.get_user_activity` — properly awaits ``search_users``, ``iter_search_tickets``, and ``get_task_durations`` internally (reason: internal self-calls). * ``iter_search_tickets``, ``iter_search_users``, ``iter_search_entities`` — each pagination loop body calls ``self.search_*(...)``; the async variants are native async generators that ``await`` those calls directly (reason: internal self-calls). 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. Custom thread pools ~~~~~~~~~~~~~~~~~~~ Applications that want to bound the worker pool size, name the worker threads, or share a pool with other components can pass an explicit executor: .. code-block:: python import asyncio from concurrent.futures import ThreadPoolExecutor from glpi_python_client import AsyncGlpiClient async def main() -> None: with ThreadPoolExecutor(max_workers=8, thread_name_prefix="glpi") as pool: async with AsyncGlpiClient.from_env(executor=pool) as client: tickets = await client.search_tickets("status==1", limit=200) print(len(tickets)) asyncio.run(main()) .. _seed-data: 3. Seed data for the examples ----------------------------- Every later snippet operates on a small, predictable set of records. Run the seed coroutine below once against a throwaway GLPI instance to materialise the records; the rest of the guide assumes the identifiers it prints are available under the variable names ``location_id``, ``alice_id``, ``bob_id``, and ``ticket_id``. .. warning:: This guide intentionally creates and deletes real records. Always target a development or sandbox GLPI environment, never a production tenant. .. code-block:: python from glpi_python_client import ( GlpiClient, PostFollowup, PostLocation, PostTeamMember, PostTicket, PostUser, ) def seed() -> dict[str, int]: """Create the demo records reused by the rest of the user guide.""" with GlpiClient.from_env() as client: location_id = client.create_location( PostLocation(name="HQ Paris") ) alice_id = client.create_user( PostUser( username="alice.dupont", password="initial-pwd", password2="initial-pwd", realname="Dupont", firstname="Alice", ) ) bob_id = client.create_user( PostUser( username="bob.martin", password="initial-pwd", password2="initial-pwd", realname="Martin", firstname="Bob", ) ) ticket_id = client.create_ticket( PostTicket( name="Wi-Fi unreachable", content="802.1X handshake fails on the 5 GHz radio.", ) ) client.add_ticket_team_member( ticket_id, PostTeamMember(type="User", id=bob_id, role="assigned"), ) client.create_ticket_followup( ticket_id, PostFollowup(content="Reproduced on the lab laptop."), ) return { "location_id": location_id, "alice_id": alice_id, "bob_id": bob_id, "ticket_id": ticket_id, } if __name__ == "__main__": print(seed()) Example output (identifiers vary across instances):: {'location_id': 7, 'alice_id': 21, 'bob_id': 22, 'ticket_id': 123} A teardown snippet to drop the seed records once the walkthrough is complete: .. code-block:: python def cleanup(ids: dict[str, int]) -> None: """Delete the seed records previously created by ``seed``.""" with GlpiClient.from_env() as client: client.delete_ticket(ids["ticket_id"], force=True) client.delete_user(ids["alice_id"], force=True) client.delete_user(ids["bob_id"], force=True) client.delete_location(ids["location_id"], force=True) In the rest of the guide every snippet is wrapped in an ``with GlpiClient.from_env() as client:`` block. The integer variables ``ticket_id``, ``alice_id``, ``bob_id``, and ``location_id`` are assumed to come from the seed dictionary above. .. _api-interface: 4. GLPI API interface --------------------- The helpers in this section map one-to-one to GLPI v2 endpoints. They all return Pydantic models from the public package root. Get / Post / Patch / Delete models ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Each GLPI resource is represented by four Pydantic models named after the verb of the HTTP operation: * ``Get`` — what the server returns from list and read endpoints. * ``Post`` — request body for the create endpoint. * ``Patch`` — partial body for the update endpoint. * ``Delete`` — optional body for the delete endpoint (typically a single ``force`` flag). The full set is re-exported from the package root, including ``GetTicket`` / ``PostTicket`` / ``PatchTicket`` / ``DeleteTicket``, ``GetUser`` / ``PostUser`` / ``PatchUser`` / ``DeleteUser``, ``GetLocation`` / ``PostLocation`` / ``PatchLocation`` / ``DeleteLocation``, ``GetEntity`` / ``PostEntity`` / ``PatchEntity`` / ``DeleteEntity``, ``GetFollowup``, ``GetTicketTask``, ``GetSolution``, ``GetTimelineDocument``, ``GetTeamMember``, and ``GetDocument`` together with their post / patch / delete variants. All models inherit from a permissive base: the GLPI server is the authoritative validator, so any extra keys returned by the live server flow into the public ``extra_payload`` attribute rather than raising a validation error. Caller-provided ``extra_payload`` keys win over ambient extras when both are present. .. code-block:: python from glpi_python_client import PostTicket ticket = PostTicket( name="Printer offline", content="The third-floor printer cannot be reached.", extra_payload={"_room_code": "PAR-3F-12"}, ) new_id = client.create_ticket(ticket) fetched = client.get_ticket(new_id) print(fetched.id, fetched.name) print(fetched.extra_payload) Example output:: 124 Printer offline {'_room_code': 'PAR-3F-12'} Tickets ~~~~~~~ The ticket mixin exposes search, fetch, create, update, and delete helpers under ``/Assistance/Ticket``. .. code-block:: python from glpi_python_client import PatchTicket client.update_ticket( ticket_id, PatchTicket(content="Updated diagnosis: radius timeout."), ) ticket = client.get_ticket(ticket_id) print(ticket.id, ticket.name, ticket.status) results = client.search_tickets("status==1", limit=3) for t in results: print(t.id, t.name) Example output:: 123 Wi-Fi unreachable id=1 name='New' 123 Wi-Fi unreachable 124 Printer offline 125 VPN drops ``force=True`` on :meth:`GlpiClient.delete_ticket` permanently deletes the ticket; omit it (or pass ``force=False``) to send the record to the GLPI trash. ``search_tickets`` accepts a raw RSQL filter string and forwards ``limit`` / ``start`` to the API for pagination. Ticket timeline ~~~~~~~~~~~~~~~ The ticket timeline groups followups, tasks, solutions, and document links under ``/Assistance/Ticket/{id}/Timeline/{Followup|Task|Solution|Document}``. Each subresource exposes the same ``list_ / get_ / create_ / update_ / delete_`` shape (``link_`` / ``unlink_`` for documents). .. code-block:: python from glpi_python_client import ( PostFollowup, PostSolution, PostTicketTask, ) followup_id = client.create_ticket_followup( ticket_id, PostFollowup(content="Triaged: ongoing"), ) task_id = client.create_ticket_task( ticket_id, PostTicketTask(content="On-site visit", duration=900), ) solution_id = client.create_ticket_solution( ticket_id, PostSolution(content="Replaced the access point"), ) followups = client.list_ticket_followups(ticket_id) tasks = client.list_ticket_tasks(ticket_id) solutions = client.list_ticket_solutions(ticket_id) print(len(followups), len(tasks), len(solutions)) print(followups[0].content) Example output:: 2 1 1 Reproduced on the lab laptop. .. note:: The live GLPI v2 server returns each timeline list entry wrapped in a ``{"type": ..., "item": {...}}`` envelope, even when the OpenAPI contract documents a flat array. The client unwraps that envelope transparently for ``list_ticket_followups``, ``list_ticket_tasks``, ``list_ticket_solutions``, and ``list_ticket_timeline_documents``. Team members ~~~~~~~~~~~~ Team members are managed via ``/Assistance/Ticket/{id}/TeamMember``. .. code-block:: python from glpi_python_client import PostTeamMember client.add_ticket_team_member( ticket_id, PostTeamMember(type="User", id=alice_id, role="observer"), ) members = client.list_ticket_team_members(ticket_id) for m in members: print(m.id, m.type, m.name, m.role) client.remove_ticket_team_member( ticket_id, team_member_id=members[0].id, ) Example output:: 22 User bob.martin assigned 21 User alice.dupont observer The OpenAPI contract marks the ``id`` field as read-only, but the live server requires it on the ``POST`` body. The client honours the live behaviour and exposes ``id`` as a writable field on :class:`glpi_python_client.PostTeamMember`. Users, locations, entities ~~~~~~~~~~~~~~~~~~~~~~~~~~ Each of these resources exposes the same ``search_ / get_ / create_ / update_ / delete_`` shape: .. code-block:: python alice = client.get_user(alice_id) print(alice.id, alice.username, alice.realname, alice.firstname) matches = client.search_users(f"username=={alice.username}") print([(u.id, u.username) for u in matches]) location = client.get_location(location_id) print(location.id, location.name) entities = client.search_entities(limit=2) for e in entities: print(e.id, e.name, e.completename) Example output:: 21 alice.dupont Dupont Alice [(21, 'alice.dupont')] 7 HQ Paris 0 Root entity Root entity 1 Paris Root entity > Paris Documents ~~~~~~~~~ Document metadata is handled with the standard ``Get/Post/Patch/Delete`` helpers under ``/Management/Document``. Binary content goes through two dedicated helpers: .. code-block:: python uploaded_id = client.upload_document( filename="diagnostic.txt", content=b"link layer ok\nradius timeout 3s\n", mime_type="text/plain", ticket_id=ticket_id, ) print("uploaded document", uploaded_id) raw_bytes = client.download_document_content(uploaded_id) print(len(raw_bytes), "bytes downloaded") Example output:: uploaded document 88 34 bytes downloaded ``upload_document`` requires the legacy v1 session to be configured on the client (``v1_base_url`` and ``v1_user_token``) because the GLPI v2 contract does not advertise a binary upload endpoint. Enums ~~~~~ Public IntEnum classes mirror the GLPI numeric constants and stay at the package root for easy use in RSQL filters: :class:`glpi_python_client.GlpiTicketStatus`, :class:`glpi_python_client.GlpiTicketType`, :class:`glpi_python_client.GlpiPriority`, :class:`glpi_python_client.GlpiTaskState`, :class:`glpi_python_client.GlpiSolutionStatus`, :class:`glpi_python_client.GlpiTimelinePosition`, :class:`glpi_python_client.GlpiUserAuthType`, and :class:`glpi_python_client.GlpiGlobalValidation`. .. code-block:: python from glpi_python_client import GlpiTicketStatus solved = client.search_tickets( f"status=={int(GlpiTicketStatus.SOLVED)}", limit=2 ) print([(t.id, t.name) for t in solved]) Example output:: [(120, 'Replaced toner cartridge'), (121, 'Reset VPN profile')] .. _added-functionalities: 5. Added functionalities ------------------------ The helpers in this section are not part of the GLPI contract. They are small utilities the client builds on top of the API mixins. Ticket custom fields via the Fields plugin ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The `Fields plugin `_ exposes ticket custom fields through the legacy v1 API rather than the GLPI v2 contract. Configure the client with ``v1_base_url`` and ``v1_user_token`` (or the matching ``GLPI_V1_*`` environment variables), then use the discovery helpers when you need the plugin's internal container and field names: .. code-block:: python from glpi_python_client import GlpiClient with GlpiClient( glpi_api_url="https://glpi.example.com/api.php/v2", client_id="oauth-client-id", client_secret="oauth-client-secret", username="api-user", password="api-password", v1_base_url="https://glpi.example.com/apirest.php", v1_user_token="legacy-user-token", ) as client: containers = client.list_plugin_fields_containers(itemtype="Ticket") for container in containers: print(container.id, container.name) fields = client.list_plugin_fields_fields(container_id=container.id) print([field.name for field in fields]) custom_fields = client.get_ticket_custom_fields(ticket_id) print(custom_fields) client.set_ticket_custom_fields( ticket_id, { "aidelarsolution": { "aidelarsolutionfield": "

Handled by the NOC shift

", } }, ) The high-level ``get_ticket_custom_fields`` / ``set_ticket_custom_fields`` pair uses the mapping ``{container_name: {field_name: value}}`` and automatically decides whether the v1 plugin needs a row creation or an in-place update. Drop to ``list_item_plugin_field_rows``, ``create_item_plugin_field_row``, or ``update_item_plugin_field_row`` only when you need the raw v1 row shape. Aggregated ticket context ~~~~~~~~~~~~~~~~~~~~~~~~~ :meth:`GlpiClient.get_ticket_context` runs the ticket fetch and the four timeline list calls concurrently and returns a single :class:`glpi_python_client.GlpiTicketContext` model: .. code-block:: python bundle = client.get_ticket_context(ticket_id) print(bundle.ticket.id, bundle.ticket.name) print( len(bundle.followups), len(bundle.tasks), len(bundle.solutions), len(bundle.documents), ) Example output:: 123 Wi-Fi unreachable 2 1 1 1 :meth:`GlpiTicketContext.to_markdown` renders the ticket title, a metadata subtitle, and every timeline event (followups, tasks, solutions, document links) as a single Markdown transcript. Events are always ordered by ``date_creation``: .. code-block:: python print(bundle.to_markdown()) Example output:: # Ticket #123 — Wi-Fi unreachable > Status: New | Requester: Alice Dupont | Last edited by: Bob Martin | Created at: 2026-01-02T09:00:00+00:00 | Updated at: 2026-01-02T09:20:00+00:00 ## Description 802.1X handshake fails on the 5 GHz radio. ## Timeline ### Followup #45 > Created by: Bob Martin | Created at: 2026-01-02T09:05:00+00:00 Reproduced on the lab laptop. ### Task #12 > Created by: Bob Martin | Created at: 2026-01-02T09:10:00+00:00 | Duration: 900s | State: Todo On-site visit. ### Solution #7 > Created by: Bob Martin | Created at: 2026-01-02T09:20:00+00:00 | Status: Approved Replaced the access point. ## Documents - diagnostic.txt Customising the Markdown output ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Pass a :class:`TicketMarkdownOptions` instance to select which sections and metadata fields appear in the output. All flags default to ``True`` so the default call reproduces the full transcript shown above. +-------------------------------+----------------------------------------------+ | Flag | Controls | +===============================+==============================================+ | ``include_description`` | ``## Description`` section | +-------------------------------+----------------------------------------------+ | ``include_followups`` | Followup entries in ``## Timeline`` | +-------------------------------+----------------------------------------------+ | ``include_tasks`` | Task entries in ``## Timeline`` | +-------------------------------+----------------------------------------------+ | ``include_solutions`` | Solution entries in ``## Timeline`` | +-------------------------------+----------------------------------------------+ | ``include_documents`` | ``## Documents`` section | +-------------------------------+----------------------------------------------+ | ``show_status`` | ``Status`` in the ticket subtitle | +-------------------------------+----------------------------------------------+ | ``show_requester`` | ``Requester`` in the ticket subtitle | +-------------------------------+----------------------------------------------+ | ``show_editor`` | ``Last edited by`` in the ticket subtitle | +-------------------------------+----------------------------------------------+ | ``show_dates`` | All ticket-level date fields | +-------------------------------+----------------------------------------------+ | ``show_event_author`` | ``Created by`` in event subtitles | +-------------------------------+----------------------------------------------+ | ``show_event_editor`` | ``Last edited by`` in event subtitles | +-------------------------------+----------------------------------------------+ | ``show_event_dates`` | All date fields in event subtitles | +-------------------------------+----------------------------------------------+ | ``show_event_state`` | ``State`` in event subtitles | +-------------------------------+----------------------------------------------+ | ``show_event_status`` | ``Status`` in event subtitles | +-------------------------------+----------------------------------------------+ | ``show_duration`` | ``Duration`` in task subtitles | +-------------------------------+----------------------------------------------+ | ``show_technician`` | ``Technician`` / ``Technician group`` | +-------------------------------+----------------------------------------------+ | ``show_approver`` | ``Approver`` in solution subtitles | +-------------------------------+----------------------------------------------+ Example — description and timeline only, no metadata fields: .. code-block:: python from glpi_python_client import TicketMarkdownOptions opts = TicketMarkdownOptions( include_documents=False, show_status=False, show_requester=False, show_editor=False, show_dates=False, show_event_author=False, show_event_editor=False, show_event_dates=False, show_event_state=False, show_event_status=False, show_duration=False, show_technician=False, show_approver=False, ) print(bundle.to_markdown(opts)) Reporting helpers ~~~~~~~~~~~~~~~~~ The custom statistics mixin exposes several helpers that aggregate the ticket and ticket-task records returned by the contract-aligned mixins. 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") .. note:: Always pass an RSQL filter to ``iter_search_tickets``. Querying without any filter can return very large result sets and may cause the GLPI server to return a 500 errors. 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. The ``start_date`` is inclusive from 00:00:00 and the ``end_date`` is inclusive through 23:59:59, so tickets created at any time on those days are counted. 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):: { "entities": { "0": { "total": 12, "by_status": {"1": 5, "2": 3, "5": 4}, "by_priority": {"LOW": 2, "MEDIUM": 7, "HIGH": 3}, "by_type": {"INCIDENT": 9, "REQUEST": 3}, }, "1": { "total": 4, "by_status": {"1": 1, "5": 3}, "by_priority": {"MEDIUM": 4}, "by_type": {"INCIDENT": 4}, }, } } * ``total`` — number of tickets in the entity bucket. * ``by_status`` — keyed by the GLPI numeric status as a string; resolve with :class:`glpi_python_client.GlpiTicketStatus`. * ``by_priority`` / ``by_type`` — keyed by the matching IntEnum member name (``"LOW"``, ``"INCIDENT"``, …); unknown values fall back to the raw numeric value as a string. ``get_task_statistics`` ^^^^^^^^^^^^^^^^^^^^^^^ Aggregates task durations across a caller-supplied list of ticket identifiers. GLPI does not expose a global task collection endpoint, so callers typically collect the relevant ticket IDs through ``search_tickets`` first. .. code-block:: python ticket_ids = [ t.id for t in client.search_tickets("status==2", limit=200) ] tasks = client.get_task_statistics(ticket_ids) print(tasks) Returned shape (durations are integer seconds, matching the GLPI ``duration`` field; user keys are the GLPI numeric user identifier as a string, ``"unknown"`` when missing):: { "ticket_count": 3, "task_count": 5, "total_duration": 6300, "duration_by_user": {"22": 4500, "21": 1800}, "duration_by_ticket": {123: 2700, 124: 1800, 125: 1800}, } Returned identifiers are the raw GLPI numeric values; resolve them with the appropriate ``search_*`` helpers when human-readable labels are 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; ``start_date`` is inclusive from 00:00:00, ``end_date`` is inclusive through 23:59:59, and ``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 ---------------------- The snippets below combine the building blocks of the previous sections. Every example is mirrored by an integration test in ``integration_tests/test_integration.py`` (named ``test_example_*``). They all assume the seed step from :ref:`seed-data` has been executed and that ``ticket_id`` / ``alice_id`` / ``bob_id`` are bound to the matching identifiers. Example 1 — Create a ticket and read it back ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python from glpi_python_client import GlpiClient, PostTicket with GlpiClient.from_env() as client: new_id = client.create_ticket( PostTicket( name="Printer offline", content="The third-floor printer cannot be reached.", ) ) context = client.get_ticket_context(new_id) print(context.to_markdown()) Expected Markdown (abridged):: # Ticket #124 — Printer offline > Status: New ## Description The third-floor printer cannot be reached. Example 2 — Add a followup response ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python from glpi_python_client import PostFollowup client.create_ticket_followup( ticket_id, PostFollowup(content="Capturing radius logs."), ) context = client.get_ticket_context(ticket_id) print(context.to_markdown()) Expected Markdown (abridged):: # Ticket #123 — Wi-Fi unreachable ## Timeline ### Followup #46 > Created at: 2026-01-02T10:15:00+00:00 Capturing radius logs. Example 3 — Add a task with a duration ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python from glpi_python_client import PostTicketTask client.create_ticket_task( ticket_id, PostTicketTask( content="On-site visit to swap the access point.", duration=1800, ), ) context = client.get_ticket_context(ticket_id) print(context.to_markdown()) Expected Markdown (abridged):: ### Task #13 > Duration: 1800s On-site visit to swap the access point. Example 4 — Close a ticket with a solution ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ GLPI moves a ticket to the *Solved* status as soon as a solution is posted, so adding a solution is the supported way to change the ticket status from the v2 API. .. code-block:: python from glpi_python_client import PostSolution client.create_ticket_solution( ticket_id, PostSolution(content="Replaced the access point firmware."), ) context = client.get_ticket_context(ticket_id) print(context.to_markdown()) Expected Markdown (abridged):: # Ticket #123 — Wi-Fi unreachable > Status: Solved ## Timeline ### Solution #8 Replaced the access point firmware. Example 5 — Upload a document to an existing ticket ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``upload_document`` accepts a ``ticket_id`` and links the new document to the timeline in a single call. The call requires the legacy v1 session (``v1_base_url`` and ``v1_user_token``). .. code-block:: python client.upload_document( filename="diagnostic.txt", content=b"link layer ok\nradius timeout 3s\n", mime_type="text/plain", ticket_id=ticket_id, ) context = client.get_ticket_context(ticket_id) print(context.to_markdown()) Expected Markdown (abridged):: ## Documents - diagnostic.txt Example 6 — Full ticket workflow with a dedicated technician ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The following script mirrors the integration test suite. It creates a fresh ticket, exercises every timeline subresource, assigns a technician, and tears the records down at the end. .. code-block:: python from glpi_python_client import ( GlpiClient, PostFollowup, PostSolution, PostTeamMember, PostTicket, PostTicketTask, PostUser, ) def workflow() -> None: with GlpiClient.from_env() as client: user_id = client.create_user( PostUser( username="bob.workflow", password="initial-pwd", password2="initial-pwd", realname="Workflow", firstname="Bob", ) ) new_ticket_id = client.create_ticket( PostTicket(name="VPN drops", content="Daily VPN drops at 11:00") ) try: client.create_ticket_followup( new_ticket_id, PostFollowup(content="Reproduced on lab laptop"), ) client.create_ticket_task( new_ticket_id, PostTicketTask(content="Capture VPN logs", duration=1800), ) client.add_ticket_team_member( new_ticket_id, PostTeamMember(type="User", id=user_id, role="assigned"), ) client.create_ticket_solution( new_ticket_id, PostSolution(content="Upgraded VPN client"), ) context = client.get_ticket_context(new_ticket_id) print(context.ticket.name, len(context.followups)) finally: client.delete_ticket(new_ticket_id, force=True) client.delete_user(user_id, force=True) workflow() Example output:: VPN drops 1 Example 7 — Build a monthly report ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Combines :meth:`get_ticket_statistics` and :meth:`get_task_statistics` to summarise a calendar month. .. code-block:: python from glpi_python_client import GlpiClient def monthly_report(start: str, end: str) -> dict[str, object]: with GlpiClient.from_env() as client: ticket_stats = client.get_ticket_statistics( start_date=start, end_date=end ) solved_tickets = client.search_tickets( "status==5", limit=200 ) task_stats = client.get_task_statistics( [t.id for t in solved_tickets] ) return {"tickets": ticket_stats, "tasks": task_stats} if __name__ == "__main__": print(monthly_report("2026-01-01", "2026-01-31")) Example output:: { 'tickets': { 'entities': { '0': { 'total': 12, 'by_status': {'1': 5, '2': 3, '5': 4}, 'by_priority': {'MEDIUM': 9, 'HIGH': 3}, 'by_type': {'INCIDENT': 9, 'REQUEST': 3}, } } }, 'tasks': { 'ticket_count': 4, 'task_count': 5, 'total_duration': 6300, 'duration_by_user': {'22': 4500, '21': 1800}, 'duration_by_ticket': {120: 1800, 121: 900, 122: 1800, 123: 1800}, }, }