@josephyan/qingflow-cli 0.2.0-beta.74 → 0.2.0-beta.76

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.
@@ -57,6 +57,7 @@ from .models import (
57
57
  PortalListResponse,
58
58
  PortalReadSummaryResponse,
59
59
  PortalSectionPatch,
60
+ PublicDepartmentScopeMode,
60
61
  PublicFieldType,
61
62
  PublicRelationMode,
62
63
  PublicChartType,
@@ -1599,29 +1600,34 @@ class AiBuilderFacade:
1599
1600
  transport_error=api_error,
1600
1601
  ),
1601
1602
  )
1602
- return finalize(response)
1603
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=True, response=response))
1603
1604
  button = _normalize_custom_button_detail(readback.get("result") if isinstance(readback.get("result"), dict) else {})
1604
1605
  return finalize(
1605
- {
1606
- "status": "success",
1607
- "error_code": None,
1608
- "recoverable": False,
1609
- "message": "created custom button",
1610
- "normalized_args": normalized_args,
1611
- "missing_fields": [],
1612
- "allowed_values": {},
1613
- "details": {},
1614
- "request_id": None,
1615
- "suggested_next_call": None,
1616
- "noop": False,
1617
- "warnings": [],
1618
- "verification": {"custom_button_verified": True},
1619
- "verified": True,
1620
- "app_key": app_key,
1621
- "button_id": button_id,
1622
- "edit_version_no": edit_version_no,
1623
- "button": button,
1624
- }
1606
+ self._append_publish_result(
1607
+ profile=profile,
1608
+ app_key=app_key,
1609
+ publish=True,
1610
+ response={
1611
+ "status": "success",
1612
+ "error_code": None,
1613
+ "recoverable": False,
1614
+ "message": "created custom button",
1615
+ "normalized_args": normalized_args,
1616
+ "missing_fields": [],
1617
+ "allowed_values": {},
1618
+ "details": {},
1619
+ "request_id": None,
1620
+ "suggested_next_call": None,
1621
+ "noop": False,
1622
+ "warnings": [],
1623
+ "verification": {"custom_button_verified": True},
1624
+ "verified": True,
1625
+ "app_key": app_key,
1626
+ "button_id": button_id,
1627
+ "edit_version_no": edit_version_no,
1628
+ "button": button,
1629
+ },
1630
+ )
1625
1631
  )
1626
1632
 
1627
1633
  def app_custom_button_update(
@@ -1707,29 +1713,34 @@ class AiBuilderFacade:
1707
1713
  transport_error=api_error,
1708
1714
  ),
1709
1715
  )
1710
- return finalize(response)
1716
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=True, response=response))
1711
1717
  button = _normalize_custom_button_detail(readback.get("result") if isinstance(readback.get("result"), dict) else {})
1712
1718
  return finalize(
1713
- {
1714
- "status": "success",
1715
- "error_code": None,
1716
- "recoverable": False,
1717
- "message": "updated custom button",
1718
- "normalized_args": normalized_args,
1719
- "missing_fields": [],
1720
- "allowed_values": {},
1721
- "details": {},
1722
- "request_id": None,
1723
- "suggested_next_call": None,
1724
- "noop": False,
1725
- "warnings": [],
1726
- "verification": {"custom_button_verified": True},
1727
- "verified": True,
1728
- "app_key": app_key,
1729
- "button_id": button_id,
1730
- "edit_version_no": edit_version_no,
1731
- "button": button,
1732
- }
1719
+ self._append_publish_result(
1720
+ profile=profile,
1721
+ app_key=app_key,
1722
+ publish=True,
1723
+ response={
1724
+ "status": "success",
1725
+ "error_code": None,
1726
+ "recoverable": False,
1727
+ "message": "updated custom button",
1728
+ "normalized_args": normalized_args,
1729
+ "missing_fields": [],
1730
+ "allowed_values": {},
1731
+ "details": {},
1732
+ "request_id": None,
1733
+ "suggested_next_call": None,
1734
+ "noop": False,
1735
+ "warnings": [],
1736
+ "verification": {"custom_button_verified": True},
1737
+ "verified": True,
1738
+ "app_key": app_key,
1739
+ "button_id": button_id,
1740
+ "edit_version_no": edit_version_no,
1741
+ "button": button,
1742
+ },
1743
+ )
1733
1744
  )
