@qingflow-tech/qingflow-app-builder-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.
@@ -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 _resolve_app_matches_in_visible_apps(
2752
- self,
2753
- *,
2754
- profile: str,
2755
- app_name: str,
2756
- package_tag_id: int,
2757
- ) -> list[JSONObject]:
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
- listing = self.apps.app_list(profile=profile, ship_auth=False)
2760
- except (QingflowApiError, RuntimeError):
2761
- return []
2762
- items = listing.get("items") if isinstance(listing.get("items"), list) else []
2763
- matches: list[JSONObject] = []
2764
- seen_app_keys: set[str] = set()
2765
- for item in items:
2766
- if not isinstance(item, dict):
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
- title = str(item.get("title") or item.get("app_name") or "").strip()
2769
- if title != app_name:
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
- candidate_key = str(item.get("app_key") or item.get("appKey") or "").strip()
2772
- if not candidate_key or candidate_key in seen_app_keys:
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
- tag_ids = _coerce_int_list(item.get("tag_ids"))
2775
- tag_id = _coerce_positive_int(item.get("tag_id"))
2776
- if tag_id is not None and tag_id not in tag_ids:
2777
- tag_ids.append(tag_id)
2778
- if package_tag_id not in tag_ids:
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
- seen_app_keys.add(candidate_key)
2781
- matches.append({"app_key": candidate_key, "app_name": title, "tag_ids": tag_ids})
2782
- return matches
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
- def _resolve_app_matches_in_package(
2785
- self,
2786
- *,
2787
- profile: str,
2788
- app_name: str,
2789
- package_tag_id: int,
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=_summarize_views(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(_summarize_views(views)),
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 and not views_unavailable and not workflow_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 portal_get(self, *, profile: str, dash_key: str, being_draft: bool = True) -> JSONObject:
3804
- try:
3805
- result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft).get("result") or {}
3806
- except (QingflowApiError, RuntimeError) as error:
3807
- api_error = _coerce_api_error(error)
3808
- return _failed_from_api_error(
3809
- "PORTAL_GET_FAILED",
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
- response = PortalGetResponse(
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
- def portal_read_summary(self, *, profile: str, dash_key: str, being_draft: bool = True) -> JSONObject:
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
- result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft).get("result") or {}
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": {"views_verified": True, "view_filters_verified": True},
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": "app_get_views", "arguments": {"profile": profile, "app_key": app_key}},
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": "app_custom_button_list", "arguments": {"profile": profile, "app_key": app_key}},
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 app_get_views and resolve duplicates before removing by name",
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": "app_get_views", "arguments": {"profile": profile, "app_key": app_key}},
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": "app_custom_button_list", "arguments": {"profile": profile, "app_key": app_key}},
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": "app_get_views", "arguments": {"profile": profile, "app_key": app_key}},
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": "app_get_views", "arguments": {"profile": profile, "app_key": app_key}},
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": "app_get_views", "arguments": {"profile": profile, "app_key": app_key}},
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": "app_get_views", "arguments": {"profile": profile, "app_key": app_key}},
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 verified and view_filters_verified and view_buttons_verified else "partial_success",
6562
- "error_code": None if verified and view_filters_verified and view_buttons_verified else ("VIEW_BUTTON_READBACK_MISMATCH" if button_mismatches else "VIEW_FILTER_READBACK_MISMATCH" if filter_mismatches else "VIEWS_READBACK_PENDING"),
6563
- "recoverable": not (verified and view_filters_verified and view_buttons_verified),
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 verified and view_filters_verified and view_buttons_verified
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 verified and view_filters_verified and view_buttons_verified else {"tool_name": "app_get_views", "arguments": {"profile": profile, "app_key": app_key}},
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": verified and view_filters_verified and view_buttons_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
- version_result = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key).get("result") or {}
7375
- edit_version_no = _coerce_positive_int(version_result.get("editVersionNo") or version_result.get("versionNo")) or 1
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(payload: CustomButtonPatch) -> dict[str, Any]:
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 _build_selector_map(fields: list[dict[str, Any]]) -> dict[str, int]:
10802
- mapping: dict[str, int] = {}
10803
- for index, field in enumerate(fields):
10804
- field_id = str(field.get("field_id") or "")
10805
- field_name = str(field.get("name") or "")
10806
- que_id = _coerce_nonnegative_int(field.get("que_id"))
10807
- if field_id:
10808
- mapping[f"field_id:{field_id}"] = index
10809
- if field_name:
10810
- mapping[f"name:{field_name}"] = index
10811
- if que_id is not None:
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 _resolve_selector(selector_map: dict[str, int], selector: FieldSelector) -> int | None:
10817
- if selector.field_id:
10818
- value = selector_map.get(f"field_id:{selector.field_id}")
10819
- if value is not None:
10820
- return value
10821
- if selector.que_id is not None:
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 _resolve_remove_selector(fields: list[dict[str, Any]], patch: FieldRemovePatch) -> int | None:
10833
- selector = FieldSelector(field_id=patch.field_id, que_id=patch.que_id, name=patch.name)
10834
- return _resolve_selector(_build_selector_map(fields), selector)
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 _field_patch_to_internal(patch: FieldPatch) -> dict[str, Any]:
10838
- field_id = _slugify(patch.name, default=f"field_{uuid4().hex[:8]}")
10839
- remote_lookup_config = patch.remote_lookup_config.model_dump(mode="json", exclude_none=True) if patch.remote_lookup_config is not None else None
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
- "field_id": field_id,
10845
- "name": patch.name,
10846
- "type": patch.type.value,
10847
- "required": patch.required,
10848
- "description": patch.description,
10849
- "options": list(patch.options),
10850
- "target_app_key": patch.target_app_key,
10851
- "display_field": patch.display_field.model_dump(mode="json", exclude_none=True) if patch.display_field is not None else None,
10852
- "visible_fields": [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields],
10853
- "relation_mode": patch.relation_mode.value if patch.relation_mode is not None else None,
10854
- "department_scope": patch.department_scope.model_dump(mode="json", exclude_none=True) if patch.department_scope is not None else None,
10855
- "remote_lookup_config": remote_lookup_config,
10856
- "_explicit_remote_lookup_config": remote_lookup_config is not None,
10857
- "q_linker_binding": q_linker_binding,
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
- if actual == expected:
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 = _normalize_backend_chart_type(chart.get("chartType"))
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