@josephyan/qingflow-cli 0.2.0-beta.61 → 0.2.0-beta.63

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.
@@ -21,6 +21,7 @@ from ..solution.compiler.view_compiler import VIEW_TYPE_MAP
21
21
  from ..solution.executor import _build_viewgraph_questions, _compact_dict, extract_field_map
22
22
  from ..solution.spec_models import FieldType, FormLayoutRowSpec, FormLayoutSectionSpec, ViewSpec
23
23
  from ..tools.app_tools import AppTools
24
+ from ..tools.custom_button_tools import CustomButtonTools
24
25
  from ..tools.directory_tools import DirectoryTools
25
26
  from ..tools.package_tools import PackageTools
26
27
  from ..tools.portal_tools import PortalTools
@@ -39,6 +40,8 @@ from .models import (
39
40
  AppViewsReadResponse,
40
41
  ChartApplyRequest,
41
42
  ChartUpsertPatch,
43
+ CustomButtonMatchRulePatch,
44
+ CustomButtonPatch,
42
45
  FieldPatch,
43
46
  FieldRemovePatch,
44
47
  FieldSelector,
@@ -54,8 +57,12 @@ from .models import (
54
57
  PublicFieldType,
55
58
  PublicRelationMode,
56
59
  PublicChartType,
60
+ PublicButtonTriggerAction,
57
61
  PublicViewType,
62
+ PublicViewButtonConfigType,
63
+ PublicViewButtonType,
58
64
  SchemaPlanRequest,
65
+ ViewButtonBindingPatch,
59
66
  ViewUpsertPatch,
60
67
  ViewFilterOperator,
61
68
  ViewsPlanRequest,
@@ -142,6 +149,7 @@ class AiBuilderFacade:
142
149
  self,
143
150
  *,
144
151
  apps: AppTools,
152
+ buttons: CustomButtonTools,
145
153
  packages: PackageTools,
146
154
  views: ViewTools,
147
155
  workflows: WorkflowTools,
@@ -152,6 +160,7 @@ class AiBuilderFacade:
152
160
  solutions: SolutionTools,
153
161
  ) -> None:
154
162
  self.apps = apps
163
+ self.buttons = buttons
155
164
  self.packages = packages
156
165
  self.views = views
157
166
  self.workflows = workflows
@@ -1291,6 +1300,333 @@ class AiBuilderFacade:
1291
1300
  **match,
1292
1301
  }
1293
1302
 
