@josephyan/qingflow-app-builder-mcp 1.1.31 → 1.1.33

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.
Files changed (88) hide show
  1. package/README.md +6 -4
  2. package/docs/local-agent-install.md +57 -6
  3. package/entry_point.py +1 -1
  4. package/npm/bin/qingflow-app-builder-mcp.mjs +1 -1
  5. package/npm/bin/qingflow-skills.mjs +5 -0
  6. package/npm/lib/runtime.mjs +20 -59
  7. package/npm/scripts/postinstall.mjs +1 -10
  8. package/package.json +3 -2
  9. package/pyproject.toml +1 -1
  10. package/skills/qingflow-app-builder/SKILL.md +111 -55
  11. package/skills/qingflow-app-builder/references/complete-system-development-guide.md +169 -0
  12. package/skills/qingflow-app-builder/references/create-app.md +31 -24
  13. package/skills/qingflow-app-builder/references/environments.md +1 -1
  14. package/skills/qingflow-app-builder/references/gotchas.md +40 -14
  15. package/skills/qingflow-app-builder/references/match-rules.md +196 -85
  16. package/skills/qingflow-app-builder/references/public-surface-sync.md +2 -2
  17. package/skills/qingflow-app-builder/references/single-app-development-guide.md +84 -0
  18. package/skills/qingflow-app-builder/references/solution-playbooks.md +10 -0
  19. package/skills/qingflow-app-builder/references/tool-selection.md +40 -25
  20. package/skills/qingflow-app-builder/references/update-flow.md +27 -52
  21. package/skills/qingflow-app-builder/references/update-schema.md +14 -0
  22. package/skills/qingflow-app-builder/references/update-views.md +101 -32
  23. package/skills/qingflow-app-builder/scripts/validate_system_build_summary.py +124 -0
  24. package/skills/qingflow-app-builder-code-integrations/SKILL.md +5 -3
  25. package/skills/qingflow-app-builder-code-integrations/references/code-block.md +1 -1
  26. package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +1 -1
  27. package/skills/qingflow-mcp-setup/SKILL.md +13 -9
  28. package/skills/qingflow-mcp-setup/references/claude-desktop.md +1 -1
  29. package/skills/qingflow-mcp-setup/references/environments.md +2 -1
  30. package/skills/qingflow-mcp-setup/references/generic-stdio.md +6 -5
  31. package/skills/qingflow-workflow-builder/SKILL.md +9 -7
  32. package/skills/qingflow-workflow-builder/references/01-overview.md +4 -2
  33. package/skills/qingflow-workflow-builder/references/03-flow-patterns.md +2 -0
  34. package/skills/qingflow-workflow-builder/references/06-stage3-build-spec.md +59 -7
  35. package/skills/qingflow-workflow-builder/references/07-stage4-validate-spec.md +14 -5
  36. package/skills/qingflow-workflow-builder/references/08-stage5-apply-verify.md +19 -3
  37. package/skills/qingflow-workflow-builder/references/11-troubleshooting.md +2 -0
  38. package/src/qingflow_mcp/__main__.py +6 -2
  39. package/src/qingflow_mcp/builder_facade/models.py +473 -145
  40. package/src/qingflow_mcp/builder_facade/service.py +6288 -1826
  41. package/src/qingflow_mcp/cli/commands/builder.py +358 -355
  42. package/src/qingflow_mcp/cli/commands/chart.py +1 -1
  43. package/src/qingflow_mcp/cli/commands/common.py +12 -3
  44. package/src/qingflow_mcp/cli/commands/exports.py +2 -2
  45. package/src/qingflow_mcp/cli/commands/imports.py +3 -3
  46. package/src/qingflow_mcp/cli/commands/portal.py +2 -2
  47. package/src/qingflow_mcp/cli/commands/record.py +101 -27
  48. package/src/qingflow_mcp/cli/commands/task.py +28 -47
  49. package/src/qingflow_mcp/cli/commands/view.py +1 -1
  50. package/src/qingflow_mcp/cli/context.py +0 -3
  51. package/src/qingflow_mcp/cli/formatters.py +784 -16
  52. package/src/qingflow_mcp/cli/main.py +120 -41
  53. package/src/qingflow_mcp/errors.py +43 -2
  54. package/src/qingflow_mcp/public_surface.py +26 -17
  55. package/src/qingflow_mcp/response_trim.py +81 -17
  56. package/src/qingflow_mcp/server.py +14 -12
  57. package/src/qingflow_mcp/server_app_builder.py +83 -39
  58. package/src/qingflow_mcp/server_app_user.py +22 -16
  59. package/src/qingflow_mcp/session_store.py +11 -7
  60. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  61. package/src/qingflow_mcp/solution/executor.py +114 -15
  62. package/src/qingflow_mcp/tools/ai_builder_tools.py +3573 -1486
  63. package/src/qingflow_mcp/tools/app_tools.py +184 -43
  64. package/src/qingflow_mcp/tools/approval_tools.py +197 -35
  65. package/src/qingflow_mcp/tools/auth_tools.py +92 -16
  66. package/src/qingflow_mcp/tools/code_block_tools.py +298 -40
  67. package/src/qingflow_mcp/tools/custom_button_tools.py +64 -10
  68. package/src/qingflow_mcp/tools/directory_tools.py +236 -72
  69. package/src/qingflow_mcp/tools/export_tools.py +244 -34
  70. package/src/qingflow_mcp/tools/feedback_tools.py +9 -0
  71. package/src/qingflow_mcp/tools/file_tools.py +9 -3
  72. package/src/qingflow_mcp/tools/import_tools.py +336 -49
  73. package/src/qingflow_mcp/tools/navigation_tools.py +91 -12
  74. package/src/qingflow_mcp/tools/package_tools.py +118 -6
  75. package/src/qingflow_mcp/tools/portal_tools.py +39 -3
  76. package/src/qingflow_mcp/tools/qingbi_report_tools.py +116 -7
  77. package/src/qingflow_mcp/tools/record_tools.py +1141 -356
  78. package/src/qingflow_mcp/tools/resource_read_tools.py +188 -39
  79. package/src/qingflow_mcp/tools/role_tools.py +80 -9
  80. package/src/qingflow_mcp/tools/solution_tools.py +57 -15
  81. package/src/qingflow_mcp/tools/task_context_tools.py +687 -159
  82. package/src/qingflow_mcp/tools/task_tools.py +113 -29
  83. package/src/qingflow_mcp/tools/view_tools.py +106 -3
  84. package/src/qingflow_mcp/tools/workflow_tools.py +17 -1
  85. package/src/qingflow_mcp/tools/workspace_tools.py +71 -3
  86. package/src/qingflow_mcp/version.py +0 -2
  87. package/skills/qingflow-app-builder/references/build-complete-system.md +0 -234
  88. package/skills/qingflow-app-builder/references/build-single-app.md +0 -138
@@ -71,20 +71,36 @@ class PublicExternalVisibilityMode(str, Enum):
71
71
 
72
72
 
