@qingflow-tech/qingflow-app-user-mcp 1.0.5 → 1.0.7
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/skills/qingflow-app-user/references/data-gotchas.md +3 -2
- package/skills/qingflow-app-user/references/public-surface-sync.md +1 -1
- package/skills/qingflow-app-user/references/record-patterns.md +5 -4
- package/skills/qingflow-record-insert/SKILL.md +26 -5
- package/src/qingflow_mcp/builder_facade/models.py +269 -2
- package/src/qingflow_mcp/builder_facade/service.py +3549 -172
- package/src/qingflow_mcp/cli/commands/builder.py +39 -26
- package/src/qingflow_mcp/cli/commands/record.py +19 -1
- package/src/qingflow_mcp/public_surface.py +2 -5
- package/src/qingflow_mcp/response_trim.py +65 -7
- package/src/qingflow_mcp/server.py +4 -3
- package/src/qingflow_mcp/server_app_builder.py +33 -20
- package/src/qingflow_mcp/server_app_user.py +4 -3
- package/src/qingflow_mcp/tools/ai_builder_tools.py +395 -117
- package/src/qingflow_mcp/tools/record_tools.py +622 -56
|
@@ -43,10 +43,18 @@ from .models import (
|
|
|
43
43
|
AppLayoutReadResponse,
|
|
44
44
|
AppReadSummaryResponse,
|
|
45
45
|
AppViewsReadResponse,
|
|
46
|
+
AssociatedResourcesApplyRequest,
|
|
47
|
+
AssociatedResourceUpsertPatch,
|
|
48
|
+
AssociatedResourceViewConfigPatch,
|
|
46
49
|
ChartApplyRequest,
|
|
47
50
|
ChartUpsertPatch,
|
|
51
|
+
CustomButtonsApplyRequest,
|
|
52
|
+
CustomButtonViewButtonBindingPatch,
|
|
53
|
+
CustomButtonViewConfigPatch,
|
|
48
54
|
CustomButtonMatchRulePatch,
|
|
49
55
|
CustomButtonPatch,
|
|
56
|
+
CustomButtonRemovePatch,
|
|
57
|
+
CustomButtonUpsertPatch,
|
|
50
58
|
FieldPatch,
|
|
51
59
|
FieldRemovePatch,
|
|
52
60
|
FieldSelector,
|
|
@@ -67,6 +75,7 @@ from .models import (
|
|
|
67
75
|
PublicFieldType,
|
|
68
76
|
PublicRelationMode,
|
|
69
77
|
PublicChartType,
|
|
78
|
+
PublicButtonPlacement,
|
|
70
79
|
PublicButtonTriggerAction,
|
|
71
80
|
PublicVisibilityMode,
|
|
72
81
|
PublicViewType,
|
|
@@ -74,6 +83,7 @@ from .models import (
|
|
|
74
83
|
PublicViewButtonType,
|
|
75
84
|
SchemaPlanRequest,
|
|
76
85
|
VisibilityPatch,
|
|
86
|
+
ViewAssociatedResourcesPatch,
|
|
77
87
|
ViewButtonBindingPatch,
|
|
78
88
|
ViewUpsertPatch,
|
|
79
89
|
ViewFilterOperator,
|
|
@@ -140,6 +150,7 @@ INTEGRATION_OUTPUT_TARGET_FIELD_TYPES: tuple[str, ...] = (
|
|
|
140
150
|
)
|
|
141
151
|
|
|
142
152
|
MATCH_TYPE_ACCURACY = 1
|
|
153
|
+
MATCH_TYPE_QUESTION = 2
|
|
143
154
|
JUDGE_EQUAL = 0
|
|
144
155
|
JUDGE_UNEQUAL = 1
|
|
145
156
|
JUDGE_GREATER_OR_EQUAL = 5
|
|
@@ -158,6 +169,18 @@ INCLUDE_ANY_FLOW_FIELD_TYPES = {
|
|
|
158
169
|
FieldType.department.value,
|
|
159
170
|
}
|
|
160
171
|
|
|
172
|
+
QUERY_CONDITION_UNSUPPORTED_FIELD_TYPES = {
|
|
173
|
+
FieldType.address.value,
|
|
174
|
+
FieldType.attachment.value,
|
|
175
|
+
FieldType.code_block.value,
|
|
176
|
+
FieldType.q_linker.value,
|
|
177
|
+
FieldType.relation.value,
|
|
178
|
+
FieldType.subtable.value,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
ASSOCIATED_RESOURCE_LIMIT_TYPE_ALL = 1
|
|
182
|
+
ASSOCIATED_RESOURCE_LIMIT_TYPE_SELECT = 2
|
|
183
|
+
|
|
161
184
|
STABLE_PUBLIC_FLOW_NODE_TYPES = {"start", "approve", "fill", "copy", "webhook", "end"}
|
|
162
185
|
DISABLED_PUBLIC_FLOW_NODE_TYPES = {"branch", "condition"}
|
|
163
186
|
|
|
@@ -2374,6 +2397,469 @@ class AiBuilderFacade:
|
|
|
2374
2397
|
**button_style_catalog_payload(),
|
|
2375
2398
|
}
|
|
2376
2399
|
|
|
2400
|
+
def app_custom_buttons_apply(self, *, profile: str, request: CustomButtonsApplyRequest) -> JSONObject:
|
|
2401
|
+
normalized_args = request.model_dump(mode="json")
|
|
2402
|
+
app_key = request.app_key
|
|
2403
|
+
permission_outcomes: list[PermissionCheckOutcome] = []
|
|
2404
|
+
permission_outcome = self._guard_app_permission(
|
|
2405
|
+
profile=profile,
|
|
2406
|
+
app_key=app_key,
|
|
2407
|
+
required_permission="edit_app",
|
|
2408
|
+
normalized_args=normalized_args,
|
|
2409
|
+
)
|
|
2410
|
+
if permission_outcome.block is not None:
|
|
2411
|
+
return permission_outcome.block
|
|
2412
|
+
permission_outcomes.append(permission_outcome)
|
|
2413
|
+
|
|
2414
|
+
def finalize(response: JSONObject) -> JSONObject:
|
|
2415
|
+
return _apply_permission_outcomes(response, *permission_outcomes)
|
|
2416
|
+
|
|
2417
|
+
try:
|
|
2418
|
+
existing_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
|
|
2419
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2420
|
+
api_error = _coerce_api_error(error)
|
|
2421
|
+
return finalize(_failed_from_api_error(
|
|
2422
|
+
"CUSTOM_BUTTON_LIST_FAILED",
|
|
2423
|
+
api_error,
|
|
2424
|
+
normalized_args=normalized_args,
|
|
2425
|
+
details=_with_state_read_blocked_details({"app_key": app_key}, resource="custom_buttons", error=api_error),
|
|
2426
|
+
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2427
|
+
))
|
|
2428
|
+
|
|
2429
|
+
existing_by_id = {
|
|
2430
|
+
button_id: item
|
|
2431
|
+
for item in existing_buttons
|
|
2432
|
+
if (button_id := _coerce_positive_int(item.get("button_id"))) is not None
|
|
2433
|
+
}
|
|
2434
|
+
existing_by_text: dict[str, list[dict[str, Any]]] = {}
|
|
2435
|
+
for item in existing_buttons:
|
|
2436
|
+
text = str(item.get("button_text") or "").strip()
|
|
2437
|
+
if text:
|
|
2438
|
+
existing_by_text.setdefault(text, []).append(item)
|
|
2439
|
+
|
|
2440
|
+
compiled_add_data_configs, add_data_issues = self._compile_custom_button_semantic_add_data_configs(
|
|
2441
|
+
profile=profile,
|
|
2442
|
+
app_key=app_key,
|
|
2443
|
+
patches=request.upsert_buttons,
|
|
2444
|
+
)
|
|
2445
|
+
upsert_ops: list[dict[str, Any]] = []
|
|
2446
|
+
remove_ops: list[dict[str, Any]] = []
|
|
2447
|
+
blocking_issues: list[dict[str, Any]] = []
|
|
2448
|
+
blocking_issues.extend(add_data_issues)
|
|
2449
|
+
touched_existing_ids: set[int] = set()
|
|
2450
|
+
used_client_keys: set[str] = set()
|
|
2451
|
+
|
|
2452
|
+
for index, patch in enumerate(request.upsert_buttons):
|
|
2453
|
+
patch_payload = patch.model_dump(mode="json", exclude_none=True)
|
|
2454
|
+
client_key = str(patch.client_key or "").strip()
|
|
2455
|
+
if client_key:
|
|
2456
|
+
if client_key in used_client_keys:
|
|
2457
|
+
blocking_issues.append(
|
|
2458
|
+
{
|
|
2459
|
+
"error_code": "DUPLICATE_CLIENT_KEY",
|
|
2460
|
+
"reason_path": f"upsert_buttons[{index}].client_key",
|
|
2461
|
+
"client_key": client_key,
|
|
2462
|
+
"message": "client_key must be unique within one button apply call",
|
|
2463
|
+
}
|
|
2464
|
+
)
|
|
2465
|
+
used_client_keys.add(client_key)
|
|
2466
|
+
button_id = _coerce_positive_int(patch.button_id)
|
|
2467
|
+
if button_id is not None:
|
|
2468
|
+
if button_id not in existing_by_id:
|
|
2469
|
+
blocking_issues.append(
|
|
2470
|
+
{
|
|
2471
|
+
"error_code": "CUSTOM_BUTTON_NOT_FOUND",
|
|
2472
|
+
"reason_path": f"upsert_buttons[{index}].button_id",
|
|
2473
|
+
"button_id": button_id,
|
|
2474
|
+
"message": "button_id does not exist in the current app draft",
|
|
2475
|
+
"next_action": "call app_get and use custom_buttons[].button_id",
|
|
2476
|
+
}
|
|
2477
|
+
)
|
|
2478
|
+
continue
|
|
2479
|
+
if button_id in touched_existing_ids:
|
|
2480
|
+
blocking_issues.append(
|
|
2481
|
+
{
|
|
2482
|
+
"error_code": "DUPLICATE_CUSTOM_BUTTON_OPERATION",
|
|
2483
|
+
"reason_path": f"upsert_buttons[{index}]",
|
|
2484
|
+
"button_id": button_id,
|
|
2485
|
+
"message": "the same button is targeted more than once",
|
|
2486
|
+
}
|
|
2487
|
+
)
|
|
2488
|
+
continue
|
|
2489
|
+
touched_existing_ids.add(button_id)
|
|
2490
|
+
upsert_ops.append({"operation": "update", "button_id": button_id, "patch": patch, "index": index})
|
|
2491
|
+
continue
|
|
2492
|
+
text = str(patch.button_text or "").strip()
|
|
2493
|
+
matches = existing_by_text.get(text, [])
|
|
2494
|
+
if len(matches) > 1:
|
|
2495
|
+
blocking_issues.append(
|
|
2496
|
+
{
|
|
2497
|
+
"error_code": "AMBIGUOUS_CUSTOM_BUTTON",
|
|
2498
|
+
"reason_path": f"upsert_buttons[{index}].button_text",
|
|
2499
|
+
"button_text": text,
|
|
2500
|
+
"candidate_button_ids": [
|
|
2501
|
+
item.get("button_id")
|
|
2502
|
+
for item in matches
|
|
2503
|
+
if _coerce_positive_int(item.get("button_id")) is not None
|
|
2504
|
+
],
|
|
2505
|
+
"message": "button_text matches multiple existing custom buttons; pass button_id",
|
|
2506
|
+
}
|
|
2507
|
+
)
|
|
2508
|
+
continue
|
|
2509
|
+
if len(matches) == 1:
|
|
2510
|
+
matched_id = _coerce_positive_int(matches[0].get("button_id"))
|
|
2511
|
+
if matched_id is None:
|
|
2512
|
+
blocking_issues.append(
|
|
2513
|
+
{
|
|
2514
|
+
"error_code": "CUSTOM_BUTTON_ID_MISSING",
|
|
2515
|
+
"reason_path": f"upsert_buttons[{index}].button_text",
|
|
2516
|
+
"button_text": text,
|
|
2517
|
+
"message": "matched button has no readable button_id",
|
|
2518
|
+
}
|
|
2519
|
+
)
|
|
2520
|
+
continue
|
|
2521
|
+
if matched_id in touched_existing_ids:
|
|
2522
|
+
blocking_issues.append(
|
|
2523
|
+
{
|
|
2524
|
+
"error_code": "DUPLICATE_CUSTOM_BUTTON_OPERATION",
|
|
2525
|
+
"reason_path": f"upsert_buttons[{index}]",
|
|
2526
|
+
"button_id": matched_id,
|
|
2527
|
+
"message": "the same button is targeted more than once",
|
|
2528
|
+
}
|
|
2529
|
+
)
|
|
2530
|
+
continue
|
|
2531
|
+
touched_existing_ids.add(matched_id)
|
|
2532
|
+
upsert_ops.append({"operation": "update", "button_id": matched_id, "patch": patch, "index": index})
|
|
2533
|
+
else:
|
|
2534
|
+
upsert_ops.append({"operation": "create", "button_id": None, "patch": patch, "index": index})
|
|
2535
|
+
|
|
2536
|
+
for index, selector in enumerate(request.remove_buttons):
|
|
2537
|
+
selector_id = _coerce_positive_int(selector.button_id)
|
|
2538
|
+
if selector_id is not None:
|
|
2539
|
+
if selector_id not in existing_by_id:
|
|
2540
|
+
blocking_issues.append(
|
|
2541
|
+
{
|
|
2542
|
+
"error_code": "CUSTOM_BUTTON_NOT_FOUND",
|
|
2543
|
+
"reason_path": f"remove_buttons[{index}].button_id",
|
|
2544
|
+
"button_id": selector_id,
|
|
2545
|
+
"message": "button_id does not exist in the current app draft",
|
|
2546
|
+
"next_action": "call app_get and use custom_buttons[].button_id",
|
|
2547
|
+
}
|
|
2548
|
+
)
|
|
2549
|
+
continue
|
|
2550
|
+
if selector_id in touched_existing_ids:
|
|
2551
|
+
blocking_issues.append(
|
|
2552
|
+
{
|
|
2553
|
+
"error_code": "DUPLICATE_CUSTOM_BUTTON_OPERATION",
|
|
2554
|
+
"reason_path": f"remove_buttons[{index}]",
|
|
2555
|
+
"button_id": selector_id,
|
|
2556
|
+
"message": "the same button is targeted more than once",
|
|
2557
|
+
}
|
|
2558
|
+
)
|
|
2559
|
+
continue
|
|
2560
|
+
touched_existing_ids.add(selector_id)
|
|
2561
|
+
remove_ops.append({"operation": "remove", "button_id": selector_id, "selector": selector, "index": index})
|
|
2562
|
+
continue
|
|
2563
|
+
text = str(selector.button_text or "").strip()
|
|
2564
|
+
matches = existing_by_text.get(text, [])
|
|
2565
|
+
if not matches:
|
|
2566
|
+
blocking_issues.append(
|
|
2567
|
+
{
|
|
2568
|
+
"error_code": "CUSTOM_BUTTON_NOT_FOUND",
|
|
2569
|
+
"reason_path": f"remove_buttons[{index}].button_text",
|
|
2570
|
+
"button_text": text,
|
|
2571
|
+
"message": "button_text does not match any existing custom button",
|
|
2572
|
+
}
|
|
2573
|
+
)
|
|
2574
|
+
continue
|
|
2575
|
+
if len(matches) > 1:
|
|
2576
|
+
blocking_issues.append(
|
|
2577
|
+
{
|
|
2578
|
+
"error_code": "AMBIGUOUS_CUSTOM_BUTTON",
|
|
2579
|
+
"reason_path": f"remove_buttons[{index}].button_text",
|
|
2580
|
+
"button_text": text,
|
|
2581
|
+
"candidate_button_ids": [
|
|
2582
|
+
item.get("button_id")
|
|
2583
|
+
for item in matches
|
|
2584
|
+
if _coerce_positive_int(item.get("button_id")) is not None
|
|
2585
|
+
],
|
|
2586
|
+
"message": "button_text matches multiple existing custom buttons; pass button_id",
|
|
2587
|
+
}
|
|
2588
|
+
)
|
|
2589
|
+
continue
|
|
2590
|
+
matched_id = _coerce_positive_int(matches[0].get("button_id"))
|
|
2591
|
+
if matched_id is None:
|
|
2592
|
+
blocking_issues.append(
|
|
2593
|
+
{
|
|
2594
|
+
"error_code": "CUSTOM_BUTTON_ID_MISSING",
|
|
2595
|
+
"reason_path": f"remove_buttons[{index}].button_text",
|
|
2596
|
+
"button_text": text,
|
|
2597
|
+
"message": "matched button has no readable button_id",
|
|
2598
|
+
}
|
|
2599
|
+
)
|
|
2600
|
+
continue
|
|
2601
|
+
if matched_id in touched_existing_ids:
|
|
2602
|
+
blocking_issues.append(
|
|
2603
|
+
{
|
|
2604
|
+
"error_code": "DUPLICATE_CUSTOM_BUTTON_OPERATION",
|
|
2605
|
+
"reason_path": f"remove_buttons[{index}]",
|
|
2606
|
+
"button_id": matched_id,
|
|
2607
|
+
"message": "the same button is targeted more than once",
|
|
2608
|
+
}
|
|
2609
|
+
)
|
|
2610
|
+
continue
|
|
2611
|
+
touched_existing_ids.add(matched_id)
|
|
2612
|
+
remove_ops.append({"operation": "remove", "button_id": matched_id, "selector": selector, "index": index})
|
|
2613
|
+
|
|
2614
|
+
if blocking_issues:
|
|
2615
|
+
return finalize(
|
|
2616
|
+
_failed(
|
|
2617
|
+
"CUSTOM_BUTTON_APPLY_BLOCKED",
|
|
2618
|
+
"custom button apply has blocking issues; no write was executed",
|
|
2619
|
+
normalized_args=normalized_args,
|
|
2620
|
+
details={"blocking_issues": blocking_issues},
|
|
2621
|
+
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2622
|
+
)
|
|
2623
|
+
)
|
|
2624
|
+
|
|
2625
|
+
edit_version_no, edit_context_error = self._ensure_app_edit_context(
|
|
2626
|
+
profile=profile,
|
|
2627
|
+
app_key=app_key,
|
|
2628
|
+
normalized_args=normalized_args,
|
|
2629
|
+
failure_code="CUSTOM_BUTTON_APPLY_FAILED",
|
|
2630
|
+
)
|
|
2631
|
+
if edit_context_error is not None:
|
|
2632
|
+
return finalize(edit_context_error)
|
|
2633
|
+
|
|
2634
|
+
created: list[dict[str, Any]] = []
|
|
2635
|
+
updated: list[dict[str, Any]] = []
|
|
2636
|
+
removed: list[dict[str, Any]] = []
|
|
2637
|
+
failed: list[dict[str, Any]] = []
|
|
2638
|
+
client_key_map: dict[str, int] = {}
|
|
2639
|
+
write_executed = False
|
|
2640
|
+
|
|
2641
|
+
for op in upsert_ops:
|
|
2642
|
+
patch = cast(CustomButtonUpsertPatch, op["patch"])
|
|
2643
|
+
patch_payload = _serialize_custom_button_payload(patch, add_data_config_override=compiled_add_data_configs.get(int(op["index"])))
|
|
2644
|
+
try:
|
|
2645
|
+
if op["operation"] == "create":
|
|
2646
|
+
write_executed = True
|
|
2647
|
+
result = self.buttons.custom_button_create(profile=profile, app_key=app_key, payload=patch_payload)
|
|
2648
|
+
button_id = _extract_custom_button_id(result.get("result"))
|
|
2649
|
+
if button_id is None:
|
|
2650
|
+
try:
|
|
2651
|
+
readback_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
|
|
2652
|
+
matches = [
|
|
2653
|
+
item
|
|
2654
|
+
for item in readback_buttons
|
|
2655
|
+
if str(item.get("button_text") or "").strip() == str(patch.button_text or "").strip()
|
|
2656
|
+
and _coerce_positive_int(item.get("button_id")) is not None
|
|
2657
|
+
]
|
|
2658
|
+
if len(matches) == 1:
|
|
2659
|
+
button_id = _coerce_positive_int(matches[0].get("button_id"))
|
|
2660
|
+
except (QingflowApiError, RuntimeError):
|
|
2661
|
+
button_id = None
|
|
2662
|
+
entry = {
|
|
2663
|
+
"index": op["index"],
|
|
2664
|
+
"operation": "create",
|
|
2665
|
+
"status": "success" if button_id is not None else "unverified",
|
|
2666
|
+
"button_id": button_id,
|
|
2667
|
+
"button_text": patch.button_text,
|
|
2668
|
+
}
|
|
2669
|
+
created.append(entry)
|
|
2670
|
+
if button_id is not None and patch.client_key:
|
|
2671
|
+
client_key_map[str(patch.client_key)] = button_id
|
|
2672
|
+
else:
|
|
2673
|
+
button_id = int(op["button_id"])
|
|
2674
|
+
write_executed = True
|
|
2675
|
+
self.buttons.custom_button_update(profile=profile, app_key=app_key, button_id=button_id, payload=patch_payload)
|
|
2676
|
+
updated.append(
|
|
2677
|
+
{
|
|
2678
|
+
"index": op["index"],
|
|
2679
|
+
"operation": "update",
|
|
2680
|
+
"status": "success",
|
|
2681
|
+
"button_id": button_id,
|
|
2682
|
+
"button_text": patch.button_text,
|
|
2683
|
+
}
|
|
2684
|
+
)
|
|
2685
|
+
if patch.client_key:
|
|
2686
|
+
client_key_map[str(patch.client_key)] = button_id
|
|
2687
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2688
|
+
api_error = _coerce_api_error(error)
|
|
2689
|
+
failed.append(
|
|
2690
|
+
{
|
|
2691
|
+
"index": op["index"],
|
|
2692
|
+
"operation": op["operation"],
|
|
2693
|
+
"button_id": op.get("button_id"),
|
|
2694
|
+
"button_text": patch.button_text,
|
|
2695
|
+
"status": "failed",
|
|
2696
|
+
"error_code": "CUSTOM_BUTTON_WRITE_FAILED",
|
|
2697
|
+
"message": api_error.message,
|
|
2698
|
+
"transport_error": _transport_error_payload(api_error),
|
|
2699
|
+
}
|
|
2700
|
+
)
|
|
2701
|
+
|
|
2702
|
+
for op in remove_ops:
|
|
2703
|
+
selector = cast(CustomButtonRemovePatch, op["selector"])
|
|
2704
|
+
button_id = int(op["button_id"])
|
|
2705
|
+
try:
|
|
2706
|
+
write_executed = True
|
|
2707
|
+
self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
|
|
2708
|
+
removed.append(
|
|
2709
|
+
{
|
|
2710
|
+
"index": op["index"],
|
|
2711
|
+
"operation": "remove",
|
|
2712
|
+
"status": "success",
|
|
2713
|
+
"button_id": button_id,
|
|
2714
|
+
"button_text": selector.button_text or (existing_by_id.get(button_id) or {}).get("button_text"),
|
|
2715
|
+
}
|
|
2716
|
+
)
|
|
2717
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
2718
|
+
api_error = _coerce_api_error(error)
|
|
2719
|
+
failed.append(
|
|
2720
|
+
{
|
|
2721
|
+
"index": op["index"],
|
|
2722
|
+
"operation": "remove",
|
|
2723
|
+
"button_id": button_id,
|
|
2724
|
+
"button_text": selector.button_text,
|
|
2725
|
+
"status": "failed",
|
|
2726
|
+
"error_code": "CUSTOM_BUTTON_WRITE_FAILED",
|
|
2727
|
+
"message": api_error.message,
|
|
2728
|
+
"transport_error": _transport_error_payload(api_error),
|
|
2729
|
+
}
|
|
2730
|
+
)
|
|
2731
|
+
|
|
2732
|
+
readback_buttons: list[dict[str, Any]] = []
|
|
2733
|
+
readback_failed = False
|
|
2734
|
+
try:
|
|
2735
|
+
readback_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
|
|
2736
|
+
except (QingflowApiError, RuntimeError):
|
|
2737
|
+
readback_failed = True
|
|
2738
|
+
readback_ids = {
|
|
2739
|
+
button_id
|
|
2740
|
+
for item in readback_buttons
|
|
2741
|
+
if (button_id := _coerce_positive_int(item.get("button_id"))) is not None
|
|
2742
|
+
}
|
|
2743
|
+
created_ids = [
|
|
2744
|
+
int(item["button_id"])
|
|
2745
|
+
for item in created
|
|
2746
|
+
if _coerce_positive_int(item.get("button_id")) is not None
|
|
2747
|
+
]
|
|
2748
|
+
updated_ids = [
|
|
2749
|
+
int(item["button_id"])
|
|
2750
|
+
for item in updated
|
|
2751
|
+
if _coerce_positive_int(item.get("button_id")) is not None
|
|
2752
|
+
]
|
|
2753
|
+
removed_ids = [
|
|
2754
|
+
int(item["button_id"])
|
|
2755
|
+
for item in removed
|
|
2756
|
+
if _coerce_positive_int(item.get("button_id")) is not None
|
|
2757
|
+
]
|
|
2758
|
+
verified = (
|
|
2759
|
+
not readback_failed
|
|
2760
|
+
and all(button_id in readback_ids for button_id in created_ids + updated_ids)
|
|
2761
|
+
and all(button_id not in readback_ids for button_id in removed_ids)
|
|
2762
|
+
and not failed
|
|
2763
|
+
and all(_coerce_positive_int(item.get("button_id")) is not None for item in created)
|
|
2764
|
+
)
|
|
2765
|
+
custom_buttons_verified = verified
|
|
2766
|
+
view_config_results: list[dict[str, Any]] = []
|
|
2767
|
+
view_config_failed: list[dict[str, Any]] = []
|
|
2768
|
+
view_config_warnings: list[dict[str, Any]] = []
|
|
2769
|
+
view_config_verified = True
|
|
2770
|
+
view_config_write_executed = False
|
|
2771
|
+
view_config_write_succeeded = False
|
|
2772
|
+
if request.view_configs:
|
|
2773
|
+
view_config_result = self._apply_custom_button_view_configs(
|
|
2774
|
+
profile=profile,
|
|
2775
|
+
app_key=app_key,
|
|
2776
|
+
view_configs=request.view_configs,
|
|
2777
|
+
client_key_map=client_key_map,
|
|
2778
|
+
existing_buttons=existing_buttons,
|
|
2779
|
+
readback_buttons=readback_buttons,
|
|
2780
|
+
created_ids=created_ids,
|
|
2781
|
+
updated_ids=updated_ids,
|
|
2782
|
+
removed_ids=removed_ids,
|
|
2783
|
+
)
|
|
2784
|
+
view_config_results = list(view_config_result.get("view_configs") or [])
|
|
2785
|
+
view_config_failed = list(view_config_result.get("failed") or [])
|
|
2786
|
+
view_config_warnings = list(view_config_result.get("warnings") or [])
|
|
2787
|
+
view_config_verified = bool(view_config_result.get("verified", False))
|
|
2788
|
+
view_config_write_executed = bool(view_config_result.get("write_executed", False))
|
|
2789
|
+
view_config_write_succeeded = bool(view_config_result.get("write_succeeded", False))
|
|
2790
|
+
write_executed = write_executed or view_config_write_executed
|
|
2791
|
+
if view_config_failed:
|
|
2792
|
+
failed.extend(view_config_failed)
|
|
2793
|
+
if view_config_warnings:
|
|
2794
|
+
if not isinstance(view_config_warnings, list):
|
|
2795
|
+
view_config_warnings = []
|
|
2796
|
+
verified = custom_buttons_verified and view_config_verified
|
|
2797
|
+
write_succeeded = bool(created or updated or removed or view_config_write_succeeded)
|
|
2798
|
+
status = "success" if verified else ("partial_success" if write_succeeded else "failed")
|
|
2799
|
+
error_code = None if verified else (
|
|
2800
|
+
"CUSTOM_BUTTON_READBACK_PENDING" if readback_failed else "CUSTOM_BUTTON_APPLY_PARTIAL" if write_succeeded else "CUSTOM_BUTTON_APPLY_FAILED"
|
|
2801
|
+
)
|
|
2802
|
+
message = (
|
|
2803
|
+
"applied custom button patch"
|
|
2804
|
+
if verified
|
|
2805
|
+
else "custom button apply completed with partial verification"
|
|
2806
|
+
if write_succeeded
|
|
2807
|
+
else "custom button apply failed; no successful write was confirmed"
|
|
2808
|
+
)
|
|
2809
|
+
warnings = []
|
|
2810
|
+
warnings.extend(view_config_warnings)
|
|
2811
|
+
if not verified:
|
|
2812
|
+
warnings.append(
|
|
2813
|
+
_warning(
|
|
2814
|
+
"CUSTOM_BUTTON_APPLY_PARTIAL" if write_succeeded else "CUSTOM_BUTTON_APPLY_FAILED",
|
|
2815
|
+
"custom button write landed but verification is partial; inspect created/updated/removed/failed"
|
|
2816
|
+
if write_succeeded
|
|
2817
|
+
else "custom button writes all failed or produced no confirmed result; application was not published",
|
|
2818
|
+
)
|
|
2819
|
+
)
|
|
2820
|
+
response = {
|
|
2821
|
+
"status": status,
|
|
2822
|
+
"error_code": error_code,
|
|
2823
|
+
"recoverable": not verified,
|
|
2824
|
+
"message": message,
|
|
2825
|
+
"normalized_args": normalized_args,
|
|
2826
|
+
"missing_fields": [],
|
|
2827
|
+
"allowed_values": {
|
|
2828
|
+
"trigger_action": [member.value for member in PublicButtonTriggerAction],
|
|
2829
|
+
"style_preset": [item["key"] for item in button_style_catalog_payload()["presets"]],
|
|
2830
|
+
},
|
|
2831
|
+
"details": {
|
|
2832
|
+
"edit_version_no": edit_version_no,
|
|
2833
|
+
"button_ids_by_client_key": client_key_map,
|
|
2834
|
+
"readback_failed": readback_failed,
|
|
2835
|
+
},
|
|
2836
|
+
"request_id": None,
|
|
2837
|
+
"suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2838
|
+
"noop": False,
|
|
2839
|
+
"warnings": warnings,
|
|
2840
|
+
"verification": {
|
|
2841
|
+
"custom_buttons_verified": custom_buttons_verified,
|
|
2842
|
+
"readback_loaded": not readback_failed,
|
|
2843
|
+
"created_verified": not readback_failed and all(button_id in readback_ids for button_id in created_ids),
|
|
2844
|
+
"updated_verified": not readback_failed and all(button_id in readback_ids for button_id in updated_ids),
|
|
2845
|
+
"removed_verified": not readback_failed and all(button_id not in readback_ids for button_id in removed_ids),
|
|
2846
|
+
"view_button_bindings_verified": view_config_verified,
|
|
2847
|
+
},
|
|
2848
|
+
"verified": verified,
|
|
2849
|
+
"app_key": app_key,
|
|
2850
|
+
"mode": "apply",
|
|
2851
|
+
"created": created,
|
|
2852
|
+
"updated": updated,
|
|
2853
|
+
"removed": removed,
|
|
2854
|
+
"failed": failed,
|
|
2855
|
+
"view_configs": view_config_results,
|
|
2856
|
+
"button_ids_by_client_key": client_key_map,
|
|
2857
|
+
"write_executed": write_executed,
|
|
2858
|
+
"write_succeeded": write_succeeded,
|
|
2859
|
+
"safe_to_retry": not write_executed,
|
|
2860
|
+
}
|
|
2861
|
+
return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=write_succeeded, response=response, edit_version_no=edit_version_no))
|
|
2862
|
+
|
|
2377
2863
|
def app_custom_button_list(self, *, profile: str, app_key: str) -> JSONObject:
|
|
2378
2864
|
normalized_args = {"app_key": app_key}
|
|
2379
2865
|
permission_outcomes: list[PermissionCheckOutcome] = []
|
|
@@ -2748,45 +3234,541 @@ class AiBuilderFacade:
|
|
|
2748
3234
|
)
|
|
2749
3235
|
)
|
|
2750
3236
|
|
|
2751
|
-
def
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
3237
|
+
def app_associated_resources_apply(self, *, profile: str, request: AssociatedResourcesApplyRequest) -> JSONObject:
|
|
3238
|
+
normalized_args = request.model_dump(mode="json", exclude_none=True)
|
|
3239
|
+
app_key = request.app_key
|
|
3240
|
+
permission_outcomes: list[PermissionCheckOutcome] = []
|
|
3241
|
+
permission_outcome = self._guard_app_permission(
|
|
3242
|
+
profile=profile,
|
|
3243
|
+
app_key=app_key,
|
|
3244
|
+
required_permission="data_manage",
|
|
3245
|
+
normalized_args=normalized_args,
|
|
3246
|
+
)
|
|
3247
|
+
if permission_outcome.block is not None:
|
|
3248
|
+
return permission_outcome.block
|
|
3249
|
+
permission_outcomes.append(permission_outcome)
|
|
3250
|
+
|
|
3251
|
+
def finalize(response: JSONObject) -> JSONObject:
|
|
3252
|
+
return _apply_permission_outcomes(response, *permission_outcomes)
|
|
3253
|
+
|
|
2758
3254
|
try:
|
|
2759
|
-
|
|
2760
|
-
except (QingflowApiError, RuntimeError):
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
3255
|
+
existing_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
|
|
3256
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
3257
|
+
api_error = _coerce_api_error(error)
|
|
3258
|
+
return finalize(_failed_from_api_error(
|
|
3259
|
+
"ASSOCIATED_RESOURCES_READ_FAILED",
|
|
3260
|
+
api_error,
|
|
3261
|
+
normalized_args=normalized_args,
|
|
3262
|
+
details=_with_state_read_blocked_details({"app_key": app_key}, resource="associated_resources", error=api_error),
|
|
3263
|
+
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
3264
|
+
))
|
|
3265
|
+
|
|
3266
|
+
existing_by_id = _associated_resource_index(existing_resources)
|
|
3267
|
+
blocking_issues: list[dict[str, Any]] = []
|
|
3268
|
+
upsert_ops: list[dict[str, Any]] = []
|
|
3269
|
+
touched_ids: set[int] = set()
|
|
3270
|
+
client_key_to_patch: dict[str, AssociatedResourceUpsertPatch] = {}
|
|
3271
|
+
client_key_to_id: dict[str, int] = {}
|
|
3272
|
+
used_client_keys: set[str] = set()
|
|
3273
|
+
|
|
3274
|
+
for index, patch in enumerate(request.upsert_resources):
|
|
3275
|
+
client_key = str(patch.client_key or "").strip()
|
|
3276
|
+
if client_key:
|
|
3277
|
+
if client_key in used_client_keys:
|
|
3278
|
+
blocking_issues.append({"error_code": "DUPLICATE_CLIENT_KEY", "reason_path": f"upsert_resources[{index}].client_key", "client_key": client_key})
|
|
3279
|
+
used_client_keys.add(client_key)
|
|
3280
|
+
client_key_to_patch[client_key] = patch
|
|
3281
|
+
validation_issue = _validate_associated_resource_patch(patch, reason_path=f"upsert_resources[{index}]")
|
|
3282
|
+
if validation_issue is not None:
|
|
3283
|
+
blocking_issues.append(validation_issue)
|
|
2767
3284
|
continue
|
|
2768
|
-
|
|
2769
|
-
if
|
|
3285
|
+
associated_item_id = _coerce_positive_int(patch.associated_item_id)
|
|
3286
|
+
if associated_item_id is not None:
|
|
3287
|
+
if associated_item_id not in existing_by_id:
|
|
3288
|
+
blocking_issues.append(_associated_resource_not_found_issue(f"upsert_resources[{index}].associated_item_id", associated_item_id, existing_by_id))
|
|
3289
|
+
continue
|
|
3290
|
+
if associated_item_id in touched_ids:
|
|
3291
|
+
blocking_issues.append(_duplicate_associated_resource_issue(f"upsert_resources[{index}]", associated_item_id))
|
|
3292
|
+
continue
|
|
3293
|
+
touched_ids.add(associated_item_id)
|
|
3294
|
+
if client_key:
|
|
3295
|
+
client_key_to_id[client_key] = associated_item_id
|
|
3296
|
+
operation = "unchanged" if _associated_resource_matches_patch(existing_by_id[associated_item_id], patch) else "update"
|
|
3297
|
+
upsert_ops.append({"operation": operation, "associated_item_id": associated_item_id, "patch": patch, "index": index})
|
|
2770
3298
|
continue
|
|
2771
|
-
|
|
2772
|
-
|
|
3299
|
+
matches = [
|
|
3300
|
+
item
|
|
3301
|
+
for item in existing_resources
|
|
3302
|
+
if _associated_resource_matches_patch(item, patch)
|
|
3303
|
+
and _coerce_positive_int(item.get("associated_item_id")) is not None
|
|
3304
|
+
]
|
|
3305
|
+
if len(matches) > 1:
|
|
3306
|
+
blocking_issues.append(
|
|
3307
|
+
{
|
|
3308
|
+
"error_code": "AMBIGUOUS_ASSOCIATED_RESOURCE",
|
|
3309
|
+
"reason_path": f"upsert_resources[{index}]",
|
|
3310
|
+
"candidate_associated_item_ids": [
|
|
3311
|
+
item.get("associated_item_id")
|
|
3312
|
+
for item in matches
|
|
3313
|
+
if _coerce_positive_int(item.get("associated_item_id")) is not None
|
|
3314
|
+
],
|
|
3315
|
+
"message": "resource identity matches multiple associated resources; pass associated_item_id",
|
|
3316
|
+
}
|
|
3317
|
+
)
|
|
2773
3318
|
continue
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
3319
|
+
if len(matches) == 1:
|
|
3320
|
+
matched_id = _coerce_positive_int(matches[0].get("associated_item_id"))
|
|
3321
|
+
if matched_id is None:
|
|
3322
|
+
blocking_issues.append({"error_code": "ASSOCIATED_RESOURCE_ID_MISSING", "reason_path": f"upsert_resources[{index}]"})
|
|
3323
|
+
continue
|
|
3324
|
+
if matched_id in touched_ids:
|
|
3325
|
+
blocking_issues.append(_duplicate_associated_resource_issue(f"upsert_resources[{index}]", matched_id))
|
|
3326
|
+
continue
|
|
3327
|
+
touched_ids.add(matched_id)
|
|
3328
|
+
if client_key:
|
|
3329
|
+
client_key_to_id[client_key] = matched_id
|
|
3330
|
+
upsert_ops.append({"operation": "unchanged", "associated_item_id": matched_id, "patch": patch, "index": index})
|
|
3331
|
+
else:
|
|
3332
|
+
upsert_ops.append({"operation": "create", "associated_item_id": None, "patch": patch, "index": index})
|
|
3333
|
+
|
|
3334
|
+
remove_ids = [
|
|
3335
|
+
item_id
|
|
3336
|
+
for item_id in (_coerce_positive_int(raw_id) for raw_id in request.remove_associated_item_ids)
|
|
3337
|
+
if item_id is not None
|
|
3338
|
+
]
|
|
3339
|
+
for raw_id in request.remove_associated_item_ids:
|
|
3340
|
+
item_id = _coerce_positive_int(raw_id)
|
|
3341
|
+
if item_id is None:
|
|
3342
|
+
blocking_issues.append({"error_code": "ASSOCIATED_RESOURCE_NOT_FOUND", "reason_path": "remove_associated_item_ids", "received": raw_id})
|
|
2779
3343
|
continue
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
3344
|
+
if item_id not in existing_by_id:
|
|
3345
|
+
blocking_issues.append(_associated_resource_not_found_issue("remove_associated_item_ids", item_id, existing_by_id))
|
|
3346
|
+
elif item_id in touched_ids:
|
|
3347
|
+
blocking_issues.append(_duplicate_associated_resource_issue("remove_associated_item_ids", item_id))
|
|
3348
|
+
else:
|
|
3349
|
+
touched_ids.add(item_id)
|
|
2783
3350
|
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
3351
|
+
reorder_ids = [
|
|
3352
|
+
item_id
|
|
3353
|
+
for item_id in (_coerce_positive_int(raw_id) for raw_id in request.reorder_associated_item_ids)
|
|
3354
|
+
if item_id is not None
|
|
3355
|
+
]
|
|
3356
|
+
for raw_id in request.reorder_associated_item_ids:
|
|
3357
|
+
item_id = _coerce_positive_int(raw_id)
|
|
3358
|
+
if item_id is None:
|
|
3359
|
+
blocking_issues.append({"error_code": "ASSOCIATED_RESOURCE_NOT_FOUND", "reason_path": "reorder_associated_item_ids", "received": raw_id})
|
|
3360
|
+
elif item_id not in existing_by_id:
|
|
3361
|
+
blocking_issues.append(_associated_resource_not_found_issue("reorder_associated_item_ids", item_id, existing_by_id))
|
|
3362
|
+
|
|
3363
|
+
for index, view_config in enumerate(request.view_configs):
|
|
3364
|
+
refs = [str(ref or "").strip() for ref in view_config.associated_item_refs if str(ref or "").strip()]
|
|
3365
|
+
missing_refs = [ref for ref in refs if ref not in client_key_to_patch]
|
|
3366
|
+
if missing_refs:
|
|
3367
|
+
blocking_issues.append(
|
|
3368
|
+
{
|
|
3369
|
+
"error_code": "ASSOCIATED_RESOURCE_REF_NOT_FOUND",
|
|
3370
|
+
"reason_path": f"view_configs[{index}].associated_item_refs",
|
|
3371
|
+
"missing_refs": missing_refs,
|
|
3372
|
+
"message": "associated_item_refs must reference client_key values from upsert_resources in the same apply call",
|
|
3373
|
+
}
|
|
3374
|
+
)
|
|
3375
|
+
invalid_ids = [
|
|
3376
|
+
item_id
|
|
3377
|
+
for item_id in view_config.associated_item_ids
|
|
3378
|
+
if _coerce_positive_int(item_id) is None or _coerce_positive_int(item_id) not in existing_by_id
|
|
3379
|
+
]
|
|
3380
|
+
if invalid_ids:
|
|
3381
|
+
blocking_issues.append(
|
|
3382
|
+
{
|
|
3383
|
+
"error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
|
|
3384
|
+
"reason_path": f"view_configs[{index}].associated_item_ids",
|
|
3385
|
+
"invalid_associated_item_ids": invalid_ids,
|
|
3386
|
+
"available_associated_item_ids": sorted(existing_by_id),
|
|
3387
|
+
"message": "view_configs.associated_item_ids must use app-level associated_item_id values from app_get",
|
|
3388
|
+
}
|
|
3389
|
+
)
|
|
3390
|
+
raw_limit_type = str(view_config.limit_type or ("all" if view_config.visible else "")).strip().lower()
|
|
3391
|
+
if view_config.visible and raw_limit_type and raw_limit_type not in {"all", "select"}:
|
|
3392
|
+
blocking_issues.append(
|
|
3393
|
+
{
|
|
3394
|
+
"error_code": "INVALID_ASSOCIATED_RESOURCE_LIMIT_TYPE",
|
|
3395
|
+
"reason_path": f"view_configs[{index}].limit_type",
|
|
3396
|
+
"received": view_config.limit_type,
|
|
3397
|
+
"expected_values": ["all", "select"],
|
|
3398
|
+
}
|
|
3399
|
+
)
|
|
3400
|
+
|
|
3401
|
+
if blocking_issues:
|
|
3402
|
+
return finalize(
|
|
3403
|
+
_failed(
|
|
3404
|
+
"ASSOCIATED_RESOURCES_APPLY_BLOCKED",
|
|
3405
|
+
"associated resources apply has blocking issues; no write was executed",
|
|
3406
|
+
normalized_args=normalized_args,
|
|
3407
|
+
details={"blocking_issues": blocking_issues},
|
|
3408
|
+
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
3409
|
+
)
|
|
3410
|
+
)
|
|
3411
|
+
|
|
3412
|
+
has_write_intent = (
|
|
3413
|
+
any(str(op.get("operation") or "") != "unchanged" for op in upsert_ops)
|
|
3414
|
+
or bool(remove_ids)
|
|
3415
|
+
or bool(reorder_ids)
|
|
3416
|
+
or bool(request.view_configs)
|
|
3417
|
+
)
|
|
3418
|
+
if not has_write_intent:
|
|
3419
|
+
unchanged: list[dict[str, Any]] = []
|
|
3420
|
+
for op in upsert_ops:
|
|
3421
|
+
patch = cast(AssociatedResourceUpsertPatch, op["patch"])
|
|
3422
|
+
item_id = int(op["associated_item_id"])
|
|
3423
|
+
unchanged.append(_associated_resource_result_entry("unchanged", op["index"], patch, associated_item_id=item_id))
|
|
3424
|
+
if patch.client_key:
|
|
3425
|
+
client_key_to_id[str(patch.client_key)] = item_id
|
|
3426
|
+
response = {
|
|
3427
|
+
"status": "success",
|
|
3428
|
+
"error_code": None,
|
|
3429
|
+
"recoverable": False,
|
|
3430
|
+
"message": "associated resources already match requested state; no write was executed",
|
|
3431
|
+
"normalized_args": normalized_args,
|
|
3432
|
+
"missing_fields": [],
|
|
3433
|
+
"allowed_values": {"graph_type": ["chart", "view"], "report_source": ["app", "dataset"], "view_config.limit_type": ["all", "select"]},
|
|
3434
|
+
"details": {"edit_version_no": None, "associated_item_ids_by_client_key": client_key_to_id, "readback_failed": False},
|
|
3435
|
+
"request_id": None,
|
|
3436
|
+
"suggested_next_call": None,
|
|
3437
|
+
"noop": True,
|
|
3438
|
+
"warnings": [],
|
|
3439
|
+
"verification": {"associated_resources_verified": True, "associated_resource_view_configs_verified": True, "readback_loaded": True, "published": False},
|
|
3440
|
+
"verified": True,
|
|
3441
|
+
"app_key": app_key,
|
|
3442
|
+
"mode": "apply",
|
|
3443
|
+
"created": [],
|
|
3444
|
+
"updated": [],
|
|
3445
|
+
"unchanged": unchanged,
|
|
3446
|
+
"removed": [],
|
|
3447
|
+
"reordered_associated_item_ids": [],
|
|
3448
|
+
"view_configs": [],
|
|
3449
|
+
"failed": [],
|
|
3450
|
+
"associated_item_ids_by_client_key": client_key_to_id,
|
|
3451
|
+
"write_executed": False,
|
|
3452
|
+
"write_succeeded": False,
|
|
3453
|
+
"safe_to_retry": True,
|
|
3454
|
+
"publish_requested": False,
|
|
3455
|
+
"published": False,
|
|
3456
|
+
"associated_resources": existing_resources,
|
|
3457
|
+
}
|
|
3458
|
+
return finalize(response)
|
|
3459
|
+
|
|
3460
|
+
edit_version_no, edit_context_error = self._ensure_app_edit_context(
|
|
3461
|
+
profile=profile,
|
|
3462
|
+
app_key=app_key,
|
|
3463
|
+
normalized_args=normalized_args,
|
|
3464
|
+
failure_code="ASSOCIATED_RESOURCES_APPLY_FAILED",
|
|
3465
|
+
)
|
|
3466
|
+
if edit_context_error is not None:
|
|
3467
|
+
return finalize(edit_context_error)
|
|
3468
|
+
|
|
3469
|
+
created: list[dict[str, Any]] = []
|
|
3470
|
+
updated: list[dict[str, Any]] = []
|
|
3471
|
+
unchanged: list[dict[str, Any]] = []
|
|
3472
|
+
removed: list[dict[str, Any]] = []
|
|
3473
|
+
reordered: list[int] = []
|
|
3474
|
+
view_config_results: list[dict[str, Any]] = []
|
|
3475
|
+
failed: list[dict[str, Any]] = []
|
|
3476
|
+
write_executed = False
|
|
3477
|
+
|
|
3478
|
+
for op in upsert_ops:
|
|
3479
|
+
patch = cast(AssociatedResourceUpsertPatch, op["patch"])
|
|
3480
|
+
try:
|
|
3481
|
+
if op["operation"] == "unchanged":
|
|
3482
|
+
item_id = int(op["associated_item_id"])
|
|
3483
|
+
unchanged.append(_associated_resource_result_entry("unchanged", op["index"], patch, associated_item_id=item_id))
|
|
3484
|
+
if patch.client_key:
|
|
3485
|
+
client_key_to_id[str(patch.client_key)] = item_id
|
|
3486
|
+
elif op["operation"] == "create":
|
|
3487
|
+
write_executed = True
|
|
3488
|
+
self._associated_resource_create(profile=profile, app_key=app_key, patch=patch)
|
|
3489
|
+
readback_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
|
|
3490
|
+
matches = [
|
|
3491
|
+
item
|
|
3492
|
+
for item in readback_resources
|
|
3493
|
+
if _associated_resource_matches_patch(item, patch)
|
|
3494
|
+
and _coerce_positive_int(item.get("associated_item_id")) is not None
|
|
3495
|
+
]
|
|
3496
|
+
created_id = _coerce_positive_int(matches[0].get("associated_item_id")) if len(matches) == 1 else None
|
|
3497
|
+
created.append(_associated_resource_result_entry("create", op["index"], patch, associated_item_id=created_id))
|
|
3498
|
+
if created_id is not None and patch.client_key:
|
|
3499
|
+
client_key_to_id[str(patch.client_key)] = created_id
|
|
3500
|
+
else:
|
|
3501
|
+
item_id = int(op["associated_item_id"])
|
|
3502
|
+
write_executed = True
|
|
3503
|
+
self._associated_resource_update(profile=profile, app_key=app_key, associated_item_id=item_id, patch=patch)
|
|
3504
|
+
updated.append(_associated_resource_result_entry("update", op["index"], patch, associated_item_id=item_id))
|
|
3505
|
+
if patch.client_key:
|
|
3506
|
+
client_key_to_id[str(patch.client_key)] = item_id
|
|
3507
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
3508
|
+
api_error = _coerce_api_error(error)
|
|
3509
|
+
failed.append(
|
|
3510
|
+
{
|
|
3511
|
+
"index": op["index"],
|
|
3512
|
+
"operation": op["operation"],
|
|
3513
|
+
"associated_item_id": op.get("associated_item_id"),
|
|
3514
|
+
"status": "failed",
|
|
3515
|
+
"error_code": "ASSOCIATED_RESOURCE_WRITE_FAILED",
|
|
3516
|
+
"message": api_error.message,
|
|
3517
|
+
"transport_error": _transport_error_payload(api_error),
|
|
3518
|
+
}
|
|
3519
|
+
)
|
|
3520
|
+
|
|
3521
|
+
for item_id in remove_ids:
|
|
3522
|
+
try:
|
|
3523
|
+
write_executed = True
|
|
3524
|
+
self._associated_resource_delete(profile=profile, app_key=app_key, associated_item_id=item_id)
|
|
3525
|
+
removed.append({"associated_item_id": item_id, "status": "success"})
|
|
3526
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
3527
|
+
api_error = _coerce_api_error(error)
|
|
3528
|
+
failed.append({"operation": "remove", "associated_item_id": item_id, "status": "failed", "error_code": "ASSOCIATED_RESOURCE_WRITE_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
|
|
3529
|
+
|
|
3530
|
+
if reorder_ids:
|
|
3531
|
+
try:
|
|
3532
|
+
write_executed = True
|
|
3533
|
+
self._associated_resource_reorder(profile=profile, app_key=app_key, associated_item_ids=reorder_ids)
|
|
3534
|
+
reordered = list(reorder_ids)
|
|
3535
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
3536
|
+
api_error = _coerce_api_error(error)
|
|
3537
|
+
failed.append({"operation": "reorder", "associated_item_ids": reorder_ids, "status": "failed", "error_code": "ASSOCIATED_RESOURCE_REORDER_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
|
|
3538
|
+
|
|
3539
|
+
try:
|
|
3540
|
+
resources_after = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
|
|
3541
|
+
except (QingflowApiError, RuntimeError):
|
|
3542
|
+
resources_after = []
|
|
3543
|
+
|
|
3544
|
+
for index, view_config in enumerate(request.view_configs):
|
|
3545
|
+
selected_ids = [
|
|
3546
|
+
item_id
|
|
3547
|
+
for item_id in (
|
|
3548
|
+
_coerce_positive_int(raw_id)
|
|
3549
|
+
for raw_id in [
|
|
3550
|
+
*view_config.associated_item_ids,
|
|
3551
|
+
*[client_key_to_id.get(str(ref or "").strip()) for ref in view_config.associated_item_refs],
|
|
3552
|
+
]
|
|
3553
|
+
)
|
|
3554
|
+
if item_id is not None
|
|
3555
|
+
]
|
|
3556
|
+
view_payload, expected_config, issues = _build_view_associated_resources_payload(
|
|
3557
|
+
associated_resources={"visible": view_config.visible, "limit_type": view_config.limit_type or ("all" if view_config.visible else None), "associated_item_ids": selected_ids},
|
|
3558
|
+
available_resources=resources_after,
|
|
3559
|
+
)
|
|
3560
|
+
if issues:
|
|
3561
|
+
failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "status": "failed", "issues": issues})
|
|
3562
|
+
continue
|
|
3563
|
+
try:
|
|
3564
|
+
write_executed = True
|
|
3565
|
+
self._update_view_associated_resources_config(profile=profile, view_key=view_config.view_key, associated_resources_payload=view_payload)
|
|
3566
|
+
try:
|
|
3567
|
+
config = self.views.view_get_config(profile=profile, viewgraph_key=view_config.view_key).get("result") or {}
|
|
3568
|
+
actual_config = _extract_view_associated_resources_config(config if isinstance(config, dict) else {}, available_resources=resources_after)
|
|
3569
|
+
verified_config = expected_config is not None and _associated_resources_config_matches(expected_config, actual_config)
|
|
3570
|
+
if not verified_config and expected_config is not None:
|
|
3571
|
+
self._update_view_associated_resources_config(profile=profile, view_key=view_config.view_key, associated_resources_payload=view_payload)
|
|
3572
|
+
refreshed_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
|
|
3573
|
+
refreshed_config = self.views.view_get_config(profile=profile, viewgraph_key=view_config.view_key).get("result") or {}
|
|
3574
|
+
actual_config = _extract_view_associated_resources_config(
|
|
3575
|
+
refreshed_config if isinstance(refreshed_config, dict) else {},
|
|
3576
|
+
available_resources=refreshed_resources,
|
|
3577
|
+
)
|
|
3578
|
+
verified_config = _associated_resources_config_matches(expected_config, actual_config)
|
|
3579
|
+
except (QingflowApiError, RuntimeError):
|
|
3580
|
+
actual_config = {}
|
|
3581
|
+
verified_config = False
|
|
3582
|
+
view_config_results.append({"index": index, "view_key": view_config.view_key, "status": "success" if verified_config else "partial_success", "associated_resources_verified": verified_config, "expected": expected_config, "actual": actual_config})
|
|
3583
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
3584
|
+
api_error = _coerce_api_error(error)
|
|
3585
|
+
failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "status": "failed", "error_code": "VIEW_ASSOCIATED_RESOURCES_WRITE_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
|
|
3586
|
+
|
|
3587
|
+
final_resources: list[dict[str, Any]] = []
|
|
3588
|
+
readback_failed = False
|
|
3589
|
+
try:
|
|
3590
|
+
final_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
|
|
3591
|
+
except (QingflowApiError, RuntimeError):
|
|
3592
|
+
readback_failed = True
|
|
3593
|
+
final_by_id = _associated_resource_index(final_resources)
|
|
3594
|
+
created_ids = [int(item["associated_item_id"]) for item in created if _coerce_positive_int(item.get("associated_item_id")) is not None]
|
|
3595
|
+
updated_ids = [int(item["associated_item_id"]) for item in updated if _coerce_positive_int(item.get("associated_item_id")) is not None]
|
|
3596
|
+
unchanged_ids = [int(item["associated_item_id"]) for item in unchanged if _coerce_positive_int(item.get("associated_item_id")) is not None]
|
|
3597
|
+
removed_ids = [int(item["associated_item_id"]) for item in removed if _coerce_positive_int(item.get("associated_item_id")) is not None]
|
|
3598
|
+
pool_verified = (
|
|
3599
|
+
not readback_failed
|
|
3600
|
+
and all(item_id in final_by_id for item_id in created_ids + updated_ids + unchanged_ids)
|
|
3601
|
+
and all(item_id not in final_by_id for item_id in removed_ids)
|
|
3602
|
+
and not failed
|
|
3603
|
+
and all(_coerce_positive_int(item.get("associated_item_id")) is not None for item in created)
|
|
3604
|
+
)
|
|
3605
|
+
view_configs_verified = all(bool(item.get("associated_resources_verified")) for item in view_config_results) and not any(
|
|
3606
|
+
item.get("operation") == "view_config" for item in failed
|
|
3607
|
+
)
|
|
3608
|
+
verified = pool_verified and view_configs_verified
|
|
3609
|
+
write_succeeded = bool(created or updated or removed or reordered or view_config_results)
|
|
3610
|
+
operation_succeeded = write_succeeded or bool(unchanged)
|
|
3611
|
+
status = "success" if verified else ("partial_success" if operation_succeeded else "failed")
|
|
3612
|
+
error_code = None if verified else (
|
|
3613
|
+
"ASSOCIATED_RESOURCES_READBACK_PENDING"
|
|
3614
|
+
if readback_failed
|
|
3615
|
+
else "ASSOCIATED_RESOURCES_APPLY_PARTIAL"
|
|
3616
|
+
if operation_succeeded
|
|
3617
|
+
else "ASSOCIATED_RESOURCES_APPLY_FAILED"
|
|
3618
|
+
)
|
|
3619
|
+
message = (
|
|
3620
|
+
"applied associated resources patch"
|
|
3621
|
+
if verified
|
|
3622
|
+
else "associated resources apply completed with partial verification"
|
|
3623
|
+
if operation_succeeded
|
|
3624
|
+
else "associated resources apply failed; no successful write was confirmed"
|
|
3625
|
+
)
|
|
3626
|
+
warnings = []
|
|
3627
|
+
if not verified:
|
|
3628
|
+
warnings.append(
|
|
3629
|
+
_warning(
|
|
3630
|
+
"ASSOCIATED_RESOURCES_APPLY_PARTIAL" if operation_succeeded else "ASSOCIATED_RESOURCES_APPLY_FAILED",
|
|
3631
|
+
"associated resource write landed but verification is partial; inspect created/updated/removed/failed/view_configs"
|
|
3632
|
+
if operation_succeeded
|
|
3633
|
+
else "associated resource writes all failed or produced no confirmed result; application was not published",
|
|
3634
|
+
)
|
|
3635
|
+
)
|
|
3636
|
+
response = {
|
|
3637
|
+
"status": status,
|
|
3638
|
+
"error_code": error_code,
|
|
3639
|
+
"recoverable": not verified,
|
|
3640
|
+
"message": message,
|
|
3641
|
+
"normalized_args": normalized_args,
|
|
3642
|
+
"missing_fields": [],
|
|
3643
|
+
"allowed_values": {"graph_type": ["chart", "view"], "report_source": ["app", "dataset"], "view_config.limit_type": ["all", "select"]},
|
|
3644
|
+
"details": {"edit_version_no": edit_version_no, "associated_item_ids_by_client_key": client_key_to_id, "readback_failed": readback_failed},
|
|
3645
|
+
"request_id": None,
|
|
3646
|
+
"suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
3647
|
+
"noop": False,
|
|
3648
|
+
"warnings": warnings,
|
|
3649
|
+
"verification": {"associated_resources_verified": pool_verified, "associated_resource_view_configs_verified": view_configs_verified, "readback_loaded": not readback_failed},
|
|
3650
|
+
"verified": verified,
|
|
3651
|
+
"app_key": app_key,
|
|
3652
|
+
"mode": "apply",
|
|
3653
|
+
"created": created,
|
|
3654
|
+
"updated": updated,
|
|
3655
|
+
"unchanged": unchanged,
|
|
3656
|
+
"removed": removed,
|
|
3657
|
+
"reordered_associated_item_ids": reordered,
|
|
3658
|
+
"view_configs": view_config_results,
|
|
3659
|
+
"failed": failed,
|
|
3660
|
+
"associated_item_ids_by_client_key": client_key_to_id,
|
|
3661
|
+
"write_executed": write_executed,
|
|
3662
|
+
"write_succeeded": write_succeeded,
|
|
3663
|
+
"safe_to_retry": not write_executed,
|
|
3664
|
+
"associated_resources": final_resources,
|
|
3665
|
+
}
|
|
3666
|
+
response = self._append_publish_result(profile=profile, app_key=app_key, publish=write_succeeded, response=response, edit_version_no=edit_version_no)
|
|
3667
|
+
if response.get("published") and view_config_results:
|
|
3668
|
+
try:
|
|
3669
|
+
post_publish_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
|
|
3670
|
+
except (QingflowApiError, RuntimeError):
|
|
3671
|
+
post_publish_resources = final_resources
|
|
3672
|
+
if post_publish_resources:
|
|
3673
|
+
response["associated_resources"] = post_publish_resources
|
|
3674
|
+
post_publish_verified_any = False
|
|
3675
|
+
for result in view_config_results:
|
|
3676
|
+
if result.get("associated_resources_verified"):
|
|
3677
|
+
continue
|
|
3678
|
+
expected_config = result.get("expected") if isinstance(result.get("expected"), dict) else None
|
|
3679
|
+
view_key = str(result.get("view_key") or "").strip()
|
|
3680
|
+
if expected_config is None or not view_key:
|
|
3681
|
+
continue
|
|
3682
|
+
try:
|
|
3683
|
+
config = self.views.view_get_config(profile=profile, viewgraph_key=view_key).get("result") or {}
|
|
3684
|
+
actual_config = _extract_view_associated_resources_config(
|
|
3685
|
+
config if isinstance(config, dict) else {},
|
|
3686
|
+
available_resources=post_publish_resources,
|
|
3687
|
+
)
|
|
3688
|
+
except (QingflowApiError, RuntimeError):
|
|
3689
|
+
continue
|
|
3690
|
+
if _associated_resources_config_matches(expected_config, actual_config):
|
|
3691
|
+
result["status"] = "success"
|
|
3692
|
+
result["associated_resources_verified"] = True
|
|
3693
|
+
result["actual"] = actual_config
|
|
3694
|
+
result["post_publish_verified"] = True
|
|
3695
|
+
post_publish_verified_any = True
|
|
3696
|
+
verification = response.get("verification")
|
|
3697
|
+
if not isinstance(verification, dict):
|
|
3698
|
+
verification = {}
|
|
3699
|
+
response["verification"] = verification
|
|
3700
|
+
post_view_configs_verified = all(bool(item.get("associated_resources_verified")) for item in view_config_results) and not any(
|
|
3701
|
+
item.get("operation") == "view_config" for item in failed
|
|
3702
|
+
)
|
|
3703
|
+
verification["associated_resource_view_configs_verified"] = post_view_configs_verified
|
|
3704
|
+
if post_publish_verified_any:
|
|
3705
|
+
warnings = response.get("warnings")
|
|
3706
|
+
if not isinstance(warnings, list):
|
|
3707
|
+
warnings = []
|
|
3708
|
+
response["warnings"] = warnings
|
|
3709
|
+
warnings[:] = [
|
|
3710
|
+
warning
|
|
3711
|
+
for warning in warnings
|
|
3712
|
+
if not (
|
|
3713
|
+
isinstance(warning, dict)
|
|
3714
|
+
and warning.get("code") in {"ASSOCIATED_RESOURCES_APPLY_PARTIAL", "ASSOCIATED_RESOURCES_APPLY_FAILED"}
|
|
3715
|
+
)
|
|
3716
|
+
]
|
|
3717
|
+
warnings.append(
|
|
3718
|
+
_warning(
|
|
3719
|
+
"VIEW_ASSOCIATED_RESOURCE_POST_PUBLISH_VERIFIED",
|
|
3720
|
+
"view associated resources were verified after publish because immediate draft readback lagged",
|
|
3721
|
+
)
|
|
3722
|
+
)
|
|
3723
|
+
post_verified = bool(verification.get("associated_resources_verified")) and post_view_configs_verified and response.get("published") and not failed
|
|
3724
|
+
response["verified"] = post_verified
|
|
3725
|
+
if post_verified:
|
|
3726
|
+
response["status"] = "success"
|
|
3727
|
+
response["error_code"] = None
|
|
3728
|
+
response["recoverable"] = False
|
|
3729
|
+
response["message"] = "applied associated resources patch"
|
|
3730
|
+
response["suggested_next_call"] = None
|
|
3731
|
+
return finalize(response)
|
|
3732
|
+
|
|
3733
|
+
def _resolve_app_matches_in_visible_apps(
|
|
3734
|
+
self,
|
|
3735
|
+
*,
|
|
3736
|
+
profile: str,
|
|
3737
|
+
app_name: str,
|
|
3738
|
+
package_tag_id: int,
|
|
3739
|
+
) -> list[JSONObject]:
|
|
3740
|
+
try:
|
|
3741
|
+
listing = self.apps.app_list(profile=profile, ship_auth=False)
|
|
3742
|
+
except (QingflowApiError, RuntimeError):
|
|
3743
|
+
return []
|
|
3744
|
+
items = listing.get("items") if isinstance(listing.get("items"), list) else []
|
|
3745
|
+
matches: list[JSONObject] = []
|
|
3746
|
+
seen_app_keys: set[str] = set()
|
|
3747
|
+
for item in items:
|
|
3748
|
+
if not isinstance(item, dict):
|
|
3749
|
+
continue
|
|
3750
|
+
title = str(item.get("title") or item.get("app_name") or "").strip()
|
|
3751
|
+
if title != app_name:
|
|
3752
|
+
continue
|
|
3753
|
+
candidate_key = str(item.get("app_key") or item.get("appKey") or "").strip()
|
|
3754
|
+
if not candidate_key or candidate_key in seen_app_keys:
|
|
3755
|
+
continue
|
|
3756
|
+
tag_ids = _coerce_int_list(item.get("tag_ids"))
|
|
3757
|
+
tag_id = _coerce_positive_int(item.get("tag_id"))
|
|
3758
|
+
if tag_id is not None and tag_id not in tag_ids:
|
|
3759
|
+
tag_ids.append(tag_id)
|
|
3760
|
+
if package_tag_id not in tag_ids:
|
|
3761
|
+
continue
|
|
3762
|
+
seen_app_keys.add(candidate_key)
|
|
3763
|
+
matches.append({"app_key": candidate_key, "app_name": title, "tag_ids": tag_ids})
|
|
3764
|
+
return matches
|
|
3765
|
+
|
|
3766
|
+
def _resolve_app_matches_in_package(
|
|
3767
|
+
self,
|
|
3768
|
+
*,
|
|
3769
|
+
profile: str,
|
|
3770
|
+
app_name: str,
|
|
3771
|
+
package_tag_id: int,
|
|
2790
3772
|
) -> list[JSONObject]:
|
|
2791
3773
|
try:
|
|
2792
3774
|
package_result = self.packages.package_get(profile=profile, tag_id=package_tag_id, include_raw=True)
|
|
@@ -3093,6 +4075,26 @@ class AiBuilderFacade:
|
|
|
3093
4075
|
tolerate_404=True,
|
|
3094
4076
|
tolerate_permission_restricted=True,
|
|
3095
4077
|
)
|
|
4078
|
+
view_summaries = _summarize_views(views)
|
|
4079
|
+
charts_unavailable = False
|
|
4080
|
+
try:
|
|
4081
|
+
chart_items, _chart_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
|
|
4082
|
+
chart_summaries = _summarize_charts(chart_items)
|
|
4083
|
+
except (QingflowApiError, RuntimeError):
|
|
4084
|
+
charts_unavailable = True
|
|
4085
|
+
chart_summaries = []
|
|
4086
|
+
associated_resources_unavailable = False
|
|
4087
|
+
try:
|
|
4088
|
+
associated_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
|
|
4089
|
+
except (QingflowApiError, RuntimeError):
|
|
4090
|
+
associated_resources_unavailable = True
|
|
4091
|
+
associated_resources = []
|
|
4092
|
+
custom_buttons_unavailable = False
|
|
4093
|
+
try:
|
|
4094
|
+
custom_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
|
|
4095
|
+
except (QingflowApiError, RuntimeError):
|
|
4096
|
+
custom_buttons_unavailable = True
|
|
4097
|
+
custom_buttons = []
|
|
3096
4098
|
workflow, workflow_unavailable = self._load_workflow_result(
|
|
3097
4099
|
profile=profile,
|
|
3098
4100
|
app_key=app_key,
|
|
@@ -3103,14 +4105,28 @@ class AiBuilderFacade:
|
|
|
3103
4105
|
tag_ids=_coerce_int_list(base_result.get("tagIds")),
|
|
3104
4106
|
fields=parsed["fields"],
|
|
3105
4107
|
layout=parsed["layout"],
|
|
3106
|
-
views=
|
|
4108
|
+
views=view_summaries,
|
|
3107
4109
|
)
|
|
3108
4110
|
if schema_unavailable:
|
|
3109
4111
|
verification_hints.append("schema_read_unavailable")
|
|
3110
4112
|
if views_unavailable:
|
|
3111
4113
|
verification_hints.append("views_read_unavailable")
|
|
4114
|
+
if charts_unavailable:
|
|
4115
|
+
verification_hints.append("charts_read_unavailable")
|
|
4116
|
+
if associated_resources_unavailable:
|
|
4117
|
+
verification_hints.append("associated_resources_read_unavailable")
|
|
4118
|
+
if custom_buttons_unavailable:
|
|
4119
|
+
verification_hints.append("custom_buttons_read_unavailable")
|
|
3112
4120
|
if workflow_unavailable:
|
|
3113
4121
|
verification_hints.append("workflow_read_unavailable")
|
|
4122
|
+
counts = {
|
|
4123
|
+
"fields": len(parsed["fields"]),
|
|
4124
|
+
"layout_sections": len(parsed["layout"].get("sections", [])),
|
|
4125
|
+
"views": len(view_summaries),
|
|
4126
|
+
"charts": len(chart_summaries),
|
|
4127
|
+
"associated_resources": len(associated_resources),
|
|
4128
|
+
"custom_buttons": len(custom_buttons),
|
|
4129
|
+
}
|
|
3114
4130
|
response = AppReadSummaryResponse(
|
|
3115
4131
|
app_key=app_key,
|
|
3116
4132
|
title=base_result.get("formTitle"),
|
|
@@ -3120,9 +4136,18 @@ class AiBuilderFacade:
|
|
|
3120
4136
|
publish_status=base_result.get("appPublishStatus"),
|
|
3121
4137
|
field_count=len(parsed["fields"]),
|
|
3122
4138
|
layout_section_count=len(parsed["layout"].get("sections", [])),
|
|
3123
|
-
view_count=len(
|
|
4139
|
+
view_count=len(view_summaries),
|
|
4140
|
+
chart_count=len(chart_summaries),
|
|
4141
|
+
associated_resource_count=len(associated_resources),
|
|
4142
|
+
custom_button_count=len(custom_buttons),
|
|
4143
|
+
counts=counts,
|
|
4144
|
+
views=view_summaries,
|
|
4145
|
+
charts=chart_summaries,
|
|
4146
|
+
associated_resources=associated_resources,
|
|
4147
|
+
custom_buttons=custom_buttons,
|
|
3124
4148
|
workflow_enabled=bool(workflow),
|
|
3125
4149
|
verification_hints=verification_hints,
|
|
4150
|
+
form_settings={} if schema_unavailable else _form_settings_from_schema(schema_result, parsed["fields"]),
|
|
3126
4151
|
)
|
|
3127
4152
|
return {
|
|
3128
4153
|
"status": "success",
|
|
@@ -3141,9 +4166,17 @@ class AiBuilderFacade:
|
|
|
3141
4166
|
"app_exists": True,
|
|
3142
4167
|
"schema_read_unavailable": schema_unavailable,
|
|
3143
4168
|
"views_read_unavailable": views_unavailable,
|
|
4169
|
+
"charts_read_unavailable": charts_unavailable,
|
|
4170
|
+
"associated_resources_read_unavailable": associated_resources_unavailable,
|
|
4171
|
+
"custom_buttons_read_unavailable": custom_buttons_unavailable,
|
|
3144
4172
|
"workflow_read_unavailable": workflow_unavailable,
|
|
3145
4173
|
},
|
|
3146
|
-
"verified": not schema_unavailable
|
|
4174
|
+
"verified": not schema_unavailable
|
|
4175
|
+
and not views_unavailable
|
|
4176
|
+
and not charts_unavailable
|
|
4177
|
+
and not associated_resources_unavailable
|
|
4178
|
+
and not custom_buttons_unavailable
|
|
4179
|
+
and not workflow_unavailable,
|
|
3147
4180
|
**response.model_dump(mode="json"),
|
|
3148
4181
|
}
|
|
3149
4182
|
|
|
@@ -3485,6 +4518,7 @@ class AiBuilderFacade:
|
|
|
3485
4518
|
app_key=app_key,
|
|
3486
4519
|
fields=[_compact_public_field_read(field=field, layout=parsed["layout"]) for field in parsed["fields"]],
|
|
3487
4520
|
field_count=len(parsed["fields"]),
|
|
4521
|
+
form_settings=_form_settings_from_schema(state["schema"], parsed["fields"]),
|
|
3488
4522
|
)
|
|
3489
4523
|
return {
|
|
3490
4524
|
"status": "success",
|
|
@@ -3597,6 +4631,7 @@ class AiBuilderFacade:
|
|
|
3597
4631
|
"verification": {
|
|
3598
4632
|
"app_exists": True,
|
|
3599
4633
|
"view_filters_verified": False,
|
|
4634
|
+
"view_query_conditions_verified": False,
|
|
3600
4635
|
"view_display_readback_complete": not config_read_errors,
|
|
3601
4636
|
},
|
|
3602
4637
|
"verified": True,
|
|
@@ -3800,61 +4835,711 @@ class AiBuilderFacade:
|
|
|
3800
4835
|
fallback_items = self.charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
|
|
3801
4836
|
return list(fallback_items) if isinstance(fallback_items, list) else [], "fallback"
|
|
3802
4837
|
|
|
3803
|
-
def
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
"
|
|
3810
|
-
api_error,
|
|
3811
|
-
normalized_args={"dash_key": dash_key, "being_draft": being_draft},
|
|
3812
|
-
details={"dash_key": dash_key, "being_draft": being_draft},
|
|
3813
|
-
suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
|
|
4838
|
+
def _load_associated_resources_for_builder(self, *, profile: str, app_key: str) -> list[dict[str, Any]]:
|
|
4839
|
+
def runner(_: Any, context: BackendRequestContext) -> list[dict[str, Any]]:
|
|
4840
|
+
payload = self.apps.backend.request(
|
|
4841
|
+
"GET",
|
|
4842
|
+
context,
|
|
4843
|
+
f"/app/{app_key}/asosChart",
|
|
4844
|
+
params={"role": 1, "beingDraft": True},
|
|
3814
4845
|
)
|
|
3815
|
-
|
|
3816
|
-
dash_key=dash_key,
|
|
3817
|
-
being_draft=being_draft,
|
|
3818
|
-
dash_name=str(result.get("dashName") or "").strip() or None,
|
|
3819
|
-
package_tag_ids=[
|
|
3820
|
-
tag_id
|
|
3821
|
-
for tag_id in (
|
|
3822
|
-
_coerce_positive_int((item or {}).get("tagId"))
|
|
3823
|
-
for item in (result.get("tags") or [])
|
|
3824
|
-
if isinstance(item, dict)
|
|
3825
|
-
)
|
|
3826
|
-
if tag_id is not None
|
|
3827
|
-
],
|
|
3828
|
-
dash_icon=str(result.get("dashIcon") or "").strip() or None,
|
|
3829
|
-
hide_copyright=bool(result.get("hideCopyright")) if "hideCopyright" in result else None,
|
|
3830
|
-
visibility=_public_visibility_from_member_auth(result.get("auth")),
|
|
3831
|
-
auth=deepcopy(result.get("auth")) if isinstance(result.get("auth"), dict) else {},
|
|
3832
|
-
config=deepcopy(result.get("config")) if isinstance(result.get("config"), dict) else {},
|
|
3833
|
-
dash_global_config=deepcopy(result.get("dashGlobalConfig")) if isinstance(result.get("dashGlobalConfig"), dict) else {},
|
|
3834
|
-
component_count=len(result.get("components") or []) if isinstance(result.get("components"), list) else 0,
|
|
3835
|
-
components=_normalize_portal_components(result.get("components")),
|
|
3836
|
-
)
|
|
3837
|
-
return {
|
|
3838
|
-
"status": "success",
|
|
3839
|
-
"error_code": None,
|
|
3840
|
-
"recoverable": False,
|
|
3841
|
-
"message": "read portal detail",
|
|
3842
|
-
"normalized_args": {"dash_key": dash_key, "being_draft": being_draft},
|
|
3843
|
-
"missing_fields": [],
|
|
3844
|
-
"allowed_values": {},
|
|
3845
|
-
"details": {},
|
|
3846
|
-
"request_id": None,
|
|
3847
|
-
"suggested_next_call": None,
|
|
3848
|
-
"noop": False,
|
|
3849
|
-
"warnings": [],
|
|
3850
|
-
"verification": {"portal_exists": True, "being_draft": being_draft},
|
|
3851
|
-
"verified": True,
|
|
3852
|
-
**response.model_dump(mode="json"),
|
|
3853
|
-
}
|
|
4846
|
+
return _normalize_associated_resources_payload(payload)
|
|
3854
4847
|
|
|
3855
|
-
|
|
4848
|
+
return self.apps._run(profile, runner)
|
|
4849
|
+
|
|
4850
|
+
def _associated_resource_create(self, *, profile: str, app_key: str, patch: AssociatedResourceUpsertPatch) -> JSONObject:
|
|
4851
|
+
payload = _serialize_associated_resource_create_payload(patch)
|
|
4852
|
+
|
|
4853
|
+
def runner(_: Any, context: BackendRequestContext) -> JSONObject:
|
|
4854
|
+
result = self.apps.backend.request("POST", context, f"/app/{app_key}/asosChart", json_body=payload)
|
|
4855
|
+
return result if isinstance(result, dict) else {"result": result}
|
|
4856
|
+
|
|
4857
|
+
return self.apps._run(profile, runner)
|
|
4858
|
+
|
|
4859
|
+
def _associated_resource_update(
|
|
4860
|
+
self,
|
|
4861
|
+
*,
|
|
4862
|
+
profile: str,
|
|
4863
|
+
app_key: str,
|
|
4864
|
+
associated_item_id: int,
|
|
4865
|
+
patch: AssociatedResourceUpsertPatch,
|
|
4866
|
+
) -> JSONObject:
|
|
4867
|
+
payload = _serialize_associated_resource_update_payload(patch, associated_item_id=associated_item_id)
|
|
4868
|
+
|
|
4869
|
+
def runner(_: Any, context: BackendRequestContext) -> JSONObject:
|
|
4870
|
+
result = self.apps.backend.request("POST", context, f"/app/{app_key}/asosChart/{associated_item_id}", json_body=payload)
|
|
4871
|
+
return result if isinstance(result, dict) else {"result": result}
|
|
4872
|
+
|
|
4873
|
+
return self.apps._run(profile, runner)
|
|
4874
|
+
|
|
4875
|
+
def _associated_resource_delete(self, *, profile: str, app_key: str, associated_item_id: int) -> JSONObject:
|
|
4876
|
+
def runner(_: Any, context: BackendRequestContext) -> JSONObject:
|
|
4877
|
+
result = self.apps.backend.request("DELETE", context, f"/app/{app_key}/asosChart/{associated_item_id}")
|
|
4878
|
+
return result if isinstance(result, dict) else {"result": result}
|
|
4879
|
+
|
|
4880
|
+
return self.apps._run(profile, runner)
|
|
4881
|
+
|
|
4882
|
+
def _associated_resource_reorder(self, *, profile: str, app_key: str, associated_item_ids: list[int]) -> JSONObject:
|
|
4883
|
+
def runner(_: Any, context: BackendRequestContext) -> JSONObject:
|
|
4884
|
+
result = self.apps.backend.request("POST", context, f"/app/{app_key}/asosChart/ordinal", json_body=list(associated_item_ids))
|
|
4885
|
+
return result if isinstance(result, dict) else {"result": result}
|
|
4886
|
+
|
|
4887
|
+
return self.apps._run(profile, runner)
|
|
4888
|
+
|
|
4889
|
+
def _update_view_associated_resources_config(
|
|
4890
|
+
self,
|
|
4891
|
+
*,
|
|
4892
|
+
profile: str,
|
|
4893
|
+
view_key: str,
|
|
4894
|
+
associated_resources_payload: dict[str, Any] | None,
|
|
4895
|
+
) -> JSONObject:
|
|
4896
|
+
config_response = self.views.view_get_config(profile=profile, viewgraph_key=view_key)
|
|
4897
|
+
config = config_response.get("result") if isinstance(config_response.get("result"), dict) else {}
|
|
4898
|
+
payload = _build_view_associated_resources_only_update_payload(config, associated_resources_payload=associated_resources_payload)
|
|
4899
|
+
return self.views.view_update(profile=profile, viewgraph_key=view_key, payload=payload)
|
|
4900
|
+
|
|
4901
|
+
def _load_custom_buttons_for_builder(self, *, profile: str, app_key: str) -> list[dict[str, Any]]:
|
|
4902
|
+
listing = self.buttons.custom_button_list(profile=profile, app_key=app_key, being_draft=True, include_raw=False)
|
|
4903
|
+
return [
|
|
4904
|
+
_normalize_custom_button_summary(item)
|
|
4905
|
+
for item in (listing.get("items") or [])
|
|
4906
|
+
if isinstance(item, dict)
|
|
4907
|
+
]
|
|
4908
|
+
|
|
4909
|
+
def _compile_custom_button_semantic_add_data_configs(
|
|
4910
|
+
self,
|
|
4911
|
+
*,
|
|
4912
|
+
profile: str,
|
|
4913
|
+
app_key: str,
|
|
4914
|
+
patches: list[CustomButtonUpsertPatch],
|
|
4915
|
+
) -> tuple[dict[int, dict[str, Any]], list[dict[str, Any]]]:
|
|
4916
|
+
compiled_by_index: dict[int, dict[str, Any]] = {}
|
|
4917
|
+
issues: list[dict[str, Any]] = []
|
|
4918
|
+
semantic_patches = [
|
|
4919
|
+
(index, patch, patch.trigger_add_data_config)
|
|
4920
|
+
for index, patch in enumerate(patches)
|
|
4921
|
+
if patch.trigger_add_data_config is not None
|
|
4922
|
+
and _custom_button_add_data_config_has_semantic_inputs(patch.trigger_add_data_config.model_dump(mode="json", exclude_none=True))
|
|
4923
|
+
]
|
|
4924
|
+
if not semantic_patches:
|
|
4925
|
+
return compiled_by_index, issues
|
|
3856
4926
|
try:
|
|
3857
|
-
|
|
4927
|
+
source_schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
|
|
4928
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
4929
|
+
api_error = _coerce_api_error(error)
|
|
4930
|
+
return {}, [
|
|
4931
|
+
{
|
|
4932
|
+
"error_code": "CUSTOM_BUTTON_SOURCE_SCHEMA_READ_FAILED",
|
|
4933
|
+
"reason_path": "upsert_buttons[].trigger_add_data_config.field_mappings",
|
|
4934
|
+
"message": api_error.message,
|
|
4935
|
+
"transport_error": _transport_error_payload(api_error),
|
|
4936
|
+
"next_action": "retry after app schema is readable",
|
|
4937
|
+
}
|
|
4938
|
+
]
|
|
4939
|
+
source_fields = _parse_schema(source_schema).get("fields") or []
|
|
4940
|
+
target_schema_cache: dict[str, list[dict[str, Any]]] = {}
|
|
4941
|
+
target_name_cache: dict[str, str | None] = {}
|
|
4942
|
+
for index, patch, config in semantic_patches:
|
|
4943
|
+
config_payload = config.model_dump(mode="json", exclude_none=True)
|
|
4944
|
+
reason_base = f"upsert_buttons[{index}].trigger_add_data_config"
|
|
4945
|
+
if config.que_relation:
|
|
4946
|
+
issues.append(
|
|
4947
|
+
{
|
|
4948
|
+
"error_code": "MIXED_CUSTOM_BUTTON_MAPPING_MODES",
|
|
4949
|
+
"reason_path": reason_base,
|
|
4950
|
+
"message": "field_mappings/default_values cannot be used together with legacy que_relation",
|
|
4951
|
+
"next_action": "use field_mappings/default_values only, or pass legacy que_relation only",
|
|
4952
|
+
}
|
|
4953
|
+
)
|
|
4954
|
+
continue
|
|
4955
|
+
target_app_key = str(config.related_app_key or "").strip()
|
|
4956
|
+
if not target_app_key:
|
|
4957
|
+
issues.append(
|
|
4958
|
+
{
|
|
4959
|
+
"error_code": "CUSTOM_BUTTON_TARGET_APP_REQUIRED",
|
|
4960
|
+
"reason_path": f"{reason_base}.target_app_key",
|
|
4961
|
+
"missing_fields": ["target_app_key"],
|
|
4962
|
+
"message": "addData field_mappings/default_values require target_app_key",
|
|
4963
|
+
}
|
|
4964
|
+
)
|
|
4965
|
+
continue
|
|
4966
|
+
if target_app_key not in target_schema_cache:
|
|
4967
|
+
try:
|
|
4968
|
+
target_schema, _target_schema_source = self._read_schema_with_fallback(profile=profile, app_key=target_app_key)
|
|
4969
|
+
target_schema_cache[target_app_key] = list(_parse_schema(target_schema).get("fields") or [])
|
|
4970
|
+
target_base = self.apps.app_get_base(profile=profile, app_key=target_app_key, include_raw=True).get("result") or {}
|
|
4971
|
+
target_name_cache[target_app_key] = str(target_base.get("formTitle") or target_base.get("appName") or "").strip() or None
|
|
4972
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
4973
|
+
api_error = _coerce_api_error(error)
|
|
4974
|
+
issues.append(
|
|
4975
|
+
{
|
|
4976
|
+
"error_code": "CUSTOM_BUTTON_TARGET_SCHEMA_READ_FAILED",
|
|
4977
|
+
"reason_path": f"{reason_base}.target_app_key",
|
|
4978
|
+
"target_app_key": target_app_key,
|
|
4979
|
+
"message": api_error.message,
|
|
4980
|
+
"transport_error": _transport_error_payload(api_error),
|
|
4981
|
+
"next_action": "verify target_app_key with app_get",
|
|
4982
|
+
}
|
|
4983
|
+
)
|
|
4984
|
+
continue
|
|
4985
|
+
compiled_config, config_issues = self._compile_custom_button_add_data_config(
|
|
4986
|
+
profile=profile,
|
|
4987
|
+
source_fields=list(source_fields),
|
|
4988
|
+
target_fields=target_schema_cache[target_app_key],
|
|
4989
|
+
target_app_key=target_app_key,
|
|
4990
|
+
target_app_name=config.related_app_name or target_name_cache.get(target_app_key),
|
|
4991
|
+
config=config_payload,
|
|
4992
|
+
reason_path=reason_base,
|
|
4993
|
+
)
|
|
4994
|
+
if config_issues:
|
|
4995
|
+
issues.extend(config_issues)
|
|
4996
|
+
continue
|
|
4997
|
+
compiled_by_index[index] = compiled_config
|
|
4998
|
+
return compiled_by_index, issues
|
|
4999
|
+
|
|
5000
|
+
def _compile_custom_button_add_data_config(
|
|
5001
|
+
self,
|
|
5002
|
+
*,
|
|
5003
|
+
profile: str,
|
|
5004
|
+
source_fields: list[dict[str, Any]],
|
|
5005
|
+
target_fields: list[dict[str, Any]],
|
|
5006
|
+
target_app_key: str,
|
|
5007
|
+
target_app_name: str | None,
|
|
5008
|
+
config: dict[str, Any],
|
|
5009
|
+
reason_path: str,
|
|
5010
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
5011
|
+
issues: list[dict[str, Any]] = []
|
|
5012
|
+
rules: list[dict[str, Any]] = []
|
|
5013
|
+
for mapping_index, mapping in enumerate(config.get("field_mappings") or []):
|
|
5014
|
+
if not isinstance(mapping, dict):
|
|
5015
|
+
continue
|
|
5016
|
+
source_field, source_issue = _resolve_custom_button_schema_field(
|
|
5017
|
+
fields=source_fields,
|
|
5018
|
+
selector=mapping.get("source_field"),
|
|
5019
|
+
reason_path=f"{reason_path}.field_mappings[{mapping_index}].source_field",
|
|
5020
|
+
role="source",
|
|
5021
|
+
)
|
|
5022
|
+
target_field, target_issue = _resolve_custom_button_schema_field(
|
|
5023
|
+
fields=target_fields,
|
|
5024
|
+
selector=mapping.get("target_field"),
|
|
5025
|
+
reason_path=f"{reason_path}.field_mappings[{mapping_index}].target_field",
|
|
5026
|
+
role="target",
|
|
5027
|
+
)
|
|
5028
|
+
if source_issue:
|
|
5029
|
+
issues.append(source_issue)
|
|
5030
|
+
if target_issue:
|
|
5031
|
+
issues.append(target_issue)
|
|
5032
|
+
if source_issue or target_issue or source_field is None or target_field is None:
|
|
5033
|
+
continue
|
|
5034
|
+
type_issue = _custom_button_mapping_type_issue(
|
|
5035
|
+
source_field=source_field,
|
|
5036
|
+
target_field=target_field,
|
|
5037
|
+
reason_path=f"{reason_path}.field_mappings[{mapping_index}]",
|
|
5038
|
+
)
|
|
5039
|
+
if type_issue:
|
|
5040
|
+
issues.append(type_issue)
|
|
5041
|
+
continue
|
|
5042
|
+
rules.append(_custom_button_field_mapping_rule(source_field=source_field, target_field=target_field))
|
|
5043
|
+
|
|
5044
|
+
default_values = config.get("default_values") if isinstance(config.get("default_values"), dict) else {}
|
|
5045
|
+
for field_selector, raw_value in default_values.items():
|
|
5046
|
+
target_field, target_issue = _resolve_custom_button_schema_field(
|
|
5047
|
+
fields=target_fields,
|
|
5048
|
+
selector=field_selector,
|
|
5049
|
+
reason_path=f"{reason_path}.default_values.{field_selector}",
|
|
5050
|
+
role="target",
|
|
5051
|
+
)
|
|
5052
|
+
if target_issue or target_field is None:
|
|
5053
|
+
if target_issue:
|
|
5054
|
+
issues.append(target_issue)
|
|
5055
|
+
continue
|
|
5056
|
+
rule, value_issue = self._custom_button_default_value_rule(
|
|
5057
|
+
profile=profile,
|
|
5058
|
+
target_field=target_field,
|
|
5059
|
+
value=raw_value,
|
|
5060
|
+
reason_path=f"{reason_path}.default_values.{field_selector}",
|
|
5061
|
+
)
|
|
5062
|
+
if value_issue:
|
|
5063
|
+
issues.append(value_issue)
|
|
5064
|
+
continue
|
|
5065
|
+
rules.append(rule)
|
|
5066
|
+
|
|
5067
|
+
return {
|
|
5068
|
+
"related_app_key": target_app_key,
|
|
5069
|
+
"related_app_name": target_app_name,
|
|
5070
|
+
"que_relation": rules,
|
|
5071
|
+
}, issues
|
|
5072
|
+
|
|
5073
|
+
def _custom_button_default_value_rule(
|
|
5074
|
+
self,
|
|
5075
|
+
*,
|
|
5076
|
+
profile: str,
|
|
5077
|
+
target_field: dict[str, Any],
|
|
5078
|
+
value: Any,
|
|
5079
|
+
reason_path: str,
|
|
5080
|
+
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
|
5081
|
+
field_type = str(target_field.get("type") or "")
|
|
5082
|
+
values = value if isinstance(value, list) else [value]
|
|
5083
|
+
details: list[dict[str, Any]] = []
|
|
5084
|
+
for raw_value in values:
|
|
5085
|
+
detail, issue = self._custom_button_default_value_detail(
|
|
5086
|
+
profile=profile,
|
|
5087
|
+
target_field=target_field,
|
|
5088
|
+
value=raw_value,
|
|
5089
|
+
reason_path=reason_path,
|
|
5090
|
+
)
|
|
5091
|
+
if issue:
|
|
5092
|
+
return {}, issue
|
|
5093
|
+
if detail is not None:
|
|
5094
|
+
details.append(detail)
|
|
5095
|
+
if field_type not in {
|
|
5096
|
+
FieldType.text.value,
|
|
5097
|
+
FieldType.long_text.value,
|
|
5098
|
+
FieldType.number.value,
|
|
5099
|
+
FieldType.amount.value,
|
|
5100
|
+
FieldType.date.value,
|
|
5101
|
+
FieldType.datetime.value,
|
|
5102
|
+
FieldType.single_select.value,
|
|
5103
|
+
FieldType.multi_select.value,
|
|
5104
|
+
FieldType.boolean.value,
|
|
5105
|
+
FieldType.member.value,
|
|
5106
|
+
FieldType.department.value,
|
|
5107
|
+
FieldType.phone.value,
|
|
5108
|
+
FieldType.email.value,
|
|
5109
|
+
FieldType.relation.value,
|
|
5110
|
+
}:
|
|
5111
|
+
return {}, _custom_button_default_value_issue(
|
|
5112
|
+
target_field=target_field,
|
|
5113
|
+
reason_path=reason_path,
|
|
5114
|
+
value=value,
|
|
5115
|
+
message="this field type cannot be encoded as a custom button static default value",
|
|
5116
|
+
)
|
|
5117
|
+
return _custom_button_default_value_rule(target_field=target_field, value_details=details), None
|
|
5118
|
+
|
|
5119
|
+
def _custom_button_default_value_detail(
|
|
5120
|
+
self,
|
|
5121
|
+
*,
|
|
5122
|
+
profile: str,
|
|
5123
|
+
target_field: dict[str, Any],
|
|
5124
|
+
value: Any,
|
|
5125
|
+
reason_path: str,
|
|
5126
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
5127
|
+
field_type = str(target_field.get("type") or "")
|
|
5128
|
+
if value is None:
|
|
5129
|
+
return None, None
|
|
5130
|
+
if field_type in {FieldType.single_select.value, FieldType.multi_select.value, FieldType.boolean.value}:
|
|
5131
|
+
option = _resolve_custom_button_option_detail(target_field=target_field, value=value)
|
|
5132
|
+
if option is None:
|
|
5133
|
+
return None, _custom_button_default_value_issue(
|
|
5134
|
+
target_field=target_field,
|
|
5135
|
+
reason_path=reason_path,
|
|
5136
|
+
value=value,
|
|
5137
|
+
message="default value must match an existing option title or option id",
|
|
5138
|
+
allowed_values={"options": list(target_field.get("options") or [])},
|
|
5139
|
+
)
|
|
5140
|
+
return option, None
|
|
5141
|
+
if field_type == FieldType.member.value:
|
|
5142
|
+
member = self._resolve_custom_button_member_default(profile=profile, value=value)
|
|
5143
|
+
if member is None:
|
|
5144
|
+
return None, _custom_button_default_value_issue(
|
|
5145
|
+
target_field=target_field,
|
|
5146
|
+
reason_path=reason_path,
|
|
5147
|
+
value=value,
|
|
5148
|
+
message="member default value must resolve to exactly one member or pass {'id': uid, 'value': name}",
|
|
5149
|
+
next_action="retry with an explicit member uid object",
|
|
5150
|
+
)
|
|
5151
|
+
return member, None
|
|
5152
|
+
if field_type == FieldType.department.value:
|
|
5153
|
+
department = self._resolve_custom_button_department_default(profile=profile, value=value)
|
|
5154
|
+
if department is None:
|
|
5155
|
+
return None, _custom_button_default_value_issue(
|
|
5156
|
+
target_field=target_field,
|
|
5157
|
+
reason_path=reason_path,
|
|
5158
|
+
value=value,
|
|
5159
|
+
message="department default value must resolve to exactly one department or pass {'id': deptId, 'value': name}",
|
|
5160
|
+
next_action="retry with an explicit department id object",
|
|
5161
|
+
)
|
|
5162
|
+
return department, None
|
|
5163
|
+
scalar = _stringify_custom_button_default_value(value)
|
|
5164
|
+
return {"value": scalar}, None
|
|
5165
|
+
|
|
5166
|
+
def _resolve_custom_button_member_default(self, *, profile: str, value: Any) -> dict[str, Any] | None:
|
|
5167
|
+
explicit_id = _coerce_positive_int(value.get("id", value.get("uid", value.get("member_id"))) if isinstance(value, dict) else value)
|
|
5168
|
+
if explicit_id is not None:
|
|
5169
|
+
return {"id": explicit_id, "value": str(value.get("value", value.get("name", explicit_id)) if isinstance(value, dict) else explicit_id)}
|
|
5170
|
+
keyword = str(value.get("value", value.get("name", value.get("email", ""))) if isinstance(value, dict) else value or "").strip()
|
|
5171
|
+
if not keyword:
|
|
5172
|
+
return None
|
|
5173
|
+
result = self.member_search(profile=profile, query=keyword, page_num=1, page_size=20)
|
|
5174
|
+
if result.get("status") != "success":
|
|
5175
|
+
return None
|
|
5176
|
+
items = [
|
|
5177
|
+
item
|
|
5178
|
+
for item in result.get("items") or []
|
|
5179
|
+
if isinstance(item, dict)
|
|
5180
|
+
and (
|
|
5181
|
+
str(item.get("name") or "").strip() == keyword
|
|
5182
|
+
or str(item.get("email") or "").strip() == keyword
|
|
5183
|
+
)
|
|
5184
|
+
]
|
|
5185
|
+
if len(items) != 1:
|
|
5186
|
+
return None
|
|
5187
|
+
return {"id": items[0].get("uid"), "value": items[0].get("name") or items[0].get("email") or str(items[0].get("uid"))}
|
|
5188
|
+
|
|
5189
|
+
def _resolve_custom_button_department_default(self, *, profile: str, value: Any) -> dict[str, Any] | None:
|
|
5190
|
+
explicit_id = _coerce_positive_int(value.get("id", value.get("deptId", value.get("dept_id"))) if isinstance(value, dict) else value)
|
|
5191
|
+
if explicit_id is not None:
|
|
5192
|
+
return {"id": explicit_id, "value": str(value.get("value", value.get("name", value.get("deptName", explicit_id))) if isinstance(value, dict) else explicit_id)}
|
|
5193
|
+
keyword = str(value.get("value", value.get("name", value.get("deptName", ""))) if isinstance(value, dict) else value or "").strip()
|
|
5194
|
+
if not keyword:
|
|
5195
|
+
return None
|
|
5196
|
+
resolved = self._resolve_department_references(profile=profile, dept_ids=[], dept_names=[keyword])
|
|
5197
|
+
if resolved.get("issues"):
|
|
5198
|
+
return None
|
|
5199
|
+
entries = resolved.get("department_entries") or []
|
|
5200
|
+
if len(entries) != 1:
|
|
5201
|
+
return None
|
|
5202
|
+
return {"id": entries[0].get("deptId"), "value": entries[0].get("deptName") or str(entries[0].get("deptId"))}
|
|
5203
|
+
|
|
5204
|
+
def _apply_custom_button_view_configs(
|
|
5205
|
+
self,
|
|
5206
|
+
*,
|
|
5207
|
+
profile: str,
|
|
5208
|
+
app_key: str,
|
|
5209
|
+
view_configs: list[CustomButtonViewConfigPatch],
|
|
5210
|
+
client_key_map: dict[str, int],
|
|
5211
|
+
existing_buttons: list[dict[str, Any]],
|
|
5212
|
+
readback_buttons: list[dict[str, Any]],
|
|
5213
|
+
created_ids: list[int],
|
|
5214
|
+
updated_ids: list[int],
|
|
5215
|
+
removed_ids: list[int],
|
|
5216
|
+
) -> dict[str, Any]:
|
|
5217
|
+
results: list[dict[str, Any]] = []
|
|
5218
|
+
failed: list[dict[str, Any]] = []
|
|
5219
|
+
warnings: list[dict[str, Any]] = []
|
|
5220
|
+
if not view_configs:
|
|
5221
|
+
return {"verified": True, "write_executed": False, "write_succeeded": False, "view_configs": results, "failed": failed, "warnings": warnings}
|
|
5222
|
+
try:
|
|
5223
|
+
schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
|
|
5224
|
+
parsed_schema = _parse_schema(schema)
|
|
5225
|
+
current_fields_by_name = {
|
|
5226
|
+
str(field.get("name") or ""): field
|
|
5227
|
+
for field in parsed_schema.get("fields") or []
|
|
5228
|
+
if isinstance(field, dict) and str(field.get("name") or "")
|
|
5229
|
+
}
|
|
5230
|
+
existing_views, _views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
|
|
5231
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
5232
|
+
api_error = _coerce_api_error(error)
|
|
5233
|
+
failed.append(
|
|
5234
|
+
{
|
|
5235
|
+
"operation": "view_config",
|
|
5236
|
+
"status": "failed",
|
|
5237
|
+
"error_code": "CUSTOM_BUTTON_VIEW_CONFIG_READ_FAILED",
|
|
5238
|
+
"message": api_error.message,
|
|
5239
|
+
"transport_error": _transport_error_payload(api_error),
|
|
5240
|
+
}
|
|
5241
|
+
)
|
|
5242
|
+
return {"verified": False, "write_executed": False, "write_succeeded": False, "view_configs": results, "failed": failed, "warnings": warnings}
|
|
5243
|
+
|
|
5244
|
+
view_keys = {
|
|
5245
|
+
_extract_view_key(view)
|
|
5246
|
+
for view in (existing_views if isinstance(existing_views, list) else [])
|
|
5247
|
+
if isinstance(view, dict) and _extract_view_key(view)
|
|
5248
|
+
}
|
|
5249
|
+
button_inventory: dict[int, dict[str, Any]] = {}
|
|
5250
|
+
for item in [*existing_buttons, *readback_buttons]:
|
|
5251
|
+
if not isinstance(item, dict):
|
|
5252
|
+
continue
|
|
5253
|
+
button_id = _coerce_positive_int(item.get("button_id"))
|
|
5254
|
+
if button_id is not None:
|
|
5255
|
+
button_inventory[button_id] = item
|
|
5256
|
+
valid_custom_button_ids = (set(button_inventory) | set(created_ids) | set(updated_ids)) - set(removed_ids)
|
|
5257
|
+
write_executed = False
|
|
5258
|
+
write_succeeded = False
|
|
5259
|
+
all_verified = True
|
|
5260
|
+
|
|
5261
|
+
for config_index, config in enumerate(view_configs):
|
|
5262
|
+
view_key = str(config.view_key or "").strip()
|
|
5263
|
+
if not view_key or view_key not in view_keys:
|
|
5264
|
+
issue = {
|
|
5265
|
+
"index": config_index,
|
|
5266
|
+
"operation": "view_config",
|
|
5267
|
+
"status": "failed",
|
|
5268
|
+
"error_code": "UNKNOWN_VIEW",
|
|
5269
|
+
"view_key": view_key,
|
|
5270
|
+
"message": "view_key does not exist on this app",
|
|
5271
|
+
"next_action": "call app_get and use views[].view_key",
|
|
5272
|
+
}
|
|
5273
|
+
failed.append(issue)
|
|
5274
|
+
results.append(issue)
|
|
5275
|
+
all_verified = False
|
|
5276
|
+
continue
|
|
5277
|
+
try:
|
|
5278
|
+
current_response = self.views.view_get_config(profile=profile, viewgraph_key=view_key)
|
|
5279
|
+
current_config = current_response.get("result") if isinstance(current_response.get("result"), dict) else {}
|
|
5280
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
5281
|
+
api_error = _coerce_api_error(error)
|
|
5282
|
+
issue = {
|
|
5283
|
+
"index": config_index,
|
|
5284
|
+
"operation": "view_config",
|
|
5285
|
+
"status": "failed",
|
|
5286
|
+
"error_code": "VIEW_CONFIG_READ_FAILED",
|
|
5287
|
+
"view_key": view_key,
|
|
5288
|
+
"message": api_error.message,
|
|
5289
|
+
"transport_error": _transport_error_payload(api_error),
|
|
5290
|
+
}
|
|
5291
|
+
failed.append(issue)
|
|
5292
|
+
results.append(issue)
|
|
5293
|
+
all_verified = False
|
|
5294
|
+
continue
|
|
5295
|
+
existing_dtos = _extract_existing_view_button_dtos(current_config)
|
|
5296
|
+
explicit_buttons = "buttons" in getattr(config, "model_fields_set", set())
|
|
5297
|
+
new_dtos: list[dict[str, Any]] = []
|
|
5298
|
+
config_issues: list[dict[str, Any]] = []
|
|
5299
|
+
for button_index, binding in enumerate(config.buttons):
|
|
5300
|
+
button_id, ref_issue = _resolve_custom_button_view_button_ref(
|
|
5301
|
+
button_ref=binding.button_ref,
|
|
5302
|
+
client_key_map=client_key_map,
|
|
5303
|
+
button_inventory=button_inventory,
|
|
5304
|
+
valid_custom_button_ids=valid_custom_button_ids,
|
|
5305
|
+
reason_path=f"view_configs[{config_index}].buttons[{button_index}].button_ref",
|
|
5306
|
+
)
|
|
5307
|
+
if ref_issue:
|
|
5308
|
+
config_issues.append(ref_issue)
|
|
5309
|
+
continue
|
|
5310
|
+
if button_id is None:
|
|
5311
|
+
continue
|
|
5312
|
+
view_binding = _custom_button_view_binding_to_view_button_patch(binding=binding, button_id=button_id)
|
|
5313
|
+
dto, binding_issues = _serialize_view_button_binding(
|
|
5314
|
+
binding=view_binding,
|
|
5315
|
+
current_fields_by_name=current_fields_by_name,
|
|
5316
|
+
valid_custom_button_ids=valid_custom_button_ids,
|
|
5317
|
+
)
|
|
5318
|
+
if binding_issues:
|
|
5319
|
+
config_issues.extend(binding_issues)
|
|
5320
|
+
continue
|
|
5321
|
+
new_dtos.append(dto)
|
|
5322
|
+
if config_issues:
|
|
5323
|
+
issue = {
|
|
5324
|
+
"index": config_index,
|
|
5325
|
+
"operation": "view_config",
|
|
5326
|
+
"status": "failed",
|
|
5327
|
+
"error_code": "CUSTOM_BUTTON_VIEW_CONFIG_BLOCKED",
|
|
5328
|
+
"view_key": view_key,
|
|
5329
|
+
"issues": config_issues,
|
|
5330
|
+
"message": "view button config references invalid buttons or fields; no view write was executed for this view",
|
|
5331
|
+
}
|
|
5332
|
+
failed.append(issue)
|
|
5333
|
+
results.append(issue)
|
|
5334
|
+
all_verified = False
|
|
5335
|
+
continue
|
|
5336
|
+
replace_existing = str(config.mode or "").strip().lower() == "replace" or (explicit_buttons and not config.buttons)
|
|
5337
|
+
merged_dtos = (
|
|
5338
|
+
[deepcopy(item) for item in new_dtos]
|
|
5339
|
+
if replace_existing
|
|
5340
|
+
else _merge_custom_button_view_button_dtos(existing_dtos=existing_dtos, new_dtos=new_dtos)
|
|
5341
|
+
)
|
|
5342
|
+
payload = _build_view_buttons_only_update_payload(current_config, button_config_dtos=merged_dtos)
|
|
5343
|
+
effective_merged_dtos = merged_dtos
|
|
5344
|
+
unsupported_list_issue: dict[str, Any] | None = None
|
|
5345
|
+
try:
|
|
5346
|
+
write_executed = True
|
|
5347
|
+
self.views.view_update(profile=profile, viewgraph_key=view_key, payload=payload)
|
|
5348
|
+
write_succeeded = True
|
|
5349
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
5350
|
+
api_error = _coerce_api_error(error)
|
|
5351
|
+
has_requested_list_buttons = any(
|
|
5352
|
+
_normalize_view_button_config_type(item.get("configType")) == "LIST"
|
|
5353
|
+
for item in new_dtos
|
|
5354
|
+
if isinstance(item, dict)
|
|
5355
|
+
)
|
|
5356
|
+
fallback_dtos = [
|
|
5357
|
+
item
|
|
5358
|
+
for item in merged_dtos
|
|
5359
|
+
if _normalize_view_button_config_type(item.get("configType")) != "LIST"
|
|
5360
|
+
]
|
|
5361
|
+
fallback_new_dtos = [
|
|
5362
|
+
item
|
|
5363
|
+
for item in new_dtos
|
|
5364
|
+
if _normalize_view_button_config_type(item.get("configType")) != "LIST"
|
|
5365
|
+
]
|
|
5366
|
+
if has_requested_list_buttons and fallback_new_dtos and fallback_dtos != merged_dtos:
|
|
5367
|
+
fallback_payload = _build_view_buttons_only_update_payload(current_config, button_config_dtos=fallback_dtos)
|
|
5368
|
+
try:
|
|
5369
|
+
self.views.view_update(profile=profile, viewgraph_key=view_key, payload=fallback_payload)
|
|
5370
|
+
write_succeeded = True
|
|
5371
|
+
effective_merged_dtos = fallback_dtos
|
|
5372
|
+
unsupported_list_issue = {
|
|
5373
|
+
"index": config_index,
|
|
5374
|
+
"operation": "view_config",
|
|
5375
|
+
"status": "failed",
|
|
5376
|
+
"error_code": "LIST_BUTTON_BACKEND_UNSUPPORTED",
|
|
5377
|
+
"view_key": view_key,
|
|
5378
|
+
"message": "backend rejected list-button placement; header/detail placements were retried without list buttons",
|
|
5379
|
+
"backend_message": api_error.message,
|
|
5380
|
+
"transport_error": _transport_error_payload(api_error),
|
|
5381
|
+
"next_action": "use placement=header/detail for now, or verify the backend list-button endpoint/payload separately",
|
|
5382
|
+
}
|
|
5383
|
+
failed.append(unsupported_list_issue)
|
|
5384
|
+
except (QingflowApiError, RuntimeError) as fallback_error:
|
|
5385
|
+
fallback_api_error = _coerce_api_error(fallback_error)
|
|
5386
|
+
issue = {
|
|
5387
|
+
"index": config_index,
|
|
5388
|
+
"operation": "view_config",
|
|
5389
|
+
"status": "failed",
|
|
5390
|
+
"error_code": "VIEW_BUTTON_CONFIG_WRITE_FAILED",
|
|
5391
|
+
"view_key": view_key,
|
|
5392
|
+
"message": fallback_api_error.message,
|
|
5393
|
+
"transport_error": _transport_error_payload(fallback_api_error),
|
|
5394
|
+
"initial_error": _transport_error_payload(api_error),
|
|
5395
|
+
}
|
|
5396
|
+
failed.append(issue)
|
|
5397
|
+
results.append(issue)
|
|
5398
|
+
all_verified = False
|
|
5399
|
+
continue
|
|
5400
|
+
elif has_requested_list_buttons:
|
|
5401
|
+
issue = {
|
|
5402
|
+
"index": config_index,
|
|
5403
|
+
"operation": "view_config",
|
|
5404
|
+
"status": "failed",
|
|
5405
|
+
"error_code": "LIST_BUTTON_BACKEND_UNSUPPORTED",
|
|
5406
|
+
"view_key": view_key,
|
|
5407
|
+
"message": "backend rejected list-button placement",
|
|
5408
|
+
"backend_message": api_error.message,
|
|
5409
|
+
"transport_error": _transport_error_payload(api_error),
|
|
5410
|
+
"next_action": "use placement=header/detail for now, or verify the backend list-button endpoint/payload separately",
|
|
5411
|
+
}
|
|
5412
|
+
failed.append(issue)
|
|
5413
|
+
results.append(issue)
|
|
5414
|
+
all_verified = False
|
|
5415
|
+
continue
|
|
5416
|
+
else:
|
|
5417
|
+
issue = {
|
|
5418
|
+
"index": config_index,
|
|
5419
|
+
"operation": "view_config",
|
|
5420
|
+
"status": "failed",
|
|
5421
|
+
"error_code": "VIEW_BUTTON_CONFIG_WRITE_FAILED",
|
|
5422
|
+
"view_key": view_key,
|
|
5423
|
+
"message": api_error.message,
|
|
5424
|
+
"transport_error": _transport_error_payload(api_error),
|
|
5425
|
+
}
|
|
5426
|
+
failed.append(issue)
|
|
5427
|
+
results.append(issue)
|
|
5428
|
+
all_verified = False
|
|
5429
|
+
continue
|
|
5430
|
+
try:
|
|
5431
|
+
verify_response = self.views.view_get_config(profile=profile, viewgraph_key=view_key)
|
|
5432
|
+
verify_config = verify_response.get("result") if isinstance(verify_response.get("result"), dict) else {}
|
|
5433
|
+
expected_summary = _normalize_expected_view_buttons_for_compare(
|
|
5434
|
+
effective_merged_dtos,
|
|
5435
|
+
custom_button_details_by_id=button_inventory,
|
|
5436
|
+
)
|
|
5437
|
+
actual_summary = _normalize_view_buttons_for_compare(verify_config)
|
|
5438
|
+
comparison = _compare_view_button_summaries(
|
|
5439
|
+
expected=expected_summary,
|
|
5440
|
+
actual=actual_summary,
|
|
5441
|
+
pending_custom_button_ids=set(created_ids),
|
|
5442
|
+
)
|
|
5443
|
+
verified = bool(comparison.get("verified")) and unsupported_list_issue is None
|
|
5444
|
+
if not verified:
|
|
5445
|
+
all_verified = False
|
|
5446
|
+
results.append(
|
|
5447
|
+
{
|
|
5448
|
+
"index": config_index,
|
|
5449
|
+
"operation": "view_config",
|
|
5450
|
+
"status": "success" if verified else ("partial_success" if unsupported_list_issue is not None else "unverified"),
|
|
5451
|
+
"view_key": view_key,
|
|
5452
|
+
"mode": "replace" if replace_existing else "merge",
|
|
5453
|
+
"buttons_configured": len(new_dtos),
|
|
5454
|
+
"view_buttons_verified": verified,
|
|
5455
|
+
"supported_buttons_verified": bool(comparison.get("verified")) if unsupported_list_issue is not None else None,
|
|
5456
|
+
"unsupported_placements": ["list"] if unsupported_list_issue is not None else [],
|
|
5457
|
+
"custom_button_readback_pending": bool(comparison.get("custom_button_readback_pending")),
|
|
5458
|
+
"expected_buttons": expected_summary,
|
|
5459
|
+
"actual_buttons": actual_summary,
|
|
5460
|
+
}
|
|
5461
|
+
)
|
|
5462
|
+
if comparison.get("custom_button_readback_pending"):
|
|
5463
|
+
warnings.append(_warning("VIEW_CUSTOM_BUTTON_READBACK_PENDING", "view config write landed but custom button readback is delayed"))
|
|
5464
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
5465
|
+
api_error = _coerce_api_error(error)
|
|
5466
|
+
all_verified = False
|
|
5467
|
+
warnings.append(_warning("VIEW_BUTTON_CONFIG_READBACK_FAILED", api_error.message))
|
|
5468
|
+
results.append(
|
|
5469
|
+
{
|
|
5470
|
+
"index": config_index,
|
|
5471
|
+
"operation": "view_config",
|
|
5472
|
+
"status": "unverified",
|
|
5473
|
+
"view_key": view_key,
|
|
5474
|
+
"mode": "replace" if replace_existing else "merge",
|
|
5475
|
+
"buttons_configured": len(new_dtos),
|
|
5476
|
+
"view_buttons_verified": None,
|
|
5477
|
+
}
|
|
5478
|
+
)
|
|
5479
|
+
return {
|
|
5480
|
+
"verified": all_verified,
|
|
5481
|
+
"write_executed": write_executed,
|
|
5482
|
+
"write_succeeded": write_succeeded,
|
|
5483
|
+
"view_configs": results,
|
|
5484
|
+
"failed": failed,
|
|
5485
|
+
"warnings": warnings,
|
|
5486
|
+
}
|
|
5487
|
+
|
|
5488
|
+
def portal_get(self, *, profile: str, dash_key: str, being_draft: bool = True) -> JSONObject:
|
|
5489
|
+
try:
|
|
5490
|
+
result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft).get("result") or {}
|
|
5491
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
5492
|
+
api_error = _coerce_api_error(error)
|
|
5493
|
+
return _failed_from_api_error(
|
|
5494
|
+
"PORTAL_GET_FAILED",
|
|
5495
|
+
api_error,
|
|
5496
|
+
normalized_args={"dash_key": dash_key, "being_draft": being_draft},
|
|
5497
|
+
details={"dash_key": dash_key, "being_draft": being_draft},
|
|
5498
|
+
suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
|
|
5499
|
+
)
|
|
5500
|
+
response = PortalGetResponse(
|
|
5501
|
+
dash_key=dash_key,
|
|
5502
|
+
being_draft=being_draft,
|
|
5503
|
+
dash_name=str(result.get("dashName") or "").strip() or None,
|
|
5504
|
+
package_tag_ids=[
|
|
5505
|
+
tag_id
|
|
5506
|
+
for tag_id in (
|
|
5507
|
+
_coerce_positive_int((item or {}).get("tagId"))
|
|
5508
|
+
for item in (result.get("tags") or [])
|
|
5509
|
+
if isinstance(item, dict)
|
|
5510
|
+
)
|
|
5511
|
+
if tag_id is not None
|
|
5512
|
+
],
|
|
5513
|
+
dash_icon=str(result.get("dashIcon") or "").strip() or None,
|
|
5514
|
+
hide_copyright=bool(result.get("hideCopyright")) if "hideCopyright" in result else None,
|
|
5515
|
+
visibility=_public_visibility_from_member_auth(result.get("auth")),
|
|
5516
|
+
auth=deepcopy(result.get("auth")) if isinstance(result.get("auth"), dict) else {},
|
|
5517
|
+
config=deepcopy(result.get("config")) if isinstance(result.get("config"), dict) else {},
|
|
5518
|
+
dash_global_config=deepcopy(result.get("dashGlobalConfig")) if isinstance(result.get("dashGlobalConfig"), dict) else {},
|
|
5519
|
+
component_count=len(result.get("components") or []) if isinstance(result.get("components"), list) else 0,
|
|
5520
|
+
components=_normalize_portal_components(result.get("components")),
|
|
5521
|
+
)
|
|
5522
|
+
return {
|
|
5523
|
+
"status": "success",
|
|
5524
|
+
"error_code": None,
|
|
5525
|
+
"recoverable": False,
|
|
5526
|
+
"message": "read portal detail",
|
|
5527
|
+
"normalized_args": {"dash_key": dash_key, "being_draft": being_draft},
|
|
5528
|
+
"missing_fields": [],
|
|
5529
|
+
"allowed_values": {},
|
|
5530
|
+
"details": {},
|
|
5531
|
+
"request_id": None,
|
|
5532
|
+
"suggested_next_call": None,
|
|
5533
|
+
"noop": False,
|
|
5534
|
+
"warnings": [],
|
|
5535
|
+
"verification": {"portal_exists": True, "being_draft": being_draft},
|
|
5536
|
+
"verified": True,
|
|
5537
|
+
**response.model_dump(mode="json"),
|
|
5538
|
+
}
|
|
5539
|
+
|
|
5540
|
+
def portal_read_summary(self, *, profile: str, dash_key: str, being_draft: bool = True) -> JSONObject:
|
|
5541
|
+
try:
|
|
5542
|
+
result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft).get("result") or {}
|
|
3858
5543
|
except (QingflowApiError, RuntimeError) as error:
|
|
3859
5544
|
api_error = _coerce_api_error(error)
|
|
3860
5545
|
return _failed_from_api_error(
|
|
@@ -3921,6 +5606,7 @@ class AiBuilderFacade:
|
|
|
3921
5606
|
"base_info_verified": True,
|
|
3922
5607
|
"questions_verified": True,
|
|
3923
5608
|
"associations_verified": True,
|
|
5609
|
+
"associated_resources_verified": True,
|
|
3924
5610
|
}
|
|
3925
5611
|
|
|
3926
5612
|
base_info: dict[str, Any] = {}
|
|
@@ -3950,6 +5636,19 @@ class AiBuilderFacade:
|
|
|
3950
5636
|
verification["associations_verified"] = False
|
|
3951
5637
|
warnings.append(_warning("VIEW_ASSOCIATIONS_UNAVAILABLE", "view association list readback is unavailable"))
|
|
3952
5638
|
|
|
5639
|
+
app_key = str(_first_present(config, "appKey", "formKey") or _first_present(base_info, "appKey", "formKey") or "").strip()
|
|
5640
|
+
associated_resources: list[dict[str, Any]] = []
|
|
5641
|
+
if app_key:
|
|
5642
|
+
try:
|
|
5643
|
+
associated_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
|
|
5644
|
+
except (QingflowApiError, RuntimeError):
|
|
5645
|
+
verification["associated_resources_verified"] = False
|
|
5646
|
+
warnings.append(_warning("VIEW_ASSOCIATED_RESOURCES_UNAVAILABLE", "view associated resource pool readback is unavailable"))
|
|
5647
|
+
associated_resources_config = _extract_view_associated_resources_config(
|
|
5648
|
+
config if isinstance(config, dict) else {},
|
|
5649
|
+
available_resources=associated_resources,
|
|
5650
|
+
)
|
|
5651
|
+
|
|
3953
5652
|
response = ViewGetResponse(
|
|
3954
5653
|
view_key=view_key,
|
|
3955
5654
|
base_info=base_info,
|
|
@@ -3957,7 +5656,16 @@ class AiBuilderFacade:
|
|
|
3957
5656
|
config=deepcopy(config) if isinstance(config, dict) else {},
|
|
3958
5657
|
questions=questions,
|
|
3959
5658
|
associations=associations,
|
|
5659
|
+
associated_resources_config=associated_resources_config,
|
|
3960
5660
|
)
|
|
5661
|
+
question_entries = _extract_view_question_entries(config.get("viewgraphQuestions"))
|
|
5662
|
+
canonical_question_entries = _extract_view_question_entries(questions)
|
|
5663
|
+
question_entries_by_id = {
|
|
5664
|
+
field_id: entry
|
|
5665
|
+
for entry in [*question_entries, *canonical_question_entries]
|
|
5666
|
+
if (field_id := _coerce_nonnegative_int(entry.get("field_id"))) is not None
|
|
5667
|
+
}
|
|
5668
|
+
query_conditions = _extract_view_query_conditions_config(config, question_entries_by_id=question_entries_by_id)
|
|
3961
5669
|
return {
|
|
3962
5670
|
"status": "success",
|
|
3963
5671
|
"error_code": None,
|
|
@@ -3973,6 +5681,8 @@ class AiBuilderFacade:
|
|
|
3973
5681
|
"warnings": warnings,
|
|
3974
5682
|
"verification": verification,
|
|
3975
5683
|
"verified": all(bool(value) for value in verification.values()),
|
|
5684
|
+
"query_conditions": query_conditions,
|
|
5685
|
+
"associated_resources_config": associated_resources_config,
|
|
3976
5686
|
**response.model_dump(mode="json"),
|
|
3977
5687
|
}
|
|
3978
5688
|
|
|
@@ -4414,6 +6124,18 @@ class AiBuilderFacade:
|
|
|
4414
6124
|
)
|
|
4415
6125
|
if translated_filters:
|
|
4416
6126
|
patch["filters"] = [dict(rule) for rule in (patch.get("filters") or [])]
|
|
6127
|
+
_, _, query_condition_issues = _build_view_query_conditions_payload(
|
|
6128
|
+
current_fields_by_name=current_fields_by_name,
|
|
6129
|
+
query_conditions=patch.get("query_conditions"),
|
|
6130
|
+
)
|
|
6131
|
+
if query_condition_issues:
|
|
6132
|
+
blocking_issues.extend(
|
|
6133
|
+
{
|
|
6134
|
+
**issue,
|
|
6135
|
+
"view_name": patch.get("name"),
|
|
6136
|
+
}
|
|
6137
|
+
for issue in query_condition_issues
|
|
6138
|
+
)
|
|
4417
6139
|
normalized_args = {
|
|
4418
6140
|
"app_key": request.app_key,
|
|
4419
6141
|
"upsert_views": upsert_views,
|
|
@@ -4465,6 +6187,7 @@ class AiBuilderFacade:
|
|
|
4465
6187
|
"fields": parsed["fields"],
|
|
4466
6188
|
"field_count": len(parsed["fields"]),
|
|
4467
6189
|
},
|
|
6190
|
+
"form_settings": _form_settings_from_schema(schema_result, parsed["fields"]),
|
|
4468
6191
|
"layout": parsed["layout"],
|
|
4469
6192
|
"flow_summary": {
|
|
4470
6193
|
"enabled": bool(state["workflow"]),
|
|
@@ -4688,6 +6411,7 @@ class AiBuilderFacade:
|
|
|
4688
6411
|
schema_readback_delayed = True
|
|
4689
6412
|
parsed = _parse_schema(schema_result)
|
|
4690
6413
|
current_fields = parsed["fields"]
|
|
6414
|
+
original_fields = deepcopy(current_fields)
|
|
4691
6415
|
layout = parsed["layout"]
|
|
4692
6416
|
existing_index = {field["field_id"]: index for index, field in enumerate(current_fields)}
|
|
4693
6417
|
selector_map = _build_selector_map(current_fields)
|
|
@@ -4832,6 +6556,45 @@ class AiBuilderFacade:
|
|
|
4832
6556
|
suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
|
|
4833
6557
|
)
|
|
4834
6558
|
|
|
6559
|
+
try:
|
|
6560
|
+
data_display_selection = _collect_data_display_marker_selection(current_fields)
|
|
6561
|
+
except _DataDisplayConfigError as error:
|
|
6562
|
+
return _failed(
|
|
6563
|
+
error.error_code,
|
|
6564
|
+
error.message,
|
|
6565
|
+
normalized_args=normalized_args,
|
|
6566
|
+
details={"app_key": target.app_key, **error.details},
|
|
6567
|
+
suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
|
|
6568
|
+
)
|
|
6569
|
+
|
|
6570
|
+
schema_write_requested = bool(
|
|
6571
|
+
added
|
|
6572
|
+
or updated
|
|
6573
|
+
or removed
|
|
6574
|
+
or normalized_code_block_fields
|
|
6575
|
+
or data_display_selection.has_any
|
|
6576
|
+
or bool(resolved.get("created"))
|
|
6577
|
+
)
|
|
6578
|
+
if schema_write_requested:
|
|
6579
|
+
try:
|
|
6580
|
+
_ensure_required_data_title_config(
|
|
6581
|
+
current_schema=schema_result,
|
|
6582
|
+
original_fields=original_fields,
|
|
6583
|
+
fields=current_fields,
|
|
6584
|
+
selection=data_display_selection,
|
|
6585
|
+
)
|
|
6586
|
+
except _DataDisplayConfigError as error:
|
|
6587
|
+
return _failed(
|
|
6588
|
+
error.error_code,
|
|
6589
|
+
error.message,
|
|
6590
|
+
normalized_args=normalized_args,
|
|
6591
|
+
details={"app_key": target.app_key, **error.details},
|
|
6592
|
+
suggested_next_call={
|
|
6593
|
+
"tool_name": "app_get_fields",
|
|
6594
|
+
"arguments": {"profile": profile, "app_key": target.app_key},
|
|
6595
|
+
},
|
|
6596
|
+
)
|
|
6597
|
+
|
|
4835
6598
|
relation_field_count = _count_relation_fields(current_fields)
|
|
4836
6599
|
relation_limit_verified = relation_field_count <= 1
|
|
4837
6600
|
relation_warnings = (
|
|
@@ -4851,7 +6614,7 @@ class AiBuilderFacade:
|
|
|
4851
6614
|
else []
|
|
4852
6615
|
)
|
|
4853
6616
|
|
|
4854
|
-
if not added and not updated and not removed and not normalized_code_block_fields and not bool(resolved.get("created")):
|
|
6617
|
+
if not added and not updated and not removed and not normalized_code_block_fields and not data_display_selection.has_any and not bool(resolved.get("created")):
|
|
4855
6618
|
base_info = self.apps.app_get_base(profile=profile, app_key=target.app_key, include_raw=True).get("result") or {}
|
|
4856
6619
|
tag_ids_after = _coerce_int_list(base_info.get("tagIds"))
|
|
4857
6620
|
package_attached = None if package_tag_id is None else package_tag_id in tag_ids_after
|
|
@@ -4915,6 +6678,7 @@ class AiBuilderFacade:
|
|
|
4915
6678
|
question_relations=compiled_question_relations,
|
|
4916
6679
|
)
|
|
4917
6680
|
)
|
|
6681
|
+
_apply_data_display_selection_to_payload(payload, fields=current_fields, selection=data_display_selection)
|
|
4918
6682
|
payload["editVersionNo"] = self._resolve_form_edit_version(
|
|
4919
6683
|
profile=profile,
|
|
4920
6684
|
app_key=target.app_key,
|
|
@@ -5033,6 +6797,7 @@ class AiBuilderFacade:
|
|
|
5033
6797
|
question_relations=compiled_question_relations,
|
|
5034
6798
|
)
|
|
5035
6799
|
)
|
|
6800
|
+
_apply_data_display_selection_to_payload(rebound_payload, fields=rebound_fields, selection=data_display_selection)
|
|
5036
6801
|
rebound_payload["editVersionNo"] = self._resolve_form_edit_version(
|
|
5037
6802
|
profile=profile,
|
|
5038
6803
|
app_key=target.app_key,
|
|
@@ -5106,6 +6871,13 @@ class AiBuilderFacade:
|
|
|
5106
6871
|
verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
|
|
5107
6872
|
verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
|
|
5108
6873
|
verification_ok = all(name in verified_field_names for name in added + updated) and all(name not in verified_field_names for name in removed)
|
|
6874
|
+
data_display_verification = _verify_data_display_readback(
|
|
6875
|
+
form_settings=verified.get("form_settings"),
|
|
6876
|
+
selection=data_display_selection,
|
|
6877
|
+
)
|
|
6878
|
+
response["verification"].update(data_display_verification)
|
|
6879
|
+
if data_display_verification.get("data_display_config_verified") is False:
|
|
6880
|
+
verification_ok = False
|
|
5109
6881
|
if relation_degraded_expectations:
|
|
5110
6882
|
relation_verified_fields = cast(list[dict[str, Any]], verified["schema"]["fields"])
|
|
5111
6883
|
try:
|
|
@@ -5706,13 +7478,18 @@ class AiBuilderFacade:
|
|
|
5706
7478
|
"message": "no view changes requested",
|
|
5707
7479
|
"normalized_args": normalized_args,
|
|
5708
7480
|
"missing_fields": [],
|
|
5709
|
-
"allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
|
|
7481
|
+
"allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator], "view.associated_resources.limit_type": ["all", "select"]},
|
|
5710
7482
|
"details": {},
|
|
5711
7483
|
"request_id": None,
|
|
5712
7484
|
"suggested_next_call": None,
|
|
5713
7485
|
"noop": True,
|
|
5714
7486
|
"warnings": [],
|
|
5715
|
-
"verification": {
|
|
7487
|
+
"verification": {
|
|
7488
|
+
"views_verified": True,
|
|
7489
|
+
"view_filters_verified": True,
|
|
7490
|
+
"view_query_conditions_verified": True,
|
|
7491
|
+
"view_associated_resources_verified": True,
|
|
7492
|
+
},
|
|
5716
7493
|
"app_key": app_key,
|
|
5717
7494
|
"views_diff": {"created": [], "updated": [], "removed": []},
|
|
5718
7495
|
"verified": True,
|
|
@@ -5743,7 +7520,7 @@ class AiBuilderFacade:
|
|
|
5743
7520
|
api_error,
|
|
5744
7521
|
normalized_args=normalized_args,
|
|
5745
7522
|
details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
|
|
5746
|
-
suggested_next_call={"tool_name": "
|
|
7523
|
+
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
5747
7524
|
))
|
|
5748
7525
|
existing_views = existing_views or []
|
|
5749
7526
|
existing_by_key: dict[str, dict[str, Any]] = {}
|
|
@@ -5785,7 +7562,7 @@ class AiBuilderFacade:
|
|
|
5785
7562
|
api_error,
|
|
5786
7563
|
normalized_args=normalized_args,
|
|
5787
7564
|
details=_with_state_read_blocked_details({"app_key": app_key}, resource="custom_button", error=api_error),
|
|
5788
|
-
suggested_next_call={"tool_name": "
|
|
7565
|
+
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
5789
7566
|
)
|
|
5790
7567
|
)
|
|
5791
7568
|
valid_custom_button_ids = {
|
|
@@ -5813,6 +7590,22 @@ class AiBuilderFacade:
|
|
|
5813
7590
|
detail_result = detail.get("result")
|
|
5814
7591
|
if isinstance(detail_result, dict):
|
|
5815
7592
|
custom_button_details_by_id[button_id] = _normalize_custom_button_detail(detail_result)
|
|
7593
|
+
requires_associated_resource_validation = any(patch.associated_resources is not None for patch in upsert_views)
|
|
7594
|
+
associated_resources: list[dict[str, Any]] = []
|
|
7595
|
+
if requires_associated_resource_validation:
|
|
7596
|
+
try:
|
|
7597
|
+
associated_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
|
|
7598
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
7599
|
+
api_error = _coerce_api_error(error)
|
|
7600
|
+
return finalize(
|
|
7601
|
+
_failed_from_api_error(
|
|
7602
|
+
"ASSOCIATED_RESOURCES_READ_FAILED",
|
|
7603
|
+
api_error,
|
|
7604
|
+
normalized_args=normalized_args,
|
|
7605
|
+
details=_with_state_read_blocked_details({"app_key": app_key}, resource="associated_resources", error=api_error),
|
|
7606
|
+
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
7607
|
+
)
|
|
7608
|
+
)
|
|
5816
7609
|
removed: list[str] = []
|
|
5817
7610
|
view_results: list[dict[str, Any]] = []
|
|
5818
7611
|
for name in remove_views:
|
|
@@ -5820,7 +7613,7 @@ class AiBuilderFacade:
|
|
|
5820
7613
|
if len(matches) > 1:
|
|
5821
7614
|
return _failed(
|
|
5822
7615
|
"AMBIGUOUS_VIEW",
|
|
5823
|
-
"multiple views matched remove request; use
|
|
7616
|
+
"multiple views matched remove request; use app_get and resolve duplicates before removing by name",
|
|
5824
7617
|
normalized_args=normalized_args,
|
|
5825
7618
|
details={
|
|
5826
7619
|
"app_key": app_key,
|
|
@@ -5830,7 +7623,7 @@ class AiBuilderFacade:
|
|
|
5830
7623
|
for view in matches
|
|
5831
7624
|
],
|
|
5832
7625
|
},
|
|
5833
|
-
suggested_next_call={"tool_name": "
|
|
7626
|
+
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
5834
7627
|
)
|
|
5835
7628
|
if len(matches) == 1:
|
|
5836
7629
|
key = _extract_view_key(matches[0])
|
|
@@ -5924,6 +7717,52 @@ class AiBuilderFacade:
|
|
|
5924
7717
|
allowed_values=first_issue.get("allowed_values") or {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
|
|
5925
7718
|
suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
|
|
5926
7719
|
)
|
|
7720
|
+
query_condition_payload, expected_query_conditions, query_condition_issues = _build_view_query_conditions_payload(
|
|
7721
|
+
current_fields_by_name=current_fields_by_name,
|
|
7722
|
+
query_conditions=patch.query_conditions,
|
|
7723
|
+
)
|
|
7724
|
+
if query_condition_issues:
|
|
7725
|
+
first_issue = query_condition_issues[0]
|
|
7726
|
+
return _failed(
|
|
7727
|
+
str(first_issue.get("error_code") or "INVALID_QUERY_CONDITION_FIELD"),
|
|
7728
|
+
"view query conditions reference invalid fields or values",
|
|
7729
|
+
normalized_args=normalized_args,
|
|
7730
|
+
details={
|
|
7731
|
+
"app_key": app_key,
|
|
7732
|
+
"view_name": patch.name,
|
|
7733
|
+
**first_issue,
|
|
7734
|
+
},
|
|
7735
|
+
missing_fields=list(first_issue.get("missing_fields") or []),
|
|
7736
|
+
allowed_values={
|
|
7737
|
+
"view_types": [member.value for member in PublicViewType],
|
|
7738
|
+
"view.query_condition.supported_field_types": sorted(
|
|
7739
|
+
set(FIELD_TYPE_TO_QUESTION_TYPE) - QUERY_CONDITION_UNSUPPORTED_FIELD_TYPES
|
|
7740
|
+
),
|
|
7741
|
+
},
|
|
7742
|
+
suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
|
|
7743
|
+
)
|
|
7744
|
+
associated_resources_payload, expected_associated_resources, associated_resource_issues = _build_view_associated_resources_payload(
|
|
7745
|
+
associated_resources=patch.associated_resources,
|
|
7746
|
+
available_resources=associated_resources,
|
|
7747
|
+
)
|
|
7748
|
+
if associated_resource_issues:
|
|
7749
|
+
first_issue = associated_resource_issues[0]
|
|
7750
|
+
return _failed(
|
|
7751
|
+
str(first_issue.get("error_code") or "INVALID_ASSOCIATED_RESOURCE"),
|
|
7752
|
+
"view associated resources reference invalid ids or values",
|
|
7753
|
+
normalized_args=normalized_args,
|
|
7754
|
+
details={
|
|
7755
|
+
"app_key": app_key,
|
|
7756
|
+
"view_name": patch.name,
|
|
7757
|
+
**first_issue,
|
|
7758
|
+
},
|
|
7759
|
+
missing_fields=list(first_issue.get("missing_fields") or []),
|
|
7760
|
+
allowed_values={
|
|
7761
|
+
"view.associated_resources.limit_type": ["all", "select"],
|
|
7762
|
+
"view.associated_resources.available_associated_item_ids": sorted(_associated_resource_index(associated_resources)),
|
|
7763
|
+
},
|
|
7764
|
+
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
7765
|
+
)
|
|
5927
7766
|
explicit_button_dtos: list[dict[str, Any]] | None = None
|
|
5928
7767
|
expected_button_summary: list[dict[str, Any]] | None = None
|
|
5929
7768
|
if patch.buttons is not None:
|
|
@@ -5945,7 +7784,7 @@ class AiBuilderFacade:
|
|
|
5945
7784
|
},
|
|
5946
7785
|
missing_fields=list(first_issue.get("missing_fields") or []),
|
|
5947
7786
|
allowed_values=first_issue.get("allowed_values") or {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
|
|
5948
|
-
suggested_next_call={"tool_name": "
|
|
7787
|
+
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
5949
7788
|
)
|
|
5950
7789
|
expected_button_summary = _normalize_expected_view_buttons_for_compare(
|
|
5951
7790
|
explicit_button_dtos or [],
|
|
@@ -5961,7 +7800,7 @@ class AiBuilderFacade:
|
|
|
5961
7800
|
f"view_key '{patch.view_key}' does not exist on this app",
|
|
5962
7801
|
normalized_args=normalized_args,
|
|
5963
7802
|
details={"app_key": app_key, "view_key": patch.view_key, "view_name": patch.name},
|
|
5964
|
-
suggested_next_call={"tool_name": "
|
|
7803
|
+
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
5965
7804
|
)
|
|
5966
7805
|
existing_key = patch.view_key
|
|
5967
7806
|
else:
|
|
@@ -5979,7 +7818,7 @@ class AiBuilderFacade:
|
|
|
5979
7818
|
for view in name_matches
|
|
5980
7819
|
],
|
|
5981
7820
|
},
|
|
5982
|
-
suggested_next_call={"tool_name": "
|
|
7821
|
+
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
5983
7822
|
)
|
|
5984
7823
|
if len(name_matches) == 1:
|
|
5985
7824
|
matched_existing_view = name_matches[0]
|
|
@@ -5987,6 +7826,13 @@ class AiBuilderFacade:
|
|
|
5987
7826
|
created_key: str | None = None
|
|
5988
7827
|
system_view_list_type = _resolve_system_view_list_type(view_key=existing_key, view_name=patch.name) if existing_key else None
|
|
5989
7828
|
operation_phase = "view_update" if existing_key else "view_create"
|
|
7829
|
+
query_condition_payload_for_apply = deepcopy(query_condition_payload)
|
|
7830
|
+
expected_query_conditions_for_verify = deepcopy(expected_query_conditions)
|
|
7831
|
+
associated_resources_payload_for_apply = deepcopy(associated_resources_payload)
|
|
7832
|
+
expected_associated_resources_for_verify = deepcopy(expected_associated_resources)
|
|
7833
|
+
if not existing_key and query_condition_payload_for_apply is None:
|
|
7834
|
+
query_condition_payload_for_apply = _empty_view_query_conditions_payload()
|
|
7835
|
+
expected_query_conditions_for_verify = _normalize_view_query_conditions_for_compare(query_condition_payload_for_apply)
|
|
5990
7836
|
try:
|
|
5991
7837
|
view_auth_override = (
|
|
5992
7838
|
self._compile_visibility_to_member_auth(profile=profile, visibility=patch.visibility)
|
|
@@ -6013,6 +7859,8 @@ class AiBuilderFacade:
|
|
|
6013
7859
|
current_fields_by_name=current_fields_by_name,
|
|
6014
7860
|
auth_override=view_auth_override,
|
|
6015
7861
|
explicit_button_dtos=explicit_button_dtos,
|
|
7862
|
+
query_condition_payload=query_condition_payload_for_apply,
|
|
7863
|
+
associated_resources_payload=associated_resources_payload_for_apply,
|
|
6016
7864
|
)
|
|
6017
7865
|
self.views.view_update(profile=profile, viewgraph_key=existing_key, payload=payload)
|
|
6018
7866
|
system_view_sync: dict[str, Any] | None = None
|
|
@@ -6062,6 +7910,8 @@ class AiBuilderFacade:
|
|
|
6062
7910
|
"status": "updated",
|
|
6063
7911
|
"expected_filters": deepcopy(translated_filters),
|
|
6064
7912
|
"expected_buttons": deepcopy(expected_button_summary),
|
|
7913
|
+
"expected_query_conditions": deepcopy(expected_query_conditions_for_verify),
|
|
7914
|
+
"expected_associated_resources": deepcopy(expected_associated_resources_for_verify),
|
|
6065
7915
|
"system_view_sync": system_view_sync,
|
|
6066
7916
|
"apply_columns": deepcopy(apply_columns),
|
|
6067
7917
|
}
|
|
@@ -6082,6 +7932,8 @@ class AiBuilderFacade:
|
|
|
6082
7932
|
current_fields_by_name=current_fields_by_name,
|
|
6083
7933
|
auth_override=view_auth_override,
|
|
6084
7934
|
explicit_button_dtos=explicit_button_dtos,
|
|
7935
|
+
query_condition_payload=query_condition_payload_for_apply,
|
|
7936
|
+
associated_resources_payload=associated_resources_payload_for_apply,
|
|
6085
7937
|
)
|
|
6086
7938
|
self.views.view_update(profile=profile, viewgraph_key=created_key, payload=payload)
|
|
6087
7939
|
else:
|
|
@@ -6095,6 +7947,8 @@ class AiBuilderFacade:
|
|
|
6095
7947
|
current_fields_by_name=current_fields_by_name,
|
|
6096
7948
|
auth_override=view_auth_override,
|
|
6097
7949
|
explicit_button_dtos=explicit_button_dtos,
|
|
7950
|
+
query_condition_payload=query_condition_payload_for_apply,
|
|
7951
|
+
associated_resources_payload=associated_resources_payload_for_apply,
|
|
6098
7952
|
)
|
|
6099
7953
|
create_result = self.views.view_create(profile=profile, payload=payload)
|
|
6100
7954
|
raw_created = create_result.get("result")
|
|
@@ -6112,6 +7966,8 @@ class AiBuilderFacade:
|
|
|
6112
7966
|
"status": "created",
|
|
6113
7967
|
"expected_filters": deepcopy(translated_filters),
|
|
6114
7968
|
"expected_buttons": deepcopy(expected_button_summary),
|
|
7969
|
+
"expected_query_conditions": deepcopy(expected_query_conditions_for_verify),
|
|
7970
|
+
"expected_associated_resources": deepcopy(expected_associated_resources_for_verify),
|
|
6115
7971
|
}
|
|
6116
7972
|
)
|
|
6117
7973
|
except (QingflowApiError, RuntimeError) as error:
|
|
@@ -6142,6 +7998,8 @@ class AiBuilderFacade:
|
|
|
6142
7998
|
current_fields_by_name=current_fields_by_name,
|
|
6143
7999
|
auth_override=view_auth_override,
|
|
6144
8000
|
explicit_button_dtos=fallback_button_dtos,
|
|
8001
|
+
query_condition_payload=query_condition_payload_for_apply,
|
|
8002
|
+
associated_resources_payload=associated_resources_payload_for_apply,
|
|
6145
8003
|
)
|
|
6146
8004
|
self.views.view_update(profile=profile, viewgraph_key=target_key, payload=fallback_payload)
|
|
6147
8005
|
system_view_sync: dict[str, Any] | None = None
|
|
@@ -6198,6 +8056,8 @@ class AiBuilderFacade:
|
|
|
6198
8056
|
"fallback_applied": True,
|
|
6199
8057
|
"expected_filters": deepcopy(translated_filters),
|
|
6200
8058
|
"expected_buttons": deepcopy(expected_button_summary),
|
|
8059
|
+
"expected_query_conditions": deepcopy(expected_query_conditions_for_verify),
|
|
8060
|
+
"expected_associated_resources": deepcopy(expected_associated_resources_for_verify),
|
|
6201
8061
|
"system_view_sync": system_view_sync,
|
|
6202
8062
|
"apply_columns": deepcopy(apply_columns),
|
|
6203
8063
|
}
|
|
@@ -6213,6 +8073,8 @@ class AiBuilderFacade:
|
|
|
6213
8073
|
"fallback_applied": True,
|
|
6214
8074
|
"expected_filters": deepcopy(translated_filters),
|
|
6215
8075
|
"expected_buttons": deepcopy(expected_button_summary),
|
|
8076
|
+
"expected_query_conditions": deepcopy(expected_query_conditions_for_verify),
|
|
8077
|
+
"expected_associated_resources": deepcopy(expected_associated_resources_for_verify),
|
|
6216
8078
|
"system_view_sync": system_view_sync,
|
|
6217
8079
|
"apply_columns": deepcopy(apply_columns),
|
|
6218
8080
|
}
|
|
@@ -6227,6 +8089,8 @@ class AiBuilderFacade:
|
|
|
6227
8089
|
current_fields_by_name=current_fields_by_name,
|
|
6228
8090
|
auth_override=view_auth_override,
|
|
6229
8091
|
explicit_button_dtos=explicit_button_dtos,
|
|
8092
|
+
query_condition_payload=query_condition_payload_for_apply,
|
|
8093
|
+
associated_resources_payload=associated_resources_payload_for_apply,
|
|
6230
8094
|
)
|
|
6231
8095
|
self.views.view_create(profile=profile, payload=fallback_payload)
|
|
6232
8096
|
created.append(patch.name)
|
|
@@ -6238,6 +8102,8 @@ class AiBuilderFacade:
|
|
|
6238
8102
|
"status": "created",
|
|
6239
8103
|
"fallback_applied": True,
|
|
6240
8104
|
"expected_filters": deepcopy(translated_filters),
|
|
8105
|
+
"expected_query_conditions": deepcopy(expected_query_conditions_for_verify),
|
|
8106
|
+
"expected_associated_resources": deepcopy(expected_associated_resources_for_verify),
|
|
6241
8107
|
}
|
|
6242
8108
|
)
|
|
6243
8109
|
continue
|
|
@@ -6290,7 +8156,7 @@ class AiBuilderFacade:
|
|
|
6290
8156
|
api_error,
|
|
6291
8157
|
normalized_args=normalized_args,
|
|
6292
8158
|
details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
|
|
6293
|
-
suggested_next_call={"tool_name": "
|
|
8159
|
+
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
6294
8160
|
))
|
|
6295
8161
|
verified_names = {
|
|
6296
8162
|
_extract_view_name(item)
|
|
@@ -6313,6 +8179,10 @@ class AiBuilderFacade:
|
|
|
6313
8179
|
verification_by_view: list[dict[str, Any]] = []
|
|
6314
8180
|
filter_readback_pending = False
|
|
6315
8181
|
filter_mismatches: list[dict[str, Any]] = []
|
|
8182
|
+
query_condition_readback_pending = False
|
|
8183
|
+
query_condition_mismatches: list[dict[str, Any]] = []
|
|
8184
|
+
associated_resource_readback_pending = False
|
|
8185
|
+
associated_resource_mismatches: list[dict[str, Any]] = []
|
|
6316
8186
|
button_readback_pending = False
|
|
6317
8187
|
button_mismatches: list[dict[str, Any]] = []
|
|
6318
8188
|
custom_button_readback_pending = False
|
|
@@ -6340,6 +8210,8 @@ class AiBuilderFacade:
|
|
|
6340
8210
|
if isinstance(system_view_sync, dict):
|
|
6341
8211
|
verification_entry["system_view_sync"] = deepcopy(system_view_sync)
|
|
6342
8212
|
expected_filters = item.get("expected_filters") or []
|
|
8213
|
+
expected_query_conditions = item.get("expected_query_conditions") if isinstance(item.get("expected_query_conditions"), dict) else None
|
|
8214
|
+
expected_associated_resources = item.get("expected_associated_resources") if isinstance(item.get("expected_associated_resources"), dict) else None
|
|
6343
8215
|
expected_buttons = item.get("expected_buttons") if isinstance(item.get("expected_buttons"), list) else None
|
|
6344
8216
|
if expected_filters:
|
|
6345
8217
|
if verified_views_unavailable or not present_in_readback:
|
|
@@ -6396,6 +8268,108 @@ class AiBuilderFacade:
|
|
|
6396
8268
|
"category": api_error.category,
|
|
6397
8269
|
}
|
|
6398
8270
|
filter_readback_pending = True
|
|
8271
|
+
if expected_query_conditions is not None:
|
|
8272
|
+
if verified_views_unavailable or not present_in_readback:
|
|
8273
|
+
verification_entry["query_conditions_verified"] = None
|
|
8274
|
+
verification_entry["query_condition_readback_pending"] = True
|
|
8275
|
+
query_condition_readback_pending = True
|
|
8276
|
+
else:
|
|
8277
|
+
verification_key = item_view_key
|
|
8278
|
+
if not verification_key:
|
|
8279
|
+
matched_keys = verified_view_keys_by_name.get(name) or []
|
|
8280
|
+
if len(matched_keys) == 1:
|
|
8281
|
+
verification_key = matched_keys[0]
|
|
8282
|
+
else:
|
|
8283
|
+
verification_entry["query_conditions_verified"] = None
|
|
8284
|
+
verification_entry["query_condition_readback_pending"] = True
|
|
8285
|
+
verification_entry["readback_ambiguous"] = True
|
|
8286
|
+
verification_entry["matching_view_keys"] = matched_keys
|
|
8287
|
+
query_condition_readback_pending = True
|
|
8288
|
+
verification_by_view.append(verification_entry)
|
|
8289
|
+
continue
|
|
8290
|
+
try:
|
|
8291
|
+
config_response = self.views.view_get_config(profile=profile, viewgraph_key=verification_key)
|
|
8292
|
+
config_result = (config_response.get("result") or {}) if isinstance(config_response.get("result"), dict) else {}
|
|
8293
|
+
actual_query_conditions = _normalize_view_query_conditions_for_compare(config_result)
|
|
8294
|
+
query_conditions_verified = actual_query_conditions == expected_query_conditions
|
|
8295
|
+
verification_entry["query_conditions_verified"] = query_conditions_verified
|
|
8296
|
+
verification_entry["view_key"] = verification_key
|
|
8297
|
+
verification_entry["expected_query_conditions"] = deepcopy(expected_query_conditions)
|
|
8298
|
+
verification_entry["actual_query_conditions"] = actual_query_conditions
|
|
8299
|
+
if not query_conditions_verified:
|
|
8300
|
+
query_condition_mismatches.append(
|
|
8301
|
+
{
|
|
8302
|
+
"name": name,
|
|
8303
|
+
"type": item.get("type"),
|
|
8304
|
+
"expected_query_conditions": deepcopy(expected_query_conditions),
|
|
8305
|
+
"actual_query_conditions": actual_query_conditions,
|
|
8306
|
+
}
|
|
8307
|
+
)
|
|
8308
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
8309
|
+
api_error = _coerce_api_error(error)
|
|
8310
|
+
verification_entry["query_conditions_verified"] = None
|
|
8311
|
+
verification_entry["query_condition_readback_pending"] = True
|
|
8312
|
+
verification_entry["request_id"] = api_error.request_id
|
|
8313
|
+
verification_entry["transport_error"] = {
|
|
8314
|
+
"http_status": api_error.http_status,
|
|
8315
|
+
"backend_code": api_error.backend_code,
|
|
8316
|
+
"category": api_error.category,
|
|
8317
|
+
}
|
|
8318
|
+
query_condition_readback_pending = True
|
|
8319
|
+
if expected_associated_resources is not None:
|
|
8320
|
+
if verified_views_unavailable or not present_in_readback:
|
|
8321
|
+
verification_entry["associated_resources_verified"] = None
|
|
8322
|
+
verification_entry["associated_resource_readback_pending"] = True
|
|
8323
|
+
associated_resource_readback_pending = True
|
|
8324
|
+
else:
|
|
8325
|
+
verification_key = item_view_key
|
|
8326
|
+
if not verification_key:
|
|
8327
|
+
matched_keys = verified_view_keys_by_name.get(name) or []
|
|
8328
|
+
if len(matched_keys) == 1:
|
|
8329
|
+
verification_key = matched_keys[0]
|
|
8330
|
+
else:
|
|
8331
|
+
verification_entry["associated_resources_verified"] = None
|
|
8332
|
+
verification_entry["associated_resource_readback_pending"] = True
|
|
8333
|
+
verification_entry["readback_ambiguous"] = True
|
|
8334
|
+
verification_entry["matching_view_keys"] = matched_keys
|
|
8335
|
+
associated_resource_readback_pending = True
|
|
8336
|
+
verification_by_view.append(verification_entry)
|
|
8337
|
+
continue
|
|
8338
|
+
try:
|
|
8339
|
+
config_response = self.views.view_get_config(profile=profile, viewgraph_key=verification_key)
|
|
8340
|
+
config_result = (config_response.get("result") or {}) if isinstance(config_response.get("result"), dict) else {}
|
|
8341
|
+
actual_associated_resources = _extract_view_associated_resources_config(
|
|
8342
|
+
config_result,
|
|
8343
|
+
available_resources=associated_resources,
|
|
8344
|
+
)
|
|
8345
|
+
associated_resources_verified = _associated_resources_config_matches(
|
|
8346
|
+
expected_associated_resources,
|
|
8347
|
+
actual_associated_resources,
|
|
8348
|
+
)
|
|
8349
|
+
verification_entry["associated_resources_verified"] = associated_resources_verified
|
|
8350
|
+
verification_entry["view_key"] = verification_key
|
|
8351
|
+
verification_entry["expected_associated_resources"] = deepcopy(expected_associated_resources)
|
|
8352
|
+
verification_entry["actual_associated_resources"] = actual_associated_resources
|
|
8353
|
+
if not associated_resources_verified:
|
|
8354
|
+
associated_resource_mismatches.append(
|
|
8355
|
+
{
|
|
8356
|
+
"name": name,
|
|
8357
|
+
"type": item.get("type"),
|
|
8358
|
+
"expected_associated_resources": deepcopy(expected_associated_resources),
|
|
8359
|
+
"actual_associated_resources": actual_associated_resources,
|
|
8360
|
+
}
|
|
8361
|
+
)
|
|
8362
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
8363
|
+
api_error = _coerce_api_error(error)
|
|
8364
|
+
verification_entry["associated_resources_verified"] = None
|
|
8365
|
+
verification_entry["associated_resource_readback_pending"] = True
|
|
8366
|
+
verification_entry["request_id"] = api_error.request_id
|
|
8367
|
+
verification_entry["transport_error"] = {
|
|
8368
|
+
"http_status": api_error.http_status,
|
|
8369
|
+
"backend_code": api_error.backend_code,
|
|
8370
|
+
"category": api_error.category,
|
|
8371
|
+
}
|
|
8372
|
+
associated_resource_readback_pending = True
|
|
6399
8373
|
if expected_buttons is not None:
|
|
6400
8374
|
if verified_views_unavailable or not present_in_readback:
|
|
6401
8375
|
verification_entry["buttons_verified"] = None
|
|
@@ -6486,6 +8460,8 @@ class AiBuilderFacade:
|
|
|
6486
8460
|
and all(name not in verified_names for name in removed)
|
|
6487
8461
|
)
|
|
6488
8462
|
view_filters_verified = verified and not filter_readback_pending and not filter_mismatches
|
|
8463
|
+
view_query_conditions_verified = verified and not query_condition_readback_pending and not query_condition_mismatches
|
|
8464
|
+
view_associated_resources_verified = verified and not associated_resource_readback_pending and not associated_resource_mismatches
|
|
6489
8465
|
view_buttons_verified = verified and not button_readback_pending and not button_mismatches
|
|
6490
8466
|
noop = not created and not updated and not removed
|
|
6491
8467
|
if failed_views:
|
|
@@ -6502,6 +8478,8 @@ class AiBuilderFacade:
|
|
|
6502
8478
|
"details": {
|
|
6503
8479
|
"per_view_results": view_results,
|
|
6504
8480
|
"filter_mismatches": filter_mismatches,
|
|
8481
|
+
"query_condition_mismatches": query_condition_mismatches,
|
|
8482
|
+
"associated_resource_mismatches": associated_resource_mismatches,
|
|
6505
8483
|
"button_mismatches": button_mismatches,
|
|
6506
8484
|
**(
|
|
6507
8485
|
{"custom_button_readback_pending": deepcopy(custom_button_readback_pending_entries)}
|
|
@@ -6510,7 +8488,7 @@ class AiBuilderFacade:
|
|
|
6510
8488
|
),
|
|
6511
8489
|
},
|
|
6512
8490
|
"request_id": first_failure.get("request_id"),
|
|
6513
|
-
"suggested_next_call": {"tool_name": "
|
|
8491
|
+
"suggested_next_call": {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
6514
8492
|
"backend_code": first_failure.get("backend_code"),
|
|
6515
8493
|
"http_status": first_failure.get("http_status"),
|
|
6516
8494
|
"noop": noop,
|
|
@@ -6520,6 +8498,16 @@ class AiBuilderFacade:
|
|
|
6520
8498
|
if (filter_readback_pending or filter_mismatches)
|
|
6521
8499
|
else []
|
|
6522
8500
|
)
|
|
8501
|
+
+ (
|
|
8502
|
+
[_warning("VIEW_QUERY_CONDITIONS_UNVERIFIED", "view definitions may exist, but query condition behavior is not fully verified")]
|
|
8503
|
+
if (query_condition_readback_pending or query_condition_mismatches)
|
|
8504
|
+
else []
|
|
8505
|
+
)
|
|
8506
|
+
+ (
|
|
8507
|
+
[_warning("VIEW_ASSOCIATED_RESOURCES_UNVERIFIED", "view definitions may exist, but associated resource visibility is not fully verified")]
|
|
8508
|
+
if (associated_resource_readback_pending or associated_resource_mismatches)
|
|
8509
|
+
else []
|
|
8510
|
+
)
|
|
6523
8511
|
+ (
|
|
6524
8512
|
[_warning("VIEW_BUTTONS_UNVERIFIED", "view definitions may exist, but saved button behavior is not fully verified")]
|
|
6525
8513
|
if (button_readback_pending or button_mismatches)
|
|
@@ -6534,6 +8522,8 @@ class AiBuilderFacade:
|
|
|
6534
8522
|
"verification": {
|
|
6535
8523
|
"views_verified": verified,
|
|
6536
8524
|
"view_filters_verified": view_filters_verified,
|
|
8525
|
+
"view_query_conditions_verified": view_query_conditions_verified,
|
|
8526
|
+
"view_associated_resources_verified": view_associated_resources_verified,
|
|
6537
8527
|
"view_buttons_verified": view_buttons_verified,
|
|
6538
8528
|
"views_read_unavailable": verified_views_unavailable,
|
|
6539
8529
|
"by_view": verification_by_view,
|
|
@@ -6542,12 +8532,16 @@ class AiBuilderFacade:
|
|
|
6542
8532
|
},
|
|
6543
8533
|
"app_key": app_key,
|
|
6544
8534
|
"views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
|
|
6545
|
-
"verified": verified and view_filters_verified and view_buttons_verified,
|
|
8535
|
+
"verified": verified and view_filters_verified and view_query_conditions_verified and view_associated_resources_verified and view_buttons_verified,
|
|
6546
8536
|
}
|
|
6547
8537
|
return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
|
|
6548
8538
|
warnings: list[dict[str, Any]] = []
|
|
6549
8539
|
if filter_readback_pending or filter_mismatches:
|
|
6550
8540
|
warnings.append(_warning("VIEW_FILTERS_UNVERIFIED", "view definitions were applied, but saved filter behavior is not fully verified"))
|
|
8541
|
+
if query_condition_readback_pending or query_condition_mismatches:
|
|
8542
|
+
warnings.append(_warning("VIEW_QUERY_CONDITIONS_UNVERIFIED", "view definitions were applied, but query condition behavior is not fully verified"))
|
|
8543
|
+
if associated_resource_readback_pending or associated_resource_mismatches:
|
|
8544
|
+
warnings.append(_warning("VIEW_ASSOCIATED_RESOURCES_UNVERIFIED", "view definitions were applied, but associated resource visibility is not fully verified"))
|
|
6551
8545
|
if button_readback_pending or button_mismatches:
|
|
6552
8546
|
warnings.append(_warning("VIEW_BUTTONS_UNVERIFIED", "view definitions were applied, but saved button behavior is not fully verified"))
|
|
6553
8547
|
if custom_button_readback_pending:
|
|
@@ -6557,24 +8551,31 @@ class AiBuilderFacade:
|
|
|
6557
8551
|
"system buttons verified, but draft custom button bindings are not fully visible through view readback yet",
|
|
6558
8552
|
)
|
|
6559
8553
|
)
|
|
8554
|
+
all_verified = verified and view_filters_verified and view_query_conditions_verified and view_associated_resources_verified and view_buttons_verified
|
|
6560
8555
|
response = {
|
|
6561
|
-
"status": "success" if
|
|
6562
|
-
"error_code": None if
|
|
6563
|
-
"recoverable": not
|
|
8556
|
+
"status": "success" if all_verified else "partial_success",
|
|
8557
|
+
"error_code": None if all_verified else ("VIEW_BUTTON_READBACK_MISMATCH" if button_mismatches else "VIEW_ASSOCIATED_RESOURCE_READBACK_MISMATCH" if associated_resource_mismatches else "VIEW_QUERY_CONDITION_READBACK_MISMATCH" if query_condition_mismatches else "VIEW_FILTER_READBACK_MISMATCH" if filter_mismatches else "VIEWS_READBACK_PENDING"),
|
|
8558
|
+
"recoverable": not all_verified,
|
|
6564
8559
|
"message": (
|
|
6565
8560
|
"applied view patch"
|
|
6566
|
-
if
|
|
8561
|
+
if all_verified
|
|
6567
8562
|
else "applied view patch; buttons did not fully verify"
|
|
6568
8563
|
if button_mismatches
|
|
8564
|
+
else "applied view patch; associated resources did not fully verify"
|
|
8565
|
+
if associated_resource_mismatches
|
|
8566
|
+
else "applied view patch; query conditions did not fully verify"
|
|
8567
|
+
if query_condition_mismatches
|
|
6569
8568
|
else "applied view patch; filters did not fully verify"
|
|
6570
8569
|
if filter_mismatches
|
|
6571
8570
|
else "applied view patch; views readback pending"
|
|
6572
8571
|
),
|
|
6573
8572
|
"normalized_args": normalized_args,
|
|
6574
8573
|
"missing_fields": [],
|
|
6575
|
-
"allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
|
|
8574
|
+
"allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator], "view.associated_resources.limit_type": ["all", "select"]},
|
|
6576
8575
|
"details": {
|
|
6577
8576
|
**({"filter_mismatches": filter_mismatches} if filter_mismatches else {}),
|
|
8577
|
+
**({"query_condition_mismatches": query_condition_mismatches} if query_condition_mismatches else {}),
|
|
8578
|
+
**({"associated_resource_mismatches": associated_resource_mismatches} if associated_resource_mismatches else {}),
|
|
6578
8579
|
**({"button_mismatches": button_mismatches} if button_mismatches else {}),
|
|
6579
8580
|
**(
|
|
6580
8581
|
{"custom_button_readback_pending": deepcopy(custom_button_readback_pending_entries)}
|
|
@@ -6583,15 +8584,19 @@ class AiBuilderFacade:
|
|
|
6583
8584
|
),
|
|
6584
8585
|
},
|
|
6585
8586
|
"request_id": None,
|
|
6586
|
-
"suggested_next_call": None if
|
|
8587
|
+
"suggested_next_call": None if all_verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
6587
8588
|
"noop": noop,
|
|
6588
8589
|
"warnings": warnings,
|
|
6589
8590
|
"verification": {
|
|
6590
8591
|
"views_verified": verified,
|
|
6591
8592
|
"view_filters_verified": view_filters_verified,
|
|
8593
|
+
"view_query_conditions_verified": view_query_conditions_verified,
|
|
8594
|
+
"view_associated_resources_verified": view_associated_resources_verified,
|
|
6592
8595
|
"view_buttons_verified": view_buttons_verified,
|
|
6593
8596
|
"views_read_unavailable": verified_views_unavailable,
|
|
6594
8597
|
"filter_readback_pending": filter_readback_pending,
|
|
8598
|
+
"query_condition_readback_pending": query_condition_readback_pending,
|
|
8599
|
+
"associated_resource_readback_pending": associated_resource_readback_pending,
|
|
6595
8600
|
"button_readback_pending": button_readback_pending,
|
|
6596
8601
|
"custom_button_readback_pending": custom_button_readback_pending,
|
|
6597
8602
|
"custom_button_readback_pending_entries": deepcopy(custom_button_readback_pending_entries),
|
|
@@ -6599,7 +8604,7 @@ class AiBuilderFacade:
|
|
|
6599
8604
|
},
|
|
6600
8605
|
"app_key": app_key,
|
|
6601
8606
|
"views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
|
|
6602
|
-
"verified":
|
|
8607
|
+
"verified": all_verified,
|
|
6603
8608
|
}
|
|
6604
8609
|
return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
|
|
6605
8610
|
|
|
@@ -7369,10 +9374,11 @@ class AiBuilderFacade:
|
|
|
7369
9374
|
"live_result": live_result,
|
|
7370
9375
|
})
|
|
7371
9376
|
|
|
7372
|
-
def _publish_current_edit_version(self, *, profile: str, app_key: str) -> JSONObject:
|
|
9377
|
+
def _publish_current_edit_version(self, *, profile: str, app_key: str, edit_version_no: int | None = None) -> JSONObject:
|
|
7373
9378
|
normalized_args = {"app_key": app_key}
|
|
7374
|
-
|
|
7375
|
-
|
|
9379
|
+
if edit_version_no is None:
|
|
9380
|
+
version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
|
|
9381
|
+
edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
|
|
7376
9382
|
try:
|
|
7377
9383
|
self.apps.app_publish(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
|
|
7378
9384
|
self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
|
|
@@ -7433,7 +9439,7 @@ class AiBuilderFacade:
|
|
|
7433
9439
|
edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
|
|
7434
9440
|
return edit_version_no, None
|
|
7435
9441
|
|
|
7436
|
-
def _append_publish_result(self, *, profile: str, app_key: str, publish: bool, response: JSONObject) -> JSONObject:
|
|
9442
|
+
def _append_publish_result(self, *, profile: str, app_key: str, publish: bool, response: JSONObject, edit_version_no: int | None = None) -> JSONObject:
|
|
7437
9443
|
verification = response.get("verification")
|
|
7438
9444
|
if not isinstance(verification, dict):
|
|
7439
9445
|
verification = {}
|
|
@@ -7453,7 +9459,7 @@ class AiBuilderFacade:
|
|
|
7453
9459
|
response["published"] = False
|
|
7454
9460
|
verification["published"] = False
|
|
7455
9461
|
return response
|
|
7456
|
-
publish_result = self._publish_current_edit_version(profile=profile, app_key=app_key)
|
|
9462
|
+
publish_result = self._publish_current_edit_version(profile=profile, app_key=app_key, edit_version_no=edit_version_no)
|
|
7457
9463
|
response["publish_result"] = publish_result
|
|
7458
9464
|
response["published"] = bool(publish_result.get("published"))
|
|
7459
9465
|
verification["published"] = bool(publish_result.get("published"))
|
|
@@ -8047,7 +10053,11 @@ def _extract_custom_button_id(value: Any) -> int | None:
|
|
|
8047
10053
|
return _coerce_positive_int(value)
|
|
8048
10054
|
|
|
8049
10055
|
|
|
8050
|
-
def _serialize_custom_button_payload(
|
|
10056
|
+
def _serialize_custom_button_payload(
|
|
10057
|
+
payload: CustomButtonPatch,
|
|
10058
|
+
*,
|
|
10059
|
+
add_data_config_override: dict[str, Any] | None = None,
|
|
10060
|
+
) -> dict[str, Any]:
|
|
8051
10061
|
data = payload.model_dump(mode="json", exclude_none=True)
|
|
8052
10062
|
serialized: dict[str, Any] = {
|
|
8053
10063
|
"buttonText": data["button_text"],
|
|
@@ -8058,7 +10068,7 @@ def _serialize_custom_button_payload(payload: CustomButtonPatch) -> dict[str, An
|
|
|
8058
10068
|
}
|
|
8059
10069
|
if str(data.get("trigger_link_url") or "").strip():
|
|
8060
10070
|
serialized["triggerLinkUrl"] = data["trigger_link_url"]
|
|
8061
|
-
trigger_add_data_config = data.get("trigger_add_data_config")
|
|
10071
|
+
trigger_add_data_config = add_data_config_override if add_data_config_override is not None else data.get("trigger_add_data_config")
|
|
8062
10072
|
if isinstance(trigger_add_data_config, dict):
|
|
8063
10073
|
serialized["triggerAddDataConfig"] = _serialize_custom_button_add_data_config(trigger_add_data_config)
|
|
8064
10074
|
else:
|
|
@@ -8140,6 +10150,274 @@ def _serialize_custom_button_match_rule(value: dict[str, Any]) -> dict[str, Any]
|
|
|
8140
10150
|
return {key: deepcopy(item) for key, item in serialized.items() if item is not None}
|
|
8141
10151
|
|
|
8142
10152
|
|
|
10153
|
+
def _custom_button_add_data_config_has_semantic_inputs(value: dict[str, Any]) -> bool:
|
|
10154
|
+
return bool(value.get("field_mappings") or value.get("fieldMappings") or value.get("default_values") or value.get("defaultValues"))
|
|
10155
|
+
|
|
10156
|
+
|
|
10157
|
+
def _custom_button_selector_payload(selector: Any) -> dict[str, Any]:
|
|
10158
|
+
if isinstance(selector, dict):
|
|
10159
|
+
payload = dict(selector)
|
|
10160
|
+
if "title" in payload and "name" not in payload:
|
|
10161
|
+
payload["name"] = payload.pop("title")
|
|
10162
|
+
if "label" in payload and "name" not in payload:
|
|
10163
|
+
payload["name"] = payload.pop("label")
|
|
10164
|
+
if "fieldId" in payload and "field_id" not in payload:
|
|
10165
|
+
payload["field_id"] = payload.pop("fieldId")
|
|
10166
|
+
if "queId" in payload and "que_id" not in payload:
|
|
10167
|
+
payload["que_id"] = payload.pop("queId")
|
|
10168
|
+
return payload
|
|
10169
|
+
if isinstance(selector, int):
|
|
10170
|
+
return {"que_id": selector}
|
|
10171
|
+
raw = str(selector or "").strip()
|
|
10172
|
+
if not raw:
|
|
10173
|
+
return {}
|
|
10174
|
+
if raw.isdigit():
|
|
10175
|
+
return {"que_id": int(raw)}
|
|
10176
|
+
return {"name": raw}
|
|
10177
|
+
|
|
10178
|
+
|
|
10179
|
+
def _resolve_custom_button_schema_field(
|
|
10180
|
+
*,
|
|
10181
|
+
fields: list[dict[str, Any]],
|
|
10182
|
+
selector: Any,
|
|
10183
|
+
reason_path: str,
|
|
10184
|
+
role: str,
|
|
10185
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
10186
|
+
selector_payload = _custom_button_selector_payload(selector)
|
|
10187
|
+
if not selector_payload:
|
|
10188
|
+
return None, {
|
|
10189
|
+
"error_code": "CUSTOM_BUTTON_MAPPING_FIELD_NOT_FOUND",
|
|
10190
|
+
"reason_path": reason_path,
|
|
10191
|
+
"message": f"{role} field selector is empty",
|
|
10192
|
+
"next_action": "pass a field title or {'field_id': ...}",
|
|
10193
|
+
}
|
|
10194
|
+
matched: list[dict[str, Any]] = []
|
|
10195
|
+
field_id = selector_payload.get("field_id")
|
|
10196
|
+
que_id = _coerce_nonnegative_int(selector_payload.get("que_id"))
|
|
10197
|
+
name = str(selector_payload.get("name") or "").strip()
|
|
10198
|
+
if field_id is not None:
|
|
10199
|
+
field_id_text = str(field_id).strip()
|
|
10200
|
+
matched = [
|
|
10201
|
+
field
|
|
10202
|
+
for field in fields
|
|
10203
|
+
if str(field.get("field_id") or "").strip() == field_id_text
|
|
10204
|
+
or (_coerce_nonnegative_int(field_id_text) is not None and _coerce_nonnegative_int(field.get("que_id")) == _coerce_nonnegative_int(field_id_text))
|
|
10205
|
+
]
|
|
10206
|
+
elif que_id is not None:
|
|
10207
|
+
matched = [field for field in fields if _coerce_nonnegative_int(field.get("que_id")) == que_id]
|
|
10208
|
+
elif name:
|
|
10209
|
+
matched = [field for field in fields if str(field.get("name") or "").strip() == name]
|
|
10210
|
+
if len(matched) == 1:
|
|
10211
|
+
return matched[0], None
|
|
10212
|
+
return None, {
|
|
10213
|
+
"error_code": "CUSTOM_BUTTON_MAPPING_FIELD_NOT_FOUND" if not matched else "CUSTOM_BUTTON_MAPPING_FIELD_AMBIGUOUS",
|
|
10214
|
+
"reason_path": reason_path,
|
|
10215
|
+
"selector": selector,
|
|
10216
|
+
"message": f"{role} field selector did not resolve to exactly one field",
|
|
10217
|
+
"candidates": [
|
|
10218
|
+
{"field_id": item.get("field_id"), "que_id": item.get("que_id"), "title": item.get("name"), "type": item.get("type")}
|
|
10219
|
+
for item in matched[:10]
|
|
10220
|
+
],
|
|
10221
|
+
"next_action": "call app_get_fields and retry with an exact field title or field_id",
|
|
10222
|
+
}
|
|
10223
|
+
|
|
10224
|
+
|
|
10225
|
+
def _custom_button_mapping_type_issue(
|
|
10226
|
+
*,
|
|
10227
|
+
source_field: dict[str, Any],
|
|
10228
|
+
target_field: dict[str, Any],
|
|
10229
|
+
reason_path: str,
|
|
10230
|
+
) -> dict[str, Any] | None:
|
|
10231
|
+
source_type = str(source_field.get("type") or "")
|
|
10232
|
+
target_type = str(target_field.get("type") or "")
|
|
10233
|
+
compatible_groups = [
|
|
10234
|
+
{FieldType.text.value, FieldType.long_text.value, FieldType.phone.value, FieldType.email.value},
|
|
10235
|
+
{FieldType.number.value, FieldType.amount.value},
|
|
10236
|
+
{FieldType.date.value, FieldType.datetime.value},
|
|
10237
|
+
{FieldType.single_select.value, FieldType.multi_select.value, FieldType.boolean.value},
|
|
10238
|
+
]
|
|
10239
|
+
compatible = source_type == target_type or any(source_type in group and target_type in group for group in compatible_groups)
|
|
10240
|
+
if source_type == FieldType.relation.value or target_type == FieldType.relation.value:
|
|
10241
|
+
compatible = source_type == target_type
|
|
10242
|
+
if compatible:
|
|
10243
|
+
return None
|
|
10244
|
+
return {
|
|
10245
|
+
"error_code": "CUSTOM_BUTTON_MAPPING_TYPE_MISMATCH",
|
|
10246
|
+
"reason_path": reason_path,
|
|
10247
|
+
"source_field": {"title": source_field.get("name"), "field_id": source_field.get("field_id"), "type": source_type},
|
|
10248
|
+
"target_field": {"title": target_field.get("name"), "field_id": target_field.get("field_id"), "type": target_type},
|
|
10249
|
+
"message": "source and target fields have incompatible types for addData copy mapping",
|
|
10250
|
+
"next_action": "choose fields with the same type family or use default_values for static text",
|
|
10251
|
+
}
|
|
10252
|
+
|
|
10253
|
+
|
|
10254
|
+
def _custom_button_question_ref(field: dict[str, Any]) -> dict[str, Any]:
|
|
10255
|
+
return {
|
|
10256
|
+
"que_id": _coerce_nonnegative_int(field.get("que_id")),
|
|
10257
|
+
"que_title": str(field.get("name") or ""),
|
|
10258
|
+
"que_type": _coerce_nonnegative_int(field.get("que_type")),
|
|
10259
|
+
}
|
|
10260
|
+
|
|
10261
|
+
|
|
10262
|
+
def _custom_button_field_mapping_rule(*, source_field: dict[str, Any], target_field: dict[str, Any]) -> dict[str, Any]:
|
|
10263
|
+
source_que_id = _coerce_nonnegative_int(source_field.get("que_id"))
|
|
10264
|
+
return {
|
|
10265
|
+
**_custom_button_question_ref(target_field),
|
|
10266
|
+
"match_type": MATCH_TYPE_QUESTION,
|
|
10267
|
+
"judge_type": JUDGE_EQUAL,
|
|
10268
|
+
"judge_values": [str(source_que_id)] if source_que_id is not None else [],
|
|
10269
|
+
"judge_que_id": source_que_id,
|
|
10270
|
+
"judge_que_type": _coerce_nonnegative_int(source_field.get("que_type")),
|
|
10271
|
+
"judge_que_detail": _custom_button_question_ref(source_field),
|
|
10272
|
+
}
|
|
10273
|
+
|
|
10274
|
+
|
|
10275
|
+
def _custom_button_default_value_rule(*, target_field: dict[str, Any], value_details: list[dict[str, Any]]) -> dict[str, Any]:
|
|
10276
|
+
return {
|
|
10277
|
+
**_custom_button_question_ref(target_field),
|
|
10278
|
+
"match_type": MATCH_TYPE_ACCURACY,
|
|
10279
|
+
"judge_type": JUDGE_EQUAL,
|
|
10280
|
+
"judge_values": [
|
|
10281
|
+
str(detail.get("id") if detail.get("id") is not None else detail.get("value", ""))
|
|
10282
|
+
for detail in value_details
|
|
10283
|
+
],
|
|
10284
|
+
"judge_value_details": value_details,
|
|
10285
|
+
}
|
|
10286
|
+
|
|
10287
|
+
|
|
10288
|
+
def _resolve_custom_button_option_detail(*, target_field: dict[str, Any], value: Any) -> dict[str, Any] | None:
|
|
10289
|
+
option_details = [item for item in target_field.get("option_details") or [] if isinstance(item, dict)]
|
|
10290
|
+
value_id = _coerce_positive_int(value.get("id", value.get("opt_id", value.get("optId"))) if isinstance(value, dict) else value)
|
|
10291
|
+
value_text = str(value.get("value", value.get("name", "")) if isinstance(value, dict) else value or "").strip()
|
|
10292
|
+
for item in option_details:
|
|
10293
|
+
opt_id = _coerce_positive_int(item.get("id"))
|
|
10294
|
+
opt_value = str(item.get("value") or "").strip()
|
|
10295
|
+
if (value_id is not None and opt_id == value_id) or (value_text and opt_value == value_text):
|
|
10296
|
+
return {"id": opt_id, "value": opt_value}
|
|
10297
|
+
return None
|
|
10298
|
+
|
|
10299
|
+
|
|
10300
|
+
def _custom_button_default_value_issue(
|
|
10301
|
+
*,
|
|
10302
|
+
target_field: dict[str, Any],
|
|
10303
|
+
reason_path: str,
|
|
10304
|
+
value: Any,
|
|
10305
|
+
message: str,
|
|
10306
|
+
allowed_values: dict[str, Any] | None = None,
|
|
10307
|
+
next_action: str | None = None,
|
|
10308
|
+
) -> dict[str, Any]:
|
|
10309
|
+
return {
|
|
10310
|
+
"error_code": "CUSTOM_BUTTON_DEFAULT_VALUE_UNSUPPORTED",
|
|
10311
|
+
"reason_path": reason_path,
|
|
10312
|
+
"target_field": {
|
|
10313
|
+
"title": target_field.get("name"),
|
|
10314
|
+
"field_id": target_field.get("field_id"),
|
|
10315
|
+
"que_id": target_field.get("que_id"),
|
|
10316
|
+
"type": target_field.get("type"),
|
|
10317
|
+
},
|
|
10318
|
+
"received_value": deepcopy(value),
|
|
10319
|
+
"allowed_values": allowed_values or {},
|
|
10320
|
+
"message": message,
|
|
10321
|
+
"next_action": next_action or "retry with a literal value supported by the target field type",
|
|
10322
|
+
}
|
|
10323
|
+
|
|
10324
|
+
|
|
10325
|
+
def _stringify_custom_button_default_value(value: Any) -> str:
|
|
10326
|
+
if isinstance(value, bool):
|
|
10327
|
+
return "true" if value else "false"
|
|
10328
|
+
if isinstance(value, (int, float)):
|
|
10329
|
+
return str(value)
|
|
10330
|
+
if isinstance(value, dict):
|
|
10331
|
+
if "value" in value:
|
|
10332
|
+
return str(value.get("value") or "")
|
|
10333
|
+
if "name" in value:
|
|
10334
|
+
return str(value.get("name") or "")
|
|
10335
|
+
return str(value or "")
|
|
10336
|
+
|
|
10337
|
+
|
|
10338
|
+
def _view_button_config_type_from_placement(placement: PublicButtonPlacement) -> PublicViewButtonConfigType:
|
|
10339
|
+
if placement == PublicButtonPlacement.header:
|
|
10340
|
+
return PublicViewButtonConfigType.top
|
|
10341
|
+
if placement == PublicButtonPlacement.list:
|
|
10342
|
+
return PublicViewButtonConfigType.list
|
|
10343
|
+
return PublicViewButtonConfigType.detail
|
|
10344
|
+
|
|
10345
|
+
|
|
10346
|
+
def _custom_button_view_binding_to_view_button_patch(
|
|
10347
|
+
*,
|
|
10348
|
+
binding: CustomButtonViewButtonBindingPatch,
|
|
10349
|
+
button_id: int,
|
|
10350
|
+
) -> ViewButtonBindingPatch:
|
|
10351
|
+
config_type = _view_button_config_type_from_placement(binding.placement)
|
|
10352
|
+
being_main = bool(binding.primary) if config_type == PublicViewButtonConfigType.detail else True
|
|
10353
|
+
return ViewButtonBindingPatch.model_validate(
|
|
10354
|
+
{
|
|
10355
|
+
"button_type": PublicViewButtonType.custom.value,
|
|
10356
|
+
"config_type": config_type.value,
|
|
10357
|
+
"button_id": button_id,
|
|
10358
|
+
"being_main": being_main,
|
|
10359
|
+
"button_limit": binding.button_limit,
|
|
10360
|
+
"button_formula": binding.button_formula,
|
|
10361
|
+
"button_formula_type": binding.button_formula_type,
|
|
10362
|
+
"print_tpls": binding.print_tpls,
|
|
10363
|
+
}
|
|
10364
|
+
)
|
|
10365
|
+
|
|
10366
|
+
|
|
10367
|
+
def _resolve_custom_button_view_button_ref(
|
|
10368
|
+
*,
|
|
10369
|
+
button_ref: Any,
|
|
10370
|
+
client_key_map: dict[str, int],
|
|
10371
|
+
button_inventory: dict[int, dict[str, Any]],
|
|
10372
|
+
valid_custom_button_ids: set[int],
|
|
10373
|
+
reason_path: str,
|
|
10374
|
+
) -> tuple[int | None, dict[str, Any] | None]:
|
|
10375
|
+
explicit_id = _coerce_positive_int(button_ref)
|
|
10376
|
+
if explicit_id is not None:
|
|
10377
|
+
if explicit_id in valid_custom_button_ids:
|
|
10378
|
+
return explicit_id, None
|
|
10379
|
+
return None, {
|
|
10380
|
+
"error_code": "UNKNOWN_CUSTOM_BUTTON",
|
|
10381
|
+
"reason_path": reason_path,
|
|
10382
|
+
"button_ref": button_ref,
|
|
10383
|
+
"message": "button_ref id does not exist in the current app draft or was removed in this apply",
|
|
10384
|
+
"next_action": "call app_get and use custom_buttons[].button_id, or use a client_key from upsert_buttons",
|
|
10385
|
+
}
|
|
10386
|
+
ref_text = str(button_ref or "").strip()
|
|
10387
|
+
if ref_text in client_key_map:
|
|
10388
|
+
return client_key_map[ref_text], None
|
|
10389
|
+
text_matches = [
|
|
10390
|
+
button_id
|
|
10391
|
+
for button_id, item in button_inventory.items()
|
|
10392
|
+
if str(item.get("button_text") or "").strip() == ref_text
|
|
10393
|
+
and button_id in valid_custom_button_ids
|
|
10394
|
+
]
|
|
10395
|
+
if len(text_matches) == 1:
|
|
10396
|
+
return text_matches[0], None
|
|
10397
|
+
return None, {
|
|
10398
|
+
"error_code": "UNKNOWN_CUSTOM_BUTTON_REF" if not text_matches else "AMBIGUOUS_CUSTOM_BUTTON_REF",
|
|
10399
|
+
"reason_path": reason_path,
|
|
10400
|
+
"button_ref": button_ref,
|
|
10401
|
+
"candidate_button_ids": text_matches[:10],
|
|
10402
|
+
"message": "button_ref must be a button_id, a same-call client_key, or an exact unique existing button_text",
|
|
10403
|
+
"next_action": "use upsert_buttons[].client_key for same-call placement or pass a numeric button_id",
|
|
10404
|
+
}
|
|
10405
|
+
|
|
10406
|
+
|
|
10407
|
+
def _merge_custom_button_view_button_dtos(
|
|
10408
|
+
*,
|
|
10409
|
+
existing_dtos: list[dict[str, Any]],
|
|
10410
|
+
new_dtos: list[dict[str, Any]],
|
|
10411
|
+
) -> list[dict[str, Any]]:
|
|
10412
|
+
def key(item: dict[str, Any]) -> tuple[int | None, str]:
|
|
10413
|
+
return (_coerce_positive_int(item.get("buttonId")), str(item.get("configType") or "").strip().upper())
|
|
10414
|
+
|
|
10415
|
+
replacement_keys = {key(item) for item in new_dtos}
|
|
10416
|
+
merged = [deepcopy(item) for item in existing_dtos if key(item) not in replacement_keys]
|
|
10417
|
+
merged.extend(deepcopy(item) for item in new_dtos)
|
|
10418
|
+
return merged
|
|
10419
|
+
|
|
10420
|
+
|
|
8143
10421
|
def _normalize_custom_button_summary(item: dict[str, Any]) -> dict[str, Any]:
|
|
8144
10422
|
normalized = {
|
|
8145
10423
|
"button_id": _coerce_positive_int(item.get("button_id") or item.get("buttonId") or item.get("id")),
|
|
@@ -8613,6 +10891,8 @@ def _normalize_view_collection(values: Any) -> list[dict[str, Any]]:
|
|
|
8613
10891
|
normalized.append(item)
|
|
8614
10892
|
return normalized
|
|
8615
10893
|
if isinstance(values, dict):
|
|
10894
|
+
if _extract_view_key(values):
|
|
10895
|
+
return [values]
|
|
8616
10896
|
for key in ("list", "viewList", "views", "result"):
|
|
8617
10897
|
candidate = values.get(key)
|
|
8618
10898
|
if isinstance(candidate, list):
|
|
@@ -8979,6 +11259,16 @@ def _normalize_backend_chart_type(value: Any) -> str:
|
|
|
8979
11259
|
return by_code.get(raw, raw.lower())
|
|
8980
11260
|
|
|
8981
11261
|
|
|
11262
|
+
def _public_chart_type_from_backend(value: Any) -> str:
|
|
11263
|
+
normalized = _normalize_backend_chart_type(value)
|
|
11264
|
+
return {
|
|
11265
|
+
"indicator": PublicChartType.target.value,
|
|
11266
|
+
"summary": PublicChartType.target.value,
|
|
11267
|
+
"columnar": PublicChartType.bar.value,
|
|
11268
|
+
"detail": PublicChartType.table.value,
|
|
11269
|
+
}.get(normalized, normalized)
|
|
11270
|
+
|
|
11271
|
+
|
|
8982
11272
|
def _find_charts_by_name(items: Any, *, chart_name: str, chart_type: str | None = None) -> list[dict[str, Any]]:
|
|
8983
11273
|
target_name = str(chart_name or "").strip()
|
|
8984
11274
|
target_type = _normalize_backend_chart_type(chart_type)
|
|
@@ -10798,63 +13088,259 @@ def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any],
|
|
|
10798
13088
|
return payload
|
|
10799
13089
|
|
|
10800
13090
|
|
|
10801
|
-
def
|
|
10802
|
-
|
|
10803
|
-
|
|
10804
|
-
|
|
10805
|
-
|
|
10806
|
-
|
|
10807
|
-
|
|
10808
|
-
|
|
10809
|
-
|
|
10810
|
-
|
|
10811
|
-
|
|
10812
|
-
mapping[f"que_id:{que_id}"] = index
|
|
10813
|
-
return mapping
|
|
13091
|
+
def _field_public_ref(field: dict[str, Any] | None, *, fallback_que_id: int | None = None) -> dict[str, Any] | None:
|
|
13092
|
+
if not isinstance(field, dict):
|
|
13093
|
+
if fallback_que_id is None:
|
|
13094
|
+
return None
|
|
13095
|
+
return {"field_id": None, "que_id": fallback_que_id, "name": None, "type": None}
|
|
13096
|
+
return {
|
|
13097
|
+
"field_id": field.get("field_id"),
|
|
13098
|
+
"que_id": _coerce_any_int(field.get("que_id")),
|
|
13099
|
+
"name": field.get("name"),
|
|
13100
|
+
"type": field.get("type"),
|
|
13101
|
+
}
|
|
10814
13102
|
|
|
10815
13103
|
|
|
10816
|
-
def
|
|
10817
|
-
if
|
|
10818
|
-
|
|
10819
|
-
|
|
10820
|
-
|
|
10821
|
-
|
|
10822
|
-
value = selector_map.get(f"que_id:{selector.que_id}")
|
|
10823
|
-
if value is not None:
|
|
10824
|
-
return value
|
|
10825
|
-
if selector.name:
|
|
10826
|
-
value = selector_map.get(f"name:{selector.name}")
|
|
10827
|
-
if value is not None:
|
|
10828
|
-
return value
|
|
13104
|
+
def _find_top_level_field_by_que_id(fields: list[dict[str, Any]], que_id: int | None) -> dict[str, Any] | None:
|
|
13105
|
+
if que_id is None:
|
|
13106
|
+
return None
|
|
13107
|
+
for field in fields:
|
|
13108
|
+
if _coerce_any_int(field.get("que_id")) == que_id:
|
|
13109
|
+
return field
|
|
10829
13110
|
return None
|
|
10830
13111
|
|
|
10831
13112
|
|
|
10832
|
-
def
|
|
10833
|
-
|
|
10834
|
-
|
|
13113
|
+
def _find_top_level_field_by_name(fields: list[dict[str, Any]], field_name: str | None) -> dict[str, Any] | None:
|
|
13114
|
+
if not field_name:
|
|
13115
|
+
return None
|
|
13116
|
+
for field in fields:
|
|
13117
|
+
if str(field.get("name") or "") == field_name:
|
|
13118
|
+
return field
|
|
13119
|
+
return None
|
|
10835
13120
|
|
|
10836
13121
|
|
|
10837
|
-
def
|
|
10838
|
-
|
|
10839
|
-
|
|
10840
|
-
q_linker_binding = patch.q_linker_binding.model_dump(mode="json", exclude_none=True) if patch.q_linker_binding is not None else None
|
|
10841
|
-
code_block_config = patch.code_block_config.model_dump(mode="json", exclude_none=True) if patch.code_block_config is not None else None
|
|
10842
|
-
code_block_binding = patch.code_block_binding.model_dump(mode="json", exclude_none=True) if patch.code_block_binding is not None else None
|
|
13122
|
+
def _form_settings_from_schema(schema: dict[str, Any], fields: list[dict[str, Any]]) -> dict[str, Any]:
|
|
13123
|
+
data_title_que_id = _coerce_any_int(schema.get("dataTitleQueId", schema.get("data_title_que_id")))
|
|
13124
|
+
data_cover_que_id = _coerce_any_int(schema.get("dataCoverQueId", schema.get("data_cover_que_id")))
|
|
10843
13125
|
return {
|
|
10844
|
-
"
|
|
10845
|
-
|
|
10846
|
-
|
|
10847
|
-
|
|
10848
|
-
"
|
|
10849
|
-
|
|
10850
|
-
|
|
10851
|
-
|
|
10852
|
-
|
|
10853
|
-
|
|
10854
|
-
|
|
10855
|
-
|
|
10856
|
-
|
|
10857
|
-
|
|
13126
|
+
"data_title_field": _field_public_ref(
|
|
13127
|
+
_find_top_level_field_by_que_id(fields, data_title_que_id),
|
|
13128
|
+
fallback_que_id=data_title_que_id,
|
|
13129
|
+
),
|
|
13130
|
+
"data_cover_field": _field_public_ref(
|
|
13131
|
+
_find_top_level_field_by_que_id(fields, data_cover_que_id),
|
|
13132
|
+
fallback_que_id=data_cover_que_id,
|
|
13133
|
+
),
|
|
13134
|
+
}
|
|
13135
|
+
|
|
13136
|
+
|
|
13137
|
+
def _collect_data_display_marker_selection(fields: list[dict[str, Any]]) -> _DataDisplayMarkerSelection:
|
|
13138
|
+
title_fields: list[dict[str, Any]] = []
|
|
13139
|
+
cover_fields: list[dict[str, Any]] = []
|
|
13140
|
+
|
|
13141
|
+
def visit(field: dict[str, Any], *, parent_name: str | None = None) -> None:
|
|
13142
|
+
name = str(field.get("name") or "").strip()
|
|
13143
|
+
field_type = str(field.get("type") or "")
|
|
13144
|
+
if bool(field.get("as_data_title")):
|
|
13145
|
+
if parent_name or field_type == FieldType.subtable.value:
|
|
13146
|
+
raise _DataDisplayConfigError(
|
|
13147
|
+
"INVALID_DATA_TITLE_FIELD",
|
|
13148
|
+
"data title must be a top-level non-subtable field",
|
|
13149
|
+
details={"field_name": name, "parent_field_name": parent_name, "field_type": field_type},
|
|
13150
|
+
)
|
|
13151
|
+
title_fields.append(field)
|
|
13152
|
+
if bool(field.get("as_data_cover")):
|
|
13153
|
+
if parent_name:
|
|
13154
|
+
raise _DataDisplayConfigError(
|
|
13155
|
+
"INVALID_DATA_COVER_FIELD",
|
|
13156
|
+
"data cover must be a top-level attachment field",
|
|
13157
|
+
details={"field_name": name, "parent_field_name": parent_name, "field_type": field_type},
|
|
13158
|
+
)
|
|
13159
|
+
if field_type != FieldType.attachment.value:
|
|
13160
|
+
raise _DataDisplayConfigError(
|
|
13161
|
+
"INVALID_DATA_COVER_FIELD",
|
|
13162
|
+
"data cover must be a top-level attachment field",
|
|
13163
|
+
details={"field_name": name, "field_type": field_type, "expected_type": FieldType.attachment.value},
|
|
13164
|
+
)
|
|
13165
|
+
cover_fields.append(field)
|
|
13166
|
+
for subfield in cast(list[dict[str, Any]], field.get("subfields") or []):
|
|
13167
|
+
if isinstance(subfield, dict):
|
|
13168
|
+
visit(subfield, parent_name=name)
|
|
13169
|
+
|
|
13170
|
+
for field in fields:
|
|
13171
|
+
if isinstance(field, dict):
|
|
13172
|
+
visit(field)
|
|
13173
|
+
|
|
13174
|
+
if len(title_fields) > 1:
|
|
13175
|
+
raise _DataDisplayConfigError(
|
|
13176
|
+
"MULTIPLE_DATA_TITLE_FIELDS",
|
|
13177
|
+
"only one field can be marked as data title",
|
|
13178
|
+
details={"fields": [_field_public_ref(field) for field in title_fields]},
|
|
13179
|
+
)
|
|
13180
|
+
if len(cover_fields) > 1:
|
|
13181
|
+
raise _DataDisplayConfigError(
|
|
13182
|
+
"MULTIPLE_DATA_COVER_FIELDS",
|
|
13183
|
+
"only one field can be marked as data cover",
|
|
13184
|
+
details={"fields": [_field_public_ref(field) for field in cover_fields]},
|
|
13185
|
+
)
|
|
13186
|
+
return _DataDisplayMarkerSelection(
|
|
13187
|
+
data_title_field_name=str(title_fields[0].get("name") or "") if title_fields else None,
|
|
13188
|
+
data_cover_field_name=str(cover_fields[0].get("name") or "") if cover_fields else None,
|
|
13189
|
+
)
|
|
13190
|
+
|
|
13191
|
+
|
|
13192
|
+
def _ensure_required_data_title_config(
|
|
13193
|
+
*,
|
|
13194
|
+
current_schema: dict[str, Any],
|
|
13195
|
+
original_fields: list[dict[str, Any]],
|
|
13196
|
+
fields: list[dict[str, Any]],
|
|
13197
|
+
selection: _DataDisplayMarkerSelection,
|
|
13198
|
+
) -> None:
|
|
13199
|
+
if selection.data_title_field_name:
|
|
13200
|
+
return
|
|
13201
|
+
current_title_que_id = _coerce_any_int(
|
|
13202
|
+
current_schema.get("dataTitleQueId", current_schema.get("data_title_que_id"))
|
|
13203
|
+
)
|
|
13204
|
+
if _find_top_level_field_by_que_id(fields, current_title_que_id) is not None:
|
|
13205
|
+
return
|
|
13206
|
+
if current_title_que_id is None and original_fields:
|
|
13207
|
+
# Older or mocked schema payloads may omit the form-level title config even
|
|
13208
|
+
# though the app already exists with fields. Avoid blocking unrelated edits
|
|
13209
|
+
# unless we can prove the final title is missing.
|
|
13210
|
+
return
|
|
13211
|
+
suggested_fields = [
|
|
13212
|
+
str(field.get("name") or "").strip()
|
|
13213
|
+
for field in fields
|
|
13214
|
+
if isinstance(field, dict)
|
|
13215
|
+
and str(field.get("name") or "").strip()
|
|
13216
|
+
and str(field.get("type") or "") != FieldType.subtable.value
|
|
13217
|
+
][:8]
|
|
13218
|
+
raise _DataDisplayConfigError(
|
|
13219
|
+
"MISSING_DATA_TITLE_FIELD",
|
|
13220
|
+
"data title is required; mark exactly one top-level field with as_data_title=true",
|
|
13221
|
+
details={
|
|
13222
|
+
"current_data_title_que_id": current_title_que_id,
|
|
13223
|
+
"suggested_field_names": suggested_fields,
|
|
13224
|
+
"next_action": "mark one top-level field as the data title, for example {'name': '项目名称', 'as_data_title': true}",
|
|
13225
|
+
},
|
|
13226
|
+
)
|
|
13227
|
+
|
|
13228
|
+
|
|
13229
|
+
def _field_form_config_identifier(field: dict[str, Any] | None) -> int | None:
|
|
13230
|
+
if not isinstance(field, dict):
|
|
13231
|
+
return None
|
|
13232
|
+
que_id = _coerce_positive_int(field.get("que_id"))
|
|
13233
|
+
if que_id is not None:
|
|
13234
|
+
return que_id
|
|
13235
|
+
que_temp_id = _coerce_any_int(field.get("que_temp_id"))
|
|
13236
|
+
if que_temp_id is not None and que_temp_id != 0:
|
|
13237
|
+
return que_temp_id
|
|
13238
|
+
return None
|
|
13239
|
+
|
|
13240
|
+
|
|
13241
|
+
def _apply_data_display_selection_to_payload(
|
|
13242
|
+
payload: dict[str, Any],
|
|
13243
|
+
*,
|
|
13244
|
+
fields: list[dict[str, Any]],
|
|
13245
|
+
selection: _DataDisplayMarkerSelection,
|
|
13246
|
+
) -> None:
|
|
13247
|
+
if selection.data_title_field_name:
|
|
13248
|
+
data_title_field = _find_top_level_field_by_name(fields, selection.data_title_field_name)
|
|
13249
|
+
data_title_que_id = _field_form_config_identifier(data_title_field)
|
|
13250
|
+
if data_title_que_id is not None:
|
|
13251
|
+
payload["dataTitleQueId"] = data_title_que_id
|
|
13252
|
+
if selection.data_cover_field_name:
|
|
13253
|
+
data_cover_field = _find_top_level_field_by_name(fields, selection.data_cover_field_name)
|
|
13254
|
+
data_cover_que_id = _field_form_config_identifier(data_cover_field)
|
|
13255
|
+
if data_cover_que_id is not None:
|
|
13256
|
+
payload["dataCoverQueId"] = data_cover_que_id
|
|
13257
|
+
|
|
13258
|
+
|
|
13259
|
+
def _verify_data_display_readback(
|
|
13260
|
+
*,
|
|
13261
|
+
form_settings: Any,
|
|
13262
|
+
selection: _DataDisplayMarkerSelection,
|
|
13263
|
+
) -> dict[str, Any]:
|
|
13264
|
+
if not selection.has_any:
|
|
13265
|
+
return {
|
|
13266
|
+
"data_title_field_verified": None,
|
|
13267
|
+
"data_cover_field_verified": None,
|
|
13268
|
+
"data_display_config_verified": None,
|
|
13269
|
+
}
|
|
13270
|
+
settings = form_settings if isinstance(form_settings, dict) else {}
|
|
13271
|
+
title_ref = settings.get("data_title_field") if isinstance(settings.get("data_title_field"), dict) else None
|
|
13272
|
+
cover_ref = settings.get("data_cover_field") if isinstance(settings.get("data_cover_field"), dict) else None
|
|
13273
|
+
title_verified = None
|
|
13274
|
+
cover_verified = None
|
|
13275
|
+
if selection.data_title_field_name:
|
|
13276
|
+
title_verified = bool(title_ref and str(title_ref.get("name") or "") == selection.data_title_field_name)
|
|
13277
|
+
if selection.data_cover_field_name:
|
|
13278
|
+
cover_verified = bool(cover_ref and str(cover_ref.get("name") or "") == selection.data_cover_field_name)
|
|
13279
|
+
requested_results = [value for value in (title_verified, cover_verified) if value is not None]
|
|
13280
|
+
return {
|
|
13281
|
+
"data_title_field_verified": title_verified,
|
|
13282
|
+
"data_cover_field_verified": cover_verified,
|
|
13283
|
+
"data_display_config_verified": all(requested_results) if requested_results else None,
|
|
13284
|
+
}
|
|
13285
|
+
|
|
13286
|
+
|
|
13287
|
+
def _build_selector_map(fields: list[dict[str, Any]]) -> dict[str, int]:
|
|
13288
|
+
mapping: dict[str, int] = {}
|
|
13289
|
+
for index, field in enumerate(fields):
|
|
13290
|
+
field_id = str(field.get("field_id") or "")
|
|
13291
|
+
field_name = str(field.get("name") or "")
|
|
13292
|
+
que_id = _coerce_nonnegative_int(field.get("que_id"))
|
|
13293
|
+
if field_id:
|
|
13294
|
+
mapping[f"field_id:{field_id}"] = index
|
|
13295
|
+
if field_name:
|
|
13296
|
+
mapping[f"name:{field_name}"] = index
|
|
13297
|
+
if que_id is not None:
|
|
13298
|
+
mapping[f"que_id:{que_id}"] = index
|
|
13299
|
+
return mapping
|
|
13300
|
+
|
|
13301
|
+
|
|
13302
|
+
def _resolve_selector(selector_map: dict[str, int], selector: FieldSelector) -> int | None:
|
|
13303
|
+
if selector.field_id:
|
|
13304
|
+
value = selector_map.get(f"field_id:{selector.field_id}")
|
|
13305
|
+
if value is not None:
|
|
13306
|
+
return value
|
|
13307
|
+
if selector.que_id is not None:
|
|
13308
|
+
value = selector_map.get(f"que_id:{selector.que_id}")
|
|
13309
|
+
if value is not None:
|
|
13310
|
+
return value
|
|
13311
|
+
if selector.name:
|
|
13312
|
+
value = selector_map.get(f"name:{selector.name}")
|
|
13313
|
+
if value is not None:
|
|
13314
|
+
return value
|
|
13315
|
+
return None
|
|
13316
|
+
|
|
13317
|
+
|
|
13318
|
+
def _resolve_remove_selector(fields: list[dict[str, Any]], patch: FieldRemovePatch) -> int | None:
|
|
13319
|
+
selector = FieldSelector(field_id=patch.field_id, que_id=patch.que_id, name=patch.name)
|
|
13320
|
+
return _resolve_selector(_build_selector_map(fields), selector)
|
|
13321
|
+
|
|
13322
|
+
|
|
13323
|
+
def _field_patch_to_internal(patch: FieldPatch) -> dict[str, Any]:
|
|
13324
|
+
field_id = _slugify(patch.name, default=f"field_{uuid4().hex[:8]}")
|
|
13325
|
+
remote_lookup_config = patch.remote_lookup_config.model_dump(mode="json", exclude_none=True) if patch.remote_lookup_config is not None else None
|
|
13326
|
+
q_linker_binding = patch.q_linker_binding.model_dump(mode="json", exclude_none=True) if patch.q_linker_binding is not None else None
|
|
13327
|
+
code_block_config = patch.code_block_config.model_dump(mode="json", exclude_none=True) if patch.code_block_config is not None else None
|
|
13328
|
+
code_block_binding = patch.code_block_binding.model_dump(mode="json", exclude_none=True) if patch.code_block_binding is not None else None
|
|
13329
|
+
return {
|
|
13330
|
+
"field_id": field_id,
|
|
13331
|
+
"name": patch.name,
|
|
13332
|
+
"type": patch.type.value,
|
|
13333
|
+
"required": patch.required,
|
|
13334
|
+
"description": patch.description,
|
|
13335
|
+
"options": list(patch.options),
|
|
13336
|
+
"target_app_key": patch.target_app_key,
|
|
13337
|
+
"display_field": patch.display_field.model_dump(mode="json", exclude_none=True) if patch.display_field is not None else None,
|
|
13338
|
+
"visible_fields": [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields],
|
|
13339
|
+
"relation_mode": patch.relation_mode.value if patch.relation_mode is not None else None,
|
|
13340
|
+
"department_scope": patch.department_scope.model_dump(mode="json", exclude_none=True) if patch.department_scope is not None else None,
|
|
13341
|
+
"remote_lookup_config": remote_lookup_config,
|
|
13342
|
+
"_explicit_remote_lookup_config": remote_lookup_config is not None,
|
|
13343
|
+
"q_linker_binding": q_linker_binding,
|
|
10858
13344
|
"code_block_config": code_block_config,
|
|
10859
13345
|
"code_block_binding": code_block_binding,
|
|
10860
13346
|
"_explicit_code_block_binding": code_block_binding is not None,
|
|
@@ -10862,6 +13348,8 @@ def _field_patch_to_internal(patch: FieldPatch) -> dict[str, Any]:
|
|
|
10862
13348
|
"auto_trigger": patch.auto_trigger,
|
|
10863
13349
|
"custom_button_text_enabled": patch.custom_button_text_enabled,
|
|
10864
13350
|
"custom_button_text": patch.custom_button_text,
|
|
13351
|
+
"as_data_title": patch.as_data_title,
|
|
13352
|
+
"as_data_cover": patch.as_data_cover,
|
|
10865
13353
|
"subfields": [_field_patch_to_internal(subfield) for subfield in patch.subfields],
|
|
10866
13354
|
"que_id": None,
|
|
10867
13355
|
"default_type": 1,
|
|
@@ -10928,6 +13416,16 @@ def _merge_existing_field_with_patch(field: dict[str, Any], patch: FieldPatch) -
|
|
|
10928
13416
|
if str(field.get("type") or "") == FieldType.department.value and patch.type == PublicFieldType.department:
|
|
10929
13417
|
if field.get("department_scope") is None and patch.department_scope is not None:
|
|
10930
13418
|
field["department_scope"] = patch.department_scope.model_dump(mode="json", exclude_none=True)
|
|
13419
|
+
if patch.as_data_title is not None:
|
|
13420
|
+
if patch.as_data_title:
|
|
13421
|
+
field["as_data_title"] = True
|
|
13422
|
+
else:
|
|
13423
|
+
field.pop("as_data_title", None)
|
|
13424
|
+
if patch.as_data_cover is not None:
|
|
13425
|
+
if patch.as_data_cover:
|
|
13426
|
+
field["as_data_cover"] = True
|
|
13427
|
+
else:
|
|
13428
|
+
field.pop("as_data_cover", None)
|
|
10931
13429
|
|
|
10932
13430
|
|
|
10933
13431
|
def _field_selector_payload_equal(left: Any, right: Any) -> bool:
|
|
@@ -11090,14 +13588,14 @@ def _code_block_binding_equal(left: Any, right: Any) -> bool:
|
|
|
11090
13588
|
return _normalize_code_block_binding(left) == _normalize_code_block_binding(right)
|
|
11091
13589
|
|
|
11092
13590
|
|
|
11093
|
-
_SAFE_SUBFIELD_MUTATION_KEYS = frozenset({"name", "required", "description", "subfield_updates"})
|
|
13591
|
+
_SAFE_SUBFIELD_MUTATION_KEYS = frozenset({"name", "required", "description", "subfield_updates", "as_data_title", "as_data_cover"})
|
|
11094
13592
|
|
|
11095
13593
|
|
|
11096
13594
|
def _validate_safe_subfield_mutation(*, payload: dict[str, Any], location: str) -> None:
|
|
11097
13595
|
unsupported = sorted(key for key in payload if key not in _SAFE_SUBFIELD_MUTATION_KEYS)
|
|
11098
13596
|
if unsupported:
|
|
11099
13597
|
raise ValueError(
|
|
11100
|
-
f"{location} only supports safe overlay keys: name, required, description, subfield_updates; "
|
|
13598
|
+
f"{location} only supports safe overlay keys: name, required, description, subfield_updates, as_data_title, as_data_cover; "
|
|
11101
13599
|
f"unsupported keys: {', '.join(unsupported)}"
|
|
11102
13600
|
)
|
|
11103
13601
|
|
|
@@ -11184,6 +13682,16 @@ def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
|
|
|
11184
13682
|
if "custom_button_text" in payload:
|
|
11185
13683
|
field["custom_button_text"] = payload["custom_button_text"]
|
|
11186
13684
|
question_rebuild_required = True
|
|
13685
|
+
if "as_data_title" in payload:
|
|
13686
|
+
if payload["as_data_title"]:
|
|
13687
|
+
field["as_data_title"] = True
|
|
13688
|
+
else:
|
|
13689
|
+
field.pop("as_data_title", None)
|
|
13690
|
+
if "as_data_cover" in payload:
|
|
13691
|
+
if payload["as_data_cover"]:
|
|
13692
|
+
field["as_data_cover"] = True
|
|
13693
|
+
else:
|
|
13694
|
+
field.pop("as_data_cover", None)
|
|
11187
13695
|
if "subfields" in payload:
|
|
11188
13696
|
field["subfields"] = [_field_patch_to_internal(item) for item in payload["subfields"]]
|
|
11189
13697
|
question_rebuild_required = True
|
|
@@ -11270,6 +13778,26 @@ def _strip_code_block_generated_input_prelude(code_content: str) -> str:
|
|
|
11270
13778
|
return body
|
|
11271
13779
|
|
|
11272
13780
|
|
|
13781
|
+
@dataclass(frozen=True)
|
|
13782
|
+
class _DataDisplayMarkerSelection:
|
|
13783
|
+
data_title_field_name: str | None = None
|
|
13784
|
+
data_cover_field_name: str | None = None
|
|
13785
|
+
|
|
13786
|
+
@property
|
|
13787
|
+
def has_any(self) -> bool:
|
|
13788
|
+
return bool(self.data_title_field_name or self.data_cover_field_name)
|
|
13789
|
+
|
|
13790
|
+
|
|
13791
|
+
@dataclass
|
|
13792
|
+
class _DataDisplayConfigError(ValueError):
|
|
13793
|
+
error_code: str
|
|
13794
|
+
message: str
|
|
13795
|
+
details: JSONObject = field(default_factory=dict)
|
|
13796
|
+
|
|
13797
|
+
def __str__(self) -> str:
|
|
13798
|
+
return self.message
|
|
13799
|
+
|
|
13800
|
+
|
|
11273
13801
|
def _normalize_code_block_output_assignment(code_content: str) -> str:
|
|
11274
13802
|
return re.sub(r"(?<![A-Za-z0-9_$])(?:const|let)\s+qf_output\s*=", "qf_output =", code_content)
|
|
11275
13803
|
|
|
@@ -13100,6 +15628,8 @@ _FORM_SAVE_BASE_KEYS = (
|
|
|
13100
15628
|
"formStyle",
|
|
13101
15629
|
"serialNumType",
|
|
13102
15630
|
"serialNumConfig",
|
|
15631
|
+
"dataTitleQueId",
|
|
15632
|
+
"dataCoverQueId",
|
|
13103
15633
|
"attachVisibleOnlyConfig",
|
|
13104
15634
|
"externalLang",
|
|
13105
15635
|
"hideCopyright",
|
|
@@ -13768,6 +16298,10 @@ def _build_form_save_base_payload(current_schema: dict[str, Any], title: str) ->
|
|
|
13768
16298
|
for key in _FORM_SAVE_BASE_KEYS:
|
|
13769
16299
|
if key in current_schema:
|
|
13770
16300
|
payload[key] = deepcopy(current_schema.get(key))
|
|
16301
|
+
if "dataTitleQueId" not in payload and "data_title_que_id" in current_schema:
|
|
16302
|
+
payload["dataTitleQueId"] = deepcopy(current_schema.get("data_title_que_id"))
|
|
16303
|
+
if "dataCoverQueId" not in payload and "data_cover_que_id" in current_schema:
|
|
16304
|
+
payload["dataCoverQueId"] = deepcopy(current_schema.get("data_cover_que_id"))
|
|
13771
16305
|
payload["editVersionNo"] = int(current_schema.get("editVersionNo") or 1)
|
|
13772
16306
|
payload["formQues"] = []
|
|
13773
16307
|
payload["questionRelations"] = []
|
|
@@ -14633,6 +17167,20 @@ def _merge_view_summary_with_config(
|
|
|
14633
17167
|
if not summary.get("group_by") and display_config.get("group_by"):
|
|
14634
17168
|
summary["group_by"] = display_config.get("group_by")
|
|
14635
17169
|
config_enriched = True
|
|
17170
|
+
query_question_entries_by_id = dict(question_entries_by_id)
|
|
17171
|
+
for entry in canonical_question_entries:
|
|
17172
|
+
field_id = _coerce_nonnegative_int(entry.get("field_id"))
|
|
17173
|
+
if field_id is not None:
|
|
17174
|
+
query_question_entries_by_id[field_id] = entry
|
|
17175
|
+
if any(key in config for key in ("queryConditionStatus", "queryConditionExact", "hideBeforeQueryCondition", "queryCondition")):
|
|
17176
|
+
summary["query_conditions"] = _extract_view_query_conditions_config(
|
|
17177
|
+
config,
|
|
17178
|
+
question_entries_by_id=query_question_entries_by_id,
|
|
17179
|
+
)
|
|
17180
|
+
config_enriched = True
|
|
17181
|
+
if any(key in config for key in ("asosChartVisible", "asosChartConfig", "asosChartIdList", "limitType")):
|
|
17182
|
+
summary["associated_resources_config"] = _extract_view_associated_resources_config(config)
|
|
17183
|
+
config_enriched = True
|
|
14636
17184
|
button_entries, button_source = _extract_view_button_entries(config)
|
|
14637
17185
|
if button_entries:
|
|
14638
17186
|
summary["buttons"] = [_normalize_view_button_entry(entry) for entry in button_entries]
|
|
@@ -14802,7 +17350,7 @@ def _normalize_view_button_type(value: Any) -> str | None:
|
|
|
14802
17350
|
|
|
14803
17351
|
def _normalize_view_button_config_type(value: Any) -> str | None:
|
|
14804
17352
|
normalized = str(value or "").strip().upper()
|
|
14805
|
-
if normalized in {"TOP", "DETAIL"}:
|
|
17353
|
+
if normalized in {"TOP", "DETAIL", "LIST"}:
|
|
14806
17354
|
return normalized
|
|
14807
17355
|
return None
|
|
14808
17356
|
|
|
@@ -14884,6 +17432,12 @@ def _extract_view_button_entries(config: dict[str, Any]) -> tuple[list[dict[str,
|
|
|
14884
17432
|
entry.setdefault("configType", "DETAIL")
|
|
14885
17433
|
entry["beingMain"] = False
|
|
14886
17434
|
entries.append(entry)
|
|
17435
|
+
for item in grouped.get("listButtonList") or []:
|
|
17436
|
+
if not isinstance(item, dict):
|
|
17437
|
+
continue
|
|
17438
|
+
entry = deepcopy(item)
|
|
17439
|
+
entry.setdefault("configType", "LIST")
|
|
17440
|
+
entries.append(entry)
|
|
14887
17441
|
return entries, "buttonConfig"
|
|
14888
17442
|
|
|
14889
17443
|
|
|
@@ -15044,12 +17598,60 @@ def _partition_view_button_summaries(
|
|
|
15044
17598
|
return system_buttons, custom_buttons, other_buttons
|
|
15045
17599
|
|
|
15046
17600
|
|
|
17601
|
+
def _canonicalize_view_button_summary_order(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
17602
|
+
top_buttons: list[dict[str, Any]] = []
|
|
17603
|
+
detail_main_buttons: list[dict[str, Any]] = []
|
|
17604
|
+
detail_more_buttons: list[dict[str, Any]] = []
|
|
17605
|
+
list_buttons: list[dict[str, Any]] = []
|
|
17606
|
+
other_buttons: list[dict[str, Any]] = []
|
|
17607
|
+
for item in items:
|
|
17608
|
+
config_type = str(item.get("config_type") or "").strip().upper()
|
|
17609
|
+
if config_type == "TOP":
|
|
17610
|
+
top_buttons.append(item)
|
|
17611
|
+
elif config_type == "DETAIL" and bool(item.get("being_main", False)):
|
|
17612
|
+
detail_main_buttons.append(item)
|
|
17613
|
+
elif config_type == "DETAIL":
|
|
17614
|
+
detail_more_buttons.append(item)
|
|
17615
|
+
elif config_type == "LIST":
|
|
17616
|
+
list_buttons.append(item)
|
|
17617
|
+
else:
|
|
17618
|
+
other_buttons.append(item)
|
|
17619
|
+
return [*top_buttons, *detail_main_buttons, *detail_more_buttons, *list_buttons, *other_buttons]
|
|
17620
|
+
|
|
17621
|
+
|
|
17622
|
+
def _view_button_compare_signature(item: dict[str, Any]) -> dict[str, Any]:
|
|
17623
|
+
button_type = str(item.get("button_type") or "").strip().upper()
|
|
17624
|
+
signature: dict[str, Any] = {
|
|
17625
|
+
"button_type": button_type,
|
|
17626
|
+
"config_type": str(item.get("config_type") or "").strip().upper(),
|
|
17627
|
+
"button_id": item.get("button_id") if button_type == "CUSTOM" else None,
|
|
17628
|
+
"being_main": bool(item.get("being_main", False)),
|
|
17629
|
+
"print_tpls": list(item.get("print_tpls") or []),
|
|
17630
|
+
"button_formula": str(item.get("button_formula") or ""),
|
|
17631
|
+
"button_formula_type": _coerce_positive_int(item.get("button_formula_type")) or 1,
|
|
17632
|
+
"button_limit": deepcopy(item.get("button_limit") or []),
|
|
17633
|
+
}
|
|
17634
|
+
if button_type == "SYSTEM":
|
|
17635
|
+
signature["trigger_action"] = item.get("trigger_action")
|
|
17636
|
+
signature["button_icon"] = item.get("button_icon")
|
|
17637
|
+
signature["button_text"] = item.get("button_text")
|
|
17638
|
+
return signature
|
|
17639
|
+
|
|
17640
|
+
|
|
17641
|
+
def _sorted_view_button_compare_signatures(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
17642
|
+
signatures = [_view_button_compare_signature(item) for item in items]
|
|
17643
|
+
return sorted(signatures, key=lambda item: json.dumps(item, ensure_ascii=False, sort_keys=True, default=str))
|
|
17644
|
+
|
|
17645
|
+
|
|
15047
17646
|
def _compare_view_button_summaries(
|
|
15048
17647
|
*,
|
|
15049
17648
|
expected: list[dict[str, Any]],
|
|
15050
17649
|
actual: list[dict[str, Any]],
|
|
17650
|
+
pending_custom_button_ids: set[int] | None = None,
|
|
15051
17651
|
) -> dict[str, Any]:
|
|
15052
|
-
|
|
17652
|
+
expected = _canonicalize_view_button_summary_order(expected)
|
|
17653
|
+
actual = _canonicalize_view_button_summary_order(actual)
|
|
17654
|
+
if _sorted_view_button_compare_signatures(actual) == _sorted_view_button_compare_signatures(expected):
|
|
15053
17655
|
return {
|
|
15054
17656
|
"verified": True,
|
|
15055
17657
|
"custom_button_readback_pending": False,
|
|
@@ -15057,11 +17659,38 @@ def _compare_view_button_summaries(
|
|
|
15057
17659
|
}
|
|
15058
17660
|
expected_system, expected_custom, expected_other = _partition_view_button_summaries(expected)
|
|
15059
17661
|
actual_system, actual_custom, actual_other = _partition_view_button_summaries(actual)
|
|
17662
|
+
pending_ids = pending_custom_button_ids or set()
|
|
17663
|
+
if pending_ids:
|
|
17664
|
+
actual_custom_signatures = {
|
|
17665
|
+
json.dumps(_view_button_compare_signature(item), ensure_ascii=False, sort_keys=True, default=str)
|
|
17666
|
+
for item in actual_custom
|
|
17667
|
+
}
|
|
17668
|
+
pending_indexes: set[int] = set()
|
|
17669
|
+
pending_custom_buttons: list[dict[str, Any]] = []
|
|
17670
|
+
for index, item in enumerate(expected):
|
|
17671
|
+
if str(item.get("button_type") or "").strip().upper() != "CUSTOM":
|
|
17672
|
+
continue
|
|
17673
|
+
button_id = _coerce_positive_int(item.get("button_id"))
|
|
17674
|
+
if button_id not in pending_ids:
|
|
17675
|
+
continue
|
|
17676
|
+
signature = json.dumps(_view_button_compare_signature(item), ensure_ascii=False, sort_keys=True, default=str)
|
|
17677
|
+
if signature in actual_custom_signatures:
|
|
17678
|
+
continue
|
|
17679
|
+
pending_indexes.add(index)
|
|
17680
|
+
pending_custom_buttons.append(item)
|
|
17681
|
+
if pending_indexes:
|
|
17682
|
+
expected_without_pending = [item for index, item in enumerate(expected) if index not in pending_indexes]
|
|
17683
|
+
if _sorted_view_button_compare_signatures(actual) == _sorted_view_button_compare_signatures(expected_without_pending):
|
|
17684
|
+
return {
|
|
17685
|
+
"verified": True,
|
|
17686
|
+
"custom_button_readback_pending": True,
|
|
17687
|
+
"pending_custom_buttons": deepcopy(pending_custom_buttons),
|
|
17688
|
+
}
|
|
15060
17689
|
custom_button_readback_pending = (
|
|
15061
17690
|
bool(expected_custom)
|
|
15062
17691
|
and not actual_custom
|
|
15063
|
-
and actual_system == expected_system
|
|
15064
|
-
and actual_other == expected_other
|
|
17692
|
+
and _sorted_view_button_compare_signatures(actual_system) == _sorted_view_button_compare_signatures(expected_system)
|
|
17693
|
+
and _sorted_view_button_compare_signatures(actual_other) == _sorted_view_button_compare_signatures(expected_other)
|
|
15065
17694
|
)
|
|
15066
17695
|
return {
|
|
15067
17696
|
"verified": custom_button_readback_pending,
|
|
@@ -15127,7 +17756,7 @@ def _resolve_view_button_dtos_for_patch(
|
|
|
15127
17756
|
|
|
15128
17757
|
|
|
15129
17758
|
def _build_grouped_view_button_config(button_config_dtos: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
|
15130
|
-
grouped = {"topButtonList": [], "mainButtonDetailList": [], "moreButtonDetailList": []}
|
|
17759
|
+
grouped = {"topButtonList": [], "mainButtonDetailList": [], "moreButtonDetailList": [], "listButtonList": []}
|
|
15131
17760
|
for raw_item in button_config_dtos:
|
|
15132
17761
|
if not isinstance(raw_item, dict):
|
|
15133
17762
|
continue
|
|
@@ -15136,6 +17765,8 @@ def _build_grouped_view_button_config(button_config_dtos: list[dict[str, Any]])
|
|
|
15136
17765
|
being_main = bool(item.get("beingMain", False))
|
|
15137
17766
|
if config_type == "TOP":
|
|
15138
17767
|
grouped["topButtonList"].append(item)
|
|
17768
|
+
elif config_type == "LIST":
|
|
17769
|
+
grouped["listButtonList"].append(item)
|
|
15139
17770
|
elif being_main:
|
|
15140
17771
|
grouped["mainButtonDetailList"].append(item)
|
|
15141
17772
|
else:
|
|
@@ -15252,7 +17883,7 @@ def _summarize_charts(result: Any) -> list[dict[str, Any]]:
|
|
|
15252
17883
|
continue
|
|
15253
17884
|
chart_id = _extract_chart_identifier(chart)
|
|
15254
17885
|
name = str(chart.get("chartName") or "").strip()
|
|
15255
|
-
chart_type =
|
|
17886
|
+
chart_type = _public_chart_type_from_backend(chart.get("chartType"))
|
|
15256
17887
|
if not any((chart_id, name, chart_type)):
|
|
15257
17888
|
continue
|
|
15258
17889
|
items.append(
|
|
@@ -15660,6 +18291,736 @@ def _stringify_condition_value(value: Any) -> str:
|
|
|
15660
18291
|
return str(value)
|
|
15661
18292
|
|
|
15662
18293
|
|
|
18294
|
+
def _empty_view_query_conditions_payload() -> dict[str, Any]:
|
|
18295
|
+
return {
|
|
18296
|
+
"queryConditionStatus": False,
|
|
18297
|
+
"queryConditionExact": False,
|
|
18298
|
+
"hideBeforeQueryCondition": False,
|
|
18299
|
+
"queryCondition": [],
|
|
18300
|
+
}
|
|
18301
|
+
|
|
18302
|
+
|
|
18303
|
+
def _build_view_query_conditions_payload(
|
|
18304
|
+
*,
|
|
18305
|
+
current_fields_by_name: dict[str, dict[str, Any]],
|
|
18306
|
+
query_conditions: Any,
|
|
18307
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any] | None, list[dict[str, Any]]]:
|
|
18308
|
+
if query_conditions is None:
|
|
18309
|
+
return None, None, []
|
|
18310
|
+
if isinstance(query_conditions, dict):
|
|
18311
|
+
enabled = bool(query_conditions.get("enabled", True))
|
|
18312
|
+
exact = bool(query_conditions.get("exact", False))
|
|
18313
|
+
hide_before_query = bool(query_conditions.get("hide_before_query", False))
|
|
18314
|
+
raw_rows = query_conditions.get("rows") or []
|
|
18315
|
+
else:
|
|
18316
|
+
enabled = bool(getattr(query_conditions, "enabled", True))
|
|
18317
|
+
exact = bool(getattr(query_conditions, "exact", False))
|
|
18318
|
+
hide_before_query = bool(getattr(query_conditions, "hide_before_query", False))
|
|
18319
|
+
raw_rows = getattr(query_conditions, "rows", []) or []
|
|
18320
|
+
if enabled and not raw_rows:
|
|
18321
|
+
return None, None, [
|
|
18322
|
+
{
|
|
18323
|
+
"error_code": "QUERY_CONDITION_ROWS_REQUIRED",
|
|
18324
|
+
"reason_path": "query_conditions.rows",
|
|
18325
|
+
"missing_fields": [],
|
|
18326
|
+
"message": "query_conditions.enabled=true requires at least one query field",
|
|
18327
|
+
}
|
|
18328
|
+
]
|
|
18329
|
+
rows: list[list[int]] = []
|
|
18330
|
+
issues: list[dict[str, Any]] = []
|
|
18331
|
+
for row_index, row in enumerate(raw_rows):
|
|
18332
|
+
if not isinstance(row, list):
|
|
18333
|
+
issues.append(
|
|
18334
|
+
{
|
|
18335
|
+
"error_code": "INVALID_QUERY_CONDITION_ROW",
|
|
18336
|
+
"reason_path": f"query_conditions.rows[{row_index}]",
|
|
18337
|
+
"missing_fields": [],
|
|
18338
|
+
"message": "query condition rows must be lists of field names or ids",
|
|
18339
|
+
}
|
|
18340
|
+
)
|
|
18341
|
+
continue
|
|
18342
|
+
compiled_row: list[int] = []
|
|
18343
|
+
for column_index, selector in enumerate(row):
|
|
18344
|
+
field, issue = _resolve_query_condition_field(
|
|
18345
|
+
current_fields_by_name=current_fields_by_name,
|
|
18346
|
+
selector=selector,
|
|
18347
|
+
reason_path=f"query_conditions.rows[{row_index}][{column_index}]",
|
|
18348
|
+
)
|
|
18349
|
+
if issue:
|
|
18350
|
+
issues.append(issue)
|
|
18351
|
+
continue
|
|
18352
|
+
que_id = _coerce_positive_int(field.get("que_id"))
|
|
18353
|
+
if que_id is not None:
|
|
18354
|
+
compiled_row.append(que_id)
|
|
18355
|
+
if compiled_row:
|
|
18356
|
+
rows.append(compiled_row)
|
|
18357
|
+
if issues:
|
|
18358
|
+
return None, None, issues
|
|
18359
|
+
payload = {
|
|
18360
|
+
"queryConditionStatus": enabled,
|
|
18361
|
+
"queryConditionExact": exact,
|
|
18362
|
+
"hideBeforeQueryCondition": hide_before_query,
|
|
18363
|
+
"queryCondition": rows if enabled else [],
|
|
18364
|
+
}
|
|
18365
|
+
return payload, _normalize_view_query_conditions_for_compare(payload), []
|
|
18366
|
+
|
|
18367
|
+
|
|
18368
|
+
def _resolve_query_condition_field(
|
|
18369
|
+
*,
|
|
18370
|
+
current_fields_by_name: dict[str, dict[str, Any]],
|
|
18371
|
+
selector: Any,
|
|
18372
|
+
reason_path: str,
|
|
18373
|
+
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
|
18374
|
+
raw = str(selector or "").strip()
|
|
18375
|
+
if not raw:
|
|
18376
|
+
return {}, {
|
|
18377
|
+
"error_code": "QUERY_CONDITION_FIELD_NOT_FOUND",
|
|
18378
|
+
"reason_path": reason_path,
|
|
18379
|
+
"missing_fields": [],
|
|
18380
|
+
"message": "query condition field selector cannot be empty",
|
|
18381
|
+
}
|
|
18382
|
+
fields = [field for field in current_fields_by_name.values() if isinstance(field, dict)]
|
|
18383
|
+
field = current_fields_by_name.get(raw)
|
|
18384
|
+
if field is None:
|
|
18385
|
+
for candidate in fields:
|
|
18386
|
+
if raw == str(candidate.get("field_id") or "").strip():
|
|
18387
|
+
field = candidate
|
|
18388
|
+
break
|
|
18389
|
+
candidate_que_id = _coerce_positive_int(candidate.get("que_id"))
|
|
18390
|
+
if candidate_que_id is not None and raw == str(candidate_que_id):
|
|
18391
|
+
field = candidate
|
|
18392
|
+
break
|
|
18393
|
+
if field is None:
|
|
18394
|
+
subfield_parent = _find_subtable_subfield_parent(current_fields_by_name=current_fields_by_name, selector=raw)
|
|
18395
|
+
if subfield_parent is not None:
|
|
18396
|
+
return {}, {
|
|
18397
|
+
"error_code": "INVALID_QUERY_CONDITION_FIELD",
|
|
18398
|
+
"reason_path": reason_path,
|
|
18399
|
+
"missing_fields": [],
|
|
18400
|
+
"field": raw,
|
|
18401
|
+
"parent_field": subfield_parent.get("name"),
|
|
18402
|
+
"message": "subtable subfields cannot be used as view query conditions",
|
|
18403
|
+
}
|
|
18404
|
+
return {}, {
|
|
18405
|
+
"error_code": "QUERY_CONDITION_FIELD_NOT_FOUND",
|
|
18406
|
+
"reason_path": reason_path,
|
|
18407
|
+
"missing_fields": [raw],
|
|
18408
|
+
"message": "query condition references an unknown field",
|
|
18409
|
+
}
|
|
18410
|
+
field_type = str(field.get("type") or "")
|
|
18411
|
+
if field_type in QUERY_CONDITION_UNSUPPORTED_FIELD_TYPES:
|
|
18412
|
+
return {}, {
|
|
18413
|
+
"error_code": "INVALID_QUERY_CONDITION_FIELD",
|
|
18414
|
+
"reason_path": reason_path,
|
|
18415
|
+
"missing_fields": [],
|
|
18416
|
+
"field": field.get("name"),
|
|
18417
|
+
"field_type": field_type,
|
|
18418
|
+
"message": "this field type is not supported by the frontend query condition panel",
|
|
18419
|
+
}
|
|
18420
|
+
que_id = _coerce_positive_int(field.get("que_id"))
|
|
18421
|
+
if que_id is None:
|
|
18422
|
+
return {}, {
|
|
18423
|
+
"error_code": "INVALID_QUERY_CONDITION_FIELD",
|
|
18424
|
+
"reason_path": reason_path,
|
|
18425
|
+
"missing_fields": [],
|
|
18426
|
+
"field": field.get("name"),
|
|
18427
|
+
"message": "query condition field has no backend queId",
|
|
18428
|
+
}
|
|
18429
|
+
return field, None
|
|
18430
|
+
|
|
18431
|
+
|
|
18432
|
+
def _find_subtable_subfield_parent(
|
|
18433
|
+
*,
|
|
18434
|
+
current_fields_by_name: dict[str, dict[str, Any]],
|
|
18435
|
+
selector: str,
|
|
18436
|
+
) -> dict[str, Any] | None:
|
|
18437
|
+
for field in current_fields_by_name.values():
|
|
18438
|
+
if not isinstance(field, dict) or str(field.get("type") or "") != FieldType.subtable.value:
|
|
18439
|
+
continue
|
|
18440
|
+
for subfield in field.get("subfields") or []:
|
|
18441
|
+
if not isinstance(subfield, dict):
|
|
18442
|
+
continue
|
|
18443
|
+
if selector == str(subfield.get("name") or "").strip():
|
|
18444
|
+
return field
|
|
18445
|
+
if selector == str(subfield.get("field_id") or "").strip():
|
|
18446
|
+
return field
|
|
18447
|
+
sub_que_id = _coerce_positive_int(subfield.get("que_id"))
|
|
18448
|
+
if sub_que_id is not None and selector == str(sub_que_id):
|
|
18449
|
+
return field
|
|
18450
|
+
return None
|
|
18451
|
+
|
|
18452
|
+
|
|
18453
|
+
def _apply_view_query_conditions_payload(data: dict[str, Any], query_condition_payload: dict[str, Any] | None) -> None:
|
|
18454
|
+
if query_condition_payload is None:
|
|
18455
|
+
return
|
|
18456
|
+
data["queryConditionStatus"] = bool(query_condition_payload.get("queryConditionStatus", False))
|
|
18457
|
+
data["queryConditionExact"] = bool(query_condition_payload.get("queryConditionExact", False))
|
|
18458
|
+
data["hideBeforeQueryCondition"] = bool(query_condition_payload.get("hideBeforeQueryCondition", False))
|
|
18459
|
+
data["queryCondition"] = deepcopy(query_condition_payload.get("queryCondition") or [])
|
|
18460
|
+
|
|
18461
|
+
|
|
18462
|
+
def _extract_view_query_conditions_config(
|
|
18463
|
+
config: dict[str, Any],
|
|
18464
|
+
*,
|
|
18465
|
+
question_entries_by_id: dict[int, dict[str, Any]] | None = None,
|
|
18466
|
+
) -> dict[str, Any]:
|
|
18467
|
+
question_entries_by_id = question_entries_by_id or {}
|
|
18468
|
+
normalized = _normalize_view_query_conditions_for_compare(config)
|
|
18469
|
+
rows: list[list[str]] = []
|
|
18470
|
+
for row in normalized["rows"]:
|
|
18471
|
+
public_row: list[str] = []
|
|
18472
|
+
for field_id in row:
|
|
18473
|
+
name = str((question_entries_by_id.get(field_id) or {}).get("name") or "").strip()
|
|
18474
|
+
public_row.append(name or str(field_id))
|
|
18475
|
+
rows.append(public_row)
|
|
18476
|
+
public_config = _public_view_query_conditions_from_compare(normalized)
|
|
18477
|
+
public_config["rows"] = rows
|
|
18478
|
+
public_config["row_field_ids"] = deepcopy(normalized["rows"])
|
|
18479
|
+
return public_config
|
|
18480
|
+
|
|
18481
|
+
|
|
18482
|
+
def _public_view_query_conditions_from_compare(normalized: dict[str, Any]) -> dict[str, Any]:
|
|
18483
|
+
return {
|
|
18484
|
+
"enabled": bool(normalized.get("enabled", False)),
|
|
18485
|
+
"exact": bool(normalized.get("exact", False)),
|
|
18486
|
+
"hide_before_query": bool(normalized.get("hide_before_query", False)),
|
|
18487
|
+
"rows": deepcopy(normalized.get("rows") or []),
|
|
18488
|
+
}
|
|
18489
|
+
|
|
18490
|
+
|
|
18491
|
+
def _normalize_view_query_conditions_for_compare(config: Any) -> dict[str, Any]:
|
|
18492
|
+
if not isinstance(config, dict):
|
|
18493
|
+
config = {}
|
|
18494
|
+
raw_rows = config.get("queryCondition")
|
|
18495
|
+
rows: list[list[int]] = []
|
|
18496
|
+
if isinstance(raw_rows, list):
|
|
18497
|
+
for raw_row in raw_rows:
|
|
18498
|
+
if not isinstance(raw_row, list):
|
|
18499
|
+
continue
|
|
18500
|
+
compiled_row = [
|
|
18501
|
+
field_id
|
|
18502
|
+
for field_id in (_coerce_positive_int(item) for item in raw_row)
|
|
18503
|
+
if field_id is not None
|
|
18504
|
+
]
|
|
18505
|
+
if compiled_row:
|
|
18506
|
+
rows.append(compiled_row)
|
|
18507
|
+
enabled = bool(config.get("queryConditionStatus", bool(rows)))
|
|
18508
|
+
return {
|
|
18509
|
+
"enabled": enabled,
|
|
18510
|
+
"exact": bool(config.get("queryConditionExact", False)),
|
|
18511
|
+
"hide_before_query": bool(config.get("hideBeforeQueryCondition", False)),
|
|
18512
|
+
"rows": rows,
|
|
18513
|
+
}
|
|
18514
|
+
|
|
18515
|
+
|
|
18516
|
+
def _build_view_associated_resources_payload(
|
|
18517
|
+
*,
|
|
18518
|
+
associated_resources: ViewAssociatedResourcesPatch | dict[str, Any] | None,
|
|
18519
|
+
available_resources: list[dict[str, Any]],
|
|
18520
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any] | None, list[dict[str, Any]]]:
|
|
18521
|
+
if associated_resources is None:
|
|
18522
|
+
return None, None, []
|
|
18523
|
+
if isinstance(associated_resources, dict):
|
|
18524
|
+
visible = bool(associated_resources.get("visible", associated_resources.get("enabled", True)))
|
|
18525
|
+
raw_limit_type = associated_resources.get("limit_type", associated_resources.get("limitType"))
|
|
18526
|
+
raw_item_ids = (
|
|
18527
|
+
associated_resources.get("associated_item_ids")
|
|
18528
|
+
or associated_resources.get("associatedItemIds")
|
|
18529
|
+
or associated_resources.get("asosChartIdList")
|
|
18530
|
+
or associated_resources.get("items")
|
|
18531
|
+
or []
|
|
18532
|
+
)
|
|
18533
|
+
else:
|
|
18534
|
+
visible = bool(getattr(associated_resources, "visible", True))
|
|
18535
|
+
raw_limit_type = getattr(associated_resources, "limit_type", None)
|
|
18536
|
+
raw_item_ids = getattr(associated_resources, "associated_item_ids", []) or []
|
|
18537
|
+
if not visible:
|
|
18538
|
+
expected = {
|
|
18539
|
+
"visible": False,
|
|
18540
|
+
"limit_type": None,
|
|
18541
|
+
"configured_associated_item_ids": [],
|
|
18542
|
+
"effective_associated_item_ids": [],
|
|
18543
|
+
"items": [],
|
|
18544
|
+
}
|
|
18545
|
+
return {"asosChartVisible": False}, expected, []
|
|
18546
|
+
limit_type = str(raw_limit_type or "all").strip().lower()
|
|
18547
|
+
if limit_type not in {"all", "select"}:
|
|
18548
|
+
return None, None, [
|
|
18549
|
+
{
|
|
18550
|
+
"error_code": "INVALID_ASSOCIATED_RESOURCE_LIMIT_TYPE",
|
|
18551
|
+
"reason_path": "associated_resources.limit_type",
|
|
18552
|
+
"missing_fields": [],
|
|
18553
|
+
"received": raw_limit_type,
|
|
18554
|
+
"expected_values": ["all", "select"],
|
|
18555
|
+
"message": "associated_resources.limit_type must be 'all' or 'select'",
|
|
18556
|
+
}
|
|
18557
|
+
]
|
|
18558
|
+
available_by_id = _associated_resource_index(available_resources)
|
|
18559
|
+
if limit_type == "all":
|
|
18560
|
+
payload = {
|
|
18561
|
+
"asosChartVisible": True,
|
|
18562
|
+
"asosChartConfig": {
|
|
18563
|
+
"limitType": ASSOCIATED_RESOURCE_LIMIT_TYPE_ALL,
|
|
18564
|
+
"asosChartIdList": [],
|
|
18565
|
+
},
|
|
18566
|
+
}
|
|
18567
|
+
expected = _extract_view_associated_resources_config(payload, available_resources=available_resources)
|
|
18568
|
+
return payload, expected, []
|
|
18569
|
+
item_ids: list[int] = []
|
|
18570
|
+
for index, raw_item_id in enumerate(raw_item_ids):
|
|
18571
|
+
item_id = _coerce_positive_int(raw_item_id)
|
|
18572
|
+
if item_id is None:
|
|
18573
|
+
return None, None, [
|
|
18574
|
+
{
|
|
18575
|
+
"error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
|
|
18576
|
+
"reason_path": f"associated_resources.associated_item_ids[{index}]",
|
|
18577
|
+
"missing_fields": [],
|
|
18578
|
+
"received": raw_item_id,
|
|
18579
|
+
"message": "associated_item_id must be an app-level associated resource id from app_get.associated_resources",
|
|
18580
|
+
"next_action": "call app_get and use associated_resources[].associated_item_id, not chart_id",
|
|
18581
|
+
}
|
|
18582
|
+
]
|
|
18583
|
+
item_ids.append(item_id)
|
|
18584
|
+
missing_ids = [item_id for item_id in item_ids if item_id not in available_by_id]
|
|
18585
|
+
if missing_ids:
|
|
18586
|
+
return None, None, [
|
|
18587
|
+
{
|
|
18588
|
+
"error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
|
|
18589
|
+
"reason_path": "associated_resources.associated_item_ids",
|
|
18590
|
+
"missing_fields": [],
|
|
18591
|
+
"invalid_associated_item_ids": missing_ids,
|
|
18592
|
+
"available_associated_item_ids": sorted(available_by_id),
|
|
18593
|
+
"message": "associated_resource references ids that are not in the app-level associated resource pool",
|
|
18594
|
+
"next_action": "call app_get and use associated_resources[].associated_item_id, not chart_id",
|
|
18595
|
+
}
|
|
18596
|
+
]
|
|
18597
|
+
payload = {
|
|
18598
|
+
"asosChartVisible": True,
|
|
18599
|
+
"asosChartConfig": {
|
|
18600
|
+
"limitType": ASSOCIATED_RESOURCE_LIMIT_TYPE_SELECT,
|
|
18601
|
+
"asosChartIdList": item_ids,
|
|
18602
|
+
},
|
|
18603
|
+
}
|
|
18604
|
+
expected = _extract_view_associated_resources_config(payload, available_resources=available_resources)
|
|
18605
|
+
return payload, expected, []
|
|
18606
|
+
|
|
18607
|
+
|
|
18608
|
+
def _apply_view_associated_resources_payload(data: dict[str, Any], associated_resources_payload: dict[str, Any] | None) -> None:
|
|
18609
|
+
if associated_resources_payload is None:
|
|
18610
|
+
return
|
|
18611
|
+
if "asosChartVisible" in associated_resources_payload:
|
|
18612
|
+
data["asosChartVisible"] = bool(associated_resources_payload.get("asosChartVisible", False))
|
|
18613
|
+
if "asosChartConfig" in associated_resources_payload:
|
|
18614
|
+
data["asosChartConfig"] = deepcopy(associated_resources_payload.get("asosChartConfig") or {})
|
|
18615
|
+
|
|
18616
|
+
|
|
18617
|
+
def _extract_view_associated_resources_config(
|
|
18618
|
+
config: dict[str, Any],
|
|
18619
|
+
*,
|
|
18620
|
+
available_resources: list[dict[str, Any]] | None = None,
|
|
18621
|
+
) -> dict[str, Any]:
|
|
18622
|
+
available_resources = available_resources or []
|
|
18623
|
+
available_by_id = _associated_resource_index(available_resources)
|
|
18624
|
+
visible = bool(config.get("asosChartVisible", False))
|
|
18625
|
+
raw_config = config.get("asosChartConfig")
|
|
18626
|
+
if not isinstance(raw_config, dict):
|
|
18627
|
+
raw_config = {}
|
|
18628
|
+
raw_limit_type = raw_config.get("limitType", config.get("limitType", ASSOCIATED_RESOURCE_LIMIT_TYPE_ALL))
|
|
18629
|
+
limit_type = "select" if _coerce_positive_int(raw_limit_type) == ASSOCIATED_RESOURCE_LIMIT_TYPE_SELECT else "all"
|
|
18630
|
+
raw_ids = raw_config.get("asosChartIdList", config.get("asosChartIdList", [])) or []
|
|
18631
|
+
configured_ids = [
|
|
18632
|
+
item_id
|
|
18633
|
+
for item_id in (_coerce_positive_int(item) for item in raw_ids)
|
|
18634
|
+
if item_id is not None
|
|
18635
|
+
]
|
|
18636
|
+
if not visible:
|
|
18637
|
+
effective_ids: list[int] = []
|
|
18638
|
+
elif limit_type == "all":
|
|
18639
|
+
effective_ids = sorted(available_by_id) if available_by_id else []
|
|
18640
|
+
else:
|
|
18641
|
+
effective_ids = configured_ids
|
|
18642
|
+
items = [deepcopy(available_by_id[item_id]) for item_id in effective_ids if item_id in available_by_id]
|
|
18643
|
+
return {
|
|
18644
|
+
"visible": visible,
|
|
18645
|
+
"limit_type": limit_type,
|
|
18646
|
+
"configured_associated_item_ids": configured_ids,
|
|
18647
|
+
"effective_associated_item_ids": effective_ids,
|
|
18648
|
+
"items": items,
|
|
18649
|
+
}
|
|
18650
|
+
|
|
18651
|
+
|
|
18652
|
+
def _associated_resources_config_matches(expected: dict[str, Any], actual: dict[str, Any]) -> bool:
|
|
18653
|
+
if bool(expected.get("visible", False)) != bool(actual.get("visible", False)):
|
|
18654
|
+
return False
|
|
18655
|
+
if not bool(expected.get("visible", False)):
|
|
18656
|
+
return True
|
|
18657
|
+
if str(expected.get("limit_type") or "") != str(actual.get("limit_type") or ""):
|
|
18658
|
+
return False
|
|
18659
|
+
if sorted(expected.get("configured_associated_item_ids") or []) != sorted(actual.get("configured_associated_item_ids") or []):
|
|
18660
|
+
return False
|
|
18661
|
+
if sorted(expected.get("effective_associated_item_ids") or []) != sorted(actual.get("effective_associated_item_ids") or []):
|
|
18662
|
+
return False
|
|
18663
|
+
return True
|
|
18664
|
+
|
|
18665
|
+
|
|
18666
|
+
def _normalize_associated_resources_payload(payload: Any) -> list[dict[str, Any]]:
|
|
18667
|
+
if isinstance(payload, dict):
|
|
18668
|
+
raw_items = (
|
|
18669
|
+
payload.get("associated_resources")
|
|
18670
|
+
or payload.get("associatedResources")
|
|
18671
|
+
or payload.get("asosCharts")
|
|
18672
|
+
or payload.get("items")
|
|
18673
|
+
or payload.get("list")
|
|
18674
|
+
or payload.get("data")
|
|
18675
|
+
or payload.get("result")
|
|
18676
|
+
or []
|
|
18677
|
+
)
|
|
18678
|
+
else:
|
|
18679
|
+
raw_items = payload or []
|
|
18680
|
+
if isinstance(raw_items, dict):
|
|
18681
|
+
raw_items = raw_items.get("items") or raw_items.get("list") or raw_items.get("data") or []
|
|
18682
|
+
if not isinstance(raw_items, list):
|
|
18683
|
+
return []
|
|
18684
|
+
normalized_items: list[dict[str, Any]] = []
|
|
18685
|
+
seen_ids: set[int] = set()
|
|
18686
|
+
for raw_item in raw_items:
|
|
18687
|
+
item = _normalize_associated_resource_item(raw_item)
|
|
18688
|
+
item_id = _coerce_positive_int(item.get("associated_item_id"))
|
|
18689
|
+
if item_id is None or item_id in seen_ids:
|
|
18690
|
+
continue
|
|
18691
|
+
item["associated_item_id"] = item_id
|
|
18692
|
+
normalized_items.append(item)
|
|
18693
|
+
seen_ids.add(item_id)
|
|
18694
|
+
return normalized_items
|
|
18695
|
+
|
|
18696
|
+
|
|
18697
|
+
def _normalize_associated_resource_item(raw_item: Any) -> dict[str, Any]:
|
|
18698
|
+
if not isinstance(raw_item, dict):
|
|
18699
|
+
return {}
|
|
18700
|
+
item_id = _first_present(raw_item, "associated_item_id", "associatedItemId", "asosChartId", "id")
|
|
18701
|
+
chart_key = _first_present(raw_item, "chart_key", "chartKey", "graphKey", "chartId")
|
|
18702
|
+
view_key = _first_present(raw_item, "view_key", "viewKey", "viewgraphKey", "viewGraphKey")
|
|
18703
|
+
graph_type = _normalize_associated_graph_type(raw_item, chart_key=chart_key, view_key=view_key)
|
|
18704
|
+
name = _first_present(raw_item, "name", "chartName", "chart_name", "viewName", "view_name", "title")
|
|
18705
|
+
source_type = _first_present(raw_item, "source_type", "sourceType", "type")
|
|
18706
|
+
target_app_key = _first_present(raw_item, "target_app_key", "targetAppKey", "appKey", "formKey")
|
|
18707
|
+
resource_type = "view" if graph_type == "view" else "report"
|
|
18708
|
+
item: dict[str, Any] = {
|
|
18709
|
+
"associated_item_id": item_id,
|
|
18710
|
+
"resource_type": resource_type,
|
|
18711
|
+
"graph_type": graph_type,
|
|
18712
|
+
"name": name,
|
|
18713
|
+
}
|
|
18714
|
+
if target_app_key is not None:
|
|
18715
|
+
item["target_app_key"] = target_app_key
|
|
18716
|
+
if graph_type == "view":
|
|
18717
|
+
item["view_key"] = view_key or chart_key
|
|
18718
|
+
view_type = _first_present(raw_item, "view_type", "viewType", "viewgraphType", "viewGraphType")
|
|
18719
|
+
if view_type is not None:
|
|
18720
|
+
item["view_type"] = view_type
|
|
18721
|
+
else:
|
|
18722
|
+
item["chart_id"] = _first_present(raw_item, "chart_id", "chartId", "idOfChart")
|
|
18723
|
+
item["chart_key"] = chart_key
|
|
18724
|
+
chart_type = _first_present(raw_item, "chart_type", "chartType", "chartGraphType")
|
|
18725
|
+
if chart_type is not None:
|
|
18726
|
+
item["chart_type"] = _public_chart_type_from_backend(chart_type)
|
|
18727
|
+
item["report_source"] = _public_report_source_from_backend_source(source_type)
|
|
18728
|
+
return _compact_dict(item)
|
|
18729
|
+
|
|
18730
|
+
|
|
18731
|
+
def _normalize_associated_graph_type(raw_item: dict[str, Any], *, chart_key: Any, view_key: Any) -> str:
|
|
18732
|
+
raw_graph_type = str(_first_present(raw_item, "graph_type", "graphType", "resourceType", "type") or "").strip().lower()
|
|
18733
|
+
raw_source_type = str(_first_present(raw_item, "source_type", "sourceType") or "").strip().lower()
|
|
18734
|
+
combined = f"{raw_graph_type} {raw_source_type}"
|
|
18735
|
+
if "view" in combined or "viewgraph" in combined:
|
|
18736
|
+
return "view"
|
|
18737
|
+
if "chart" in combined or "report" in combined or "qingbi" in combined:
|
|
18738
|
+
return "chart"
|
|
18739
|
+
if view_key:
|
|
18740
|
+
return "view"
|
|
18741
|
+
if chart_key:
|
|
18742
|
+
return "chart"
|
|
18743
|
+
return "chart"
|
|
18744
|
+
|
|
18745
|
+
|
|
18746
|
+
def _associated_resource_index(resources: list[dict[str, Any]]) -> dict[int, dict[str, Any]]:
|
|
18747
|
+
indexed: dict[int, dict[str, Any]] = {}
|
|
18748
|
+
for resource in resources:
|
|
18749
|
+
item_id = _coerce_positive_int(resource.get("associated_item_id"))
|
|
18750
|
+
if item_id is not None:
|
|
18751
|
+
indexed[item_id] = resource
|
|
18752
|
+
return indexed
|
|
18753
|
+
|
|
18754
|
+
|
|
18755
|
+
def _public_associated_graph_type(value: Any) -> str:
|
|
18756
|
+
normalized = str(value or "").strip().lower()
|
|
18757
|
+
if normalized in {"view", "viewgraph"}:
|
|
18758
|
+
return "view"
|
|
18759
|
+
if normalized in {"chart", "report", "qingbi"}:
|
|
18760
|
+
return "chart"
|
|
18761
|
+
return normalized
|
|
18762
|
+
|
|
18763
|
+
|
|
18764
|
+
def _backend_associated_graph_type(value: Any) -> str:
|
|
18765
|
+
graph_type = _public_associated_graph_type(value)
|
|
18766
|
+
return "VIEW" if graph_type == "view" else "CHART"
|
|
18767
|
+
|
|
18768
|
+
|
|
18769
|
+
def _public_report_source_from_backend_source(source_type: Any) -> str:
|
|
18770
|
+
normalized = str(source_type or "").strip().upper()
|
|
18771
|
+
if normalized == "BI_DATASET":
|
|
18772
|
+
return "dataset"
|
|
18773
|
+
return "app"
|
|
18774
|
+
|
|
18775
|
+
|
|
18776
|
+
def _backend_source_type_for_associated_resource_patch(patch: AssociatedResourceUpsertPatch) -> str:
|
|
18777
|
+
graph_type = _public_associated_graph_type(patch.graph_type)
|
|
18778
|
+
if graph_type == "view":
|
|
18779
|
+
return "QINGFLOW"
|
|
18780
|
+
raw_source_type = str(patch.source_type or "").strip().upper()
|
|
18781
|
+
if raw_source_type in {"BI_QINGFLOW", "BI_DATASET"}:
|
|
18782
|
+
return raw_source_type
|
|
18783
|
+
report_source = str(patch.report_source or "app").strip().lower()
|
|
18784
|
+
return "BI_DATASET" if report_source == "dataset" else "BI_QINGFLOW"
|
|
18785
|
+
|
|
18786
|
+
|
|
18787
|
+
def _backend_source_type_for_associated_resource_item(item: dict[str, Any]) -> str:
|
|
18788
|
+
graph_type = str(item.get("graph_type") or item.get("resource_type") or "").strip().lower()
|
|
18789
|
+
if graph_type == "view":
|
|
18790
|
+
return "QINGFLOW"
|
|
18791
|
+
return "BI_DATASET" if str(item.get("report_source") or "").strip().lower() == "dataset" else "BI_QINGFLOW"
|
|
18792
|
+
|
|
18793
|
+
|
|
18794
|
+
def _validate_associated_resource_patch(patch: AssociatedResourceUpsertPatch, *, reason_path: str) -> dict[str, Any] | None:
|
|
18795
|
+
graph_type = _public_associated_graph_type(patch.graph_type)
|
|
18796
|
+
if graph_type not in {"chart", "view"}:
|
|
18797
|
+
return {
|
|
18798
|
+
"error_code": "INVALID_ASSOCIATED_RESOURCE_GRAPH_TYPE",
|
|
18799
|
+
"reason_path": f"{reason_path}.graph_type",
|
|
18800
|
+
"received": patch.graph_type,
|
|
18801
|
+
"expected_values": ["chart", "view"],
|
|
18802
|
+
"message": "graph_type must be 'chart' or 'view'",
|
|
18803
|
+
}
|
|
18804
|
+
source_type = str(patch.source_type or "").strip().upper()
|
|
18805
|
+
if source_type and source_type not in {"QINGFLOW", "BI_QINGFLOW", "BI_DATASET"}:
|
|
18806
|
+
return {
|
|
18807
|
+
"error_code": "INVALID_ASSOCIATED_RESOURCE_SOURCE_TYPE",
|
|
18808
|
+
"reason_path": f"{reason_path}.source_type",
|
|
18809
|
+
"received": patch.source_type,
|
|
18810
|
+
"expected_values": ["BI_QINGFLOW", "BI_DATASET"],
|
|
18811
|
+
"message": "source_type is a legacy compatibility field; use report_source='app' or 'dataset'",
|
|
18812
|
+
}
|
|
18813
|
+
report_source = str(patch.report_source or "").strip().lower()
|
|
18814
|
+
if report_source and report_source not in {"app", "dataset"}:
|
|
18815
|
+
return {
|
|
18816
|
+
"error_code": "INVALID_ASSOCIATED_RESOURCE_REPORT_SOURCE",
|
|
18817
|
+
"reason_path": f"{reason_path}.report_source",
|
|
18818
|
+
"received": patch.report_source,
|
|
18819
|
+
"expected_values": ["app", "dataset"],
|
|
18820
|
+
"message": "report_source must be 'app' or 'dataset'",
|
|
18821
|
+
}
|
|
18822
|
+
if graph_type == "view" and report_source:
|
|
18823
|
+
return {
|
|
18824
|
+
"error_code": "REPORT_SOURCE_ONLY_FOR_REPORT",
|
|
18825
|
+
"reason_path": f"{reason_path}.report_source",
|
|
18826
|
+
"message": "report_source is only valid for graph_type=chart/report; omit it for views",
|
|
18827
|
+
}
|
|
18828
|
+
if graph_type == "chart" and source_type == "QINGFLOW":
|
|
18829
|
+
return {
|
|
18830
|
+
"error_code": "INVALID_ASSOCIATED_RESOURCE_SOURCE_TYPE",
|
|
18831
|
+
"reason_path": f"{reason_path}.source_type",
|
|
18832
|
+
"received": patch.source_type,
|
|
18833
|
+
"expected_values": ["BI_QINGFLOW", "BI_DATASET"],
|
|
18834
|
+
"message": "chart/report associated resources must use BI source; omit source_type or use report_source='app'/'dataset'",
|
|
18835
|
+
"next_action": "remove source_type; the tool will infer BI_QINGFLOW for chart/report resources",
|
|
18836
|
+
}
|
|
18837
|
+
if graph_type == "view" and source_type and source_type != "QINGFLOW":
|
|
18838
|
+
return {
|
|
18839
|
+
"error_code": "INVALID_ASSOCIATED_RESOURCE_SOURCE_TYPE",
|
|
18840
|
+
"reason_path": f"{reason_path}.source_type",
|
|
18841
|
+
"received": patch.source_type,
|
|
18842
|
+
"expected_values": ["QINGFLOW"],
|
|
18843
|
+
"message": "view associated resources use the internal Qingflow view source; omit source_type for views",
|
|
18844
|
+
}
|
|
18845
|
+
if graph_type == "view" and not str(patch.view_key or "").strip():
|
|
18846
|
+
return {
|
|
18847
|
+
"error_code": "ASSOCIATED_RESOURCE_VIEW_KEY_REQUIRED",
|
|
18848
|
+
"reason_path": f"{reason_path}.view_key",
|
|
18849
|
+
"message": "graph_type=view requires view_key",
|
|
18850
|
+
}
|
|
18851
|
+
if graph_type == "chart" and not str(patch.chart_key or "").strip():
|
|
18852
|
+
return {
|
|
18853
|
+
"error_code": "ASSOCIATED_RESOURCE_CHART_KEY_REQUIRED",
|
|
18854
|
+
"reason_path": f"{reason_path}.chart_key",
|
|
18855
|
+
"message": "graph_type=chart requires chart_key",
|
|
18856
|
+
}
|
|
18857
|
+
if not str(patch.target_app_key or "").strip():
|
|
18858
|
+
return {
|
|
18859
|
+
"error_code": "ASSOCIATED_RESOURCE_TARGET_APP_REQUIRED",
|
|
18860
|
+
"reason_path": f"{reason_path}.target_app_key",
|
|
18861
|
+
"message": "target_app_key is required",
|
|
18862
|
+
}
|
|
18863
|
+
return None
|
|
18864
|
+
|
|
18865
|
+
|
|
18866
|
+
def _associated_resource_public_key(patch_or_item: Any) -> str:
|
|
18867
|
+
if isinstance(patch_or_item, AssociatedResourceUpsertPatch):
|
|
18868
|
+
graph_type = _public_associated_graph_type(patch_or_item.graph_type)
|
|
18869
|
+
return str(patch_or_item.view_key if graph_type == "view" else patch_or_item.chart_key or "").strip()
|
|
18870
|
+
if isinstance(patch_or_item, dict):
|
|
18871
|
+
graph_type = str(patch_or_item.get("graph_type") or "").strip().lower()
|
|
18872
|
+
return str(patch_or_item.get("view_key") if graph_type == "view" else patch_or_item.get("chart_key") or "").strip()
|
|
18873
|
+
return ""
|
|
18874
|
+
|
|
18875
|
+
|
|
18876
|
+
def _associated_resource_matches_patch(item: dict[str, Any], patch: AssociatedResourceUpsertPatch) -> bool:
|
|
18877
|
+
graph_type = _public_associated_graph_type(patch.graph_type)
|
|
18878
|
+
item_graph_type = str(item.get("graph_type") or "").strip().lower()
|
|
18879
|
+
if item_graph_type != graph_type:
|
|
18880
|
+
return False
|
|
18881
|
+
if _backend_source_type_for_associated_resource_item(item) != _backend_source_type_for_associated_resource_patch(patch):
|
|
18882
|
+
return False
|
|
18883
|
+
patch_target_app = str(patch.target_app_key or "").strip()
|
|
18884
|
+
item_target_app = str(item.get("target_app_key") or "").strip()
|
|
18885
|
+
if patch_target_app and item_target_app and patch_target_app != item_target_app:
|
|
18886
|
+
return False
|
|
18887
|
+
return _associated_resource_public_key(item) == _associated_resource_public_key(patch)
|
|
18888
|
+
|
|
18889
|
+
|
|
18890
|
+
def _associated_resource_not_found_issue(reason_path: str, associated_item_id: int, existing_by_id: dict[int, dict[str, Any]]) -> dict[str, Any]:
|
|
18891
|
+
return {
|
|
18892
|
+
"error_code": "ASSOCIATED_RESOURCE_NOT_FOUND",
|
|
18893
|
+
"reason_path": reason_path,
|
|
18894
|
+
"associated_item_id": associated_item_id,
|
|
18895
|
+
"available_associated_item_ids": sorted(existing_by_id),
|
|
18896
|
+
"message": "associated_item_id is not in the app-level associated resource pool",
|
|
18897
|
+
"next_action": "call app_get and use associated_resources[].associated_item_id; do not pass chart_id/chart_key",
|
|
18898
|
+
}
|
|
18899
|
+
|
|
18900
|
+
|
|
18901
|
+
def _duplicate_associated_resource_issue(reason_path: str, associated_item_id: int) -> dict[str, Any]:
|
|
18902
|
+
return {
|
|
18903
|
+
"error_code": "DUPLICATE_ASSOCIATED_RESOURCE_OPERATION",
|
|
18904
|
+
"reason_path": reason_path,
|
|
18905
|
+
"associated_item_id": associated_item_id,
|
|
18906
|
+
"message": "the same associated resource is targeted more than once",
|
|
18907
|
+
}
|
|
18908
|
+
|
|
18909
|
+
|
|
18910
|
+
def _serialize_associated_resource_match_rules(match_rules: list[CustomButtonMatchRulePatch]) -> list[list[dict[str, Any]]]:
|
|
18911
|
+
if not match_rules:
|
|
18912
|
+
return []
|
|
18913
|
+
return [[_serialize_custom_button_match_rule(rule.model_dump(mode="json", exclude_none=True)) for rule in match_rules]]
|
|
18914
|
+
|
|
18915
|
+
|
|
18916
|
+
def _serialize_associated_resource_create_payload(patch: AssociatedResourceUpsertPatch) -> dict[str, Any]:
|
|
18917
|
+
graph_type = _public_associated_graph_type(patch.graph_type)
|
|
18918
|
+
return {
|
|
18919
|
+
"appKey": patch.target_app_key,
|
|
18920
|
+
"chartList": [
|
|
18921
|
+
{
|
|
18922
|
+
"chartKey": patch.view_key if graph_type == "view" else patch.chart_key,
|
|
18923
|
+
"sourceType": _backend_source_type_for_associated_resource_patch(patch),
|
|
18924
|
+
"graphType": _backend_associated_graph_type(graph_type),
|
|
18925
|
+
}
|
|
18926
|
+
],
|
|
18927
|
+
"matchRules": _serialize_associated_resource_match_rules(patch.match_rules),
|
|
18928
|
+
}
|
|
18929
|
+
|
|
18930
|
+
|
|
18931
|
+
def _serialize_associated_resource_update_payload(patch: AssociatedResourceUpsertPatch, *, associated_item_id: int) -> dict[str, Any]:
|
|
18932
|
+
graph_type = _public_associated_graph_type(patch.graph_type)
|
|
18933
|
+
return _compact_dict(
|
|
18934
|
+
{
|
|
18935
|
+
"id": associated_item_id,
|
|
18936
|
+
"appKey": patch.target_app_key,
|
|
18937
|
+
"chartKey": patch.view_key if graph_type == "view" else patch.chart_key,
|
|
18938
|
+
"sourceType": _backend_source_type_for_associated_resource_patch(patch),
|
|
18939
|
+
"graphType": _backend_associated_graph_type(graph_type),
|
|
18940
|
+
"matchRules": _serialize_associated_resource_match_rules(patch.match_rules),
|
|
18941
|
+
}
|
|
18942
|
+
)
|
|
18943
|
+
|
|
18944
|
+
|
|
18945
|
+
def _associated_resource_result_entry(
|
|
18946
|
+
operation: str,
|
|
18947
|
+
index: int,
|
|
18948
|
+
patch: AssociatedResourceUpsertPatch,
|
|
18949
|
+
*,
|
|
18950
|
+
associated_item_id: int | None,
|
|
18951
|
+
) -> dict[str, Any]:
|
|
18952
|
+
graph_type = _public_associated_graph_type(patch.graph_type)
|
|
18953
|
+
return _compact_dict(
|
|
18954
|
+
{
|
|
18955
|
+
"index": index,
|
|
18956
|
+
"operation": operation,
|
|
18957
|
+
"status": "success" if associated_item_id is not None else "unverified",
|
|
18958
|
+
"associated_item_id": associated_item_id,
|
|
18959
|
+
"client_key": patch.client_key,
|
|
18960
|
+
"resource_type": "view" if graph_type == "view" else "report",
|
|
18961
|
+
"graph_type": graph_type,
|
|
18962
|
+
"target_app_key": patch.target_app_key,
|
|
18963
|
+
"report_source": _public_report_source_from_backend_source(_backend_source_type_for_associated_resource_patch(patch)) if graph_type == "chart" else None,
|
|
18964
|
+
"chart_key": patch.chart_key if graph_type == "chart" else None,
|
|
18965
|
+
"view_key": patch.view_key if graph_type == "view" else None,
|
|
18966
|
+
}
|
|
18967
|
+
)
|
|
18968
|
+
|
|
18969
|
+
|
|
18970
|
+
def _build_view_associated_resources_only_update_payload(
|
|
18971
|
+
config: Any,
|
|
18972
|
+
*,
|
|
18973
|
+
associated_resources_payload: dict[str, Any] | None,
|
|
18974
|
+
) -> dict[str, Any]:
|
|
18975
|
+
payload = deepcopy(config) if isinstance(config, dict) else {}
|
|
18976
|
+
for key in (
|
|
18977
|
+
"appKey",
|
|
18978
|
+
"formId",
|
|
18979
|
+
"wsId",
|
|
18980
|
+
"viewgraphKey",
|
|
18981
|
+
"createTime",
|
|
18982
|
+
"updateTime",
|
|
18983
|
+
"creator",
|
|
18984
|
+
"editor",
|
|
18985
|
+
"queBaseInfo",
|
|
18986
|
+
"buttonConfigVO",
|
|
18987
|
+
"buttonConfigDTOList",
|
|
18988
|
+
"buttonConfig",
|
|
18989
|
+
):
|
|
18990
|
+
payload.pop(key, None)
|
|
18991
|
+
payload.pop("id", None)
|
|
18992
|
+
payload.setdefault("viewgraphName", str(config.get("viewgraphName") or config.get("viewName") or "") if isinstance(config, dict) else "")
|
|
18993
|
+
payload.setdefault("viewgraphType", str(config.get("viewgraphType") or "tableView") if isinstance(config, dict) else "tableView")
|
|
18994
|
+
payload.setdefault("viewgraphQueIds", list(config.get("viewgraphQueIds") or []) if isinstance(config, dict) else [])
|
|
18995
|
+
payload.setdefault("viewgraphQuestions", deepcopy(config.get("viewgraphQuestions") or []) if isinstance(config, dict) else [])
|
|
18996
|
+
payload.setdefault("auth", deepcopy(config.get("auth") or default_member_auth()) if isinstance(config, dict) else default_member_auth())
|
|
18997
|
+
payload.setdefault("sortType", "defaultSort")
|
|
18998
|
+
payload.setdefault("viewgraphSorts", [{"queId": 0, "beingSortAscend": True, "queType": 8}])
|
|
18999
|
+
payload.setdefault("viewgraphLimitType", 1)
|
|
19000
|
+
payload.setdefault("viewgraphLimit", [])
|
|
19001
|
+
payload.setdefault("viewgraphLimitFormula", "")
|
|
19002
|
+
button_config_dtos = _extract_existing_view_button_dtos(config if isinstance(config, dict) else {})
|
|
19003
|
+
if button_config_dtos:
|
|
19004
|
+
payload["buttonConfigDTOList"] = button_config_dtos
|
|
19005
|
+
payload["buttonConfig"] = _build_grouped_view_button_config(button_config_dtos)
|
|
19006
|
+
_apply_view_associated_resources_payload(payload, associated_resources_payload)
|
|
19007
|
+
return payload
|
|
19008
|
+
|
|
19009
|
+
|
|
19010
|
+
def _build_view_buttons_only_update_payload(config: Any, *, button_config_dtos: list[dict[str, Any]]) -> dict[str, Any]:
|
|
19011
|
+
payload = _build_view_associated_resources_only_update_payload(config, associated_resources_payload=None)
|
|
19012
|
+
payload["buttonConfigDTOList"] = deepcopy(button_config_dtos)
|
|
19013
|
+
payload["buttonConfig"] = _build_grouped_view_button_config(button_config_dtos)
|
|
19014
|
+
return payload
|
|
19015
|
+
|
|
19016
|
+
|
|
19017
|
+
def _first_present(mapping: dict[str, Any], *keys: str) -> Any:
|
|
19018
|
+
for key in keys:
|
|
19019
|
+
if key in mapping and mapping[key] is not None:
|
|
19020
|
+
return mapping[key]
|
|
19021
|
+
return None
|
|
19022
|
+
|
|
19023
|
+
|
|
15663
19024
|
def _build_view_create_payload(
|
|
15664
19025
|
*,
|
|
15665
19026
|
app_key: str,
|
|
@@ -15671,6 +19032,8 @@ def _build_view_create_payload(
|
|
|
15671
19032
|
current_fields_by_name: dict[str, dict[str, Any]],
|
|
15672
19033
|
auth_override: dict[str, Any] | None = None,
|
|
15673
19034
|
explicit_button_dtos: list[dict[str, Any]] | None = None,
|
|
19035
|
+
query_condition_payload: dict[str, Any] | None = None,
|
|
19036
|
+
associated_resources_payload: dict[str, Any] | None = None,
|
|
15674
19037
|
) -> JSONObject:
|
|
15675
19038
|
entity = _entity_spec_from_app(base_info=base_info, schema=schema, views=None)
|
|
15676
19039
|
parsed_schema = _parse_schema(schema)
|
|
@@ -15712,6 +19075,8 @@ def _build_view_create_payload(
|
|
|
15712
19075
|
view_filters=view_filters,
|
|
15713
19076
|
gantt_payload=gantt_config,
|
|
15714
19077
|
button_config_dtos=explicit_button_dtos,
|
|
19078
|
+
query_condition_payload=query_condition_payload,
|
|
19079
|
+
associated_resources_payload=associated_resources_payload,
|
|
15715
19080
|
)
|
|
15716
19081
|
|
|
15717
19082
|
|
|
@@ -15865,6 +19230,8 @@ def _build_view_update_payload(
|
|
|
15865
19230
|
current_fields_by_name: dict[str, dict[str, Any]],
|
|
15866
19231
|
auth_override: dict[str, Any] | None = None,
|
|
15867
19232
|
explicit_button_dtos: list[dict[str, Any]] | None = None,
|
|
19233
|
+
query_condition_payload: dict[str, Any] | None = None,
|
|
19234
|
+
associated_resources_payload: dict[str, Any] | None = None,
|
|
15868
19235
|
) -> JSONObject:
|
|
15869
19236
|
config_response = views.view_get_config(profile=profile, viewgraph_key=source_viewgraph_key)
|
|
15870
19237
|
config = config_response.get("result") if isinstance(config_response.get("result"), dict) else {}
|
|
@@ -15940,6 +19307,8 @@ def _build_view_update_payload(
|
|
|
15940
19307
|
view_filters=view_filters,
|
|
15941
19308
|
gantt_payload=gantt_payload,
|
|
15942
19309
|
button_config_dtos=button_config_dtos,
|
|
19310
|
+
query_condition_payload=query_condition_payload,
|
|
19311
|
+
associated_resources_payload=associated_resources_payload,
|
|
15943
19312
|
)
|
|
15944
19313
|
|
|
15945
19314
|
|
|
@@ -15969,6 +19338,8 @@ def _build_minimal_view_payload(
|
|
|
15969
19338
|
current_fields_by_name: dict[str, dict[str, Any]],
|
|
15970
19339
|
auth_override: dict[str, Any] | None = None,
|
|
15971
19340
|
explicit_button_dtos: list[dict[str, Any]] | None = None,
|
|
19341
|
+
query_condition_payload: dict[str, Any] | None = None,
|
|
19342
|
+
associated_resources_payload: dict[str, Any] | None = None,
|
|
15972
19343
|
) -> JSONObject:
|
|
15973
19344
|
field_map = extract_field_map(schema)
|
|
15974
19345
|
parsed_schema = _parse_schema(schema)
|
|
@@ -16002,6 +19373,8 @@ def _build_minimal_view_payload(
|
|
|
16002
19373
|
view_filters=view_filters,
|
|
16003
19374
|
gantt_payload=gantt_payload,
|
|
16004
19375
|
button_config_dtos=explicit_button_dtos,
|
|
19376
|
+
query_condition_payload=query_condition_payload,
|
|
19377
|
+
associated_resources_payload=associated_resources_payload,
|
|
16005
19378
|
)
|
|
16006
19379
|
|
|
16007
19380
|
|
|
@@ -16014,6 +19387,8 @@ def _hydrate_view_backend_payload(
|
|
|
16014
19387
|
view_filters: list[list[dict[str, Any]]] | None = None,
|
|
16015
19388
|
gantt_payload: dict[str, Any] | None = None,
|
|
16016
19389
|
button_config_dtos: list[dict[str, Any]] | None = None,
|
|
19390
|
+
query_condition_payload: dict[str, Any] | None = None,
|
|
19391
|
+
associated_resources_payload: dict[str, Any] | None = None,
|
|
16017
19392
|
) -> JSONObject:
|
|
16018
19393
|
data = deepcopy(payload)
|
|
16019
19394
|
data.setdefault("beingPinNavigate", True)
|
|
@@ -16081,6 +19456,8 @@ def _hydrate_view_backend_payload(
|
|
|
16081
19456
|
data["viewgraphLimitFormula"] = ""
|
|
16082
19457
|
data["dataPermissionType"] = "CUSTOM"
|
|
16083
19458
|
data["dataScope"] = "CUSTOM" if view_filters else "ALL"
|
|
19459
|
+
_apply_view_query_conditions_payload(data, query_condition_payload)
|
|
19460
|
+
_apply_view_associated_resources_payload(data, associated_resources_payload)
|
|
16084
19461
|
return data
|
|
16085
19462
|
|
|
16086
19463
|
|