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.
@@ -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 = None
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
- user_roles: set[str] = set(user["type"] if user else [])
121
- missing_roles = set(operation.specification()["roles"]) - user_roles
122
- if missing_roles:
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(missing_roles)}). Contact HOWLER Support for more information."
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)
@@ -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": []},
@@ -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
- matching_hits = ds.hit.search(safe_query)["items"]
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": []},
@@ -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": []},
@@ -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": [
@@ -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
- matching_hits = ds.hit.search(safe_query)["items"]
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 ds.hit.search(safe_query)["items"]],
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": []},
@@ -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": []},
@@ -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
- rows = 1000 if "automation_advanced" in user.type else 10
74
- hits = datastore().hit.search(f"({query}) AND howler.status:{status}", rows=rows, fl="howler.id")
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(audit=False, check_xsrf_token=False, required_type=["automation_basic"])
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(audit=True, check_xsrf_token=False, required_type=["automation_basic"])
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(audit=False, check_xsrf_token=False, required_type=["automation_basic"])
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(audit=True, check_xsrf_token=False, required_type=["automation_basic"])
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 Dict, Union
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
- def __init__(self, values: PyEnum | list[typing.Any] | set[typing.Any], *args, **kwargs):
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 hasattr(cls, "_odm_field_cache_skip"):
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 hasattr(cls, "_odm_field_cache"):
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 skip_mappings:
1088
- cls._odm_field_cache_skip = out
1089
- else:
1090
- cls._odm_field_cache = out
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 hasattr(cls, "_odm_field_cache_skip"):
1116
+ if "_odm_field_cache_skip" in cls.__dict__:
1098
1117
  cls._odm_field_cache_skip[namespace.rstrip("_")] = field
1099
1118
 
1100
- if hasattr(cls, "_odm_field_cache"):
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 hasattr(cls, "_odm_field_cache_skip"):
1134
+ if "_odm_field_cache_skip" in cls.__dict__:
1116
1135
  del cls._odm_field_cache_skip[namespace.rstrip("_")]
1117
1136
 
1118
- if hasattr(cls, "_odm_field_cache"):
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 isinstance(field, Compound) and show_compound:
1151
- out[name] = field
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
- ) -> Union[str, Dict]:
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 to create model objects."""
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
- for name, field_data in cls.fields().items():
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", "automation_basic"],
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": ["admin", "user", "automation_basic", "automation_advanced"],
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: howler-api
3
- Version: 3.4.0.dev830
3
+ Version: 3.4.0.dev845
4
4
  Summary: Howler - API server
5
5
  License: MIT
6
6
  Keywords: howler,alerting,gc,canada,cse-cst,cse,cst,cyber,cccs
@@ -1,20 +1,20 @@
1
1
  howler/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- howler/actions/__init__.py,sha256=Xkr37l1JSyjed5mrgZmxYKHX6AcG1rw3y48i86HlLjQ,5172
3
- howler/actions/add_label.py,sha256=3Vvjsn3FBPReFLGRANyoJB28xY7ZK31Zm1xIThpbq0s,3270
4
- howler/actions/add_to_bundle.py,sha256=SY0v5uTns5OLi_BHY50wGCLQmi1zSw1CaErbhOgAq1U,5245
5
- howler/actions/change_field.py,sha256=wC2n8IzfD7TtgdP3M2Kzws2hu0OuQRq1_QeQ6iNzj8M,2181
6
- howler/actions/demote.py,sha256=dYz7XpAAs03SyXlOHwGZN-NKb2GfAhbfVHeQa51NOp8,5134
7
- howler/actions/example_plugin.py,sha256=TPF-ERmfhWrU1H64uC1DheWKG6Z1RZle5inRi5tUmnE,4749
8
- howler/actions/prioritization.py,sha256=qXgvw98YzaFVICIW6jQR-cQSIAZIwqL7-jioHE6s_Y0,2821
9
- howler/actions/promote.py,sha256=v2lFS4WkOpX47ChbSYbhJKzwAShYE3Fg2UVQ6sbr7Yw,4703
10
- howler/actions/remove_from_bundle.py,sha256=Tt9zPZuPrplM8lvCo2sHKORY4U4z3S6RfltXRigDvF4,4251
11
- howler/actions/remove_label.py,sha256=I6_4KnBgvW6OwP7tNe5LMbaOZfAVf5S9ADijBt7g-9A,3325
12
- howler/actions/transition.py,sha256=B_qIbnBdcFvvhPg9H8gE6jYGVDF9sAxBnJTMBajMO-Q,6456
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=Q-2GdL-xWMNno0GnmggjTe4Zm3XUzfTaaSlMk24h4NY,11182
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=VgoNwf8G95t4MIe9TBYhnSDmU1XZlxsmeNEP1wFcFrg,5455
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=jXJBaLTx83s1g9BbAZp4S1GJJz_fBxWHIP8CW0em27Q,52956
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=T1VTbUfRHU4g48iccW3TS7lWSgGXVfd1CuSx-b3TDjE,3388
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=96HRJu1O47hDvZ3pA55UPN6ckPh458oxrxQrmupmJng,29490
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.dev830.dist-info/METADATA,sha256=MHWu4BXE8lJmKTHkP9jq3jHd0l8SxZyNytPKXUfYvE4,2879
200
- howler_api-3.4.0.dev830.dist-info/WHEEL,sha256=Vz2fHgx6HFtSwhs8KvkHLqH5Ea4w1_rner5uNVGCeIE,88
201
- howler_api-3.4.0.dev830.dist-info/entry_points.txt,sha256=Lu9SBGvwe0wczJHmc-RudC24lmQk7tv3ZBXon9RIihg,259
202
- howler_api-3.4.0.dev830.dist-info/RECORD,,
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,,