diff --git a/glpi_python_client/testing/tests/test_skill_references.py b/glpi_python_client/testing/tests/test_skill_references.py index 7305ba3..fe3281a 100644 --- a/glpi_python_client/testing/tests/test_skill_references.py +++ b/glpi_python_client/testing/tests/test_skill_references.py @@ -184,3 +184,73 @@ def test_no_skill_describes_the_retired_thread_pool_bridge() -> None: "these skills describe machinery that no longer exists:\n" + "\n".join(offenders) ) + + +#: Public client methods deliberately left out of ``skills/``, each with the +#: reason. Empty on purpose: every method the client exposes is documented +#: somewhere, and an entry here is a decision someone has to justify rather +#: than a place to park undone work. +_UNDOCUMENTED: dict[str, str] = {} + + +def _public_methods() -> set[str]: + """Every public callable on the client surface.""" + + return { + name + for name in dir(GlpiClient) + if not name.startswith("_") and callable(getattr(GlpiClient, name, None)) + } + + +def _documented() -> str: + """Every skill document concatenated, for a whole-word name search.""" + + return "\n".join(path.read_text(encoding="utf-8-sig") for path in _skill_files()) + + +def test_every_public_method_is_named_by_some_skill() -> None: + """A method no skill mentions is a method no agent will ever call. + + The other checks in this module all validate what the skills *say*. + None of them asks what the skills *omit*, which is how the knowledge + base and plugin-fields families -- twenty-five public methods between + them -- shipped across two releases with no documentation anywhere + and a fully green suite. + + Matched on a word boundary rather than a substring, so documenting + ``get_ticket_task`` does not silently satisfy ``get_ticket``. + """ + + prose = _documented() + missing = sorted( + name + for name in _public_methods() + if name not in _UNDOCUMENTED and not re.search(rf"\b{re.escape(name)}\b", prose) + ) + assert missing == [], ( + "these public client methods are named by no skill -- document them, " + "or add them to _UNDOCUMENTED with a reason:\n" + "\n".join(missing) + ) + + +def test_the_undocumented_allowlist_has_no_dead_entries() -> None: + """An allowlist outliving the method it excused hides the next gap.""" + + dead = sorted(_UNDOCUMENTED.keys() - _public_methods()) + assert dead == [], ( + "these _UNDOCUMENTED entries name methods that no longer exist:\n" + + "\n".join(dead) + ) + + +def test_the_coverage_scan_discriminates() -> None: + """Positive control: the word boundary is load-bearing, so prove it. + + Without this, a regex that quietly stopped matching would leave the + coverage check passing forever while reading nothing. + """ + + assert not re.search(r"\bget_ticket\b", "see get_ticket_task above") + assert re.search(r"\bget_ticket\b", "call get_ticket(321) now") + assert len(_public_methods()) > 50, "client surface looks wrong -- API moved?" diff --git a/skills/README.md b/skills/README.md index 83046c7..44e4170 100644 --- a/skills/README.md +++ b/skills/README.md @@ -10,11 +10,13 @@ These skills are source-tree project material. They are included in source distr | --- | --- | --- | | `glpi-client-setup` | Build and configure an authenticated client | `GlpiClient`, `AsyncGlpiClient`, `.from_env()` | | `glpi-ticket-workflow` | Search, fetch, create, update, or delete tickets | `GetTicket`, `PostTicket`, `PatchTicket`, `DeleteTicket` | -| `glpi-ticket-timeline` | Read timeline records or write followups, tasks, solutions, and document links | `PostFollowup`, `PostTicketTask`, `PostSolution`, `PostTimelineDocument` (plus matching Get/Patch/Delete) | +| `glpi-ticket-timeline` | Read timeline records or write followups, tasks, solutions, and document links | `PostFollowup`, `PostTicketTask`, `PostSolution`, `PostTimelineDocument` (plus matching Get/Patch/Delete for followups/tasks/solutions; document reads return `GetDocument`, not a `GetTimelineDocument`) | | `glpi-document-workflow` | Manage document metadata, upload binary content, download binaries | `GetDocument`, `PostDocument`, `PatchDocument`, `DeleteDocument` | | `glpi-user-location-provisioning` | Search and provision users, locations, and entities | `GetUser`, `PostUser`, `GetLocation`, `PostLocation`, `GetEntity`, `PostEntity` | | `glpi-reporting-and-context` | Aggregate ticket statistics, aggregate task durations, or load one ticket context bundle | `GlpiClient`, `GlpiTicketContext`, public enums | | `glpi-team-members` | List, add, or remove ticket team members | `GetTeamMember`, `PostTeamMember` | +| `glpi-knowledge-base` | Search, read, or write KB articles, categories, comments, and revisions | `GetKBArticle`, `PostKBArticle`, `GetKBCategory`, `GetKBArticleComment`, `GetKBArticleRevision` | +| `glpi-plugin-fields` | Discover and read/write Fields-plugin custom fields | `GetPluginFieldsContainer`, `GetPluginFieldsField`, `GetPluginFieldsValueRow` | ## Sync and async @@ -23,6 +25,6 @@ The package ships two clients with identical endpoint surfaces: - `GlpiClient` — synchronous. `with GlpiClient(...) as client`, no `await`. - `AsyncGlpiClient` — asynchronous, performing real non-blocking I/O. `async with AsyncGlpiClient(...) as client`, `await` every method. -Neither wraps the other: the async tree is hand-written and the synchronous one is generated from it by `unasync_build.py`, so the two cannot drift apart. The snippets in each skill are written against `AsyncGlpiClient`; every skill opens with a note on how to read them for the synchronous client. +Neither wraps the other: the async tree is hand-written and the synchronous one is generated from it by `unasync_build.py`, so the two cannot drift apart. Every skill opens with a note telling you how to read its snippets across the two surfaces. For eight of the nine that note says the same thing -- the snippets are written against `AsyncGlpiClient`, so drop the `await` and the `async` for `GlpiClient`. `glpi-client-setup` is the exception and says so in its own note: choosing between the two clients is what that skill is *for*, so it shows both directly, side by side, and neither surface is a translation of the other. When fanning out concurrently on the async client, bound the fan-out with an `asyncio.Semaphore` — see `glpi-client-setup`. An unbounded fan-out is slower, not faster. diff --git a/skills/glpi-client-setup/SKILL.md b/skills/glpi-client-setup/SKILL.md index 59092dd..2550fcd 100644 --- a/skills/glpi-client-setup/SKILL.md +++ b/skills/glpi-client-setup/SKILL.md @@ -1,15 +1,21 @@ --- name: glpi-client-setup -description: "Create and configure the synchronous glpi_python_client.GlpiClient or the asynchronous glpi_python_client.AsyncGlpiClient, including from_env, OAuth credential pairs, entity/profile headers, SSL settings, and the optional legacy v1 document-upload session. Use before calling GLPI APIs or when the user asks how to connect to GLPI with glpi_python_client." +description: "Create and configure the synchronous glpi_python_client.GlpiClient or the asynchronous glpi_python_client.AsyncGlpiClient, including from_env, OAuth credential pairs, entity/profile headers, SSL settings, and the optional legacy v1 session (v1_base_url / v1_user_token) that backs document uploads, the Fields plugin helpers, KB category writes and actor-based statistics. Use before calling GLPI APIs, when configuring the v1 session for any of those features, or when the user asks how to connect to GLPI with glpi_python_client." license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to a GLPI v2 API, and valid GLPI credentials." metadata: package: glpi-python-client - version: "0.4.0" + version: "0.4.1" --- # GLPI Client Setup +> Unlike the other skills in this package, the snippets below are not written +> against one client and translated for the other: `with GlpiClient(...)` and +> `async with AsyncGlpiClient(...)` examples both appear directly, side by +> side, because choosing between the two surfaces is what this skill is for. +> Read each example as written for the client it names. + The package exposes two clients with identical endpoint surfaces: - `glpi_python_client.GlpiClient` — synchronous, blocking client. Use it from @@ -44,8 +50,22 @@ call `client.close()` (or `await client.close()`) when finished. `client_secret`, `username`/`password`, or both pairs together. 5. Add `glpi_entity`, `glpi_profile`, and `entity_recursive=True` only when the operation must run in a specific GLPI scope. -6. Add `v1_base_url` and `v1_user_token` only when binary document - uploads are needed (`upload_document`). `v1_app_token` is optional. +6. Add `v1_base_url` and `v1_user_token` whenever a v1-backed feature is + used, not only for uploads. They are required by: binary document + uploads (`upload_document`); the Fields plugin helpers + (`get_ticket_custom_fields`, `set_ticket_custom_fields`, + `list_plugin_fields_containers`, `list_plugin_fields_fields`, + `list_item_plugin_field_rows`, `create_item_plugin_field_row`, + `update_item_plugin_field_row`); KB category writes + (`set_kb_article_categories`, and `PostKBArticle.categories` / + `PatchKBArticle.categories` passed to `create_kb_article` / + `update_kb_article`); and actor-based statistics + (`get_user_activity`, `get_task_durations(user_id=...)`) — v2 cannot + filter on a ticket's actors at all, so those resolve through the v1 + search engine. The same session also switches `get_task_durations` + to a bulk v1 task sweep once a run covers 25 tickets or more. Any of + these raises `RuntimeError` when the v1 session is absent. + `v1_app_token` is optional. 7. Keep `verify_ssl=True` unless the user explicitly confirms a test or internal endpoint that cannot validate TLS. 8. Bound any large async fan-out with an `asyncio.Semaphore` on the @@ -130,17 +150,25 @@ with GlpiClient.from_env() as glpi: Environment setup, asynchronous: ```python +import asyncio + from glpi_python_client import AsyncGlpiClient -async with AsyncGlpiClient.from_env() as glpi: - tickets = await glpi.search_tickets("status==1") + +async def main() -> None: + async with AsyncGlpiClient.from_env() as glpi: + tickets = await glpi.search_tickets("status==1") + + +asyncio.run(main()) ``` -Document-upload setup (works on either client): +Legacy v1 session setup — enables every v1-backed feature from step 6, +not only uploads (works on either client): ```python with GlpiClient.from_env( - v1_base_url="https://glpi.example.com/apirest.php", + v1_base_url="https://glpi.example.com/api.php/v1", v1_user_token="legacy-user-token", ) as glpi: ... @@ -154,10 +182,29 @@ with GlpiClient.from_env( - The package no longer exports `GLPIV1Session`. Configure `v1_base_url`/`v1_user_token` on the client and call `upload_document` instead. -- Use `glpi_api_url` for the v2 API; `v1_base_url` is only for the - document-upload fallback. +- Use `glpi_api_url` for the v2 API; `v1_base_url` additionally enables + every v1-backed feature listed in step 6 — document uploads, the + Fields plugin helpers, KB category writes and actor-based statistics. - Closing the client matters because it owns one or two HTTP sessions plus an OAuth token manager. Prefer the context-manager form. +- Every **API** failure the library raises derives from `GlpiError`, + exported from the package root. Construction raises + `GlpiValidationError` for a missing `glpi_api_url`, a half-supplied + credential pair, or a `v1_base_url` without a `v1_user_token`; calls + raise `GlpiAuthError` (401/403), `GlpiNotFoundError` (404), + `GlpiServerError` (persistent 5xx), + `GlpiTransportError`/`GlpiTimeoutError` (network fault) or + `GlpiProtocolError` (unusable 2xx body). Do not catch `requests` + exceptions — `requests` is not a dependency — and do not catch + `tenacity.RetryError`; the retry decorators re-raise the real error. +- A small set of raise sites is deliberately **outside** that hierarchy, + so `except GlpiError:` will not catch them. Plain `RuntimeError`: + using a closed client; a v1-backed call on a client built without + `v1_base_url`; and a `create_kb_article` whose category fallback + failed *after* the article was already created (the article exists, + its categories were not applied). Plain `TypeError`: an environment + value that is neither a string nor the expected scalar when `from_env` + parses an integer or boolean setting. - Concurrent callers cannot stampede the token endpoint: the client holds a lock around OAuth acquisition, so it is safe to launch a fan-out on `AsyncGlpiClient` before the token has ever been fetched. diff --git a/skills/glpi-document-workflow/SKILL.md b/skills/glpi-document-workflow/SKILL.md index d87b5b4..56f73c4 100644 --- a/skills/glpi-document-workflow/SKILL.md +++ b/skills/glpi-document-workflow/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and v1 credentials configured on the client for binary uploads." metadata: package: glpi-python-client - version: "0.4.0" + version: "0.4.1" --- # GLPI Document Workflow @@ -25,7 +25,7 @@ The `GLPIV1Session` class is no longer part of the public surface; the v1 sessio 6. Delete with `await client.delete_document(document_id, force=True|False|None)`. 7. Download bytes with `content = await client.download_document_content(document_id)`. 8. Upload bytes with `await client.upload_document(filename=..., content=..., mime_type=..., ticket_id=..., entity_id=...)`. -9. To attach an existing GLPI document to a ticket timeline, use `link_ticket_timeline_document` from the timeline skill. +9. To put a file on a ticket timeline, use `upload_document(..., ticket_id=...)` -- it creates the document *and* the ticket link in one call. `link_ticket_timeline_document` from the timeline skill cannot be told **which** existing document to link: `PostTimelineDocument` declares only `extra_payload` and `timeline_position`, and the POST URL carries only the ticket id, so there is no typed slot for a document id. See the timeline skill for the `extra_payload` escape hatch and its caveat. ## Examples @@ -64,9 +64,10 @@ document_id = await client.create_document(PostDocument(name="Diagnostic notes") ## Gotchas +- **`search_documents` swallows 4xx and returns `[]`.** This is a library-wide contract, not a document peculiarity: `_resource_list` checks the response status only when the caller passes a `failure_message`, and none of the seven `search_*` helpers (`search_documents`, `search_tickets`, `search_users`, `search_locations`, `search_entities`, `search_kb_articles`, `search_kb_categories`) passes one -- a GLPI error body is not a JSON list, so it is coerced to `[]`. A malformed RSQL filter, a 403 on `/Management/Document`, a missing route and "no such document" all look identical. `get_document`, `download_document_content` and every `list_*` helper do pass a `failure_message` and raise `GlpiStatusError` (narrowed to `GlpiAuthError` / `GlpiNotFoundError` / `GlpiServerError`) normally. So never conclude from an empty `search_documents` that a file is not on the server and re-upload it -- that is how duplicate documents get created; corroborate with a call that raises first. - `upload_document` raises `RuntimeError` when the v1 session is not configured. Pass `v1_base_url` and `v1_user_token` to the client constructor or `from_env`. - `upload_document` requires a non-empty `filename`. On the async client the multipart POST is awaited like any other call, so the event loop is not blocked. - `download_document_content` returns `bytes` and raises on non-200 responses. - `mime_type` defaults to `application/octet-stream` when omitted on `upload_document`. -- All methods are async; always `await` them. +- The snippets above use `AsyncGlpiClient`, so every call is awaited. The same methods on the synchronous `GlpiClient` are plain blocking calls -- drop the `await`. - The `delete_document(force=True)` flag permanently deletes; omit (or `False`) to move to the trash. \ No newline at end of file diff --git a/skills/glpi-knowledge-base/SKILL.md b/skills/glpi-knowledge-base/SKILL.md new file mode 100644 index 0000000..ebe30fa --- /dev/null +++ b/skills/glpi-knowledge-base/SKILL.md @@ -0,0 +1,168 @@ +--- +name: glpi-knowledge-base +description: "Search, read, create, update, and delete GLPI knowledge base articles, categories, comments, and revisions with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient, and the GetKBArticle/PostKBArticle/GetKBCategory/GetKBArticleComment/GetKBArticleRevision models. Use for GLPI knowledge base content, FAQ articles, article categories, article comments, article revision history, or assigning categories to a KB article." +license: MIT +compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and — for category writes only — a legacy v1 session (v1_base_url + v1_user_token)." +metadata: + package: glpi-python-client + version: "0.4.1" +--- + +# GLPI Knowledge Base +> The snippets below use `AsyncGlpiClient` (`async with` + `await`). Every method shown also exists on the synchronous `GlpiClient` with the same signature -- replace `async with` with `with`, drop the `await` keyword, and skip the surrounding `async def`/`asyncio.run` scaffolding. + +The GLPI knowledge base lives under `/Knowledgebase/*` on the v2 API and covers four resources: articles, their categories, article comments, and article revisions. Eighteen methods expose them, present on both `GlpiClient` and `AsyncGlpiClient` with identical signatures. One operation -- assigning categories to an article -- is not a v2 call at all and needs a legacy v1 session; everything else in the family is pure v2. + +## Procedure + +1. Create a client from the `glpi-client-setup` skill. Add `v1_base_url` and `v1_user_token` **only** if you will write article categories. +2. Articles: `search_kb_articles(rsql_filter, limit=..., start=..., sort=..., language=...)` for lists and `get_kb_article(article_id)` for one. Write with `create_kb_article(PostKBArticle(...))` (returns the new id), `update_kb_article(article_id, PatchKBArticle(...))` and `delete_kb_article(article_id, force=...)` (both return `None`). +3. Article categories: `set_kb_article_categories(article_id, category_ids)`. The ids **replace** the whole set; an empty sequence clears it. Ids are not validated against the server -- an unknown id is simply not linked. +4. Categories: `search_kb_categories(...)` (same parameters as the article search), `get_kb_category(category_id)`, `create_kb_category(PostKBCategory(...))`, `update_kb_category(category_id, PatchKBCategory(...))`, `delete_kb_category(category_id, force=...)`. `completename` and `level` are server-managed and absent from the write models. +5. Comments: `list_kb_article_comments(article_id)`, `get_kb_article_comment(article_id, comment_id)`, `create_kb_article_comment(article_id, PostKBArticleComment(...))` (returns the new id), `update_kb_article_comment(article_id, comment_id, PatchKBArticleComment(...))`, `delete_kb_article_comment(article_id, comment_id, force=...)`. The parent article comes from the URL, so `PostKBArticleComment` has no `kbarticle` field. +6. Revisions, read-only: `list_kb_article_revisions(article_id, language=...)` then `get_kb_article_revision(article_id, revision, language=...)`. +7. Refetch with `get_kb_article()` when the task needs a populated model after a write. + +## Examples + +Create a category and a categorised article. `v1_base_url`/`v1_user_token` are required here **only** because the article carries `categories`: + +```python +from glpi_python_client import AsyncGlpiClient, IdNameRef, PostKBArticle, PostKBCategory + +async with AsyncGlpiClient( + glpi_api_url="https://glpi.example.com/api.php/v2", + client_id="oauth-client-id", + client_secret="oauth-client-secret", + v1_base_url="https://glpi.example.com/api.php/v1", + v1_user_token="legacy-user-token", +) as client: + category_id = await client.create_kb_category( + PostKBCategory(name="Network", comment="Networking runbooks") + ) + # content/description are Markdown here and HTML on the wire. + article_id = await client.create_kb_article( + PostKBArticle( + name="Reset a password", + content="Run **passwd**, then check `logs`.", + description="A *short* summary.", + is_faq=True, + categories=[IdNameRef(id=category_id)], # IdRef would not validate + ) + ) +``` + +Recover from the non-atomic create. There is no rollback: on failure the article exists and its id is only available from the message text: + +```python +import re + +from glpi_python_client import AsyncGlpiClient, PostKBArticle + + +async def create_with_categories(client: AsyncGlpiClient, article: PostKBArticle) -> int: + """Create `article`, re-linking its categories if the fallback failed. + + The retry presupposes a configured v1 session: when the missing session + is itself the cause, `set_kb_article_categories` raises the same + `RuntimeError` again. It helps only for a transient legacy failure. + """ + try: + return await client.create_kb_article(article) + except RuntimeError as exc: # plain builtin, NOT a GlpiError + match = re.search(r"KB article (\d+) was created", str(exc)) + if match is None: + raise + article_id = int(match.group(1)) + # The v2 article is intact; retry only the legacy category link. + # Derive the ids from the model -- they are the same list that + # triggered the fallback, so the retry cannot link nothing. + await client.set_kb_article_categories( + article_id, [c.id for c in article.categories or [] if c.id is not None] + ) + return article_id +``` + +Search, and disambiguate an empty result. `language` is a query parameter on both search helpers: + +```python +from glpi_python_client import GlpiNotFoundError + +faq = await client.search_kb_articles( + "is_faq==1", limit=25, start=0, sort="date_mod desc", language="fr_FR" +) +categories = await client.search_kb_categories("name==Network", limit=10) +print([(c.id, c.completename) for c in categories]) + +# A search never raises on a 4xx -- it returns []. To tell 'no matches' +# from 'this GLPI serves no /Knowledgebase routes', probe an article you +# know exists. Only the SUCCEEDING branch is informative: a 404 is raised +# both by an absent route and by an absent id, so it proves nothing. +if not faq: + known_article_id = 1 # an article known to exist on this instance + try: + await client.get_kb_article(known_article_id) + except GlpiNotFoundError: + print("inconclusive: no such article, or no /Knowledgebase routes") + else: + print("the endpoint is served -- the filter simply matched nothing") +``` + +Comments and revisions on one article: + +```python +from glpi_python_client import PatchKBArticleComment, PostKBArticleComment + +# `comment` is plain text -- no Markdown conversion, unlike article content. +comment_id = await client.create_kb_article_comment( + 5, PostKBArticleComment(comment="Confirmed on GLPI 11.") +) +await client.update_kb_article_comment( + 5, comment_id, PatchKBArticleComment(comment="Edited.") +) +one = await client.get_kb_article_comment(5, comment_id) +for listed in await client.list_kb_article_comments(5): + print(listed.id, listed.comment) +await client.delete_kb_article_comment(5, comment_id, force=True) + +# `language` is a PATH SEGMENT here: Knowledgebase/Article/5/fr_FR/Revision +revisions = await client.list_kb_article_revisions(5, language="fr_FR") +if revisions and revisions[0].revision is not None: + revision = await client.get_kb_article_revision( + 5, revisions[0].revision, language="fr_FR" # the revision NUMBER + ) + print(revision.revision, revision.content) # content comes back as Markdown +``` + +Category maintenance is pure v2 -- no legacy session is involved: + +```python +from glpi_python_client import IdNameRef, PatchKBCategory, PostKBCategory + +parent_id = await client.create_kb_category(PostKBCategory(name="IT")) +child_id = await client.create_kb_category( + PostKBCategory(name="Network", parent=IdNameRef(id=parent_id), is_recursive=True) +) +await client.update_kb_category(child_id, PatchKBCategory(comment="Moved")) +category = await client.get_kb_category(child_id) +print(category.completename, category.level) # both server-managed + +await client.delete_kb_category(child_id, force=True) # omit force to trash it +await client.delete_kb_article(42, force=True) +``` + +## Gotchas + +- Assigning categories to an article is the only KB operation that needs the legacy v1 session, and it is not a v2 call at all: `set_kb_article_categories` issues a legacy `PUT KnowbaseItem/{article_id}` with the body `{"input": {"_categories": [ids]}}`. Every category CRUD call, every comment call and every revision call is pure v2. +- When no v1 session is configured, that path raises a plain builtin `RuntimeError`, **not** a `GlpiError` -- `except GlpiError:` will not catch it. The message is `GLPI knowledge base category assignments require the legacy v1 session to be configured (set v1_base_url and v1_user_token).` The session is only built when *both* `v1_base_url` and `v1_user_token` are supplied; exactly one of the pair raises `GlpiValidationError` at client construction. +- `create_kb_article` is not atomic and there is no rollback. If the v2 POST succeeds and the category fallback then fails, the article stays on the server: you get an error *and* an uncategorised article. The failure is a `RuntimeError` shaped `KB article 88 was created but assigning its categories failed: ...`, chaining the original as `__cause__` -- so the new id is recoverable only from the message text, and `except GlpiValidationError:` around a create catches nothing. +- `update_kb_article` does not wrap the failure the way create does -- the raw error propagates (`GlpiValidationError` for a category reference with no `id`, `RuntimeError` for a missing v1 session, `GlpiStatusError` for a legacy non-success). The v2 field changes are already applied and are not reverted. +- `categories=[]` means opposite things on create and update. On create it is skipped entirely: no v1 call, no v1 session needed. On update it *clears* every category, which is a legacy write and does need v1. Only `categories=None` (the default) is a no-op on both. +- `categories` is still sent inside the v2 POST/PATCH body and GLPI silently ignores it; the body is never stripped. So a create-with-categories against a client with no v1 session yields an uncategorised article *and* an error, not a clean rejection -- and no code path persists a category through v2 alone. +- **Every `search_*` helper in the library swallows 4xx and returns `[]`. This is a library-wide contract, not a KB peculiarity.** `_resource_list` checks the response status only when the caller passes a `failure_message`, and none of the seven searches -- `search_kb_articles`, `search_kb_categories`, `search_tickets`, `search_users`, `search_locations`, `search_entities`, `search_documents` -- passes one. A GLPI error body is not a JSON list, so it is coerced to `[]`. Every `list_*` and `get_*` helper does pass a `failure_message` and raises normally: here that is `list_kb_article_comments`, `list_kb_article_revisions`, `get_kb_article`, `get_kb_category` and `get_kb_article_revision` (`GlpiNotFoundError` on a 404). So an empty list from a search means "no matches" *or* "bad RSQL filter" *or* "403" *or* "this GLPI serves no `/Knowledgebase` routes at all" (they need High-Level API >= 2.2.0), indistinguishably; an empty list from a list helper is unambiguous. Never treat `[]` from a search as proof a record is absent before creating one. +- `language` has two different mechanics in this family. On `search_kb_articles`/`search_kb_categories` it is a **query parameter**. On `list_kb_article_revisions`/`get_kb_article_revision` it is a **path segment** between the id and `Revision` (`Knowledgebase/Article/5/fr_FR/Revision`). `get_kb_article`, the comment helpers and every write helper take no `language` at all; they inherit the client-level value, sent as `Accept-Language` (default `en_GB`). +- KB write models use `IdNameRef` for every foreign key -- `categories[]`, `entity`, `user`, `parent` -- not `IdRef`. Passing `IdRef(id=4)` raises a pydantic `ValidationError`. (`GetKBArticleComment.parent` is the one KB field genuinely typed `IdRef`, and it is read-only.) +- Article `content`/`description` and revision `content` are Markdown on the Python side and HTML on the wire; the conversion is automatic, so never author HTML. Comment `comment` is a plain `str` with no conversion at all -- the inconsistency is real, not an omission here. +- `force` on `delete_kb_article`, `delete_kb_category` and `delete_kb_article_comment` is keyword-only and is serialised into the JSON request **body** via the matching `Delete*` model, not sent as a query parameter. `force=True` deletes permanently; omitting it or passing `False` moves the record to the GLPI trash. +- Revisions are read-only: there is no create/update/delete helper and no Post/Patch/Delete revision model. A revision appears as a side effect of updating an article. `get_kb_article_revision(article_id, revision)` takes the revision **number** (`GetKBArticleRevision.revision`), not the row `id` -- the two differ on the model. +- `GetKBArticle.revisions` and `.translations` hold two *different* private ref classes (leading underscore, not exported from the package root). Read their attributes; never import them. The two field sets are not interchangeable: a `revisions` entry has `.id`, `.revision`, `.language`, `.date`, while a `translations` entry has `.id`, `.language`, `.name`. `revisions[0].name` raises `AttributeError` -- the models allow unknown keys from the server, but that does not synthesise an attribute that was never sent. diff --git a/skills/glpi-plugin-fields/SKILL.md b/skills/glpi-plugin-fields/SKILL.md new file mode 100644 index 0000000..9f424a4 --- /dev/null +++ b/skills/glpi-plugin-fields/SKILL.md @@ -0,0 +1,199 @@ +--- +name: glpi-plugin-fields +description: "Discover and read/write GLPI Fields-plugin custom fields with the synchronous glpi_python_client.GlpiClient or the asynchronous AsyncGlpiClient — list_plugin_fields_containers, list_plugin_fields_fields, list_item_plugin_field_rows, create_item_plugin_field_row, update_item_plugin_field_row, and the Ticket-only get_ticket_custom_fields/set_ticket_custom_fields. Use for GLPI custom fields, the Fields plugin, per-instance extra ticket attributes, or reading a ticket's custom-field values." +license: MIT +compatibility: "Requires Python 3.10+, glpi-python-client, the GLPI Fields plugin installed server-side, and a legacy v1 session (v1_base_url + v1_user_token) — every method in this family goes over the v1 API." +metadata: + package: glpi-python-client + version: "0.4.1" +--- + +# GLPI Plugin Fields +> The snippets below use `AsyncGlpiClient` (`async with` + `await`). Every method shown also exists on the synchronous `GlpiClient` with the same signature -- replace `async with` with `with`, drop the `await` keyword, and skip the surrounding `async def`/`asyncio.run` scaffolding. + +The GLPI `Fields` plugin adds user-defined custom fields to any itemtype: a *container* is a block of fields attached to one or more itemtypes, and each container stores one value row per item. None of it exists in the GLPI v2 contract, so all seven methods -- `list_plugin_fields_containers`, `list_plugin_fields_fields`, `list_item_plugin_field_rows`, `create_item_plugin_field_row`, `update_item_plugin_field_row`, and the Ticket-only `get_ticket_custom_fields`/`set_ticket_custom_fields` -- talk to the legacy v1 REST API. They are present on both `GlpiClient` and `AsyncGlpiClient` with identical signatures. + +Two constraints decide whether any of it works, so settle them first: + +- **Every one of the seven methods needs the legacy v1 session.** Build the client with `v1_base_url` *and* `v1_user_token` (or `GLPI_V1_BASE_URL`/`GLPI_V1_USER_TOKEN` for `from_env`). Without them the call raises a plain builtin `RuntimeError`, **not** a `GlpiError` -- `except GlpiError:` will not catch it. The message is `GLPI Fields plugin helpers require the legacy v1 session to be configured (set v1_base_url and v1_user_token).` Supplying exactly one of the pair raises `GlpiValidationError` at client construction instead. +- **Discovery is mandatory, not advisory.** Container and field names are chosen by whoever configured the plugin on that instance, and the plugin itself is optional. There is nothing to hardcode: read `container.name` and `field.name` off the server and reuse them verbatim. + +```text +list_plugin_fields_containers(itemtype) → list_plugin_fields_fields(container_id) + → list_item_plugin_field_rows(itemtype, items_id, container_name) + → create_item_plugin_field_row(...) / update_item_plugin_field_row(...) +``` + +The family takes two different `values` shapes and they are not interchangeable -- flat for the low-level row helpers, nested for the two Ticket helpers: + +```python +# create_item_plugin_field_row / update_item_plugin_field_row -- FLAT, +# one level, keyed by field.name: +values = {"extrainfofield": "

