@josephyan/qingflow-app-builder-mcp 0.2.0-beta.4 → 0.2.0-beta.41

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 (39) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +3 -1
  4. package/skills/qingflow-app-builder/SKILL.md +154 -22
  5. package/skills/qingflow-app-builder/references/create-app.md +51 -21
  6. package/skills/qingflow-app-builder/references/environments.md +1 -1
  7. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  8. package/skills/qingflow-app-builder/references/gotchas.md +28 -1
  9. package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
  10. package/skills/qingflow-app-builder/references/tool-selection.md +45 -17
  11. package/skills/qingflow-app-builder/references/update-flow.md +112 -25
  12. package/skills/qingflow-app-builder/references/update-layout.md +11 -24
  13. package/skills/qingflow-app-builder/references/update-schema.md +1 -23
  14. package/skills/qingflow-app-builder/references/update-views.md +87 -21
  15. package/src/qingflow_mcp/__init__.py +1 -1
  16. package/src/qingflow_mcp/backend_client.py +189 -0
  17. package/src/qingflow_mcp/builder_facade/models.py +584 -1
  18. package/src/qingflow_mcp/builder_facade/service.py +4698 -262
  19. package/src/qingflow_mcp/config.py +39 -0
  20. package/src/qingflow_mcp/import_store.py +121 -0
  21. package/src/qingflow_mcp/list_type_labels.py +24 -0
  22. package/src/qingflow_mcp/server.py +131 -16
  23. package/src/qingflow_mcp/server_app_builder.py +132 -72
  24. package/src/qingflow_mcp/server_app_user.py +143 -187
  25. package/src/qingflow_mcp/solution/compiler/form_compiler.py +14 -4
  26. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
  27. package/src/qingflow_mcp/solution/executor.py +44 -7
  28. package/src/qingflow_mcp/tools/ai_builder_tools.py +1567 -144
  29. package/src/qingflow_mcp/tools/app_tools.py +243 -14
  30. package/src/qingflow_mcp/tools/approval_tools.py +411 -76
  31. package/src/qingflow_mcp/tools/directory_tools.py +203 -31
  32. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  33. package/src/qingflow_mcp/tools/file_tools.py +1 -0
  34. package/src/qingflow_mcp/tools/import_tools.py +1164 -0
  35. package/src/qingflow_mcp/tools/portal_tools.py +31 -0
  36. package/src/qingflow_mcp/tools/record_tools.py +4943 -1025
  37. package/src/qingflow_mcp/tools/task_context_tools.py +1335 -0
  38. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  39. package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
@@ -43,11 +43,38 @@ FIELD_TYPE_ALIASES: dict[str, PublicFieldType] = {
43
43
  "departments": PublicFieldType.department,
44
44
  }
45
45
 
46
+ FIELD_TYPE_ID_ALIASES: dict[int, PublicFieldType] = {
47
+ 2: PublicFieldType.text,
48
+ 3: PublicFieldType.long_text,
49
+ 4: PublicFieldType.date,
50
+ 5: PublicFieldType.member,
51
+ 6: PublicFieldType.email,
52
+ 7: PublicFieldType.phone,
53
+ 8: PublicFieldType.number,
54
+ 10: PublicFieldType.boolean,
55
+ 11: PublicFieldType.single_select,
56
+ 12: PublicFieldType.multi_select,
57
+ 13: PublicFieldType.attachment,
58
+ 18: PublicFieldType.subtable,
59
+ 21: PublicFieldType.address,
60
+ 22: PublicFieldType.department,
61
+ 25: PublicFieldType.relation,
62
+ }
63
+
46
64
 
47
65
  class PublicViewType(str, Enum):
48
66
  table = "table"
49
67
  card = "card"
50
68
  board = "board"
69
+ gantt = "gantt"
70
+
71
+
72
+ class PublicChartType(str, Enum):
73
+ target = "target"
74
+ pie = "pie"
75
+ bar = "bar"
76
+ line = "line"
77
+ table = "table"
51
78
 
52
79
 
53
80
  class LayoutApplyMode(str, Enum):
@@ -69,6 +96,7 @@ class FlowPreset(str, Enum):
69
96
  class ViewsPreset(str, Enum):
70
97
  default_table = "default_table"
71
98
  status_board = "status_board"
99
+ default_gantt = "default_gantt"
72
100
 
73
101
 
74
102
  class PublicFlowNodeType(str, Enum):
@@ -82,6 +110,143 @@ class PublicFlowNodeType(str, Enum):
82
110
  end = "end"
83
111
 
84
112
 
