@josephyan/qingflow-app-builder-mcp 0.2.0-beta.49 → 0.2.0-beta.50

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.
@@ -12,6 +12,7 @@ from uuid import uuid4
12
12
  from ..backend_client import BackendRequestContext
13
13
  from ..errors import QingflowApiError
14
14
  from ..json_types import JSONObject
15
+ from ..list_type_labels import RECORD_LIST_TYPE_LABELS, SYSTEM_VIEW_ID_TO_LIST_TYPE
15
16
  from ..solution.build_assembly_store import BuildAssemblyStore, default_artifacts, default_manifest
16
17
  from ..solution.compiler.chart_compiler import qingbi_workspace_visible_auth
17
18
  from ..solution.compiler.form_compiler import build_question, default_form_payload, default_member_auth
@@ -51,6 +52,7 @@ from .models import (
51
52
  PortalApplyRequest,
52
53
  PortalSectionPatch,
53
54
  PublicFieldType,
55
+ PublicRelationMode,
54
56
  PublicChartType,
55
57
  PublicViewType,
56
58
  SchemaPlanRequest,
@@ -1110,17 +1112,7 @@ class AiBuilderFacade:
1110
1112
  parsed = state["parsed"]
1111
1113
  response = AppFieldsReadResponse(
1112
1114
  app_key=app_key,
1113
- fields=[
1114
- {
1115
- "field_id": field.get("field_id"),
1116
- "que_id": field.get("que_id"),
1117
- "name": field.get("name"),
1118
- "type": field.get("type"),
1119
- "required": bool(field.get("required")),
1120
- "section_id": _find_field_section_id(parsed["layout"], str(field.get("name") or "")),
1121
- }
1122
- for field in parsed["fields"]
1123
- ],
1115
+ fields=[_compact_public_field_read(field=field, layout=parsed["layout"]) for field in parsed["fields"]],
1124
1116
  field_count=len(parsed["fields"]),
1125
1117
  )
1126
1118
  return {
@@ -1198,10 +1190,26 @@ class AiBuilderFacade:
1198
1190
  details={"app_key": app_key},
1199
1191
  suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1200
1192
  )
1193
+ summarized_views, config_read_errors = _summarize_views_with_config(self.views, profile=profile, views=views)
1201
1194
  response = AppViewsReadResponse(
1202
1195
  app_key=app_key,
1203
- views=_summarize_views(views),
1196
+ views=summarized_views,
1204
1197
  )
1198
+ warnings = []
1199
+ if response.views:
1200
+ warnings.append(
1201
+ _warning(
1202
+ "VIEW_FILTERS_UNVERIFIED",
1203
+ "view summary does not verify saved filter behavior; use apply verification or explicit reads before relying on filters",
1204
+ )
1205
+ )
1206
+ if config_read_errors:
1207
+ warnings.append(
1208
+ _warning(
1209
+ "VIEW_CONFIG_READ_PARTIAL",
1210
+ "some view configs could not be read back; field order and display order may be incomplete for those views",
1211
+ )
1212
+ )
1205
1213
  return {
1206
1214
  "status": "success",
1207
1215
  "error_code": None,
@@ -1210,12 +1218,16 @@ class AiBuilderFacade:
1210
1218
  "normalized_args": {"app_key": app_key},
1211
1219
  "missing_fields": [],
1212
1220
  "allowed_values": {},
1213
- "details": {},
1221
+ "details": {"view_config_read_errors": config_read_errors} if config_read_errors else {},
1214
1222
  "request_id": None,
1215
1223
  "suggested_next_call": None,
1216
1224
  "noop": False,
1217
- "warnings": [_warning("VIEW_FILTERS_UNVERIFIED", "view summary does not verify saved filter behavior; use apply verification or explicit reads before relying on filters")] if response.views else [],
1218
- "verification": {"app_exists": True, "view_filters_verified": False},
1225
+ "warnings": warnings,
1226
+ "verification": {
1227
+ "app_exists": True,
1228
+ "view_filters_verified": False,
1229
+ "view_display_readback_complete": not config_read_errors,
1230
+ },
1219
1231
  "verified": True,
1220
1232
  **response.model_dump(mode="json"),
1221
1233
  }
@@ -2752,6 +2764,8 @@ class AiBuilderFacade:
2752
2764
  matched_existing_view = name_matches[0]
2753
2765
  existing_key = _extract_view_key(matched_existing_view)
2754
2766
  created_key: str | None = None
2767
+ system_view_list_type = _resolve_system_view_list_type(view_key=existing_key, view_name=patch.name) if existing_key else None
2768
+ operation_phase = "view_update" if existing_key else "view_create"
2755
2769
  try:
2756
2770
  if existing_key:
2757
2771
  payload = _build_view_update_payload(
@@ -2763,6 +2777,41 @@ class AiBuilderFacade:
2763
2777
  view_filters=translated_filters,
2764
2778
  )
2765
2779
  self.views.view_update(profile=profile, viewgraph_key=existing_key, payload=payload)
2780
+ system_view_sync: dict[str, Any] | None = None
2781
+ if system_view_list_type is not None and patch.type.value == "table":
2782
+ operation_phase = "default_view_apply_config_sync"
2783
+ system_view_sync = self._sync_system_view_apply_config(
2784
+ profile=profile,
2785
+ app_key=app_key,
2786
+ list_type=system_view_list_type,
2787
+ schema=schema,
2788
+ visible_field_names=patch.columns,
2789
+ )
2790
+ if not bool(system_view_sync.get("verified")):
2791
+ failure_entry = {
2792
+ "name": patch.name,
2793
+ "view_key": existing_key,
2794
+ "type": patch.type.value,
2795
+ "status": "failed",
2796
+ "error_code": "SYSTEM_VIEW_ORDER_SYNC_FAILED",
2797
+ "message": "default view column order did not verify through app apply/baseInfo readback",
2798
+ "request_id": None,
2799
+ "backend_code": None,
2800
+ "http_status": None,
2801
+ "operation": "sync_default_view",
2802
+ "details": {
2803
+ "app_key": app_key,
2804
+ "view_name": patch.name,
2805
+ "view_key": existing_key,
2806
+ "view_type": patch.type.value,
2807
+ "list_type": system_view_list_type,
2808
+ "expected_visible_order": system_view_sync.get("expected_visible_order"),
2809
+ "actual_visible_order": system_view_sync.get("actual_visible_order"),
2810
+ },
2811
+ }
2812
+ failed_views.append(failure_entry)
2813
+ view_results.append(failure_entry)
2814
+ continue
2766
2815
  updated.append(patch.name)
2767
2816
  view_results.append(
2768
2817
  {
@@ -2771,6 +2820,7 @@ class AiBuilderFacade:
2771
2820
  "type": patch.type.value,
2772
2821
  "status": "updated",
2773
2822
  "expected_filters": deepcopy(translated_filters),
2823
+ "system_view_sync": system_view_sync,
2774
2824
  }
2775
2825
  )
2776
2826
  else:
@@ -2816,8 +2866,9 @@ class AiBuilderFacade:
2816
2866
  )
