howler-api 3.0.0.dev374__py3-none-any.whl

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.

Potentially problematic release.


This version of howler-api might be problematic. Click here for more details.

Files changed (198) hide show
  1. howler/__init__.py +0 -0
  2. howler/actions/__init__.py +168 -0
  3. howler/actions/add_label.py +111 -0
  4. howler/actions/add_to_bundle.py +159 -0
  5. howler/actions/change_field.py +76 -0
  6. howler/actions/demote.py +160 -0
  7. howler/actions/example_plugin.py +104 -0
  8. howler/actions/prioritization.py +93 -0
  9. howler/actions/promote.py +147 -0
  10. howler/actions/remove_from_bundle.py +133 -0
  11. howler/actions/remove_label.py +111 -0
  12. howler/actions/transition.py +200 -0
  13. howler/api/__init__.py +249 -0
  14. howler/api/base.py +88 -0
  15. howler/api/socket.py +114 -0
  16. howler/api/v1/__init__.py +97 -0
  17. howler/api/v1/action.py +372 -0
  18. howler/api/v1/analytic.py +748 -0
  19. howler/api/v1/auth.py +382 -0
  20. howler/api/v1/clue.py +99 -0
  21. howler/api/v1/configs.py +58 -0
  22. howler/api/v1/dossier.py +222 -0
  23. howler/api/v1/help.py +28 -0
  24. howler/api/v1/hit.py +1181 -0
  25. howler/api/v1/notebook.py +82 -0
  26. howler/api/v1/overview.py +191 -0
  27. howler/api/v1/search.py +788 -0
  28. howler/api/v1/template.py +206 -0
  29. howler/api/v1/tool.py +183 -0
  30. howler/api/v1/user.py +416 -0
  31. howler/api/v1/utils/__init__.py +0 -0
  32. howler/api/v1/utils/etag.py +84 -0
  33. howler/api/v1/view.py +288 -0
  34. howler/app.py +235 -0
  35. howler/common/README.md +125 -0
  36. howler/common/__init__.py +0 -0
  37. howler/common/classification.py +979 -0
  38. howler/common/classification.yml +107 -0
  39. howler/common/exceptions.py +167 -0
  40. howler/common/loader.py +154 -0
  41. howler/common/logging/__init__.py +241 -0
  42. howler/common/logging/audit.py +138 -0
  43. howler/common/logging/format.py +38 -0
  44. howler/common/net.py +79 -0
  45. howler/common/net_static.py +1494 -0
  46. howler/common/random_user.py +316 -0
  47. howler/common/swagger.py +117 -0
  48. howler/config.py +64 -0
  49. howler/cronjobs/__init__.py +29 -0
  50. howler/cronjobs/retention.py +61 -0
  51. howler/cronjobs/rules.py +274 -0
  52. howler/cronjobs/view_cleanup.py +88 -0
  53. howler/datastore/README.md +112 -0
  54. howler/datastore/__init__.py +0 -0
  55. howler/datastore/bulk.py +72 -0
  56. howler/datastore/collection.py +2342 -0
  57. howler/datastore/constants.py +119 -0
  58. howler/datastore/exceptions.py +41 -0
  59. howler/datastore/howler_store.py +105 -0
  60. howler/datastore/migrations/fix_process.py +41 -0
  61. howler/datastore/operations.py +130 -0
  62. howler/datastore/schemas.py +90 -0
  63. howler/datastore/store.py +231 -0
  64. howler/datastore/support/__init__.py +0 -0
  65. howler/datastore/support/build.py +215 -0
  66. howler/datastore/support/schemas.py +90 -0
  67. howler/datastore/types.py +22 -0
  68. howler/error.py +91 -0
  69. howler/external/__init__.py +0 -0
  70. howler/external/generate_mitre.py +96 -0
  71. howler/external/generate_sigma_rules.py +31 -0
  72. howler/external/generate_tlds.py +47 -0
  73. howler/external/reindex_data.py +66 -0
  74. howler/external/wipe_databases.py +58 -0
  75. howler/gunicorn_config.py +25 -0
  76. howler/healthz.py +47 -0
  77. howler/helper/__init__.py +0 -0
  78. howler/helper/azure.py +50 -0
  79. howler/helper/discover.py +59 -0
  80. howler/helper/hit.py +236 -0
  81. howler/helper/oauth.py +247 -0
  82. howler/helper/search.py +92 -0
  83. howler/helper/workflow.py +110 -0
  84. howler/helper/ws.py +378 -0
  85. howler/odm/README.md +102 -0
  86. howler/odm/__init__.py +1 -0
  87. howler/odm/base.py +1543 -0
  88. howler/odm/charter.txt +146 -0
  89. howler/odm/helper.py +416 -0
  90. howler/odm/howler_enum.py +25 -0
  91. howler/odm/models/__init__.py +0 -0
  92. howler/odm/models/action.py +33 -0
  93. howler/odm/models/analytic.py +90 -0
  94. howler/odm/models/assemblyline.py +48 -0
  95. howler/odm/models/aws.py +23 -0
  96. howler/odm/models/azure.py +16 -0
  97. howler/odm/models/cbs.py +44 -0
  98. howler/odm/models/config.py +558 -0
  99. howler/odm/models/dossier.py +33 -0
  100. howler/odm/models/ecs/__init__.py +0 -0
  101. howler/odm/models/ecs/agent.py +17 -0
  102. howler/odm/models/ecs/autonomous_system.py +16 -0
  103. howler/odm/models/ecs/client.py +149 -0
  104. howler/odm/models/ecs/cloud.py +141 -0
  105. howler/odm/models/ecs/code_signature.py +27 -0
  106. howler/odm/models/ecs/container.py +32 -0
  107. howler/odm/models/ecs/dns.py +62 -0
  108. howler/odm/models/ecs/egress.py +10 -0
  109. howler/odm/models/ecs/elf.py +74 -0
  110. howler/odm/models/ecs/email.py +122 -0
  111. howler/odm/models/ecs/error.py +14 -0
  112. howler/odm/models/ecs/event.py +140 -0
  113. howler/odm/models/ecs/faas.py +24 -0
  114. howler/odm/models/ecs/file.py +84 -0
  115. howler/odm/models/ecs/geo.py +30 -0
  116. howler/odm/models/ecs/group.py +18 -0
  117. howler/odm/models/ecs/hash.py +16 -0
  118. howler/odm/models/ecs/host.py +17 -0
  119. howler/odm/models/ecs/http.py +37 -0
  120. howler/odm/models/ecs/ingress.py +12 -0
  121. howler/odm/models/ecs/interface.py +21 -0
  122. howler/odm/models/ecs/network.py +30 -0
  123. howler/odm/models/ecs/observer.py +45 -0
  124. howler/odm/models/ecs/organization.py +12 -0
  125. howler/odm/models/ecs/os.py +21 -0
  126. howler/odm/models/ecs/pe.py +17 -0
  127. howler/odm/models/ecs/process.py +216 -0
  128. howler/odm/models/ecs/registry.py +26 -0
  129. howler/odm/models/ecs/related.py +45 -0
  130. howler/odm/models/ecs/rule.py +51 -0
  131. howler/odm/models/ecs/server.py +24 -0
  132. howler/odm/models/ecs/threat.py +247 -0
  133. howler/odm/models/ecs/tls.py +58 -0
  134. howler/odm/models/ecs/url.py +51 -0
  135. howler/odm/models/ecs/user.py +57 -0
  136. howler/odm/models/ecs/user_agent.py +20 -0
  137. howler/odm/models/ecs/vulnerability.py +41 -0
  138. howler/odm/models/gcp.py +16 -0
  139. howler/odm/models/hit.py +356 -0
  140. howler/odm/models/howler_data.py +328 -0
  141. howler/odm/models/lead.py +24 -0
  142. howler/odm/models/localized_label.py +13 -0
  143. howler/odm/models/overview.py +16 -0
  144. howler/odm/models/pivot.py +40 -0
  145. howler/odm/models/template.py +24 -0
  146. howler/odm/models/user.py +83 -0
  147. howler/odm/models/view.py +34 -0
  148. howler/odm/random_data.py +888 -0
  149. howler/odm/randomizer.py +609 -0
  150. howler/patched.py +5 -0
  151. howler/plugins/__init__.py +25 -0
  152. howler/plugins/config.py +123 -0
  153. howler/remote/__init__.py +0 -0
  154. howler/remote/datatypes/README.md +355 -0
  155. howler/remote/datatypes/__init__.py +98 -0
  156. howler/remote/datatypes/counters.py +63 -0
  157. howler/remote/datatypes/events.py +66 -0
  158. howler/remote/datatypes/hash.py +206 -0
  159. howler/remote/datatypes/lock.py +42 -0
  160. howler/remote/datatypes/queues/__init__.py +0 -0
  161. howler/remote/datatypes/queues/comms.py +59 -0
  162. howler/remote/datatypes/queues/multi.py +32 -0
  163. howler/remote/datatypes/queues/named.py +93 -0
  164. howler/remote/datatypes/queues/priority.py +215 -0
  165. howler/remote/datatypes/set.py +118 -0
  166. howler/remote/datatypes/user_quota_tracker.py +54 -0
  167. howler/security/__init__.py +253 -0
  168. howler/security/socket.py +108 -0
  169. howler/security/utils.py +185 -0
  170. howler/services/__init__.py +0 -0
  171. howler/services/action_service.py +111 -0
  172. howler/services/analytic_service.py +128 -0
  173. howler/services/auth_service.py +323 -0
  174. howler/services/config_service.py +128 -0
  175. howler/services/dossier_service.py +252 -0
  176. howler/services/event_service.py +93 -0
  177. howler/services/hit_service.py +893 -0
  178. howler/services/jwt_service.py +158 -0
  179. howler/services/lucene_service.py +286 -0
  180. howler/services/notebook_service.py +119 -0
  181. howler/services/overview_service.py +44 -0
  182. howler/services/template_service.py +45 -0
  183. howler/services/user_service.py +331 -0
  184. howler/utils/__init__.py +0 -0
  185. howler/utils/annotations.py +28 -0
  186. howler/utils/chunk.py +38 -0
  187. howler/utils/dict_utils.py +200 -0
  188. howler/utils/isotime.py +17 -0
  189. howler/utils/list_utils.py +11 -0
  190. howler/utils/lucene.py +77 -0
  191. howler/utils/path.py +27 -0
  192. howler/utils/socket_utils.py +61 -0
  193. howler/utils/str_utils.py +256 -0
  194. howler/utils/uid.py +47 -0
  195. howler_api-3.0.0.dev374.dist-info/METADATA +71 -0
  196. howler_api-3.0.0.dev374.dist-info/RECORD +198 -0
  197. howler_api-3.0.0.dev374.dist-info/WHEEL +4 -0
  198. howler_api-3.0.0.dev374.dist-info/entry_points.txt +8 -0