113
+ class FlowConditionOperator(str, Enum):
114
+ eq = "eq"
115
+ neq = "neq"
116
+ in_ = "in"
117
+ contains = "contains"
118
+ gte = "gte"
119
+ lte = "lte"
120
+ is_empty = "is_empty"
121
+ not_empty = "not_empty"
122
+
123
+
124
+ class ViewFilterOperator(str, Enum):
125
+ eq = "eq"
126
+ neq = "neq"
127
+ in_ = "in"
128
+ contains = "contains"
129
+ gte = "gte"
130
+ lte = "lte"
131
+ is_empty = "is_empty"
132
+ not_empty = "not_empty"
133
+
134
+
135
+ class FlowAssigneePatch(StrictModel):
136
+ role_ids: list[int] = Field(default_factory=list)
137
+ role_names: list[str] = Field(default_factory=list)
138
+ member_uids: list[int] = Field(default_factory=list)
139
+ member_emails: list[str] = Field(default_factory=list)
140
+ member_names: list[str] = Field(default_factory=list)
141
+ include_sub_departs: bool | None = None
142
+
143
+
144
+ class FlowNodePermissionsPatch(StrictModel):
145
+ editable_fields: list[str] = Field(default_factory=list)
146
+
147
+
148
+ class FlowConditionRulePatch(StrictModel):
149
+ field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
150
+ operator: FlowConditionOperator = Field(validation_alias=AliasChoices("operator", "op"))
151
+ values: list[Any] = Field(default_factory=list)
152
+
153
+ @model_validator(mode="before")
154
+ @classmethod
155
+ def normalize_aliases(cls, value: Any) -> Any:
156
+ if not isinstance(value, dict):
157
+ return value
158
+ payload = dict(value)
159
+ if "value" in payload and "values" not in payload:
160
+ payload["values"] = [payload.pop("value")]
161
+ raw_operator = payload.get("operator", payload.get("op"))
162
+ if isinstance(raw_operator, str):
163
+ normalized = raw_operator.strip().lower()
164
+ operator_aliases = {
165
+ "equals": FlowConditionOperator.eq.value,
166
+ "equal": FlowConditionOperator.eq.value,
167
+ "=": FlowConditionOperator.eq.value,
168
+ "not_equals": FlowConditionOperator.neq.value,
169
+ "not_equal": FlowConditionOperator.neq.value,
170
+ "!=": FlowConditionOperator.neq.value,
171
+ ">=": FlowConditionOperator.gte.value,
172
+ "<=": FlowConditionOperator.lte.value,
173
+ "any_of": FlowConditionOperator.in_.value,
174
+ "one_of": FlowConditionOperator.in_.value,
175
+ "between_any": FlowConditionOperator.in_.value,
176
+ "empty": FlowConditionOperator.is_empty.value,
177
+ "is blank": FlowConditionOperator.is_empty.value,
178
+ "blank": FlowConditionOperator.is_empty.value,
179
+ "not_empty": FlowConditionOperator.not_empty.value,
180
+ "not blank": FlowConditionOperator.not_empty.value,
181
+ }
182
+ if normalized in operator_aliases:
183
+ payload["operator"] = operator_aliases[normalized]
184
+ elif "operator" not in payload:
185
+ payload["operator"] = normalized
186
+ payload.pop("op", None)
187
+ return payload
188
+
189
+ @model_validator(mode="after")
190
+ def validate_shape(self) -> "FlowConditionRulePatch":
191
+ if self.operator in {FlowConditionOperator.is_empty, FlowConditionOperator.not_empty}:
192
+ self.values = []
193
+ return self
194
+ if not self.values:
195
+ raise ValueError("condition rule requires values")
196
+ return self
197
+
198
+
199
+ class ViewFilterRulePatch(StrictModel):
200
+ field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
201
+ operator: ViewFilterOperator = Field(validation_alias=AliasChoices("operator", "op"))
202
+ values: list[Any] = Field(default_factory=list)
203
+
204
+ @model_validator(mode="before")
205
+ @classmethod
206
+ def normalize_aliases(cls, value: Any) -> Any:
207
+ if not isinstance(value, dict):
208
+ return value
209
+ payload = dict(value)
210
+ if "value" in payload and "values" not in payload:
211
+ payload["values"] = [payload.pop("value")]
212
+ raw_operator = payload.get("operator", payload.get("op"))
213
+ if isinstance(raw_operator, str):
214
+ normalized = raw_operator.strip().lower()
215
+ operator_aliases = {
216
+ "equals": ViewFilterOperator.eq.value,
217
+ "equal": ViewFilterOperator.eq.value,
218
+ "=": ViewFilterOperator.eq.value,
219
+ "not_equals": ViewFilterOperator.neq.value,
220
+ "not_equal": ViewFilterOperator.neq.value,
221
+ "!=": ViewFilterOperator.neq.value,
222
+ ">=": ViewFilterOperator.gte.value,
223
+ "<=": ViewFilterOperator.lte.value,
224
+ "any_of": ViewFilterOperator.in_.value,
225
+ "one_of": ViewFilterOperator.in_.value,
226
+ "between_any": ViewFilterOperator.in_.value,
227
+ "empty": ViewFilterOperator.is_empty.value,
228
+ "is blank": ViewFilterOperator.is_empty.value,
229
+ "blank": ViewFilterOperator.is_empty.value,
230
+ "not_empty": ViewFilterOperator.not_empty.value,
231
+ "not blank": ViewFilterOperator.not_empty.value,
232
+ }
233
+ if normalized in operator_aliases:
234
+ payload["operator"] = operator_aliases[normalized]
235
+ elif "operator" not in payload:
236
+ payload["operator"] = normalized
237
+ payload.pop("op", None)
238
+ return payload
239
+
240
+ @model_validator(mode="after")
241
+ def validate_shape(self) -> "ViewFilterRulePatch":
242
+ if self.operator in {ViewFilterOperator.is_empty, ViewFilterOperator.not_empty}:
243
+ self.values = []
244
+ return self
245
+ if not self.values:
246
+ raise ValueError("view filter rule requires values")
247
+ return self
248
+
249
+
85
250
  class FieldSelector(StrictModel):