73
73
  FIELD_TYPE_ALIASES: dict[str, PublicFieldType] = {
74
- "textarea": PublicFieldType.long_text,
75
74
  "multiline": PublicFieldType.long_text,
76
- "multi_line": PublicFieldType.long_text,
77
75
  "multiline_text": PublicFieldType.long_text,
76
+ "multi_line": PublicFieldType.long_text,
77
+ "multi_line_text": PublicFieldType.long_text,
78
+ "textarea": PublicFieldType.long_text,
79
+ "longtext": PublicFieldType.long_text,
80
+ "long-text": PublicFieldType.long_text,
78
81
  "amount": PublicFieldType.amount,
79
82
  "currency": PublicFieldType.amount,
80
83
  "mobile": PublicFieldType.phone,
81
84
  "user": PublicFieldType.member,
82
85
  "users": PublicFieldType.member,
83
86
  "select": PublicFieldType.single_select,
87
+ "single_choice": PublicFieldType.single_select,
88
+ "single-choice": PublicFieldType.single_select,
89
+ "single choice": PublicFieldType.single_select,
90
+ "choice": PublicFieldType.single_select,
91
+ "dropdown": PublicFieldType.single_select,
84
92
  "radio": PublicFieldType.single_select,
85
93
  "checkbox": PublicFieldType.multi_select,
86
94
  "multi_select": PublicFieldType.multi_select,
87
95
  "multi-select": PublicFieldType.multi_select,
96
+ "multi select": PublicFieldType.multi_select,
97
+ "multiselect": PublicFieldType.multi_select,
98
+ "multi_choice": PublicFieldType.multi_select,
99
+ "multi-choice": PublicFieldType.multi_select,
100
+ "multi choice": PublicFieldType.multi_select,
101
+ "multiple_choice": PublicFieldType.multi_select,
102
+ "multiple-choice": PublicFieldType.multi_select,
103
+ "multiple choice": PublicFieldType.multi_select,
88
104
  "departments": PublicFieldType.department,
89
105
  "qlinker": PublicFieldType.q_linker,
90
106
  "q_linker": PublicFieldType.q_linker,
@@ -187,6 +203,10 @@ class LayoutPreset(str, Enum):
187
203
  single_section = "single_section"
188
204
 
189
205
 
206
+ class FlowPreset(str, Enum):
207
+ basic_approval = "basic_approval"
208
+ basic_fill_then_approve = "basic_fill_then_approve"
209
+
190
210
 
191
211
  class ViewsPreset(str, Enum):
192
212
  default_table = "default_table"
@@ -227,6 +247,116 @@ class ViewFilterOperator(str, Enum):
227
247
  not_empty = "not_empty"
228
248
 
229
249
 
250
+ _PUBLIC_CONDITION_OPERATOR_ALIASES: dict[str, str] = {
251
+ "eq": "eq",
252
+ "equal": "eq",
253
+ "equals": "eq",
254
+ "=": "eq",
255
+ "==": "eq",
256
+ "neq": "neq",
257
+ "ne": "neq",
258
+ "not_eq": "neq",
259
+ "not_equal": "neq",
260
+ "not_equals": "neq",
261
+ "noteq": "neq",
262
+ "notequal": "neq",
263
+ "notequals": "neq",
264
+ "!=": "neq",
265
+ "in": "in",
266
+ "any": "in",
267
+ "any_of": "in",
268
+ "anyof": "in",
269
+ "one_of": "in",
270
+ "oneof": "in",
271
+ "between_any": "in",
272
+ "betweenany": "in",
273
+ "equal_any": "in",
274
+ "equalany": "in",
275
+ "any_match": "in",
276
+ "anymatch": "in",
277
+ "contains": "contains",
278
+ "contain": "contains",
279
+ "include": "contains",
280
+ "includes": "contains",
281
+ "like": "contains",
282
+ "fuzzy": "contains",
283
+ "fuzzy_match": "contains",
284
+ "fuzzymatch": "contains",
285
+ "gte": "gte",
286
+ "ge": "gte",
287
+ ">=": "gte",
288
+ "greater_or_equal": "gte",
289
+ "greaterorequal": "gte",
290
+ "greater_than_or_equal": "gte",
291
+ "greaterthanorequal": "gte",
292
+ "lte": "lte",
293
+ "le": "lte",
294
+ "<=": "lte",
295
+ "less_or_equal": "lte",
296
+ "lessorequal": "lte",
297
+ "less_than_or_equal": "lte",
298
+ "lessthanorequal": "lte",
299
+ "is_empty": "is_empty",
300
+ "isempty": "is_empty",
301
+ "empty": "is_empty",
302
+ "blank": "is_empty",
303
+ "is_blank": "is_empty",
304
+ "isblank": "is_empty",
305
+ "null": "is_empty",
306
+ "is_null": "is_empty",
307
+ "isnull": "is_empty",
308
+ "not_empty": "not_empty",
309
+ "notempty": "not_empty",
310
+ "is_not_empty": "not_empty",
311
+ "isnotempty": "not_empty",
312
+ "not_blank": "not_empty",
313
+ "notblank": "not_empty",
314
+ "not_null": "not_empty",
315
+ "notnull": "not_empty",
316
+ "is_not_null": "not_empty",
317
+ "isnotnull": "not_empty",
318
+ }
319
+
320
+
321
+ def normalize_public_condition_operator(raw_operator: Any, *, extra_aliases: dict[str, str] | None = None) -> Any:
322
+ if not isinstance(raw_operator, str):
323
+ return raw_operator
324
+ text = raw_operator.strip().lower()
325
+ if not text:
326
+ return text
327
+ lookup = dict(_PUBLIC_CONDITION_OPERATOR_ALIASES)
328
+ if extra_aliases:
329
+ lookup.update(extra_aliases)
330
+ candidates = [
331
+ text,
332
+ re.sub(r"[\s-]+", "_", text),
333
+ re.sub(r"[\s_-]+", "", text),
334
+ ]
335
+ for candidate in candidates:
336
+ if candidate in lookup:
337
+ return lookup[candidate]
338
+ return text
339
+
340
+
341
+ def _normalize_public_condition_payload(value: Any) -> Any:
342
+ if not isinstance(value, dict):
343
+ return value
344
+ payload = dict(value)
345
+ if "value" in payload and "values" not in payload:
346
+ raw_value = payload.pop("value")
347
+ payload["values"] = list(raw_value) if isinstance(raw_value, list) else [raw_value]
348
+ elif "values" in payload and not isinstance(payload.get("values"), list):
349
+ payload["values"] = [payload.get("values")]
350
+ raw_operator = payload.get("operator", payload.get("op"))
351
+ if raw_operator is None:
352
+ payload["operator"] = "eq"
353
+ raw_operator = "eq"
354
+ if isinstance(raw_operator, str):
355
+ payload["operator"] = normalize_public_condition_operator(raw_operator)
356
+ payload.pop("op", None)
357
+ return payload
358
+
359
+
230
360
  class FlowAssigneePatch(StrictModel):
231
361
  role_ids: list[int] = Field(default_factory=list)
232
362
  role_names: list[str] = Field(default_factory=list)
@@ -242,45 +372,13 @@ class FlowNodePermissionsPatch(StrictModel):
242
372
 
243
373
  class FlowConditionRulePatch(StrictModel):
244
374
  field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
245
- operator: FlowConditionOperator = Field(validation_alias=AliasChoices("operator", "op"))
375
+ operator: FlowConditionOperator = Field(default=FlowConditionOperator.eq, validation_alias=AliasChoices("operator", "op"))
246
376
  values: list[Any] = Field(default_factory=list)
247
377
 
248
378
  @model_validator(mode="before")
249
379
  @classmethod
250
380
  def normalize_aliases(cls, value: Any) -> Any:
251
- if not isinstance(value, dict):
252
- return value
253
- payload = dict(value)
254
- if "value" in payload and "values" not in payload:
255
- raw_value = payload.pop("value")
256
- payload["values"] = list(raw_value) if isinstance(raw_value, list) else [raw_value]
257
- raw_operator = payload.get("operator", payload.get("op"))
258
- if isinstance(raw_operator, str):
259
- normalized = raw_operator.strip().lower()
260
- operator_aliases = {
261
- "equals": FlowConditionOperator.eq.value,
262
- "equal": FlowConditionOperator.eq.value,
263
- "=": FlowConditionOperator.eq.value,
264
- "not_equals": FlowConditionOperator.neq.value,
265
- "not_equal": FlowConditionOperator.neq.value,
266
- "!=": FlowConditionOperator.neq.value,
267
- ">=": FlowConditionOperator.gte.value,
268
- "<=": FlowConditionOperator.lte.value,
269
- "any_of": FlowConditionOperator.in_.value,
270
- "one_of": FlowConditionOperator.in_.value,
271
- "between_any": FlowConditionOperator.in_.value,
272
- "empty": FlowConditionOperator.is_empty.value,
273
- "is blank": FlowConditionOperator.is_empty.value,
274
- "blank": FlowConditionOperator.is_empty.value,
275
- "not_empty": FlowConditionOperator.not_empty.value,
276
- "not blank": FlowConditionOperator.not_empty.value,
277
- }
278
- if normalized in operator_aliases:
279
- payload["operator"] = operator_aliases[normalized]
280
- elif "operator" not in payload:
281
- payload["operator"] = normalized
282
- payload.pop("op", None)
283
- return payload
381
+ return _normalize_public_condition_payload(value)
284
382
 
285
383
  @model_validator(mode="after")
286
384
  def validate_shape(self) -> "FlowConditionRulePatch":
@@ -294,45 +392,13 @@ class FlowConditionRulePatch(StrictModel):
294
392
 
295
393
  class ViewFilterRulePatch(StrictModel):
296
394
  field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
297
- operator: ViewFilterOperator = Field(validation_alias=AliasChoices("operator", "op"))
395
+ operator: ViewFilterOperator = Field(default=ViewFilterOperator.eq, validation_alias=AliasChoices("operator", "op"))
298
396
  values: list[Any] = Field(default_factory=list)
299
397
 
300
398
  @model_validator(mode="before")
301
399
  @classmethod
302
400
  def normalize_aliases(cls, value: Any) -> Any:
303
- if not isinstance(value, dict):
304
- return value
305
- payload = dict(value)
306
- if "value" in payload and "values" not in payload:
307
- raw_value = payload.pop("value")
308
- payload["values"] = list(raw_value) if isinstance(raw_value, list) else [raw_value]
309
- raw_operator = payload.get("operator", payload.get("op"))
310
- if isinstance(raw_operator, str):
311
- normalized = raw_operator.strip().lower()
312
- operator_aliases = {
313
- "equals": ViewFilterOperator.eq.value,
314
- "equal": ViewFilterOperator.eq.value,
315
- "=": ViewFilterOperator.eq.value,
316
- "not_equals": ViewFilterOperator.neq.value,
317
- "not_equal": ViewFilterOperator.neq.value,
318
- "!=": ViewFilterOperator.neq.value,
319
- ">=": ViewFilterOperator.gte.value,
320
- "<=": ViewFilterOperator.lte.value,
321
- "any_of": ViewFilterOperator.in_.value,
322
- "one_of": ViewFilterOperator.in_.value,
323
- "between_any": ViewFilterOperator.in_.value,
324
- "empty": ViewFilterOperator.is_empty.value,
325
- "is blank": ViewFilterOperator.is_empty.value,
326
- "blank": ViewFilterOperator.is_empty.value,
327
- "not_empty": ViewFilterOperator.not_empty.value,
328
- "not blank": ViewFilterOperator.not_empty.value,
329
- }
330
- if normalized in operator_aliases:
331
- payload["operator"] = operator_aliases[normalized]
332
- elif "operator" not in payload:
333
- payload["operator"] = normalized
334
- payload.pop("op", None)
335
- return payload
401
+ return _normalize_public_condition_payload(value)
336
402
 
337
403
  @model_validator(mode="after")
338
404
  def validate_shape(self) -> "ViewFilterRulePatch":
@@ -1085,6 +1151,91 @@ class ViewAssociatedResourcesPatch(StrictModel):
1085
1151
  )
