howler-api 4.1.0.dev1405__tar.gz → 4.1.0.dev1430__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 (227) hide show
  1. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/PKG-INFO +1 -1
  2. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/actions/transition.py +32 -10
  3. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/hit.py +19 -14
  4. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/cronjobs/action_queue_worker.py +6 -3
  5. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/collection.py +4 -0
  6. howler_api-4.1.0.dev1430/howler/external/check_duplicates.py +152 -0
  7. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/correlation_service.py +1 -1
  8. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/hit_service.py +31 -16
  9. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/pyproject.toml +1 -1
  10. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/README.md +0 -0
  11. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/__init__.py +0 -0
  12. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/actions/__init__.py +0 -0
  13. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/actions/add_label.py +0 -0
  14. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/actions/add_to_bundle.py +0 -0
  15. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/actions/add_to_case.py +0 -0
  16. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/actions/change_field.py +0 -0
  17. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/actions/demote.py +0 -0
  18. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/actions/example_plugin.py +0 -0
  19. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/actions/prioritization.py +0 -0
  20. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/actions/promote.py +0 -0
  21. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/actions/remove_from_bundle.py +0 -0
  22. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/actions/remove_label.py +0 -0
  23. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/__init__.py +0 -0
  24. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/base.py +0 -0
  25. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/socket.py +0 -0
  26. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/__init__.py +0 -0
  27. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/action.py +0 -0
  28. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/analytic.py +0 -0
  29. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/auth.py +0 -0
  30. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/clue.py +0 -0
  31. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/configs.py +0 -0
  32. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/dossier.py +0 -0
  33. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/help.py +0 -0
  34. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/notebook.py +0 -0
  35. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/overview.py +0 -0
  36. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/search.py +0 -0
  37. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/template.py +0 -0
  38. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/tool.py +0 -0
  39. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/user.py +0 -0
  40. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/utils/__init__.py +0 -0
  41. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/utils/etag.py +0 -0
  42. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/utils/params.py +0 -0
  43. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v1/view.py +0 -0
  44. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v2/__init__.py +0 -0
  45. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v2/case.py +0 -0
  46. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v2/fuzzy.py +0 -0
  47. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v2/ingest.py +0 -0
  48. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/api/v2/search.py +0 -0
  49. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/app.py +0 -0
  50. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/common/README.md +0 -0
  51. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/common/__init__.py +0 -0
  52. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/common/classification.py +0 -0
  53. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/common/classification.yml +0 -0
  54. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/common/exceptions.py +0 -0
  55. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/common/loader.py +0 -0
  56. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/common/logging/__init__.py +0 -0
  57. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/common/logging/audit.py +0 -0
  58. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/common/logging/format.py +0 -0
  59. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/common/net.py +0 -0
  60. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/common/net_static.py +0 -0
  61. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/common/random_user.py +0 -0
  62. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/common/swagger.py +0 -0
  63. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/config.py +0 -0
  64. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/cronjobs/__init__.py +0 -0
  65. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/cronjobs/correlation.py +0 -0
  66. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/cronjobs/retention.py +0 -0
  67. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/cronjobs/view_cleanup.py +0 -0
  68. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/README.md +0 -0
  69. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/__init__.py +0 -0
  70. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/bulk.py +0 -0
  71. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/constants.py +0 -0
  72. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/exceptions.py +0 -0
  73. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/howler_store.py +0 -0
  74. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/migrations/fix_process.py +0 -0
  75. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/operations.py +0 -0
  76. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/schemas.py +0 -0
  77. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/store.py +0 -0
  78. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/support/__init__.py +0 -0
  79. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/support/build.py +0 -0
  80. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/support/schemas.py +0 -0
  81. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/types.py +0 -0
  82. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/datastore/utils.py +0 -0
  83. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/error.py +0 -0
  84. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/external/README.md +0 -0
  85. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/external/__init__.py +0 -0
  86. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/external/generate_mitre.py +0 -0
  87. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/external/generate_sigma_rules.py +0 -0
  88. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/external/generate_tlds.py +0 -0
  89. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/external/reindex_data.py +0 -0
  90. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/external/wipe_databases.py +0 -0
  91. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/gunicorn_config.py +0 -0
  92. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/healthz.py +0 -0
  93. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/helper/__init__.py +0 -0
  94. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/helper/azure.py +0 -0
  95. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/helper/discover.py +0 -0
  96. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/helper/hit.py +0 -0
  97. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/helper/oauth.py +0 -0
  98. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/helper/search.py +0 -0
  99. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/helper/workflow.py +0 -0
  100. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/helper/ws.py +0 -0
  101. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/README.md +0 -0
  102. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/__init__.py +0 -0
  103. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/base.py +0 -0
  104. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/charter.txt +0 -0
  105. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/constants.py +0 -0
  106. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/helper.py +0 -0
  107. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/howler_enum.py +0 -0
  108. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/mixins.py +0 -0
  109. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/__init__.py +0 -0
  110. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/action.py +0 -0
  111. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/analytic.py +0 -0
  112. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/assemblyline.py +0 -0
  113. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/aws.py +0 -0
  114. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/azure.py +0 -0
  115. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/case.py +0 -0
  116. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/cbs.py +0 -0
  117. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/clue.py +0 -0
  118. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/config.py +0 -0
  119. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/dossier.py +0 -0
  120. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/__init__.py +0 -0
  121. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/agent.py +0 -0
  122. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/autonomous_system.py +0 -0
  123. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/client.py +0 -0
  124. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/cloud.py +0 -0
  125. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/code_signature.py +0 -0
  126. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/container.py +0 -0
  127. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/dns.py +0 -0
  128. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/egress.py +0 -0
  129. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/elf.py +0 -0
  130. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/email.py +0 -0
  131. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/error.py +0 -0
  132. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/event.py +0 -0
  133. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/faas.py +0 -0
  134. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/file.py +0 -0
  135. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/geo.py +0 -0
  136. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/group.py +0 -0
  137. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/hash.py +0 -0
  138. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/host.py +0 -0
  139. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/http.py +0 -0
  140. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/ingress.py +0 -0
  141. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/interface.py +0 -0
  142. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/network.py +0 -0
  143. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/observer.py +0 -0
  144. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/organization.py +0 -0
  145. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/os.py +0 -0
  146. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/pe.py +0 -0
  147. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/process.py +0 -0
  148. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/registry.py +0 -0
  149. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/related.py +0 -0
  150. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/rule.py +0 -0
  151. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/server.py +0 -0
  152. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/threat.py +0 -0
  153. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/tls.py +0 -0
  154. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/url.py +0 -0
  155. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/user.py +0 -0
  156. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/user_agent.py +0 -0
  157. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/ecs/vulnerability.py +0 -0
  158. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/event.py +0 -0
  159. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/gcp.py +0 -0
  160. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/hit.py +0 -0
  161. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/howler_data.py +0 -0
  162. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/lead.py +0 -0
  163. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/localized_label.py +0 -0
  164. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/overview.py +0 -0
  165. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/pivot.py +0 -0
  166. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/record.py +0 -0
  167. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/template.py +0 -0
  168. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/user.py +0 -0
  169. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/models/view.py +0 -0
  170. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/random_data.py +0 -0
  171. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/odm/randomizer.py +0 -0
  172. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/patched.py +0 -0
  173. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/plugins/__init__.py +0 -0
  174. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/plugins/config.py +0 -0
  175. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/remote/__init__.py +0 -0
  176. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/remote/datatypes/README.md +0 -0
  177. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/remote/datatypes/__init__.py +0 -0
  178. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/remote/datatypes/counters.py +0 -0
  179. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/remote/datatypes/events.py +0 -0
  180. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/remote/datatypes/hash.py +0 -0
  181. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/remote/datatypes/lock.py +0 -0
  182. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/remote/datatypes/queues/__init__.py +0 -0
  183. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/remote/datatypes/queues/comms.py +0 -0
  184. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/remote/datatypes/queues/multi.py +0 -0
  185. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/remote/datatypes/queues/named.py +0 -0
  186. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/remote/datatypes/queues/priority.py +0 -0
  187. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/remote/datatypes/set.py +0 -0
  188. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/remote/datatypes/user_quota_tracker.py +0 -0
  189. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/security/__init__.py +0 -0
  190. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/security/socket.py +0 -0
  191. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/security/utils.py +0 -0
  192. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/__init__.py +0 -0
  193. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/action_service.py +0 -0
  194. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/analytic_service.py +0 -0
  195. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/auth_service.py +0 -0
  196. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/bundle_compat_service.py +0 -0
  197. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/case_service.py +0 -0
  198. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/comms_service.py +0 -0
  199. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/config_service.py +0 -0
  200. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/docs_service.py +0 -0
  201. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/dossier_service.py +0 -0
  202. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/event_service.py +0 -0
  203. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/fuzzy_service.py +0 -0
  204. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/jwt_service.py +0 -0
  205. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/lucene_service.py +0 -0
  206. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/notebook_service.py +0 -0
  207. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/overview_service.py +0 -0
  208. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/search_service.py +0 -0
  209. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/template_service.py +0 -0
  210. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/user_service.py +0 -0
  211. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/services/viewer_service.py +0 -0
  212. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/telemetry.py +0 -0
  213. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/__init__.py +0 -0
  214. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/annotations.py +0 -0
  215. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/chunk.py +0 -0
  216. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/compat.py +0 -0
  217. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/constants.py +0 -0
  218. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/dict_utils.py +0 -0
  219. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/indexes.py +0 -0
  220. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/isotime.py +0 -0
  221. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/list_utils.py +0 -0
  222. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/lucene.py +0 -0
  223. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/net_utils.py +0 -0
  224. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/path.py +0 -0
  225. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/socket_utils.py +0 -0
  226. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/str_utils.py +0 -0
  227. {howler_api-4.1.0.dev1405 → howler_api-4.1.0.dev1430}/howler/utils/uid.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: howler-api
