howler-api 3.4.0.dev830__py3-none-any.whl → 3.4.0.dev845__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.
- howler/actions/__init__.py +91 -21
- howler/actions/add_label.py +3 -1
- howler/actions/add_to_bundle.py +15 -3
- howler/actions/change_field.py +1 -1
- howler/actions/demote.py +3 -1
- howler/actions/example_plugin.py +1 -1
- howler/actions/prioritization.py +3 -1
- howler/actions/promote.py +3 -1
- howler/actions/remove_from_bundle.py +16 -5
- howler/actions/remove_label.py +3 -1
- howler/actions/transition.py +16 -3
- howler/api/v1/action.py +23 -7
- howler/common/loader.py +1 -1
- howler/odm/base.py +80 -21
- howler/odm/models/user.py +1 -1
- howler/odm/random_data.py +13 -1
- howler/utils/compat.py +27 -0
- {howler_api-3.4.0.dev830.dist-info → howler_api-3.4.0.dev845.dist-info}/METADATA +1 -1
- {howler_api-3.4.0.dev830.dist-info → howler_api-3.4.0.dev845.dist-info}/RECORD +21 -20
- {howler_api-3.4.0.dev830.dist-info → howler_api-3.4.0.dev845.dist-info}/WHEEL +0 -0
- {howler_api-3.4.0.dev830.dist-info → howler_api-3.4.0.dev845.dist-info}/entry_points.txt +0 -0
howler/actions/__init__.py
CHANGED
|
@@ -2,8 +2,10 @@ import importlib
|
|
|
2
2
|
import os
|
|
3
3
|
import re
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
from types import ModuleType
|
|
5
6
|
from typing import Any, Optional
|
|
6
7
|
|
|
8
|
+
from howler.common.loader import datastore
|
|
7
9
|
from howler.common.logging import get_logger
|
|
8
10
|
from howler.odm.models.user import User
|
|
9
11
|
from howler.plugins import get_plugins
|
|
@@ -12,6 +14,9 @@ logger = get_logger(__file__)
|
|
|
12
14
|
|
|
13
15
|
PLUGIN_PATH = Path(os.environ.get("HWL_PLUGIN_DIRECTORY", "/etc/howler/plugins"))
|
|
14
16
|
|
|
17
|
+
# Roles that grant advanced hit limits
|
|
18
|
+
ADVANCED_ROLES = {"automation_advanced", "actionrunner_advanced", "admin"}
|
|
19
|
+
|
|
15
20
|
|
|
16
21
|
def __sanitize_specification(spec: dict[str, Any]) -> dict[str, Any]:
|
|
17
22
|
"""Adapt the specification for use in the UI
|
|
@@ -71,6 +76,68 @@ def __sanitize_report(report: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
|
71
76
|
return sanitized
|
|
72
77
|
|
|
73
78
|
|
|
79
|
+
def _get_operation(operation_id: str) -> ModuleType | None:
|
|
80
|
+
"""Find and return an operation module by ID."""
|
|
81
|
+
operation = None
|
|
82
|
+
try:
|
|
83
|
+
operation = importlib.import_module(f"howler.actions.{operation_id}")
|
|
84
|
+
except ImportError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
if not operation:
|
|
88
|
+
for plugin in get_plugins():
|
|
89
|
+
if not plugin.modules.operations:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
operation = next(
|
|
93
|
+
(operation for operation in plugin.modules.operations if operation.OPERATION_ID == operation_id), None
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if operation:
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
return operation
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def check_hit_limit(
|
|
103
|
+
query: str, user: User, max_hits_basic: int | None, max_hits_advanced: int | None
|
|
104
|
+
) -> dict[str, Any] | None:
|
|
105
|
+
"""Check if the user exceeds hit count limits. Returns error dict if exceeded, None otherwise.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
query: The query to check hit count for (should be the effective query the operation will execute).
|
|
109
|
+
user: The user executing the action.
|
|
110
|
+
max_hits_basic: Maximum hits allowed for basic users (None for no limit).
|
|
111
|
+
max_hits_advanced: Maximum hits allowed for advanced users (None for no limit).
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Error dict if limit exceeded, None otherwise.
|
|
115
|
+
"""
|
|
116
|
+
is_advanced = bool(ADVANCED_ROLES & set(user["type"]))
|
|
117
|
+
limit = max_hits_advanced if is_advanced else max_hits_basic
|
|
118
|
+
|
|
119
|
+
if limit is not None:
|
|
120
|
+
hit_count = datastore().hit.search(query, rows=0)["total"]
|
|
121
|
+
if hit_count > limit:
|
|
122
|
+
return {
|
|
123
|
+
"query": query,
|
|
124
|
+
"outcome": "error",
|
|
125
|
+
"title": "Hit limit exceeded",
|
|
126
|
+
"message": (
|
|
127
|
+
f"This action affects {hit_count} hits, but you can only process {limit} at a time. "
|
|
128
|
+
"Contact an administrator for bulk operations."
|
|
129
|
+
),
|
|
130
|
+
}
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _check_hit_limit(operation: ModuleType, query: str, user: User) -> dict[str, Any] | None:
|
|
135
|
+
"""Central hit limit check using raw query. Skipped if operation sets SKIP_CENTRAL_LIMIT."""
|
|
136
|
+
max_hits_basic = getattr(operation, "MAX_HITS_BASIC", None)
|
|
137
|
+
max_hits_advanced = getattr(operation, "MAX_HITS_ADVANCED", None)
|
|
138
|
+
return check_hit_limit(query, user, max_hits_basic, max_hits_advanced)
|
|
139
|
+
|
|
140
|
+
|
|
74
141
|
def execute(
|
|
75
142
|
operation_id: str,
|
|
76
143
|
query: str,
|
|
@@ -89,23 +156,7 @@ def execute(
|
|
|
89
156
|
Returns:
|
|
90
157
|
list[dict[str, Any]]: A report on the execution
|
|
91
158
|
"""
|
|
92
|
-
operation =
|
|
93
|
-
try:
|
|
94
|
-
operation = importlib.import_module(f"howler.actions.{operation_id}")
|
|
95
|
-
except ImportError:
|
|
96
|
-
pass
|
|
97
|
-
|
|
98
|
-
if not operation:
|
|
99
|
-
for plugin in get_plugins():
|
|
100
|
-
if not plugin.modules.operations:
|
|
101
|
-
continue
|
|
102
|
-
|
|
103
|
-
operation = next(
|
|
104
|
-
(operation for operation in plugin.modules.operations if operation.OPERATION_ID == operation_id), None
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
if operation:
|
|
108
|
-
break
|
|
159
|
+
operation = _get_operation(operation_id)
|
|
109
160
|
|
|
110
161
|
if not operation:
|
|
111
162
|
return [
|
|
@@ -117,9 +168,21 @@ def execute(
|
|
|
117
168
|
}
|
|
118
169
|
]
|
|
119
170
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
171
|
+
if not user:
|
|
172
|
+
return [
|
|
173
|
+
{
|
|
174
|
+
"query": query,
|
|
175
|
+
"outcome": "error",
|
|
176
|
+
"title": "Authentication required",
|
|
177
|
+
"message": "You must be logged in to execute actions.",
|
|
178
|
+
}
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
user_roles = set(user["type"])
|
|
182
|
+
is_admin = "admin" in user_roles
|
|
183
|
+
required_roles = set(operation.specification()["roles"])
|
|
184
|
+
has_roles = required_roles & user_roles
|
|
185
|
+
if not is_admin and not has_roles:
|
|
123
186
|
return [
|
|
124
187
|
{
|
|
125
188
|
"query": query,
|
|
@@ -127,11 +190,18 @@ def execute(
|
|
|
127
190
|
"title": "Insufficient permissions",
|
|
128
191
|
"message": (
|
|
129
192
|
f"The operation ID provided ({operation_id}) requires permissions you do not have "
|
|
130
|
-
f"({', '.join(
|
|
193
|
+
f"(missing one of: {', '.join(sorted(required_roles))}). "
|
|
194
|
+
"Contact HOWLER Support for more information."
|
|
131
195
|
),
|
|
132
196
|
}
|
|
133
197
|
]
|
|
134
198
|
|
|
199
|
+
# Skip central limit check if operation handles it locally with transformed query
|
|
200
|
+
if not getattr(operation, "SKIP_CENTRAL_LIMIT", False):
|
|
201
|
+
limit_error = _check_hit_limit(operation, query, user)
|
|
202
|
+
if limit_error:
|
|
203
|
+
return [limit_error]
|
|
204
|
+
|
|
135
205
|
report = operation.execute(query=query, request_id=request_id, user=user, **kwargs)
|
|
136
206
|
|
|
137
207
|
return __sanitize_report(report)
|
howler/actions/add_label.py
CHANGED
|
@@ -10,6 +10,8 @@ from howler.utils.str_utils import sanitize_lucene_query
|
|
|
10
10
|
hit_helper = OdmHelper(Hit)
|
|
11
11
|
|
|
12
12
|
OPERATION_ID = "add_label"
|
|
13
|
+
MAX_HITS_BASIC = 20
|
|
14
|
+
MAX_HITS_ADVANCED = 1000
|
|
13
15
|
|
|
14
16
|
CATEGORIES = list(Label.fields().keys())
|
|
15
17
|
|
|
@@ -99,7 +101,7 @@ def specification():
|
|
|
99
101
|
"short": "Add a label to a hit",
|
|
100
102
|
"long": execute.__doc__,
|
|
101
103
|
},
|
|
102
|
-
"roles": ["automation_basic"],
|
|
104
|
+
"roles": ["automation_basic", "actionrunner_basic"],
|
|
103
105
|
"steps": [
|
|
104
106
|
{
|
|
105
107
|
"args": {"category": [], "label": []},
|
howler/actions/add_to_bundle.py
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
|
|
3
|
+
from howler.actions import check_hit_limit
|
|
3
4
|
from howler.common.loader import datastore
|
|
4
5
|
from howler.datastore.operations import OdmHelper
|
|
5
6
|
from howler.odm.models.action import VALID_TRIGGERS
|
|
6
7
|
from howler.odm.models.hit import Hit
|
|
8
|
+
from howler.odm.models.user import User
|
|
7
9
|
from howler.services import hit_service
|
|
8
10
|
from howler.utils.str_utils import sanitize_lucene_query
|
|
9
11
|
|
|
10
12
|
hit_helper = OdmHelper(Hit)
|
|
11
13
|
|
|
12
14
|
OPERATION_ID = "add_to_bundle"
|
|
15
|
+
MAX_HITS_BASIC = 10
|
|
16
|
+
MAX_HITS_ADVANCED = 1000
|
|
17
|
+
SKIP_CENTRAL_LIMIT = True # This operation transforms the query, handles limit check locally
|
|
13
18
|
|
|
14
19
|
|
|
15
|
-
def execute(query: str, bundle_id: Optional[str] = None, **kwargs):
|
|
20
|
+
def execute(query: str, bundle_id: Optional[str] = None, user: Optional[User] = None, **kwargs):
|
|
16
21
|
"""Add a set of hits matching the query to the specified bundle.
|
|
17
22
|
|
|
18
23
|
Args:
|
|
@@ -77,7 +82,14 @@ def execute(query: str, bundle_id: Optional[str] = None, **kwargs):
|
|
|
77
82
|
)
|
|
78
83
|
|
|
79
84
|
safe_query = f"({query}) AND (-howler.bundles:({sanitize_lucene_query(bundle_id)}) AND howler.is_bundle:false)"
|
|
80
|
-
|
|
85
|
+
|
|
86
|
+
# Check hit limit against the effective query (not raw query)
|
|
87
|
+
if user:
|
|
88
|
+
limit_error = check_hit_limit(safe_query, user, MAX_HITS_BASIC, MAX_HITS_ADVANCED)
|
|
89
|
+
if limit_error:
|
|
90
|
+
return [limit_error]
|
|
91
|
+
|
|
92
|
+
matching_hits = ds.hit.search(safe_query, rows=MAX_HITS_ADVANCED, fl="howler.id")["items"]
|
|
81
93
|
if len(matching_hits) < 1:
|
|
82
94
|
report.append(
|
|
83
95
|
{
|
|
@@ -141,7 +153,7 @@ def specification():
|
|
|
141
153
|
"short": "Add a set of hits to a bundle",
|
|
142
154
|
"long": execute.__doc__,
|
|
143
155
|
},
|
|
144
|
-
"roles": ["automation_basic"],
|
|
156
|
+
"roles": ["automation_basic", "actionrunner_basic"],
|
|
145
157
|
"steps": [
|
|
146
158
|
{
|
|
147
159
|
"args": {"bundle_id": []},
|
howler/actions/change_field.py
CHANGED
|
@@ -65,7 +65,7 @@ def specification():
|
|
|
65
65
|
"short": "Change one of the fields of a hit",
|
|
66
66
|
"long": execute.__doc__,
|
|
67
67
|
},
|
|
68
|
-
"roles": ["automation_advanced", "admin"],
|
|
68
|
+
"roles": ["automation_advanced", "actionrunner_advanced", "admin"],
|
|
69
69
|
"steps": [
|
|
70
70
|
{
|
|
71
71
|
"args": {"field": [], "value": []},
|
howler/actions/demote.py
CHANGED
|
@@ -15,6 +15,8 @@ from howler.odm.models.user import User
|
|
|
15
15
|
from howler.utils.str_utils import sanitize_lucene_query
|
|
16
16
|
|
|
17
17
|
OPERATION_ID = "demote"
|
|
18
|
+
MAX_HITS_BASIC = 10
|
|
19
|
+
MAX_HITS_ADVANCED = 1000
|
|
18
20
|
|
|
19
21
|
ESCALATIONS = [esc for esc in Escalation.list() if esc != Escalation.EVIDENCE]
|
|
20
22
|
|
|
@@ -131,7 +133,7 @@ def specification():
|
|
|
131
133
|
"short": "Demote a hit",
|
|
132
134
|
"long": execute.__doc__,
|
|
133
135
|
},
|
|
134
|
-
"roles": ["automation_basic"],
|
|
136
|
+
"roles": ["automation_basic", "actionrunner_basic"],
|
|
135
137
|
"steps": [
|
|
136
138
|
{
|
|
137
139
|
"args": {"escalation": []},
|
howler/actions/example_plugin.py
CHANGED
|
@@ -67,7 +67,7 @@ def specification():
|
|
|
67
67
|
},
|
|
68
68
|
# What roles should be necessary to run this action? In general, automation_basic should always be required,
|
|
69
69
|
# while automation_advanced should be set when this action could be dangerous or costly in terms of resources.
|
|
70
|
-
"roles": ["automation_basic"],
|
|
70
|
+
"roles": ["automation_basic", "actionrunner_basic"],
|
|
71
71
|
# What data should the user be required to provide? This is split intop steps, so arguments can depend on each
|
|
72
72
|
# other, giving basic control flow for specifying arguments.
|
|
73
73
|
"steps": [
|
howler/actions/prioritization.py
CHANGED
|
@@ -6,6 +6,8 @@ from howler.odm.models.hit import Hit
|
|
|
6
6
|
hit_helper = OdmHelper(Hit)
|
|
7
7
|
|
|
8
8
|
OPERATION_ID = "prioritization"
|
|
9
|
+
MAX_HITS_BASIC = 10
|
|
10
|
+
MAX_HITS_ADVANCED = 1000
|
|
9
11
|
|
|
10
12
|
VALID_FIELDS = ["reliability", "severity", "volume", "confidence", "score"]
|
|
11
13
|
|
|
@@ -82,7 +84,7 @@ def specification():
|
|
|
82
84
|
"short": "Change one of the prioritization fields of a hit",
|
|
83
85
|
"long": execute.__doc__,
|
|
84
86
|
},
|
|
85
|
-
"roles": ["automation_basic"],
|
|
87
|
+
"roles": ["automation_basic", "actionrunner_basic"],
|
|
86
88
|
"steps": [
|
|
87
89
|
{
|
|
88
90
|
"args": {"field": [], "value": []},
|
howler/actions/promote.py
CHANGED
|
@@ -13,6 +13,8 @@ from howler.odm.models.howler_data import (
|
|
|
13
13
|
from howler.utils.str_utils import sanitize_lucene_query
|
|
14
14
|
|
|
15
15
|
OPERATION_ID = "promote"
|
|
16
|
+
MAX_HITS_BASIC = 10
|
|
17
|
+
MAX_HITS_ADVANCED = 1000
|
|
16
18
|
|
|
17
19
|
ESCALATIONS = [esc for esc in Escalation.list() if esc != Escalation.MISS]
|
|
18
20
|
|
|
@@ -118,7 +120,7 @@ def specification():
|
|
|
118
120
|
"short": "Promote a hit",
|
|
119
121
|
"long": execute.__doc__,
|
|
120
122
|
},
|
|
121
|
-
"roles": ["automation_basic"],
|
|
123
|
+
"roles": ["automation_basic", "actionrunner_basic"],
|
|
122
124
|
"steps": [
|
|
123
125
|
{
|
|
124
126
|
"args": {"escalation": []},
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
|
|
3
|
+
from howler.actions import check_hit_limit
|
|
3
4
|
from howler.common.exceptions import HowlerException
|
|
4
5
|
from howler.common.loader import datastore
|
|
5
6
|
from howler.datastore.operations import OdmHelper
|
|
6
7
|
from howler.odm.models.action import VALID_TRIGGERS
|
|
7
8
|
from howler.odm.models.hit import Hit
|
|
9
|
+
from howler.odm.models.user import User
|
|
8
10
|
from howler.services import hit_service
|
|
9
11
|
from howler.utils.str_utils import sanitize_lucene_query
|
|
10
12
|
|
|
11
13
|
hit_helper = OdmHelper(Hit)
|
|
12
14
|
|
|
13
15
|
OPERATION_ID = "remove_from_bundle"
|
|
16
|
+
MAX_HITS_BASIC = 10
|
|
17
|
+
MAX_HITS_ADVANCED = 1000
|
|
18
|
+
SKIP_CENTRAL_LIMIT = True # This operation transforms the query, handles limit check locally
|
|
14
19
|
|
|
15
20
|
|
|
16
|
-
def execute(query: str, bundle_id: Optional[str] = None, **kwargs):
|
|
21
|
+
def execute(query: str, bundle_id: Optional[str] = None, user: Optional[User] = None, **kwargs):
|
|
17
22
|
"""Remove a set of hits matching the query from the specified bundle.
|
|
18
23
|
|
|
19
24
|
Args:
|
|
@@ -62,9 +67,15 @@ def execute(query: str, bundle_id: Optional[str] = None, **kwargs):
|
|
|
62
67
|
}
|
|
63
68
|
)
|
|
64
69
|
|
|
65
|
-
safe_query = f"{query} AND (howler.bundles:{bundle_id})"
|
|
70
|
+
safe_query = f"{query} AND (howler.bundles:{sanitize_lucene_query(bundle_id)})"
|
|
66
71
|
|
|
67
|
-
|
|
72
|
+
# Check hit limit against the effective query (not raw query)
|
|
73
|
+
if user:
|
|
74
|
+
limit_error = check_hit_limit(safe_query, user, MAX_HITS_BASIC, MAX_HITS_ADVANCED)
|
|
75
|
+
if limit_error:
|
|
76
|
+
return [limit_error]
|
|
77
|
+
|
|
78
|
+
matching_hits = ds.hit.search(safe_query, rows=MAX_HITS_ADVANCED, fl="howler.id")["items"]
|
|
68
79
|
if len(matching_hits) < 1:
|
|
69
80
|
report.append(
|
|
70
81
|
{
|
|
@@ -83,7 +94,7 @@ def execute(query: str, bundle_id: Optional[str] = None, **kwargs):
|
|
|
83
94
|
|
|
84
95
|
hit_service.update_hit(
|
|
85
96
|
bundle_id,
|
|
86
|
-
[hit_helper.list_remove("howler.hits", h["howler"]["id"]) for h in
|
|
97
|
+
[hit_helper.list_remove("howler.hits", h["howler"]["id"]) for h in matching_hits],
|
|
87
98
|
)
|
|
88
99
|
|
|
89
100
|
if len(ds.hit.get(bundle_id).howler.hits) < 1:
|
|
@@ -121,7 +132,7 @@ def specification():
|
|
|
121
132
|
"short": "Remove a set of hits from a bundle",
|
|
122
133
|
"long": execute.__doc__,
|
|
123
134
|
},
|
|
124
|
-
"roles": ["automation_basic"],
|
|
135
|
+
"roles": ["automation_basic", "actionrunner_basic"],
|
|
125
136
|
"steps": [
|
|
126
137
|
{
|
|
127
138
|
"args": {"bundle_id": []},
|
howler/actions/remove_label.py
CHANGED
|
@@ -10,6 +10,8 @@ from howler.utils.str_utils import sanitize_lucene_query
|
|
|
10
10
|
hit_helper = OdmHelper(Hit)
|
|
11
11
|
|
|
12
12
|
OPERATION_ID = "remove_label"
|
|
13
|
+
MAX_HITS_BASIC = 20
|
|
14
|
+
MAX_HITS_ADVANCED = 1000
|
|
13
15
|
|
|
14
16
|
CATEGORIES = list(Label.fields().keys())
|
|
15
17
|
|
|
@@ -99,7 +101,7 @@ def specification():
|
|
|
99
101
|
"short": "Remove a label from a hit",
|
|
100
102
|
"long": execute.__doc__,
|
|
101
103
|
},
|
|
102
|
-
"roles": ["automation_basic"],
|
|
104
|
+
"roles": ["automation_basic", "actionrunner_basic"],
|
|
103
105
|
"steps": [
|
|
104
106
|
{
|
|
105
107
|
"args": {"category": [], "label": []},
|
howler/actions/transition.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import inspect
|
|
2
2
|
from typing import Optional, cast
|
|
3
3
|
|
|
4
|
+
from howler.actions import check_hit_limit
|
|
4
5
|
from howler.common.exceptions import InvalidDataException, NotFoundException
|
|
5
6
|
from howler.common.loader import datastore
|
|
6
7
|
from howler.common.logging import get_logger
|
|
@@ -17,6 +18,9 @@ from howler.services import event_service, hit_service
|
|
|
17
18
|
from howler.utils.list_utils import flatten_list
|
|
18
19
|
|
|
19
20
|
OPERATION_ID = "transition"
|
|
21
|
+
MAX_HITS_BASIC = 10
|
|
22
|
+
MAX_HITS_ADVANCED = 1000
|
|
23
|
+
SKIP_CENTRAL_LIMIT = True # This operation transforms the query, handles limit check locally
|
|
20
24
|
|
|
21
25
|
log = get_logger(__file__)
|
|
22
26
|
|
|
@@ -70,8 +74,17 @@ def execute(
|
|
|
70
74
|
status (str): The status from which to transition.
|
|
71
75
|
transition (str): The transition to attempt to execute.
|
|
72
76
|
"""
|
|
73
|
-
|
|
74
|
-
|
|
77
|
+
# Build effective query with status filter
|
|
78
|
+
effective_query = f"({query}) AND howler.status:{status}"
|
|
79
|
+
|
|
80
|
+
# Check hit limit against the effective query (not raw query)
|
|
81
|
+
limit_error = check_hit_limit(effective_query, user, MAX_HITS_BASIC, MAX_HITS_ADVANCED)
|
|
82
|
+
if limit_error:
|
|
83
|
+
return [limit_error]
|
|
84
|
+
|
|
85
|
+
is_advanced = "automation_advanced" in user.type or "actionrunner_advanced" in user.type or "admin" in user.type
|
|
86
|
+
rows = MAX_HITS_ADVANCED if is_advanced else MAX_HITS_BASIC
|
|
87
|
+
hits = datastore().hit.search(effective_query, rows=rows, fl="howler.id")
|
|
75
88
|
|
|
76
89
|
ids = [hit.howler.id for hit in hits["items"]]
|
|
77
90
|
|
|
@@ -176,7 +189,7 @@ def specification():
|
|
|
176
189
|
"short": "Transition a hit",
|
|
177
190
|
"long": execute.__doc__,
|
|
178
191
|
},
|
|
179
|
-
"roles": ["automation_basic"],
|
|
192
|
+
"roles": ["automation_basic", "actionrunner_basic"],
|
|
180
193
|
"steps": [
|
|
181
194
|
{
|
|
182
195
|
"args": {"status": []},
|
howler/api/v1/action.py
CHANGED
|
@@ -23,7 +23,11 @@ action_api._doc = "Endpoints relating to bulk actions and automation" # type: i
|
|
|
23
23
|
|
|
24
24
|
@generate_swagger_docs()
|
|
25
25
|
@action_api.route("/")
|
|
26
|
-
@api_login(
|
|
26
|
+
@api_login(
|
|
27
|
+
audit=False,
|
|
28
|
+
check_xsrf_token=False,
|
|
29
|
+
required_type=["admin", "automation_basic", "automation_advanced", "actionrunner_basic", "actionrunner_advanced"],
|
|
30
|
+
)
|
|
27
31
|
def get_actions(**_) -> Response:
|
|
28
32
|
"""Get a list of existing actions
|
|
29
33
|
|
|
@@ -43,7 +47,7 @@ def get_actions(**_) -> Response:
|
|
|
43
47
|
|
|
44
48
|
@generate_swagger_docs()
|
|
45
49
|
@action_api.route("/", methods=["POST"])
|
|
46
|
-
@api_login(audit=False, check_xsrf_token=False, required_type=["automation_basic"])
|
|
50
|
+
@api_login(audit=False, check_xsrf_token=False, required_type=["admin", "automation_basic", "automation_advanced"])
|
|
47
51
|
def add_action(user: User, **_) -> Response:
|
|
48
52
|
"""Create a new action
|
|
49
53
|
|
|
@@ -97,7 +101,7 @@ def add_action(user: User, **_) -> Response:
|
|
|
97
101
|
@api_login(
|
|
98
102
|
audit=False,
|
|
99
103
|
check_xsrf_token=False,
|
|
100
|
-
required_type=["automation_basic"],
|
|
104
|
+
required_type=["admin", "automation_basic", "automation_advanced"],
|
|
101
105
|
)
|
|
102
106
|
def update_action(id: str, user: User, **_) -> Response:
|
|
103
107
|
"""Update an existing action
|
|
@@ -164,7 +168,7 @@ def update_action(id: str, user: User, **_) -> Response:
|
|
|
164
168
|
|
|
165
169
|
@generate_swagger_docs()
|
|
166
170
|
@action_api.route("/<id>", methods=["DELETE"])
|
|
167
|
-
@api_login(audit=True, check_xsrf_token=False, required_type=["automation_basic"])
|
|
171
|
+
@api_login(audit=True, check_xsrf_token=False, required_type=["admin", "automation_basic", "automation_advanced"])
|
|
168
172
|
def delete_action(id: str, user: User, **kwargs) -> Response:
|
|
169
173
|
"""Delete an existing action
|
|
170
174
|
|
|
@@ -200,7 +204,11 @@ def delete_action(id: str, user: User, **kwargs) -> Response:
|
|
|
200
204
|
|
|
201
205
|
@generate_swagger_docs()
|
|
202
206
|
@action_api.route("/<id>/execute", methods=["POST"])
|
|
203
|
-
@api_login(
|
|
207
|
+
@api_login(
|
|
208
|
+
audit=True,
|
|
209
|
+
check_xsrf_token=False,
|
|
210
|
+
required_type=["admin", "automation_basic", "automation_advanced", "actionrunner_basic", "actionrunner_advanced"],
|
|
211
|
+
)
|
|
204
212
|
def execute_action(id: str, **kwargs) -> Response:
|
|
205
213
|
"""Execute one or more actions on a given query
|
|
206
214
|
|
|
@@ -275,7 +283,11 @@ def execute_action(id: str, **kwargs) -> Response:
|
|
|
275
283
|
|
|
276
284
|
@generate_swagger_docs()
|
|
277
285
|
@action_api.route("/operations")
|
|
278
|
-
@api_login(
|
|
286
|
+
@api_login(
|
|
287
|
+
audit=False,
|
|
288
|
+
check_xsrf_token=False,
|
|
289
|
+
required_type=["admin", "automation_basic", "automation_advanced", "actionrunner_basic", "actionrunner_advanced"],
|
|
290
|
+
)
|
|
279
291
|
def get_operations(**_) -> Response:
|
|
280
292
|
"""Get a list of operations the user can run on a query
|
|
281
293
|
|
|
@@ -295,7 +307,11 @@ def get_operations(**_) -> Response:
|
|
|
295
307
|
|
|
296
308
|
@generate_swagger_docs()
|
|
297
309
|
@action_api.route("/execute", methods=["POST"])
|
|
298
|
-
@api_login(
|
|
310
|
+
@api_login(
|
|
311
|
+
audit=True,
|
|
312
|
+
check_xsrf_token=False,
|
|
313
|
+
required_type=["admin", "automation_basic", "automation_advanced", "actionrunner_basic", "actionrunner_advanced"],
|
|
314
|
+
)
|
|
299
315
|
def execute_operations(**kwargs) -> Response:
|
|
300
316
|
"""Execute one or more operations on a given query
|
|
301
317
|
|
howler/common/loader.py
CHANGED
|
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
|
|
13
13
|
|
|
14
14
|
APP_NAME = os.environ.get("APP_NAME", "howler")
|
|
15
15
|
APP_PREFIX = os.environ.get("APP_PREFIX", "hwl")
|
|
16
|
-
USER_TYPES = {"admin", "user", "automation_basic", "automation_advanced"}
|
|
16
|
+
USER_TYPES = {"admin", "user", "automation_basic", "automation_advanced", "actionrunner_basic", "actionrunner_advanced"}
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def env_substitute(buffer):
|
howler/odm/base.py
CHANGED
|
@@ -19,7 +19,7 @@ from datetime import datetime
|
|
|
19
19
|
from enum import Enum as PyEnum
|
|
20
20
|
from enum import EnumMeta
|
|
21
21
|
from typing import Any as _Any
|
|
22
|
-
from typing import
|
|
22
|
+
from typing import Callable
|
|
23
23
|
from venv import logger
|
|
24
24
|
|
|
25
25
|
import arrow
|
|
@@ -29,6 +29,8 @@ from dateutil.tz import tzutc
|
|
|
29
29
|
from howler.common import loader
|
|
30
30
|
from howler.common.exceptions import HowlerKeyError, HowlerNotImplementedError, HowlerTypeError, HowlerValueError
|
|
31
31
|
from howler.common.net import is_valid_domain, is_valid_ip
|
|
32
|
+
from howler.odm.howler_enum import HowlerEnum
|
|
33
|
+
from howler.utils.compat import StrEnum as PyStrEnum
|
|
32
34
|
from howler.utils.dict_utils import flatten, recursive_update
|
|
33
35
|
from howler.utils.isotime import now_as_iso
|
|
34
36
|
from howler.utils.uid import get_random_id
|
|
@@ -549,15 +551,23 @@ class Processor(ValidatedKeyword):
|
|
|
549
551
|
|
|
550
552
|
|
|
551
553
|
class Enum(Keyword):
|
|
552
|
-
"""A field storing a short string that has predefined list of possible values
|
|
554
|
+
"""A field storing a short string that has predefined list of possible values.
|
|
553
555
|
|
|
554
|
-
|
|
556
|
+
Accepts values from lists/sets, Enum classes, and StrEnum (PyStrEnum) members.
|
|
557
|
+
"""
|
|
558
|
+
|
|
559
|
+
def __init__(
|
|
560
|
+
self,
|
|
561
|
+
values: type[HowlerEnum] | type[PyEnum] | type[PyStrEnum] | list[typing.Any] | set[typing.Any],
|
|
562
|
+
*args,
|
|
563
|
+
**kwargs,
|
|
564
|
+
):
|
|
555
565
|
super().__init__(*args, **kwargs)
|
|
556
566
|
if isinstance(values, set):
|
|
557
567
|
self.values = values
|
|
558
568
|
elif isinstance(values, (list, tuple)):
|
|
559
569
|
self.values = set(values)
|
|
560
|
-
elif isinstance(values, (PyEnum, EnumMeta)):
|
|
570
|
+
elif isinstance(values, (PyEnum, PyStrEnum, EnumMeta)):
|
|
561
571
|
self.values = set([e.value for e in values]) # type: ignore
|
|
562
572
|
else:
|
|
563
573
|
raise HowlerTypeError(f"Type unsupported for Enum odm: {type(values)}")
|
|
@@ -1063,7 +1073,7 @@ class Optional(_Field):
|
|
|
1063
1073
|
|
|
1064
1074
|
class Model:
|
|
1065
1075
|
@classmethod
|
|
1066
|
-
def fields(cls, skip_mappings=False) -> dict[str, _Field]:
|
|
1076
|
+
def fields(cls, skip_mappings=False, no_cache=False) -> dict[str, _Field]:
|
|
1067
1077
|
"""Describe the elements of the model.
|
|
1068
1078
|
|
|
1069
1079
|
For compound fields return the field object.
|
|
@@ -1071,33 +1081,42 @@ class Model:
|
|
|
1071
1081
|
Args:
|
|
1072
1082
|
skip_mappings (bool): Skip over mappings where the real subfield names are unknown.
|
|
1073
1083
|
"""
|
|
1074
|
-
if skip_mappings and
|
|
1084
|
+
if not no_cache and skip_mappings and "_odm_field_cache_skip" in cls.__dict__:
|
|
1075
1085
|
return cls._odm_field_cache_skip
|
|
1076
1086
|
|
|
1077
|
-
if not skip_mappings and
|
|
1087
|
+
if not no_cache and not skip_mappings and "_odm_field_cache" in cls.__dict__:
|
|
1078
1088
|
return cls._odm_field_cache
|
|
1079
1089
|
|
|
1080
1090
|
out = dict()
|
|
1091
|
+
# Iterate through inherited classes. If any of them are odm.Model (e.g., they expose fields())
|
|
1092
|
+
# then include their fields as well. This allows for class inheritance.
|
|
1093
|
+
for base in cls.__bases__:
|
|
1094
|
+
_fields: Callable[..., dict[str, _Field]] | None = getattr(base, "fields", None)
|
|
1095
|
+
if _fields and callable(_fields):
|
|
1096
|
+
out.update(_fields(skip_mappings=skip_mappings, no_cache=True))
|
|
1097
|
+
|
|
1081
1098
|
for name, field_data in cls.__dict__.items():
|
|
1082
1099
|
if isinstance(field_data, _Field):
|
|
1083
1100
|
if skip_mappings and isinstance(field_data, Mapping):
|
|
1084
1101
|
continue
|
|
1085
1102
|
out[name.rstrip("_")] = field_data
|
|
1086
1103
|
|
|
1087
|
-
if
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1104
|
+
if not no_cache:
|
|
1105
|
+
if skip_mappings:
|
|
1106
|
+
cls._odm_field_cache_skip = out
|
|
1107
|
+
else:
|
|
1108
|
+
cls._odm_field_cache = out
|
|
1109
|
+
|
|
1091
1110
|
return out
|
|
1092
1111
|
|
|
1093
1112
|
@classmethod
|
|
1094
1113
|
def add_namespace(cls, namespace: str, field: _Field, index=None, store=None, description=None):
|
|
1095
1114
|
recursive_set_name(field, namespace)
|
|
1096
1115
|
|
|
1097
|
-
if
|
|
1116
|
+
if "_odm_field_cache_skip" in cls.__dict__:
|
|
1098
1117
|
cls._odm_field_cache_skip[namespace.rstrip("_")] = field
|
|
1099
1118
|
|
|
1100
|
-
if
|
|
1119
|
+
if "_odm_field_cache" in cls.__dict__:
|
|
1101
1120
|
cls._odm_field_cache[namespace.rstrip("_")] = field
|
|
1102
1121
|
|
|
1103
1122
|
setattr(cls, namespace, field)
|
|
@@ -1112,10 +1131,10 @@ class Model:
|
|
|
1112
1131
|
|
|
1113
1132
|
@classmethod
|
|
1114
1133
|
def remove_namespace(cls, namespace: str):
|
|
1115
|
-
if
|
|
1134
|
+
if "_odm_field_cache_skip" in cls.__dict__:
|
|
1116
1135
|
del cls._odm_field_cache_skip[namespace.rstrip("_")]
|
|
1117
1136
|
|
|
1118
|
-
if
|
|
1137
|
+
if "_odm_field_cache" in cls.__dict__:
|
|
1119
1138
|
del cls._odm_field_cache[namespace.rstrip("_")]
|
|
1120
1139
|
|
|
1121
1140
|
delattr(cls, namespace)
|
|
@@ -1147,8 +1166,11 @@ class Model:
|
|
|
1147
1166
|
else:
|
|
1148
1167
|
out[name] = sub_field
|
|
1149
1168
|
|
|
1150
|
-
if
|
|
1151
|
-
|
|
1169
|
+
if show_compound:
|
|
1170
|
+
if isinstance(field, Compound):
|
|
1171
|
+
out[name] = field
|
|
1172
|
+
elif isinstance(field, List) and isinstance(field.child_type, Compound):
|
|
1173
|
+
out[name] = field.child_type
|
|
1152
1174
|
|
|
1153
1175
|
return out
|
|
1154
1176
|
|
|
@@ -1163,6 +1185,11 @@ class Model:
|
|
|
1163
1185
|
skip_mappings (bool): Skip over mappings where the real subfield names are unknown.
|
|
1164
1186
|
"""
|
|
1165
1187
|
out = dict()
|
|
1188
|
+
for base in cls.__bases__:
|
|
1189
|
+
_flat_fields: Callable[..., dict[str, _Field]] | None = getattr(base, "flat_fields", None)
|
|
1190
|
+
if _flat_fields and callable(_flat_fields):
|
|
1191
|
+
out.update(_flat_fields(show_compound=show_compound, skip_mappings=skip_mappings))
|
|
1192
|
+
|
|
1166
1193
|
for name, field in cls.__dict__.items():
|
|
1167
1194
|
if isinstance(field, _Field):
|
|
1168
1195
|
if skip_mappings and isinstance(field, Mapping):
|
|
@@ -1185,7 +1212,7 @@ class Model:
|
|
|
1185
1212
|
include_autogen_note=True,
|
|
1186
1213
|
defaults=None,
|
|
1187
1214
|
url_prefix="/howler/odm/class/",
|
|
1188
|
-
) ->
|
|
1215
|
+
) -> str:
|
|
1189
1216
|
markdown_content = (
|
|
1190
1217
|
(
|
|
1191
1218
|
'??? success "Auto-Generated Documentation"\n '
|
|
@@ -1476,12 +1503,44 @@ def recursive_set_name(field, name, to_parent=False):
|
|
|
1476
1503
|
recursive_set_name(field.child_type, name, to_parent=True)
|
|
1477
1504
|
|
|
1478
1505
|
|
|
1479
|
-
def model(index=None, store=None, description=None):
|
|
1480
|
-
"""Decorator
|
|
1506
|
+
def model(index=None, store=None, description=None, id_field=None):
|
|
1507
|
+
"""Decorator that finalizes a Model subclass for use with the datastore.
|
|
1508
|
+
Assigns metadata to the class (description, id field), validates that all
|
|
1509
|
+
declared field names are legal, recursively sets each field's name, and
|
|
1510
|
+
applies default index/store settings to every field.
|
|
1511
|
+
If ``id_field`` is not provided, it defaults to ``<classname_lower>_id``.
|
|
1512
|
+
Args:
|
|
1513
|
+
index: Default index setting applied to all fields on the model.
|
|
1514
|
+
store: Default store setting applied to all fields on the model.
|
|
1515
|
+
description: Human-readable description of the model.
|
|
1516
|
+
id_field: Name of the field used as the primary key. Defaults to
|
|
1517
|
+
``<classname_lower>_id`` when not specified.
|
|
1518
|
+
Returns:
|
|
1519
|
+
A class decorator that configures and returns the decorated Model subclass.
|
|
1520
|
+
Raises:
|
|
1521
|
+
HowlerValueError: If any field name fails the ``FIELD_SANITIZER`` regex
|
|
1522
|
+
or appears in ``BANNED_FIELDS``.
|
|
1523
|
+
"""
|
|
1481
1524
|
|
|
1482
1525
|
def _finish_model(cls):
|
|
1483
1526
|
cls._Model__description = description
|
|
1484
|
-
|
|
1527
|
+
fields = cls.fields()
|
|
1528
|
+
|
|
1529
|
+
if id_field is None:
|
|
1530
|
+
cls._Model__id_field = f"{cls.__name__.lower()}_id"
|
|
1531
|
+
else:
|
|
1532
|
+
if not isinstance(id_field, str):
|
|
1533
|
+
raise HowlerTypeError(f"id_field must be a str, got {type(id_field).__name__}")
|
|
1534
|
+
|
|
1535
|
+
if not FIELD_SANITIZER.match(id_field) or id_field in BANNED_FIELDS:
|
|
1536
|
+
raise HowlerValueError(f"Illegal id_field name: {id_field}")
|
|
1537
|
+
|
|
1538
|
+
if id_field not in fields:
|
|
1539
|
+
raise HowlerValueError(f"id_field must reference a declared field: {id_field}")
|
|
1540
|
+
|
|
1541
|
+
cls._Model__id_field = id_field
|
|
1542
|
+
|
|
1543
|
+
for name, field_data in fields.items():
|
|
1485
1544
|
if not FIELD_SANITIZER.match(name) or name in BANNED_FIELDS:
|
|
1486
1545
|
raise HowlerValueError(f"Illegal variable name: {name}")
|
|
1487
1546
|
|
howler/odm/models/user.py
CHANGED
|
@@ -63,7 +63,7 @@ class User(odm.Model):
|
|
|
63
63
|
access_control: str = odm.Keyword(index=False, store=False, optional=True, description="Access control filter")
|
|
64
64
|
type: list[str] = odm.List(
|
|
65
65
|
odm.Enum(values=loader.USER_TYPES),
|
|
66
|
-
default=["user", "
|
|
66
|
+
default=["user", "actionrunner_basic"],
|
|
67
67
|
description="Type of user",
|
|
68
68
|
)
|
|
69
69
|
uname: str = odm.Keyword(copyto="__text__", description="Username")
|
howler/odm/random_data.py
CHANGED
|
@@ -119,7 +119,14 @@ def create_users(ds):
|
|
|
119
119
|
"email": "admin@howler.cyber.gc.ca",
|
|
120
120
|
"password": admin_hash,
|
|
121
121
|
"uname": "admin",
|
|
122
|
-
"type": [
|
|
122
|
+
"type": [
|
|
123
|
+
"admin",
|
|
124
|
+
"user",
|
|
125
|
+
"automation_basic",
|
|
126
|
+
"automation_advanced",
|
|
127
|
+
"actionrunner_basic",
|
|
128
|
+
"actionrunner_advanced",
|
|
129
|
+
],
|
|
123
130
|
"groups": [
|
|
124
131
|
"group1",
|
|
125
132
|
"group2",
|
|
@@ -169,6 +176,11 @@ def create_users(ds):
|
|
|
169
176
|
"password": user_hash,
|
|
170
177
|
},
|
|
171
178
|
},
|
|
179
|
+
"type": [
|
|
180
|
+
"user",
|
|
181
|
+
"automation_basic",
|
|
182
|
+
"actionrunner_basic",
|
|
183
|
+
],
|
|
172
184
|
"password": user_hash,
|
|
173
185
|
"uname": "user",
|
|
174
186
|
"favourite_views": [user_view.view_id],
|
howler/utils/compat.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Compatibility shims for symbols added in Python 3.11.
|
|
2
|
+
|
|
3
|
+
Import from this module instead of using `if sys.version_info` guards inline.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
if sys.version_info >= (3, 11):
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from typing import NotRequired, TypedDict
|
|
11
|
+
else:
|
|
12
|
+
from enum import Enum as _Enum
|
|
13
|
+
|
|
14
|
+
class StrEnum(str, _Enum): # type: ignore[no-redef]
|
|
15
|
+
"""str + Enum backport for Python < 3.11."""
|
|
16
|
+
|
|
17
|
+
def __str__(self) -> str:
|
|
18
|
+
return str.__str__(self)
|
|
19
|
+
|
|
20
|
+
def __format__(self, format_spec: str) -> str:
|
|
21
|
+
return str.__format__(self, format_spec)
|
|
22
|
+
|
|
23
|
+
# typing_extensions.TypedDict supports Generic[T] mixing on Python < 3.11;
|
|
24
|
+
# the stdlib version does not gain that until 3.11.
|
|
25
|
+
from typing_extensions import NotRequired, TypedDict # noqa: F401
|
|
26
|
+
|
|
27
|
+
__all__ = ["NotRequired", "StrEnum", "TypedDict"]
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
howler/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
howler/actions/__init__.py,sha256=
|
|
3
|
-
howler/actions/add_label.py,sha256=
|
|
4
|
-
howler/actions/add_to_bundle.py,sha256=
|
|
5
|
-
howler/actions/change_field.py,sha256=
|
|
6
|
-
howler/actions/demote.py,sha256=
|
|
7
|
-
howler/actions/example_plugin.py,sha256=
|
|
8
|
-
howler/actions/prioritization.py,sha256=
|
|
9
|
-
howler/actions/promote.py,sha256=
|
|
10
|
-
howler/actions/remove_from_bundle.py,sha256=
|
|
11
|
-
howler/actions/remove_label.py,sha256=
|
|
12
|
-
howler/actions/transition.py,sha256=
|
|
2
|
+
howler/actions/__init__.py,sha256=dE2-GcmPu-co02NN1bwEBK5PXb0jCWqkM6V54-JgyuM,7865
|
|
3
|
+
howler/actions/add_label.py,sha256=eBQTdAa66_T7hZrVOIbLvMObTuoxpaJ3gmlzo-L1e2M,3337
|
|
4
|
+
howler/actions/add_to_bundle.py,sha256=u_Fb2HA_tbWgbaH8Pg1UhKxvqYPmum_ObrRWr-xNvi8,5807
|
|
5
|
+
howler/actions/change_field.py,sha256=OjL8Z2ZfifoJdFpUV876CEzrEPxsteqGfH6CksORpXo,2206
|
|
6
|
+
howler/actions/demote.py,sha256=b31yzpGiTSdEKcC9QFxF2Q-fE3OJ4Q7gSNVjM7Z9Vrk,5201
|
|
7
|
+
howler/actions/example_plugin.py,sha256=CVY2wenxHWjyhtEu8S4HOqGxYAYwE_3Otq8DIdmuFgU,4771
|
|
8
|
+
howler/actions/prioritization.py,sha256=ZpSo2L2WV3NtBSazqmLmskohWFGn2C9wdlAJc162gCo,2888
|
|
9
|
+
howler/actions/promote.py,sha256=VPpEmzQ7ApeSESNilNpUwiLRR5AdT4-xwYlOjenexgg,4770
|
|
10
|
+
howler/actions/remove_from_bundle.py,sha256=hgLULSQPqrHo4p1xmUmMmA-VoiEr4mjQhgCj1gyDZXw,4814
|
|
11
|
+
howler/actions/remove_label.py,sha256=W4eFe_Io0QPTaMCNPT53GLt4xvkrTKU05ipqJSZFitk,3392
|
|
12
|
+
howler/actions/transition.py,sha256=4eckby4SiWmy-JGjQE0sHNMmrH1x5zXLJIoTeWYuEPU,7073
|
|
13
13
|
howler/api/__init__.py,sha256=8IjKws_NIhDl22oHRNrVMqgwmO2i2aMwEPHUPb1hnes,8717
|
|
14
14
|
howler/api/base.py,sha256=i--N7f4itJBRNVayDAqOiZYIq9thXwdblRBGfePyP1c,2689
|
|
15
15
|
howler/api/socket.py,sha256=VaH3O5rBAn9tZdPcdC-6TaJ-gksQ3u41kz2YAtafNA4,4097
|
|
16
16
|
howler/api/v1/__init__.py,sha256=Qy4PXXm9r3VNvuvPUZPtY8rtXgnmGIB8U3cNt65um-w,3872
|
|
17
|
-
howler/api/v1/action.py,sha256=
|
|
17
|
+
howler/api/v1/action.py,sha256=21lkZsWTeCfn1FITKplcW7PyVE9TlnXWInqyKERM6qo,11654
|
|
18
18
|
howler/api/v1/analytic.py,sha256=dPrWOPIZUOUGLObkWy8W8ErcvbkEVoya8aBY1ERUD7M,19788
|
|
19
19
|
howler/api/v1/auth.py,sha256=aM0PWXtySvgbrkT3RqWgJFzxOaM4cwtRThoYsGHCjac,14313
|
|
20
20
|
howler/api/v1/clue.py,sha256=zTEIq_sPomWaY8xIKhbN03l1UfZ_OVoqBT_3eoGZfN4,2938
|
|
@@ -37,7 +37,7 @@ howler/common/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
37
37
|
howler/common/classification.py,sha256=oOpyfZh4SudrCcLjBpJIOi3Ye_iy5Kc1Nu27KScp9og,43057
|
|
38
38
|
howler/common/classification.yml,sha256=3VBqzKYkiqz8bCsSuqNvqxeqUPN1NmXBKHusGFO3hbg,4092
|
|
39
39
|
howler/common/exceptions.py,sha256=An8MW-qB8g7EYLTxddnoIiyxNDQAqU9AmH8MrDFfnwQ,5804
|
|
40
|
-
howler/common/loader.py,sha256=
|
|
40
|
+
howler/common/loader.py,sha256=A8xDyfm6J_bqoiO-jH4G0h1ONd8WaORx7NGntUyR_94,5502
|
|
41
41
|
howler/common/logging/__init__.py,sha256=79UQ0ZbLwrHgHwHEaMpkRxRgtARudn1oV4AfBJQBNyU,7843
|
|
42
42
|
howler/common/logging/audit.py,sha256=iR42dJ8hhuXQFN60yyXcms31dPtgZSHBQlHH_VZTEZc,3528
|
|
43
43
|
howler/common/logging/format.py,sha256=UnkOJ5lw0m0XIT57CQHV9OCrOPGC8_2GVg1rD_jM4qg,1138
|
|
@@ -85,7 +85,7 @@ howler/helper/workflow.py,sha256=ATvEv5CI7Bm2bTEy0U0EPEdz6-fZfyqIkc-gZCYmRY8,480
|
|
|
85
85
|
howler/helper/ws.py,sha256=im7pFQJDmGs3EyaBviGD1csYmTTRwLx8Gfih6-MlPhs,15911
|
|
86
86
|
howler/odm/README.md,sha256=Ihc_DyjVQlLaIOEbPoQNPkum9Ecn8kn37-PMFQsX77s,5645
|
|
87
87
|
howler/odm/__init__.py,sha256=1n6vgBOrFcCHSBFysqgODERvqP7s5DIeJe8N8UeE5pM,44
|
|
88
|
-
howler/odm/base.py,sha256=
|
|
88
|
+
howler/odm/base.py,sha256=oO5MW5hziQp5xd_x2NNCl1F2v3vbm731Bn6-71Ou65Q,55745
|
|
89
89
|
howler/odm/charter.txt,sha256=-Wgrv7nqugZmeQknJk0_m6klLJStjVbuqKbi_KaDinQ,15277
|
|
90
90
|
howler/odm/helper.py,sha256=t12eFRnDfEELn4K2lpccXRWHAPdz2-f_pVFUp4lva2Q,13945
|
|
91
91
|
howler/odm/howler_enum.py,sha256=JzRK3_adlhvfkoGdMZD1jgOwlneZs8-x7OxGEj3zcpY,768
|
|
@@ -145,9 +145,9 @@ howler/odm/models/localized_label.py,sha256=G7gfQ1cngiI4KprqldHWE1KHkAgK4AG_JsfH
|
|
|
145
145
|
howler/odm/models/overview.py,sha256=kvZcMYPDlkJEGa0L1jq9pG0RFjLOVudC64-2GTWVu2w,684
|
|
146
146
|
howler/odm/models/pivot.py,sha256=ZewcGh91xbE64snZ5Ahz2So1onAqO8U9H4CFe6jBOXs,1405
|
|
147
147
|
howler/odm/models/template.py,sha256=-Tqq_36qD_3nQ4jv13OPeH_EyyERKhc55wT03CU-Kbk,979
|
|
148
|
-
howler/odm/models/user.py,sha256=
|
|
148
|
+
howler/odm/models/user.py,sha256=1MSQEUopCUHZLjGaUY_L_2uJnBQYlXODzC6VoHRPWLE,3390
|
|
149
149
|
howler/odm/models/view.py,sha256=DYFrYcx4NT-Z_0GUg_5qjRRLwrMMFK78NQ-20iuFx8o,1323
|
|
150
|
-
howler/odm/random_data.py,sha256=
|
|
150
|
+
howler/odm/random_data.py,sha256=9nTxel-hw3ujLyZer41-AVQUXieemdx7KbpPXNlCVng,29783
|
|
151
151
|
howler/odm/randomizer.py,sha256=2gBwQA6karZQhX82cz0oBhgCXvmVlFXhdKXGdjoAjLM,23870
|
|
152
152
|
howler/patched.py,sha256=Br4BGU5raaqjSMDLD7ogb5A8Yn0dzecouh6uWVV2jlQ,77
|
|
153
153
|
howler/plugins/__init__.py,sha256=P5P-t4KgIInOzp4NmIturNIhUbb3jPO81n55Q_b_gM0,841
|
|
@@ -187,6 +187,7 @@ howler/telemetry.py,sha256=bXjXhZXwiwcGDJAD2ZNN_Zh6aZd19cQqtmn3G47Ckac,2584
|
|
|
187
187
|
howler/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
188
188
|
howler/utils/annotations.py,sha256=GLuDbjbXp8esDji3qhQY_uQyOWVqfIdpF_zs2t0IaMI,878
|
|
189
189
|
howler/utils/chunk.py,sha256=NoVDKzZkO8O92xXk0s0Pny2g7It9BX0PqWbknmnRSFg,895
|
|
190
|
+
howler/utils/compat.py,sha256=iFUqWgdbrilxKZkRAGR_nPGqi9rWUtIhtOR_7oX6uRk,851
|
|
190
191
|
howler/utils/constants.py,sha256=FxmABq-DE5eFTHDlGvy5jRNm-6AU-o4mRo093QWBofw,121
|
|
191
192
|
howler/utils/dict_utils.py,sha256=L5bB7WRACZLxBlxHY4cRNH7ASBieEPGTbeSIqU_VOVs,7063
|
|
192
193
|
howler/utils/isotime.py,sha256=iCGae-3OPS6PxQx76IwOpV_IRYc0wF9ezp61h7MifsY,449
|
|
@@ -196,7 +197,7 @@ howler/utils/path.py,sha256=DfOU4i4zSs4wchHoE8iE7aWVLkTxiC_JRGepF2hBYBk,690
|
|
|
196
197
|
howler/utils/socket_utils.py,sha256=nz1SklC9xBHUSfHyTJjpq3mbozX1GDf01WzdGxfaUII,2212
|
|
197
198
|
howler/utils/str_utils.py,sha256=HE8Hqh2HlOLaj16w0H9zKOyDJLp-f1LQ50y_WeGZaEk,8389
|
|
198
199
|
howler/utils/uid.py,sha256=p9dsqyvZ-lpiAuzZWCPCeEM99kdk0Ly9czf04HNdSuw,1341
|
|
199
|
-
howler_api-3.4.0.
|
|
200
|
-
howler_api-3.4.0.
|
|
201
|
-
howler_api-3.4.0.
|
|
202
|
-
howler_api-3.4.0.
|
|
200
|
+
howler_api-3.4.0.dev845.dist-info/METADATA,sha256=G95U-YGAkHdhBvqYdZD6iqzaaUAbYTWGRxjCiA8A3yI,2879
|
|
201
|
+
howler_api-3.4.0.dev845.dist-info/WHEEL,sha256=Vz2fHgx6HFtSwhs8KvkHLqH5Ea4w1_rner5uNVGCeIE,88
|
|
202
|
+
howler_api-3.4.0.dev845.dist-info/entry_points.txt,sha256=Lu9SBGvwe0wczJHmc-RudC24lmQk7tv3ZBXon9RIihg,259
|
|
203
|
+
howler_api-3.4.0.dev845.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|