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,2235 @@
|
|
|
1
|
+
"""MCP tools for BPA determinant operations.
|
|
2
|
+
|
|
3
|
+
This module provides tools for listing, retrieving, creating, and updating
|
|
4
|
+
BPA determinants. Determinants are accessed through service endpoints
|
|
5
|
+
(service-centric API design).
|
|
6
|
+
|
|
7
|
+
Write operations follow the audit-before-write pattern:
|
|
8
|
+
1. Validate parameters (pre-flight, no audit record if validation fails)
|
|
9
|
+
2. Create PENDING audit record
|
|
10
|
+
3. Execute BPA API call
|
|
11
|
+
4. Update audit record to SUCCESS or FAILED
|
|
12
|
+
|
|
13
|
+
API Endpoints used:
|
|
14
|
+
- GET /service/{service_id}/determinant - List determinants for service
|
|
15
|
+
- GET /determinant/{id} - Get determinant by ID
|
|
16
|
+
- POST /service/{service_id}/textdeterminant - Create text determinant
|
|
17
|
+
- PUT /service/{service_id}/textdeterminant - Update text determinant
|
|
18
|
+
- POST /service/{service_id}/selectdeterminant - Create select determinant
|
|
19
|
+
- POST /service/{service_id}/numericdeterminant - Create numeric determinant
|
|
20
|
+
- POST /service/{service_id}/booleandeterminant - Create boolean determinant
|
|
21
|
+
- POST /service/{service_id}/datedeterminant - Create date determinant
|
|
22
|
+
- POST /service/{service_id}/classificationdeterminant - Create classification det.
|
|
23
|
+
- DELETE /service/{service_id}/determinant/{determinant_id} - Delete determinant
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
31
|
+
|
|
32
|
+
from mcp_eregistrations_bpa.audit.context import (
|
|
33
|
+
NotAuthenticatedError,
|
|
34
|
+
get_current_user_email,
|
|
35
|
+
)
|
|
36
|
+
from mcp_eregistrations_bpa.audit.logger import AuditLogger
|
|
37
|
+
from mcp_eregistrations_bpa.bpa_client import BPAClient
|
|
38
|
+
from mcp_eregistrations_bpa.bpa_client.errors import (
|
|
39
|
+
BPAClientError,
|
|
40
|
+
BPANotFoundError,
|
|
41
|
+
translate_error,
|
|
42
|
+
)
|
|
43
|
+
from mcp_eregistrations_bpa.tools.large_response import large_response_handler
|
|
44
|
+
|
|
45
|
+
# Operator normalization mapping (Story 10-4)
|
|
46
|
+
# Maps common operator variants to canonical BPA API format
|
|
47
|
+
_OPERATOR_ALIASES: dict[str, str] = {
|
|
48
|
+
# Equals variants
|
|
49
|
+
"equals": "EQUAL",
|
|
50
|
+
"eq": "EQUAL",
|
|
51
|
+
"==": "EQUAL",
|
|
52
|
+
# Not equals variants
|
|
53
|
+
"notequals": "NOT_EQUAL",
|
|
54
|
+
"noteq": "NOT_EQUAL",
|
|
55
|
+
"ne": "NOT_EQUAL",
|
|
56
|
+
"!=": "NOT_EQUAL",
|
|
57
|
+
"not_equals": "NOT_EQUAL",
|
|
58
|
+
# Contains variants
|
|
59
|
+
"contains": "CONTAINS",
|
|
60
|
+
# Starts with variants
|
|
61
|
+
"startswith": "STARTS_WITH",
|
|
62
|
+
"starts_with": "STARTS_WITH",
|
|
63
|
+
# Ends with variants
|
|
64
|
+
"endswith": "ENDS_WITH",
|
|
65
|
+
"ends_with": "ENDS_WITH",
|
|
66
|
+
# Greater than variants
|
|
67
|
+
"greaterthan": "GREATER_THAN",
|
|
68
|
+
"greater_than": "GREATER_THAN",
|
|
69
|
+
"gt": "GREATER_THAN",
|
|
70
|
+
">": "GREATER_THAN",
|
|
71
|
+
# Less than variants
|
|
72
|
+
"lessthan": "LESS_THAN",
|
|
73
|
+
"less_than": "LESS_THAN",
|
|
74
|
+
"lt": "LESS_THAN",
|
|
75
|
+
"<": "LESS_THAN",
|
|
76
|
+
# Greater than or equal variants
|
|
77
|
+
"greaterthanorequal": "GREATER_THAN_OR_EQUAL",
|
|
78
|
+
"greater_than_or_equal": "GREATER_THAN_OR_EQUAL",
|
|
79
|
+
"gte": "GREATER_THAN_OR_EQUAL",
|
|
80
|
+
"ge": "GREATER_THAN_OR_EQUAL",
|
|
81
|
+
">=": "GREATER_THAN_OR_EQUAL",
|
|
82
|
+
# Less than or equal variants
|
|
83
|
+
"lessthanorequal": "LESS_THAN_OR_EQUAL",
|
|
84
|
+
"less_than_or_equal": "LESS_THAN_OR_EQUAL",
|
|
85
|
+
"lte": "LESS_THAN_OR_EQUAL",
|
|
86
|
+
"le": "LESS_THAN_OR_EQUAL",
|
|
87
|
+
"<=": "LESS_THAN_OR_EQUAL",
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _normalize_operator(operator: str) -> str:
|
|
92
|
+
"""Normalize operator to canonical BPA API format.
|
|
93
|
+
|
|
94
|
+
Accepts various formats (camelCase, lowercase, symbols) and converts
|
|
95
|
+
to the uppercase underscore format expected by BPA API.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
operator: Operator in any supported format.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Normalized operator string (e.g., "EQUAL", "NOT_EQUAL", "GREATER_THAN").
|
|
102
|
+
|
|
103
|
+
Examples:
|
|
104
|
+
>>> _normalize_operator("equals")
|
|
105
|
+
"EQUAL"
|
|
106
|
+
>>> _normalize_operator("notEquals")
|
|
107
|
+
"NOT_EQUAL"
|
|
108
|
+
>>> _normalize_operator(">=")
|
|
109
|
+
"GREATER_THAN_OR_EQUAL"
|
|
110
|
+
"""
|
|
111
|
+
cleaned = operator.strip().lower()
|
|
112
|
+
|
|
113
|
+
# Check alias mapping first
|
|
114
|
+
if cleaned in _OPERATOR_ALIASES:
|
|
115
|
+
return _OPERATOR_ALIASES[cleaned]
|
|
116
|
+
|
|
117
|
+
# If already in canonical format, just uppercase
|
|
118
|
+
return operator.strip().upper()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = [
|
|
122
|
+
"determinant_list",
|
|
123
|
+
"determinant_get",
|
|
124
|
+
"determinant_search",
|
|
125
|
+
"determinant_delete",
|
|
126
|
+
"textdeterminant_create",
|
|
127
|
+
"textdeterminant_update",
|
|
128
|
+
"selectdeterminant_create",
|
|
129
|
+
"numericdeterminant_create",
|
|
130
|
+
"booleandeterminant_create",
|
|
131
|
+
"datedeterminant_create",
|
|
132
|
+
"classificationdeterminant_create",
|
|
133
|
+
"griddeterminant_create",
|
|
134
|
+
"register_determinant_tools",
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@large_response_handler(
|
|
139
|
+
threshold_bytes=50 * 1024, # 50KB threshold for list tools
|
|
140
|
+
navigation={
|
|
141
|
+
"list_all": "jq '.determinants'",
|
|
142
|
+
"by_type": "jq '.determinants[] | select(.type == \"text\")'",
|
|
143
|
+
"by_field": "jq '.determinants[] | select(.target_form_field_key==\"x\")'",
|
|
144
|
+
"by_name": "jq '.determinants[] | select(.name | contains(\"x\"))'",
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
async def determinant_list(service_id: str | int) -> dict[str, Any]:
|
|
148
|
+
"""List determinants for a service.
|
|
149
|
+
|
|
150
|
+
Large responses (>50KB) are saved to file with navigation hints.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
service_id: Service ID to list determinants for.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
dict with determinants, total, service_id.
|
|
157
|
+
"""
|
|
158
|
+
try:
|
|
159
|
+
async with BPAClient() as client:
|
|
160
|
+
determinants_data = await client.get_list(
|
|
161
|
+
"/service/{service_id}/determinant",
|
|
162
|
+
path_params={"service_id": service_id},
|
|
163
|
+
resource_type="determinant",
|
|
164
|
+
)
|
|
165
|
+
except BPAClientError as e:
|
|
166
|
+
raise translate_error(e, resource_type="determinant")
|
|
167
|
+
|
|
168
|
+
# Transform to consistent output format with snake_case keys
|
|
169
|
+
determinants = []
|
|
170
|
+
for det in determinants_data:
|
|
171
|
+
determinant_item: dict[str, Any] = {
|
|
172
|
+
"id": det.get("id"),
|
|
173
|
+
"name": det.get("name"),
|
|
174
|
+
"type": det.get("type"),
|
|
175
|
+
"operator": det.get("operator"),
|
|
176
|
+
"target_form_field_key": det.get("targetFormFieldKey"),
|
|
177
|
+
"condition_summary": det.get("conditionSummary"),
|
|
178
|
+
"json_condition": det.get("jsonCondition"),
|
|
179
|
+
}
|
|
180
|
+
# Include type-specific value fields if present
|
|
181
|
+
if det.get("textValue") is not None:
|
|
182
|
+
determinant_item["text_value"] = det.get("textValue")
|
|
183
|
+
if det.get("selectValue") is not None:
|
|
184
|
+
determinant_item["select_value"] = det.get("selectValue")
|
|
185
|
+
if det.get("numericValue") is not None:
|
|
186
|
+
determinant_item["numeric_value"] = det.get("numericValue")
|
|
187
|
+
if det.get("booleanValue") is not None:
|
|
188
|
+
determinant_item["boolean_value"] = det.get("booleanValue")
|
|
189
|
+
if det.get("dateValue") is not None:
|
|
190
|
+
determinant_item["date_value"] = det.get("dateValue")
|
|
191
|
+
if det.get("isCurrentDate") is not None:
|
|
192
|
+
determinant_item["is_current_date"] = det.get("isCurrentDate")
|
|
193
|
+
determinants.append(determinant_item)
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
"determinants": determinants,
|
|
197
|
+
"total": len(determinants),
|
|
198
|
+
"service_id": service_id,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def determinant_get(determinant_id: str | int) -> dict[str, Any]:
|
|
203
|
+
"""Get determinant details by ID.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
determinant_id: Determinant ID.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
dict with id, name, type, service_id (if available), condition_logic,
|
|
210
|
+
json_condition.
|
|
211
|
+
"""
|
|
212
|
+
try:
|
|
213
|
+
async with BPAClient() as client:
|
|
214
|
+
try:
|
|
215
|
+
determinant_data = await client.get(
|
|
216
|
+
"/determinant/{id}",
|
|
217
|
+
path_params={"id": determinant_id},
|
|
218
|
+
resource_type="determinant",
|
|
219
|
+
resource_id=determinant_id,
|
|
220
|
+
)
|
|
221
|
+
except BPANotFoundError:
|
|
222
|
+
raise ToolError(
|
|
223
|
+
f"Determinant '{determinant_id}' not found. "
|
|
224
|
+
"Use 'determinant_list' with service_id to see determinants."
|
|
225
|
+
)
|
|
226
|
+
except ToolError:
|
|
227
|
+
raise
|
|
228
|
+
except BPAClientError as e:
|
|
229
|
+
raise translate_error(
|
|
230
|
+
e, resource_type="determinant", resource_id=determinant_id
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Build response with all condition-related fields
|
|
234
|
+
result: dict[str, Any] = {
|
|
235
|
+
"id": determinant_data.get("id"),
|
|
236
|
+
"name": determinant_data.get("name"),
|
|
237
|
+
"type": determinant_data.get("type"),
|
|
238
|
+
"operator": determinant_data.get("operator"),
|
|
239
|
+
"target_form_field_key": determinant_data.get("targetFormFieldKey"),
|
|
240
|
+
"condition_logic": determinant_data.get("conditionLogic"),
|
|
241
|
+
"json_condition": determinant_data.get("jsonCondition"),
|
|
242
|
+
"condition_summary": determinant_data.get("conditionSummary"),
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# Include service_id if present in API response (Story 10-7: NFR4 complete context)
|
|
246
|
+
if determinant_data.get("serviceId") is not None:
|
|
247
|
+
result["service_id"] = determinant_data.get("serviceId")
|
|
248
|
+
|
|
249
|
+
# Include type-specific value fields if present
|
|
250
|
+
if determinant_data.get("textValue") is not None:
|
|
251
|
+
result["text_value"] = determinant_data.get("textValue")
|
|
252
|
+
if determinant_data.get("selectValue") is not None:
|
|
253
|
+
result["select_value"] = determinant_data.get("selectValue")
|
|
254
|
+
if determinant_data.get("numericValue") is not None:
|
|
255
|
+
result["numeric_value"] = determinant_data.get("numericValue")
|
|
256
|
+
if determinant_data.get("booleanValue") is not None:
|
|
257
|
+
result["boolean_value"] = determinant_data.get("booleanValue")
|
|
258
|
+
if determinant_data.get("dateValue") is not None:
|
|
259
|
+
result["date_value"] = determinant_data.get("dateValue")
|
|
260
|
+
if determinant_data.get("isCurrentDate") is not None:
|
|
261
|
+
result["is_current_date"] = determinant_data.get("isCurrentDate")
|
|
262
|
+
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _validate_textdeterminant_create_params(
|
|
267
|
+
service_id: str | int,
|
|
268
|
+
name: str,
|
|
269
|
+
operator: str,
|
|
270
|
+
target_form_field_key: str,
|
|
271
|
+
text_value: str = "",
|
|
272
|
+
) -> dict[str, Any]:
|
|
273
|
+
"""Validate textdeterminant_create parameters (pre-flight).
|
|
274
|
+
|
|
275
|
+
Returns validated params dict or raises ToolError if invalid.
|
|
276
|
+
No audit record is created for validation failures.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
service_id: Parent service ID (required).
|
|
280
|
+
name: Determinant name (required).
|
|
281
|
+
operator: Comparison operator (required). Valid values: equals, notEquals,
|
|
282
|
+
contains, notContains, startsWith, endsWith, isEmpty, isNotEmpty.
|
|
283
|
+
target_form_field_key: The form field key this determinant targets (required).
|
|
284
|
+
text_value: The text value to compare against (default: "" for isEmpty checks).
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
dict: Validated parameters ready for API call.
|
|
288
|
+
|
|
289
|
+
Raises:
|
|
290
|
+
ToolError: If validation fails.
|
|
291
|
+
"""
|
|
292
|
+
errors = []
|
|
293
|
+
|
|
294
|
+
if not service_id:
|
|
295
|
+
errors.append("'service_id' is required")
|
|
296
|
+
|
|
297
|
+
if not name or not name.strip():
|
|
298
|
+
errors.append("'name' is required and cannot be empty")
|
|
299
|
+
|
|
300
|
+
if name and len(name.strip()) > 255:
|
|
301
|
+
errors.append("'name' must be 255 characters or less")
|
|
302
|
+
|
|
303
|
+
if not operator or not operator.strip():
|
|
304
|
+
errors.append("'operator' is required")
|
|
305
|
+
|
|
306
|
+
valid_operators = [
|
|
307
|
+
"EQUAL",
|
|
308
|
+
"NOT_EQUAL",
|
|
309
|
+
"CONTAINS",
|
|
310
|
+
"STARTS_WITH",
|
|
311
|
+
"ENDS_WITH",
|
|
312
|
+
]
|
|
313
|
+
# Normalize operator to canonical format (Story 10-4)
|
|
314
|
+
normalized_operator = _normalize_operator(operator) if operator else ""
|
|
315
|
+
if operator and normalized_operator not in valid_operators:
|
|
316
|
+
errors.append(f"'operator' must be one of: {', '.join(valid_operators)}")
|
|
317
|
+
|
|
318
|
+
if not target_form_field_key or not target_form_field_key.strip():
|
|
319
|
+
errors.append("'target_form_field_key' is required")
|
|
320
|
+
|
|
321
|
+
if errors:
|
|
322
|
+
error_msg = "; ".join(errors)
|
|
323
|
+
raise ToolError(
|
|
324
|
+
f"Cannot create text determinant: {error_msg}. "
|
|
325
|
+
"Provide valid 'service_id', 'name', 'operator', and "
|
|
326
|
+
"'target_form_field_key' parameters."
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
"name": name.strip(),
|
|
331
|
+
"operator": normalized_operator,
|
|
332
|
+
"targetFormFieldKey": target_form_field_key.strip(),
|
|
333
|
+
"determinantType": "FORMFIELD",
|
|
334
|
+
"type": "text",
|
|
335
|
+
"textValue": text_value.strip() if text_value else "",
|
|
336
|
+
"determinantInsideGrid": False,
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _validate_textdeterminant_update_params(
|
|
341
|
+
service_id: str | int,
|
|
342
|
+
determinant_id: str | int,
|
|
343
|
+
name: str | None,
|
|
344
|
+
operator: str | None,
|
|
345
|
+
target_form_field_key: str | None,
|
|
346
|
+
condition_logic: str | None,
|
|
347
|
+
json_condition: str | None,
|
|
348
|
+
) -> dict[str, Any]:
|
|
349
|
+
"""Validate textdeterminant_update parameters (pre-flight).
|
|
350
|
+
|
|
351
|
+
Returns validated params dict or raises ToolError if invalid.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
service_id: Parent service ID (required).
|
|
355
|
+
determinant_id: Determinant ID to update (required).
|
|
356
|
+
name: New name (optional).
|
|
357
|
+
operator: Comparison operator (optional). Valid values: equals, notEquals,
|
|
358
|
+
contains, notContains, startsWith, endsWith, isEmpty, isNotEmpty.
|
|
359
|
+
target_form_field_key: The form field key this determinant targets (optional).
|
|
360
|
+
condition_logic: New condition logic (optional).
|
|
361
|
+
json_condition: New JSON condition (optional).
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
dict: Validated parameters ready for API call.
|
|
365
|
+
|
|
366
|
+
Raises:
|
|
367
|
+
ToolError: If validation fails.
|
|
368
|
+
"""
|
|
369
|
+
errors = []
|
|
370
|
+
|
|
371
|
+
if not service_id:
|
|
372
|
+
errors.append("'service_id' is required")
|
|
373
|
+
|
|
374
|
+
if not determinant_id:
|
|
375
|
+
errors.append("'determinant_id' is required")
|
|
376
|
+
|
|
377
|
+
if name is not None and not name.strip():
|
|
378
|
+
errors.append("'name' cannot be empty when provided")
|
|
379
|
+
|
|
380
|
+
if name and len(name.strip()) > 255:
|
|
381
|
+
errors.append("'name' must be 255 characters or less")
|
|
382
|
+
|
|
383
|
+
valid_operators = [
|
|
384
|
+
"EQUAL",
|
|
385
|
+
"NOT_EQUAL",
|
|
386
|
+
"CONTAINS",
|
|
387
|
+
"STARTS_WITH",
|
|
388
|
+
"ENDS_WITH",
|
|
389
|
+
]
|
|
390
|
+
# Normalize operator to canonical format (Story 10-4)
|
|
391
|
+
normalized_operator = _normalize_operator(operator) if operator else None
|
|
392
|
+
if operator is not None:
|
|
393
|
+
if not operator.strip():
|
|
394
|
+
errors.append("'operator' cannot be empty when provided")
|
|
395
|
+
elif normalized_operator not in valid_operators:
|
|
396
|
+
errors.append(f"'operator' must be one of: {', '.join(valid_operators)}")
|
|
397
|
+
|
|
398
|
+
if target_form_field_key is not None and not target_form_field_key.strip():
|
|
399
|
+
errors.append("'target_form_field_key' cannot be empty when provided")
|
|
400
|
+
|
|
401
|
+
# At least one field must be provided for update
|
|
402
|
+
if all(
|
|
403
|
+
v is None
|
|
404
|
+
for v in [
|
|
405
|
+
name,
|
|
406
|
+
operator,
|
|
407
|
+
target_form_field_key,
|
|
408
|
+
condition_logic,
|
|
409
|
+
json_condition,
|
|
410
|
+
]
|
|
411
|
+
):
|
|
412
|
+
errors.append(
|
|
413
|
+
"At least one field (name, operator, target_form_field_key, "
|
|
414
|
+
"condition_logic, json_condition) required"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
if errors:
|
|
418
|
+
error_msg = "; ".join(errors)
|
|
419
|
+
raise ToolError(
|
|
420
|
+
f"Cannot update text determinant: {error_msg}. Check required fields."
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
params: dict[str, Any] = {"id": determinant_id}
|
|
424
|
+
if name is not None:
|
|
425
|
+
params["name"] = name.strip()
|
|
426
|
+
if normalized_operator is not None:
|
|
427
|
+
params["operator"] = normalized_operator
|
|
428
|
+
if target_form_field_key is not None:
|
|
429
|
+
params["targetFormFieldKey"] = target_form_field_key.strip()
|
|
430
|
+
if condition_logic is not None:
|
|
431
|
+
params["conditionLogic"] = condition_logic
|
|
432
|
+
if json_condition is not None:
|
|
433
|
+
params["jsonCondition"] = json_condition
|
|
434
|
+
|
|
435
|
+
return params
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _validate_selectdeterminant_create_params(
|
|
439
|
+
service_id: str | int,
|
|
440
|
+
name: str,
|
|
441
|
+
operator: str,
|
|
442
|
+
target_form_field_key: str,
|
|
443
|
+
select_value: str,
|
|
444
|
+
) -> dict[str, Any]:
|
|
445
|
+
"""Validate selectdeterminant_create parameters (pre-flight).
|
|
446
|
+
|
|
447
|
+
Returns validated params dict or raises ToolError if invalid.
|
|
448
|
+
No audit record is created for validation failures.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
service_id: Parent service ID (required).
|
|
452
|
+
name: Determinant name (required).
|
|
453
|
+
operator: Comparison operator (required). Valid values: equals, notEquals,
|
|
454
|
+
contains, notContains, startsWith, endsWith, isEmpty, isNotEmpty.
|
|
455
|
+
target_form_field_key: The form field key this determinant targets (required).
|
|
456
|
+
select_value: The select option value this determinant matches (required).
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
dict: Validated parameters ready for API call.
|
|
460
|
+
|
|
461
|
+
Raises:
|
|
462
|
+
ToolError: If validation fails.
|
|
463
|
+
"""
|
|
464
|
+
errors = []
|
|
465
|
+
|
|
466
|
+
if not service_id:
|
|
467
|
+
errors.append("'service_id' is required")
|
|
468
|
+
|
|
469
|
+
if not name or not name.strip():
|
|
470
|
+
errors.append("'name' is required and cannot be empty")
|
|
471
|
+
|
|
472
|
+
if name and len(name.strip()) > 255:
|
|
473
|
+
errors.append("'name' must be 255 characters or less")
|
|
474
|
+
|
|
475
|
+
if not operator or not operator.strip():
|
|
476
|
+
errors.append("'operator' is required")
|
|
477
|
+
|
|
478
|
+
valid_operators = [
|
|
479
|
+
"EQUAL",
|
|
480
|
+
"NOT_EQUAL",
|
|
481
|
+
]
|
|
482
|
+
# Normalize operator to canonical format (Story 10-4)
|
|
483
|
+
normalized_operator = _normalize_operator(operator) if operator else ""
|
|
484
|
+
if operator and normalized_operator not in valid_operators:
|
|
485
|
+
errors.append(f"'operator' must be one of: {', '.join(valid_operators)}")
|
|
486
|
+
|
|
487
|
+
if not target_form_field_key or not target_form_field_key.strip():
|
|
488
|
+
errors.append("'target_form_field_key' is required")
|
|
489
|
+
|
|
490
|
+
if not select_value or not select_value.strip():
|
|
491
|
+
errors.append("'select_value' is required")
|
|
492
|
+
|
|
493
|
+
if errors:
|
|
494
|
+
error_msg = "; ".join(errors)
|
|
495
|
+
raise ToolError(
|
|
496
|
+
f"Cannot create select determinant: {error_msg}. "
|
|
497
|
+
"Provide valid 'service_id', 'name', 'operator', "
|
|
498
|
+
"'target_form_field_key', and 'select_value' parameters."
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
"name": name.strip(),
|
|
503
|
+
"operator": normalized_operator,
|
|
504
|
+
"targetFormFieldKey": target_form_field_key.strip(),
|
|
505
|
+
"determinantType": "FORMFIELD",
|
|
506
|
+
"type": "radio",
|
|
507
|
+
"selectValue": select_value.strip(),
|
|
508
|
+
"determinantInsideGrid": False,
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
async def textdeterminant_create(
|
|
513
|
+
service_id: str | int,
|
|
514
|
+
name: str,
|
|
515
|
+
operator: str,
|
|
516
|
+
target_form_field_key: str,
|
|
517
|
+
text_value: str = "",
|
|
518
|
+
condition_logic: str | None = None,
|
|
519
|
+
json_condition: str | None = None,
|
|
520
|
+
) -> dict[str, Any]:
|
|
521
|
+
"""Create text determinant in a service. Audited write operation.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
service_id: Parent service ID.
|
|
525
|
+
name: Determinant name.
|
|
526
|
+
operator: EQUAL, NOT_EQUAL, CONTAINS, STARTS_WITH, or ENDS_WITH.
|
|
527
|
+
target_form_field_key: Form field key to evaluate.
|
|
528
|
+
text_value: Value to compare (default: "" for isEmpty).
|
|
529
|
+
condition_logic: Optional condition expression.
|
|
530
|
+
json_condition: Optional JSON condition.
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
dict with id, name, type, operator, target_form_field_key, service_id, audit_id.
|
|
534
|
+
"""
|
|
535
|
+
# Pre-flight validation (no audit record for validation failures)
|
|
536
|
+
validated_params = _validate_textdeterminant_create_params(
|
|
537
|
+
service_id, name, operator, target_form_field_key, text_value
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
# Add optional parameters
|
|
541
|
+
if condition_logic is not None:
|
|
542
|
+
validated_params["conditionLogic"] = condition_logic
|
|
543
|
+
if json_condition is not None:
|
|
544
|
+
validated_params["jsonCondition"] = json_condition
|
|
545
|
+
|
|
546
|
+
# Get authenticated user for audit (before any API calls)
|
|
547
|
+
try:
|
|
548
|
+
user_email = get_current_user_email()
|
|
549
|
+
except NotAuthenticatedError as e:
|
|
550
|
+
raise ToolError(str(e))
|
|
551
|
+
|
|
552
|
+
# Use single BPAClient connection for all operations
|
|
553
|
+
try:
|
|
554
|
+
async with BPAClient() as client:
|
|
555
|
+
# Verify parent service exists before creating audit record
|
|
556
|
+
try:
|
|
557
|
+
await client.get(
|
|
558
|
+
"/service/{id}",
|
|
559
|
+
path_params={"id": service_id},
|
|
560
|
+
resource_type="service",
|
|
561
|
+
resource_id=service_id,
|
|
562
|
+
)
|
|
563
|
+
except BPANotFoundError:
|
|
564
|
+
raise ToolError(
|
|
565
|
+
f"Cannot create text determinant: Service '{service_id}' "
|
|
566
|
+
"not found. Use 'service_list' to see available services."
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
# Create audit record BEFORE API call (audit-before-write pattern)
|
|
570
|
+
audit_logger = AuditLogger()
|
|
571
|
+
audit_id = await audit_logger.record_pending(
|
|
572
|
+
user_email=user_email,
|
|
573
|
+
operation_type="create",
|
|
574
|
+
object_type="textdeterminant",
|
|
575
|
+
params={
|
|
576
|
+
"service_id": str(service_id),
|
|
577
|
+
**validated_params,
|
|
578
|
+
},
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
try:
|
|
582
|
+
determinant_data = await client.post(
|
|
583
|
+
"/service/{service_id}/textdeterminant",
|
|
584
|
+
path_params={"service_id": service_id},
|
|
585
|
+
json=validated_params,
|
|
586
|
+
resource_type="determinant",
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
# Save rollback state (for create, save ID to enable deletion)
|
|
590
|
+
created_id = determinant_data.get("id")
|
|
591
|
+
await audit_logger.save_rollback_state(
|
|
592
|
+
audit_id=audit_id,
|
|
593
|
+
object_type="textdeterminant",
|
|
594
|
+
object_id=str(created_id),
|
|
595
|
+
previous_state={
|
|
596
|
+
"id": created_id,
|
|
597
|
+
"name": determinant_data.get("name"),
|
|
598
|
+
"operator": determinant_data.get("operator"),
|
|
599
|
+
"targetFormFieldKey": determinant_data.get(
|
|
600
|
+
"targetFormFieldKey"
|
|
601
|
+
),
|
|
602
|
+
"textValue": determinant_data.get("textValue"),
|
|
603
|
+
"serviceId": str(service_id),
|
|
604
|
+
"_operation": "create", # Marker for rollback to DELETE
|
|
605
|
+
},
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
# Mark audit as success
|
|
609
|
+
await audit_logger.mark_success(
|
|
610
|
+
audit_id,
|
|
611
|
+
result={
|
|
612
|
+
"determinant_id": created_id,
|
|
613
|
+
"name": determinant_data.get("name"),
|
|
614
|
+
"service_id": str(service_id),
|
|
615
|
+
},
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
"id": created_id,
|
|
620
|
+
"name": determinant_data.get("name"),
|
|
621
|
+
"type": "text",
|
|
622
|
+
"operator": determinant_data.get("operator"),
|
|
623
|
+
"target_form_field_key": determinant_data.get("targetFormFieldKey"),
|
|
624
|
+
"text_value": determinant_data.get("textValue"),
|
|
625
|
+
"condition_logic": determinant_data.get("conditionLogic"),
|
|
626
|
+
"json_condition": determinant_data.get("jsonCondition"),
|
|
627
|
+
"service_id": service_id,
|
|
628
|
+
"audit_id": audit_id,
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
except BPAClientError as e:
|
|
632
|
+
# Mark audit as failed
|
|
633
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
634
|
+
raise translate_error(e, resource_type="determinant")
|
|
635
|
+
|
|
636
|
+
except ToolError:
|
|
637
|
+
raise
|
|
638
|
+
except BPAClientError as e:
|
|
639
|
+
raise translate_error(e, resource_type="service", resource_id=service_id)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
async def textdeterminant_update(
|
|
643
|
+
service_id: str | int,
|
|
644
|
+
determinant_id: str | int,
|
|
645
|
+
name: str | None = None,
|
|
646
|
+
operator: str | None = None,
|
|
647
|
+
target_form_field_key: str | None = None,
|
|
648
|
+
condition_logic: str | None = None,
|
|
649
|
+
json_condition: str | None = None,
|
|
650
|
+
) -> dict[str, Any]:
|
|
651
|
+
"""Update a text determinant. Audited write operation.
|
|
652
|
+
|
|
653
|
+
Args:
|
|
654
|
+
service_id: Parent service ID.
|
|
655
|
+
determinant_id: Determinant ID to update.
|
|
656
|
+
name: New name (optional).
|
|
657
|
+
operator: New operator (optional).
|
|
658
|
+
target_form_field_key: New field key (optional).
|
|
659
|
+
condition_logic: New condition (optional).
|
|
660
|
+
json_condition: New JSON condition (optional).
|
|
661
|
+
|
|
662
|
+
Returns:
|
|
663
|
+
dict with id, name, operator, target_form_field_key, previous_state, audit_id.
|
|
664
|
+
"""
|
|
665
|
+
# Pre-flight validation (no audit record for validation failures)
|
|
666
|
+
validated_params = _validate_textdeterminant_update_params(
|
|
667
|
+
service_id,
|
|
668
|
+
determinant_id,
|
|
669
|
+
name,
|
|
670
|
+
operator,
|
|
671
|
+
target_form_field_key,
|
|
672
|
+
condition_logic,
|
|
673
|
+
json_condition,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
# Get authenticated user for audit
|
|
677
|
+
try:
|
|
678
|
+
user_email = get_current_user_email()
|
|
679
|
+
except NotAuthenticatedError as e:
|
|
680
|
+
raise ToolError(str(e))
|
|
681
|
+
|
|
682
|
+
# Use single BPAClient connection for all operations
|
|
683
|
+
try:
|
|
684
|
+
async with BPAClient() as client:
|
|
685
|
+
# Capture current state for rollback BEFORE making changes
|
|
686
|
+
try:
|
|
687
|
+
previous_state = await client.get(
|
|
688
|
+
"/determinant/{id}",
|
|
689
|
+
path_params={"id": determinant_id},
|
|
690
|
+
resource_type="determinant",
|
|
691
|
+
resource_id=determinant_id,
|
|
692
|
+
)
|
|
693
|
+
except BPANotFoundError:
|
|
694
|
+
raise ToolError(
|
|
695
|
+
f"Determinant '{determinant_id}' not found. "
|
|
696
|
+
"Use 'determinant_list' with service_id to see determinants."
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
# Normalize previous_state to snake_case for consistency
|
|
700
|
+
normalized_previous_state = {
|
|
701
|
+
"id": previous_state.get("id"),
|
|
702
|
+
"name": previous_state.get("name"),
|
|
703
|
+
"operator": previous_state.get("operator"),
|
|
704
|
+
"target_form_field_key": previous_state.get("targetFormFieldKey"),
|
|
705
|
+
"condition_logic": previous_state.get("conditionLogic"),
|
|
706
|
+
"json_condition": previous_state.get("jsonCondition"),
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
# Create audit record BEFORE API call (audit-before-write pattern)
|
|
710
|
+
audit_logger = AuditLogger()
|
|
711
|
+
audit_id = await audit_logger.record_pending(
|
|
712
|
+
user_email=user_email,
|
|
713
|
+
operation_type="update",
|
|
714
|
+
object_type="textdeterminant",
|
|
715
|
+
object_id=str(determinant_id),
|
|
716
|
+
params={
|
|
717
|
+
"service_id": str(service_id),
|
|
718
|
+
"changes": validated_params,
|
|
719
|
+
},
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
# Save rollback state for undo capability
|
|
723
|
+
await audit_logger.save_rollback_state(
|
|
724
|
+
audit_id=audit_id,
|
|
725
|
+
object_type="textdeterminant",
|
|
726
|
+
object_id=str(determinant_id),
|
|
727
|
+
previous_state={
|
|
728
|
+
"id": previous_state.get("id"),
|
|
729
|
+
"name": previous_state.get("name"),
|
|
730
|
+
"operator": previous_state.get("operator"),
|
|
731
|
+
"targetFormFieldKey": previous_state.get("targetFormFieldKey"),
|
|
732
|
+
"conditionLogic": previous_state.get("conditionLogic"),
|
|
733
|
+
"jsonCondition": previous_state.get("jsonCondition"),
|
|
734
|
+
"serviceId": service_id,
|
|
735
|
+
},
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
try:
|
|
739
|
+
determinant_data = await client.put(
|
|
740
|
+
"/service/{service_id}/textdeterminant",
|
|
741
|
+
path_params={"service_id": service_id},
|
|
742
|
+
json=validated_params,
|
|
743
|
+
resource_type="determinant",
|
|
744
|
+
resource_id=determinant_id,
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
# Mark audit as success
|
|
748
|
+
await audit_logger.mark_success(
|
|
749
|
+
audit_id,
|
|
750
|
+
result={
|
|
751
|
+
"determinant_id": determinant_data.get("id"),
|
|
752
|
+
"name": determinant_data.get("name"),
|
|
753
|
+
"changes_applied": {
|
|
754
|
+
k: v for k, v in validated_params.items() if k != "id"
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
"id": determinant_data.get("id"),
|
|
761
|
+
"name": determinant_data.get("name"),
|
|
762
|
+
"type": "text",
|
|
763
|
+
"operator": determinant_data.get("operator"),
|
|
764
|
+
"target_form_field_key": determinant_data.get("targetFormFieldKey"),
|
|
765
|
+
"condition_logic": determinant_data.get("conditionLogic"),
|
|
766
|
+
"json_condition": determinant_data.get("jsonCondition"),
|
|
767
|
+
"service_id": service_id,
|
|
768
|
+
"previous_state": normalized_previous_state,
|
|
769
|
+
"audit_id": audit_id,
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
except BPAClientError as e:
|
|
773
|
+
# Mark audit as failed
|
|
774
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
775
|
+
raise translate_error(
|
|
776
|
+
e, resource_type="determinant", resource_id=determinant_id
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
except ToolError:
|
|
780
|
+
raise
|
|
781
|
+
except BPAClientError as e:
|
|
782
|
+
raise translate_error(
|
|
783
|
+
e, resource_type="determinant", resource_id=determinant_id
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
async def selectdeterminant_create(
|
|
788
|
+
service_id: str | int,
|
|
789
|
+
name: str,
|
|
790
|
+
operator: str,
|
|
791
|
+
target_form_field_key: str,
|
|
792
|
+
select_value: str,
|
|
793
|
+
condition_logic: str | None = None,
|
|
794
|
+
json_condition: str | None = None,
|
|
795
|
+
) -> dict[str, Any]:
|
|
796
|
+
"""Create select determinant in a service. Audited write operation.
|
|
797
|
+
|
|
798
|
+
Args:
|
|
799
|
+
service_id: Parent service ID.
|
|
800
|
+
name: Determinant name.
|
|
801
|
+
operator: EQUAL or NOT_EQUAL.
|
|
802
|
+
target_form_field_key: Form field key to evaluate.
|
|
803
|
+
select_value: Option value to match.
|
|
804
|
+
condition_logic: Optional condition expression.
|
|
805
|
+
json_condition: Optional JSON condition.
|
|
806
|
+
|
|
807
|
+
Returns:
|
|
808
|
+
dict with id, name, type, operator, select_value, service_id, audit_id.
|
|
809
|
+
"""
|
|
810
|
+
# Pre-flight validation
|
|
811
|
+
validated_params = _validate_selectdeterminant_create_params(
|
|
812
|
+
service_id, name, operator, target_form_field_key, select_value
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
# Add optional parameters
|
|
816
|
+
if condition_logic is not None:
|
|
817
|
+
validated_params["conditionLogic"] = condition_logic
|
|
818
|
+
if json_condition is not None:
|
|
819
|
+
validated_params["jsonCondition"] = json_condition
|
|
820
|
+
|
|
821
|
+
# Get authenticated user for audit (before any API calls)
|
|
822
|
+
try:
|
|
823
|
+
user_email = get_current_user_email()
|
|
824
|
+
except NotAuthenticatedError as e:
|
|
825
|
+
raise ToolError(str(e))
|
|
826
|
+
|
|
827
|
+
# Use single BPAClient connection for all operations
|
|
828
|
+
try:
|
|
829
|
+
async with BPAClient() as client:
|
|
830
|
+
# Verify parent service exists before creating audit record
|
|
831
|
+
try:
|
|
832
|
+
await client.get(
|
|
833
|
+
"/service/{id}",
|
|
834
|
+
path_params={"id": service_id},
|
|
835
|
+
resource_type="service",
|
|
836
|
+
resource_id=service_id,
|
|
837
|
+
)
|
|
838
|
+
except BPANotFoundError:
|
|
839
|
+
raise ToolError(
|
|
840
|
+
f"Cannot create select determinant: Service '{service_id}' "
|
|
841
|
+
"not found. Use 'service_list' to see available services."
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
# Create audit record BEFORE API call (audit-before-write pattern)
|
|
845
|
+
audit_logger = AuditLogger()
|
|
846
|
+
audit_id = await audit_logger.record_pending(
|
|
847
|
+
user_email=user_email,
|
|
848
|
+
operation_type="create",
|
|
849
|
+
object_type="selectdeterminant",
|
|
850
|
+
params={
|
|
851
|
+
"service_id": str(service_id),
|
|
852
|
+
**validated_params,
|
|
853
|
+
},
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
try:
|
|
857
|
+
determinant_data = await client.post(
|
|
858
|
+
"/service/{service_id}/selectdeterminant",
|
|
859
|
+
path_params={"service_id": service_id},
|
|
860
|
+
json=validated_params,
|
|
861
|
+
resource_type="determinant",
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
# Save rollback state (for create, save ID to enable deletion)
|
|
865
|
+
created_id = determinant_data.get("id")
|
|
866
|
+
await audit_logger.save_rollback_state(
|
|
867
|
+
audit_id=audit_id,
|
|
868
|
+
object_type="selectdeterminant",
|
|
869
|
+
object_id=str(created_id),
|
|
870
|
+
previous_state={
|
|
871
|
+
"id": created_id,
|
|
872
|
+
"name": determinant_data.get("name"),
|
|
873
|
+
"operator": determinant_data.get("operator"),
|
|
874
|
+
"targetFormFieldKey": determinant_data.get(
|
|
875
|
+
"targetFormFieldKey"
|
|
876
|
+
),
|
|
877
|
+
"selectValue": determinant_data.get("selectValue"),
|
|
878
|
+
"serviceId": str(service_id),
|
|
879
|
+
"_operation": "create", # Marker for rollback to DELETE
|
|
880
|
+
},
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
# Mark audit as success
|
|
884
|
+
await audit_logger.mark_success(
|
|
885
|
+
audit_id,
|
|
886
|
+
result={
|
|
887
|
+
"determinant_id": created_id,
|
|
888
|
+
"name": determinant_data.get("name"),
|
|
889
|
+
"service_id": str(service_id),
|
|
890
|
+
},
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
return {
|
|
894
|
+
"id": created_id,
|
|
895
|
+
"name": determinant_data.get("name"),
|
|
896
|
+
"type": "radio",
|
|
897
|
+
"operator": determinant_data.get("operator"),
|
|
898
|
+
"target_form_field_key": determinant_data.get("targetFormFieldKey"),
|
|
899
|
+
"select_value": determinant_data.get("selectValue"),
|
|
900
|
+
"condition_logic": determinant_data.get("conditionLogic"),
|
|
901
|
+
"json_condition": determinant_data.get("jsonCondition"),
|
|
902
|
+
"service_id": service_id,
|
|
903
|
+
"audit_id": audit_id,
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
except BPAClientError as e:
|
|
907
|
+
# Mark audit as failed
|
|
908
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
909
|
+
raise translate_error(e, resource_type="determinant")
|
|
910
|
+
|
|
911
|
+
except ToolError:
|
|
912
|
+
raise
|
|
913
|
+
except BPAClientError as e:
|
|
914
|
+
raise translate_error(e, resource_type="service", resource_id=service_id)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
# =============================================================================
|
|
918
|
+
# numericdeterminant_create
|
|
919
|
+
# =============================================================================
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _validate_numericdeterminant_create_params(
|
|
923
|
+
service_id: str | int,
|
|
924
|
+
name: str,
|
|
925
|
+
operator: str,
|
|
926
|
+
target_form_field_key: str,
|
|
927
|
+
numeric_value: int | float,
|
|
928
|
+
) -> dict[str, Any]:
|
|
929
|
+
"""Validate numericdeterminant_create parameters (pre-flight).
|
|
930
|
+
|
|
931
|
+
Returns validated params dict or raises ToolError if invalid.
|
|
932
|
+
No audit record is created for validation failures.
|
|
933
|
+
|
|
934
|
+
Args:
|
|
935
|
+
service_id: Parent service ID (required).
|
|
936
|
+
name: Determinant name (required).
|
|
937
|
+
operator: Comparison operator (required). Valid values: EQUAL, NOT_EQUAL,
|
|
938
|
+
GREATER_THAN, LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL.
|
|
939
|
+
target_form_field_key: The form field key this determinant targets (required).
|
|
940
|
+
numeric_value: The numeric value to compare against (required).
|
|
941
|
+
|
|
942
|
+
Returns:
|
|
943
|
+
dict: Validated parameters ready for API call.
|
|
944
|
+
|
|
945
|
+
Raises:
|
|
946
|
+
ToolError: If validation fails.
|
|
947
|
+
"""
|
|
948
|
+
errors = []
|
|
949
|
+
|
|
950
|
+
if not service_id:
|
|
951
|
+
errors.append("'service_id' is required")
|
|
952
|
+
|
|
953
|
+
if not name or not name.strip():
|
|
954
|
+
errors.append("'name' is required and cannot be empty")
|
|
955
|
+
|
|
956
|
+
if name and len(name.strip()) > 255:
|
|
957
|
+
errors.append("'name' must be 255 characters or less")
|
|
958
|
+
|
|
959
|
+
if not operator or not operator.strip():
|
|
960
|
+
errors.append("'operator' is required")
|
|
961
|
+
|
|
962
|
+
valid_operators = [
|
|
963
|
+
"EQUAL",
|
|
964
|
+
"NOT_EQUAL",
|
|
965
|
+
"GREATER_THAN",
|
|
966
|
+
"LESS_THAN",
|
|
967
|
+
"GREATER_THAN_OR_EQUAL",
|
|
968
|
+
"LESS_THAN_OR_EQUAL",
|
|
969
|
+
]
|
|
970
|
+
# Normalize operator to canonical format (Story 10-4)
|
|
971
|
+
normalized_operator = _normalize_operator(operator) if operator else ""
|
|
972
|
+
if operator and normalized_operator not in valid_operators:
|
|
973
|
+
errors.append(f"'operator' must be one of: {', '.join(valid_operators)}")
|
|
974
|
+
|
|
975
|
+
if not target_form_field_key or not target_form_field_key.strip():
|
|
976
|
+
errors.append("'target_form_field_key' is required")
|
|
977
|
+
|
|
978
|
+
if numeric_value is None:
|
|
979
|
+
errors.append("'numeric_value' is required")
|
|
980
|
+
elif not isinstance(numeric_value, int | float):
|
|
981
|
+
errors.append("'numeric_value' must be a number (int or float)")
|
|
982
|
+
|
|
983
|
+
if errors:
|
|
984
|
+
error_msg = "; ".join(errors)
|
|
985
|
+
raise ToolError(
|
|
986
|
+
f"Cannot create numeric determinant: {error_msg}. "
|
|
987
|
+
"Provide valid 'service_id', 'name', 'operator', "
|
|
988
|
+
"'target_form_field_key', and 'numeric_value' parameters."
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
return {
|
|
992
|
+
"name": name.strip(),
|
|
993
|
+
"operator": normalized_operator,
|
|
994
|
+
"targetFormFieldKey": target_form_field_key.strip(),
|
|
995
|
+
"determinantType": "FORMFIELD",
|
|
996
|
+
"type": "numeric",
|
|
997
|
+
"numericValue": numeric_value,
|
|
998
|
+
"determinantInsideGrid": False,
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
async def numericdeterminant_create(
|
|
1003
|
+
service_id: str | int,
|
|
1004
|
+
name: str,
|
|
1005
|
+
operator: str,
|
|
1006
|
+
target_form_field_key: str,
|
|
1007
|
+
numeric_value: int | float,
|
|
1008
|
+
) -> dict[str, Any]:
|
|
1009
|
+
"""Create numeric determinant in a service. Audited write operation.
|
|
1010
|
+
|
|
1011
|
+
Args:
|
|
1012
|
+
service_id: Parent service ID.
|
|
1013
|
+
name: Determinant name.
|
|
1014
|
+
operator: EQUAL, NOT_EQUAL, GREATER_THAN, LESS_THAN,
|
|
1015
|
+
GREATER_THAN_OR_EQUAL, or LESS_THAN_OR_EQUAL.
|
|
1016
|
+
target_form_field_key: Form field key to evaluate.
|
|
1017
|
+
numeric_value: Numeric value to compare against.
|
|
1018
|
+
|
|
1019
|
+
Returns:
|
|
1020
|
+
dict with id, name, type, operator, numeric_value, target_form_field_key,
|
|
1021
|
+
service_id, audit_id.
|
|
1022
|
+
"""
|
|
1023
|
+
# Pre-flight validation (no audit record for validation failures)
|
|
1024
|
+
validated_params = _validate_numericdeterminant_create_params(
|
|
1025
|
+
service_id, name, operator, target_form_field_key, numeric_value
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
# Get authenticated user for audit (before any API calls)
|
|
1029
|
+
try:
|
|
1030
|
+
user_email = get_current_user_email()
|
|
1031
|
+
except NotAuthenticatedError as e:
|
|
1032
|
+
raise ToolError(str(e))
|
|
1033
|
+
|
|
1034
|
+
# Use single BPAClient connection for all operations
|
|
1035
|
+
try:
|
|
1036
|
+
async with BPAClient() as client:
|
|
1037
|
+
# Verify parent service exists before creating audit record
|
|
1038
|
+
try:
|
|
1039
|
+
await client.get(
|
|
1040
|
+
"/service/{id}",
|
|
1041
|
+
path_params={"id": service_id},
|
|
1042
|
+
resource_type="service",
|
|
1043
|
+
resource_id=service_id,
|
|
1044
|
+
)
|
|
1045
|
+
except BPANotFoundError:
|
|
1046
|
+
raise ToolError(
|
|
1047
|
+
f"Cannot create numeric determinant: Service '{service_id}' "
|
|
1048
|
+
"not found. Use 'service_list' to see available services."
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
# Create audit record BEFORE API call (audit-before-write pattern)
|
|
1052
|
+
audit_logger = AuditLogger()
|
|
1053
|
+
audit_id = await audit_logger.record_pending(
|
|
1054
|
+
user_email=user_email,
|
|
1055
|
+
operation_type="create",
|
|
1056
|
+
object_type="numericdeterminant",
|
|
1057
|
+
params={
|
|
1058
|
+
"service_id": str(service_id),
|
|
1059
|
+
**validated_params,
|
|
1060
|
+
},
|
|
1061
|
+
)
|
|
1062
|
+
|
|
1063
|
+
try:
|
|
1064
|
+
determinant_data = await client.post(
|
|
1065
|
+
"/service/{service_id}/numericdeterminant",
|
|
1066
|
+
path_params={"service_id": service_id},
|
|
1067
|
+
json=validated_params,
|
|
1068
|
+
resource_type="determinant",
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
# Save rollback state (for create, save ID to enable deletion)
|
|
1072
|
+
created_id = determinant_data.get("id")
|
|
1073
|
+
await audit_logger.save_rollback_state(
|
|
1074
|
+
audit_id=audit_id,
|
|
1075
|
+
object_type="numericdeterminant",
|
|
1076
|
+
object_id=str(created_id),
|
|
1077
|
+
previous_state={
|
|
1078
|
+
"id": created_id,
|
|
1079
|
+
"name": determinant_data.get("name"),
|
|
1080
|
+
"operator": determinant_data.get("operator"),
|
|
1081
|
+
"targetFormFieldKey": determinant_data.get(
|
|
1082
|
+
"targetFormFieldKey"
|
|
1083
|
+
),
|
|
1084
|
+
"numericValue": determinant_data.get("numericValue"),
|
|
1085
|
+
"serviceId": str(service_id),
|
|
1086
|
+
"_operation": "create", # Marker for rollback to DELETE
|
|
1087
|
+
},
|
|
1088
|
+
)
|
|
1089
|
+
|
|
1090
|
+
# Mark audit as success
|
|
1091
|
+
await audit_logger.mark_success(
|
|
1092
|
+
audit_id,
|
|
1093
|
+
result={
|
|
1094
|
+
"determinant_id": created_id,
|
|
1095
|
+
"name": determinant_data.get("name"),
|
|
1096
|
+
"service_id": str(service_id),
|
|
1097
|
+
},
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
return {
|
|
1101
|
+
"id": created_id,
|
|
1102
|
+
"name": determinant_data.get("name"),
|
|
1103
|
+
"type": "numeric",
|
|
1104
|
+
"operator": determinant_data.get("operator"),
|
|
1105
|
+
"target_form_field_key": determinant_data.get("targetFormFieldKey"),
|
|
1106
|
+
"numeric_value": determinant_data.get("numericValue"),
|
|
1107
|
+
"service_id": service_id,
|
|
1108
|
+
"audit_id": audit_id,
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
except BPAClientError as e:
|
|
1112
|
+
# Mark audit as failed
|
|
1113
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
1114
|
+
raise translate_error(e, resource_type="determinant")
|
|
1115
|
+
|
|
1116
|
+
except ToolError:
|
|
1117
|
+
raise
|
|
1118
|
+
except BPAClientError as e:
|
|
1119
|
+
raise translate_error(e, resource_type="service", resource_id=service_id)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
# =============================================================================
|
|
1123
|
+
# booleandeterminant_create
|
|
1124
|
+
# =============================================================================
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
def _validate_booleandeterminant_create_params(
|
|
1128
|
+
service_id: str | int,
|
|
1129
|
+
name: str,
|
|
1130
|
+
target_form_field_key: str,
|
|
1131
|
+
boolean_value: bool,
|
|
1132
|
+
) -> dict[str, Any]:
|
|
1133
|
+
"""Validate booleandeterminant_create parameters (pre-flight).
|
|
1134
|
+
|
|
1135
|
+
Returns validated params dict or raises ToolError if invalid.
|
|
1136
|
+
No audit record is created for validation failures.
|
|
1137
|
+
|
|
1138
|
+
Args:
|
|
1139
|
+
service_id: Parent service ID (required).
|
|
1140
|
+
name: Determinant name (required).
|
|
1141
|
+
target_form_field_key: The form field key this determinant targets (required).
|
|
1142
|
+
boolean_value: The boolean value to check (True/False) (required).
|
|
1143
|
+
|
|
1144
|
+
Returns:
|
|
1145
|
+
dict: Validated parameters ready for API call.
|
|
1146
|
+
|
|
1147
|
+
Raises:
|
|
1148
|
+
ToolError: If validation fails.
|
|
1149
|
+
"""
|
|
1150
|
+
errors = []
|
|
1151
|
+
|
|
1152
|
+
if not service_id:
|
|
1153
|
+
errors.append("'service_id' is required")
|
|
1154
|
+
|
|
1155
|
+
if not name or not name.strip():
|
|
1156
|
+
errors.append("'name' is required and cannot be empty")
|
|
1157
|
+
|
|
1158
|
+
if name and len(name.strip()) > 255:
|
|
1159
|
+
errors.append("'name' must be 255 characters or less")
|
|
1160
|
+
|
|
1161
|
+
if not target_form_field_key or not target_form_field_key.strip():
|
|
1162
|
+
errors.append("'target_form_field_key' is required")
|
|
1163
|
+
|
|
1164
|
+
if boolean_value is None:
|
|
1165
|
+
errors.append("'boolean_value' is required")
|
|
1166
|
+
elif not isinstance(boolean_value, bool):
|
|
1167
|
+
errors.append("'boolean_value' must be a boolean (True or False)")
|
|
1168
|
+
|
|
1169
|
+
if errors:
|
|
1170
|
+
error_msg = "; ".join(errors)
|
|
1171
|
+
raise ToolError(
|
|
1172
|
+
f"Cannot create boolean determinant: {error_msg}. "
|
|
1173
|
+
"Provide valid 'service_id', 'name', 'target_form_field_key', "
|
|
1174
|
+
"and 'boolean_value' parameters."
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
return {
|
|
1178
|
+
"name": name.strip(),
|
|
1179
|
+
"targetFormFieldKey": target_form_field_key.strip(),
|
|
1180
|
+
"determinantType": "FORMFIELD",
|
|
1181
|
+
"type": "boolean",
|
|
1182
|
+
"booleanValue": boolean_value,
|
|
1183
|
+
"determinantInsideGrid": False,
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
async def booleandeterminant_create(
|
|
1188
|
+
service_id: str | int,
|
|
1189
|
+
name: str,
|
|
1190
|
+
target_form_field_key: str,
|
|
1191
|
+
boolean_value: bool,
|
|
1192
|
+
) -> dict[str, Any]:
|
|
1193
|
+
"""Create boolean determinant in a service. Audited write operation.
|
|
1194
|
+
|
|
1195
|
+
Args:
|
|
1196
|
+
service_id: Parent service ID.
|
|
1197
|
+
name: Determinant name.
|
|
1198
|
+
target_form_field_key: Form field key to evaluate (checkbox field).
|
|
1199
|
+
boolean_value: Boolean value to check (True or False).
|
|
1200
|
+
|
|
1201
|
+
Returns:
|
|
1202
|
+
dict with id, name, type, boolean_value, target_form_field_key,
|
|
1203
|
+
service_id, audit_id.
|
|
1204
|
+
"""
|
|
1205
|
+
# Pre-flight validation (no audit record for validation failures)
|
|
1206
|
+
validated_params = _validate_booleandeterminant_create_params(
|
|
1207
|
+
service_id, name, target_form_field_key, boolean_value
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
# Get authenticated user for audit (before any API calls)
|
|
1211
|
+
try:
|
|
1212
|
+
user_email = get_current_user_email()
|
|
1213
|
+
except NotAuthenticatedError as e:
|
|
1214
|
+
raise ToolError(str(e))
|
|
1215
|
+
|
|
1216
|
+
# Use single BPAClient connection for all operations
|
|
1217
|
+
try:
|
|
1218
|
+
async with BPAClient() as client:
|
|
1219
|
+
# Verify parent service exists before creating audit record
|
|
1220
|
+
try:
|
|
1221
|
+
await client.get(
|
|
1222
|
+
"/service/{id}",
|
|
1223
|
+
path_params={"id": service_id},
|
|
1224
|
+
resource_type="service",
|
|
1225
|
+
resource_id=service_id,
|
|
1226
|
+
)
|
|
1227
|
+
except BPANotFoundError:
|
|
1228
|
+
raise ToolError(
|
|
1229
|
+
f"Cannot create boolean determinant: Service '{service_id}' "
|
|
1230
|
+
"not found. Use 'service_list' to see available services."
|
|
1231
|
+
)
|
|
1232
|
+
|
|
1233
|
+
# Create audit record BEFORE API call (audit-before-write pattern)
|
|
1234
|
+
audit_logger = AuditLogger()
|
|
1235
|
+
audit_id = await audit_logger.record_pending(
|
|
1236
|
+
user_email=user_email,
|
|
1237
|
+
operation_type="create",
|
|
1238
|
+
object_type="booleandeterminant",
|
|
1239
|
+
params={
|
|
1240
|
+
"service_id": str(service_id),
|
|
1241
|
+
**validated_params,
|
|
1242
|
+
},
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
try:
|
|
1246
|
+
determinant_data = await client.post(
|
|
1247
|
+
"/service/{service_id}/booleandeterminant",
|
|
1248
|
+
path_params={"service_id": service_id},
|
|
1249
|
+
json=validated_params,
|
|
1250
|
+
resource_type="determinant",
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
# Save rollback state (for create, save ID to enable deletion)
|
|
1254
|
+
created_id = determinant_data.get("id")
|
|
1255
|
+
await audit_logger.save_rollback_state(
|
|
1256
|
+
audit_id=audit_id,
|
|
1257
|
+
object_type="booleandeterminant",
|
|
1258
|
+
object_id=str(created_id),
|
|
1259
|
+
previous_state={
|
|
1260
|
+
"id": created_id,
|
|
1261
|
+
"name": determinant_data.get("name"),
|
|
1262
|
+
"targetFormFieldKey": determinant_data.get(
|
|
1263
|
+
"targetFormFieldKey"
|
|
1264
|
+
),
|
|
1265
|
+
"booleanValue": determinant_data.get("booleanValue"),
|
|
1266
|
+
"serviceId": str(service_id),
|
|
1267
|
+
"_operation": "create", # Marker for rollback to DELETE
|
|
1268
|
+
},
|
|
1269
|
+
)
|
|
1270
|
+
|
|
1271
|
+
# Mark audit as success
|
|
1272
|
+
await audit_logger.mark_success(
|
|
1273
|
+
audit_id,
|
|
1274
|
+
result={
|
|
1275
|
+
"determinant_id": created_id,
|
|
1276
|
+
"name": determinant_data.get("name"),
|
|
1277
|
+
"service_id": str(service_id),
|
|
1278
|
+
},
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
return {
|
|
1282
|
+
"id": created_id,
|
|
1283
|
+
"name": determinant_data.get("name"),
|
|
1284
|
+
"type": "boolean",
|
|
1285
|
+
"target_form_field_key": determinant_data.get("targetFormFieldKey"),
|
|
1286
|
+
"boolean_value": determinant_data.get("booleanValue"),
|
|
1287
|
+
"service_id": service_id,
|
|
1288
|
+
"audit_id": audit_id,
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
except BPAClientError as e:
|
|
1292
|
+
# Mark audit as failed
|
|
1293
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
1294
|
+
raise translate_error(e, resource_type="determinant")
|
|
1295
|
+
|
|
1296
|
+
except ToolError:
|
|
1297
|
+
raise
|
|
1298
|
+
except BPAClientError as e:
|
|
1299
|
+
raise translate_error(e, resource_type="service", resource_id=service_id)
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
# =============================================================================
|
|
1303
|
+
# datedeterminant_create
|
|
1304
|
+
# =============================================================================
|
|
1305
|
+
|
|
1306
|
+
# Valid operators for date determinants
|
|
1307
|
+
DATE_DETERMINANT_OPERATORS = frozenset(
|
|
1308
|
+
{
|
|
1309
|
+
"EQUAL",
|
|
1310
|
+
"NOT_EQUAL",
|
|
1311
|
+
"GREATER_THAN",
|
|
1312
|
+
"LESS_THAN",
|
|
1313
|
+
"GREATER_THAN_OR_EQUAL",
|
|
1314
|
+
"LESS_THAN_OR_EQUAL",
|
|
1315
|
+
}
|
|
1316
|
+
)
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
def _validate_datedeterminant_create_params(
|
|
1320
|
+
service_id: str | int,
|
|
1321
|
+
name: str,
|
|
1322
|
+
target_form_field_key: str,
|
|
1323
|
+
operator: str,
|
|
1324
|
+
is_current_date: bool | None = None,
|
|
1325
|
+
date_value: str | None = None,
|
|
1326
|
+
) -> dict[str, Any]:
|
|
1327
|
+
"""Validate datedeterminant_create parameters (pre-flight).
|
|
1328
|
+
|
|
1329
|
+
Returns validated params dict or raises ToolError if invalid.
|
|
1330
|
+
No audit record is created for validation failures.
|
|
1331
|
+
|
|
1332
|
+
Args:
|
|
1333
|
+
service_id: Parent service ID (required).
|
|
1334
|
+
name: Determinant name (required).
|
|
1335
|
+
target_form_field_key: Form field key to evaluate (required).
|
|
1336
|
+
operator: Comparison operator (required).
|
|
1337
|
+
is_current_date: Whether to compare against current date.
|
|
1338
|
+
date_value: Specific date to compare against (ISO 8601: YYYY-MM-DD).
|
|
1339
|
+
|
|
1340
|
+
Returns:
|
|
1341
|
+
dict: Validated parameters ready for API call.
|
|
1342
|
+
|
|
1343
|
+
Raises:
|
|
1344
|
+
ToolError: If validation fails.
|
|
1345
|
+
"""
|
|
1346
|
+
import re
|
|
1347
|
+
|
|
1348
|
+
errors = []
|
|
1349
|
+
|
|
1350
|
+
if not service_id:
|
|
1351
|
+
errors.append("'service_id' is required")
|
|
1352
|
+
|
|
1353
|
+
if not name or not name.strip():
|
|
1354
|
+
errors.append("'name' is required and cannot be empty")
|
|
1355
|
+
|
|
1356
|
+
if name and len(name.strip()) > 255:
|
|
1357
|
+
errors.append("'name' must be 255 characters or less")
|
|
1358
|
+
|
|
1359
|
+
if not target_form_field_key or not target_form_field_key.strip():
|
|
1360
|
+
errors.append("'target_form_field_key' is required")
|
|
1361
|
+
|
|
1362
|
+
if not operator or not operator.strip():
|
|
1363
|
+
errors.append("'operator' is required")
|
|
1364
|
+
# Normalize operator to canonical format (Story 10-4)
|
|
1365
|
+
normalized_operator = _normalize_operator(operator) if operator else ""
|
|
1366
|
+
if operator and normalized_operator not in DATE_DETERMINANT_OPERATORS:
|
|
1367
|
+
valid_ops = ", ".join(sorted(DATE_DETERMINANT_OPERATORS))
|
|
1368
|
+
errors.append(f"'operator' must be one of: {valid_ops}")
|
|
1369
|
+
|
|
1370
|
+
# Must provide either is_current_date=True or a date_value
|
|
1371
|
+
if not is_current_date and not date_value:
|
|
1372
|
+
errors.append("Either 'is_current_date=True' or 'date_value' must be provided")
|
|
1373
|
+
|
|
1374
|
+
# Validate date format if provided
|
|
1375
|
+
if date_value:
|
|
1376
|
+
date_pattern = r"^\d{4}-\d{2}-\d{2}$"
|
|
1377
|
+
if not re.match(date_pattern, date_value.strip()):
|
|
1378
|
+
errors.append("'date_value' must be in ISO 8601 format (YYYY-MM-DD)")
|
|
1379
|
+
|
|
1380
|
+
if errors:
|
|
1381
|
+
error_msg = "; ".join(errors)
|
|
1382
|
+
raise ToolError(
|
|
1383
|
+
f"Cannot create date determinant: {error_msg}. "
|
|
1384
|
+
"Provide valid 'service_id', 'name', 'target_form_field_key', "
|
|
1385
|
+
"'operator', and either 'is_current_date=True' or 'date_value'."
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
# Build API payload
|
|
1389
|
+
payload: dict[str, Any] = {
|
|
1390
|
+
"name": name.strip(),
|
|
1391
|
+
"targetFormFieldKey": target_form_field_key.strip(),
|
|
1392
|
+
"determinantType": "FORMFIELD",
|
|
1393
|
+
"type": "date",
|
|
1394
|
+
"operator": normalized_operator,
|
|
1395
|
+
"determinantInsideGrid": False,
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
if is_current_date:
|
|
1399
|
+
payload["isCurrentDate"] = True
|
|
1400
|
+
else:
|
|
1401
|
+
payload["isCurrentDate"] = False
|
|
1402
|
+
payload["dateValue"] = date_value.strip() if date_value else None
|
|
1403
|
+
|
|
1404
|
+
return payload
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
async def datedeterminant_create(
|
|
1408
|
+
service_id: str | int,
|
|
1409
|
+
name: str,
|
|
1410
|
+
target_form_field_key: str,
|
|
1411
|
+
operator: str,
|
|
1412
|
+
is_current_date: bool | None = None,
|
|
1413
|
+
date_value: str | None = None,
|
|
1414
|
+
) -> dict[str, Any]:
|
|
1415
|
+
"""Create date determinant in a service. Audited write operation.
|
|
1416
|
+
|
|
1417
|
+
Args:
|
|
1418
|
+
service_id: Parent service ID.
|
|
1419
|
+
name: Determinant name.
|
|
1420
|
+
target_form_field_key: Form field key to evaluate (date field).
|
|
1421
|
+
operator: Comparison operator (EQUAL, NOT_EQUAL, GREATER_THAN,
|
|
1422
|
+
LESS_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN_OR_EQUAL).
|
|
1423
|
+
is_current_date: Compare against today's date (default: None).
|
|
1424
|
+
date_value: Specific date to compare (ISO 8601: YYYY-MM-DD).
|
|
1425
|
+
|
|
1426
|
+
Returns:
|
|
1427
|
+
dict with id, name, type, operator, target_form_field_key,
|
|
1428
|
+
is_current_date, date_value, service_id, audit_id.
|
|
1429
|
+
"""
|
|
1430
|
+
# Pre-flight validation (no audit record for validation failures)
|
|
1431
|
+
validated_params = _validate_datedeterminant_create_params(
|
|
1432
|
+
service_id, name, target_form_field_key, operator, is_current_date, date_value
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
# Get authenticated user for audit (before any API calls)
|
|
1436
|
+
try:
|
|
1437
|
+
user_email = get_current_user_email()
|
|
1438
|
+
except NotAuthenticatedError as e:
|
|
1439
|
+
raise ToolError(str(e))
|
|
1440
|
+
|
|
1441
|
+
# Use single BPAClient connection for all operations
|
|
1442
|
+
try:
|
|
1443
|
+
async with BPAClient() as client:
|
|
1444
|
+
# Verify parent service exists before creating audit record
|
|
1445
|
+
try:
|
|
1446
|
+
await client.get(
|
|
1447
|
+
"/service/{id}",
|
|
1448
|
+
path_params={"id": service_id},
|
|
1449
|
+
resource_type="service",
|
|
1450
|
+
resource_id=service_id,
|
|
1451
|
+
)
|
|
1452
|
+
except BPANotFoundError:
|
|
1453
|
+
raise ToolError(
|
|
1454
|
+
f"Cannot create date determinant: Service '{service_id}' "
|
|
1455
|
+
"not found. Use 'service_list' to see available services."
|
|
1456
|
+
)
|
|
1457
|
+
|
|
1458
|
+
# Create audit record BEFORE API call (audit-before-write pattern)
|
|
1459
|
+
audit_logger = AuditLogger()
|
|
1460
|
+
audit_id = await audit_logger.record_pending(
|
|
1461
|
+
user_email=user_email,
|
|
1462
|
+
operation_type="create",
|
|
1463
|
+
object_type="datedeterminant",
|
|
1464
|
+
params={
|
|
1465
|
+
"service_id": str(service_id),
|
|
1466
|
+
**validated_params,
|
|
1467
|
+
},
|
|
1468
|
+
)
|
|
1469
|
+
|
|
1470
|
+
try:
|
|
1471
|
+
determinant_data = await client.post(
|
|
1472
|
+
"/service/{service_id}/datedeterminant",
|
|
1473
|
+
path_params={"service_id": service_id},
|
|
1474
|
+
json=validated_params,
|
|
1475
|
+
resource_type="determinant",
|
|
1476
|
+
)
|
|
1477
|
+
|
|
1478
|
+
# Save rollback state (for create, save ID to enable deletion)
|
|
1479
|
+
created_id = determinant_data.get("id")
|
|
1480
|
+
await audit_logger.save_rollback_state(
|
|
1481
|
+
audit_id=audit_id,
|
|
1482
|
+
object_type="datedeterminant",
|
|
1483
|
+
object_id=str(created_id),
|
|
1484
|
+
previous_state={
|
|
1485
|
+
"id": created_id,
|
|
1486
|
+
"name": determinant_data.get("name"),
|
|
1487
|
+
"targetFormFieldKey": determinant_data.get(
|
|
1488
|
+
"targetFormFieldKey"
|
|
1489
|
+
),
|
|
1490
|
+
"operator": determinant_data.get("operator"),
|
|
1491
|
+
"isCurrentDate": determinant_data.get("isCurrentDate"),
|
|
1492
|
+
"dateValue": determinant_data.get("dateValue"),
|
|
1493
|
+
"serviceId": str(service_id),
|
|
1494
|
+
"_operation": "create", # Marker for rollback to DELETE
|
|
1495
|
+
},
|
|
1496
|
+
)
|
|
1497
|
+
|
|
1498
|
+
# Mark audit as success
|
|
1499
|
+
await audit_logger.mark_success(
|
|
1500
|
+
audit_id,
|
|
1501
|
+
result={
|
|
1502
|
+
"determinant_id": created_id,
|
|
1503
|
+
"name": determinant_data.get("name"),
|
|
1504
|
+
"service_id": str(service_id),
|
|
1505
|
+
},
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
return {
|
|
1509
|
+
"id": created_id,
|
|
1510
|
+
"name": determinant_data.get("name"),
|
|
1511
|
+
"type": "date",
|
|
1512
|
+
"operator": determinant_data.get("operator"),
|
|
1513
|
+
"target_form_field_key": determinant_data.get("targetFormFieldKey"),
|
|
1514
|
+
"is_current_date": determinant_data.get("isCurrentDate"),
|
|
1515
|
+
"date_value": determinant_data.get("dateValue"),
|
|
1516
|
+
"service_id": service_id,
|
|
1517
|
+
"audit_id": audit_id,
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
except BPAClientError as e:
|
|
1521
|
+
# Mark audit as failed
|
|
1522
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
1523
|
+
raise translate_error(e, resource_type="determinant")
|
|
1524
|
+
|
|
1525
|
+
except ToolError:
|
|
1526
|
+
raise
|
|
1527
|
+
except BPAClientError as e:
|
|
1528
|
+
raise translate_error(e, resource_type="service", resource_id=service_id)
|
|
1529
|
+
|
|
1530
|
+
|
|
1531
|
+
# =============================================================================
|
|
1532
|
+
# classificationdeterminant_create
|
|
1533
|
+
# =============================================================================
|
|
1534
|
+
|
|
1535
|
+
# Valid operators for classification determinants
|
|
1536
|
+
CLASSIFICATION_DETERMINANT_OPERATORS = frozenset({"EQUAL", "NOT_EQUAL"})
|
|
1537
|
+
|
|
1538
|
+
# Valid subjects for classification determinants
|
|
1539
|
+
CLASSIFICATION_DETERMINANT_SUBJECTS = frozenset({"ALL", "ANY", "NONE"})
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
def _validate_classificationdeterminant_create_params(
|
|
1543
|
+
service_id: str | int,
|
|
1544
|
+
name: str,
|
|
1545
|
+
target_form_field_key: str,
|
|
1546
|
+
classification_field: str,
|
|
1547
|
+
operator: str,
|
|
1548
|
+
subject: str | None = None,
|
|
1549
|
+
) -> dict[str, Any]:
|
|
1550
|
+
"""Validate classificationdeterminant_create parameters (pre-flight).
|
|
1551
|
+
|
|
1552
|
+
Returns validated params dict or raises ToolError if invalid.
|
|
1553
|
+
No audit record is created for validation failures.
|
|
1554
|
+
|
|
1555
|
+
Args:
|
|
1556
|
+
service_id: Parent service ID (required).
|
|
1557
|
+
name: Determinant name (required).
|
|
1558
|
+
target_form_field_key: Form field key to evaluate (required).
|
|
1559
|
+
classification_field: Catalog field ID (required).
|
|
1560
|
+
operator: Comparison operator (EQUAL, NOT_EQUAL) (required).
|
|
1561
|
+
subject: How to evaluate multi-select (ALL, ANY, NONE) (default: ALL).
|
|
1562
|
+
|
|
1563
|
+
Returns:
|
|
1564
|
+
dict: Validated parameters ready for API call.
|
|
1565
|
+
|
|
1566
|
+
Raises:
|
|
1567
|
+
ToolError: If validation fails.
|
|
1568
|
+
"""
|
|
1569
|
+
errors = []
|
|
1570
|
+
|
|
1571
|
+
if not service_id:
|
|
1572
|
+
errors.append("'service_id' is required")
|
|
1573
|
+
|
|
1574
|
+
if not name or not name.strip():
|
|
1575
|
+
errors.append("'name' is required and cannot be empty")
|
|
1576
|
+
|
|
1577
|
+
if name and len(name.strip()) > 255:
|
|
1578
|
+
errors.append("'name' must be 255 characters or less")
|
|
1579
|
+
|
|
1580
|
+
if not target_form_field_key or not target_form_field_key.strip():
|
|
1581
|
+
errors.append("'target_form_field_key' is required")
|
|
1582
|
+
|
|
1583
|
+
if not classification_field or not classification_field.strip():
|
|
1584
|
+
errors.append("'classification_field' is required")
|
|
1585
|
+
|
|
1586
|
+
if not operator or not operator.strip():
|
|
1587
|
+
errors.append("'operator' is required")
|
|
1588
|
+
elif operator.strip().upper() not in CLASSIFICATION_DETERMINANT_OPERATORS:
|
|
1589
|
+
valid_ops = ", ".join(sorted(CLASSIFICATION_DETERMINANT_OPERATORS))
|
|
1590
|
+
errors.append(f"'operator' must be one of: {valid_ops}")
|
|
1591
|
+
|
|
1592
|
+
# Default subject to ALL if not provided
|
|
1593
|
+
resolved_subject = subject.strip().upper() if subject else "ALL"
|
|
1594
|
+
if resolved_subject not in CLASSIFICATION_DETERMINANT_SUBJECTS:
|
|
1595
|
+
valid_subjects = ", ".join(sorted(CLASSIFICATION_DETERMINANT_SUBJECTS))
|
|
1596
|
+
errors.append(f"'subject' must be one of: {valid_subjects}")
|
|
1597
|
+
|
|
1598
|
+
if errors:
|
|
1599
|
+
error_msg = "; ".join(errors)
|
|
1600
|
+
raise ToolError(
|
|
1601
|
+
f"Cannot create classification determinant: {error_msg}. "
|
|
1602
|
+
"Provide valid 'service_id', 'name', 'target_form_field_key', "
|
|
1603
|
+
"'classification_field', 'operator', and optionally 'subject'."
|
|
1604
|
+
)
|
|
1605
|
+
|
|
1606
|
+
return {
|
|
1607
|
+
"name": name.strip(),
|
|
1608
|
+
"targetFormFieldKey": target_form_field_key.strip(),
|
|
1609
|
+
"determinantType": "FORMFIELD",
|
|
1610
|
+
"type": "classification",
|
|
1611
|
+
"operator": operator.strip().upper(),
|
|
1612
|
+
"subject": resolved_subject,
|
|
1613
|
+
"classificationField": classification_field.strip(),
|
|
1614
|
+
"determinantInsideGrid": False,
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
async def classificationdeterminant_create(
|
|
1619
|
+
service_id: str | int,
|
|
1620
|
+
name: str,
|
|
1621
|
+
target_form_field_key: str,
|
|
1622
|
+
classification_field: str,
|
|
1623
|
+
operator: str,
|
|
1624
|
+
subject: str | None = None,
|
|
1625
|
+
) -> dict[str, Any]:
|
|
1626
|
+
"""Create classification determinant in a service. Audited write operation.
|
|
1627
|
+
|
|
1628
|
+
Args:
|
|
1629
|
+
service_id: Parent service ID.
|
|
1630
|
+
name: Determinant name.
|
|
1631
|
+
target_form_field_key: Form field key to evaluate.
|
|
1632
|
+
classification_field: Catalog field ID (UUID).
|
|
1633
|
+
operator: Comparison operator (EQUAL, NOT_EQUAL).
|
|
1634
|
+
subject: How to evaluate multi-select (ALL, ANY, NONE; default: ALL).
|
|
1635
|
+
|
|
1636
|
+
Returns:
|
|
1637
|
+
dict with id, name, type, operator, subject, target_form_field_key,
|
|
1638
|
+
classification_field, service_id, audit_id.
|
|
1639
|
+
"""
|
|
1640
|
+
# Pre-flight validation (no audit record for validation failures)
|
|
1641
|
+
validated_params = _validate_classificationdeterminant_create_params(
|
|
1642
|
+
service_id, name, target_form_field_key, classification_field, operator, subject
|
|
1643
|
+
)
|
|
1644
|
+
|
|
1645
|
+
# Get authenticated user for audit (before any API calls)
|
|
1646
|
+
try:
|
|
1647
|
+
user_email = get_current_user_email()
|
|
1648
|
+
except NotAuthenticatedError as e:
|
|
1649
|
+
raise ToolError(str(e))
|
|
1650
|
+
|
|
1651
|
+
# Use single BPAClient connection for all operations
|
|
1652
|
+
try:
|
|
1653
|
+
async with BPAClient() as client:
|
|
1654
|
+
# Verify parent service exists before creating audit record
|
|
1655
|
+
try:
|
|
1656
|
+
await client.get(
|
|
1657
|
+
"/service/{id}",
|
|
1658
|
+
path_params={"id": service_id},
|
|
1659
|
+
resource_type="service",
|
|
1660
|
+
resource_id=service_id,
|
|
1661
|
+
)
|
|
1662
|
+
except BPANotFoundError:
|
|
1663
|
+
raise ToolError(
|
|
1664
|
+
f"Cannot create classification determinant: Service '{service_id}' "
|
|
1665
|
+
"not found. Use 'service_list' to see available services."
|
|
1666
|
+
)
|
|
1667
|
+
|
|
1668
|
+
# Create audit record BEFORE API call (audit-before-write pattern)
|
|
1669
|
+
audit_logger = AuditLogger()
|
|
1670
|
+
audit_id = await audit_logger.record_pending(
|
|
1671
|
+
user_email=user_email,
|
|
1672
|
+
operation_type="create",
|
|
1673
|
+
object_type="classificationdeterminant",
|
|
1674
|
+
params={
|
|
1675
|
+
"service_id": str(service_id),
|
|
1676
|
+
**validated_params,
|
|
1677
|
+
},
|
|
1678
|
+
)
|
|
1679
|
+
|
|
1680
|
+
try:
|
|
1681
|
+
determinant_data = await client.post(
|
|
1682
|
+
"/service/{service_id}/classificationdeterminant",
|
|
1683
|
+
path_params={"service_id": service_id},
|
|
1684
|
+
json=validated_params,
|
|
1685
|
+
resource_type="determinant",
|
|
1686
|
+
)
|
|
1687
|
+
|
|
1688
|
+
# Save rollback state (for create, save ID to enable deletion)
|
|
1689
|
+
created_id = determinant_data.get("id")
|
|
1690
|
+
await audit_logger.save_rollback_state(
|
|
1691
|
+
audit_id=audit_id,
|
|
1692
|
+
object_type="classificationdeterminant",
|
|
1693
|
+
object_id=str(created_id),
|
|
1694
|
+
previous_state={
|
|
1695
|
+
"id": created_id,
|
|
1696
|
+
"name": determinant_data.get("name"),
|
|
1697
|
+
"targetFormFieldKey": determinant_data.get(
|
|
1698
|
+
"targetFormFieldKey"
|
|
1699
|
+
),
|
|
1700
|
+
"operator": determinant_data.get("operator"),
|
|
1701
|
+
"subject": determinant_data.get("subject"),
|
|
1702
|
+
"classificationField": determinant_data.get(
|
|
1703
|
+
"classificationField"
|
|
1704
|
+
),
|
|
1705
|
+
"serviceId": str(service_id),
|
|
1706
|
+
"_operation": "create", # Marker for rollback to DELETE
|
|
1707
|
+
},
|
|
1708
|
+
)
|
|
1709
|
+
|
|
1710
|
+
# Mark audit as success
|
|
1711
|
+
await audit_logger.mark_success(
|
|
1712
|
+
audit_id,
|
|
1713
|
+
result={
|
|
1714
|
+
"determinant_id": created_id,
|
|
1715
|
+
"name": determinant_data.get("name"),
|
|
1716
|
+
"service_id": str(service_id),
|
|
1717
|
+
},
|
|
1718
|
+
)
|
|
1719
|
+
|
|
1720
|
+
return {
|
|
1721
|
+
"id": created_id,
|
|
1722
|
+
"name": determinant_data.get("name"),
|
|
1723
|
+
"type": "classification",
|
|
1724
|
+
"operator": determinant_data.get("operator"),
|
|
1725
|
+
"subject": determinant_data.get("subject"),
|
|
1726
|
+
"target_form_field_key": determinant_data.get("targetFormFieldKey"),
|
|
1727
|
+
"classification_field": determinant_data.get("classificationField"),
|
|
1728
|
+
"service_id": service_id,
|
|
1729
|
+
"audit_id": audit_id,
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
except BPAClientError as e:
|
|
1733
|
+
# Mark audit as failed
|
|
1734
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
1735
|
+
raise translate_error(e, resource_type="determinant")
|
|
1736
|
+
|
|
1737
|
+
except ToolError:
|
|
1738
|
+
raise
|
|
1739
|
+
except BPAClientError as e:
|
|
1740
|
+
raise translate_error(e, resource_type="service", resource_id=service_id)
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
# =============================================================================
|
|
1744
|
+
# griddeterminant_create
|
|
1745
|
+
# =============================================================================
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
def _validate_griddeterminant_create_params(
|
|
1749
|
+
service_id: str | int,
|
|
1750
|
+
name: str,
|
|
1751
|
+
target_form_field_key: str,
|
|
1752
|
+
row_determinant_id: str,
|
|
1753
|
+
) -> dict[str, Any]:
|
|
1754
|
+
"""Validate griddeterminant_create parameters (pre-flight).
|
|
1755
|
+
|
|
1756
|
+
Returns validated params dict or raises ToolError if invalid.
|
|
1757
|
+
No audit record is created for validation failures.
|
|
1758
|
+
|
|
1759
|
+
Args:
|
|
1760
|
+
service_id: Parent service ID (required).
|
|
1761
|
+
name: Determinant name (required).
|
|
1762
|
+
target_form_field_key: Grid component key to evaluate (required).
|
|
1763
|
+
row_determinant_id: ID of the determinant that evaluates each row (required).
|
|
1764
|
+
|
|
1765
|
+
Returns:
|
|
1766
|
+
dict: Validated parameters ready for API call.
|
|
1767
|
+
|
|
1768
|
+
Raises:
|
|
1769
|
+
ToolError: If validation fails.
|
|
1770
|
+
"""
|
|
1771
|
+
errors = []
|
|
1772
|
+
|
|
1773
|
+
if not service_id:
|
|
1774
|
+
errors.append("'service_id' is required")
|
|
1775
|
+
|
|
1776
|
+
if not name or not name.strip():
|
|
1777
|
+
errors.append("'name' is required and cannot be empty")
|
|
1778
|
+
|
|
1779
|
+
if name and len(name.strip()) > 255:
|
|
1780
|
+
errors.append("'name' must be 255 characters or less")
|
|
1781
|
+
|
|
1782
|
+
if not target_form_field_key or not target_form_field_key.strip():
|
|
1783
|
+
errors.append("'target_form_field_key' is required")
|
|
1784
|
+
|
|
1785
|
+
if not row_determinant_id or not row_determinant_id.strip():
|
|
1786
|
+
errors.append("'row_determinant_id' is required")
|
|
1787
|
+
|
|
1788
|
+
if errors:
|
|
1789
|
+
error_msg = "; ".join(errors)
|
|
1790
|
+
raise ToolError(
|
|
1791
|
+
f"Cannot create grid determinant: {error_msg}. "
|
|
1792
|
+
"Provide valid 'service_id', 'name', 'target_form_field_key', "
|
|
1793
|
+
"and 'row_determinant_id' parameters."
|
|
1794
|
+
)
|
|
1795
|
+
|
|
1796
|
+
return {
|
|
1797
|
+
"name": name.strip(),
|
|
1798
|
+
"targetFormFieldKey": target_form_field_key.strip(),
|
|
1799
|
+
"determinantType": "FORMFIELD",
|
|
1800
|
+
"type": "grid",
|
|
1801
|
+
"determinantInsideGrid": True,
|
|
1802
|
+
"rowDeterminantId": row_determinant_id.strip(),
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
|
|
1806
|
+
async def griddeterminant_create(
|
|
1807
|
+
service_id: str | int,
|
|
1808
|
+
name: str,
|
|
1809
|
+
target_form_field_key: str,
|
|
1810
|
+
row_determinant_id: str,
|
|
1811
|
+
) -> dict[str, Any]:
|
|
1812
|
+
"""Create grid determinant in a service. Audited write operation.
|
|
1813
|
+
|
|
1814
|
+
Args:
|
|
1815
|
+
service_id: Parent service ID.
|
|
1816
|
+
name: Determinant name.
|
|
1817
|
+
target_form_field_key: Grid component key to evaluate.
|
|
1818
|
+
row_determinant_id: ID of the determinant evaluating each row.
|
|
1819
|
+
|
|
1820
|
+
Returns:
|
|
1821
|
+
dict with id, name, type, target_form_field_key, determinant_inside_grid,
|
|
1822
|
+
row_determinant_id, service_id, audit_id.
|
|
1823
|
+
"""
|
|
1824
|
+
# Pre-flight validation (no audit record for validation failures)
|
|
1825
|
+
validated_params = _validate_griddeterminant_create_params(
|
|
1826
|
+
service_id, name, target_form_field_key, row_determinant_id
|
|
1827
|
+
)
|
|
1828
|
+
|
|
1829
|
+
# Get authenticated user for audit (before any API calls)
|
|
1830
|
+
try:
|
|
1831
|
+
user_email = get_current_user_email()
|
|
1832
|
+
except NotAuthenticatedError as e:
|
|
1833
|
+
raise ToolError(str(e))
|
|
1834
|
+
|
|
1835
|
+
# Use single BPAClient connection for all operations
|
|
1836
|
+
try:
|
|
1837
|
+
async with BPAClient() as client:
|
|
1838
|
+
# Verify parent service exists before creating audit record
|
|
1839
|
+
try:
|
|
1840
|
+
await client.get(
|
|
1841
|
+
"/service/{id}",
|
|
1842
|
+
path_params={"id": service_id},
|
|
1843
|
+
resource_type="service",
|
|
1844
|
+
resource_id=service_id,
|
|
1845
|
+
)
|
|
1846
|
+
except BPANotFoundError:
|
|
1847
|
+
raise ToolError(
|
|
1848
|
+
f"Cannot create grid determinant: Service '{service_id}' "
|
|
1849
|
+
"not found. Use 'service_list' to see available services."
|
|
1850
|
+
)
|
|
1851
|
+
|
|
1852
|
+
# Verify row determinant exists before creating audit record
|
|
1853
|
+
try:
|
|
1854
|
+
await client.get(
|
|
1855
|
+
"/determinant/{id}",
|
|
1856
|
+
path_params={"id": row_determinant_id},
|
|
1857
|
+
resource_type="determinant",
|
|
1858
|
+
resource_id=row_determinant_id,
|
|
1859
|
+
)
|
|
1860
|
+
except BPANotFoundError:
|
|
1861
|
+
raise ToolError(
|
|
1862
|
+
f"Cannot create grid determinant: Row determinant "
|
|
1863
|
+
f"'{row_determinant_id}' not found. Create the row determinant "
|
|
1864
|
+
"first using textdeterminant_create, selectdeterminant_create, "
|
|
1865
|
+
"or similar tools."
|
|
1866
|
+
)
|
|
1867
|
+
|
|
1868
|
+
# Create audit record BEFORE API call (audit-before-write pattern)
|
|
1869
|
+
audit_logger = AuditLogger()
|
|
1870
|
+
audit_id = await audit_logger.record_pending(
|
|
1871
|
+
user_email=user_email,
|
|
1872
|
+
operation_type="create",
|
|
1873
|
+
object_type="griddeterminant",
|
|
1874
|
+
params={
|
|
1875
|
+
"service_id": str(service_id),
|
|
1876
|
+
**validated_params,
|
|
1877
|
+
},
|
|
1878
|
+
)
|
|
1879
|
+
|
|
1880
|
+
try:
|
|
1881
|
+
determinant_data = await client.post(
|
|
1882
|
+
"/service/{service_id}/griddeterminant",
|
|
1883
|
+
path_params={"service_id": service_id},
|
|
1884
|
+
json=validated_params,
|
|
1885
|
+
resource_type="determinant",
|
|
1886
|
+
)
|
|
1887
|
+
|
|
1888
|
+
# Save rollback state (for create, save ID to enable deletion)
|
|
1889
|
+
created_id = determinant_data.get("id")
|
|
1890
|
+
await audit_logger.save_rollback_state(
|
|
1891
|
+
audit_id=audit_id,
|
|
1892
|
+
object_type="griddeterminant",
|
|
1893
|
+
object_id=str(created_id),
|
|
1894
|
+
previous_state={
|
|
1895
|
+
"id": created_id,
|
|
1896
|
+
"name": determinant_data.get("name"),
|
|
1897
|
+
"targetFormFieldKey": determinant_data.get(
|
|
1898
|
+
"targetFormFieldKey"
|
|
1899
|
+
),
|
|
1900
|
+
"rowDeterminantId": determinant_data.get("rowDeterminantId"),
|
|
1901
|
+
"determinantInsideGrid": determinant_data.get(
|
|
1902
|
+
"determinantInsideGrid"
|
|
1903
|
+
),
|
|
1904
|
+
"serviceId": str(service_id),
|
|
1905
|
+
"_operation": "create", # Marker for rollback to DELETE
|
|
1906
|
+
},
|
|
1907
|
+
)
|
|
1908
|
+
|
|
1909
|
+
# Mark audit as success
|
|
1910
|
+
await audit_logger.mark_success(
|
|
1911
|
+
audit_id,
|
|
1912
|
+
result={
|
|
1913
|
+
"determinant_id": created_id,
|
|
1914
|
+
"name": determinant_data.get("name"),
|
|
1915
|
+
"service_id": str(service_id),
|
|
1916
|
+
},
|
|
1917
|
+
)
|
|
1918
|
+
|
|
1919
|
+
return {
|
|
1920
|
+
"id": created_id,
|
|
1921
|
+
"name": determinant_data.get("name"),
|
|
1922
|
+
"type": "grid",
|
|
1923
|
+
"target_form_field_key": determinant_data.get("targetFormFieldKey"),
|
|
1924
|
+
"determinant_inside_grid": determinant_data.get(
|
|
1925
|
+
"determinantInsideGrid"
|
|
1926
|
+
),
|
|
1927
|
+
"row_determinant_id": determinant_data.get("rowDeterminantId"),
|
|
1928
|
+
"service_id": service_id,
|
|
1929
|
+
"audit_id": audit_id,
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
except BPAClientError as e:
|
|
1933
|
+
# Mark audit as failed
|
|
1934
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
1935
|
+
raise translate_error(e, resource_type="determinant")
|
|
1936
|
+
|
|
1937
|
+
except ToolError:
|
|
1938
|
+
raise
|
|
1939
|
+
except BPAClientError as e:
|
|
1940
|
+
raise translate_error(e, resource_type="service", resource_id=service_id)
|
|
1941
|
+
|
|
1942
|
+
|
|
1943
|
+
# =============================================================================
|
|
1944
|
+
# determinant_delete
|
|
1945
|
+
# =============================================================================
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
def _validate_determinant_delete_params(
|
|
1949
|
+
service_id: str | int, determinant_id: str | int
|
|
1950
|
+
) -> None:
|
|
1951
|
+
"""Validate determinant_delete parameters before processing.
|
|
1952
|
+
|
|
1953
|
+
Args:
|
|
1954
|
+
service_id: ID of the service containing the determinant.
|
|
1955
|
+
determinant_id: ID of the determinant to delete.
|
|
1956
|
+
|
|
1957
|
+
Raises:
|
|
1958
|
+
ToolError: If validation fails.
|
|
1959
|
+
"""
|
|
1960
|
+
if not service_id or (isinstance(service_id, str) and not service_id.strip()):
|
|
1961
|
+
raise ToolError(
|
|
1962
|
+
"'service_id' is required. Use 'service_list' to see available services."
|
|
1963
|
+
)
|
|
1964
|
+
if not determinant_id or (
|
|
1965
|
+
isinstance(determinant_id, str) and not determinant_id.strip()
|
|
1966
|
+
):
|
|
1967
|
+
raise ToolError(
|
|
1968
|
+
"'determinant_id' is required. "
|
|
1969
|
+
"Use 'determinant_list' with service_id to see available determinants."
|
|
1970
|
+
)
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
async def determinant_delete(
|
|
1974
|
+
service_id: str | int, determinant_id: str | int
|
|
1975
|
+
) -> dict[str, Any]:
|
|
1976
|
+
"""Delete a determinant. Audited write operation.
|
|
1977
|
+
|
|
1978
|
+
Args:
|
|
1979
|
+
service_id: Service ID containing the determinant.
|
|
1980
|
+
determinant_id: Determinant ID to delete.
|
|
1981
|
+
|
|
1982
|
+
Returns:
|
|
1983
|
+
dict with deleted (bool), determinant_id, service_id, deleted_determinant,
|
|
1984
|
+
audit_id.
|
|
1985
|
+
"""
|
|
1986
|
+
# Pre-flight validation (no audit record for validation failures)
|
|
1987
|
+
_validate_determinant_delete_params(service_id, determinant_id)
|
|
1988
|
+
|
|
1989
|
+
# Get authenticated user for audit
|
|
1990
|
+
try:
|
|
1991
|
+
user_email = get_current_user_email()
|
|
1992
|
+
except NotAuthenticatedError as e:
|
|
1993
|
+
raise ToolError(str(e))
|
|
1994
|
+
|
|
1995
|
+
# Use single BPAClient connection for all operations
|
|
1996
|
+
try:
|
|
1997
|
+
async with BPAClient() as client:
|
|
1998
|
+
# Capture current state for rollback BEFORE making changes
|
|
1999
|
+
try:
|
|
2000
|
+
previous_state = await client.get(
|
|
2001
|
+
"/determinant/{determinant_id}",
|
|
2002
|
+
path_params={"determinant_id": determinant_id},
|
|
2003
|
+
resource_type="determinant",
|
|
2004
|
+
resource_id=determinant_id,
|
|
2005
|
+
)
|
|
2006
|
+
except BPANotFoundError:
|
|
2007
|
+
raise ToolError(
|
|
2008
|
+
f"Determinant '{determinant_id}' not found. "
|
|
2009
|
+
"Use 'determinant_list' with service_id to see available "
|
|
2010
|
+
"determinants."
|
|
2011
|
+
)
|
|
2012
|
+
|
|
2013
|
+
# Create audit record BEFORE API call (audit-before-write pattern)
|
|
2014
|
+
audit_logger = AuditLogger()
|
|
2015
|
+
audit_id = await audit_logger.record_pending(
|
|
2016
|
+
user_email=user_email,
|
|
2017
|
+
operation_type="delete",
|
|
2018
|
+
object_type="determinant",
|
|
2019
|
+
object_id=str(determinant_id),
|
|
2020
|
+
params={"service_id": str(service_id)},
|
|
2021
|
+
)
|
|
2022
|
+
|
|
2023
|
+
# Save rollback state for undo capability (recreate on rollback)
|
|
2024
|
+
await audit_logger.save_rollback_state(
|
|
2025
|
+
audit_id=audit_id,
|
|
2026
|
+
object_type="determinant",
|
|
2027
|
+
object_id=str(determinant_id),
|
|
2028
|
+
previous_state=previous_state, # Keep full state for recreation
|
|
2029
|
+
)
|
|
2030
|
+
|
|
2031
|
+
try:
|
|
2032
|
+
await client.delete(
|
|
2033
|
+
"/service/{service_id}/determinant/{determinant_id}",
|
|
2034
|
+
path_params={
|
|
2035
|
+
"service_id": service_id,
|
|
2036
|
+
"determinant_id": determinant_id,
|
|
2037
|
+
},
|
|
2038
|
+
resource_type="determinant",
|
|
2039
|
+
resource_id=determinant_id,
|
|
2040
|
+
)
|
|
2041
|
+
|
|
2042
|
+
# Mark audit as success
|
|
2043
|
+
await audit_logger.mark_success(
|
|
2044
|
+
audit_id,
|
|
2045
|
+
result={
|
|
2046
|
+
"deleted": True,
|
|
2047
|
+
"determinant_id": str(determinant_id),
|
|
2048
|
+
"service_id": str(service_id),
|
|
2049
|
+
},
|
|
2050
|
+
)
|
|
2051
|
+
|
|
2052
|
+
return {
|
|
2053
|
+
"deleted": True,
|
|
2054
|
+
"determinant_id": str(determinant_id),
|
|
2055
|
+
"service_id": str(service_id),
|
|
2056
|
+
"deleted_determinant": {
|
|
2057
|
+
"id": previous_state.get("id"),
|
|
2058
|
+
"name": previous_state.get("name"),
|
|
2059
|
+
"type": previous_state.get("type"),
|
|
2060
|
+
"operator": previous_state.get("operator"),
|
|
2061
|
+
"target_form_field_key": previous_state.get(
|
|
2062
|
+
"targetFormFieldKey"
|
|
2063
|
+
),
|
|
2064
|
+
},
|
|
2065
|
+
"audit_id": audit_id,
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
except BPAClientError as e:
|
|
2069
|
+
# Mark audit as failed
|
|
2070
|
+
await audit_logger.mark_failed(audit_id, str(e))
|
|
2071
|
+
raise translate_error(
|
|
2072
|
+
e, resource_type="determinant", resource_id=determinant_id
|
|
2073
|
+
)
|
|
2074
|
+
|
|
2075
|
+
except ToolError:
|
|
2076
|
+
raise
|
|
2077
|
+
except BPAClientError as e:
|
|
2078
|
+
raise translate_error(
|
|
2079
|
+
e, resource_type="determinant", resource_id=determinant_id
|
|
2080
|
+
)
|
|
2081
|
+
|
|
2082
|
+
|
|
2083
|
+
# =============================================================================
|
|
2084
|
+
# determinant_search
|
|
2085
|
+
# =============================================================================
|
|
2086
|
+
|
|
2087
|
+
|
|
2088
|
+
async def determinant_search(
|
|
2089
|
+
service_id: str | int,
|
|
2090
|
+
name_pattern: str | None = None,
|
|
2091
|
+
determinant_type: str | None = None,
|
|
2092
|
+
operator: str | None = None,
|
|
2093
|
+
target_field_key: str | None = None,
|
|
2094
|
+
limit: int = 20,
|
|
2095
|
+
) -> dict[str, Any]:
|
|
2096
|
+
"""Search determinants by criteria to discover reusable conditions.
|
|
2097
|
+
|
|
2098
|
+
This read-only tool helps find existing determinants before creating new ones,
|
|
2099
|
+
promoting reuse and consistency.
|
|
2100
|
+
|
|
2101
|
+
Args:
|
|
2102
|
+
service_id: Service ID to search within.
|
|
2103
|
+
name_pattern: Substring to match in determinant names (case-insensitive).
|
|
2104
|
+
determinant_type: Filter by type (text, boolean, date, radio, numeric,
|
|
2105
|
+
classification, grid).
|
|
2106
|
+
operator: Filter by operator (e.g., EQUAL, NOT_EQUAL, GREATER_THAN).
|
|
2107
|
+
target_field_key: Filter by target form field key.
|
|
2108
|
+
limit: Maximum results to return (default: 20, max: 100).
|
|
2109
|
+
|
|
2110
|
+
Returns:
|
|
2111
|
+
dict with determinants, total, returned, service_id, filters_applied.
|
|
2112
|
+
"""
|
|
2113
|
+
import re
|
|
2114
|
+
|
|
2115
|
+
# Validate limit
|
|
2116
|
+
if limit < 1:
|
|
2117
|
+
limit = 20
|
|
2118
|
+
elif limit > 100:
|
|
2119
|
+
limit = 100
|
|
2120
|
+
|
|
2121
|
+
try:
|
|
2122
|
+
async with BPAClient() as client:
|
|
2123
|
+
# Fetch all determinants for the service
|
|
2124
|
+
determinants_data = await client.get_list(
|
|
2125
|
+
"/service/{service_id}/determinant",
|
|
2126
|
+
path_params={"service_id": service_id},
|
|
2127
|
+
resource_type="determinant",
|
|
2128
|
+
)
|
|
2129
|
+
except BPANotFoundError:
|
|
2130
|
+
raise ToolError(
|
|
2131
|
+
f"Service '{service_id}' not found. "
|
|
2132
|
+
"Use 'service_list' to see available services."
|
|
2133
|
+
)
|
|
2134
|
+
except BPAClientError as e:
|
|
2135
|
+
raise translate_error(e, resource_type="determinant")
|
|
2136
|
+
|
|
2137
|
+
# Apply filters
|
|
2138
|
+
filtered_determinants = []
|
|
2139
|
+
for det in determinants_data:
|
|
2140
|
+
# Filter by name pattern (case-insensitive substring match)
|
|
2141
|
+
if name_pattern:
|
|
2142
|
+
det_name = det.get("name", "") or ""
|
|
2143
|
+
if not re.search(re.escape(name_pattern), det_name, re.IGNORECASE):
|
|
2144
|
+
continue
|
|
2145
|
+
|
|
2146
|
+
# Filter by type
|
|
2147
|
+
if determinant_type:
|
|
2148
|
+
if det.get("type", "").lower() != determinant_type.lower():
|
|
2149
|
+
continue
|
|
2150
|
+
|
|
2151
|
+
# Filter by operator
|
|
2152
|
+
if operator:
|
|
2153
|
+
det_operator = det.get("operator", "") or ""
|
|
2154
|
+
if det_operator.upper() != operator.upper():
|
|
2155
|
+
continue
|
|
2156
|
+
|
|
2157
|
+
# Filter by target field key
|
|
2158
|
+
if target_field_key:
|
|
2159
|
+
det_field = det.get("targetFormFieldKey", "") or ""
|
|
2160
|
+
if det_field != target_field_key:
|
|
2161
|
+
continue
|
|
2162
|
+
|
|
2163
|
+
# Transform to consistent output format with snake_case keys
|
|
2164
|
+
transformed = {
|
|
2165
|
+
"id": det.get("id"),
|
|
2166
|
+
"name": det.get("name"),
|
|
2167
|
+
"type": det.get("type"),
|
|
2168
|
+
"operator": det.get("operator"),
|
|
2169
|
+
"target_field_key": det.get("targetFormFieldKey"),
|
|
2170
|
+
"condition_summary": det.get("conditionSummary"),
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
# Add type-specific value fields
|
|
2174
|
+
if det.get("textValue") is not None:
|
|
2175
|
+
transformed["text_value"] = det.get("textValue")
|
|
2176
|
+
if det.get("selectValue") is not None:
|
|
2177
|
+
transformed["select_value"] = det.get("selectValue")
|
|
2178
|
+
if det.get("numericValue") is not None:
|
|
2179
|
+
transformed["numeric_value"] = det.get("numericValue")
|
|
2180
|
+
if det.get("booleanValue") is not None:
|
|
2181
|
+
transformed["boolean_value"] = det.get("booleanValue")
|
|
2182
|
+
if det.get("dateValue") is not None:
|
|
2183
|
+
transformed["date_value"] = det.get("dateValue")
|
|
2184
|
+
if det.get("isCurrentDate") is not None:
|
|
2185
|
+
transformed["is_current_date"] = det.get("isCurrentDate")
|
|
2186
|
+
|
|
2187
|
+
filtered_determinants.append(transformed)
|
|
2188
|
+
|
|
2189
|
+
# Sort by name for consistent ordering
|
|
2190
|
+
filtered_determinants.sort(key=lambda d: (d.get("name") or "").lower())
|
|
2191
|
+
|
|
2192
|
+
# Apply limit
|
|
2193
|
+
total_matches = len(filtered_determinants)
|
|
2194
|
+
limited_determinants = filtered_determinants[:limit]
|
|
2195
|
+
|
|
2196
|
+
# Build filters_applied for response
|
|
2197
|
+
filters_applied: dict[str, Any] = {}
|
|
2198
|
+
if name_pattern:
|
|
2199
|
+
filters_applied["name_pattern"] = name_pattern
|
|
2200
|
+
if determinant_type:
|
|
2201
|
+
filters_applied["determinant_type"] = determinant_type
|
|
2202
|
+
if operator:
|
|
2203
|
+
filters_applied["operator"] = operator
|
|
2204
|
+
if target_field_key:
|
|
2205
|
+
filters_applied["target_field_key"] = target_field_key
|
|
2206
|
+
|
|
2207
|
+
return {
|
|
2208
|
+
"determinants": limited_determinants,
|
|
2209
|
+
"total": total_matches,
|
|
2210
|
+
"returned": len(limited_determinants),
|
|
2211
|
+
"service_id": service_id,
|
|
2212
|
+
"filters_applied": filters_applied,
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
|
|
2216
|
+
def register_determinant_tools(mcp: Any) -> None:
|
|
2217
|
+
"""Register determinant tools with the MCP server.
|
|
2218
|
+
|
|
2219
|
+
Args:
|
|
2220
|
+
mcp: The FastMCP server instance.
|
|
2221
|
+
"""
|
|
2222
|
+
# Read operations
|
|
2223
|
+
mcp.tool()(determinant_list)
|
|
2224
|
+
mcp.tool()(determinant_get)
|
|
2225
|
+
mcp.tool()(determinant_search)
|
|
2226
|
+
# Write operations (audit-before-write pattern)
|
|
2227
|
+
mcp.tool()(textdeterminant_create)
|
|
2228
|
+
mcp.tool()(textdeterminant_update)
|
|
2229
|
+
mcp.tool()(selectdeterminant_create)
|
|
2230
|
+
mcp.tool()(numericdeterminant_create)
|
|
2231
|
+
mcp.tool()(booleandeterminant_create)
|
|
2232
|
+
mcp.tool()(datedeterminant_create)
|
|
2233
|
+
mcp.tool()(classificationdeterminant_create)
|
|
2234
|
+
mcp.tool()(griddeterminant_create)
|
|
2235
|
+
mcp.tool()(determinant_delete)
|