@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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +3 -1
- package/skills/qingflow-app-builder/SKILL.md +154 -22
- package/skills/qingflow-app-builder/references/create-app.md +51 -21
- package/skills/qingflow-app-builder/references/environments.md +1 -1
- package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
- package/skills/qingflow-app-builder/references/gotchas.md +28 -1
- package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
- package/skills/qingflow-app-builder/references/tool-selection.md +45 -17
- package/skills/qingflow-app-builder/references/update-flow.md +112 -25
- package/skills/qingflow-app-builder/references/update-layout.md +11 -24
- package/skills/qingflow-app-builder/references/update-schema.md +1 -23
- package/skills/qingflow-app-builder/references/update-views.md +87 -21
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/backend_client.py +189 -0
- package/src/qingflow_mcp/builder_facade/models.py +584 -1
- package/src/qingflow_mcp/builder_facade/service.py +4698 -262
- package/src/qingflow_mcp/config.py +39 -0
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/list_type_labels.py +24 -0
- package/src/qingflow_mcp/server.py +131 -16
- package/src/qingflow_mcp/server_app_builder.py +132 -72
- package/src/qingflow_mcp/server_app_user.py +143 -187
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +14 -4
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
- package/src/qingflow_mcp/solution/executor.py +44 -7
- package/src/qingflow_mcp/tools/ai_builder_tools.py +1567 -144
- package/src/qingflow_mcp/tools/app_tools.py +243 -14
- package/src/qingflow_mcp/tools/approval_tools.py +411 -76
- package/src/qingflow_mcp/tools/directory_tools.py +203 -31
- package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
- package/src/qingflow_mcp/tools/file_tools.py +1 -0
- package/src/qingflow_mcp/tools/import_tools.py +1164 -0
- package/src/qingflow_mcp/tools/portal_tools.py +31 -0
- package/src/qingflow_mcp/tools/record_tools.py +4943 -1025
- package/src/qingflow_mcp/tools/task_context_tools.py +1335 -0
- package/src/qingflow_mcp/tools/task_tools.py +376 -225
- 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[
|
|
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:
|