1303
+ def app_custom_button_list(self, *, profile: str, app_key: str) -> JSONObject:
1304
+ normalized_args = {"app_key": app_key}
1305
+ permission_outcomes: list[PermissionCheckOutcome] = []
1306
+ permission_outcome = self._guard_app_permission(
1307
+ profile=profile,
1308
+ app_key=app_key,
1309
+ required_permission="edit_app",
1310
+ normalized_args=normalized_args,
1311
+ )
1312
+ if permission_outcome.block is not None:
1313
+ return permission_outcome.block
1314
+ permission_outcomes.append(permission_outcome)
1315
+
1316
+ def finalize(response: JSONObject) -> JSONObject:
1317
+ return _apply_permission_outcomes(response, *permission_outcomes)
1318
+
1319
+ listing = self.buttons.custom_button_list(profile=profile, app_key=app_key, being_draft=True, include_raw=False)
1320
+ items = [
1321
+ _normalize_custom_button_summary(item)
1322
+ for item in (listing.get("items") or [])
1323
+ if isinstance(item, dict)
1324
+ ]
1325
+ return finalize(
1326
+ {
1327
+ "status": "success",
1328
+ "error_code": None,
1329
+ "recoverable": False,
1330
+ "message": "read custom button summary",
1331
+ "normalized_args": normalized_args,
1332
+ "missing_fields": [],
1333
+ "allowed_values": {},
1334
+ "details": {},
1335
+ "request_id": None,
1336
+ "suggested_next_call": None,
1337
+ "noop": False,
1338
+ "warnings": [],
1339
+ "verification": {"custom_buttons_verified": True},
1340
+ "verified": True,
1341
+ "app_key": app_key,
1342
+ "count": len(items),
1343
+ "buttons": items,
1344
+ }
1345
+ )
1346
+
1347
+ def app_custom_button_get(self, *, profile: str, app_key: str, button_id: int) -> JSONObject:
1348
+ normalized_args = {"app_key": app_key, "button_id": button_id}
1349
+ permission_outcomes: list[PermissionCheckOutcome] = []
1350
+ permission_outcome = self._guard_app_permission(
1351
+ profile=profile,
1352
+ app_key=app_key,
1353
+ required_permission="edit_app",
1354
+ normalized_args=normalized_args,
1355
+ )
1356
+ if permission_outcome.block is not None:
1357
+ return permission_outcome.block
1358
+ permission_outcomes.append(permission_outcome)
1359
+
1360
+ def finalize(response: JSONObject) -> JSONObject:
1361
+ return _apply_permission_outcomes(response, *permission_outcomes)
1362
+
1363
+ detail = self.buttons.custom_button_get(
1364
+ profile=profile,
1365
+ app_key=app_key,
1366
+ button_id=button_id,
1367
+ being_draft=True,
1368
+ include_raw=False,
1369
+ )
1370
+ button = _normalize_custom_button_detail(detail.get("result") if isinstance(detail.get("result"), dict) else {})
1371
+ return finalize(
1372
+ {
1373
+ "status": "success",
1374
+ "error_code": None,
1375
+ "recoverable": False,
1376
+ "message": "read custom button detail",
1377
+ "normalized_args": normalized_args,
1378
+ "missing_fields": [],
1379
+ "allowed_values": {},
1380
+ "details": {},
1381
+ "request_id": None,
1382
+ "suggested_next_call": None,
1383
+ "noop": False,
1384
+ "warnings": [],
1385
+ "verification": {"custom_button_verified": True},
1386
+ "verified": True,
1387
+ "app_key": app_key,
1388
+ "button_id": button_id,
1389
+ "button": button,
1390
+ }
1391
+ )
1392
+
1393
+ def app_custom_button_create(self, *, profile: str, app_key: str, payload: CustomButtonPatch) -> JSONObject:
1394
+ normalized_args = {"app_key": app_key, "payload": payload.model_dump(mode="json")}
1395
+ permission_outcomes: list[PermissionCheckOutcome] = []
1396
+ permission_outcome = self._guard_app_permission(
1397
+ profile=profile,
1398
+ app_key=app_key,
1399
+ required_permission="edit_app",
1400
+ normalized_args=normalized_args,
1401
+ )
1402
+ if permission_outcome.block is not None:
1403
+ return permission_outcome.block
1404
+ permission_outcomes.append(permission_outcome)
1405
+
1406
+ def finalize(response: JSONObject) -> JSONObject:
1407
+ return _apply_permission_outcomes(response, *permission_outcomes)
1408
+
1409
+ create_result = self.buttons.custom_button_create(
1410
+ profile=profile,
1411
+ app_key=app_key,
1412
+ payload=_serialize_custom_button_payload(payload),
1413
+ )
1414
+ raw_result = create_result.get("result")
1415
+ button_id = _extract_custom_button_id(raw_result)
1416
+ if button_id is None:
1417
+ return finalize(
1418
+ _failed(
1419
+ "CUSTOM_BUTTON_CREATE_FAILED",
1420
+ "custom button create succeeded but no button_id was returned",
1421
+ normalized_args=normalized_args,
1422
+ details={"app_key": app_key, "result": deepcopy(raw_result)},
1423
+ suggested_next_call={"tool_name": "app_custom_button_list", "arguments": {"profile": profile, "app_key": app_key}},
1424
+ )
1425
+ )
1426
+ try:
1427
+ readback = self.buttons.custom_button_get(
1428
+ profile=profile,
1429
+ app_key=app_key,
1430
+ button_id=button_id,
1431
+ being_draft=True,
1432
+ include_raw=False,
1433
+ )
1434
+ except (QingflowApiError, RuntimeError) as error:
1435
+ api_error = _coerce_api_error(error)
1436
+ response = {
1437
+ "status": "partial_success",
1438
+ "error_code": "CUSTOM_BUTTON_READBACK_PENDING",
1439
+ "recoverable": True,
1440
+ "message": "created custom button; detail readback unavailable",
1441
+ "normalized_args": normalized_args,
1442
+ "missing_fields": [],
1443
+ "allowed_values": {},
1444
+ "details": {
1445
+ "app_key": app_key,
1446
+ "button_id": button_id,
1447
+ "transport_error": _transport_error_payload(api_error),
1448
+ },
1449
+ "request_id": api_error.request_id,
1450
+ "suggested_next_call": {
1451
+ "tool_name": "app_custom_button_get",
1452
+ "arguments": {"profile": profile, "app_key": app_key, "button_id": button_id},
1453
+ },
1454
+ "noop": False,
1455
+ "warnings": [_warning("CUSTOM_BUTTON_READBACK_PENDING", "custom button was created, but detail readback is not available")],
1456
+ "verification": {"custom_button_verified": False},
1457
+ "verified": False,
1458
+ "app_key": app_key,
1459
+ "button_id": button_id,
1460
+ }
1461
+ if _is_permission_restricted_api_error(api_error):
1462
+ response = _apply_permission_outcomes(
1463
+ response,
1464
+ _verification_read_outcome(
1465
+ resource="custom_button",
1466
+ target={"app_key": app_key, "button_id": button_id},
1467
+ transport_error=api_error,
1468
+ ),
1469
+ )
1470
+ return finalize(response)
1471
+ button = _normalize_custom_button_detail(readback.get("result") if isinstance(readback.get("result"), dict) else {})
1472
+ return finalize(
1473
+ {
1474
+ "status": "success",
1475
+ "error_code": None,
1476
+ "recoverable": False,
1477
+ "message": "created custom button",
1478
+ "normalized_args": normalized_args,
1479
+ "missing_fields": [],
1480
+ "allowed_values": {},
1481
+ "details": {},
1482
+ "request_id": None,
1483
+ "suggested_next_call": None,
1484
+ "noop": False,
1485
+ "warnings": [],
1486
+ "verification": {"custom_button_verified": True},
1487
+ "verified": True,
1488
+ "app_key": app_key,
1489
+ "button_id": button_id,
1490
+ "button": button,
1491
+ }
1492
+ )
1493
+
1494
+ def app_custom_button_update(
1495
+ self,
1496
+ *,
1497
+ profile: str,
1498
+ app_key: str,
1499
+ button_id: int,
1500
+ payload: CustomButtonPatch,
1501
+ ) -> JSONObject:
1502
+ normalized_args = {"app_key": app_key, "button_id": button_id, "payload": payload.model_dump(mode="json")}
1503
+ permission_outcomes: list[PermissionCheckOutcome] = []
1504
+ permission_outcome = self._guard_app_permission(
1505
+ profile=profile,
1506
+ app_key=app_key,
1507
+ required_permission="edit_app",
1508
+ normalized_args=normalized_args,
1509
+ )
1510
+ if permission_outcome.block is not None:
1511
+ return permission_outcome.block
1512
+ permission_outcomes.append(permission_outcome)
1513
+
1514
+ def finalize(response: JSONObject) -> JSONObject:
1515
+ return _apply_permission_outcomes(response, *permission_outcomes)
1516
+
1517
+ self.buttons.custom_button_update(
1518
+ profile=profile,
1519
+ app_key=app_key,
1520
+ button_id=button_id,
1521
+ payload=_serialize_custom_button_payload(payload),
1522
+ )
1523
+ try:
1524
+ readback = self.buttons.custom_button_get(
1525
+ profile=profile,
1526
+ app_key=app_key,
1527
+ button_id=button_id,
1528
+ being_draft=True,
1529
+ include_raw=False,
1530
+ )
1531
+ except (QingflowApiError, RuntimeError) as error:
1532
+ api_error = _coerce_api_error(error)
1533
+ response = {
1534
+ "status": "partial_success",
1535
+ "error_code": "CUSTOM_BUTTON_READBACK_PENDING",
1536
+ "recoverable": True,
1537
+ "message": "updated custom button; detail readback unavailable",
1538
+ "normalized_args": normalized_args,
1539
+ "missing_fields": [],
1540
+ "allowed_values": {},
1541
+ "details": {
1542
+ "app_key": app_key,
1543
+ "button_id": button_id,
1544
+ "transport_error": _transport_error_payload(api_error),
1545
+ },
1546
+ "request_id": api_error.request_id,
1547
+ "suggested_next_call": {
1548
+ "tool_name": "app_custom_button_get",
1549
+ "arguments": {"profile": profile, "app_key": app_key, "button_id": button_id},
1550
+ },
1551
+ "noop": False,
1552
+ "warnings": [_warning("CUSTOM_BUTTON_READBACK_PENDING", "custom button was updated, but detail readback is not available")],
1553
+ "verification": {"custom_button_verified": False},
1554
+ "verified": False,
1555
+ "app_key": app_key,
1556
+ "button_id": button_id,
1557
+ }
1558
+ if _is_permission_restricted_api_error(api_error):
1559
+ response = _apply_permission_outcomes(
1560
+ response,
1561
+ _verification_read_outcome(
1562
+ resource="custom_button",
1563
+ target={"app_key": app_key, "button_id": button_id},
1564
+ transport_error=api_error,
1565
+ ),
1566
+ )
1567
+ return finalize(response)
1568
+ button = _normalize_custom_button_detail(readback.get("result") if isinstance(readback.get("result"), dict) else {})
1569
+ return finalize(
1570
+ {
1571
+ "status": "success",
1572
+ "error_code": None,
1573
+ "recoverable": False,
1574
+ "message": "updated custom button",
1575
+ "normalized_args": normalized_args,
1576
+ "missing_fields": [],
1577
+ "allowed_values": {},
1578
+ "details": {},
1579
+ "request_id": None,
1580
+ "suggested_next_call": None,
1581
+ "noop": False,
1582
+ "warnings": [],
1583
+ "verification": {"custom_button_verified": True},
1584
+ "verified": True,
1585
+ "app_key": app_key,
1586
+ "button_id": button_id,
1587
+ "button": button,
1588
+ }
1589
+ )
1590
+
1591
+ def app_custom_button_delete(self, *, profile: str, app_key: str, button_id: int) -> JSONObject:
1592
+ normalized_args = {"app_key": app_key, "button_id": button_id}
1593
+ permission_outcomes: list[PermissionCheckOutcome] = []
1594
+ permission_outcome = self._guard_app_permission(
1595
+ profile=profile,
1596
+ app_key=app_key,
1597
+ required_permission="edit_app",
1598
+ normalized_args=normalized_args,
1599
+ )
1600
+ if permission_outcome.block is not None:
1601
+ return permission_outcome.block
1602
+ permission_outcomes.append(permission_outcome)
1603
+
1604
+ def finalize(response: JSONObject) -> JSONObject:
1605
+ return _apply_permission_outcomes(response, *permission_outcomes)
1606
+
1607
+ self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
1608
+ return finalize(
1609
+ {
1610
+ "status": "success",
1611
+ "error_code": None,
1612
+ "recoverable": False,
1613
+ "message": "deleted custom button",
1614
+ "normalized_args": normalized_args,
1615
+ "missing_fields": [],
1616
+ "allowed_values": {},
1617
+ "details": {},
1618
+ "request_id": None,
1619
+ "suggested_next_call": None,
1620
+ "noop": False,
1621
+ "warnings": [],
1622
+ "verification": {"custom_button_deleted": True},
1623
+ "verified": True,
1624
+ "app_key": app_key,
1625
+ "button_id": button_id,
1626
+ "deleted": True,
1627
+ }
1628
+ )
1629
+
1294
1630
  def _resolve_app_matches_in_visible_apps(
1295
1631
  self,
1296
1632
  *,
@@ -3303,6 +3639,35 @@ class AiBuilderFacade:
3303
3639
  for field in parsed_schema["fields"]
3304
3640
  if isinstance(field, dict) and str(field.get("name") or "")
3305
3641
  }