1734
1745
 
1735
1746
  def app_custom_button_delete(self, *, profile: str, app_key: str, button_id: int) -> JSONObject:
@@ -1748,27 +1759,42 @@ class AiBuilderFacade:
1748
1759
  def finalize(response: JSONObject) -> JSONObject:
1749
1760
  return _apply_permission_outcomes(response, *permission_outcomes)
1750
1761
 
1762
+ edit_version_no, edit_context_error = self._ensure_app_edit_context(
1763
+ profile=profile,
1764
+ app_key=app_key,
1765
+ normalized_args=normalized_args,
1766
+ failure_code="CUSTOM_BUTTON_DELETE_FAILED",
1767
+ )
1768
+ if edit_context_error is not None:
1769
+ return finalize(edit_context_error)
1770
+
1751
1771
  self.buttons.custom_button_delete(profile=profile, app_key=app_key, button_id=button_id)
1752
1772
  return finalize(
1753
- {
1754
- "status": "success",
1755
- "error_code": None,
1756
- "recoverable": False,
1757
- "message": "deleted custom button",
1758
- "normalized_args": normalized_args,
1759
- "missing_fields": [],
1760
- "allowed_values": {},
1761
- "details": {},
1762
- "request_id": None,
1763
- "suggested_next_call": None,
1764
- "noop": False,
1765
- "warnings": [],
1766
- "verification": {"custom_button_deleted": True},
1767
- "verified": True,
1768
- "app_key": app_key,
1769
- "button_id": button_id,
1770
- "deleted": True,
1771
- }
1773
+ self._append_publish_result(
1774
+ profile=profile,
1775
+ app_key=app_key,
1776
+ publish=True,
1777
+ response={
1778
+ "status": "success",
1779
+ "error_code": None,
1780
+ "recoverable": False,
1781
+ "message": "deleted custom button",
1782
+ "normalized_args": normalized_args,
1783
+ "missing_fields": [],
1784
+ "allowed_values": {},
1785
+ "details": {},
1786
+ "request_id": None,
1787
+ "suggested_next_call": None,
1788
+ "noop": False,
1789
+ "warnings": [],
1790
+ "verification": {"custom_button_deleted": True},
1791
+ "verified": True,
1792
+ "app_key": app_key,
1793
+ "button_id": button_id,
1794
+ "edit_version_no": edit_version_no,
1795
+ "deleted": True,
1796
+ },
1797
+ )
1772
1798
  )
1773
1799
 
