@josephyan/qingflow-app-builder-mcp 1.1.26 → 1.1.28
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 +4 -3
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-builder/SKILL.md +24 -75
- package/skills/qingflow-app-builder/references/build-complete-system.md +185 -379
- package/skills/qingflow-app-builder/references/build-single-app.md +103 -495
- package/skills/qingflow-app-builder/references/create-app.md +15 -3
- package/skills/qingflow-app-builder/references/gotchas.md +6 -10
- package/skills/qingflow-app-builder/references/match-rules.md +18 -14
- package/skills/qingflow-app-builder/references/public-surface-sync.md +2 -2
- package/skills/qingflow-app-builder/references/tool-selection.md +9 -11
- package/src/qingflow_mcp/builder_facade/models.py +167 -71
- package/src/qingflow_mcp/builder_facade/service.py +617 -155
- package/src/qingflow_mcp/cli/commands/builder.py +3 -1
- package/src/qingflow_mcp/cli/main.py +8 -3
- package/src/qingflow_mcp/server_app_builder.py +9 -8
- package/src/qingflow_mcp/tools/ai_builder_tools.py +231 -180
- package/src/qingflow_mcp/version.py +2 -0
|
@@ -6,7 +6,7 @@ This playbook follows the current public builder surface, not legacy package hel
|
|
|
6
6
|
Do not use this playbook when the user is really asking for a system/package with multiple forms or modules. In that case:
|
|
7
7
|
|
|
8
8
|
1. read or create the package through `package_get` / `package_apply`
|
|
9
|
-
2. create
|
|
9
|
+
2. create related apps together with one `app_schema_apply(apps=[...])`
|
|
10
10
|
3. keep package ownership on the public `package_id` path instead of a separate attach step
|
|
11
11
|
4. add relation fields between apps
|
|
12
12
|
|
|
@@ -85,6 +85,12 @@ Apply schema for a new app:
|
|
|
85
85
|
{"name": "订单金额", "type": "amount"},
|
|
86
86
|
{"name": "状态", "type": "single_select", "options": ["草稿", "进行中", "已完成"], "required": true}
|
|
87
87
|
],
|
|
88
|
+
"form": {
|
|
89
|
+
"sections": [
|
|
90
|
+
{"title": "基础信息", "rows": [["订单编号", "客户名称"], ["状态"]]},
|
|
91
|
+
{"title": "金额与附件", "rows": [["订单金额"], ["订单封面"]]}
|
|
92
|
+
]
|
|
93
|
+
},
|
|
88
94
|
"update_fields": [],
|
|
89
95
|
"remove_fields": []
|
|
90
96
|
}
|
|
@@ -105,13 +111,18 @@ Apply schema for multiple apps in one call:
|
|
|
105
111
|
{
|
|
106
112
|
"client_key": "customer",
|
|
107
113
|
"app_name": "客户",
|
|
114
|
+
"icon": "user-group",
|
|
115
|
+
"color": "blue",
|
|
108
116
|
"add_fields": [
|
|
109
117
|
{"name": "客户名称", "type": "text", "required": true, "as_data_title": true}
|
|
110
|
-
]
|
|
118
|
+
],
|
|
119
|
+
"form": {"sections": [{"title": "基础信息", "rows": [["客户名称"]]}]}
|
|
111
120
|
},
|
|
112
121
|
{
|
|
113
122
|
"client_key": "order",
|
|
114
123
|
"app_name": "订单",
|
|
124
|
+
"icon": "delivery-box-1",
|
|
125
|
+
"color": "green",
|
|
115
126
|
"add_fields": [
|
|
116
127
|
{"name": "订单编号", "type": "text", "required": true, "as_data_title": true},
|
|
117
128
|
{
|
|
@@ -121,7 +132,8 @@ Apply schema for multiple apps in one call:
|
|
|
121
132
|
"display_field": {"name": "客户名称"},
|
|
122
133
|
"visible_fields": [{"name": "客户名称"}]
|
|
123
134
|
}
|
|
124
|
-
]
|
|
135
|
+
],
|
|
136
|
+
"form": {"sections": [{"title": "基础信息", "rows": [["订单编号", "关联客户"]]}]}
|
|
125
137
|
}
|
|
126
138
|
]
|
|
127
139
|
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
- Always pass `publish: true` explicitly on `app_schema_apply`, `app_layout_apply`, `app_flow_apply`, `app_views_apply`, and `portal_apply` — do not rely on the default
|
|
24
24
|
- Pass `publish: false` on those five tools only when the user explicitly wants to leave changes in draft
|
|
25
|
-
- `
|
|
25
|
+
- `app_associated_resources_apply` publishes after at least one write succeeds and does not accept a `publish` parameter
|
|
26
26
|
- `app_charts_apply` is immediate-live and has no publish step
|
|
27
27
|
- `app_publish_verify` is for explicit final verification, not the default next step after every write
|
|
28
28
|
|
|
@@ -34,20 +34,16 @@
|
|
|
34
34
|
- Creating a chart with `app_charts_apply` does not automatically show it in the Qingflow app UI; attach it separately if display is required.
|
|
35
35
|
- `target` and `table` remain compatibility aliases for QingBI `indicator` and `detail`; use the real QingBI chart type when you need a specific type such as `summary`, `columnar`, `stacked_bar`, `scatter`, or `dualaxes`.
|
|
36
36
|
|
|
37
|
-
##
|
|
37
|
+
## View actions
|
|
38
38
|
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
- For add-data buttons, prefer semantic `trigger_add_data_config.target_app_key`, `field_mappings`, and `default_values`.
|
|
39
|
+
- Put business actions directly in the target view's `action_buttons`.
|
|
40
|
+
- For add-data actions, use semantic `target_app_key`, `field_mappings`, and `default_values`.
|
|
42
41
|
- Do not handwrite raw `que_relation` unless you are preserving an existing backend config. If `field_mappings` and `que_relation` are mixed, the tool blocks the write.
|
|
43
42
|
- `field_mappings.source_field` can use source app fields and supported system fields such as `数据ID` (`field_id=-17`) and `编号` (`field_id=0`).
|
|
44
43
|
- To fill a target relation field with the current source record, map `{"source_field": "数据ID", "target_field": "目标引用字段"}`. Use `default_values` only for static constants.
|
|
45
44
|
- For field type compatibility, read `references/match-rules.md`.
|
|
46
45
|
- Default button placements are `header` and `detail`; these map to the frontend's header and detail button areas.
|
|
47
|
-
- `view_configs[].buttons` is required in merge mode. Do not send a view config with only `view_key`; it is blocked to avoid no-op writes and accidental publish.
|
|
48
46
|
- Builder view configs use raw `view_key` from `app_get.views[].view_key`; do not prefix it with `custom:`. The `custom:<viewKey>` form is for record-data `view_id`.
|
|
49
|
-
- View button bindings merge by default. Use `view_configs[].mode="replace"` to replace the full set, or pass an explicit empty `buttons: []` when you intend to clear all custom button bindings for that view.
|
|
50
|
-
- Advanced view button bindings can include `button_limit`, `button_formula`, `button_formula_type`, and `print_tpls`, but keep ordinary buttons simple unless the user asks for conditional visibility or print templates.
|
|
51
47
|
- `placement=list` configures row/list buttons. The tool maps it to the backend `INSIDE` button position, not a raw `LIST` config type.
|
|
52
48
|
|
|
53
49
|
## Associated resources
|
|
@@ -68,9 +64,9 @@
|
|
|
68
64
|
|
|
69
65
|
## Partial update discipline
|
|
70
66
|
|
|
71
|
-
- Existing views,
|
|
67
|
+
- Existing views, associated resources, and charts support the same public partial pattern: `patch_*[].set` plus optional `patch_*[].unset`.
|
|
72
68
|
- The backend may still save a full payload. The MCP/CLI patch path is responsible for reading current config and preserving fields the user did not mention.
|
|
73
|
-
- Do not use `upsert_views`, `
|
|
69
|
+
- Do not use `upsert_views`, `upsert_resources`, or `upsert_charts` for a tiny parameter replacement unless you are deliberately providing the full desired target config.
|
|
74
70
|
- `app_layout_apply(mode=merge)` is already a safe layout merge path; `mode=replace` is full layout replacement.
|
|
75
71
|
- `portal_apply` without `sections` is base-info-only. Supplying `sections` replaces the portal sections list.
|
|
76
72
|
- `app_flow_apply` is intentionally replace-only for the public linear workflow graph.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Builder Match Rules
|
|
2
2
|
|
|
3
|
-
Use this reference for builder-side field matching rules in
|
|
3
|
+
Use this reference for builder-side field matching rules in view actions and associated view/report filters.
|
|
4
4
|
|
|
5
5
|
This is not the same thing as `record_access` analysis filters or normal view `filters`.
|
|
6
6
|
|
|
7
7
|
## Where These Rules Apply
|
|
8
8
|
|
|
9
|
-
- `
|
|
10
|
-
- `
|
|
9
|
+
- `app_views_apply.views[].action_buttons[].field_mappings`
|
|
10
|
+
- `app_views_apply.patch_views[].set.action_buttons[].field_mappings`
|
|
11
11
|
- `app_associated_resources_apply.upsert_resources[].match_mappings`
|
|
12
12
|
- `app_associated_resources_apply.patch_resources[].set.match_mappings`
|
|
13
13
|
|
|
@@ -24,7 +24,7 @@ System fields can participate in mappings:
|
|
|
24
24
|
|
|
25
25
|
Explicit selectors such as `{"field_id": -17}` and `{"field_id": 0}` are allowed and remove ambiguity.
|
|
26
26
|
|
|
27
|
-
##
|
|
27
|
+
## View Actions
|
|
28
28
|
|
|
29
29
|
Use `field_mappings` for dynamic values copied from the current source record:
|
|
30
30
|
|
|
@@ -43,21 +43,25 @@ Use `field_mappings` for dynamic values copied from the current source record:
|
|
|
43
43
|
|
|
44
44
|
Use `default_values` only for static constants. Do not use `default_values` as the preferred way to pass the current record.
|
|
45
45
|
|
|
46
|
-
For an existing
|
|
46
|
+
For an existing view, patch the view's action list through `patch_views[].set.action_buttons`:
|
|
47
47
|
|
|
48
48
|
```json
|
|
49
49
|
{
|
|
50
|
-
"
|
|
50
|
+
"view_key": "VIEW_KEY",
|
|
51
51
|
"set": {
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
"action_buttons": [
|
|
53
|
+
{
|
|
54
|
+
"text": "新建工时",
|
|
55
|
+
"action": "add_data",
|
|
56
|
+
"target_app_key": "WORKLOG_APP",
|
|
57
|
+
"field_mappings": [
|
|
58
|
+
{"source_field": "数据ID", "target_field": "关联员工"}
|
|
59
|
+
],
|
|
60
|
+
"default_values": {
|
|
61
|
+
"状态": "待提交"
|
|
62
|
+
}
|
|
59
63
|
}
|
|
60
|
-
|
|
64
|
+
]
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
```
|
|
@@ -39,14 +39,14 @@ It is not a user-facing product spec. It exists to prevent skill drift.
|
|
|
39
39
|
- `app_layout_apply`
|
|
40
40
|
- `app_flow_apply`
|
|
41
41
|
- `app_views_apply`
|
|
42
|
-
- `app_custom_buttons_apply`
|
|
43
42
|
- `app_associated_resources_apply`
|
|
44
43
|
- `app_charts_apply`
|
|
45
44
|
- `portal_apply`
|
|
46
45
|
- `app_publish_verify`
|
|
47
46
|
- `portal_apply` edit mode may omit `sections` for base-info-only updates
|
|
48
47
|
- `app_charts_apply.visibility` is a public capability and should be treated as a base-only visibility update
|
|
49
|
-
-
|
|
48
|
+
- View actions are declared through `app_views_apply` `action_buttons`
|
|
49
|
+
- Existing object parameter replacement should use `patch_views`, `patch_resources`, and `patch_charts`; `upsert_*` is for creation or full target config
|
|
50
50
|
- `app_get.editability` uses:
|
|
51
51
|
- `can_edit_app_base`
|
|
52
52
|
- `can_edit_form`
|
|
@@ -38,7 +38,6 @@ All `app_get_*` tools accept either `app_key` (single) or `app_keys[]` (batch).
|
|
|
38
38
|
- `app_get_views`: current view names, types, columns, group-by
|
|
39
39
|
- `app_get_flow`: workflow enabled state, full spec (nodes + transitions)
|
|
40
40
|
- `app_get_charts`: current chart ids, names, types, order
|
|
41
|
-
- `app_get_buttons`: current custom button list (draft state)
|
|
42
41
|
- `app_get_associated_resources`: current associated resource pool (draft state)
|
|
43
42
|
- `app_publish_verify`: publish state and package tag verification — accepts `app_keys[]` for multi-app batch
|
|
44
43
|
- `portal_get`: current portal config detail and component inventory
|
|
@@ -48,15 +47,14 @@ All `app_get_*` tools accept either `app_key` (single) or `app_keys[]` (batch).
|
|
|
48
47
|
These execute normalized patches. Some app apply tools publish by default and still accept `publish=false`; custom button and associated-resource apply publish after at least one write succeeds and do not expose that switch.
|
|
49
48
|
|
|
50
49
|
- `app_schema_apply`: create app shell or change fields
|
|
51
|
-
- `app_layout_apply`:
|
|
50
|
+
- `app_layout_apply`: layout-only maintenance for existing apps; new app layout belongs in `app_schema_apply` `form.sections`
|
|
52
51
|
- `app_flow_apply`: replace full workflow spec; use `patch_nodes[]` (`{id, set, unset}`) to update specific nodes without rewriting the full spec
|
|
53
|
-
- `app_views_apply`: use
|
|
54
|
-
- `app_custom_buttons_apply`: use `patch_buttons` for existing-button parameter replacement; use `upsert_buttons` for creation or full target config; configure add-data field mappings/default values; bind buttons to header/detail/list view positions; `placement=list` maps to the backend `INSIDE` row/list button position; merge-mode view configs require `buttons`; use `view_configs[].mode="replace"` or `buttons=[]` to clear a view's custom button bindings. For child-record creation linked to the current source record, map `source_field: "数据ID"` to the target relation field. Use `apps[]` for multi-app batch.
|
|
52
|
+
- `app_views_apply`: use top-level `views[]` for same-app/cross-app batch create, patch, and remove; view business actions belong in each view item's `action_buttons`
|
|
55
53
|
- `app_associated_resources_apply`: attach existing BI reports/views to the Qingflow app associated-resource pool and per-view display area; it does not create or edit QingBI report bodies/configs. Use `patch_resources` for existing associated-resource parameter replacement; use `upsert_resources` for creation or full target config; `view_configs`, remove, and reorder may reference existing resources by internal `associated_item_id` or by `chart_id`/`chart_key`/`view_key`; use `match_mappings` for associated view/report filters; publishes after successful writes; omit raw `sourceType`, and use `report_source="dataset"` only to attach an existing BI dataset report. Before `upsert_resources`, read `app_get.associated_resources` and reuse an existing matching `target_app_key + view_key/chart_key`; repeated upsert can create duplicates because `client_key` is only valid inside one apply call. Use `apps[]` for multi-app batch.
|
|
56
54
|
- `app_charts_apply`: create/edit/remove/reorder app-source QingBI report bodies/configs with `dataSourceType=qingflow`; it does not create/edit dataset BI reports and does not attach reports to Qingflow app associated-resource display. Use `patch_charts` for existing-chart parameter replacement; use `upsert_charts` for creation or full target config; supports `target/table` aliases plus QingBI chart types such as `summary`, `columnar`, `area`, `funnel`, `radar`, `scatter`, `dualaxes`, and `map`; charts are immediate-live and do not publish; use `chart_id` when names are not unique; use `apps[]` for multi-app batch
|
|
57
|
-
- `portal_apply`: create or replace-update portal pages; use `dash_key` for update mode or `package_id + dash_name` for create mode; edit mode may omit `sections` for base-info-only updates; when sections are supplied they still use replace semantics; sections use a 24-column PC grid — all components in the same row (same `y`) must share the same `rows` value and their `cols` must sum to exactly 24; mismatched `rows` causes height misalignment, `cols` under 24 leaves trailing blank space; use `patch_sections[]` (`{
|
|
55
|
+
- `portal_apply`: create or replace-update portal pages; use `dash_key` for update mode or `package_id + dash_name` for create mode; edit mode may omit `sections` for base-info-only updates; when sections are supplied they still use replace semantics; sections use a 24-column PC grid — all components in the same row (same `y`) must share the same `rows` value and their `cols` must sum to exactly 24; mismatched `rows` causes height misalignment, `cols` under 24 leaves trailing blank space; use `patch_sections[]` (`{chart/view_ref/order, set, unset}`) to update individual sections without replacing all
|
|
58
56
|
|
|
59
|
-
For object-level updates, the safe partial syntax is `patch_*` with the object's real selector field plus `set` and optional `unset`. `selector` is only a concept, not a literal key. Examples: `patch_views: [{"view_key": "VIEW_KEY", "set": {...}}]`, `
|
|
57
|
+
For object-level updates, the safe partial syntax is `patch_*` with the object's real selector field plus `set` and optional `unset`. `selector` is only a concept, not a literal key. Examples: `patch_views: [{"view_key": "VIEW_KEY", "set": {...}}]`, `patch_resources: [{"associated_item_id": 123, "set": {...}}]`, `patch_charts: [{"chart_id": 456, "set": {...}}]`, `patch_nodes: [{"id": "approve_1", "set": {...}}]`, `patch_sections: [{"chart": {"chart_key": "ck_001"}, "set": {"title": "新标题"}}]`. The tool reads the current backend config, merges the patch, then submits the full backend payload internally. Do not send a partial `upsert_*` and expect missing required fields to be preserved.
|
|
60
58
|
|
|
61
59
|
## Explicit post-apply tools
|
|
62
60
|
|
|
@@ -69,19 +67,19 @@ For object-level updates, the safe partial syntax is `patch_*` with the object's
|
|
|
69
67
|
- Create a brand new package, then create one app in it:
|
|
70
68
|
`package_apply(create_if_missing=true) -> app_schema_apply`
|
|
71
69
|
- Create a brand new multi-app system/package:
|
|
72
|
-
`package_apply(create_if_missing=true) -> app_schema_apply(apps[])`
|
|
70
|
+
`package_apply(create_if_missing=true) -> app_schema_apply(apps[] with form.sections)`
|
|
73
71
|
- Update fields on an existing app:
|
|
74
72
|
`app_resolve -> app_get_fields -> app_schema_apply`
|
|
75
73
|
- Tidy layout:
|
|
76
|
-
`app_get_fields -> app_get_layout ->
|
|
74
|
+
`app_get_fields -> app_get_layout -> app_layout_apply`
|
|
77
75
|
- Add workflow:
|
|
78
76
|
`builder_tool_contract -> app_get_fields -> app_get_flow -> role_search/member_search -> app_flow_apply -> app_get_flow`
|
|
79
77
|
- Update specific flow nodes:
|
|
80
78
|
`app_get_flow -> app_flow_apply.patch_nodes[]`
|
|
81
79
|
- Add views:
|
|
82
|
-
`
|
|
83
|
-
- Add QingBI charts:
|
|
84
|
-
`
|
|
80
|
+
`app_get_fields -> app_get_views -> app_views_apply.views[] -> app_get_views`
|
|
81
|
+
- Add QingBI charts for a portal:
|
|
82
|
+
`portal_apply.sections[].chart`
|
|
85
83
|
- Show an existing QingBI chart inside a Qingflow app/view:
|
|
86
84
|
`app_get -> app_associated_resources_apply.upsert_resources/patch_resources + view_configs -> app_get`
|
|
87
85
|
- Create or update a portal:
|
|
@@ -72,6 +72,9 @@ class PublicExternalVisibilityMode(str, Enum):
|
|
|
72
72
|
|
|
73
73
|
FIELD_TYPE_ALIASES: dict[str, PublicFieldType] = {
|
|
74
74
|
"textarea": PublicFieldType.long_text,
|
|
75
|
+
"multiline": PublicFieldType.long_text,
|
|
76
|
+
"multi_line": PublicFieldType.long_text,
|
|
77
|
+
"multiline_text": PublicFieldType.long_text,
|
|
75
78
|
"amount": PublicFieldType.amount,
|
|
76
79
|
"currency": PublicFieldType.amount,
|
|
77
80
|
"mobile": PublicFieldType.phone,
|
|
@@ -1456,6 +1459,9 @@ class CustomButtonViewButtonBindingPatch(StrictModel):
|
|
|
1456
1459
|
raw_limits = payload.get("button_limit", payload.get("buttonLimit", payload.get("visible_when", payload.get("visibleWhen"))))
|
|
1457
1460
|
if isinstance(raw_limits, list) and raw_limits and all(isinstance(item, dict) for item in raw_limits):
|
|
1458
1461
|
payload["button_limit"] = [raw_limits]
|
|
1462
|
+
for alias in ("buttonLimit", "visible_when", "visibleWhen"):
|
|
1463
|
+
if alias in payload and alias != "button_limit":
|
|
1464
|
+
payload.pop(alias, None)
|
|
1459
1465
|
return payload
|
|
1460
1466
|
|
|
1461
1467
|
|
|
@@ -1642,7 +1648,7 @@ class ViewButtonBindingPatch(StrictModel):
|
|
|
1642
1648
|
being_main: bool = Field(default=False, validation_alias=AliasChoices("being_main", "beingMain"))
|
|
1643
1649
|
button_limit: list[list[ViewFilterRulePatch]] = Field(
|
|
1644
1650
|
default_factory=list,
|
|
1645
|
-
validation_alias=AliasChoices("button_limit", "buttonLimit"),
|
|
1651
|
+
validation_alias=AliasChoices("button_limit", "buttonLimit", "visible_when", "visibleWhen"),
|
|
1646
1652
|
)
|
|
1647
1653
|
button_formula: str | None = Field(default=None, validation_alias=AliasChoices("button_formula", "buttonFormula"))
|
|
1648
1654
|
button_formula_type: int = Field(default=1, validation_alias=AliasChoices("button_formula_type", "buttonFormulaType"))
|
|
@@ -1671,9 +1677,12 @@ class ViewButtonBindingPatch(StrictModel):
|
|
|
1671
1677
|
payload["config_type"] = "DETAIL"
|
|
1672
1678
|
elif normalized_config in {"inside", "list", "row", "row_action"}:
|
|
1673
1679
|
payload["config_type"] = "INSIDE"
|
|
1674
|
-
raw_limits = payload.get("button_limit", payload.get("buttonLimit"))
|
|
1680
|
+
raw_limits = payload.get("button_limit", payload.get("buttonLimit", payload.get("visible_when", payload.get("visibleWhen"))))
|
|
1675
1681
|
if isinstance(raw_limits, list) and raw_limits and all(isinstance(item, dict) for item in raw_limits):
|
|
1676
1682
|
payload["button_limit"] = [raw_limits]
|
|
1683
|
+
for alias in ("buttonLimit", "visible_when", "visibleWhen"):
|
|
1684
|
+
if alias in payload and alias != "button_limit":
|
|
1685
|
+
payload.pop(alias, None)
|
|
1677
1686
|
return payload
|
|
1678
1687
|
|
|
1679
1688
|
@model_validator(mode="after")
|
|
@@ -1763,68 +1772,63 @@ class ChartFilterRulePatch(StrictModel):
|
|
|
1763
1772
|
return self
|
|
1764
1773
|
|
|
1765
1774
|
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
if value is None:
|
|
1771
|
-
return []
|
|
1772
|
-
if isinstance(value, (list, tuple)):
|
|
1773
|
-
return [str(item).strip() for item in value if item is not None and str(item).strip()]
|
|
1774
|
-
raw = str(value).strip()
|
|
1775
|
-
return [raw] if raw else []
|
|
1775
|
+
class ChartMetricPatch(StrictModel):
|
|
1776
|
+
op: str = "count"
|
|
1777
|
+
field_name: str | None = Field(default=None, validation_alias=AliasChoices("field", "field_name", "fieldName", "name"))
|
|
1778
|
+
alias: str | None = None
|
|
1776
1779
|
|
|
1780
|
+
@model_validator(mode="before")
|
|
1781
|
+
@classmethod
|
|
1782
|
+
def normalize_metric(cls, value: Any) -> Any:
|
|
1783
|
+
if isinstance(value, str):
|
|
1784
|
+
raw = value.strip()
|
|
1785
|
+
match = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*(.*?)\s*\)", raw)
|
|
1786
|
+
if match:
|
|
1787
|
+
op = match.group(1).strip().lower()
|
|
1788
|
+
field = match.group(2).strip()
|
|
1789
|
+
payload: dict[str, Any] = {"op": op}
|
|
1790
|
+
if field and field != "*":
|
|
1791
|
+
payload["field_name"] = field
|
|
1792
|
+
return payload
|
|
1793
|
+
if raw:
|
|
1794
|
+
return {"op": "sum", "field_name": raw}
|
|
1795
|
+
return {"op": "count"}
|
|
1796
|
+
if not isinstance(value, dict):
|
|
1797
|
+
return value
|
|
1798
|
+
payload = dict(value)
|
|
1799
|
+
if "operation" in payload and "op" not in payload:
|
|
1800
|
+
payload["op"] = payload.pop("operation")
|
|
1801
|
+
if "agg" in payload and "op" not in payload:
|
|
1802
|
+
payload["op"] = payload.pop("agg")
|
|
1803
|
+
if "aggregate" in payload and "op" not in payload:
|
|
1804
|
+
payload["op"] = payload.pop("aggregate")
|
|
1805
|
+
if "aggregation" in payload and "op" not in payload:
|
|
1806
|
+
payload["op"] = payload.pop("aggregation")
|
|
1807
|
+
return payload
|
|
1777
1808
|
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
def _apply_semantic_chart_metric_aliases(payload: dict[str, Any]) -> None:
|
|
1802
|
-
raw_metrics: list[Any] = []
|
|
1803
|
-
if "metric" in payload:
|
|
1804
|
-
raw_metrics.append(payload.pop("metric"))
|
|
1805
|
-
if "metrics" in payload:
|
|
1806
|
-
metrics = payload.pop("metrics")
|
|
1807
|
-
raw_metrics.extend(metrics if isinstance(metrics, list) else [metrics])
|
|
1808
|
-
if not raw_metrics:
|
|
1809
|
-
return
|
|
1810
|
-
selectors: list[str] = []
|
|
1811
|
-
aggregates: list[str] = []
|
|
1812
|
-
for metric in raw_metrics:
|
|
1813
|
-
metric_selectors, aggregate = _parse_chart_metric_item(metric)
|
|
1814
|
-
selectors.extend(metric_selectors)
|
|
1815
|
-
if aggregate:
|
|
1816
|
-
aggregates.append(aggregate)
|
|
1817
|
-
if selectors and "indicator_field_ids" not in payload:
|
|
1818
|
-
payload["indicator_field_ids"] = selectors
|
|
1819
|
-
if aggregates:
|
|
1820
|
-
normalized_aggregate = aggregates[0]
|
|
1821
|
-
config = payload.get("config")
|
|
1822
|
-
if not isinstance(config, dict):
|
|
1823
|
-
config = {}
|
|
1824
|
-
else:
|
|
1825
|
-
config = dict(config)
|
|
1826
|
-
config.setdefault("aggregate", normalized_aggregate)
|
|
1827
|
-
payload["config"] = config
|
|
1809
|
+
@model_validator(mode="after")
|
|
1810
|
+
def validate_metric(self) -> "ChartMetricPatch":
|
|
1811
|
+
normalized_op = str(self.op or "count").strip().lower()
|
|
1812
|
+
op_aliases = {
|
|
1813
|
+
"average": "avg",
|
|
1814
|
+
"mean": "avg",
|
|
1815
|
+
"total": "sum",
|
|
1816
|
+
"cnt": "count",
|
|
1817
|
+
"count_all": "count",
|
|
1818
|
+
}
|
|
1819
|
+
self.op = op_aliases.get(normalized_op, normalized_op)
|
|
1820
|
+
if self.field_name is not None:
|
|
1821
|
+
field_name = str(self.field_name).strip()
|
|
1822
|
+
self.field_name = field_name or None
|
|
1823
|
+
if self.alias is not None:
|
|
1824
|
+
alias = str(self.alias).strip()
|
|
1825
|
+
self.alias = alias or None
|
|
1826
|
+
supported = {"count", "sum", "avg", "max", "min"}
|
|
1827
|
+
if self.op not in supported:
|
|
1828
|
+
raise ValueError(f"chart metric op must be one of {sorted(supported)}")
|
|
1829
|
+
if self.op != "count" and not self.field_name:
|
|
1830
|
+
raise ValueError(f"chart metric op '{self.op}' requires field")
|
|
1831
|
+
return self
|
|
1828
1832
|
|
|
1829
1833
|
|
|
1830
1834
|
class ChartUpsertPatch(StrictModel):
|
|
@@ -1834,6 +1838,17 @@ class ChartUpsertPatch(StrictModel):
|
|
|
1834
1838
|
dimension_field_ids: list[str] = Field(default_factory=list)
|
|
1835
1839
|
indicator_field_ids: list[str] = Field(default_factory=list)
|
|
1836
1840
|
filters: list[ChartFilterRulePatch] = Field(default_factory=list)
|
|
1841
|
+
group_by: list[str] = Field(default_factory=list)
|
|
1842
|
+
rows: list[str] = Field(default_factory=list)
|
|
1843
|
+
columns: list[str] = Field(default_factory=list)
|
|
1844
|
+
metric: ChartMetricPatch | None = None
|
|
1845
|
+
metrics: list[ChartMetricPatch] = Field(default_factory=list)
|
|
1846
|
+
x_metric: ChartMetricPatch | None = None
|
|
1847
|
+
y_metric: ChartMetricPatch | None = None
|
|
1848
|
+
left_metric: ChartMetricPatch | None = None
|
|
1849
|
+
right_metric: ChartMetricPatch | None = None
|
|
1850
|
+
value_metric: ChartMetricPatch | None = None
|
|
1851
|
+
target_metric: ChartMetricPatch | None = None
|
|
1837
1852
|
question_config: list[dict[str, Any]] = Field(default_factory=list)
|
|
1838
1853
|
user_config: list[dict[str, Any]] = Field(default_factory=list)
|
|
1839
1854
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -1851,19 +1866,74 @@ class ChartUpsertPatch(StrictModel):
|
|
|
1851
1866
|
payload["chart_type"] = payload.pop("type")
|
|
1852
1867
|
if "dimension_fields" in payload and "dimension_field_ids" not in payload:
|
|
1853
1868
|
payload["dimension_field_ids"] = payload.pop("dimension_fields")
|
|
1869
|
+
if "dimensions" in payload and "dimension_field_ids" not in payload and "group_by" not in payload:
|
|
1870
|
+
payload["group_by"] = payload.pop("dimensions")
|
|
1871
|
+
if "groupBy" in payload and "group_by" not in payload:
|
|
1872
|
+
payload["group_by"] = payload.pop("groupBy")
|
|
1873
|
+
if "where" in payload and "filters" not in payload:
|
|
1874
|
+
payload["filters"] = payload.pop("where")
|
|
1875
|
+
if "filter_rules" in payload and "filters" not in payload:
|
|
1876
|
+
payload["filters"] = payload.pop("filter_rules")
|
|
1877
|
+
if "filterRules" in payload and "filters" not in payload:
|
|
1878
|
+
payload["filters"] = payload.pop("filterRules")
|
|
1854
1879
|
if "indicator_fields" in payload and "indicator_field_ids" not in payload:
|
|
1855
1880
|
payload["indicator_field_ids"] = payload.pop("indicator_fields")
|
|
1856
1881
|
if "metric_field_ids" in payload and "indicator_field_ids" not in payload:
|
|
1857
1882
|
payload["indicator_field_ids"] = payload.pop("metric_field_ids")
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1883
|
+
metric_slots: list[Any] = []
|
|
1884
|
+
generic_metric_keys = ("metric", "metrics")
|
|
1885
|
+
axis_metric_keys = (
|
|
1886
|
+
"x_metric",
|
|
1887
|
+
"xMetric",
|
|
1888
|
+
"y_metric",
|
|
1889
|
+
"yMetric",
|
|
1890
|
+
"left_metric",
|
|
1891
|
+
"leftMetric",
|
|
1892
|
+
"right_metric",
|
|
1893
|
+
"rightMetric",
|
|
1894
|
+
"value_metric",
|
|
1895
|
+
"valueMetric",
|
|
1896
|
+
"target_metric",
|
|
1897
|
+
"targetMetric",
|
|
1898
|
+
)
|
|
1899
|
+
|
|
1900
|
+
def has_metric_value(key: str) -> bool:
|
|
1901
|
+
entry = payload.get(key)
|
|
1902
|
+
return entry is not None and entry != "" and entry != []
|
|
1903
|
+
|
|
1904
|
+
if any(has_metric_value(key) for key in generic_metric_keys) and any(has_metric_value(key) for key in axis_metric_keys):
|
|
1905
|
+
raise ValueError(
|
|
1906
|
+
"chart metric input is ambiguous: use either metric/metrics or axis-specific "
|
|
1907
|
+
"x_metric/y_metric, left_metric/right_metric, value_metric/target_metric, not both"
|
|
1908
|
+
)
|
|
1909
|
+
if "metric" in payload and "metrics" not in payload:
|
|
1910
|
+
payload["metrics"] = [payload["metric"]]
|
|
1911
|
+
for key in ("x_metric", "xMetric", "left_metric", "leftMetric", "value_metric", "valueMetric"):
|
|
1912
|
+
if key in payload:
|
|
1913
|
+
slot_value = payload.get(key)
|
|
1914
|
+
if slot_value is not None:
|
|
1915
|
+
metric_slots.append(slot_value)
|
|
1916
|
+
canonical = {
|
|
1917
|
+
"xMetric": "x_metric",
|
|
1918
|
+
"leftMetric": "left_metric",
|
|
1919
|
+
"valueMetric": "value_metric",
|
|
1920
|
+
}.get(key)
|
|
1921
|
+
if canonical and canonical not in payload:
|
|
1922
|
+
payload[canonical] = payload.pop(key)
|
|
1923
|
+
for key in ("y_metric", "yMetric", "right_metric", "rightMetric", "target_metric", "targetMetric"):
|
|
1924
|
+
if key in payload:
|
|
1925
|
+
slot_value = payload.get(key)
|
|
1926
|
+
if slot_value is not None:
|
|
1927
|
+
metric_slots.append(slot_value)
|
|
1928
|
+
canonical = {
|
|
1929
|
+
"yMetric": "y_metric",
|
|
1930
|
+
"rightMetric": "right_metric",
|
|
1931
|
+
"targetMetric": "target_metric",
|
|
1932
|
+
}.get(key)
|
|
1933
|
+
if canonical and canonical not in payload:
|
|
1934
|
+
payload[canonical] = payload.pop(key)
|
|
1935
|
+
if metric_slots and "metrics" not in payload:
|
|
1936
|
+
payload["metrics"] = metric_slots
|
|
1867
1937
|
raw_type = payload.get("chart_type")
|
|
1868
1938
|
if isinstance(raw_type, str):
|
|
1869
1939
|
normalized = raw_type.strip().lower()
|
|
@@ -1890,8 +1960,31 @@ class ChartUpsertPatch(StrictModel):
|
|
|
1890
1960
|
payload["dimension_field_ids"] = [str(item) for item in payload["dimension_field_ids"] if item is not None and str(item).strip()]
|
|
1891
1961
|
if isinstance(payload.get("indicator_field_ids"), list):
|
|
1892
1962
|
payload["indicator_field_ids"] = [str(item) for item in payload["indicator_field_ids"] if item is not None and str(item).strip()]
|
|
1963
|
+
for key in ("group_by", "rows", "columns"):
|
|
1964
|
+
if isinstance(payload.get(key), str):
|
|
1965
|
+
payload[key] = [payload[key]]
|
|
1966
|
+
if isinstance(payload.get(key), list):
|
|
1967
|
+
payload[key] = [str(item) for item in payload[key] if item is not None and str(item).strip()]
|
|
1893
1968
|
return payload
|
|
1894
1969
|
|
|
1970
|
+
@model_validator(mode="after")
|
|
1971
|
+
def apply_semantic_chart_fields(self) -> "ChartUpsertPatch":
|
|
1972
|
+
if self.group_by and not self.dimension_field_ids:
|
|
1973
|
+
self.dimension_field_ids = list(self.group_by)
|
|
1974
|
+
if self.rows and not self.dimension_field_ids:
|
|
1975
|
+
self.dimension_field_ids = list(self.rows)
|
|
1976
|
+
semantic_metrics: list[ChartMetricPatch] = []
|
|
1977
|
+
if self.metrics:
|
|
1978
|
+
semantic_metrics.extend(self.metrics)
|
|
1979
|
+
for metric in (self.x_metric, self.y_metric, self.left_metric, self.right_metric, self.value_metric, self.target_metric):
|
|
1980
|
+
if metric is not None and metric not in semantic_metrics:
|
|
1981
|
+
semantic_metrics.append(metric)
|
|
1982
|
+
if semantic_metrics and not self.metrics:
|
|
1983
|
+
self.metrics = semantic_metrics
|
|
1984
|
+
if self.metrics and not self.metric:
|
|
1985
|
+
self.metric = self.metrics[0]
|
|
1986
|
+
return self
|
|
1987
|
+
|
|
1895
1988
|
|
|
1896
1989
|
class ChartPartialPatch(StrictModel):
|
|
1897
1990
|
chart_id: str | None = None
|
|
@@ -2287,6 +2380,9 @@ class ChartGetResponse(StrictModel):
|
|
|
2287
2380
|
chart_id: str
|
|
2288
2381
|
base: dict[str, Any] = Field(default_factory=dict)
|
|
2289
2382
|
visibility: dict[str, Any] = Field(default_factory=dict)
|
|
2383
|
+
filters: list[list[dict[str, Any]]] = Field(default_factory=list)
|
|
2384
|
+
group_by: list[str] = Field(default_factory=list)
|
|
2385
|
+
metrics: list[dict[str, Any]] = Field(default_factory=list)
|
|
2290
2386
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
2291
2387
|
|
|
2292
2388
|
|