x

"} + +# get_ticket_custom_fields / set_ticket_custom_fields -- NESTED, +# outer key is container.name: +values = {"extrainfo": {"extrainfofield": "

new

"}} +``` + +## Procedure + +1. Create a client from the `glpi-client-setup` skill, adding `v1_base_url` and `v1_user_token`. +2. Discover containers with `list_plugin_fields_containers(itemtype="Ticket")`. `itemtype` is optional and filtered client-side. An uninstalled plugin does **not** return `[]`: it raises `GlpiStatusError` with `.status_code == 400` and `ERROR_RESOURCE_NOT_FOUND_NOR_COMMONDBTM` in `.response_text`. An installed-but-unused plugin returns `[]`. +3. Discover fields with `list_plugin_fields_fields(container_id=container.id)`. `field.name` is the key you put in a `values` dict; `field.type` (`string`, `text`, `richtext`, `dropdown`, `yesno`, `date`, `datetime`, `number`, `url`, `header`) tells you the value format. +4. Read the stored row with `list_item_plugin_field_rows(itemtype, items_id, container_name)` -- ordinary parameters, passable positionally or by keyword. It returns zero or one `GetPluginFieldsValueRow`; the values live in `row.extra_payload` and `row.id` is the `row_id` an update needs. +5. Write with `update_item_plugin_field_row(itemtype=..., container_name=..., row_id=..., values=...)` when a row exists, otherwise `create_item_plugin_field_row(itemtype=..., items_id=..., container_id=..., container_name=..., values=..., entities_id=...)`, which returns the new row id. Both are keyword-only. +6. On Tickets only, `get_ticket_custom_fields(ticket_id)` and `set_ticket_custom_fields(ticket_id, values)` fold steps 2-5 into one call each, using the nested mapping. For every other itemtype, drive steps 2-5 yourself. + +## Examples + +Discovery, including the branch that tells "plugin absent" apart from "plugin configured but empty": + +```python +import asyncio + +from glpi_python_client import AsyncGlpiClient, GlpiStatusError + +# GLPI answers 400 with this marker when the itemtype in the URL is not a +# known CommonDBTM subclass -- which is what an uninstalled plugin looks +# like from the outside. +PLUGIN_ABSENT = "ERROR_RESOURCE_NOT_FOUND_NOR_COMMONDBTM" + + +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", + v1_base_url="https://glpi.example.com/api.php/v1", + v1_user_token="legacy-user-token", + ) as client: + try: + containers = await client.list_plugin_fields_containers(itemtype="Ticket") + except GlpiStatusError as exc: + if exc.status_code == 400 and PLUGIN_ABSENT in (exc.response_text or ""): + print("the GLPI Fields plugin is not installed on this instance") + return + raise + + for container in containers: + if container.id is None or not container.name: + continue + # container.name is the internal key you reuse verbatim; + # container.label is the UI label and is never a valid key. + print(container.id, container.name, container.label, container.is_active) + fields = await client.list_plugin_fields_fields(container_id=container.id) + for field in fields: + print(" ", field.name, field.type, field.is_active, field.is_readonly) + + +asyncio.run(main()) +``` + +Read one ticket's custom fields. The result is the nested mapping, and a container with nothing saved is missing from it entirely: + +```python +from glpi_python_client import AsyncGlpiClient + + +async def ticket_note(client: AsyncGlpiClient, ticket_id: int) -> str | None: + """Return one ticket's `extrainfo.extrainfofield` value, if it has one.""" + values = await client.get_ticket_custom_fields(ticket_id) + # {'extrainfo': {'extrainfofield': '