howler/api/v1/hit.py ADDED
@@ -0,0 +1,1181 @@
1
+ import difflib
2
+ import json
3
+ from typing import Any, Optional, cast
4
+
5
+ from flask import request
6
+ from mergedeep import Strategy, merge
7
+
8
+ from howler.api import (
9
+ bad_request,
10
+ conflict,
11
+ created,
12
+ forbidden,
13
+ internal_error,
14
+ make_subapi_blueprint,
15
+ no_content,
16
+ not_found,
17
+ ok,
18
+ )
19
+ from howler.api.v1.utils.etag import add_etag
20
+ from howler.common.exceptions import HowlerException, HowlerValueError, InvalidDataException
21
+ from howler.common.loader import datastore
22
+ from howler.common.logging import get_logger
23
+ from howler.common.swagger import generate_swagger_docs
24
+ from howler.datastore.collection import ESCollection
25
+ from howler.datastore.exceptions import DataStoreException, VersionConflictException
26
+ from howler.datastore.operations import OdmHelper, OdmUpdateOperation
27
+ from howler.helper.workflow import WorkflowException
28
+ from howler.odm.models.hit import Hit
29
+ from howler.odm.models.howler_data import Comment, HitOperationType, HitStatusTransition
30
+ from howler.odm.models.user import User
31
+ from howler.security import api_login
32
+ from howler.services import action_service, analytic_service, event_service, hit_service
33
+ from howler.utils.str_utils import sanitize_lucene_query
34
+
35
+ MAX_COMMENT_LEN = 5000
36
+
37
+ SUB_API = "hit"
38
+ hit_api = make_subapi_blueprint(SUB_API, api_version=1)
39
+ hit_api._doc = "Manage the different hits in the system"
40
+
41
+ FIELDS = Hit.flat_fields()
42
+
43
+ logger = get_logger(__file__)
44
+
45
+ hit_helper = OdmHelper(Hit)
46
+
47
+
48
+ @generate_swagger_docs()
49
+ @hit_api.route("/", methods=["POST"])
50
+ @api_login(required_priv=["W"])
51
+ def create_hits(user: User, **kwargs):
52
+ """Create hits.
53
+
54
+ Variables:
55
+ None
56
+
57
+ Arguments:
58
+ None
59
+
60
+ Data Block:
61
+ {
62
+ [
63
+ {
64
+ ...hit
65
+ },
66
+ {
67
+ ...hit
68
+ }
69
+ ]
70
+ }
71
+
72
+ Result Example:
73
+ {
74
+ "valid": [
75
+ {
76
+ ...hit
77
+ },
78
+ {
79
+ ...hit
80
+ }
81
+ ],
82
+ "invalid": [
83
+ {
84
+ "input": { ...hit },
85
+ "error": "Id already exists"
86
+ },
87
+ {
88
+ "input": { ...hit },
89
+ "error": "Object 'HowlerData' expected a parameter named: score"
90
+ }
91
+ ]
92
+ }
93
+ """
94
+ hits = request.json
95
+
96
+ if hits is None:
97
+ return bad_request(err="No hits were sent.")
98
+
99
+ response_body: dict[str, list[Any]] = {"valid": [], "invalid": []}
100
+ odms = []
101
+ ignore_extra_values: bool = bool(request.args.get("ignore_extra_values", False, type=lambda v: v.lower() == "true"))
102
+ logger.debug(f"ignore_extra_values = {ignore_extra_values}")
103
+ warnings = []
104
+ for hit in hits:
105
+ try:
106
+ odm, _warnings = hit_service.convert_hit(hit, unique=True, ignore_extra_values=ignore_extra_values)
107
+ response_body["valid"].append(odm.as_primitives())
108
+ odms.append(odm)
109
+ warnings.extend(_warnings)
110
+ except HowlerException as e:
111
+ logger.warning(f"{type(e).__name__} when saving new hit!")
112
+ logger.warning(e)
113
+ response_body["invalid"].append({"input": hit, "error": str(e)})
114
+
115
+ if len(response_body["invalid"]) == 0:
116
+ if len(odms) > 0:
117
+ for odm in odms:
118
+ # Ensure all ids are consistent
119
+ if odm.event is not None:
120
+ odm.event.id = odm.howler.id
121
+ hit_service.create_hit(odm.howler.id, odm, user=user["uname"])
122
+ analytic_service.save_from_hit(odm, user)
123
+
124
+ datastore().hit.commit()
125
+
126
+ action_service.bulk_execute_on_query(f"howler.id:({' OR '.join(odm.howler.id for odm in odms)})", user=user)
127
+
128
+ response_body["warnings"] = warnings
129
+
130
+ return created(response_body, warnings=warnings)
131
+ else:
132
+ err_msg = ", ".join(item["error"] for item in response_body["invalid"])
133
+
134
+ return bad_request(response_body, err=err_msg, warnings=warnings)
135
+
136
+
137
+ @generate_swagger_docs()
138
+ @hit_api.route("/", methods=["DELETE"])
139
+ @api_login(required_priv=["W"])
140
+ def delete_hits(user: User, **kwargs):
141
+ """Delete hits.
142
+
143
+ Variables:
144
+ None
145
+
146
+ Arguments:
147
+ None
148
+
149
+ Data Block:
150
+ {
151
+ [
152
+ hitId, hitId, hitId
153
+ ]
154
+ }
155
+
156
+ Result Example:
157
+ {
158
+ "success": True # Deleting the hits succeded
159
+ }
160
+ """
161
+ hit_ids = request.json
162
+
163
+ if hit_ids is None:
164
+ return bad_request(err="No hit ids were sent.")
165
+
166
+ if "admin" not in user["type"]:
167
+ return forbidden(err="Cannot delete hit, only admin is allowed to delete")
168
+
169
+ non_existing_hit_ids = [hit_id for hit_id in hit_ids if not hit_service.exists(hit_id)]
170
+
171
+ if len(non_existing_hit_ids) == 1:
172
+ return not_found(err=f"Hit id {non_existing_hit_ids[0]} does not exist.")
173
+
174
+ if len(non_existing_hit_ids) > 1:
175
+ return not_found(err=f"Hit ids {', '.join(non_existing_hit_ids)} do not exist.")
176
+
177
+ for hit_id in hit_ids:
178
+ if not hit_service.exists(hit_id):
179
+ return not_found(err=f"Hit id {hit_id} does not exist.")
180
+
181
+ hit_service.delete_hits(hit_ids)
182
+
183
+ return no_content()
184
+
185
+
186
+ @generate_swagger_docs()
187
+ @hit_api.route("/validate", methods=["POST"])
188
+ def validate_hits(**kwargs):
189
+ """Validates hits.
190
+
191
+ Variables:
192
+ None
193
+
194
+ Arguments:
195
+ None
196
+
197
+ Data Block:
198
+ {
199
+ [
200
+ {
201
+ ...hit
202
+ },
203
+ {
204
+ ...hit
205
+ }
206
+ ]
207
+ }
208
+
209
+ Result Example:
210
+ {
211
+ "valid": [
212
+ {
213
+ ...hit
214
+ },
215
+ {
216
+ ...hit
217
+ }
218
+ ],
219
+ "invalid": [
220
+ {
221
+ "input": { ...hit },
222
+ "error": "Id already exists"
223
+ },
224
+ {
225
+ "input": { ...hit },
226
+ "error": "Object 'HowlerData' expected a parameter named: score"
227
+ }
228
+ ]
229
+ }
230
+ """
231
+ hits = request.json
232
+
233
+ if hits is None:
234
+ return bad_request(err="No hits were sent.")
235
+
236
+ validation: dict[str, list[dict[str, Any]]] = {"valid": [], "invalid": []}
237
+
238
+ for hit in hits:
239
+ try:
240
+ hit_service.convert_hit(hit, unique=True)
241
+ validation["valid"].append(hit)
242
+ except HowlerException as e:
243
+ validation["invalid"].append({"input": hit, "error": str(e)})
244
+
245
+ return ok(validation)
246
+
247
+
248
+ @generate_swagger_docs()
249
+ @hit_api.route("/<id>", methods=["GET"])
250
+ @api_login(audit=False, required_priv=["R"])
251
+ @add_etag(getter=hit_service.get_hit)
252
+ def get_hit(id: str, server_version: str, **kwargs):
253
+ """Get a hit.
254
+
255
+ Variables:
256
+ id => Id of the hit you would like to get
257
+
258
+ Arguments:
259
+ None
260
+
261
+ Result Example:
262
+ https://github.com/CybercentreCanada/howler-api/blob/main/howler/odm/models/hit.py
263
+ """
264
+ hit = cast(Optional[Any], kwargs.get("cached_hit"))
265
+
266
+ if not hit:
267
+ return not_found(err="Hit %s does not exist" % id)
268
+
269
+ if "metadata" in request.args:
270
+ metadata = (request.args.get("metadata", type=str) or "").split(",")
271
+
272
+ hit = hit.as_primitives()
273
+
274
+ if len(metadata) > 0:
275
+ hit_service.augment_metadata(hit, metadata, kwargs["user"])
276
+
277
+ return ok(hit), server_version
278
+
279
+
280
+ @generate_swagger_docs()
281
+ @hit_api.route("/<id>/overwrite", methods=["PUT"])
282
+ @api_login(audit=False, required_priv=["W"])
283
+ @add_etag(getter=hit_service.get_hit, check_if_match=False)
284
+ def overwrite_hit(id: str, server_version: str, **kwargs):
285
+ """Overwrite a hit.
286
+
287
+ Instead of providing a list of operations to run, provide a partial hit object to overwrite many fields at once.
288
+
289
+ Variables:
290
+ id => Id of the hit you would like to update
291
+
292
+ Arguments:
293
+ replace => Should lists of values be replaced or merged?
294
+
295
+ Data Block:
296
+ {
297
+ ...hit
298
+ }
299
+
300
+ Result Example:
301
+ https://github.com/CybercentreCanada/howler-api/blob/main/howler/odm/models/hit.py
302
+ """
303
+ hit = cast(Optional[Hit], kwargs.get("cached_hit"))
304
+
305
+ if not hit:
306
+ return not_found(err="Hit %s does not exist" % id)
307
+
308
+ new_fields = request.json
309
+
310
+ if not isinstance(new_fields, dict):
311
+ return bad_request(err="The JSON payload must be a subset of a valid Hit object.")
312
+
313
+ try:
314
+ new_hit = merge(
315
+ hit_service.flatten(hit.as_primitives(), odm=Hit),
316
+ hit_service.flatten(new_fields),
317
+ strategy=Strategy.REPLACE
318
+ if bool(request.args.get("replace", False, type=lambda v: v.lower() == "true"))
319
+ else Strategy.ADDITIVE,
320
+ )
321
+
322
+ new_hit, new_version = hit_service.save_hit(Hit(new_hit), server_version)
323
+
324
+ return ok(new_hit), new_version
325
+ except HowlerValueError as e:
326
+ return bad_request(err=e.message)
327
+
328
+
329
+ @generate_swagger_docs()
330
+ @hit_api.route("/<id>/update", methods=["PUT"])
331
+ @api_login(audit=False, required_priv=["W"])
332
+ @add_etag(getter=hit_service.get_hit, check_if_match=False)
333
+ def update_hit(id: str, server_version: str, **kwargs):
334
+ """Update a hit.
335
+
336
+ Variables:
337
+ id => Id of the hit you would like to update
338
+
339
+ Arguments:
340
+ None
341
+
342
+ Data Block:
343
+ [
344
+ ("SET", "howler.assignment", "user"),
345
+ ("REMOVE", "howler.labels.generic", "some_label")
346
+ ]
347
+
348
+ Result Example:
349
+ https://github.com/CybercentreCanada/howler-api/blob/main/howler/odm/models/hit.py
350
+ """
351
+ hit = cast(Optional[Hit], kwargs.get("cached_hit"))
352
+
353
+ if not hit:
354
+ return not_found(err="Hit %s does not exist" % id)
355
+
356
+ try:
357
+ attempted_operations = cast(list[tuple[str, str, Any]], request.json)
358
+
359
+ operations: list[OdmUpdateOperation] = []
360
+
361
+ explanation: list[str] = []
362
+
363
+ for operation, key, value in attempted_operations:
364
+ operations.append(OdmUpdateOperation(operation, key, value, silent=True))
365
+ explanation.append(f"- `{operation}` - `{key}` - `{json.dumps(value)}`")
366
+
367
+ operations.append(
368
+ OdmUpdateOperation(
369
+ ESCollection.UPDATE_APPEND,
370
+ "howler.log",
371
+ {
372
+ "timestamp": "NOW",
373
+ "previous_version": server_version,
374
+ "key": "howler.log",
375
+ "explanation": f"Hit updated by {kwargs['user']['uname']}\n\n" + "\n".join(explanation),
376
+ "new_value": "N/A",
377
+ "previous_value": "None",
378
+ "type": HitOperationType.APPENDED,
379
+ "user": kwargs["user"]["uname"],
380
+ },
381
+ silent=True,
382
+ )
383
+ )
384
+
385
+ new_hit, new_version = hit_service.update_hit(
386
+ hit.howler.id, operations, kwargs["user"]["uname"], server_version
387
+ )
388
+
389
+ event_service.emit("hits", {"hit": new_hit, "version": new_version})
390
+
391
+ return ok(new_hit), new_version
392
+ except HowlerValueError as e:
393
+ return bad_request(err=e.message)
394
+
395
+
396
+ @generate_swagger_docs()
397
+ @hit_api.route("/update", methods=["PUT"])
398
+ @api_login(audit=False, required_priv=["W"])
399
+ def update_by_query(**kwargs):
400
+ """Update a set of hits using a query.
401
+
402
+ Variables:
403
+ None
404
+
405
+ Arguments:
406
+ None
407
+
408
+ Data Block:
409
+ {
410
+ "query": "howler.id:*",
411
+ "operations": [
412
+ ("SET", "howler.assignment", "user"),
413
+ ("REMOVE", "howler.labels.generic", "some_label")
414
+ ]
415
+ }
416
+
417
+ Result Example:
418
+ {
419
+ "success": True
420
+ }
421
+ """
422
+ data = cast(dict[str, Any], request.json)
423
+
424
+ try:
425
+ query = cast(str, data["query"])
426
+ operations = cast(list[tuple[str, str, Any]], data["operations"])
427
+
428
+ explanation: list[str] = []
429
+ for operation, key, value in operations:
430
+ # Just using this for validation
431
+ OdmUpdateOperation(operation, key, value)
432
+ explanation.append(f"- `{operation}` - `{key}` - `{json.dumps(value)}`")
433
+
434
+ operations.append(
435
+ (
436
+ ESCollection.UPDATE_APPEND,
437
+ "howler.log",
438
+ {
439
+ "timestamp": "NOW",
440
+ "explanation": f"Hit updated by {kwargs['user']['uname']}\n\n" + "\n".join(explanation),
441
+ "user": kwargs["user"]["uname"],
442
+ },
443
+ )
444
+ )
445
+
446
+ datastore().hit.update_by_query(query, operations)
447
+
448
+ return ok({"success": True})
449
+ except (HowlerValueError, KeyError, DataStoreException) as e:
450
+ return bad_request(err=str(e))
451
+ except Exception as e:
452
+ return internal_error(err=str(e))
453
+
454
+
455
+ @generate_swagger_docs()
456
+ @hit_api.route("/user", methods=["GET"])
457
+ @api_login(audit=True, required_priv=["R"])
458
+ def get_assigned_hits(user, **kwargs):
459
+ """Get hits assigned to the user.
460
+
461
+ Variables:
462
+ None
463
+
464
+ Arguments:
465
+ deep_paging_id => ID of the next page or * to start deep paging
466
+ offset => Offset in the results
467
+ rows => Number of results per page
468
+ sort => How to sort the results (not available in deep paging)
469
+ fl => List of fields to return
470
+ timeout => Maximum execution time (ms)
471
+
472
+ Result Example:
473
+ {
474
+ [
475
+ hit1,
476
+ hit2
477
+ ]
478
+ }
479
+
480
+ https://github.com/CybercentreCanada/howler-api/blob/main/howler/odm/models/hit.py
481
+ """
482
+ uname = user["uname"]
483
+
484
+ hits = hit_service.search(
485
+ query=f"howler.assignment:{sanitize_lucene_query(uname)}",
486
+ deep_paging_id=request.args.get("deep_paging_id", None),
487
+ offset=request.args.get("offset", 0, type=int), # type: ignore[union-attr]
488
+ rows=request.args.get("rows", None, type=int), # type: ignore[union-attr]
489
+ sort=request.args.get("sort", None),
490
+ fl=request.args.get("fl", None),
491
+ timeout=request.args.get("timeout", None),
492
+ as_obj=False,
493
+ )["items"]
494
+
495
+ return ok(hits)
496
+
497
+
498
+ @generate_swagger_docs()
499
+ @hit_api.route("/<id>/labels/<label_set>", methods=["PUT"])
500
+ @api_login(audit=False, required_priv=["W"])
501
+ @add_etag(getter=hit_service.get_hit, check_if_match=False)
502
+ def add_label(id, label_set, user, **kwargs):
503
+ """Add labels to a hit.
504
+
505
+ Variables:
506
+ id => id of the hit to add labels to
507
+ label_set => the label set to add to
508
+
509
+ Optional Arguments:
510
+ None
511
+
512
+ Data Block:
513
+ {
514
+ "value": ["label1", "label2"], # Label values to add
515
+ }
516
+
517
+ Result Example:
518
+ {
519
+ "success": True # Adding the label succeeded
520
+ }
521
+ """
522
+ if not hit_service.does_hit_exist(id):
523
+ return not_found(err=f"Hit {id} does not exist")
524
+
525
+ existing_hit: Hit = hit_service.get_hit(id, as_odm=True)
526
+ if f"howler.labels.{label_set}" not in existing_hit.flat_fields():
527
+ return not_found(err=f"Label set {label_set} does not exist")
528
+
529
+ label_data = request.json
530
+ if not isinstance(label_data, dict):
531
+ return bad_request("Invalid data format")
532
+
533
+ labels: list[str] = label_data["value"]
534
+
535
+ if not labels or len(labels) == 0:
536
+ return bad_request(err="Labels were not provided")
537
+
538
+ existing_labels = existing_hit[f"howler.labels.{label_set}"]
539
+
540
+ if not set(labels).isdisjoint(set(existing_labels)):
541
+ return bad_request(err=f"Cannot add duplicate labels: {set(labels) & set(existing_labels)}")
542
+
543
+ hit_service.update_hit(
544
+ id,
545
+ [hit_helper.list_add(f"howler.labels.{label_set}", label) for label in labels],
546
+ user["uname"],
547
+ )
548
+
549
+ datastore().hit.commit()
550
+
551
+ action_service.bulk_execute_on_query(
552
+ f"howler.id:{id}",
553
+ trigger="add_label",
554
+ user=user,
555
+ )
556
+
557
+ hit, version = hit_service.get_hit(id, version=True)
558
+
559
+ return ok(hit), version
560
+
561
+
562
+ @generate_swagger_docs()
563
+ @hit_api.route("/<id>/labels/<label_set>", methods=["DELETE"])
564
+ @api_login(audit=False, required_priv=["W"])
565
+ @add_etag(getter=hit_service.get_hit, check_if_match=False)
566
+ def remove_labels(id, label_set, user, **kwargs):
567
+ """Remove labels from a hit.
568
+
569
+ Variables:
570
+ id => id of the hit to remove labels from
571
+ label_set => label_set the label set to remove from
572
+
573
+ Optional Arguments:
574
+ None
575
+
576
+ Data Block:
577
+ {
578
+ "value": ["label1", "label2"], # Label values to remove
579
+ }
580
+
581
+ Result Example:
582
+ {
583
+ "success": True # Removing the labels succeeded
584
+ }
585
+ """
586
+ if not hit_service.does_hit_exist(id):
587
+ return not_found(err=f"Hit {id} does not exist")
588
+
589
+ if f"howler.labels.{label_set}" not in hit_service.get_hit(id, as_odm=True).flat_fields():
590
+ return not_found(err=f"Label set {label_set} does not exist")
591
+
592
+ label_data = request.json
593
+ if not isinstance(label_data, dict):
594
+ return bad_request("Invalid data format")
595
+
596
+ labels: list[str] = label_data["value"]
597
+
598
+ if not labels or len(labels) == 0:
599
+ return bad_request(err="Labels were not provided")
600
+
601
+ hit_service.update_hit(
602
+ id,
603
+ [hit_helper.list_remove(f"howler.labels.{label_set}", label) for label in labels],
604
+ user["uname"],
605
+ )
606
+
607
+ datastore().hit.commit()
608
+
609
+ action_service.bulk_execute_on_query(
610
+ f"howler.id:{id}",
611
+ trigger="remove_label",
612
+ user=user,
613
+ )
614
+
615
+ hit, version = hit_service.get_hit(id, version=True)
616
+
617
+ return ok(hit), version
618
+
619
+
620
+ @generate_swagger_docs()
621
+ @hit_api.route("/<id>/transition", methods=["POST"])
622
+ @api_login(audit=False, required_priv=["W"])
623
+ @add_etag(getter=hit_service.get_hit, check_if_match=True)
624
+ def transition(id: str, user: User, **kwargs):
625
+ """Transition a hit
626
+
627
+ Variables:
628
+ id => id of the hit to transition
629
+
630
+ Optional Arguments:
631
+ None
632
+
633
+ Data Block:
634
+ {
635
+ "transition": "release", # Transition to execute
636
+ "data": {}, # Optional data used by the transition
637
+ }
638
+
639
+ Result Example:
640
+ {
641
+ ...hit # The new data for the hit
642
+ }
643
+ """
644
+ if not kwargs.get("cached_hit"):
645
+ return not_found(err="Hit %s does not exist" % id)
646
+
647
+ transition_data = request.json
648
+ if not isinstance(transition_data, dict):
649
+ return bad_request(err="Invalid data format")
650
+
651
+ transition = transition_data["transition"]
652
+ if "If-Match" in request.headers:
653
+ version = request.headers["If-Match"]
654
+ else:
655
+ logger.warning("User is mising version - no If-Match header in request.")
656
+ version = None
657
+
658
+ try:
659
+ if transition not in HitStatusTransition.list():
660
+ return bad_request(
661
+ (
662
+ f"Transition '{transition}' not supported. Please use one of the following: "
663
+ f"{HitStatusTransition.list()}"
664
+ )
665
+ )
666
+
667
+ hit_service.transition_hit(id, transition, user, version, **kwargs, **transition_data.get("data", {}))
668
+ except (WorkflowException, DataStoreException, InvalidDataException) as e:
669
+ return bad_request(err=str(e))
670
+ except VersionConflictException as e:
671
+ return conflict(err=str(e))
672
+ except HowlerException as e:
673
+ return internal_error(err=str(e))
674
+
675
+ hit, version = hit_service.get_hit(id, version=True)
676
+ return ok(hit), version
677
+
678
+
679
+ @generate_swagger_docs()
680
+ @hit_api.route("/<id>/comments/<comment_id>", methods=["GET"])
681
+ @api_login(audit=False, required_priv=["R"])
682
+ @add_etag(getter=hit_service.get_hit, check_if_match=False)
683
+ def get_comment(id: str, comment_id: str, user: User, server_version: str, **kwargs):
684
+ """Get a comment associated with a particular hit
685
+
686
+ Variables:
687
+ id => id of the hit corresponding to the comment
688
+ comment_id => the id of the comment to get
689
+
690
+ Optional Arguments:
691
+ None
692
+
693
+ Result Example:
694
+ See: https://github.com/CybercentreCanada/howler-api/blob/main/howler/odm/models/howler_data.py#L17
695
+ """
696
+ hit: Optional[Hit] = kwargs.get("cached_hit")
697
+ if not hit:
698
+ return not_found(err=f"Hit {id} does not exist")
699
+
700
+ comment: Optional[Comment] = next((c for c in hit.howler.comment if c.id == comment_id), None)
701
+
702
+ if not comment:
703
+ return not_found(err=f"Comment {comment_id} does not exist")
704
+
705
+ return ok(comment), server_version
706
+
707
+
708
+ @generate_swagger_docs()
709
+ @hit_api.route("/<id>/comments", methods=["POST"])
710
+ @api_login(audit=False, required_priv=["W"])
711
+ @add_etag(getter=hit_service.get_hit, check_if_match=False)
712
+ def add_comment(id: str, user: dict[str, Any], **kwargs):
713
+ """Add a comment
714
+
715
+ Variables:
716
+ id => id of the hit to add a comment to
717
+
718
+ Optional Arguments:
719
+ None
720
+
721
+ Data Block:
722
+ {
723
+ value: "New comment value"
724
+ }
725
+
726
+ Result Example:
727
+ {
728
+ ...hit # The new data for the hit
729
+ }
730
+ """
731
+ comment_data = request.json
732
+ if not isinstance(comment_data, dict):
733
+ return bad_request(err="Invalid data format")
734
+
735
+ comment_value = comment_data.get("value", None)
736
+
737
+ if not comment_value:
738
+ return bad_request(err="Value cannot be empty.")
739
+
740
+ if len(comment_value) > MAX_COMMENT_LEN:
741
+ return bad_request(err="Comment is too long.")
742
+
743
+ if not kwargs.get("cached_hit"):
744
+ return not_found(err="Hit %s does not exist" % id)
745
+
746
+ try:
747
+ hit_service.update_hit(
748
+ id,
749
+ [
750
+ hit_helper.list_add(
751
+ "howler.comment",
752
+ Comment({"user": user["uname"], "value": comment_value}),
753
+ explanation=f"Added a comment:\n\n{comment_value}",
754
+ if_missing=True,
755
+ ),
756
+ ],
757
+ user["uname"],
758
+ )
759
+ except DataStoreException as e:
760
+ return bad_request(err=str(e))
761
+
762
+ hit, version = hit_service.get_hit(id, version=True)
763
+
764
+ return ok(hit), version
765
+
766
+
767
+ @generate_swagger_docs()
768
+ @hit_api.route("/<id>/comments/<comment_id>", methods=["PUT"])
769
+ @api_login(audit=False, required_priv=["W"])
770
+ @add_etag(getter=hit_service.get_hit, check_if_match=False)
771
+ def edit_comment(id: str, comment_id: str, user: dict[str, Any], **kwargs):
772
+ """Edit a comment
773
+
774
+ Variables:
775
+ id => id of the hit the comment belongs to
776
+ comment_id => id of the comment we are editing
777
+
778
+ Optional Arguments:
779
+ None
780
+
781
+ Data Block:
782
+ {
783
+ value: "New comment value"
784
+ }
785
+
786
+ Result Example:
787
+ {
788
+ ...hit # The new data for the hit
789
+ }
790
+ """
791
+ comment_data = request.json
792
+ if not isinstance(comment_data, dict):
793
+ return bad_request(err="Invalid data format")
794
+
795
+ comment_value = comment_data.get("value", None)
796
+
797
+ if not comment_value:
798
+ return bad_request(err="Value cannot be empty.")
799
+
800
+ if len(comment_value) > MAX_COMMENT_LEN:
801
+ return bad_request(err="Comment is too long.")
802
+
803
+ if not hit_service.does_hit_exist(id):
804
+ return not_found(err=f"Hit {id} does not exist")
805
+
806
+ hit: Hit = kwargs["cached_hit"]
807
+
808
+ comment: Optional[Comment] = next((c for c in hit.howler.comment if c.id == comment_id), None)
809
+
810
+ if not comment:
811
+ return not_found(err=f"Comment {comment_id} does not exist")
812
+
813
+ if comment.user != user["uname"]:
814
+ return forbidden(err="Cannot edit comment that wasn't made by you.")
815
+
816
+ new_comment = comment.as_primitives()
817
+ new_comment["value"] = comment_value
818
+ new_comment["modified"] = "NOW"
819
+
820
+ diff = []
821
+ for line in difflib.unified_diff(comment.value.split("\n"), new_comment["value"].split("\n")):
822
+ if line[:3] not in ("+++", "---", "@@ "):
823
+ diff.append(line)
824
+
825
+ (hit, version) = hit_service.update_hit(
826
+ id,
827
+ [
828
+ hit_helper.list_remove("howler.comment", comment, silent=True),
829
+ hit_helper.list_add(
830
+ "howler.comment",
831
+ new_comment,
832
+ explanation="Edited a comment. Changes:\n\n````diff\n" + "\n".join(diff) + "\n````",
833
+ ),
834
+ ],
835
+ user["uname"],
836
+ )
837
+ return ok(hit), version
838
+
839
+
840
+ @generate_swagger_docs()
841
+ @hit_api.route("/<id>/comments", methods=["DELETE"])
842
+ @api_login(audit=False, required_priv=["W"])
843
+ @add_etag(getter=hit_service.get_hit, check_if_match=False)
844
+ def delete_comments(id: str, user: User, **kwargs):
845
+ """Delete a set of comments
846
+
847
+ Variables:
848
+ id => id of the hit whose comments we are deleting
849
+
850
+ Optional Arguments:
851
+ None
852
+
853
+ Data Block:
854
+ [
855
+ ...comment_ids
856
+ ]
857
+
858
+ Result Example:
859
+ {
860
+ ...hit # The new data for the hit
861
+ }
862
+ """
863
+ if not hit_service.does_hit_exist(id):
864
+ return not_found(err=f"Hit {id} does not exist")
865
+
866
+ comment_ids: list[str] = request.json or []
867
+
868
+ if len(comment_ids) == 0:
869
+ return bad_request(err="Supply at least one comment to delete.")
870
+
871
+ hit: Hit = kwargs["cached_hit"]
872
+ comments = [comment for comment in hit.howler.comment if comment.id in comment_ids]
873
+
874
+ if ("admin" not in user["type"]) and any(comment for comment in comments if comment.user != user["uname"]):
875
+ return forbidden(err="You cannot delete the comment of someone else.")
876
+
877
+ if len(comments) != len(comment_ids):
878
+ missing_id = next(id for id in comment_ids if not any(comment for comment in comments if comment.id == id))
879
+ return not_found(err=f"Comment with id {missing_id} not found")
880
+
881
+ try:
882
+ hit_service.update_hit(
883
+ id,
884
+ [
885
+ hit_helper.list_remove(
886
+ "howler.comment",
887
+ comment,
888
+ "Deleted a comment",
889
+ )
890
+ for comment in comments
891
+ ],
892
+ user["uname"],
893
+ )
894
+
895
+ except DataStoreException as e:
896
+ return bad_request(err=str(e))
897
+ hit, version = hit_service.get_hit(id, version=True)
898
+ return ok(hit), version
899
+
900
+
901
+ @generate_swagger_docs()
902
+ @hit_api.route("/<id>/comments/<comment_id>/react", methods=["PUT"])
903
+ @api_login(audit=False, required_priv=["W"])
904
+ @add_etag(getter=hit_service.get_hit, check_if_match=False)
905
+ def react_comment(id: str, comment_id: str, user: dict[str, Any], **kwargs):
906
+ """React to a comment
907
+
908
+ Variables:
909
+ id => id of the hit the comment belongs to
910
+ comment_id => id of the comment we are editing
911
+
912
+ Optional Arguments:
913
+ None
914
+
915
+ Data Block:
916
+ {
917
+ type: "thumbsup"
918
+ }
919
+
920
+ Result Example:
921
+ {
922
+ ...hit # The new data for the hit
923
+ }
924
+ """
925
+ react_data: Optional[str] = request.json
926
+ if not isinstance(react_data, dict):
927
+ return bad_request(err="Invalid data format")
928
+
929
+ react_value = react_data.get("type", None)
930
+
931
+ if not react_value:
932
+ return bad_request(err="Type cannot be empty.")
933
+
934
+ hit: Optional[Hit] = kwargs.get("cached_hit")
935
+ if not hit:
936
+ return not_found(err=f"Hit {id} does not exist")
937
+
938
+ for comment in hit.howler.comment:
939
+ if comment.id == comment_id:
940
+ comment["reactions"] = {
941
+ **comment.get("reactions", {}),
942
+ user["uname"]: react_value,
943
+ }
944
+
945
+ new_hit, version = hit_service.save_hit(hit, version=kwargs.get("server_version"))
946
+
947
+ return ok(new_hit), version
948
+
949
+
950
+ @generate_swagger_docs()
951
+ @hit_api.route("/<id>/comments/<comment_id>/react", methods=["DELETE"])
952
+ @api_login(audit=False, required_priv=["W"])
953
+ @add_etag(getter=hit_service.get_hit, check_if_match=False)
954
+ def remove_react_comment(id: str, comment_id: str, user: dict[str, Any], **kwargs):
955
+ """React to a comment
956
+
957
+ Variables:
958
+ id => id of the hit the comment belongs to
959
+ comment_id => id of the comment we are editing
960
+
961
+ Optional Arguments:
962
+ None
963
+
964
+ Result Example:
965
+ {
966
+ ...hit # The new data for the hit
967
+ }
968
+ """
969
+ hit: Optional[Hit] = kwargs.get("cached_hit")
970
+ if not hit:
971
+ return not_found(err=f"Hit {id} does not exist")
972
+
973
+ for comment in hit.howler.comment:
974
+ if comment.id == comment_id:
975
+ reactions = comment.get("reactions", {})
976
+ reactions.pop(user["uname"], None)
977
+ comment["reactions"] = {**reactions}
978
+
979
+ new_hit, version = hit_service.save_hit(hit, version=kwargs.get("server_version"))
980
+
981
+ return ok(new_hit), version
982
+
983
+
984
+ @generate_swagger_docs()
985
+ @hit_api.route("/bundle", methods=["POST"])
986
+ @api_login(audit=False, required_priv=["W"])
987
+ def create_bundle(user: User, **kwargs):
988
+ """Create a new bundle
989
+
990
+ Variables:
991
+ None
992
+
993
+ Arguments:
994
+ None
995
+
996
+ Data Block:
997
+ {
998
+ "bundle": {
999
+ ...hit # A howler hit that will be used as a template for this new bundle
1000
+ },
1001
+ "hits": [...ids] # A list of existing howler hits to add as children to the new bundle
1002
+ }
1003
+
1004
+ Result Example:
1005
+ {
1006
+ ...hit # The created bundle
1007
+ }
1008
+ """
1009
+ data = request.json
1010
+ if not isinstance(data, dict):
1011
+ return bad_request(err="Invalid data format")
1012
+
1013
+ bundle_hit: Optional[dict[str, Any]] = data.get("bundle")
1014
+
1015
+ if bundle_hit is None:
1016
+ return bad_request(err="You did not provide a bundle hit.")
1017
+
1018
+ try:
1019
+ odm, _ = hit_service.convert_hit(bundle_hit, unique=True)
1020
+ odm.howler.is_bundle = True
1021
+
1022
+ child_hits = data.get("hits", [])
1023
+
1024
+ if len(odm.howler.hits) < 1 and len(child_hits) < 1:
1025
+ return bad_request(err="You did not provide any child hits.")
1026
+
1027
+ for hit_id in child_hits:
1028
+ if hit_id not in odm.howler.hits:
1029
+ odm.howler.hits.append(hit_id)
1030
+
1031
+ hit_service.create_hit(odm.howler.id, odm, user=user["uname"])
1032
+ analytic_service.save_from_hit(odm, user)
1033
+
1034
+ for hit_id in odm.howler.hits:
1035
+ child_hit: Hit = hit_service.get_hit(hit_id, as_odm=True)
1036
+
1037
+ if child_hit.howler.is_bundle:
1038
+ return bad_request(
1039
+ err=f"You cannot specify a bundle as a child of another bundle - {child_hit.howler.id} is a bundle."
1040
+ )
1041
+
1042
+ new_bundle_list = child_hit.howler.get("bundles", [])
1043
+ new_bundle_list.append(odm.howler.id)
1044
+ child_hit.howler.bundles = new_bundle_list
1045
+ datastore().hit.save(child_hit.howler.id, child_hit)
1046
+
1047
+ return created(odm)
1048
+ except HowlerException as e:
1049
+ return bad_request(err=str(e))
1050
+
1051
+
1052
+ @generate_swagger_docs()
1053
+ @hit_api.route("/bundle/<id>", methods=["PUT"])
1054
+ @api_login(audit=False, required_priv=["W"])
1055
+ @add_etag(getter=hit_service.get_hit, check_if_match=False)
1056
+ def update_bundle(id, **kwargs):
1057
+ """Update a hit's child hits. Can be used to convert an existing hit into a bundle, or to update an existing bundle.
1058
+
1059
+ Variables:
1060
+ id => The ID of the bundle to update
1061
+
1062
+ Arguments:
1063
+ None
1064
+
1065
+ Data Block:
1066
+ [
1067
+ ...ids
1068
+ ]
1069
+
1070
+ Result Example:
1071
+ {
1072
+ ...hit # The updated bundle
1073
+ }
1074
+ """
1075
+ bundle_hit: Hit = cast(Hit, kwargs.get("cached_hit", None))
1076
+ if not bundle_hit:
1077
+ return not_found(err="This bundle does not exist.")
1078
+
1079
+ hit_ids = request.json
1080
+ if not isinstance(hit_ids, list):
1081
+ return bad_request(err="Invalid data format")
1082
+
1083
+ new_hit_list = bundle_hit.howler.as_primitives().get("hits", [])
1084
+ if bundle_hit.howler.is_bundle:
1085
+ for hit_id in hit_ids:
1086
+ if hit_id not in new_hit_list:
1087
+ new_hit_list.append(hit_id)
1088
+ else:
1089
+ return conflict(err=f"The hit {hit_id} is already in the bundle {bundle_hit.howler.id}.")
1090
+ else:
1091
+ new_hit_list = hit_ids
1092
+
1093
+ bundle_hit.howler.hits = new_hit_list
1094
+ bundle_hit.howler.is_bundle = True
1095
+
1096
+ try:
1097
+ for hit_id in new_hit_list:
1098
+ child_hit: Hit = hit_service.get_hit(hit_id, as_odm=True)
1099
+
1100
+ if child_hit.howler.is_bundle:
1101
+ return bad_request(
1102
+ err=f"You cannot specify a bundle as a child of another bundle - {child_hit.howler.id} is a bundle."
1103
+ )
1104
+
1105
+ new_bundle_list = child_hit.howler.as_primitives().get("bundles", [])
1106
+ new_bundle_list.append(bundle_hit.howler.id)
1107
+ child_hit.howler.bundles = new_bundle_list
1108
+ datastore().hit.save(child_hit.howler.id, child_hit)
1109
+
1110
+ datastore().hit.save(bundle_hit.howler.id, bundle_hit)
1111
+
1112
+ return ok(bundle_hit)
1113
+ except HowlerException as e:
1114
+ return bad_request(err=str(e))
1115
+
1116
+
1117
+ @generate_swagger_docs()
1118
+ @hit_api.route("/bundle/<id>", methods=["DELETE"])
1119
+ @api_login(audit=False, required_priv=["W"])
1120
+ @add_etag(getter=hit_service.get_hit, check_if_match=False)
1121
+ def remove_bundle_children(id, **kwargs):
1122
+ """Remove a bundle's child hits.
1123
+
1124
+ Can be used to convert an existing bundle back into a normal hit, or to remove a subset of
1125
+ existing hits from the bundle.
1126
+
1127
+ Variables:
1128
+ id => The ID of the bundle to update
1129
+
1130
+ Arguments:
1131
+ None
1132
+
1133
+ Data Block:
1134
+ [
1135
+ ...ids OR '*' # A list of ids to remove, or a single '*' to remove all
1136
+ ]
1137
+
1138
+ Result Example:
1139
+ {
1140
+ ...hit # The updated hit
1141
+ }
1142
+ """
1143
+ bundle_hit = kwargs.get("cached_hit", None)
1144
+ if not bundle_hit:
1145
+ return not_found(err="This bundle does not exist.")
1146
+
1147
+ hit_ids = request.json
1148
+ if not isinstance(hit_ids, list):
1149
+ return bad_request(err="Invalid data format")
1150
+
1151
+ new_hit_list = bundle_hit.howler.get("hits", [])
1152
+ if bundle_hit.howler.is_bundle:
1153
+ if hit_ids == ["*"]:
1154
+ hit_ids = new_hit_list
1155
+ new_hit_list = []
1156
+ else:
1157
+ new_hit_list = [_id for _id in new_hit_list if _id not in hit_ids]
1158
+ else:
1159
+ return bad_request(err="The specified hit must be a bundle.")
1160
+
1161
+ bundle_hit.howler.hits = new_hit_list
1162
+ bundle_hit.howler.is_bundle = len(new_hit_list) > 0
1163
+
1164
+ try:
1165
+ for hit_id in hit_ids:
1166
+ child_hit: Hit = hit_service.get_hit(hit_id, as_odm=True)
1167
+
1168
+ new_bundle_list = child_hit.howler.get("bundles", [])
1169
+ try:
1170
+ new_bundle_list.remove(bundle_hit.howler.id)
1171
+ except ValueError:
1172
+ logger.warning("Bundle isn't included in child %s!", bundle_hit.howler.id)
1173
+ child_hit.howler.bundles = new_bundle_list
1174
+
1175
+ datastore().hit.save(child_hit.howler.id, child_hit)
1176
+
1177
+ datastore().hit.save(bundle_hit.howler.id, bundle_hit)
1178
+
1179
+ return ok(bundle_hit)
1180
+ except HowlerException as e:
1181
+ return bad_request(err=str(e))