glpi-python-client 0.4.2__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/CHANGELOG.md +178 -1
  2. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/PKG-INFO +5 -2
  3. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/README.md +2 -0
  4. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/docs/user_guide.rst +44 -6
  5. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/__init__.py +9 -1
  6. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/_base_client.py +25 -1
  7. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/administration/_user.py +76 -0
  8. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/assistance/_team.py +2 -3
  9. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/assistance/_ticket.py +8 -2
  10. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/dropdowns/_location.py +44 -0
  11. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/knowledgebase/_article.py +59 -2
  12. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/knowledgebase/_category.py +60 -1
  13. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/management/_document.py +92 -0
  14. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/plugins/_fields.py +19 -3
  15. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/commons/_config.py +61 -0
  16. {glpi_python_client-0.4.2/glpi_python_client/_sync → glpi_python_client-0.4.3/glpi_python_client/_async}/clients/commons/_filters.py +24 -0
  17. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/commons/_http.py +64 -5
  18. glpi_python_client-0.4.3/glpi_python_client/_async/clients/commons/_payloads.py +95 -0
  19. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/commons/_transport.py +99 -15
  20. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/custom/_statistics.py +32 -22
  21. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/_base_client.py +25 -1
  22. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/administration/_user.py +76 -0
  23. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/assistance/_team.py +2 -3
  24. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/assistance/_ticket.py +8 -2
  25. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/dropdowns/_location.py +44 -0
  26. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/knowledgebase/_article.py +59 -2
  27. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/knowledgebase/_category.py +60 -1
  28. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/management/_document.py +92 -0
  29. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/plugins/_fields.py +19 -3
  30. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/commons/_config.py +61 -0
  31. {glpi_python_client-0.4.2/glpi_python_client/_async → glpi_python_client-0.4.3/glpi_python_client/_sync}/clients/commons/_filters.py +24 -0
  32. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/commons/_http.py +64 -5
  33. glpi_python_client-0.4.3/glpi_python_client/_sync/clients/commons/_payloads.py +95 -0
  34. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/commons/_transport.py +99 -15
  35. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/custom/_statistics.py +32 -22
  36. glpi_python_client-0.4.3/glpi_python_client/content/conversion.py +130 -0
  37. glpi_python_client-0.4.3/glpi_python_client/models/_base.py +176 -0
  38. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/_content.py +13 -4
  39. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/assistance/_ticket.py +42 -6
  40. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/custom_schema/_ticket_context.py +18 -3
  41. glpi_python_client-0.4.3/glpi_python_client/rsql.py +188 -0
  42. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/testing/utils.py +1 -0
  43. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/pyproject.toml +8 -1
  44. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/skills/glpi-client-setup/SKILL.md +4 -1
  45. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/skills/glpi-document-workflow/SKILL.md +6 -6
  46. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/skills/glpi-knowledge-base/SKILL.md +5 -4
  47. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/skills/glpi-plugin-fields/SKILL.md +2 -1
  48. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/skills/glpi-reporting-and-context/SKILL.md +3 -3
  49. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/skills/glpi-team-members/SKILL.md +1 -1
  50. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/skills/glpi-ticket-timeline/SKILL.md +1 -1
  51. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/skills/glpi-ticket-workflow/SKILL.md +6 -3
  52. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/skills/glpi-user-location-provisioning/SKILL.md +13 -10
  53. glpi_python_client-0.4.2/glpi_python_client/_async/clients/commons/_payloads.py +0 -42
  54. glpi_python_client-0.4.2/glpi_python_client/_sync/clients/commons/_payloads.py +0 -42
  55. glpi_python_client-0.4.2/glpi_python_client/content/conversion.py +0 -58
  56. glpi_python_client-0.4.2/glpi_python_client/models/_base.py +0 -61
  57. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/.gitignore +0 -0
  58. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/.pre-commit-config.yaml +0 -0
  59. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/.readthedocs.yaml +0 -0
  60. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/CONTRIBUTING.md +0 -0
  61. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/LICENSE +0 -0
  62. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/docs/_static/.gitkeep +0 -0
  63. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/docs/api_reference.rst +0 -0
  64. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/docs/conf.py +0 -0
  65. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/docs/development.md +0 -0
  66. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/docs/development_rtd.rst +0 -0
  67. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/docs/index.rst +0 -0
  68. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/docs/installation.rst +0 -0
  69. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/docs/publishing.md +0 -0
  70. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/docs/publishing_rtd.rst +0 -0
  71. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/docs/sponsoring.rst +0 -0
  72. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/__init__.py +0 -0
  73. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/_concurrency.py +0 -0
  74. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/_testing.py +0 -0
  75. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/auth/__init__.py +0 -0
  76. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/auth/_v1_session.py +0 -0
  77. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/auth/auth.py +0 -0
  78. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/__init__.py +0 -0
  79. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/__init__.py +0 -0
  80. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/administration/__init__.py +0 -0
  81. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/administration/_entity.py +0 -0
  82. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/assistance/__init__.py +0 -0
  83. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/assistance/timeline/__init__.py +0 -0
  84. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/assistance/timeline/_document.py +0 -0
  85. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/assistance/timeline/_followup.py +0 -0
  86. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/assistance/timeline/_solution.py +0 -0
  87. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/assistance/timeline/_task.py +0 -0
  88. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/dropdowns/__init__.py +0 -0
  89. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/knowledgebase/__init__.py +0 -0
  90. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/knowledgebase/_comment.py +0 -0
  91. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/knowledgebase/_revision.py +0 -0
  92. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/management/__init__.py +0 -0
  93. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/api/plugins/__init__.py +0 -0
  94. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/client.py +0 -0
  95. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/commons/__init__.py +0 -0
  96. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/commons/_constants.py +0 -0
  97. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/custom/__init__.py +0 -0
  98. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_async/clients/custom/_ticket_context.py +0 -0
  99. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_errors.py +0 -0
  100. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/__init__.py +0 -0
  101. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/_concurrency.py +0 -0
  102. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/_testing.py +0 -0
  103. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/auth/__init__.py +0 -0
  104. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/auth/_v1_session.py +0 -0
  105. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/auth/auth.py +0 -0
  106. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/__init__.py +0 -0
  107. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/__init__.py +0 -0
  108. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/administration/__init__.py +0 -0
  109. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/administration/_entity.py +0 -0
  110. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/assistance/__init__.py +0 -0
  111. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/assistance/timeline/__init__.py +0 -0
  112. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/assistance/timeline/_document.py +0 -0
  113. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/assistance/timeline/_followup.py +0 -0
  114. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/assistance/timeline/_solution.py +0 -0
  115. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/assistance/timeline/_task.py +0 -0
  116. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/dropdowns/__init__.py +0 -0
  117. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/knowledgebase/__init__.py +0 -0
  118. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/knowledgebase/_comment.py +0 -0
  119. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/knowledgebase/_revision.py +0 -0
  120. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/management/__init__.py +0 -0
  121. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/api/plugins/__init__.py +0 -0
  122. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/client.py +0 -0
  123. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/commons/__init__.py +0 -0
  124. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/commons/_constants.py +0 -0
  125. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/custom/__init__.py +0 -0
  126. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/_sync/clients/custom/_ticket_context.py +0 -0
  127. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/content/__init__.py +0 -0
  128. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/__init__.py +0 -0
  129. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/__init__.py +0 -0
  130. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/_common.py +0 -0
  131. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/administration/__init__.py +0 -0
  132. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/administration/_entity.py +0 -0
  133. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/administration/_user.py +0 -0
  134. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/assistance/__init__.py +0 -0
  135. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/assistance/_team.py +0 -0
  136. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/assistance/timeline/__init__.py +0 -0
  137. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/assistance/timeline/_document.py +0 -0
  138. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/assistance/timeline/_followup.py +0 -0
  139. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/assistance/timeline/_solution.py +0 -0
  140. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/assistance/timeline/_task.py +0 -0
  141. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/dropdowns/__init__.py +0 -0
  142. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/dropdowns/_location.py +0 -0
  143. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/enums.py +0 -0
  144. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/knowledgebase/__init__.py +0 -0
  145. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/knowledgebase/_article.py +0 -0
  146. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/knowledgebase/_category.py +0 -0
  147. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/knowledgebase/_comment.py +0 -0
  148. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/knowledgebase/_revision.py +0 -0
  149. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/management/__init__.py +0 -0
  150. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/management/_document.py +0 -0
  151. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/plugins/__init__.py +0 -0
  152. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/api_schema/plugins/_fields.py +0 -0
  153. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/models/custom_schema/__init__.py +0 -0
  154. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/py.typed +0 -0
  155. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/testing/__init__.py +0 -0
  156. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/glpi_python_client/testing/fixtures.py +0 -0
  157. {glpi_python_client-0.4.2 → glpi_python_client-0.4.3}/skills/README.md +0 -0