1774
1800
  def _resolve_app_matches_in_visible_apps(
@@ -3593,6 +3619,7 @@ class AiBuilderFacade:
3593
3619
  existing_field = next((field for field in current_fields if str(field.get("name") or "") == patch.name), None)
3594
3620
  if existing_field is not None:
3595
3621
  if _field_matches_patch(existing_field, patch):
3622
+ _merge_existing_field_with_patch(existing_field, patch)
3596
3623
  continue
3597
3624
  return _failed(
3598
3625
  "DUPLICATE_FIELD",
@@ -8145,6 +8172,133 @@ def _normalize_relation_mode(value: Any) -> str:
8145
8172
  return _relation_mode_from_optional_data_num(value)
8146
8173
 
8147
8174
 
8175
+ def _normalize_department_scope_mode(value: Any) -> str | None:
8176
+ if value is None:
8177
+ return None
8178
+ if isinstance(value, int):
8179
+ if value == 1:
8180
+ return PublicDepartmentScopeMode.all.value
8181
+ if value == 2:
8182
+ return PublicDepartmentScopeMode.custom.value
8183
+ return None
8184
+ normalized = str(value).strip().lower()
8185
+ if normalized in {"all", "workspace_all", "workspace-all", "default", "default_all", "default-all", "1"}:
8186
+ return PublicDepartmentScopeMode.all.value
8187
+ if normalized in {"custom", "explicit", "selected", "2"}:
8188
+ return PublicDepartmentScopeMode.custom.value
8189
+ return normalized or None
8190
+
8191
+
8192
+ def _normalize_department_scope(value: Any) -> dict[str, Any] | None:
8193
+ if not isinstance(value, dict):
8194
+ return None
8195
+ raw_departments = value.get("departments", value.get("depart", value.get("departs")))
8196
+ departments: list[dict[str, Any]] = []
8197
+ if isinstance(raw_departments, list):
8198
+ for item in raw_departments:
8199
+ if isinstance(item, dict):
8200
+ dept_id = _coerce_positive_int(item.get("dept_id", item.get("deptId", item.get("id"))))
8201
+ dept_name = str(
8202
+ item.get("dept_name", item.get("deptName", item.get("name", item.get("value")))) or ""
8203
+ ).strip() or None
8204
+ else:
8205
+ dept_id = _coerce_positive_int(item)
8206
+ dept_name = None
8207
+ if dept_id is None and dept_name is None:
8208
+ continue
8209
+ entry: dict[str, Any] = {}
8210
+ if dept_id is not None:
8211
+ entry["dept_id"] = dept_id
8212
+ if dept_name is not None:
8213
+ entry["dept_name"] = dept_name
8214
+ departments.append(entry)
8215
+ normalized_mode = _normalize_department_scope_mode(value.get("mode"))
8216
+ if normalized_mode is None:
8217
+ normalized_mode = PublicDepartmentScopeMode.custom.value if departments else PublicDepartmentScopeMode.all.value
8218
+ return {
8219
+ "mode": normalized_mode,
8220
+ "departments": departments,
8221
+ "include_sub_departs": (
8222
+ None
8223
+ if value.get("include_sub_departs", value.get("includeSubDeparts")) is None
8224
+ else bool(value.get("include_sub_departs", value.get("includeSubDeparts")))
8225
+ ),
8226
+ }
8227
+
8228
+
8229
+ def _normalize_department_scope_from_question(question: dict[str, Any]) -> dict[str, Any] | None:
8230
+ scope_type = _coerce_positive_int(question.get("deptSelectScopeType"))
8231
+ scope = question.get("deptSelectScope") if isinstance(question.get("deptSelectScope"), dict) else {}
8232
+ if not isinstance(scope, dict):
8233
+ scope = {}
8234
+ if isinstance(scope.get("dynamic"), list) and scope.get("dynamic"):
8235
+ return None
8236
+ if isinstance(scope.get("externalDepartList"), list) and scope.get("externalDepartList"):
8237
+ return None
8238
+ if scope_type == 1:
8239
+ return {
8240
+ "mode": PublicDepartmentScopeMode.all.value,
8241
+ "departments": [],
8242
+ "include_sub_departs": None
8243
+ if scope.get("includeSubDeparts") is None
8244
+ else bool(scope.get("includeSubDeparts")),
8245
+ }
8246
+ if scope_type == 2:
8247
+ departments: list[dict[str, Any]] = []
8248
+ for item in cast(list[Any], scope.get("depart") or []):
8249
+ if not isinstance(item, dict):
8250
+ continue
8251
+ dept_id = _coerce_positive_int(item.get("deptId", item.get("id")))
8252
+ dept_name = str(item.get("deptName", item.get("name", item.get("value"))) or "").strip() or None
8253
+ if dept_id is None and dept_name is None:
8254
+ continue
8255
+ entry: dict[str, Any] = {}
8256
+ if dept_id is not None:
8257
+ entry["dept_id"] = dept_id
8258
+ if dept_name is not None:
8259
+ entry["dept_name"] = dept_name
8260
+ departments.append(entry)
8261
+ return {
8262
+ "mode": PublicDepartmentScopeMode.custom.value,
8263
+ "departments": departments,
8264
+ "include_sub_departs": None
8265
+ if scope.get("includeSubDeparts") is None
8266
+ else bool(scope.get("includeSubDeparts")),
8267
+ }
8268
+ return None
8269
+
8270
+
8271
+ def _serialize_department_scope_for_question(value: Any) -> tuple[int, dict[str, Any]]:
8272
+ normalized = _normalize_department_scope(value)
8273
+ if normalized is None or normalized.get("mode") == PublicDepartmentScopeMode.all.value:
8274
+ scope: dict[str, Any] = {"depart": [], "dynamic": []}
8275
+ if normalized is not None and normalized.get("include_sub_departs") is not None:
8276
+ scope["includeSubDeparts"] = bool(normalized.get("include_sub_departs"))
8277
+ return 1, scope
8278
+ departments = []
8279
+ for item in cast(list[Any], normalized.get("departments") or []):
8280
+ if not isinstance(item, dict):
8281
+ continue
8282
+ dept_id = _coerce_positive_int(item.get("dept_id"))
8283
+ dept_name = str(item.get("dept_name") or "").strip() or None
8284
+ if dept_id is None and dept_name is None:
8285
+ continue
8286
+ entry: dict[str, Any] = {}
8287
+ if dept_id is not None:
8288
+ entry["deptId"] = dept_id
8289
+ if dept_name is not None:
8290
+ entry["deptName"] = dept_name
8291
+ departments.append(entry)
8292
+ scope = {"depart": departments, "dynamic": []}
8293
+ if normalized.get("include_sub_departs") is not None:
8294
+ scope["includeSubDeparts"] = bool(normalized.get("include_sub_departs"))
8295
+ return 2, scope
8296
+
8297
+
8298
+ def _department_scope_equal(left: Any, right: Any) -> bool:
8299
+ return _normalize_department_scope(left) == _normalize_department_scope(right)
8300
+
8301
+
8148
8302
  def _is_relation_target_metadata_read_restricted_api_error(error: QingflowApiError) -> bool:
8149
8303
  return error.backend_code in {40002, 40027, 40161}
8150
8304
 
@@ -8311,8 +8465,10 @@ def _hydrate_relation_field_configs(
8311
8465
  field["config"] = config
8312
8466
  display_selector = field.get("display_field") if isinstance(field.get("display_field"), dict) else None
8313
8467
  visible_selector_payloads = [item for item in cast(list[Any], field.get("visible_fields") or []) if isinstance(item, dict)]
8314
- if display_selector is None and not visible_selector_payloads:
8315
- continue
8468
+ if display_selector is None:
8469
+ raise ValueError("relation field requires display_field")
8470
+ if not visible_selector_payloads:
8471
+ raise ValueError("relation field requires visible_fields")
8316
8472
  target_fields = target_field_cache.get(target_app_key)
8317
8473
  if target_fields is None:
8318
8474
  try:
@@ -8349,7 +8505,31 @@ def _hydrate_relation_field_configs(
8349
8505
  )
8350
8506
  continue
8351
8507
  if not target_fields:
8352
- raise ValueError(f"target relation app '{target_app_key}' has no readable fields")
8508
+ display_field = _normalize_relation_target_stub(
8509
+ display_selector,
8510
+ fallback_name=str((display_selector or {}).get("name") or "").strip() or None,
8511
+ )
8512
+ visible_fields = [
8513
+ _normalize_relation_target_stub(item, fallback_name=str(item.get("name") or "").strip() or None)
8514
+ for item in visible_selector_payloads
8515
+ ]
8516
+ _apply_relation_target_selection(
8517
+ field=field,
8518
+ config=config,
8519
+ display_field=display_field,
8520
+ visible_fields=visible_fields,
8521
+ )
8522
+ degraded_entries.append(
8523
+ {
8524
+ "field_name": field.get("name"),
8525
+ "target_app_key": target_app_key,
8526
+ "display_field": deepcopy(field.get("display_field") or {}),
8527
+ "visible_fields": deepcopy(field.get("visible_fields") or []),
8528
+ "relation_mode": field.get("relation_mode"),
8529
+ "transport_error": None,
8530
+ }
8531
+ )
8532
+ continue
8353
8533
  display_field = _resolve_relation_target_field(
8354
8534
  target_fields=target_fields,
8355
8535
  selector_payload=display_selector,
@@ -8454,6 +8634,10 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
8454
8634
  }
8455
8635
  field["visible_fields"] = visible_fields
8456
8636
  field["field_name_show"] = bool(reference.get("fieldNameShow", True))
8637
+ if field_type == FieldType.department:
8638
+ department_scope = _normalize_department_scope_from_question(question)
8639
+ if department_scope is not None:
8640
+ field["department_scope"] = department_scope
8457
8641
  if field_type == FieldType.code_block:
8458
8642
  code_block_config = question.get("codeBlockConfig") if isinstance(question.get("codeBlockConfig"), dict) else {}
8459
8643
  field["code_block_config"] = {
@@ -9075,6 +9259,8 @@ def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any])
9075
9259
  payload["relation_mode"] = _normalize_relation_mode(field.get("relation_mode"))
9076
9260
  payload["display_field"] = deepcopy(field.get("display_field"))
9077
9261
  payload["visible_fields"] = deepcopy(field.get("visible_fields") or [])
9262
+ if field.get("type") == FieldType.department.value and field.get("department_scope") is not None:
9263
+ payload["department_scope"] = deepcopy(field.get("department_scope"))
9078
9264
  if field.get("type") == FieldType.code_block.value:
9079
9265
  payload["code_block_config"] = deepcopy(field.get("code_block_config") or {})
9080
9266
  if field.get("auto_trigger") is not None:
@@ -9155,6 +9341,7 @@ def _field_patch_to_internal(patch: FieldPatch) -> dict[str, Any]:
9155
9341
  "display_field": patch.display_field.model_dump(mode="json", exclude_none=True) if patch.display_field is not None else None,
9156
9342
  "visible_fields": [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields],
9157
9343
  "relation_mode": patch.relation_mode.value if patch.relation_mode is not None else None,
9344
+ "department_scope": patch.department_scope.model_dump(mode="json", exclude_none=True) if patch.department_scope is not None else None,
9158
9345
  "remote_lookup_config": remote_lookup_config,
9159
9346
  "_explicit_remote_lookup_config": remote_lookup_config is not None,
9160
9347
  "q_linker_binding": q_linker_binding,
@@ -9173,6 +9360,23 @@ def _field_patch_to_internal(patch: FieldPatch) -> dict[str, Any]:
9173
9360
 
9174
9361
 
9175
9362
  def _field_matches_patch(field: dict[str, Any], patch: FieldPatch) -> bool:
9363
+ field_display_selector = field.get("display_field")
9364
+ patch_display_selector = patch.display_field.model_dump(mode="json", exclude_none=True) if patch.display_field is not None else None
9365
+ field_visible_selectors = field.get("visible_fields")
9366
+ patch_visible_selectors = [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields]
9367
+ if (
9368
+ str(field.get("type") or "") == FieldType.relation.value
9369
+ and patch.type == PublicFieldType.relation
9370
+ and (field.get("target_app_key") or None) == patch.target_app_key
9371
+ and field_display_selector is None
9372
+ and not list(field_visible_selectors or [])
9373
+ ):
9374
+ relation_selector_match = True
9375
+ else:
9376
+ relation_selector_match = (
9377
+ _field_selector_payload_equal(field_display_selector, patch_display_selector)
9378
+ and _field_selector_list_equal(field_visible_selectors, patch_visible_selectors)
9379
+ )
9176
9380
  return (
9177
9381
  str(field.get("name") or "") == patch.name
9178
9382
  and str(field.get("type") or "") == patch.type.value
@@ -9180,9 +9384,12 @@ def _field_matches_patch(field: dict[str, Any], patch: FieldPatch) -> bool:
9180
9384
  and (field.get("description") or None) == patch.description
9181
9385
  and list(field.get("options") or []) == list(patch.options)
9182
9386
  and (field.get("target_app_key") or None) == patch.target_app_key
9183
- and _field_selector_payload_equal(field.get("display_field"), patch.display_field.model_dump(mode="json", exclude_none=True) if patch.display_field is not None else None)
9184
- and _field_selector_list_equal(field.get("visible_fields"), [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields])
9387
+ and relation_selector_match
9185
9388
  and _normalize_relation_mode(field.get("relation_mode")) == _normalize_relation_mode(patch.relation_mode.value if patch.relation_mode is not None else None)
9389
+ and (
9390
+ patch.department_scope is None
9391
+ or _department_scope_equal(field.get("department_scope"), patch.department_scope.model_dump(mode="json", exclude_none=True))
9392
+ )
9186
9393
  and _remote_lookup_config_equal(field.get("remote_lookup_config"), patch.remote_lookup_config.model_dump(mode="json", exclude_none=True) if patch.remote_lookup_config is not None else None)
9187
9394
  and _q_linker_binding_equal(field.get("q_linker_binding"), patch.q_linker_binding.model_dump(mode="json", exclude_none=True) if patch.q_linker_binding is not None else None)
9188
9395
  and _code_block_config_equal(field.get("code_block_config"), patch.code_block_config.model_dump(mode="json", exclude_none=True) if patch.code_block_config is not None else None)
@@ -9194,6 +9401,24 @@ def _field_matches_patch(field: dict[str, Any], patch: FieldPatch) -> bool:
9194
9401
  )
9195
9402
 
9196
9403
 
9404
+ def _merge_existing_field_with_patch(field: dict[str, Any], patch: FieldPatch) -> None:
9405
+ if str(field.get("type") or "") == FieldType.relation.value and patch.type == PublicFieldType.relation:
9406
+ if not str(field.get("target_app_key") or "").strip() and patch.target_app_key:
9407
+ field["target_app_key"] = patch.target_app_key
9408
+ if not isinstance(field.get("display_field"), dict) and patch.display_field is not None:
9409
+ field["display_field"] = patch.display_field.model_dump(mode="json", exclude_none=True)
9410
+ if not list(field.get("visible_fields") or []) and patch.visible_fields:
9411
+ field["visible_fields"] = [
9412
+ selector.model_dump(mode="json", exclude_none=True)
9413
+ for selector in patch.visible_fields
9414
+ ]
9415
+ if field.get("relation_mode") is None and patch.relation_mode is not None:
9416
+ field["relation_mode"] = patch.relation_mode.value
9417
+ if str(field.get("type") or "") == FieldType.department.value and patch.type == PublicFieldType.department:
9418
+ if field.get("department_scope") is None and patch.department_scope is not None:
9419
+ field["department_scope"] = patch.department_scope.model_dump(mode="json", exclude_none=True)
9420
+
9421
+
9197
9422
  def _field_selector_payload_equal(left: Any, right: Any) -> bool:
9198
9423
  if left is None and right is None:
9199
9424
  return True
@@ -9374,6 +9599,8 @@ def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
9374
9599
  field["visible_fields"] = list(payload["visible_fields"])
9375
9600
  if "relation_mode" in payload:
9376
9601
  field["relation_mode"] = payload["relation_mode"]
9602
+ if "department_scope" in payload:
9603
+ field["department_scope"] = payload["department_scope"]
9377
9604
  if "remote_lookup_config" in payload:
9378
9605
  field["remote_lookup_config"] = payload["remote_lookup_config"]
9379
9606
  field["config"] = deepcopy(payload["remote_lookup_config"])
@@ -10884,6 +11111,10 @@ def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]
10884
11111
  reference["referQueId"] = field.get("target_field_que_id")
10885
11112
  reference["optionalDataNum"] = _relation_mode_to_optional_data_num(field.get("relation_mode"))
10886
11113
  question["referenceConfig"] = reference
11114
+ if field.get("type") == FieldType.department.value:
11115
+ scope_type, scope_payload = _serialize_department_scope_for_question(field.get("department_scope"))
11116
+ question["deptSelectScopeType"] = scope_type
11117
+ question["deptSelectScope"] = scope_payload
10887
11118
  if field.get("type") == FieldType.code_block.value:
10888
11119
  code_block_config = _normalize_code_block_config(field.get("code_block_config") or field.get("config") or {}) or {
10889
11120
  "config_mode": 1,