86
251
  field_id: str | None = None
87
252
  que_id: int | None = None
@@ -101,6 +266,8 @@ class FieldPatch(StrictModel):
101
266
  description: str | None = None
102
267
  options: list[str] = Field(default_factory=list)
103
268
  target_app_key: str | None = None
269
+ display_field: FieldSelector | None = None
270
+ visible_fields: list[FieldSelector] = Field(default_factory=list)
104
271
  subfields: list["FieldPatch"] = Field(default_factory=list)
105
272
 
106
273
  @model_validator(mode="after")
@@ -109,6 +276,8 @@ class FieldPatch(StrictModel):
109
276
  raise ValueError("relation field requires target_app_key")
110
277
  if self.type != PublicFieldType.relation and self.target_app_key:
111
278
  raise ValueError("target_app_key is only allowed for relation fields")
279
+ if self.type != PublicFieldType.relation and (self.display_field is not None or self.visible_fields):
280
+ raise ValueError("display_field and visible_fields are only allowed for relation fields")
112
281
  if self.type == PublicFieldType.subtable and not self.subfields:
113
282
  raise ValueError("subtable field requires subfields")
114
283
  if self.type != PublicFieldType.subtable and self.subfields:
@@ -128,12 +297,16 @@ class FieldMutation(StrictModel):
128
297
  description: str | None = None
129
298
  options: list[str] | None = None
130
299
  target_app_key: str | None = None
300
+ display_field: FieldSelector | None = None
301
+ visible_fields: list[FieldSelector] | None = None
131
302
  subfields: list[FieldPatch] | None = None
132
303
 
133
304
  @model_validator(mode="after")
134
305
  def validate_shape(self) -> "FieldMutation":
135
306
  if self.type == PublicFieldType.relation and not self.target_app_key:
136
307
  raise ValueError("relation field requires target_app_key")
308
+ if self.type is not None and self.type != PublicFieldType.relation and (self.display_field is not None or self.visible_fields):
309
+ raise ValueError("display_field and visible_fields are only allowed for relation fields")
137
310
  if self.type == PublicFieldType.subtable and not self.subfields:
138
311
  raise ValueError("subtable field requires subfields")
139
312
  return self
@@ -161,15 +334,65 @@ class FieldRemovePatch(StrictModel):
161
334
  return self
162
335
 
163
336
 
337
+ def _coerce_layout_columns(value: Any) -> int | None:
338
+ if isinstance(value, bool):
339
+ return None
340
+ if isinstance(value, int):
341
+ return value if value > 0 else None
342
+ if isinstance(value, str):
343
+ stripped = value.strip()
344
+ if stripped.isdigit():
345
+ parsed = int(stripped)
346
+ return parsed if parsed > 0 else None
347
+ return None
348
+
349
+
350
+ def _normalize_layout_rows(value: Any, *, columns: int | None = None) -> Any:
351
+ if not isinstance(value, list):
352
+ return value
353
+ if value and all(isinstance(item, list) for item in value):
354
+ return value
355
+ if not value:
356
+ return []
357
+ width = columns if columns and columns > 0 else None
358
+ if width is None:
359
+ return [list(value)]
360
+ return [list(value[index : index + width]) for index in range(0, len(value), width) if value[index : index + width]]
361
+
362
+
164
363
  class LayoutSectionPatch(StrictModel):
