User Guide

The glpi_python_client package exposes two high-level clients whose surface is built from contract-aligned per-endpoint mixins:

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.

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 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.

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.

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:

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 GlpiClient.upload_document() and the Fields plugin helpers such as GlpiClient.get_ticket_custom_fields().

  • executor (AsyncGlpiClient only) — an explicit concurrent.futures.Executor used to dispatch the wrapped synchronous calls. Defaults to the standard library thread pool through asyncio.to_thread().

from_env

When the same configuration is already exposed through environment variables, GlpiClient.from_env() (and 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

from glpi_python_client import AsyncGlpiClient, GlpiClient

sync_client = GlpiClient.from_env()
async_client = AsyncGlpiClient.from_env()

2. Sync vs async surface

Both GlpiClient and 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 GlpiClient for plain Python scripts, CLI tools, cron entries, and synchronous services. No event loop, no await.

  • Use 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 AsyncBridge base class walks the MRO of AsyncGlpiClient at class-creation time and wraps every inherited public synchronous method into a coroutine wrapper that schedules the call on a worker thread:

Because the underlying HTTP layer is still backed by the blocking requests library, every concurrent worker runs on a distinct thread. A shared threading.Lock (not 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 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 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:

  • AsyncGlpiClient.get_ticket_context() — fans the five underlying GLPI calls out concurrently (reason: concurrency).

  • AsyncGlpiClient.get_task_statistics() — fans the per-ticket task-list calls out concurrently (reason: concurrency).

  • 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).

  • AsyncGlpiClient.get_ticket_statistics() — properly awaits search_tickets and search_entities internally (reason: internal self-calls).

  • 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:

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:

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())

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.

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:

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.

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<Name> — what the server returns from list and read endpoints.

  • Post<Name> — request body for the create endpoint.

  • Patch<Name> — partial body for the update endpoint.

  • Delete<Name> — 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.

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.

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 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).

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.

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 glpi_python_client.PostTeamMember.

Users, locations, entities

Each of these resources exposes the same search_ / get_ / create_ / update_ / delete_ shape:

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:

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: glpi_python_client.GlpiTicketStatus, glpi_python_client.GlpiTicketType, glpi_python_client.GlpiPriority, glpi_python_client.GlpiTaskState, glpi_python_client.GlpiSolutionStatus, glpi_python_client.GlpiTimelinePosition, glpi_python_client.GlpiUserAuthType, and glpi_python_client.GlpiGlobalValidation.

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')]

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:

 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": "<p>Handled by the NOC shift</p>",
            }
        },
    )

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

GlpiClient.get_ticket_context() runs the ticket fetch and the four timeline list calls concurrently and returns a single glpi_python_client.GlpiTicketContext model:

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

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:

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 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:

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.

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.

# 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 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.

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 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 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.

# 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 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.

# 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},
            },
        }
    }
}

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 3. Seed data for the examples 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

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

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

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.

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).

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.

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 get_ticket_statistics() and get_task_statistics() to summarise a calendar month.

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},
    },
}