@@ -4,7 +4,178 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
- ## Unreleased
7
+ ## 0.4.3 — 2026-08-13
8
+
9
+ ### Changed (breaking)
10
+
11
+ - **`server_timezone` is now a required client argument** (`GLPI_SERVER_TIMEZONE`
12
+ for `from_env`). It takes an IANA zone name — `"Europe/Paris"` — or a
13
+ `tzinfo`.
14
+
15
+ GLPI 11 sends most timestamps with the correct historical offset, but not
16
+ all of them. Measured against a live instance: 19 of the 20 datetime fields
17
+ across every resource are offset-bearing, and `KBArticle.revisions[].date`
18
+ is not. One response therefore carries both kinds, and comparing them raises
19
+ `TypeError: can't compare offset-naive and offset-aware datetimes` — sorting
20
+ an article's revision history against the article's own dates was enough to
21
+ trigger it.
22
+
23
+ There is deliberately **no default**. Every candidate is wrong somewhere:
24
+ against a Europe/Paris instance, assuming UTC shifts the affected timestamps
25
+ by one or two hours *and stops raising*, turning a loud failure into a quiet
26
+ wrong answer. An IANA name is preferred over a fixed offset because a name
27
+ follows DST — the same instance emits both `+01:00` and `+02:00`.
28
+
29
+ An offset already on the wire always wins over the configured zone, and a
30
+ model built outside the client (no validation context) keeps its naive values
31
+ rather than being stamped with a guess.
32
+
33
+ Adds `tzdata` as a dependency on Windows, which ships no system timezone
34
+ database; without it `zoneinfo` resolves on Linux CI and raises on a
35
+ developer machine.
36
+
37
+ - **Search endpoints now raise on a 4xx instead of returning `[]`.** The seven
38
+ `search_*` helpers passed no `failure_message` to `_resource_list`, which
39
+ skipped the status check entirely, so a 400, 401, 403 or 404 came back as an
40
+ empty list — indistinguishable from a filter that legitimately matched
41
+ nothing. (5xx already raised.) It composed badly with the batch iterators:
42
+ they stop on a page shorter than `batch_size`, so a 403 on the first page
43
+ ended the walk having yielded nothing and the caller saw a *successful*
44
+ empty result. This reverses decision D2 of the 0.4.0 error work, which chose
45
+ tolerance deliberately; the silent-empty failure mode has proved worse than
46
+ the exception. An empty list now means the server said the result set is
47
+ empty. **Callers that relied on `[]` after a permission error must catch
48
+ `GlpiStatusError`.**
49
+
50
+ ### Added
51
+
52
+ - **`PatchTicket.status`** — ticket status is writable after all, typed
53
+ `GlpiTicketStatus | None`:
54
+
55
+ ```python
56
+ client.update_ticket(ticket_id, PatchTicket(status=GlpiTicketStatus.PENDING))
57
+ ```
58
+
59
+ The field had been excluded on the grounds that GLPI "manages the ticket
60
+ lifecycle through dedicated timeline routes", which is what the contract
61
+ says: it publishes `Ticket.status.id` as `readOnly: true`. Measured against
62
+ a live GLPI 11 instance, that is wrong — `PATCH` with `{"status": 5}`
63
+ answers 200 and the ticket moves. Same failure mode as the `Major` priority
64
+ level the contract omits: observed server behaviour wins.
65
+
66
+ It is declared on `PatchTicket` and **not** on `PostTicket`, because the two
67
+ routes genuinely differ — `POST` with a status answers 201 and creates a
68
+ *New* ticket, dropping the field. A create argument for it would have done
69
+ nothing. `status_id` is likewise ignored; `status` is the spelling that
70
+ works.
71
+
72
+ The annotation is the enum rather than `int` deliberately. GLPI validates
73
+ nothing here: `{"status": 99}` answers 200 and stores it, after which the
74
+ API reports `{"id": 99, "name": "99"}`, the web form displays the ticket as
75
+ *New*, and the ticket vanishes from the ticket list while remaining open in
76
+ the database — only the history records the change. A typo like `55` for
77
+ `5` would lose a ticket in silence, so this model is the only validation on
78
+ the path. `GetTicket.status` stays the permissive `IdNameRef`, since a
79
+ strict enum on the read side would fail a whole search over one bad row.
80
+
81
+ - **`glpi_python_client.rsql`** — public date builders for the v2 filter
82
+ grammar: `created_between`, `date_window` and `changed_since`, all exported
83
+ from the package root. The end-of-day detail on a window's upper bound is
84
+ easy to get wrong and impossible to notice, since GLPI answers a malformed
85
+ filter by ignoring it and returning the whole table.
86
+
87
+ - **`find_user_by_email(email)`** — resolves a person by address. It scans,
88
+ because GLPI exposes addresses as the nested array `User.emails` and the v2
89
+ filter engine cannot join a nested array. Narrow it with `rsql_filter` and
90
+ cache the id; do not hand-roll an RSQL e-mail filter.
91
+
92
+ - **`stream_document_content(document_id, chunk_size=...)`** — yields a
93
+ document body in chunks instead of buffering it whole, as
94
+ `download_document_content` does. Upload still buffers.
95
+
96
+ - **Batch iterators for the four resources that lacked one**:
97
+ `iter_search_kb_articles`, `iter_search_kb_categories`,
98
+ `iter_search_documents` and `iter_search_locations`.
99
+
100
+ ### Fixed
101
+
102
+ - **Every `datetime` write raised `TypeError`.** `model_to_payload` dumped in
103
+ Pydantic's python mode, so a request body reached `json.dumps` still holding
104
+ a live `datetime`. The failure landed at the encoder — after the model had
105
+ validated and outside any transport stub — which is why the suite never saw
106
+ it. The dump now runs in JSON mode.
107
+
108
+ - **GLPI discards the offset on every datetime it is sent, so aware values
109
+ were written as the wrong moment.** Measured against a live Europe/Paris
110
+ instance: `2026-08-01T12:30:00` written bare, as `...Z`, and with `+02:00`,
111
+ `+09:00`, `-08:00` and `+14:00` all store 12:30 Paris. The server reads the
112
+ naive prefix, interprets it in its own timezone, and throws the rest away —
113
+ with a 200. It does parse the offset first, since `+99:99` answers HTTP 500,
114
+ which is the worst combination: a malformed offset crashes, a well-formed
115
+ wrong one is silent. `12:30-08:00` is 21:30 in Paris and landed nine hours
116
+ early.
117
+
118
+ An aware `datetime` is now converted onto the server's clock and the offset
119
+ dropped, via a serialisation context mirroring the validation context used
120
+ on the inbound half. Naive values are untouched — they already mean the
121
+ server's clock — and no context means no conversion, so a model dumped
122
+ outside the client is unchanged. This is the second half of the
123
+ `server_timezone` contract above; `mode="json"` alone would have shipped
124
+ writes wrong by up to twelve hours.
125
+
126
+ - **`get_ticket_statistics` silently truncated at 200 tickets**, on an
127
+ instance whose own docstring records 59,690. It issued one `search_tickets`
128
+ call with no paging loop, so every statistic was computed over whichever
129
+ 200 tickets came back first and reported as if it covered the corpus. The
130
+ two entity and one user name-resolution sites had the same shape. All four
131
+ now page through `iter_search_*`.
132
+
133
+ - **`from_transport` silently deleted text.** The HTML path was taken whenever
134
+ the content held both `<` and `>`, so `"use the <Enter> key"` became
135
+ `"use the key"` — an unknown tag's markup is dropped and its empty body
136
+ kept, removing the word with nothing left to show it was ever there.
137
+ `"cmd </dev/null > out"` and `"if x<y then z>0"` lost text the same way. The
138
+ decision is now made on the element *name*.
139
+
140
+ - **Fenced code blocks, tables and prose punctuation were mangled.** Without
141
+ the `fenced_code` extension a fence rendered as inline `<code>`, which the
142
+ GLPI web UI shows as one run-on line and which a later read wrote back as
143
+ inline code — so a pasted log degraded further on every edit. Without
144
+ `tables`, a table rendered as literal pipes. Inbound, `markdownify` escaped
145
+ underscores and asterisks, so `snake_case` came back as `snake\_case` and
146
+ accumulated a backslash on every read-modify-write cycle.
147
+
148
+ - **`_MAX_DATETIME` was naive, so `to_markdown()` raised on a mixed-awareness
149
+ timeline.** Sorting events padded absent timestamps with `datetime.max`,
150
+ which cannot be compared against the offset-bearing values GLPI sends. The
151
+ sort key now normalises both sides to UTC. The live probe confirmed the
152
+ mixed population is real, not hypothetical.
153
+
154
+ - **`require_response_int` rejected create responses GLPI actually returns.**
155
+ A numeric-string id, an id nested under a `data` envelope, and a create that
156
+ reports only a `Location` header all raised a protocol error over a
157
+ perfectly usable identifier. It now probes top-level keys, then the
158
+ envelope, then the header.
159
+
160
+ - **`sort="date_mod desc"` — the library's own documented example — is HTTP
161
+ 400.** Found while running the wire-format probe. The accepted syntax is
162
+ `field:direction`; a bare `date_mod` is accepted but sorts *ascending*, and
163
+ `order=` is ignored entirely.
164
+
165
+ - **Three client construction examples had `server_timezone` inserted twice
166
+ and misindented**, by the sweep that added it. A repeated keyword argument
167
+ is a `SyntaxError`, so those examples could not be copied at all. Two older
168
+ documentation defects surfaced alongside them: three lines of expected
169
+ output stranded inside a `code-block:: python` (they belong to the example
170
+ above), and an import indented four spaces inside a three-space block. A new
171
+ test compiles all 77 Python snippets in the skills, the guide and the
172
+ README.
173
+
174
+ ## 0.4.0 – 0.4.2
175
+
176
+ These three releases were tagged without the changelog ever being
177
+ sectioned, so their notes accumulated under a single `Unreleased`
178
+ heading. They are grouped here rather than split retroactively.
8
179
 