1086
1152
 
1087
1153
 
1154
+ class ViewActionButtonPatch(StrictModel):
1155
+ button_id: int | None = Field(default=None, validation_alias=AliasChoices("button_id", "buttonId", "id"))
1156
+ client_key: str | None = Field(default=None, validation_alias=AliasChoices("client_key", "clientKey"))
1157
+ text: str = Field(validation_alias=AliasChoices("text", "button_text", "buttonText", "name"))
1158
+ action: PublicButtonTriggerAction = Field(validation_alias=AliasChoices("action", "trigger_action", "triggerAction"))
1159
+ url: str | None = Field(default=None, validation_alias=AliasChoices("url", "link_url", "linkUrl", "trigger_link_url", "triggerLinkUrl"))
1160
+ target_app_key: str | None = Field(default=None, validation_alias=AliasChoices("target_app_key", "targetAppKey", "related_app_key", "relatedAppKey"))
1161
+ target_app_name: str | None = Field(default=None, validation_alias=AliasChoices("target_app_name", "targetAppName", "related_app_name", "relatedAppName"))
1162
+ field_mappings: list[dict[str, Any]] = Field(default_factory=list, validation_alias=AliasChoices("field_mappings", "fieldMappings", "mappings"))
1163
+ default_values: dict[str, Any] = Field(default_factory=dict, validation_alias=AliasChoices("default_values", "defaultValues", "defaults"))
1164
+ placement: PublicButtonPlacement = Field(default=PublicButtonPlacement.detail, validation_alias=AliasChoices("placement", "position"))
1165
+ primary: bool = Field(default=False, validation_alias=AliasChoices("primary", "being_main", "beingMain"))
1166
+ visible_when: list[list[ViewFilterRulePatch]] = Field(
1167
+ default_factory=list,
1168
+ validation_alias=AliasChoices("visible_when", "visibleWhen", "button_limit", "buttonLimit"),
1169
+ )
1170
+ button_formula: str | None = Field(default=None, validation_alias=AliasChoices("button_formula", "buttonFormula"))
1171
+ button_formula_type: int = Field(default=1, validation_alias=AliasChoices("button_formula_type", "buttonFormulaType"))
1172
+ print_tpls: list[Any] = Field(default_factory=list, validation_alias=AliasChoices("print_tpls", "printTpls"))
1173
+ style_preset: str | None = Field(default=None, validation_alias=AliasChoices("style_preset", "stylePreset"))
1174
+ background_color: str | None = Field(default=None, validation_alias=AliasChoices("background_color", "backgroundColor"))
1175
+ text_color: str | None = Field(default=None, validation_alias=AliasChoices("text_color", "textColor"))
1176
+ button_icon: str | None = Field(default=None, validation_alias=AliasChoices("button_icon", "buttonIcon"))
1177
+ external_qrobot_config: dict[str, Any] | None = Field(
1178
+ default=None,
1179
+ validation_alias=AliasChoices(
1180
+ "external_qrobot_config",
1181
+ "externalQrobotConfig",
1182
+ "externalQRobotConfig",
1183
+ "custom_button_external_qrobot_relation_vo",
1184
+ "customButtonExternalQRobotRelationVO",
1185
+ ),
1186
+ )
1187
+ trigger_wings_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("trigger_wings_config", "triggerWingsConfig"))
1188
+
1189
+ @model_validator(mode="before")
1190
+ @classmethod
1191
+ def normalize_aliases(cls, value: Any) -> Any:
1192
+ if not isinstance(value, dict):
1193
+ return value
1194
+ payload = dict(value)
1195
+ raw_action = payload.get("action", payload.get("trigger_action", payload.get("triggerAction")))
1196
+ if isinstance(raw_action, str):
1197
+ normalized = raw_action.strip()
1198
+ aliases = {
1199
+ "add_data": PublicButtonTriggerAction.add_data.value,
1200
+ "add-data": PublicButtonTriggerAction.add_data.value,
1201
+ "adddata": PublicButtonTriggerAction.add_data.value,
1202
+ "addData": PublicButtonTriggerAction.add_data.value,
1203
+ "link": PublicButtonTriggerAction.link.value,
1204
+ "url": PublicButtonTriggerAction.link.value,
1205
+ "qrobot": PublicButtonTriggerAction.qrobot.value,
1206
+ "qRobot": PublicButtonTriggerAction.qrobot.value,
1207
+ "wings": PublicButtonTriggerAction.wings.value,
1208
+ }
1209
+ payload["action"] = aliases.get(normalized, aliases.get(normalized.lower(), normalized))
1210
+ raw_placement = payload.get("placement", payload.get("position", payload.get("config_type", payload.get("configType"))))
1211
+ if isinstance(raw_placement, str):
1212
+ normalized = raw_placement.strip().lower()
1213
+ if normalized in {"top", "header"}:
1214
+ payload["placement"] = PublicButtonPlacement.header.value
1215
+ elif normalized in {"detail", "data_detail"}:
1216
+ payload["placement"] = PublicButtonPlacement.detail.value
1217
+ elif normalized in {"list", "row", "row_action", "inside"}:
1218
+ payload["placement"] = PublicButtonPlacement.list.value
1219
+ raw_limits = payload.get("visible_when", payload.get("visibleWhen", payload.get("button_limit", payload.get("buttonLimit"))))
1220
+ if isinstance(raw_limits, list) and raw_limits and all(isinstance(item, dict) for item in raw_limits):
1221
+ payload["visible_when"] = [raw_limits]
1222
+ return payload
1223
+
1224
+ @model_validator(mode="after")
1225
+ def validate_shape(self) -> "ViewActionButtonPatch":
1226
+ if not str(self.text or "").strip():
1227
+ raise ValueError("action_buttons[].text is required")
1228
+ if self.action == PublicButtonTriggerAction.link and not str(self.url or "").strip():
1229
+ raise ValueError("link action_buttons require url")
1230
+ if self.action == PublicButtonTriggerAction.add_data and not str(self.target_app_key or "").strip():
1231
+ raise ValueError("add_data action_buttons require target_app_key")
1232
+ if self.action == PublicButtonTriggerAction.qrobot and self.external_qrobot_config is None:
1233
+ raise ValueError("qRobot action_buttons require external_qrobot_config")
1234
+ if self.action == PublicButtonTriggerAction.wings and self.trigger_wings_config is None:
1235
+ raise ValueError("wings action_buttons require trigger_wings_config")
1236
+ return self
1237
+
1238
+
1088
1239
  class ViewUpsertPatch(StrictModel):
