@qingflow-tech/qingflow-app-builder-mcp 1.0.9 → 1.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-builder/SKILL.md +8 -0
- package/skills/qingflow-app-builder/references/create-app.md +39 -2
- package/skills/qingflow-app-builder/references/tool-selection.md +2 -2
- package/skills/qingflow-app-builder/references/update-views.md +2 -1
- package/src/qingflow_mcp/builder_facade/models.py +43 -2
- package/src/qingflow_mcp/builder_facade/service.py +1476 -172
- package/src/qingflow_mcp/cli/commands/app.py +3 -16
- package/src/qingflow_mcp/cli/commands/builder.py +68 -13
- package/src/qingflow_mcp/cli/commands/record.py +16 -1
- package/src/qingflow_mcp/cli/formatters.py +32 -1
- package/src/qingflow_mcp/cli/main.py +204 -3
- package/src/qingflow_mcp/public_surface.py +3 -1
- package/src/qingflow_mcp/response_trim.py +70 -13
- package/src/qingflow_mcp/server.py +10 -9
- package/src/qingflow_mcp/server_app_builder.py +53 -7
- package/src/qingflow_mcp/server_app_user.py +12 -15
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +294 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +1642 -75
- package/src/qingflow_mcp/tools/app_tools.py +53 -8
- package/src/qingflow_mcp/tools/package_tools.py +16 -2
- package/src/qingflow_mcp/tools/record_tools.py +1423 -70
- package/src/qingflow_mcp/tools/resource_read_tools.py +3 -0
|
@@ -20,7 +20,7 @@ from ..list_type_labels import RECORD_LIST_TYPE_LABELS, SYSTEM_VIEW_ID_TO_LIST_T
|
|
|
20
20
|
from ..solution.build_assembly_store import BuildAssemblyStore, default_artifacts, default_manifest
|
|
21
21
|
from ..solution.compiler.chart_compiler import qingbi_workspace_visible_auth
|
|
22
22
|
from ..solution.compiler.form_compiler import build_question, default_form_payload, default_member_auth
|
|
23
|
-
from ..solution.compiler.icon_utils import encode_workspace_icon_with_defaults
|
|
23
|
+
from ..solution.compiler.icon_utils import encode_workspace_icon_with_defaults, workspace_icon_config
|
|
24
24
|
from ..solution.compiler.view_compiler import VIEW_TYPE_MAP
|
|
25
25
|
from ..solution.executor import _build_viewgraph_questions, _compact_dict, extract_field_map
|
|
26
26
|
from ..solution.spec_models import FieldType, FormLayoutRowSpec, FormLayoutSectionSpec, ViewSpec
|
|
@@ -878,14 +878,21 @@ class AiBuilderFacade:
|
|
|
878
878
|
"verification": {},
|
|
879
879
|
}
|
|
880
880
|
|
|
881
|
-
def package_list(self, *, profile: str, trial_status: str = "all") -> JSONObject:
|
|
881
|
+
def package_list(self, *, profile: str, trial_status: str = "all", query: str = "") -> JSONObject:
|
|
882
882
|
listed = self.packages.package_list(profile=profile, trial_status=trial_status, include_raw=False)
|
|
883
|
+
raw_items = listed.get("items") if isinstance(listed.get("items"), list) else []
|
|
884
|
+
items = [_publicize_package_list_item(item) for item in raw_items if isinstance(item, dict)]
|
|
885
|
+
normalized_query = str(query or "").strip()
|
|
886
|
+
if normalized_query:
|
|
887
|
+
filtered_items = [item for item in items if _package_list_item_matches_query(item, normalized_query)]
|
|
888
|
+
else:
|
|
889
|
+
filtered_items = items
|
|
883
890
|
return {
|
|
884
891
|
"status": "success",
|
|
885
892
|
"error_code": None,
|
|
886
893
|
"recoverable": False,
|
|
887
894
|
"message": "listed packages",
|
|
888
|
-
"normalized_args": {"trial_status": trial_status},
|
|
895
|
+
"normalized_args": {"trial_status": trial_status, "query": normalized_query},
|
|
889
896
|
"missing_fields": [],
|
|
890
897
|
"allowed_values": {},
|
|
891
898
|
"details": {},
|
|
@@ -894,8 +901,12 @@ class AiBuilderFacade:
|
|
|
894
901
|
"noop": False,
|
|
895
902
|
"verification": {},
|
|
896
903
|
"trial_status": trial_status,
|
|
897
|
-
"
|
|
898
|
-
"
|
|
904
|
+
"query": normalized_query,
|
|
905
|
+
"items": filtered_items,
|
|
906
|
+
"count": len(filtered_items),
|
|
907
|
+
"matched_count": len(filtered_items),
|
|
908
|
+
"unfiltered_count": len(items),
|
|
909
|
+
"filter_mode": "local_packages",
|
|
899
910
|
"source_shape": listed.get("source_shape"),
|
|
900
911
|
"retried": bool(listed.get("retried", False)),
|
|
901
912
|
}
|
|
@@ -2564,6 +2575,7 @@ class AiBuilderFacade:
|
|
|
2564
2575
|
details=_with_state_read_blocked_details({"app_key": app_key}, resource="custom_buttons", error=api_error),
|
|
2565
2576
|
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
2566
2577
|
))
|
|
2578
|
+
app_name = self._read_app_name_for_builder_output(profile=profile, app_key=app_key)
|
|
2567
2579
|
|
|
2568
2580
|
existing_by_id = {
|
|
2569
2581
|
button_id: item
|
|
@@ -2867,13 +2879,29 @@ class AiBuilderFacade:
|
|
|
2867
2879
|
try:
|
|
2868
2880
|
write_executed = True
|
|
2869
2881
|
self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
|
|
2882
|
+
delete_readback = self._verify_custom_button_deleted_by_id(profile=profile, app_key=app_key, button_id=button_id)
|
|
2870
2883
|
removed.append(
|
|
2871
2884
|
{
|
|
2872
2885
|
"index": op["index"],
|
|
2873
2886
|
"operation": "remove",
|
|
2874
|
-
"status": "
|
|
2887
|
+
"status": delete_readback.get("status") or "readback_pending",
|
|
2875
2888
|
"button_id": button_id,
|
|
2876
2889
|
"button_text": selector.button_text or (existing_by_id.get(button_id) or {}).get("button_text"),
|
|
2890
|
+
"delete_executed": True,
|
|
2891
|
+
"readback_status": delete_readback.get("readback_status"),
|
|
2892
|
+
"safe_to_retry_delete": False,
|
|
2893
|
+
**(
|
|
2894
|
+
{
|
|
2895
|
+
"error_code": delete_readback.get("error_code"),
|
|
2896
|
+
"message": delete_readback.get("message"),
|
|
2897
|
+
"request_id": delete_readback.get("request_id"),
|
|
2898
|
+
"backend_code": delete_readback.get("backend_code"),
|
|
2899
|
+
"http_status": delete_readback.get("http_status"),
|
|
2900
|
+
"transport_error": delete_readback.get("transport_error"),
|
|
2901
|
+
}
|
|
2902
|
+
if delete_readback.get("readback_status") != "deleted"
|
|
2903
|
+
else {}
|
|
2904
|
+
),
|
|
2877
2905
|
}
|
|
2878
2906
|
)
|
|
2879
2907
|
except (QingflowApiError, RuntimeError) as error:
|
|
@@ -2891,12 +2919,14 @@ class AiBuilderFacade:
|
|
|
2891
2919
|
}
|
|
2892
2920
|
)
|
|
2893
2921
|
|
|
2922
|
+
needs_button_list_readback = bool(created or updated or request.view_configs)
|
|
2894
2923
|
readback_buttons: list[dict[str, Any]] = []
|
|
2895
2924
|
readback_failed = False
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2925
|
+
if needs_button_list_readback:
|
|
2926
|
+
try:
|
|
2927
|
+
readback_buttons = self._load_custom_buttons_for_builder(profile=profile, app_key=app_key)
|
|
2928
|
+
except (QingflowApiError, RuntimeError):
|
|
2929
|
+
readback_failed = True
|
|
2900
2930
|
readback_ids = {
|
|
2901
2931
|
button_id
|
|
2902
2932
|
for item in readback_buttons
|
|
@@ -2917,10 +2947,12 @@ class AiBuilderFacade:
|
|
|
2917
2947
|
for item in removed
|
|
2918
2948
|
if _coerce_positive_int(item.get("button_id")) is not None
|
|
2919
2949
|
]
|
|
2950
|
+
removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed)
|
|
2951
|
+
remove_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed)
|
|
2920
2952
|
verified = (
|
|
2921
2953
|
not readback_failed
|
|
2922
2954
|
and all(button_id in readback_ids for button_id in created_ids + updated_ids)
|
|
2923
|
-
and
|
|
2955
|
+
and removed_verified
|
|
2924
2956
|
and not failed
|
|
2925
2957
|
and all(_coerce_positive_int(item.get("button_id")) is not None for item in created)
|
|
2926
2958
|
)
|
|
@@ -2979,6 +3011,13 @@ class AiBuilderFacade:
|
|
|
2979
3011
|
else "custom button writes all failed or produced no confirmed result; application was not published",
|
|
2980
3012
|
)
|
|
2981
3013
|
)
|
|
3014
|
+
if remove_readback_pending:
|
|
3015
|
+
warnings.append(
|
|
3016
|
+
_warning(
|
|
3017
|
+
"CUSTOM_BUTTON_DELETE_READBACK_PENDING",
|
|
3018
|
+
"custom button delete was sent, but deletion readback is not fully verified; do not blindly repeat delete",
|
|
3019
|
+
)
|
|
3020
|
+
)
|
|
2982
3021
|
response = {
|
|
2983
3022
|
"status": status,
|
|
2984
3023
|
"error_code": error_code,
|
|
@@ -3008,11 +3047,14 @@ class AiBuilderFacade:
|
|
|
3008
3047
|
"readback_loaded": not readback_failed,
|
|
3009
3048
|
"created_verified": not readback_failed and all(button_id in readback_ids for button_id in created_ids),
|
|
3010
3049
|
"updated_verified": not readback_failed and all(button_id in readback_ids for button_id in updated_ids),
|
|
3011
|
-
"removed_verified":
|
|
3050
|
+
"removed_verified": removed_verified,
|
|
3051
|
+
"remove_readback_pending": remove_readback_pending,
|
|
3052
|
+
"removed_readback_results": deepcopy(removed),
|
|
3012
3053
|
"view_button_bindings_verified": view_config_verified,
|
|
3013
3054
|
},
|
|
3014
3055
|
"verified": verified,
|
|
3015
3056
|
"app_key": app_key,
|
|
3057
|
+
"app_name": app_name,
|
|
3016
3058
|
"mode": "apply",
|
|
3017
3059
|
"created": created,
|
|
3018
3060
|
"updated": updated,
|
|
@@ -3372,30 +3414,35 @@ class AiBuilderFacade:
|
|
|
3372
3414
|
return finalize(edit_context_error)
|
|
3373
3415
|
|
|
3374
3416
|
self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
|
|
3417
|
+
delete_readback = self._verify_custom_button_deleted_by_id(profile=profile, app_key=app_key, button_id=button_id)
|
|
3418
|
+
verified = delete_readback.get("readback_status") == "deleted"
|
|
3375
3419
|
return finalize(
|
|
3376
3420
|
self._append_publish_result(
|
|
3377
3421
|
profile=profile,
|
|
3378
3422
|
app_key=app_key,
|
|
3379
3423
|
publish=True,
|
|
3380
3424
|
response={
|
|
3381
|
-
"status": "success",
|
|
3382
|
-
"error_code": None,
|
|
3383
|
-
"recoverable":
|
|
3384
|
-
"message": "deleted custom button",
|
|
3425
|
+
"status": "success" if verified else "partial_success",
|
|
3426
|
+
"error_code": None if verified else delete_readback.get("error_code") or "CUSTOM_BUTTON_DELETE_READBACK_PENDING",
|
|
3427
|
+
"recoverable": not verified,
|
|
3428
|
+
"message": "deleted custom button" if verified else "custom button delete completed; readback pending",
|
|
3385
3429
|
"normalized_args": normalized_args,
|
|
3386
3430
|
"missing_fields": [],
|
|
3387
3431
|
"allowed_values": {},
|
|
3388
3432
|
"details": {},
|
|
3389
|
-
"request_id":
|
|
3390
|
-
"suggested_next_call": None,
|
|
3433
|
+
"request_id": delete_readback.get("request_id"),
|
|
3434
|
+
"suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
3391
3435
|
"noop": False,
|
|
3392
|
-
"warnings": [],
|
|
3393
|
-
"verification": {"custom_button_deleted":
|
|
3394
|
-
"verified":
|
|
3436
|
+
"warnings": [] if verified else [_warning("CUSTOM_BUTTON_DELETE_READBACK_PENDING", "custom button delete was sent, but deletion readback is not fully verified")],
|
|
3437
|
+
"verification": {"custom_button_deleted": verified, "delete_readback": delete_readback},
|
|
3438
|
+
"verified": verified,
|
|
3395
3439
|
"app_key": app_key,
|
|
3396
3440
|
"button_id": button_id,
|
|
3397
3441
|
"edit_version_no": edit_version_no,
|
|
3398
|
-
"deleted":
|
|
3442
|
+
"deleted": verified,
|
|
3443
|
+
"delete_executed": True,
|
|
3444
|
+
"readback_status": delete_readback.get("readback_status"),
|
|
3445
|
+
"safe_to_retry_delete": False,
|
|
3399
3446
|
},
|
|
3400
3447
|
)
|
|
3401
3448
|
)
|
|
@@ -3663,6 +3710,7 @@ class AiBuilderFacade:
|
|
|
3663
3710
|
details=_with_state_read_blocked_details({"app_key": app_key}, resource="associated_resources", error=api_error),
|
|
3664
3711
|
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
3665
3712
|
))
|
|
3713
|
+
app_name = self._read_app_name_for_builder_output(profile=profile, app_key=app_key)
|
|
3666
3714
|
|
|
3667
3715
|
existing_by_id = _associated_resource_index(existing_resources)
|
|
3668
3716
|
blocking_issues: list[dict[str, Any]] = []
|
|
@@ -3671,7 +3719,6 @@ class AiBuilderFacade:
|
|
|
3671
3719
|
client_key_to_patch: dict[str, AssociatedResourceUpsertPatch] = {}
|
|
3672
3720
|
client_key_to_id: dict[str, int] = {}
|
|
3673
3721
|
used_client_keys: set[str] = set()
|
|
3674
|
-
force_update_resource_ids: set[int] = set()
|
|
3675
3722
|
resolved_associated_item_refs: dict[str, list[int]] = {}
|
|
3676
3723
|
|
|
3677
3724
|
upsert_resources = list(request.upsert_resources)
|
|
@@ -3691,11 +3738,6 @@ class AiBuilderFacade:
|
|
|
3691
3738
|
)
|
|
3692
3739
|
)
|
|
3693
3740
|
upsert_resources.extend(expanded_resources)
|
|
3694
|
-
force_update_resource_ids.update(
|
|
3695
|
-
item_id
|
|
3696
|
-
for item_id in (_coerce_positive_int(patch.associated_item_id) for patch in expanded_resources)
|
|
3697
|
-
if item_id is not None
|
|
3698
|
-
)
|
|
3699
3741
|
normalized_args["upsert_resources"] = [patch.model_dump(mode="json") for patch in upsert_resources]
|
|
3700
3742
|
normalized_args["patch_results"] = patch_results
|
|
3701
3743
|
|
|
@@ -3733,14 +3775,9 @@ class AiBuilderFacade:
|
|
|
3733
3775
|
touched_ids.add(associated_item_id)
|
|
3734
3776
|
if client_key:
|
|
3735
3777
|
client_key_to_id[client_key] = associated_item_id
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
or _associated_resource_patch_has_match_config(patch)
|
|
3740
|
-
else "unchanged"
|
|
3741
|
-
if _associated_resource_matches_patch(existing_by_id[associated_item_id], patch)
|
|
3742
|
-
else "update"
|
|
3743
|
-
)
|
|
3778
|
+
has_match_config = _associated_resource_patch_has_match_config(patch)
|
|
3779
|
+
identity_matches = _associated_resource_matches_patch(existing_by_id[associated_item_id], patch)
|
|
3780
|
+
operation = "update" if has_match_config or not identity_matches else "unchanged"
|
|
3744
3781
|
upsert_ops.append({"operation": operation, "associated_item_id": associated_item_id, "patch": patch, "index": index})
|
|
3745
3782
|
continue
|
|
3746
3783
|
matches = [
|
|
@@ -3997,7 +4034,16 @@ class AiBuilderFacade:
|
|
|
3997
4034
|
try:
|
|
3998
4035
|
write_executed = True
|
|
3999
4036
|
self._associated_resource_delete(profile=profile, app_key=app_key, associated_item_id=item_id)
|
|
4000
|
-
removed.append(
|
|
4037
|
+
removed.append(
|
|
4038
|
+
{
|
|
4039
|
+
"associated_item_id": item_id,
|
|
4040
|
+
"operation": "remove",
|
|
4041
|
+
"status": "readback_pending",
|
|
4042
|
+
"delete_executed": True,
|
|
4043
|
+
"readback_status": "unavailable",
|
|
4044
|
+
"safe_to_retry_delete": False,
|
|
4045
|
+
}
|
|
4046
|
+
)
|
|
4001
4047
|
except (QingflowApiError, RuntimeError) as error:
|
|
4002
4048
|
api_error = _coerce_api_error(error)
|
|
4003
4049
|
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)})
|
|
@@ -4011,13 +4057,39 @@ class AiBuilderFacade:
|
|
|
4011
4057
|
api_error = _coerce_api_error(error)
|
|
4012
4058
|
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)})
|
|
4013
4059
|
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4060
|
+
resources_after: list[dict[str, Any]] = []
|
|
4061
|
+
resources_after_loaded = False
|
|
4062
|
+
resources_after_readback_failed = False
|
|
4063
|
+
if request.view_configs:
|
|
4064
|
+
try:
|
|
4065
|
+
resources_after = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
|
|
4066
|
+
resources_after_loaded = True
|
|
4067
|
+
except (QingflowApiError, RuntimeError):
|
|
4068
|
+
resources_after = []
|
|
4069
|
+
resources_after_readback_failed = True
|
|
4018
4070
|
|
|
4019
4071
|
for index, view_config in enumerate(request.view_configs):
|
|
4020
4072
|
resolved_view_config_ids = resolved_associated_item_refs.get(f"view_configs[{index}].associated_item_ids", [])
|
|
4073
|
+
view_name = str(view_config.view_key or "").strip()
|
|
4074
|
+
missing_ref_ids = [
|
|
4075
|
+
str(ref or "").strip()
|
|
4076
|
+
for ref in view_config.associated_item_refs
|
|
4077
|
+
if str(ref or "").strip() and str(ref or "").strip() not in client_key_to_id
|
|
4078
|
+
]
|
|
4079
|
+
if missing_ref_ids:
|
|
4080
|
+
failed.append(
|
|
4081
|
+
{
|
|
4082
|
+
"operation": "view_config",
|
|
4083
|
+
"index": index,
|
|
4084
|
+
"view_key": view_config.view_key,
|
|
4085
|
+
"view_name": view_name,
|
|
4086
|
+
"status": "failed",
|
|
4087
|
+
"error_code": "ASSOCIATED_RESOURCE_REF_UNRESOLVED",
|
|
4088
|
+
"missing_refs": missing_ref_ids,
|
|
4089
|
+
"message": "view_config references associated resources that were not successfully created or resolved; skipped view config write to avoid clearing existing associations",
|
|
4090
|
+
}
|
|
4091
|
+
)
|
|
4092
|
+
continue
|
|
4021
4093
|
selected_ids = [
|
|
4022
4094
|
item_id
|
|
4023
4095
|
for item_id in (
|
|
@@ -4034,19 +4106,21 @@ class AiBuilderFacade:
|
|
|
4034
4106
|
available_resources=resources_after,
|
|
4035
4107
|
)
|
|
4036
4108
|
if issues:
|
|
4037
|
-
failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "status": "failed", "issues": issues})
|
|
4109
|
+
failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "failed", "issues": issues})
|
|
4038
4110
|
continue
|
|
4039
4111
|
try:
|
|
4040
4112
|
write_executed = True
|
|
4041
4113
|
self._update_view_associated_resources_config(profile=profile, view_key=view_config.view_key, associated_resources_payload=view_payload)
|
|
4042
4114
|
try:
|
|
4043
4115
|
config = self.views.view_get_config(profile=profile, viewgraph_key=view_config.view_key).get("result") or {}
|
|
4116
|
+
view_name = _extract_view_name(config if isinstance(config, dict) else {}) or view_name
|
|
4044
4117
|
actual_config = _extract_view_associated_resources_config(config if isinstance(config, dict) else {}, available_resources=resources_after)
|
|
4045
4118
|
verified_config = expected_config is not None and _associated_resources_config_matches(expected_config, actual_config)
|
|
4046
4119
|
if not verified_config and expected_config is not None:
|
|
4047
4120
|
self._update_view_associated_resources_config(profile=profile, view_key=view_config.view_key, associated_resources_payload=view_payload)
|
|
4048
4121
|
refreshed_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
|
|
4049
4122
|
refreshed_config = self.views.view_get_config(profile=profile, viewgraph_key=view_config.view_key).get("result") or {}
|
|
4123
|
+
view_name = _extract_view_name(refreshed_config if isinstance(refreshed_config, dict) else {}) or view_name
|
|
4050
4124
|
actual_config = _extract_view_associated_resources_config(
|
|
4051
4125
|
refreshed_config if isinstance(refreshed_config, dict) else {},
|
|
4052
4126
|
available_resources=refreshed_resources,
|
|
@@ -4055,26 +4129,35 @@ class AiBuilderFacade:
|
|
|
4055
4129
|
except (QingflowApiError, RuntimeError):
|
|
4056
4130
|
actual_config = {}
|
|
4057
4131
|
verified_config = False
|
|
4058
|
-
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})
|
|
4132
|
+
view_config_results.append({"index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "success" if verified_config else "partial_success", "associated_resources_verified": verified_config, "expected": expected_config, "actual": actual_config})
|
|
4059
4133
|
except (QingflowApiError, RuntimeError) as error:
|
|
4060
4134
|
api_error = _coerce_api_error(error)
|
|
4061
|
-
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)})
|
|
4135
|
+
failed.append({"operation": "view_config", "index": index, "view_key": view_config.view_key, "view_name": view_name, "status": "failed", "error_code": "VIEW_ASSOCIATED_RESOURCES_WRITE_FAILED", "message": api_error.message, "transport_error": _transport_error_payload(api_error)})
|
|
4062
4136
|
|
|
4063
|
-
final_resources: list[dict[str, Any]] = []
|
|
4064
|
-
readback_failed =
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4137
|
+
final_resources: list[dict[str, Any]] = resources_after if resources_after_loaded else []
|
|
4138
|
+
readback_failed = resources_after_readback_failed
|
|
4139
|
+
if not resources_after_loaded and not resources_after_readback_failed:
|
|
4140
|
+
try:
|
|
4141
|
+
final_resources = self._load_associated_resources_for_builder(profile=profile, app_key=app_key)
|
|
4142
|
+
except (QingflowApiError, RuntimeError):
|
|
4143
|
+
readback_failed = True
|
|
4069
4144
|
final_by_id = _associated_resource_index(final_resources)
|
|
4145
|
+
if removed:
|
|
4146
|
+
removed = self._verify_associated_resources_deleted_by_pool(
|
|
4147
|
+
deleted_items=removed,
|
|
4148
|
+
resources=final_resources,
|
|
4149
|
+
readback_failed=readback_failed,
|
|
4150
|
+
)
|
|
4070
4151
|
created_ids = [int(item["associated_item_id"]) for item in created if _coerce_positive_int(item.get("associated_item_id")) is not None]
|
|
4071
4152
|
updated_ids = [int(item["associated_item_id"]) for item in updated if _coerce_positive_int(item.get("associated_item_id")) is not None]
|
|
4072
4153
|
unchanged_ids = [int(item["associated_item_id"]) for item in unchanged if _coerce_positive_int(item.get("associated_item_id")) is not None]
|
|
4073
4154
|
removed_ids = [int(item["associated_item_id"]) for item in removed if _coerce_positive_int(item.get("associated_item_id")) is not None]
|
|
4155
|
+
removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed)
|
|
4156
|
+
remove_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed)
|
|
4074
4157
|
pool_verified = (
|
|
4075
4158
|
not readback_failed
|
|
4076
4159
|
and all(item_id in final_by_id for item_id in created_ids + updated_ids + unchanged_ids)
|
|
4077
|
-
and
|
|
4160
|
+
and removed_verified
|
|
4078
4161
|
and not failed
|
|
4079
4162
|
and all(_coerce_positive_int(item.get("associated_item_id")) is not None for item in created)
|
|
4080
4163
|
)
|
|
@@ -4109,6 +4192,13 @@ class AiBuilderFacade:
|
|
|
4109
4192
|
else "associated resource writes all failed or produced no confirmed result; application was not published",
|
|
4110
4193
|
)
|
|
4111
4194
|
)
|
|
4195
|
+
if remove_readback_pending:
|
|
4196
|
+
warnings.append(
|
|
4197
|
+
_warning(
|
|
4198
|
+
"ASSOCIATED_RESOURCE_DELETE_READBACK_PENDING",
|
|
4199
|
+
"associated resource delete was sent, but deletion readback is not fully verified; do not blindly repeat delete",
|
|
4200
|
+
)
|
|
4201
|
+
)
|
|
4112
4202
|
response = {
|
|
4113
4203
|
"status": status,
|
|
4114
4204
|
"error_code": error_code,
|
|
@@ -4131,9 +4221,17 @@ class AiBuilderFacade:
|
|
|
4131
4221
|
"suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
4132
4222
|
"noop": False,
|
|
4133
4223
|
"warnings": warnings,
|
|
4134
|
-
"verification": {
|
|
4224
|
+
"verification": {
|
|
4225
|
+
"associated_resources_verified": pool_verified,
|
|
4226
|
+
"associated_resource_view_configs_verified": view_configs_verified,
|
|
4227
|
+
"readback_loaded": not readback_failed,
|
|
4228
|
+
"removed_verified": removed_verified,
|
|
4229
|
+
"remove_readback_pending": remove_readback_pending,
|
|
4230
|
+
"removed_readback_results": deepcopy(removed),
|
|
4231
|
+
},
|
|
4135
4232
|
"verified": verified,
|
|
4136
4233
|
"app_key": app_key,
|
|
4234
|
+
"app_name": app_name,
|
|
4137
4235
|
"mode": "apply",
|
|
4138
4236
|
"created": created,
|
|
4139
4237
|
"updated": updated,
|
|
@@ -4612,10 +4710,21 @@ class AiBuilderFacade:
|
|
|
4612
4710
|
"associated_resources": len(associated_resources),
|
|
4613
4711
|
"custom_buttons": len(custom_buttons),
|
|
4614
4712
|
}
|
|
4713
|
+
app_name = str(
|
|
4714
|
+
base_result.get("formTitle")
|
|
4715
|
+
or base_result.get("title")
|
|
4716
|
+
or base_result.get("appName")
|
|
4717
|
+
or base_result.get("name")
|
|
4718
|
+
or app_key
|
|
4719
|
+
).strip() or app_key
|
|
4720
|
+
app_icon = str(base_result.get("appIcon") or "").strip() or None
|
|
4615
4721
|
response = AppReadSummaryResponse(
|
|
4616
4722
|
app_key=app_key,
|
|
4617
|
-
|
|
4618
|
-
|
|
4723
|
+
app_name=app_name,
|
|
4724
|
+
name=app_name,
|
|
4725
|
+
title=app_name,
|
|
4726
|
+
app_icon=app_icon,
|
|
4727
|
+
icon_config=workspace_icon_config(app_icon),
|
|
4619
4728
|
visibility=_public_visibility_from_member_auth(base_result.get("auth")),
|
|
4620
4729
|
tag_ids=_coerce_int_list(base_result.get("tagIds")),
|
|
4621
4730
|
publish_status=base_result.get("appPublishStatus"),
|
|
@@ -4999,10 +5108,32 @@ class AiBuilderFacade:
|
|
|
4999
5108
|
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
5000
5109
|
)
|
|
5001
5110
|
parsed = state["parsed"]
|
|
5111
|
+
warnings: list[dict[str, Any]] = []
|
|
5112
|
+
field_lookup = _build_public_field_lookup(cast(list[dict[str, Any]], parsed["fields"]))
|
|
5113
|
+
chart_fields: list[dict[str, Any]] = []
|
|
5114
|
+
try:
|
|
5115
|
+
qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
|
|
5116
|
+
chart_fields = _compact_public_chart_fields_read(
|
|
5117
|
+
app_key=app_key,
|
|
5118
|
+
qingbi_fields=[item for item in qingbi_fields if isinstance(item, dict)],
|
|
5119
|
+
field_lookup=field_lookup,
|
|
5120
|
+
)
|
|
5121
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
5122
|
+
api_error = _coerce_api_error(error)
|
|
5123
|
+
warnings.append(
|
|
5124
|
+
_warning(
|
|
5125
|
+
"QINGBI_FIELDS_READ_FAILED",
|
|
5126
|
+
"form fields were read, but QingBI chart fields could not be read; chart configuration should use chart_fields when available",
|
|
5127
|
+
backend_code=api_error.backend_code,
|
|
5128
|
+
request_id=api_error.request_id,
|
|
5129
|
+
)
|
|
5130
|
+
)
|
|
5002
5131
|
response = AppFieldsReadResponse(
|
|
5003
5132
|
app_key=app_key,
|
|
5004
5133
|
fields=[_compact_public_field_read(field=field, layout=parsed["layout"]) for field in parsed["fields"]],
|
|
5005
5134
|
field_count=len(parsed["fields"]),
|
|
5135
|
+
chart_fields=chart_fields,
|
|
5136
|
+
chart_field_count=len(chart_fields),
|
|
5006
5137
|
form_settings=_form_settings_from_schema(state["schema"], parsed["fields"]),
|
|
5007
5138
|
)
|
|
5008
5139
|
return {
|
|
@@ -5017,7 +5148,7 @@ class AiBuilderFacade:
|
|
|
5017
5148
|
"request_id": None,
|
|
5018
5149
|
"suggested_next_call": None,
|
|
5019
5150
|
"noop": False,
|
|
5020
|
-
"warnings":
|
|
5151
|
+
"warnings": warnings,
|
|
5021
5152
|
"verification": {"app_exists": True},
|
|
5022
5153
|
"verified": True,
|
|
5023
5154
|
**response.model_dump(mode="json"),
|
|
@@ -5441,7 +5572,7 @@ class AiBuilderFacade:
|
|
|
5441
5572
|
for index, patch, config in semantic_patches:
|
|
5442
5573
|
config_payload = config.model_dump(mode="json", exclude_none=True)
|
|
5443
5574
|
reason_base = f"upsert_buttons[{index}].trigger_add_data_config"
|
|
5444
|
-
if config.
|
|
5575
|
+
if "que_relation" in config.model_fields_set:
|
|
5445
5576
|
issues.append(
|
|
5446
5577
|
{
|
|
5447
5578
|
"error_code": "MIXED_CUSTOM_BUTTON_MAPPING_MODES",
|
|
@@ -5745,11 +5876,12 @@ class AiBuilderFacade:
|
|
|
5745
5876
|
)
|
|
5746
5877
|
return {"verified": False, "write_executed": False, "write_succeeded": False, "view_configs": results, "failed": failed, "warnings": warnings}
|
|
5747
5878
|
|
|
5748
|
-
|
|
5749
|
-
_extract_view_key(view)
|
|
5879
|
+
existing_views_by_key = {
|
|
5880
|
+
_extract_view_key(view): view
|
|
5750
5881
|
for view in (existing_views if isinstance(existing_views, list) else [])
|
|
5751
5882
|
if isinstance(view, dict) and _extract_view_key(view)
|
|
5752
5883
|
}
|
|
5884
|
+
view_keys = set(existing_views_by_key)
|
|
5753
5885
|
button_inventory: dict[int, dict[str, Any]] = {}
|
|
5754
5886
|
for item in [*existing_buttons, *readback_buttons]:
|
|
5755
5887
|
if not isinstance(item, dict):
|
|
@@ -5781,6 +5913,7 @@ class AiBuilderFacade:
|
|
|
5781
5913
|
try:
|
|
5782
5914
|
current_response = self.views.view_get_config(profile=profile, viewgraph_key=view_key)
|
|
5783
5915
|
current_config = current_response.get("result") if isinstance(current_response.get("result"), dict) else {}
|
|
5916
|
+
view_name = _extract_view_name(current_config) or _extract_view_name(existing_views_by_key.get(view_key) or {}) or view_key
|
|
5784
5917
|
except (QingflowApiError, RuntimeError) as error:
|
|
5785
5918
|
api_error = _coerce_api_error(error)
|
|
5786
5919
|
issue = {
|
|
@@ -5789,6 +5922,7 @@ class AiBuilderFacade:
|
|
|
5789
5922
|
"status": "failed",
|
|
5790
5923
|
"error_code": "VIEW_CONFIG_READ_FAILED",
|
|
5791
5924
|
"view_key": view_key,
|
|
5925
|
+
"view_name": _extract_view_name(existing_views_by_key.get(view_key) or {}) or view_key,
|
|
5792
5926
|
"message": api_error.message,
|
|
5793
5927
|
"transport_error": _transport_error_payload(api_error),
|
|
5794
5928
|
}
|
|
@@ -5879,6 +6013,7 @@ class AiBuilderFacade:
|
|
|
5879
6013
|
"status": "failed",
|
|
5880
6014
|
"error_code": "INSIDE_BUTTON_BACKEND_UNSUPPORTED",
|
|
5881
6015
|
"view_key": view_key,
|
|
6016
|
+
"view_name": view_name,
|
|
5882
6017
|
"message": "backend rejected inside/list-button placement; header/detail placements were retried without inside buttons",
|
|
5883
6018
|
"backend_message": api_error.message,
|
|
5884
6019
|
"transport_error": _transport_error_payload(api_error),
|
|
@@ -5891,9 +6026,10 @@ class AiBuilderFacade:
|
|
|
5891
6026
|
"index": config_index,
|
|
5892
6027
|
"operation": "view_config",
|
|
5893
6028
|
"status": "failed",
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
6029
|
+
"error_code": "VIEW_BUTTON_CONFIG_WRITE_FAILED",
|
|
6030
|
+
"view_key": view_key,
|
|
6031
|
+
"view_name": view_name,
|
|
6032
|
+
"message": fallback_api_error.message,
|
|
5897
6033
|
"transport_error": _transport_error_payload(fallback_api_error),
|
|
5898
6034
|
"initial_error": _transport_error_payload(api_error),
|
|
5899
6035
|
}
|
|
@@ -5908,6 +6044,7 @@ class AiBuilderFacade:
|
|
|
5908
6044
|
"status": "failed",
|
|
5909
6045
|
"error_code": "INSIDE_BUTTON_BACKEND_UNSUPPORTED",
|
|
5910
6046
|
"view_key": view_key,
|
|
6047
|
+
"view_name": view_name,
|
|
5911
6048
|
"message": "backend rejected inside/list-button placement",
|
|
5912
6049
|
"backend_message": api_error.message,
|
|
5913
6050
|
"transport_error": _transport_error_payload(api_error),
|
|
@@ -5924,6 +6061,7 @@ class AiBuilderFacade:
|
|
|
5924
6061
|
"status": "failed",
|
|
5925
6062
|
"error_code": "VIEW_BUTTON_CONFIG_WRITE_FAILED",
|
|
5926
6063
|
"view_key": view_key,
|
|
6064
|
+
"view_name": view_name,
|
|
5927
6065
|
"message": api_error.message,
|
|
5928
6066
|
"transport_error": _transport_error_payload(api_error),
|
|
5929
6067
|
}
|
|
@@ -5953,6 +6091,7 @@ class AiBuilderFacade:
|
|
|
5953
6091
|
"operation": "view_config",
|
|
5954
6092
|
"status": "success" if verified else ("partial_success" if unsupported_list_issue is not None else "unverified"),
|
|
5955
6093
|
"view_key": view_key,
|
|
6094
|
+
"view_name": view_name,
|
|
5956
6095
|
"mode": "replace" if replace_existing else "merge",
|
|
5957
6096
|
"buttons_configured": len(new_dtos),
|
|
5958
6097
|
"view_buttons_verified": verified,
|
|
@@ -5975,6 +6114,7 @@ class AiBuilderFacade:
|
|
|
5975
6114
|
"operation": "view_config",
|
|
5976
6115
|
"status": "unverified",
|
|
5977
6116
|
"view_key": view_key,
|
|
6117
|
+
"view_name": view_name,
|
|
5978
6118
|
"mode": "replace" if replace_existing else "merge",
|
|
5979
6119
|
"buttons_configured": len(new_dtos),
|
|
5980
6120
|
"view_buttons_verified": None,
|
|
@@ -6001,6 +6141,7 @@ class AiBuilderFacade:
|
|
|
6001
6141
|
details={"dash_key": dash_key, "being_draft": being_draft},
|
|
6002
6142
|
suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
|
|
6003
6143
|
)
|
|
6144
|
+
dash_icon = str(result.get("dashIcon") or "").strip() or None
|
|
6004
6145
|
response = PortalGetResponse(
|
|
6005
6146
|
dash_key=dash_key,
|
|
6006
6147
|
being_draft=being_draft,
|
|
@@ -6014,7 +6155,8 @@ class AiBuilderFacade:
|
|
|
6014
6155
|
)
|
|
6015
6156
|
if tag_id is not None
|
|
6016
6157
|
],
|
|
6017
|
-
dash_icon=
|
|
6158
|
+
dash_icon=dash_icon,
|
|
6159
|
+
icon_config=workspace_icon_config(dash_icon),
|
|
6018
6160
|
hide_copyright=bool(result.get("hideCopyright")) if "hideCopyright" in result else None,
|
|
6019
6161
|
visibility=_public_visibility_from_member_auth(result.get("auth")),
|
|
6020
6162
|
auth=deepcopy(result.get("auth")) if isinstance(result.get("auth"), dict) else {},
|
|
@@ -6053,6 +6195,7 @@ class AiBuilderFacade:
|
|
|
6053
6195
|
details={"dash_key": dash_key, "being_draft": being_draft},
|
|
6054
6196
|
suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
|
|
6055
6197
|
)
|
|
6198
|
+
dash_icon = str(result.get("dashIcon") or "").strip() or None
|
|
6056
6199
|
response = PortalReadSummaryResponse(
|
|
6057
6200
|
dash_key=dash_key,
|
|
6058
6201
|
being_draft=being_draft,
|
|
@@ -6066,7 +6209,8 @@ class AiBuilderFacade:
|
|
|
6066
6209
|
)
|
|
6067
6210
|
if tag_id is not None
|
|
6068
6211
|
],
|
|
6069
|
-
dash_icon=
|
|
6212
|
+
dash_icon=dash_icon,
|
|
6213
|
+
icon_config=workspace_icon_config(dash_icon),
|
|
6070
6214
|
hide_copyright=bool(result.get("hideCopyright")) if "hideCopyright" in result else None,
|
|
6071
6215
|
config_keys=sorted(str(key) for key in (result.get("config") or {}).keys()) if isinstance(result.get("config"), dict) else [],
|
|
6072
6216
|
dash_global_config_keys=sorted(str(key) for key in (result.get("dashGlobalConfig") or {}).keys()) if isinstance(result.get("dashGlobalConfig"), dict) else [],
|
|
@@ -7055,6 +7199,7 @@ class AiBuilderFacade:
|
|
|
7055
7199
|
},
|
|
7056
7200
|
"app_key": target.app_key,
|
|
7057
7201
|
"app_icon": str(resolved.get("app_icon") or visual_result.get("app_icon") or "").strip() or None,
|
|
7202
|
+
"app_name": str(visual_result.get("app_name_after") or target.app_name),
|
|
7058
7203
|
"app_name_before": str(visual_result.get("app_name_before") or target.app_name),
|
|
7059
7204
|
"app_name_after": str(visual_result.get("app_name_after") or target.app_name),
|
|
7060
7205
|
"app_base_updated": bool(visual_result.get("updated")),
|
|
@@ -7295,7 +7440,8 @@ class AiBuilderFacade:
|
|
|
7295
7440
|
actual_app_name = str(base_info.get("formTitle") or effective_app_name).strip() or effective_app_name
|
|
7296
7441
|
actual_app_icon = str(base_info.get("appIcon") or visual_result.get("app_icon") or "").strip() or None
|
|
7297
7442
|
expected_app_icon = str(visual_result.get("app_icon") or "").strip() or None
|
|
7298
|
-
|
|
7443
|
+
app_icon_verified = True if expected_app_icon is None else actual_app_icon == expected_app_icon
|
|
7444
|
+
app_base_verified = actual_app_name == effective_app_name and app_icon_verified
|
|
7299
7445
|
verified = app_base_verified and relation_target_metadata_verified
|
|
7300
7446
|
response = {
|
|
7301
7447
|
"status": "success" if verified else "partial_success",
|
|
@@ -7319,6 +7465,7 @@ class AiBuilderFacade:
|
|
|
7319
7465
|
},
|
|
7320
7466
|
"app_key": target.app_key,
|
|
7321
7467
|
"app_icon": str(visual_result.get("app_icon") or "").strip() or None,
|
|
7468
|
+
"app_name": effective_app_name,
|
|
7322
7469
|
"app_name_before": str(visual_result.get("app_name_before") or target.app_name),
|
|
7323
7470
|
"app_name_after": effective_app_name,
|
|
7324
7471
|
"app_base_updated": bool(visual_result.get("updated")),
|
|
@@ -7516,6 +7663,7 @@ class AiBuilderFacade:
|
|
|
7516
7663
|
},
|
|
7517
7664
|
"app_key": target.app_key,
|
|
7518
7665
|
"app_icon": str(visual_result.get("app_icon") or resolved.get("app_icon") or "").strip() or None,
|
|
7666
|
+
"app_name": effective_app_name,
|
|
7519
7667
|
"app_name_before": str(visual_result.get("app_name_before") or target.app_name),
|
|
7520
7668
|
"app_name_after": effective_app_name,
|
|
7521
7669
|
"app_base_updated": bool(visual_result.get("updated")),
|
|
@@ -7525,6 +7673,13 @@ class AiBuilderFacade:
|
|
|
7525
7673
|
"updated": updated,
|
|
7526
7674
|
"removed": removed,
|
|
7527
7675
|
},
|
|
7676
|
+
"field_diff_details": _schema_field_diff_details(
|
|
7677
|
+
added=added,
|
|
7678
|
+
updated=updated,
|
|
7679
|
+
removed=removed,
|
|
7680
|
+
before_fields=original_fields,
|
|
7681
|
+
after_fields=current_fields,
|
|
7682
|
+
),
|
|
7528
7683
|
"verified": False,
|
|
7529
7684
|
"tag_ids_after": [],
|
|
7530
7685
|
"package_attached": None,
|
|
@@ -7544,6 +7699,13 @@ class AiBuilderFacade:
|
|
|
7544
7699
|
try:
|
|
7545
7700
|
verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
|
|
7546
7701
|
verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
|
|
7702
|
+
response["field_diff_details"] = _schema_field_diff_details(
|
|
7703
|
+
added=added,
|
|
7704
|
+
updated=updated,
|
|
7705
|
+
removed=removed,
|
|
7706
|
+
before_fields=original_fields,
|
|
7707
|
+
after_fields=cast(list[dict[str, Any]], verified["schema"]["fields"]),
|
|
7708
|
+
)
|
|
7547
7709
|
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)
|
|
7548
7710
|
data_display_verification = _verify_data_display_readback(
|
|
7549
7711
|
form_settings=verified.get("form_settings"),
|
|
@@ -7575,7 +7737,8 @@ class AiBuilderFacade:
|
|
|
7575
7737
|
actual_app_name = str(base_info.get("formTitle") or effective_app_name).strip() or effective_app_name
|
|
7576
7738
|
actual_app_icon = str(base_info.get("appIcon") or visual_result.get("app_icon") or "").strip() or None
|
|
7577
7739
|
expected_app_icon = str(visual_result.get("app_icon") or "").strip() or None
|
|
7578
|
-
|
|
7740
|
+
app_icon_verified = True if expected_app_icon is None else actual_app_icon == expected_app_icon
|
|
7741
|
+
app_base_verified = actual_app_name == effective_app_name and app_icon_verified
|
|
7579
7742
|
except (QingflowApiError, RuntimeError) as error:
|
|
7580
7743
|
base_error = _coerce_api_error(error)
|
|
7581
7744
|
if verification_error is None:
|
|
@@ -7669,6 +7832,7 @@ class AiBuilderFacade:
|
|
|
7669
7832
|
details=_with_state_read_blocked_details({"app_key": app_key}, resource="schema", error=api_error),
|
|
7670
7833
|
suggested_next_call={"tool_name": "app_get_layout", "arguments": {"profile": profile, "app_key": app_key}},
|
|
7671
7834
|
))
|
|
7835
|
+
app_name = str(schema_result.get("formTitle") or schema_result.get("title") or schema_result.get("appName") or app_key).strip() or app_key
|
|
7672
7836
|
parsed = _parse_schema(schema_result)
|
|
7673
7837
|
current_fields = parsed["fields"]
|
|
7674
7838
|
requested_sections, missing_selectors = _resolve_layout_sections_to_names(requested_sections, current_fields)
|
|
@@ -7751,6 +7915,7 @@ class AiBuilderFacade:
|
|
|
7751
7915
|
"warnings": [],
|
|
7752
7916
|
"verification": {"layout_verified": True, "layout_summary_verified": True},
|
|
7753
7917
|
"app_key": app_key,
|
|
7918
|
+
"app_name": app_name,
|
|
7754
7919
|
"layout_diff": {
|
|
7755
7920
|
"mode": mode.value,
|
|
7756
7921
|
"replaced": mode == LayoutApplyMode.replace,
|
|
@@ -7839,6 +8004,7 @@ class AiBuilderFacade:
|
|
|
7839
8004
|
"warnings": [],
|
|
7840
8005
|
"verification": {"layout_verified": False, "layout_summary_verified": False, "layout_read_unavailable": True},
|
|
7841
8006
|
"app_key": app_key,
|
|
8007
|
+
"app_name": app_name,
|
|
7842
8008
|
"layout_diff": {
|
|
7843
8009
|
"mode": mode.value,
|
|
7844
8010
|
"replaced": mode == LayoutApplyMode.replace,
|
|
@@ -7895,6 +8061,7 @@ class AiBuilderFacade:
|
|
|
7895
8061
|
"warnings": warnings,
|
|
7896
8062
|
"verification": {"layout_verified": layout_verified, "layout_summary_verified": layout_summary_verified},
|
|
7897
8063
|
"app_key": app_key,
|
|
8064
|
+
"app_name": app_name,
|
|
7898
8065
|
"layout_diff": {
|
|
7899
8066
|
"mode": mode.value,
|
|
7900
8067
|
"replaced": mode == LayoutApplyMode.replace,
|
|
@@ -7957,6 +8124,7 @@ class AiBuilderFacade:
|
|
|
7957
8124
|
details=_with_state_read_blocked_details({"app_key": app_key}, resource="workflow", error=api_error),
|
|
7958
8125
|
suggested_next_call={"tool_name": "app_get_flow", "arguments": {"profile": profile, "app_key": app_key}},
|
|
7959
8126
|
))
|
|
8127
|
+
app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or app_key).strip() or app_key
|
|
7960
8128
|
entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
|
|
7961
8129
|
current_fields = _parse_schema(schema)["fields"]
|
|
7962
8130
|
normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
|
|
@@ -8124,6 +8292,7 @@ class AiBuilderFacade:
|
|
|
8124
8292
|
"workflow_read_unavailable": verified_nodes_unavailable,
|
|
8125
8293
|
},
|
|
8126
8294
|
"app_key": app_key,
|
|
8295
|
+
"app_name": app_name,
|
|
8127
8296
|
"flow_diff": {"mode": "replace", "node_count": desired_node_count},
|
|
8128
8297
|
"verified": workflow_verified,
|
|
8129
8298
|
}
|
|
@@ -8199,6 +8368,7 @@ class AiBuilderFacade:
|
|
|
8199
8368
|
details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
|
|
8200
8369
|
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
8201
8370
|
))
|
|
8371
|
+
app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or "").strip() or None
|
|
8202
8372
|
existing_views = existing_views or []
|
|
8203
8373
|
existing_by_key: dict[str, dict[str, Any]] = {}
|
|
8204
8374
|
existing_by_name: dict[str, list[dict[str, Any]]] = {}
|
|
@@ -8306,9 +8476,15 @@ class AiBuilderFacade:
|
|
|
8306
8476
|
)
|
|
8307
8477
|
)
|
|
8308
8478
|
removed: list[str] = []
|
|
8479
|
+
removed_keys: set[str] = set()
|
|
8309
8480
|
view_results: list[dict[str, Any]] = []
|
|
8310
|
-
|
|
8311
|
-
|
|
8481
|
+
failed_views: list[dict[str, Any]] = []
|
|
8482
|
+
for selector in remove_views:
|
|
8483
|
+
selector_text = str(selector or "").strip()
|
|
8484
|
+
if not selector_text:
|
|
8485
|
+
continue
|
|
8486
|
+
key_match = existing_by_key.get(selector_text)
|
|
8487
|
+
matches = [key_match] if isinstance(key_match, dict) else existing_by_name.get(selector_text, [])
|
|
8312
8488
|
if len(matches) > 1:
|
|
8313
8489
|
return _failed(
|
|
8314
8490
|
"AMBIGUOUS_VIEW",
|
|
@@ -8316,7 +8492,7 @@ class AiBuilderFacade:
|
|
|
8316
8492
|
normalized_args=normalized_args,
|
|
8317
8493
|
details={
|
|
8318
8494
|
"app_key": app_key,
|
|
8319
|
-
"view_name":
|
|
8495
|
+
"view_name": selector_text,
|
|
8320
8496
|
"matches": [
|
|
8321
8497
|
{"name": _extract_view_name(view), "view_key": _extract_view_key(view), "type": _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))}
|
|
8322
8498
|
for view in matches
|
|
@@ -8324,21 +8500,79 @@ class AiBuilderFacade:
|
|
|
8324
8500
|
},
|
|
8325
8501
|
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
8326
8502
|
)
|
|
8503
|
+
if not matches:
|
|
8504
|
+
failed_view = {
|
|
8505
|
+
"name": selector_text,
|
|
8506
|
+
"view_key": selector_text,
|
|
8507
|
+
"type": None,
|
|
8508
|
+
"status": "failed",
|
|
8509
|
+
"error_code": "VIEW_NOT_FOUND",
|
|
8510
|
+
"message": "remove_views item did not match an existing view name or view_key",
|
|
8511
|
+
}
|
|
8512
|
+
failed_views.append(failed_view)
|
|
8513
|
+
view_results.append(deepcopy(failed_view))
|
|
8514
|
+
continue
|
|
8327
8515
|
if len(matches) == 1:
|
|
8328
8516
|
key = _extract_view_key(matches[0])
|
|
8329
|
-
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8517
|
+
removed_name = _extract_view_name(matches[0]) or selector_text
|
|
8518
|
+
try:
|
|
8519
|
+
self.views.view_delete(profile=profile, viewgraph_key=key)
|
|
8520
|
+
delete_readback = self._verify_view_deleted_by_key(profile=profile, view_key=key)
|
|
8521
|
+
removed.append(removed_name)
|
|
8522
|
+
if key:
|
|
8523
|
+
removed_keys.add(key)
|
|
8524
|
+
existing_by_key.pop(key, None)
|
|
8525
|
+
existing_by_name.pop(removed_name, None)
|
|
8526
|
+
view_results.append(
|
|
8527
|
+
{
|
|
8528
|
+
"name": removed_name,
|
|
8529
|
+
"view_key": key,
|
|
8530
|
+
"type": None,
|
|
8531
|
+
"status": delete_readback.get("status") or "readback_pending",
|
|
8532
|
+
"operation": "delete",
|
|
8533
|
+
"delete_executed": True,
|
|
8534
|
+
"readback_status": delete_readback.get("readback_status"),
|
|
8535
|
+
"safe_to_retry_delete": False,
|
|
8536
|
+
**(
|
|
8537
|
+
{
|
|
8538
|
+
"error_code": delete_readback.get("error_code"),
|
|
8539
|
+
"message": delete_readback.get("message"),
|
|
8540
|
+
"request_id": delete_readback.get("request_id"),
|
|
8541
|
+
"backend_code": delete_readback.get("backend_code"),
|
|
8542
|
+
"http_status": delete_readback.get("http_status"),
|
|
8543
|
+
"transport_error": delete_readback.get("transport_error"),
|
|
8544
|
+
}
|
|
8545
|
+
if delete_readback.get("readback_status") != "deleted"
|
|
8546
|
+
else {}
|
|
8547
|
+
),
|
|
8548
|
+
}
|
|
8549
|
+
)
|
|
8550
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
8551
|
+
api_error = _coerce_api_error(error)
|
|
8552
|
+
failed_view = {
|
|
8553
|
+
"name": removed_name,
|
|
8554
|
+
"view_key": key,
|
|
8555
|
+
"type": None,
|
|
8556
|
+
"status": "failed",
|
|
8557
|
+
"operation": "delete",
|
|
8558
|
+
"delete_executed": False,
|
|
8559
|
+
"safe_to_retry_delete": True,
|
|
8560
|
+
"error_code": "VIEW_DELETE_FAILED",
|
|
8561
|
+
"message": _public_error_message("VIEW_APPLY_FAILED", api_error),
|
|
8562
|
+
"request_id": api_error.request_id,
|
|
8563
|
+
"backend_code": api_error.backend_code,
|
|
8564
|
+
"http_status": None if api_error.http_status == 404 else api_error.http_status,
|
|
8565
|
+
}
|
|
8566
|
+
failed_views.append(failed_view)
|
|
8567
|
+
view_results.append(deepcopy(failed_view))
|
|
8334
8568
|
created: list[str] = []
|
|
8335
8569
|
updated: list[str] = []
|
|
8336
|
-
failed_views: list[dict[str, Any]] = []
|
|
8337
8570
|
existing_view_list = [
|
|
8338
8571
|
view
|
|
8339
8572
|
for view in (existing_views if isinstance(existing_views, list) else [])
|
|
8340
8573
|
if isinstance(view, dict)
|
|
8341
|
-
and _extract_view_name(view) not in
|
|
8574
|
+
and _extract_view_name(view) not in removed
|
|
8575
|
+
and _extract_view_key(view) not in removed_keys
|
|
8342
8576
|
]
|
|
8343
8577
|
for ordinal, patch in enumerate(upsert_views, start=1):
|
|
8344
8578
|
apply_columns = _resolve_view_visible_field_names(patch)
|
|
@@ -8551,6 +8785,11 @@ class AiBuilderFacade:
|
|
|
8551
8785
|
if not existing_key and query_condition_payload_for_apply is None:
|
|
8552
8786
|
query_condition_payload_for_apply = _empty_view_query_conditions_payload()
|
|
8553
8787
|
expected_query_conditions_for_verify = _normalize_view_query_conditions_for_compare(query_condition_payload_for_apply)
|
|
8788
|
+
if not existing_key and associated_resources_payload_for_apply is None:
|
|
8789
|
+
associated_resources_payload_for_apply, _, _ = _build_view_associated_resources_payload(
|
|
8790
|
+
associated_resources={"visible": True, "limit_type": "all", "associated_item_ids": []},
|
|
8791
|
+
available_resources=associated_resources,
|
|
8792
|
+
)
|
|
8554
8793
|
try:
|
|
8555
8794
|
view_auth_override = (
|
|
8556
8795
|
self._compile_visibility_to_member_auth(profile=profile, visibility=patch.visibility)
|
|
@@ -8865,17 +9104,21 @@ class AiBuilderFacade:
|
|
|
8865
9104
|
failed_views.append(failure_entry)
|
|
8866
9105
|
view_results.append(failure_entry)
|
|
8867
9106
|
continue
|
|
8868
|
-
|
|
8869
|
-
|
|
8870
|
-
|
|
8871
|
-
|
|
8872
|
-
|
|
8873
|
-
|
|
8874
|
-
|
|
8875
|
-
|
|
8876
|
-
|
|
8877
|
-
|
|
8878
|
-
|
|
9107
|
+
needs_view_list_readback = bool(created or updated)
|
|
9108
|
+
verified_view_result: list[dict[str, Any]] | None = []
|
|
9109
|
+
verified_views_unavailable = False
|
|
9110
|
+
if needs_view_list_readback:
|
|
9111
|
+
try:
|
|
9112
|
+
verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
9113
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
9114
|
+
api_error = _coerce_api_error(error)
|
|
9115
|
+
return finalize(_failed_from_api_error(
|
|
9116
|
+
"VIEWS_READ_FAILED",
|
|
9117
|
+
api_error,
|
|
9118
|
+
normalized_args=normalized_args,
|
|
9119
|
+
details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
|
|
9120
|
+
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
9121
|
+
))
|
|
8879
9122
|
verified_names = {
|
|
8880
9123
|
_extract_view_name(item)
|
|
8881
9124
|
for item in (verified_view_result or [])
|
|
@@ -9160,9 +9403,30 @@ class AiBuilderFacade:
|
|
|
9160
9403
|
verification_by_view.append(
|
|
9161
9404
|
{
|
|
9162
9405
|
"name": name,
|
|
9406
|
+
"view_key": item.get("view_key"),
|
|
9163
9407
|
"type": item.get("type"),
|
|
9164
9408
|
"status": "removed",
|
|
9165
|
-
"present_in_readback":
|
|
9409
|
+
"present_in_readback": False,
|
|
9410
|
+
"removed_verified": True,
|
|
9411
|
+
"delete_executed": bool(item.get("delete_executed")),
|
|
9412
|
+
"readback_status": item.get("readback_status") or "deleted",
|
|
9413
|
+
"safe_to_retry_delete": False,
|
|
9414
|
+
}
|
|
9415
|
+
)
|
|
9416
|
+
elif status == "readback_pending" and item.get("operation") == "delete":
|
|
9417
|
+
readback_status = str(item.get("readback_status") or "unavailable")
|
|
9418
|
+
verification_by_view.append(
|
|
9419
|
+
{
|
|
9420
|
+
"name": name,
|
|
9421
|
+
"view_key": item.get("view_key"),
|
|
9422
|
+
"type": item.get("type"),
|
|
9423
|
+
"status": "readback_pending",
|
|
9424
|
+
"present_in_readback": True if readback_status == "still_exists" else None,
|
|
9425
|
+
"removed_verified": False,
|
|
9426
|
+
"delete_executed": bool(item.get("delete_executed")),
|
|
9427
|
+
"readback_status": readback_status,
|
|
9428
|
+
"safe_to_retry_delete": False,
|
|
9429
|
+
"error_code": item.get("error_code"),
|
|
9166
9430
|
}
|
|
9167
9431
|
)
|
|
9168
9432
|
else:
|
|
@@ -9175,15 +9439,22 @@ class AiBuilderFacade:
|
|
|
9175
9439
|
"error_code": item.get("error_code"),
|
|
9176
9440
|
}
|
|
9177
9441
|
)
|
|
9442
|
+
removed_delete_results = [
|
|
9443
|
+
item
|
|
9444
|
+
for item in view_results
|
|
9445
|
+
if item.get("operation") == "delete" and bool(item.get("delete_executed"))
|
|
9446
|
+
]
|
|
9447
|
+
removed_verified = all(str(item.get("readback_status") or "") == "deleted" for item in removed_delete_results)
|
|
9448
|
+
delete_readback_pending = any(str(item.get("readback_status") or "") != "deleted" for item in removed_delete_results)
|
|
9178
9449
|
verified = (
|
|
9179
9450
|
(not verified_views_unavailable)
|
|
9180
9451
|
and all(name in verified_names for name in created + updated)
|
|
9181
|
-
and
|
|
9452
|
+
and removed_verified
|
|
9182
9453
|
)
|
|
9183
9454
|
view_filters_verified = verified and not filter_readback_pending and not filter_mismatches
|
|
9184
9455
|
view_query_conditions_verified = verified and not query_condition_readback_pending and not query_condition_mismatches
|
|
9185
9456
|
view_associated_resources_verified = verified and not associated_resource_readback_pending and not associated_resource_mismatches
|
|
9186
|
-
view_buttons_verified = verified and not button_readback_pending and not button_mismatches
|
|
9457
|
+
view_buttons_verified = verified and not button_readback_pending and not button_mismatches and not custom_button_readback_pending
|
|
9187
9458
|
noop = not created and not updated and not removed
|
|
9188
9459
|
if failed_views:
|
|
9189
9460
|
successful_changes = bool(created or updated or removed)
|
|
@@ -9252,6 +9523,7 @@ class AiBuilderFacade:
|
|
|
9252
9523
|
"custom_button_readback_pending_entries": deepcopy(custom_button_readback_pending_entries),
|
|
9253
9524
|
},
|
|
9254
9525
|
"app_key": app_key,
|
|
9526
|
+
"app_name": app_name,
|
|
9255
9527
|
"views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
|
|
9256
9528
|
"verified": verified and view_filters_verified and view_query_conditions_verified and view_associated_resources_verified and view_buttons_verified,
|
|
9257
9529
|
}
|
|
@@ -9272,6 +9544,13 @@ class AiBuilderFacade:
|
|
|
9272
9544
|
"system buttons verified, but draft custom button bindings are not fully visible through view readback yet",
|
|
9273
9545
|
)
|
|
9274
9546
|
)
|
|
9547
|
+
if delete_readback_pending:
|
|
9548
|
+
warnings.append(
|
|
9549
|
+
_warning(
|
|
9550
|
+
"VIEW_DELETE_READBACK_PENDING",
|
|
9551
|
+
"view delete was sent, but deletion readback is not fully verified; do not blindly repeat delete",
|
|
9552
|
+
)
|
|
9553
|
+
)
|
|
9275
9554
|
all_verified = verified and view_filters_verified and view_query_conditions_verified and view_associated_resources_verified and view_buttons_verified
|
|
9276
9555
|
response = {
|
|
9277
9556
|
"status": "success" if all_verified else "partial_success",
|
|
@@ -9319,11 +9598,13 @@ class AiBuilderFacade:
|
|
|
9319
9598
|
"query_condition_readback_pending": query_condition_readback_pending,
|
|
9320
9599
|
"associated_resource_readback_pending": associated_resource_readback_pending,
|
|
9321
9600
|
"button_readback_pending": button_readback_pending,
|
|
9601
|
+
"delete_readback_pending": delete_readback_pending,
|
|
9322
9602
|
"custom_button_readback_pending": custom_button_readback_pending,
|
|
9323
9603
|
"custom_button_readback_pending_entries": deepcopy(custom_button_readback_pending_entries),
|
|
9324
9604
|
"by_view": verification_by_view,
|
|
9325
9605
|
},
|
|
9326
9606
|
"app_key": app_key,
|
|
9607
|
+
"app_name": app_name,
|
|
9327
9608
|
"views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
|
|
9328
9609
|
"verified": all_verified,
|
|
9329
9610
|
}
|
|
@@ -9349,6 +9630,7 @@ class AiBuilderFacade:
|
|
|
9349
9630
|
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
9350
9631
|
)
|
|
9351
9632
|
tag_ids_before = _coerce_int_list(base_before.get("tagIds"))
|
|
9633
|
+
app_name_before = str(base_before.get("formTitle") or base_before.get("title") or base_before.get("appName") or "").strip() or None
|
|
9352
9634
|
already_published = bool(base_before.get("appPublishStatus") in {1, 2})
|
|
9353
9635
|
package_already_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_before
|
|
9354
9636
|
try:
|
|
@@ -9379,6 +9661,7 @@ class AiBuilderFacade:
|
|
|
9379
9661
|
"warnings": [],
|
|
9380
9662
|
"verification": {"published": True, "package_attached": package_already_attached, "views_ok": True},
|
|
9381
9663
|
"app_key": app_key,
|
|
9664
|
+
"app_name": app_name_before,
|
|
9382
9665
|
"published": True,
|
|
9383
9666
|
"package_attached": package_already_attached,
|
|
9384
9667
|
"tag_ids_after": tag_ids_before,
|
|
@@ -9411,6 +9694,7 @@ class AiBuilderFacade:
|
|
|
9411
9694
|
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
9412
9695
|
)
|
|
9413
9696
|
tag_ids_after = _coerce_int_list(base.get("tagIds"))
|
|
9697
|
+
app_name_after = str(base.get("formTitle") or base.get("title") or base.get("appName") or app_name_before or "").strip() or None
|
|
9414
9698
|
package_attached = None if not expected_package_tag_id else expected_package_tag_id in tag_ids_after
|
|
9415
9699
|
try:
|
|
9416
9700
|
views, views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
|
|
@@ -9451,6 +9735,7 @@ class AiBuilderFacade:
|
|
|
9451
9735
|
"warnings": warnings,
|
|
9452
9736
|
"verification": {"published": bool(base.get("appPublishStatus") in {1, 2}), "package_attached": package_attached, "views_ok": views_ok, "views_read_unavailable": views_unavailable},
|
|
9453
9737
|
"app_key": app_key,
|
|
9738
|
+
"app_name": app_name_after,
|
|
9454
9739
|
"published": bool(base.get("appPublishStatus") in {1, 2}),
|
|
9455
9740
|
"package_attached": package_attached,
|
|
9456
9741
|
"tag_ids_after": tag_ids_after,
|
|
@@ -9578,6 +9863,159 @@ class AiBuilderFacade:
|
|
|
9578
9863
|
)
|
|
9579
9864
|
return expanded, issues, results
|
|
9580
9865
|
|
|
9866
|
+
def _verify_view_deleted_by_key(self, *, profile: str, view_key: str) -> JSONObject:
|
|
9867
|
+
try:
|
|
9868
|
+
self.views.view_get_config(profile=profile, viewgraph_key=view_key)
|
|
9869
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
9870
|
+
api_error = _coerce_api_error(error)
|
|
9871
|
+
if _delete_readback_is_not_found(api_error):
|
|
9872
|
+
return {
|
|
9873
|
+
"view_key": view_key,
|
|
9874
|
+
"operation": "delete",
|
|
9875
|
+
"status": "removed",
|
|
9876
|
+
"delete_executed": True,
|
|
9877
|
+
"readback_status": "deleted",
|
|
9878
|
+
"safe_to_retry_delete": False,
|
|
9879
|
+
}
|
|
9880
|
+
return {
|
|
9881
|
+
"view_key": view_key,
|
|
9882
|
+
"operation": "delete",
|
|
9883
|
+
"status": "readback_pending",
|
|
9884
|
+
"delete_executed": True,
|
|
9885
|
+
"readback_status": "unavailable",
|
|
9886
|
+
"safe_to_retry_delete": False,
|
|
9887
|
+
"error_code": "VIEW_DELETE_READBACK_UNAVAILABLE",
|
|
9888
|
+
"message": "delete request completed, but view existence could not be verified by view_key readback",
|
|
9889
|
+
"request_id": api_error.request_id,
|
|
9890
|
+
"backend_code": api_error.backend_code,
|
|
9891
|
+
"http_status": None if api_error.http_status == 404 else api_error.http_status,
|
|
9892
|
+
"transport_error": _transport_error_payload(api_error),
|
|
9893
|
+
}
|
|
9894
|
+
return {
|
|
9895
|
+
"view_key": view_key,
|
|
9896
|
+
"operation": "delete",
|
|
9897
|
+
"status": "readback_pending",
|
|
9898
|
+
"delete_executed": True,
|
|
9899
|
+
"readback_status": "still_exists",
|
|
9900
|
+
"safe_to_retry_delete": False,
|
|
9901
|
+
"error_code": "VIEW_DELETE_READBACK_STILL_EXISTS",
|
|
9902
|
+
"message": "delete request completed, but the view still exists during view_key readback",
|
|
9903
|
+
}
|
|
9904
|
+
|
|
9905
|
+
def _verify_custom_button_deleted_by_id(self, *, profile: str, app_key: str, button_id: int) -> JSONObject:
|
|
9906
|
+
try:
|
|
9907
|
+
self.buttons.custom_button_get(profile=profile, app_key=app_key, button_id=button_id, being_draft=True, include_raw=False)
|
|
9908
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
9909
|
+
api_error = _coerce_api_error(error)
|
|
9910
|
+
if _delete_readback_is_not_found(api_error):
|
|
9911
|
+
return {
|
|
9912
|
+
"button_id": button_id,
|
|
9913
|
+
"operation": "delete",
|
|
9914
|
+
"status": "removed",
|
|
9915
|
+
"delete_executed": True,
|
|
9916
|
+
"readback_status": "deleted",
|
|
9917
|
+
"safe_to_retry_delete": False,
|
|
9918
|
+
}
|
|
9919
|
+
return {
|
|
9920
|
+
"button_id": button_id,
|
|
9921
|
+
"operation": "delete",
|
|
9922
|
+
"status": "readback_pending",
|
|
9923
|
+
"delete_executed": True,
|
|
9924
|
+
"readback_status": "unavailable",
|
|
9925
|
+
"safe_to_retry_delete": False,
|
|
9926
|
+
"error_code": "CUSTOM_BUTTON_DELETE_READBACK_UNAVAILABLE",
|
|
9927
|
+
"message": "delete request completed, but custom button existence could not be verified by button_id readback",
|
|
9928
|
+
"request_id": api_error.request_id,
|
|
9929
|
+
"backend_code": api_error.backend_code,
|
|
9930
|
+
"http_status": None if api_error.http_status == 404 else api_error.http_status,
|
|
9931
|
+
"transport_error": _transport_error_payload(api_error),
|
|
9932
|
+
}
|
|
9933
|
+
return {
|
|
9934
|
+
"button_id": button_id,
|
|
9935
|
+
"operation": "delete",
|
|
9936
|
+
"status": "readback_pending",
|
|
9937
|
+
"delete_executed": True,
|
|
9938
|
+
"readback_status": "still_exists",
|
|
9939
|
+
"safe_to_retry_delete": False,
|
|
9940
|
+
"error_code": "CUSTOM_BUTTON_DELETE_READBACK_STILL_EXISTS",
|
|
9941
|
+
"message": "delete request completed, but the custom button still exists during button_id readback",
|
|
9942
|
+
}
|
|
9943
|
+
|
|
9944
|
+
def _verify_associated_resources_deleted_by_pool(
|
|
9945
|
+
self,
|
|
9946
|
+
*,
|
|
9947
|
+
deleted_items: list[JSONObject],
|
|
9948
|
+
resources: list[dict[str, Any]],
|
|
9949
|
+
readback_failed: bool,
|
|
9950
|
+
) -> list[JSONObject]:
|
|
9951
|
+
existing_by_id = _associated_resource_index(resources) if not readback_failed else {}
|
|
9952
|
+
verified_items: list[JSONObject] = []
|
|
9953
|
+
for item in deleted_items:
|
|
9954
|
+
associated_item_id = _coerce_positive_int(item.get("associated_item_id"))
|
|
9955
|
+
verified = deepcopy(item)
|
|
9956
|
+
verified["operation"] = "remove"
|
|
9957
|
+
verified["delete_executed"] = True
|
|
9958
|
+
verified["safe_to_retry_delete"] = False
|
|
9959
|
+
if associated_item_id is None or readback_failed:
|
|
9960
|
+
verified["status"] = "readback_pending"
|
|
9961
|
+
verified["readback_status"] = "unavailable"
|
|
9962
|
+
verified["error_code"] = "ASSOCIATED_RESOURCE_DELETE_READBACK_UNAVAILABLE"
|
|
9963
|
+
verified["message"] = "delete request completed, but associated resource pool readback is unavailable"
|
|
9964
|
+
elif associated_item_id in existing_by_id:
|
|
9965
|
+
verified["status"] = "readback_pending"
|
|
9966
|
+
verified["readback_status"] = "still_exists"
|
|
9967
|
+
verified["error_code"] = "ASSOCIATED_RESOURCE_DELETE_READBACK_STILL_EXISTS"
|
|
9968
|
+
verified["message"] = "delete request completed, but the associated resource still exists in pool readback"
|
|
9969
|
+
else:
|
|
9970
|
+
verified["status"] = "removed"
|
|
9971
|
+
verified["readback_status"] = "deleted"
|
|
9972
|
+
verified.pop("error_code", None)
|
|
9973
|
+
verified.pop("message", None)
|
|
9974
|
+
verified_items.append(verified)
|
|
9975
|
+
return verified_items
|
|
9976
|
+
|
|
9977
|
+
def _verify_chart_deleted_by_id(self, *, profile: str, chart_id: str) -> JSONObject:
|
|
9978
|
+
base_result: dict[str, Any] | None = None
|
|
9979
|
+
try:
|
|
9980
|
+
raw = self.charts.qingbi_report_get_base(profile=profile, chart_id=chart_id).get("result") or {}
|
|
9981
|
+
base_result = raw if isinstance(raw, dict) else {}
|
|
9982
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
9983
|
+
api_error = _coerce_api_error(error)
|
|
9984
|
+
if _chart_delete_readback_is_not_found(api_error):
|
|
9985
|
+
return {
|
|
9986
|
+
"chart_id": chart_id,
|
|
9987
|
+
"operation": "delete",
|
|
9988
|
+
"status": "removed",
|
|
9989
|
+
"delete_executed": True,
|
|
9990
|
+
"readback_status": "deleted",
|
|
9991
|
+
"safe_to_retry_delete": False,
|
|
9992
|
+
}
|
|
9993
|
+
return {
|
|
9994
|
+
"chart_id": chart_id,
|
|
9995
|
+
"operation": "delete",
|
|
9996
|
+
"status": "readback_pending",
|
|
9997
|
+
"delete_executed": True,
|
|
9998
|
+
"readback_status": "unavailable",
|
|
9999
|
+
"safe_to_retry_delete": False,
|
|
10000
|
+
"error_code": "CHART_DELETE_READBACK_UNAVAILABLE",
|
|
10001
|
+
"message": "delete request completed, but chart existence could not be verified by chart_id readback",
|
|
10002
|
+
"request_id": api_error.request_id,
|
|
10003
|
+
"backend_code": api_error.backend_code,
|
|
10004
|
+
"http_status": None if api_error.http_status == 404 else api_error.http_status,
|
|
10005
|
+
"transport_error": _transport_error_payload(api_error),
|
|
10006
|
+
}
|
|
10007
|
+
return {
|
|
10008
|
+
"chart_id": chart_id,
|
|
10009
|
+
"operation": "delete",
|
|
10010
|
+
"status": "readback_pending",
|
|
10011
|
+
"delete_executed": True,
|
|
10012
|
+
"readback_status": "still_exists",
|
|
10013
|
+
"safe_to_retry_delete": False,
|
|
10014
|
+
"error_code": "CHART_DELETE_READBACK_STILL_EXISTS",
|
|
10015
|
+
"message": "delete request completed, but the chart still exists during chart_id readback",
|
|
10016
|
+
"readback_name": base_result.get("chartName") or base_result.get("name") if isinstance(base_result, dict) else None,
|
|
10017
|
+
}
|
|
10018
|
+
|
|
9581
10019
|
def chart_apply(self, *, profile: str, request: ChartApplyRequest) -> JSONObject:
|
|
9582
10020
|
normalized_args = request.model_dump(mode="json")
|
|
9583
10021
|
permission_outcomes: list[PermissionCheckOutcome] = []
|
|
@@ -9588,6 +10026,7 @@ class AiBuilderFacade:
|
|
|
9588
10026
|
if resolved_outcome is not None:
|
|
9589
10027
|
permission_outcomes.append(resolved_outcome)
|
|
9590
10028
|
app_key = str(app_result.get("app_key") or request.app_key)
|
|
10029
|
+
app_name = str(app_result.get("app_name") or "").strip() or None
|
|
9591
10030
|
permission_outcome = self._guard_app_permission(
|
|
9592
10031
|
profile=profile,
|
|
9593
10032
|
app_key=app_key,
|
|
@@ -9601,21 +10040,27 @@ class AiBuilderFacade:
|
|
|
9601
10040
|
def finalize(response: JSONObject) -> JSONObject:
|
|
9602
10041
|
return _apply_permission_outcomes(response, *permission_outcomes)
|
|
9603
10042
|
|
|
9604
|
-
|
|
9605
|
-
|
|
9606
|
-
|
|
9607
|
-
|
|
9608
|
-
|
|
9609
|
-
|
|
9610
|
-
|
|
9611
|
-
|
|
9612
|
-
|
|
9613
|
-
"
|
|
9614
|
-
|
|
9615
|
-
|
|
9616
|
-
|
|
9617
|
-
|
|
9618
|
-
|
|
10043
|
+
fields: list[dict[str, Any]] = []
|
|
10044
|
+
qingbi_fields: list[Any] = []
|
|
10045
|
+
existing_chart_items: list[Any] = []
|
|
10046
|
+
existing_chart_list_source: str | None = None
|
|
10047
|
+
needs_chart_inventory = bool(request.upsert_charts or request.patch_charts or request.reorder_chart_ids)
|
|
10048
|
+
if needs_chart_inventory:
|
|
10049
|
+
try:
|
|
10050
|
+
schema_state = self._load_base_schema_state(profile=profile, app_key=app_key)
|
|
10051
|
+
parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
|
|
10052
|
+
fields = parsed.get("fields") if isinstance(parsed.get("fields"), list) else []
|
|
10053
|
+
qingbi_fields = self.charts.qingbi_report_list_fields(profile=profile, app_key=app_key).get("items") or []
|
|
10054
|
+
existing_chart_items, existing_chart_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
|
|
10055
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
10056
|
+
api_error = _coerce_api_error(error)
|
|
10057
|
+
return finalize(_failed_from_api_error(
|
|
10058
|
+
"CHART_APPLY_FAILED",
|
|
10059
|
+
api_error,
|
|
10060
|
+
normalized_args=normalized_args,
|
|
10061
|
+
details=_with_state_read_blocked_details({"app_key": app_key}, resource="chart", error=api_error),
|
|
10062
|
+
suggested_next_call={"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
|
|
10063
|
+
))
|
|
9619
10064
|
|
|
9620
10065
|
field_lookup = _build_public_field_lookup(fields)
|
|
9621
10066
|
qingbi_fields_by_id = {
|
|
@@ -9623,6 +10068,11 @@ class AiBuilderFacade:
|
|
|
9623
10068
|
for item in qingbi_fields
|
|
9624
10069
|
if isinstance(item, dict) and item.get("fieldId")
|
|
9625
10070
|
}
|
|
10071
|
+
chart_field_lookup = _build_qingbi_chart_field_lookup(
|
|
10072
|
+
app_key=app_key,
|
|
10073
|
+
qingbi_fields=[item for item in qingbi_fields if isinstance(item, dict)],
|
|
10074
|
+
field_lookup=field_lookup,
|
|
10075
|
+
)
|
|
9626
10076
|
existing_by_id = {
|
|
9627
10077
|
_extract_chart_identifier(item): deepcopy(item)
|
|
9628
10078
|
for item in existing_chart_items
|
|
@@ -9665,8 +10115,12 @@ class AiBuilderFacade:
|
|
|
9665
10115
|
updated_ids: list[str] = []
|
|
9666
10116
|
removed_ids: list[str] = []
|
|
9667
10117
|
failed_items: list[dict[str, Any]] = []
|
|
10118
|
+
delete_readback_issues: list[dict[str, Any]] = []
|
|
9668
10119
|
|
|
9669
10120
|
for patch in upsert_charts:
|
|
10121
|
+
chart_id = ""
|
|
10122
|
+
target_type = ""
|
|
10123
|
+
config_payload: dict[str, Any] | None = None
|
|
9670
10124
|
try:
|
|
9671
10125
|
dataset_source = _chart_patch_dataset_source_type(patch)
|
|
9672
10126
|
if dataset_source:
|
|
@@ -9705,6 +10159,14 @@ class AiBuilderFacade:
|
|
|
9705
10159
|
f"existing chart '{chart_id or patch.name}' uses dataset report source '{existing_source_type}' and is not supported for update yet. "
|
|
9706
10160
|
"Update it in QingBI directly, then attach the existing report with app_associated_resources_apply using report_source='dataset'."
|
|
9707
10161
|
)
|
|
10162
|
+
if existing is None or config_update_requested:
|
|
10163
|
+
config_payload = _build_public_chart_config_payload(
|
|
10164
|
+
patch=patch,
|
|
10165
|
+
app_key=app_key,
|
|
10166
|
+
field_lookup=field_lookup,
|
|
10167
|
+
chart_field_lookup=chart_field_lookup,
|
|
10168
|
+
qingbi_fields_by_id=qingbi_fields_by_id,
|
|
10169
|
+
)
|
|
9708
10170
|
if existing is None:
|
|
9709
10171
|
temp_chart_id = str(patch.chart_id or f"mcp_{uuid4().hex[:16]}")
|
|
9710
10172
|
create_payload = {
|
|
@@ -9788,13 +10250,7 @@ class AiBuilderFacade:
|
|
|
9788
10250
|
|
|
9789
10251
|
config_updated = False
|
|
9790
10252
|
if existing is None or config_update_requested:
|
|
9791
|
-
|
|
9792
|
-
patch=patch,
|
|
9793
|
-
app_key=app_key,
|
|
9794
|
-
field_lookup=field_lookup,
|
|
9795
|
-
qingbi_fields_by_id=qingbi_fields_by_id,
|
|
9796
|
-
)
|
|
9797
|
-
self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload)
|
|
10253
|
+
self.charts.qingbi_report_update_config(profile=profile, chart_id=chart_id, payload=config_payload or {})
|
|
9798
10254
|
config_updated = True
|
|
9799
10255
|
if existing is not None and chart_id not in updated_ids and config_updated:
|
|
9800
10256
|
updated_ids.append(chart_id)
|
|
@@ -9824,11 +10280,21 @@ class AiBuilderFacade:
|
|
|
9824
10280
|
)
|
|
9825
10281
|
except (QingflowApiError, RuntimeError, ValueError, VisibilityResolutionError) as error:
|
|
9826
10282
|
api_error = _coerce_api_error(error) if not isinstance(error, ValueError) else None
|
|
10283
|
+
diagnostics = (
|
|
10284
|
+
error.diagnostics
|
|
10285
|
+
if isinstance(error, ChartRuleViolation)
|
|
10286
|
+
else _explain_chart_backend_validation_error(api_error=api_error, chart_type=target_type or patch.chart_type.value, payload=config_payload)
|
|
10287
|
+
if api_error is not None
|
|
10288
|
+
else None
|
|
10289
|
+
)
|
|
9827
10290
|
failure = {
|
|
9828
|
-
"chart_id": str(
|
|
10291
|
+
"chart_id": str(chart_id or patch.chart_id or ""),
|
|
9829
10292
|
"name": patch.name,
|
|
10293
|
+
"chart_type": patch.chart_type.value,
|
|
9830
10294
|
"status": "failed",
|
|
9831
|
-
"
|
|
10295
|
+
"error_code": diagnostics.get("rule_code") if isinstance(diagnostics, dict) else "CHART_APPLY_FAILED",
|
|
10296
|
+
"message": str(diagnostics.get("message") if isinstance(diagnostics, dict) and diagnostics.get("message") else error),
|
|
10297
|
+
"diagnostics": diagnostics,
|
|
9832
10298
|
"request_id": api_error.request_id if api_error else None,
|
|
9833
10299
|
"backend_code": api_error.backend_code if api_error else None,
|
|
9834
10300
|
"http_status": None if api_error is None or api_error.http_status == 404 else api_error.http_status,
|
|
@@ -9840,12 +10306,18 @@ class AiBuilderFacade:
|
|
|
9840
10306
|
try:
|
|
9841
10307
|
self.charts.qingbi_report_delete(profile=profile, chart_id=chart_id)
|
|
9842
10308
|
removed_ids.append(chart_id)
|
|
9843
|
-
|
|
10309
|
+
delete_result = self._verify_chart_deleted_by_id(profile=profile, chart_id=chart_id)
|
|
10310
|
+
if delete_result.get("readback_status") != "deleted":
|
|
10311
|
+
delete_readback_issues.append(delete_result)
|
|
10312
|
+
chart_results.append(delete_result)
|
|
9844
10313
|
except (QingflowApiError, RuntimeError) as error:
|
|
9845
10314
|
api_error = _coerce_api_error(error)
|
|
9846
10315
|
failure = {
|
|
9847
10316
|
"chart_id": chart_id,
|
|
10317
|
+
"operation": "delete",
|
|
9848
10318
|
"status": "failed",
|
|
10319
|
+
"delete_executed": False,
|
|
10320
|
+
"safe_to_retry_delete": True,
|
|
9849
10321
|
"message": _public_error_message("CHART_APPLY_FAILED", api_error),
|
|
9850
10322
|
"request_id": api_error.request_id,
|
|
9851
10323
|
"backend_code": api_error.backend_code,
|
|
@@ -9880,30 +10352,37 @@ class AiBuilderFacade:
|
|
|
9880
10352
|
chart_results.append(failure)
|
|
9881
10353
|
|
|
9882
10354
|
noop = not created_ids and not updated_ids and not removed_ids and not reordered and not failed_items
|
|
9883
|
-
|
|
9884
|
-
|
|
9885
|
-
|
|
9886
|
-
|
|
9887
|
-
|
|
9888
|
-
|
|
9889
|
-
|
|
9890
|
-
|
|
9891
|
-
|
|
9892
|
-
and all(chart_id not in readback_ids for chart_id in removed_ids)
|
|
9893
|
-
)
|
|
9894
|
-
if request.reorder_chart_ids:
|
|
9895
|
-
ordered_readback = [
|
|
10355
|
+
needs_list_readback = bool(created_ids or updated_ids or reordered)
|
|
10356
|
+
delete_readback_unavailable = any(item.get("readback_status") == "unavailable" for item in delete_readback_issues)
|
|
10357
|
+
deletes_verified = not delete_readback_issues
|
|
10358
|
+
readback_unavailable = False
|
|
10359
|
+
readback_list_source: str | None = existing_chart_list_source
|
|
10360
|
+
if needs_list_readback:
|
|
10361
|
+
try:
|
|
10362
|
+
readback_items, readback_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
|
|
10363
|
+
readback_ids = {
|
|
9896
10364
|
_extract_chart_identifier(item)
|
|
9897
10365
|
for item in readback_items
|
|
9898
10366
|
if isinstance(item, dict) and _extract_chart_identifier(item)
|
|
9899
|
-
|
|
9900
|
-
|
|
9901
|
-
|
|
9902
|
-
|
|
9903
|
-
|
|
9904
|
-
|
|
9905
|
-
|
|
9906
|
-
|
|
10367
|
+
}
|
|
10368
|
+
verified = all(chart_id in readback_ids for chart_id in created_ids + updated_ids)
|
|
10369
|
+
if request.reorder_chart_ids:
|
|
10370
|
+
ordered_readback = [
|
|
10371
|
+
_extract_chart_identifier(item)
|
|
10372
|
+
for item in readback_items
|
|
10373
|
+
if isinstance(item, dict) and _extract_chart_identifier(item)
|
|
10374
|
+
]
|
|
10375
|
+
requested_existing = [chart_id for chart_id in request.reorder_chart_ids if chart_id in ordered_readback]
|
|
10376
|
+
verified = verified and readback_list_source == "sorted" and ordered_readback[: len(requested_existing)] == requested_existing
|
|
10377
|
+
readback_unavailable = False
|
|
10378
|
+
except (QingflowApiError, RuntimeError):
|
|
10379
|
+
verified = False
|
|
10380
|
+
readback_unavailable = True
|
|
10381
|
+
readback_list_source = None
|
|
10382
|
+
else:
|
|
10383
|
+
verified = True
|
|
10384
|
+
verified = verified and deletes_verified
|
|
10385
|
+
any_readback_unavailable = readback_unavailable or delete_readback_unavailable
|
|
9907
10386
|
|
|
9908
10387
|
if failed_items:
|
|
9909
10388
|
successful_changes = bool(created_ids or updated_ids or removed_ids or reordered)
|
|
@@ -9925,42 +10404,56 @@ class AiBuilderFacade:
|
|
|
9925
10404
|
failed_items=failed_items,
|
|
9926
10405
|
readback_unavailable=readback_unavailable,
|
|
9927
10406
|
verified=False if failed_items else verified,
|
|
10407
|
+
delete_readback_issues=delete_readback_issues,
|
|
9928
10408
|
),
|
|
9929
10409
|
"verification": {
|
|
9930
10410
|
"charts_verified": False if failed_items else verified,
|
|
9931
|
-
"readback_unavailable":
|
|
9932
|
-
"
|
|
10411
|
+
"readback_unavailable": any_readback_unavailable,
|
|
10412
|
+
"chart_delete_readback_results": [deepcopy(item) for item in chart_results if item.get("operation") == "delete"],
|
|
10413
|
+
"chart_order_verified": False if request.reorder_chart_ids else True,
|
|
9933
10414
|
"chart_list_source": readback_list_source or existing_chart_list_source,
|
|
9934
10415
|
},
|
|
9935
10416
|
"app_key": app_key,
|
|
10417
|
+
"app_name": app_name,
|
|
9936
10418
|
"chart_results": chart_results,
|
|
9937
10419
|
"verified": False if failed_items else verified,
|
|
9938
10420
|
})
|
|
9939
10421
|
result_verified = verified or noop
|
|
10422
|
+
pending_delete = bool(delete_readback_issues)
|
|
10423
|
+
pending_error_code = "CHART_DELETE_READBACK_PENDING" if pending_delete and not readback_unavailable else "CHART_READBACK_PENDING"
|
|
10424
|
+
pending_message = "applied chart operations; delete readback pending" if pending_delete else "applied chart operations; readback pending"
|
|
10425
|
+
pending_suggestion = (
|
|
10426
|
+
{"tool_name": "chart_get", "arguments": {"profile": profile, "chart_id": str(delete_readback_issues[0].get("chart_id") or "CHART_ID")}}
|
|
10427
|
+
if pending_delete
|
|
10428
|
+
else {"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}}
|
|
10429
|
+
)
|
|
9940
10430
|
return finalize({
|
|
9941
10431
|
"status": "success" if result_verified else "partial_success",
|
|
9942
|
-
"error_code": None if result_verified else
|
|
10432
|
+
"error_code": None if result_verified else pending_error_code,
|
|
9943
10433
|
"recoverable": not result_verified,
|
|
9944
|
-
"message": "no chart changes requested" if noop else ("applied chart operations" if verified else
|
|
10434
|
+
"message": "no chart changes requested" if noop else ("applied chart operations" if verified else pending_message),
|
|
9945
10435
|
"normalized_args": normalized_args,
|
|
9946
10436
|
"missing_fields": [],
|
|
9947
10437
|
"allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
|
|
9948
10438
|
"details": {},
|
|
9949
10439
|
"request_id": None,
|
|
9950
|
-
"suggested_next_call": None if result_verified else
|
|
10440
|
+
"suggested_next_call": None if result_verified else pending_suggestion,
|
|
9951
10441
|
"noop": noop,
|
|
9952
10442
|
"warnings": _chart_apply_warnings(
|
|
9953
10443
|
failed_items=[],
|
|
9954
10444
|
readback_unavailable=False if noop else readback_unavailable,
|
|
9955
10445
|
verified=result_verified,
|
|
10446
|
+
delete_readback_issues=delete_readback_issues,
|
|
9956
10447
|
),
|
|
9957
10448
|
"verification": {
|
|
9958
10449
|
"charts_verified": result_verified,
|
|
9959
|
-
"readback_unavailable": False if noop else
|
|
9960
|
-
"
|
|
10450
|
+
"readback_unavailable": False if noop else any_readback_unavailable,
|
|
10451
|
+
"chart_delete_readback_results": [deepcopy(item) for item in chart_results if item.get("operation") == "delete"],
|
|
10452
|
+
"chart_order_verified": (readback_list_source == "sorted" and result_verified) if request.reorder_chart_ids else True,
|
|
9961
10453
|
"chart_list_source": existing_chart_list_source if noop else readback_list_source,
|
|
9962
10454
|
},
|
|
9963
10455
|
"app_key": app_key,
|
|
10456
|
+
"app_name": app_name,
|
|
9964
10457
|
"chart_results": chart_results,
|
|
9965
10458
|
"verified": result_verified,
|
|
9966
10459
|
})
|
|
@@ -10072,6 +10565,7 @@ class AiBuilderFacade:
|
|
|
10072
10565
|
suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
|
|
10073
10566
|
)
|
|
10074
10567
|
try:
|
|
10568
|
+
layout_diagnostics: dict[str, Any] = _empty_portal_layout_diagnostics()
|
|
10075
10569
|
if creating:
|
|
10076
10570
|
create_payload = _build_public_portal_base_payload(
|
|
10077
10571
|
dash_name=request.dash_name or "未命名门户",
|
|
@@ -10108,7 +10602,12 @@ class AiBuilderFacade:
|
|
|
10108
10602
|
base_payload=base_payload,
|
|
10109
10603
|
)
|
|
10110
10604
|
if sections_requested:
|
|
10111
|
-
component_payload = self._build_portal_components_from_sections(
|
|
10605
|
+
component_payload = self._build_portal_components_from_sections(
|
|
10606
|
+
profile=profile,
|
|
10607
|
+
sections=request.sections,
|
|
10608
|
+
layout_preset=request.layout_preset,
|
|
10609
|
+
)
|
|
10610
|
+
layout_diagnostics = _portal_layout_diagnostics(request.sections, component_payload)
|
|
10112
10611
|
update_payload["components"] = component_payload
|
|
10113
10612
|
self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
|
|
10114
10613
|
self.portals.portal_update_base_info(
|
|
@@ -10211,6 +10710,7 @@ class AiBuilderFacade:
|
|
|
10211
10710
|
live_meta_verified=live_meta_verified,
|
|
10212
10711
|
publish_requested=request.publish,
|
|
10213
10712
|
)
|
|
10713
|
+
warnings.extend(_portal_layout_warning_items(layout_diagnostics))
|
|
10214
10714
|
return finalize({
|
|
10215
10715
|
"status": status,
|
|
10216
10716
|
"error_code": error_code,
|
|
@@ -10237,6 +10737,7 @@ class AiBuilderFacade:
|
|
|
10237
10737
|
"suggested_next_call": None if verified else {"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
|
|
10238
10738
|
"noop": False,
|
|
10239
10739
|
"warnings": warnings,
|
|
10740
|
+
"layout_diagnostics": layout_diagnostics,
|
|
10240
10741
|
"verification": {
|
|
10241
10742
|
"draft_verified": draft_verified,
|
|
10242
10743
|
"draft_metadata_verified": draft_meta_verified,
|
|
@@ -10246,6 +10747,9 @@ class AiBuilderFacade:
|
|
|
10246
10747
|
"publish_failed": publish_failed,
|
|
10247
10748
|
},
|
|
10248
10749
|
"dash_key": dash_key,
|
|
10750
|
+
"dash_name": update_payload.get("dashName"),
|
|
10751
|
+
"package_id": target_package_tag_id,
|
|
10752
|
+
"created": creating,
|
|
10249
10753
|
"published": published,
|
|
10250
10754
|
"verified": verified,
|
|
10251
10755
|
"draft_result": draft_result,
|
|
@@ -10338,6 +10842,49 @@ class AiBuilderFacade:
|
|
|
10338
10842
|
verification["published"] = False
|
|
10339
10843
|
return response
|
|
10340
10844
|
publish_result = self._publish_current_edit_version(profile=profile, app_key=app_key, edit_version_no=edit_version_no)
|
|
10845
|
+
if publish_result.get("status") == "failed" and publish_result.get("error_code") == "APP_EDIT_LOCKED":
|
|
10846
|
+
details = response.get("details")
|
|
10847
|
+
if not isinstance(details, dict):
|
|
10848
|
+
details = {}
|
|
10849
|
+
response["details"] = details
|
|
10850
|
+
suggested = publish_result.get("suggested_next_call")
|
|
10851
|
+
release_args = suggested.get("arguments") if isinstance(suggested, dict) else {}
|
|
10852
|
+
if isinstance(release_args, dict):
|
|
10853
|
+
release_result = self.app_release_edit_lock_if_mine(
|
|
10854
|
+
profile=profile,
|
|
10855
|
+
app_key=app_key,
|
|
10856
|
+
lock_owner_email=str(release_args.get("lock_owner_email") or ""),
|
|
10857
|
+
lock_owner_name=str(release_args.get("lock_owner_name") or ""),
|
|
10858
|
+
)
|
|
10859
|
+
details["edit_lock_release_result"] = release_result
|
|
10860
|
+
if release_result.get("status") == "success":
|
|
10861
|
+
retry_result = self._publish_current_edit_version(profile=profile, app_key=app_key, edit_version_no=None)
|
|
10862
|
+
details["publish_retry_after_edit_lock_release"] = retry_result
|
|
10863
|
+
publish_result = retry_result
|
|
10864
|
+
response["retried_after_edit_lock_release"] = True
|
|
10865
|
+
response["edit_lock_released"] = True
|
|
10866
|
+
elif release_result.get("error_code") == "EDIT_LOCK_OWNER_UNKNOWN" and edit_version_no is not None:
|
|
10867
|
+
try:
|
|
10868
|
+
self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
|
|
10869
|
+
release_result = {
|
|
10870
|
+
"status": "success",
|
|
10871
|
+
"error_code": None,
|
|
10872
|
+
"recoverable": False,
|
|
10873
|
+
"message": "released current apply edit context before publish retry",
|
|
10874
|
+
"details": {"app_key": app_key, "edit_version_no": edit_version_no, "release_strategy": "current_apply_edit_context"},
|
|
10875
|
+
"verification": {"released": True},
|
|
10876
|
+
"app_key": app_key,
|
|
10877
|
+
"verified": True,
|
|
10878
|
+
}
|
|
10879
|
+
details["edit_lock_release_result"] = release_result
|
|
10880
|
+
retry_result = self._publish_current_edit_version(profile=profile, app_key=app_key, edit_version_no=None)
|
|
10881
|
+
details["publish_retry_after_edit_lock_release"] = retry_result
|
|
10882
|
+
publish_result = retry_result
|
|
10883
|
+
response["retried_after_edit_lock_release"] = True
|
|
10884
|
+
response["edit_lock_released"] = True
|
|
10885
|
+
except (QingflowApiError, RuntimeError) as error:
|
|
10886
|
+
api_error = _coerce_api_error(error)
|
|
10887
|
+
details["current_apply_edit_context_release_error"] = _transport_error_payload(api_error)
|
|
10341
10888
|
response["publish_result"] = publish_result
|
|
10342
10889
|
response["published"] = bool(publish_result.get("published"))
|
|
10343
10890
|
verification["published"] = bool(publish_result.get("published"))
|
|
@@ -10362,6 +10909,14 @@ class AiBuilderFacade:
|
|
|
10362
10909
|
"schema_source": schema_source,
|
|
10363
10910
|
}
|
|
10364
10911
|
|
|
10912
|
+
def _read_app_name_for_builder_output(self, *, profile: str, app_key: str) -> str | None:
|
|
10913
|
+
try:
|
|
10914
|
+
base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
|
|
10915
|
+
except (QingflowApiError, RuntimeError):
|
|
10916
|
+
return None
|
|
10917
|
+
app_name = str(base.get("formTitle") or base.get("title") or base.get("appName") or "").strip()
|
|
10918
|
+
return app_name or None
|
|
10919
|
+
|
|
10365
10920
|
def _sync_system_view_apply_config(
|
|
10366
10921
|
self,
|
|
10367
10922
|
*,
|
|
@@ -10793,6 +11348,7 @@ class AiBuilderFacade:
|
|
|
10793
11348
|
*,
|
|
10794
11349
|
profile: str,
|
|
10795
11350
|
sections: list[PortalSectionPatch],
|
|
11351
|
+
layout_preset: str | None = None,
|
|
10796
11352
|
) -> list[dict[str, Any]]:
|
|
10797
11353
|
resolved_components: list[dict[str, Any]] = []
|
|
10798
11354
|
pc_x = 0
|
|
@@ -10809,9 +11365,15 @@ class AiBuilderFacade:
|
|
|
10809
11365
|
pc_y=pc_y,
|
|
10810
11366
|
pc_row_height=pc_row_height,
|
|
10811
11367
|
mobile_y=mobile_y,
|
|
11368
|
+
layout_preset=layout_preset,
|
|
10812
11369
|
)
|
|
10813
11370
|
else:
|
|
10814
|
-
position_payload = _portal_position_payload(section.position)
|
|
11371
|
+
position_payload = _portal_position_payload(section.position, inferred_mobile_y=mobile_y)
|
|
11372
|
+
mobile = position_payload.get("mobile") if isinstance(position_payload.get("mobile"), dict) else {}
|
|
11373
|
+
mobile_y = max(
|
|
11374
|
+
mobile_y,
|
|
11375
|
+
int(mobile.get("y") or 0) + int(mobile.get("rows") or 0),
|
|
11376
|
+
)
|
|
10815
11377
|
dash_style = deepcopy(section.dash_style_config) if isinstance(section.dash_style_config, dict) else None
|
|
10816
11378
|
component: dict[str, Any]
|
|
10817
11379
|
if section.source_type == "chart":
|
|
@@ -12210,6 +12772,28 @@ def _public_error_message(error_code: str, error: QingflowApiError) -> str:
|
|
|
12210
12772
|
return mapping.get(error_code, "requested builder resource is unavailable in the current route")
|
|
12211
12773
|
|
|
12212
12774
|
|
|
12775
|
+
def _chart_delete_readback_is_not_found(error: QingflowApiError) -> bool:
|
|
12776
|
+
return _delete_readback_is_not_found(error)
|
|
12777
|
+
|
|
12778
|
+
|
|
12779
|
+
def _delete_readback_is_not_found(error: QingflowApiError) -> bool:
|
|
12780
|
+
backend_code = int(error.backend_code or 0) if str(error.backend_code or "").isdigit() else error.backend_code
|
|
12781
|
+
if error.http_status == 404 or backend_code in {404, 40038, 81007}:
|
|
12782
|
+
return True
|
|
12783
|
+
message = str(error.message or "").lower()
|
|
12784
|
+
return any(
|
|
12785
|
+
marker in message
|
|
12786
|
+
for marker in (
|
|
12787
|
+
"object not exist",
|
|
12788
|
+
"not found",
|
|
12789
|
+
"not exist",
|
|
12790
|
+
"does not exist",
|
|
12791
|
+
"不存在",
|
|
12792
|
+
"未找到",
|
|
12793
|
+
)
|
|
12794
|
+
)
|
|
12795
|
+
|
|
12796
|
+
|
|
12213
12797
|
def _extract_edit_lock_owner(message: str) -> JSONObject:
|
|
12214
12798
|
text = str(message or "").strip()
|
|
12215
12799
|
if not text:
|
|
@@ -12217,6 +12801,8 @@ def _extract_edit_lock_owner(message: str) -> JSONObject:
|
|
|
12217
12801
|
patterns = [
|
|
12218
12802
|
r"应用已被\s*(?P<name>[^((]+?)\s*[((](?P<email>[^))]+)[))]\s*编辑",
|
|
12219
12803
|
r"edited by\s*(?P<name>[^<(]+?)\s*<(?P<email>[^>]+)>",
|
|
12804
|
+
r"active editor\s+(?P<email>[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})",
|
|
12805
|
+
r"(?P<email>[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})",
|
|
12220
12806
|
]
|
|
12221
12807
|
for pattern in patterns:
|
|
12222
12808
|
match = re.search(pattern, text)
|
|
@@ -12512,18 +13098,20 @@ def _build_public_dimension_fields(
|
|
|
12512
13098
|
*,
|
|
12513
13099
|
app_key: str,
|
|
12514
13100
|
field_lookup: dict[str, dict[str, Any]],
|
|
13101
|
+
chart_field_lookup: dict[str, Any],
|
|
12515
13102
|
qingbi_fields_by_id: dict[str, dict[str, Any]],
|
|
13103
|
+
chart_type: str = "chart",
|
|
12516
13104
|
) -> list[dict[str, Any]]:
|
|
12517
13105
|
dimensions: list[dict[str, Any]] = []
|
|
12518
13106
|
for selector in selectors:
|
|
12519
|
-
|
|
12520
|
-
field_id =
|
|
12521
|
-
|
|
13107
|
+
qingbi_field = _resolve_qingbi_chart_field(selector, chart_field_lookup=chart_field_lookup, chart_type=chart_type, role="dimension")
|
|
13108
|
+
field_id = _chart_field_id(qingbi_field)
|
|
13109
|
+
form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
|
|
12522
13110
|
dimensions.append(
|
|
12523
13111
|
{
|
|
12524
13112
|
"fieldId": field_id,
|
|
12525
|
-
"fieldName": qingbi_field.get("fieldName") or
|
|
12526
|
-
"fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(
|
|
13113
|
+
"fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
|
|
13114
|
+
"fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
|
|
12527
13115
|
"orderType": "default",
|
|
12528
13116
|
"alignType": "left",
|
|
12529
13117
|
"dateFormat": "yyyy-MM-dd",
|
|
@@ -12565,27 +13153,540 @@ def _default_public_total_metric() -> dict[str, Any]:
|
|
|
12565
13153
|
}
|
|
12566
13154
|
|
|
12567
13155
|
|
|
13156
|
+
_QINGBI_TOTAL_FIELD_ID = ":-100"
|
|
13157
|
+
_QINGBI_DECIMAL_FIELD_TYPES = {"decimal", "number", "numeric", "amount", "integer", "int", "long", "double", "float"}
|
|
13158
|
+
|
|
13159
|
+
|
|
13160
|
+
class ChartRuleViolation(ValueError):
|
|
13161
|
+
def __init__(self, diagnostics: dict[str, Any]) -> None:
|
|
13162
|
+
self.diagnostics = diagnostics
|
|
13163
|
+
super().__init__(str(diagnostics.get("message") or diagnostics.get("next_action") or "chart rule violation"))
|
|
13164
|
+
|
|
13165
|
+
|
|
13166
|
+
def _chart_field_id(field: dict[str, Any]) -> str:
|
|
13167
|
+
return str(field.get("fieldId") or field.get("field_id") or "").strip()
|
|
13168
|
+
|
|
13169
|
+
|
|
13170
|
+
def _chart_field_name(field: dict[str, Any]) -> str | None:
|
|
13171
|
+
name = str(field.get("fieldName") or field.get("field_name") or "").strip()
|
|
13172
|
+
return name or None
|
|
13173
|
+
|
|
13174
|
+
|
|
13175
|
+
def _chart_fields(payload: dict[str, Any], key: str) -> list[dict[str, Any]]:
|
|
13176
|
+
value = payload.get(key)
|
|
13177
|
+
if not isinstance(value, list):
|
|
13178
|
+
return []
|
|
13179
|
+
return [item for item in value if isinstance(item, dict)]
|
|
13180
|
+
|
|
13181
|
+
|
|
13182
|
+
def _chart_field_summary(field: dict[str, Any]) -> dict[str, Any]:
|
|
13183
|
+
return _compact_dict(
|
|
13184
|
+
{
|
|
13185
|
+
"field_id": _chart_field_id(field),
|
|
13186
|
+
"field_name": _chart_field_name(field),
|
|
13187
|
+
"field_type": field.get("fieldType") or field.get("field_type"),
|
|
13188
|
+
"field_source": field.get("fieldSource") or field.get("field_source"),
|
|
13189
|
+
"bi_formula_type": field.get("biFormulaType") or field.get("bi_formula_type"),
|
|
13190
|
+
"aggre_field_id": field.get("aggreFieldId") or field.get("aggre_field_id"),
|
|
13191
|
+
}
|
|
13192
|
+
)
|
|
13193
|
+
|
|
13194
|
+
|
|
13195
|
+
def _chart_duplicate_fields(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
13196
|
+
seen: dict[str, dict[str, Any]] = {}
|
|
13197
|
+
duplicates: dict[str, dict[str, Any]] = {}
|
|
13198
|
+
for field in fields:
|
|
13199
|
+
field_id = _chart_field_id(field)
|
|
13200
|
+
if not field_id:
|
|
13201
|
+
continue
|
|
13202
|
+
if field_id in seen:
|
|
13203
|
+
duplicates[field_id] = _chart_field_summary(field)
|
|
13204
|
+
else:
|
|
13205
|
+
seen[field_id] = field
|
|
13206
|
+
return list(duplicates.values())
|
|
13207
|
+
|
|
13208
|
+
|
|
13209
|
+
def _chart_rule_diagnostics(
|
|
13210
|
+
*,
|
|
13211
|
+
rule_code: str,
|
|
13212
|
+
chart_type: str,
|
|
13213
|
+
message: str,
|
|
13214
|
+
expected: str,
|
|
13215
|
+
actual: dict[str, Any],
|
|
13216
|
+
next_action: str,
|
|
13217
|
+
offending_fields: list[dict[str, Any]] | None = None,
|
|
13218
|
+
) -> dict[str, Any]:
|
|
13219
|
+
return _compact_dict(
|
|
13220
|
+
{
|
|
13221
|
+
"rule_code": rule_code,
|
|
13222
|
+
"chart_type": chart_type,
|
|
13223
|
+
"message": message,
|
|
13224
|
+
"expected": expected,
|
|
13225
|
+
"actual": actual,
|
|
13226
|
+
"offending_fields": offending_fields or [],
|
|
13227
|
+
"next_action": next_action,
|
|
13228
|
+
}
|
|
13229
|
+
)
|
|
13230
|
+
|
|
13231
|
+
|
|
13232
|
+
def _raise_chart_rule(
|
|
13233
|
+
*,
|
|
13234
|
+
rule_code: str,
|
|
13235
|
+
chart_type: str,
|
|
13236
|
+
message: str,
|
|
13237
|
+
expected: str,
|
|
13238
|
+
actual: dict[str, Any],
|
|
13239
|
+
next_action: str,
|
|
13240
|
+
offending_fields: list[dict[str, Any]] | None = None,
|
|
13241
|
+
) -> None:
|
|
13242
|
+
raise ChartRuleViolation(
|
|
13243
|
+
_chart_rule_diagnostics(
|
|
13244
|
+
rule_code=rule_code,
|
|
13245
|
+
chart_type=chart_type,
|
|
13246
|
+
message=message,
|
|
13247
|
+
expected=expected,
|
|
13248
|
+
actual=actual,
|
|
13249
|
+
next_action=next_action,
|
|
13250
|
+
offending_fields=offending_fields,
|
|
13251
|
+
)
|
|
13252
|
+
)
|
|
13253
|
+
|
|
13254
|
+
|
|
13255
|
+
_QINGBI_TOTAL_FIELD_ALIASES = {_QINGBI_TOTAL_FIELD_ID, "数据总量", "data_total", "total", "count"}
|
|
13256
|
+
|
|
13257
|
+
|
|
13258
|
+
def _qingbi_field_que_id(*, app_key: str, field_id: Any) -> int | None:
|
|
13259
|
+
raw = str(field_id or "").strip()
|
|
13260
|
+
if not raw or raw == _QINGBI_TOTAL_FIELD_ID:
|
|
13261
|
+
return None
|
|
13262
|
+
if raw.startswith("field_"):
|
|
13263
|
+
return _coerce_positive_int(raw.removeprefix("field_"))
|
|
13264
|
+
if raw.startswith(f"{app_key}:"):
|
|
13265
|
+
return _coerce_positive_int(raw.split(":", 1)[1])
|
|
13266
|
+
if ":" in raw:
|
|
13267
|
+
return _coerce_positive_int(raw.rsplit(":", 1)[1])
|
|
13268
|
+
return _coerce_positive_int(raw)
|
|
13269
|
+
|
|
13270
|
+
|
|
13271
|
+
def _dedupe_qingbi_fields(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
13272
|
+
deduped: list[dict[str, Any]] = []
|
|
13273
|
+
seen: set[str] = set()
|
|
13274
|
+
for field in fields:
|
|
13275
|
+
field_id = _chart_field_id(field)
|
|
13276
|
+
key = field_id or json.dumps(field, sort_keys=True, ensure_ascii=False, default=str)
|
|
13277
|
+
if key in seen:
|
|
13278
|
+
continue
|
|
13279
|
+
seen.add(key)
|
|
13280
|
+
deduped.append(field)
|
|
13281
|
+
return deduped
|
|
13282
|
+
|
|
13283
|
+
|
|
13284
|
+
def _build_qingbi_chart_field_lookup(
|
|
13285
|
+
*,
|
|
13286
|
+
app_key: str,
|
|
13287
|
+
qingbi_fields: list[dict[str, Any]],
|
|
13288
|
+
field_lookup: dict[str, dict[str, Any]],
|
|
13289
|
+
) -> dict[str, Any]:
|
|
13290
|
+
by_selector: dict[str, list[dict[str, Any]]] = {}
|
|
13291
|
+
form_by_que_id = field_lookup.get("by_que_id") or {}
|
|
13292
|
+
|
|
13293
|
+
def add_selector(key: Any, field: dict[str, Any]) -> None:
|
|
13294
|
+
normalized = str(key or "").strip()
|
|
13295
|
+
if not normalized:
|
|
13296
|
+
return
|
|
13297
|
+
by_selector.setdefault(normalized, []).append(field)
|
|
13298
|
+
lower = normalized.lower()
|
|
13299
|
+
if lower != normalized:
|
|
13300
|
+
by_selector.setdefault(lower, []).append(field)
|
|
13301
|
+
|
|
13302
|
+
for raw_field in qingbi_fields:
|
|
13303
|
+
if not isinstance(raw_field, dict):
|
|
13304
|
+
continue
|
|
13305
|
+
field_id = _chart_field_id(raw_field)
|
|
13306
|
+
if not field_id:
|
|
13307
|
+
continue
|
|
13308
|
+
field = deepcopy(raw_field)
|
|
13309
|
+
que_id = _qingbi_field_que_id(app_key=app_key, field_id=field_id)
|
|
13310
|
+
form_field = form_by_que_id.get(que_id) if que_id is not None else None
|
|
13311
|
+
if isinstance(form_field, dict):
|
|
13312
|
+
field["_public_form_field"] = deepcopy(form_field)
|
|
13313
|
+
if not _chart_field_name(field) and isinstance(form_field, dict) and form_field.get("name"):
|
|
13314
|
+
field["fieldName"] = form_field.get("name")
|
|
13315
|
+
|
|
13316
|
+
add_selector(field_id, field)
|
|
13317
|
+
if que_id is not None:
|
|
13318
|
+
add_selector(que_id, field)
|
|
13319
|
+
add_selector(f"field_{que_id}", field)
|
|
13320
|
+
if isinstance(form_field, dict):
|
|
13321
|
+
add_selector(form_field.get("field_id"), field)
|
|
13322
|
+
title = _chart_field_name(field)
|
|
13323
|
+
if title:
|
|
13324
|
+
add_selector(title, field)
|
|
13325
|
+
return {"by_selector": by_selector}
|
|
13326
|
+
|
|
13327
|
+
|
|
13328
|
+
def _compact_public_chart_fields_read(
|
|
13329
|
+
*,
|
|
13330
|
+
app_key: str,
|
|
13331
|
+
qingbi_fields: list[dict[str, Any]],
|
|
13332
|
+
field_lookup: dict[str, dict[str, Any]],
|
|
13333
|
+
) -> list[dict[str, Any]]:
|
|
13334
|
+
form_by_que_id = field_lookup.get("by_que_id") or {}
|
|
13335
|
+
compact_fields: list[dict[str, Any]] = []
|
|
13336
|
+
seen: set[str] = set()
|
|
13337
|
+
for field in qingbi_fields:
|
|
13338
|
+
if not isinstance(field, dict):
|
|
13339
|
+
continue
|
|
13340
|
+
bi_field_id = _chart_field_id(field)
|
|
13341
|
+
if not bi_field_id or bi_field_id in seen:
|
|
13342
|
+
continue
|
|
13343
|
+
seen.add(bi_field_id)
|
|
13344
|
+
que_id = _qingbi_field_que_id(app_key=app_key, field_id=bi_field_id)
|
|
13345
|
+
form_field = form_by_que_id.get(que_id) if que_id is not None else None
|
|
13346
|
+
public_field_id = (
|
|
13347
|
+
str(form_field.get("field_id"))
|
|
13348
|
+
if isinstance(form_field, dict) and form_field.get("field_id")
|
|
13349
|
+
else f"field_{que_id}"
|
|
13350
|
+
if que_id is not None
|
|
13351
|
+
else bi_field_id
|
|
13352
|
+
)
|
|
13353
|
+
title = _chart_field_name(field) or (
|
|
13354
|
+
str(form_field.get("name")) if isinstance(form_field, dict) and form_field.get("name") else bi_field_id
|
|
13355
|
+
)
|
|
13356
|
+
compact_fields.append(
|
|
13357
|
+
_compact_dict(
|
|
13358
|
+
{
|
|
13359
|
+
"field_id": public_field_id,
|
|
13360
|
+
"que_id": que_id,
|
|
13361
|
+
"bi_field_id": bi_field_id,
|
|
13362
|
+
"title": title,
|
|
13363
|
+
"field_type": field.get("fieldType") or field.get("field_type"),
|
|
13364
|
+
"system_field": bool(que_id is not None and not isinstance(form_field, dict)),
|
|
13365
|
+
"available_for_charts": True,
|
|
13366
|
+
}
|
|
13367
|
+
)
|
|
13368
|
+
)
|
|
13369
|
+
return compact_fields
|
|
13370
|
+
|
|
13371
|
+
|
|
13372
|
+
def _chart_field_candidates(
|
|
13373
|
+
selector: Any,
|
|
13374
|
+
*,
|
|
13375
|
+
chart_field_lookup: dict[str, Any],
|
|
13376
|
+
) -> list[dict[str, Any]]:
|
|
13377
|
+
raw = str(selector or "").strip()
|
|
13378
|
+
if not raw:
|
|
13379
|
+
return []
|
|
13380
|
+
by_selector = chart_field_lookup.get("by_selector") if isinstance(chart_field_lookup.get("by_selector"), dict) else {}
|
|
13381
|
+
return _dedupe_qingbi_fields(list(by_selector.get(raw) or by_selector.get(raw.lower()) or []))
|
|
13382
|
+
|
|
13383
|
+
|
|
13384
|
+
def _resolve_qingbi_chart_field(
|
|
13385
|
+
selector: Any,
|
|
13386
|
+
*,
|
|
13387
|
+
chart_field_lookup: dict[str, Any],
|
|
13388
|
+
chart_type: str,
|
|
13389
|
+
role: str,
|
|
13390
|
+
) -> dict[str, Any]:
|
|
13391
|
+
raw = str(selector or "").strip()
|
|
13392
|
+
if not raw:
|
|
13393
|
+
_raise_chart_rule(
|
|
13394
|
+
rule_code="CHART_FIELD_NOT_IN_QINGBI_SCHEMA",
|
|
13395
|
+
chart_type=chart_type,
|
|
13396
|
+
message="chart field selector cannot be empty",
|
|
13397
|
+
expected="use a field from app_get_fields.chart_fields",
|
|
13398
|
+
actual={"selector": raw, "role": role},
|
|
13399
|
+
next_action="Call app_get_fields and choose a field from chart_fields for chart dimensions, metrics, filters, or query conditions.",
|
|
13400
|
+
)
|
|
13401
|
+
if raw in _QINGBI_TOTAL_FIELD_ALIASES or raw.lower() in _QINGBI_TOTAL_FIELD_ALIASES:
|
|
13402
|
+
if role == "metric":
|
|
13403
|
+
return _default_public_total_metric()
|
|
13404
|
+
_raise_chart_rule(
|
|
13405
|
+
rule_code="CHART_TOTAL_FIELD_NOT_ALLOWED",
|
|
13406
|
+
chart_type=chart_type,
|
|
13407
|
+
message="数据总量 is only valid as a metric field, not as a dimension/filter/query field",
|
|
13408
|
+
expected="use 数据总量 only in indicator_field_ids or omit metrics for count-style charts",
|
|
13409
|
+
actual={"selector": raw, "role": role},
|
|
13410
|
+
next_action="Choose a real QingBI field from app_get_fields.chart_fields for dimensions, filters, and query conditions.",
|
|
13411
|
+
offending_fields=[{"field_id": _QINGBI_TOTAL_FIELD_ID, "field_name": "数据总量"}],
|
|
13412
|
+
)
|
|
13413
|
+
candidates = _chart_field_candidates(raw, chart_field_lookup=chart_field_lookup)
|
|
13414
|
+
if not candidates:
|
|
13415
|
+
_raise_chart_rule(
|
|
13416
|
+
rule_code="CHART_FIELD_NOT_IN_QINGBI_SCHEMA",
|
|
13417
|
+
chart_type=chart_type,
|
|
13418
|
+
message=f"field '{raw}' was not found in QingBI datasource fields for this app",
|
|
13419
|
+
expected="chart fields must come from app_get_fields.chart_fields, not record schema or form-only fields",
|
|
13420
|
+
actual={"selector": raw, "role": role},
|
|
13421
|
+
next_action="Call app_get_fields and choose a field from chart_fields; if the system field is absent there, QingBI cannot use it for this report.",
|
|
13422
|
+
)
|
|
13423
|
+
if len(candidates) > 1:
|
|
13424
|
+
_raise_chart_rule(
|
|
13425
|
+
rule_code="CHART_FIELD_AMBIGUOUS",
|
|
13426
|
+
chart_type=chart_type,
|
|
13427
|
+
message=f"field '{raw}' matched multiple QingBI datasource fields",
|
|
13428
|
+
expected="use an unambiguous field selector such as bi_field_id or field_<queId>",
|
|
13429
|
+
actual={"selector": raw, "role": role, "candidate_count": len(candidates)},
|
|
13430
|
+
next_action="Use one of the returned candidate bi_field_id values or field_<queId> selectors.",
|
|
13431
|
+
offending_fields=[_chart_field_summary(item) for item in candidates],
|
|
13432
|
+
)
|
|
13433
|
+
return deepcopy(candidates[0])
|
|
13434
|
+
|
|
13435
|
+
|
|
13436
|
+
def _check_chart_slot_duplicates(*, chart_type: str, payload: dict[str, Any], slot_names: list[str]) -> None:
|
|
13437
|
+
for slot_name in slot_names:
|
|
13438
|
+
duplicates = _chart_duplicate_fields(_chart_fields(payload, slot_name))
|
|
13439
|
+
if duplicates:
|
|
13440
|
+
_raise_chart_rule(
|
|
13441
|
+
rule_code="CHART_FIELD_ID_REPEAT",
|
|
13442
|
+
chart_type=chart_type,
|
|
13443
|
+
message=f"{chart_type} chart has duplicate field ids in {slot_name}",
|
|
13444
|
+
expected=f"{slot_name} must not contain duplicated fieldId values",
|
|
13445
|
+
actual={"slot": slot_name, "duplicate_count": len(duplicates)},
|
|
13446
|
+
offending_fields=duplicates,
|
|
13447
|
+
next_action="Use different fields for this slot, or remove the duplicated field before retrying.",
|
|
13448
|
+
)
|
|
13449
|
+
|
|
13450
|
+
|
|
13451
|
+
def _histogram_metric_issue(metric: dict[str, Any]) -> dict[str, Any] | None:
|
|
13452
|
+
field_id = _chart_field_id(metric)
|
|
13453
|
+
if field_id == _QINGBI_TOTAL_FIELD_ID:
|
|
13454
|
+
return {
|
|
13455
|
+
"rule_code": "HISTOGRAM_DEFAULT_TOTAL_METRIC_UNSUPPORTED",
|
|
13456
|
+
"message": "histogram cannot use 数据总量 as its metric",
|
|
13457
|
+
"next_action": "Pass one explicit numeric field in indicator_field_ids and set config.aggregate such as sum/avg.",
|
|
13458
|
+
}
|
|
13459
|
+
field_type = str(metric.get("fieldType") or metric.get("field_type") or "").strip().lower()
|
|
13460
|
+
if field_type not in _QINGBI_DECIMAL_FIELD_TYPES:
|
|
13461
|
+
return {
|
|
13462
|
+
"rule_code": "HISTOGRAM_METRIC_FIELD_TYPE_UNSUPPORTED",
|
|
13463
|
+
"message": "histogram metric must be a numeric field",
|
|
13464
|
+
"next_action": "Choose one number/amount field as indicator_field_ids for histogram.",
|
|
13465
|
+
}
|
|
13466
|
+
field_source = str(metric.get("fieldSource") or metric.get("field_source") or "").strip().lower()
|
|
13467
|
+
bi_formula_type = str(metric.get("biFormulaType") or metric.get("bi_formula_type") or "").strip().lower()
|
|
13468
|
+
aggre_field_id = str(metric.get("aggreFieldId") or metric.get("aggre_field_id") or "").strip()
|
|
13469
|
+
if field_source == "formula" and (bi_formula_type in {"chart_agg", "agg"} or aggre_field_id):
|
|
13470
|
+
return {
|
|
13471
|
+
"rule_code": "HISTOGRAM_AGG_FORMULA_METRIC_UNSUPPORTED",
|
|
13472
|
+
"message": "histogram metric cannot be an aggregate formula field",
|
|
13473
|
+
"next_action": "Choose a plain numeric field, not an aggregate formula field.",
|
|
13474
|
+
}
|
|
13475
|
+
return None
|
|
13476
|
+
|
|
13477
|
+
|
|
13478
|
+
def _validate_public_chart_payload_rules(payload: dict[str, Any]) -> None:
|
|
13479
|
+
chart_type = str(payload.get("chartType") or "").strip().lower()
|
|
13480
|
+
dimensions = _chart_fields(payload, "selectedDimensions")
|
|
13481
|
+
metrics = _chart_fields(payload, "selectedMetrics")
|
|
13482
|
+
_check_chart_slot_duplicates(
|
|
13483
|
+
chart_type=chart_type,
|
|
13484
|
+
payload=payload,
|
|
13485
|
+
slot_names=[
|
|
13486
|
+
"selectedDimensions",
|
|
13487
|
+
"selectedMetrics",
|
|
13488
|
+
"xDimensions",
|
|
13489
|
+
"yDimensions",
|
|
13490
|
+
"xMetrics",
|
|
13491
|
+
"yMetrics",
|
|
13492
|
+
"leftMetrics",
|
|
13493
|
+
"rightMetrics",
|
|
13494
|
+
],
|
|
13495
|
+
)
|
|
13496
|
+
|
|
13497
|
+
if chart_type == "gauge":
|
|
13498
|
+
if dimensions:
|
|
13499
|
+
_raise_chart_rule(
|
|
13500
|
+
rule_code="GAUGE_DIMENSION_NOT_ALLOWED",
|
|
13501
|
+
chart_type=chart_type,
|
|
13502
|
+
message="gauge chart must not have dimensions",
|
|
13503
|
+
expected="0 dimensions",
|
|
13504
|
+
actual={"dimension_count": len(dimensions)},
|
|
13505
|
+
offending_fields=[_chart_field_summary(field) for field in dimensions],
|
|
13506
|
+
next_action="Remove dimension_field_ids for gauge. The CLI clears public dimensions, but custom selectedDimensions in config must also be removed.",
|
|
13507
|
+
)
|
|
13508
|
+
if len(metrics) != 2:
|
|
13509
|
+
_raise_chart_rule(
|
|
13510
|
+
rule_code="GAUGE_METRIC_COUNT_INVALID",
|
|
13511
|
+
chart_type=chart_type,
|
|
13512
|
+
message="gauge chart requires exactly two metrics",
|
|
13513
|
+
expected="exactly 2 non-duplicated metrics; one real metric plus 数据总量 is allowed",
|
|
13514
|
+
actual={"metric_count": len(metrics), "metric_field_ids": [_chart_field_id(field) for field in metrics]},
|
|
13515
|
+
offending_fields=[_chart_field_summary(field) for field in metrics],
|
|
13516
|
+
next_action="Pass two different indicator_field_ids, or pass one explicit real numeric metric so the CLI can pair it with 数据总量.",
|
|
13517
|
+
)
|
|
13518
|
+
elif chart_type == "histogram":
|
|
13519
|
+
if len(dimensions) > 1:
|
|
13520
|
+
_raise_chart_rule(
|
|
13521
|
+
rule_code="HISTOGRAM_DIMENSION_COUNT_INVALID",
|
|
13522
|
+
chart_type=chart_type,
|
|
13523
|
+
message="histogram chart supports at most one dimension",
|
|
13524
|
+
expected="0 or 1 dimension",
|
|
13525
|
+
actual={"dimension_count": len(dimensions)},
|
|
13526
|
+
offending_fields=[_chart_field_summary(field) for field in dimensions],
|
|
13527
|
+
next_action="Keep at most one dimension_field_ids value for histogram.",
|
|
13528
|
+
)
|
|
13529
|
+
if len(metrics) != 1:
|
|
13530
|
+
_raise_chart_rule(
|
|
13531
|
+
rule_code="HISTOGRAM_METRIC_COUNT_INVALID",
|
|
13532
|
+
chart_type=chart_type,
|
|
13533
|
+
message="histogram chart requires exactly one explicit metric",
|
|
13534
|
+
expected="exactly 1 plain numeric metric",
|
|
13535
|
+
actual={"metric_count": len(metrics), "metric_field_ids": [_chart_field_id(field) for field in metrics]},
|
|
13536
|
+
offending_fields=[_chart_field_summary(field) for field in metrics],
|
|
13537
|
+
next_action="Pass exactly one numeric field in indicator_field_ids; histogram cannot rely on the default count metric.",
|
|
13538
|
+
)
|
|
13539
|
+
issue = _histogram_metric_issue(metrics[0])
|
|
13540
|
+
if issue:
|
|
13541
|
+
_raise_chart_rule(
|
|
13542
|
+
rule_code=str(issue["rule_code"]),
|
|
13543
|
+
chart_type=chart_type,
|
|
13544
|
+
message=str(issue["message"]),
|
|
13545
|
+
expected="one plain decimal metric; not 数据总量 and not aggregate formula",
|
|
13546
|
+
actual={"metric": _chart_field_summary(metrics[0])},
|
|
13547
|
+
offending_fields=[_chart_field_summary(metrics[0])],
|
|
13548
|
+
next_action=str(issue["next_action"]),
|
|
13549
|
+
)
|
|
13550
|
+
elif chart_type == "heatmap":
|
|
13551
|
+
if len(dimensions) != 2 or len(metrics) != 1:
|
|
13552
|
+
_raise_chart_rule(
|
|
13553
|
+
rule_code="HEATMAP_FIELD_COUNT_INVALID",
|
|
13554
|
+
chart_type=chart_type,
|
|
13555
|
+
message="heatmap chart requires two dimensions and one metric",
|
|
13556
|
+
expected="2 dimensions and 1 metric",
|
|
13557
|
+
actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
|
|
13558
|
+
next_action="Pass exactly two dimension_field_ids and one indicator_field_ids value for heatmap.",
|
|
13559
|
+
)
|
|
13560
|
+
elif chart_type == "waterfall":
|
|
13561
|
+
if len(dimensions) != 1 or len(metrics) != 1:
|
|
13562
|
+
_raise_chart_rule(
|
|
13563
|
+
rule_code="WATERFALL_FIELD_COUNT_INVALID",
|
|
13564
|
+
chart_type=chart_type,
|
|
13565
|
+
message="waterfall chart requires one dimension and one metric",
|
|
13566
|
+
expected="1 dimension and 1 metric",
|
|
13567
|
+
actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
|
|
13568
|
+
next_action="Pass exactly one dimension_field_ids value and one indicator_field_ids value for waterfall.",
|
|
13569
|
+
)
|
|
13570
|
+
elif chart_type == "treemap":
|
|
13571
|
+
if len(dimensions) < 1 or len(dimensions) > 2 or len(metrics) != 1:
|
|
13572
|
+
_raise_chart_rule(
|
|
13573
|
+
rule_code="TREEMAP_FIELD_COUNT_INVALID",
|
|
13574
|
+
chart_type=chart_type,
|
|
13575
|
+
message="treemap chart requires one or two dimensions and one metric",
|
|
13576
|
+
expected="1-2 dimensions and 1 metric",
|
|
13577
|
+
actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
|
|
13578
|
+
next_action="Pass one or two dimension_field_ids values and exactly one indicator_field_ids value for treemap.",
|
|
13579
|
+
)
|
|
13580
|
+
elif chart_type == "map":
|
|
13581
|
+
if len(dimensions) != 1 or len(metrics) != 1:
|
|
13582
|
+
_raise_chart_rule(
|
|
13583
|
+
rule_code="MAP_FIELD_COUNT_INVALID",
|
|
13584
|
+
chart_type=chart_type,
|
|
13585
|
+
message="map chart requires one dimension and one metric",
|
|
13586
|
+
expected="1 dimension and 1 metric",
|
|
13587
|
+
actual={"dimension_count": len(dimensions), "metric_count": len(metrics)},
|
|
13588
|
+
next_action="Pass exactly one location/address dimension and one metric for map.",
|
|
13589
|
+
)
|
|
13590
|
+
elif chart_type == "scatter":
|
|
13591
|
+
x_metrics = _chart_fields(payload, "xMetrics")
|
|
13592
|
+
y_metrics = _chart_fields(payload, "yMetrics")
|
|
13593
|
+
if not dimensions or len(x_metrics) != 1 or len(y_metrics) != 1:
|
|
13594
|
+
_raise_chart_rule(
|
|
13595
|
+
rule_code="SCATTER_FIELD_COUNT_INVALID",
|
|
13596
|
+
chart_type=chart_type,
|
|
13597
|
+
message="scatter chart requires at least one dimension, one x metric, and one y metric",
|
|
13598
|
+
expected=">=1 dimensions, exactly 1 x metric and 1 y metric",
|
|
13599
|
+
actual={"dimension_count": len(dimensions), "x_metric_count": len(x_metrics), "y_metric_count": len(y_metrics)},
|
|
13600
|
+
next_action="Pass at least one dimension_field_ids value and one or two indicator_field_ids values for scatter.",
|
|
13601
|
+
)
|
|
13602
|
+
elif chart_type == "dualaxes":
|
|
13603
|
+
left_metrics = _chart_fields(payload, "leftMetrics")
|
|
13604
|
+
right_metrics = _chart_fields(payload, "rightMetrics")
|
|
13605
|
+
if not dimensions or (not left_metrics and not right_metrics):
|
|
13606
|
+
_raise_chart_rule(
|
|
13607
|
+
rule_code="DUALAXES_FIELD_COUNT_INVALID",
|
|
13608
|
+
chart_type=chart_type,
|
|
13609
|
+
message="dualaxes chart requires at least one dimension and at least one metric axis",
|
|
13610
|
+
expected=">=1 dimensions and at least one left/right metric",
|
|
13611
|
+
actual={"dimension_count": len(dimensions), "left_metric_count": len(left_metrics), "right_metric_count": len(right_metrics)},
|
|
13612
|
+
next_action="Pass at least one dimension_field_ids value and one or two indicator_field_ids values for dualaxes.",
|
|
13613
|
+
)
|
|
13614
|
+
|
|
13615
|
+
|
|
13616
|
+
def _explain_chart_backend_validation_error(
|
|
13617
|
+
*,
|
|
13618
|
+
api_error: QingflowApiError,
|
|
13619
|
+
chart_type: str,
|
|
13620
|
+
payload: dict[str, Any] | None,
|
|
13621
|
+
) -> dict[str, Any] | None:
|
|
13622
|
+
backend_code = api_error.backend_code
|
|
13623
|
+
if backend_code not in {81002, 81005}:
|
|
13624
|
+
return None
|
|
13625
|
+
chart_type = str(chart_type or (payload or {}).get("chartType") or "").strip().lower()
|
|
13626
|
+
if isinstance(payload, dict):
|
|
13627
|
+
try:
|
|
13628
|
+
_validate_public_chart_payload_rules(payload)
|
|
13629
|
+
except ChartRuleViolation as violation:
|
|
13630
|
+
return violation.diagnostics
|
|
13631
|
+
if backend_code == 81005:
|
|
13632
|
+
duplicate_fields: list[dict[str, Any]] = []
|
|
13633
|
+
if isinstance(payload, dict):
|
|
13634
|
+
for slot_name in [
|
|
13635
|
+
"selectedDimensions",
|
|
13636
|
+
"selectedMetrics",
|
|
13637
|
+
"xDimensions",
|
|
13638
|
+
"yDimensions",
|
|
13639
|
+
"xMetrics",
|
|
13640
|
+
"yMetrics",
|
|
13641
|
+
"leftMetrics",
|
|
13642
|
+
"rightMetrics",
|
|
13643
|
+
]:
|
|
13644
|
+
duplicate_fields.extend(_chart_duplicate_fields(_chart_fields(payload, slot_name)))
|
|
13645
|
+
return _chart_rule_diagnostics(
|
|
13646
|
+
rule_code="CHART_FIELD_ID_REPEAT",
|
|
13647
|
+
chart_type=chart_type,
|
|
13648
|
+
message="QingBI rejected the chart because one field id is repeated in a chart slot",
|
|
13649
|
+
expected="field ids must be unique within each dimension/metric slot",
|
|
13650
|
+
actual={"backend_code": backend_code},
|
|
13651
|
+
offending_fields=duplicate_fields,
|
|
13652
|
+
next_action="Remove duplicated fields or pass two different explicit metrics before retrying.",
|
|
13653
|
+
)
|
|
13654
|
+
return _chart_rule_diagnostics(
|
|
13655
|
+
rule_code="WRONG_METRIC_COUNT_OR_TYPE",
|
|
13656
|
+
chart_type=chart_type,
|
|
13657
|
+
message="QingBI rejected the chart because metric count or metric type does not satisfy this chart type",
|
|
13658
|
+
expected="use the chart-type metric count/type rules from builder charts documentation",
|
|
13659
|
+
actual={"backend_code": backend_code},
|
|
13660
|
+
next_action="Check indicator_field_ids count and field types; for histogram use exactly one plain numeric metric, and for gauge use two non-duplicated metrics.",
|
|
13661
|
+
)
|
|
13662
|
+
|
|
13663
|
+
|
|
12568
13664
|
def _build_public_metric_fields(
|
|
12569
13665
|
selectors: list[str],
|
|
12570
13666
|
*,
|
|
12571
13667
|
app_key: str,
|
|
12572
13668
|
field_lookup: dict[str, dict[str, Any]],
|
|
13669
|
+
chart_field_lookup: dict[str, Any],
|
|
12573
13670
|
qingbi_fields_by_id: dict[str, dict[str, Any]],
|
|
12574
13671
|
aggregate: str,
|
|
13672
|
+
chart_type: str = "chart",
|
|
12575
13673
|
) -> list[dict[str, Any]]:
|
|
12576
13674
|
normalized_aggregate = str(aggregate or "count").strip().lower()
|
|
12577
13675
|
if normalized_aggregate == "count" or not selectors:
|
|
12578
13676
|
return [_default_public_total_metric()]
|
|
12579
13677
|
metrics: list[dict[str, Any]] = []
|
|
12580
13678
|
for selector in selectors:
|
|
12581
|
-
|
|
12582
|
-
field_id =
|
|
12583
|
-
|
|
13679
|
+
qingbi_field = _resolve_qingbi_chart_field(selector, chart_field_lookup=chart_field_lookup, chart_type=chart_type, role="metric")
|
|
13680
|
+
field_id = _chart_field_id(qingbi_field)
|
|
13681
|
+
if field_id == _QINGBI_TOTAL_FIELD_ID:
|
|
13682
|
+
metrics.append(qingbi_field)
|
|
13683
|
+
continue
|
|
13684
|
+
form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
|
|
12584
13685
|
metrics.append(
|
|
12585
13686
|
{
|
|
12586
13687
|
"fieldId": field_id,
|
|
12587
|
-
"fieldName": qingbi_field.get("fieldName") or
|
|
12588
|
-
"fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(
|
|
13688
|
+
"fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
|
|
13689
|
+
"fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
|
|
12589
13690
|
"orderType": "default",
|
|
12590
13691
|
"alignType": "left",
|
|
12591
13692
|
"dateFormat": "yyyy-MM-dd",
|
|
@@ -12602,6 +13703,8 @@ def _build_public_metric_fields(
|
|
|
12602
13703
|
"supId": qingbi_field.get("supId"),
|
|
12603
13704
|
"beingTable": bool(qingbi_field.get("beingTable", False)),
|
|
12604
13705
|
"returnType": qingbi_field.get("returnType"),
|
|
13706
|
+
"biFormulaType": qingbi_field.get("biFormulaType"),
|
|
13707
|
+
"aggreFieldId": qingbi_field.get("aggreFieldId"),
|
|
12605
13708
|
}
|
|
12606
13709
|
)
|
|
12607
13710
|
return metrics or [_default_public_total_metric()]
|
|
@@ -12631,7 +13734,9 @@ def _build_public_chart_filter_matrix(
|
|
|
12631
13734
|
*,
|
|
12632
13735
|
app_key: str,
|
|
12633
13736
|
field_lookup: dict[str, dict[str, Any]],
|
|
13737
|
+
chart_field_lookup: dict[str, Any],
|
|
12634
13738
|
qingbi_fields_by_id: dict[str, dict[str, Any]],
|
|
13739
|
+
chart_type: str = "chart",
|
|
12635
13740
|
) -> list[list[dict[str, Any]]]:
|
|
12636
13741
|
if not rules:
|
|
12637
13742
|
return []
|
|
@@ -12647,16 +13752,21 @@ def _build_public_chart_filter_matrix(
|
|
|
12647
13752
|
ViewFilterOperator.not_empty.value: 16,
|
|
12648
13753
|
}
|
|
12649
13754
|
for rule in rules:
|
|
12650
|
-
|
|
12651
|
-
|
|
12652
|
-
|
|
13755
|
+
qingbi_field = _resolve_qingbi_chart_field(
|
|
13756
|
+
getattr(rule, "field_name", None),
|
|
13757
|
+
chart_field_lookup=chart_field_lookup,
|
|
13758
|
+
chart_type=chart_type,
|
|
13759
|
+
role="filter",
|
|
13760
|
+
)
|
|
13761
|
+
field_id = _chart_field_id(qingbi_field)
|
|
13762
|
+
form_field = qingbi_field.get("_public_form_field") if isinstance(qingbi_field.get("_public_form_field"), dict) else {}
|
|
12653
13763
|
operator = str(getattr(rule, "operator", ViewFilterOperator.eq.value).value if hasattr(getattr(rule, "operator", None), "value") else getattr(rule, "operator", ViewFilterOperator.eq.value))
|
|
12654
13764
|
values = list(getattr(rule, "values", []) or [])
|
|
12655
13765
|
group.append(
|
|
12656
13766
|
{
|
|
12657
13767
|
"fieldId": field_id,
|
|
12658
|
-
"fieldName": qingbi_field.get("fieldName") or
|
|
12659
|
-
"fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(
|
|
13768
|
+
"fieldName": qingbi_field.get("fieldName") or form_field.get("name") or field_id,
|
|
13769
|
+
"fieldType": qingbi_field.get("fieldType") or _qingbi_field_type_from_public_field(str(form_field.get("type") or "")),
|
|
12660
13770
|
"judgeType": judge_map.get(operator, 0),
|
|
12661
13771
|
"judgeValues": values,
|
|
12662
13772
|
"matchType": 1,
|
|
@@ -12670,6 +13780,7 @@ def _build_public_chart_config_payload(
|
|
|
12670
13780
|
patch: ChartUpsertPatch,
|
|
12671
13781
|
app_key: str,
|
|
12672
13782
|
field_lookup: dict[str, dict[str, Any]],
|
|
13783
|
+
chart_field_lookup: dict[str, Any],
|
|
12673
13784
|
qingbi_fields_by_id: dict[str, dict[str, Any]],
|
|
12674
13785
|
) -> dict[str, Any]:
|
|
12675
13786
|
config = deepcopy(patch.config)
|
|
@@ -12688,12 +13799,19 @@ def _build_public_chart_config_payload(
|
|
|
12688
13799
|
patch.filters,
|
|
12689
13800
|
app_key=app_key,
|
|
12690
13801
|
field_lookup=field_lookup,
|
|
13802
|
+
chart_field_lookup=chart_field_lookup,
|
|
12691
13803
|
qingbi_fields_by_id=qingbi_fields_by_id,
|
|
13804
|
+
chart_type=patch.chart_type.value,
|
|
12692
13805
|
)
|
|
12693
13806
|
query_condition_field_ids = []
|
|
12694
13807
|
for selector in list(config.pop("query_condition_field_ids", []) or []):
|
|
12695
|
-
field =
|
|
12696
|
-
|
|
13808
|
+
field = _resolve_qingbi_chart_field(
|
|
13809
|
+
selector,
|
|
13810
|
+
chart_field_lookup=chart_field_lookup,
|
|
13811
|
+
chart_type=patch.chart_type.value,
|
|
13812
|
+
role="query_condition",
|
|
13813
|
+
)
|
|
13814
|
+
query_condition_field_ids.append(_chart_field_id(field))
|
|
12697
13815
|
backend_chart_type = _map_public_chart_type_to_backend(patch.chart_type)
|
|
12698
13816
|
if backend_chart_type == "gauge" and not patch.indicator_field_ids and "selectedMetrics" not in config:
|
|
12699
13817
|
raise ValueError("gauge charts require at least one indicator_field_ids value; pass one metric and the CLI will pair it with 数据总量")
|
|
@@ -12701,14 +13819,18 @@ def _build_public_chart_config_payload(
|
|
|
12701
13819
|
patch.dimension_field_ids,
|
|
12702
13820
|
app_key=app_key,
|
|
12703
13821
|
field_lookup=field_lookup,
|
|
13822
|
+
chart_field_lookup=chart_field_lookup,
|
|
12704
13823
|
qingbi_fields_by_id=qingbi_fields_by_id,
|
|
13824
|
+
chart_type=patch.chart_type.value,
|
|
12705
13825
|
)
|
|
12706
13826
|
selected_metrics = _build_public_metric_fields(
|
|
12707
13827
|
patch.indicator_field_ids,
|
|
12708
13828
|
app_key=app_key,
|
|
12709
13829
|
field_lookup=field_lookup,
|
|
13830
|
+
chart_field_lookup=chart_field_lookup,
|
|
12710
13831
|
qingbi_fields_by_id=qingbi_fields_by_id,
|
|
12711
13832
|
aggregate=aggregate,
|
|
13833
|
+
chart_type=patch.chart_type.value,
|
|
12712
13834
|
)
|
|
12713
13835
|
payload: dict[str, Any] = {
|
|
12714
13836
|
"chartName": patch.name,
|
|
@@ -12762,6 +13884,7 @@ def _build_public_chart_config_payload(
|
|
|
12762
13884
|
if key in config:
|
|
12763
13885
|
payload[key] = deepcopy(config.pop(key))
|
|
12764
13886
|
payload.update(config)
|
|
13887
|
+
_validate_public_chart_payload_rules(payload)
|
|
12765
13888
|
return payload
|
|
12766
13889
|
|
|
12767
13890
|
|
|
@@ -12937,7 +14060,9 @@ def _find_chart_by_name(items: Any, *, chart_name: str, chart_type: str | None =
|
|
|
12937
14060
|
return deepcopy(candidates[-1])
|
|
12938
14061
|
|
|
12939
14062
|
|
|
12940
|
-
def _portal_position_payload(position: Any) -> dict[str, Any]:
|
|
14063
|
+
def _portal_position_payload(position: Any, *, inferred_mobile_y: int = 0) -> dict[str, Any]:
|
|
14064
|
+
mobile_provided = bool(getattr(position, "mobile_provided", False))
|
|
14065
|
+
mobile_rows = int(getattr(position, "mobile_h", 8)) if mobile_provided else int(getattr(position, "pc_h", 8))
|
|
12941
14066
|
return {
|
|
12942
14067
|
"pc": {
|
|
12943
14068
|
"x": int(getattr(position, "pc_x", 0)),
|
|
@@ -12946,10 +14071,10 @@ def _portal_position_payload(position: Any) -> dict[str, Any]:
|
|
|
12946
14071
|
"rows": int(getattr(position, "pc_h", 8)),
|
|
12947
14072
|
},
|
|
12948
14073
|
"mobile": {
|
|
12949
|
-
"x": int(getattr(position, "mobile_x", 0)),
|
|
12950
|
-
"y": int(getattr(position, "mobile_y", 0)),
|
|
12951
|
-
"cols": int(getattr(position, "mobile_w",
|
|
12952
|
-
"rows":
|
|
14074
|
+
"x": int(getattr(position, "mobile_x", 0)) if mobile_provided else 0,
|
|
14075
|
+
"y": int(getattr(position, "mobile_y", 0)) if mobile_provided else int(inferred_mobile_y),
|
|
14076
|
+
"cols": int(getattr(position, "mobile_w", 6)) if mobile_provided else 6,
|
|
14077
|
+
"rows": mobile_rows,
|
|
12953
14078
|
},
|
|
12954
14079
|
}
|
|
12955
14080
|
|
|
@@ -12961,6 +14086,7 @@ def _portal_component_position_public(
|
|
|
12961
14086
|
pc_y: int,
|
|
12962
14087
|
pc_row_height: int,
|
|
12963
14088
|
mobile_y: int,
|
|
14089
|
+
layout_preset: str | None = None,
|
|
12964
14090
|
) -> tuple[dict[str, Any], int, int, int, int]:
|
|
12965
14091
|
source_name = str(source_type or "").lower()
|
|
12966
14092
|
if source_name == "filter":
|
|
@@ -12979,8 +14105,11 @@ def _portal_component_position_public(
|
|
|
12979
14105
|
cols = 12
|
|
12980
14106
|
rows = 2
|
|
12981
14107
|
else:
|
|
12982
|
-
|
|
12983
|
-
|
|
14108
|
+
if layout_preset == "dashboard_2col":
|
|
14109
|
+
cols = 12
|
|
14110
|
+
else:
|
|
14111
|
+
cols = 8
|
|
14112
|
+
rows = 6
|
|
12984
14113
|
if cols == 24:
|
|
12985
14114
|
if pc_x != 0:
|
|
12986
14115
|
pc_y += pc_row_height
|
|
@@ -13003,6 +14132,73 @@ def _portal_component_position_public(
|
|
|
13003
14132
|
return position, next_pc_x, next_pc_y, next_row_height, mobile_y + rows
|
|
13004
14133
|
|
|
13005
14134
|
|
|
14135
|
+
def _empty_portal_layout_diagnostics() -> dict[str, Any]:
|
|
14136
|
+
return {
|
|
14137
|
+
"pc_grid_columns": 24,
|
|
14138
|
+
"mobile_grid_columns": 6,
|
|
14139
|
+
"section_count": 0,
|
|
14140
|
+
"explicit_position_count": 0,
|
|
14141
|
+
"max_pc_right": None,
|
|
14142
|
+
"safe_for_display": True,
|
|
14143
|
+
"warnings": [],
|
|
14144
|
+
}
|
|
14145
|
+
|
|
14146
|
+
|
|
14147
|
+
def _portal_layout_diagnostics(sections: list[PortalSectionPatch], components: list[dict[str, Any]]) -> dict[str, Any]:
|
|
14148
|
+
diagnostics = _empty_portal_layout_diagnostics()
|
|
14149
|
+
diagnostics["section_count"] = len(sections)
|
|
14150
|
+
explicit_count = sum(1 for section in sections if section.position is not None)
|
|
14151
|
+
diagnostics["explicit_position_count"] = explicit_count
|
|
14152
|
+
pc_positions: list[dict[str, Any]] = []
|
|
14153
|
+
warnings: list[dict[str, Any]] = []
|
|
14154
|
+
for index, component in enumerate(components):
|
|
14155
|
+
if not isinstance(component, dict):
|
|
14156
|
+
continue
|
|
14157
|
+
position = component.get("position") if isinstance(component.get("position"), dict) else {}
|
|
14158
|
+
pc = position.get("pc") if isinstance(position.get("pc"), dict) else {}
|
|
14159
|
+
if pc:
|
|
14160
|
+
pc_positions.append(pc)
|
|
14161
|
+
section = sections[index] if index < len(sections) else None
|
|
14162
|
+
source_type = str(getattr(section, "source_type", "") or "").lower() if section is not None else ""
|
|
14163
|
+
title = str(getattr(section, "title", "") or "").strip() if section is not None else None
|
|
14164
|
+
cols = int(pc.get("cols") or 0)
|
|
14165
|
+
rows = int(pc.get("rows") or 0)
|
|
14166
|
+
if source_type == "chart" and (cols < 8 or rows < 5):
|
|
14167
|
+
warnings.append(_warning(
|
|
14168
|
+
"PORTAL_CHART_CARD_TOO_SMALL",
|
|
14169
|
+
"chart portal card is too small; use at least pc.cols >= 8 and pc.rows >= 5, preferably rows >= 6",
|
|
14170
|
+
section_index=index,
|
|
14171
|
+
title=title,
|
|
14172
|
+
pc=deepcopy(pc),
|
|
14173
|
+
))
|
|
14174
|
+
if section is not None and section.position is not None and not bool(getattr(section.position, "mobile_provided", False)):
|
|
14175
|
+
warnings.append(_warning(
|
|
14176
|
+
"PORTAL_MOBILE_POSITION_MISSING",
|
|
14177
|
+
"pc position was provided without mobile position; mobile layout was generated with 6-column grid",
|
|
14178
|
+
section_index=index,
|
|
14179
|
+
title=title,
|
|
14180
|
+
))
|
|
14181
|
+
max_right = None
|
|
14182
|
+
if pc_positions:
|
|
14183
|
+
max_right = max(int(position.get("x") or 0) + int(position.get("cols") or 0) for position in pc_positions)
|
|
14184
|
+
diagnostics["max_pc_right"] = max_right
|
|
14185
|
+
if len(pc_positions) > 1 and max_right is not None and max_right <= 12:
|
|
14186
|
+
warnings.append(_warning(
|
|
14187
|
+
"PORTAL_LAYOUT_HALF_WIDTH",
|
|
14188
|
+
"portal components only occupy the left half of the 24-column pc grid; this looks like a 12-column layout was used",
|
|
14189
|
+
max_pc_right=max_right,
|
|
14190
|
+
fix_hint="Use x=0/12 with cols=12 for two columns, x=0/8/16 with cols=8 for three columns, or omit position/use layout_preset.",
|
|
14191
|
+
))
|
|
14192
|
+
diagnostics["warnings"] = warnings
|
|
14193
|
+
diagnostics["safe_for_display"] = not any(item.get("code") in {"PORTAL_LAYOUT_HALF_WIDTH", "PORTAL_CHART_CARD_TOO_SMALL"} for item in warnings)
|
|
14194
|
+
return diagnostics
|
|
14195
|
+
|
|
14196
|
+
|
|
14197
|
+
def _portal_layout_warning_items(layout_diagnostics: dict[str, Any]) -> list[dict[str, Any]]:
|
|
14198
|
+
warnings = layout_diagnostics.get("warnings") if isinstance(layout_diagnostics, dict) else None
|
|
14199
|
+
return [deepcopy(item) for item in warnings if isinstance(item, dict)] if isinstance(warnings, list) else []
|
|
14200
|
+
|
|
14201
|
+
|
|
13006
14202
|
def _resolve_chart_reference(*, charts: QingbiReportTools, profile: str, ref: Any) -> dict[str, Any]:
|
|
13007
14203
|
app_key = str(getattr(ref, "app_key", "") or "").strip()
|
|
13008
14204
|
chart_id = str(getattr(ref, "chart_id", "") or "").strip()
|
|
@@ -14193,6 +15389,38 @@ def _parse_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|
|
14193
15389
|
return parsed
|
|
14194
15390
|
|
|
14195
15391
|
|
|
15392
|
+
def _schema_field_identity(field: dict[str, Any] | None, *, fallback_name: str | None = None) -> dict[str, Any]:
|
|
15393
|
+
field = field or {}
|
|
15394
|
+
name = str(field.get("name") or fallback_name or "").strip() or None
|
|
15395
|
+
field_id = str(field.get("field_id") or "").strip() or None
|
|
15396
|
+
que_id = _coerce_positive_int(field.get("que_id") or field.get("queId"))
|
|
15397
|
+
return {
|
|
15398
|
+
"name": name,
|
|
15399
|
+
"field_id": field_id,
|
|
15400
|
+
"que_id": que_id,
|
|
15401
|
+
}
|
|
15402
|
+
|
|
15403
|
+
|
|
15404
|
+
def _schema_field_diff_details(
|
|
15405
|
+
*,
|
|
15406
|
+
added: list[str],
|
|
15407
|
+
updated: list[str],
|
|
15408
|
+
removed: list[str],
|
|
15409
|
+
before_fields: list[dict[str, Any]],
|
|
15410
|
+
after_fields: list[dict[str, Any]],
|
|
15411
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
15412
|
+
before_by_name = {str(field.get("name") or ""): field for field in before_fields if str(field.get("name") or "").strip()}
|
|
15413
|
+
after_by_name = {str(field.get("name") or ""): field for field in after_fields if str(field.get("name") or "").strip()}
|
|
15414
|
+
return {
|
|
15415
|
+
"added": [_schema_field_identity(after_by_name.get(name), fallback_name=name) for name in added],
|
|
15416
|
+
"updated": [
|
|
15417
|
+
_schema_field_identity(after_by_name.get(name) or before_by_name.get(name), fallback_name=name)
|
|
15418
|
+
for name in updated
|
|
15419
|
+
],
|
|
15420
|
+
"removed": [_schema_field_identity(before_by_name.get(name), fallback_name=name) for name in removed],
|
|
15421
|
+
}
|
|
15422
|
+
|
|
15423
|
+
|
|
14196
15424
|
def _resolve_layout_sections_to_names(
|
|
14197
15425
|
requested_sections: list[dict[str, Any]],
|
|
14198
15426
|
fields: list[dict[str, Any]],
|
|
@@ -16584,13 +17812,38 @@ def _publish_verify_warnings(*, package_attached: bool | None, views_unavailable
|
|
|
16584
17812
|
return warnings
|
|
16585
17813
|
|
|
16586
17814
|
|
|
16587
|
-
def _chart_apply_warnings(
|
|
17815
|
+
def _chart_apply_warnings(
|
|
17816
|
+
*,
|
|
17817
|
+
failed_items: list[dict[str, Any]],
|
|
17818
|
+
readback_unavailable: bool,
|
|
17819
|
+
verified: bool,
|
|
17820
|
+
delete_readback_issues: list[dict[str, Any]] | None = None,
|
|
17821
|
+
) -> list[dict[str, Any]]:
|
|
16588
17822
|
warnings: list[dict[str, Any]] = []
|
|
17823
|
+
delete_readback_issues = delete_readback_issues or []
|
|
16589
17824
|
if failed_items:
|
|
16590
17825
|
warnings.append(_warning("CHART_OPERATION_FAILED", "one or more chart operations failed", failed_count=len(failed_items)))
|
|
17826
|
+
still_exists = [item for item in delete_readback_issues if item.get("readback_status") == "still_exists"]
|
|
17827
|
+
unavailable = [item for item in delete_readback_issues if item.get("readback_status") == "unavailable"]
|
|
17828
|
+
if still_exists:
|
|
17829
|
+
warnings.append(
|
|
17830
|
+
_warning(
|
|
17831
|
+
"CHART_DELETE_READBACK_STILL_EXISTS",
|
|
17832
|
+
"one or more delete requests completed, but chart_id readback still found the chart",
|
|
17833
|
+
chart_ids=[item.get("chart_id") for item in still_exists if item.get("chart_id")],
|
|
17834
|
+
)
|
|
17835
|
+
)
|
|
17836
|
+
if unavailable:
|
|
17837
|
+
warnings.append(
|
|
17838
|
+
_warning(
|
|
17839
|
+
"CHART_DELETE_READBACK_UNAVAILABLE",
|
|
17840
|
+
"one or more delete requests completed, but chart_id readback was unavailable",
|
|
17841
|
+
chart_ids=[item.get("chart_id") for item in unavailable if item.get("chart_id")],
|
|
17842
|
+
)
|
|
17843
|
+
)
|
|
16591
17844
|
if readback_unavailable:
|
|
16592
17845
|
warnings.append(_warning("CHART_READBACK_PENDING", "chart readback is unavailable after apply"))
|
|
16593
|
-
elif not verified and not failed_items:
|
|
17846
|
+
elif not verified and not failed_items and not delete_readback_issues:
|
|
16594
17847
|
warnings.append(_warning("CHART_VERIFICATION_INCOMPLETE", "chart apply completed but verification is incomplete"))
|
|
16595
17848
|
return warnings
|
|
16596
17849
|
|
|
@@ -17089,6 +18342,56 @@ def _package_resource_signature(items: Any, *, public: bool) -> tuple[tuple[str,
|
|
|
17089
18342
|
return tuple(sorted(_flatten_package_resource_identities(items, public=public)))
|
|
17090
18343
|
|
|
17091
18344
|
|
|
18345
|
+
def _publicize_package_list_item(item: dict[str, Any]) -> JSONObject:
|
|
18346
|
+
package_id = _coerce_positive_int(item.get("package_id") or item.get("packageId") or item.get("tag_id") or item.get("tagId"))
|
|
18347
|
+
raw_package_id = package_id if package_id is not None else item.get("package_id") or item.get("packageId") or item.get("tag_id") or item.get("tagId")
|
|
18348
|
+
package_name = str(item.get("package_name") or item.get("packageName") or item.get("tag_name") or item.get("tagName") or "").strip()
|
|
18349
|
+
raw_items = item.get("tagItems") if isinstance(item.get("tagItems"), list) else None
|
|
18350
|
+
item_count = None
|
|
18351
|
+
raw_item_count = item.get("item_count") if "item_count" in item else item.get("itemCount")
|
|
18352
|
+
try:
|
|
18353
|
+
if raw_item_count is not None:
|
|
18354
|
+
coerced_count = int(raw_item_count)
|
|
18355
|
+
if coerced_count >= 0:
|
|
18356
|
+
item_count = coerced_count
|
|
18357
|
+
except (TypeError, ValueError):
|
|
18358
|
+
item_count = None
|
|
18359
|
+
if item_count is None and raw_items is not None:
|
|
18360
|
+
item_count = len(raw_items)
|
|
18361
|
+
tag_icon = item.get("tag_icon") if "tag_icon" in item else item.get("tagIcon")
|
|
18362
|
+
return {
|
|
18363
|
+
"package_id": raw_package_id,
|
|
18364
|
+
"package_name": package_name,
|
|
18365
|
+
"tag_id": raw_package_id,
|
|
18366
|
+
"tag_name": package_name,
|
|
18367
|
+
"publish_status": item.get("publish_status") if "publish_status" in item else item.get("publishStatus"),
|
|
18368
|
+
"being_trial": item.get("being_trial") if "being_trial" in item else item.get("beingTrial"),
|
|
18369
|
+
"item_count": item_count,
|
|
18370
|
+
"item_preview": deepcopy(item.get("item_preview") if "item_preview" in item else item.get("itemPreview") or []),
|
|
18371
|
+
"tag_icon": tag_icon,
|
|
18372
|
+
"icon_config": workspace_icon_config(str(tag_icon).strip() if tag_icon not in (None, "") else None),
|
|
18373
|
+
"permissions": {
|
|
18374
|
+
"can_add_app": item.get("can_add_app") if "can_add_app" in item else item.get("addAppStatus"),
|
|
18375
|
+
"can_edit_app": item.get("can_edit_app") if "can_edit_app" in item else item.get("editAppStatus"),
|
|
18376
|
+
"can_delete_app": item.get("can_delete_app") if "can_delete_app" in item else item.get("delAppStatus"),
|
|
18377
|
+
"can_edit_package": item.get("can_edit_package") if "can_edit_package" in item else item.get("editTagStatus"),
|
|
18378
|
+
},
|
|
18379
|
+
}
|
|
18380
|
+
|
|
18381
|
+
|
|
18382
|
+
def _package_list_item_matches_query(item: dict[str, Any], query: str) -> bool:
|
|
18383
|
+
needle = str(query or "").strip().casefold()
|
|
18384
|
+
if not needle:
|
|
18385
|
+
return True
|
|
18386
|
+
haystacks = (
|
|
18387
|
+
item.get("package_id"),
|
|
18388
|
+
item.get("tag_id"),
|
|
18389
|
+
item.get("package_name"),
|
|
18390
|
+
item.get("tag_name"),
|
|
18391
|
+
)
|
|
18392
|
+
return any(needle in str(value or "").casefold() for value in haystacks)
|
|
18393
|
+
|
|
18394
|
+
|
|
17092
18395
|
def _backend_package_items_from_public_items(items: list[dict[str, Any]], group_ids_by_path: dict[tuple[int, ...], int], *, path: tuple[int, ...] = ()) -> list[JSONObject]:
|
|
17093
18396
|
backend_items: list[JSONObject] = []
|
|
17094
18397
|
for index, item in enumerate(items):
|
|
@@ -19360,7 +20663,7 @@ def _compare_view_button_summaries(
|
|
|
19360
20663
|
expected_without_pending = [item for index, item in enumerate(expected) if index not in pending_indexes]
|
|
19361
20664
|
if _sorted_view_button_compare_signatures(actual) == _sorted_view_button_compare_signatures(expected_without_pending):
|
|
19362
20665
|
return {
|
|
19363
|
-
"verified":
|
|
20666
|
+
"verified": False,
|
|
19364
20667
|
"custom_button_readback_pending": True,
|
|
19365
20668
|
"pending_custom_buttons": deepcopy(pending_custom_buttons),
|
|
19366
20669
|
}
|
|
@@ -19371,7 +20674,7 @@ def _compare_view_button_summaries(
|
|
|
19371
20674
|
and _sorted_view_button_compare_signatures(actual_other) == _sorted_view_button_compare_signatures(expected_other)
|
|
19372
20675
|
)
|
|
19373
20676
|
return {
|
|
19374
|
-
"verified":
|
|
20677
|
+
"verified": False,
|
|
19375
20678
|
"custom_button_readback_pending": custom_button_readback_pending,
|
|
19376
20679
|
"pending_custom_buttons": deepcopy(expected_custom) if custom_button_readback_pending else [],
|
|
19377
20680
|
}
|
|
@@ -19601,6 +20904,7 @@ def _normalize_portal_list_items(raw_items: Any) -> list[dict[str, Any]]:
|
|
|
19601
20904
|
"dash_key": dash_key or None,
|
|
19602
20905
|
"dash_name": dash_name or None,
|
|
19603
20906
|
"dash_icon": dash_icon,
|
|
20907
|
+
"icon_config": workspace_icon_config(dash_icon),
|
|
19604
20908
|
"package_tag_ids": package_tag_ids,
|
|
19605
20909
|
}
|
|
19606
20910
|
)
|