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
64 changes: 64 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,70 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Fixed

- **The statistics layer sent GLPI v1 field names to the v2 API, which
silently ignored them and returned unfiltered results.** v2 drops a
`filter=` conjunct whose field it does not recognise, honours the rest,
and answers 200/206 with no error — so the aggregations narrowed by date
and looked plausible while ignoring the user and entity selection
entirely. Measured against a live GLPI 11 instance,
`get_user_activity` reported `tickets_as_technician == tickets_as_recipient
== 963` — the window's *total* ticket count — for every user regardless of
who they were. Corrected:
- `entities_id==N` → `entity.id==N` (v2 types `entity` as an object).
- `users_id_lastupdater==N` → `user_editor.id==N`.
- `users_id_requester==N` (as `user_recipient_id`) → `user_recipient.id==N`,
which is what that parameter's *name* has always meant. Note the v2
`user_recipient` field is `users_id_recipient` — who *recorded* the
ticket — not the requester link; the two are different people.
- `users_id_assign` / `users_id_requester` (as `user_id`) have **no v2
equivalent at all**: the v2 `team` array cannot be joined by the RSQL
engine (the four contract-declared subfields answer HTTP 500 and every
other spelling is silently ignored — 19 spellings were tested). These
now resolve through the legacy v1 search engine, whose searchOption 5
(`Technicien`) and 4 (`Demandeur`) map exactly onto the
`glpi_tickets_users` link types, and which fails **loudly** (HTTP 400)
on an unknown field instead of silently returning everything.
- **`rsql_any_filter` produced an unparenthesised OR group**, and RSQL binds
`;` (AND) tighter than `,` (OR). `get_ticket_statistics(entity_name=...)`
matching several entities emitted `date;e==1,e==2`, which the server reads
as `(date AND e==1) OR e==2` — the date window stopped applying to every
entity after the first. Measured live: 16,245 tickets returned where the
correct answer was 1,552. OR groups are now wrapped in parentheses.
- **v2 ticket searches counted soft-deleted tickets.** The v2 search includes
trashed tickets by default while v1 excludes them (59,690 live + 258
trashed = 59,948), so every aggregation was inflated by the trash bin — for
one user 92% of matches were deleted tickets. All v2 ticket queries in the
statistics layer now pin `is_deleted==false`.
- Actor identifiers are validated before reaching the v1 search, which fails
*open* rather than rejecting bad input: `equals 0` matched 20,905 tickets
(a LEFT-JOIN-NULL "has no actor" match), an empty value matched the entire
baseline, and a non-numeric value returned the same arbitrary 3 rows
whatever the string. A non-positive or non-`int` id now raises
`GlpiValidationError`.
- **The per-ticket task fan-out is replaced by one bulk sweep.** The v2 API
publishes tasks only under `/Assistance/Ticket/{id}/Timeline/Task`, so
aggregating N tickets cost N requests. The v1 `TicketTask` *collection*
returns whole rows including `tickets_id`, paged 1000 at a time, so the
same aggregate now costs one page per 1000 tasks created since the window
opened. Measured live on a 120-ticket set: **120 requests / 11.7 s -> 2
requests / 0.4 s**, with `ticket_count`, `task_count`, `total_duration`,
`duration_by_ticket` and `duration_by_user` all byte-identical between the
two paths. Below 25 tickets the per-ticket path is cheaper and is kept, so
clients without a v1 session are unaffected.

Note v1 `search/TicketTask` is *not* usable for this: its searchOptions
expose the task id, content, category, date, privacy, technician, duration
and state, but no parent ticket id, so results cannot be attributed back
to a ticket. The plain collection endpoint is what carries `tickets_id`.

- `get_user_activity` walks the date window **once** for all users instead of
twice per user. Combined with the corrected filters this took one user over
90 days from **979 requests / 120 s to 9 requests / 5.1 s**, verified live.

Actor-based statistics now require the legacy v1 session (`v1_base_url` +
`v1_user_token`) and raise `RuntimeError` naming the missing options when
it is absent, rather than returning a wrong number.

- `GLPITokenManager._refresh_access_token`'s retry decorator no longer
retries a `GlpiServerError` from its fall-through to the nested
`_acquire_token()` call. That nested call already carries its own
Expand Down
13 changes: 12 additions & 1 deletion glpi_python_client/clients/commons/_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,23 @@ def rsql_any_filter(*filters: str | None) -> str | None:
"""Join non-empty RSQL filter fragments with OR semantics.

Empty fragments are ignored and an all-empty input returns ``None``.

The joined result is wrapped in parentheses whenever it contains more
than one fragment. RSQL binds ``;`` (AND) tighter than ``,`` (OR), so
an unparenthesised group silently loses every preceding AND clause for
all but its first alternative: ``date;e==1,e==2`` parses as
``(date AND e==1) OR e==2``, which matches every ``e==2`` ticket
regardless of the date window. Measured against a live GLPI 11
instance, the unparenthesised form returned 16,245 tickets where the
parenthesised form correctly returned 1,552.
"""

parts = [fragment for fragment in filters if fragment]
if not parts:
return None
return ",".join(parts)
if len(parts) == 1:
return parts[0]
return "(" + ",".join(parts) + ")"


def rsql_all_filter(*filters: str | None) -> str | None:
Expand Down
28 changes: 26 additions & 2 deletions glpi_python_client/clients/commons/tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,33 @@ def test_rsql_all_filter_joins_with_semicolons() -> None:


def test_rsql_any_filter_joins_with_commas() -> None:
"""``rsql_any_filter`` joins non-empty parts with ``,``."""
"""``rsql_any_filter`` ORs parts and parenthesises the group."""

assert rsql_any_filter("a==1", None, "b==2") == "a==1,b==2"
assert rsql_any_filter("a==1", None, "b==2") == "(a==1,b==2)"


def test_rsql_any_filter_single_part_is_not_wrapped() -> None:
"""A lone fragment needs no group and is returned unchanged."""

assert rsql_any_filter(None, "a==1", "") == "a==1"


def test_rsql_any_filter_group_survives_an_and_join() -> None:
"""The OR group keeps its AND clauses when nested in ``rsql_all_filter``.

RSQL binds ``;`` tighter than ``,``. Without the parentheses this
composes to ``date;e==1,e==2``, which the server reads as
``(date AND e==1) OR e==2`` -- so every ``e==2`` record matches
regardless of the date window. Measured against a live GLPI 11
instance, that returned 16,245 tickets where the correct answer,
reproduced by the parenthesised form, was 1,552.
"""

combined = rsql_all_filter(
"date_creation=ge=2026-01-01",
rsql_any_filter("entity.id==1", "entity.id==2"),
)
assert combined == "date_creation=ge=2026-01-01;(entity.id==1,entity.id==2)"


def test_escape_rsql_like_value_escapes_special_characters() -> None:
Expand Down
Loading
Loading