mcp-eregistrations-bpa 0.8.5__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 mcp-eregistrations-bpa might be problematic. Click here for more details.
- mcp_eregistrations_bpa/__init__.py +121 -0
- mcp_eregistrations_bpa/__main__.py +6 -0
- mcp_eregistrations_bpa/arazzo/__init__.py +21 -0
- mcp_eregistrations_bpa/arazzo/expression.py +379 -0
- mcp_eregistrations_bpa/audit/__init__.py +56 -0
- mcp_eregistrations_bpa/audit/context.py +66 -0
- mcp_eregistrations_bpa/audit/logger.py +236 -0
- mcp_eregistrations_bpa/audit/models.py +131 -0
- mcp_eregistrations_bpa/auth/__init__.py +64 -0
- mcp_eregistrations_bpa/auth/callback.py +391 -0
- mcp_eregistrations_bpa/auth/cas.py +409 -0
- mcp_eregistrations_bpa/auth/oidc.py +252 -0
- mcp_eregistrations_bpa/auth/permissions.py +162 -0
- mcp_eregistrations_bpa/auth/token_manager.py +348 -0
- mcp_eregistrations_bpa/bpa_client/__init__.py +84 -0
- mcp_eregistrations_bpa/bpa_client/client.py +740 -0
- mcp_eregistrations_bpa/bpa_client/endpoints.py +193 -0
- mcp_eregistrations_bpa/bpa_client/errors.py +276 -0
- mcp_eregistrations_bpa/bpa_client/models.py +203 -0
- mcp_eregistrations_bpa/config.py +349 -0
- mcp_eregistrations_bpa/db/__init__.py +21 -0
- mcp_eregistrations_bpa/db/connection.py +64 -0
- mcp_eregistrations_bpa/db/migrations.py +168 -0
- mcp_eregistrations_bpa/exceptions.py +39 -0
- mcp_eregistrations_bpa/py.typed +0 -0
- mcp_eregistrations_bpa/rollback/__init__.py +19 -0
- mcp_eregistrations_bpa/rollback/manager.py +616 -0
- mcp_eregistrations_bpa/server.py +152 -0
- mcp_eregistrations_bpa/tools/__init__.py +372 -0
- mcp_eregistrations_bpa/tools/actions.py +155 -0
- mcp_eregistrations_bpa/tools/analysis.py +352 -0
- mcp_eregistrations_bpa/tools/audit.py +399 -0
- mcp_eregistrations_bpa/tools/behaviours.py +1042 -0
- mcp_eregistrations_bpa/tools/bots.py +627 -0
- mcp_eregistrations_bpa/tools/classifications.py +575 -0
- mcp_eregistrations_bpa/tools/costs.py +765 -0
- mcp_eregistrations_bpa/tools/debug_strategies.py +351 -0
- mcp_eregistrations_bpa/tools/debugger.py +1230 -0
- mcp_eregistrations_bpa/tools/determinants.py +2235 -0
- mcp_eregistrations_bpa/tools/document_requirements.py +670 -0
- mcp_eregistrations_bpa/tools/export.py +899 -0
- mcp_eregistrations_bpa/tools/fields.py +162 -0
- mcp_eregistrations_bpa/tools/form_errors.py +36 -0
- mcp_eregistrations_bpa/tools/formio_helpers.py +971 -0
- mcp_eregistrations_bpa/tools/forms.py +1269 -0
- mcp_eregistrations_bpa/tools/jsonlogic_builder.py +466 -0
- mcp_eregistrations_bpa/tools/large_response.py +163 -0
- mcp_eregistrations_bpa/tools/messages.py +523 -0
- mcp_eregistrations_bpa/tools/notifications.py +241 -0
- mcp_eregistrations_bpa/tools/registration_institutions.py +680 -0
- mcp_eregistrations_bpa/tools/registrations.py +897 -0
- mcp_eregistrations_bpa/tools/role_status.py +447 -0
- mcp_eregistrations_bpa/tools/role_units.py +400 -0
- mcp_eregistrations_bpa/tools/roles.py +1236 -0
- mcp_eregistrations_bpa/tools/rollback.py +335 -0
- mcp_eregistrations_bpa/tools/services.py +674 -0
- mcp_eregistrations_bpa/tools/workflows.py +2487 -0
- mcp_eregistrations_bpa/tools/yaml_transformer.py +991 -0
- mcp_eregistrations_bpa/workflows/__init__.py +28 -0
- mcp_eregistrations_bpa/workflows/loader.py +440 -0
- mcp_eregistrations_bpa/workflows/models.py +336 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/METADATA +965 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/RECORD +66 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/WHEEL +4 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/entry_points.txt +2 -0
- mcp_eregistrations_bpa-0.8.5.dist-info/licenses/LICENSE +86 -0
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
"""Component behaviour tools for BPA API.
|
|
2
|
+
|
|
3
|
+
This module provides MCP tools for managing component behaviours
|
|
4
|
+
in the BPA API. Component behaviours link determinants to form
|
|
5
|
+
components via effects that control component visibility, activation,
|
|
6
|
+
and other properties.
|
|
7
|
+
|
|
8
|
+
The BPA API does NOT have a direct endpoint to list all component
|
|
9
|
+
behaviours for a service. This module extracts behaviours from the
|
|
10
|
+
service export endpoint.
|
|
11
|
+
|
|
12
|
+
API Endpoints referenced:
|
|
13
|
+
- POST /download_service/{service_id} - Export service with componentBehaviours
|
|
14
|
+
- GET /service/{service_id}/behaviour/{component_key} - Get behaviour by component
|
|
15
|
+
- GET /behaviour/{id} - Get behaviour by ID
|
|
16
|
+
- POST /service/{service_id}/behaviour - Create component behaviour
|
|
17
|
+
|
|
18
|
+
Architecture (from bpa-determinants-research.md)::
|
|
19
|
+
|
|
20
|
+
Form Component
|
|
21
|
+
-> behaviourId -> componentBehaviours[id]
|
|
22
|
+
-> effects[]
|
|
23
|
+
-> jsonDeterminants (JSONLogic expression)
|
|
24
|
+
-> propertyEffects[]
|
|
25
|
+
-> name: show|hide|enable|disable|etc.
|
|
26
|
+
-> value: "true"|"false"
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
35
|
+
|
|
36
|
+
from mcp_eregistrations_bpa.audit.context import (
|
|
37
|
+
NotAuthenticatedError,
|
|
38
|
+
get_current_user_email,
|
|
39
|
+
)
|
|
40
|
+
from mcp_eregistrations_bpa.audit.logger import AuditLogger
|
|
41
|
+
from mcp_eregistrations_bpa.bpa_client import BPAClient
|
|
42
|
+
from mcp_eregistrations_bpa.bpa_client.errors import (
|
|
43
|
+
BPAClientError,
|
|
44
|
+
BPANotFoundError,
|
|
45
|
+
translate_error,
|
|
46
|
+
)
|
|
47
|
+
from mcp_eregistrations_bpa.tools.large_response import large_response_handler
|
|
48
|
+
|
|
49
|
+
__all__ = [
|
|
50
|
+
"componentbehaviour_list",
|
|
51
|
+
"componentbehaviour_get",
|
|
52
|
+
"componentbehaviour_get_by_component",
|
|
53
|
+
"effect_create",
|
|
54
|
+
"effect_delete",
|
|
55
|
+
"register_behaviour_tools",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
# Valid effect types for property effects
|
|
59
|
+
VALID_EFFECT_TYPES = ["activate", "deactivate", "show", "hide", "enable", "disable"]
|
|
60
|
+
|
|
61
|
+
# Valid logic operators for combining determinants
|
|
62
|
+
VALID_LOGIC_OPERATORS = ["AND", "OR"]
|
|
63
|
+
|
|
64
|
+
# Mapping from BPA operator to JSONLogic operator
|
|
65
|
+
OPERATOR_MAPPING = {
|
|
66
|
+
"EQUAL": "==",
|
|
67
|
+
"NOT_EQUAL": "!=",
|
|
68
|
+
"GREATER_THAN": ">",
|
|
69
|
+
"LESS_THAN": "<",
|
|
70
|
+
"GREATER_THAN_OR_EQUAL": ">=",
|
|
71
|
+
"LESS_THAN_OR_EQUAL": "<=",
|
|
72
|
+
"CONTAINS": "in", # JSONLogic uses 'in' for contains
|
|
73
|
+
"IS_EMPTY": "!", # Will need special handling
|
|
74
|
+
"IS_NOT_EMPTY": "!!", # Will need special handling
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@large_response_handler(
|
|
79
|
+
threshold_bytes=50 * 1024, # 50KB threshold for list tools
|
|
80
|
+
navigation={
|
|
81
|
+
"list_all": "jq '.behaviours'",
|
|
82
|
+
"find_by_key": "jq '.behaviours[] | select(.component_key==\"x\")'",
|
|
83
|
+
"with_effects": "jq '.behaviours[] | select(.effect_count > 0)'",
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
async def componentbehaviour_list(service_id: str | int) -> dict[str, Any]:
|
|
87
|
+
"""List component behaviours for a service.
|
|
88
|
+
|
|
89
|
+
Extracts behaviours from service export (no direct BPA endpoint exists).
|
|
90
|
+
Large responses (>50KB) are saved to file with navigation hints.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
service_id: Service ID to list behaviours for.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
dict with behaviours (id, component_key, effect_count), total, service_id.
|
|
97
|
+
"""
|
|
98
|
+
# Validate service_id
|
|
99
|
+
if not service_id or (isinstance(service_id, str) and not service_id.strip()):
|
|
100
|
+
raise ToolError(
|
|
101
|
+
"'service_id' is required. Use 'service_list' to see available services."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
async with BPAClient() as client:
|
|
106
|
+
# First verify service exists
|
|
107
|
+
try:
|
|
108
|
+
await client.get(
|
|
109
|
+
"/service/{id}",
|
|
110
|
+
path_params={"id": service_id},
|
|
111
|
+
resource_type="service",
|
|
112
|
+
resource_id=service_id,
|
|
113
|
+
)
|
|
114
|
+
except BPANotFoundError:
|
|
115
|
+
raise ToolError(
|
|
116
|
+
f"Service '{service_id}' not found. "
|
|
117
|
+
"Use 'service_list' to see available services."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Fetch service export to get componentBehaviours
|
|
121
|
+
# Use minimal export options - only need behaviours data
|
|
122
|
+
export_options = {
|
|
123
|
+
"serviceSelected": True,
|
|
124
|
+
"registrationsSelected": True,
|
|
125
|
+
"determinantsSelected": True,
|
|
126
|
+
"guideFormSelected": True,
|
|
127
|
+
"applicantFormSelected": True,
|
|
128
|
+
"sendFileFormSelected": True,
|
|
129
|
+
"paymentFormSelected": True,
|
|
130
|
+
"costsSelected": False,
|
|
131
|
+
"requirementsSelected": False,
|
|
132
|
+
"resultsSelected": False,
|
|
133
|
+
"activityConditionsSelected": False,
|
|
134
|
+
"registrationLawsSelected": False,
|
|
135
|
+
"serviceLocationsSelected": False,
|
|
136
|
+
"serviceTutorialsSelected": False,
|
|
137
|
+
"serviceTranslationsSelected": False,
|
|
138
|
+
"catalogsSelected": False,
|
|
139
|
+
"rolesSelected": False,
|
|
140
|
+
"printDocumentsSelected": False,
|
|
141
|
+
"botsSelected": False,
|
|
142
|
+
"copyService": False,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
export_data, _ = await client.download_service(
|
|
147
|
+
str(service_id),
|
|
148
|
+
options=export_options,
|
|
149
|
+
)
|
|
150
|
+
except BPANotFoundError:
|
|
151
|
+
raise ToolError(
|
|
152
|
+
f"Service '{service_id}' not found. "
|
|
153
|
+
"Use 'service_list' to see available services."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
except ToolError:
|
|
157
|
+
raise
|
|
158
|
+
except BPAClientError as e:
|
|
159
|
+
raise translate_error(e, resource_type="service", resource_id=service_id)
|
|
160
|
+
|
|
161
|
+
# Extract behaviours from export data
|
|
162
|
+
behaviours = _extract_behaviours_from_export(export_data)
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
"behaviours": behaviours,
|
|
166
|
+
"total": len(behaviours),
|
|
167
|
+
"service_id": str(service_id),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def componentbehaviour_get(behaviour_id: str) -> dict[str, Any]:
|
|
172
|
+
"""Get behaviour configuration with parsed JSONLogic determinants.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
behaviour_id: Behaviour UUID.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
dict with id, component_key, service_id, effects (id, determinants,
|
|
179
|
+
property_effects).
|
|
180
|
+
"""
|
|
181
|
+
# Validate behaviour_id
|
|
182
|
+
if not behaviour_id or (isinstance(behaviour_id, str) and not behaviour_id.strip()):
|
|
183
|
+
raise ToolError(
|
|
184
|
+
"'behaviour_id' is required. "
|
|
185
|
+
"Use 'componentbehaviour_list' or 'form_component_get' "
|
|
186
|
+
"to find behaviour IDs."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
async with BPAClient() as client:
|
|
191
|
+
try:
|
|
192
|
+
data = await client.get(
|
|
193
|
+
"/behaviour/{id}",
|
|
194
|
+
path_params={"id": behaviour_id},
|
|
195
|
+
resource_type="behaviour",
|
|
196
|
+
resource_id=behaviour_id,
|
|
197
|
+
)
|
|
198
|
+
except BPANotFoundError:
|
|
199
|
+
raise ToolError(
|
|
200
|
+
f"Behaviour '{behaviour_id}' not found. "
|
|
201
|
+
"Use 'componentbehaviour_list' or 'form_component_get' "
|
|
202
|
+
"to find behaviour IDs."
|
|
203
|
+
)
|
|
204
|
+
except ToolError:
|
|
205
|
+
raise
|
|
206
|
+
except BPAClientError as e:
|
|
207
|
+
raise translate_error(e, resource_type="behaviour", resource_id=behaviour_id)
|
|
208
|
+
|
|
209
|
+
return _transform_behaviour(data)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _extract_behaviours_from_export(
|
|
213
|
+
export_data: dict[str, Any],
|
|
214
|
+
) -> list[dict[str, Any]]:
|
|
215
|
+
"""Extract and transform component behaviours from service export.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
export_data: Raw service export data from BPA API.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
list: List of behaviour summaries with id, component_key, effect_count.
|
|
222
|
+
"""
|
|
223
|
+
behaviours: list[dict[str, Any]] = []
|
|
224
|
+
|
|
225
|
+
# Handle nested service structure (live API wraps in 'service' key)
|
|
226
|
+
data = export_data
|
|
227
|
+
if "service" in export_data and isinstance(export_data["service"], dict):
|
|
228
|
+
data = export_data["service"]
|
|
229
|
+
|
|
230
|
+
# Get componentBehaviours array
|
|
231
|
+
component_behaviours = data.get("componentBehaviours", [])
|
|
232
|
+
|
|
233
|
+
if not isinstance(component_behaviours, list):
|
|
234
|
+
return behaviours
|
|
235
|
+
|
|
236
|
+
for behaviour in component_behaviours:
|
|
237
|
+
if not isinstance(behaviour, dict):
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
behaviour_id = behaviour.get("id")
|
|
241
|
+
component_key = behaviour.get("componentKey")
|
|
242
|
+
effects = behaviour.get("effects", [])
|
|
243
|
+
|
|
244
|
+
# Count effects
|
|
245
|
+
effect_count = len(effects) if isinstance(effects, list) else 0
|
|
246
|
+
|
|
247
|
+
behaviours.append(
|
|
248
|
+
{
|
|
249
|
+
"id": behaviour_id,
|
|
250
|
+
"component_key": component_key,
|
|
251
|
+
"effect_count": effect_count,
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
return behaviours
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def componentbehaviour_get_by_component(
|
|
259
|
+
service_id: str | int,
|
|
260
|
+
component_key: str,
|
|
261
|
+
) -> dict[str, Any]:
|
|
262
|
+
"""Get behaviour configuration for a component with parsed JSONLogic.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
service_id: Service containing the component.
|
|
266
|
+
component_key: Form component key.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
dict with id, component_key, service_id, effects (id, determinants,
|
|
270
|
+
property_effects).
|
|
271
|
+
"""
|
|
272
|
+
# Validate parameters
|
|
273
|
+
if not service_id or (isinstance(service_id, str) and not service_id.strip()):
|
|
274
|
+
raise ToolError(
|
|
275
|
+
"'service_id' is required. Use 'service_list' to see available services."
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
comp_key_empty = isinstance(component_key, str) and not component_key.strip()
|
|
279
|
+
if not component_key or comp_key_empty:
|
|
280
|
+
raise ToolError(
|
|
281
|
+
"'component_key' is required. Use 'form_get' to see component keys."
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
async with BPAClient() as client:
|
|
286
|
+
try:
|
|
287
|
+
data = await client.get(
|
|
288
|
+
"/service/{service_id}/behaviour/{component_key}",
|
|
289
|
+
path_params={
|
|
290
|
+
"service_id": service_id,
|
|
291
|
+
"component_key": component_key,
|
|
292
|
+
},
|
|
293
|
+
resource_type="behaviour",
|
|
294
|
+
)
|
|
295
|
+
except BPANotFoundError:
|
|
296
|
+
raise ToolError(
|
|
297
|
+
f"No behaviour found for component '{component_key}' "
|
|
298
|
+
f"in service '{service_id}'. The component may not have "
|
|
299
|
+
"conditional logic configured."
|
|
300
|
+
)
|
|
301
|
+
except ToolError:
|
|
302
|
+
raise
|
|
303
|
+
except BPAClientError as e:
|
|
304
|
+
raise translate_error(e, resource_type="behaviour")
|
|
305
|
+
|
|
306
|
+
# Pass service_id to transformer for context (AC1 requirement)
|
|
307
|
+
return _transform_behaviour(data, service_id=service_id)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _parse_jsonlogic_condition(condition: dict[str, Any]) -> dict[str, Any]:
|
|
311
|
+
"""Parse a single JSONLogic condition into readable format.
|
|
312
|
+
|
|
313
|
+
Converts conditions like:
|
|
314
|
+
{"==": [{"var": "data.field"}, value]}
|
|
315
|
+
into:
|
|
316
|
+
{"field": "data.field", "operator": "==", "value": value}
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
condition: A JSONLogic condition object.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
dict: Parsed condition with field, operator, value.
|
|
323
|
+
"""
|
|
324
|
+
if not isinstance(condition, dict):
|
|
325
|
+
return {"raw": condition}
|
|
326
|
+
|
|
327
|
+
# Check for and/or logic operators (nested conditions)
|
|
328
|
+
for logic_op in ["and", "or"]:
|
|
329
|
+
if logic_op in condition:
|
|
330
|
+
nested = condition[logic_op]
|
|
331
|
+
if isinstance(nested, list):
|
|
332
|
+
parsed_conditions = [_parse_jsonlogic_condition(c) for c in nested]
|
|
333
|
+
return {
|
|
334
|
+
"logic": logic_op,
|
|
335
|
+
"conditions": parsed_conditions,
|
|
336
|
+
}
|
|
337
|
+
return {"logic": logic_op, "raw": nested}
|
|
338
|
+
|
|
339
|
+
# Check for comparison operators
|
|
340
|
+
comparison_ops = ["==", "!=", ">", "<", ">=", "<=", "in", "===", "!=="]
|
|
341
|
+
for op in comparison_ops:
|
|
342
|
+
if op in condition:
|
|
343
|
+
args = condition[op]
|
|
344
|
+
if isinstance(args, list) and len(args) >= 2:
|
|
345
|
+
# First arg should be {"var": "data.field"}
|
|
346
|
+
var_ref = args[0]
|
|
347
|
+
value = args[1]
|
|
348
|
+
|
|
349
|
+
field = None
|
|
350
|
+
if isinstance(var_ref, dict) and "var" in var_ref:
|
|
351
|
+
field = var_ref["var"]
|
|
352
|
+
elif isinstance(value, dict) and "var" in value:
|
|
353
|
+
# Swap if var is second
|
|
354
|
+
field = value["var"]
|
|
355
|
+
value = var_ref
|
|
356
|
+
|
|
357
|
+
if field:
|
|
358
|
+
return {
|
|
359
|
+
"field": field,
|
|
360
|
+
"operator": op,
|
|
361
|
+
"value": value,
|
|
362
|
+
}
|
|
363
|
+
return {"operator": op, "raw": args}
|
|
364
|
+
|
|
365
|
+
# Check for unary operators (!, !!)
|
|
366
|
+
if "!" in condition:
|
|
367
|
+
arg = condition["!"]
|
|
368
|
+
if isinstance(arg, list) and len(arg) == 1:
|
|
369
|
+
arg = arg[0]
|
|
370
|
+
if isinstance(arg, dict) and "var" in arg:
|
|
371
|
+
return {
|
|
372
|
+
"field": arg["var"],
|
|
373
|
+
"operator": "isEmpty",
|
|
374
|
+
"value": True,
|
|
375
|
+
}
|
|
376
|
+
return {"operator": "not", "raw": arg}
|
|
377
|
+
|
|
378
|
+
if "!!" in condition:
|
|
379
|
+
arg = condition["!!"]
|
|
380
|
+
if isinstance(arg, list) and len(arg) == 1:
|
|
381
|
+
arg = arg[0]
|
|
382
|
+
if isinstance(arg, dict) and "var" in arg:
|
|
383
|
+
return {
|
|
384
|
+
"field": arg["var"],
|
|
385
|
+
"operator": "isNotEmpty",
|
|
386
|
+
"value": True,
|
|
387
|
+
}
|
|
388
|
+
return {"operator": "truthy", "raw": arg}
|
|
389
|
+
|
|
390
|
+
# Check for 'if' conditions
|
|
391
|
+
if "if" in condition:
|
|
392
|
+
return {"operator": "if", "raw": condition["if"]}
|
|
393
|
+
|
|
394
|
+
# Unknown structure - return as raw
|
|
395
|
+
return {"raw": condition}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _parse_jsonlogic_expression(expr: Any) -> list[dict[str, Any]]:
|
|
399
|
+
"""Parse a JSONLogic expression (which may be an array) into readable format.
|
|
400
|
+
|
|
401
|
+
The jsonDeterminants field is typically a JSON string containing an array
|
|
402
|
+
of JSONLogic expressions, e.g.:
|
|
403
|
+
'[{"and": [{"==": [{"var": "data.field"}, true]}, ...]}]'
|
|
404
|
+
|
|
405
|
+
Args:
|
|
406
|
+
expr: Parsed JSON (list or dict) from jsonDeterminants.
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
list: List of parsed determinant conditions.
|
|
410
|
+
"""
|
|
411
|
+
if expr is None:
|
|
412
|
+
return []
|
|
413
|
+
|
|
414
|
+
# If it's a list, parse each element
|
|
415
|
+
if isinstance(expr, list):
|
|
416
|
+
return [_parse_jsonlogic_condition(item) for item in expr]
|
|
417
|
+
|
|
418
|
+
# If it's a single dict, wrap in list
|
|
419
|
+
if isinstance(expr, dict):
|
|
420
|
+
return [_parse_jsonlogic_condition(expr)]
|
|
421
|
+
|
|
422
|
+
# Return as raw for unexpected types
|
|
423
|
+
return [{"raw": expr}]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _parse_json_determinants(
|
|
427
|
+
json_determinants: str | list[dict[str, Any]] | dict[str, Any] | None,
|
|
428
|
+
) -> list[dict[str, Any]]:
|
|
429
|
+
"""Parse jsonDeterminants field into readable structure.
|
|
430
|
+
|
|
431
|
+
Handles:
|
|
432
|
+
- String: Parse JSON first, then parse JSONLogic
|
|
433
|
+
- List: Parse each JSONLogic expression
|
|
434
|
+
- Dict: Parse single JSONLogic expression
|
|
435
|
+
- None: Return empty list
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
json_determinants: Raw jsonDeterminants value from API.
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
list: Parsed determinants with readable structure.
|
|
442
|
+
"""
|
|
443
|
+
if json_determinants is None:
|
|
444
|
+
return []
|
|
445
|
+
|
|
446
|
+
# Parse string to JSON first
|
|
447
|
+
if isinstance(json_determinants, str):
|
|
448
|
+
if not json_determinants.strip():
|
|
449
|
+
return []
|
|
450
|
+
try:
|
|
451
|
+
parsed = json.loads(json_determinants)
|
|
452
|
+
return _parse_jsonlogic_expression(parsed)
|
|
453
|
+
except json.JSONDecodeError:
|
|
454
|
+
# Return raw string as fallback
|
|
455
|
+
return [{"raw": json_determinants}]
|
|
456
|
+
|
|
457
|
+
# Already parsed - process directly
|
|
458
|
+
return _parse_jsonlogic_expression(json_determinants)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _transform_behaviour(
|
|
462
|
+
data: dict[str, Any], service_id: str | int | None = None
|
|
463
|
+
) -> dict[str, Any]:
|
|
464
|
+
"""Transform behaviour API response to snake_case format with readable JSONLogic.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
data: Raw API response with camelCase keys.
|
|
468
|
+
service_id: Optional service ID to include in response for context.
|
|
469
|
+
If not provided, will attempt to extract from API response.
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
dict: Transformed response with:
|
|
473
|
+
- id: Behaviour UUID
|
|
474
|
+
- component_key: Form component key
|
|
475
|
+
- service_id: Service ID for context (if available)
|
|
476
|
+
- effects: List with id, determinants (readable format), property_effects
|
|
477
|
+
"""
|
|
478
|
+
effects: list[dict[str, Any]] = []
|
|
479
|
+
|
|
480
|
+
raw_effects = data.get("effects", [])
|
|
481
|
+
if isinstance(raw_effects, list):
|
|
482
|
+
for effect in raw_effects:
|
|
483
|
+
if not isinstance(effect, dict):
|
|
484
|
+
continue
|
|
485
|
+
|
|
486
|
+
# Parse jsonDeterminants into readable format
|
|
487
|
+
json_determinants = effect.get("jsonDeterminants")
|
|
488
|
+
parsed_determinants = _parse_json_determinants(json_determinants)
|
|
489
|
+
|
|
490
|
+
# Transform property effects
|
|
491
|
+
property_effects = []
|
|
492
|
+
for pe in effect.get("propertyEffects", []):
|
|
493
|
+
if isinstance(pe, dict):
|
|
494
|
+
property_effects.append(
|
|
495
|
+
{
|
|
496
|
+
"name": pe.get("name"),
|
|
497
|
+
"value": pe.get("value"),
|
|
498
|
+
}
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
effects.append(
|
|
502
|
+
{
|
|
503
|
+
"id": effect.get("id"),
|
|
504
|
+
"determinants": parsed_determinants,
|
|
505
|
+
"property_effects": property_effects,
|
|
506
|
+
}
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
# Determine service_id: use provided value, or extract from API response
|
|
510
|
+
resolved_service_id = service_id
|
|
511
|
+
if resolved_service_id is None:
|
|
512
|
+
# Try to extract from API response (may be serviceId or service_id)
|
|
513
|
+
resolved_service_id = data.get("serviceId") or data.get("service_id")
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
"id": data.get("id"),
|
|
517
|
+
"component_key": data.get("componentKey"),
|
|
518
|
+
"service_id": str(resolved_service_id) if resolved_service_id else None,
|
|
519
|
+
"effects": effects,
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _validate_effect_create_params(
|
|
524
|
+
service_id: str | int,
|
|
525
|
+
component_key: str,
|
|
526
|
+
determinant_ids: list[str],
|
|
527
|
+
logic: str,
|
|
528
|
+
effect_type: str,
|
|
529
|
+
effect_value: bool,
|
|
530
|
+
) -> None:
|
|
531
|
+
"""Validate effect_create parameters (pre-flight validation).
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
service_id: Service ID (required).
|
|
535
|
+
component_key: Form component key (required).
|
|
536
|
+
determinant_ids: List of determinant IDs (required, non-empty).
|
|
537
|
+
logic: Logic operator (AND or OR).
|
|
538
|
+
effect_type: Effect type (activate, deactivate, show, hide, enable, disable).
|
|
539
|
+
effect_value: Boolean value for the effect.
|
|
540
|
+
|
|
541
|
+
Raises:
|
|
542
|
+
ToolError: If validation fails.
|
|
543
|
+
"""
|
|
544
|
+
errors = []
|
|
545
|
+
|
|
546
|
+
if not service_id or (isinstance(service_id, str) and not service_id.strip()):
|
|
547
|
+
errors.append("'service_id' is required")
|
|
548
|
+
|
|
549
|
+
if not component_key or (
|
|
550
|
+
isinstance(component_key, str) and not component_key.strip()
|
|
551
|
+
):
|
|
552
|
+
errors.append("'component_key' is required")
|
|
553
|
+
|
|
554
|
+
if not determinant_ids:
|
|
555
|
+
errors.append("'determinant_ids' must contain at least one determinant ID")
|
|
556
|
+
elif not isinstance(determinant_ids, list):
|
|
557
|
+
errors.append("'determinant_ids' must be a list of determinant IDs")
|
|
558
|
+
elif len(determinant_ids) == 0:
|
|
559
|
+
errors.append("'determinant_ids' must contain at least one determinant ID")
|
|
560
|
+
|
|
561
|
+
if logic and logic.upper() not in VALID_LOGIC_OPERATORS:
|
|
562
|
+
errors.append(f"'logic' must be one of: {', '.join(VALID_LOGIC_OPERATORS)}")
|
|
563
|
+
|
|
564
|
+
if effect_type and effect_type.lower() not in VALID_EFFECT_TYPES:
|
|
565
|
+
errors.append(f"'effect_type' must be one of: {', '.join(VALID_EFFECT_TYPES)}")
|
|
566
|
+
|
|
567
|
+
if not isinstance(effect_value, bool):
|
|
568
|
+
errors.append("'effect_value' must be a boolean (true or false)")
|
|
569
|
+
|
|
570
|
+
if errors:
|
|
571
|
+
error_msg = "; ".join(errors)
|
|
572
|
+
raise ToolError(
|
|
573
|
+
f"Cannot create effect: {error_msg}. "
|
|
574
|
+
"Provide valid parameters for effect creation."
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _build_condition_for_determinant(determinant: dict[str, Any]) -> dict[str, Any]:
|
|
579
|
+
"""Build a JSONLogic condition from a determinant.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
determinant: Determinant data from BPA API.
|
|
583
|
+
|
|
584
|
+
Returns:
|
|
585
|
+
dict: JSONLogic condition object.
|
|
586
|
+
"""
|
|
587
|
+
det_type = determinant.get("type", "text")
|
|
588
|
+
target_field = determinant.get("targetFormFieldKey", "")
|
|
589
|
+
operator = determinant.get("operator", "EQUAL")
|
|
590
|
+
|
|
591
|
+
# Build the variable reference
|
|
592
|
+
var_ref = {"var": f"data.{target_field}"}
|
|
593
|
+
|
|
594
|
+
# Get the value based on determinant type
|
|
595
|
+
if det_type == "boolean":
|
|
596
|
+
value = determinant.get("booleanValue", True)
|
|
597
|
+
return {"==": [var_ref, value]}
|
|
598
|
+
|
|
599
|
+
elif det_type == "numeric":
|
|
600
|
+
value = determinant.get("numericValue", 0)
|
|
601
|
+
jsonlogic_op = OPERATOR_MAPPING.get(operator, "==")
|
|
602
|
+
return {jsonlogic_op: [var_ref, value]}
|
|
603
|
+
|
|
604
|
+
elif det_type in ("radio", "select"):
|
|
605
|
+
value = determinant.get("selectValue", "")
|
|
606
|
+
# For catalog/classification selections, may need .key suffix
|
|
607
|
+
if "." not in target_field:
|
|
608
|
+
var_ref = {"var": f"data.{target_field}.key"}
|
|
609
|
+
jsonlogic_op = OPERATOR_MAPPING.get(operator, "==")
|
|
610
|
+
return {jsonlogic_op: [var_ref, value]}
|
|
611
|
+
|
|
612
|
+
elif det_type == "classification":
|
|
613
|
+
value = determinant.get("classificationField", "")
|
|
614
|
+
var_ref = {"var": f"data.{target_field}.key"}
|
|
615
|
+
jsonlogic_op = OPERATOR_MAPPING.get(operator, "==")
|
|
616
|
+
return {jsonlogic_op: [var_ref, value]}
|
|
617
|
+
|
|
618
|
+
elif det_type == "date":
|
|
619
|
+
is_current_date = determinant.get("isCurrentDate", False)
|
|
620
|
+
if is_current_date:
|
|
621
|
+
# For current date comparison, use special handling
|
|
622
|
+
var_ref_date = {"var": f"data.{target_field}"}
|
|
623
|
+
current_date = {"var": "_currentDate"}
|
|
624
|
+
jsonlogic_op = OPERATOR_MAPPING.get(operator, "==")
|
|
625
|
+
return {jsonlogic_op: [var_ref_date, current_date]}
|
|
626
|
+
else:
|
|
627
|
+
value = determinant.get("dateValue", "")
|
|
628
|
+
jsonlogic_op = OPERATOR_MAPPING.get(operator, "==")
|
|
629
|
+
return {jsonlogic_op: [var_ref, value]}
|
|
630
|
+
|
|
631
|
+
else: # text and others
|
|
632
|
+
value = determinant.get("textValue", "")
|
|
633
|
+
jsonlogic_op = OPERATOR_MAPPING.get(operator, "==")
|
|
634
|
+
|
|
635
|
+
# Handle IS_EMPTY and IS_NOT_EMPTY specially
|
|
636
|
+
if operator == "IS_EMPTY":
|
|
637
|
+
return {"!": [var_ref]}
|
|
638
|
+
elif operator == "IS_NOT_EMPTY":
|
|
639
|
+
return {"!!": [var_ref]}
|
|
640
|
+
|
|
641
|
+
return {jsonlogic_op: [var_ref, value]}
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _build_jsonlogic_for_determinants(
|
|
645
|
+
determinants: list[dict[str, Any]], logic: str
|
|
646
|
+
) -> str:
|
|
647
|
+
"""Build stringified JSONLogic from multiple determinants.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
determinants: List of determinant data from BPA API.
|
|
651
|
+
logic: "AND" or "OR" for combining conditions.
|
|
652
|
+
|
|
653
|
+
Returns:
|
|
654
|
+
str: Stringified JSONLogic array (e.g., '[{"and": [...]}]')
|
|
655
|
+
"""
|
|
656
|
+
conditions = []
|
|
657
|
+
for det in determinants:
|
|
658
|
+
condition = _build_condition_for_determinant(det)
|
|
659
|
+
conditions.append(condition)
|
|
660
|
+
|
|
661
|
+
# If only one condition, no need for and/or wrapper
|
|
662
|
+
if len(conditions) == 1:
|
|
663
|
+
jsonlogic = conditions
|
|
664
|
+
else:
|
|
665
|
+
# Wrap in and/or based on logic parameter
|
|
666
|
+
logic_key = logic.lower() # "and" or "or"
|
|
667
|
+
jsonlogic = [{logic_key: conditions}]
|
|
668
|
+
|
|
669
|
+
return json.dumps(jsonlogic)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
async def _fetch_determinant_details(
|
|
673
|
+
client: BPAClient, determinant_id: str
|
|
674
|
+
) -> dict[str, Any]:
|
|
675
|
+
"""Fetch determinant details from BPA API.
|
|
676
|
+
|
|
677
|
+
Args:
|
|
678
|
+
client: BPA client instance.
|
|
679
|
+
determinant_id: ID of the determinant to fetch.
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
dict: Determinant data from API.
|
|
683
|
+
|
|
684
|
+
Raises:
|
|
685
|
+
ToolError: If determinant not found.
|
|
686
|
+
"""
|
|
687
|
+
try:
|
|
688
|
+
return await client.get(
|
|
689
|
+
"/determinant/{id}",
|
|
690
|
+
path_params={"id": determinant_id},
|
|
691
|
+
resource_type="determinant",
|
|
692
|
+
resource_id=determinant_id,
|
|
693
|
+
)
|
|
694
|
+
except BPANotFoundError:
|
|
695
|
+
raise ToolError(
|
|
696
|
+
f"Determinant '{determinant_id}' not found. "
|
|
697
|
+
"Use 'determinant_list' with service_id to see available determinants."
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
async def _get_existing_behaviour(
|
|
702
|
+
client: BPAClient, service_id: str | int, component_key: str
|
|
703
|
+
) -> dict[str, Any] | None:
|
|
704
|
+
"""Check if a behaviour already exists for a component.
|
|
705
|
+
|
|
706
|
+
Args:
|
|
707
|
+
client: BPA client instance.
|
|
708
|
+
service_id: Service ID.
|
|
709
|
+
component_key: Form component key.
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
dict: Existing behaviour data, or None if not found.
|
|
713
|
+
"""
|
|
714
|
+
try:
|
|
715
|
+
return await client.get(
|
|
716
|
+
"/service/{service_id}/behaviour/{component_key}",
|
|
717
|
+
path_params={
|
|
718
|
+
"service_id": service_id,
|
|
719
|
+
"component_key": component_key,
|
|
720
|
+
},
|
|
721
|
+
resource_type="behaviour",
|
|
722
|
+
)
|
|
723
|
+
except BPANotFoundError:
|
|
724
|
+
return None
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
async def effect_create(
|
|
728
|
+
service_id: str | int,
|
|
729
|
+
component_key: str,
|
|
730
|
+
determinant_ids: list[str],
|
|
731
|
+
logic: str = "AND",
|
|
732
|
+
effect_type: str = "activate",
|
|
733
|
+
effect_value: bool = True,
|
|
734
|
+
) -> dict[str, Any]:
|
|
735
|
+
"""Create effect linking determinants to a component. Audited write operation.
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
service_id: Parent service ID.
|
|
739
|
+
component_key: Form component key to apply effect to.
|
|
740
|
+
determinant_ids: Determinant IDs to combine (at least one).
|
|
741
|
+
logic: "AND" (all match) or "OR" (any match). Default: "AND".
|
|
742
|
+
effect_type: activate/deactivate/show/hide/enable/disable (default: activate).
|
|
743
|
+
effect_value: Boolean value for effect (default: True).
|
|
744
|
+
|
|
745
|
+
Returns:
|
|
746
|
+
dict with behaviour_id, effect_id, component_key, determinant_count,
|
|
747
|
+
effect_type, effect_value, logic, service_id, audit_id.
|
|
748
|
+
"""
|
|
749
|
+
# Pre-flight validation (no audit record for validation failures)
|
|
750
|
+
_validate_effect_create_params(
|
|
751
|
+
service_id, component_key, determinant_ids, logic, effect_type, effect_value
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
# Normalize parameters
|
|
755
|
+
logic = logic.upper()
|
|
756
|
+
effect_type = effect_type.lower()
|
|
757
|
+
|
|
758
|
+
# Get authenticated user for audit (before any API calls)
|
|
759
|
+
try:
|
|
760
|
+
user_email = get_current_user_email()
|
|
761
|
+
except NotAuthenticatedError as e:
|
|
762
|
+
raise ToolError(str(e))
|
|
763
|
+
|
|
764
|
+
# Use single BPAClient connection for all operations
|
|
765
|
+
try:
|
|
766
|
+
async with BPAClient() as client:
|
|
767
|
+
# Verify parent service exists before creating audit record
|
|
768
|
+
try:
|
|
769
|
+
await client.get(
|
|
770
|
+
"/service/{id}",
|
|
771
|
+
path_params={"id": service_id},
|
|
772
|
+
resource_type="service",
|
|
773
|
+
resource_id=service_id,
|
|
774
|
+
)
|
|
775
|
+
except BPANotFoundError:
|
|
776
|
+
raise ToolError(
|
|
777
|
+
f"Cannot create effect: Service '{service_id}' not found. "
|
|
778
|
+
"Use 'service_list' to see available services."
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
# Fetch all determinant details to build JSONLogic
|
|
782
|
+
determinants = []
|
|
783
|
+
for det_id in determinant_ids:
|
|
784
|
+
det_data = await _fetch_determinant_details(client, det_id)
|
|
785
|
+
determinants.append(det_data)
|
|
786
|
+
|
|
787
|
+
# Build JSONLogic expression
|
|
788
|
+
json_determinants = _build_jsonlogic_for_determinants(determinants, logic)
|
|
789
|
+
|
|
790
|
+
# Build property effect
|
|
791
|
+
property_effect = {
|
|
792
|
+
"name": effect_type,
|
|
793
|
+
"type": "boolean",
|
|
794
|
+
"value": "true" if effect_value else "false",
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
# Build the new effect
|
|
798
|
+
new_effect = {
|
|
799
|
+
"jsonDeterminants": json_determinants,
|
|
800
|
+
"propertyEffects": [property_effect],
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
# Check if behaviour already exists for this component
|
|
804
|
+
existing_behaviour = await _get_existing_behaviour(
|
|
805
|
+
client, service_id, component_key
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
# Create audit record BEFORE API call (audit-before-write pattern)
|
|
809
|
+
audit_logger = AuditLogger()
|
|
810
|
+
audit_id = await audit_logger.record_pending(
|
|
811
|
+
user_email=user_email,
|
|
812
|
+
operation_type="create",
|
|
813
|
+
object_type="effect",
|
|
814
|
+
params={
|
|
815
|
+
"service_id": str(service_id),
|
|
816
|
+
"component_key": component_key,
|
|
817
|
+
"determinant_ids": determinant_ids,
|
|
818
|
+
"logic": logic,
|
|
819
|
+
"effect_type": effect_type,
|
|
820
|
+
"effect_value": effect_value,
|
|
821
|
+
"existing_behaviour_id": existing_behaviour.get("id")
|
|
822
|
+
if existing_behaviour
|
|
823
|
+
else None,
|
|
824
|
+
},
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
try:
|
|
828
|
+
if existing_behaviour:
|
|
829
|
+
# Add effect to existing behaviour
|
|
830
|
+
existing_effects = existing_behaviour.get("effects", [])
|
|
831
|
+
existing_effects.append(new_effect)
|
|
832
|
+
|
|
833
|
+
payload = {
|
|
834
|
+
"id": existing_behaviour.get("id"),
|
|
835
|
+
"componentKey": component_key,
|
|
836
|
+
"effects": existing_effects,
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
# Update via PUT /behaviour
|
|
840
|
+
result = await client.put(
|
|
841
|
+
"/behaviour",
|
|
842
|
+
json=payload,
|
|
843
|
+
resource_type="behaviour",
|
|
844
|
+
resource_id=existing_behaviour.get("id"),
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
behaviour_id = existing_behaviour.get("id")
|
|
848
|
+
else:
|
|
849
|
+
# Create new behaviour
|
|
850
|
+
payload = {
|
|
851
|
+
"componentKey": component_key,
|
|
852
|
+
"effects": [new_effect],
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
result = await client.post(
|
|
856
|
+
"/service/{service_id}/behaviour",
|
|
857
|
+
path_params={"service_id": service_id},
|
|
858
|
+
json=payload,
|
|
859
|
+
resource_type="behaviour",
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
behaviour_id = result.get("id")
|
|
863
|
+
|
|
864
|
+
# Extract effect ID from result
|
|
865
|
+
effects = result.get("effects", [])
|
|
866
|
+
effect_id = None
|
|
867
|
+
if effects:
|
|
868
|
+
# The new effect should be the last one
|
|
869
|
+
effect_id = effects[-1].get("id")
|
|
870
|
+
|
|
871
|
+
# Save rollback state
|
|
872
|
+
await audit_logger.save_rollback_state(
|
|
873
|
+
audit_id=audit_id,
|
|
874
|
+
object_type="effect",
|
|
875
|
+
object_id=str(effect_id) if effect_id else str(behaviour_id),
|
|
876
|
+
previous_state={
|
|
877
|
+
"behaviour_id": behaviour_id,
|
|
878
|
+
"effect_id": effect_id,
|
|
879
|
+
"component_key": component_key,
|
|
880
|
+
"service_id": str(service_id),
|
|
881
|
+
"was_new_behaviour": existing_behaviour is None,
|
|
882
|
+
"_operation": "create",
|
|
883
|
+
},
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
# Mark audit as success
|
|
887
|
+
await audit_logger.mark_success(
|
|
888
|
+
audit_id,
|
|
889
|
+
result={
|
|
890
|
+
"behaviour_id": behaviour_id,
|
|
891
|
+
"effect_id": effect_id,
|
|
892
|
+
"component_key": component_key,
|
|
893
|
+
"determinant_count": len(determinant_ids),
|
|
894
|
+
},
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
"behaviour_id": behaviour_id,
|
|
899
|
+
"effect_id": effect_id,
|
|
900
|
+
"component_key": component_key,
|
|
901
|
+
"determinant_count": len(determinant_ids),
|
|
902
|
+
"effect_type": effect_type,
|
|
903
|
+
"effect_value": effect_value,
|
|
904
|
+
"logic": logic,
|
|
905
|
+
"service_id": str(service_id),
|
|
906
|
+
"audit_id": audit_id,
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
except BPAClientError as e:
|
|
910
|
+
# Mark audit as failed
|
|
911
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
912
|
+
raise translate_error(e, resource_type="behaviour")
|
|
913
|
+
|
|
914
|
+
except ToolError:
|
|
915
|
+
raise
|
|
916
|
+
except BPAClientError as e:
|
|
917
|
+
raise translate_error(e, resource_type="service", resource_id=service_id)
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _validate_effect_delete_params(behaviour_id: str) -> None:
|
|
921
|
+
"""Validate effect_delete parameters (pre-flight validation).
|
|
922
|
+
|
|
923
|
+
Args:
|
|
924
|
+
behaviour_id: Behaviour ID to delete (required).
|
|
925
|
+
|
|
926
|
+
Raises:
|
|
927
|
+
ToolError: If validation fails.
|
|
928
|
+
"""
|
|
929
|
+
if not behaviour_id or (isinstance(behaviour_id, str) and not behaviour_id.strip()):
|
|
930
|
+
raise ToolError(
|
|
931
|
+
"'behaviour_id' is required. "
|
|
932
|
+
"Use 'componentbehaviour_list' or 'componentbehaviour_get' "
|
|
933
|
+
"to see available behaviours."
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
async def effect_delete(behaviour_id: str) -> dict[str, Any]:
|
|
938
|
+
"""Delete a behaviour/effect from a component. Audited write operation.
|
|
939
|
+
|
|
940
|
+
Args:
|
|
941
|
+
behaviour_id: Behaviour UUID to delete.
|
|
942
|
+
|
|
943
|
+
Returns:
|
|
944
|
+
dict with deleted (bool), behaviour_id, deleted_behaviour, audit_id.
|
|
945
|
+
"""
|
|
946
|
+
# Pre-flight validation (no audit record for validation failures)
|
|
947
|
+
_validate_effect_delete_params(behaviour_id)
|
|
948
|
+
|
|
949
|
+
# Get authenticated user for audit
|
|
950
|
+
try:
|
|
951
|
+
user_email = get_current_user_email()
|
|
952
|
+
except NotAuthenticatedError as e:
|
|
953
|
+
raise ToolError(str(e))
|
|
954
|
+
|
|
955
|
+
# Use single BPAClient connection for all operations
|
|
956
|
+
try:
|
|
957
|
+
async with BPAClient() as client:
|
|
958
|
+
# Capture current state for rollback BEFORE making changes
|
|
959
|
+
try:
|
|
960
|
+
previous_state = await client.get(
|
|
961
|
+
"/behaviour/{id}",
|
|
962
|
+
path_params={"id": behaviour_id},
|
|
963
|
+
resource_type="behaviour",
|
|
964
|
+
resource_id=behaviour_id,
|
|
965
|
+
)
|
|
966
|
+
except BPANotFoundError:
|
|
967
|
+
raise ToolError(
|
|
968
|
+
f"[BEHAVIOUR_NOT_FOUND] Behaviour '{behaviour_id}' not found. "
|
|
969
|
+
"Use 'componentbehaviour_list' or 'componentbehaviour_get' "
|
|
970
|
+
"to see available behaviours."
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
# Create audit record BEFORE API call (audit-before-write pattern)
|
|
974
|
+
audit_logger = AuditLogger()
|
|
975
|
+
audit_id = await audit_logger.record_pending(
|
|
976
|
+
user_email=user_email,
|
|
977
|
+
operation_type="delete",
|
|
978
|
+
object_type="behaviour",
|
|
979
|
+
object_id=str(behaviour_id),
|
|
980
|
+
params={},
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
# Save rollback state for undo capability
|
|
984
|
+
await audit_logger.save_rollback_state(
|
|
985
|
+
audit_id=audit_id,
|
|
986
|
+
object_type="behaviour",
|
|
987
|
+
object_id=str(behaviour_id),
|
|
988
|
+
previous_state=previous_state, # Full state for recreation
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
try:
|
|
992
|
+
await client.delete(
|
|
993
|
+
"/behaviour/{behaviour_id}",
|
|
994
|
+
path_params={"behaviour_id": behaviour_id},
|
|
995
|
+
resource_type="behaviour",
|
|
996
|
+
resource_id=behaviour_id,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
# Mark audit as success
|
|
1000
|
+
await audit_logger.mark_success(
|
|
1001
|
+
audit_id,
|
|
1002
|
+
result={
|
|
1003
|
+
"deleted": True,
|
|
1004
|
+
"behaviour_id": str(behaviour_id),
|
|
1005
|
+
},
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
return {
|
|
1009
|
+
"deleted": True,
|
|
1010
|
+
"behaviour_id": str(behaviour_id),
|
|
1011
|
+
"deleted_behaviour": {
|
|
1012
|
+
"id": previous_state.get("id"),
|
|
1013
|
+
"component_key": previous_state.get("componentKey"),
|
|
1014
|
+
"effect_count": len(previous_state.get("effects", [])),
|
|
1015
|
+
},
|
|
1016
|
+
"audit_id": audit_id,
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
except BPAClientError as e:
|
|
1020
|
+
# Mark audit as failed
|
|
1021
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
1022
|
+
raise translate_error(
|
|
1023
|
+
e, resource_type="behaviour", resource_id=behaviour_id
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
except ToolError:
|
|
1027
|
+
raise
|
|
1028
|
+
except BPAClientError as e:
|
|
1029
|
+
raise translate_error(e, resource_type="behaviour", resource_id=behaviour_id)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def register_behaviour_tools(mcp: Any) -> None:
|
|
1033
|
+
"""Register behaviour tools with the MCP server.
|
|
1034
|
+
|
|
1035
|
+
Args:
|
|
1036
|
+
mcp: The FastMCP server instance.
|
|
1037
|
+
"""
|
|
1038
|
+
mcp.tool()(componentbehaviour_list)
|
|
1039
|
+
mcp.tool()(componentbehaviour_get)
|
|
1040
|
+
mcp.tool()(componentbehaviour_get_by_component)
|
|
1041
|
+
mcp.tool()(effect_create)
|
|
1042
|
+
mcp.tool()(effect_delete)
|