165
364
  section_id: str | None = Field(default=None, validation_alias=AliasChoices("section_id", "sectionId"))
166
365
  title: str
167
- rows: list[list[str]] = Field(default_factory=list)
366
+ rows: list[list[Any]] = Field(default_factory=list)
367
+
368
+ @model_validator(mode="before")
369
+ @classmethod
370
+ def normalize_aliases(cls, value: Any) -> Any:
371
+ if not isinstance(value, dict):
372
+ return value
373
+ payload = dict(value)
374
+ if "name" in payload and "title" not in payload:
375
+ payload["title"] = payload.pop("name")
376
+ shorthand: Any | None = None
377
+ if "rows" not in payload:
378
+ if "fields" in payload:
379
+ shorthand = payload.pop("fields")
380
+ elif "field_ids" in payload:
381
+ shorthand = payload.pop("field_ids")
382
+ if shorthand is not None:
383
+ payload["rows"] = _normalize_layout_rows(
384
+ shorthand,
385
+ columns=_coerce_layout_columns(payload.pop("columns", None)),
386
+ )
387
+ return payload
168
388
 
169
389
  @model_validator(mode="after")
170
390
  def validate_rows(self) -> "LayoutSectionPatch":
171
391
  if not self.rows:
172
392
  raise ValueError("section rows must be a non-empty list")
393
+ for row in self.rows:
394
+ if not isinstance(row, list) or not row:
395
+ raise ValueError("section rows must be a non-empty list")
173
396
  if not self.section_id:
174
397
  self.section_id = _slugify_title(self.title)
175
398
  return self
@@ -179,8 +402,54 @@ class FlowNodePatch(StrictModel):
179
402
  id: str
180
403
  type: PublicFlowNodeType
181
404
  name: str
405
+ assignees: FlowAssigneePatch = Field(default_factory=FlowAssigneePatch)
406
+ permissions: FlowNodePermissionsPatch = Field(default_factory=FlowNodePermissionsPatch)
407
+ conditions: list[FlowConditionRulePatch] = Field(default_factory=list)
408
+ condition_groups: list[list[FlowConditionRulePatch]] = Field(default_factory=list)
182
409
  config: dict[str, Any] = Field(default_factory=dict)
183
410
 
411
+ @model_validator(mode="before")
412
+ @classmethod
413
+ def normalize_aliases(cls, value: Any) -> Any:
414
+ if not isinstance(value, dict):
415
+ return value
416
+ payload = dict(value)
417
+ assignees = dict(payload.get("assignees") or {})
418
+ permissions = dict(payload.get("permissions") or {})
419
+
420
+ for key in ("role_ids", "role_names", "member_uids", "member_emails", "member_names", "include_sub_departs"):
421
+ if key in payload and key not in assignees:
422
+ assignees[key] = payload.pop(key)
423
+ for key in ("editable_fields",):
424
+ if key in payload and key not in permissions:
425
+ permissions[key] = payload.pop(key)
426
+ if "filters" in payload and "conditions" not in payload:
427
+ payload["conditions"] = payload.pop("filters")
428
+ if "rules" in payload and "conditions" not in payload:
429
+ payload["conditions"] = payload.pop("rules")
430
+ if "conditionRules" in payload and "condition_groups" not in payload:
431
+ payload["condition_groups"] = payload.pop("conditionRules")
432
+ if "conditionGroups" in payload and "condition_groups" not in payload:
433
+ payload["condition_groups"] = payload.pop("conditionGroups")
434
+ if "owners" in payload and "member_names" not in assignees:
435
+ assignees["member_names"] = payload.pop("owners")
436
+ if "approvers" in payload and "role_names" not in assignees:
437
+ assignees["role_names"] = payload.pop("approvers")
438
+ if assignees:
439
+ payload["assignees"] = assignees
440
+ if permissions:
441
+ payload["permissions"] = permissions
442
+ return payload
443
+
444
+ @model_validator(mode="after")
445
+ def validate_branch_conditions(self) -> "FlowNodePatch":
446
+ if self.conditions:
447
+ self.condition_groups = [list(self.conditions), *self.condition_groups]
448
+ self.conditions = []
449
+ if self.type != PublicFlowNodeType.condition and self.condition_groups:
450
+ raise ValueError("condition_groups are only allowed on condition nodes")
451
+ return self
452
+
184
453
 
185
454
  class FlowTransitionPatch(StrictModel):
186
455
  source: str = Field(alias="from")
@@ -189,9 +458,14 @@ class FlowTransitionPatch(StrictModel):
189
458
 
190
459
  class ViewUpsertPatch(StrictModel):
191
460
  name: str