3642
+ requires_custom_button_validation = any(
3643
+ any(binding.button_type == PublicViewButtonType.custom for binding in (patch.buttons or []))
3644
+ for patch in upsert_views
3645
+ )
3646
+ valid_custom_button_ids: set[int] = set()
3647
+ if requires_custom_button_validation:
3648
+ try:
3649
+ button_listing = self.buttons.custom_button_list(
3650
+ profile=profile,
3651
+ app_key=app_key,
3652
+ being_draft=True,
3653
+ include_raw=False,
3654
+ )
3655
+ except (QingflowApiError, RuntimeError) as error:
3656
+ api_error = _coerce_api_error(error)
3657
+ return finalize(
3658
+ _failed_from_api_error(
3659
+ "CUSTOM_BUTTON_LIST_FAILED",
3660
+ api_error,
3661
+ normalized_args=normalized_args,
3662
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="custom_button", error=api_error),
3663
+ suggested_next_call={"tool_name": "app_custom_button_list", "arguments": {"profile": profile, "app_key": app_key}},
3664
+ )
3665
+ )
3666
+ valid_custom_button_ids = {
3667
+ button_id
3668
+ for item in (button_listing.get("items") or [])
3669
+ if isinstance(item, dict) and (button_id := _coerce_positive_int(item.get("button_id"))) is not None
3670
+ }
3306
3671
  removed: list[str] = []
3307
3672
  view_results: list[dict[str, Any]] = []
3308
3673
  for name in remove_views:
@@ -3396,6 +3761,30 @@ class AiBuilderFacade:
3396
3761
  allowed_values=first_issue.get("allowed_values") or {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
3397
3762
  suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": app_key}},
3398
3763
  )
3764
+ explicit_button_dtos: list[dict[str, Any]] | None = None
3765
+ expected_button_summary: list[dict[str, Any]] | None = None
3766
+ if patch.buttons is not None:
3767
+ explicit_button_dtos, button_issues = _build_view_button_dtos(
3768
+ current_fields_by_name=current_fields_by_name,
3769
+ bindings=patch.buttons,
3770
+ valid_custom_button_ids=valid_custom_button_ids,
3771
+ )
3772
+ if button_issues:
3773
+ first_issue = button_issues[0]
3774
+ return _failed(
3775
+ str(first_issue.get("error_code") or "INVALID_VIEW_BUTTON"),
3776
+ "view buttons reference invalid fields, values, or custom buttons",
3777
+ normalized_args=normalized_args,
3778
+ details={
3779
+ "app_key": app_key,
3780
+ "view_name": patch.name,
3781
+ **first_issue,
3782
+ },
3783
+ missing_fields=list(first_issue.get("missing_fields") or []),
3784
+ allowed_values=first_issue.get("allowed_values") or {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
3785
+ suggested_next_call={"tool_name": "app_custom_button_list", "arguments": {"profile": profile, "app_key": app_key}},
3786
+ )
3787
+ expected_button_summary = _normalize_view_buttons_for_compare(explicit_button_dtos or [])
3399
3788
  matched_existing_view: dict[str, Any] | None = None
3400
3789
  existing_key: str | None = None
3401
3790
  if patch.view_key:
@@ -3441,6 +3830,8 @@ class AiBuilderFacade:
3441
3830
  schema=schema,
3442
3831
  patch=patch,
3443
3832
  view_filters=translated_filters,
3833
+ current_fields_by_name=current_fields_by_name,
3834
+ explicit_button_dtos=explicit_button_dtos,
3444
3835
  )
3445
3836
  self.views.view_update(profile=profile, viewgraph_key=existing_key, payload=payload)
3446
3837
  system_view_sync: dict[str, Any] | None = None
@@ -3481,14 +3872,15 @@ class AiBuilderFacade:
3481
3872
  updated.append(patch.name)
3482
3873
  view_results.append(
3483
3874
  {
3484
- "name": patch.name,
3485
- "view_key": existing_key,
3486
- "type": patch.type.value,
3487
- "status": "updated",
3488
- "expected_filters": deepcopy(translated_filters),
3489
- "system_view_sync": system_view_sync,
3490
- }
3491
- )
3875
+ "name": patch.name,
3876
+ "view_key": existing_key,
3877
+ "type": patch.type.value,
3878
+ "status": "updated",
3879
+ "expected_filters": deepcopy(translated_filters),
3880
+ "expected_buttons": deepcopy(expected_button_summary),
3881
+ "system_view_sync": system_view_sync,
3882
+ }
3883
+ )
3492
3884
  else:
3493
3885
  template_key = _pick_view_template_key(existing_view_list, desired_type=patch.type.value)
3494
3886
  should_copy_template = patch.type.value == "table" and template_key and not translated_filters
@@ -3502,6 +3894,8 @@ class AiBuilderFacade:
3502
3894
  schema=schema,
3503
3895
  patch=patch,
3504
3896
  view_filters=translated_filters,
3897
+ current_fields_by_name=current_fields_by_name,
3898
+ explicit_button_dtos=explicit_button_dtos,
3505
3899
  )
3506
3900
  self.views.view_update(profile=profile, viewgraph_key=created_key, payload=payload)
3507
3901
  else:
@@ -3512,6 +3906,8 @@ class AiBuilderFacade:
3512
3906
  patch=patch,
3513
3907
  ordinal=ordinal,
3514
3908
  view_filters=translated_filters,
3909
+ current_fields_by_name=current_fields_by_name,
3910
+ explicit_button_dtos=explicit_button_dtos,
3515
3911
  )
3516
3912
  create_result = self.views.view_create(profile=profile, payload=payload)
3517
3913
  raw_created = create_result.get("result")
@@ -3522,14 +3918,15 @@ class AiBuilderFacade:
3522
3918
  created_key = raw_created.strip() or None
3523
3919
  created.append(patch.name)
3524
3920
  view_results.append(
3525
- {
3526
- "name": patch.name,
3527
- "view_key": created_key,
3528
- "type": patch.type.value,
3529
- "status": "created",
3530
- "expected_filters": deepcopy(translated_filters),
3531
- }
3532
- )
3921
+ {
3922
+ "name": patch.name,
3923
+ "view_key": created_key,
3924
+ "type": patch.type.value,
3925
+ "status": "created",
3926
+ "expected_filters": deepcopy(translated_filters),
3927
+ "expected_buttons": deepcopy(expected_button_summary),
3928
+ }
3929
+ )
3533
3930
  except (QingflowApiError, RuntimeError) as error:
3534
3931
  api_error = _coerce_api_error(error)
3535
3932
  should_retry_minimal = operation_phase != "default_view_apply_config_sync" and (
@@ -3540,12 +3937,23 @@ class AiBuilderFacade:
3540
3937
  try:
3541
3938
  if existing_key or created_key:
3542
3939
  target_key = created_key or existing_key or ""
3940
+ fallback_button_dtos = explicit_button_dtos
3941
+ if fallback_button_dtos is None:
3942
+ fallback_config_response = self.views.view_get_config(profile=profile, viewgraph_key=target_key)
3943
+ fallback_config = (
3944
+ fallback_config_response.get("result")
3945
+ if isinstance(fallback_config_response.get("result"), dict)
3946
+ else {}
3947
+ )
3948
+ fallback_button_dtos = _extract_existing_view_button_dtos(fallback_config)
3543
3949
  fallback_payload = _build_minimal_view_payload(
3544
3950
  app_key=app_key,
3545
3951
  schema=schema,
3546
3952
  patch=patch,
3547
3953
  ordinal=ordinal,
3548
3954
  view_filters=translated_filters,
3955
+ current_fields_by_name=current_fields_by_name,
3956
+ explicit_button_dtos=fallback_button_dtos,
3549
3957
  )
3550
3958
  self.views.view_update(profile=profile, viewgraph_key=target_key, payload=fallback_payload)
3551
3959
  if existing_key:
@@ -3558,6 +3966,7 @@ class AiBuilderFacade:
3558
3966
  "status": "updated",
3559
3967
  "fallback_applied": True,
3560
3968
  "expected_filters": deepcopy(translated_filters),
3969
+ "expected_buttons": deepcopy(expected_button_summary),
3561
3970
  }
3562
3971
  )
3563
3972
  else:
@@ -3570,6 +3979,7 @@ class AiBuilderFacade:
3570
3979
  "status": "created",
3571
3980
  "fallback_applied": True,
3572
3981
  "expected_filters": deepcopy(translated_filters),
3982
+ "expected_buttons": deepcopy(expected_button_summary),
3573
3983
  }
3574
3984
  )
3575
3985
  continue
@@ -3579,6 +3989,8 @@ class AiBuilderFacade:
3579
3989
  patch=patch,
3580
3990
  ordinal=ordinal,
3581
3991
  view_filters=translated_filters,
