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.
- howler/__init__.py +0 -0
- howler/actions/__init__.py +168 -0
- howler/actions/add_label.py +111 -0
- howler/actions/add_to_bundle.py +159 -0
- howler/actions/change_field.py +76 -0
- howler/actions/demote.py +160 -0
- howler/actions/example_plugin.py +104 -0
- howler/actions/prioritization.py +93 -0
- howler/actions/promote.py +147 -0
- howler/actions/remove_from_bundle.py +133 -0
- howler/actions/remove_label.py +111 -0
- howler/actions/transition.py +200 -0
- howler/api/__init__.py +249 -0
- howler/api/base.py +88 -0
- howler/api/socket.py +114 -0
- howler/api/v1/__init__.py +97 -0
- howler/api/v1/action.py +372 -0
- howler/api/v1/analytic.py +748 -0
- howler/api/v1/auth.py +382 -0
- howler/api/v1/clue.py +99 -0
- howler/api/v1/configs.py +58 -0
- howler/api/v1/dossier.py +222 -0
- howler/api/v1/help.py +28 -0
- howler/api/v1/hit.py +1181 -0
- howler/api/v1/notebook.py +82 -0
- howler/api/v1/overview.py +191 -0
- howler/api/v1/search.py +788 -0
- howler/api/v1/template.py +206 -0
- howler/api/v1/tool.py +183 -0
- howler/api/v1/user.py +416 -0
- howler/api/v1/utils/__init__.py +0 -0
- howler/api/v1/utils/etag.py +84 -0
- howler/api/v1/view.py +288 -0
- howler/app.py +235 -0
- howler/common/README.md +125 -0
- howler/common/__init__.py +0 -0
- howler/common/classification.py +979 -0
- howler/common/classification.yml +107 -0
- howler/common/exceptions.py +167 -0
- howler/common/loader.py +154 -0
- howler/common/logging/__init__.py +241 -0
- howler/common/logging/audit.py +138 -0
- howler/common/logging/format.py +38 -0
- howler/common/net.py +79 -0
- howler/common/net_static.py +1494 -0
- howler/common/random_user.py +316 -0
- howler/common/swagger.py +117 -0
- howler/config.py +64 -0
- howler/cronjobs/__init__.py +29 -0
- howler/cronjobs/retention.py +61 -0
- howler/cronjobs/rules.py +274 -0
- howler/cronjobs/view_cleanup.py +88 -0
- howler/datastore/README.md +112 -0
- howler/datastore/__init__.py +0 -0
- howler/datastore/bulk.py +72 -0
- howler/datastore/collection.py +2342 -0
- howler/datastore/constants.py +119 -0
- howler/datastore/exceptions.py +41 -0
- howler/datastore/howler_store.py +105 -0
- howler/datastore/migrations/fix_process.py +41 -0
- howler/datastore/operations.py +130 -0
- howler/datastore/schemas.py +90 -0
- howler/datastore/store.py +231 -0
- howler/datastore/support/__init__.py +0 -0
- howler/datastore/support/build.py +215 -0
- howler/datastore/support/schemas.py +90 -0
- howler/datastore/types.py +22 -0
- howler/error.py +91 -0
- howler/external/__init__.py +0 -0
- howler/external/generate_mitre.py +96 -0
- howler/external/generate_sigma_rules.py +31 -0
- howler/external/generate_tlds.py +47 -0
- howler/external/reindex_data.py +66 -0
- howler/external/wipe_databases.py +58 -0
- howler/gunicorn_config.py +25 -0
- howler/healthz.py +47 -0
- howler/helper/__init__.py +0 -0
- howler/helper/azure.py +50 -0
- howler/helper/discover.py +59 -0
- howler/helper/hit.py +236 -0
- howler/helper/oauth.py +247 -0
- howler/helper/search.py +92 -0
- howler/helper/workflow.py +110 -0
- howler/helper/ws.py +378 -0
- howler/odm/README.md +102 -0
- howler/odm/__init__.py +1 -0
- howler/odm/base.py +1543 -0
- howler/odm/charter.txt +146 -0
- howler/odm/helper.py +416 -0
- howler/odm/howler_enum.py +25 -0
- howler/odm/models/__init__.py +0 -0
- howler/odm/models/action.py +33 -0
- howler/odm/models/analytic.py +90 -0
- howler/odm/models/assemblyline.py +48 -0
- howler/odm/models/aws.py +23 -0
- howler/odm/models/azure.py +16 -0
- howler/odm/models/cbs.py +44 -0
- howler/odm/models/config.py +558 -0
- howler/odm/models/dossier.py +33 -0
- howler/odm/models/ecs/__init__.py +0 -0
- howler/odm/models/ecs/agent.py +17 -0
- howler/odm/models/ecs/autonomous_system.py +16 -0
- howler/odm/models/ecs/client.py +149 -0
- howler/odm/models/ecs/cloud.py +141 -0
- howler/odm/models/ecs/code_signature.py +27 -0
- howler/odm/models/ecs/container.py +32 -0
- howler/odm/models/ecs/dns.py +62 -0
- howler/odm/models/ecs/egress.py +10 -0
- howler/odm/models/ecs/elf.py +74 -0
- howler/odm/models/ecs/email.py +122 -0
- howler/odm/models/ecs/error.py +14 -0
- howler/odm/models/ecs/event.py +140 -0
- howler/odm/models/ecs/faas.py +24 -0
- howler/odm/models/ecs/file.py +84 -0
- howler/odm/models/ecs/geo.py +30 -0
- howler/odm/models/ecs/group.py +18 -0
- howler/odm/models/ecs/hash.py +16 -0
- howler/odm/models/ecs/host.py +17 -0
- howler/odm/models/ecs/http.py +37 -0
- howler/odm/models/ecs/ingress.py +12 -0
- howler/odm/models/ecs/interface.py +21 -0
- howler/odm/models/ecs/network.py +30 -0
- howler/odm/models/ecs/observer.py +45 -0
- howler/odm/models/ecs/organization.py +12 -0
- howler/odm/models/ecs/os.py +21 -0
- howler/odm/models/ecs/pe.py +17 -0
- howler/odm/models/ecs/process.py +216 -0
- howler/odm/models/ecs/registry.py +26 -0
- howler/odm/models/ecs/related.py +45 -0
- howler/odm/models/ecs/rule.py +51 -0
- howler/odm/models/ecs/server.py +24 -0
- howler/odm/models/ecs/threat.py +247 -0
- howler/odm/models/ecs/tls.py +58 -0
- howler/odm/models/ecs/url.py +51 -0
- howler/odm/models/ecs/user.py +57 -0
- howler/odm/models/ecs/user_agent.py +20 -0
- howler/odm/models/ecs/vulnerability.py +41 -0
- howler/odm/models/gcp.py +16 -0
- howler/odm/models/hit.py +356 -0
- howler/odm/models/howler_data.py +328 -0
- howler/odm/models/lead.py +24 -0
- howler/odm/models/localized_label.py +13 -0
- howler/odm/models/overview.py +16 -0
- howler/odm/models/pivot.py +40 -0
- howler/odm/models/template.py +24 -0
- howler/odm/models/user.py +83 -0
- howler/odm/models/view.py +34 -0
- howler/odm/random_data.py +888 -0
- howler/odm/randomizer.py +609 -0
- howler/patched.py +5 -0
- howler/plugins/__init__.py +25 -0
- howler/plugins/config.py +123 -0
- howler/remote/__init__.py +0 -0
- howler/remote/datatypes/README.md +355 -0
- howler/remote/datatypes/__init__.py +98 -0
- howler/remote/datatypes/counters.py +63 -0
- howler/remote/datatypes/events.py +66 -0
- howler/remote/datatypes/hash.py +206 -0
- howler/remote/datatypes/lock.py +42 -0
- howler/remote/datatypes/queues/__init__.py +0 -0
- howler/remote/datatypes/queues/comms.py +59 -0
- howler/remote/datatypes/queues/multi.py +32 -0
- howler/remote/datatypes/queues/named.py +93 -0
- howler/remote/datatypes/queues/priority.py +215 -0
- howler/remote/datatypes/set.py +118 -0
- howler/remote/datatypes/user_quota_tracker.py +54 -0
- howler/security/__init__.py +253 -0
- howler/security/socket.py +108 -0
- howler/security/utils.py +185 -0
- howler/services/__init__.py +0 -0
- howler/services/action_service.py +111 -0
- howler/services/analytic_service.py +128 -0
- howler/services/auth_service.py +323 -0
- howler/services/config_service.py +128 -0
- howler/services/dossier_service.py +252 -0
- howler/services/event_service.py +93 -0
- howler/services/hit_service.py +893 -0
- howler/services/jwt_service.py +158 -0
- howler/services/lucene_service.py +286 -0
- howler/services/notebook_service.py +119 -0
- howler/services/overview_service.py +44 -0
- howler/services/template_service.py +45 -0
- howler/services/user_service.py +331 -0
- howler/utils/__init__.py +0 -0
- howler/utils/annotations.py +28 -0
- howler/utils/chunk.py +38 -0
- howler/utils/dict_utils.py +200 -0
- howler/utils/isotime.py +17 -0
- howler/utils/list_utils.py +11 -0
- howler/utils/lucene.py +77 -0
- howler/utils/path.py +27 -0
- howler/utils/socket_utils.py +61 -0
- howler/utils/str_utils.py +256 -0
- howler/utils/uid.py +47 -0
- howler_api-3.0.0.dev374.dist-info/METADATA +71 -0
- howler_api-3.0.0.dev374.dist-info/RECORD +198 -0
- howler_api-3.0.0.dev374.dist-info/WHEEL +4 -0
- 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))
|