461
+ view_key: str | None = Field(default=None, validation_alias=AliasChoices("view_key", "viewKey"))
192
462
  type: PublicViewType
193
463
  columns: list[str] = Field(default_factory=list)
194
464
  group_by: str | None = None
465
+ filters: list[ViewFilterRulePatch] = Field(default_factory=list)
466
+ start_field: str | None = Field(default=None, validation_alias=AliasChoices("start_field", "startField"))
467
+ end_field: str | None = Field(default=None, validation_alias=AliasChoices("end_field", "endField"))
468
+ title_field: str | None = Field(default=None, validation_alias=AliasChoices("title_field", "titleField"))
195
469
 
196
470
  @model_validator(mode="before")
197
471
  @classmethod
@@ -201,6 +475,14 @@ class ViewUpsertPatch(StrictModel):
201
475
  payload = dict(value)
202
476
  if "fields" in payload and "columns" not in payload:
203
477
  payload["columns"] = payload.pop("fields")
478
+ if "column_names" in payload and "columns" not in payload:
479
+ payload["columns"] = payload.pop("column_names")
480
+ if "columnNames" in payload and "columns" not in payload:
481
+ payload["columns"] = payload.pop("columnNames")
482
+ if "filter_rules" in payload and "filters" not in payload:
483
+ payload["filters"] = payload.pop("filter_rules")
484
+ if "filterRules" in payload and "filters" not in payload:
485
+ payload["filters"] = payload.pop("filterRules")
204
486
  raw_type = payload.get("type")
205
487
  if isinstance(raw_type, str):
206
488
  normalized = raw_type.strip().lower()
@@ -210,6 +492,8 @@ class ViewUpsertPatch(StrictModel):
210
492
  payload["type"] = "card"
211
493
  elif normalized == "kanban":
212
494
  payload["type"] = "board"
495
+ elif normalized == "ganttview":
496
+ payload["type"] = "gantt"
213
497
  return payload
214
498
 
215
499
  @model_validator(mode="after")
@@ -218,6 +502,263 @@ class ViewUpsertPatch(StrictModel):
218
502
  raise ValueError("table/card views require columns")
219
503
  if self.type == PublicViewType.board and not self.group_by:
220
504
  raise ValueError("board view requires group_by")