3992
+ current_fields_by_name=current_fields_by_name,
3993
+ explicit_button_dtos=explicit_button_dtos,
3582
3994
  )
3583
3995
  self.views.view_create(profile=profile, payload=fallback_payload)
3584
3996
  created.append(patch.name)
@@ -3665,6 +4077,8 @@ class AiBuilderFacade:
3665
4077
  verification_by_view: list[dict[str, Any]] = []
3666
4078
  filter_readback_pending = False
3667
4079
  filter_mismatches: list[dict[str, Any]] = []
4080
+ button_readback_pending = False
4081
+ button_mismatches: list[dict[str, Any]] = []
3668
4082
  for item in view_results:
3669
4083
  status = str(item.get("status") or "")
3670
4084
  name = str(item.get("name") or "")
@@ -3688,6 +4102,7 @@ class AiBuilderFacade:
3688
4102
  if isinstance(system_view_sync, dict):
3689
4103
  verification_entry["system_view_sync"] = deepcopy(system_view_sync)
3690
4104
  expected_filters = item.get("expected_filters") or []
4105
+ expected_buttons = item.get("expected_buttons") if isinstance(item.get("expected_buttons"), list) else None
3691
4106
  if expected_filters:
3692
4107
  if verified_views_unavailable or not present_in_readback:
3693
4108
  verification_entry["filters_verified"] = None
@@ -3743,6 +4158,54 @@ class AiBuilderFacade:
3743
4158
  "category": api_error.category,
3744
4159
  }
3745
4160
  filter_readback_pending = True
4161
+ if expected_buttons is not None:
4162
+ if verified_views_unavailable or not present_in_readback:
4163
+ verification_entry["buttons_verified"] = None
4164
+ verification_entry["button_readback_pending"] = True
4165
+ button_readback_pending = True
4166
+ else:
4167
+ verification_key = item_view_key
4168
+ if not verification_key:
4169
+ matched_keys = verified_view_keys_by_name.get(name) or []
4170
+ if len(matched_keys) == 1:
4171
+ verification_key = matched_keys[0]
4172
+ else:
4173
+ verification_entry["buttons_verified"] = None
4174
+ verification_entry["button_readback_pending"] = True
4175
+ verification_entry["readback_ambiguous"] = True
4176
+ verification_entry["matching_view_keys"] = matched_keys
4177
+ button_readback_pending = True
4178
+ verification_by_view.append(verification_entry)
4179
+ continue
4180
+ try:
4181
+ config_response = self.views.view_get_config(profile=profile, viewgraph_key=verification_key)
4182
+ config_result = (config_response.get("result") or {}) if isinstance(config_response.get("result"), dict) else {}
4183
+ actual_buttons = _normalize_view_buttons_for_compare(config_result)
4184
+ buttons_verified = actual_buttons == expected_buttons
4185
+ verification_entry["buttons_verified"] = buttons_verified
4186
+ verification_entry["view_key"] = verification_key
4187
+ verification_entry["expected_buttons"] = deepcopy(expected_buttons)
4188
+ verification_entry["actual_buttons"] = actual_buttons
4189
+ if not buttons_verified:
4190
+ button_mismatches.append(
4191
+ {
4192
+ "name": name,
4193
+ "type": item.get("type"),
4194
+ "expected_buttons": deepcopy(expected_buttons),
4195
+ "actual_buttons": actual_buttons,
4196
+ }
4197
+ )
4198
+ except (QingflowApiError, RuntimeError) as error:
4199
+ api_error = _coerce_api_error(error)
4200
+ verification_entry["buttons_verified"] = None
4201
+ verification_entry["button_readback_pending"] = True
4202
+ verification_entry["request_id"] = api_error.request_id
4203
+ verification_entry["transport_error"] = {
4204
+ "http_status": api_error.http_status,
4205
+ "backend_code": api_error.backend_code,
4206
+ "category": api_error.category,
4207
+ }
4208
+ button_readback_pending = True
3746
4209
  verification_by_view.append(verification_entry)
3747
4210
  elif status == "removed":
3748
4211
  verification_by_view.append(
@@ -3769,6 +4232,7 @@ class AiBuilderFacade:
3769
4232
  and all(name not in verified_names for name in removed)
3770
4233
  )
3771
4234
  view_filters_verified = verified and not filter_readback_pending and not filter_mismatches
4235
+ view_buttons_verified = verified and not button_readback_pending and not button_mismatches
3772
4236
  noop = not created and not updated and not removed
3773
4237
  if failed_views:
3774
4238
  successful_changes = bool(created or updated or removed)
@@ -3781,34 +4245,48 @@ class AiBuilderFacade:
3781
4245
  "normalized_args": normalized_args,
3782
4246
  "missing_fields": [],
3783
4247
  "allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
3784
- "details": {"per_view_results": view_results, "filter_mismatches": filter_mismatches},
4248
+ "details": {
4249
+ "per_view_results": view_results,
4250
+ "filter_mismatches": filter_mismatches,
4251
+ "button_mismatches": button_mismatches,
4252
+ },
3785
4253
  "request_id": first_failure.get("request_id"),
3786
4254
  "suggested_next_call": {"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
3787
4255
  "backend_code": first_failure.get("backend_code"),
3788
4256
  "http_status": first_failure.get("http_status"),
3789
4257
  "noop": noop,
3790
- "warnings": [_warning("VIEW_FILTERS_UNVERIFIED", "view definitions may exist, but saved filter behavior is not fully verified")] if (filter_readback_pending or filter_mismatches) else [],
4258
+ "warnings": (
4259
+ [_warning("VIEW_FILTERS_UNVERIFIED", "view definitions may exist, but saved filter behavior is not fully verified")] if (filter_readback_pending or filter_mismatches) else []
4260
+ )
4261
+ + (
4262
+ [_warning("VIEW_BUTTONS_UNVERIFIED", "view definitions may exist, but saved button behavior is not fully verified")] if (button_readback_pending or button_mismatches) else []
4263
+ ),
3791
4264
  "verification": {
3792
4265
  "views_verified": verified,
3793
4266
  "view_filters_verified": view_filters_verified,
4267
+ "view_buttons_verified": view_buttons_verified,
3794
4268
  "views_read_unavailable": verified_views_unavailable,
3795
4269
  "by_view": verification_by_view,
3796
4270
  },
3797
4271
  "app_key": app_key,
3798
4272
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
3799
- "verified": verified and view_filters_verified,
4273
+ "verified": verified and view_filters_verified and view_buttons_verified,
3800
4274
  }
3801
4275
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
3802
4276
  warnings: list[dict[str, Any]] = []
3803
4277
  if filter_readback_pending or filter_mismatches:
3804
4278
  warnings.append(_warning("VIEW_FILTERS_UNVERIFIED", "view definitions were applied, but saved filter behavior is not fully verified"))
