@josephyan/qingflow-app-builder-mcp 0.2.0-beta.2 → 0.2.0-beta.21
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 +11 -2
- package/npm/lib/runtime.mjs +37 -0
- package/npm/scripts/postinstall.mjs +5 -1
- package/package.json +3 -2
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-builder/SKILL.md +225 -0
- package/skills/qingflow-app-builder/agents/openai.yaml +4 -0
- package/skills/qingflow-app-builder/references/create-app.md +148 -0
- package/skills/qingflow-app-builder/references/environments.md +63 -0
- package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +124 -0
- package/skills/qingflow-app-builder/references/gotchas.md +64 -0
- package/skills/qingflow-app-builder/references/solution-playbooks.md +58 -0
- package/skills/qingflow-app-builder/references/tool-selection.md +96 -0
- package/skills/qingflow-app-builder/references/update-flow.md +233 -0
- package/skills/qingflow-app-builder/references/update-layout.md +91 -0
- package/skills/qingflow-app-builder/references/update-schema.md +90 -0
- package/skills/qingflow-app-builder/references/update-views.md +184 -0
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/builder_facade/models.py +294 -1
- package/src/qingflow_mcp/builder_facade/service.py +2727 -235
- package/src/qingflow_mcp/server.py +7 -5
- package/src/qingflow_mcp/server_app_builder.py +80 -4
- package/src/qingflow_mcp/server_app_user.py +8 -182
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +1 -1
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +21 -2
- package/src/qingflow_mcp/solution/executor.py +34 -7
- package/src/qingflow_mcp/tools/ai_builder_tools.py +1038 -30
- package/src/qingflow_mcp/tools/app_tools.py +1 -2
- package/src/qingflow_mcp/tools/approval_tools.py +357 -75
- package/src/qingflow_mcp/tools/directory_tools.py +158 -28
- package/src/qingflow_mcp/tools/record_tools.py +1954 -973
- package/src/qingflow_mcp/tools/task_tools.py +376 -225
- package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Update Schema
|
|
2
|
+
|
|
3
|
+
Use this when the app already exists and the task is only about fields.
|
|
4
|
+
|
|
5
|
+
## Minimal sequence
|
|
6
|
+
|
|
7
|
+
1. `app_resolve`
|
|
8
|
+
2. `app_read_fields`
|
|
9
|
+
3. `app_schema_plan`
|
|
10
|
+
4. `app_schema_apply`
|
|
11
|
+
|
|
12
|
+
## Example
|
|
13
|
+
|
|
14
|
+
Read current fields first:
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"tool_name": "app_read_fields",
|
|
19
|
+
"arguments": {
|
|
20
|
+
"profile": "default",
|
|
21
|
+
"app_key": "APP_123"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Plan the patch:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"tool_name": "app_schema_plan",
|
|
31
|
+
"arguments": {
|
|
32
|
+
"profile": "default",
|
|
33
|
+
"app_key": "APP_123",
|
|
34
|
+
"add_fields": [
|
|
35
|
+
{"name": "跟进日期", "type": "date"}
|
|
36
|
+
],
|
|
37
|
+
"update_fields": [
|
|
38
|
+
{"selector": {"name": "金额"}, "set": {"name": "订单金额", "required": true}}
|
|
39
|
+
],
|
|
40
|
+
"remove_fields": [
|
|
41
|
+
{"name": "旧字段"}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Apply the patch:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"tool_name": "app_schema_apply",
|
|
52
|
+
"arguments": {
|
|
53
|
+
"profile": "default",
|
|
54
|
+
"app_key": "APP_123",
|
|
55
|
+
"publish": true,
|
|
56
|
+
"add_fields": [
|
|
57
|
+
{"name": "跟进日期", "type": "date"}
|
|
58
|
+
],
|
|
59
|
+
"update_fields": [
|
|
60
|
+
{"selector": {"name": "金额"}, "set": {"name": "订单金额", "required": true}}
|
|
61
|
+
],
|
|
62
|
+
"remove_fields": [
|
|
63
|
+
{"name": "旧字段"}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Common failures
|
|
70
|
+
|
|
71
|
+
### `FIELD_NOT_FOUND`
|
|
72
|
+
|
|
73
|
+
The selector did not match current schema. Re-read with `app_read_fields` and use `name`, `field_id`, or `que_id`.
|
|
74
|
+
|
|
75
|
+
### `DUPLICATE_FIELD`
|
|
76
|
+
|
|
77
|
+
The field already exists with a different shape. Switch from `add_fields` to `update_fields`.
|
|
78
|
+
|
|
79
|
+
### `SCHEMA_APPLY_FAILED`
|
|
80
|
+
|
|
81
|
+
Treat it as uncertain write state. Read back with `app_read_fields` before retrying.
|
|
82
|
+
|
|
83
|
+
## Notes
|
|
84
|
+
|
|
85
|
+
- `title` and `label` are accepted aliases for `name`
|
|
86
|
+
- `textarea -> long_text`
|
|
87
|
+
- `currency -> amount`
|
|
88
|
+
- `mobile -> phone`
|
|
89
|
+
- `select/radio -> single_select`
|
|
90
|
+
- `checkbox -> multi_select`
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Update Views
|
|
2
|
+
|
|
3
|
+
Use this when the task is only about table, card, board, or gantt views.
|
|
4
|
+
|
|
5
|
+
## Minimal sequence
|
|
6
|
+
|
|
7
|
+
1. `builder_tool_contract(tool_name="app_views_plan")`
|
|
8
|
+
2. `app_read_fields`
|
|
9
|
+
3. `app_read_views_summary`
|
|
10
|
+
4. `app_views_plan`
|
|
11
|
+
5. `app_views_apply`
|
|
12
|
+
6. `app_read_views_summary` again whenever apply returns `failed` or `partial_success`
|
|
13
|
+
|
|
14
|
+
If you are unsure about keys or view types, call `builder_tool_contract(tool_name="app_views_apply")` before guessing.
|
|
15
|
+
|
|
16
|
+
Important verification rule:
|
|
17
|
+
|
|
18
|
+
- `app_views_apply` can create a view object before every filter is fully verified in readback
|
|
19
|
+
- Do not report “筛选已成功应用” unless the apply result also shows `verification.views_verified=true`
|
|
20
|
+
- If apply returns `partial_success`, inspect `verification.by_view` and `details.filter_mismatches` before claiming the filters are active
|
|
21
|
+
|
|
22
|
+
## Example
|
|
23
|
+
|
|
24
|
+
Canonical rules before any example:
|
|
25
|
+
|
|
26
|
+
- Always use `columns`
|
|
27
|
+
- Do not emit `column_names`
|
|
28
|
+
- Treat `fields` only as a legacy alias the MCP may normalize, not as the preferred shape
|
|
29
|
+
- Use `filters` with canonical keys `field_name`, `operator`, `value`/`values`
|
|
30
|
+
- For gantt, use `start_field`, `end_field`, and optionally `title_field`
|
|
31
|
+
- If `app_read_views_summary` shows duplicate view names, include `view_key` in `upsert_views[]` and update that exact target
|
|
32
|
+
|
|
33
|
+
Plan a default table view:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"tool_name": "app_views_plan",
|
|
38
|
+
"arguments": {
|
|
39
|
+
"profile": "default",
|
|
40
|
+
"app_key": "APP_123",
|
|
41
|
+
"upsert_views": [
|
|
42
|
+
{
|
|
43
|
+
"name": "全部订单",
|
|
44
|
+
"view_key": "VIEW_KEY_IF_DUPLICATE_NAMES_EXIST",
|
|
45
|
+
"type": "table",
|
|
46
|
+
"columns": ["订单编号", "客户名称", "订单金额", "状态"]
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
"remove_views": []
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Apply it:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"tool_name": "app_views_apply",
|
|
59
|
+
"arguments": {
|
|
60
|
+
"profile": "default",
|
|
61
|
+
"app_key": "APP_123",
|
|
62
|
+
"publish": true,
|
|
63
|
+
"upsert_views": [
|
|
64
|
+
{
|
|
65
|
+
"name": "全部订单",
|
|
66
|
+
"type": "table",
|
|
67
|
+
"columns": ["订单编号", "客户名称", "订单金额", "状态"]
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"remove_views": []
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
After `app_views_plan` succeeds, prefer reusing its `suggested_next_call.arguments` directly. Do not rewrite aliases back into non-canonical keys such as `column_names`.
|
|
76
|
+
|
|
77
|
+
Board example:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"tool_name": "app_views_apply",
|
|
82
|
+
"arguments": {
|
|
83
|
+
"profile": "default",
|
|
84
|
+
"app_key": "APP_123",
|
|
85
|
+
"upsert_views": [
|
|
86
|
+
{
|
|
87
|
+
"name": "按状态看板",
|
|
88
|
+
"type": "board",
|
|
89
|
+
"group_by": "状态",
|
|
90
|
+
"columns": ["订单编号", "客户名称", "订单金额"]
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"remove_views": []
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Gantt example with filters:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"tool_name": "app_views_apply",
|
|
103
|
+
"arguments": {
|
|
104
|
+
"profile": "default",
|
|
105
|
+
"app_key": "APP_123",
|
|
106
|
+
"upsert_views": [
|
|
107
|
+
{
|
|
108
|
+
"name": "项目甘特图",
|
|
109
|
+
"type": "gantt",
|
|
110
|
+
"columns": ["项目名称", "开始日期", "结束日期", "状态"],
|
|
111
|
+
"start_field": "开始日期",
|
|
112
|
+
"end_field": "结束日期",
|
|
113
|
+
"title_field": "项目名称",
|
|
114
|
+
"filters": [
|
|
115
|
+
{
|
|
116
|
+
"field_name": "状态",
|
|
117
|
+
"operator": "eq",
|
|
118
|
+
"value": "进行中"
|
|
119
|
+
}
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
"remove_views": []
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Common failures
|
|
129
|
+
|
|
130
|
+
### `UNKNOWN_VIEW_FIELD`
|
|
131
|
+
|
|
132
|
+
At least one `columns` or `group_by` field name does not exist.
|
|
133
|
+
|
|
134
|
+
### `INVALID_VIEW_TYPE`
|
|
135
|
+
|
|
136
|
+
Public view types are only `table`, `card`, `board`, `gantt`.
|
|
137
|
+
|
|
138
|
+
Map old or intuitive labels before calling the tool:
|
|
139
|
+
|
|
140
|
+
- `tableView` -> `table`
|
|
141
|
+
- `cardView` -> `card`
|
|
142
|
+
- `kanban` -> `board`
|
|
143
|
+
|
|
144
|
+
### `INVALID_GANTT_CONFIG`
|
|
145
|
+
|
|
146
|
+
Gantt views require at least:
|
|
147
|
+
|
|
148
|
+
- `start_field`
|
|
149
|
+
- `end_field`
|
|
150
|
+
|
|
151
|
+
Also make sure these field names already exist on the app.
|
|
152
|
+
|
|
153
|
+
### `VIEW_APPLY_FAILED`
|
|
154
|
+
|
|
155
|
+
The backend rejected the normalized view payload. Re-read fields and inspect `request_id` before retrying.
|
|
156
|
+
|
|
157
|
+
Do not repeat `app_views_apply` with guessed keys. First:
|
|
158
|
+
|
|
159
|
+
1. check `suggested_next_call`
|
|
160
|
+
2. reuse `canonical_arguments` if present
|
|
161
|
+
3. call `app_read_views_summary` to see whether any requested views landed anyway
|
|
162
|
+
4. if needed, call `builder_tool_contract`
|
|
163
|
+
5. retry only the minimal failed view patch
|
|
164
|
+
|
|
165
|
+
### `VIEW_FILTER_READBACK_MISMATCH`
|
|
166
|
+
|
|
167
|
+
The view object was created or updated, but the readback config did not keep the intended filter values.
|
|
168
|
+
|
|
169
|
+
Treat this as:
|
|
170
|
+
|
|
171
|
+
- the view exists
|
|
172
|
+
- the filter is **not yet verified**
|
|
173
|
+
|
|
174
|
+
Do not tell the user the filter is active until the readback verification matches the intended filter.
|
|
175
|
+
|
|
176
|
+
## Notes
|
|
177
|
+
|
|
178
|
+
- `fields` is accepted as an alias for `columns`, but skill examples should still use `columns`
|
|
179
|
+
- `column_names` should not appear in skill examples
|
|
180
|
+
- `app_read_views_summary` should be treated as canonical readback and now returns `columns`
|
|
181
|
+
- If `app_views_apply` returns `AMBIGUOUS_VIEW`, stop and re-run `app_read_views_summary`; then retry with the exact `view_key`
|
|
182
|
+
- `filters` are ANDed together as one flat condition group
|
|
183
|
+
- `app_views_apply` publishes by default
|
|
184
|
+
- For select-style filters, success means the backend preserved the option value in readback, not just that the view name now exists
|
|
@@ -43,11 +43,30 @@ 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"
|
|
51
70
|
|
|
52
71
|
|
|
53
72
|
class LayoutApplyMode(str, Enum):
|
|
@@ -69,6 +88,7 @@ class FlowPreset(str, Enum):
|
|
|
69
88
|
class ViewsPreset(str, Enum):
|
|
70
89
|
default_table = "default_table"
|
|
71
90
|
status_board = "status_board"
|
|
91
|
+
default_gantt = "default_gantt"
|
|
72
92
|
|
|
73
93
|
|
|
74
94
|
class PublicFlowNodeType(str, Enum):
|
|
@@ -82,6 +102,143 @@ class PublicFlowNodeType(str, Enum):
|
|
|
82
102
|
end = "end"
|
|
83
103
|
|
|
84
104
|
|
|
105
|
+
class FlowConditionOperator(str, Enum):
|
|
106
|
+
eq = "eq"
|
|
107
|
+
neq = "neq"
|
|
108
|
+
in_ = "in"
|
|
109
|
+
contains = "contains"
|
|
110
|
+
gte = "gte"
|
|
111
|
+
lte = "lte"
|
|
112
|
+
is_empty = "is_empty"
|
|
113
|
+
not_empty = "not_empty"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ViewFilterOperator(str, Enum):
|
|
117
|
+
eq = "eq"
|
|
118
|
+
neq = "neq"
|
|
119
|
+
in_ = "in"
|
|
120
|
+
contains = "contains"
|
|
121
|
+
gte = "gte"
|
|
122
|
+
lte = "lte"
|
|
123
|
+
is_empty = "is_empty"
|
|
124
|
+
not_empty = "not_empty"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class FlowAssigneePatch(StrictModel):
|
|
128
|
+
role_ids: list[int] = Field(default_factory=list)
|
|
129
|
+
role_names: list[str] = Field(default_factory=list)
|
|
130
|
+
member_uids: list[int] = Field(default_factory=list)
|
|
131
|
+
member_emails: list[str] = Field(default_factory=list)
|
|
132
|
+
member_names: list[str] = Field(default_factory=list)
|
|
133
|
+
include_sub_departs: bool | None = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class FlowNodePermissionsPatch(StrictModel):
|
|
137
|
+
editable_fields: list[str] = Field(default_factory=list)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class FlowConditionRulePatch(StrictModel):
|
|
141
|
+
field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
|
|
142
|
+
operator: FlowConditionOperator = Field(validation_alias=AliasChoices("operator", "op"))
|
|
143
|
+
values: list[Any] = Field(default_factory=list)
|
|
144
|
+
|
|
145
|
+
@model_validator(mode="before")
|
|
146
|
+
@classmethod
|
|
147
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
148
|
+
if not isinstance(value, dict):
|
|
149
|
+
return value
|
|
150
|
+
payload = dict(value)
|
|
151
|
+
if "value" in payload and "values" not in payload:
|
|
152
|
+
payload["values"] = [payload.pop("value")]
|
|
153
|
+
raw_operator = payload.get("operator", payload.get("op"))
|
|
154
|
+
if isinstance(raw_operator, str):
|
|
155
|
+
normalized = raw_operator.strip().lower()
|
|
156
|
+
operator_aliases = {
|
|
157
|
+
"equals": FlowConditionOperator.eq.value,
|
|
158
|
+
"equal": FlowConditionOperator.eq.value,
|
|
159
|
+
"=": FlowConditionOperator.eq.value,
|
|
160
|
+
"not_equals": FlowConditionOperator.neq.value,
|
|
161
|
+
"not_equal": FlowConditionOperator.neq.value,
|
|
162
|
+
"!=": FlowConditionOperator.neq.value,
|
|
163
|
+
">=": FlowConditionOperator.gte.value,
|
|
164
|
+
"<=": FlowConditionOperator.lte.value,
|
|
165
|
+
"any_of": FlowConditionOperator.in_.value,
|
|
166
|
+
"one_of": FlowConditionOperator.in_.value,
|
|
167
|
+
"between_any": FlowConditionOperator.in_.value,
|
|
168
|
+
"empty": FlowConditionOperator.is_empty.value,
|
|
169
|
+
"is blank": FlowConditionOperator.is_empty.value,
|
|
170
|
+
"blank": FlowConditionOperator.is_empty.value,
|
|
171
|
+
"not_empty": FlowConditionOperator.not_empty.value,
|
|
172
|
+
"not blank": FlowConditionOperator.not_empty.value,
|
|
173
|
+
}
|
|
174
|
+
if normalized in operator_aliases:
|
|
175
|
+
payload["operator"] = operator_aliases[normalized]
|
|
176
|
+
elif "operator" not in payload:
|
|
177
|
+
payload["operator"] = normalized
|
|
178
|
+
payload.pop("op", None)
|
|
179
|
+
return payload
|
|
180
|
+
|
|
181
|
+
@model_validator(mode="after")
|
|
182
|
+
def validate_shape(self) -> "FlowConditionRulePatch":
|
|
183
|
+
if self.operator in {FlowConditionOperator.is_empty, FlowConditionOperator.not_empty}:
|
|
184
|
+
self.values = []
|
|
185
|
+
return self
|
|
186
|
+
if not self.values:
|
|
187
|
+
raise ValueError("condition rule requires values")
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class ViewFilterRulePatch(StrictModel):
|
|
192
|
+
field_name: str = Field(validation_alias=AliasChoices("field_name", "fieldName", "field", "name"))
|
|
193
|
+
operator: ViewFilterOperator = Field(validation_alias=AliasChoices("operator", "op"))
|
|
194
|
+
values: list[Any] = Field(default_factory=list)
|
|
195
|
+
|
|
196
|
+
@model_validator(mode="before")
|
|
197
|
+
@classmethod
|
|
198
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
199
|
+
if not isinstance(value, dict):
|
|
200
|
+
return value
|
|
201
|
+
payload = dict(value)
|
|
202
|
+
if "value" in payload and "values" not in payload:
|
|
203
|
+
payload["values"] = [payload.pop("value")]
|
|
204
|
+
raw_operator = payload.get("operator", payload.get("op"))
|
|
205
|
+
if isinstance(raw_operator, str):
|
|
206
|
+
normalized = raw_operator.strip().lower()
|
|
207
|
+
operator_aliases = {
|
|
208
|
+
"equals": ViewFilterOperator.eq.value,
|
|
209
|
+
"equal": ViewFilterOperator.eq.value,
|
|
210
|
+
"=": ViewFilterOperator.eq.value,
|
|
211
|
+
"not_equals": ViewFilterOperator.neq.value,
|
|
212
|
+
"not_equal": ViewFilterOperator.neq.value,
|
|
213
|
+
"!=": ViewFilterOperator.neq.value,
|
|
214
|
+
">=": ViewFilterOperator.gte.value,
|
|
215
|
+
"<=": ViewFilterOperator.lte.value,
|
|
216
|
+
"any_of": ViewFilterOperator.in_.value,
|
|
217
|
+
"one_of": ViewFilterOperator.in_.value,
|
|
218
|
+
"between_any": ViewFilterOperator.in_.value,
|
|
219
|
+
"empty": ViewFilterOperator.is_empty.value,
|
|
220
|
+
"is blank": ViewFilterOperator.is_empty.value,
|
|
221
|
+
"blank": ViewFilterOperator.is_empty.value,
|
|
222
|
+
"not_empty": ViewFilterOperator.not_empty.value,
|
|
223
|
+
"not blank": ViewFilterOperator.not_empty.value,
|
|
224
|
+
}
|
|
225
|
+
if normalized in operator_aliases:
|
|
226
|
+
payload["operator"] = operator_aliases[normalized]
|
|
227
|
+
elif "operator" not in payload:
|
|
228
|
+
payload["operator"] = normalized
|
|
229
|
+
payload.pop("op", None)
|
|
230
|
+
return payload
|
|
231
|
+
|
|
232
|
+
@model_validator(mode="after")
|
|
233
|
+
def validate_shape(self) -> "ViewFilterRulePatch":
|
|
234
|
+
if self.operator in {ViewFilterOperator.is_empty, ViewFilterOperator.not_empty}:
|
|
235
|
+
self.values = []
|
|
236
|
+
return self
|
|
237
|
+
if not self.values:
|
|
238
|
+
raise ValueError("view filter rule requires values")
|
|
239
|
+
return self
|
|
240
|
+
|
|
241
|
+
|
|
85
242
|
class FieldSelector(StrictModel):
|
|
86
243
|
field_id: str | None = None
|
|
87
244
|
que_id: int | None = None
|
|
@@ -161,15 +318,65 @@ class FieldRemovePatch(StrictModel):
|
|
|
161
318
|
return self
|
|
162
319
|
|
|
163
320
|
|
|
321
|
+
def _coerce_layout_columns(value: Any) -> int | None:
|
|
322
|
+
if isinstance(value, bool):
|
|
323
|
+
return None
|
|
324
|
+
if isinstance(value, int):
|
|
325
|
+
return value if value > 0 else None
|
|
326
|
+
if isinstance(value, str):
|
|
327
|
+
stripped = value.strip()
|
|
328
|
+
if stripped.isdigit():
|
|
329
|
+
parsed = int(stripped)
|
|
330
|
+
return parsed if parsed > 0 else None
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _normalize_layout_rows(value: Any, *, columns: int | None = None) -> Any:
|
|
335
|
+
if not isinstance(value, list):
|
|
336
|
+
return value
|
|
337
|
+
if value and all(isinstance(item, list) for item in value):
|
|
338
|
+
return value
|
|
339
|
+
if not value:
|
|
340
|
+
return []
|
|
341
|
+
width = columns if columns and columns > 0 else None
|
|
342
|
+
if width is None:
|
|
343
|
+
return [list(value)]
|
|
344
|
+
return [list(value[index : index + width]) for index in range(0, len(value), width) if value[index : index + width]]
|
|
345
|
+
|
|
346
|
+
|
|
164
347
|
class LayoutSectionPatch(StrictModel):
|
|
165
348
|
section_id: str | None = Field(default=None, validation_alias=AliasChoices("section_id", "sectionId"))
|
|
166
349
|
title: str
|
|
167
|
-
rows: list[list[
|
|
350
|
+
rows: list[list[Any]] = Field(default_factory=list)
|
|
351
|
+
|
|
352
|
+
@model_validator(mode="before")
|
|
353
|
+
@classmethod
|
|
354
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
355
|
+
if not isinstance(value, dict):
|
|
356
|
+
return value
|
|
357
|
+
payload = dict(value)
|
|
358
|
+
if "name" in payload and "title" not in payload:
|
|
359
|
+
payload["title"] = payload.pop("name")
|
|
360
|
+
shorthand: Any | None = None
|
|
361
|
+
if "rows" not in payload:
|
|
362
|
+
if "fields" in payload:
|
|
363
|
+
shorthand = payload.pop("fields")
|
|
364
|
+
elif "field_ids" in payload:
|
|
365
|
+
shorthand = payload.pop("field_ids")
|
|
366
|
+
if shorthand is not None:
|
|
367
|
+
payload["rows"] = _normalize_layout_rows(
|
|
368
|
+
shorthand,
|
|
369
|
+
columns=_coerce_layout_columns(payload.pop("columns", None)),
|
|
370
|
+
)
|
|
371
|
+
return payload
|
|
168
372
|
|
|
169
373
|
@model_validator(mode="after")
|
|
170
374
|
def validate_rows(self) -> "LayoutSectionPatch":
|
|
171
375
|
if not self.rows:
|
|
172
376
|
raise ValueError("section rows must be a non-empty list")
|
|
377
|
+
for row in self.rows:
|
|
378
|
+
if not isinstance(row, list) or not row:
|
|
379
|
+
raise ValueError("section rows must be a non-empty list")
|
|
173
380
|
if not self.section_id:
|
|
174
381
|
self.section_id = _slugify_title(self.title)
|
|
175
382
|
return self
|
|
@@ -179,8 +386,54 @@ class FlowNodePatch(StrictModel):
|
|
|
179
386
|
id: str
|
|
180
387
|
type: PublicFlowNodeType
|
|
181
388
|
name: str
|
|
389
|
+
assignees: FlowAssigneePatch = Field(default_factory=FlowAssigneePatch)
|
|
390
|
+
permissions: FlowNodePermissionsPatch = Field(default_factory=FlowNodePermissionsPatch)
|
|
391
|
+
conditions: list[FlowConditionRulePatch] = Field(default_factory=list)
|
|
392
|
+
condition_groups: list[list[FlowConditionRulePatch]] = Field(default_factory=list)
|
|
182
393
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
183
394
|
|
|
395
|
+
@model_validator(mode="before")
|
|
396
|
+
@classmethod
|
|
397
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
398
|
+
if not isinstance(value, dict):
|
|
399
|
+
return value
|
|
400
|
+
payload = dict(value)
|
|
401
|
+
assignees = dict(payload.get("assignees") or {})
|
|
402
|
+
permissions = dict(payload.get("permissions") or {})
|
|
403
|
+
|
|
404
|
+
for key in ("role_ids", "role_names", "member_uids", "member_emails", "member_names", "include_sub_departs"):
|
|
405
|
+
if key in payload and key not in assignees:
|
|
406
|
+
assignees[key] = payload.pop(key)
|
|
407
|
+
for key in ("editable_fields",):
|
|
408
|
+
if key in payload and key not in permissions:
|
|
409
|
+
permissions[key] = payload.pop(key)
|
|
410
|
+
if "filters" in payload and "conditions" not in payload:
|
|
411
|
+
payload["conditions"] = payload.pop("filters")
|
|
412
|
+
if "rules" in payload and "conditions" not in payload:
|
|
413
|
+
payload["conditions"] = payload.pop("rules")
|
|
414
|
+
if "conditionRules" in payload and "condition_groups" not in payload:
|
|
415
|
+
payload["condition_groups"] = payload.pop("conditionRules")
|
|
416
|
+
if "conditionGroups" in payload and "condition_groups" not in payload:
|
|
417
|
+
payload["condition_groups"] = payload.pop("conditionGroups")
|
|
418
|
+
if "owners" in payload and "member_names" not in assignees:
|
|
419
|
+
assignees["member_names"] = payload.pop("owners")
|
|
420
|
+
if "approvers" in payload and "role_names" not in assignees:
|
|
421
|
+
assignees["role_names"] = payload.pop("approvers")
|
|
422
|
+
if assignees:
|
|
423
|
+
payload["assignees"] = assignees
|
|
424
|
+
if permissions:
|
|
425
|
+
payload["permissions"] = permissions
|
|
426
|
+
return payload
|
|
427
|
+
|
|
428
|
+
@model_validator(mode="after")
|
|
429
|
+
def validate_branch_conditions(self) -> "FlowNodePatch":
|
|
430
|
+
if self.conditions:
|
|
431
|
+
self.condition_groups = [list(self.conditions), *self.condition_groups]
|
|
432
|
+
self.conditions = []
|
|
433
|
+
if self.type != PublicFlowNodeType.condition and self.condition_groups:
|
|
434
|
+
raise ValueError("condition_groups are only allowed on condition nodes")
|
|
435
|
+
return self
|
|
436
|
+
|
|
184
437
|
|
|
185
438
|
class FlowTransitionPatch(StrictModel):
|
|
186
439
|
source: str = Field(alias="from")
|
|
@@ -189,9 +442,14 @@ class FlowTransitionPatch(StrictModel):
|
|
|
189
442
|
|
|
190
443
|
class ViewUpsertPatch(StrictModel):
|
|
191
444
|
name: str
|
|
445
|
+
view_key: str | None = Field(default=None, validation_alias=AliasChoices("view_key", "viewKey"))
|
|
192
446
|
type: PublicViewType
|
|
193
447
|
columns: list[str] = Field(default_factory=list)
|
|
194
448
|
group_by: str | None = None
|
|
449
|
+
filters: list[ViewFilterRulePatch] = Field(default_factory=list)
|
|
450
|
+
start_field: str | None = Field(default=None, validation_alias=AliasChoices("start_field", "startField"))
|
|
451
|
+
end_field: str | None = Field(default=None, validation_alias=AliasChoices("end_field", "endField"))
|
|
452
|
+
title_field: str | None = Field(default=None, validation_alias=AliasChoices("title_field", "titleField"))
|
|
195
453
|
|
|
196
454
|
@model_validator(mode="before")
|
|
197
455
|
@classmethod
|
|
@@ -201,6 +459,14 @@ class ViewUpsertPatch(StrictModel):
|
|
|
201
459
|
payload = dict(value)
|
|
202
460
|
if "fields" in payload and "columns" not in payload:
|
|
203
461
|
payload["columns"] = payload.pop("fields")
|
|
462
|
+
if "column_names" in payload and "columns" not in payload:
|
|
463
|
+
payload["columns"] = payload.pop("column_names")
|
|
464
|
+
if "columnNames" in payload and "columns" not in payload:
|
|
465
|
+
payload["columns"] = payload.pop("columnNames")
|
|
466
|
+
if "filter_rules" in payload and "filters" not in payload:
|
|
467
|
+
payload["filters"] = payload.pop("filter_rules")
|
|
468
|
+
if "filterRules" in payload and "filters" not in payload:
|
|
469
|
+
payload["filters"] = payload.pop("filterRules")
|
|
204
470
|
raw_type = payload.get("type")
|
|
205
471
|
if isinstance(raw_type, str):
|
|
206
472
|
normalized = raw_type.strip().lower()
|
|
@@ -210,6 +476,8 @@ class ViewUpsertPatch(StrictModel):
|
|
|
210
476
|
payload["type"] = "card"
|
|
211
477
|
elif normalized == "kanban":
|
|
212
478
|
payload["type"] = "board"
|
|
479
|
+
elif normalized == "ganttview":
|
|
480
|
+
payload["type"] = "gantt"
|
|
213
481
|
return payload
|
|
214
482
|
|
|
215
483
|
@model_validator(mode="after")
|
|
@@ -218,6 +486,8 @@ class ViewUpsertPatch(StrictModel):
|
|
|
218
486
|
raise ValueError("table/card views require columns")
|
|
219
487
|
if self.type == PublicViewType.board and not self.group_by:
|
|
220
488
|
raise ValueError("board view requires group_by")
|
|
489
|
+
if self.type == PublicViewType.gantt and not (self.start_field and self.end_field):
|
|
490
|
+
raise ValueError("gantt view requires start_field and end_field")
|
|
221
491
|
return self
|
|
222
492
|
|
|
223
493
|
|
|
@@ -303,6 +573,22 @@ class FlowPlanRequest(StrictModel):
|
|
|
303
573
|
payload = dict(value)
|
|
304
574
|
if str(payload.get("mode") or "").strip().lower() == "overwrite":
|
|
305
575
|
payload["mode"] = "replace"
|
|
576
|
+
raw_preset = payload.get("preset")
|
|
577
|
+
if raw_preset is None and isinstance(payload.get("base_preset"), str):
|
|
578
|
+
raw_preset = payload["base_preset"]
|
|
579
|
+
payload["preset"] = raw_preset
|
|
580
|
+
if isinstance(raw_preset, str):
|
|
581
|
+
normalized_preset = raw_preset.strip().lower()
|
|
582
|
+
preset_aliases = {
|
|
583
|
+
"default_approval": FlowPreset.basic_approval.value,
|
|
584
|
+
"approval": FlowPreset.basic_approval.value,
|
|
585
|
+
"basic approval": FlowPreset.basic_approval.value,
|
|
586
|
+
"default_fill_then_approve": FlowPreset.basic_fill_then_approve.value,
|
|
587
|
+
"default-fill-then-approve": FlowPreset.basic_fill_then_approve.value,
|
|
588
|
+
"fill_then_approve": FlowPreset.basic_fill_then_approve.value,
|
|
589
|
+
}
|
|
590
|
+
if normalized_preset in preset_aliases:
|
|
591
|
+
payload["preset"] = preset_aliases[normalized_preset]
|
|
306
592
|
return payload
|
|
307
593
|
|
|
308
594
|
|
|
@@ -332,7 +618,14 @@ def _normalize_field_payload(value: Any) -> Any:
|
|
|
332
618
|
if not isinstance(value, dict):
|
|
333
619
|
return value
|
|
334
620
|
payload = dict(value)
|
|
621
|
+
if "fields" in payload and "subfields" not in payload:
|
|
622
|
+
payload["subfields"] = payload.pop("fields")
|
|
335
623
|
raw_type = payload.get("type")
|
|
624
|
+
if isinstance(raw_type, int):
|
|
625
|
+
normalized_from_id = FIELD_TYPE_ID_ALIASES.get(raw_type)
|
|
626
|
+
if normalized_from_id is not None:
|
|
627
|
+
payload["type"] = normalized_from_id.value
|
|
628
|
+
return payload
|
|
336
629
|
if isinstance(raw_type, str):
|
|
337
630
|
normalized = FIELD_TYPE_ALIASES.get(raw_type.strip().lower())
|
|
338
631
|
if normalized is not None:
|