505
+ if self.type == PublicViewType.gantt and not (self.start_field and self.end_field):
506
+ raise ValueError("gantt view requires start_field and end_field")
507
+ return self
508
+
509
+
510
+ class ChartFilterRulePatch(StrictModel):
511
+ field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
512
+ operator: ViewFilterOperator = Field(validation_alias=AliasChoices("operator", "op"))
513
+ values: list[Any] = Field(default_factory=list)
514
+
515
+ @model_validator(mode="before")
516
+ @classmethod
517
+ def normalize_aliases(cls, value: Any) -> Any:
518
+ if not isinstance(value, dict):
519
+ return value
520
+ payload = dict(value)
521
+ if "value" in payload and "values" not in payload:
522
+ payload["values"] = [payload.pop("value")]
523
+ raw_operator = payload.get("operator", payload.get("op"))
524
+ if isinstance(raw_operator, str):
525
+ normalized = raw_operator.strip().lower()
526
+ operator_aliases = {
527
+ "equals": ViewFilterOperator.eq.value,
528
+ "equal": ViewFilterOperator.eq.value,
529
+ "=": ViewFilterOperator.eq.value,
530
+ "not_equals": ViewFilterOperator.neq.value,
531
+ "not_equal": ViewFilterOperator.neq.value,
532
+ "!=": ViewFilterOperator.neq.value,
533
+ ">=": ViewFilterOperator.gte.value,
534
+ "<=": ViewFilterOperator.lte.value,
535
+ "any_of": ViewFilterOperator.in_.value,
536
+ "one_of": ViewFilterOperator.in_.value,
537
+ "between_any": ViewFilterOperator.in_.value,
538
+ "empty": ViewFilterOperator.is_empty.value,
539
+ "is blank": ViewFilterOperator.is_empty.value,
540
+ "blank": ViewFilterOperator.is_empty.value,
541
+ "not_empty": ViewFilterOperator.not_empty.value,
542
+ "not blank": ViewFilterOperator.not_empty.value,
543
+ }
544
+ if normalized in operator_aliases:
545
+ payload["operator"] = operator_aliases[normalized]
546
+ elif "operator" not in payload:
547
+ payload["operator"] = normalized
548
+ payload.pop("op", None)
549
+ return payload
550
+
551
+ @model_validator(mode="after")
552
+ def validate_shape(self) -> "ChartFilterRulePatch":
553
+ if self.operator in {ViewFilterOperator.is_empty, ViewFilterOperator.not_empty}:
554
+ self.values = []
555
+ return self
556
+ if not self.values:
557
+ raise ValueError("chart filter rule requires values")
558
+ return self
559
+
560
+
561
+ class ChartUpsertPatch(StrictModel):
562
+ chart_id: str | None = None
563
+ name: str
564
+ chart_type: PublicChartType
565
+ dimension_field_ids: list[str] = Field(default_factory=list)
566
+ indicator_field_ids: list[str] = Field(default_factory=list)
567
+ filters: list[ChartFilterRulePatch] = Field(default_factory=list)
568
+ question_config: list[dict[str, Any]] = Field(default_factory=list)
569
+ user_config: list[dict[str, Any]] = Field(default_factory=list)
570
+ config: dict[str, Any] = Field(default_factory=dict)
571
+
572
+ @model_validator(mode="before")
573
+ @classmethod
574
+ def normalize_aliases(cls, value: Any) -> Any:
575
+ if not isinstance(value, dict):
576
+ return value
577
+ payload = dict(value)
578
+ if "id" in payload and "chart_id" not in payload:
579
+ payload["chart_id"] = payload.pop("id")
580
+ if "type" in payload and "chart_type" not in payload:
581
+ payload["chart_type"] = payload.pop("type")
582
+ if "dimension_fields" in payload and "dimension_field_ids" not in payload:
583
+ payload["dimension_field_ids"] = payload.pop("dimension_fields")
584
+ if "indicator_fields" in payload and "indicator_field_ids" not in payload:
585
+ payload["indicator_field_ids"] = payload.pop("indicator_fields")
586
+ if "metric_field_ids" in payload and "indicator_field_ids" not in payload:
587
+ payload["indicator_field_ids"] = payload.pop("metric_field_ids")
588
+ raw_type = payload.get("chart_type")
589
+ if isinstance(raw_type, str):
590
+ normalized = raw_type.strip().lower()
591
+ aliases = {
592
+ "targetchart": PublicChartType.target.value,
593
+ "piechart": PublicChartType.pie.value,
594
+ "barchart": PublicChartType.bar.value,
595
+ "linechart": PublicChartType.line.value,
596
+ "tablechart": PublicChartType.table.value,
597
+ }
598
+ if normalized in aliases:
599
+ payload["chart_type"] = aliases[normalized]
600
+ if isinstance(payload.get("chart_id"), int):
601
+ payload["chart_id"] = str(payload["chart_id"])
602
+ if isinstance(payload.get("dimension_field_ids"), list):
603
+ payload["dimension_field_ids"] = [str(item) for item in payload["dimension_field_ids"] if item is not None and str(item).strip()]
604
+ if isinstance(payload.get("indicator_field_ids"), list):
605
+ payload["indicator_field_ids"] = [str(item) for item in payload["indicator_field_ids"] if item is not None and str(item).strip()]
606
+ return payload
607
+
608
+
609
+ class ChartApplyRequest(StrictModel):
610
+ app_key: str
611
+ upsert_charts: list[ChartUpsertPatch] = Field(default_factory=list)
612
+ remove_chart_ids: list[str] = Field(default_factory=list)
613
+ reorder_chart_ids: list[str] = Field(default_factory=list)
614
+
615
+ @model_validator(mode="before")
616
+ @classmethod
617
+ def normalize_ids(cls, value: Any) -> Any:
618
+ if not isinstance(value, dict):
619
+ return value
620
+ payload = dict(value)
621
+ for key in ("remove_chart_ids", "reorder_chart_ids"):
622
+ raw = payload.get(key)
623
+ if isinstance(raw, list):
624
+ payload[key] = [str(item) for item in raw if item is not None and str(item).strip()]
625
+ return payload
626
+
627
+ @model_validator(mode="after")
628
+ def validate_shape(self) -> "ChartApplyRequest":
629
+ if not self.upsert_charts and not self.remove_chart_ids and not self.reorder_chart_ids:
630
+ raise ValueError("chart apply requires at least one upsert, remove, or reorder operation")
631
+ return self
632
+
633
+
634
+ class PortalComponentPositionPatch(StrictModel):
635
+ pc_x: int = Field(default=0, validation_alias=AliasChoices("pc_x", "pcX", "x"))
636
+ pc_y: int = Field(default=0, validation_alias=AliasChoices("pc_y", "pcY", "y"))
637
+ pc_w: int = Field(default=12, validation_alias=AliasChoices("pc_w", "pcW", "w"))
638
+ pc_h: int = Field(default=8, validation_alias=AliasChoices("pc_h", "pcH", "h"))
639
+ mobile_x: int = Field(default=0, validation_alias=AliasChoices("mobile_x", "mobileX"))
640
+ mobile_y: int = Field(default=0, validation_alias=AliasChoices("mobile_y", "mobileY"))
641
+ mobile_w: int = Field(default=12, validation_alias=AliasChoices("mobile_w", "mobileW"))
642
+ mobile_h: int = Field(default=8, validation_alias=AliasChoices("mobile_h", "mobileH"))
643
+
644
+ @model_validator(mode="before")
645
+ @classmethod
646
+ def normalize_nested_layout(cls, value: Any) -> Any:
647
+ if not isinstance(value, dict):
648
+ return value
649
+ payload = dict(value)
650
+ pc = payload.pop("pc", None)
651
+ mobile = payload.pop("mobile", None)
652
+ if isinstance(pc, dict):
653
+ if "pc_x" not in payload and "x" in pc:
654
+ payload["pc_x"] = pc.get("x")
655
+ if "pc_y" not in payload and "y" in pc:
656
+ payload["pc_y"] = pc.get("y")
657
+ if "pc_w" not in payload and "cols" in pc:
658
+ payload["pc_w"] = pc.get("cols")
659
+ if "pc_h" not in payload and "rows" in pc:
660
+ payload["pc_h"] = pc.get("rows")
661
+ if isinstance(mobile, dict):
662
+ if "mobile_x" not in payload and "x" in mobile:
663
+ payload["mobile_x"] = mobile.get("x")
664
+ if "mobile_y" not in payload and "y" in mobile:
665
+ payload["mobile_y"] = mobile.get("y")
666
+ if "mobile_w" not in payload and "cols" in mobile:
667
+ payload["mobile_w"] = mobile.get("cols")
668
+ if "mobile_h" not in payload and "rows" in mobile:
669
+ payload["mobile_h"] = mobile.get("rows")
670
+ return payload
671
+
672
+
673
+ class PortalChartRefPatch(StrictModel):
674
+ app_key: str
675
+ chart_id: str | None = None
676
+ chart_name: str | None = None
677
+
678
+ @model_validator(mode="after")
679
+ def validate_target(self) -> "PortalChartRefPatch":
680
+ if not (self.chart_id or self.chart_name):
681
+ raise ValueError("chart_ref requires chart_id or chart_name")
682
+ return self
683
+
684
+
685
+ class PortalViewRefPatch(StrictModel):
686
+ app_key: str
687
+ view_key: str | None = None
688
+ view_name: str | None = None
689
+
690
+ @model_validator(mode="after")
691
+ def validate_target(self) -> "PortalViewRefPatch":
692
+ if not (self.view_key or self.view_name):
693
+ raise ValueError("view_ref requires view_key or view_name")
694
+ return self
695
+
696
+
697
+ class PortalSectionPatch(StrictModel):
698
+ title: str
699
+ source_type: str = Field(validation_alias=AliasChoices("source_type", "sourceType"))
700
+ position: PortalComponentPositionPatch | None = None
701
+ dash_style_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_style_config", "dashStyleConfigBO"))
702
+ config: dict[str, Any] = Field(default_factory=dict)
703
+ chart_ref: PortalChartRefPatch | None = None
704
+ view_ref: PortalViewRefPatch | None = None
705
+ text: str | None = None
706
+ url: str | None = None
707
+
708
+ @model_validator(mode="before")
709
+ @classmethod
710
+ def normalize_aliases(cls, value: Any) -> Any:
711
+ if not isinstance(value, dict):
712
+ return value
713
+ payload = dict(value)
714
+ raw_type = payload.get("source_type", payload.get("sourceType"))
715
+ if isinstance(raw_type, str):
716
+ payload["source_type"] = raw_type.strip().lower()
717
+ if "chartRef" in payload and "chart_ref" not in payload:
718
+ payload["chart_ref"] = payload.pop("chartRef")
719
+ if "viewRef" in payload and "view_ref" not in payload:
720
+ payload["view_ref"] = payload.pop("viewRef")
721
+ if "dashStyleConfigBO" in payload and "dash_style_config" not in payload:
722
+ payload["dash_style_config"] = payload.pop("dashStyleConfigBO")
723
+ return payload
724
+
725
+ @model_validator(mode="after")
726
+ def validate_shape(self) -> "PortalSectionPatch":
727
+ supported = {"chart", "view", "grid", "filter", "text", "link"}
728
+ if self.source_type not in supported:
729
+ raise ValueError(f"unsupported portal source_type '{self.source_type}'")
730
+ if self.source_type == "chart" and self.chart_ref is None:
731
+ raise ValueError("chart section requires chart_ref")
732
+ if self.source_type == "view" and self.view_ref is None:
733
+ raise ValueError("view section requires view_ref")
734
+ if self.source_type == "text" and self.text is None:
735
+ raise ValueError("text section requires text")
736
+ if self.source_type == "link" and self.url is None:
737
+ raise ValueError("link section requires url")
738
+ return self
739
+
740
+
741
+ class PortalApplyRequest(StrictModel):
742
+ dash_key: str | None = None
743
+ dash_name: str | None = None
744
+ package_tag_id: int | None = None
745
+ publish: bool = True
746
+ sections: list[PortalSectionPatch] = Field(default_factory=list)
747
+ auth: dict[str, Any] | None = None
748
+ icon: str | None = None
749
+ color: str | None = None
750
+ hide_copyright: bool | None = Field(default=None, validation_alias=AliasChoices("hide_copyright", "hideCopyright"))
751
+ dash_global_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_global_config", "dashGlobalConfig"))
752
+ config: dict[str, Any] = Field(default_factory=dict)
753
+
754
+ @model_validator(mode="after")
755
+ def validate_shape(self) -> "PortalApplyRequest":
756
+ if not self.dash_key and not self.package_tag_id:
757
+ raise ValueError("package_tag_id is required when dash_key is empty")
758
+ if not self.dash_key and not self.dash_name:
759
+ raise ValueError("dash_name is required when creating a portal")
760
+ if not self.sections:
761
+ raise ValueError("portal apply requires a non-empty sections list")
221
762
  return self