test

'}} + + # Containers with no persisted row are ABSENT, not empty: `.get`, never []. + note = values.get("extrainfo", {}).get("extrainfofield") + + # The inner dict is the row's extra_payload -- dynamic columns only, so + # it carries no row id. For that, drop to the low-level row listing: + rows = await client.list_item_plugin_field_rows("Ticket", ticket_id, "extrainfo") + if rows: + print(rows[0].id, rows[0].items_id, rows[0].extra_payload) + return note +``` + +Write to a ticket with the high-level upsert. Values go over the wire verbatim, so a `richtext` field takes raw HTML: + +```python +from glpi_python_client import GlpiValidationError + +await client.set_ticket_custom_fields( + 1234, {"extrainfo": {"extrainfofield": "

Handled by the NOC shift

"}} +) +# GET containers, GET fields, GET rows, then PUT {"input": {"id": 1, ...}} +# -- or POST with items_id/itemtype/plugin_fields_containers_id if no row exists. + +await client.set_ticket_custom_fields(1234, {}) # empty mapping: zero HTTP calls + +# Container names are matched EXACT-CASE against container.name. +try: + await client.set_ticket_custom_fields(1234, {"ExtraInfo": {"extrainfofield": "x"}}) +except GlpiValidationError as exc: + print(exc) # Unknown plugin-fields container(s) for Ticket: ExtraInfo + +# The call is not atomic across containers, so write one per call when a +# rejected field name must not leave the earlier container already written. +payload = { + "extrainfo": {"extrainfofield": "