4279
+ if button_readback_pending or button_mismatches:
4280
+ warnings.append(_warning("VIEW_BUTTONS_UNVERIFIED", "view definitions were applied, but saved button behavior is not fully verified"))
3805
4281
  response = {
3806
- "status": "success" if verified and view_filters_verified else "partial_success",
3807
- "error_code": None if verified and view_filters_verified else ("VIEW_FILTER_READBACK_MISMATCH" if filter_mismatches else "VIEWS_READBACK_PENDING"),
3808
- "recoverable": not (verified and view_filters_verified),
4282
+ "status": "success" if verified and view_filters_verified and view_buttons_verified else "partial_success",
4283
+ "error_code": None if verified and view_filters_verified and view_buttons_verified else ("VIEW_BUTTON_READBACK_MISMATCH" if button_mismatches else "VIEW_FILTER_READBACK_MISMATCH" if filter_mismatches else "VIEWS_READBACK_PENDING"),
4284
+ "recoverable": not (verified and view_filters_verified and view_buttons_verified),
3809
4285
  "message": (
3810
4286
  "applied view patch"
3811
- if verified and view_filters_verified
4287
+ if verified and view_filters_verified and view_buttons_verified
4288
+ else "applied view patch; buttons did not fully verify"
4289
+ if button_mismatches
3812
4290
  else "applied view patch; filters did not fully verify"
3813
4291
  if filter_mismatches
3814
4292
  else "applied view patch; views readback pending"
@@ -3816,21 +4294,26 @@ class AiBuilderFacade:
3816
4294
  "normalized_args": normalized_args,
3817
4295
  "missing_fields": [],
3818
4296
  "allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
3819
- "details": {"filter_mismatches": filter_mismatches} if filter_mismatches else {},
4297
+ "details": {
4298
+ **({"filter_mismatches": filter_mismatches} if filter_mismatches else {}),
4299
+ **({"button_mismatches": button_mismatches} if button_mismatches else {}),
4300
+ },
3820
4301
  "request_id": None,
3821
- "suggested_next_call": None if verified and view_filters_verified else {"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
4302
+ "suggested_next_call": None if verified and view_filters_verified and view_buttons_verified else {"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
3822
4303
  "noop": noop,
3823
4304
  "warnings": warnings,
3824
4305
  "verification": {
3825
4306
  "views_verified": verified,
3826
4307
  "view_filters_verified": view_filters_verified,
4308
+ "view_buttons_verified": view_buttons_verified,
3827
4309
  "views_read_unavailable": verified_views_unavailable,
3828
4310
  "filter_readback_pending": filter_readback_pending,
4311
+ "button_readback_pending": button_readback_pending,
3829
4312
  "by_view": verification_by_view,
3830
4313
  },
3831
4314
  "app_key": app_key,
3832
4315
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
3833
- "verified": verified and view_filters_verified,
4316
+ "verified": verified and view_filters_verified and view_buttons_verified,
3834
4317
  }
3835
4318
  return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
3836
4319
 
@@ -5002,6 +5485,156 @@ class AiBuilderFacade:
5002
5485
  raise QingflowApiError(category="runtime", message="failed to attach app to package")
5003
5486
 
5004
5487
 
5488
+ def _extract_custom_button_id(value: Any) -> int | None:
5489
+ if isinstance(value, dict):
5490
+ for key in ("buttonId", "customButtonId", "id"):
5491
+ button_id = _coerce_positive_int(value.get(key))
5492
+ if button_id is not None:
5493
+ return button_id
5494
+ nested_result = value.get("result")
5495
+ if nested_result is not value:
5496
+ return _extract_custom_button_id(nested_result)
5497
+ return None
5498
+ return _coerce_positive_int(value)
5499
+
5500
+
5501
+ def _serialize_custom_button_payload(payload: CustomButtonPatch) -> dict[str, Any]:
5502
+ data = payload.model_dump(mode="json", exclude_none=True)
5503
+ serialized: dict[str, Any] = {
5504
+ "buttonText": data["button_text"],
5505
+ "backgroundColor": data["background_color"],
5506
+ "textColor": data["text_color"],
5507
+ "buttonIcon": data["button_icon"],
5508
+ "triggerAction": data["trigger_action"],
5509
+ }
5510
+ if str(data.get("trigger_link_url") or "").strip():
5511
+ serialized["triggerLinkUrl"] = data["trigger_link_url"]
5512
+ trigger_add_data_config = data.get("trigger_add_data_config")
5513
+ if isinstance(trigger_add_data_config, dict):
5514
+ serialized["triggerAddDataConfig"] = _serialize_custom_button_add_data_config(trigger_add_data_config)
5515
+ external_qrobot_config = data.get("external_qrobot_config")
5516
+ if isinstance(external_qrobot_config, dict):
5517
+ serialized["customButtonExternalQRobotRelationVO"] = _serialize_custom_button_external_qrobot_config(external_qrobot_config)
5518
+ trigger_wings_config = data.get("trigger_wings_config")
5519
+ if isinstance(trigger_wings_config, dict):
5520
+ serialized["triggerWingsConfig"] = _serialize_custom_button_wings_config(trigger_wings_config)
5521
+ return serialized
5522
+
5523
+
5524
+ def _serialize_custom_button_add_data_config(value: dict[str, Any]) -> dict[str, Any]:
5525
+ relation_rules = value.get("que_relation") or []
5526
+ return {
5527
+ "relatedAppKey": value.get("related_app_key"),
5528
+ "relatedAppName": value.get("related_app_name"),
5529
+ "queRelation": [_serialize_custom_button_match_rule(rule) for rule in relation_rules if isinstance(rule, dict)],
5530
+ }
5531
+
5532
+
5533
+ def _serialize_custom_button_external_qrobot_config(value: dict[str, Any]) -> dict[str, Any]:
5534
+ return {
5535
+ "externalQRobotConfigId": value.get("external_qrobot_config_id"),
5536
+ "triggeredText": value.get("triggered_text"),
5537
+ }
5538
+
5539
+
5540
+ def _serialize_custom_button_wings_config(value: dict[str, Any]) -> dict[str, Any]:
5541
+ return {
5542
+ "wingsAgentId": value.get("wings_agent_id"),
5543
+ "wingsAgentName": value.get("wings_agent_name"),
5544
+ "bindQueIdList": list(value.get("bind_que_id_list") or []),
5545
+ "bindFileQueIdList": list(value.get("bind_file_que_id_list") or []),
5546
+ "defaultPrompt": value.get("default_prompt"),
5547
+ "beingAutoSend": value.get("being_auto_send"),
5548
+ }
5549
+
5550
+
5551
+ def _serialize_custom_button_match_rule(value: dict[str, Any]) -> dict[str, Any]:
5552
+ serialized = {
5553
+ "queId": value.get("que_id"),
5554
+ "queTitle": value.get("que_title"),
5555
+ "queType": value.get("que_type"),
5556
+ "dateType": value.get("date_type"),
5557
+ "judgeType": value.get("judge_type"),
5558
+ "matchType": value.get("match_type"),
5559
+ "judgeValues": list(value.get("judge_values") or []),
5560
+ "judgeQueType": value.get("judge_que_type"),
5561
+ "judgeQueId": value.get("judge_que_id"),
5562
+ "pathValue": value.get("path_value"),
5563
+ "tableUpdateType": value.get("table_update_type"),
5564
+ "multiValue": value.get("multi_value"),
5565
+ "addRule": value.get("add_rule"),
5566
+ "fieldIdPrefix": value.get("field_id_prefix"),
5567
+ }
5568
+ judge_que_detail = value.get("judge_que_detail")
5569
+ if isinstance(judge_que_detail, dict):
5570
+ serialized["judgeQueDetail"] = {
5571
+ "queId": judge_que_detail.get("que_id"),
5572
+ "queTitle": judge_que_detail.get("que_title"),
5573
+ "queType": judge_que_detail.get("que_type"),
5574
+ }
5575
+ judge_value_details = value.get("judge_value_details")
5576
+ if isinstance(judge_value_details, list):
5577
+ serialized["judgeValueDetails"] = [
5578
+ {"id": item.get("id"), "value": item.get("value")}
5579
+ for item in judge_value_details
5580
+ if isinstance(item, dict)
5581
+ ]
5582
+ filter_condition = value.get("filter_condition")
5583
+ if isinstance(filter_condition, list):
5584
+ serialized["filterCondition"] = [
5585
+ [_serialize_custom_button_match_rule(item) for item in group if isinstance(item, dict)]
5586
+ for group in filter_condition
5587
+ if isinstance(group, list)
5588
+ ]
5589
+ return {key: deepcopy(item) for key, item in serialized.items() if item is not None}
5590
+
5591
+
5592
+ def _normalize_custom_button_summary(item: dict[str, Any]) -> dict[str, Any]:
5593
+ normalized = {
5594
+ "button_id": _coerce_positive_int(item.get("button_id") or item.get("buttonId") or item.get("id")),
5595
+ "button_text": str(item.get("button_text") or item.get("buttonText") or "").strip() or None,
5596
+ "button_icon": str(item.get("button_icon") or item.get("buttonIcon") or "").strip() or None,
5597
+ "background_color": str(item.get("background_color") or item.get("backgroundColor") or "").strip() or None,
5598
+ "text_color": str(item.get("text_color") or item.get("textColor") or "").strip() or None,
5599
+ "used_in_chart_count": _coerce_nonnegative_int(item.get("used_in_chart_count") or item.get("userInChartCount")),
5600
+ "being_effective_external_qrobot": bool(item.get("being_effective_external_qrobot") or item.get("beingEffectiveExternalQRobot")),
5601
+ }
5602
+ creator = item.get("creator_user_info") if isinstance(item.get("creator_user_info"), dict) else item.get("creatorUserInfo")
5603
+ if isinstance(creator, dict):
5604
+ normalized["creator_user_info"] = {
5605
+ "uid": creator.get("uid"),
5606
+ "name": creator.get("name"),
5607
+ "email": creator.get("email"),
5608
+ }
5609
+ return normalized
5610
+
5611
+
5612
+ def _normalize_custom_button_detail(item: dict[str, Any]) -> dict[str, Any]:
5613
+ normalized = _normalize_custom_button_summary(item)
5614
+ normalized.update(
5615
+ {
5616
+ "trigger_action": str(item.get("trigger_action") or item.get("triggerAction") or "").strip() or None,
5617
+ "trigger_link_url": str(item.get("trigger_link_url") or item.get("triggerLinkUrl") or "").strip() or None,
5618
+ }
5619
+ )
5620
+ trigger_add_data_config = item.get("trigger_add_data_config")
5621
+ if not isinstance(trigger_add_data_config, dict):
5622
+ trigger_add_data_config = item.get("triggerAddDataConfig")
5623
+ if isinstance(trigger_add_data_config, dict):
5624
+ normalized["trigger_add_data_config"] = deepcopy(trigger_add_data_config)
5625
+ external_qrobot_config = item.get("external_qrobot_config")
5626
+ if not isinstance(external_qrobot_config, dict):
5627
+ external_qrobot_config = item.get("customButtonExternalQRobotRelationVO")
5628
+ if isinstance(external_qrobot_config, dict):
5629
+ normalized["external_qrobot_config"] = deepcopy(external_qrobot_config)
5630
+ trigger_wings_config = item.get("trigger_wings_config")
5631
+ if not isinstance(trigger_wings_config, dict):
5632
+ trigger_wings_config = item.get("triggerWingsConfig")
5633
+ if isinstance(trigger_wings_config, dict):
5634
+ normalized["trigger_wings_config"] = deepcopy(trigger_wings_config)
5635
+ return normalized
5636
+
5637
+
5005
5638
  def _failed(
5006
5639
  error_code: str,
5007
5640
  message: str,
@@ -7467,6 +8100,18 @@ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, A
7467
8100
  if not summary.get("group_by") and display_config.get("group_by"):
7468
8101
  summary["group_by"] = display_config.get("group_by")
7469
8102
  config_enriched = True
8103
+ button_entries, button_source = _extract_view_button_entries(config)
8104
+ if button_entries:
8105
+ summary["buttons"] = [_normalize_view_button_entry(entry) for entry in button_entries]
8106
+ summary["button_count"] = len(button_entries)
8107
+ if button_source:
8108
+ summary["button_read_source"] = button_source
8109
+ config_enriched = True
8110
+ elif button_source:
8111
+ summary["buttons"] = []
8112
+ summary["button_count"] = 0
8113
+ summary["button_read_source"] = button_source
8114
+ config_enriched = True
7470
8115
  if config_enriched:
7471
8116
  summary["read_source"] = "view_config"
7472
8117
  return summary
@@ -7580,6 +8225,339 @@ def _extract_view_display_config(
7580
8225
  return display_config
7581
8226
 
7582
8227
 
8228
+ def _normalize_view_button_type(value: Any) -> str | None:
8229
+ normalized = str(value or "").strip().upper()
8230
+ if normalized in {"SYSTEM", "CUSTOM"}:
8231
+ return normalized
8232
+ return None
8233
+
8234
+
8235
+ def _normalize_view_button_config_type(value: Any) -> str | None:
8236
+ normalized = str(value or "").strip().upper()
8237
+ if normalized in {"TOP", "DETAIL"}:
8238
+ return normalized
8239
+ return None
8240
+
8241
+
8242
+ def _normalize_print_tpls(value: Any) -> list[dict[str, Any]]:
8243
+ if not isinstance(value, list):
8244
+ return []
8245
+ items: list[dict[str, Any]] = []
8246
+ for item in value:
8247
+ if isinstance(item, dict):
8248
+ tpl_id = item.get("printTplId")
8249
+ if tpl_id is None:
8250
+ tpl_id = item.get("templateId")
8251
+ if tpl_id is None:
8252
+ tpl_id = item.get("id")
8253
+ tpl_name = item.get("printTplName")
8254
+ if tpl_name is None:
8255
+ tpl_name = item.get("templateName")
8256
+ if tpl_name is None:
8257
+ tpl_name = item.get("name")
8258
+ normalized: dict[str, Any] = {}
8259
+ if tpl_id is not None:
8260
+ normalized["id"] = str(tpl_id)
8261
+ if str(tpl_name or "").strip():
8262
+ normalized["name"] = str(tpl_name).strip()
8263
+ if normalized:
8264
+ items.append(normalized)
8265
+ continue
8266
+ scalar = str(item or "").strip()
8267
+ if scalar:
8268
+ items.append({"id": scalar})
8269
+ return items
8270
+
8271
+
8272
+ def _normalize_print_tpls_for_compare(value: Any) -> list[str]:
8273
+ normalized_items: list[str] = []
8274
+ for item in _normalize_print_tpls(value):
8275
+ item_id = str(item.get("id") or "").strip()
8276
+ item_name = str(item.get("name") or "").strip()
8277
+ normalized_items.append(item_id or item_name)
8278
+ return normalized_items
8279
+
8280
+
8281
+ def _serialize_print_tpl_ids(value: Any) -> list[str]:
8282
+ return [item for item in _normalize_print_tpls_for_compare(value) if item]
8283
+
8284
+
8285
+ def _extract_view_button_entries(config: dict[str, Any]) -> tuple[list[dict[str, Any]], str | None]:
8286
+ if not isinstance(config, dict):
8287
+ return [], None
8288
+ raw_vo = config.get("buttonConfigVO")
8289
+ if isinstance(raw_vo, list):
8290
+ return [deepcopy(item) for item in raw_vo if isinstance(item, dict)], "buttonConfigVO"
8291
+ raw_dtos = config.get("buttonConfigDTOList")
8292
+ if isinstance(raw_dtos, list):
8293
+ return [deepcopy(item) for item in raw_dtos if isinstance(item, dict)], "buttonConfigDTOList"
8294
+ grouped = config.get("buttonConfig")
8295
+ if not isinstance(grouped, dict):
8296
+ return [], None
8297
+ entries: list[dict[str, Any]] = []
8298
+ for item in grouped.get("topButtonList") or []:
8299
+ if not isinstance(item, dict):
8300
+ continue
8301
+ entry = deepcopy(item)
8302
+ entry.setdefault("configType", "TOP")
8303
+ entry.setdefault("beingMain", True)
8304
+ entries.append(entry)
8305
+ for item in grouped.get("mainButtonDetailList") or []:
8306
+ if not isinstance(item, dict):
8307
+ continue
8308
+ entry = deepcopy(item)
8309
+ entry.setdefault("configType", "DETAIL")
8310
+ entry["beingMain"] = True
8311
+ entries.append(entry)
8312
+ for item in grouped.get("moreButtonDetailList") or []:
8313
+ if not isinstance(item, dict):
8314
+ continue
8315
+ entry = deepcopy(item)
8316
+ entry.setdefault("configType", "DETAIL")
8317
+ entry["beingMain"] = False
8318
+ entries.append(entry)
8319
+ return entries, "buttonConfig"
8320
+
8321
+
8322
+ def _normalize_view_button_entry(entry: dict[str, Any]) -> dict[str, Any]:
8323
+ button_id = _coerce_positive_int(entry.get("buttonId") or entry.get("button_id") or entry.get("id"))
8324
+ button_text = str(entry.get("buttonText") or "").strip() or None
8325
+ default_button_text = str(entry.get("defaultButtonText") or "").strip() or None
8326
+ normalized: dict[str, Any] = {
8327
+ "button_type": _normalize_view_button_type(entry.get("buttonType") or entry.get("button_type")),
8328
+ "config_type": _normalize_view_button_config_type(entry.get("configType") or entry.get("config_type")),
8329
+ "button_id": button_id,
8330
+ "button_text": button_text or default_button_text,
8331
+ "being_main": bool(entry.get("beingMain", False)),
8332
+ "trigger_action": str(entry.get("triggerAction") or "").strip() or None,
8333
+ "print_tpls": _normalize_print_tpls(entry.get("printTpls")),
8334
+ "button_formula_type": _coerce_positive_int(entry.get("buttonFormulaType")) or 1,
8335
+ "button_limit": _normalize_view_filter_groups_for_compare(entry.get("buttonLimit")),
8336
+ }
8337
+ for public_key, source_key in (
8338
+ ("default_button_text", "defaultButtonText"),
8339
+ ("button_icon", "buttonIcon"),
8340
+ ("background_color", "backgroundColor"),
8341
+ ("text_color", "textColor"),
8342
+ ("trigger_link_url", "triggerLinkUrl"),
8343
+ ("button_formula", "buttonFormula"),
8344
+ ):
8345
+ value = entry.get(source_key)
8346
+ if isinstance(value, str):
8347
+ value = value.strip() or None
8348
+ if value not in {None, ""}:
8349
+ normalized[public_key] = deepcopy(value)
8350
+ trigger_add_data_config = entry.get("triggerAddDataConfig")
8351
+ if isinstance(trigger_add_data_config, dict):
8352
+ normalized["trigger_add_data_config"] = deepcopy(trigger_add_data_config)
8353
+ trigger_wings_config = entry.get("triggerWingsConfig")
8354
+ if isinstance(trigger_wings_config, dict):
8355
+ normalized["trigger_wings_config"] = deepcopy(trigger_wings_config)
8356
+ return normalized
8357
+
8358
+
8359
+ def _normalize_view_buttons_for_compare(value: Any) -> list[dict[str, Any]]:
8360
+ if isinstance(value, dict):
8361
+ entries, _ = _extract_view_button_entries(value)
8362
+ elif isinstance(value, list):
8363
+ entries = [item for item in value if isinstance(item, dict)]
8364
+ else:
8365
+ entries = []
8366
+ normalized_entries: list[dict[str, Any]] = []
8367
+ for entry in entries:
8368
+ normalized = _normalize_view_button_entry(entry)
8369
+ normalized_entries.append(
8370
+ {
8371
+ "button_type": normalized.get("button_type"),
8372
+ "config_type": normalized.get("config_type"),
8373
+ "button_id": normalized.get("button_id"),
8374
+ "button_text": normalized.get("button_text"),
8375
+ "button_icon": normalized.get("button_icon"),
8376
+ "background_color": normalized.get("background_color"),
8377
+ "text_color": normalized.get("text_color"),
8378
+ "trigger_action": normalized.get("trigger_action"),
8379
+ "trigger_link_url": normalized.get("trigger_link_url"),
8380
+ "being_main": bool(normalized.get("being_main", False)),
8381
+ "print_tpls": _normalize_print_tpls_for_compare(normalized.get("print_tpls")),
8382
+ "button_formula": str(normalized.get("button_formula") or ""),
8383
+ "button_formula_type": _coerce_positive_int(normalized.get("button_formula_type")) or 1,
8384
+ "button_limit": deepcopy(normalized.get("button_limit") or []),
8385
+ }
8386
+ )
8387
+ return normalized_entries
8388
+
8389
+
8390
+ def _serialize_existing_view_button_entry(entry: dict[str, Any]) -> dict[str, Any]:
8391
+ dto: dict[str, Any] = {}
8392
+ button_id = _coerce_positive_int(entry.get("buttonId") or entry.get("button_id") or entry.get("id"))
8393
+ if button_id is not None:
8394
+ dto["buttonId"] = button_id
8395
+ button_type = _normalize_view_button_type(entry.get("buttonType") or entry.get("button_type"))
8396
+ if button_type is not None:
8397
+ dto["buttonType"] = button_type
8398
+ config_type = _normalize_view_button_config_type(entry.get("configType") or entry.get("config_type"))
8399
+ if config_type is not None:
8400
+ dto["configType"] = config_type
8401
+ dto["beingMain"] = bool(entry.get("beingMain", False))
8402
+ dto["buttonLimit"] = deepcopy(entry.get("buttonLimit") or [])
8403
+ dto["buttonFormula"] = str(entry.get("buttonFormula") or "")
8404
+ dto["buttonFormulaType"] = _coerce_positive_int(entry.get("buttonFormulaType")) or 1
8405
+ dto["printTpls"] = _serialize_print_tpl_ids(entry.get("printTpls"))
8406
+ for source_key, target_key in (
8407
+ ("buttonText", "buttonText"),
8408
+ ("buttonIcon", "buttonIcon"),
8409
+ ("backgroundColor", "backgroundColor"),
8410
+ ("textColor", "textColor"),
8411
+ ("triggerAction", "triggerAction"),
8412
+ ("triggerLinkUrl", "triggerLinkUrl"),
8413
+ ):
8414
+ if source_key in entry:
8415
+ dto[target_key] = deepcopy(entry.get(source_key))
8416
+ if isinstance(entry.get("triggerAddDataConfig"), dict):
8417
+ dto["triggerAddDataConfig"] = deepcopy(entry.get("triggerAddDataConfig"))
8418
+ if isinstance(entry.get("triggerWingsConfig"), dict):
8419
+ dto["triggerWingsConfig"] = deepcopy(entry.get("triggerWingsConfig"))
8420
+ return dto
8421
+
8422
+
8423
+ def _extract_existing_view_button_dtos(config: dict[str, Any]) -> list[dict[str, Any]]:
8424
+ if not isinstance(config, dict):
8425
+ return []
8426
+ button_config_dtos = config.get("buttonConfigDTOList")
8427
+ if isinstance(button_config_dtos, list):
8428
+ return [deepcopy(item) for item in button_config_dtos if isinstance(item, dict)]
8429
+ entries, _ = _extract_view_button_entries(config)
8430
+ return [_serialize_existing_view_button_entry(entry) for entry in entries if isinstance(entry, dict)]
8431
+
8432
+
8433
+ def _resolve_view_button_dtos_for_patch(
8434
+ *,
8435
+ config: dict[str, Any],
8436
+ patch: ViewUpsertPatch,
8437
+ explicit_button_dtos: list[dict[str, Any]] | None,
8438
+ ) -> list[dict[str, Any]] | None:
8439
+ if patch.buttons is None:
8440
+ return _extract_existing_view_button_dtos(config)
8441
+ return deepcopy(explicit_button_dtos or [])
8442
+
8443
+
8444
+ def _build_grouped_view_button_config(button_config_dtos: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
8445
+ grouped = {"topButtonList": [], "mainButtonDetailList": [], "moreButtonDetailList": []}
8446
+ for raw_item in button_config_dtos:
8447
+ if not isinstance(raw_item, dict):
8448
+ continue
8449
+ item = deepcopy(raw_item)
8450
+ config_type = _normalize_view_button_config_type(item.get("configType"))
8451
+ being_main = bool(item.get("beingMain", False))
8452
+ if config_type == "TOP":
8453
+ grouped["topButtonList"].append(item)
8454
+ elif being_main:
8455
+ grouped["mainButtonDetailList"].append(item)
8456
+ else:
8457
+ grouped["moreButtonDetailList"].append(item)
8458
+ return grouped
8459
+
8460
+
8461
+ def _build_view_button_dtos(
8462
+ *,
8463
+ current_fields_by_name: dict[str, dict[str, Any]],
8464
+ bindings: list[ViewButtonBindingPatch],
8465
+ valid_custom_button_ids: set[int],
8466
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
8467
+ dtos: list[dict[str, Any]] = []
8468
+ issues: list[dict[str, Any]] = []
8469
+ for binding in bindings:
8470
+ dto, binding_issues = _serialize_view_button_binding(
8471
+ binding=binding,
8472
+ current_fields_by_name=current_fields_by_name,
8473
+ valid_custom_button_ids=valid_custom_button_ids,
8474
+ )
8475
+ if binding_issues:
8476
+ issues.extend(binding_issues)
8477
+ continue
8478
+ dtos.append(dto)
8479
+ return dtos, issues
8480
+
8481
+
8482
+ def _serialize_view_button_binding(
8483
+ *,
8484
+ binding: ViewButtonBindingPatch,
8485
+ current_fields_by_name: dict[str, dict[str, Any]],
8486
+ valid_custom_button_ids: set[int],
8487
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
8488
+ if binding.button_type == PublicViewButtonType.custom and binding.button_id not in valid_custom_button_ids:
8489
+ return {}, [
8490
+ {
8491
+ "error_code": "UNKNOWN_CUSTOM_BUTTON",
8492
+ "reason_path": "buttons[].button_id",
8493
+ "missing_fields": [],
8494
+ "details": {"button_id": binding.button_id},
8495
+ }
8496
+ ]
8497
+ translated_limits, limit_issues = _build_view_button_limit_groups(
8498
+ current_fields_by_name=current_fields_by_name,
8499
+ groups=binding.button_limit,
8500
+ )
8501
+ if limit_issues:
8502
+ return {}, limit_issues
8503
+ dto: dict[str, Any] = {
8504
+ "buttonId": binding.button_id,
8505
+ "buttonType": binding.button_type.value,
8506
+ "configType": binding.config_type.value,
8507
+ "beingMain": bool(binding.being_main),
8508
+ "buttonLimit": translated_limits,
8509
+ "buttonFormula": binding.button_formula or "",
8510
+ "buttonFormulaType": binding.button_formula_type,
8511
+ "printTpls": _serialize_print_tpl_ids(binding.print_tpls),
8512
+ }
8513
+ if binding.button_type == PublicViewButtonType.system:
8514
+ dto["buttonText"] = binding.button_text
8515
+ dto["buttonIcon"] = binding.button_icon
8516
+ dto["backgroundColor"] = binding.background_color
8517
+ dto["textColor"] = binding.text_color
8518
+ dto["triggerAction"] = binding.trigger_action
8519
+ return dto, []
8520
+
8521
+
8522
+ def _build_view_button_limit_groups(
8523
+ *,
8524
+ current_fields_by_name: dict[str, dict[str, Any]],
8525
+ groups: list[list[ViewFilterRulePatch]],
8526
+ ) -> tuple[list[list[dict[str, Any]]], list[dict[str, Any]]]:
8527
+ translated_groups: list[list[dict[str, Any]]] = []
8528
+ issues: list[dict[str, Any]] = []
8529
+ for raw_group in groups:
8530
+ if not isinstance(raw_group, list):
8531
+ continue
8532
+ translated_rules: list[dict[str, Any]] = []
8533
+ for raw_rule in raw_group:
8534
+ if hasattr(raw_rule, "model_dump"):
8535
+ raw_rule = raw_rule.model_dump(mode="json")
8536
+ if not isinstance(raw_rule, dict):
8537
+ continue
8538
+ field_name = str(raw_rule.get("field_name") or "").strip()
8539
+ field = current_fields_by_name.get(field_name)
8540
+ if field is None:
8541
+ issues.append(
8542
+ {
8543
+ "error_code": "UNKNOWN_VIEW_FIELD",
8544
+ "missing_fields": [field_name] if field_name else [],
8545
+ "reason_path": "buttons[].button_limit[].field_name",
8546
+ }
8547
+ )
8548
+ continue
8549
+ translated_rule, issue = _translate_view_filter_rule(field=field, rule=raw_rule)
8550
+ if issue:
8551
+ issue = deepcopy(issue)
8552
+ issue["reason_path"] = "buttons[].button_limit[].values"
8553
+ issues.append(issue)
8554
+ continue
8555
+ translated_rules.append(translated_rule)
8556
+ if translated_rules:
8557
+ translated_groups.append(translated_rules)
8558
+ return translated_groups, issues
8559
+
8560
+
7583
8561
  def _summarize_charts(result: Any) -> list[dict[str, Any]]:
7584
8562
  if not isinstance(result, list):
7585
8563
  return []
@@ -7929,6 +8907,8 @@ def _build_view_create_payload(
7929
8907
  patch: ViewUpsertPatch,
7930
8908
  ordinal: int,
7931
8909
  view_filters: list[list[dict[str, Any]]],
8910
+ current_fields_by_name: dict[str, dict[str, Any]],
8911
+ explicit_button_dtos: list[dict[str, Any]] | None = None,
7932
8912
  ) -> JSONObject:
7933
8913
  entity = _entity_spec_from_app(base_info=base_info, schema=schema, views=None)
7934
8914
  parsed_schema = _parse_schema(schema)
@@ -7969,6 +8949,7 @@ def _build_view_create_payload(
7969
8949
  group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
7970
8950
  view_filters=view_filters,
7971
8951
  gantt_payload=gantt_config,
8952
+ button_config_dtos=explicit_button_dtos,
7972
8953
  )
7973
8954
 
7974
8955
 
@@ -8111,6 +9092,8 @@ def _build_view_update_payload(
8111
9092
  schema: dict[str, Any],
8112
9093
  patch: ViewUpsertPatch,
8113
9094
  view_filters: list[list[dict[str, Any]]],
9095
+ current_fields_by_name: dict[str, dict[str, Any]],
9096
+ explicit_button_dtos: list[dict[str, Any]] | None = None,
8114
9097
  ) -> JSONObject:
8115
9098
  config_response = views.view_get_config(profile=profile, viewgraph_key=source_viewgraph_key)
8116
9099
  config = config_response.get("result") if isinstance(config_response.get("result"), dict) else {}
@@ -8149,7 +9132,11 @@ def _build_view_update_payload(
8149
9132
  payload.setdefault("defaultRowHigh", "compact")
8150
9133
  payload.setdefault("viewgraphLimitType", 1)
8151
9134
  payload.setdefault("viewgraphLimit", deepcopy(view_filters) if view_filters else [])
8152
- payload.setdefault("buttonConfigDTOList", [])
9135
+ button_config_dtos = _resolve_view_button_dtos_for_patch(
9136
+ config=config,
9137
+ patch=patch,
9138
+ explicit_button_dtos=explicit_button_dtos,
9139
+ )
8153
9140
 
8154
9141
  normalized_type = patch.type.value
8155
9142
  existing_type = _normalize_view_type_name(payload.get("viewgraphType") or payload.get("type"))
@@ -8180,6 +9167,7 @@ def _build_view_update_payload(
8180
9167
  group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
8181
9168
  view_filters=view_filters,
8182
9169
  gantt_payload=gantt_payload,
9170
+ button_config_dtos=button_config_dtos,
8183
9171
  )
8184
9172
 
8185
9173
 
@@ -8190,6 +9178,8 @@ def _build_minimal_view_payload(
8190
9178
  patch: ViewUpsertPatch,
8191
9179
  ordinal: int,
8192
9180
  view_filters: list[list[dict[str, Any]]],
9181
+ current_fields_by_name: dict[str, dict[str, Any]],
9182
+ explicit_button_dtos: list[dict[str, Any]] | None = None,
8193
9183
  ) -> JSONObject:
8194
9184
  field_map = extract_field_map(schema)
8195
9185
  parsed_schema = _parse_schema(schema)
@@ -8222,6 +9212,7 @@ def _build_minimal_view_payload(
8222
9212
  group_que_id=field_map.get(patch.group_by or "") if patch.group_by else None,
8223
9213
  view_filters=view_filters,
8224
9214
  gantt_payload=gantt_payload,
9215
+ button_config_dtos=explicit_button_dtos,
8225
9216
  )
8226
9217
 
8227
9218
 
@@ -8233,6 +9224,7 @@ def _hydrate_view_backend_payload(
8233
9224
  group_que_id: int | None,
8234
9225
  view_filters: list[list[dict[str, Any]]] | None = None,
8235
9226
  gantt_payload: dict[str, Any] | None = None,
9227
+ button_config_dtos: list[dict[str, Any]] | None = None,
8236
9228
  ) -> JSONObject:
8237
9229
  data = deepcopy(payload)
8238
9230
  data.setdefault("beingPinNavigate", True)
@@ -8268,9 +9260,11 @@ def _hydrate_view_backend_payload(
8268
9260
  data.setdefault("asosChartConfig", {"limitType": 1, "asosChartIdList": []})
8269
9261
  data.setdefault("viewgraphGanttConfigVO", None)
8270
9262
  data.setdefault("viewgraphHierarchyConfigVO", None)
8271
- data.setdefault("buttonConfigDTOList", [])
8272
- if "buttonConfig" not in data:
8273
- data["buttonConfig"] = {"topButtonList": [], "mainButtonDetailList": [], "moreButtonDetailList": []}
9263
+ if button_config_dtos is not None:
9264
+ data["buttonConfigDTOList"] = deepcopy(button_config_dtos)
9265
+ data["buttonConfig"] = _build_grouped_view_button_config(button_config_dtos)
9266
+ else:
9267
+ data.pop("buttonConfigVO", None)
8274
9268
  if view_type == "table":
8275
9269
  data["viewgraphType"] = "tableView"
8276
9270
  data["beingShowTitleQue"] = False