9
180
  ### Fixed
10
181
 
@@ -314,6 +485,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
314
485
  - The `requests` intersphinx mapping is removed; it survived the transport
315
486
  swap and made every docs build fetch an inventory nothing referenced.
316
487
 
488
+ ## Pre-0.4.0 notes
489
+
490
+ Kept for history. Written before the httpx and unasync rewrites, so the
491
+ status they describe is superseded by everything above — the transport is
492
+ no longer `requests`, and tolerant searches no longer swallow a 4xx.
493
+
317
494
  ### Unchanged (deliberately)
318
495
 
319
496
  - Retry semantics: 5xx retried 3 times with a 3-second fixed wait, 4xx never
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: glpi-python-client
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: A typed Python client for GLPI ITSM APIs.
5
5
  Project-URL: Homepage, https://github.com/baraline/glpi_python_client
6
6
  Project-URL: Documentation, https://glpi-python-client.readthedocs.io/en/latest/
@@ -33,6 +33,7 @@ Requires-Dist: pydantic>=2.8
33
33
  Requires-Dist: sniffio>=1.3
34
34
  Requires-Dist: tenacity>=8.2
35
35
  Requires-Dist: typing-extensions>=4.7; python_version < '3.11'
36
+ Requires-Dist: tzdata>=2024.1; platform_system == 'Windows'
36
37
  Provides-Extra: dev