a

"}, + "secondary": {"othercolumn": "b"}, +} +for container_name, columns in payload.items(): + await client.set_ticket_custom_fields(1234, {container_name: columns}) +``` + +Upsert on any other itemtype, with the flat `values` dict and the low-level helpers: + +```python +from glpi_python_client import AsyncGlpiClient + + +async def upsert_plugin_field_row( + client: AsyncGlpiClient, + itemtype: str, # "Computer", "Problem", "Change", ... + items_id: int, + container_id: int, # container.id -- create needs it in the body + container_name: str, # container.name -- it builds the URL itemtype + values: dict[str, object], # FLAT: {field.name: value} +) -> int: + """Update this container's row for one item, creating it when absent.""" + # Positional is allowed here (ordinary parameters); the two writers + # below are keyword-only and raise TypeError if called positionally. + rows = await client.list_item_plugin_field_rows(itemtype, items_id, container_name) + if rows and rows[0].id is not None: + await client.update_item_plugin_field_row( + itemtype=itemtype, + container_name=container_name, + row_id=rows[0].id, + values=values, # only these columns are touched + ) + return rows[0].id + return await client.create_item_plugin_field_row( + itemtype=itemtype, + items_id=items_id, + container_id=container_id, + container_name=container_name, + values=values, + # `entities_id` is create-only and is omitted from the body unless + # you pass it, letting the server apply its default scope. Do NOT + # hardcode 0 here: 0 is not None, so it would pin every row you + # create to entity 0. Pass a real entity id only when you mean one. + ) +``` + +## Gotchas + +- **The two `values` shapes are the main trap.** `create_item_plugin_field_row` and `update_item_plugin_field_row` take a **flat** `dict[str, object]` of field name to value -- `values={"extrainfofield": "

x

"}`. `get_ticket_custom_fields` returns, and `set_ticket_custom_fields` accepts, a **nested** `dict[str, dict[str, Any]]` keyed by container name -- `{"extrainfo": {"extrainfofield": "

new

"}}`. Passing the nested shape to the low-level create sends the inner dict as a column value; passing the flat shape to `set_ticket_custom_fields` makes field names look like container names and raises `GlpiValidationError: Unknown plugin-fields container(s) for Ticket: ...`. +- `container_name` and `container_id` are not interchangeable, and `create_item_plugin_field_row` needs **both**. The name builds the URL itemtype, lowercased (`Ticket` + `extrainfo` gives `PluginFieldsTicketextrainfo`, and `Ticket/1234/PluginFieldsTicketextrainfo` for the row list). The id is a body column, `plugin_fields_containers_id`, and it is also what `list_plugin_fields_fields(container_id=...)` filters on. `update_item_plugin_field_row` needs only the name plus a `row_id`, which identifies the record on its own. `list_item_plugin_field_rows` needs only the name too, and has **no** `row_id` parameter -- its signature is exactly `(itemtype, items_id, container_name)`; it is what you call *to obtain* a `row_id`. +- Container-name matching in `set_ticket_custom_fields` is **exact-case** against `container.name`, while the URL derivation lowercases. So `{"ExtraInfo": {...}}` raises `GlpiValidationError` when the container is actually named `extrainfo`, even though the derived URL would have been identical. Copy `container.name` verbatim from discovery; never retype it and never substitute `container.label`. +- `set_ticket_custom_fields` is **not atomic across multiple containers**, despite a docstring claiming validation happens "before any write to keep the call atomic". Only the unknown-*container* check runs up front for the whole payload; the unknown-*field* check runs per container inside the write loop. With two containers where the second has a typo, the first is already written when the error raises. Write one container per call when you need all-or-nothing. +- `get_ticket_custom_fields` returns **only `extra_payload`** -- every *undeclared* key of the row, not a curated list of the plugin's fields. So the row's `id`, `items_id`, `itemtype`, `plugin_fields_containers_id` and `entities_id` are absent (use `list_item_plugin_field_rows` when you need the `row_id`), and any other bookkeeping column the v1 server returns appears alongside real values. Intersect against `list_plugin_fields_fields` names if you need only declared fields. +- A container that has never had a value saved for that ticket is **silently absent** from the `get_ticket_custom_fields` result -- you do not get `{"container": {}}`. Use `result.get(name, {})`, never `result[name]`. An empty overall dict is **ambiguous**: `get_ticket_custom_fields` builds its result by skipping every container with no persisted row, so `{}` comes back both when the instance declares no Ticket containers at all and when it declares several but this ticket has saved nothing in any of them. The return value cannot tell you which -- call `list_plugin_fields_containers(itemtype="Ticket")` if you need to know. +- Both discovery listings fetch **one fixed page, `range=0-999`**, with no pagination and no server-side filtering. The `itemtype` and `container_id` narrowing happens client-side *after* that cap, so an instance with more than 1000 containers or field declarations silently loses the tail -- possibly including the container you are looking for. +- Neither listing filters on `is_active`, so **disabled containers and disabled or read-only fields come back from discovery looking exactly like live ones**. `set_ticket_custom_fields` accepts a field whose `is_readonly` is `True`, because its guard only checks that the name is declared. Check `container.is_active`, `field.is_active` and `field.is_readonly` yourself. +- Values are transmitted **verbatim -- there is no HTML/Markdown conversion on this path**, unlike ticket and KB article content. A `richtext` field takes raw HTML (`"

