@qingflow-tech/qingflow-app-builder-mcp 1.0.10 → 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/src/qingflow_mcp/builder_facade/models.py +41 -2
- package/src/qingflow_mcp/builder_facade/service.py +1261 -141
- package/src/qingflow_mcp/cli/commands/app.py +3 -16
- package/src/qingflow_mcp/cli/commands/builder.py +28 -2
- package/src/qingflow_mcp/cli/commands/record.py +16 -1
- package/src/qingflow_mcp/cli/formatters.py +32 -1
- package/src/qingflow_mcp/public_surface.py +3 -1
- package/src/qingflow_mcp/response_trim.py +55 -3
- package/src/qingflow_mcp/server.py +10 -9
- package/src/qingflow_mcp/server_app_builder.py +26 -5
- 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 +461 -54
- 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 +1262 -103
- 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
|
}
|
|
@@ -2868,13 +2879,29 @@ class AiBuilderFacade:
|
|
|
2868
2879
|
try:
|
|
2869
2880
|
write_executed = True
|
|
2870
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)
|
|
2871
2883
|
removed.append(
|
|
2872
2884
|
{
|
|
2873
2885
|
"index": op["index"],
|
|
2874
2886
|
"operation": "remove",
|
|
2875
|
-
"status": "
|
|
2887
|
+
"status": delete_readback.get("status") or "readback_pending",
|
|
2876
2888
|
"button_id": button_id,
|
|
2877
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
|
+
),
|
|
2878
2905
|
}
|
|
2879
2906
|
)
|
|
2880
2907
|
except (QingflowApiError, RuntimeError) as error:
|
|
@@ -2892,12 +2919,14 @@ class AiBuilderFacade:
|
|
|
2892
2919
|
}
|
|
2893
2920
|
)
|
|
2894
2921
|
|
|
2922
|
+
needs_button_list_readback = bool(created or updated or request.view_configs)
|
|
2895
2923
|
readback_buttons: list[dict[str, Any]] = []
|
|
2896
2924
|
readback_failed = False
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
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
|
|
2901
2930
|
readback_ids = {
|
|
2902
2931
|
button_id
|
|
2903
2932
|
for item in readback_buttons
|
|
@@ -2918,10 +2947,12 @@ class AiBuilderFacade:
|
|
|
2918
2947
|
for item in removed
|
|
2919
2948
|
if _coerce_positive_int(item.get("button_id")) is not None
|
|
2920
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)
|
|
2921
2952
|
verified = (
|
|
2922
2953
|
not readback_failed
|
|
2923
2954
|
and all(button_id in readback_ids for button_id in created_ids + updated_ids)
|
|
2924
|
-
and
|
|
2955
|
+
and removed_verified
|
|
2925
2956
|
and not failed
|
|
2926
2957
|
and all(_coerce_positive_int(item.get("button_id")) is not None for item in created)
|
|
2927
2958
|
)
|
|
@@ -2980,6 +3011,13 @@ class AiBuilderFacade:
|
|
|
2980
3011
|
else "custom button writes all failed or produced no confirmed result; application was not published",
|
|
2981
3012
|
)
|
|
2982
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
|
+
)
|
|
2983
3021
|
response = {
|
|
2984
3022
|
"status": status,
|
|
2985
3023
|
"error_code": error_code,
|
|
@@ -3009,7 +3047,9 @@ class AiBuilderFacade:
|
|
|
3009
3047
|
"readback_loaded": not readback_failed,
|
|
3010
3048
|
"created_verified": not readback_failed and all(button_id in readback_ids for button_id in created_ids),
|
|
3011
3049
|
"updated_verified": not readback_failed and all(button_id in readback_ids for button_id in updated_ids),
|
|
3012
|
-
"removed_verified":
|
|
3050
|
+
"removed_verified": removed_verified,
|
|
3051
|
+
"remove_readback_pending": remove_readback_pending,
|
|
3052
|
+
"removed_readback_results": deepcopy(removed),
|
|
3013
3053
|
"view_button_bindings_verified": view_config_verified,
|
|
3014
3054
|
},
|
|
3015
3055
|
"verified": verified,
|
|
@@ -3374,30 +3414,35 @@ class AiBuilderFacade:
|
|
|
3374
3414
|
return finalize(edit_context_error)
|
|
3375
3415
|
|
|
3376
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"
|
|
3377
3419
|
return finalize(
|
|
3378
3420
|
self._append_publish_result(
|
|
3379
3421
|
profile=profile,
|
|
3380
3422
|
app_key=app_key,
|
|
3381
3423
|
publish=True,
|
|
3382
3424
|
response={
|
|
3383
|
-
"status": "success",
|
|
3384
|
-
"error_code": None,
|
|
3385
|
-
"recoverable":
|
|
3386
|
-
"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",
|
|
3387
3429
|
"normalized_args": normalized_args,
|
|
3388
3430
|
"missing_fields": [],
|
|
3389
3431
|
"allowed_values": {},
|
|
3390
3432
|
"details": {},
|
|
3391
|
-
"request_id":
|
|
3392
|
-
"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}},
|
|
3393
3435
|
"noop": False,
|
|
3394
|
-
"warnings": [],
|
|
3395
|
-
"verification": {"custom_button_deleted":
|
|
3396
|
-
"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,
|
|
3397
3439
|
"app_key": app_key,
|
|
3398
3440
|
"button_id": button_id,
|
|
3399
3441
|
"edit_version_no": edit_version_no,
|
|
3400
|
-
"deleted":
|
|
3442
|
+
"deleted": verified,
|
|
3443
|
+
"delete_executed": True,
|
|
3444
|
+
"readback_status": delete_readback.get("readback_status"),
|
|
3445
|
+
"safe_to_retry_delete": False,
|
|
3401
3446
|
},
|
|
3402
3447
|
)
|
|
3403
3448
|
)
|
|
@@ -3989,7 +4034,16 @@ class AiBuilderFacade:
|
|
|
3989
4034
|
try:
|
|
3990
4035
|
write_executed = True
|
|
3991
4036
|
self._associated_resource_delete(profile=profile, app_key=app_key, associated_item_id=item_id)
|
|
3992
|
-
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
|
+
)
|
|
3993
4047
|
except (QingflowApiError, RuntimeError) as error:
|
|
3994
4048
|
api_error = _coerce_api_error(error)
|
|
3995
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)})
|
|
@@ -4003,10 +4057,16 @@ class AiBuilderFacade:
|
|
|
4003
4057
|
api_error = _coerce_api_error(error)
|
|
4004
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)})
|
|
4005
4059
|
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
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
|
|
4010
4070
|
|
|
4011
4071
|
for index, view_config in enumerate(request.view_configs):
|
|
4012
4072
|
resolved_view_config_ids = resolved_associated_item_refs.get(f"view_configs[{index}].associated_item_ids", [])
|
|
@@ -4074,21 +4134,30 @@ class AiBuilderFacade:
|
|
|
4074
4134
|
api_error = _coerce_api_error(error)
|
|
4075
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)})
|
|
4076
4136
|
|
|
4077
|
-
final_resources: list[dict[str, Any]] = []
|
|
4078
|
-
readback_failed =
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
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
|
|
4083
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
|
+
)
|
|
4084
4151
|
created_ids = [int(item["associated_item_id"]) for item in created if _coerce_positive_int(item.get("associated_item_id")) is not None]
|
|
4085
4152
|
updated_ids = [int(item["associated_item_id"]) for item in updated if _coerce_positive_int(item.get("associated_item_id")) is not None]
|
|
4086
4153
|
unchanged_ids = [int(item["associated_item_id"]) for item in unchanged if _coerce_positive_int(item.get("associated_item_id")) is not None]
|
|
4087
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)
|
|
4088
4157
|
pool_verified = (
|
|
4089
4158
|
not readback_failed
|
|
4090
4159
|
and all(item_id in final_by_id for item_id in created_ids + updated_ids + unchanged_ids)
|
|
4091
|
-
and
|
|
4160
|
+
and removed_verified
|
|
4092
4161
|
and not failed
|
|
4093
4162
|
and all(_coerce_positive_int(item.get("associated_item_id")) is not None for item in created)
|
|
4094
4163
|
)
|
|
@@ -4123,6 +4192,13 @@ class AiBuilderFacade:
|
|
|
4123
4192
|
else "associated resource writes all failed or produced no confirmed result; application was not published",
|
|
4124
4193
|
)
|
|
4125
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
|
+
)
|
|
4126
4202
|
response = {
|
|
4127
4203
|
"status": status,
|
|
4128
4204
|
"error_code": error_code,
|
|
@@ -4145,7 +4221,14 @@ class AiBuilderFacade:
|
|
|
4145
4221
|
"suggested_next_call": None if verified else {"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
4146
4222
|
"noop": False,
|
|
4147
4223
|
"warnings": warnings,
|
|
4148
|
-
"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
|
+
},
|
|
4149
4232
|
"verified": verified,
|
|
4150
4233
|
"app_key": app_key,
|
|
4151
4234
|
"app_name": app_name,
|
|
@@ -4634,12 +4717,14 @@ class AiBuilderFacade:
|
|
|
4634
4717
|
or base_result.get("name")
|
|
4635
4718
|
or app_key
|
|
4636
4719
|
).strip() or app_key
|
|
4720
|
+
app_icon = str(base_result.get("appIcon") or "").strip() or None
|
|
4637
4721
|
response = AppReadSummaryResponse(
|
|
4638
4722
|
app_key=app_key,
|
|
4639
4723
|
app_name=app_name,
|
|
4640
4724
|
name=app_name,
|
|
4641
4725
|
title=app_name,
|
|
4642
|
-
app_icon=
|
|
4726
|
+
app_icon=app_icon,
|
|
4727
|
+
icon_config=workspace_icon_config(app_icon),
|
|
4643
4728
|
visibility=_public_visibility_from_member_auth(base_result.get("auth")),
|
|
4644
4729
|
tag_ids=_coerce_int_list(base_result.get("tagIds")),
|
|
4645
4730
|
publish_status=base_result.get("appPublishStatus"),
|
|
@@ -5023,10 +5108,32 @@ class AiBuilderFacade:
|
|
|
5023
5108
|
suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
|
|
5024
5109
|
)
|
|
5025
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
|
+
)
|
|
5026
5131
|
response = AppFieldsReadResponse(
|
|
5027
5132
|
app_key=app_key,
|
|
5028
5133
|
fields=[_compact_public_field_read(field=field, layout=parsed["layout"]) for field in parsed["fields"]],
|
|
5029
5134
|
field_count=len(parsed["fields"]),
|
|
5135
|
+
chart_fields=chart_fields,
|
|
5136
|
+
chart_field_count=len(chart_fields),
|
|
5030
5137
|
form_settings=_form_settings_from_schema(state["schema"], parsed["fields"]),
|
|
5031
5138
|
)
|
|
5032
5139
|
return {
|
|
@@ -5041,7 +5148,7 @@ class AiBuilderFacade:
|
|
|
5041
5148
|
"request_id": None,
|
|
5042
5149
|
"suggested_next_call": None,
|
|
5043
5150
|
"noop": False,
|
|
5044
|
-
"warnings":
|
|
5151
|
+
"warnings": warnings,
|
|
5045
5152
|
"verification": {"app_exists": True},
|
|
5046
5153
|
"verified": True,
|
|
5047
5154
|
**response.model_dump(mode="json"),
|
|
@@ -6034,6 +6141,7 @@ class AiBuilderFacade:
|
|
|
6034
6141
|
details={"dash_key": dash_key, "being_draft": being_draft},
|
|
6035
6142
|
suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
|
|
6036
6143
|
)
|
|
6144
|
+
dash_icon = str(result.get("dashIcon") or "").strip() or None
|
|
6037
6145
|
response = PortalGetResponse(
|
|
6038
6146
|
dash_key=dash_key,
|
|
6039
6147
|
being_draft=being_draft,
|
|
@@ -6047,7 +6155,8 @@ class AiBuilderFacade:
|
|
|
6047
6155
|
)
|
|
6048
6156
|
if tag_id is not None
|
|
6049
6157
|
],
|
|
6050
|
-
dash_icon=
|
|
6158
|
+
dash_icon=dash_icon,
|
|
6159
|
+
icon_config=workspace_icon_config(dash_icon),
|
|
6051
6160
|
hide_copyright=bool(result.get("hideCopyright")) if "hideCopyright" in result else None,
|
|
6052
6161
|
visibility=_public_visibility_from_member_auth(result.get("auth")),
|
|
6053
6162
|
auth=deepcopy(result.get("auth")) if isinstance(result.get("auth"), dict) else {},
|
|
@@ -6086,6 +6195,7 @@ class AiBuilderFacade:
|
|
|
6086
6195
|
details={"dash_key": dash_key, "being_draft": being_draft},
|
|
6087
6196
|
suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
|
|
6088
6197
|
)
|
|
6198
|
+
dash_icon = str(result.get("dashIcon") or "").strip() or None
|
|
6089
6199
|
response = PortalReadSummaryResponse(
|
|
6090
6200
|
dash_key=dash_key,
|
|
6091
6201
|
being_draft=being_draft,
|
|
@@ -6099,7 +6209,8 @@ class AiBuilderFacade:
|
|
|
6099
6209
|
)
|
|
6100
6210
|
if tag_id is not None
|
|
6101
6211
|
],
|
|
6102
|
-
dash_icon=
|
|
6212
|
+
dash_icon=dash_icon,
|
|
6213
|
+
icon_config=workspace_icon_config(dash_icon),
|
|
6103
6214
|
hide_copyright=bool(result.get("hideCopyright")) if "hideCopyright" in result else None,
|
|
6104
6215
|
config_keys=sorted(str(key) for key in (result.get("config") or {}).keys()) if isinstance(result.get("config"), dict) else [],
|
|
6105
6216
|
dash_global_config_keys=sorted(str(key) for key in (result.get("dashGlobalConfig") or {}).keys()) if isinstance(result.get("dashGlobalConfig"), dict) else [],
|
|
@@ -8404,13 +8515,56 @@ class AiBuilderFacade:
|
|
|
8404
8515
|
if len(matches) == 1:
|
|
8405
8516
|
key = _extract_view_key(matches[0])
|
|
8406
8517
|
removed_name = _extract_view_name(matches[0]) or selector_text
|
|
8407
|
-
|
|
8408
|
-
|
|
8409
|
-
|
|
8410
|
-
|
|
8411
|
-
|
|
8412
|
-
|
|
8413
|
-
|
|
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))
|
|
8414
8568
|
created: list[str] = []
|
|
8415
8569
|
updated: list[str] = []
|
|
8416
8570
|
existing_view_list = [
|
|
@@ -8950,17 +9104,21 @@ class AiBuilderFacade:
|
|
|
8950
9104
|
failed_views.append(failure_entry)
|
|
8951
9105
|
view_results.append(failure_entry)
|
|
8952
9106
|
continue
|
|
8953
|
-
|
|
8954
|
-
|
|
8955
|
-
|
|
8956
|
-
|
|
8957
|
-
|
|
8958
|
-
|
|
8959
|
-
|
|
8960
|
-
|
|
8961
|
-
|
|
8962
|
-
|
|
8963
|
-
|
|
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
|
+
))
|
|
8964
9122
|
verified_names = {
|
|
8965
9123
|
_extract_view_name(item)
|
|
8966
9124
|
for item in (verified_view_result or [])
|
|
@@ -9248,8 +9406,27 @@ class AiBuilderFacade:
|
|
|
9248
9406
|
"view_key": item.get("view_key"),
|
|
9249
9407
|
"type": item.get("type"),
|
|
9250
9408
|
"status": "removed",
|
|
9251
|
-
"present_in_readback":
|
|
9252
|
-
"removed_verified":
|
|
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"),
|
|
9253
9430
|
}
|
|
9254
9431
|
)
|
|
9255
9432
|
else:
|
|
@@ -9262,10 +9439,17 @@ class AiBuilderFacade:
|
|
|
9262
9439
|
"error_code": item.get("error_code"),
|
|
9263
9440
|
}
|
|
9264
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)
|
|
9265
9449
|
verified = (
|
|
9266
9450
|
(not verified_views_unavailable)
|
|
9267
9451
|
and all(name in verified_names for name in created + updated)
|
|
9268
|
-
and
|
|
9452
|
+
and removed_verified
|
|
9269
9453
|
)
|
|
9270
9454
|
view_filters_verified = verified and not filter_readback_pending and not filter_mismatches
|
|
9271
9455
|
view_query_conditions_verified = verified and not query_condition_readback_pending and not query_condition_mismatches
|
|
@@ -9360,6 +9544,13 @@ class AiBuilderFacade:
|
|
|
9360
9544
|
"system buttons verified, but draft custom button bindings are not fully visible through view readback yet",
|
|
9361
9545
|
)
|
|
9362
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
|
+
)
|
|
9363
9554
|
all_verified = verified and view_filters_verified and view_query_conditions_verified and view_associated_resources_verified and view_buttons_verified
|
|
9364
9555
|
response = {
|
|
9365
9556
|
"status": "success" if all_verified else "partial_success",
|
|
@@ -9407,6 +9598,7 @@ class AiBuilderFacade:
|
|
|
9407
9598
|
"query_condition_readback_pending": query_condition_readback_pending,
|
|
9408
9599
|
"associated_resource_readback_pending": associated_resource_readback_pending,
|
|
9409
9600
|
"button_readback_pending": button_readback_pending,
|
|
9601
|
+
"delete_readback_pending": delete_readback_pending,
|
|
9410
9602
|
"custom_button_readback_pending": custom_button_readback_pending,
|
|
9411
9603
|
"custom_button_readback_pending_entries": deepcopy(custom_button_readback_pending_entries),
|
|
9412
9604
|
"by_view": verification_by_view,
|
|
@@ -9671,6 +9863,159 @@ class AiBuilderFacade:
|
|
|
9671
9863
|
)
|
|
9672
9864
|
return expanded, issues, results
|
|
9673
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
|
+
|
|
9674
10019
|
def chart_apply(self, *, profile: str, request: ChartApplyRequest) -> JSONObject:
|
|
9675
10020
|
normalized_args = request.model_dump(mode="json")
|
|
9676
10021
|
permission_outcomes: list[PermissionCheckOutcome] = []
|
|
@@ -9695,21 +10040,27 @@ class AiBuilderFacade:
|
|
|
9695
10040
|
def finalize(response: JSONObject) -> JSONObject:
|
|
9696
10041
|
return _apply_permission_outcomes(response, *permission_outcomes)
|
|
9697
10042
|
|
|
9698
|
-
|
|
9699
|
-
|
|
9700
|
-
|
|
9701
|
-
|
|
9702
|
-
|
|
9703
|
-
|
|
9704
|
-
|
|
9705
|
-
|
|
9706
|
-
|
|
9707
|
-
"
|
|
9708
|
-
|
|
9709
|
-
|
|
9710
|
-
|
|
9711
|
-
|
|
9712
|
-
|
|
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
|
+
))
|
|
9713
10064
|
|
|
9714
10065
|
field_lookup = _build_public_field_lookup(fields)
|
|
9715
10066
|
qingbi_fields_by_id = {
|
|
@@ -9717,6 +10068,11 @@ class AiBuilderFacade:
|
|
|
9717
10068
|
for item in qingbi_fields
|
|
9718
10069
|
if isinstance(item, dict) and item.get("fieldId")
|
|
9719
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
|
+
)
|
|
9720
10076
|
existing_by_id = {
|
|
9721
10077
|
_extract_chart_identifier(item): deepcopy(item)
|
|
9722
10078
|
for item in existing_chart_items
|
|
@@ -9759,8 +10115,12 @@ class AiBuilderFacade:
|
|
|
9759
10115
|
updated_ids: list[str] = []
|
|
9760
10116
|
removed_ids: list[str] = []
|
|
9761
10117
|
failed_items: list[dict[str, Any]] = []
|
|
10118
|
+
delete_readback_issues: list[dict[str, Any]] = []
|
|
9762
10119
|
|
|
9763
10120
|
for patch in upsert_charts:
|
|
10121
|
+
chart_id = ""
|
|
10122
|
+
target_type = ""
|
|
10123
|
+
config_payload: dict[str, Any] | None = None
|
|
9764
10124
|
try:
|
|
9765
10125
|
dataset_source = _chart_patch_dataset_source_type(patch)
|
|
9766
10126
|
if dataset_source:
|
|
@@ -9799,6 +10159,14 @@ class AiBuilderFacade:
|
|
|
9799
10159
|
f"existing chart '{chart_id or patch.name}' uses dataset report source '{existing_source_type}' and is not supported for update yet. "
|
|
9800
10160
|
"Update it in QingBI directly, then attach the existing report with app_associated_resources_apply using report_source='dataset'."
|
|
9801
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
|
+
)
|
|
9802
10170
|
if existing is None:
|
|
9803
10171
|
temp_chart_id = str(patch.chart_id or f"mcp_{uuid4().hex[:16]}")
|
|
9804
10172
|
create_payload = {
|
|
@@ -9882,13 +10250,7 @@ class AiBuilderFacade:
|
|
|
9882
10250
|
|
|
9883
10251
|
config_updated = False
|
|
9884
10252
|
if existing is None or config_update_requested:
|
|
9885
|
-
|
|
9886
|
-
patch=patch,
|
|
9887
|
-
app_key=app_key,
|
|
9888
|
-
field_lookup=field_lookup,
|
|
9889
|
-
qingbi_fields_by_id=qingbi_fields_by_id,
|
|
9890
|
-
)
|
|
9891
|
-
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 {})
|
|
9892
10254
|
config_updated = True
|
|
9893
10255
|
if existing is not None and chart_id not in updated_ids and config_updated:
|
|
9894
10256
|
updated_ids.append(chart_id)
|
|
@@ -9918,11 +10280,21 @@ class AiBuilderFacade:
|
|
|
9918
10280
|
)
|
|
9919
10281
|
except (QingflowApiError, RuntimeError, ValueError, VisibilityResolutionError) as error:
|
|
9920
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
|
+
)
|
|
9921
10290
|
failure = {
|
|
9922
|
-
"chart_id": str(
|
|
10291
|
+
"chart_id": str(chart_id or patch.chart_id or ""),
|
|
9923
10292
|
"name": patch.name,
|
|
10293
|
+
"chart_type": patch.chart_type.value,
|
|
9924
10294
|
"status": "failed",
|
|
9925
|
-
"
|
|
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,
|
|
9926
10298
|
"request_id": api_error.request_id if api_error else None,
|
|
9927
10299
|
"backend_code": api_error.backend_code if api_error else None,
|
|
9928
10300
|
"http_status": None if api_error is None or api_error.http_status == 404 else api_error.http_status,
|
|
@@ -9934,12 +10306,18 @@ class AiBuilderFacade:
|
|
|
9934
10306
|
try:
|
|
9935
10307
|
self.charts.qingbi_report_delete(profile=profile, chart_id=chart_id)
|
|
9936
10308
|
removed_ids.append(chart_id)
|
|
9937
|
-
|
|
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)
|
|
9938
10313
|
except (QingflowApiError, RuntimeError) as error:
|
|
9939
10314
|
api_error = _coerce_api_error(error)
|
|
9940
10315
|
failure = {
|
|
9941
10316
|
"chart_id": chart_id,
|
|
10317
|
+
"operation": "delete",
|
|
9942
10318
|
"status": "failed",
|
|
10319
|
+
"delete_executed": False,
|
|
10320
|
+
"safe_to_retry_delete": True,
|
|
9943
10321
|
"message": _public_error_message("CHART_APPLY_FAILED", api_error),
|
|
9944
10322
|
"request_id": api_error.request_id,
|
|
9945
10323
|
"backend_code": api_error.backend_code,
|
|
@@ -9974,30 +10352,37 @@ class AiBuilderFacade:
|
|
|
9974
10352
|
chart_results.append(failure)
|
|
9975
10353
|
|
|
9976
10354
|
noop = not created_ids and not updated_ids and not removed_ids and not reordered and not failed_items
|
|
9977
|
-
|
|
9978
|
-
|
|
9979
|
-
|
|
9980
|
-
|
|
9981
|
-
|
|
9982
|
-
|
|
9983
|
-
|
|
9984
|
-
|
|
9985
|
-
|
|
9986
|
-
and all(chart_id not in readback_ids for chart_id in removed_ids)
|
|
9987
|
-
)
|
|
9988
|
-
if request.reorder_chart_ids:
|
|
9989
|
-
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 = {
|
|
9990
10364
|
_extract_chart_identifier(item)
|
|
9991
10365
|
for item in readback_items
|
|
9992
10366
|
if isinstance(item, dict) and _extract_chart_identifier(item)
|
|
9993
|
-
|
|
9994
|
-
|
|
9995
|
-
|
|
9996
|
-
|
|
9997
|
-
|
|
9998
|
-
|
|
9999
|
-
|
|
10000
|
-
|
|
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
|
|
10001
10386
|
|
|
10002
10387
|
if failed_items:
|
|
10003
10388
|
successful_changes = bool(created_ids or updated_ids or removed_ids or reordered)
|
|
@@ -10019,11 +10404,13 @@ class AiBuilderFacade:
|
|
|
10019
10404
|
failed_items=failed_items,
|
|
10020
10405
|
readback_unavailable=readback_unavailable,
|
|
10021
10406
|
verified=False if failed_items else verified,
|
|
10407
|
+
delete_readback_issues=delete_readback_issues,
|
|
10022
10408
|
),
|
|
10023
10409
|
"verification": {
|
|
10024
10410
|
"charts_verified": False if failed_items else verified,
|
|
10025
|
-
"readback_unavailable":
|
|
10026
|
-
"
|
|
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,
|
|
10027
10414
|
"chart_list_source": readback_list_source or existing_chart_list_source,
|
|
10028
10415
|
},
|
|
10029
10416
|
"app_key": app_key,
|
|
@@ -10032,27 +10419,37 @@ class AiBuilderFacade:
|
|
|
10032
10419
|
"verified": False if failed_items else verified,
|
|
10033
10420
|
})
|
|
10034
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
|
+
)
|
|
10035
10430
|
return finalize({
|
|
10036
10431
|
"status": "success" if result_verified else "partial_success",
|
|
10037
|
-
"error_code": None if result_verified else
|
|
10432
|
+
"error_code": None if result_verified else pending_error_code,
|
|
10038
10433
|
"recoverable": not result_verified,
|
|
10039
|
-
"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),
|
|
10040
10435
|
"normalized_args": normalized_args,
|
|
10041
10436
|
"missing_fields": [],
|
|
10042
10437
|
"allowed_values": {"chart.chart_type": [member.value for member in PublicChartType], "chart.filter.operator": [member.value for member in ViewFilterOperator]},
|
|
10043
10438
|
"details": {},
|
|
10044
10439
|
"request_id": None,
|
|
10045
|
-
"suggested_next_call": None if result_verified else
|
|
10440
|
+
"suggested_next_call": None if result_verified else pending_suggestion,
|
|
10046
10441
|
"noop": noop,
|
|
10047
10442
|
"warnings": _chart_apply_warnings(
|
|
10048
10443
|
failed_items=[],
|
|
10049
10444
|
readback_unavailable=False if noop else readback_unavailable,
|
|
10050
10445
|
verified=result_verified,
|
|
10446
|
+
delete_readback_issues=delete_readback_issues,
|
|
10051
10447
|
),
|
|
10052
10448
|
"verification": {
|
|
10053
10449
|
"charts_verified": result_verified,
|
|
10054
|
-
"readback_unavailable": False if noop else
|
|
10055
|
-
"
|
|
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,
|
|
10056
10453
|
"chart_list_source": existing_chart_list_source if noop else readback_list_source,
|
|
10057
10454
|
},
|
|
10058
10455
|
"app_key": app_key,
|
|
@@ -10168,6 +10565,7 @@ class AiBuilderFacade:
|
|
|
10168
10565
|
suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
|
|
10169
10566
|
)
|
|
10170
10567
|
try:
|
|
10568
|
+
layout_diagnostics: dict[str, Any] = _empty_portal_layout_diagnostics()
|
|
10171
10569
|
if creating:
|
|
10172
10570
|
create_payload = _build_public_portal_base_payload(
|
|
10173
10571
|
dash_name=request.dash_name or "未命名门户",
|
|
@@ -10204,7 +10602,12 @@ class AiBuilderFacade:
|
|
|
10204
10602
|
base_payload=base_payload,
|
|
10205
10603
|
)
|
|
10206
10604
|
if sections_requested:
|
|
10207
|
-
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)
|
|
10208
10611
|
update_payload["components"] = component_payload
|
|
10209
10612
|
self.portals.portal_update(profile=profile, dash_key=dash_key, payload=update_payload)
|
|
10210
10613
|
self.portals.portal_update_base_info(
|
|
@@ -10307,6 +10710,7 @@ class AiBuilderFacade:
|
|
|
10307
10710
|
live_meta_verified=live_meta_verified,
|
|
10308
10711
|
publish_requested=request.publish,
|
|
10309
10712
|
)
|
|
10713
|
+
warnings.extend(_portal_layout_warning_items(layout_diagnostics))
|
|
10310
10714
|
return finalize({
|
|
10311
10715
|
"status": status,
|
|
10312
10716
|
"error_code": error_code,
|
|
@@ -10333,6 +10737,7 @@ class AiBuilderFacade:
|
|
|
10333
10737
|
"suggested_next_call": None if verified else {"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
|
|
10334
10738
|
"noop": False,
|
|
10335
10739
|
"warnings": warnings,
|
|
10740
|
+
"layout_diagnostics": layout_diagnostics,
|
|
10336
10741
|
"verification": {
|
|
10337
10742
|
"draft_verified": draft_verified,
|
|
10338
10743
|
"draft_metadata_verified": draft_meta_verified,
|
|
@@ -10943,6 +11348,7 @@ class AiBuilderFacade:
|
|
|
10943
11348
|
*,
|
|
10944
11349
|
profile: str,
|
|
10945
11350
|
sections: list[PortalSectionPatch],
|
|
11351
|
+
layout_preset: str | None = None,
|
|
10946
11352
|
) -> list[dict[str, Any]]:
|
|
10947
11353
|
resolved_components: list[dict[str, Any]] = []
|
|
10948
11354
|
pc_x = 0
|
|
@@ -10959,9 +11365,15 @@ class AiBuilderFacade:
|
|
|
10959
11365
|
pc_y=pc_y,
|
|
10960
11366
|
pc_row_height=pc_row_height,
|
|
10961
11367
|
mobile_y=mobile_y,
|
|
11368
|
+
layout_preset=layout_preset,
|
|
10962
11369
|
)
|
|
10963
11370
|
else:
|
|
10964
|
-
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
|
+
)
|
|
10965
11377
|
dash_style = deepcopy(section.dash_style_config) if isinstance(section.dash_style_config, dict) else None
|
|
10966
11378
|
component: dict[str, Any]
|
|
10967
11379
|
if section.source_type == "chart":
|
|
@@ -12360,6 +12772,28 @@ def _public_error_message(error_code: str, error: QingflowApiError) -> str:
|
|
|
12360
12772
|
return mapping.get(error_code, "requested builder resource is unavailable in the current route")
|
|
12361
12773
|
|
|
12362
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
|
+
|
|
12363
12797
|
def _extract_edit_lock_owner(message: str) -> JSONObject:
|
|
12364
12798
|
text = str(message or "").strip()
|
|
12365
12799
|
if not text:
|
|
@@ -12664,18 +13098,20 @@ def _build_public_dimension_fields(
|
|
|
12664
13098
|
*,
|
|
12665
13099
|
app_key: str,
|
|
12666
13100
|
field_lookup: dict[str, dict[str, Any]],
|
|
13101
|
+
chart_field_lookup: dict[str, Any],
|
|
12667
13102
|
qingbi_fields_by_id: dict[str, dict[str, Any]],
|
|
13103
|
+
chart_type: str = "chart",
|
|
12668
13104
|
) -> list[dict[str, Any]]:
|
|
12669
13105
|
dimensions: list[dict[str, Any]] = []
|
|
12670
13106
|
for selector in selectors:
|
|
12671
|
-
|
|
12672
|
-
field_id =
|
|
12673
|
-
|
|
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 {}
|
|
12674
13110
|
dimensions.append(
|
|
12675
13111
|
{
|
|
12676
13112
|
"fieldId": field_id,
|
|
12677
|
-
"fieldName": qingbi_field.get("fieldName") or
|
|
12678
|
-
"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 "")),
|
|
12679
13115
|
"orderType": "default",
|
|
12680
13116
|
"alignType": "left",
|
|
12681
13117
|
"dateFormat": "yyyy-MM-dd",
|
|
@@ -12717,27 +13153,540 @@ def _default_public_total_metric() -> dict[str, Any]:
|
|
|
12717
13153
|
}
|
|
12718
13154
|
|
|
12719
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
|
+
|
|
12720
13664
|
def _build_public_metric_fields(
|
|
12721
13665
|
selectors: list[str],
|
|
12722
13666
|
*,
|
|
12723
13667
|
app_key: str,
|
|
12724
13668
|
field_lookup: dict[str, dict[str, Any]],
|
|
13669
|
+
chart_field_lookup: dict[str, Any],
|
|
12725
13670
|
qingbi_fields_by_id: dict[str, dict[str, Any]],
|
|
12726
13671
|
aggregate: str,
|
|
13672
|
+
chart_type: str = "chart",
|
|
12727
13673
|
) -> list[dict[str, Any]]:
|
|
12728
13674
|
normalized_aggregate = str(aggregate or "count").strip().lower()
|
|
12729
13675
|
if normalized_aggregate == "count" or not selectors:
|
|
12730
13676
|
return [_default_public_total_metric()]
|
|
12731
13677
|
metrics: list[dict[str, Any]] = []
|
|
12732
13678
|
for selector in selectors:
|
|
12733
|
-
|
|
12734
|
-
field_id =
|
|
12735
|
-
|
|
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 {}
|
|
12736
13685
|
metrics.append(
|
|
12737
13686
|
{
|
|
12738
13687
|
"fieldId": field_id,
|
|
12739
|
-
"fieldName": qingbi_field.get("fieldName") or
|
|
12740
|
-
"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 "")),
|
|
12741
13690
|
"orderType": "default",
|
|
12742
13691
|
"alignType": "left",
|
|
12743
13692
|
"dateFormat": "yyyy-MM-dd",
|
|
@@ -12754,6 +13703,8 @@ def _build_public_metric_fields(
|
|
|
12754
13703
|
"supId": qingbi_field.get("supId"),
|
|
12755
13704
|
"beingTable": bool(qingbi_field.get("beingTable", False)),
|
|
12756
13705
|
"returnType": qingbi_field.get("returnType"),
|
|
13706
|
+
"biFormulaType": qingbi_field.get("biFormulaType"),
|
|
13707
|
+
"aggreFieldId": qingbi_field.get("aggreFieldId"),
|
|
12757
13708
|
}
|
|
12758
13709
|
)
|
|
12759
13710
|
return metrics or [_default_public_total_metric()]
|
|
@@ -12783,7 +13734,9 @@ def _build_public_chart_filter_matrix(
|
|
|
12783
13734
|
*,
|
|
12784
13735
|
app_key: str,
|
|
12785
13736
|
field_lookup: dict[str, dict[str, Any]],
|
|
13737
|
+
chart_field_lookup: dict[str, Any],
|
|
12786
13738
|
qingbi_fields_by_id: dict[str, dict[str, Any]],
|
|
13739
|
+
chart_type: str = "chart",
|
|
12787
13740
|
) -> list[list[dict[str, Any]]]:
|
|
12788
13741
|
if not rules:
|
|
12789
13742
|
return []
|
|
@@ -12799,16 +13752,21 @@ def _build_public_chart_filter_matrix(
|
|
|
12799
13752
|
ViewFilterOperator.not_empty.value: 16,
|
|
12800
13753
|
}
|
|
12801
13754
|
for rule in rules:
|
|
12802
|
-
|
|
12803
|
-
|
|
12804
|
-
|
|
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 {}
|
|
12805
13763
|
operator = str(getattr(rule, "operator", ViewFilterOperator.eq.value).value if hasattr(getattr(rule, "operator", None), "value") else getattr(rule, "operator", ViewFilterOperator.eq.value))
|
|
12806
13764
|
values = list(getattr(rule, "values", []) or [])
|
|
12807
13765
|
group.append(
|
|
12808
13766
|
{
|
|
12809
13767
|
"fieldId": field_id,
|
|
12810
|
-
"fieldName": qingbi_field.get("fieldName") or
|
|
12811
|
-
"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 "")),
|
|
12812
13770
|
"judgeType": judge_map.get(operator, 0),
|
|
12813
13771
|
"judgeValues": values,
|
|
12814
13772
|
"matchType": 1,
|
|
@@ -12822,6 +13780,7 @@ def _build_public_chart_config_payload(
|
|
|
12822
13780
|
patch: ChartUpsertPatch,
|
|
12823
13781
|
app_key: str,
|
|
12824
13782
|
field_lookup: dict[str, dict[str, Any]],
|
|
13783
|
+
chart_field_lookup: dict[str, Any],
|
|
12825
13784
|
qingbi_fields_by_id: dict[str, dict[str, Any]],
|
|
12826
13785
|
) -> dict[str, Any]:
|
|
12827
13786
|
config = deepcopy(patch.config)
|
|
@@ -12840,12 +13799,19 @@ def _build_public_chart_config_payload(
|
|
|
12840
13799
|
patch.filters,
|
|
12841
13800
|
app_key=app_key,
|
|
12842
13801
|
field_lookup=field_lookup,
|
|
13802
|
+
chart_field_lookup=chart_field_lookup,
|
|
12843
13803
|
qingbi_fields_by_id=qingbi_fields_by_id,
|
|
13804
|
+
chart_type=patch.chart_type.value,
|
|
12844
13805
|
)
|
|
12845
13806
|
query_condition_field_ids = []
|
|
12846
13807
|
for selector in list(config.pop("query_condition_field_ids", []) or []):
|
|
12847
|
-
field =
|
|
12848
|
-
|
|
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))
|
|
12849
13815
|
backend_chart_type = _map_public_chart_type_to_backend(patch.chart_type)
|
|
12850
13816
|
if backend_chart_type == "gauge" and not patch.indicator_field_ids and "selectedMetrics" not in config:
|
|
12851
13817
|
raise ValueError("gauge charts require at least one indicator_field_ids value; pass one metric and the CLI will pair it with 数据总量")
|
|
@@ -12853,14 +13819,18 @@ def _build_public_chart_config_payload(
|
|
|
12853
13819
|
patch.dimension_field_ids,
|
|
12854
13820
|
app_key=app_key,
|
|
12855
13821
|
field_lookup=field_lookup,
|
|
13822
|
+
chart_field_lookup=chart_field_lookup,
|
|
12856
13823
|
qingbi_fields_by_id=qingbi_fields_by_id,
|
|
13824
|
+
chart_type=patch.chart_type.value,
|
|
12857
13825
|
)
|
|
12858
13826
|
selected_metrics = _build_public_metric_fields(
|
|
12859
13827
|
patch.indicator_field_ids,
|
|
12860
13828
|
app_key=app_key,
|
|
12861
13829
|
field_lookup=field_lookup,
|
|
13830
|
+
chart_field_lookup=chart_field_lookup,
|
|
12862
13831
|
qingbi_fields_by_id=qingbi_fields_by_id,
|
|
12863
13832
|
aggregate=aggregate,
|
|
13833
|
+
chart_type=patch.chart_type.value,
|
|
12864
13834
|
)
|
|
12865
13835
|
payload: dict[str, Any] = {
|
|
12866
13836
|
"chartName": patch.name,
|
|
@@ -12914,6 +13884,7 @@ def _build_public_chart_config_payload(
|
|
|
12914
13884
|
if key in config:
|
|
12915
13885
|
payload[key] = deepcopy(config.pop(key))
|
|
12916
13886
|
payload.update(config)
|
|
13887
|
+
_validate_public_chart_payload_rules(payload)
|
|
12917
13888
|
return payload
|
|
12918
13889
|
|
|
12919
13890
|
|
|
@@ -13089,7 +14060,9 @@ def _find_chart_by_name(items: Any, *, chart_name: str, chart_type: str | None =
|
|
|
13089
14060
|
return deepcopy(candidates[-1])
|
|
13090
14061
|
|
|
13091
14062
|
|
|
13092
|
-
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))
|
|
13093
14066
|
return {
|
|
13094
14067
|
"pc": {
|
|
13095
14068
|
"x": int(getattr(position, "pc_x", 0)),
|
|
@@ -13098,10 +14071,10 @@ def _portal_position_payload(position: Any) -> dict[str, Any]:
|
|
|
13098
14071
|
"rows": int(getattr(position, "pc_h", 8)),
|
|
13099
14072
|
},
|
|
13100
14073
|
"mobile": {
|
|
13101
|
-
"x": int(getattr(position, "mobile_x", 0)),
|
|
13102
|
-
"y": int(getattr(position, "mobile_y", 0)),
|
|
13103
|
-
"cols": int(getattr(position, "mobile_w",
|
|
13104
|
-
"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,
|
|
13105
14078
|
},
|
|
13106
14079
|
}
|
|
13107
14080
|
|
|
@@ -13113,6 +14086,7 @@ def _portal_component_position_public(
|
|
|
13113
14086
|
pc_y: int,
|
|
13114
14087
|
pc_row_height: int,
|
|
13115
14088
|
mobile_y: int,
|
|
14089
|
+
layout_preset: str | None = None,
|
|
13116
14090
|
) -> tuple[dict[str, Any], int, int, int, int]:
|
|
13117
14091
|
source_name = str(source_type or "").lower()
|
|
13118
14092
|
if source_name == "filter":
|
|
@@ -13131,8 +14105,11 @@ def _portal_component_position_public(
|
|
|
13131
14105
|
cols = 12
|
|
13132
14106
|
rows = 2
|
|
13133
14107
|
else:
|
|
13134
|
-
|
|
13135
|
-
|
|
14108
|
+
if layout_preset == "dashboard_2col":
|
|
14109
|
+
cols = 12
|
|
14110
|
+
else:
|
|
14111
|
+
cols = 8
|
|
14112
|
+
rows = 6
|
|
13136
14113
|
if cols == 24:
|
|
13137
14114
|
if pc_x != 0:
|
|
13138
14115
|
pc_y += pc_row_height
|
|
@@ -13155,6 +14132,73 @@ def _portal_component_position_public(
|
|
|
13155
14132
|
return position, next_pc_x, next_pc_y, next_row_height, mobile_y + rows
|
|
13156
14133
|
|
|
13157
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
|
+
|
|
13158
14202
|
def _resolve_chart_reference(*, charts: QingbiReportTools, profile: str, ref: Any) -> dict[str, Any]:
|
|
13159
14203
|
app_key = str(getattr(ref, "app_key", "") or "").strip()
|
|
13160
14204
|
chart_id = str(getattr(ref, "chart_id", "") or "").strip()
|
|
@@ -16768,13 +17812,38 @@ def _publish_verify_warnings(*, package_attached: bool | None, views_unavailable
|
|
|
16768
17812
|
return warnings
|
|
16769
17813
|
|
|
16770
17814
|
|
|
16771
|
-
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]]:
|
|
16772
17822
|
warnings: list[dict[str, Any]] = []
|
|
17823
|
+
delete_readback_issues = delete_readback_issues or []
|
|
16773
17824
|
if failed_items:
|
|
16774
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
|
+
)
|
|
16775
17844
|
if readback_unavailable:
|
|
16776
17845
|
warnings.append(_warning("CHART_READBACK_PENDING", "chart readback is unavailable after apply"))
|
|
16777
|
-
elif not verified and not failed_items:
|
|
17846
|
+
elif not verified and not failed_items and not delete_readback_issues:
|
|
16778
17847
|
warnings.append(_warning("CHART_VERIFICATION_INCOMPLETE", "chart apply completed but verification is incomplete"))
|
|
16779
17848
|
return warnings
|
|
16780
17849
|
|
|
@@ -17273,6 +18342,56 @@ def _package_resource_signature(items: Any, *, public: bool) -> tuple[tuple[str,
|
|
|
17273
18342
|
return tuple(sorted(_flatten_package_resource_identities(items, public=public)))
|
|
17274
18343
|
|
|
17275
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
|
+
|
|
17276
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]:
|
|
17277
18396
|
backend_items: list[JSONObject] = []
|
|
17278
18397
|
for index, item in enumerate(items):
|
|
@@ -19785,6 +20904,7 @@ def _normalize_portal_list_items(raw_items: Any) -> list[dict[str, Any]]:
|
|
|
19785
20904
|
"dash_key": dash_key or None,
|
|
19786
20905
|
"dash_name": dash_name or None,
|
|
19787
20906
|
"dash_icon": dash_icon,
|
|
20907
|
+
"icon_config": workspace_icon_config(dash_icon),
|
|
19788
20908
|
"package_tag_ids": package_tag_ids,
|
|
19789
20909
|
}
|
|
19790
20910
|
)
|