3
- Version: 4.1.0.dev1405
3
+ Version: 4.1.0.dev1430
4
4
  Summary: Howler - API server
5
5
  License: MIT
6
6
  Keywords: howler,alerting,gc,canada,cse-cst,cse,cst,cyber,cccs
@@ -5,6 +5,7 @@ from howler.actions import check_hit_limit
5
5
  from howler.common.exceptions import InvalidDataException, NotFoundException
6
6
  from howler.common.loader import datastore
7
7
  from howler.common.logging import get_logger
8
+ from howler.datastore.exceptions import VersionConflictException
8
9
  from howler.helper.workflow import Workflow, WorkflowException
9
10
  from howler.odm.models.action import VALID_TRIGGERS
10
11
  from howler.odm.models.howler_data import (
@@ -20,11 +21,39 @@ from howler.utils.list_utils import flatten_list
20
21
  OPERATION_ID = "transition"
21
22
  MAX_HITS_BASIC = 10
22
23
  MAX_HITS_ADVANCED = 1000
24
+ MAX_VERSION_CONFLICT_ATTEMPTS = 3
23
25
  SKIP_CENTRAL_LIMIT = True # This operation transforms the query, handles limit check locally
24
26
 
25
27
  log = get_logger(__file__)
26
28
 
27
29
 
30
+ def _transition_failure_report(hit_id: str, error: Exception) -> dict[str, str]:
31
+ if isinstance(error, VersionConflictException):
32
+ return {
33
+ "query": f"howler.id:{hit_id}",
34
+ "outcome": "error",
35
+ "title": "Version Conflict",
36
+ "message": "The hit was modified while this transition was running.",
37
+ }
38
+
39
+ return {
40
+ "query": f"howler.id:{hit_id}",
41
+ "outcome": "error",
42
+ "title": "An error occurred while processing.",
43
+ "message": str(error),
44
+ }
45
+
46
+
47
+ def _transition_hit_with_retry(hit_id: str, transition: HitStatusTransition, user: User, **kwargs) -> None:
48
+ for attempt in range(MAX_VERSION_CONFLICT_ATTEMPTS):
49
+ try:
50
+ hit_service.transition_hit(hit_id, transition, user, **kwargs)
51
+ return
52
+ except VersionConflictException:
53
+ if attempt == MAX_VERSION_CONFLICT_ATTEMPTS - 1:
54
+ raise
55
+
56
+
28
57
  def __parse_workflow_actions(workflow: Workflow) -> dict[str, set[str]]:
29
58
  """Take in a workflow, and parse the steps and transitions of that workflow into a format understood by the UI"""
30
59
  parsed_args: dict[str, set[str]] = {}
@@ -126,22 +155,15 @@ def execute(
126
155
  total_processed = 0
127
156
  for hit_id in ids:
128
157
  try:
129
- hit_service.transition_hit(
158
+ _transition_hit_with_retry(
130
159
  hit_id,
131
160
  cast(HitStatusTransition, HitStatusTransition[transition]),
132
161
  user,
133
162
  **kwargs,
134
163
  )
135
164
  success_ids.add(hit_id)
136
- except (InvalidDataException, NotFoundException, WorkflowException) as e:
137
- report.append(
138
- {
139
- "query": f"howler.id:{hit_id}",
140
- "outcome": "error",
141
- "title": "An error occurred while processing.",
142
- "message": str(e),
143
- }
144
- )
165
+ except (InvalidDataException, NotFoundException, VersionConflictException, WorkflowException) as e:
166
+ report.append(_transition_failure_report(hit_id, e))
145
167
 
146
168
  total_processed += 1
147
169
  if total_processed % 10 == 0:
@@ -528,7 +528,7 @@ def get_assigned_hits(user, **kwargs):
528
528
  @api_login(audit=False, required_priv=["W"])
529
529
  @add_etag(getter=hit_service.get_hit, check_if_match=False)
530
530
  @parse_parameters(refresh=parse_refresh)
531
- def add_label(id, label_set, user, **kwargs):
531
+ def add_label(id: str, label_set: str, user: User, server_version: str | None = None, **kwargs):
532
532
  """Add labels to a hit.
533
533
 
534
534
  Variables:
@@ -575,7 +575,8 @@ def add_label(id, label_set, user, **kwargs):
575
575
  hit_service.update_hit(
576
576
  id,
577
577
  [hit_helper.list_add(f"howler.labels.{label_set}", label) for label in labels],
578
- user["uname"],
578
+ user.uname,
579
+ version=server_version,
579
580
  refresh="true" if DEBUG_FORCE_REFRESH else refresh,
580
581
  )
581
582
 
@@ -591,7 +592,7 @@ def add_label(id, label_set, user, **kwargs):
591
592
  @api_login(audit=False, required_priv=["W"])
592
593
  @add_etag(getter=hit_service.get_hit, check_if_match=False)
593
594
  @parse_parameters(refresh=parse_refresh)
594
- def remove_labels(id, label_set, user, **kwargs):
595
+ def remove_labels(id: str, label_set: str, user: User, server_version: str | None = None, **kwargs):
595
596
  """Remove labels from a hit.
596
597
 
597
598
  Variables:
@@ -632,7 +633,8 @@ def remove_labels(id, label_set, user, **kwargs):
632
633
  hit_service.update_hit(
633
634
  id,
634
635
  [hit_helper.list_remove(f"howler.labels.{label_set}", label) for label in labels],
635
- user["uname"],
636
+ user.uname,
637
+ version=server_version,
636
638
  refresh="true" if DEBUG_FORCE_REFRESH else refresh,
637
639
  )
638
640
 
@@ -648,7 +650,7 @@ def remove_labels(id, label_set, user, **kwargs):
648
650
  @api_login(audit=False, required_priv=["W"])
649
651
  @add_etag(getter=hit_service.get_hit, check_if_match=True)
650
652
  @parse_parameters(refresh=parse_refresh)
651
- def transition(id: str, user: User, **kwargs):
653
+ def transition(id: str, user: User, server_version: str | None = None, **kwargs):
652
654
  """Transition a hit
653
655
 
654
656
  Variables:
@@ -679,11 +681,12 @@ def transition(id: str, user: User, **kwargs):
679
681
  return bad_request(err="Invalid data format")
680
682
 
681
683
  transition = transition_data["transition"]
684
+ version: str | None = None
682
685
  if "If-Match" in request.headers:
683
686
  version = request.headers["If-Match"]
684
687
  else:
685
688
  logger.warning("User is mising version - no If-Match header in request.")
686
- version = None
689
+ version = server_version
687
690
 
688
691
  try:
689
692
  if transition not in HitStatusTransition.list():
@@ -694,7 +697,7 @@ def transition(id: str, user: User, **kwargs):
694
697
  )
695
698
  )
696
699
 
697
- hit_service.transition_hit(
700
+ hit, version = hit_service.transition_hit(
698
701
  id, transition, user, version, refresh=refresh, **kwargs, **transition_data.get("data", {})
699
702
  )
700
703
  except (WorkflowException, DataStoreException, InvalidDataException) as e:
@@ -704,7 +707,6 @@ def transition(id: str, user: User, **kwargs):
704
707
  except HowlerException as e:
705
708
  return internal_error(err=str(e))
706
709
 
707
- hit, version = hit_service.get_hit(id, as_odm=False, version=True)
708
710
  return ok(hit), version
709
711
 
710
712
 
@@ -712,7 +714,7 @@ def transition(id: str, user: User, **kwargs):
712
714
  @hit_api.route("/<id>/comments/<comment_id>", methods=["GET"])
713
715
  @api_login(audit=False, required_priv=["R"])
714
716
  @add_etag(getter=hit_service.get_hit, check_if_match=False)
715
- def get_comment(id: str, comment_id: str, user: User, server_version: str, **kwargs):
717
+ def get_comment(id: str, comment_id: str, user: User, server_version: str | None = None, **kwargs):
716
718
  """Get a comment associated with a particular hit
717
719
 
718
720
  Variables:
@@ -741,7 +743,7 @@ def get_comment(id: str, comment_id: str, user: User, server_version: str, **kwa
741
743
  @hit_api.route("/<id>/comments", methods=["POST"])
742
744
  @api_login(audit=False, required_priv=["W"])
743
745
  @add_etag(getter=hit_service.get_hit, check_if_match=False)
744
- def add_comment(id: str, user: dict[str, Any], **kwargs):
746
+ def add_comment(id: str, user: User, server_version: str | None = None, **kwargs):
745
747
  """Add a comment
746
748
 
747
749
  Variables:
@@ -781,12 +783,13 @@ def add_comment(id: str, user: dict[str, Any], **kwargs):
781
783
  [
782
784
  hit_helper.list_add(
783
785
  "howler.comment",
784
- Comment({"user": user["uname"], "value": comment_value}),
786
+ Comment({"user": user.uname, "value": comment_value}),
785
787
  explanation=f"Added a comment:\n\n{comment_value}",
786
788
  if_missing=True,
787
789
  ),
788
790
  ],
789
- user["uname"],
791
+ user.uname,
792
+ version=server_version,
790
793
  )
791
794
  except DataStoreException as e:
792
795
  return bad_request(err=str(e))
@@ -800,7 +803,7 @@ def add_comment(id: str, user: dict[str, Any], **kwargs):
800
803
  @hit_api.route("/<id>/comments/<comment_id>", methods=["PUT"])
801
804
  @api_login(audit=False, required_priv=["W"])
802
805
  @add_etag(getter=hit_service.get_hit, check_if_match=False)
803
- def edit_comment(id: str, comment_id: str, user: dict[str, Any], **kwargs):
806
+ def edit_comment(id: str, comment_id: str, user: dict[str, Any], server_version: str | None = None, **kwargs):
804
807
  """Edit a comment
805
808
 
806
809
  Variables:
@@ -865,6 +868,7 @@ def edit_comment(id: str, comment_id: str, user: dict[str, Any], **kwargs):
865
868
  ),
866
869
  ],
867
870
  user["uname"],
871
+ version=server_version,
868
872
  )
869
873
 
870
874
  return ok(new_hit), version
@@ -874,7 +878,7 @@ def edit_comment(id: str, comment_id: str, user: dict[str, Any], **kwargs):
874
878
  @hit_api.route("/<id>/comments", methods=["DELETE"])
875
879
  @api_login(audit=False, required_priv=["W"])
876
880
  @add_etag(getter=hit_service.get_hit, check_if_match=False)
877
- def delete_comments(id: str, user: User, **kwargs):
881
+ def delete_comments(id: str, user: User, server_version: str | None = None, **kwargs):
878
882
  """Delete a set of comments
879
883
 
880
884
  Variables:
@@ -923,6 +927,7 @@ def delete_comments(id: str, user: User, **kwargs):
923
927
  for comment in comments
924
928
  ],
925
929
  user.uname,
930
+ version=server_version,
926
931
  )
927
932
 
928
933
  except DataStoreException as e:
@@ -50,15 +50,18 @@ def run_worker(trigger: str) -> None: # pragma: no cover – long-running loop,
50
50
  if item is not None:
51
51
  batch.append(item)
52
52
 
53
+ if len(batch) % 10 == 0 or len(batch) >= BATCH_SIZE:
54
+ logger.info("Batch size: %s", len(batch))
55
+
53
56
  if len(batch) >= BATCH_SIZE or (item is None and batch):
57
+ finalized_batch = [*batch]
58
+ batch = []
54
59
  logger.debug("Processing action batch of %d item(s) for trigger=%s", len(batch), trigger)
55
60
  try:
56
- process_action_batch(trigger, batch)
61
+ process_action_batch(trigger, finalized_batch)
57
62
  logger.info("Action batch complete: %d item(s) processed for trigger=%s", len(batch), trigger)
58
63
  except Exception:
59
64
  logger.exception("Error processing action batch for trigger=%s", trigger)
60
- finally:
61
- batch = []
62
65
  except Exception:
63
66
  logger.exception("Unexpected error in action queue worker loop for trigger=%s", trigger)
64
67
 
@@ -1613,6 +1613,10 @@ class ESCollection(Generic[ModelType]):
1613
1613
  index = self.name
1614
1614
  seq_no = None
1615
1615
  primary_term = None
1616
+
1617
+ if version is None and self.ilm_config:
1618
+ _, version = self.get_if_exists(key, as_obj=False, version=True)
1619
+
1616
1620
  if version:
1617
1621
  index, seq_no, primary_term = self._get_version_write_target(version)
1618
1622
 
@@ -0,0 +1,152 @@
1
+ """Find and optionally remove duplicate documents from an ILM collection."""
2
+
3
+ import argparse
4
+ import heapq
5
+ import sys
6
+ from collections.abc import Iterator
7
+ from typing import Any
8
+
9
+
10
+ def iter_index_ids(collection: Any, index: str, batch_size: int) -> Iterator[str]:
11
+ """Yield document IDs from *index* in sorted order without retaining prior pages."""
12
+ search_after = None
13
+
14
+ while True:
15
+ search_args = {
16
+ "index": index,
17
+ "query": {"match_all": {}},
18
+ "sort": [{"id": "asc"}],
19
+ "size": batch_size,
20
+ "_source": False,
21
+ "track_total_hits": False,
22
+ }
23
+ if search_after is not None:
24
+ search_args["search_after"] = search_after
25
+
26
+ response = collection.with_retries(collection.datastore.client.search, **search_args)
27
+ hits = response["hits"]["hits"]
28
+ if not hits:
29
+ return
30
+
31
+ for hit in hits:
32
+ yield hit["_id"]
33
+
34
+ search_after = hits[-1]["sort"]
35
+
36
+
37
+ def _push_next_id(heap: list[tuple[str, str, Iterator[str]]], index: str, document_ids: Iterator[str]) -> None:
38
+ try:
39
+ heapq.heappush(heap, (next(document_ids), index, document_ids))
40
+ except StopIteration:
41
+ pass
42
+
43
+
44
+ def iter_duplicates(collection: Any, indexes: list[str], batch_size: int) -> Iterator[tuple[str, list[str]]]:
45
+ """Yield each duplicated ID and the physical indexes that contain it.
46
+
47
+ This is a k-way merge over paginated, sorted searches. Its memory use is bounded by
48
+ the number of physical indexes and one Elasticsearch result page per active iterator.
49
+ """
50
+ heap: list[tuple[str, str, Iterator[str]]] = []
51
+ for index in indexes:
52
+ document_ids = iter_index_ids(collection, index, batch_size)
53
+ _push_next_id(heap, index, document_ids)
54
+
55
+ while heap:
56
+ document_id, index, document_ids = heapq.heappop(heap)
57
+ duplicate_indexes = [index]
58
+ _push_next_id(heap, index, document_ids)
59
+
60
+ while heap and heap[0][0] == document_id:
61
+ _, index, document_ids = heapq.heappop(heap)
62
+ duplicate_indexes.append(index)
63
+ _push_next_id(heap, index, document_ids)
64
+
65
+ if len(duplicate_indexes) > 1:
66
+ yield document_id, duplicate_indexes
67
+
68
+
69
+ def delete_duplicates(collection: Any, document_id: str, indexes: list[str]) -> int:
70
+ """Delete all supplied physical-index copies of a document ID."""
71
+ deleted = 0
72
+ for index in indexes:
73
+ response = collection.with_retries(collection.datastore.client.delete, index=index, id=document_id)
74
+ if response["result"] == "deleted":
75
+ deleted += 1
76
+ return deleted
77
+
78
+
79
+ def main() -> int:
80
+ """Run the interactive duplicate scan for an ILM collection."""
81
+ parser = argparse.ArgumentParser(
82
+ description="Find duplicate document IDs across an ILM collection's physical indexes."
83
+ )
84
+ parser.add_argument("collection", help="Collection name registered in the datastore.")
85
+ parser.add_argument(
86
+ "--batch-size",
87
+ type=int,
88
+ default=1_000,
89
+ help="IDs fetched from each physical index per Elasticsearch request (default: 1000).",
90
+ )
91
+ args = parser.parse_args()
92
+
93
+ if args.batch_size <= 0:
94
+ parser.error("--batch-size must be a positive integer.")
95
+
96
+ from howler.datastore.collection import ESCollection
97
+
98
+ # Checking duplicates must not create collections or alter their ILM configuration.
99
+ ESCollection.IGNORE_ENSURE_COLLECTION = True
100
+ ESCollection.ENSURE_COLLECTION_WARNED = True
101
+
102
+ from howler.common import loader
103
+
104
+ datastore = loader.datastore(archive_access=False)
105
+ collection_names = datastore.ds.get_models()
106
+ if args.collection not in collection_names:
107
+ parser.error(
108
+ f"Unknown collection '{args.collection}'. Valid collections: {', '.join(sorted(collection_names))}"
109
+ )
110
+
111
+ collection = getattr(datastore, args.collection)
112
+ if collection.ilm_config is None:
113
+ print(f"Collection '{args.collection}' is not ILM-enabled; nothing to check.")
114
+ return 0
115
+
116
+ collection._refresh_ilm_index_name()
117
+ indexes = collection.index_list_full
118
+ if len(indexes) < 2:
119
+ print(f"Collection '{args.collection}' has fewer than two physical ILM indexes; nothing to check.")
120
+ return 0
121
+
122
+ print(f"Scanning for duplicate IDs in: {', '.join(indexes)}")
123
+ duplicates_found = 0
124
+ copies_deleted = 0
125
+ index_order = {index: position for position, index in enumerate(indexes)}
126
+
127
+ try:
128
+ for document_id, duplicate_indexes in iter_duplicates(collection, indexes, args.batch_size):
129
+ duplicates_found += 1
130
+ keep_index = min(duplicate_indexes, key=index_order.__getitem__)
131
+ stale_indexes = [index for index in duplicate_indexes if index != keep_index]
132
+ print(
133
+ f"Duplicate ID '{document_id}' found in {', '.join(duplicate_indexes)}. "
134
+ f"Keeping the newest copy in '{keep_index}'."
135
+ )
136
+ answer = input(f"Delete copies from {', '.join(stale_indexes)}? [yes/NO] ")
137
+ if answer.lower() in {"y", "yes"}:
138
+ deleted_count = delete_duplicates(collection, document_id, stale_indexes)
139
+ copies_deleted += deleted_count
140
+ print(f"Deleted {deleted_count} copy/copies of '{document_id}'.")
141
+ except KeyboardInterrupt:
142
+ print("\nInterrupted; all completed deletions have been retained.", file=sys.stderr)
143
+ return 130
144
+ finally:
145
+ datastore.ds.close()
146
+
147
+ print(f"Scan complete. Found {duplicates_found} duplicate ID(s); deleted {copies_deleted} stale copy/copies.")
148
+ return 0
149
+
150
+
151
+ if __name__ == "__main__":
152
+ sys.exit(main())
@@ -345,7 +345,7 @@ def run_worker() -> None: # pragma: no cover – long-running loop, tested via
345
345
  if item is not None:
346
346
  batch.append(item)
347
347
 
348
- if len(batch) > 0:
348
+ if len(batch) % 10 == 0 or len(batch) >= BATCH_SIZE:
349
349
  logger.info("Batch size: %s", len(batch))
350
350
 
351
351
  if len(batch) >= BATCH_SIZE or (item is None and batch):
@@ -549,7 +549,7 @@ def _update_hit(
549
549
  user: Optional[str] = None,
550
550
  version: Optional[str] = None,
551
551
  refresh: str | None = None,
552
- ) -> tuple[dict[str, Any] | None, str | None]:
552
+ ) -> tuple[Hit | None, str | None]:
553
553
  """Internal function to update a hit with proper logging and event emission.
554
554
 
555
555
  This function applies update operations to a hit, automatically adding worklog entries
@@ -573,7 +573,11 @@ def _update_hit(
573
573
  if user and not isinstance(user, str):
574
574
  raise HowlerValueError("User must be of type string")
575
575
 
576
- current_hit = cast(Hit, get_hit(hit_id, as_odm=True))
576
+ if version is None:
577
+ fetched_hit, version = datastore().hit.get(hit_id, as_obj=True, version=True)
578
+ current_hit = cast(Hit, fetched_hit)
579
+ else:
580
+ current_hit = cast(Hit, get_hit(hit_id, as_odm=True))
577
581
 
578
582
  for operation in operations:
579
583
  if not operation:
@@ -590,15 +594,16 @@ def _update_hit(
590
594
  is_list = current_hit.flat_fields()[key].multivalued
591
595
  previous_value = "list"
592
596
 
593
- operation_type = ""
594
597
  if is_list:
595
- if operation.operation in (
596
- ESCollection.UPDATE_APPEND,
597
- ESCollection.UPDATE_APPEND_IF_MISSING,
598
- ):
599
- operation_type = HitOperationType.APPENDED
600
- else:
601
- operation_type = HitOperationType.REMOVED
598
+ operation_type = (
599
+ HitOperationType.APPENDED
600
+ if operation.operation
601
+ in (
602
+ ESCollection.UPDATE_APPEND,
603
+ ESCollection.UPDATE_APPEND_IF_MISSING,
604
+ )
605
+ else HitOperationType.REMOVED
606
+ )
602
607
  else:
603
608
  operation_type = HitOperationType.SET
604
609
 
@@ -625,9 +630,9 @@ def _update_hit(
625
630
 
626
631
  datastore().hit.update(hit_id, final_operations, version, refresh=refresh)
627
632
  # Need to fetch the new data of the hit for the comms_service
628
- data, _version = datastore().hit.get(hit_id, as_obj=False, version=True) or (None, None)
633
+ data, _version = datastore().hit.get(hit_id, as_obj=True, version=True) or (None, None)
629
634
  if data and _version:
630
- comms_service.emit("hits", {"hit": data, "version": _version})
635
+ comms_service.emit("hits", {"hit": data.as_primitives(), "version": _version})
631
636
 
632
637
  return data, _version
633
638
 
@@ -652,7 +657,7 @@ def transition_hit(
652
657
  version: Optional[str] = None,
653
658
  refresh: str | None = None,
654
659
  **kwargs,
655
- ):
660
+ ) -> tuple[Hit | None, str | None]:
656
661
  """Transition a hit from one status to another while updating related properties.
657
662
 
658
663
  For certain transitions (PROMOTE, DEMOTE, ASSESS, RE_EVALUATE), it also executes bulk actions and emits events.
@@ -664,6 +669,9 @@ def transition_hit(
664
669
  version: Optional version to validate against. The transition will not run if the version doesn't match.
665
670
  **kwargs: Additional arguments including potential 'hit' object and 'assessment' value
666
671
 
672
+ Returns:
673
+ A tuple containing the updated hit and the optimistic-lock version token after the transition.
674
+
667
675
  Raises:
668
676
  NotFoundException: If the hit does not exist
669
677
  """
@@ -684,10 +692,12 @@ def transition_hit(
684
692
 
685
693
  # Apply the workflow transition to get required updates
686
694
  updates = workflow.transition(hit_status, transition, user=user, hit=hit, **kwargs)
695
+ new_version = version
687
696
 
688
697
  # Apply updates if any were generated by the workflow
698
+ updated_hit: Hit | None = None
689
699
  if updates:
690
- _update_hit(hit_id, updates, user.uname, version=version, refresh=refresh)
700
+ updated_hit, new_version = _update_hit(hit_id, updates, user.uname, version=version, refresh=refresh)
691
701
 
692
702
  # Execute bulk actions for transitions that require them
693
703
  # These transitions need additional processing beyond the workflow
@@ -720,8 +730,13 @@ def transition_hit(
720
730
  action_service.enqueue_action_execution([hit_id], trigger=trigger, user=user)
721
731
 
722
732
  # Emit events for processed hit to notify other systems
723
- data, hit_version = datastore().hit.get(hit_id, as_obj=False, version=True)
724
- comms_service.emit("hits", {"hit": data, "version": hit_version})
733
+ updated_hit, new_version = datastore().hit.get(hit_id, as_obj=True, version=True)
734
+ comms_service.emit(
735
+ "hits", {"hit": updated_hit.as_primitives() if updated_hit else None, "version": new_version}
736
+ )
737
+
738
+ updated_hit, new_version = datastore().hit.get(hit_id, as_obj=True, version=True)
739
+ return updated_hit, new_version
725
740
 
726
741
 
727
742
  DELETED_HITS = Counter(f"{APP_NAME.replace('-', '_')}_deleted_hits_total", "The number of deleted hits")
@@ -152,7 +152,7 @@ suppress-none-returning = true
152
152
  [tool.poetry]
153
153
  package-mode = true
154
154
  name = "howler-api"
155
- version = "4.1.0.dev1405"
155
+ version = "4.1.0.dev1430"
156
156
  description = "Howler - API server"
157
157
  authors = [
158
158
  "Canadian Centre for Cyber Security <howler@cyber.gc.ca>",