test

"`). Convert yourself if you want Markdown: `GlpiContentConverter` is not exported from the package root, import it from `glpi_python_client.content`. +- The two convenience helpers are **Ticket-only** -- the itemtype is hardcoded. There is no `get_item_custom_fields` and no `set_item_custom_fields`. For Computer, Problem, Change and the rest, drive the generic row helpers. +- Parameter-passing style is inconsistent across the family, and only one half of it is a rule. `list_item_plugin_field_rows(itemtype, items_id, container_name)` declares ordinary `POSITIONAL_OR_KEYWORD` parameters, so both `("Ticket", 1234, "extrainfo")` and `(itemtype="Ticket", items_id=1234, container_name="extrainfo")` are legal -- the examples above pass them positionally by choice, not by requirement. `create_item_plugin_field_row` and `update_item_plugin_field_row` are genuinely **keyword-only** (`*` in the signature): calling either writer positionally is a `TypeError`. +- Error taxonomy on the write path: an unknown container name or an unknown field name raises `GlpiValidationError`; a container the server returned without an `id`, or a create whose v1 reply carries no numeric row id, raises `GlpiProtocolError`. Both inherit `ValueError`. A missing v1 session raises a plain `RuntimeError`, and a non-success v1 status raises `GlpiStatusError` (with `.status_code`, `.url` and `.response_text`). +- `entities_id` exists on **create only**, and is left out of the body unless explicitly passed. `update_item_plugin_field_row` has no such parameter -- its body is exactly `{"input": {"id": row_id, **values}}`. `set_ticket_custom_fields` never passes it, so rows it creates take the GLPI server's default scope. +- Cost is **linear and sequential**; there is no concurrent fan-out in this family. `get_ticket_custom_fields` costs one container list plus one row list per Ticket container. `set_ticket_custom_fields` costs one container list plus, per container in the payload, one field list, one row list and one write -- and it re-reads discovery on every call, with no caching. Cache the container and field listings yourself when writing many tickets in a loop. +- `PostPluginFieldsValueRow` is exported at the package root but is **dead surface for callers**: no client method takes or returns it. `create_item_plugin_field_row` assembles the request body itself from a plain dict. Building this model and handing it to the client neither type-checks nor works. diff --git a/skills/glpi-reporting-and-context/SKILL.md b/skills/glpi-reporting-and-context/SKILL.md index 946a57d..db93bce 100644 --- a/skills/glpi-reporting-and-context/SKILL.md +++ b/skills/glpi-reporting-and-context/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read tickets, tasks, users, entities, and timeline records." metadata: package: glpi-python-client - version: "0.4.0" + version: "0.4.1" --- # GLPI Reporting And Context @@ -14,10 +14,10 @@ metadata: Custom helpers on `GlpiClient` build on top of the contract-aligned API mixins: - `get_ticket_context(ticket_id)` returns one `GlpiTicketContext` bundling the primary ticket together with its tasks, followups, solutions, and timeline document links. The five underlying calls are independent and are issued through the library's internal `gather` helper, so they fan out concurrently on `AsyncGlpiClient` and run one after another on `GlpiClient`. Expect the synchronous call to take roughly five round trips. -- `get_ticket_statistics(...)` returns ticket counts grouped by entity, status, priority, and type over an ISO date window applied to GLPI `date_creation`. Accepts `entity_id`, `entity_name` (substring match resolved via `search_entities`), and `extra_filter` (raw RSQL AND-joined with the window). +- `get_ticket_statistics(...)` returns ticket counts grouped by entity, status, priority, and type over an ISO date window applied to GLPI `date_creation`. Accepts `entity_id`, `entity_name` (substring match resolved via `search_entities`), and `extra_filter` (raw RSQL AND-joined with the window). **It aggregates at most 200 tickets**: it issues a single `search_tickets(..., limit=200)` call and does not paginate, so any window matching more than 200 tickets is silently truncated and the counts are wrong. For windows that can exceed 200 tickets, page `iter_search_tickets` yourself and aggregate, or narrow the window/entity until the total is under the cap. The same 200-row cap applies to the `entity_name` lookup, so a substring matching more than 200 entities is truncated too. Every v2 ticket search in the statistics layer also pins `is_deleted==false`, so soft-deleted ("trashed") tickets are excluded from these aggregates and from `get_task_durations` / `get_user_activity` — a raw `search_tickets` call with the same filter will return more rows, because v2 includes the trash by default. - `get_task_statistics(ticket_ids)` returns task duration totals grouped by user and ticket for a caller-supplied list of ticket IDs. -- `get_task_durations(...)` is a higher-level helper that internally iterates `iter_search_tickets` with a date/entity/user filter, computes per-user and per-entity duration totals, and optionally returns a flat per-task list when `return_task_details=True`. -- `get_user_activity(...)` aggregates per-user activity (tickets as technician, tickets as recipient, task durations) over a date window; resolves users by `user_id`, `username`, `realname`, or `firstname` and merges users that share the same display key. +- `get_task_durations(...)` is a higher-level helper that internally iterates `iter_search_tickets` with a date/entity RSQL filter, computes per-user and per-entity duration totals, and optionally returns a flat per-task list when `return_task_details=True`. `user_id` is **not** part of the RSQL filter: the v2 `team` array cannot be joined by the RSQL engine, so the ticket ids for that actor are resolved through the legacy v1 search engine (searchOptions 5 `Technicien` and 4 `Demandeur`, OR-ed) and intersected client-side. Passing `user_id` therefore requires a client built with `v1_base_url` + `v1_user_token`, or the call raises `RuntimeError`; a non-positive or non-`int` id raises `GlpiValidationError`. Once the matched ticket set reaches 25 tickets and a v1 session is present, task aggregation switches from the per-ticket v2 fan-out to one bulk sweep of the v1 `TicketTask` collection (paged 1000 rows at a time); the aggregate is identical either way. +- `get_user_activity(...)` aggregates per-user activity (tickets as technician, tickets as recipient, task durations) over a date window; resolves users by `user_id`, `username`, `realname`, or `firstname` and merges users that share the same display key. The technician and recipient counts have **no v2 equivalent** and are resolved through the legacy v1 search engine (searchOption 5 `Technicien`, 4 `Demandeur`), intersected with the ids returned by a single walk of the date window. This helper therefore **always** requires a client built with `v1_base_url` + `v1_user_token` and raises `RuntimeError` naming the missing options when they are absent. - `iter_search_tickets`, `iter_search_users`, `iter_search_entities` yield successive `list[...]` batches of contract models and stop on the first short batch. They handle pagination so callers do not manage `start` cursors manually. Returned identifiers are raw GLPI numeric values; resolve them with the appropriate `search_*` helpers when human-readable labels are needed. @@ -25,12 +25,12 @@ Returned identifiers are raw GLPI numeric values; resolve them with the appropri ## Procedure 1. Create a client (`GlpiClient` or `AsyncGlpiClient`) with the correct entity/profile scope. -2. For one ticket, call `await client.get_ticket_context(ticket_id)` and read `bundle.ticket`, `bundle.tasks`, `bundle.followups`, `bundle.solutions`, and `bundle.documents`. +2. For one ticket, call `await client.get_ticket_context(ticket_id)` and read `bundle.ticket`, `bundle.tasks`, `bundle.followups`, `bundle.solutions`, and `bundle.documents`. To render the whole bundle as one Markdown transcript (ticket title, subtitle metadata, description, chronologically sorted timeline, linked documents), call `bundle.to_markdown()`. Pass a `TicketMarkdownOptions` to drop sections or metadata, e.g. `bundle.to_markdown(TicketMarkdownOptions(include_documents=False, show_dates=False))`; all 17 flags default to `True`, so a bare `to_markdown()` emits everything. Both `GlpiTicketContext` and `TicketMarkdownOptions` are exported from `glpi_python_client`. 3. For ticket counts, call `await client.get_ticket_statistics(start_date=..., end_date=..., default_days=..., entity_id=..., entity_name=..., extra_filter=...)`. All keyword arguments are optional; the default window is the last 30 days ending today. 4. For task duration totals on a known ticket list, call `await client.get_task_statistics(ticket_ids)`. For an end-to-end "duration over a window with filters" report, call `await client.get_task_durations(...)` instead; it gathers the ticket IDs internally. 5. For a per-user activity report, call `await client.get_user_activity(username=..., start_date=..., end_date=...)`. Supply at least one of `user_id`, `username`, `realname`, `firstname`. 6. For memory-bounded pagination over large result sets, iterate `iter_search_tickets` / `iter_search_users` / `iter_search_entities` with `async for batch in client.iter_search_*(...): ...`. -7. Use the public enums (`GlpiTicketStatus`, `GlpiTicketType`, `GlpiPriority`, ...) when composing additional RSQL filters. +7. Use the public enums when composing additional RSQL filters. There are eight, all exported from `glpi_python_client`, and this is the whole list: `GlpiTicketStatus` (`NEW = 1`, `ASSIGNED = 2`, `PLANNED = 3`, `PENDING = 4`, `SOLVED = 5`, `CLOSED = 6`, `VALIDATION = 10`), `GlpiTicketType` (`INCIDENT = 1`, `REQUEST = 2`), `GlpiPriority` (`VERY_LOW = 1` .. `VERY_HIGH = 5`, `MAJOR = 6`), `GlpiGlobalValidation` and `GlpiSolutionStatus` (both `NONE = 1`, `WAITING = 2`, `ACCEPTED = 3`, `REFUSED = 4`), `GlpiTaskState` (`INFORMATION = 0`, `TODO = 1`, `DONE = 2`), `GlpiTimelinePosition` (`INVALID = -1`, `NONE = 0`, `LEFT = 1`, `RIGHT = 2`, `LEFT_BIG = 3`, `RIGHT_BIG = 4`) and `GlpiUserAuthType` (`LOCAL = 1`, `LDAP = 2`, `MAIL = 3`, `CAS = 4`, `X509 = 5`, `EXTERNAL = 6`). All eight subclass `GlpiEnum`, which is exported too and is a plain `IntEnum` with two conveniences for filter building: `.glpi_id` returns the number, and `.rsql_equals("status")` returns the RSQL fragment, so `GlpiTicketStatus.NEW.rsql_equals("status")` replaces the hand-written `f"status=={int(GlpiTicketStatus.NEW)}"` below. ## Examples @@ -57,8 +57,10 @@ stats = await client.get_ticket_statistics( print(stats["entities"]) ``` -Aggregate task durations across the open tickets of an entity using -`get_task_durations` (no manual ticket-list gathering): +Aggregate task durations for one entity over the default 30-day window using +`get_task_durations` (no manual ticket-list gathering). `user_id` resolves +through the legacy v1 search engine, so the client must carry `v1_base_url` + +`v1_user_token` or this call raises `RuntimeError`: ```python durations = await client.get_task_durations( @@ -116,8 +118,8 @@ print(f"processed {total} tickets") ``` Measured against a 50 ms server, a fan-out of 16 took 350 ms unbounded and 108 ms capped at 16. This is a property of the HTTP layer, not of this library, and there is no version to upgrade to. -- `get_ticket_statistics`, `get_task_durations`, and `get_user_activity` validate their date window locally and raise `ValueError` when `default_days < 1` or `start_date > end_date`. The window is applied to `date_creation` server-side. +- `get_ticket_statistics`, `get_task_durations`, and `get_user_activity` validate their date window locally and raise `GlpiValidationError` when `default_days < 1`, when `start_date` / `end_date` is not a valid ISO `YYYY-MM-DD` string, or when `start_date > end_date`. `GlpiValidationError` is exported from the package root and inherits `ValueError`, so `except ValueError` still catches it. The window is applied to `date_creation` server-side. - `get_task_statistics(ticket_ids=[])` returns zeroed totals without any HTTP call. `get_task_durations` likewise returns zeroed totals when no tickets match the filter, and short-circuits with zeros when `entity_name` resolves to no entities. -- `get_user_activity` raises `ValueError` when no identifier is supplied and when the criteria match no users. Multiple users with the same `f"{firstname} {realname}"` display key are merged into one bucket. -- Returned counter keys are raw GLPI numeric identifiers (entity IDs, status numbers, user IDs as strings) for stable behaviour. Resolve to labels with `search_entities` / `get_user` or the appropriate enum. +- `get_user_activity` raises `GlpiValidationError` (a `ValueError` subclass, exported from the package root) when no identifier is supplied and when the criteria match no users. Multiple users with the same `f"{firstname} {realname}"` display key are merged into one bucket. +- Counter keys are raw GLPI numeric identifiers **only where a numeric id exists**: entity bucket keys and `by_status` keys are the numeric id as a string (falling back to the name, then `"unknown"` / `"UNKNOWN"`), and `duration_by_user` keys are user IDs as strings. `by_priority` and `by_type` keys are instead the **enum member names** — `"VERY_LOW"`, `"LOW"`, `"MEDIUM"`, `"HIGH"`, `"VERY_HIGH"`, `"MAJOR"` for `GlpiPriority` (GLPI's priority scale has six levels; `MAJOR = 6` exists even though the published contract advertises five), and `"INCIDENT"` / `"REQUEST"` for `GlpiTicketType`, with `"UNKNOWN"` when the field is absent. Resolve the numeric ids to labels with `search_entities` / `get_user`. - Extra ticket fields (plugin keys, custom dropdowns) flow through `ticket.extra_payload` and are visible on `context.ticket` as well. \ No newline at end of file diff --git a/skills/glpi-team-members/SKILL.md b/skills/glpi-team-members/SKILL.md index 871e832..f6777e9 100644 --- a/skills/glpi-team-members/SKILL.md +++ b/skills/glpi-team-members/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to manage ticket teams." metadata: package: glpi-python-client - version: "0.4.0" + version: "0.4.1" --- # GLPI Team Members @@ -57,6 +57,7 @@ await client.remove_ticket_team_member( ## Gotchas - The OpenAPI contract marks `PostTeamMember.id` as read-only, but the live GLPI server requires it on the `POST` body. The client honours the live behaviour and exposes `id` as a writable field; this is a deliberate "behaviour wins over the contract" decision. +- **There is no update method, and `PatchTeamMember` is dead surface for callers.** The family is exactly three methods -- `list_ticket_team_members(ticket_id)`, `add_ticket_team_member(ticket_id, member: PostTeamMember)`, `remove_ticket_team_member(ticket_id, member: PostTeamMember)` -- and both writers take a `PostTeamMember`, never a `PatchTeamMember`. `PatchTeamMember` *is* exported from `glpi_python_client` (it subclasses `PostTeamMember` and declares the same three optional fields, `id`, `type`, `role`, plus `extra_payload`), but no client method accepts or returns it, so building one and handing it to the client neither type-checks nor works. To change someone's role, remove the old entry and add the new one. - The server returns extra fields such as `display_name`, `firstname`, `realname`, and `href` on `GetTeamMember`. These flow into `member.extra_payload`. -- All methods are async; always `await` them. -- If the user provides a name rather than an ID, look the user or group up first with `search_users` (or the equivalent group search) and confirm the ID before changing membership. \ No newline at end of file +- The three methods are coroutines on `AsyncGlpiClient` and must be awaited; on the synchronous `GlpiClient` they are ordinary blocking methods with identical signatures (`_sync/` is generated from `_async/`, so the two surfaces cannot drift). Do not `await` the synchronous client. +- If the user provides a name rather than an ID, resolve it first with `await client.search_users(rsql_filter)` (or page through `client.iter_search_users(...)`) and confirm the ID before changing membership. The client exposes **no group search endpoint** — a `type="Group"` member's `id` has to come from elsewhere (a known id, or the GLPI UI); `search_users` only covers `type="User"`. \ No newline at end of file diff --git a/skills/glpi-ticket-timeline/SKILL.md b/skills/glpi-ticket-timeline/SKILL.md index 7d2e806..b1f65d0 100644 --- a/skills/glpi-ticket-timeline/SKILL.md +++ b/skills/glpi-ticket-timeline/SKILL.md @@ -5,7 +5,7 @@ license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, and network access to the GLPI v2 API." metadata: package: glpi-python-client - version: "0.4.0" + version: "0.4.1" --- # GLPI Ticket Timeline @@ -18,8 +18,14 @@ The ticket timeline is exposed by four resource families under `/Assistance/Tick 1. Create a `GlpiClient` from the `glpi-client-setup` skill. 2. Read collections with `list_ticket_followups`, `list_ticket_tasks`, `list_ticket_solutions`, and `list_ticket_timeline_documents`. 3. Read individual records with `get_ticket_followup`, `get_ticket_task`, `get_ticket_solution`, `get_ticket_timeline_document`. -4. Create entries with `create_ticket_followup`, `create_ticket_task`, `create_ticket_solution` or `link_ticket_timeline_document`. Each returns the new identifier as `int`. -5. Update entries with `update_ticket_followup`, `update_ticket_task`, `update_ticket_solution`, `update_ticket_timeline_document`. +4. Create entries with `create_ticket_followup(ticket_id, followup: PostFollowup)`, `create_ticket_task(ticket_id, task: PostTicketTask)` or `create_ticket_solution(ticket_id, solution: PostSolution)`. Each returns the new identifier as `int`. `link_ticket_timeline_document(ticket_id, document_link: PostTimelineDocument)` also returns an `int`, but it cannot be told *which* existing document to link -- see the note under "Attach a document" and prefer `upload_document(..., ticket_id=...)`. +5. Update entries with, exactly: + - `update_ticket_followup(ticket_id, followup_id, followup: PatchFollowup)` + - `update_ticket_task(ticket_id, task_id, task: PatchTicketTask)` + - `update_ticket_solution(ticket_id, solution_id, solution: PatchSolution)` + - `update_ticket_timeline_document(ticket_id, document_link_id, document_link: PatchTimelineDocument)` + + All four return `None`. `PatchFollowup`, `PatchTicketTask`, `PatchSolution` and `PatchTimelineDocument` are exported from `glpi_python_client`. Each *subclasses* its `Post*` counterpart and declares exactly the same fields, every one optional, so build one with only the fields you intend to change. The subclassing runs Patch-from-Post, which means a `Patch*` is accepted where a `Post*` is annotated but **not** the other way round: handing a `PostFollowup` to `update_ticket_followup` is a mypy error, so write the `Patch*` name. 6. Delete with `delete_ticket_followup`, `delete_ticket_task`, `delete_ticket_solution`, or `unlink_ticket_timeline_document`. Pass `force=True` to permanently delete instead of moving to the trash. ## Examples @@ -36,7 +42,13 @@ documents = await client.list_ticket_timeline_documents(321) Add a followup, a task, and a solution: ```python -from glpi_python_client import PostFollowup, PostSolution, PostTicketTask +from glpi_python_client import ( + GlpiSolutionStatus, + GlpiTaskState, + PostFollowup, + PostSolution, + PostTicketTask, +) followup_id = await client.create_ticket_followup( 321, @@ -44,29 +56,69 @@ followup_id = await client.create_ticket_followup( ) task_id = await client.create_ticket_task( 321, - PostTicketTask(content="On-site visit", duration=900), + # `state` is GlpiTaskState: INFORMATION = 0, TODO = 1, DONE = 2. + PostTicketTask(content="On-site visit", duration=900, state=GlpiTaskState.TODO), ) solution_id = await client.create_ticket_solution( 321, - PostSolution(content="Replaced the access point."), + # `status` is GlpiSolutionStatus: NONE = 1, WAITING = 2, ACCEPTED = 3, + # REFUSED = 4. + PostSolution( + content="Replaced the access point.", status=GlpiSolutionStatus.WAITING + ), +) +``` + +Update an entry. The third argument is always a `Patch*` model, never the `Post*` one: + +```python +from glpi_python_client import ( + GlpiTaskState, + PatchFollowup, + PatchSolution, + PatchTicketTask, +) + +await client.update_ticket_followup( + 321, followup_id, PatchFollowup(content="Triaged: root cause identified.") +) +await client.update_ticket_task( + 321, task_id, PatchTicketTask(state=GlpiTaskState.DONE, duration=1800) +) +await client.update_ticket_solution( + 321, solution_id, PatchSolution(content="Replaced the access point (AP-14).") ) ``` -Link an existing GLPI document to the ticket timeline: +Attach a document to the ticket timeline. Note `PostTimelineDocument` exposes only one typed field, `timeline_position` (every other `Document_Item` field is read-only on the contract), and the POST URL carries only the ticket id -- so there is no *typed* slot naming which existing document to link. `extra_payload` is merged into the request body verbatim, so `PostTimelineDocument(extra_payload={"documents_id": 654})` does put a document id on the wire; whether the server honours it is untested here. The supported way to put a file on a ticket is `upload_document(..., ticket_id=...)`, which creates the document and the ticket link in one call and requires `v1_base_url` + `v1_user_token` on the client (it raises `RuntimeError` otherwise): ```python -from glpi_python_client import PostTimelineDocument +from pathlib import Path + +from glpi_python_client import GlpiTimelinePosition, PatchTimelineDocument + +path = Path("diagnostic.png") +await client.upload_document( + filename=path.name, + content=path.read_bytes(), + mime_type="image/png", + ticket_id=321, +) -link_id = await client.link_ticket_timeline_document( +# Reposition an existing timeline link (document_link_id comes from +# list_ticket_timeline_documents, whose entries are GetDocument records): +await client.update_ticket_timeline_document( 321, - PostTimelineDocument(), + 654, + PatchTimelineDocument(timeline_position=GlpiTimelinePosition.LEFT), ) ``` ## Gotchas -- The live GLPI v2 server returns each timeline list entry wrapped in `{"type": ..., "item": {...}}` even though the OpenAPI contract documents a flat array. The client unwraps that envelope transparently for the four `list_*` helpers; you receive plain `Get` instances. +- The live GLPI v2 server returns each timeline list entry wrapped in `{"type": ..., "item": {...}}` even though the OpenAPI contract documents a flat array. The client unwraps that envelope transparently for the four `list_*` helpers; you receive plain `Get` instances. For the document family that entity is `GetDocument` -- the full `/Management/Document` record with `filename`, `mime`, `filepath`, `sha1sum` -- and not `GetTimelineDocument`, which is exported from the package root but is never returned by any client method. `get_ticket_timeline_document` returns `GetDocument` for the same reason. The other three are `list_ticket_followups` -> `GetFollowup`, `list_ticket_tasks` -> `GetTicketTask`, `list_ticket_solutions` -> `GetSolution`. - `create_*` methods return new identifiers as plain `int`. `update_*` and `delete_*`/`unlink_*` return `None`. -- Timeline content fields accept GLPI HTML directly. +- Three enums carry this family's value vocabularies, all exported from `glpi_python_client` and all subclasses of `GlpiEnum` (itself an `IntEnum`, so a member serialises as its number and compares equal to one): `GlpiTaskState` on `PostTicketTask.state`/`PatchTicketTask.state` (`INFORMATION = 0`, `TODO = 1`, `DONE = 2` -- note `INFORMATION` is `0`, so `if task.state:` is false for it; test against `None`), `GlpiSolutionStatus` on `PostSolution.status`/`PatchSolution.status` (`NONE = 1`, `WAITING = 2`, `ACCEPTED = 3`, `REFUSED = 4`), and `GlpiTimelinePosition` on `timeline_position` (`INVALID = -1`, `NONE = 0`, `LEFT = 1`, `RIGHT = 2`, `LEFT_BIG = 3`, `RIGHT_BIG = 4`), which the followup, task and document models carry -- the solution models do not have the field at all. +- Timeline `content` fields are Markdown on the Python side, not HTML. `PostFollowup`/`PostTicketTask`/`PostSolution` render Markdown to GLPI's HTML on serialisation, and `GetFollowup`/`GetTicketTask`/`GetSolution` convert the server's HTML back to Markdown on validation -- so `record.content` is always Markdown (`GetFollowup(content="