37
38
  Requires-Dist: build>=1.2; extra == 'dev'
38
39
  Requires-Dist: mypy>=1.11; extra == 'dev'
@@ -114,6 +115,7 @@ from glpi_python_client import GlpiClient, PostTicket
114
115
 
115
116
  with GlpiClient(
116
117
  glpi_api_url="https://glpi.example.com/api.php/v2",
118
+ server_timezone="Europe/Paris",
117
119
  client_id="oauth-client-id",
118
120
  client_secret="oauth-client-secret",
119
121
  username="api-user",
@@ -140,6 +142,7 @@ from glpi_python_client import AsyncGlpiClient, PostTicket
140
142
  async def main() -> None:
141
143
  async with AsyncGlpiClient(
142
144
  glpi_api_url="https://glpi.example.com/api.php/v2",
145
+ server_timezone="Europe/Paris",
143
146
  client_id="oauth-client-id",
144
147
  client_secret="oauth-client-secret",
145
148
  username="api-user",
@@ -57,6 +57,7 @@ from glpi_python_client import GlpiClient, PostTicket
57
57
 
58
58
  with GlpiClient(
59
59
  glpi_api_url="https://glpi.example.com/api.php/v2",
60
+ server_timezone="Europe/Paris",
60
61
  client_id="oauth-client-id",
61
62
  client_secret="oauth-client-secret",
62
63
  username="api-user",
@@ -83,6 +84,7 @@ from glpi_python_client import AsyncGlpiClient, PostTicket
83
84
  async def main() -> None:
84
85
  async with AsyncGlpiClient(
85
86
  glpi_api_url="https://glpi.example.com/api.php/v2",
87
+ server_timezone="Europe/Paris",
86
88
  client_id="oauth-client-id",
87
89
  client_secret="oauth-client-secret",
88
90
  username="api-user",
@@ -68,6 +68,7 @@ pair. The OAuth password grant accepts either ``client_id`` /
68
68
 
69
69
  with GlpiClient(
70
70
  glpi_api_url="https://glpi.example.com/api.php/v2",
71
+ server_timezone="Europe/Paris",
71
72
  client_id="oauth-client-id",
72
73
  client_secret="oauth-client-secret",
73
74
  username="api-user",
@@ -92,6 +93,7 @@ The asynchronous client takes the same arguments and is used inside an
92
93
  async def main() -> None:
93
94
  async with AsyncGlpiClient(
94
95
  glpi_api_url="https://glpi.example.com/api.php/v2",
96
+ server_timezone="Europe/Paris",
95
97
  client_id="oauth-client-id",
96
98
  client_secret="oauth-client-secret",
97
99
  username="api-user",
@@ -134,6 +136,11 @@ build the client for you:
134
136
  * ``GLPI_CLIENT_ID`` and ``GLPI_CLIENT_SECRET``
135
137
  * ``GLPI_USERNAME`` and ``GLPI_PASSWORD``
136
138
  * ``GLPI_ENTITY``, ``GLPI_PROFILE``, ``GLPI_ENTITY_RECURSIVE``
139
+ * ``GLPI_SERVER_TIMEZONE`` -- **required**. IANA name of the timezone the
140
+ GLPI server runs in (e.g. ``Europe/Paris``). GLPI does not advertise it,
141
+ and it is needed to interpret the timestamps the server sends without an
142
+ offset. There is no default: guessing UTC against a Europe/Paris instance
143
+ shifts those values by an hour or two and never raises.
137
144
  * ``GLPI_LANGUAGE``, ``GLPI_VERIFY_SSL``
138
145
  * ``GLPI_V1_BASE_URL``, ``GLPI_V1_USER_TOKEN``, ``GLPI_V1_APP_TOKEN``
139
146
 
@@ -628,6 +635,9 @@ so the client sets it through a legacy fallback — see
628
635
  Example output::
629
636
 
630
637
  ['Networking']
638
+ 42 Reset a Wi-Fi controller
639
+ 42 Reset a Wi-Fi controller
640
+ 1 revision(s)
631
641
 
632
642
  Assigning categories
633
643
  ^^^^^^^^^^^^^^^^^^^^^
@@ -659,9 +669,6 @@ article's full category set; passing an empty list clears every category.
659
669
  # Or set them explicitly at any time.
660
670
  client.set_kb_article_categories(article_id, [14]) # replace the full set
661
671
  client.set_kb_article_categories(article_id, []) # clear all
662
- 42 Reset a Wi-Fi controller
663
- 42 Reset a Wi-Fi controller
664
- 1 revision(s)
665
672
 
666
673
  Enums
667
674
  ~~~~~
@@ -710,10 +717,11 @@ internal container and field names:
710
717
 
711
718
  .. code-block:: python
712
719
 
713
- from glpi_python_client import GlpiClient
720
+ from glpi_python_client import GlpiClient
714
721
 
715
722
  with GlpiClient(
716
723
  glpi_api_url="https://glpi.example.com/api.php/v2",
724
+ server_timezone="Europe/Paris",
717
725
  client_id="oauth-client-id",
718
726
  client_secret="oauth-client-secret",
719
727
  username="api-user",
@@ -1223,8 +1231,10 @@ Example 4 — Close a ticket with a solution
1223
1231
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1224
1232
 
1225
1233
  GLPI moves a ticket to the *Solved* status as soon as a solution is
1226
- posted, so adding a solution is the supported way to change the ticket
1227
- status from the v2 API.
1234
+ posted, so this both records *how* the ticket was resolved and advances
1235
+ its lifecycle in one call. Prefer it over setting the status directly
1236
+ whenever there is a resolution to record — a status moved on its own
1237
+ leaves no trace of why.
1228
1238
 
1229
1239
  .. code-block:: python
1230
1240
 
@@ -1248,6 +1258,34 @@ Expected Markdown (abridged)::
1248
1258
 
1249
1259
  Replaced the access point firmware.
1250
1260
 
1261
+ For the transitions that have no timeline record behind them — putting a
1262
+ ticket on hold, planning it, taking it back from *Solved* — set the
1263
+ status directly. ``PatchTicket`` carries a ``status`` field and
1264
+ ``update_ticket`` sends it:
1265
+
1266
+ .. code-block:: python
1267
+
1268
+ from glpi_python_client import GlpiTicketStatus, PatchTicket
1269
+
1270
+ client.update_ticket(ticket_id, PatchTicket(status=GlpiTicketStatus.PENDING))
1271
+
1272
+ The field lives on ``PatchTicket`` and deliberately not on
1273
+ ``PostTicket``: GLPI ignores a status sent at creation time and the new
1274
+ ticket comes back as *New*, so a create argument for it would do
1275
+ nothing.
1276
+
1277
+ .. warning::
1278
+
1279
+ ``status`` is typed as :class:`glpi_python_client.GlpiTicketStatus`
1280
+ and rejects anything outside it, because the server accepts
1281
+ everything and its interface hides the result. Writing ``99``
1282
+ answers ``200`` and stores it; the API then reports
1283
+ ``{"id": 99, "name": "99"}``, the web form displays the ticket as
1284
+ *New*, and the ticket disappears from the ticket list while staying
1285
+ open in the database — only the history records the change. A typo
1286
+ such as ``55`` for ``5`` would lose a ticket with no error anywhere,
1287
+ which is why the enum refuses it here.
1288
+
1251
1289
  Example 5 — Upload a document to an existing ticket
1252
1290
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1253
1291
 
@@ -103,8 +103,13 @@ from glpi_python_client.models import (
103
103
  PostUser,
104
104
  TicketMarkdownOptions,
105
105
  )
106
+ from glpi_python_client.rsql import (
107
+ changed_since,
108
+ created_between,
109
+ date_window,
110
+ )
106
111
 
107
- __version__ = "0.4.2"
112
+ __version__ = "0.4.3"
108
113
 
109
114
  __all__ = [
110
115
  "AsyncGlpiClient",
@@ -190,4 +195,7 @@ __all__ = [
190
195
  "PostUser",
191
196
  "TicketMarkdownOptions",
192
197
  "__version__",
198
+ "changed_since",
199
+ "created_between",
200
+ "date_window",
193
201
  ]
@@ -25,10 +25,12 @@ from glpi_python_client._async._concurrency import Lock
25
25
  from glpi_python_client._async.clients.commons._config import (
26
26
  build_client_env_config,
27
27
  build_client_resources,
28
+ resolve_server_timezone,
28
29
  )
29
30
 
30
31
  if TYPE_CHECKING:
31
32
  from collections.abc import Mapping
33
+ from datetime import tzinfo
32
34
 
33
35
  logger = logging.getLogger(__name__)
34
36
 
@@ -45,6 +47,7 @@ class _BaseGlpiClient:
45
47
  self,
46
48
  *,
47
49
  glpi_api_url: str,
50
+ server_timezone: str | tzinfo,
48
51
  client_id: str | None = None,
49
52
  client_secret: str | None = None,
50
53
  username: str | None = None,
@@ -66,6 +69,25 @@ class _BaseGlpiClient:
66
69
  glpi_api_url : str
67
70
  Base URL of the GLPI v2 REST API, e.g.
68
71
  ``https://glpi.example.com/api.php/v2``.
72
+ server_timezone : str | tzinfo
73
+ IANA name of the timezone the GLPI server runs in (e.g.
74
+ ``"Europe/Paris"``), or a ``tzinfo``. **Required**: GLPI does
75
+ not advertise it, and it governs both directions of every
76
+ timestamp the client exchanges.
77
+
78
+ Reading, it interprets the timestamps the server sends without
79
+ an offset. Writing, it is what makes an aware ``datetime``
80
+ arrive as the moment it names: GLPI reads the naive prefix of a
81
+ timestamp and discards the offset, so the value has to be
82
+ converted onto the server's clock before it is sent. Measured on
83
+ a live instance, offsets from ``-08:00`` to ``+14:00`` written
84
+ to one field all stored the same wall clock.
85
+
86
+ There is no default because every candidate is wrong somewhere
87
+ -- guessing UTC against a Europe/Paris instance shifts those
88
+ timestamps by an hour or two and never raises. Prefer a name
89
+ over a fixed offset: a name follows DST, and one instance emits
90
+ both ``+01:00`` and ``+02:00``.
69
91
  client_id : str | None, optional
70
92
  OAuth client identifier used to obtain access tokens.
71
93
  client_secret : str | None, optional
@@ -103,6 +125,7 @@ class _BaseGlpiClient:
103
125
  missing OAuth credentials together with no v1 fallback).
104
126
  """
105
127
 
128
+ self.server_timezone = resolve_server_timezone(server_timezone)
106
129
  resources = build_client_resources(
107
130
  glpi_api_url=glpi_api_url,
108
131
  client_name=type(self).__name__,
@@ -142,7 +165,8 @@ class _BaseGlpiClient:
142
165
  ``GLPI_USERNAME``, ``GLPI_PASSWORD``, ``GLPI_VERIFY_SSL``,
143
166
  ``GLPI_V1_BASE_URL``, ``GLPI_V1_USER_TOKEN``, ``GLPI_V1_APP_TOKEN``,
144
167
  ``GLPI_ENTITY``, ``GLPI_PROFILE``, ``GLPI_ENTITY_RECURSIVE``,
145
- ``GLPI_LANGUAGE``, ``GLPI_AUTH_TOKEN_REFRESH``).
168
+ ``GLPI_LANGUAGE``, ``GLPI_AUTH_TOKEN_REFRESH``,
169
+ ``GLPI_SERVER_TIMEZONE``).
146
170
 
147
171
  Parameters
148
172
  ----------
@@ -12,6 +12,7 @@ from collections.abc import AsyncIterator
12
12
 
13
13
  from glpi_python_client._async.clients.commons._constants import USER_ENDPOINT, GlpiId
14
14
  from glpi_python_client._async.clients.commons._transport import TransportMixin
15
+ from glpi_python_client._errors import GlpiValidationError
15
16
  from glpi_python_client.models.api_schema.administration._user import (
16
17
  DeleteUser,
17
18
  GetUser,
@@ -110,6 +111,81 @@ class UserMixin(TransportMixin):
110
111
  break
111
112
  start += batch_size
112
113
 
114
+ async def find_user_by_email(
115
+ self,
116
+ email: str,
117
+ *,
118
+ rsql_filter: str = "",
119
+ batch_size: int = 100,
120
+ skip_entity: bool = True,
121
+ ) -> GetUser | None:
122
+ """Return the first user holding ``email``, or ``None``.
123
+
124
+ **This scans.** GLPI exposes e-mail addresses as ``User.emails``, a
125
+ nested *array*, and the v2 filter engine cannot join a nested array
126
+ -- the structurally identical ``Ticket.team`` answers HTTP 500 for
127
+ its declared subfields and is silently ignored for every other
128
+ spelling. So there is no server-side e-mail filter to use, and the
129
+ addresses have to be compared client-side.
130
+
131
+ Nothing about that is cheap: the scan costs one request per
132
+ ``batch_size`` users until it matches, so it is meant for
133
+ occasional resolution, not for a per-request lookup. Narrow it with
134
+ ``rsql_filter`` when you can (``"is_active==true"`` is the usual
135
+ one), and cache the resulting id rather than calling this again.
136
+
137
+ A server-side fast path is deliberately *not* attempted. GLPI v2
138
+ ignores a filter field it does not recognise and answers with the
139
+ whole unfiltered table, so a guessed e-mail filter would not fail
140
+ -- it would return a plausible non-empty page whose first row is
141
+ the wrong person. Guessing is the one thing this helper exists to
142
+ stop each caller doing separately.
143
+
144
+ Parameters
145
+ ----------
146
+ email : str
147
+ Address to look for. Compared case-insensitively after
148
+ trimming surrounding whitespace, the way mail systems treat it.
149
+ rsql_filter : str, optional
150
+ Raw RSQL filter narrowing the population scanned. Empty by
151
+ default, which scans every visible user.
152
+ batch_size : int, optional
153
+ Users fetched per request while scanning (defaults to 100).
154
+ skip_entity : bool, optional
155
+ When ``True`` (the default) the ``GLPI-Entity`` header is
156
+ omitted so the scan spans every entity the caller can see. A
157
+ user whose account lives outside the client's configured entity
158
+ is invisible otherwise, and the helper would answer ``None``
159
+ for somebody who exists.
160
+
161
+ Returns
162
+ -------
163
+ GetUser | None
164
+ The first user with a matching address, or ``None`` when the
165
+ scanned population holds none.
166
+
167
+ Raises
168
+ ------
169
+ GlpiValidationError
170
+ If ``email`` is blank -- which would otherwise scan the whole
171
+ directory and match nothing.
172
+ """
173
+
174
+ needle = email.strip().casefold()
175
+ if not needle:
176
+ raise GlpiValidationError("find_user_by_email requires a non-empty address")
177
+
178
+ async for batch in self.iter_search_users(
179
+ rsql_filter,
180
+ batch_size=batch_size,
181
+ skip_entity=skip_entity,
182
+ ):
183
+ for user in batch:
184
+ for entry in user.emails or ():
185
+ if entry.email and entry.email.strip().casefold() == needle:
186
+ return user
187
+ return None
188
+
113
189
  async def get_user(self, user_id: GlpiId) -> GetUser:
114
190
  """Fetch one GLPI user by identifier.
115
191
 
@@ -15,7 +15,6 @@ from glpi_python_client._async.clients.commons._constants import (
15
15
  GlpiId,
16
16
  )
17
17
  from glpi_python_client._async.clients.commons._http import ensure_response_status
18
- from glpi_python_client._async.clients.commons._payloads import model_to_payload
19
18
  from glpi_python_client._async.clients.commons._transport import TransportMixin
20
19
  from glpi_python_client.models.api_schema.assistance._team import (
21
20
  GetTeamMember,
@@ -79,7 +78,7 @@ class TeamMemberMixin(TransportMixin):
79
78
  """
80
79
 
81
80
  endpoint = f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}"
82
- response = await self._post_request(endpoint, model_to_payload(member))
81
+ response = await self._post_request(endpoint, self._body(member))
83
82
  ensure_response_status(
84
83
  response,
85
84
  success_statuses=(200, 201),
@@ -114,7 +113,7 @@ class TeamMemberMixin(TransportMixin):
114
113
  f"{TICKET_ENDPOINT}/{ticket_id}/{TEAM_MEMBER_SUFFIX}",
115
114
  failure_message=f"Failed to remove team member on ticket {ticket_id}",
116
115
  log_message=f"GLPI API removed team member on ticket {ticket_id}",
117
- body=model_to_payload(member),
116
+ body=self._body(member),
118
117
  )
119
118
 
120
119
 
@@ -49,7 +49,11 @@ class TicketMixin(TransportMixin):
49
49
  start : int, optional
50
50
  Zero-based offset of the first record returned.
51
51
  sort : str | None, optional
52
- ``sort`` query parameter forwarded as-is, e.g. ``"date_mod desc"``.
52
+ ``sort`` query parameter, spelled ``field`` or ``field:direction``
53
+ (e.g. ``"date_mod:desc"``). Measured against GLPI 11: a space
54
+ before the direction answers **HTTP 400** ("Invalid property for
55
+ sorting"), a bare ``field`` sorts *ascending*, and a separate
56
+ ``order`` parameter is ignored.
53
57
  fields : tuple[str, ...], optional
54
58
  Restricted set of contract field names to request. Empty
55
59
  tuple lets the GLPI server pick its default field set.
@@ -94,7 +98,9 @@ class TicketMixin(TransportMixin):
94
98
  the ``limit`` parameter on each underlying
95
99
  :meth:`search_tickets` call.
96
100
  sort : str | None, optional
97
- ``sort`` query parameter forwarded as-is to each page request.
101
+ ``sort`` query parameter forwarded to each page request,
102
+ spelled ``field`` or ``field:direction`` (e.g. ``"date_mod:desc"``).
103
+ A space before the direction answers HTTP 400.
98
104
  fields : tuple[str, ...], optional
99
105
  Restricted set of contract field names to request.
100
106
 
@@ -7,6 +7,8 @@ models.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ from collections.abc import AsyncIterator
11
+
10
12
  from glpi_python_client._async.clients.commons._constants import (
11
13
  LOCATION_ENDPOINT,
12
14
  GlpiId,
@@ -52,6 +54,48 @@ class LocationMixin(TransportMixin):
52
54
  params["filter"] = rsql_filter
53
55
  return await self._resource_list(LOCATION_ENDPOINT, GetLocation, params=params)
54
56
 
57
+ async def iter_search_locations(
58
+ self,
59
+ rsql_filter: str = "",
60
+ *,
61
+ batch_size: int = 50,
62
+ ) -> AsyncIterator[list[GetLocation]]:
63
+ """Yield successive pages of GLPI locations until exhausted.
64
+
65
+ The generator drives pagination automatically by advancing the
66
+ ``start`` offset after each batch. Iteration stops when the server
67
+ returns fewer items than ``batch_size``, which signals the last page.
68
+
69
+ Parameters
70
+ ----------
71
+ rsql_filter : str, optional
72
+ Raw RSQL filter forwarded as the ``filter`` query parameter.
73
+ Empty by default, which lists every visible record.
74
+ batch_size : int, optional
75
+ Number of records requested per page (default 50). Acts as the
76
+ ``limit`` parameter on each underlying :meth:`search_locations`
77
+ call.
78
+
79
+ Yields
80
+ ------
81
+ list[GetLocation]
82
+ One page per iteration. The last yielded batch may be shorter
83
+ than ``batch_size``.
84
+ """
85
+
86
+ start = 0
87
+ while True:
88
+ batch = await self.search_locations(
89
+ rsql_filter,
90
+ limit=batch_size,
91
+ start=start,
92
+ )
93
+ if batch:
94
+ yield batch
95
+ if len(batch) < batch_size:
96
+ break
97
+ start += batch_size
98
+
55
99
  async def get_location(self, location_id: GlpiId) -> GetLocation:
56
100
  """Fetch one GLPI location by identifier.
57
101