@josephyan/qingflow-app-user-mcp 1.1.26 → 1.1.27
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 -6
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-app-user/SKILL.md +5 -3
- package/skills/qingflow-app-user/references/public-surface-sync.md +4 -2
- package/src/qingflow_mcp/builder_facade/models.py +167 -71
- package/src/qingflow_mcp/builder_facade/service.py +612 -152
- 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
- package/skills/qingflow-app-builder/SKILL.md +0 -277
- package/skills/qingflow-app-builder/agents/openai.yaml +0 -4
- package/skills/qingflow-app-builder/references/build-complete-system.md +0 -428
- package/skills/qingflow-app-builder/references/build-single-app.md +0 -530
- package/skills/qingflow-app-builder/references/create-app.md +0 -149
- package/skills/qingflow-app-builder/references/environments.md +0 -63
- package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +0 -123
- package/skills/qingflow-app-builder/references/gotchas.md +0 -108
- package/skills/qingflow-app-builder/references/match-rules.md +0 -114
- package/skills/qingflow-app-builder/references/public-surface-sync.md +0 -75
- package/skills/qingflow-app-builder/references/solution-playbooks.md +0 -52
- package/skills/qingflow-app-builder/references/tool-selection.md +0 -107
- package/skills/qingflow-app-builder/references/update-flow.md +0 -186
- package/skills/qingflow-app-builder/references/update-layout.md +0 -68
- package/skills/qingflow-app-builder/references/update-schema.md +0 -72
- package/skills/qingflow-app-builder/references/update-views.md +0 -291
- package/skills/qingflow-app-builder-code-integrations/SKILL.md +0 -137
- package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +0 -4
- package/skills/qingflow-app-builder-code-integrations/references/code-block.md +0 -66
- package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +0 -77
- package/skills/qingflow-workflow-builder/SKILL.md +0 -96
- package/skills/qingflow-workflow-builder/manifest.yaml +0 -8
- package/skills/qingflow-workflow-builder/references/01-overview.md +0 -45
- package/skills/qingflow-workflow-builder/references/02-update-mode.md +0 -53
- package/skills/qingflow-workflow-builder/references/03-flow-patterns.md +0 -57
- package/skills/qingflow-workflow-builder/references/04-stage1-business-modeling.md +0 -131
- package/skills/qingflow-workflow-builder/references/05-stage2-members-roles.md +0 -29
- package/skills/qingflow-workflow-builder/references/06-stage3-build-spec.md +0 -165
- package/skills/qingflow-workflow-builder/references/07-stage4-validate-spec.md +0 -33
- package/skills/qingflow-workflow-builder/references/08-stage5-apply-verify.md +0 -51
- package/skills/qingflow-workflow-builder/references/09-stage6-summary.md +0 -88
- package/skills/qingflow-workflow-builder/references/10-node-config-reference.md +0 -93
- package/skills/qingflow-workflow-builder/references/11-troubleshooting.md +0 -15
- package/skills/qingflow-workflow-builder/scripts/diff_flow_spec.py +0 -275
- package/skills/qingflow-workflow-builder/scripts/validate_flow_spec.py +0 -605
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @josephyan/qingflow-app-user-mcp@1.1.
|
|
6
|
+
npm install @josephyan/qingflow-app-user-mcp@1.1.27
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-app-user-mcp@1.1.
|
|
12
|
+
npx -y -p @josephyan/qingflow-app-user-mcp@1.1.27 qingflow-app-user-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
|
@@ -30,12 +30,10 @@ Bundled skills:
|
|
|
30
30
|
- `skills/qingflow-task-ops`
|
|
31
31
|
- `skills/qingflow-record-analysis`
|
|
32
32
|
- `skills/qingflow-mcp-setup`
|
|
33
|
-
- `skills/qingflow-app-builder`
|
|
34
|
-
- `skills/qingflow-app-builder-code-integrations`
|
|
35
|
-
- `skills/qingflow-workflow-builder`
|
|
36
33
|
|
|
37
34
|
Note:
|
|
38
35
|
|
|
39
36
|
- The skill files are included in the npm package.
|
|
40
|
-
- On install, the package
|
|
37
|
+
- On install, the package installs bundled skills into `$CODEX_HOME/skills` (or `~/.codex/skills` if `CODEX_HOME` is unset) by symlink and refreshes them on package updates.
|
|
38
|
+
- You can also run `qingflow-app-user-mcp skills list` or `qingflow-app-user-mcp skills install --agent codex|claude-code|cursor|generic --scope user|project [--copy] [--force]`.
|
|
41
39
|
- If a stdio MCP client reports `Transport closed`, delete `.npm-python`, reinstall the package, and make sure CLI/user/builder packages are on the same version. The stdio entrypoints refuse runtime bootstrap so install logs never corrupt MCP stdout.
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -7,6 +7,8 @@ metadata:
|
|
|
7
7
|
|
|
8
8
|
# Qingflow App User
|
|
9
9
|
|
|
10
|
+
> **Skill 版本**:`qingflow-skills-2026.06.30.1`
|
|
11
|
+
|
|
10
12
|
## Overview
|
|
11
13
|
|
|
12
14
|
This skill is a lightweight router for operational Qingflow work.
|
|
@@ -39,7 +41,7 @@ Route to exactly one of these specialized paths:
|
|
|
39
41
|
Switch to [$qingflow-mcp-setup](../qingflow-mcp-setup/SKILL.md)
|
|
40
42
|
|
|
41
43
|
8. App / view / workflow / chart / portal / package configuration
|
|
42
|
-
|
|
44
|
+
This is builder work, not app-user work. Use the `qingflow-cli` skill or install/use the `qingflow-app-builder` package.
|
|
43
45
|
|
|
44
46
|
## Routing Rules
|
|
45
47
|
|
|
@@ -50,7 +52,7 @@ Route to exactly one of these specialized paths:
|
|
|
50
52
|
- If the task is about deleting records directly, switch to `$qingflow-record-delete`
|
|
51
53
|
- If the task is about import templates, import capability discovery, import-file verification, authorized local file repair, import execution, or import status, switch to `$qingflow-record-import`
|
|
52
54
|
- If the task is about todo discovery, task context, approval actions, rollback or transfer, associated report review, or workflow log review, switch to `$qingflow-task-ops`
|
|
53
|
-
- If the task is about package, app, field, layout, workflow, view, chart, portal, visibility, icon, or app base configuration,
|
|
55
|
+
- If the task is about package, app, field, layout, workflow, view, chart, portal, visibility, icon, or app base configuration, stop this app-user route and use the `qingflow-cli` skill or the `qingflow-app-builder` package
|
|
54
56
|
- If the task involves member, department, or relation fields and the user only has natural names/titles, keep the same route; direct write now supports backend-native auto resolution and may return `needs_confirmation` with candidates instead of failing blind
|
|
55
57
|
- If the task involves linked visibility, upstream/downstream field dependencies, reference-driven auto fill, or formula-driven defaulting, keep the same insert/update route and read field-level `linkage` from the schema before composing payloads
|
|
56
58
|
- If the task is about subtable writes, still route to the matching insert/update skill, but shape the payload as parent subtable field -> row array; do not route users toward top-level leaf selectors
|
|
@@ -86,4 +88,4 @@ Route to exactly one of these specialized paths:
|
|
|
86
88
|
- Record import: [$qingflow-record-import](../qingflow-record-import/SKILL.md)
|
|
87
89
|
- Task workflow operations: [$qingflow-task-ops](../qingflow-task-ops/SKILL.md)
|
|
88
90
|
- Dedicated analysis workflow: [$qingflow-record-analysis](../qingflow-record-analysis/SKILL.md)
|
|
89
|
-
- Builder workflow:
|
|
91
|
+
- Builder workflow: use the `qingflow-cli` skill or the `qingflow-app-builder` package
|
|
@@ -33,20 +33,22 @@ It is not a user-facing product spec. It exists to prevent skill drift.
|
|
|
33
33
|
|
|
34
34
|
### Builder
|
|
35
35
|
|
|
36
|
+
- Builder work belongs to the `qingflow-cli` skill or the `qingflow-app-builder` package; this app-user package only records the boundary to prevent routing drift.
|
|
36
37
|
- Official package entry: `package_get`, `package_apply`
|
|
37
38
|
- Official builder writes:
|
|
38
39
|
- `app_schema_apply`
|
|
39
40
|
- `app_layout_apply`
|
|
40
41
|
- `app_flow_apply`
|
|
41
42
|
- `app_views_apply`
|
|
42
|
-
- `app_custom_buttons_apply`
|
|
43
43
|
- `app_associated_resources_apply`
|
|
44
44
|
- `app_charts_apply`
|
|
45
45
|
- `portal_apply`
|
|
46
46
|
- `app_publish_verify`
|
|
47
|
+
- Complete-system main path: package -> schema apps with `form.sections` and `target_app_ref` -> views with `action_buttons` -> portal with inline `sections[].chart`
|
|
48
|
+
- Single-app main path: resolve/get -> schema with `form.sections` -> views with `action_buttons`; do not default to portal
|
|
47
49
|
- `portal_apply` edit mode may omit `sections` for base-info-only updates
|
|
48
50
|
- `app_charts_apply.visibility` is a public capability and should be treated as a base-only visibility update
|
|
49
|
-
- Existing object parameter replacement should use `patch_views`, `
|
|
51
|
+
- Existing object parameter replacement should use `patch_views`, `patch_resources`, and `patch_charts`; `upsert_*` is for creation or full target config
|
|
50
52
|
- `app_get.editability` uses:
|
|
51
53
|
- `can_edit_app_base`
|
|
52
54
|
- `can_edit_form`
|
|
@@ -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
|
|