2817
2867
  except (QingflowApiError, RuntimeError) as error:
2818
2868
  api_error = _coerce_api_error(error)
2819
- should_retry_minimal = api_error.backend_code == 48104 or (
2820
- patch.type.value == "table" and bool(patch.filters) and api_error.http_status is not None and api_error.http_status >= 500
2869
+ should_retry_minimal = operation_phase != "default_view_apply_config_sync" and (
2870
+ api_error.backend_code == 48104
2871
+ or (patch.type.value == "table" and bool(patch.filters) and api_error.http_status is not None and api_error.http_status >= 500)
2821
2872
  )
2822
2873
  if should_retry_minimal:
2823
2874
  try:
@@ -2893,7 +2944,7 @@ class AiBuilderFacade:
2893
2944
  "request_id": api_error.request_id,
2894
2945
  "backend_code": api_error.backend_code,
2895
2946
  "http_status": None if api_error.http_status == 404 else api_error.http_status,
2896
- "operation": "update" if existing_key or created_key else "create",
2947
+ "operation": "sync_default_view" if operation_phase == "default_view_apply_config_sync" else ("update" if existing_key or created_key else "create"),
2897
2948
  "details": {
2898
2949
  "app_key": app_key,
2899
2950
  "view_name": patch.name,
@@ -2904,7 +2955,8 @@ class AiBuilderFacade:
2904
2955
  "start_field": patch.start_field,
2905
2956
  "end_field": patch.end_field,
2906
2957
  "title_field": patch.title_field,
2907
- "operation": "update" if existing_key or created_key else "create",
2958
+ "operation": "sync_default_view" if operation_phase == "default_view_apply_config_sync" else ("update" if existing_key or created_key else "create"),
2959
+ "list_type": system_view_list_type,
2908
2960
  "transport_error": {
2909
2961
  "http_status": api_error.http_status,
2910
2962
  "backend_code": api_error.backend_code,
@@ -2966,6 +3018,9 @@ class AiBuilderFacade:
2966
3018
  "status": status,
2967
3019
  "present_in_readback": present_in_readback,
2968
3020
  }
3021
+ system_view_sync = item.get("system_view_sync")
3022
+ if isinstance(system_view_sync, dict):
3023
+ verification_entry["system_view_sync"] = deepcopy(system_view_sync)
2969
3024
  expected_filters = item.get("expected_filters") or []
2970
3025
  if expected_filters:
2971
3026
  if verified_views_unavailable or not present_in_readback:
@@ -3843,6 +3898,40 @@ class AiBuilderFacade:
3843
3898
  "schema_source": schema_source,
3844
3899
  }
3845
3900
 
3901
+ def _sync_system_view_apply_config(
3902
+ self,
3903
+ *,
3904
+ profile: str,
3905
+ app_key: str,
3906
+ list_type: int,
3907
+ schema: dict[str, Any],
3908
+ visible_field_names: list[str],
3909
+ ) -> dict[str, Any]:
3910
+ base_info_response = self.apps.app_get_apply_base_info(profile=profile, app_key=app_key, list_type=list_type)
3911
+ base_info_result = base_info_response.get("result") if isinstance(base_info_response.get("result"), dict) else {}
3912
+ current_que_base_infos = base_info_result.get("queBaseInfos") if isinstance(base_info_result.get("queBaseInfos"), list) else []
3913
+ change_ques = _build_apply_config_change_ques(
3914
+ current_que_base_infos=current_que_base_infos,
3915
+ schema=schema,
3916
+ visible_field_names=visible_field_names,
3917
+ )
3918
+ self.apps.app_update_apply_config(
3919
+ profile=profile,
3920
+ app_key=app_key,
3921
+ payload={"type": list_type, "changeQues": change_ques},
3922
+ )
3923
+ readback_response = self.apps.app_get_apply_base_info(profile=profile, app_key=app_key, list_type=list_type)
3924
+ readback_result = readback_response.get("result") if isinstance(readback_response.get("result"), dict) else {}
3925
+ actual_visible_order = _extract_apply_visible_field_names(readback_result.get("queBaseInfos"))
3926
+ expected_visible_order = [name for name in visible_field_names if name]
3927
+ verified = actual_visible_order[: len(expected_visible_order)] == expected_visible_order
3928
+ return {
3929
+ "list_type": list_type,
3930
+ "expected_visible_order": expected_visible_order,
3931
+ "actual_visible_order": actual_visible_order,
3932
+ "verified": verified,
3933
+ }
3934
+
3846
3935
  def _load_views_result(self, *, profile: str, app_key: str, tolerate_404: bool) -> tuple[Any, bool]:
3847
3936
  try:
3848
3937
  views = self.views.view_list_flat(profile=profile, app_key=app_key)
@@ -4402,6 +4491,23 @@ def _coerce_positive_int(value: Any) -> int | None:
4402
4491
  return None
4403
4492
 
4404
4493
 
4494
+ def _coerce_nonnegative_int(value: Any) -> int | None:
4495
+ if isinstance(value, bool) or value is None:
4496
+ return None
4497
+ if isinstance(value, int):
4498
+ return value if value >= 0 else None
4499
+ if isinstance(value, float):
4500
+ parsed = int(value)
4501
+ return parsed if parsed >= 0 else None
4502
+ if isinstance(value, str) and value.strip():
4503
+ try:
4504
+ parsed = int(value)
4505
+ except ValueError:
4506
+ return None
4507
+ return parsed if parsed >= 0 else None
4508
+ return None
4509
+
4510
+
4405
4511
  def _coerce_int_list(values: Any) -> list[int]:
4406
4512
  if not isinstance(values, list):
4407
4513
  return []
@@ -5035,6 +5141,143 @@ def _extract_view_key(view: dict[str, Any]) -> str:
5035
5141
  return str(view.get("viewgraphKey") or view.get("viewKey") or "").strip()
5036
5142
 
5037
5143
 
5144
+ def _resolve_system_view_list_type(*, view_key: str | None, view_name: str | None) -> int | None:
5145
+ normalized_key = str(view_key or "").strip()
5146
+ if normalized_key in SYSTEM_VIEW_ID_TO_LIST_TYPE:
5147
+ return SYSTEM_VIEW_ID_TO_LIST_TYPE[normalized_key]
5148
+ numeric_key = _coerce_positive_int(normalized_key)
5149
+ if numeric_key is not None and numeric_key in RECORD_LIST_TYPE_LABELS:
5150
+ return numeric_key
5151
+ normalized_name = str(view_name or "").strip()
5152
+ if not normalized_name:
5153
+ return None
5154
+ if normalized_name == "全部数据":
5155
+ return 8
5156
+ if normalized_name == "我发起的":
5157
+ return 14
5158
+ if normalized_name == "待办":
5159
+ return 1
5160
+ if normalized_name == "已办":
5161
+ return 2
5162
+ if normalized_name in {"抄送", "抄送我的"}:
5163
+ return 12
5164
+ return None
5165
+
5166
+
5167
+ def _build_apply_config_change_ques(
5168
+ *,
5169
+ current_que_base_infos: list[dict[str, Any]],
5170
+ schema: dict[str, Any],
5171
+ visible_field_names: list[str],
5172
+ ) -> list[dict[str, Any]]:
5173
+ parsed_schema = _parse_schema(schema)
5174
+ fields_by_name = {
5175
+ str(field.get("name") or ""): field
5176
+ for field in parsed_schema.get("fields", [])
5177
+ if isinstance(field, dict) and str(field.get("name") or "")
5178
+ }
5179
+ current_items: list[dict[str, Any]] = [item for item in current_que_base_infos if isinstance(item, dict)]
5180
+ current_by_que_id = {
5181
+ _coerce_positive_int(item.get("queId")): item
5182
+ for item in current_items
5183
+ if _coerce_positive_int(item.get("queId")) is not None
5184
+ }
5185
+ ordered_visible_que_ids: list[int] = []
5186
+ for field_name in visible_field_names:
5187
+ field = fields_by_name.get(str(field_name or "").strip())
5188
+ que_id = _coerce_positive_int(field.get("que_id")) if isinstance(field, dict) else None
5189
+ if que_id is not None and que_id not in ordered_visible_que_ids:
5190
+ ordered_visible_que_ids.append(que_id)
5191
+ selected_que_ids = set(ordered_visible_que_ids)
5192
+ ordered_que_ids: list[int] = list(ordered_visible_que_ids)
5193
+ for item in current_items:
5194
+ que_id = _coerce_positive_int(item.get("queId"))
5195
+ if que_id is not None and que_id not in selected_que_ids and que_id not in ordered_que_ids:
5196
+ ordered_que_ids.append(que_id)
5197
+ for field in parsed_schema.get("fields", []):
5198
+ if not isinstance(field, dict):
5199
+ continue
5200
+ que_id = _coerce_positive_int(field.get("que_id"))
5201
+ if que_id is not None and que_id not in ordered_que_ids:
5202
+ ordered_que_ids.append(que_id)
5203
+ return [
5204
+ _normalize_apply_change_que(
5205
+ current_by_que_id.get(que_id),
5206
+ fallback_field=next(
5207
+ (
5208
+ field
5209
+ for field in parsed_schema.get("fields", [])
5210
+ if isinstance(field, dict) and _coerce_positive_int(field.get("que_id")) == que_id
5211
+ ),
5212
+ None,
5213
+ ),
5214
+ visible=que_id in selected_que_ids,
5215
+ )
5216
+ for que_id in ordered_que_ids
5217
+ ]
5218
+
5219
+
5220
+ def _normalize_apply_change_que(
5221
+ item: dict[str, Any] | None,
5222
+ *,
5223
+ fallback_field: dict[str, Any] | None,
5224
+ visible: bool,
5225
+ ) -> dict[str, Any]:
5226
+ que_id = _coerce_positive_int((item or {}).get("queId"))
5227
+ if que_id is None and isinstance(fallback_field, dict):
5228
+ que_id = _coerce_positive_int(fallback_field.get("que_id"))
5229
+ payload: dict[str, Any] = {
5230
+ "queId": que_id,
5231
+ "beingVisible": visible,
5232
+ }
5233
+ width = _coerce_positive_int((item or {}).get("width"))
5234
+ if width is not None:
5235
+ payload["width"] = width
5236
+ current_sub_ques = (item or {}).get("subQues")
5237
+ if isinstance(current_sub_ques, list):
5238
+ payload["subQues"] = [
5239
+ _normalize_apply_sub_change_que(sub_item, visible=visible)
5240
+ for sub_item in current_sub_ques
5241
+ if isinstance(sub_item, dict)
5242
+ ]
5243
+ elif isinstance(fallback_field, dict) and isinstance(fallback_field.get("subfields"), list):
5244
+ payload["subQues"] = [
5245
+ {
5246
+ "queId": _coerce_positive_int(subfield.get("que_id")),
5247
+ "beingVisible": visible,
5248
+ }
5249
+ for subfield in fallback_field.get("subfields", [])
5250
+ if isinstance(subfield, dict) and _coerce_positive_int(subfield.get("que_id")) is not None
5251
+ ]
5252
+ return payload
5253
+
5254
+
5255
+ def _normalize_apply_sub_change_que(item: dict[str, Any], *, visible: bool) -> dict[str, Any]:
5256
+ payload: dict[str, Any] = {
5257
+ "queId": _coerce_positive_int(item.get("queId")),
5258
+ "beingVisible": visible and bool(item.get("beingVisible", True)),
5259
+ }
5260
+ width = _coerce_positive_int(item.get("width"))
5261
+ if width is not None:
5262
+ payload["width"] = width
5263
+ return payload
5264
+
5265
+
5266
+ def _extract_apply_visible_field_names(que_base_infos: Any) -> list[str]:
5267
+ if not isinstance(que_base_infos, list):
5268
+ return []
5269
+ visible_names: list[str] = []
5270
+ for item in que_base_infos:
5271
+ if not isinstance(item, dict):
5272
+ continue
5273
+ if not bool(item.get("beingVisible", True)):
5274
+ continue
5275
+ name = str(item.get("queTitle") or "").strip()
5276
+ if name:
5277
+ visible_names.append(name)
5278
+ return visible_names
5279
+
5280
+
5038
5281
  def _empty_schema_result(title: str) -> dict[str, Any]:
5039
5282
  return {
5040
5283
  "formTitle": title,
@@ -5090,6 +5333,33 @@ def _resolve_relation_target_field(
5090
5333
  return target_fields[match_index]
5091
5334
 
5092
5335
 
5336
+ def _relation_mode_from_optional_data_num(value: Any) -> str:
5337
+ return PublicRelationMode.multiple.value if _relation_mode_to_optional_data_num(value) == 0 else PublicRelationMode.single.value
5338
+
5339
+
5340
+ def _relation_mode_to_optional_data_num(value: Any) -> int:
5341
+ if isinstance(value, bool):
5342
+ return 0 if value else 1
5343
+ if isinstance(value, int):
5344
+ return 0 if value == 0 else 1
5345
+ if isinstance(value, str):
5346
+ normalized = value.strip().lower()
5347
+ if normalized in {
5348
+ PublicRelationMode.multiple.value,
5349
+ "multi",
5350
+ "multi_select",
5351
+ "multi-select",
5352
+ "many",
5353
+ "0",
5354
+ }:
5355
+ return 0
5356
+ return 1
5357
+
5358
+
5359
+ def _normalize_relation_mode(value: Any) -> str:
5360
+ return _relation_mode_from_optional_data_num(value)
5361
+
5362
+
5093
5363
  def _hydrate_relation_field_configs(
5094
5364
  facade: "AiBuilderFacade",
5095
5365
  *,
@@ -5104,6 +5374,11 @@ def _hydrate_relation_field_configs(
5104
5374
  target_app_key = str(field.get("target_app_key") or "").strip()
5105
5375
  if not target_app_key:
5106
5376
  continue
5377
+ config = deepcopy(field.get("config") or {})
5378
+ relation_mode = _normalize_relation_mode(field.get("relation_mode", config.get("optional_data_num")))
5379
+ config["optional_data_num"] = _relation_mode_to_optional_data_num(relation_mode)
5380
+ field["relation_mode"] = relation_mode
5381
+ field["config"] = config
5107
5382
  display_selector = field.get("display_field") if isinstance(field.get("display_field"), dict) else None
5108
5383
  visible_selector_payloads = [item for item in cast(list[Any], field.get("visible_fields") or []) if isinstance(item, dict)]
5109
5384
  if display_selector is None and not visible_selector_payloads:
@@ -5132,7 +5407,6 @@ def _hydrate_relation_field_configs(
5132
5407
  for item in visible_fields
5133
5408
  ):
5134
5409
  visible_fields = [display_field, *visible_fields]
5135
- config = deepcopy(field.get("config") or {})
5136
5410
  config["target_field_label"] = display_field.get("name")
5137
5411
  config["target_field_type"] = display_field.get("type")
5138
5412
  config["target_field_que_id"] = display_field.get("que_id")
@@ -5217,6 +5491,7 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
5217
5491
  if field_type == FieldType.relation:
5218
5492
  reference = question.get("referenceConfig") if isinstance(question.get("referenceConfig"), dict) else {}
5219
5493
  field["target_app_key"] = reference.get("referAppKey")
5494
+ field["relation_mode"] = _relation_mode_from_optional_data_num(reference.get("optionalDataNum"))
5220
5495
  refer_questions = reference.get("referQuestions") if isinstance(reference.get("referQuestions"), list) else []
5221
5496
  visible_fields: list[dict[str, Any]] = []
5222
5497
  display_field_que_id = _coerce_positive_int(reference.get("referQueId"))
@@ -5367,6 +5642,23 @@ def _resolve_layout_field_name(
5367
5642
  return None
5368
5643
 
5369
5644
 
5645
+ def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
5646
+ payload = {
5647
+ "field_id": field.get("field_id"),
5648
+ "que_id": field.get("que_id"),
5649
+ "name": field.get("name"),
5650
+ "type": field.get("type"),
5651
+ "required": bool(field.get("required")),
5652
+ "section_id": _find_field_section_id(layout, str(field.get("name") or "")),
5653
+ }
5654
+ if field.get("type") == FieldType.relation.value:
5655
+ payload["target_app_key"] = field.get("target_app_key")
5656
+ payload["relation_mode"] = _normalize_relation_mode(field.get("relation_mode"))
5657
+ payload["display_field"] = deepcopy(field.get("display_field"))
5658
+ payload["visible_fields"] = deepcopy(field.get("visible_fields") or [])
5659
+ return payload
5660
+
5661
+
5370
5662
  def _build_selector_map(fields: list[dict[str, Any]]) -> dict[str, int]:
5371
5663
  mapping: dict[str, int] = {}
5372
5664
  for index, field in enumerate(fields):
@@ -5415,6 +5707,7 @@ def _field_patch_to_internal(patch: FieldPatch) -> dict[str, Any]:
5415
5707
  "target_app_key": patch.target_app_key,
5416
5708
  "display_field": patch.display_field.model_dump(mode="json", exclude_none=True) if patch.display_field is not None else None,
5417
5709
  "visible_fields": [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields],
5710
+ "relation_mode": patch.relation_mode.value if patch.relation_mode is not None else None,
5418
5711
  "subfields": [_field_patch_to_internal(subfield) for subfield in patch.subfields],
5419
5712
  "que_id": None,
5420
5713
  }
@@ -5430,6 +5723,7 @@ def _field_matches_patch(field: dict[str, Any], patch: FieldPatch) -> bool:
5430
5723
  and (field.get("target_app_key") or None) == patch.target_app_key
5431
5724
  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)
5432
5725
  and _field_selector_list_equal(field.get("visible_fields"), [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields])
5726
+ and _normalize_relation_mode(field.get("relation_mode")) == _normalize_relation_mode(patch.relation_mode.value if patch.relation_mode is not None else None)
5433
5727
  and len(field.get("subfields") or []) == len(patch.subfields)
5434
5728
  )
5435
5729
 
@@ -5472,6 +5766,8 @@ def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
5472
5766
  field["display_field"] = payload["display_field"]
5473
5767
  if "visible_fields" in payload:
5474
5768
  field["visible_fields"] = list(payload["visible_fields"])
5769
+ if "relation_mode" in payload:
5770
+ field["relation_mode"] = payload["relation_mode"]
5475
5771
  if "subfields" in payload:
5476
5772
  field["subfields"] = [_field_patch_to_internal(item) for item in payload["subfields"]]
5477
5773
 
@@ -5956,6 +6252,7 @@ def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]
5956
6252
  reference["_targetEntityId"] = field.get("target_app_key")
5957
6253
  if field.get("target_field_que_id"):
5958
6254
  reference["referQueId"] = field.get("target_field_que_id")
6255
+ reference["optionalDataNum"] = _relation_mode_to_optional_data_num(field.get("relation_mode"))
5959
6256
  question["referenceConfig"] = reference
5960
6257
  return question
5961
6258
 
@@ -6188,6 +6485,214 @@ def _summarize_views(result: Any) -> list[dict[str, Any]]:
6188
6485
  return items
6189
6486
 
6190
6487
 
6488
+ def _summarize_views_with_config(views_tool: ViewTools, *, profile: str, views: Any) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
6489
+ base_items = _summarize_views(views)
6490
+ if not base_items:
6491
+ return [], []
6492
+ config_read_errors: list[dict[str, Any]] = []
6493
+ enriched_items: list[dict[str, Any]] = []
6494
+ for item in base_items:
6495
+ view_key = str(item.get("view_key") or "").strip()
6496
+ if not view_key:
6497
+ enriched_items.append(item)
6498
+ continue
6499
+ try:
6500
+ config_response = views_tool.view_get_config(profile=profile, viewgraph_key=view_key)
6501
+ except (QingflowApiError, RuntimeError) as error:
6502
+ api_error = _coerce_api_error(error)
6503
+ config_read_errors.append(
6504
+ {
6505
+ "view_key": view_key,
6506
+ "name": item.get("name"),
6507
+ "request_id": api_error.request_id,
6508
+ "http_status": api_error.http_status,
6509
+ "backend_code": api_error.backend_code,
6510
+ "category": api_error.category,
6511
+ }
6512
+ )
6513
+ enriched_items.append(item)
6514
+ continue
6515
+ config = config_response.get("result") if isinstance(config_response.get("result"), dict) else {}
6516
+ enriched_items.append(_merge_view_summary_with_config(item, config=config))
6517
+ return enriched_items, config_read_errors
6518
+
6519
+
6520
+ def _merge_view_summary_with_config(base: dict[str, Any], *, config: dict[str, Any]) -> dict[str, Any]:
6521
+ summary = deepcopy(base)
6522
+ if not isinstance(config, dict) or not config:
6523
+ return summary
6524
+ legacy_columns = [str(value) for value in (summary.get("columns") or []) if str(value or "").strip()]
6525
+ question_entries = _extract_view_question_entries(config.get("viewgraphQuestions"))
6526
+ question_entries_by_id = {
6527
+ field_id: entry
6528
+ for entry in question_entries
6529
+ if (field_id := _coerce_nonnegative_int(entry.get("field_id"))) is not None
6530
+ }
6531
+ configured_column_ids = [
6532
+ field_id
6533
+ for field_id in (_coerce_nonnegative_int(value) for value in (config.get("viewgraphQueIds") or []))
6534
+ if field_id is not None
6535
+ ]
6536
+ configured_columns = _resolve_view_column_names(
6537
+ configured_column_ids,
6538
+ question_entries_by_id=question_entries_by_id,
6539
+ legacy_columns=legacy_columns,
6540
+ )
6541
+ config_enriched = False
6542
+ display_entries = _sort_view_question_entries(
6543
+ [entry for entry in question_entries if bool(entry.get("visible", True))],
6544
+ )
6545
+ display_column_ids = [
6546
+ field_id
6547
+ for field_id in (_coerce_nonnegative_int(entry.get("field_id")) for entry in display_entries)
6548
+ if field_id is not None
6549
+ ]
6550
+ display_columns = [
6551
+ str(entry.get("name") or "").strip()
6552
+ for entry in display_entries
6553
+ if str(entry.get("name") or "").strip()
6554
+ ]
6555
+ if not display_columns and configured_columns:
6556
+ display_columns = configured_columns
6557
+ display_column_ids = list(configured_column_ids)
6558
+ if display_columns:
6559
+ summary["columns"] = display_columns
6560
+ summary["display_columns"] = display_columns
6561
+ summary["display_column_ids"] = display_column_ids
6562
+ config_enriched = True
6563
+ elif configured_columns:
6564
+ summary["columns"] = configured_columns
6565
+ config_enriched = True
6566
+ if configured_columns:
6567
+ summary["configured_columns"] = configured_columns
6568
+ summary["configured_column_ids"] = configured_column_ids
6569
+ config_enriched = True
6570
+ if question_entries:
6571
+ summary["column_details"] = display_entries or _sort_view_question_entries(question_entries)
6572
+ config_enriched = True
6573
+ display_config = _extract_view_display_config(
6574
+ config,
6575
+ question_entries_by_id=question_entries_by_id,
6576
+ fallback_group_by=str(summary.get("group_by") or "").strip() or None,
6577
+ )
6578
+ if display_config:
6579
+ summary["display_config"] = display_config
6580
+ if not summary.get("group_by") and display_config.get("group_by"):
6581
+ summary["group_by"] = display_config.get("group_by")
6582
+ config_enriched = True
6583
+ if config_enriched:
6584
+ summary["read_source"] = "view_config"
6585
+ return summary
6586
+
6587
+
6588
+ def _extract_view_question_entries(questions: Any) -> list[dict[str, Any]]:
6589
+ if not isinstance(questions, list):
6590
+ return []
6591
+ entries: list[dict[str, Any]] = []
6592
+ for index, item in enumerate(questions, start=1):
6593
+ if not isinstance(item, dict):
6594
+ continue
6595
+ field_id = _coerce_nonnegative_int(item.get("queId"))
6596
+ name = str(item.get("queTitle") or "").strip() or None
6597
+ visible_raw = item.get("beingListDisplay")
6598
+ if visible_raw is None:
6599
+ visible_raw = item.get("beingVisible")
6600
+ visible = bool(visible_raw) if visible_raw is not None else True
6601
+ display_order = _coerce_positive_int(item.get("displayOrdinal"))
6602
+ entry: dict[str, Any] = {
6603
+ "field_id": field_id,
6604
+ "name": name,
6605
+ "visible": visible,
6606
+ "display_order": display_order if display_order is not None else index,
6607
+ }
6608
+ width = _coerce_positive_int(item.get("width"))
6609
+ if width is not None:
6610
+ entry["width"] = width
6611
+ entries.append(entry)
6612
+ return entries
6613
+
6614
+
6615
+ def _sort_view_question_entries(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
6616
+ return sorted(
6617
+ entries,
6618
+ key=lambda entry: (
6619
+ _coerce_positive_int(entry.get("display_order")) if _coerce_positive_int(entry.get("display_order")) is not None else 10**9,
6620
+ str(entry.get("name") or ""),
6621
+ ),
6622
+ )
6623
+
6624
+
6625
+ def _resolve_view_column_names(
6626
+ field_ids: list[int],
6627
+ *,
6628
+ question_entries_by_id: dict[int, dict[str, Any]],
6629
+ legacy_columns: list[str],
6630
+ ) -> list[str]:
6631
+ names: list[str] = []
6632
+ for index, field_id in enumerate(field_ids):
6633
+ entry = question_entries_by_id.get(field_id) or {}
6634
+ name = str(entry.get("name") or "").strip()
6635
+ if not name and index < len(legacy_columns):
6636
+ name = str(legacy_columns[index] or "").strip()
6637
+ if name:
6638
+ names.append(name)
6639
+ return names
6640
+
6641
+
6642
+ def _extract_view_display_config(
6643
+ config: dict[str, Any],
6644
+ *,
6645
+ question_entries_by_id: dict[int, dict[str, Any]],
6646
+ fallback_group_by: str | None,
6647
+ ) -> dict[str, Any]:
6648
+ if not isinstance(config, dict):
6649
+ return {}
6650
+ display_config: dict[str, Any] = {}
6651
+ for source_key, public_key in (
6652
+ ("defaultRowHigh", "row_height"),
6653
+ ("sortType", "sort_type"),
6654
+ ("beingPinNavigate", "pin_navigation"),
6655
+ ("beingShowCover", "show_cover"),
6656
+ ("beingShowTitleQue", "show_title_field"),
6657
+ ):
6658
+ if source_key in config:
6659
+ display_config[public_key] = config.get(source_key)
6660
+ title_field_id = _coerce_positive_int(config.get("titleQue"))
6661
+ if title_field_id is not None:
6662
+ display_config["title_field_id"] = title_field_id
6663
+ title_field = str((question_entries_by_id.get(title_field_id) or {}).get("name") or "").strip()
6664
+ if title_field:
6665
+ display_config["title_field"] = title_field
6666
+ group_field_id = _coerce_positive_int(config.get("groupQueId"))
6667
+ if group_field_id is not None:
6668
+ display_config["group_by_field_id"] = group_field_id
6669
+ group_by = str((question_entries_by_id.get(group_field_id) or {}).get("name") or "").strip()
6670
+ if group_by:
6671
+ display_config["group_by"] = group_by
6672
+ elif fallback_group_by:
6673
+ display_config["group_by"] = fallback_group_by
6674
+ raw_sorts = config.get("viewgraphSorts")
6675
+ if isinstance(raw_sorts, list):
6676
+ sorts: list[dict[str, Any]] = []
6677
+ for item in raw_sorts:
6678
+ if not isinstance(item, dict):
6679
+ continue
6680
+ sort_field_id = _coerce_positive_int(item.get("queId"))
6681
+ if sort_field_id is None:
6682
+ continue
6683
+ sort_entry: dict[str, Any] = {
6684
+ "field_id": sort_field_id,
6685
+ "ascending": bool(item.get("beingSortAscend", True)),
6686
+ }
6687
+ sort_name = str((question_entries_by_id.get(sort_field_id) or {}).get("name") or "").strip()
6688
+ if sort_name:
6689
+ sort_entry["field"] = sort_name
6690
+ sorts.append(sort_entry)
6691
+ if sorts:
6692
+ display_config["sorts"] = sorts
6693
+ return display_config
6694
+
6695
+
6191
6696
  def _summarize_charts(result: Any) -> list[dict[str, Any]]:
6192
6697
  if not isinstance(result, list):
6193
6698
  return []