1089
1240
  name: str
1090
1241
  view_key: str | None = Field(default=None, validation_alias=AliasChoices("view_key", "viewKey"))
@@ -1092,11 +1243,12 @@ class ViewUpsertPatch(StrictModel):
1092
1243
  columns: list[str] = Field(default_factory=list)
1093
1244
  group_by: str | None = None
1094
1245
  filters: list[ViewFilterRulePatch] = Field(default_factory=list)
1095
- filter_groups: list[list[ViewFilterRulePatch]] = Field(default_factory=list, validation_alias=AliasChoices("filter_groups", "filterGroups", "or_filters", "orFilters"))
1096
1246
  start_field: str | None = Field(default=None, validation_alias=AliasChoices("start_field", "startField"))
1097
1247
  end_field: str | None = Field(default=None, validation_alias=AliasChoices("end_field", "endField"))
1098
1248
  title_field: str | None = Field(default=None, validation_alias=AliasChoices("title_field", "titleField"))
1099
1249
  buttons: list["ViewButtonBindingPatch"] | None = None
1250
+ action_buttons: list[ViewActionButtonPatch] | None = Field(default=None, validation_alias=AliasChoices("action_buttons", "actionButtons"))
1251
+ action_buttons_mode: str = Field(default="merge", validation_alias=AliasChoices("action_buttons_mode", "actionButtonsMode", "button_mode", "buttonMode"))
1100
1252
  visibility: VisibilityPatch | None = None
1101
1253
  query_conditions: ViewQueryConditionsPatch | None = Field(default=None, validation_alias=AliasChoices("query_conditions", "queryConditions", "query_condition", "queryCondition"))
