@josephyan/qingflow-app-builder-mcp 0.2.0-beta.49 → 0.2.0-beta.50
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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/builder_facade/models.py +61 -5
- package/src/qingflow_mcp/builder_facade/service.py +525 -20
- package/src/qingflow_mcp/tools/ai_builder_tools.py +51 -0
- package/src/qingflow_mcp/tools/app_tools.py +36 -0
- package/src/qingflow_mcp/tools/code_block_tools.py +18 -3
- package/src/qingflow_mcp/tools/record_tools.py +735 -90
|
@@ -85,11 +85,20 @@ class FormField:
|
|
|
85
85
|
raw: JSONObject
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
@dataclass(slots=True)
|
|
89
|
+
class SubtableLeafRef:
|
|
90
|
+
field: FormField
|
|
91
|
+
parent_field: FormField
|
|
92
|
+
|
|
93
|
+
|
|
88
94
|
@dataclass(slots=True)
|
|
89
95
|
class FieldIndex:
|
|
90
96
|
by_id: dict[str, FormField]
|
|
91
97
|
by_title: dict[str, list[FormField]]
|
|
92
98
|
by_alias: dict[str, list[FormField]]
|
|
99
|
+
subtable_leaf_by_id: dict[str, list[SubtableLeafRef]]
|
|
100
|
+
subtable_leaf_by_title: dict[str, list[SubtableLeafRef]]
|
|
101
|
+
subtable_leaf_by_alias: dict[str, list[SubtableLeafRef]]
|
|
93
102
|
|
|
94
103
|
|
|
95
104
|
@dataclass(slots=True)
|
|
@@ -351,6 +360,7 @@ class RecordTools(ToolBase):
|
|
|
351
360
|
description=(
|
|
352
361
|
"Write Qingflow records with a SQL-like JSON DSL. "
|
|
353
362
|
"Use record_schema_get first, then choose operation=insert|update|delete. "
|
|
363
|
+
"Insert follows applicant-node write scope; update requires an explicit view selector and is constrained by that view plus real edit permission. "
|
|
354
364
|
"This tool performs internal preflight validation before any write is applied. "
|
|
355
365
|
"This route does not accept raw SQL strings or free-form WHERE clauses."
|
|
356
366
|
)
|
|
@@ -363,6 +373,10 @@ class RecordTools(ToolBase):
|
|
|
363
373
|
record_ids: list[int] | None = None,
|
|
364
374
|
values: list[JSONObject] | None = None,
|
|
365
375
|
set: list[JSONObject] | None = None,
|
|
376
|
+
view_id: str | None = None,
|
|
377
|
+
list_type: int | None = None,
|
|
378
|
+
view_key: str | None = None,
|
|
379
|
+
view_name: str | None = None,
|
|
366
380
|
submit_type: str | int = "submit",
|
|
367
381
|
verify_write: bool = True,
|
|
368
382
|
output_profile: str = "normal",
|
|
@@ -375,6 +389,10 @@ class RecordTools(ToolBase):
|
|
|
375
389
|
record_ids=record_ids or [],
|
|
376
390
|
values=values or [],
|
|
377
391
|
set=set or [],
|
|
392
|
+
view_id=view_id,
|
|
393
|
+
list_type=list_type,
|
|
394
|
+
view_key=view_key,
|
|
395
|
+
view_name=view_name,
|
|
378
396
|
submit_type=submit_type,
|
|
379
397
|
verify_write=verify_write,
|
|
380
398
|
output_profile=output_profile,
|
|
@@ -401,6 +419,7 @@ class RecordTools(ToolBase):
|
|
|
401
419
|
def runner(session_profile, context):
|
|
402
420
|
warnings: list[JSONObject] = []
|
|
403
421
|
resolved_view: AccessibleViewRoute | None = None
|
|
422
|
+
browse_scope: JSONObject | None = None
|
|
404
423
|
if normalized_schema_mode == "applicant":
|
|
405
424
|
if any(item is not None for item in (view_id, list_type, view_key, view_name)):
|
|
406
425
|
raise_tool_error(
|
|
@@ -430,11 +449,19 @@ class RecordTools(ToolBase):
|
|
|
430
449
|
),
|
|
431
450
|
}
|
|
432
451
|
)
|
|
452
|
+
browse_scope = self._build_browse_write_scope(
|
|
453
|
+
profile,
|
|
454
|
+
context,
|
|
455
|
+
app_key,
|
|
456
|
+
resolved_view,
|
|
457
|
+
force_refresh=False,
|
|
458
|
+
)
|
|
433
459
|
index = (
|
|
434
|
-
self.
|
|
460
|
+
self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=False)
|
|
435
461
|
if normalized_schema_mode == "applicant"
|
|
436
|
-
else
|
|
462
|
+
else cast(FieldIndex, browse_scope["index"])
|
|
437
463
|
)
|
|
464
|
+
browse_writable_field_ids = cast(set[int], browse_scope["writable_field_ids"]) if isinstance(browse_scope, dict) else set()
|
|
438
465
|
fields = [
|
|
439
466
|
self._schema_field_payload(
|
|
440
467
|
profile,
|
|
@@ -443,6 +470,7 @@ class RecordTools(ToolBase):
|
|
|
443
470
|
workflow_node_id=None,
|
|
444
471
|
ws_id=session_profile.selected_ws_id,
|
|
445
472
|
schema_mode=normalized_schema_mode,
|
|
473
|
+
browse_writable=field.que_id in browse_writable_field_ids if normalized_schema_mode == "browse" else None,
|
|
446
474
|
)
|
|
447
475
|
for field in self._schema_fields_for_mode(
|
|
448
476
|
profile,
|
|
@@ -965,14 +993,18 @@ class RecordTools(ToolBase):
|
|
|
965
993
|
*,
|
|
966
994
|
profile: str,
|
|
967
995
|
app_key: str,
|
|
968
|
-
operation: str,
|
|
969
|
-
record_id: int | None,
|
|
970
|
-
record_ids: list[int],
|
|
971
|
-
values: list[JSONObject],
|
|
972
|
-
set: list[JSONObject],
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
996
|
+
operation: str = "insert",
|
|
997
|
+
record_id: int | None = None,
|
|
998
|
+
record_ids: list[int] | None = None,
|
|
999
|
+
values: list[JSONObject] | None = None,
|
|
1000
|
+
set: list[JSONObject] | None = None,
|
|
1001
|
+
view_id: str | None = None,
|
|
1002
|
+
list_type: int | None = None,
|
|
1003
|
+
view_key: str | None = None,
|
|
1004
|
+
view_name: str | None = None,
|
|
1005
|
+
submit_type: str | int = "submit",
|
|
1006
|
+
verify_write: bool = True,
|
|
1007
|
+
output_profile: str = "normal",
|
|
976
1008
|
mode: str | None = None,
|
|
977
1009
|
) -> JSONObject:
|
|
978
1010
|
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
@@ -987,15 +1019,24 @@ class RecordTools(ToolBase):
|
|
|
987
1019
|
)
|
|
988
1020
|
if not app_key:
|
|
989
1021
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
990
|
-
|
|
1022
|
+
normalized_values = list(values or [])
|
|
1023
|
+
normalized_set = list(set or [])
|
|
1024
|
+
normalized_record_ids = [item for item in (record_ids or []) if isinstance(item, int) and item > 0]
|
|
991
1025
|
submit_type_value = self._normalize_record_write_submit_type(submit_type)
|
|
1026
|
+
uses_view_scope = any(item is not None for item in (view_id, list_type, view_key, view_name))
|
|
992
1027
|
|
|
993
1028
|
if normalized_operation == "insert":
|
|
1029
|
+
if uses_view_scope:
|
|
1030
|
+
raise_tool_error(
|
|
1031
|
+
QingflowApiError.config_error(
|
|
1032
|
+
"insert strictly follows applicant-node write scope and does not accept view selectors; use record_schema_get(schema_mode='applicant') first"
|
|
1033
|
+
)
|
|
1034
|
+
)
|
|
994
1035
|
if record_id is not None or normalized_record_ids:
|
|
995
1036
|
raise_tool_error(QingflowApiError.config_error("insert must not include record_id or record_ids"))
|
|
996
|
-
if
|
|
1037
|
+
if normalized_set:
|
|
997
1038
|
raise_tool_error(QingflowApiError.config_error("insert must use values, not set"))
|
|
998
|
-
normalized_answers = self._normalize_record_write_clauses(
|
|
1039
|
+
normalized_answers = self._normalize_record_write_clauses(normalized_values, location="values")
|
|
999
1040
|
raw_preflight = self._preflight_record_write(
|
|
1000
1041
|
profile=profile,
|
|
1001
1042
|
operation="create",
|
|
@@ -1004,6 +1045,10 @@ class RecordTools(ToolBase):
|
|
|
1004
1045
|
answers=normalized_answers,
|
|
1005
1046
|
fields={},
|
|
1006
1047
|
force_refresh_form=False,
|
|
1048
|
+
view_id=view_id,
|
|
1049
|
+
list_type=list_type,
|
|
1050
|
+
view_key=view_key,
|
|
1051
|
+
view_name=view_name,
|
|
1007
1052
|
)
|
|
1008
1053
|
preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
1009
1054
|
normalized_payload: JSONObject = self._record_write_normalized_payload(
|
|
@@ -1012,6 +1057,7 @@ class RecordTools(ToolBase):
|
|
|
1012
1057
|
record_ids=[],
|
|
1013
1058
|
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
1014
1059
|
submit_type=submit_type_value,
|
|
1060
|
+
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
1015
1061
|
)
|
|
1016
1062
|
if preflight_data.get("blockers"):
|
|
1017
1063
|
return self._record_write_blocked_response(
|
|
@@ -1022,15 +1068,25 @@ class RecordTools(ToolBase):
|
|
|
1022
1068
|
human_review=False,
|
|
1023
1069
|
target_resource={"type": "record", "app_key": app_key, "record_id": None, "record_ids": []},
|
|
1024
1070
|
)
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1071
|
+
try:
|
|
1072
|
+
raw_apply = self.record_create(
|
|
1073
|
+
profile=profile,
|
|
1074
|
+
app_key=app_key,
|
|
1075
|
+
answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
1076
|
+
fields={},
|
|
1077
|
+
submit_type=submit_type_value,
|
|
1078
|
+
verify_write=verify_write,
|
|
1079
|
+
force_refresh_form=False,
|
|
1080
|
+
)
|
|
1081
|
+
except QingflowApiError as exc:
|
|
1082
|
+
self._raise_record_write_permission_error(
|
|
1083
|
+
exc,
|
|
1084
|
+
operation="insert",
|
|
1085
|
+
app_key=app_key,
|
|
1086
|
+
record_id=None,
|
|
1087
|
+
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
1088
|
+
)
|
|
1089
|
+
raise
|
|
1034
1090
|
return self._record_write_apply_response(
|
|
1035
1091
|
raw_apply,
|
|
1036
1092
|
operation="insert",
|
|
@@ -1041,13 +1097,19 @@ class RecordTools(ToolBase):
|
|
|
1041
1097
|
)
|
|
1042
1098
|
|
|
1043
1099
|
if normalized_operation == "update":
|
|
1100
|
+
if not uses_view_scope:
|
|
1101
|
+
raise_tool_error(
|
|
1102
|
+
QingflowApiError.config_error(
|
|
1103
|
+
"update requires view_id/view_key/view_name/list_type; call app_get first to inspect accessible_views and choose a write scope"
|
|
1104
|
+
)
|
|
1105
|
+
)
|
|
1044
1106
|
if record_id is None or record_id <= 0:
|
|
1045
1107
|
raise_tool_error(QingflowApiError.config_error("update requires record_id"))
|
|
1046
1108
|
if normalized_record_ids:
|
|
1047
1109
|
raise_tool_error(QingflowApiError.config_error("update does not support record_ids"))
|
|
1048
|
-
if
|
|
1110
|
+
if normalized_values:
|
|
1049
1111
|
raise_tool_error(QingflowApiError.config_error("update must use set, not values"))
|
|
1050
|
-
normalized_answers = self._normalize_record_write_clauses(
|
|
1112
|
+
normalized_answers = self._normalize_record_write_clauses(normalized_set, location="set")
|
|
1051
1113
|
raw_preflight = self._preflight_record_write(
|
|
1052
1114
|
profile=profile,
|
|
1053
1115
|
operation="update",
|
|
@@ -1056,6 +1118,10 @@ class RecordTools(ToolBase):
|
|
|
1056
1118
|
answers=normalized_answers,
|
|
1057
1119
|
fields={},
|
|
1058
1120
|
force_refresh_form=False,
|
|
1121
|
+
view_id=view_id,
|
|
1122
|
+
list_type=list_type,
|
|
1123
|
+
view_key=view_key,
|
|
1124
|
+
view_name=view_name,
|
|
1059
1125
|
)
|
|
1060
1126
|
preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
1061
1127
|
normalized_payload = self._record_write_normalized_payload(
|
|
@@ -1064,6 +1130,7 @@ class RecordTools(ToolBase):
|
|
|
1064
1130
|
record_ids=[],
|
|
1065
1131
|
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
1066
1132
|
submit_type=submit_type_value,
|
|
1133
|
+
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
1067
1134
|
)
|
|
1068
1135
|
if preflight_data.get("blockers"):
|
|
1069
1136
|
return self._record_write_blocked_response(
|
|
@@ -1074,16 +1141,26 @@ class RecordTools(ToolBase):
|
|
|
1074
1141
|
human_review=True,
|
|
1075
1142
|
target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
|
|
1076
1143
|
)
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1144
|
+
try:
|
|
1145
|
+
raw_apply = self.record_update(
|
|
1146
|
+
profile=profile,
|
|
1147
|
+
app_key=app_key,
|
|
1148
|
+
apply_id=record_id,
|
|
1149
|
+
answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
1150
|
+
fields={},
|
|
1151
|
+
role=1,
|
|
1152
|
+
verify_write=verify_write,
|
|
1153
|
+
force_refresh_form=False,
|
|
1154
|
+
)
|
|
1155
|
+
except QingflowApiError as exc:
|
|
1156
|
+
self._raise_record_write_permission_error(
|
|
1157
|
+
exc,
|
|
1158
|
+
operation="update",
|
|
1159
|
+
app_key=app_key,
|
|
1160
|
+
record_id=record_id,
|
|
1161
|
+
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
1162
|
+
)
|
|
1163
|
+
raise
|
|
1087
1164
|
return self._record_write_apply_response(
|
|
1088
1165
|
raw_apply,
|
|
1089
1166
|
operation="update",
|
|
@@ -1093,7 +1170,13 @@ class RecordTools(ToolBase):
|
|
|
1093
1170
|
preflight=raw_preflight,
|
|
1094
1171
|
)
|
|
1095
1172
|
|
|
1096
|
-
if
|
|
1173
|
+
if uses_view_scope:
|
|
1174
|
+
raise_tool_error(
|
|
1175
|
+
QingflowApiError.config_error(
|
|
1176
|
+
"delete does not accept view selectors yet; resolve target record_ids from the selected view first, then call delete by record_id/record_ids"
|
|
1177
|
+
)
|
|
1178
|
+
)
|
|
1179
|
+
if normalized_values or normalized_set:
|
|
1097
1180
|
raise_tool_error(QingflowApiError.config_error("delete must not include values or set"))
|
|
1098
1181
|
delete_ids = normalized_record_ids or ([record_id] if record_id is not None and record_id > 0 else [])
|
|
1099
1182
|
if not delete_ids:
|
|
@@ -1124,10 +1207,15 @@ class RecordTools(ToolBase):
|
|
|
1124
1207
|
workflow_node_id: int | None,
|
|
1125
1208
|
ws_id: int | None,
|
|
1126
1209
|
schema_mode: str = "applicant",
|
|
1210
|
+
browse_writable: bool | None = None,
|
|
1127
1211
|
) -> JSONObject: # type: ignore[no-untyped-def]
|
|
1128
1212
|
write_hints = self._schema_write_hints(field)
|
|
1129
|
-
|
|
1130
|
-
|
|
1213
|
+
if schema_mode == "applicant":
|
|
1214
|
+
writable = write_hints["writable"]
|
|
1215
|
+
supported_write_ops = write_hints["supported_write_ops"]
|
|
1216
|
+
else:
|
|
1217
|
+
writable = bool(browse_writable)
|
|
1218
|
+
supported_write_ops = ["update"] if writable else []
|
|
1131
1219
|
payload = {
|
|
1132
1220
|
"field_id": field.que_id,
|
|
1133
1221
|
"title": field.que_title,
|
|
@@ -2426,10 +2514,27 @@ class RecordTools(ToolBase):
|
|
|
2426
2514
|
answers: list[JSONObject] | None = None,
|
|
2427
2515
|
fields: JSONObject | None = None,
|
|
2428
2516
|
force_refresh_form: bool = False,
|
|
2517
|
+
view_id: str | None = None,
|
|
2518
|
+
list_type: int | None = None,
|
|
2519
|
+
view_key: str | None = None,
|
|
2520
|
+
view_name: str | None = None,
|
|
2429
2521
|
) -> JSONObject:
|
|
2430
2522
|
if not app_key:
|
|
2431
2523
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
2432
2524
|
inferred_operation = operation if operation in {"create", "update"} else ("update" if apply_id else "create")
|
|
2525
|
+
uses_view_scope = any(item is not None for item in (view_id, list_type, view_key, view_name))
|
|
2526
|
+
if inferred_operation == "create" and uses_view_scope:
|
|
2527
|
+
raise_tool_error(
|
|
2528
|
+
QingflowApiError.config_error(
|
|
2529
|
+
"insert/create strictly follows applicant-node write scope and does not accept view selectors; use record_schema_get(schema_mode='applicant') first"
|
|
2530
|
+
)
|
|
2531
|
+
)
|
|
2532
|
+
if inferred_operation == "update" and not uses_view_scope:
|
|
2533
|
+
raise_tool_error(
|
|
2534
|
+
QingflowApiError.config_error(
|
|
2535
|
+
"update requires view_id/view_key/view_name/list_type; call app_get first to inspect accessible_views and choose a write scope"
|
|
2536
|
+
)
|
|
2537
|
+
)
|
|
2433
2538
|
|
|
2434
2539
|
return self._preflight_record_write(
|
|
2435
2540
|
profile=profile,
|
|
@@ -2439,6 +2544,10 @@ class RecordTools(ToolBase):
|
|
|
2439
2544
|
answers=answers or [],
|
|
2440
2545
|
fields=fields or {},
|
|
2441
2546
|
force_refresh_form=force_refresh_form,
|
|
2547
|
+
view_id=view_id,
|
|
2548
|
+
list_type=list_type,
|
|
2549
|
+
view_key=view_key,
|
|
2550
|
+
view_name=view_name,
|
|
2442
2551
|
)
|
|
2443
2552
|
|
|
2444
2553
|
def _preflight_record_write(
|
|
@@ -2451,6 +2560,10 @@ class RecordTools(ToolBase):
|
|
|
2451
2560
|
answers: list[JSONObject],
|
|
2452
2561
|
fields: JSONObject,
|
|
2453
2562
|
force_refresh_form: bool,
|
|
2563
|
+
view_id: str | None,
|
|
2564
|
+
list_type: int | None,
|
|
2565
|
+
view_key: str | None,
|
|
2566
|
+
view_name: str | None,
|
|
2454
2567
|
) -> JSONObject:
|
|
2455
2568
|
if not app_key:
|
|
2456
2569
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
@@ -2465,6 +2578,10 @@ class RecordTools(ToolBase):
|
|
|
2465
2578
|
answers=answers,
|
|
2466
2579
|
fields=fields,
|
|
2467
2580
|
force_refresh_form=force_refresh_form,
|
|
2581
|
+
view_id=view_id,
|
|
2582
|
+
list_type=list_type,
|
|
2583
|
+
view_key=view_key,
|
|
2584
|
+
view_name=view_name,
|
|
2468
2585
|
)
|
|
2469
2586
|
return {
|
|
2470
2587
|
"profile": profile,
|
|
@@ -2487,25 +2604,75 @@ class RecordTools(ToolBase):
|
|
|
2487
2604
|
answers: list[JSONObject],
|
|
2488
2605
|
fields: JSONObject,
|
|
2489
2606
|
force_refresh_form: bool,
|
|
2607
|
+
view_id: str | None,
|
|
2608
|
+
list_type: int | None,
|
|
2609
|
+
view_key: str | None,
|
|
2610
|
+
view_name: str | None,
|
|
2490
2611
|
) -> JSONObject:
|
|
2491
2612
|
schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
|
|
2492
|
-
index =
|
|
2613
|
+
index = _build_applicant_top_level_field_index(schema)
|
|
2493
2614
|
normalized_fields = fields or {}
|
|
2494
2615
|
normalized_answers_input = answers or []
|
|
2495
|
-
|
|
2616
|
+
resolved_view: AccessibleViewRoute | None = None
|
|
2617
|
+
selector_index = index
|
|
2618
|
+
browse_writable_field_ids: set[int] = set()
|
|
2619
|
+
visible_question_ids: set[int] = set()
|
|
2620
|
+
if any(item is not None for item in (view_id, list_type, view_key, view_name)):
|
|
2621
|
+
resolved_view, compatibility_warnings = self._resolve_accessible_view_route(
|
|
2622
|
+
profile,
|
|
2623
|
+
context,
|
|
2624
|
+
app_key,
|
|
2625
|
+
view_id=view_id,
|
|
2626
|
+
list_type=list_type,
|
|
2627
|
+
view_key=view_key,
|
|
2628
|
+
view_name=view_name,
|
|
2629
|
+
allow_default=False,
|
|
2630
|
+
)
|
|
2631
|
+
browse_scope = self._build_browse_write_scope(
|
|
2632
|
+
profile,
|
|
2633
|
+
context,
|
|
2634
|
+
app_key,
|
|
2635
|
+
resolved_view,
|
|
2636
|
+
force_refresh=force_refresh_form,
|
|
2637
|
+
)
|
|
2638
|
+
selector_index = cast(FieldIndex, browse_scope["index"])
|
|
2639
|
+
browse_writable_field_ids = cast(set[int], browse_scope["writable_field_ids"])
|
|
2640
|
+
visible_question_ids = cast(set[int], browse_scope["visible_question_ids"])
|
|
2641
|
+
else:
|
|
2642
|
+
compatibility_warnings = []
|
|
2643
|
+
resolved_fields = self._collect_write_plan_field_refs(fields=normalized_fields, answers=normalized_answers_input, index=selector_index)
|
|
2496
2644
|
support_matrix = _summarize_write_support(resolved_fields)
|
|
2497
2645
|
invalid_fields: list[JSONObject] = []
|
|
2498
2646
|
normalized_answers: list[JSONObject] = []
|
|
2499
2647
|
validation_warnings = [
|
|
2500
2648
|
"record_write performs static preflight from form metadata before apply; runtime visibility and dynamic linkage can still reject writes."
|
|
2501
2649
|
]
|
|
2650
|
+
if resolved_view is not None:
|
|
2651
|
+
validation_warnings.append(
|
|
2652
|
+
"view-scoped writes only narrow selectable fields; they do not grant record edit permission."
|
|
2653
|
+
)
|
|
2654
|
+
validation_warnings.extend(
|
|
2655
|
+
str(item.get("message"))
|
|
2656
|
+
for item in compatibility_warnings
|
|
2657
|
+
if isinstance(item, dict) and item.get("message")
|
|
2658
|
+
)
|
|
2659
|
+
validation_warnings.extend(
|
|
2660
|
+
str(item.get("message"))
|
|
2661
|
+
for item in _view_filter_trust_warnings(resolved_view)
|
|
2662
|
+
if isinstance(item, dict) and item.get("message")
|
|
2663
|
+
)
|
|
2502
2664
|
try:
|
|
2665
|
+
scoped_answers_input, scoped_fields = self._canonicalize_write_scope_selectors(
|
|
2666
|
+
answers=normalized_answers_input,
|
|
2667
|
+
fields=normalized_fields,
|
|
2668
|
+
selector_index=selector_index,
|
|
2669
|
+
)
|
|
2503
2670
|
normalized_answers = self._resolve_answers(
|
|
2504
2671
|
profile,
|
|
2505
2672
|
context,
|
|
2506
2673
|
app_key,
|
|
2507
|
-
answers=
|
|
2508
|
-
fields=
|
|
2674
|
+
answers=scoped_answers_input,
|
|
2675
|
+
fields=scoped_fields,
|
|
2509
2676
|
force_refresh_form=force_refresh_form,
|
|
2510
2677
|
)
|
|
2511
2678
|
except RecordInputError as error:
|
|
@@ -2538,10 +2705,35 @@ class RecordTools(ToolBase):
|
|
|
2538
2705
|
"system": entry.get("system"),
|
|
2539
2706
|
"source": entry.get("source"),
|
|
2540
2707
|
"requested": entry.get("requested"),
|
|
2708
|
+
"reason_code": (
|
|
2709
|
+
"system"
|
|
2710
|
+
if bool(entry.get("system"))
|
|
2711
|
+
else "view_readonly"
|
|
2712
|
+
if resolved_view is not None
|
|
2713
|
+
else "readonly"
|
|
2714
|
+
),
|
|
2541
2715
|
}
|
|
2542
2716
|
for entry in resolved_fields
|
|
2543
|
-
if bool(entry.get("resolved"))
|
|
2717
|
+
if bool(entry.get("resolved"))
|
|
2718
|
+
and (
|
|
2719
|
+
bool(entry.get("system"))
|
|
2720
|
+
or (
|
|
2721
|
+
resolved_view is not None
|
|
2722
|
+
and _coerce_count(entry.get("que_id")) is not None
|
|
2723
|
+
and _coerce_count(entry.get("que_id")) not in browse_writable_field_ids
|
|
2724
|
+
)
|
|
2725
|
+
or (resolved_view is None and bool(entry.get("readonly")))
|
|
2726
|
+
)
|
|
2544
2727
|
]
|
|
2728
|
+
if resolved_view is not None and visible_question_ids:
|
|
2729
|
+
invalid_fields.extend(
|
|
2730
|
+
self._validate_view_scoped_subtable_answers(
|
|
2731
|
+
normalized_answers=normalized_answers,
|
|
2732
|
+
full_index=index,
|
|
2733
|
+
selector_index=selector_index,
|
|
2734
|
+
visible_question_ids=visible_question_ids,
|
|
2735
|
+
)
|
|
2736
|
+
)
|
|
2545
2737
|
provided_field_ids = {
|
|
2546
2738
|
str(answer.get("queId"))
|
|
2547
2739
|
for answer in validation_answers
|
|
@@ -2606,7 +2798,11 @@ class RecordTools(ToolBase):
|
|
|
2606
2798
|
if missing_required_fields:
|
|
2607
2799
|
blockers.append("required fields are missing")
|
|
2608
2800
|
if readonly_or_system_fields:
|
|
2609
|
-
blockers.append(
|
|
2801
|
+
blockers.append(
|
|
2802
|
+
"payload writes fields that are not writable in the selected view"
|
|
2803
|
+
if resolved_view is not None
|
|
2804
|
+
else "payload writes readonly or system-managed fields"
|
|
2805
|
+
)
|
|
2610
2806
|
actions = ["Use record_schema_get when field titles or field ids are ambiguous."]
|
|
2611
2807
|
if support_matrix["restricted"]:
|
|
2612
2808
|
actions.append("Review write_format.required_presteps for restricted fields before submit.")
|
|
@@ -2615,7 +2811,11 @@ class RecordTools(ToolBase):
|
|
|
2615
2811
|
if missing_required_fields:
|
|
2616
2812
|
actions.append("Fill missing required fields before applying the write.")
|
|
2617
2813
|
if readonly_or_system_fields:
|
|
2618
|
-
actions.append(
|
|
2814
|
+
actions.append(
|
|
2815
|
+
"Remove fields that the selected view does not allow for update, or choose a view that exposes those fields as writable."
|
|
2816
|
+
if resolved_view is not None
|
|
2817
|
+
else "Remove readonly/system fields from payload before applying the write."
|
|
2818
|
+
)
|
|
2619
2819
|
if question_relations:
|
|
2620
2820
|
actions.append("Treat static preflight as conservative only because linked fields can still appear at runtime.")
|
|
2621
2821
|
return {
|
|
@@ -2632,6 +2832,9 @@ class RecordTools(ToolBase):
|
|
|
2632
2832
|
"question_relations": question_relations,
|
|
2633
2833
|
"option_links": option_links,
|
|
2634
2834
|
},
|
|
2835
|
+
"selection": {
|
|
2836
|
+
"view": _accessible_view_payload(resolved_view),
|
|
2837
|
+
},
|
|
2635
2838
|
"ready_to_submit": validation["valid"],
|
|
2636
2839
|
"blockers": blockers,
|
|
2637
2840
|
"recommended_next_actions": actions,
|
|
@@ -2653,6 +2856,49 @@ class RecordTools(ToolBase):
|
|
|
2653
2856
|
answers = record.get("answers") if isinstance(record, dict) else None
|
|
2654
2857
|
return [item for item in answers if isinstance(item, dict)] if isinstance(answers, list) else []
|
|
2655
2858
|
|
|
2859
|
+
def _validate_view_scoped_subtable_answers(
|
|
2860
|
+
self,
|
|
2861
|
+
*,
|
|
2862
|
+
normalized_answers: list[JSONObject],
|
|
2863
|
+
full_index: FieldIndex,
|
|
2864
|
+
selector_index: FieldIndex,
|
|
2865
|
+
visible_question_ids: set[int],
|
|
2866
|
+
) -> list[JSONObject]:
|
|
2867
|
+
invalid_fields: list[JSONObject] = []
|
|
2868
|
+
if not visible_question_ids:
|
|
2869
|
+
return invalid_fields
|
|
2870
|
+
for answer in normalized_answers:
|
|
2871
|
+
table_values = answer.get("tableValues") if isinstance(answer.get("tableValues"), list) else []
|
|
2872
|
+
if not table_values:
|
|
2873
|
+
continue
|
|
2874
|
+
parent_que_id = _coerce_count(answer.get("queId"))
|
|
2875
|
+
if parent_que_id is None:
|
|
2876
|
+
continue
|
|
2877
|
+
parent_field = full_index.by_id.get(str(parent_que_id)) or selector_index.by_id.get(str(parent_que_id))
|
|
2878
|
+
if parent_field is None:
|
|
2879
|
+
continue
|
|
2880
|
+
subtable_index = self._subtable_field_index_optional(parent_field)
|
|
2881
|
+
for row_ordinal, row in enumerate(table_values, start=1):
|
|
2882
|
+
row_cells = [item for item in row if isinstance(item, dict)] if isinstance(row, list) else []
|
|
2883
|
+
for cell in row_cells:
|
|
2884
|
+
que_id = _coerce_count(cell.get("queId"))
|
|
2885
|
+
if que_id is None or que_id in visible_question_ids:
|
|
2886
|
+
continue
|
|
2887
|
+
subfield = subtable_index.by_id.get(str(que_id)) if subtable_index is not None else None
|
|
2888
|
+
invalid_fields.append(
|
|
2889
|
+
{
|
|
2890
|
+
"location": f"{parent_field.que_title}[{row_ordinal}].{subfield.que_title if subfield is not None else que_id}",
|
|
2891
|
+
"message": (
|
|
2892
|
+
f"subtable field '{subfield.que_title if subfield is not None else que_id}' is not visible in the selected view"
|
|
2893
|
+
),
|
|
2894
|
+
"error_code": "VIEW_SCOPE_FIELD_HIDDEN",
|
|
2895
|
+
"field": _field_ref_payload(subfield) if subfield is not None else {"que_id": que_id},
|
|
2896
|
+
"expected_format": _write_format_for_field(parent_field),
|
|
2897
|
+
"received_value": cell.get("values"),
|
|
2898
|
+
}
|
|
2899
|
+
)
|
|
2900
|
+
return invalid_fields
|
|
2901
|
+
|
|
2656
2902
|
def _merge_record_answers(
|
|
2657
2903
|
self,
|
|
2658
2904
|
existing_answers: list[JSONObject],
|
|
@@ -3387,6 +3633,18 @@ class RecordTools(ToolBase):
|
|
|
3387
3633
|
def _get_field_index(self, profile: str, context, app_key: str, *, force_refresh: bool) -> FieldIndex: # type: ignore[no-untyped-def]
|
|
3388
3634
|
return _build_field_index(self._get_form_schema(profile, context, app_key, force_refresh=force_refresh))
|
|
3389
3635
|
|
|
3636
|
+
def _get_applicant_top_level_field_index(
|
|
3637
|
+
self,
|
|
3638
|
+
profile: str,
|
|
3639
|
+
context, # type: ignore[no-untyped-def]
|
|
3640
|
+
app_key: str,
|
|
3641
|
+
*,
|
|
3642
|
+
force_refresh: bool,
|
|
3643
|
+
) -> FieldIndex:
|
|
3644
|
+
return _build_applicant_top_level_field_index(
|
|
3645
|
+
self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
|
|
3646
|
+
)
|
|
3647
|
+
|
|
3390
3648
|
def _resolve_applicant_node(self, profile: str, context, app_key: str, *, force_refresh: bool) -> WorkflowNodeRef: # type: ignore[no-untyped-def]
|
|
3391
3649
|
cache_key = (profile, app_key)
|
|
3392
3650
|
if not force_refresh and cache_key in self._applicant_node_cache:
|
|
@@ -3512,17 +3770,112 @@ class RecordTools(ToolBase):
|
|
|
3512
3770
|
*,
|
|
3513
3771
|
force_refresh: bool,
|
|
3514
3772
|
) -> FieldIndex:
|
|
3773
|
+
return self._build_browse_write_scope(
|
|
3774
|
+
profile,
|
|
3775
|
+
context,
|
|
3776
|
+
app_key,
|
|
3777
|
+
resolved_view,
|
|
3778
|
+
force_refresh=force_refresh,
|
|
3779
|
+
)["index"]
|
|
3780
|
+
|
|
3781
|
+
def _build_browse_write_scope(
|
|
3782
|
+
self,
|
|
3783
|
+
profile: str,
|
|
3784
|
+
context, # type: ignore[no-untyped-def]
|
|
3785
|
+
app_key: str,
|
|
3786
|
+
resolved_view: AccessibleViewRoute | None,
|
|
3787
|
+
*,
|
|
3788
|
+
force_refresh: bool,
|
|
3789
|
+
) -> JSONObject:
|
|
3790
|
+
applicant_index: FieldIndex | None
|
|
3791
|
+
applicant_writable_field_ids: set[int]
|
|
3792
|
+
try:
|
|
3793
|
+
applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
|
|
3794
|
+
except QingflowApiError as exc:
|
|
3795
|
+
if exc.backend_code != 40002:
|
|
3796
|
+
raise
|
|
3797
|
+
applicant_index = None
|
|
3798
|
+
applicant_writable_field_ids = set()
|
|
3799
|
+
else:
|
|
3800
|
+
applicant_writable_field_ids = {
|
|
3801
|
+
field.que_id
|
|
3802
|
+
for field in applicant_index.by_id.values()
|
|
3803
|
+
if bool(self._schema_write_hints(field)["writable"])
|
|
3804
|
+
}
|
|
3515
3805
|
if resolved_view is not None and resolved_view.kind == "custom" and resolved_view.view_selection is not None:
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3806
|
+
schema = self._get_view_form_schema(profile, context, resolved_view.view_selection.view_key, force_refresh=force_refresh)
|
|
3807
|
+
index = _build_top_level_field_index(schema)
|
|
3808
|
+
visible_question_ids = self._get_view_question_ids(profile, context, resolved_view.view_selection.view_key)
|
|
3809
|
+
if not visible_question_ids:
|
|
3810
|
+
visible_question_ids = _question_ids_from_schema(schema)
|
|
3811
|
+
elif resolved_view is not None and resolved_view.kind == "system" and resolved_view.list_type is not None:
|
|
3812
|
+
schema = self._get_system_browse_schema(
|
|
3519
3813
|
profile,
|
|
3520
3814
|
context,
|
|
3521
3815
|
app_key,
|
|
3522
3816
|
list_type=resolved_view.list_type,
|
|
3523
3817
|
force_refresh=force_refresh,
|
|
3524
3818
|
)
|
|
3525
|
-
|
|
3819
|
+
index = _build_top_level_field_index(schema)
|
|
3820
|
+
visible_question_ids = _question_ids_from_schema(schema)
|
|
3821
|
+
else:
|
|
3822
|
+
index = applicant_index or _build_top_level_field_index(
|
|
3823
|
+
self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
|
|
3824
|
+
)
|
|
3825
|
+
visible_question_ids = {field.que_id for field in index.by_id.values()}
|
|
3826
|
+
return {
|
|
3827
|
+
"index": index,
|
|
3828
|
+
"writable_field_ids": (
|
|
3829
|
+
set(applicant_writable_field_ids)
|
|
3830
|
+
if applicant_index is not None
|
|
3831
|
+
else {
|
|
3832
|
+
field.que_id
|
|
3833
|
+
for field in index.by_id.values()
|
|
3834
|
+
if bool(self._schema_write_hints(field)["writable"])
|
|
3835
|
+
}
|
|
3836
|
+
),
|
|
3837
|
+
"visible_question_ids": set(visible_question_ids),
|
|
3838
|
+
}
|
|
3839
|
+
|
|
3840
|
+
if applicant_index is None:
|
|
3841
|
+
return {
|
|
3842
|
+
"index": index,
|
|
3843
|
+
"writable_field_ids": {
|
|
3844
|
+
field.que_id
|
|
3845
|
+
for field in index.by_id.values()
|
|
3846
|
+
if bool(self._schema_write_hints(field)["writable"])
|
|
3847
|
+
},
|
|
3848
|
+
"visible_question_ids": visible_question_ids,
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
augmented_fields = [
|
|
3852
|
+
_clone_form_field(applicant_index.by_id.get(str(field.que_id)) or field)
|
|
3853
|
+
for field in index.by_id.values()
|
|
3854
|
+
]
|
|
3855
|
+
augmented_field_ids = {field.que_id for field in augmented_fields}
|
|
3856
|
+
writable_field_ids = {
|
|
3857
|
+
field_id
|
|
3858
|
+
for field_id in visible_question_ids
|
|
3859
|
+
if field_id in applicant_writable_field_ids
|
|
3860
|
+
}
|
|
3861
|
+
for field in applicant_index.by_id.values():
|
|
3862
|
+
descendant_ids = _subtable_descendant_ids(field)
|
|
3863
|
+
field_visible = field.que_id in visible_question_ids
|
|
3864
|
+
descendant_visible = bool(descendant_ids and (descendant_ids & visible_question_ids))
|
|
3865
|
+
if not field_visible and not descendant_visible:
|
|
3866
|
+
continue
|
|
3867
|
+
if field.que_id not in augmented_field_ids:
|
|
3868
|
+
augmented_fields.append(_clone_form_field(field))
|
|
3869
|
+
augmented_field_ids.add(field.que_id)
|
|
3870
|
+
if descendant_visible:
|
|
3871
|
+
visible_question_ids.add(field.que_id)
|
|
3872
|
+
if field.que_id in applicant_writable_field_ids and (field_visible or descendant_visible):
|
|
3873
|
+
writable_field_ids.add(field.que_id)
|
|
3874
|
+
return {
|
|
3875
|
+
"index": _build_top_level_field_index({"formQues": [[_form_field_to_question(field) for field in augmented_fields]]}),
|
|
3876
|
+
"writable_field_ids": writable_field_ids,
|
|
3877
|
+
"visible_question_ids": visible_question_ids,
|
|
3878
|
+
}
|
|
3526
3879
|
|
|
3527
3880
|
def _get_view_question_ids(self, profile: str, context, view_key: str) -> set[int]: # type: ignore[no-untyped-def]
|
|
3528
3881
|
try:
|
|
@@ -3552,14 +3905,7 @@ class RecordTools(ToolBase):
|
|
|
3552
3905
|
schema_mode: str,
|
|
3553
3906
|
resolved_view: AccessibleViewRoute | None,
|
|
3554
3907
|
) -> list[FormField]:
|
|
3555
|
-
|
|
3556
|
-
if schema_mode != "browse" or resolved_view is None or resolved_view.kind != "custom" or resolved_view.view_selection is None:
|
|
3557
|
-
return fields
|
|
3558
|
-
question_ids = self._get_view_question_ids(profile, context, resolved_view.view_selection.view_key)
|
|
3559
|
-
if not question_ids:
|
|
3560
|
-
return fields
|
|
3561
|
-
filtered = [field for field in fields if field.que_id in question_ids]
|
|
3562
|
-
return filtered or fields
|
|
3908
|
+
return list(index.by_id.values())
|
|
3563
3909
|
|
|
3564
3910
|
def _probe_list_type_access(
|
|
3565
3911
|
self,
|
|
@@ -4583,14 +4929,45 @@ class RecordTools(ToolBase):
|
|
|
4583
4929
|
record_ids: list[int],
|
|
4584
4930
|
normalized_answers: list[JSONObject],
|
|
4585
4931
|
submit_type: int,
|
|
4932
|
+
selection: JSONObject | None = None,
|
|
4586
4933
|
) -> JSONObject:
|
|
4587
|
-
|
|
4934
|
+
payload: JSONObject = {
|
|
4588
4935
|
"operation": operation,
|
|
4589
4936
|
"record_id": record_id,
|
|
4590
4937
|
"record_ids": record_ids,
|
|
4591
4938
|
"answers": normalized_answers,
|
|
4592
4939
|
"submit_type": submit_type,
|
|
4593
4940
|
}
|
|
4941
|
+
if selection:
|
|
4942
|
+
payload["selection"] = selection
|
|
4943
|
+
return payload
|
|
4944
|
+
|
|
4945
|
+
def _canonicalize_write_scope_selectors(
|
|
4946
|
+
self,
|
|
4947
|
+
*,
|
|
4948
|
+
answers: list[JSONObject],
|
|
4949
|
+
fields: JSONObject,
|
|
4950
|
+
selector_index: FieldIndex,
|
|
4951
|
+
) -> tuple[list[JSONObject], JSONObject]:
|
|
4952
|
+
canonical_answers: list[JSONObject] = []
|
|
4953
|
+
for item in answers:
|
|
4954
|
+
if not isinstance(item, dict):
|
|
4955
|
+
raise RecordInputError(
|
|
4956
|
+
message="answer item must be an object",
|
|
4957
|
+
error_code="INVALID_ANSWER_ITEM",
|
|
4958
|
+
fix_hint="Provide each answer as an object with a field selector and value.",
|
|
4959
|
+
)
|
|
4960
|
+
field = self._resolve_field_from_answer_item(item, selector_index)
|
|
4961
|
+
payload = dict(item)
|
|
4962
|
+
payload["queId"] = field.que_id
|
|
4963
|
+
for key in ("field_id", "fieldId", "que_id", "queTitle", "que_title"):
|
|
4964
|
+
payload.pop(key, None)
|
|
4965
|
+
canonical_answers.append(payload)
|
|
4966
|
+
canonical_fields: JSONObject = {}
|
|
4967
|
+
for requested_key, value in fields.items():
|
|
4968
|
+
field = self._resolve_field_selector(cast(str | int, requested_key), selector_index, location="fields")
|
|
4969
|
+
canonical_fields[str(field.que_id)] = value
|
|
4970
|
+
return canonical_answers, canonical_fields
|
|
4594
4971
|
|
|
4595
4972
|
def _record_write_human_review_payload(self, operation: str, *, enabled: bool) -> JSONObject | None:
|
|
4596
4973
|
if not enabled:
|
|
@@ -4601,6 +4978,39 @@ class RecordTools(ToolBase):
|
|
|
4601
4978
|
"message": "Read the current record first and confirm the exact target before applying this high-risk write.",
|
|
4602
4979
|
}
|
|
4603
4980
|
|
|
4981
|
+
def _raise_record_write_permission_error(
|
|
4982
|
+
self,
|
|
4983
|
+
exc: QingflowApiError,
|
|
4984
|
+
*,
|
|
4985
|
+
operation: str,
|
|
4986
|
+
app_key: str,
|
|
4987
|
+
record_id: int | None,
|
|
4988
|
+
selection: JSONObject | None,
|
|
4989
|
+
) -> None:
|
|
4990
|
+
if exc.backend_code != 40002:
|
|
4991
|
+
raise exc
|
|
4992
|
+
raise_tool_error(
|
|
4993
|
+
QingflowApiError(
|
|
4994
|
+
category="permission",
|
|
4995
|
+
message="record_write was blocked because the current user does not have permission to edit this record.",
|
|
4996
|
+
backend_code=exc.backend_code,
|
|
4997
|
+
request_id=exc.request_id,
|
|
4998
|
+
http_status=exc.http_status,
|
|
4999
|
+
details={
|
|
5000
|
+
"error_code": "WRITE_PERMISSION_DENIED",
|
|
5001
|
+
"operation": operation,
|
|
5002
|
+
"app_key": app_key,
|
|
5003
|
+
"record_id": record_id,
|
|
5004
|
+
"selection": selection,
|
|
5005
|
+
"fix_hint": (
|
|
5006
|
+
"View visibility only narrows field scope and does not grant edit rights. "
|
|
5007
|
+
"If this is workflow work, prefer task_list -> task_get -> task_action_execute; "
|
|
5008
|
+
"otherwise ask an operator/admin account with record edit permission to perform the update."
|
|
5009
|
+
),
|
|
5010
|
+
},
|
|
5011
|
+
)
|
|
5012
|
+
)
|
|
5013
|
+
|
|
4604
5014
|
def _record_write_blocked_response(
|
|
4605
5015
|
self,
|
|
4606
5016
|
raw_preflight: JSONObject,
|
|
@@ -4734,10 +5144,20 @@ class RecordTools(ToolBase):
|
|
|
4734
5144
|
}
|
|
4735
5145
|
)
|
|
4736
5146
|
for item in readonly_or_system_fields:
|
|
5147
|
+
reason_code = _normalize_optional_text(item.get("reason_code")) or "readonly"
|
|
5148
|
+
if reason_code == "view_readonly":
|
|
5149
|
+
error_code = "VIEW_FIELD_READONLY"
|
|
5150
|
+
message = "field is not writable in the selected view"
|
|
5151
|
+
elif reason_code == "system":
|
|
5152
|
+
error_code = "READONLY_OR_SYSTEM_FIELD"
|
|
5153
|
+
message = "field is system-managed"
|
|
5154
|
+
else:
|
|
5155
|
+
error_code = "READONLY_OR_SYSTEM_FIELD"
|
|
5156
|
+
message = "field is readonly or system-managed"
|
|
4737
5157
|
errors.append(
|
|
4738
5158
|
{
|
|
4739
|
-
"error_code":
|
|
4740
|
-
"message":
|
|
5159
|
+
"error_code": error_code,
|
|
5160
|
+
"message": message,
|
|
4741
5161
|
"field": {
|
|
4742
5162
|
"que_id": item.get("que_id"),
|
|
4743
5163
|
"que_title": item.get("que_title"),
|
|
@@ -4796,6 +5216,9 @@ class RecordTools(ToolBase):
|
|
|
4796
5216
|
field = index.by_id.get(str(int(requested)))
|
|
4797
5217
|
if field is not None:
|
|
4798
5218
|
return field
|
|
5219
|
+
leaf_matches = index.subtable_leaf_by_id.get(str(int(requested)), [])
|
|
5220
|
+
if leaf_matches:
|
|
5221
|
+
raise self._subtable_leaf_field_error(location=location, requested=requested, matches=leaf_matches, matched_via="id")
|
|
4799
5222
|
raise RecordInputError(
|
|
4800
5223
|
message=f"{location} references unknown queId '{requested}'",
|
|
4801
5224
|
error_code="FIELD_NOT_FOUND",
|
|
@@ -4818,6 +5241,9 @@ class RecordTools(ToolBase):
|
|
|
4818
5241
|
"candidates": [_field_ref_payload(item) for item in matches],
|
|
4819
5242
|
},
|
|
4820
5243
|
)
|
|
5244
|
+
leaf_matches = index.subtable_leaf_by_title.get(requested_key, [])
|
|
5245
|
+
if leaf_matches:
|
|
5246
|
+
raise self._subtable_leaf_field_error(location=location, requested=requested, matches=leaf_matches, matched_via="title")
|
|
4821
5247
|
alias_matches = index.by_alias.get(requested_key, [])
|
|
4822
5248
|
if len(alias_matches) == 1:
|
|
4823
5249
|
return alias_matches[0]
|
|
@@ -4834,6 +5260,9 @@ class RecordTools(ToolBase):
|
|
|
4834
5260
|
"candidates": [_field_ref_payload(item) for item in alias_matches],
|
|
4835
5261
|
},
|
|
4836
5262
|
)
|
|
5263
|
+
leaf_alias_matches = index.subtable_leaf_by_alias.get(requested_key, [])
|
|
5264
|
+
if leaf_alias_matches:
|
|
5265
|
+
raise self._subtable_leaf_field_error(location=location, requested=requested, matches=leaf_alias_matches, matched_via="alias")
|
|
4837
5266
|
raise RecordInputError(
|
|
4838
5267
|
message=f"{location} cannot resolve field '{requested}'",
|
|
4839
5268
|
error_code="FIELD_NOT_FOUND",
|
|
@@ -4846,6 +5275,57 @@ class RecordTools(ToolBase):
|
|
|
4846
5275
|
},
|
|
4847
5276
|
)
|
|
4848
5277
|
|
|
5278
|
+
def _subtable_leaf_field_error(
|
|
5279
|
+
self,
|
|
5280
|
+
*,
|
|
5281
|
+
location: str,
|
|
5282
|
+
requested: str,
|
|
5283
|
+
matches: list[SubtableLeafRef],
|
|
5284
|
+
matched_via: str,
|
|
5285
|
+
) -> RecordInputError:
|
|
5286
|
+
if len(matches) > 1:
|
|
5287
|
+
return RecordInputError(
|
|
5288
|
+
message=f"{location} field '{requested}' matches subtable leaf fields under multiple parent tables",
|
|
5289
|
+
error_code="AMBIGUOUS_SUBTABLE_LEAF_FIELD",
|
|
5290
|
+
fix_hint="Use the parent subtable field with rows/tableValues, or inspect record_schema_get to choose the correct parent table.",
|
|
5291
|
+
details={
|
|
5292
|
+
"location": location,
|
|
5293
|
+
"requested": requested,
|
|
5294
|
+
"matched_via": matched_via,
|
|
5295
|
+
"candidates": [
|
|
5296
|
+
{
|
|
5297
|
+
"parent_field": _field_ref_payload(item.parent_field),
|
|
5298
|
+
"subfield": _field_ref_payload(item.field),
|
|
5299
|
+
}
|
|
5300
|
+
for item in matches
|
|
5301
|
+
],
|
|
5302
|
+
},
|
|
5303
|
+
)
|
|
5304
|
+
match = matches[0]
|
|
5305
|
+
return RecordInputError(
|
|
5306
|
+
message=(
|
|
5307
|
+
f"{location} field '{requested}' is a subtable leaf field and cannot be written at the top level; "
|
|
5308
|
+
f"use parent field '{match.parent_field.que_title}' with rows/tableValues instead"
|
|
5309
|
+
),
|
|
5310
|
+
error_code="SUBTABLE_LEAF_REQUIRES_PARENT_ROWS",
|
|
5311
|
+
fix_hint=(
|
|
5312
|
+
f"Write subtable leaf '{match.field.que_title}' under parent field '{match.parent_field.que_title}', "
|
|
5313
|
+
"for example {'"
|
|
5314
|
+
+ match.parent_field.que_title
|
|
5315
|
+
+ "': [{'"
|
|
5316
|
+
+ match.field.que_title
|
|
5317
|
+
+ "': '值'}]}."
|
|
5318
|
+
),
|
|
5319
|
+
details={
|
|
5320
|
+
"location": location,
|
|
5321
|
+
"requested": requested,
|
|
5322
|
+
"matched_via": matched_via,
|
|
5323
|
+
"field": _field_ref_payload(match.field),
|
|
5324
|
+
"parent_field": _field_ref_payload(match.parent_field),
|
|
5325
|
+
"expected_format": _write_format_for_field(match.parent_field),
|
|
5326
|
+
},
|
|
5327
|
+
)
|
|
5328
|
+
|
|
4849
5329
|
def _resolve_field_from_answer_item(self, item: JSONObject, index: FieldIndex) -> FormField:
|
|
4850
5330
|
for key in ("queId", "que_id", "queTitle", "que_title", "field_id", "fieldId"):
|
|
4851
5331
|
if key in item:
|
|
@@ -5862,47 +6342,208 @@ def _compile_view_conditions(config: JSONObject) -> list[list[ViewFilterConditio
|
|
|
5862
6342
|
return compiled
|
|
5863
6343
|
|
|
5864
6344
|
|
|
5865
|
-
def
|
|
6345
|
+
def _collect_top_level_questions(payload: JSONValue) -> list[JSONObject]:
|
|
6346
|
+
questions: list[JSONObject] = []
|
|
6347
|
+
if isinstance(payload, dict):
|
|
6348
|
+
is_question = "queId" in payload or "queTitle" in payload
|
|
6349
|
+
if is_question:
|
|
6350
|
+
que_type = _coerce_count(payload.get("queType"))
|
|
6351
|
+
if _should_index_question(payload):
|
|
6352
|
+
questions.append(payload)
|
|
6353
|
+
if que_type in SUBTABLE_QUE_TYPES:
|
|
6354
|
+
return questions
|
|
6355
|
+
if not _question_is_container_wrapper(payload):
|
|
6356
|
+
return questions
|
|
6357
|
+
for key in ("baseQues", "formQues"):
|
|
6358
|
+
value = payload.get(key)
|
|
6359
|
+
if isinstance(value, list):
|
|
6360
|
+
questions.extend(_collect_top_level_questions(value))
|
|
6361
|
+
for key in ("innerQuestions", "subQues", "subQuestions"):
|
|
6362
|
+
value = payload.get(key)
|
|
6363
|
+
if isinstance(value, list):
|
|
6364
|
+
questions.extend(_collect_top_level_questions(value))
|
|
6365
|
+
return questions
|
|
6366
|
+
if isinstance(payload, list):
|
|
6367
|
+
for item in payload:
|
|
6368
|
+
questions.extend(_collect_top_level_questions(item))
|
|
6369
|
+
return questions
|
|
6370
|
+
|
|
6371
|
+
|
|
6372
|
+
def _question_to_form_field(question: JSONObject, *, is_base_question: bool) -> FormField | None:
|
|
6373
|
+
if not _should_index_question(question):
|
|
6374
|
+
return None
|
|
6375
|
+
que_id = _coerce_count(question.get("queId"))
|
|
6376
|
+
title = _stringify_json(question.get("queTitle")).strip()
|
|
6377
|
+
if que_id is None or que_id < 0 or not title:
|
|
6378
|
+
return None
|
|
6379
|
+
can_edit = question.get("canEdit")
|
|
6380
|
+
field = FormField(
|
|
6381
|
+
que_id=que_id,
|
|
6382
|
+
que_title=title,
|
|
6383
|
+
que_type=_coerce_count(question.get("queType")),
|
|
6384
|
+
required=bool(question.get("required") or question.get("beingRequired")),
|
|
6385
|
+
readonly=bool(question.get("readonly") or question.get("beingReadonly") or is_base_question or can_edit is False),
|
|
6386
|
+
system=bool(question.get("system") or question.get("beingSystem") or is_base_question),
|
|
6387
|
+
options=_extract_question_options(question),
|
|
6388
|
+
aliases=[],
|
|
6389
|
+
target_app_key=_extract_relation_target_app_key(question),
|
|
6390
|
+
target_app_name_hint=_extract_relation_target_app_name_hint(question),
|
|
6391
|
+
member_select_scope_type=_coerce_count(question.get("memberSelectScopeType")),
|
|
6392
|
+
member_select_scope=_normalize_scope_payload(question.get("memberSelectScope")),
|
|
6393
|
+
dept_select_scope_type=_coerce_count(question.get("deptSelectScopeType")),
|
|
6394
|
+
dept_select_scope=_normalize_scope_payload(question.get("deptSelectScope")),
|
|
6395
|
+
raw=question,
|
|
6396
|
+
)
|
|
6397
|
+
field.aliases = sorted(_field_alias_candidates(field))
|
|
6398
|
+
return field
|
|
6399
|
+
|
|
6400
|
+
|
|
6401
|
+
def _add_subtable_leaf_ref(
|
|
6402
|
+
target: dict[str, list[SubtableLeafRef]],
|
|
6403
|
+
key: str,
|
|
6404
|
+
ref: SubtableLeafRef,
|
|
6405
|
+
) -> None:
|
|
6406
|
+
if not key:
|
|
6407
|
+
return
|
|
6408
|
+
refs = target.setdefault(key, [])
|
|
6409
|
+
if any(existing.parent_field.que_id == ref.parent_field.que_id and existing.field.que_id == ref.field.que_id for existing in refs):
|
|
6410
|
+
return
|
|
6411
|
+
refs.append(ref)
|
|
6412
|
+
|
|
6413
|
+
|
|
6414
|
+
def _subtable_question_payload(question: JSONObject) -> list[JSONObject]:
|
|
6415
|
+
for key in ("subQuestions", "innerQuestions", "subQues"):
|
|
6416
|
+
value = question.get(key)
|
|
6417
|
+
if isinstance(value, list):
|
|
6418
|
+
return [item for item in _flatten_questions(value) if isinstance(item, dict)]
|
|
6419
|
+
return []
|
|
6420
|
+
|
|
6421
|
+
|
|
6422
|
+
def _build_field_index(
|
|
6423
|
+
schema: JSONObject,
|
|
6424
|
+
*,
|
|
6425
|
+
include_subtable_leaf_fields: bool = True,
|
|
6426
|
+
force_subtable_parents_writable: bool = False,
|
|
6427
|
+
) -> FieldIndex:
|
|
5866
6428
|
by_id: dict[str, FormField] = {}
|
|
5867
6429
|
by_title: dict[str, list[FormField]] = {}
|
|
5868
6430
|
by_alias: dict[str, list[FormField]] = {}
|
|
6431
|
+
subtable_leaf_by_id: dict[str, list[SubtableLeafRef]] = {}
|
|
6432
|
+
subtable_leaf_by_title: dict[str, list[SubtableLeafRef]] = {}
|
|
6433
|
+
subtable_leaf_by_alias: dict[str, list[SubtableLeafRef]] = {}
|
|
5869
6434
|
all_questions = [
|
|
5870
|
-
*[
|
|
5871
|
-
|
|
6435
|
+
*[
|
|
6436
|
+
(question, True)
|
|
6437
|
+
for question in (
|
|
6438
|
+
_flatten_questions(schema.get("baseQues"))
|
|
6439
|
+
if include_subtable_leaf_fields
|
|
6440
|
+
else _collect_top_level_questions(schema.get("baseQues"))
|
|
6441
|
+
)
|
|
6442
|
+
],
|
|
6443
|
+
*[
|
|
6444
|
+
(question, False)
|
|
6445
|
+
for question in (
|
|
6446
|
+
_flatten_questions(schema.get("formQues"))
|
|
6447
|
+
if include_subtable_leaf_fields
|
|
6448
|
+
else _collect_top_level_questions(schema.get("formQues"))
|
|
6449
|
+
)
|
|
6450
|
+
],
|
|
5872
6451
|
]
|
|
5873
6452
|
for question, is_base_question in all_questions:
|
|
5874
|
-
|
|
6453
|
+
field = _question_to_form_field(question, is_base_question=is_base_question)
|
|
6454
|
+
if field is None:
|
|
5875
6455
|
continue
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
if que_id
|
|
6456
|
+
if force_subtable_parents_writable and field.que_type in SUBTABLE_QUE_TYPES and not field.system:
|
|
6457
|
+
field = _clone_form_field(field, readonly=False)
|
|
6458
|
+
if str(field.que_id) in by_id:
|
|
5879
6459
|
continue
|
|
5880
|
-
|
|
5881
|
-
field
|
|
5882
|
-
que_id=que_id,
|
|
5883
|
-
que_title=title,
|
|
5884
|
-
que_type=_coerce_count(question.get("queType")),
|
|
5885
|
-
required=bool(question.get("required") or question.get("beingRequired")),
|
|
5886
|
-
readonly=bool(question.get("readonly") or question.get("beingReadonly") or is_base_question or can_edit is False),
|
|
5887
|
-
system=bool(question.get("system") or question.get("beingSystem") or is_base_question),
|
|
5888
|
-
options=_extract_question_options(question),
|
|
5889
|
-
aliases=[],
|
|
5890
|
-
target_app_key=_extract_relation_target_app_key(question),
|
|
5891
|
-
target_app_name_hint=_extract_relation_target_app_name_hint(question),
|
|
5892
|
-
member_select_scope_type=_coerce_count(question.get("memberSelectScopeType")),
|
|
5893
|
-
member_select_scope=_normalize_scope_payload(question.get("memberSelectScope")),
|
|
5894
|
-
dept_select_scope_type=_coerce_count(question.get("deptSelectScopeType")),
|
|
5895
|
-
dept_select_scope=_normalize_scope_payload(question.get("deptSelectScope")),
|
|
5896
|
-
raw=question,
|
|
5897
|
-
)
|
|
5898
|
-
if str(que_id) in by_id:
|
|
5899
|
-
continue
|
|
5900
|
-
field.aliases = sorted(_field_alias_candidates(field))
|
|
5901
|
-
by_id[str(que_id)] = field
|
|
5902
|
-
by_title.setdefault(_normalize_field_lookup_key(title), []).append(field)
|
|
6460
|
+
by_id[str(field.que_id)] = field
|
|
6461
|
+
by_title.setdefault(_normalize_field_lookup_key(field.que_title), []).append(field)
|
|
5903
6462
|
for alias in field.aliases:
|
|
5904
6463
|
by_alias.setdefault(_normalize_field_lookup_key(alias), []).append(field)
|
|
5905
|
-
|
|
6464
|
+
if field.que_type not in SUBTABLE_QUE_TYPES:
|
|
6465
|
+
continue
|
|
6466
|
+
for sub_question in _subtable_question_payload(question):
|
|
6467
|
+
sub_field = _question_to_form_field(cast(JSONObject, sub_question), is_base_question=False)
|
|
6468
|
+
if sub_field is None:
|
|
6469
|
+
continue
|
|
6470
|
+
ref = SubtableLeafRef(field=sub_field, parent_field=field)
|
|
6471
|
+
_add_subtable_leaf_ref(subtable_leaf_by_id, str(sub_field.que_id), ref)
|
|
6472
|
+
_add_subtable_leaf_ref(subtable_leaf_by_title, _normalize_field_lookup_key(sub_field.que_title), ref)
|
|
6473
|
+
for alias in sub_field.aliases:
|
|
6474
|
+
_add_subtable_leaf_ref(subtable_leaf_by_alias, _normalize_field_lookup_key(alias), ref)
|
|
6475
|
+
return FieldIndex(
|
|
6476
|
+
by_id=by_id,
|
|
6477
|
+
by_title=by_title,
|
|
6478
|
+
by_alias=by_alias,
|
|
6479
|
+
subtable_leaf_by_id=subtable_leaf_by_id,
|
|
6480
|
+
subtable_leaf_by_title=subtable_leaf_by_title,
|
|
6481
|
+
subtable_leaf_by_alias=subtable_leaf_by_alias,
|
|
6482
|
+
)
|
|
6483
|
+
|
|
6484
|
+
|
|
6485
|
+
def _build_top_level_field_index(schema: JSONObject) -> FieldIndex:
|
|
6486
|
+
return _build_field_index(schema, include_subtable_leaf_fields=False)
|
|
6487
|
+
|
|
6488
|
+
|
|
6489
|
+
def _build_applicant_top_level_field_index(schema: JSONObject) -> FieldIndex:
|
|
6490
|
+
return _build_field_index(
|
|
6491
|
+
schema,
|
|
6492
|
+
include_subtable_leaf_fields=False,
|
|
6493
|
+
force_subtable_parents_writable=True,
|
|
6494
|
+
)
|
|
6495
|
+
|
|
6496
|
+
|
|
6497
|
+
def _clone_form_field(field: FormField, *, readonly: bool | None = None) -> FormField:
|
|
6498
|
+
return FormField(
|
|
6499
|
+
que_id=field.que_id,
|
|
6500
|
+
que_title=field.que_title,
|
|
6501
|
+
que_type=field.que_type,
|
|
6502
|
+
required=field.required,
|
|
6503
|
+
readonly=field.readonly if readonly is None else readonly,
|
|
6504
|
+
system=field.system,
|
|
6505
|
+
options=list(field.options),
|
|
6506
|
+
aliases=list(field.aliases),
|
|
6507
|
+
target_app_key=field.target_app_key,
|
|
6508
|
+
target_app_name_hint=field.target_app_name_hint,
|
|
6509
|
+
member_select_scope_type=field.member_select_scope_type,
|
|
6510
|
+
member_select_scope=field.member_select_scope,
|
|
6511
|
+
dept_select_scope_type=field.dept_select_scope_type,
|
|
6512
|
+
dept_select_scope=field.dept_select_scope,
|
|
6513
|
+
raw=field.raw,
|
|
6514
|
+
)
|
|
6515
|
+
|
|
6516
|
+
|
|
6517
|
+
def _form_field_to_question(field: FormField) -> JSONObject:
|
|
6518
|
+
question = dict(field.raw) if isinstance(field.raw, dict) else {}
|
|
6519
|
+
question.setdefault("queId", field.que_id)
|
|
6520
|
+
question.setdefault("queTitle", field.que_title)
|
|
6521
|
+
if field.que_type is not None:
|
|
6522
|
+
question.setdefault("queType", field.que_type)
|
|
6523
|
+
question["readonly"] = field.readonly
|
|
6524
|
+
question["beingReadonly"] = field.readonly
|
|
6525
|
+
question["required"] = field.required
|
|
6526
|
+
question["beingRequired"] = field.required
|
|
6527
|
+
question["system"] = field.system
|
|
6528
|
+
question["beingSystem"] = field.system
|
|
6529
|
+
return question
|
|
6530
|
+
|
|
6531
|
+
|
|
6532
|
+
def _question_ids_from_schema(schema: JSONObject) -> set[int]:
|
|
6533
|
+
question_ids: set[int] = set()
|
|
6534
|
+
for question in _flatten_questions(schema):
|
|
6535
|
+
que_id = _coerce_count(question.get("queId"))
|
|
6536
|
+
if que_id is not None and que_id >= 0:
|
|
6537
|
+
question_ids.add(que_id)
|
|
6538
|
+
return question_ids
|
|
6539
|
+
|
|
6540
|
+
|
|
6541
|
+
def _subtable_descendant_ids(field: FormField) -> set[int]:
|
|
6542
|
+
return {
|
|
6543
|
+
item["que_id"]
|
|
6544
|
+
for item in _subtable_columns_for_field(field)
|
|
6545
|
+
if isinstance(item.get("que_id"), int)
|
|
6546
|
+
}
|
|
5906
6547
|
|
|
5907
6548
|
|
|
5908
6549
|
def _extract_relation_target_app_key(question: JSONObject) -> str | None:
|
|
@@ -5952,6 +6593,10 @@ def _flatten_questions(payload: JSONValue) -> list[JSONObject]:
|
|
|
5952
6593
|
return flattened
|
|
5953
6594
|
|
|
5954
6595
|
|
|
6596
|
+
def _question_is_container_wrapper(question: JSONObject) -> bool:
|
|
6597
|
+
return not _should_index_question(question)
|
|
6598
|
+
|
|
6599
|
+
|
|
5955
6600
|
def _should_index_question(question: JSONObject) -> bool:
|
|
5956
6601
|
if bool(question.get("beingHide") or question.get("hidden")):
|
|
5957
6602
|
return False
|