222
763
 
223
764
 
@@ -261,6 +802,25 @@ class AppFlowReadResponse(StrictModel):
261
802
  transitions: list[dict[str, Any]] = Field(default_factory=list)
262
803
 
263
804
 
805
+ class AppChartsReadResponse(StrictModel):
806
+ app_key: str
807
+ charts: list[dict[str, Any]] = Field(default_factory=list)
808
+ chart_count: int = 0
809
+
810
+
811
+ class PortalReadSummaryResponse(StrictModel):
812
+ dash_key: str
813
+ being_draft: bool = True
814
+ dash_name: str | None = None
815
+ package_tag_ids: list[int] = Field(default_factory=list)
816
+ dash_icon: str | None = None
817
+ hide_copyright: bool | None = None
818
+ config_keys: list[str] = Field(default_factory=list)
819
+ dash_global_config_keys: list[str] = Field(default_factory=list)
820
+ section_count: int = 0
821
+ sections: list[dict[str, Any]] = Field(default_factory=list)
822
+
823
+
264
824
  class SchemaPlanRequest(StrictModel):
265
825
  app_key: str = ""
266
826
  package_tag_id: int | None = None
@@ -303,6 +863,22 @@ class FlowPlanRequest(StrictModel):
303
863
  payload = dict(value)