1102
1254
  associated_resources: ViewAssociatedResourcesPatch | None = Field(
@@ -1132,12 +1284,6 @@ class ViewUpsertPatch(StrictModel):
1132
1284
  payload["filters"] = payload.pop("filter_rules")
1133
1285
  if "filterRules" in payload and "filters" not in payload:
1134
1286
  payload["filters"] = payload.pop("filterRules")
1135
- if "filterGroups" in payload and "filter_groups" not in payload:
1136
- payload["filter_groups"] = payload.pop("filterGroups")
1137
- if "or_filters" in payload and "filter_groups" not in payload:
1138
- payload["filter_groups"] = payload.pop("or_filters")
1139
- if "orFilters" in payload and "filter_groups" not in payload:
1140
- payload["filter_groups"] = payload.pop("orFilters")
1141
1287
  if "queryConditions" in payload and "query_conditions" not in payload:
1142
1288
  payload["query_conditions"] = payload.pop("queryConditions")
1143
1289
  if "query_condition" in payload and "query_conditions" not in payload:
@@ -1152,6 +1298,12 @@ class ViewUpsertPatch(StrictModel):
1152
1298
  payload["associated_resources"] = payload.pop("associatedReports")
1153
1299
  if "asosChartConfig" in payload and "associated_resources" not in payload:
1154
1300
  payload["associated_resources"] = payload.pop("asosChartConfig")
1301
+ if "actionButtons" in payload and "action_buttons" not in payload:
1302
+ payload["action_buttons"] = payload.pop("actionButtons")
1303
+ if "actionButtonsMode" in payload and "action_buttons_mode" not in payload:
1304
+ payload["action_buttons_mode"] = payload.pop("actionButtonsMode")
1305
+ if "buttonMode" in payload and "action_buttons_mode" not in payload:
1306
+ payload["action_buttons_mode"] = payload.pop("buttonMode")
1155
1307
  raw_type = payload.get("type")
1156
1308
  if isinstance(raw_type, str):
1157
1309
  normalized = raw_type.strip().lower()
@@ -1173,6 +1325,10 @@ class ViewUpsertPatch(StrictModel):
1173
1325
  raise ValueError("board view requires group_by")
1174
1326
  if self.type == PublicViewType.gantt and not (self.start_field and self.end_field):
1175
1327
  raise ValueError("gantt view requires start_field and end_field")
1328
+ normalized_mode = str(self.action_buttons_mode or "merge").strip().lower()
1329
+ if normalized_mode not in {"merge", "replace"}:
1330
+ raise ValueError("action_buttons_mode must be merge or replace")
1331
+ self.action_buttons_mode = normalized_mode
1176
1332
  return self
1177
1333
 
1178
1334
 
@@ -1198,6 +1354,76 @@ class ViewPartialPatch(StrictModel):
1198
1354
  return self
1199
1355
 
1200
1356
 
1357
+ def public_button_trigger_action_value(value: Any) -> str:
1358
+ raw = value.value if isinstance(value, PublicButtonTriggerAction) else str(value or "").strip()
1359
+ normalized = raw.lower()
1360
+ if raw == PublicButtonTriggerAction.add_data.value or normalized in {"add_data", "add-data", "adddata"}:
1361
+ return "add_data"
1362
+ if normalized in {PublicButtonTriggerAction.link.value, "url"}:
1363
+ return "link"
1364
+ if raw == PublicButtonTriggerAction.qrobot.value or normalized == "qrobot":
1365
+ return "qRobot"
1366
+ if normalized == PublicButtonTriggerAction.wings.value:
1367
+ return "wings"
1368
+ return raw
1369
+
1370
+
1371
+ def _drop_empty_public_payload_values(payload: dict[str, Any]) -> dict[str, Any]:
1372
+ compact: dict[str, Any] = {}
1373
+ for key, item in payload.items():
1374
+ if item is None or item == "" or item == [] or item == {}:
1375
+ continue
1376
+ if key == "primary" and item is False:
1377
+ continue
1378
+ compact[key] = item
1379
+ return compact
1380
+
1381
+
1382
+ def public_view_action_button_payload(button: Any, *, compact: bool = False) -> dict[str, Any]:
1383
+ if isinstance(button, ViewActionButtonPatch):
1384
+ payload = button.model_dump(mode="json")
1385
+ elif isinstance(button, dict):
1386
+ payload = dict(button)
1387
+ else:
1388
+ return {}
1389
+ raw_action = payload.get("action", payload.get("trigger_action", payload.get("triggerAction")))
1390
+ if raw_action is not None:
1391
+ payload["action"] = public_button_trigger_action_value(raw_action)
1392
+ payload.pop("trigger_action", None)
1393
+ payload.pop("triggerAction", None)
1394
+ if compact:
1395
+ return _drop_empty_public_payload_values(payload)
1396
+ return payload
1397
+
1398
+
1399
+ def public_view_upsert_payload(view: ViewUpsertPatch) -> dict[str, Any]:
1400
+ payload = view.model_dump(mode="json")
1401
+ if view.action_buttons is not None:
1402
+ payload["action_buttons"] = [
1403
+ public_view_action_button_payload(button, compact=True)
1404
+ for button in view.action_buttons
1405
+ ]
1406
+ return payload
1407
+
1408
+
1409
+ def public_view_partial_payload(patch: ViewPartialPatch) -> dict[str, Any]:
1410
+ payload = patch.model_dump(mode="json")
1411
+ patch_set = payload.get("set")
1412
+ if isinstance(patch_set, dict):
1413
+ raw_buttons = patch_set.get("action_buttons")
1414
+ if raw_buttons is None:
1415
+ raw_buttons = patch_set.get("actionButtons")
1416
+ if isinstance(raw_buttons, list):
1417
+ patch_set = dict(patch_set)
1418
+ patch_set.pop("actionButtons", None)
1419
+ patch_set["action_buttons"] = [
1420
+ public_view_action_button_payload(button, compact=True)
1421
+ for button in raw_buttons
1422
+ ]
1423
+ payload["set"] = patch_set
1424
+ return payload
1425
+
1426
+
1201
1427
  class CustomButtonJudgeValuePatch(StrictModel):
1202
1428
  id: int | str | None = None
1203
1429
  value: Any | None = None
@@ -1266,13 +1492,39 @@ class CustomButtonFieldMappingPatch(StrictModel):
1266
1492
 
1267
1493
 
1268
1494
  class FieldMatchMappingPatch(StrictModel):
1269
- target_field: Any = Field(validation_alias=AliasChoices("target_field", "targetField", "target", "field"))
1495
+ target_field: Any = Field(validation_alias=AliasChoices("target_field", "targetField", "target", "field", "field_name", "fieldName", "name"))
1270
1496
  source_field: Any | None = Field(default=None, validation_alias=AliasChoices("source_field", "sourceField", "source"))
1271
- value: Any | None = Field(default=None, validation_alias=AliasChoices("value", "static_value", "staticValue"))
1497
+ value: Any | None = Field(default=None, validation_alias=AliasChoices("value", "values", "static_value", "staticValue"))
1272
1498
  operator: str = Field(default="eq", validation_alias=AliasChoices("operator", "op", "judge_type", "judgeType"))
1273
1499
 
1500
+ @model_validator(mode="before")
1501
+ @classmethod
1502
+ def normalize_aliases(cls, value: Any) -> Any:
1503
+ if not isinstance(value, dict):
1504
+ return value
1505
+ payload = dict(value)
1506
+ if "values" in payload and "value" not in payload:
1507
+ raw_values = payload.pop("values")
1508
+ if isinstance(raw_values, list) and len(raw_values) == 1:
1509
+ payload["value"] = raw_values[0]
1510
+ else:
1511
+ payload["value"] = raw_values
1512
+ raw_operator = payload.get("operator", payload.get("op", payload.get("judge_type", payload.get("judgeType"))))
1513
+ if isinstance(raw_operator, str):
1514
+ payload["operator"] = normalize_public_condition_operator(
1515
+ raw_operator,
1516
+ extra_aliases={"include_any": "include_any", "includes_any": "include_any", "includeany": "include_any"},
1517
+ )
1518
+ for alias in ("op", "judge_type", "judgeType"):
1519
+ payload.pop(alias, None)
1520
+ return payload
1521
+
1274
1522
  @model_validator(mode="after")
1275
1523
  def validate_shape(self) -> "FieldMatchMappingPatch":
1524
+ if str(self.operator or "").strip().lower() in {"is_empty", "not_empty"}:
1525
+ self.source_field = None
1526
+ self.value = None
1527
+ return self
1276
1528
  has_source = self.source_field is not None and str(self.source_field).strip() != ""
1277
1529
  has_value = self.value is not None
1278
1530
  if has_source == has_value:
@@ -1466,9 +1718,6 @@ class CustomButtonViewButtonBindingPatch(StrictModel):
1466
1718
  raw_limits = payload.get("button_limit", payload.get("buttonLimit", payload.get("visible_when", payload.get("visibleWhen"))))
1467
1719
  if isinstance(raw_limits, list) and raw_limits and all(isinstance(item, dict) for item in raw_limits):
1468
1720
  payload["button_limit"] = [raw_limits]
1469
- for alias in ("buttonLimit", "visible_when", "visibleWhen"):
1470
- if alias in payload and alias != "button_limit":
1471
- payload.pop(alias, None)
1472
1721
  return payload
1473
1722
 
1474
1723
 
@@ -1620,6 +1869,7 @@ class AssociatedResourcesApplyRequest(StrictModel):
1620
1869
  if not isinstance(value, dict):
1621
1870
  return value
1622
1871
  payload = dict(value)
1872
+ default_target_app_key = str(payload.get("app_key", payload.get("appKey", "")) or "").strip()
1623
1873
  if "upsertResources" in payload and "upsert_resources" not in payload:
1624
1874
  payload["upsert_resources"] = payload.pop("upsertResources")
1625
1875
  if "patchResources" in payload and "patch_resources" not in payload:
@@ -1632,6 +1882,16 @@ class AssociatedResourcesApplyRequest(StrictModel):
1632
1882
  payload["reorder_associated_item_ids"] = payload.pop("reorderAssociatedItemIds")
1633
1883
  if "viewConfigs" in payload and "view_configs" not in payload:
1634
1884
  payload["view_configs"] = payload.pop("viewConfigs")
1885
+ if default_target_app_key and isinstance(payload.get("upsert_resources"), list):
1886
+ normalized_resources = []
1887
+ for item in payload["upsert_resources"]:
1888
+ if isinstance(item, dict) and not any(
1889
+ str(item.get(key) or "").strip()
1890
+ for key in ("target_app_key", "targetAppKey", "app_key", "appKey")
1891
+ ):
1892
+ item = {**item, "target_app_key": default_target_app_key}
1893
+ normalized_resources.append(item)
1894
+ payload["upsert_resources"] = normalized_resources
1635
1895
  return payload
1636
1896
 
1637
1897
  @model_validator(mode="after")
@@ -1655,7 +1915,7 @@ class ViewButtonBindingPatch(StrictModel):
1655
1915
  being_main: bool = Field(default=False, validation_alias=AliasChoices("being_main", "beingMain"))
1656
1916
  button_limit: list[list[ViewFilterRulePatch]] = Field(
1657
1917
  default_factory=list,
1658
- validation_alias=AliasChoices("button_limit", "buttonLimit", "visible_when", "visibleWhen"),
1918
+ validation_alias=AliasChoices("button_limit", "buttonLimit"),
1659
1919
  )
1660
1920
  button_formula: str | None = Field(default=None, validation_alias=AliasChoices("button_formula", "buttonFormula"))
1661
1921
  button_formula_type: int = Field(default=1, validation_alias=AliasChoices("button_formula_type", "buttonFormulaType"))
@@ -1684,12 +1944,9 @@ class ViewButtonBindingPatch(StrictModel):
1684
1944
  payload["config_type"] = "DETAIL"
1685
1945
  elif normalized_config in {"inside", "list", "row", "row_action"}:
1686
1946
  payload["config_type"] = "INSIDE"
1687
- raw_limits = payload.get("button_limit", payload.get("buttonLimit", payload.get("visible_when", payload.get("visibleWhen"))))
1947
+ raw_limits = payload.get("button_limit", payload.get("buttonLimit"))
1688
1948
  if isinstance(raw_limits, list) and raw_limits and all(isinstance(item, dict) for item in raw_limits):
1689
1949
  payload["button_limit"] = [raw_limits]
1690
- for alias in ("buttonLimit", "visible_when", "visibleWhen"):
1691
- if alias in payload and alias != "button_limit":
1692
- payload.pop(alias, None)
1693
1950
  return payload
1694
1951
 
1695
1952
  @model_validator(mode="after")
@@ -1729,45 +1986,13 @@ class ViewButtonBindingPatch(StrictModel):
1729
1986
 
1730
1987
  class ChartFilterRulePatch(StrictModel):
1731
1988
  field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
1732
- operator: ViewFilterOperator = Field(validation_alias=AliasChoices("operator", "op"))
1989
+ operator: ViewFilterOperator = Field(default=ViewFilterOperator.eq, validation_alias=AliasChoices("operator", "op"))
1733
1990
  values: list[Any] = Field(default_factory=list)
1734
1991
 
1735
1992
  @model_validator(mode="before")
1736
1993
  @classmethod
1737
1994
  def normalize_aliases(cls, value: Any) -> Any:
1738
- if not isinstance(value, dict):
1739
- return value
1740
- payload = dict(value)
1741
- if "value" in payload and "values" not in payload:
1742
- raw_value = payload.pop("value")
1743
- payload["values"] = list(raw_value) if isinstance(raw_value, list) else [raw_value]
1744
- raw_operator = payload.get("operator", payload.get("op"))
1745
- if isinstance(raw_operator, str):
1746
- normalized = raw_operator.strip().lower()
1747
- operator_aliases = {
1748
- "equals": ViewFilterOperator.eq.value,
1749
- "equal": ViewFilterOperator.eq.value,
1750
- "=": ViewFilterOperator.eq.value,
1751
- "not_equals": ViewFilterOperator.neq.value,
1752
- "not_equal": ViewFilterOperator.neq.value,
1753
- "!=": ViewFilterOperator.neq.value,
1754
- ">=": ViewFilterOperator.gte.value,
1755
- "<=": ViewFilterOperator.lte.value,
1756
- "any_of": ViewFilterOperator.in_.value,
1757
- "one_of": ViewFilterOperator.in_.value,
1758
- "between_any": ViewFilterOperator.in_.value,
1759
- "empty": ViewFilterOperator.is_empty.value,
1760
- "is blank": ViewFilterOperator.is_empty.value,
1761
- "blank": ViewFilterOperator.is_empty.value,
1762
- "not_empty": ViewFilterOperator.not_empty.value,
1763
- "not blank": ViewFilterOperator.not_empty.value,
1764
- }
1765
- if normalized in operator_aliases:
1766
- payload["operator"] = operator_aliases[normalized]
1767
- elif "operator" not in payload:
1768
- payload["operator"] = normalized
1769
- payload.pop("op", None)
1770
- return payload
1995
+ return _normalize_public_condition_payload(value)
1771
1996
 
1772
1997
  @model_validator(mode="after")
1773
1998
  def validate_shape(self) -> "ChartFilterRulePatch":
@@ -2056,6 +2281,22 @@ class ChartApplyRequest(StrictModel):
2056
2281
  return self
2057
2282
 
2058
2283
 
2284
+ class PortalInlineChartPatch(ChartUpsertPatch):
2285
+ app_key: str = Field(validation_alias=AliasChoices("app_key", "appKey"))
2286
+
2287
+ @model_validator(mode="before")
2288
+ @classmethod
2289
+ def normalize_inline_aliases(cls, value: Any) -> Any:
2290
+ if not isinstance(value, dict):
2291
+ return value
2292
+ payload = dict(value)
2293
+ if "chart_name" in payload and "name" not in payload:
2294
+ payload["name"] = payload.pop("chart_name")
2295
+ if "chartName" in payload and "name" not in payload:
2296
+ payload["name"] = payload.pop("chartName")
2297
+ return payload
2298
+
2299
+
2059
2300
  class PortalComponentPositionPatch(StrictModel):
2060
2301
  pc_x: int = Field(default=0, validation_alias=AliasChoices("pc_x", "pcX", "x"))
2061
2302
  pc_y: int = Field(default=0, validation_alias=AliasChoices("pc_y", "pcY", "y"))
@@ -2102,7 +2343,6 @@ class PortalComponentPositionPatch(StrictModel):
2102
2343
  class PortalChartRefPatch(StrictModel):
2103
2344
  app_key: str | None = None
2104
2345
  chart_id: str | None = None
2105
- chart_key: str | None = None
2106
2346
  chart_name: str | None = None
2107
2347
 
2108
2348
  @model_validator(mode="before")
@@ -2111,24 +2351,26 @@ class PortalChartRefPatch(StrictModel):
2111
2351
  if not isinstance(value, dict):
2112
2352
  return value
2113
2353
  payload = dict(value)
2114
- if "chartKey" in payload and "chart_key" not in payload:
2115
- payload["chart_key"] = payload.pop("chartKey")
2116
2354
  if "chartId" in payload and "chart_id" not in payload:
2117
2355
  payload["chart_id"] = payload.pop("chartId")
2118
- if "biChartId" in payload and "chart_id" not in payload:
2119
- payload["chart_id"] = payload.pop("biChartId")
2356
+ if "chart_key" in payload and "chart_id" not in payload:
2357
+ payload["chart_id"] = payload.pop("chart_key")
2358
+ if "chartKey" in payload and "chart_id" not in payload:
2359
+ payload["chart_id"] = payload.pop("chartKey")
2120
2360
  if "chartName" in payload and "chart_name" not in payload:
2121
2361
  payload["chart_name"] = payload.pop("chartName")
2362
+ if "name" in payload and "chart_name" not in payload:
2363
+ payload["chart_name"] = payload.pop("name")
2122
2364
  if "appKey" in payload and "app_key" not in payload:
2123
2365
  payload["app_key"] = payload.pop("appKey")
2124
2366
  return payload
2125
2367
 
2126
2368
  @model_validator(mode="after")
2127
2369
  def validate_target(self) -> "PortalChartRefPatch":
2128
- if not (self.chart_id or self.chart_key or self.chart_name):
2129
- raise ValueError("chart_ref requires chart_id, chart_key, or chart_name")
2130
- if self.chart_name and not self.app_key and not (self.chart_id or self.chart_key):
2131
- raise ValueError("chart_ref with chart_name requires app_key unless chart_id/chart_key is also provided")
2370
+ if not (self.chart_id or self.chart_name):
2371
+ raise ValueError("chart_ref requires chart_id or chart_name")
2372
+ if self.chart_name and not self.chart_id and not self.app_key:
2373
+ raise ValueError("chart_ref.app_key is required when resolving by chart_name")
2132
2374
  return self
2133
2375
 
2134
2376
 
@@ -2154,12 +2396,12 @@ class PortalViewRefPatch(StrictModel):
2154
2396
  class PortalSectionPatch(StrictModel):
2155
2397
  title: str
2156
2398
  source_type: str = Field(validation_alias=AliasChoices("source_type", "sourceType"))
2157
- role: str | None = None
2399
+ role: str | None = Field(default=None, validation_alias=AliasChoices("role", "zone", "section_role", "sectionRole"))
2158
2400
  position: PortalComponentPositionPatch | None = None
2159
2401
  dash_style_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_style_config", "dashStyleConfigBO"))
2160
2402
  config: dict[str, Any] = Field(default_factory=dict)
2161
- chart: dict[str, Any] | None = None
2162
2403
  chart_ref: PortalChartRefPatch | None = None
2404
+ chart: PortalInlineChartPatch | None = Field(default=None, validation_alias=AliasChoices("chart", "inline_chart", "inlineChart", "create_chart", "createChart"))
2163
2405
  view_ref: PortalViewRefPatch | None = None
2164
2406
  text: str | None = None
2165
2407
  url: str | None = None
@@ -2173,8 +2415,32 @@ class PortalSectionPatch(StrictModel):
2173
2415
  raw_type = payload.get("source_type", payload.get("sourceType"))
2174
2416
  if isinstance(raw_type, str):
2175
2417
  payload["source_type"] = raw_type.strip().lower()
2418
+ raw_role = payload.get("role", payload.get("zone", payload.get("section_role", payload.get("sectionRole"))))
2419
+ if isinstance(raw_role, str):
2420
+ payload["role"] = raw_role.strip().lower()
2176
2421
  if "chartRef" in payload and "chart_ref" not in payload:
2177
2422
  payload["chart_ref"] = payload.pop("chartRef")
2423
+ if "inlineChart" in payload and "chart" not in payload:
2424
+ payload["chart"] = payload.pop("inlineChart")
2425
+ if "inline_chart" in payload and "chart" not in payload:
2426
+ payload["chart"] = payload.pop("inline_chart")
2427
+ if "createChart" in payload and "chart" not in payload:
2428
+ payload["chart"] = payload.pop("createChart")
2429
+ if "create_chart" in payload and "chart" not in payload:
2430
+ payload["chart"] = payload.pop("create_chart")
2431
+ raw_chart = payload.get("chart")
2432
+ if isinstance(raw_chart, dict):
2433
+ chart_payload = dict(raw_chart)
2434
+ has_ref_id = any(key in chart_payload for key in ("chart_id", "chartId", "chart_key", "chartKey"))
2435
+ has_chart_type = any(key in chart_payload for key in ("chart_type", "chartType"))
2436
+ has_create_config = has_chart_type or any(
2437
+ key in chart_payload
2438
+ for key in ("metric", "metrics", "group_by", "groupBy", "where", "filters", "dimensions")
2439
+ )
2440
+ has_ref_name = any(key in chart_payload for key in ("chart_name", "chartName", "name"))
2441
+ if has_ref_id or (has_ref_name and not has_create_config):
2442
+ payload["chart_ref"] = chart_payload
2443
+ payload.pop("chart", None)
2178
2444
  if "viewRef" in payload and "view_ref" not in payload:
2179
2445
  payload["view_ref"] = payload.pop("viewRef")
2180
2446
  if "dashStyleConfigBO" in payload and "dash_style_config" not in payload:
@@ -2186,18 +2452,8 @@ class PortalSectionPatch(StrictModel):
2186
2452
  supported = {"chart", "view", "grid", "filter", "text", "link"}
2187
2453
  if self.source_type not in supported:
2188
2454
  raise ValueError(f"unsupported portal source_type '{self.source_type}'")
2189
- if self.role is not None:
2190
- normalized_role = str(self.role or "").strip().lower()
2191
- if normalized_role not in {"metric"}:
2192
- raise ValueError(f"unsupported portal section role '{self.role}'")
2193
- self.role = normalized_role
2194
- if self.source_type != "chart":
2195
- raise ValueError("portal section role is only supported for chart sections")
2196
- if self.source_type == "chart":
2197
- if self.chart is not None and self.chart_ref is not None:
2198
- raise ValueError("chart section accepts either chart or chart_ref, not both")
2199
- if self.chart is None and self.chart_ref is None:
2200
- raise ValueError("chart section requires chart or chart_ref")
2455
+ if self.source_type == "chart" and self.chart_ref is None and self.chart is None:
2456
+ raise ValueError("chart section requires chart_ref or chart")
2201
2457
  if self.source_type == "view" and self.view_ref is None:
2202
2458
  raise ValueError("view section requires view_ref")
2203
2459
  if self.source_type == "text" and self.text is None:
@@ -2334,6 +2590,7 @@ AppReadSummaryResponse = AppGetResponse
2334
2590
  AppFieldsReadResponse = AppGetFieldsResponse
2335
2591
  AppLayoutReadResponse = AppGetLayoutResponse
2336
2592
  AppViewsReadResponse = AppGetViewsResponse
2593
+ AppFlowReadResponse = AppGetFlowResponse
2337
2594
  AppChartsReadResponse = AppGetChartsResponse
2338
2595
 
2339
2596
 
@@ -2400,7 +2657,6 @@ class SchemaPlanRequest(StrictModel):
2400
2657
  icon: str | None = None
2401
2658
  color: str | None = None
2402
2659
  visibility: VisibilityPatch | None = None
2403
- create_if_missing: bool = False
2404
2660
  add_fields: list[FieldPatch] = Field(default_factory=list)
2405
2661
  update_fields: list[FieldUpdatePatch] = Field(default_factory=list)
2406
2662
  remove_fields: list[FieldRemovePatch] = Field(default_factory=list)
@@ -2423,6 +2679,40 @@ class LayoutPlanRequest(StrictModel):
2423
2679
  return payload
2424
2680
 
2425
2681
 
2682
+ class FlowPlanRequest(StrictModel):
2683
+ app_key: str
2684
+ mode: str = "replace"
2685
+ nodes: list[FlowNodePatch] = Field(default_factory=list)
2686
+ transitions: list[FlowTransitionPatch] = Field(default_factory=list)
2687
+ preset: FlowPreset | None = None
2688
+
2689
+ @model_validator(mode="before")
2690
+ @classmethod
2691
+ def normalize_mode_alias(cls, value: Any) -> Any:
2692
+ if not isinstance(value, dict):
2693
+ return value
2694
+ payload = dict(value)
2695
+ if str(payload.get("mode") or "").strip().lower() == "overwrite":
2696
+ payload["mode"] = "replace"
2697
+ raw_preset = payload.get("preset")
2698
+ if raw_preset is None and isinstance(payload.get("base_preset"), str):
2699
+ raw_preset = payload["base_preset"]
2700
+ payload["preset"] = raw_preset
2701
+ if isinstance(raw_preset, str):
2702
+ normalized_preset = raw_preset.strip().lower()
2703
+ preset_aliases = {
2704
+ "default_approval": FlowPreset.basic_approval.value,
2705
+ "approval": FlowPreset.basic_approval.value,
2706
+ "basic approval": FlowPreset.basic_approval.value,
2707
+ "default_fill_then_approve": FlowPreset.basic_fill_then_approve.value,
2708
+ "default-fill-then-approve": FlowPreset.basic_fill_then_approve.value,
2709
+ "fill_then_approve": FlowPreset.basic_fill_then_approve.value,
2710
+ }
2711
+ if normalized_preset in preset_aliases:
2712
+ payload["preset"] = preset_aliases[normalized_preset]
2713
+ return payload
2714
+
2715
+
2426
2716
  class ViewsPlanRequest(StrictModel):
2427
2717
  app_key: str
2428
2718
  upsert_views: list[ViewUpsertPatch] = Field(default_factory=list)
@@ -2450,8 +2740,18 @@ def _normalize_field_payload(value: Any) -> Any:
2450
2740
  if not isinstance(value, dict):
2451
2741
  return value
2452
2742
  payload = dict(value)
2743
+ if "data_title" in payload and "as_data_title" not in payload and "asDataTitle" not in payload:
2744
+ payload["as_data_title"] = payload.pop("data_title")
2745
+ if "dataTitle" in payload and "as_data_title" not in payload and "asDataTitle" not in payload:
2746
+ payload["as_data_title"] = payload.pop("dataTitle")
2747
+ if "data_cover" in payload and "as_data_cover" not in payload and "asDataCover" not in payload:
2748
+ payload["as_data_cover"] = payload.pop("data_cover")
2749
+ if "dataCover" in payload and "as_data_cover" not in payload and "asDataCover" not in payload:
2750
+ payload["as_data_cover"] = payload.pop("dataCover")
2453
2751
  if "fields" in payload and "subfields" not in payload:
2454
2752
  payload["subfields"] = payload.pop("fields")
2753
+ if "options" in payload:
2754
+ payload["options"] = _normalize_field_options(payload.get("options"))
2455
2755
  raw_type = payload.get("type")
2456
2756
  if isinstance(raw_type, int):
2457
2757
  normalized_from_id = FIELD_TYPE_ID_ALIASES.get(raw_type)
@@ -2484,6 +2784,34 @@ def _normalize_field_payload(value: Any) -> Any:
2484
2784
  return payload
2485
2785
 
2486
2786
 
2787
+ def _normalize_field_options(value: Any) -> Any:
2788
+ if not isinstance(value, list):
2789
+ return value
2790
+ normalized: list[str] = []
2791
+ for item in value:
2792
+ if isinstance(item, str):
2793
+ normalized.append(item)
2794
+ continue
2795
+ if isinstance(item, (int, float, bool)):
2796
+ normalized.append(str(item))
2797
+ continue
2798
+ if isinstance(item, dict):
2799
+ label = (
2800
+ item.get("label")
2801
+ or item.get("name")
2802
+ or item.get("title")
2803
+ or item.get("value")
2804
+ or item.get("text")
2805
+ or item.get("optValue")
2806
+ or item.get("optName")
2807
+ )
2808
+ if label is not None:
2809
+ normalized.append(str(label))
2810
+ continue
2811
+ normalized.append(str(item))
2812
+ return normalized
2813
+
2814
+
2487
2815
  def _slugify_title(title: str) -> str:
2488
2816
  normalized = "".join(ch.lower() if ch.isalnum() else "_" for ch in str(title or ""))
2489
2817
  collapsed = "_".join(part for part in normalized.split("_") if part)