Hello world

").content == "Hello **world**"`). Authoring raw HTML is not an error but is round-tripped through the Markdown converter and can be reshaped; write Markdown. - Extra server fields (e.g. plugin keys) flow into `record.extra_payload` rather than raising. - `delete_ticket_*` and `unlink_ticket_timeline_document` accept a keyword-only `force` parameter; pass `force=True` to permanently delete. \ No newline at end of file diff --git a/skills/glpi-ticket-workflow/SKILL.md b/skills/glpi-ticket-workflow/SKILL.md index 5256019..2009c73 100644 --- a/skills/glpi-ticket-workflow/SKILL.md +++ b/skills/glpi-ticket-workflow/SKILL.md @@ -5,13 +5,13 @@ license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials accepted by GlpiClient." metadata: package: glpi-python-client - version: "0.4.0" + version: "0.4.1" --- # GLPI Ticket Workflow > The snippets below use `AsyncGlpiClient` (`async with` + `await`). Every method shown also exists on the synchronous `GlpiClient` with the same signature -- replace `async with` with `with`, drop the `await` keyword, and skip the surrounding `async def`/`asyncio.run` scaffolding. -Use this skill for ticket reads and writes through the public client. Tickets live under `/Assistance/Ticket` on the GLPI v2 API and are exposed by five methods, present on both `GlpiClient` and `AsyncGlpiClient` with identical signatures: `search_tickets`, `get_ticket`, `create_ticket`, `update_ticket`, and `delete_ticket`. +Use this skill for ticket reads and writes through the public client. Tickets live under `/Assistance/Ticket` on the GLPI v2 API and are exposed by six methods, present on both `GlpiClient` and `AsyncGlpiClient` with identical signatures: `search_tickets`, `iter_search_tickets`, `get_ticket`, `create_ticket`, `update_ticket`, and `delete_ticket`. ## Procedure @@ -27,7 +27,7 @@ Use this skill for ticket reads and writes through the public client. Tickets li Search open tickets: ```python -tickets = await client.search_tickets("status==1", limit=20) +tickets = await client.search_tickets("is_deleted==false;status==1", limit=20) # v2 search returns trashed tickets unless `is_deleted` is pinned ``` Create a ticket. Content fields accept Markdown and are converted to GLPI's HTML transport format transparently: @@ -69,8 +69,9 @@ ticket = PostTicket( ## Gotchas -- All ticket methods are async; always `await` them. -- `search_tickets` accepts a raw RSQL filter string; pagination is via `limit` and `start`. There is no batch iterator. +- On `AsyncGlpiClient` every ticket method is a coroutine and must be awaited; on `GlpiClient` the same methods are ordinary blocking calls and must not be awaited. `iter_search_tickets` is the exception to the shape: it is an async generator on `AsyncGlpiClient` (`async for`) and a plain generator on `GlpiClient` (`for`), so it is iterated, not awaited, on either surface. +- `search_tickets` accepts a raw RSQL filter string; pagination is via the keyword-only `limit` and `start` (it also takes `sort` and `fields`). To walk a whole result set use the batch iterator `iter_search_tickets(rsql_filter, batch_size=50, sort=..., fields=...)`, which advances `start` itself and yields one `list[GetTicket]` page per step, stopping when a page comes back shorter than `batch_size` — `async for batch in client.iter_search_tickets(...)` on `AsyncGlpiClient`, `for batch in client.iter_search_tickets(...)` on `GlpiClient`. +- **`search_tickets` swallows 4xx and returns `[]`.** This is a library-wide contract, not a ticket peculiarity: `_resource_list` checks the response status only when the caller passes a `failure_message`, and none of the seven `search_*` helpers (`search_tickets`, `search_users`, `search_locations`, `search_entities`, `search_documents`, `search_kb_articles`, `search_kb_categories`) passes one -- a GLPI error body is not a JSON list, so it is coerced to `[]`. A malformed RSQL filter, a 403, a missing route and a genuinely empty result set are therefore indistinguishable at the call site. `iter_search_tickets` inherits it: a swallowed 4xx yields a short first page, so the loop simply ends and you process nothing. `get_ticket` and every `list_*`/`get_*` helper do pass a `failure_message` and raise `GlpiStatusError` (narrowed to `GlpiAuthError` / `GlpiNotFoundError` / `GlpiServerError`) normally, so probe with one of those before believing an empty search -- and never treat `[]` as proof a ticket does not exist before creating a replacement. - `create_ticket` returns the new ticket ID. `update_ticket` and `delete_ticket` return `None`. - The GLPI server is the authoritative validator. Extra keys returned by the server flow into `ticket.extra_payload` rather than raising. Caller-provided `extra_payload` keys win on conflicts. -- Read-only fields such as `status` are intentionally absent from `PostTicket`/`PatchTicket`; the server controls those transitions. \ No newline at end of file +- Read-only fields such as `status` are intentionally absent from `PostTicket`/`PatchTicket`; the server controls those transitions. `global_validation` is the exception -- it *is* declared on both write models, typed `GlpiGlobalValidation | None`, whose members are `NONE = 1`, `WAITING = 2`, `ACCEPTED = 3`, `REFUSED = 4`. `GlpiGlobalValidation` is exported from the package root alongside `GlpiTicketStatus` (`NEW = 1`, `ASSIGNED = 2`, `PLANNED = 3`, `PENDING = 4`, `SOLVED = 5`, `CLOSED = 6`, `VALIDATION = 10`), `GlpiTicketType` (`INCIDENT = 1`, `REQUEST = 2`) and `GlpiPriority`; `status` and the rest stay readable on `GetTicket`. \ No newline at end of file diff --git a/skills/glpi-user-location-provisioning/SKILL.md b/skills/glpi-user-location-provisioning/SKILL.md index 6a3cbb1..2a17260 100644 --- a/skills/glpi-user-location-provisioning/SKILL.md +++ b/skills/glpi-user-location-provisioning/SKILL.md @@ -5,18 +5,18 @@ license: MIT compatibility: "Requires Python 3.10+, glpi-python-client, network access to the GLPI v2 API, and credentials allowed to read or write users, locations, and entities." metadata: package: glpi-python-client - version: "0.4.0" + version: "0.4.1" --- # GLPI User, Location, And Entity Provisioning > The snippets below use `AsyncGlpiClient` (`async with` + `await`). Every method shown also exists on the synchronous `GlpiClient` with the same signature -- replace `async with` with `with`, drop the `await` keyword, and skip the surrounding `async def`/`asyncio.run` scaffolding. -Users live under `/Administration/User`, entities under `/Administration/Entity`, and locations under `/Dropdown/Location`. Each resource family is exposed by the same `search_/get_/create_/update_/delete_` shape on `GlpiClient` with matching `Get`/`Post`/`Patch`/`Delete` Pydantic models. +Users live under `/Administration/User`, entities under `/Administration/Entity`, and locations under `/Dropdowns/Location`. Each resource family is exposed by the same `search_/get_/create_/update_/delete_` shape on `GlpiClient` with matching `Get`/`Post`/`Patch`/`Delete` Pydantic models. ## Procedure 1. Create a `GlpiClient` with the correct entity/profile scope. -2. Search before creating duplicates: `search_users(rsql_filter, limit=..., start=...)`, `search_locations(rsql_filter, limit=..., start=...)`, `search_entities(rsql_filter, limit=..., start=...)`. +2. Search before creating duplicates: `search_users(rsql_filter, limit=..., start=..., skip_entity=False)`, `search_locations(rsql_filter, limit=..., start=...)`, `search_entities(rsql_filter, limit=..., start=...)`. Scope matters here: `search_users` and `search_locations` are narrowed by the client's `GLPI-Entity` / `GLPI-Profile` headers, so pass `skip_entity=True` to `search_users` (the only one of the three `search_*` helpers that has the flag — `iter_search_users` takes it too) to look across every entity the caller can see before deciding a user does not exist; `search_entities` always bypasses those headers. 3. Fetch one record with `get_user(user_id)`, `get_location(location_id)`, or `get_entity(entity_id)`. 4. Create with `create_user(PostUser(...))`, `create_location(PostLocation(...))`, or `create_entity(PostEntity(...))`. Each returns the new ID. 5. Update with `update_user(user_id, PatchUser(...))`, `update_location(location_id, PatchLocation(...))`, or `update_entity(entity_id, PatchEntity(...))`. @@ -46,17 +46,45 @@ user_id = await client.create_user( ) ``` -Find or create a location: +Find or create a location. The obvious spelling -- `matches[0].id if matches else create_location(...)` -- is a duplicate generator, because `search_locations` returns `[]` for a rejected filter or a 403 exactly as it does for "no such location" (see the first gotcha). Two guards close that, and neither is optional. Both fail closed: when they cannot prove the record is absent they raise rather than create, so the one case they refuse -- the very first location on an instance whose dropdown is still empty -- is an explicit opt-in, not a silent duplicate: ```python -from glpi_python_client import PostLocation - -matches = await client.search_locations('name=="Paris HQ"') -location_id = ( - matches[0].id - if matches - else await client.create_location(PostLocation(name="Paris HQ")) -) +from glpi_python_client import AsyncGlpiClient, PostLocation + +#: Characters that terminate an RSQL token. A value carrying one produces a +#: filter the server rejects with a 400 -- which `search_locations` turns +#: into `[]`, i.e. into "create a duplicate". +_RSQL_UNSAFE = set("\"'();,=<>!~*") + + +async def find_or_create_location( + client: AsyncGlpiClient, name: str, *, dropdown_may_be_empty: bool = False +) -> int: + """Return the id of the location called `name`, creating it if absent.""" + if not name or _RSQL_UNSAFE & set(name): + # Guard 1. The canary below cannot catch this case: it does not + # carry this value, so it would come back healthy while the real + # query was being rejected. Reject the value here instead. + raise ValueError(f"value is not safe to interpolate into RSQL: {name!r}") + + matches = await client.search_locations(f'name=="{name}"') + if matches and matches[0].id is not None: + return matches[0].id + + # Guard 2. Empty is not proof of absence. Re-run the same route with no + # filter at all -- same URL, same auth, same entity scope, nothing to + # reject -- so a swallowed 403/404/5xx shows up here too. An empty + # answer here has exactly two causes: the search layer failed, or the + # Locations dropdown is genuinely empty (a fresh GLPI ships it empty). + # This cannot tell them apart either, so it fails closed and makes the + # second one something the caller states on purpose. + if not await client.search_locations("", limit=1) and not dropdown_may_be_empty: + raise RuntimeError( + "search_locations returned nothing even unfiltered: assume a failed " + "search, not a missing location. Pass dropdown_may_be_empty=True " + "only once you have confirmed this instance has no locations yet." + ) + return await client.create_location(PostLocation(name=name)) ``` Look entities up by name fragment: @@ -69,9 +97,10 @@ for entity in entities: ## Gotchas -- All methods are async; always `await` them. -- `PostUser` requires `username` and the GLPI server enforces the `password`/`password2` pair when creating local users. Tweak according to your auth backend. -- Search filters are raw RSQL strings; pagination is via `limit` and `start`. +- **`search_users`, `search_locations` and `search_entities` swallow 4xx and return `[]`, and this family is where that hurts most.** It is a library-wide contract, not a peculiarity of these three: `_resource_list` checks the response status only when the caller passes a `failure_message`, and none of the seven `search_*` helpers (`search_users`, `search_locations`, `search_entities`, `search_tickets`, `search_documents`, `search_kb_articles`, `search_kb_categories`) passes one -- a GLPI error body is not a JSON list, so it is coerced to `[]`. A malformed RSQL filter, a 403, a missing route and "no such record" are therefore indistinguishable. `iter_search_users` / `iter_search_entities` inherit it: a swallowed 4xx makes the first page short, so the loop ends having yielded nothing. **The find-or-create pattern is the trap**: `matches[0].id if matches else create(...)` provisions a duplicate user or location every time the search fails, and the duplicate is then a data-repair job, not an exception someone sees. Guard both halves as the example above does -- validate anything you interpolate into the filter, and corroborate an empty result with an unfiltered control search or with a `get_user`/`get_location`/`get_entity` on a known id, all of which do pass a `failure_message` and raise `GlpiStatusError` (narrowed to `GlpiAuthError` / `GlpiNotFoundError` / `GlpiServerError`) normally. +- On `AsyncGlpiClient` every method shown is a coroutine -- always `await` it -- and `iter_search_users` / `iter_search_entities` are async generators consumed with `async for`, not `await`. The generated `GlpiClient` carries the same names and signatures as ordinary blocking calls: no `await`, and a plain `for` over the iterators. +- `PostUser` has **no** client-side required fields: every declared field defaults to `None` (bar `extra_payload`, which defaults to an empty dict) and `model_dump(exclude_none=True)` strips the unset ones, so `PostUser()` validates fine and it is the GLPI server that rejects a create without `username` and enforces the `password`/`password2` pair for local accounts. Tweak according to your auth backend: `authtype` on `PostUser`/`PatchUser`/`GetUser` is typed `GlpiUserAuthType | None`, exported from the package root, with members `LOCAL = 1`, `LDAP = 2`, `MAIL = 3`, `CAS = 4`, `X509 = 5`, `EXTERNAL = 6` -- pass the member (`authtype=GlpiUserAuthType.LDAP`) rather than a bare integer. Like every public enum in the package it subclasses `GlpiEnum`, itself an `IntEnum`, so it serialises as its number and compares equal to one. `password`/`password2` are `SecretStr` (plain `str` is coerced) and are masked in `repr` and logs, unmasked only when the request body is serialised. +- Search filters are raw RSQL strings. `search_*` pages manually with `limit` and `start`, but for users and entities the client drives pagination for you: `async for page in client.iter_search_users(rsql_filter, batch_size=50, skip_entity=False)` and `iter_search_entities(rsql_filter, batch_size=50)` yield successive pages and stop on the first short page (plain `for` on `GlpiClient`). `iter_search_users` carries the same `skip_entity` flag as `search_users`, so pass `skip_entity=True` there too when paging across every entity; `iter_search_entities` has no such parameter and always spans them. There is no `iter_search_locations` -- page `search_locations` yourself with `limit`/`start`. - Extra keys returned by the live server (`display_name`, plugin fields, ...) flow into `record.extra_payload` rather than raising. - `delete_*(force=True)` permanently deletes the record; omit (or `False`/`None`) to move it to the trash. - If the user provides a name rather than an ID, search first and confirm the ID before changing or deleting records. \ No newline at end of file