304
864
  if str(payload.get("mode") or "").strip().lower() == "overwrite":
305
865
  payload["mode"] = "replace"
866
+ raw_preset = payload.get("preset")
867
+ if raw_preset is None and isinstance(payload.get("base_preset"), str):
868
+ raw_preset = payload["base_preset"]
869
+ payload["preset"] = raw_preset
870
+ if isinstance(raw_preset, str):
871
+ normalized_preset = raw_preset.strip().lower()
872
+ preset_aliases = {
873
+ "default_approval": FlowPreset.basic_approval.value,
874
+ "approval": FlowPreset.basic_approval.value,
875
+ "basic approval": FlowPreset.basic_approval.value,
876
+ "default_fill_then_approve": FlowPreset.basic_fill_then_approve.value,
877
+ "default-fill-then-approve": FlowPreset.basic_fill_then_approve.value,
878
+ "fill_then_approve": FlowPreset.basic_fill_then_approve.value,
879
+ }
880
+ if normalized_preset in preset_aliases:
881
+ payload["preset"] = preset_aliases[normalized_preset]
306
882
  return payload
307
883
 
308
884
 
@@ -332,7 +908,14 @@ def _normalize_field_payload(value: Any) -> Any:
332
908
  if not isinstance(value, dict):
333
909
  return value
334
910
  payload = dict(value)
911
+ if "fields" in payload and "subfields" not in payload:
912
+ payload["subfields"] = payload.pop("fields")
335
913
  raw_type = payload.get("type")
914
+ if isinstance(raw_type, int):
915
+ normalized_from_id = FIELD_TYPE_ID_ALIASES.get(raw_type)
916
+ if normalized_from_id is not None:
917
+ payload["type"] = normalized_from_id.value
918
+ return payload
336
919
  if isinstance(raw_type, str):
337
920
  normalized = FIELD_TYPE_ALIASES.get(raw_type.strip().lower())
338
921
  if normalized is not None: