mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b6ee2522c | ||
|
|
44a8d9d469 | ||
|
|
d350792d43 | ||
|
|
c9136af8b6 | ||
|
|
f0838353a5 | ||
|
|
16b56873b0 | ||
|
|
62b4ee3d9e | ||
|
|
43c3c06035 | ||
|
|
40de44f6de | ||
|
|
b8ea4448d6 | ||
|
|
8d8eeb3505 | ||
|
|
a69bbcb044 | ||
|
|
f614863ed7 | ||
|
|
53e2793bc3 | ||
|
|
e73d2d0bf6 | ||
|
|
ae5823fb9c | ||
|
|
63e3a18e8f | ||
|
|
661ff31c6d | ||
|
|
ead21d9789 | ||
|
|
c3bda6baea | ||
|
|
61cb0aeae7 | ||
|
|
cf9ee99b5a | ||
|
|
e1948c87c6 | ||
|
|
63ae80ab62 | ||
|
|
8f88bbcc79 | ||
|
|
b2320820b4 | ||
|
|
8853ab5c75 | ||
|
|
b0da0cf202 | ||
|
|
96b96ad7d4 | ||
|
|
f7e1f96894 | ||
|
|
ec09cec6bd | ||
|
|
f663f47628 | ||
|
|
8a0026b173 | ||
|
|
f037449b75 | ||
|
|
e68251fa70 | ||
|
|
0dc58d1042 | ||
|
|
c73106b7a2 |
@@ -26,7 +26,7 @@ runs:
|
||||
|
||||
- name: Set up QEMU
|
||||
if: ${{ inputs.build == 'true' }}
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
with:
|
||||
# Pin the binfmt image to a specific QEMU release. The default
|
||||
# (`tonistiigi/binfmt:latest`) is a moving target, and drift across
|
||||
@@ -39,12 +39,12 @@ runs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: ${{ inputs.build == 'true' }}
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Try to login to DockerHub
|
||||
if: ${{ inputs.login-to-dockerhub == 'true' }}
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ inputs.dockerhub-user }}
|
||||
password: ${{ inputs.dockerhub-token }}
|
||||
|
||||
@@ -10,7 +10,7 @@ runs:
|
||||
steps:
|
||||
|
||||
- name: Setup Node Env
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -21,7 +21,7 @@ runs:
|
||||
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
if: ${{ inputs.from-npm == 'false' }}
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: apache-superset/supersetbot
|
||||
path: supersetbot
|
||||
|
||||
@@ -95,7 +95,11 @@ jobs:
|
||||
# in the context of push (using multi-platform build), we need to pull the image locally
|
||||
- name: Docker pull
|
||||
if: github.event_name == 'push' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker)
|
||||
run: docker pull $IMAGE_TAG
|
||||
run: |
|
||||
for i in 1 2 3; do
|
||||
docker pull $IMAGE_TAG && break
|
||||
[ $i -lt 3 ] && sleep 30
|
||||
done
|
||||
|
||||
- name: Print docker stats
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
|
||||
|
||||
@@ -6,7 +6,8 @@ on:
|
||||
- "master"
|
||||
- "[0-9].[0-9]*"
|
||||
pull_request:
|
||||
types: [synchronize, opened, reopened, ready_for_review]
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -17,10 +18,12 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -29,3 +32,6 @@ jobs:
|
||||
|
||||
- name: Run Script
|
||||
run: bash .github/workflows/github-action-validator.sh
|
||||
|
||||
- name: Check for security issues on GHA workflows
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
|
||||
@@ -45,7 +45,13 @@ jobs:
|
||||
SUPERSET_SITE_BUILD: ${{ (secrets.SUPERSET_SITE_BUILD != '' && secrets.SUPERSET_SITE_BUILD != '') || '' }}
|
||||
build-deploy:
|
||||
needs: config
|
||||
if: needs.config.outputs.has-secrets
|
||||
# For workflow_run triggers, only deploy when the triggering run originated
|
||||
# from this repository (not a fork), ensuring the checked-out code and any
|
||||
# local actions executed with deploy credentials are trusted.
|
||||
if: >-
|
||||
needs.config.outputs.has-secrets &&
|
||||
(github.event_name != 'workflow_run' ||
|
||||
github.event.workflow_run.head_repository.full_name == github.repository)
|
||||
name: Build & Deploy
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
|
||||
@@ -97,7 +97,8 @@ jobs:
|
||||
# Only runs if integration tests succeeded
|
||||
if: >
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
name: Build (after integration tests)
|
||||
runs-on: ubuntu-24.04
|
||||
defaults:
|
||||
|
||||
@@ -13,10 +13,15 @@ jobs:
|
||||
steps:
|
||||
- name: Welcome Message
|
||||
uses: actions/first-interaction@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
repo-token: ${{ github.token }}
|
||||
pr-message: |-
|
||||
repo_token: ${{ github.token }}
|
||||
issue_message: |-
|
||||
Congrats on opening your first issue and thank you for contributing to Superset! :tada: :heart:
|
||||
|
||||
Please read our [New Contributor Welcome & Expectations](https://github.com/apache/superset/wiki/New-Contributor-Welcome-&-Expectations) guide.
|
||||
pr_message: |-
|
||||
Congrats on making your first PR and thank you for contributing to Superset! :tada: :heart:
|
||||
|
||||
Please read our [New Contributor Welcome & Expectations](https://github.com/apache/superset/wiki/New-Contributor-Welcome-&-Expectations) guide.
|
||||
|
||||
We hope to see you in our [Slack](https://apache-superset.slack.com/) community too! Not signed up? Use our [Slack App](http://bit.ly/join-superset-slack) to self-register.
|
||||
|
||||
@@ -158,3 +158,14 @@ repos:
|
||||
language: system
|
||||
files: ^superset/config\.py$
|
||||
pass_filenames: false
|
||||
- id: zizmor
|
||||
name: zizmor (GHA security audit)
|
||||
entry: zizmor
|
||||
language: python
|
||||
additional_dependencies: [zizmor==1.25.2]
|
||||
files: ^\.github/
|
||||
types: [yaml]
|
||||
pass_filenames: false
|
||||
# Advisory until pre-existing findings are resolved; remove
|
||||
# --no-exit-codes to make this hook blocking.
|
||||
args: [--no-exit-codes, .github/]
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ RUN useradd --user-group -d ${SUPERSET_HOME} -m --no-log-init --shell /bin/bash
|
||||
# Some bash scripts needed throughout the layers
|
||||
COPY --chmod=755 docker/*.sh /app/docker/
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
# Using uv as it's faster/simpler than pip
|
||||
RUN uv venv /app/.venv
|
||||
|
||||
+6
-3
@@ -70,9 +70,9 @@
|
||||
"@storybook/preview-api": "^8.6.18",
|
||||
"@storybook/theming": "^8.6.15",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@swc/core": "^1.15.40",
|
||||
"antd": "^6.4.3",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"caniuse-lite": "^1.0.30001793",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
@@ -128,7 +128,10 @@
|
||||
"react-redux": "^9.2.0",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"swagger-client": "3.37.3"
|
||||
"swagger-client": "3.37.3",
|
||||
"lodash": "4.18.1",
|
||||
"lodash-es": "4.18.1",
|
||||
"yaml": "1.10.3"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
|
||||
}
|
||||
|
||||
+74
-84
@@ -4033,86 +4033,86 @@
|
||||
dependencies:
|
||||
apg-lite "^1.0.4"
|
||||
|
||||
"@swc/core-darwin-arm64@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz#d84134fb80417d41128739f0b9014542e3ed9dd3"
|
||||
integrity sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==
|
||||
"@swc/core-darwin-arm64@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz#b05d715b04c4fd47baf59288233da85a683cc0bc"
|
||||
integrity sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==
|
||||
|
||||
"@swc/core-darwin-x64@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz#0badb9834071f1c6005986571d4a96359c1d7cd0"
|
||||
integrity sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==
|
||||
"@swc/core-darwin-x64@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz#3180daef5c1e47b435f8edd084509e0a5c0d883b"
|
||||
integrity sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==
|
||||
|
||||
"@swc/core-linux-arm-gnueabihf@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz#b7577a825b59d98b6a9a5c991d842046efe1c34a"
|
||||
integrity sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==
|
||||
"@swc/core-linux-arm-gnueabihf@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz#18fcd3c70e48fdfae07c9f18751b1409ce1e5e84"
|
||||
integrity sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==
|
||||
|
||||
"@swc/core-linux-arm64-gnu@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz#304c48321494a18c67b2913c273b08674ee70d8c"
|
||||
integrity sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==
|
||||
"@swc/core-linux-arm64-gnu@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz#26304933922f2a8e3194770e404403fc25a19c89"
|
||||
integrity sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==
|
||||
|
||||
"@swc/core-linux-arm64-musl@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz#d116cbc04ccb4f4ee810da6bca79d4423605dbcd"
|
||||
integrity sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==
|
||||
"@swc/core-linux-arm64-musl@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz#3402dfba04ba7b8ea81f243e2f8fa2c336b54d03"
|
||||
integrity sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==
|
||||
|
||||
"@swc/core-linux-ppc64-gnu@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz#f5354dba36db9414305bab344c817d57b8b457c2"
|
||||
integrity sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==
|
||||
"@swc/core-linux-ppc64-gnu@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz#b3df9065cad352328c1eeef08a28fc9fe98785aa"
|
||||
integrity sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==
|
||||
|
||||
"@swc/core-linux-s390x-gnu@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz#016df9f4c9d7fd65b85ca9c558c5aec341f06da0"
|
||||
integrity sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==
|
||||
"@swc/core-linux-s390x-gnu@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz#58e5b601f641dde81b30626ef66a668701ec918f"
|
||||
integrity sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==
|
||||
|
||||
"@swc/core-linux-x64-gnu@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz#49f36558ede072e71999aa37f123367daed2a662"
|
||||
integrity sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==
|
||||
"@swc/core-linux-x64-gnu@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz#cf057dce0c148c53f2d30152baaf60ea29e5d59c"
|
||||
integrity sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==
|
||||
|
||||
"@swc/core-linux-x64-musl@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz#b096665f5cfeee2612325f301da5c1590b10d8f3"
|
||||
integrity sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==
|
||||
"@swc/core-linux-x64-musl@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz#21fb1a4d0193e9bbcd1469ecd36166d2e96e4006"
|
||||
integrity sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==
|
||||
|
||||
"@swc/core-win32-arm64-msvc@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz#f3101263a0dbaa173ec47638c9719d0b89838bd2"
|
||||
integrity sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==
|
||||
"@swc/core-win32-arm64-msvc@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz#1dba23b2b0db86b3d6d65da2abd627cc607a1fbc"
|
||||
integrity sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==
|
||||
|
||||
"@swc/core-win32-ia32-msvc@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz#eb981ef5613d42c9220559bdb0c8bc58cf6c3eb9"
|
||||
integrity sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==
|
||||
"@swc/core-win32-ia32-msvc@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz#b2da1e33165d469467b1046a2189db468da488eb"
|
||||
integrity sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==
|
||||
|
||||
"@swc/core-win32-x64-msvc@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz#a2fed9956933027ceb368857bac4bb4ee203d47c"
|
||||
integrity sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==
|
||||
"@swc/core-win32-x64-msvc@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz#3563f7e8ce8708f5fda43eb8e0956ef11e0da320"
|
||||
integrity sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==
|
||||
|
||||
"@swc/core@^1.15.33", "@swc/core@^1.7.39":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.33.tgz#2a6571c8aca961925f14beae52b3f43c18370fc6"
|
||||
integrity sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==
|
||||
"@swc/core@^1.15.40", "@swc/core@^1.7.39":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.40.tgz#941c949aa88c0d8d291f102f519f3c2c77701b90"
|
||||
integrity sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==
|
||||
dependencies:
|
||||
"@swc/counter" "^0.1.3"
|
||||
"@swc/types" "^0.1.26"
|
||||
optionalDependencies:
|
||||
"@swc/core-darwin-arm64" "1.15.33"
|
||||
"@swc/core-darwin-x64" "1.15.33"
|
||||
"@swc/core-linux-arm-gnueabihf" "1.15.33"
|
||||
"@swc/core-linux-arm64-gnu" "1.15.33"
|
||||
"@swc/core-linux-arm64-musl" "1.15.33"
|
||||
"@swc/core-linux-ppc64-gnu" "1.15.33"
|
||||
"@swc/core-linux-s390x-gnu" "1.15.33"
|
||||
"@swc/core-linux-x64-gnu" "1.15.33"
|
||||
"@swc/core-linux-x64-musl" "1.15.33"
|
||||
"@swc/core-win32-arm64-msvc" "1.15.33"
|
||||
"@swc/core-win32-ia32-msvc" "1.15.33"
|
||||
"@swc/core-win32-x64-msvc" "1.15.33"
|
||||
"@swc/core-darwin-arm64" "1.15.40"
|
||||
"@swc/core-darwin-x64" "1.15.40"
|
||||
"@swc/core-linux-arm-gnueabihf" "1.15.40"
|
||||
"@swc/core-linux-arm64-gnu" "1.15.40"
|
||||
"@swc/core-linux-arm64-musl" "1.15.40"
|
||||
"@swc/core-linux-ppc64-gnu" "1.15.40"
|
||||
"@swc/core-linux-s390x-gnu" "1.15.40"
|
||||
"@swc/core-linux-x64-gnu" "1.15.40"
|
||||
"@swc/core-linux-x64-musl" "1.15.40"
|
||||
"@swc/core-win32-arm64-msvc" "1.15.40"
|
||||
"@swc/core-win32-ia32-msvc" "1.15.40"
|
||||
"@swc/core-win32-x64-msvc" "1.15.40"
|
||||
|
||||
"@swc/counter@^0.1.3":
|
||||
version "0.1.3"
|
||||
@@ -5568,10 +5568,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.31, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.31"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz#9c6825f052601ce6974a90dd49683b1726887b0b"
|
||||
integrity sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==
|
||||
baseline-browser-mapping@^2.10.32, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.32"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz#b6b553a4285fdd606327a617de36a5351e3aaa64"
|
||||
integrity sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -9676,10 +9676,10 @@ locate-path@^7.1.0:
|
||||
dependencies:
|
||||
p-locate "^6.0.0"
|
||||
|
||||
lodash-es@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
|
||||
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
||||
lodash-es@4.18.1, lodash-es@^4.17.21:
|
||||
version "4.18.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
|
||||
integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
|
||||
|
||||
lodash.debounce@^4, lodash.debounce@^4.0.8:
|
||||
version "4.0.8"
|
||||
@@ -9701,12 +9701,7 @@ lodash.uniq@^4.5.0:
|
||||
resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz"
|
||||
integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==
|
||||
|
||||
lodash@4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||
|
||||
lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.18.1:
|
||||
lodash@4.17.21, lodash@4.18.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.18.1:
|
||||
version "4.18.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c"
|
||||
integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==
|
||||
@@ -15209,12 +15204,7 @@ yaml-ast-parser@0.0.43:
|
||||
resolved "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz"
|
||||
integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==
|
||||
|
||||
yaml@1.10.2:
|
||||
version "1.10.2"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
|
||||
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
|
||||
|
||||
yaml@^1.10.0:
|
||||
yaml@1.10.2, yaml@1.10.3, yaml@^1.10.0:
|
||||
version "1.10.3"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3"
|
||||
integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==
|
||||
|
||||
@@ -33,6 +33,7 @@ PATTERNS = {
|
||||
r"^setup\.py",
|
||||
r"^pyproject\.toml$",
|
||||
r"^requirements/.+\.txt",
|
||||
r"^pyproject\.toml",
|
||||
r"^.pylintrc",
|
||||
],
|
||||
"frontend": [
|
||||
@@ -156,7 +157,7 @@ def main(event_type: str, sha: str, repo: str) -> None:
|
||||
|
||||
def get_git_sha() -> str:
|
||||
return os.getenv("GITHUB_SHA") or subprocess.check_output( # noqa: S603
|
||||
["git", "rev-parse", "HEAD"] # noqa: S607
|
||||
["git", "rev-parse", "HEAD"] # noqa: S603, S607
|
||||
).strip().decode("utf-8")
|
||||
|
||||
|
||||
|
||||
+6
-6
@@ -8020,9 +8020,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz",
|
||||
"integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==",
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
@@ -14601,9 +14601,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"tmp": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz",
|
||||
"integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ=="
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="
|
||||
},
|
||||
"to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
|
||||
Generated
+129
-129
@@ -194,7 +194,7 @@
|
||||
"@storybook/test": "^8.6.18",
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
@@ -229,7 +229,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -249,7 +249,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.2",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -296,7 +296,7 @@
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
"webpack-manifest-plugin": "^5.0.1",
|
||||
"webpack-sources": "^3.4.1",
|
||||
"webpack-sources": "^3.5.0",
|
||||
"webpack-visualizer-plugin2": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -7768,9 +7768,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/arborist/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8034,9 +8034,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/map-workspaces/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8194,9 +8194,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/package-json/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8470,9 +8470,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nx/devkit/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -12310,9 +12310,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.33.tgz",
|
||||
"integrity": "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==",
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.40.tgz",
|
||||
"integrity": "sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
@@ -12328,18 +12328,18 @@
|
||||
"url": "https://opencollective.com/swc"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-darwin-arm64": "1.15.33",
|
||||
"@swc/core-darwin-x64": "1.15.33",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.15.33",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.33",
|
||||
"@swc/core-linux-arm64-musl": "1.15.33",
|
||||
"@swc/core-linux-ppc64-gnu": "1.15.33",
|
||||
"@swc/core-linux-s390x-gnu": "1.15.33",
|
||||
"@swc/core-linux-x64-gnu": "1.15.33",
|
||||
"@swc/core-linux-x64-musl": "1.15.33",
|
||||
"@swc/core-win32-arm64-msvc": "1.15.33",
|
||||
"@swc/core-win32-ia32-msvc": "1.15.33",
|
||||
"@swc/core-win32-x64-msvc": "1.15.33"
|
||||
"@swc/core-darwin-arm64": "1.15.40",
|
||||
"@swc/core-darwin-x64": "1.15.40",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.15.40",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.40",
|
||||
"@swc/core-linux-arm64-musl": "1.15.40",
|
||||
"@swc/core-linux-ppc64-gnu": "1.15.40",
|
||||
"@swc/core-linux-s390x-gnu": "1.15.40",
|
||||
"@swc/core-linux-x64-gnu": "1.15.40",
|
||||
"@swc/core-linux-x64-musl": "1.15.40",
|
||||
"@swc/core-win32-arm64-msvc": "1.15.40",
|
||||
"@swc/core-win32-ia32-msvc": "1.15.40",
|
||||
"@swc/core-win32-x64-msvc": "1.15.40"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/helpers": ">=0.5.17"
|
||||
@@ -12351,9 +12351,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-arm64": {
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz",
|
||||
"integrity": "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==",
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz",
|
||||
"integrity": "sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -12367,9 +12367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-x64": {
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz",
|
||||
"integrity": "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==",
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz",
|
||||
"integrity": "sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -12383,9 +12383,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz",
|
||||
"integrity": "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==",
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz",
|
||||
"integrity": "sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -12399,9 +12399,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==",
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz",
|
||||
"integrity": "sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -12415,9 +12415,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-musl": {
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz",
|
||||
"integrity": "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==",
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz",
|
||||
"integrity": "sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -12431,9 +12431,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-ppc64-gnu": {
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==",
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz",
|
||||
"integrity": "sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -12447,9 +12447,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-s390x-gnu": {
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==",
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz",
|
||||
"integrity": "sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -12463,9 +12463,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-gnu": {
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==",
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz",
|
||||
"integrity": "sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -12479,9 +12479,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-musl": {
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz",
|
||||
"integrity": "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==",
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz",
|
||||
"integrity": "sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -12495,9 +12495,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz",
|
||||
"integrity": "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==",
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz",
|
||||
"integrity": "sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -12511,9 +12511,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz",
|
||||
"integrity": "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==",
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz",
|
||||
"integrity": "sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -12527,9 +12527,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-x64-msvc": {
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz",
|
||||
"integrity": "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==",
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz",
|
||||
"integrity": "sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -12775,9 +12775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tufjs/models/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -17209,9 +17209,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.31",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz",
|
||||
"integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==",
|
||||
"version": "2.10.32",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
|
||||
"integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -17402,9 +17402,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -17416,7 +17416,7 @@
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
@@ -17793,9 +17793,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cacache/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -22661,9 +22661,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-react-you-might-not-need-an-effect": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.10.1.tgz",
|
||||
"integrity": "sha512-IK0s/+ShN0bkur5moKCu/lfx2D/9uIeozje8Wv2/XnYdmswa17pDg02aUuytEPb8Gf0eueiQFf/QsvOHHcvujg==",
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.10.2.tgz",
|
||||
"integrity": "sha512-cqm9DXcsISYZHnFXT5zPH+ITsMx/bYscmq6zIsbtYvei1vj4dZ+BxN9LgoMmjEdm7sTaWxKVRY5IqQRQvau/GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -22848,9 +22848,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-testing-library/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -23258,15 +23258,15 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.3",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
@@ -23285,7 +23285,7 @@
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
@@ -26595,9 +26595,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-walk/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -26856,9 +26856,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -35946,9 +35946,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/multimatch/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -36615,9 +36615,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nx/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -38409,9 +38409,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
|
||||
"integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -38429,7 +38429,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.8",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -39002,9 +39002,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -39423,9 +39423,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
@@ -44969,9 +44969,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -47104,9 +47104,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vm2": {
|
||||
"version": "3.11.3",
|
||||
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.11.3.tgz",
|
||||
"integrity": "sha512-DO1TTKuOc+veL11VNOvJwRab80mghFKE40Av3bl6pdXs11bdiDMuR73owy+dS2EsTZEvRUeBkkBuDVRjV/RgEw==",
|
||||
"version": "3.11.5",
|
||||
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.11.5.tgz",
|
||||
"integrity": "sha512-RSrkBiwrj6FRU+QdqNs6KG0XdlvJCjpQ4GXiqmMbrhmwfu5k/XIMpAer0L8f6iuf0uJ3a4T1xJN126Q8yf0VIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
@@ -47889,9 +47889,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-sources": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.1.tgz",
|
||||
"integrity": "sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==",
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz",
|
||||
"integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -48303,9 +48303,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -48924,9 +48924,9 @@
|
||||
}
|
||||
},
|
||||
"packages/generator-superset/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -50174,7 +50174,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"d3-cloud": "^1.2.8",
|
||||
"d3-cloud": "^1.2.9",
|
||||
"d3-scale": "^4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
"@storybook/test": "^8.6.18",
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
@@ -312,7 +312,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -332,7 +332,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.2",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -379,7 +379,7 @@
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
"webpack-manifest-plugin": "^5.0.1",
|
||||
"webpack-sources": "^3.4.1",
|
||||
"webpack-sources": "^3.5.0",
|
||||
"webpack-visualizer-plugin2": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"lodash-es": "^4.18.1",
|
||||
"yeoman-generator": "^8.1.2",
|
||||
"yeoman-generator": "^8.2.2",
|
||||
"yosay": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
|
||||
@@ -239,8 +239,6 @@ export default function transformProps(
|
||||
formatter,
|
||||
show: showLabels,
|
||||
color: theme.colorText,
|
||||
textBorderColor: theme.colorBgBase,
|
||||
textBorderWidth: 1,
|
||||
};
|
||||
const legendData = keys.sort((a: string, b: string) => {
|
||||
if (!legendSort) return 0;
|
||||
|
||||
@@ -72,6 +72,15 @@ describe('Funnel transformProps', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('does not apply a text border to segment labels', () => {
|
||||
// A white textBorder washes out the dark text on light-colored segments.
|
||||
const result = transformProps(chartProps as EchartsFunnelChartProps);
|
||||
const { label } = (result.echartOptions.series as any)[0];
|
||||
expect(label.color).toBe(supersetTheme.colorText);
|
||||
expect(label.textBorderColor).toBeUndefined();
|
||||
expect(label.textBorderWidth).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFunnelLabel', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ClientErrorObject, SupersetError } from '@superset-ui/core';
|
||||
import { FC } from 'react';
|
||||
import { useChartOwnerNames } from 'src/hooks/apiResources';
|
||||
@@ -32,7 +33,7 @@ export type Props = {
|
||||
stackTrace?: string;
|
||||
} & Omit<ClientErrorObject, 'error'>;
|
||||
|
||||
const DEFAULT_CHART_ERROR = 'Data error';
|
||||
const DEFAULT_CHART_ERROR = t('Data error');
|
||||
|
||||
export const ChartErrorMessage: FC<Props> = ({ chartId, error, ...props }) => {
|
||||
// fetches the chart owners and adds them to the extra data of the error message
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Select,
|
||||
AsyncSelect,
|
||||
} from '@superset-ui/core/components';
|
||||
import { getUserDisplayLabel } from 'src/features/users/utils';
|
||||
import { FormValues, GroupModalProps } from './types';
|
||||
import { createGroup, fetchUserOptions, updateGroup } from './utils';
|
||||
|
||||
@@ -94,7 +95,7 @@ function GroupListModal({
|
||||
users:
|
||||
group?.users?.map(user => ({
|
||||
value: user.id,
|
||||
label: user.username,
|
||||
label: getUserDisplayLabel(user),
|
||||
})) || [],
|
||||
};
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import rison from 'rison';
|
||||
import { getUserDisplayLabel } from 'src/features/users/utils';
|
||||
import { FormValues } from './types';
|
||||
|
||||
export const createGroup = async (values: FormValues) => {
|
||||
@@ -64,7 +65,7 @@ export const fetchUserOptions = async (
|
||||
return {
|
||||
data: results.map((user: any) => ({
|
||||
value: user.id,
|
||||
label: user.username,
|
||||
label: getUserDisplayLabel(user),
|
||||
})),
|
||||
totalCount: response.json?.count ?? 0,
|
||||
};
|
||||
|
||||
@@ -63,6 +63,16 @@ jest.mock('@superset-ui/core', () => {
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('RoleListEditModal', () => {
|
||||
beforeEach(() => {
|
||||
(SupersetClient.get as jest.Mock).mockResolvedValue({
|
||||
json: { count: 0, result: [] },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockRole = {
|
||||
id: 1,
|
||||
name: 'Admin',
|
||||
@@ -147,8 +157,8 @@ describe('RoleListEditModal', () => {
|
||||
|
||||
// Wait for user hydration to complete so setFieldsValue has populated
|
||||
// the form with the fetched users before submitting.
|
||||
await screen.findByText('johndoe');
|
||||
await screen.findByText('janesmith');
|
||||
await screen.findByText('John Doe');
|
||||
await screen.findByText('Jane Smith');
|
||||
|
||||
fireEvent.change(screen.getByTestId('role-name-input'), {
|
||||
target: { value: 'Updated Role' },
|
||||
@@ -241,16 +251,19 @@ describe('RoleListEditModal', () => {
|
||||
test('preserves missing IDs as numeric fallbacks on partial hydration', async () => {
|
||||
const mockGet = SupersetClient.get as jest.Mock;
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
|
||||
if (
|
||||
endpoint?.includes(
|
||||
`/api/v1/security/roles/${mockRole.id}/permissions/`,
|
||||
)
|
||||
) {
|
||||
// Only return permission id=10, not id=20
|
||||
return Promise.resolve({
|
||||
json: {
|
||||
count: 1,
|
||||
result: [
|
||||
{
|
||||
id: 10,
|
||||
permission: { name: 'can_read' },
|
||||
view_menu: { name: 'Dashboard' },
|
||||
permission_name: 'can_read',
|
||||
view_menu_name: 'Dashboard',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -284,7 +297,11 @@ describe('RoleListEditModal', () => {
|
||||
mockToasts.addDangerToast.mockClear();
|
||||
const mockGet = SupersetClient.get as jest.Mock;
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
|
||||
if (
|
||||
endpoint?.includes(
|
||||
`/api/v1/security/roles/${mockRole.id}/permissions/`,
|
||||
)
|
||||
) {
|
||||
return Promise.reject(new Error('network error'));
|
||||
}
|
||||
if (endpoint?.includes('/api/v1/security/groups/')) {
|
||||
@@ -354,24 +371,26 @@ describe('RoleListEditModal', () => {
|
||||
};
|
||||
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
|
||||
const query = rison.decode(endpoint.split('?q=')[1]) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const filters = query.filters as Array<{
|
||||
col: string;
|
||||
opr: string;
|
||||
value: number[];
|
||||
}>;
|
||||
const ids = filters?.[0]?.value || [];
|
||||
const result = ids.map((id: number) => ({
|
||||
id,
|
||||
permission: { name: `perm_${id}` },
|
||||
view_menu: { name: `view_${id}` },
|
||||
}));
|
||||
if (endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)) {
|
||||
return Promise.resolve({
|
||||
json: { count: result.length, result },
|
||||
json: {
|
||||
result: roleA.permission_ids.map(pid => ({
|
||||
id: pid,
|
||||
permission_name: `perm_${pid}`,
|
||||
view_menu_name: `view_${pid}`,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)) {
|
||||
return Promise.resolve({
|
||||
json: {
|
||||
result: roleB.permission_ids.map(pid => ({
|
||||
id: pid,
|
||||
permission_name: `perm_${pid}`,
|
||||
view_menu_name: `view_${pid}`,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ json: { count: 0, result: [] } });
|
||||
@@ -388,7 +407,7 @@ describe('RoleListEditModal', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const permCall = mockGet.mock.calls.find(([c]) =>
|
||||
c.endpoint.includes('/api/v1/security/permissions-resources/'),
|
||||
c.endpoint.includes(`/api/v1/security/roles/${roleA.id}/permissions/`),
|
||||
);
|
||||
expect(permCall).toBeTruthy();
|
||||
});
|
||||
@@ -408,26 +427,16 @@ describe('RoleListEditModal', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const permCalls = mockGet.mock.calls.filter(([c]) =>
|
||||
c.endpoint.includes('/api/v1/security/permissions-resources/'),
|
||||
c.endpoint.includes(`/api/v1/security/roles/${roleB.id}/permissions/`),
|
||||
);
|
||||
expect(permCalls.length).toBeGreaterThan(0);
|
||||
// Should request role B's IDs, not role A's
|
||||
const query = rison.decode(
|
||||
permCalls[0][0].endpoint.split('?q=')[1],
|
||||
) as Record<string, unknown>;
|
||||
const filters = query.filters as Array<{
|
||||
col: string;
|
||||
opr: string;
|
||||
value: number[];
|
||||
}>;
|
||||
expect(filters[0].value).toEqual(roleB.permission_ids);
|
||||
});
|
||||
|
||||
unmount();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
test('fetches permissions and groups by id for hydration', async () => {
|
||||
test('fetches permissions via role endpoint and groups by id for hydration', async () => {
|
||||
const mockGet = SupersetClient.get as jest.Mock;
|
||||
mockGet.mockResolvedValue({
|
||||
json: {
|
||||
@@ -442,8 +451,11 @@ describe('RoleListEditModal', () => {
|
||||
expect(mockGet).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Permissions should be fetched via the role's permissions endpoint (no ID list in URL)
|
||||
const permissionCall = mockGet.mock.calls.find(([call]) =>
|
||||
call.endpoint.includes('/api/v1/security/permissions-resources/'),
|
||||
call.endpoint.includes(
|
||||
`/api/v1/security/roles/${mockRole.id}/permissions/`,
|
||||
),
|
||||
)?.[0];
|
||||
const groupsCall = mockGet.mock.calls.find(([call]) =>
|
||||
call.endpoint.includes('/api/v1/security/groups/'),
|
||||
@@ -455,26 +467,17 @@ describe('RoleListEditModal', () => {
|
||||
throw new Error('Expected hydration calls to be defined');
|
||||
}
|
||||
|
||||
const permissionQuery = permissionCall.endpoint.match(/\?q=(.+)/);
|
||||
// Permission endpoint has no query params (role ID is in the path)
|
||||
expect(permissionCall.endpoint).toBe(
|
||||
`/api/v1/security/roles/${mockRole.id}/permissions/`,
|
||||
);
|
||||
|
||||
// Groups still use the id-in filter
|
||||
const groupsQuery = groupsCall.endpoint.match(/\?q=(.+)/);
|
||||
expect(permissionQuery).toBeTruthy();
|
||||
expect(groupsQuery).toBeTruthy();
|
||||
if (!permissionQuery || !groupsQuery) {
|
||||
throw new Error('Expected query params to be present');
|
||||
if (!groupsQuery) {
|
||||
throw new Error('Expected groups query params to be present');
|
||||
}
|
||||
|
||||
expect(rison.decode(permissionQuery[1])).toEqual({
|
||||
page_size: 100,
|
||||
page: 0,
|
||||
filters: [
|
||||
{
|
||||
col: 'id',
|
||||
opr: 'in',
|
||||
value: mockRole.permission_ids,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(rison.decode(groupsQuery[1])).toEqual({
|
||||
page_size: 100,
|
||||
page: 0,
|
||||
|
||||
@@ -30,9 +30,11 @@ import {
|
||||
import {
|
||||
BaseModalProps,
|
||||
RoleForm,
|
||||
RolePermissions,
|
||||
SelectOption,
|
||||
} from 'src/features/roles/types';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { fetchPaginatedData } from 'src/utils/fetchOptions';
|
||||
import { type UserObject } from 'src/pages/UsersList/types';
|
||||
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
|
||||
@@ -49,6 +51,7 @@ import {
|
||||
updateRoleUsers,
|
||||
formatPermissionLabel,
|
||||
} from './utils';
|
||||
import { getUserDisplayLabel } from 'src/features/users/utils';
|
||||
|
||||
export interface RoleListEditModalProps extends BaseModalProps {
|
||||
role: RoleObject;
|
||||
@@ -162,34 +165,38 @@ function RoleListEditModal({
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoadingRolePermissions(true);
|
||||
permissionFetchSucceeded.current = false;
|
||||
const filters = [{ col: 'id', opr: 'in', value: stablePermissionIds }];
|
||||
|
||||
fetchPaginatedData({
|
||||
endpoint: `/api/v1/security/permissions-resources/`,
|
||||
pageSize: 100,
|
||||
setData: (data: SelectOption[]) => {
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/security/roles/${id}/permissions/`,
|
||||
})
|
||||
.then(response => {
|
||||
if (cancelled) return;
|
||||
permissionFetchSucceeded.current = true;
|
||||
setRolePermissions(data);
|
||||
},
|
||||
filters,
|
||||
setLoadingState: (loading: boolean) => setLoadingRolePermissions(loading),
|
||||
loadingKey: 'rolePermissions',
|
||||
addDangerToast,
|
||||
errorMessage: t('There was an error loading permissions.'),
|
||||
mapResult: (permission: {
|
||||
id: number;
|
||||
permission: { name: string };
|
||||
view_menu: { name: string };
|
||||
}) => ({
|
||||
value: permission.id,
|
||||
label: formatPermissionLabel(
|
||||
permission.permission.name,
|
||||
permission.view_menu.name,
|
||||
),
|
||||
}),
|
||||
});
|
||||
const result: RolePermissions[] = response.json.result ?? [];
|
||||
setRolePermissions(
|
||||
result.map(p => ({
|
||||
value: p.id,
|
||||
label: formatPermissionLabel(p.permission_name, p.view_menu_name),
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
addDangerToast(t('There was an error loading permissions.'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoadingRolePermissions(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [addDangerToast, id, stablePermissionIds]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -226,7 +233,7 @@ function RoleListEditModal({
|
||||
if (!loadingRoleUsers && formRef.current) {
|
||||
const userOptions = roleUsers.map(user => ({
|
||||
value: user.id,
|
||||
label: user.username,
|
||||
label: getUserDisplayLabel(user),
|
||||
}));
|
||||
formRef.current.setFieldsValue({
|
||||
roleUsers: userOptions,
|
||||
@@ -315,7 +322,7 @@ function RoleListEditModal({
|
||||
roleUsers:
|
||||
roleUsers?.map(user => ({
|
||||
value: user.id,
|
||||
label: user.username,
|
||||
label: getUserDisplayLabel(user),
|
||||
})) || [],
|
||||
roleGroups: group_ids.map(groupId => ({
|
||||
value: groupId,
|
||||
|
||||
@@ -44,6 +44,15 @@ export const deleteUser = async (userId: number) =>
|
||||
endpoint: `/api/v1/security/users/${userId}`,
|
||||
});
|
||||
|
||||
export const getUserDisplayLabel = (user: {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
}): string =>
|
||||
[user.first_name, user.last_name].filter(Boolean).join(' ') ||
|
||||
user.username ||
|
||||
t('N/A');
|
||||
|
||||
export const atLeastOneRoleOrGroup =
|
||||
(fieldToCheck: 'roles' | 'groups') =>
|
||||
({
|
||||
|
||||
@@ -31,6 +31,7 @@ import 'dayjs/locale/pt';
|
||||
import 'dayjs/locale/pt-br';
|
||||
import 'dayjs/locale/ru';
|
||||
import 'dayjs/locale/ko';
|
||||
import 'dayjs/locale/cs';
|
||||
import 'dayjs/locale/sk';
|
||||
import 'dayjs/locale/sl';
|
||||
import 'dayjs/locale/nl';
|
||||
@@ -50,6 +51,7 @@ export const LOCALE_MAPPING = {
|
||||
pt_BR: () => import('antd/locale/pt_BR'),
|
||||
ru: () => import('antd/locale/ru_RU'),
|
||||
ko: () => import('antd/locale/ko_KR'),
|
||||
cs: () => import('antd/locale/cs_CZ'),
|
||||
sk: () => import('antd/locale/sk_SK'),
|
||||
sl: () => import('antd/locale/sl_SI'),
|
||||
nl: () => import('antd/locale/nl_NL'),
|
||||
|
||||
@@ -96,16 +96,21 @@ export const fetchPaginatedData = async ({
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(totalItems / pageSize);
|
||||
const concurrencyLimit = 5;
|
||||
const allResults = [...firstPageResults];
|
||||
|
||||
const requests = Array.from({ length: totalPages - 1 }, (_, i) =>
|
||||
fetchPage(i + 1),
|
||||
);
|
||||
const remainingResults = await Promise.all(requests);
|
||||
for (let batch = 1; batch < totalPages; batch += concurrencyLimit) {
|
||||
const batchEnd = Math.min(batch + concurrencyLimit, totalPages);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const batchResults = await Promise.all(
|
||||
Array.from({ length: batchEnd - batch }, (_, i) =>
|
||||
fetchPage(batch + i),
|
||||
),
|
||||
);
|
||||
allResults.push(...batchResults.flatMap(res => res.results));
|
||||
}
|
||||
|
||||
setData([
|
||||
...firstPageResults,
|
||||
...remainingResults.flatMap(res => res.results),
|
||||
]);
|
||||
setData(allResults);
|
||||
} catch (err) {
|
||||
addDangerToast(t(errorMessage));
|
||||
} finally {
|
||||
|
||||
Generated
+7
-7
@@ -15,7 +15,7 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash": "^4.18.1",
|
||||
"winston": "^3.19.0",
|
||||
"ws": "^8.20.1"
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
@@ -6428,9 +6428,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -11207,9 +11207,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"ws": {
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"requires": {}
|
||||
},
|
||||
"y18n": {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash": "^4.18.1",
|
||||
"winston": "^3.19.0",
|
||||
"ws": "^8.20.1"
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import logging
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.css.exceptions import (
|
||||
CssTemplateCreateFailedError,
|
||||
CssTemplateInvalidError,
|
||||
)
|
||||
from superset.daos.css import CssTemplateDAO
|
||||
from superset.models.core import CssTemplate
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreateCssTemplateCommand(BaseCommand):
|
||||
def __init__(self, properties: dict[str, Any]):
|
||||
self._properties = properties
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=CssTemplateCreateFailedError))
|
||||
def run(self) -> CssTemplate:
|
||||
self.validate()
|
||||
return CssTemplateDAO.create(attributes=self._properties)
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self._properties.get("template_name", "").strip():
|
||||
raise CssTemplateInvalidError()
|
||||
if "css" not in self._properties:
|
||||
raise CssTemplateInvalidError()
|
||||
@@ -16,7 +16,13 @@
|
||||
# under the License.
|
||||
from flask_babel import lazy_gettext as _
|
||||
|
||||
from superset.commands.exceptions import CommandException, DeleteFailedError
|
||||
from superset.commands.exceptions import (
|
||||
CommandException,
|
||||
CommandInvalidError,
|
||||
CreateFailedError,
|
||||
DeleteFailedError,
|
||||
UpdateFailedError,
|
||||
)
|
||||
|
||||
|
||||
class CssTemplateDeleteFailedError(DeleteFailedError):
|
||||
@@ -25,3 +31,15 @@ class CssTemplateDeleteFailedError(DeleteFailedError):
|
||||
|
||||
class CssTemplateNotFoundError(CommandException):
|
||||
message = _("CSS template not found.")
|
||||
|
||||
|
||||
class CssTemplateCreateFailedError(CreateFailedError):
|
||||
message = _("CSS template could not be created.")
|
||||
|
||||
|
||||
class CssTemplateInvalidError(CommandInvalidError):
|
||||
message = _("CSS template parameters are invalid.")
|
||||
|
||||
|
||||
class CssTemplateUpdateFailedError(UpdateFailedError):
|
||||
message = _("CSS template could not be updated.")
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import logging
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.css.exceptions import (
|
||||
CssTemplateInvalidError,
|
||||
CssTemplateNotFoundError,
|
||||
CssTemplateUpdateFailedError,
|
||||
)
|
||||
from superset.daos.css import CssTemplateDAO
|
||||
from superset.models.core import CssTemplate
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateCssTemplateCommand(BaseCommand):
|
||||
def __init__(self, model_id: int, properties: dict[str, Any]):
|
||||
self._model_id = model_id
|
||||
self._properties = properties
|
||||
self._model: CssTemplate | None = None
|
||||
|
||||
@transaction(on_error=partial(on_error, reraise=CssTemplateUpdateFailedError))
|
||||
def run(self) -> CssTemplate:
|
||||
self.validate()
|
||||
assert self._model
|
||||
return CssTemplateDAO.update(self._model, attributes=self._properties)
|
||||
|
||||
def validate(self) -> None:
|
||||
self._model = CssTemplateDAO.find_by_id(self._model_id)
|
||||
if not self._model:
|
||||
raise CssTemplateNotFoundError()
|
||||
template_name = self._properties.get("template_name")
|
||||
if template_name is not None and not template_name.strip():
|
||||
raise CssTemplateInvalidError()
|
||||
@@ -22,6 +22,7 @@ from urllib import request
|
||||
|
||||
import pandas as pd
|
||||
from flask import current_app as app
|
||||
from pandas.errors import OutOfBoundsDatetime
|
||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Float, String, Text
|
||||
from sqlalchemy.exc import MultipleResultsFound
|
||||
from sqlalchemy.sql.visitors import VisitableType
|
||||
@@ -202,6 +203,39 @@ def import_dataset( # noqa: C901
|
||||
return dataset
|
||||
|
||||
|
||||
def _convert_temporal_columns(df: pd.DataFrame, dtype: dict[str, Any]) -> None:
|
||||
"""Convert Date/DateTime columns in-place, coercing only out-of-bounds values."""
|
||||
for column_name, sqla_type in dtype.items():
|
||||
if isinstance(sqla_type, (Date, DateTime)):
|
||||
try:
|
||||
df[column_name] = pd.to_datetime(df[column_name])
|
||||
except OutOfBoundsDatetime:
|
||||
# Row-level fallback: coerce only OOB values; re-raise for malformed
|
||||
# strings. Whole-column errors="coerce" would silently swallow
|
||||
# malformed values that happen to share a column with an OOB date.
|
||||
original = df[column_name].copy()
|
||||
result = []
|
||||
for val in original:
|
||||
if pd.isna(val):
|
||||
result.append(pd.NaT)
|
||||
continue
|
||||
try:
|
||||
result.append(pd.to_datetime(val))
|
||||
except OutOfBoundsDatetime:
|
||||
result.append(pd.NaT)
|
||||
# Other exceptions (e.g. malformed strings) propagate
|
||||
converted = pd.Series(result, index=original.index)
|
||||
n_coerced = int(converted.isna().sum() - original.isna().sum())
|
||||
if n_coerced > 0:
|
||||
logger.warning(
|
||||
"Coerced %d out-of-bounds datetime value(s) "
|
||||
"in column '%s' to NaT",
|
||||
n_coerced,
|
||||
column_name,
|
||||
)
|
||||
df[column_name] = converted
|
||||
|
||||
|
||||
def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
|
||||
"""
|
||||
Load data from a data URI into a dataset.
|
||||
@@ -222,10 +256,7 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
|
||||
df = pd.read_csv(data, encoding="utf-8")
|
||||
dtype = get_dtype(df, dataset)
|
||||
|
||||
# convert temporal columns
|
||||
for column_name, sqla_type in dtype.items():
|
||||
if isinstance(sqla_type, (Date, DateTime)):
|
||||
df[column_name] = pd.to_datetime(df[column_name])
|
||||
_convert_temporal_columns(df, dtype)
|
||||
|
||||
# reuse session when loading data if possible, to make import atomic
|
||||
if database.sqlalchemy_uri == app.config.get("SQLALCHEMY_DATABASE_URI"):
|
||||
|
||||
@@ -30,6 +30,7 @@ from superset.databases.ssh_tunnel.models import SSHTunnel
|
||||
from superset.extensions import feature_flag_manager
|
||||
from superset.models.core import Database
|
||||
from superset.models.dashboard import dashboard_slices
|
||||
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES
|
||||
from superset.tags.models import Tag, TaggedObject
|
||||
from superset.utils import json
|
||||
from superset.utils.core import check_is_safe_zip
|
||||
@@ -400,3 +401,49 @@ def get_resource_mappings_batched(
|
||||
mapping.update({str(x.uuid): value_func(x) for x in batch})
|
||||
offset += batch_size
|
||||
return mapping
|
||||
|
||||
|
||||
def find_existing_for_import(model_cls: type[Any], uuid: str) -> Any | None:
|
||||
"""Look up an existing row by UUID for an import, including soft-deleted matches.
|
||||
|
||||
Bypasses the soft-delete visibility filter so a soft-deleted row with
|
||||
the matching UUID is returned, not hidden. Side-effect-free: returns
|
||||
the row as-is whether it's live or soft-deleted (or ``None`` if no
|
||||
row exists). The caller is responsible for deciding what to do with
|
||||
a soft-deleted match — typically calling
|
||||
:func:`clear_soft_deleted_for_import` to remove it before re-import,
|
||||
but only after the caller has validated overwrite/permission decisions.
|
||||
|
||||
Splitting the lookup from the destructive cleanup keeps the
|
||||
destructive action explicit at the call site, so a future change
|
||||
that adds a permission check on the overwrite path doesn't
|
||||
silently leave a "duck around it via soft-delete" backdoor.
|
||||
"""
|
||||
return (
|
||||
db.session.query(model_cls)
|
||||
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {model_cls}})
|
||||
.filter_by(uuid=uuid)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def clear_soft_deleted_for_import(existing: Any) -> None:
|
||||
"""Hard-delete a soft-deleted row to free its UUID for re-import.
|
||||
|
||||
Uses ``db.session.delete()`` rather than a raw Core ``DELETE`` so
|
||||
the ORM ``after_delete`` event listeners fire. Cleanup that depends
|
||||
on those listeners would otherwise be skipped — notably tag rows in
|
||||
``tagged_object`` (cleaned up by ``ObjectUpdater.after_delete`` in
|
||||
``superset/tags/core.py``; the table's ``object_id`` is a plain
|
||||
integer, not a foreign key, so the database cannot cascade them)
|
||||
and dataset permission-view rows (cleaned up by
|
||||
``SqlaTable.after_delete`` in ``superset/connectors/sqla/models.py``).
|
||||
|
||||
Caller contract: ``existing`` must be a soft-deleted row returned
|
||||
from :func:`find_existing_for_import`. Callers should run their
|
||||
overwrite / permission validation *before* invoking this so the
|
||||
destructive action only happens once the import path is committed
|
||||
to proceeding.
|
||||
"""
|
||||
db.session.delete(existing)
|
||||
db.session.flush()
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Base class shared by all soft-delete restore commands."""
|
||||
|
||||
from functools import partial
|
||||
from typing import Any, ClassVar, Generic, TypeVar
|
||||
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.models.helpers import SoftDeleteMixin
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
T = TypeVar("T", bound=SoftDeleteMixin)
|
||||
|
||||
|
||||
class BaseRestoreCommand(BaseCommand, Generic[T]):
|
||||
"""Base class for soft-delete restore commands.
|
||||
|
||||
Subclasses provide the entity-specific bindings as class variables —
|
||||
no method override required:
|
||||
|
||||
- ``dao``: the DAO class (e.g. ``ChartDAO``)
|
||||
- ``not_found_exc``: raised when the row doesn't exist OR isn't
|
||||
soft-deleted
|
||||
- ``forbidden_exc``: raised when the caller doesn't have ownership
|
||||
- ``restore_failed_exc``: re-raised by the transactional wrapper
|
||||
when an underlying SQLAlchemy error aborts the commit
|
||||
|
||||
The transactional wrapper is applied by this class's ``run()``
|
||||
using ``restore_failed_exc`` as the rethrow type, so each subclass
|
||||
just declares the four ClassVars and is done. There is no
|
||||
subclass-managed decorator contract — earlier iterations of this
|
||||
PR required subclasses to override ``run()`` purely to add a
|
||||
``@transaction`` decorator, which was fragile (every new entity
|
||||
rollout had to remember).
|
||||
|
||||
The model returned from ``validate()`` is the soft-deleted row,
|
||||
type-narrowed via ``Generic[T]``. ``run()`` calls ``model.restore()``
|
||||
on it (the method comes from ``SoftDeleteMixin``).
|
||||
"""
|
||||
|
||||
dao: ClassVar[Any]
|
||||
not_found_exc: ClassVar[type[Exception]]
|
||||
forbidden_exc: ClassVar[type[Exception]]
|
||||
restore_failed_exc: ClassVar[type[Exception]]
|
||||
|
||||
def __init__(self, model_uuid: str) -> None:
|
||||
self._model_uuid = model_uuid
|
||||
|
||||
def run(self) -> None:
|
||||
# Build the transactional wrapper at call time so ``on_error`` can
|
||||
# reference ``self.restore_failed_exc`` — a per-subclass ClassVar
|
||||
# that isn't available when this method is defined on the base.
|
||||
@transaction(on_error=partial(on_error, reraise=self.restore_failed_exc))
|
||||
def _perform() -> None:
|
||||
model = self.validate()
|
||||
model.restore()
|
||||
|
||||
_perform()
|
||||
|
||||
def validate(self) -> T: # type: ignore[override]
|
||||
# ``skip_visibility_filter=True`` is the *only* bypass — the
|
||||
# entity's RBAC ``base_filter`` stays in effect, matching the
|
||||
# behavior of ``find_by_ids`` on the existing delete paths.
|
||||
# Restore should not see rows the user cannot see in the live
|
||||
# UI; ownership is then verified by ``raise_for_ownership``.
|
||||
model = self.dao.find_by_id(
|
||||
self._model_uuid,
|
||||
id_column="uuid",
|
||||
skip_visibility_filter=True,
|
||||
)
|
||||
if model is None:
|
||||
raise self.not_found_exc(f"No row with uuid={self._model_uuid!r}")
|
||||
if model.deleted_at is None:
|
||||
raise self.not_found_exc(
|
||||
f"Row with uuid={self._model_uuid!r} is not soft-deleted; "
|
||||
"nothing to restore"
|
||||
)
|
||||
try:
|
||||
security_manager.raise_for_ownership(model)
|
||||
except SupersetSecurityException as ex:
|
||||
raise self.forbidden_exc() from ex
|
||||
return model
|
||||
+2
-2
@@ -51,7 +51,7 @@ from sqlalchemy.orm.query import Query
|
||||
from superset.advanced_data_type.plugins.internet_address import internet_address
|
||||
from superset.advanced_data_type.plugins.internet_port import internet_port
|
||||
from superset.advanced_data_type.types import AdvancedDataType
|
||||
from superset.constants import CHANGE_ME_SECRET_KEY
|
||||
from superset.constants import CHANGE_ME_GUEST_TOKEN_JWT_SECRET, CHANGE_ME_SECRET_KEY
|
||||
from superset.jinja_context import BaseTemplateProcessor
|
||||
from superset.key_value.types import JsonKeyValueCodec
|
||||
from superset.stats_logger import DummyStatsLogger
|
||||
@@ -2354,7 +2354,7 @@ GLOBAL_ASYNC_QUERIES_CACHE_BACKEND = {
|
||||
|
||||
# Embedded config options
|
||||
GUEST_ROLE_NAME = "Public"
|
||||
GUEST_TOKEN_JWT_SECRET = "test-guest-secret-change-me" # noqa: S105
|
||||
GUEST_TOKEN_JWT_SECRET = CHANGE_ME_GUEST_TOKEN_JWT_SECRET
|
||||
GUEST_TOKEN_JWT_ALGO = "HS256" # noqa: S105
|
||||
GUEST_TOKEN_HEADER_NAME = "X-GuestToken" # noqa: S105
|
||||
GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 minutes
|
||||
|
||||
@@ -28,6 +28,7 @@ NULL_STRING = "<NULL>"
|
||||
EMPTY_STRING = "<empty string>"
|
||||
|
||||
CHANGE_ME_SECRET_KEY = "CHANGE_ME_TO_A_COMPLEX_RANDOM_SECRET" # noqa: S105
|
||||
CHANGE_ME_GUEST_TOKEN_JWT_SECRET = "test-guest-secret-change-me" # noqa: S105
|
||||
|
||||
# UUID for the examples database
|
||||
EXAMPLES_DB_UUID = "a2dc77af-e654-49bb-b321-40f6b559a1ee"
|
||||
@@ -174,6 +175,7 @@ MODEL_API_RW_METHOD_PERMISSION_MAP = {
|
||||
"put_filters": "write",
|
||||
"put_colors": "write",
|
||||
"sync_permissions": "write",
|
||||
"restore": "write",
|
||||
}
|
||||
|
||||
EXTRA_FORM_DATA_APPEND_KEYS = {
|
||||
|
||||
+75
-13
@@ -48,6 +48,7 @@ from superset.daos.exceptions import (
|
||||
DAOFindFailedError,
|
||||
)
|
||||
from superset.extensions import db
|
||||
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES, SoftDeleteMixin
|
||||
|
||||
T = TypeVar("T", bound=CoreModel)
|
||||
|
||||
@@ -60,6 +61,7 @@ class ColumnOperatorEnum(str, Enum):
|
||||
ne = "ne"
|
||||
sw = "sw"
|
||||
ew = "ew"
|
||||
ct = "ct"
|
||||
in_ = "in"
|
||||
nin = "nin"
|
||||
gt = "gt"
|
||||
@@ -84,11 +86,12 @@ operator_map: Dict[ColumnOperatorEnum, Any] = {
|
||||
ColumnOperatorEnum.ne: lambda col, val: col != val,
|
||||
ColumnOperatorEnum.sw: lambda col, val: col.like(f"{val}%"),
|
||||
ColumnOperatorEnum.ew: lambda col, val: col.like(f"%{val}"),
|
||||
ColumnOperatorEnum.ct: lambda col, val: col.ilike(f"%{val}%"),
|
||||
ColumnOperatorEnum.in_: lambda col, val: col.in_(
|
||||
val if isinstance(val, (list, tuple)) else [val]
|
||||
),
|
||||
ColumnOperatorEnum.nin: lambda col, val: ~col.in_(
|
||||
val if isinstance(val, (list, tuple)) else [val]
|
||||
ColumnOperatorEnum.nin: lambda col, val: (
|
||||
~col.in_(val if isinstance(val, (list, tuple)) else [val])
|
||||
),
|
||||
ColumnOperatorEnum.gt: lambda col, val: col > val,
|
||||
ColumnOperatorEnum.gte: lambda col, val: col >= val,
|
||||
@@ -107,6 +110,7 @@ TYPE_OPERATOR_MAP = {
|
||||
ColumnOperatorEnum.ne,
|
||||
ColumnOperatorEnum.sw,
|
||||
ColumnOperatorEnum.ew,
|
||||
ColumnOperatorEnum.ct,
|
||||
ColumnOperatorEnum.in_,
|
||||
ColumnOperatorEnum.nin,
|
||||
ColumnOperatorEnum.like,
|
||||
@@ -181,11 +185,17 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
|
||||
cls,
|
||||
model_id_or_uuid: str,
|
||||
skip_base_filter: bool = False,
|
||||
*,
|
||||
skip_visibility_filter: bool = False,
|
||||
) -> T | None:
|
||||
"""
|
||||
Find a model by id or uuid, if defined applies `base_filter`
|
||||
"""
|
||||
query = db.session.query(cls.model_cls)
|
||||
if skip_visibility_filter:
|
||||
query = query.execution_options(
|
||||
**{SKIP_VISIBILITY_FILTER_CLASSES: {cls.model_cls}}
|
||||
)
|
||||
if cls.base_filter and not skip_base_filter:
|
||||
data_model = SQLAInterface(cls.model_cls, db.session)
|
||||
query = cls.base_filter( # pylint: disable=not-callable
|
||||
@@ -249,6 +259,8 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
|
||||
value: str | int,
|
||||
skip_base_filter: bool = False,
|
||||
query_options: list[Any] | None = None,
|
||||
*,
|
||||
skip_visibility_filter: bool = False,
|
||||
) -> T | None:
|
||||
"""
|
||||
Private method to find a model by any column value.
|
||||
@@ -257,6 +269,7 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
|
||||
column_name: Name of the column to search by
|
||||
value: Value to search for
|
||||
skip_base_filter: Whether to skip base filtering
|
||||
skip_visibility_filter: Whether to skip the soft-delete visibility filter
|
||||
query_options: SQLAlchemy query options (e.g., joinedload,
|
||||
subqueryload) to apply to the query for eager loading
|
||||
|
||||
@@ -264,6 +277,10 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
|
||||
Model instance or None if not found
|
||||
"""
|
||||
query = db.session.query(cls.model_cls)
|
||||
if skip_visibility_filter:
|
||||
query = query.execution_options(
|
||||
**{SKIP_VISIBILITY_FILTER_CLASSES: {cls.model_cls}}
|
||||
)
|
||||
query = cls._apply_base_filter(query, skip_base_filter)
|
||||
|
||||
if query_options:
|
||||
@@ -290,6 +307,8 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
|
||||
skip_base_filter: bool = False,
|
||||
id_column: str | None = None,
|
||||
query_options: list[Any] | None = None,
|
||||
*,
|
||||
skip_visibility_filter: bool = False,
|
||||
) -> T | None:
|
||||
"""
|
||||
Find a model by ID using specified or default ID column.
|
||||
@@ -300,12 +319,20 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
|
||||
id_column: Column name to use (defaults to cls.id_column_name)
|
||||
query_options: SQLAlchemy query options (e.g., joinedload,
|
||||
subqueryload) to apply to the query for eager loading
|
||||
skip_visibility_filter: Keyword-only. Whether to skip the
|
||||
soft-delete visibility filter
|
||||
|
||||
Returns:
|
||||
Model instance or None if not found
|
||||
"""
|
||||
column = id_column or cls.id_column_name
|
||||
return cls._find_by_column(column, model_id, skip_base_filter, query_options)
|
||||
return cls._find_by_column(
|
||||
column,
|
||||
model_id,
|
||||
skip_base_filter,
|
||||
query_options,
|
||||
skip_visibility_filter=skip_visibility_filter,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def find_by_ids(
|
||||
@@ -313,6 +340,8 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
|
||||
model_ids: Sequence[str | int],
|
||||
skip_base_filter: bool = False,
|
||||
id_column: str | None = None,
|
||||
*,
|
||||
skip_visibility_filter: bool = False,
|
||||
) -> list[T]:
|
||||
"""
|
||||
Find a List of models by a list of ids, if defined applies `base_filter`
|
||||
@@ -321,6 +350,8 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
|
||||
:param skip_base_filter: If true, skip applying the base filter
|
||||
:param id_column: Optional column name to use for ID lookup
|
||||
(defaults to id_column_name)
|
||||
:param skip_visibility_filter: Keyword-only. If true, skip the
|
||||
soft-delete visibility filter so soft-deleted rows are returned
|
||||
"""
|
||||
column = id_column or cls.id_column_name
|
||||
id_col = getattr(cls.model_cls, column, None)
|
||||
@@ -347,7 +378,12 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
|
||||
if not converted_ids:
|
||||
return []
|
||||
|
||||
query = db.session.query(cls.model_cls).filter(id_col.in_(converted_ids))
|
||||
query = db.session.query(cls.model_cls)
|
||||
if skip_visibility_filter:
|
||||
query = query.execution_options(
|
||||
**{SKIP_VISIBILITY_FILTER_CLASSES: {cls.model_cls}}
|
||||
)
|
||||
query = query.filter(id_col.in_(converted_ids))
|
||||
query = cls._apply_base_filter(query, skip_base_filter)
|
||||
|
||||
try:
|
||||
@@ -429,25 +465,51 @@ class BaseDAO(CoreBaseDAO[T], Generic[T]):
|
||||
return item # type: ignore
|
||||
|
||||
@classmethod
|
||||
def delete(cls, items: list[T]) -> None:
|
||||
def soft_delete(cls, items: list[T]) -> None:
|
||||
"""Mark items as soft-deleted by setting ``deleted_at``.
|
||||
|
||||
Only valid for models that include ``SoftDeleteMixin``.
|
||||
|
||||
:param items: The items to soft-delete
|
||||
"""
|
||||
Delete the specified items including their associated relationships.
|
||||
for item in items:
|
||||
item.soft_delete()
|
||||
|
||||
Note that bulk deletion via `delete` is not invoked in the base class as this
|
||||
does not dispatch the ORM `after_delete` event which may be required to augment
|
||||
additional records loosely defined via implicit relationships. Instead ORM
|
||||
objects are deleted one-by-one via `Session.delete`.
|
||||
@classmethod
|
||||
def hard_delete(cls, items: list[T]) -> None:
|
||||
"""Permanently remove rows from the database.
|
||||
|
||||
Subclasses may invoke bulk deletion but are responsible for instrumenting any
|
||||
post-deletion logic.
|
||||
Note that bulk deletion via ``delete`` is not invoked in the base
|
||||
class as this does not dispatch the ORM ``after_delete`` event which
|
||||
may be required to augment additional records loosely defined via
|
||||
implicit relationships. Instead ORM objects are deleted one-by-one
|
||||
via ``Session.delete``.
|
||||
|
||||
Subclasses may invoke bulk deletion but are responsible for
|
||||
instrumenting any post-deletion logic.
|
||||
|
||||
:param items: The items to delete
|
||||
:see: https://docs.sqlalchemy.org/en/latest/orm/queryguide/dml.html
|
||||
"""
|
||||
|
||||
for item in items:
|
||||
db.session.delete(item)
|
||||
|
||||
@classmethod
|
||||
def delete(cls, items: list[T]) -> None:
|
||||
"""Route to soft or hard delete based on whether the model supports
|
||||
soft delete.
|
||||
|
||||
For models that include ``SoftDeleteMixin``, this calls
|
||||
``soft_delete()``. For all other models, this calls ``hard_delete()``
|
||||
(the original behaviour).
|
||||
|
||||
:param items: The items to delete
|
||||
"""
|
||||
if cls.model_cls is not None and issubclass(cls.model_cls, SoftDeleteMixin):
|
||||
cls.soft_delete(items)
|
||||
else:
|
||||
cls.hard_delete(items)
|
||||
|
||||
@classmethod
|
||||
def query(cls, query: Query) -> list[T]:
|
||||
"""
|
||||
|
||||
@@ -30,6 +30,7 @@ from superset.databases.ssh_tunnel.models import SSHTunnel
|
||||
from superset.extensions import db
|
||||
from superset.models.core import Database, DatabaseUserOAuth2Tokens
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES
|
||||
from superset.models.slice import Slice
|
||||
from superset.models.sql_lab import TabState
|
||||
from superset.utils.core import DatasourceType
|
||||
@@ -71,6 +72,8 @@ class DatabaseDAO(BaseDAO[Database]):
|
||||
skip_base_filter: bool = False,
|
||||
id_column: str | None = None,
|
||||
query_options: list[Any] | None = None,
|
||||
*,
|
||||
skip_visibility_filter: bool = False,
|
||||
) -> Database | None:
|
||||
"""
|
||||
Find a database by id, eagerly loading the SSH tunnel relationship.
|
||||
@@ -79,6 +82,10 @@ class DatabaseDAO(BaseDAO[Database]):
|
||||
if query_options:
|
||||
all_options.extend(query_options)
|
||||
query = db.session.query(cls.model_cls).options(*all_options)
|
||||
if skip_visibility_filter:
|
||||
query = query.execution_options(
|
||||
**{SKIP_VISIBILITY_FILTER_CLASSES: {cls.model_cls}}
|
||||
)
|
||||
query = cls._apply_base_filter(query, skip_base_filter)
|
||||
|
||||
column_name = id_column or cls.id_column_name
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""DAO for FAB Role model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask_appbuilder.security.sqla.models import Role
|
||||
|
||||
from superset.daos.base import BaseDAO
|
||||
|
||||
|
||||
class RoleDAO(BaseDAO[Role]):
|
||||
"""DAO for FAB Role model. Provides basic CRUD via BaseDAO."""
|
||||
@@ -37,7 +37,7 @@ from flask_compress import Compress
|
||||
from flask_session import Session
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
from superset.constants import CHANGE_ME_SECRET_KEY
|
||||
from superset.constants import CHANGE_ME_GUEST_TOKEN_JWT_SECRET, CHANGE_ME_SECRET_KEY
|
||||
from superset.databases.utils import make_url_safe
|
||||
from superset.extensions import (
|
||||
_event_logger,
|
||||
@@ -634,12 +634,17 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
|
||||
self.init_all_dependencies_and_extensions()
|
||||
|
||||
@staticmethod
|
||||
def _log_config_warning(message: str) -> None:
|
||||
top_banner = 80 * "-" + "\n" + 36 * " " + "WARNING\n" + 80 * "-"
|
||||
bottom_banner = 80 * "-" + "\n" + 80 * "-"
|
||||
logger.warning(top_banner)
|
||||
logger.warning(message)
|
||||
logger.warning(bottom_banner)
|
||||
|
||||
def check_secret_key(self) -> None:
|
||||
def log_default_secret_key_warning() -> None:
|
||||
top_banner = 80 * "-" + "\n" + 36 * " " + "WARNING\n" + 80 * "-"
|
||||
bottom_banner = 80 * "-" + "\n" + 80 * "-"
|
||||
logger.warning(top_banner)
|
||||
logger.warning(
|
||||
if self.config["SECRET_KEY"] == CHANGE_ME_SECRET_KEY:
|
||||
warning = (
|
||||
"A Default SECRET_KEY was detected, please use superset_config.py "
|
||||
"to override it.\n"
|
||||
"Use a strong complex alphanumeric string and use a tool to help"
|
||||
@@ -648,21 +653,44 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
"For more info, see: https://superset.apache.org/docs/"
|
||||
"configuration/configuring-superset#specifying-a-secret_key"
|
||||
)
|
||||
logger.warning(bottom_banner)
|
||||
|
||||
if self.config["SECRET_KEY"] == CHANGE_ME_SECRET_KEY:
|
||||
if (
|
||||
self.superset_app.debug
|
||||
or self.superset_app.config["TESTING"]
|
||||
or is_test()
|
||||
):
|
||||
logger.warning("Debug mode identified with default secret key")
|
||||
log_default_secret_key_warning()
|
||||
self._log_config_warning(warning)
|
||||
return
|
||||
log_default_secret_key_warning()
|
||||
self._log_config_warning(warning)
|
||||
logger.error("Refusing to start due to insecure SECRET_KEY")
|
||||
sys.exit(1)
|
||||
|
||||
def check_guest_token_secret(self) -> None:
|
||||
"""Refuse to start with default guest JWT secret when embedding is enabled."""
|
||||
if not feature_flag_manager.is_feature_enabled("EMBEDDED_SUPERSET"):
|
||||
return
|
||||
if (
|
||||
self.config.get("GUEST_TOKEN_JWT_SECRET")
|
||||
!= CHANGE_ME_GUEST_TOKEN_JWT_SECRET
|
||||
):
|
||||
return
|
||||
self._log_config_warning(
|
||||
"EMBEDDED_SUPERSET is enabled but GUEST_TOKEN_JWT_SECRET has not "
|
||||
"been changed from its default value.\n"
|
||||
"The default value is publicly known and must be replaced before "
|
||||
"running in production.\n"
|
||||
"Set a strong random value in superset_config.py:\n"
|
||||
" GUEST_TOKEN_JWT_SECRET = "
|
||||
"'<output of: openssl rand -base64 42>'"
|
||||
)
|
||||
if self.superset_app.debug or self.superset_app.config["TESTING"] or is_test():
|
||||
return
|
||||
logger.error(
|
||||
"Refusing to start: insecure GUEST_TOKEN_JWT_SECRET "
|
||||
"with EMBEDDED_SUPERSET enabled"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
def configure_session(self) -> None:
|
||||
if self.config["SESSION_SERVER_SIDE"]:
|
||||
Session(self.superset_app)
|
||||
@@ -747,6 +775,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
# Configuration of feature_flags must be done first to allow init features
|
||||
# conditionally
|
||||
self.configure_feature_flags()
|
||||
self.check_guest_token_secret()
|
||||
self.configure_db_encrypt()
|
||||
self.setup_db()
|
||||
|
||||
@@ -767,6 +796,13 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
with self.superset_app.app_context():
|
||||
self.init_app_in_ctx()
|
||||
|
||||
# Registered outside ``init_app_in_ctx`` because the SQLAlchemy
|
||||
# event hook attaches to the ``Session`` *class* (a process-wide
|
||||
# global), not to a Session instance — it has no dependency on
|
||||
# the Flask app context. ``setup_db()`` ran earlier in
|
||||
# ``init_app``, so the ``Session`` import has already been
|
||||
# initialised by the time we get here.
|
||||
self.setup_soft_delete_listener()
|
||||
self.post_init()
|
||||
|
||||
def set_db_default_isolation(self) -> None:
|
||||
@@ -949,6 +985,23 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
|
||||
migrate.init_app(self.superset_app, db=db, directory=APP_DIR + "/migrations")
|
||||
|
||||
def setup_soft_delete_listener(self) -> None:
|
||||
"""Register the global soft-delete filter on the SQLAlchemy Session.
|
||||
|
||||
Must be called after ``setup_db()`` so the Session class is
|
||||
available. Uses the ``do_orm_execute`` + ``with_loader_criteria``
|
||||
pattern recommended by SQLAlchemy maintainer Mike Bayer for
|
||||
soft deletion in SQLAlchemy 1.4+:
|
||||
https://github.com/sqlalchemy/sqlalchemy/issues/7973#issuecomment-1112561295
|
||||
"""
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from superset.models.helpers import _add_soft_delete_filter
|
||||
|
||||
if not event.contains(Session, "do_orm_execute", _add_soft_delete_filter):
|
||||
event.listen(Session, "do_orm_execute", _add_soft_delete_filter)
|
||||
|
||||
def configure_wtf(self) -> None:
|
||||
if self.config["WTF_CSRF_ENABLED"]:
|
||||
csrf.init_app(self.superset_app)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,302 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Pydantic schemas for action-log MCP tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
model_validator,
|
||||
PositiveInt,
|
||||
)
|
||||
|
||||
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
|
||||
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
from superset.mcp_service.system.schemas import PaginationInfo
|
||||
from superset.mcp_service.utils import sanitize_for_llm_context
|
||||
from superset.mcp_service.utils.schema_utils import (
|
||||
parse_json_or_list,
|
||||
parse_json_or_model_list,
|
||||
)
|
||||
from superset.utils import json as json_utils
|
||||
|
||||
DEFAULT_LOG_COLUMNS: list[str] = ["id", "action", "user_id", "dttm"]
|
||||
ALL_LOG_COLUMNS: list[str] = [
|
||||
"id",
|
||||
"action",
|
||||
"user_id",
|
||||
"dttm",
|
||||
"dashboard_id",
|
||||
"slice_id",
|
||||
"json",
|
||||
]
|
||||
LOG_SORTABLE_COLUMNS: list[str] = ["id", "dttm"]
|
||||
|
||||
|
||||
class ActionLogFilter(ColumnOperator):
|
||||
"""Filter object for action-log listing.
|
||||
|
||||
col: Column to filter on.
|
||||
opr: Operator to use.
|
||||
value: Value to filter by.
|
||||
"""
|
||||
|
||||
col: Literal["action", "user_id", "dashboard_id", "slice_id", "dttm"] = Field(
|
||||
...,
|
||||
description="Column to filter on.",
|
||||
)
|
||||
opr: ColumnOperatorEnum = Field(..., description="Operator to use.")
|
||||
value: (
|
||||
str | int | float | bool | datetime | list[str | int | float | bool | datetime]
|
||||
) = Field(..., description="Value to filter by")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_dttm_value(self) -> "ActionLogFilter":
|
||||
"""Normalize string dttm values to datetime to avoid VARCHAR bind mismatch.
|
||||
|
||||
Pydantic's left-to-right union matching keeps ISO strings as str when
|
||||
str appears before datetime in the union. This validator parses them so
|
||||
the DAO always receives a typed datetime for TIMESTAMP column comparisons.
|
||||
Both scalar and list values are normalized so dttm IN (...) is also safe.
|
||||
|
||||
Replaces a trailing 'Z' with '+00:00' before parsing because
|
||||
datetime.fromisoformat does not accept the 'Z' suffix on Python < 3.11.
|
||||
"""
|
||||
|
||||
def _parse(val: str) -> datetime | str:
|
||||
try:
|
||||
s = val[:-1] + "+00:00" if val.endswith("Z") else val
|
||||
parsed = datetime.fromisoformat(s)
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return val
|
||||
|
||||
if self.col == "dttm":
|
||||
if isinstance(self.value, str):
|
||||
self.value = _parse(self.value)
|
||||
elif isinstance(self.value, list):
|
||||
self.value = [
|
||||
_parse(v) if isinstance(v, str) else v for v in self.value
|
||||
]
|
||||
return self
|
||||
|
||||
|
||||
class ActionLogInfo(BaseModel):
|
||||
id: int | None = Field(None, description="Log entry ID")
|
||||
action: str | None = Field(None, description="Action name")
|
||||
user_id: int | None = Field(
|
||||
None, description="ID of the user who performed the action"
|
||||
)
|
||||
dttm: str | datetime | None = Field(None, description="Timestamp of the action")
|
||||
dashboard_id: int | None = Field(None, description="Associated dashboard ID")
|
||||
slice_id: int | None = Field(None, description="Associated chart/slice ID")
|
||||
json: str | None = Field(
|
||||
None,
|
||||
description="JSON payload (user-controlled, wrapped in UNTRUSTED-CONTENT)",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
ser_json_timedelta="iso8601",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
if isinstance(self.dttm, datetime) and self.dttm.tzinfo is None:
|
||||
object.__setattr__(self, "dttm", self.dttm.replace(tzinfo=timezone.utc))
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _filter_fields_by_context(self, serializer: Any, info: Any) -> dict[str, Any]:
|
||||
data = serializer(self)
|
||||
if info.context and isinstance(info.context, dict):
|
||||
select_columns = info.context.get("select_columns")
|
||||
if select_columns:
|
||||
requested_fields = set(select_columns)
|
||||
return {k: v for k, v in data.items() if k in requested_fields}
|
||||
return data
|
||||
|
||||
|
||||
class ActionLogList(BaseModel):
|
||||
action_logs: list[ActionLogInfo]
|
||||
count: int
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
has_previous: bool
|
||||
has_next: bool
|
||||
columns_requested: list[str] = Field(default_factory=list)
|
||||
columns_loaded: list[str] = Field(default_factory=list)
|
||||
columns_available: list[str] = Field(default_factory=list)
|
||||
sortable_columns: list[str] = Field(default_factory=list)
|
||||
filters_applied: list[ActionLogFilter] = Field(default_factory=list)
|
||||
pagination: PaginationInfo | None = None
|
||||
timestamp: datetime | None = None
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class ListActionLogsRequest(BaseModel):
|
||||
"""Request schema for list_action_logs."""
|
||||
|
||||
filters: Annotated[
|
||||
list[ActionLogFilter],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"List of filter objects (col, opr, value). "
|
||||
"Filter columns: action, user_id, dashboard_id, slice_id, dttm. "
|
||||
"Cannot be used with 'search'."
|
||||
),
|
||||
),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
list[str],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="Columns to return. Defaults to common columns.",
|
||||
),
|
||||
]
|
||||
search: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Text search string matched against action. "
|
||||
"Cannot be used together with 'filters'."
|
||||
),
|
||||
),
|
||||
]
|
||||
order_column: Annotated[
|
||||
str | None,
|
||||
Field(default=None, description="Column to sort by (default: dttm)"),
|
||||
]
|
||||
order_direction: Annotated[
|
||||
Literal["asc", "desc"],
|
||||
Field(default="desc", description="Sort direction ('asc' or 'desc')"),
|
||||
]
|
||||
page: Annotated[
|
||||
PositiveInt,
|
||||
Field(default=1, description="Page number (1-based)"),
|
||||
]
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
gt=0,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Items per page (max {MAX_PAGE_SIZE})",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("filters", mode="before")
|
||||
@classmethod
|
||||
def parse_filters(cls, v: Any) -> list[ActionLogFilter]:
|
||||
return parse_json_or_model_list(v, ActionLogFilter, "filters")
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def parse_columns(cls, v: Any) -> list[str]:
|
||||
return parse_json_or_list(v, "select_columns")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_search_and_filters(self) -> "ListActionLogsRequest":
|
||||
if self.search and self.filters:
|
||||
raise ValueError(
|
||||
"Cannot use both 'search' and 'filters' simultaneously. "
|
||||
"Use 'search' for text matching on action, or 'filters' for "
|
||||
"column-based filtering, but not both."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class ActionLogError(BaseModel):
|
||||
error: str = Field(..., description="Error message")
|
||||
error_type: str = Field(..., description="Error type")
|
||||
timestamp: str | datetime | None = Field(None, description="Error timestamp")
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
@classmethod
|
||||
def create(cls, error: str, error_type: str) -> "ActionLogError":
|
||||
return cls(
|
||||
error=error,
|
||||
error_type=error_type,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
class GetActionLogInfoRequest(BaseModel):
|
||||
"""Request schema for get_action_log_info (ID-only lookup)."""
|
||||
|
||||
identifier: Annotated[
|
||||
int,
|
||||
Field(description="Log entry ID (integer)"),
|
||||
]
|
||||
|
||||
|
||||
def _sanitize_log_json(raw: Any) -> str | None:
|
||||
"""Serialize the log JSON blob to a canonical string and wrap it in
|
||||
UNTRUSTED-CONTENT delimiters.
|
||||
|
||||
The entire JSON blob — keys and values alike — is user-controlled and must
|
||||
be treated as untrusted. Wrapping the canonical JSON string (rather than
|
||||
processing individual dict leaves) closes the dict-key injection gap: no
|
||||
key can inject instructions because every byte of the blob is enclosed
|
||||
within the trust boundary.
|
||||
Falls back to wrapping the raw string when the payload is not valid JSON.
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
canonical = json_utils.dumps(json_utils.loads(raw))
|
||||
except (ValueError, TypeError):
|
||||
canonical = raw
|
||||
else:
|
||||
try:
|
||||
canonical = json_utils.dumps(raw)
|
||||
except (ValueError, TypeError):
|
||||
canonical = str(raw)
|
||||
return sanitize_for_llm_context(
|
||||
canonical,
|
||||
field_path=("json",),
|
||||
excluded_field_names=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
def serialize_action_log_object(log: Any) -> ActionLogInfo | None:
|
||||
if not log:
|
||||
return None
|
||||
dttm = getattr(log, "dttm", None)
|
||||
if isinstance(dttm, datetime) and dttm.tzinfo is None:
|
||||
dttm = dttm.replace(tzinfo=timezone.utc)
|
||||
return ActionLogInfo(
|
||||
id=getattr(log, "id", None),
|
||||
action=getattr(log, "action", None),
|
||||
user_id=getattr(log, "user_id", None),
|
||||
dttm=dttm,
|
||||
dashboard_id=getattr(log, "dashboard_id", None),
|
||||
slice_id=getattr(log, "slice_id", None),
|
||||
json=_sanitize_log_json(getattr(log, "json", None)),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .get_action_log_info import get_action_log_info
|
||||
from .list_action_logs import list_action_logs
|
||||
|
||||
__all__ = [
|
||||
"list_action_logs",
|
||||
"get_action_log_info",
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Get action log info MCP tool."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.daos.log import LogDAO
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.action_log.schemas import (
|
||||
ActionLogError,
|
||||
ActionLogInfo,
|
||||
GetActionLogInfoRequest,
|
||||
serialize_action_log_object,
|
||||
)
|
||||
from superset.mcp_service.mcp_core import ModelGetInfoCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="Log",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get action log info",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def get_action_log_info(
|
||||
request: GetActionLogInfoRequest,
|
||||
ctx: Context,
|
||||
) -> ActionLogInfo | ActionLogError:
|
||||
"""Get a single action log entry by its integer ID.
|
||||
|
||||
Returns the action, user_id, timestamp (dttm), dashboard_id, slice_id,
|
||||
and JSON payload for the specified log record.
|
||||
|
||||
Requires the Log permission (controlled by Superset's RBAC). Users without
|
||||
that permission will receive a permission error.
|
||||
|
||||
Use list_action_logs to discover log IDs.
|
||||
"""
|
||||
await ctx.info("Retrieving action log: identifier=%s" % (request.identifier,))
|
||||
|
||||
try:
|
||||
with event_logger.log_context(action="mcp.get_action_log_info.lookup"):
|
||||
get_tool = ModelGetInfoCore(
|
||||
dao_class=LogDAO,
|
||||
output_schema=ActionLogInfo,
|
||||
error_schema=ActionLogError,
|
||||
serializer=serialize_action_log_object,
|
||||
supports_slug=False,
|
||||
logger=logger,
|
||||
)
|
||||
result = get_tool.run_tool(request.identifier)
|
||||
|
||||
if isinstance(result, ActionLogInfo):
|
||||
await ctx.info(
|
||||
"Action log retrieved: id=%s, action=%s" % (result.id, result.action)
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"Action log retrieval failed: error_type=%s, error=%s"
|
||||
% (result.error_type, result.error)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Action log retrieval failed: identifier=%s, error=%s, error_type=%s"
|
||||
% (request.identifier, str(e), type(e).__name__)
|
||||
)
|
||||
return ActionLogError(
|
||||
error=f"Failed to get action log info: {str(e)}",
|
||||
error_type="InternalError",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""List action logs MCP tool."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
|
||||
from superset.daos.log import LogDAO
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.action_log.schemas import (
|
||||
ActionLogError,
|
||||
ActionLogFilter,
|
||||
ActionLogInfo,
|
||||
ActionLogList,
|
||||
ALL_LOG_COLUMNS,
|
||||
DEFAULT_LOG_COLUMNS,
|
||||
ListActionLogsRequest,
|
||||
LOG_SORTABLE_COLUMNS,
|
||||
serialize_action_log_object,
|
||||
)
|
||||
from superset.mcp_service.mcp_core import ModelListCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_LIST_ACTION_LOGS_REQUEST = ListActionLogsRequest()
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="Log",
|
||||
annotations=ToolAnnotations(
|
||||
title="List action logs",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def list_action_logs(
|
||||
request: ListActionLogsRequest | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> ActionLogList | ActionLogError:
|
||||
"""List Superset action logs with filtering and pagination.
|
||||
|
||||
Returns audit log entries recording user interactions with dashboards and
|
||||
charts. Defaults to the last 7 days to avoid pulling large result sets.
|
||||
|
||||
Requires the Log permission (controlled by Superset's RBAC). Users without
|
||||
that permission will receive a permission error.
|
||||
|
||||
Sortable columns for order_column: id, dttm
|
||||
Filter columns: action, user_id, dashboard_id, slice_id, dttm
|
||||
|
||||
When no dttm filter is provided the tool automatically applies
|
||||
dttm >= (now - 7 days). Add an explicit dttm filter to override.
|
||||
"""
|
||||
if ctx is None:
|
||||
raise RuntimeError("FastMCP context is required for list_action_logs")
|
||||
|
||||
request = request or _DEFAULT_LIST_ACTION_LOGS_REQUEST.model_copy(deep=True)
|
||||
|
||||
await ctx.info(
|
||||
"Listing action logs: page=%s, page_size=%s" % (request.page, request.page_size)
|
||||
)
|
||||
await ctx.debug(
|
||||
"Action log parameters: filters=%s, order_column=%s, order_direction=%s"
|
||||
% (request.filters, request.order_column, request.order_direction)
|
||||
)
|
||||
|
||||
try:
|
||||
# Inject default 7-day dttm filter unless caller already provides one
|
||||
filters: list[ColumnOperator] = list(request.filters)
|
||||
has_dttm_filter = any(getattr(f, "col", None) == "dttm" for f in filters)
|
||||
if not has_dttm_filter:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
default_filter = ActionLogFilter(
|
||||
col="dttm",
|
||||
opr=ColumnOperatorEnum.gte,
|
||||
value=cutoff,
|
||||
)
|
||||
filters = [default_filter] + filters
|
||||
await ctx.debug("Applied default 7-day dttm filter: cutoff=%s" % (cutoff,))
|
||||
|
||||
def _serialize(obj: object, cols: list[str] | None) -> ActionLogInfo | None:
|
||||
return serialize_action_log_object(obj)
|
||||
|
||||
list_tool = ModelListCore(
|
||||
dao_class=LogDAO,
|
||||
output_schema=ActionLogInfo,
|
||||
item_serializer=_serialize,
|
||||
filter_type=ActionLogFilter,
|
||||
default_columns=DEFAULT_LOG_COLUMNS,
|
||||
search_columns=["action"],
|
||||
list_field_name="action_logs",
|
||||
output_list_schema=ActionLogList,
|
||||
all_columns=ALL_LOG_COLUMNS,
|
||||
sortable_columns=LOG_SORTABLE_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.list_action_logs.query"):
|
||||
result = list_tool.run_tool(
|
||||
filters=filters,
|
||||
search=request.search,
|
||||
select_columns=request.select_columns,
|
||||
order_column=request.order_column or "dttm",
|
||||
order_direction=request.order_direction,
|
||||
page=max(request.page - 1, 0),
|
||||
page_size=request.page_size,
|
||||
)
|
||||
|
||||
await ctx.info(
|
||||
"Action logs listed: count=%s, total_count=%s"
|
||||
% (
|
||||
len(result.action_logs) if hasattr(result, "action_logs") else 0,
|
||||
getattr(result, "total_count", None),
|
||||
)
|
||||
)
|
||||
columns_to_filter = result.columns_requested
|
||||
await ctx.debug(
|
||||
"Applying field filtering via serialization context: columns=%s"
|
||||
% (columns_to_filter,)
|
||||
)
|
||||
with event_logger.log_context(action="mcp.list_action_logs.serialization"):
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
context={"select_columns": columns_to_filter},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Action log listing failed: page=%s, error=%s, error_type=%s"
|
||||
% (request.page, str(e), type(e).__name__)
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,367 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Pydantic schemas for annotation layer and annotation responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_validator,
|
||||
PositiveInt,
|
||||
)
|
||||
|
||||
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
|
||||
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
from superset.mcp_service.system.schemas import PaginationInfo
|
||||
from superset.mcp_service.utils import sanitize_for_llm_context
|
||||
from superset.mcp_service.utils.schema_utils import (
|
||||
parse_json_or_list,
|
||||
parse_json_or_model_list,
|
||||
)
|
||||
from superset.utils import json as json_utils
|
||||
|
||||
DEFAULT_LAYER_COLUMNS = ["id", "name", "descr"]
|
||||
DEFAULT_ANNOTATION_COLUMNS = ["id", "short_descr", "start_dttm", "end_dttm", "layer_id"]
|
||||
|
||||
|
||||
class AnnotationLayerFilter(ColumnOperator):
|
||||
"""Filter object for annotation layer listing."""
|
||||
|
||||
col: Literal["name"] = Field(
|
||||
...,
|
||||
description="Column to filter on. Supported: 'name'.",
|
||||
)
|
||||
opr: ColumnOperatorEnum = Field(..., description="Filter operator.")
|
||||
value: str | int | float | bool | list[str | int | float | bool] = Field(
|
||||
..., description="Value to filter by."
|
||||
)
|
||||
|
||||
|
||||
class AnnotationFilter(ColumnOperator):
|
||||
"""Filter object for annotation listing."""
|
||||
|
||||
col: Literal["short_descr"] = Field(
|
||||
...,
|
||||
description="Column to filter on. Supported: 'short_descr'.",
|
||||
)
|
||||
opr: ColumnOperatorEnum = Field(..., description="Filter operator.")
|
||||
value: str | int | float | bool | list[str | int | float | bool] = Field(
|
||||
..., description="Value to filter by."
|
||||
)
|
||||
|
||||
|
||||
class AnnotationLayerInfo(BaseModel):
|
||||
id: int | None = Field(None, description="Annotation layer ID")
|
||||
name: str | None = Field(None, description="Annotation layer name")
|
||||
descr: str | None = Field(None, description="Annotation layer description")
|
||||
changed_on: str | datetime | None = Field(
|
||||
None, description="Last modification timestamp"
|
||||
)
|
||||
created_on: str | datetime | None = Field(None, description="Creation timestamp")
|
||||
model_config = ConfigDict(from_attributes=True, ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class AnnotationLayerList(BaseModel):
|
||||
annotation_layers: list[AnnotationLayerInfo]
|
||||
count: int
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
has_previous: bool
|
||||
has_next: bool
|
||||
columns_requested: list[str] = Field(default_factory=list)
|
||||
columns_loaded: list[str] = Field(default_factory=list)
|
||||
columns_available: list[str] = Field(default_factory=list)
|
||||
sortable_columns: list[str] = Field(default_factory=list)
|
||||
filters_applied: list[AnnotationLayerFilter] = Field(default_factory=list)
|
||||
pagination: PaginationInfo | None = None
|
||||
timestamp: datetime | None = None
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class ListAnnotationLayersRequest(BaseModel):
|
||||
"""Request schema for list_annotation_layers."""
|
||||
|
||||
filters: Annotated[
|
||||
list[AnnotationLayerFilter],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of filter objects. Cannot be combined with 'search'.",
|
||||
),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
list[str],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="Columns to include in the response.",
|
||||
),
|
||||
]
|
||||
search: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Text search across annotation layer name and description.",
|
||||
),
|
||||
]
|
||||
order_column: Annotated[
|
||||
str | None, Field(default=None, description="Column to order results by.")
|
||||
]
|
||||
order_direction: Annotated[
|
||||
Literal["asc", "desc"],
|
||||
Field(default="desc", description="Sort direction."),
|
||||
]
|
||||
page: Annotated[
|
||||
PositiveInt,
|
||||
Field(default=1, description="Page number (1-based)."),
|
||||
]
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
gt=0,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Items per page (max {MAX_PAGE_SIZE}).",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("filters", mode="before")
|
||||
@classmethod
|
||||
def parse_filters(cls, v: Any) -> list[AnnotationLayerFilter]:
|
||||
return parse_json_or_model_list(v, AnnotationLayerFilter, "filters")
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def parse_columns(cls, v: Any) -> list[str]:
|
||||
return parse_json_or_list(v, "select_columns")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_search_and_filters(self) -> "ListAnnotationLayersRequest":
|
||||
if self.search and self.filters:
|
||||
raise ValueError("Cannot use both 'search' and 'filters' simultaneously.")
|
||||
return self
|
||||
|
||||
|
||||
class GetAnnotationLayerInfoRequest(BaseModel):
|
||||
"""Request schema for get_annotation_layer_info."""
|
||||
|
||||
id: Annotated[int, Field(description="Annotation layer ID.")]
|
||||
|
||||
|
||||
class AnnotationInfo(BaseModel):
|
||||
id: int | None = Field(None, description="Annotation ID")
|
||||
short_descr: str | None = Field(None, description="Short description")
|
||||
long_descr: str | None = Field(None, description="Long description")
|
||||
start_dttm: str | datetime | None = Field(None, description="Start datetime")
|
||||
end_dttm: str | datetime | None = Field(None, description="End datetime")
|
||||
json_metadata: str | None = Field(None, description="JSON metadata")
|
||||
layer_id: int | None = Field(None, description="Parent annotation layer ID")
|
||||
model_config = ConfigDict(from_attributes=True, ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class AnnotationList(BaseModel):
|
||||
annotations: list[AnnotationInfo]
|
||||
count: int
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
has_previous: bool
|
||||
has_next: bool
|
||||
# layer_id defaults to 0; the tool sets it after ModelListCore constructs this
|
||||
# object. ModelListCore does not know about this domain-specific field.
|
||||
layer_id: int = 0
|
||||
columns_requested: list[str] = Field(default_factory=list)
|
||||
columns_loaded: list[str] = Field(default_factory=list)
|
||||
columns_available: list[str] = Field(default_factory=list)
|
||||
sortable_columns: list[str] = Field(default_factory=list)
|
||||
filters_applied: list[ColumnOperator] = Field(default_factory=list)
|
||||
pagination: PaginationInfo | None = None
|
||||
timestamp: datetime | None = None
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class ListLayerAnnotationsRequest(BaseModel):
|
||||
"""Request schema for list_layer_annotations."""
|
||||
|
||||
layer_id: Annotated[
|
||||
int, Field(description="Annotation layer ID to list annotations for.")
|
||||
]
|
||||
filters: Annotated[
|
||||
list[AnnotationFilter],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of filter objects. Cannot be combined with 'search'.",
|
||||
),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
list[str],
|
||||
Field(default_factory=list, description="Columns to include in the response."),
|
||||
]
|
||||
search: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Text search across annotation short and long description.",
|
||||
),
|
||||
]
|
||||
order_column: Annotated[
|
||||
str | None, Field(default=None, description="Column to order results by.")
|
||||
]
|
||||
order_direction: Annotated[
|
||||
Literal["asc", "desc"],
|
||||
Field(default="desc", description="Sort direction."),
|
||||
]
|
||||
page: Annotated[
|
||||
PositiveInt,
|
||||
Field(default=1, description="Page number (1-based)."),
|
||||
]
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
gt=0,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Items per page (max {MAX_PAGE_SIZE}).",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("filters", mode="before")
|
||||
@classmethod
|
||||
def parse_filters(cls, v: Any) -> list[AnnotationFilter]:
|
||||
return parse_json_or_model_list(v, AnnotationFilter, "filters")
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def parse_columns(cls, v: Any) -> list[str]:
|
||||
return parse_json_or_list(v, "select_columns")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_search_and_filters(self) -> "ListLayerAnnotationsRequest":
|
||||
if self.search and self.filters:
|
||||
raise ValueError("Cannot use both 'search' and 'filters' simultaneously.")
|
||||
return self
|
||||
|
||||
|
||||
class GetLayerAnnotationInfoRequest(BaseModel):
|
||||
"""Request schema for get_layer_annotation_info."""
|
||||
|
||||
layer_id: Annotated[int, Field(description="Annotation layer ID.")]
|
||||
annotation_id: Annotated[int, Field(description="Annotation ID.")]
|
||||
|
||||
|
||||
class AnnotationLayerError(BaseModel):
|
||||
error: str = Field(..., description="Error message")
|
||||
error_type: str = Field(..., description="Type of error")
|
||||
timestamp: str | datetime | None = Field(None, description="Error timestamp")
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
@classmethod
|
||||
def create(cls, error: str, error_type: str) -> "AnnotationLayerError":
|
||||
from datetime import timezone
|
||||
|
||||
return cls(
|
||||
error=error,
|
||||
error_type=error_type,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_annotation_layer_for_llm_context(
|
||||
info: AnnotationLayerInfo,
|
||||
) -> AnnotationLayerInfo:
|
||||
payload = info.model_dump(mode="python")
|
||||
for field_name in ("name", "descr"):
|
||||
payload[field_name] = sanitize_for_llm_context(
|
||||
payload.get(field_name), field_path=(field_name,)
|
||||
)
|
||||
return AnnotationLayerInfo.model_validate(payload)
|
||||
|
||||
|
||||
def _sanitize_annotation_json_metadata(raw: Any) -> str | None:
|
||||
"""Canonicalize and sanitize the json_metadata blob before LLM exposure.
|
||||
|
||||
Serializing to a canonical JSON string first prevents dict-key injection:
|
||||
keys are rendered as quoted string literals inside the wrapped value rather
|
||||
than being able to escape the delimiter context.
|
||||
"""
|
||||
if raw is None:
|
||||
return None
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
canonical: str = json_utils.dumps(json_utils.loads(raw))
|
||||
except (ValueError, TypeError):
|
||||
canonical = raw
|
||||
else:
|
||||
try:
|
||||
canonical = json_utils.dumps(raw)
|
||||
except (ValueError, TypeError):
|
||||
canonical = str(raw)
|
||||
return sanitize_for_llm_context(
|
||||
canonical,
|
||||
field_path=("json_metadata",),
|
||||
excluded_field_names=frozenset(),
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_annotation_for_llm_context(info: AnnotationInfo) -> AnnotationInfo:
|
||||
payload = info.model_dump(mode="python")
|
||||
for field_name in ("short_descr", "long_descr"):
|
||||
payload[field_name] = sanitize_for_llm_context(
|
||||
payload.get(field_name), field_path=(field_name,)
|
||||
)
|
||||
payload["json_metadata"] = _sanitize_annotation_json_metadata(
|
||||
payload.get("json_metadata")
|
||||
)
|
||||
return AnnotationInfo.model_validate(payload)
|
||||
|
||||
|
||||
def serialize_annotation_layer(obj: Any) -> AnnotationLayerInfo | None:
|
||||
if not obj:
|
||||
return None
|
||||
return _sanitize_annotation_layer_for_llm_context(
|
||||
AnnotationLayerInfo(
|
||||
id=getattr(obj, "id", None),
|
||||
name=getattr(obj, "name", None),
|
||||
descr=getattr(obj, "descr", None),
|
||||
changed_on=getattr(obj, "changed_on", None),
|
||||
created_on=getattr(obj, "created_on", None),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def serialize_annotation(obj: Any) -> AnnotationInfo | None:
|
||||
if not obj:
|
||||
return None
|
||||
return _sanitize_annotation_for_llm_context(
|
||||
AnnotationInfo(
|
||||
id=getattr(obj, "id", None),
|
||||
short_descr=getattr(obj, "short_descr", None),
|
||||
long_descr=getattr(obj, "long_descr", None),
|
||||
start_dttm=getattr(obj, "start_dttm", None),
|
||||
end_dttm=getattr(obj, "end_dttm", None),
|
||||
json_metadata=getattr(obj, "json_metadata", None),
|
||||
layer_id=getattr(obj, "layer_id", None),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .get_annotation_layer_info import get_annotation_layer_info
|
||||
from .get_layer_annotation_info import get_layer_annotation_info
|
||||
from .list_annotation_layers import list_annotation_layers
|
||||
from .list_layer_annotations import list_layer_annotations
|
||||
|
||||
__all__ = [
|
||||
"list_annotation_layers",
|
||||
"get_annotation_layer_info",
|
||||
"list_layer_annotations",
|
||||
"get_layer_annotation_info",
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Get annotation layer info FastMCP tool."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.annotation_layer.schemas import (
|
||||
AnnotationLayerError,
|
||||
AnnotationLayerInfo,
|
||||
GetAnnotationLayerInfoRequest,
|
||||
serialize_annotation_layer,
|
||||
)
|
||||
from superset.mcp_service.mcp_core import ModelGetInfoCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="Annotation",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get annotation layer info",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def get_annotation_layer_info(
|
||||
request: GetAnnotationLayerInfoRequest,
|
||||
ctx: Context,
|
||||
) -> AnnotationLayerInfo | AnnotationLayerError:
|
||||
"""Get detailed information about an annotation layer by ID.
|
||||
|
||||
Returns the layer's name, description, and timestamps.
|
||||
|
||||
Example:
|
||||
```json
|
||||
{"id": 1}
|
||||
```
|
||||
"""
|
||||
await ctx.info("Retrieving annotation layer: id=%s" % (request.id,))
|
||||
|
||||
try:
|
||||
from superset.daos.annotation_layer import AnnotationLayerDAO
|
||||
|
||||
with event_logger.log_context(action="mcp.get_annotation_layer_info.lookup"):
|
||||
get_tool = ModelGetInfoCore(
|
||||
dao_class=AnnotationLayerDAO,
|
||||
output_schema=AnnotationLayerInfo,
|
||||
error_schema=AnnotationLayerError,
|
||||
serializer=serialize_annotation_layer,
|
||||
supports_slug=False,
|
||||
logger=logger,
|
||||
)
|
||||
result = get_tool.run_tool(request.id)
|
||||
|
||||
if isinstance(result, AnnotationLayerInfo):
|
||||
await ctx.info(
|
||||
"Annotation layer retrieved: id=%s, name=%s" % (result.id, result.name)
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"Annotation layer not found: id=%s, error_type=%s"
|
||||
% (request.id, result.error_type)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Annotation layer lookup failed: id=%s, error=%s, error_type=%s"
|
||||
% (request.id, str(e), type(e).__name__)
|
||||
)
|
||||
return AnnotationLayerError(
|
||||
error=f"Failed to get annotation layer info: {str(e)}",
|
||||
error_type="InternalError",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Get a single annotation within a layer FastMCP tool."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.annotation_layer.schemas import (
|
||||
AnnotationInfo,
|
||||
AnnotationLayerError,
|
||||
GetLayerAnnotationInfoRequest,
|
||||
serialize_annotation,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="Annotation",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get annotation info",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def get_layer_annotation_info(
|
||||
request: GetLayerAnnotationInfoRequest,
|
||||
ctx: Context,
|
||||
) -> AnnotationInfo | AnnotationLayerError:
|
||||
"""Get detailed information about a specific annotation within a layer.
|
||||
|
||||
Both layer_id and annotation_id are required. Returns an error if the
|
||||
annotation does not belong to the specified layer.
|
||||
|
||||
Example:
|
||||
```json
|
||||
{"layer_id": 1, "annotation_id": 42}
|
||||
```
|
||||
"""
|
||||
await ctx.info(
|
||||
"Retrieving annotation: layer_id=%s, annotation_id=%s"
|
||||
% (request.layer_id, request.annotation_id)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.annotation_layer import AnnotationDAO, AnnotationLayerDAO
|
||||
|
||||
# Verify the layer exists
|
||||
with event_logger.log_context(
|
||||
action="mcp.get_layer_annotation_info.layer_lookup"
|
||||
):
|
||||
layer = AnnotationLayerDAO.find_by_id(request.layer_id)
|
||||
|
||||
if layer is None:
|
||||
await ctx.warning("Annotation layer not found: id=%s" % (request.layer_id,))
|
||||
return AnnotationLayerError.create(
|
||||
error=f"Annotation layer with id '{request.layer_id}' not found",
|
||||
error_type="not_found",
|
||||
)
|
||||
|
||||
# Fetch the annotation
|
||||
with event_logger.log_context(
|
||||
action="mcp.get_layer_annotation_info.annotation_lookup"
|
||||
):
|
||||
annotation = AnnotationDAO.find_by_id(request.annotation_id)
|
||||
|
||||
if annotation is None:
|
||||
await ctx.warning(
|
||||
"Annotation not found: annotation_id=%s" % (request.annotation_id,)
|
||||
)
|
||||
return AnnotationLayerError.create(
|
||||
error=f"Annotation with id '{request.annotation_id}' not found",
|
||||
error_type="not_found",
|
||||
)
|
||||
|
||||
# Verify the annotation belongs to the requested layer
|
||||
if getattr(annotation, "layer_id", None) != request.layer_id:
|
||||
await ctx.warning(
|
||||
"Annotation %s does not belong to layer %s"
|
||||
% (request.annotation_id, request.layer_id)
|
||||
)
|
||||
return AnnotationLayerError.create(
|
||||
error=(
|
||||
f"Annotation '{request.annotation_id}' does not belong to "
|
||||
f"layer '{request.layer_id}'"
|
||||
),
|
||||
error_type="not_found",
|
||||
)
|
||||
|
||||
result = serialize_annotation(annotation)
|
||||
await ctx.info(
|
||||
"Annotation retrieved: id=%s, short_descr=%s"
|
||||
% (result.id if result else None, result.short_descr if result else None)
|
||||
)
|
||||
return result or AnnotationLayerError.create(
|
||||
error="Failed to serialize annotation",
|
||||
error_type="SerializationError",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Annotation lookup failed: layer_id=%s, annotation_id=%s, "
|
||||
"error=%s, error_type=%s"
|
||||
% (request.layer_id, request.annotation_id, str(e), type(e).__name__)
|
||||
)
|
||||
return AnnotationLayerError(
|
||||
error=f"Failed to get annotation info: {str(e)}",
|
||||
error_type="InternalError",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""List annotation layers FastMCP tool."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.annotation_layer.schemas import (
|
||||
AnnotationLayerError,
|
||||
AnnotationLayerFilter,
|
||||
AnnotationLayerInfo,
|
||||
AnnotationLayerList,
|
||||
DEFAULT_LAYER_COLUMNS,
|
||||
ListAnnotationLayersRequest,
|
||||
serialize_annotation_layer,
|
||||
)
|
||||
from superset.mcp_service.mcp_core import ModelListCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_REQUEST = ListAnnotationLayersRequest()
|
||||
|
||||
_ALL_LAYER_COLUMNS = ["id", "name", "descr", "changed_on", "created_on"]
|
||||
_SORTABLE_LAYER_COLUMNS = ["id", "name", "changed_on", "created_on"]
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="Annotation",
|
||||
annotations=ToolAnnotations(
|
||||
title="List annotation layers",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def list_annotation_layers(
|
||||
request: ListAnnotationLayersRequest | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> AnnotationLayerList | AnnotationLayerError:
|
||||
"""List annotation layers with filtering, search, and pagination.
|
||||
|
||||
Returns annotation layer metadata including name and description.
|
||||
|
||||
Sortable columns for order_column: id, name, changed_on, created_on
|
||||
"""
|
||||
if ctx is None:
|
||||
raise RuntimeError("FastMCP context is required for list_annotation_layers")
|
||||
|
||||
request = request or _DEFAULT_REQUEST.model_copy(deep=True)
|
||||
|
||||
await ctx.info(
|
||||
"Listing annotation layers: page=%s, page_size=%s, search=%s"
|
||||
% (request.page, request.page_size, request.search)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.annotation_layer import AnnotationLayerDAO
|
||||
|
||||
def _serialize(
|
||||
obj: object, cols: list[str] | None
|
||||
) -> AnnotationLayerInfo | None:
|
||||
return serialize_annotation_layer(obj)
|
||||
|
||||
list_tool = ModelListCore(
|
||||
dao_class=AnnotationLayerDAO,
|
||||
output_schema=AnnotationLayerInfo,
|
||||
item_serializer=_serialize,
|
||||
filter_type=AnnotationLayerFilter,
|
||||
default_columns=DEFAULT_LAYER_COLUMNS,
|
||||
search_columns=["name", "descr"],
|
||||
list_field_name="annotation_layers",
|
||||
output_list_schema=AnnotationLayerList,
|
||||
all_columns=_ALL_LAYER_COLUMNS,
|
||||
sortable_columns=_SORTABLE_LAYER_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.list_annotation_layers.query"):
|
||||
result = list_tool.run_tool(
|
||||
filters=request.filters,
|
||||
search=request.search,
|
||||
select_columns=request.select_columns,
|
||||
order_column=request.order_column,
|
||||
order_direction=request.order_direction,
|
||||
page=max(request.page - 1, 0),
|
||||
page_size=request.page_size,
|
||||
)
|
||||
|
||||
await ctx.info(
|
||||
"Annotation layers listed: count=%s, total_count=%s"
|
||||
% (
|
||||
len(result.annotation_layers)
|
||||
if hasattr(result, "annotation_layers")
|
||||
else 0,
|
||||
getattr(result, "total_count", None),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Annotation layer listing failed: error=%s, error_type=%s"
|
||||
% (str(e), type(e).__name__)
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,144 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""List annotations within a layer FastMCP tool."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.annotation_layer.schemas import (
|
||||
AnnotationFilter,
|
||||
AnnotationInfo,
|
||||
AnnotationLayerError,
|
||||
AnnotationList,
|
||||
DEFAULT_ANNOTATION_COLUMNS,
|
||||
ListLayerAnnotationsRequest,
|
||||
serialize_annotation,
|
||||
)
|
||||
from superset.mcp_service.mcp_core import ModelListCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ALL_ANNOTATION_COLUMNS = [
|
||||
"id",
|
||||
"short_descr",
|
||||
"long_descr",
|
||||
"start_dttm",
|
||||
"end_dttm",
|
||||
"json_metadata",
|
||||
"layer_id",
|
||||
]
|
||||
_SORTABLE_ANNOTATION_COLUMNS = ["id", "short_descr", "start_dttm", "end_dttm"]
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="Annotation",
|
||||
annotations=ToolAnnotations(
|
||||
title="List annotations in a layer",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def list_layer_annotations(
|
||||
request: ListLayerAnnotationsRequest,
|
||||
ctx: Context,
|
||||
) -> AnnotationList | AnnotationLayerError:
|
||||
"""List annotations within a specific annotation layer.
|
||||
|
||||
The layer_id parameter is required and scopes all results to that layer.
|
||||
|
||||
Sortable columns for order_column: id, short_descr, start_dttm, end_dttm
|
||||
|
||||
Example:
|
||||
```json
|
||||
{"layer_id": 1, "page": 1, "page_size": 25}
|
||||
```
|
||||
"""
|
||||
await ctx.info(
|
||||
"Listing annotations: layer_id=%s, page=%s, page_size=%s, search=%s"
|
||||
% (request.layer_id, request.page, request.page_size, request.search)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.annotation_layer import AnnotationDAO, AnnotationLayerDAO
|
||||
|
||||
# Verify the layer exists before listing
|
||||
layer = AnnotationLayerDAO.find_by_id(request.layer_id)
|
||||
if layer is None:
|
||||
await ctx.warning("Annotation layer not found: id=%s" % (request.layer_id,))
|
||||
return AnnotationLayerError.create(
|
||||
error=f"Annotation layer with id '{request.layer_id}' not found",
|
||||
error_type="not_found",
|
||||
)
|
||||
|
||||
# Prepend the layer_id filter so results are scoped to this layer
|
||||
layer_filter = ColumnOperator(
|
||||
col="layer_id", opr=ColumnOperatorEnum.eq, value=request.layer_id
|
||||
)
|
||||
combined_filters: list[ColumnOperator] = [layer_filter] + list(request.filters)
|
||||
|
||||
def _serialize(obj: object, cols: list[str] | None) -> AnnotationInfo | None:
|
||||
return serialize_annotation(obj)
|
||||
|
||||
list_tool = ModelListCore(
|
||||
dao_class=AnnotationDAO,
|
||||
output_schema=AnnotationInfo,
|
||||
item_serializer=_serialize,
|
||||
filter_type=AnnotationFilter,
|
||||
default_columns=DEFAULT_ANNOTATION_COLUMNS,
|
||||
search_columns=["short_descr", "long_descr"],
|
||||
list_field_name="annotations",
|
||||
output_list_schema=AnnotationList,
|
||||
all_columns=_ALL_ANNOTATION_COLUMNS,
|
||||
sortable_columns=_SORTABLE_ANNOTATION_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.list_layer_annotations.query"):
|
||||
result = list_tool.run_tool(
|
||||
filters=combined_filters,
|
||||
search=request.search,
|
||||
select_columns=request.select_columns,
|
||||
order_column=request.order_column,
|
||||
order_direction=request.order_direction,
|
||||
page=max(request.page - 1, 0),
|
||||
page_size=request.page_size,
|
||||
)
|
||||
|
||||
result.layer_id = request.layer_id
|
||||
|
||||
await ctx.info(
|
||||
"Annotations listed: layer_id=%s, count=%s, total_count=%s"
|
||||
% (
|
||||
request.layer_id,
|
||||
len(result.annotations) if hasattr(result, "annotations") else 0,
|
||||
getattr(result, "total_count", None),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Annotation listing failed: layer_id=%s, error=%s, error_type=%s"
|
||||
% (request.layer_id, str(e), type(e).__name__)
|
||||
)
|
||||
raise
|
||||
+134
-2
@@ -127,13 +127,40 @@ Available tools:
|
||||
Dashboard Management:
|
||||
- list_dashboards: List dashboards with advanced filters (1-based pagination)
|
||||
- get_dashboard_info: Get detailed dashboard information by ID
|
||||
- get_dashboard_layout: Get parsed tabs and chart positions for a dashboard (companion to get_dashboard_info when its omitted_fields hint flags position_json)
|
||||
- generate_dashboard: Create a dashboard from chart IDs (requires write access)
|
||||
- add_chart_to_existing_dashboard: Add a chart to an existing dashboard (requires write access)
|
||||
|
||||
Annotation Layers:
|
||||
- list_annotation_layers: List annotation layers with advanced filters (1-based pagination)
|
||||
- get_annotation_layer_info: Get annotation layer details by ID
|
||||
- list_layer_annotations: List annotations within a layer (requires layer_id, 1-based pagination)
|
||||
- get_layer_annotation_info: Get annotation details by layer_id and annotation_id
|
||||
|
||||
Tag Management:
|
||||
- list_tags: List tags with advanced filters (1-based pagination)
|
||||
- get_tag_info: Get detailed tag information by ID
|
||||
|
||||
Database Connections:
|
||||
- list_databases: List database connections with advanced filters (1-based pagination)
|
||||
- get_database_info: Get detailed database connection info by ID (backend, capabilities)
|
||||
|
||||
CSS Templates:
|
||||
- list_css_templates: List CSS templates with advanced filters (1-based pagination)
|
||||
- get_css_template_info: Get CSS template details by ID (includes full css content)
|
||||
- create_css_template: Create a new named CSS template for dashboard styling
|
||||
- update_css_template: Update an existing CSS template's name or CSS content
|
||||
|
||||
Themes:
|
||||
- list_themes: List themes with advanced filters (1-based pagination)
|
||||
- get_theme_info: Get theme details by ID or UUID (includes json_data configuration)
|
||||
|
||||
User and Role Management:
|
||||
- list_users: List users with filtering (1-based pagination, admin only)
|
||||
- get_user_info: Get user details by ID (admin only)
|
||||
- list_roles: List roles with filtering (1-based pagination, admin only)
|
||||
- get_role_info: Get role details by ID (admin only)
|
||||
|
||||
Dataset Management:
|
||||
- list_datasets: List datasets with advanced filters (1-based pagination)
|
||||
- get_dataset_info: Get detailed dataset information by ID (includes columns/metrics)
|
||||
@@ -155,9 +182,21 @@ SQL Lab Integration:
|
||||
- execute_sql: Execute SQL queries and get results (requires database_id and SQL access)
|
||||
- save_sql_query: Save a SQL query to Saved Queries list (requires write access)
|
||||
- open_sql_lab_with_context: Generate SQL Lab URL with pre-filled sql
|
||||
- list_saved_queries: List saved SQL queries with filtering and search (1-based pagination)
|
||||
- get_saved_query_info: Get saved query details by ID or UUID
|
||||
- list_queries: List SQL query history with filtering and search (1-based pagination)
|
||||
- get_query_info: Get SQL query history details by ID
|
||||
|
||||
Schema Discovery:
|
||||
- get_schema: Get schema metadata for chart/dataset/dashboard (columns, filters)
|
||||
- get_schema: Get schema metadata for chart/dataset/dashboard/database/css_template/theme (columns, filters)
|
||||
|
||||
Action Logs (requires SUPERSET_LOG_VIEW and FAB_ADD_SECURITY_VIEWS):
|
||||
- list_action_logs: List user action logs with filtering and pagination (defaults to last 7 days)
|
||||
- get_action_log_info: Get a single action log entry by integer ID
|
||||
|
||||
Task Management (requires GLOBAL_TASK_FRAMEWORK feature flag):
|
||||
- list_tasks: List background tasks with status filtering and pagination
|
||||
- get_task_info: Get task details by integer ID or UUID
|
||||
|
||||
System Information:
|
||||
- get_instance_info: Get instance-wide statistics, metadata, and current user identity
|
||||
@@ -365,6 +404,8 @@ IMPORTANT - Tool-Only Interaction:
|
||||
General usage tips:
|
||||
- All listing tools use 1-based pagination (first page is 1)
|
||||
- Use get_schema to discover filterable columns, sortable columns, and default columns
|
||||
for chart/dataset/dashboard/database. For action_log and task tools, consult each
|
||||
tool's docstring — filterable and sortable columns are listed there directly.
|
||||
- Use 'filters' parameter for advanced queries with filter columns from get_schema
|
||||
- IDs can be integer or UUID format where supported
|
||||
- All tools return structured, Pydantic-typed responses
|
||||
@@ -620,6 +661,16 @@ warnings.filterwarnings(
|
||||
# NOTE: Always add new prompt/resource imports here when creating new prompts/resources.
|
||||
# Prompts use @mcp.prompt decorators and resources use @mcp.resource decorators.
|
||||
# They register automatically on import, similar to tools.
|
||||
from superset.mcp_service.action_log.tool import ( # noqa: F401, E402
|
||||
get_action_log_info,
|
||||
list_action_logs,
|
||||
)
|
||||
from superset.mcp_service.annotation_layer.tool import ( # noqa: F401, E402
|
||||
get_annotation_layer_info,
|
||||
get_layer_annotation_info,
|
||||
list_annotation_layers,
|
||||
list_layer_annotations,
|
||||
)
|
||||
from superset.mcp_service.chart import ( # noqa: F401, E402
|
||||
prompts as chart_prompts,
|
||||
resources as chart_resources,
|
||||
@@ -635,10 +686,17 @@ from superset.mcp_service.chart.tool import ( # noqa: F401, E402
|
||||
update_chart,
|
||||
update_chart_preview,
|
||||
)
|
||||
from superset.mcp_service.css_template.tool import ( # noqa: F401, E402
|
||||
create_css_template,
|
||||
get_css_template_info,
|
||||
list_css_templates,
|
||||
update_css_template,
|
||||
)
|
||||
from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
|
||||
add_chart_to_existing_dashboard,
|
||||
generate_dashboard,
|
||||
get_dashboard_info,
|
||||
get_dashboard_layout,
|
||||
list_dashboards,
|
||||
)
|
||||
from superset.mcp_service.database.tool import ( # noqa: F401, E402
|
||||
@@ -654,6 +712,18 @@ from superset.mcp_service.dataset.tool import ( # noqa: F401, E402
|
||||
from superset.mcp_service.explore.tool import ( # noqa: F401, E402
|
||||
generate_explore_link,
|
||||
)
|
||||
from superset.mcp_service.query.tool import ( # noqa: F401, E402
|
||||
get_query_info,
|
||||
list_queries,
|
||||
)
|
||||
from superset.mcp_service.role.tool import ( # noqa: F401, E402
|
||||
get_role_info,
|
||||
list_roles,
|
||||
)
|
||||
from superset.mcp_service.saved_query.tool import ( # noqa: F401, E402
|
||||
get_saved_query_info,
|
||||
list_saved_queries,
|
||||
)
|
||||
from superset.mcp_service.sql_lab.tool import ( # noqa: F401, E402
|
||||
execute_sql,
|
||||
open_sql_lab_with_context,
|
||||
@@ -670,6 +740,22 @@ from superset.mcp_service.system.tool import ( # noqa: F401, E402
|
||||
get_schema,
|
||||
health_check,
|
||||
)
|
||||
from superset.mcp_service.tag.tool import ( # noqa: F401, E402
|
||||
get_tag_info,
|
||||
list_tags,
|
||||
)
|
||||
from superset.mcp_service.task.tool import ( # noqa: F401, E402
|
||||
get_task_info,
|
||||
list_tasks,
|
||||
)
|
||||
from superset.mcp_service.theme.tool import ( # noqa: F401, E402
|
||||
get_theme_info,
|
||||
list_themes,
|
||||
)
|
||||
from superset.mcp_service.user.tool import ( # noqa: F401, E402
|
||||
get_user_info,
|
||||
list_users,
|
||||
)
|
||||
|
||||
|
||||
def _remove_disabled_tools(disabled_tools: set[str]) -> None:
|
||||
@@ -691,6 +777,48 @@ def _remove_disabled_tools(disabled_tools: set[str]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _remove_tool_quietly(tool_name: str, reason: str) -> None:
|
||||
"""Remove a single tool from the global MCP instance, ignoring missing-tool errors."""
|
||||
try:
|
||||
mcp.local_provider.remove_tool(tool_name)
|
||||
logger.info("Disabled MCP tool: %s (%s)", tool_name, reason)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
def _apply_config_guards(flask_app: Any) -> set[str]:
|
||||
"""Remove tools whose backing features are administratively disabled.
|
||||
|
||||
Returns the set of tool names that were removed so that callers can exclude
|
||||
them from generated instructions.
|
||||
|
||||
- Action-log tools: mirrors LogRestApi.is_enabled() which checks
|
||||
FAB_ADD_SECURITY_VIEWS and SUPERSET_LOG_VIEW.
|
||||
- Task tools: mirrors TaskRestApi conditional registration which checks
|
||||
the GLOBAL_TASK_FRAMEWORK feature flag via feature_flag_manager so that
|
||||
all Superset enablement paths (DEFAULT_FEATURE_FLAGS, GET_FEATURE_FLAGS_FUNC,
|
||||
IS_FEATURE_ENABLED_FUNC, etc.) are respected.
|
||||
"""
|
||||
removed: set[str] = set()
|
||||
|
||||
if not (
|
||||
flask_app.config["FAB_ADD_SECURITY_VIEWS"]
|
||||
and flask_app.config["SUPERSET_LOG_VIEW"]
|
||||
):
|
||||
for tool_name in ("list_action_logs", "get_action_log_info"):
|
||||
_remove_tool_quietly(tool_name, "logging disabled by config flags")
|
||||
removed.add(tool_name)
|
||||
|
||||
from superset.extensions import feature_flag_manager # noqa: PLC0415
|
||||
|
||||
if not feature_flag_manager.is_feature_enabled("GLOBAL_TASK_FRAMEWORK"):
|
||||
for tool_name in ("list_tasks", "get_task_info"):
|
||||
_remove_tool_quietly(tool_name, "GLOBAL_TASK_FRAMEWORK not enabled")
|
||||
removed.add(tool_name)
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
def init_fastmcp_server(
|
||||
name: str | None = None,
|
||||
instructions: str | None = None,
|
||||
@@ -735,9 +863,13 @@ def init_fastmcp_server(
|
||||
# instructions never advertise tools that clients cannot actually call.
|
||||
disabled_tools: set[str] = flask_app.config.get("MCP_DISABLED_TOOLS", set())
|
||||
_remove_disabled_tools(disabled_tools)
|
||||
config_guard_removed = _apply_config_guards(flask_app)
|
||||
|
||||
if instructions is None:
|
||||
instructions = get_default_instructions(branding, disabled_tools)
|
||||
# Merge MCP_DISABLED_TOOLS with config-guard removals so the instructions
|
||||
# never advertise tools that have been suppressed by either mechanism.
|
||||
all_disabled = disabled_tools | config_guard_removed
|
||||
instructions = get_default_instructions(branding, all_disabled)
|
||||
|
||||
# Configure the global mcp instance with provided settings.
|
||||
# Tools are already registered on this instance via @tool decorator imports above.
|
||||
|
||||
@@ -22,6 +22,7 @@ This module contains shared logic for chart configuration mapping and explore li
|
||||
generation that can be used by both generate_chart and generate_explore_link tools.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict
|
||||
@@ -488,10 +489,28 @@ def create_metric_object(col: ColumnRef) -> Dict[str, Any] | str:
|
||||
|
||||
For saved metrics, returns the metric name as a plain string which
|
||||
Superset's query engine resolves via its metrics_by_name lookup.
|
||||
For ad-hoc metrics, returns a SIMPLE expression dict.
|
||||
For custom SQL metrics, returns a SQL adhoc dict (expressionType="SQL").
|
||||
For ad-hoc column metrics, returns a SIMPLE expression dict.
|
||||
"""
|
||||
if col.sql_expression:
|
||||
return {
|
||||
"aggregate": None,
|
||||
"column": None,
|
||||
"expressionType": "SQL",
|
||||
"sqlExpression": col.sql_expression,
|
||||
"label": col.label,
|
||||
"optionName": (
|
||||
"metric_sql_"
|
||||
+ hashlib.md5(
|
||||
col.sql_expression.encode("utf-8"), usedforsecurity=False
|
||||
).hexdigest()[:8]
|
||||
),
|
||||
"hasCustomLabel": True,
|
||||
"datasourceWarning": False,
|
||||
}
|
||||
|
||||
if col.saved_metric:
|
||||
return col.name
|
||||
return col.name # type: ignore[return-value]
|
||||
|
||||
# Ensure aggregate is valid - default to SUM if not specified or invalid
|
||||
valid_aggregates = {
|
||||
@@ -684,7 +703,7 @@ def _add_xy_limits(form_data: Dict[str, Any], config: XYChartConfig) -> None:
|
||||
form_data["series_limit"] = config.series_limit
|
||||
|
||||
|
||||
def map_xy_config(
|
||||
def map_xy_config( # noqa: C901
|
||||
config: XYChartConfig, dataset_id: int | str | None = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Map XY chart config to form_data with defensive validation."""
|
||||
@@ -692,9 +711,12 @@ def map_xy_config(
|
||||
if not config.y:
|
||||
raise ValueError("XY chart must have at least one Y-axis metric")
|
||||
|
||||
# Resolve x-axis default: use dataset's main_dttm_col when x is omitted
|
||||
# Resolve x-axis default: use dataset's main_dttm_col when x is omitted.
|
||||
config = _resolve_default_x_axis(config, dataset_id)
|
||||
assert config.x is not None # _resolve_default_x_axis guarantees x is set
|
||||
|
||||
# ``_resolve_default_x_axis`` guarantees x is set.
|
||||
if config.x is None or config.x.name is None:
|
||||
raise ValueError("XY chart requires an x-axis with a resolvable column name")
|
||||
|
||||
# Check if x-axis column is truly temporal (based on actual SQL type)
|
||||
x_is_temporal = is_column_truly_temporal(config.x.name, dataset_id)
|
||||
@@ -719,7 +741,8 @@ def map_xy_config(
|
||||
# Convert Y columns to metrics with validation
|
||||
metrics = []
|
||||
for col in config.y:
|
||||
if not col.name.strip(): # Validate column name is not empty
|
||||
# SQL metrics carry sql_expression instead of name.
|
||||
if not col.sql_expression and not (col.name and col.name.strip()):
|
||||
raise ValueError("Y-axis column name cannot be empty")
|
||||
metrics.append(create_metric_object(col))
|
||||
|
||||
@@ -972,7 +995,9 @@ def map_mixed_timeseries_config(
|
||||
if not config.y_secondary:
|
||||
raise ValueError("Mixed timeseries must have at least one secondary metric")
|
||||
|
||||
# Check if x-axis column is truly temporal
|
||||
# x rejects sql_expression at validation, so name is set.
|
||||
if config.x.name is None:
|
||||
raise ValueError("Mixed timeseries chart requires an x-axis column name")
|
||||
x_is_temporal = is_column_truly_temporal(config.x.name, dataset_id)
|
||||
|
||||
form_data: Dict[str, Any] = {
|
||||
@@ -1052,7 +1077,9 @@ def _humanize_column(col: ColumnRef) -> str:
|
||||
"""Return a human-readable label for a column reference."""
|
||||
if col.label:
|
||||
return col.label
|
||||
name = col.name.replace("_", " ").title()
|
||||
if col.sql_expression:
|
||||
return col.sql_expression
|
||||
name = (col.name or "").replace("_", " ").title()
|
||||
if col.saved_metric:
|
||||
return name
|
||||
if col.aggregate:
|
||||
@@ -1144,21 +1171,32 @@ def _xy_chart_context(config: XYChartConfig) -> str | None:
|
||||
def _pie_chart_what(config: PieChartConfig) -> str:
|
||||
"""Build the 'what' portion for a pie chart name."""
|
||||
dim = config.dimension.name
|
||||
metric_label = config.metric.label or config.metric.name
|
||||
metric_label = (
|
||||
config.metric.label or config.metric.name or config.metric.sql_expression
|
||||
)
|
||||
return f"{dim} by {metric_label}"
|
||||
|
||||
|
||||
def _pivot_table_what(config: PivotTableChartConfig) -> str:
|
||||
"""Build the 'what' portion for a pivot table chart name."""
|
||||
row_names = ", ".join(r.name for r in config.rows)
|
||||
# Pivot rows reject sql_expression at validation, so name is set.
|
||||
row_names = ", ".join(r.name or "" for r in config.rows)
|
||||
return f"Pivot Table \u2013 {row_names}"
|
||||
|
||||
|
||||
def _mixed_timeseries_what(config: MixedTimeseriesChartConfig) -> str:
|
||||
"""Build the 'what' portion for a mixed timeseries chart name."""
|
||||
primary = config.y[0].label or config.y[0].name if config.y else "primary"
|
||||
primary = (
|
||||
(config.y[0].label or config.y[0].name or config.y[0].sql_expression)
|
||||
if config.y
|
||||
else "primary"
|
||||
)
|
||||
secondary = (
|
||||
config.y_secondary[0].label or config.y_secondary[0].name
|
||||
(
|
||||
config.y_secondary[0].label
|
||||
or config.y_secondary[0].name
|
||||
or config.y_secondary[0].sql_expression
|
||||
)
|
||||
if config.y_secondary
|
||||
else "secondary"
|
||||
)
|
||||
@@ -1172,10 +1210,16 @@ def _handlebars_chart_what(config: HandlebarsChartConfig) -> str:
|
||||
``generate_chart_name``'s ``\u2013`` context separator.
|
||||
"""
|
||||
if config.query_mode == "raw" and config.columns:
|
||||
cols = ", ".join(col.name for col in config.columns[:3])
|
||||
# Raw columns reject sql_expression at validation, so col.name is set.
|
||||
cols = ", ".join(col.name or "" for col in config.columns[:3])
|
||||
return f"Handlebars ({cols})"
|
||||
elif config.metrics:
|
||||
metrics = ", ".join(col.name for col in config.metrics[:3])
|
||||
# Prefer raw column name for back-compat with existing chart names;
|
||||
# SQL metrics fall back to label, then the expression itself.
|
||||
metrics = ", ".join(
|
||||
col.name or col.label or col.sql_expression or ""
|
||||
for col in config.metrics[:3]
|
||||
)
|
||||
return f"Handlebars ({metrics})"
|
||||
return "Handlebars Chart"
|
||||
|
||||
@@ -1188,10 +1232,12 @@ def _big_number_chart_what(config: BigNumberChartConfig) -> str:
|
||||
"""
|
||||
if config.metric.label:
|
||||
metric_label = config.metric.label
|
||||
elif config.metric.sql_expression:
|
||||
metric_label = config.metric.sql_expression
|
||||
elif config.metric.aggregate:
|
||||
metric_label = f"{config.metric.aggregate}({config.metric.name})"
|
||||
else:
|
||||
metric_label = config.metric.name
|
||||
metric_label = config.metric.name or ""
|
||||
if config.show_trendline:
|
||||
return f"Big Number ({metric_label}, trendline)"
|
||||
return f"Big Number ({metric_label})"
|
||||
@@ -1390,7 +1436,10 @@ def analyze_chart_semantics(chart: Any | None, config: Any) -> ChartSemantics:
|
||||
if hasattr(config, "x") and config.x:
|
||||
columns.append(config.x.name)
|
||||
if hasattr(config, "y") and config.y:
|
||||
columns.extend([col.name for col in config.y])
|
||||
# SQL metrics have no name; fall back to label or the expression.
|
||||
columns.extend(
|
||||
[col.name or col.label or col.sql_expression for col in config.y]
|
||||
)
|
||||
|
||||
if columns:
|
||||
ellipsis = "..." if len(columns) > 3 else ""
|
||||
|
||||
@@ -25,7 +25,6 @@ import difflib
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, cast, Dict, List, Literal, Protocol
|
||||
|
||||
import humanize
|
||||
from pydantic import (
|
||||
AliasChoices,
|
||||
AliasPath,
|
||||
@@ -61,8 +60,10 @@ from superset.mcp_service.utils import (
|
||||
escape_llm_context_delimiters,
|
||||
sanitize_for_llm_context,
|
||||
)
|
||||
from superset.mcp_service.utils.response_utils import humanize_timestamp
|
||||
from superset.mcp_service.utils.sanitization import (
|
||||
sanitize_filter_value,
|
||||
sanitize_sql_expression,
|
||||
sanitize_user_input,
|
||||
sanitize_user_input_with_changes,
|
||||
)
|
||||
@@ -298,13 +299,6 @@ class GetChartInfoRequest(BaseModel):
|
||||
return self
|
||||
|
||||
|
||||
def _humanize_timestamp(dt: datetime | None) -> str | None:
|
||||
"""Convert a datetime to a humanized string like '2 hours ago'."""
|
||||
if dt is None:
|
||||
return None
|
||||
return humanize.naturaltime(datetime.now() - dt)
|
||||
|
||||
|
||||
def extract_filters_from_form_data(
|
||||
form_data: Dict[str, Any] | None,
|
||||
) -> ChartFiltersInfo | None:
|
||||
@@ -409,7 +403,40 @@ CHART_FORM_DATA_EXCLUDED_FIELD_NAMES = frozenset(
|
||||
)
|
||||
|
||||
|
||||
def sanitize_chart_info_for_llm_context(chart_info: ChartInfo) -> ChartInfo:
|
||||
def wrap_sql_adhoc_metrics(form_data: Any) -> None:
|
||||
"""Wrap LLM-controlled SQL adhoc metric strings in-place.
|
||||
|
||||
``metric``/``metrics`` are in ``CHART_FORM_DATA_EXCLUDED_FIELD_NAMES`` so
|
||||
SIMPLE-metric content (bounded scalars) doesn't get wrapped. SQL adhoc
|
||||
dicts carry up to 2000 chars of LLM-controlled SQL plus a 500-char label
|
||||
that still need ``<UNTRUSTED-CONTENT>`` delimiters when echoed back.
|
||||
"""
|
||||
if not isinstance(form_data, dict):
|
||||
return
|
||||
metrics = form_data.get("metrics")
|
||||
if isinstance(metrics, list):
|
||||
for index, metric in enumerate(metrics):
|
||||
if isinstance(metric, dict) and metric.get("expressionType") == "SQL":
|
||||
for key in ("sqlExpression", "label"):
|
||||
if isinstance(metric.get(key), str):
|
||||
metric[key] = sanitize_for_llm_context(
|
||||
metric[key],
|
||||
field_path=("form_data", "metrics", str(index), key),
|
||||
)
|
||||
metric_singular = form_data.get("metric")
|
||||
if (
|
||||
isinstance(metric_singular, dict)
|
||||
and metric_singular.get("expressionType") == "SQL"
|
||||
):
|
||||
for key in ("sqlExpression", "label"):
|
||||
if isinstance(metric_singular.get(key), str):
|
||||
metric_singular[key] = sanitize_for_llm_context(
|
||||
metric_singular[key],
|
||||
field_path=("form_data", "metric", key),
|
||||
)
|
||||
|
||||
|
||||
def sanitize_chart_info_for_llm_context(chart_info: ChartInfo) -> ChartInfo: # noqa: C901
|
||||
"""Wrap chart read-path descriptive fields before LLM exposure."""
|
||||
payload = chart_info.model_dump(mode="python")
|
||||
|
||||
@@ -444,6 +471,7 @@ def sanitize_chart_info_for_llm_context(chart_info: ChartInfo) -> ChartInfo:
|
||||
| frozenset({"cache_key", "database", "database_name", "schema"})
|
||||
),
|
||||
)
|
||||
wrap_sql_adhoc_metrics(payload["form_data"])
|
||||
|
||||
payload["tags"] = [
|
||||
{
|
||||
@@ -504,13 +532,9 @@ def serialize_chart_object(chart: ChartLike | None) -> ChartInfo | None:
|
||||
form_data=chart_form_data,
|
||||
filters=filters_info,
|
||||
changed_on=getattr(chart, "changed_on", None),
|
||||
changed_on_humanized=_humanize_timestamp(
|
||||
getattr(chart, "changed_on", None)
|
||||
),
|
||||
changed_on_humanized=humanize_timestamp(getattr(chart, "changed_on", None)),
|
||||
created_on=getattr(chart, "created_on", None),
|
||||
created_on_humanized=_humanize_timestamp(
|
||||
getattr(chart, "created_on", None)
|
||||
),
|
||||
created_on_humanized=humanize_timestamp(getattr(chart, "created_on", None)),
|
||||
uuid=str(getattr(chart, "uuid", ""))
|
||||
if getattr(chart, "uuid", None)
|
||||
else None,
|
||||
@@ -670,8 +694,8 @@ class UnknownFieldCheckMixin(BaseModel):
|
||||
class ColumnRef(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
name: str = Field(
|
||||
...,
|
||||
name: str | None = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
pattern=r"^[a-zA-Z0-9_][a-zA-Z0-9_\s\-\.]*$",
|
||||
@@ -700,11 +724,62 @@ class ColumnRef(BaseModel):
|
||||
"(use get_dataset_info to see available metrics). "
|
||||
"When set, 'aggregate' is ignored.",
|
||||
)
|
||||
sql_expression: str | None = Field(
|
||||
None,
|
||||
max_length=2000,
|
||||
description=(
|
||||
"Custom SQL aggregate expression for an adhoc metric, e.g. "
|
||||
"'COUNT(CASE WHEN closed_won THEN 1 END)::numeric / "
|
||||
"NULLIF(COUNT(*),0)'. Metric-only — mutually exclusive with "
|
||||
"'name', 'aggregate', and 'saved_metric'. Requires 'label'."
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_metric(self) -> bool:
|
||||
"""Whether this ref acts as a metric (has aggregate or is a saved metric)."""
|
||||
return bool(self.aggregate) or self.saved_metric
|
||||
"""Whether this ref acts as a metric (aggregate, saved, or SQL)."""
|
||||
return bool(self.aggregate) or self.saved_metric or bool(self.sql_expression)
|
||||
|
||||
# Must run before ``clear_aggregate_for_saved_metric`` (Pydantic v2 runs
|
||||
# ``mode="after"`` validators in source order) so the aggregate/saved
|
||||
# conflict surfaces before the cleanup nulls ``aggregate`` out.
|
||||
@model_validator(mode="after")
|
||||
def validate_metric_shape(self) -> "ColumnRef":
|
||||
"""Require exactly one of ``name`` or ``sql_expression``; the SQL form
|
||||
is mutually exclusive with ``aggregate`` / ``saved_metric`` and
|
||||
requires a ``label``.
|
||||
"""
|
||||
if self.sql_expression:
|
||||
if self.name is not None:
|
||||
raise ValueError(
|
||||
"ColumnRef cannot set both 'name' and 'sql_expression'. "
|
||||
"Use 'sql_expression' alone for a custom SQL metric, or "
|
||||
"'name' (plus optional 'aggregate' / 'saved_metric') for "
|
||||
"a column-based metric."
|
||||
)
|
||||
if self.aggregate is not None:
|
||||
raise ValueError(
|
||||
"ColumnRef cannot combine 'sql_expression' with "
|
||||
"'aggregate' — the SQL expression already includes the "
|
||||
"aggregation."
|
||||
)
|
||||
if self.saved_metric:
|
||||
raise ValueError(
|
||||
"ColumnRef cannot combine 'sql_expression' with "
|
||||
"'saved_metric=True' — use the saved metric's name "
|
||||
"directly instead."
|
||||
)
|
||||
if not self.label:
|
||||
raise ValueError(
|
||||
"ColumnRef with 'sql_expression' requires a 'label' "
|
||||
"(used as the metric's display name)."
|
||||
)
|
||||
elif self.name is None:
|
||||
raise ValueError(
|
||||
"ColumnRef requires either 'name' (column / dimension / "
|
||||
"saved metric) or 'sql_expression' (custom SQL metric)."
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def clear_aggregate_for_saved_metric(self) -> "ColumnRef":
|
||||
@@ -715,13 +790,13 @@ class ColumnRef(BaseModel):
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def sanitize_name(cls, v: str) -> str:
|
||||
def sanitize_name(cls, v: str | None) -> str | None:
|
||||
"""Sanitize column name to prevent XSS and SQL injection."""
|
||||
# sanitize_user_input raises ValueError when allow_empty=False (default)
|
||||
# so the return value is guaranteed to be a non-None str
|
||||
if v is None:
|
||||
return None
|
||||
return sanitize_user_input(
|
||||
v, "Column name", max_length=255, check_sql_keywords=True
|
||||
) # type: ignore[return-value]
|
||||
)
|
||||
|
||||
@field_validator("label")
|
||||
@classmethod
|
||||
@@ -729,6 +804,14 @@ class ColumnRef(BaseModel):
|
||||
"""Sanitize display label to prevent XSS attacks."""
|
||||
return sanitize_user_input(v, "Label", max_length=500, allow_empty=True)
|
||||
|
||||
@field_validator("sql_expression")
|
||||
@classmethod
|
||||
def sanitize_sql(cls, v: str | None) -> str | None:
|
||||
"""Sanitize a custom SQL aggregate expression (XSS, DDL/DML, etc.)."""
|
||||
return sanitize_sql_expression(
|
||||
v, "SQL expression", max_length=2000, allow_empty=True
|
||||
)
|
||||
|
||||
|
||||
class AxisConfig(BaseModel):
|
||||
title: str | None = Field(None, max_length=200)
|
||||
@@ -918,6 +1001,12 @@ class PieChartConfig(UnknownFieldCheckMixin):
|
||||
30, description="Donut inner radius % (1-100)", ge=1, le=100
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_sql_expression_on_dimensions(self) -> "PieChartConfig":
|
||||
"""sql_expression is metric-only; reject it on the dimension."""
|
||||
_reject_sql_expression_on_dimension(self.dimension, "dimension")
|
||||
return self
|
||||
|
||||
|
||||
class PivotTableChartConfig(UnknownFieldCheckMixin):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
@@ -972,6 +1061,16 @@ class PivotTableChartConfig(UnknownFieldCheckMixin):
|
||||
description="Currency symbol applied to numeric metric values",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_sql_expression_on_dimensions(self) -> "PivotTableChartConfig":
|
||||
"""sql_expression is metric-only; reject it on rows and columns."""
|
||||
for i, col in enumerate(self.rows):
|
||||
_reject_sql_expression_on_dimension(col, f"rows[{i}]")
|
||||
if self.columns:
|
||||
for i, col in enumerate(self.columns):
|
||||
_reject_sql_expression_on_dimension(col, f"columns[{i}]")
|
||||
return self
|
||||
|
||||
|
||||
class MixedTimeseriesChartConfig(UnknownFieldCheckMixin):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
@@ -1052,6 +1151,19 @@ class MixedTimeseriesChartConfig(UnknownFieldCheckMixin):
|
||||
def wrap_single_group_by(cls, v: Any) -> Any:
|
||||
return _normalize_group_by_input(v)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_sql_expression_on_dimensions(self) -> "MixedTimeseriesChartConfig":
|
||||
"""sql_expression is metric-only; reject it on x and group_by lists."""
|
||||
_reject_sql_expression_on_dimension(self.x, "x")
|
||||
for field_name, group in (
|
||||
("group_by", self.group_by),
|
||||
("group_by_secondary", self.group_by_secondary),
|
||||
):
|
||||
if group:
|
||||
for i, col in enumerate(group):
|
||||
_reject_sql_expression_on_dimension(col, f"{field_name}[{i}]")
|
||||
return self
|
||||
|
||||
|
||||
class HandlebarsChartConfig(UnknownFieldCheckMixin):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
@@ -1120,6 +1232,17 @@ class HandlebarsChartConfig(UnknownFieldCheckMixin):
|
||||
max_length=10000,
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_sql_expression_on_dimensions(self) -> "HandlebarsChartConfig":
|
||||
"""sql_expression is metric-only; reject it on raw columns and groupby."""
|
||||
if self.columns:
|
||||
for i, col in enumerate(self.columns):
|
||||
_reject_sql_expression_on_dimension(col, f"columns[{i}]")
|
||||
if self.groupby:
|
||||
for i, col in enumerate(self.groupby):
|
||||
_reject_sql_expression_on_dimension(col, f"groupby[{i}]")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_query_fields(self) -> "HandlebarsChartConfig":
|
||||
"""Validate that the right fields are provided for the query mode."""
|
||||
@@ -1145,7 +1268,9 @@ class HandlebarsChartConfig(UnknownFieldCheckMixin):
|
||||
"Handlebars chart in 'aggregate' query mode requires 'metrics' "
|
||||
"field. Specify at least one metric with an aggregate function."
|
||||
)
|
||||
missing_agg = [m.name for m in self.metrics if not m.is_metric]
|
||||
# SQL metrics are filtered out by ``is_metric``, so every entry in
|
||||
# ``missing_agg`` is a name-bearing column ref.
|
||||
missing_agg = [m.name or "" for m in self.metrics if not m.is_metric]
|
||||
if missing_agg:
|
||||
raise ValueError(
|
||||
f"Handlebars chart in 'aggregate' query mode requires an "
|
||||
@@ -1272,13 +1397,14 @@ class BigNumberChartConfig(UnknownFieldCheckMixin):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_metric_aggregate(self) -> Self:
|
||||
"""Ensure metric is a valid metric reference (aggregate or saved)."""
|
||||
"""Ensure metric resolves to a metric expression (aggregate, saved,
|
||||
or sql_expression)."""
|
||||
if not self.metric.is_metric:
|
||||
raise ValueError(
|
||||
"Big Number metric must be either a saved dataset metric "
|
||||
"or include an aggregate function (e.g., SUM, COUNT, AVG). "
|
||||
"Set 'saved_metric': true to use a saved metric, or add "
|
||||
"'aggregate' to the metric specification."
|
||||
"Big Number metric must include an aggregate function, "
|
||||
"reference a saved metric, or carry a sql_expression. "
|
||||
"Set 'aggregate' (e.g., SUM, COUNT, AVG), 'saved_metric': true, "
|
||||
"or 'sql_expression' (with a 'label')."
|
||||
)
|
||||
return self
|
||||
|
||||
@@ -1330,6 +1456,20 @@ class TableChartConfig(UnknownFieldCheckMixin):
|
||||
max_length=100,
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_sql_expression_in_raw_mode(self) -> "TableChartConfig":
|
||||
"""In raw mode every column is a plain selection, so a SQL metric
|
||||
there would yield ``None`` in ``form_data['all_columns']``."""
|
||||
if self.query_mode == "raw":
|
||||
for i, col in enumerate(self.columns):
|
||||
if col.sql_expression:
|
||||
raise ValueError(
|
||||
f"sql_expression is not allowed on columns[{i}] when "
|
||||
f"query_mode='raw'. Switch to query_mode='aggregate' "
|
||||
f"(or omit query_mode) to use a SQL metric."
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_column_labels(self) -> "TableChartConfig":
|
||||
"""Ensure all column labels are unique."""
|
||||
@@ -1338,7 +1478,10 @@ class TableChartConfig(UnknownFieldCheckMixin):
|
||||
|
||||
for i, col in enumerate(self.columns):
|
||||
# Generate the label that will be used (same logic as create_metric_object)
|
||||
if col.saved_metric:
|
||||
if col.sql_expression:
|
||||
# SQL metrics carry a required label; use it verbatim.
|
||||
label = col.label
|
||||
elif col.saved_metric:
|
||||
label = col.label or col.name
|
||||
elif col.aggregate:
|
||||
label = col.label or f"{col.aggregate}({col.name})"
|
||||
@@ -1360,13 +1503,25 @@ class TableChartConfig(UnknownFieldCheckMixin):
|
||||
return self
|
||||
|
||||
|
||||
def _reject_sql_expression_on_dimension(col: ColumnRef | None, position: str) -> None:
|
||||
"""Raise if a dimension-position ColumnRef carries ``sql_expression``;
|
||||
SQL adhoc metrics belong on metric positions only."""
|
||||
if col is not None and col.sql_expression:
|
||||
raise ValueError(
|
||||
f"sql_expression is only supported on metrics, not on '{position}' "
|
||||
f"(which is a dimension). Use 'name' for dimension columns."
|
||||
)
|
||||
|
||||
|
||||
def _metric_display_label(col: ColumnRef) -> str:
|
||||
"""Return the display label for a metric column reference."""
|
||||
if col.sql_expression:
|
||||
return col.label or ""
|
||||
if col.saved_metric:
|
||||
return col.label or col.name
|
||||
return col.label or col.name or ""
|
||||
if col.aggregate:
|
||||
return col.label or f"{col.aggregate}({col.name})"
|
||||
return col.label or col.name
|
||||
return col.label or col.name or ""
|
||||
|
||||
|
||||
class XYChartConfig(UnknownFieldCheckMixin):
|
||||
@@ -1454,15 +1609,25 @@ class XYChartConfig(UnknownFieldCheckMixin):
|
||||
def wrap_single_group_by(cls, v: Any) -> Any:
|
||||
return _normalize_group_by_input(v)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_sql_expression_on_dimensions(self) -> "XYChartConfig":
|
||||
"""sql_expression is metric-only; reject it on x and group_by."""
|
||||
_reject_sql_expression_on_dimension(self.x, "x")
|
||||
if self.group_by:
|
||||
for i, col in enumerate(self.group_by):
|
||||
_reject_sql_expression_on_dimension(col, f"group_by[{i}]")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_unique_column_labels(self) -> "XYChartConfig":
|
||||
"""Ensure all column labels are unique across x, y, and group_by."""
|
||||
labels_seen: dict[str, str] = {}
|
||||
duplicates: list[str] = []
|
||||
|
||||
# Add x-axis label if present (x may be None, resolved later)
|
||||
# Add x-axis label if present (x may be None, resolved later).
|
||||
# The dimension validator rejects sql_expression on x, so name is set.
|
||||
if self.x is not None:
|
||||
x_label = self.x.label or self.x.name
|
||||
x_label = self.x.label or self.x.name or ""
|
||||
labels_seen[x_label] = "x"
|
||||
|
||||
# Check Y-axis labels
|
||||
@@ -1483,7 +1648,8 @@ class XYChartConfig(UnknownFieldCheckMixin):
|
||||
# to prevent Superset "duplicate label" errors, so
|
||||
# we allow them through validation.
|
||||
continue
|
||||
group_label = col.label or col.name
|
||||
# group_by rejects sql_expression, so name is set.
|
||||
group_label = col.label or col.name or ""
|
||||
if group_label in labels_seen:
|
||||
duplicates.append(
|
||||
f"group_by[{i}]: '{group_label}' "
|
||||
|
||||
@@ -52,6 +52,7 @@ from superset.mcp_service.chart.schemas import (
|
||||
GenerateChartRequest,
|
||||
GenerateChartResponse,
|
||||
PerformanceMetadata,
|
||||
wrap_sql_adhoc_metrics,
|
||||
)
|
||||
from superset.mcp_service.utils import sanitize_for_llm_context
|
||||
from superset.mcp_service.utils.oauth2_utils import (
|
||||
@@ -73,11 +74,13 @@ def _sanitize_generate_chart_form_data_for_llm_context(
|
||||
form_data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Wrap generated-chart form_data before returning it to LLM clients."""
|
||||
return sanitize_for_llm_context(
|
||||
wrapped = sanitize_for_llm_context(
|
||||
form_data,
|
||||
field_path=("form_data",),
|
||||
excluded_field_names=GENERATE_CHART_FORM_DATA_EXCLUDED_FIELD_NAMES,
|
||||
)
|
||||
wrap_sql_adhoc_metrics(wrapped)
|
||||
return wrapped
|
||||
|
||||
|
||||
__all__ = ["CompileResult", "_compile_chart", "validate_and_compile", "generate_chart"]
|
||||
@@ -142,6 +145,26 @@ async def generate_chart( # noqa: C901
|
||||
}
|
||||
```
|
||||
|
||||
Example usage with a custom SQL metric (ratios, conditional aggregations,
|
||||
unit conversions). Pass 'sql_expression' instead of 'name'+'aggregate'.
|
||||
A 'label' is required and serves as the metric's display name:
|
||||
```json
|
||||
{
|
||||
"dataset_id": 123,
|
||||
"config": {
|
||||
"chart_type": "xy",
|
||||
"x": {"name": "order_date"},
|
||||
"y": [{
|
||||
"sql_expression":
|
||||
"COUNT(CASE WHEN closed_won THEN 1 END)::numeric / "
|
||||
"NULLIF(COUNT(*), 0)",
|
||||
"label": "Win Rate"
|
||||
}],
|
||||
"kind": "line"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
VALIDATION:
|
||||
- 5-layer pipeline: Schema, business logic, dataset, Superset compatibility, runtime
|
||||
- XSS/SQL injection prevention
|
||||
|
||||
@@ -46,6 +46,7 @@ from superset.mcp_service.chart.schemas import (
|
||||
GenerateChartResponse,
|
||||
PerformanceMetadata,
|
||||
UpdateChartRequest,
|
||||
wrap_sql_adhoc_metrics,
|
||||
)
|
||||
from superset.mcp_service.utils import escape_llm_context_delimiters
|
||||
from superset.mcp_service.utils.oauth2_utils import (
|
||||
@@ -84,6 +85,15 @@ def _missing_config_or_name_error() -> GenerateChartResponse:
|
||||
)
|
||||
|
||||
|
||||
def _wrapped_form_data_for_response(
|
||||
new_form_data: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Wrap SQL-metric strings in form_data before LLM-facing return."""
|
||||
payload = dict(new_form_data) if new_form_data is not None else {}
|
||||
wrap_sql_adhoc_metrics(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _build_update_payload(
|
||||
request: UpdateChartRequest,
|
||||
chart: Any,
|
||||
@@ -322,6 +332,26 @@ async def update_chart( # noqa: C901
|
||||
}
|
||||
```
|
||||
|
||||
Example usage with a custom SQL metric (ratios, conditional aggregations,
|
||||
unit conversions). Pass 'sql_expression' instead of 'name'+'aggregate'.
|
||||
A 'label' is required:
|
||||
```json
|
||||
{
|
||||
"identifier": 123,
|
||||
"config": {
|
||||
"chart_type": "xy",
|
||||
"x": {"name": "date"},
|
||||
"y": [{
|
||||
"sql_expression":
|
||||
"COUNT(CASE WHEN closed_won THEN 1 END)::numeric / "
|
||||
"NULLIF(COUNT(*), 0)",
|
||||
"label": "Win Rate"
|
||||
}],
|
||||
"kind": "line"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use when:
|
||||
- Modifying existing saved chart
|
||||
- Updating title, filters, or visualization settings
|
||||
@@ -532,8 +562,7 @@ async def update_chart( # noqa: C901
|
||||
},
|
||||
"error": None,
|
||||
"warnings": warnings,
|
||||
# Include form_data so callers can verify what was saved.
|
||||
"form_data": new_form_data if new_form_data is not None else {},
|
||||
"form_data": _wrapped_form_data_for_response(new_form_data),
|
||||
"previews": previews,
|
||||
"capabilities": capabilities.model_dump() if capabilities else None,
|
||||
"semantics": semantics.model_dump() if semantics else None,
|
||||
|
||||
@@ -115,7 +115,7 @@ class DatasetValidator:
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def _validate_columns_exist(
|
||||
def _validate_columns_exist( # noqa: C901
|
||||
column_refs: List[ColumnRef], dataset_context: DatasetContext
|
||||
) -> ChartGenerationError | None:
|
||||
"""Validate that non-saved-metric column refs exist in the dataset.
|
||||
@@ -139,6 +139,12 @@ class DatasetValidator:
|
||||
for col_ref in column_refs:
|
||||
if col_ref.saved_metric:
|
||||
continue
|
||||
if col_ref.sql_expression:
|
||||
# SQL metrics don't reference a dataset column.
|
||||
continue
|
||||
if col_ref.name is None:
|
||||
# Should be unreachable per validate_metric_shape; defensive.
|
||||
continue
|
||||
name_lower = col_ref.name.lower()
|
||||
if name_lower in column_names_lower:
|
||||
continue
|
||||
@@ -158,6 +164,9 @@ class DatasetValidator:
|
||||
|
||||
suggestions_map = {}
|
||||
for col_ref in invalid_columns:
|
||||
# Loop above filters out refs without a name; defensive guard.
|
||||
if col_ref.name is None:
|
||||
continue
|
||||
suggestions = DatasetValidator._get_column_suggestions(
|
||||
col_ref.name, dataset_context
|
||||
)
|
||||
@@ -371,14 +380,16 @@ class DatasetValidator:
|
||||
) -> None:
|
||||
"""Normalize column names in an XY chart config dict in place."""
|
||||
# Normalize x-axis column
|
||||
if "x" in config_dict and config_dict["x"]:
|
||||
if "x" in config_dict and config_dict["x"] and config_dict["x"].get("name"):
|
||||
config_dict["x"]["name"] = DatasetValidator._get_canonical_column_name(
|
||||
config_dict["x"]["name"], dataset_context
|
||||
)
|
||||
|
||||
# Normalize y-axis columns
|
||||
# Normalize y-axis columns (skip SQL-expression metrics; no name).
|
||||
if "y" in config_dict and config_dict["y"]:
|
||||
for y_col in config_dict["y"]:
|
||||
if not y_col.get("name"):
|
||||
continue
|
||||
y_col["name"] = DatasetValidator._get_canonical_column_name(
|
||||
y_col["name"], dataset_context
|
||||
)
|
||||
@@ -386,6 +397,8 @@ class DatasetValidator:
|
||||
# Normalize group_by columns
|
||||
if "group_by" in config_dict and config_dict["group_by"]:
|
||||
for gb_col in config_dict["group_by"]:
|
||||
if not gb_col.get("name"):
|
||||
continue
|
||||
gb_col["name"] = DatasetValidator._get_canonical_column_name(
|
||||
gb_col["name"], dataset_context
|
||||
)
|
||||
@@ -397,6 +410,9 @@ class DatasetValidator:
|
||||
"""Normalize column names in a table chart config dict in place."""
|
||||
if "columns" in config_dict and config_dict["columns"]:
|
||||
for col in config_dict["columns"]:
|
||||
# Skip SQL-expression metrics: no underlying column name.
|
||||
if not col.get("name"):
|
||||
continue
|
||||
col["name"] = DatasetValidator._get_canonical_column_name(
|
||||
col["name"], dataset_context
|
||||
)
|
||||
@@ -514,20 +530,20 @@ class DatasetValidator:
|
||||
ChartErrorBuilder,
|
||||
)
|
||||
|
||||
# Format error message
|
||||
if len(invalid_columns) == 1:
|
||||
col = invalid_columns[0]
|
||||
suggestions = suggestions_map.get(col.name, [])
|
||||
col_name = col.name or "<unknown column>"
|
||||
suggestions = suggestions_map.get(col_name, [])
|
||||
|
||||
if suggestions:
|
||||
return ChartErrorBuilder.column_not_found_error(
|
||||
col.name, [s.name for s in suggestions]
|
||||
col_name, [s.name for s in suggestions]
|
||||
)
|
||||
else:
|
||||
return ChartErrorBuilder.column_not_found_error(col.name)
|
||||
return ChartErrorBuilder.column_not_found_error(col_name)
|
||||
else:
|
||||
# Multiple invalid columns
|
||||
invalid_names = [col.name for col in invalid_columns]
|
||||
invalid_names: list[str] = [col.name for col in invalid_columns if col.name]
|
||||
return ChartErrorBuilder.build_error(
|
||||
error_type="multiple_invalid_columns",
|
||||
template_key="column_not_found",
|
||||
@@ -556,10 +572,13 @@ class DatasetValidator:
|
||||
_column_exists (which checks both lists) but fail at query time.
|
||||
"""
|
||||
metric_names = {m["name"].lower() for m in dataset_context.available_metrics}
|
||||
invalid = [
|
||||
# ``saved_metric=True`` requires ``name`` per ColumnRef.validate_metric_shape.
|
||||
invalid: list[str] = [
|
||||
col_ref.name
|
||||
for col_ref in column_refs
|
||||
if col_ref.saved_metric and col_ref.name.lower() not in metric_names
|
||||
if col_ref.saved_metric
|
||||
and col_ref.name is not None
|
||||
and col_ref.name.lower() not in metric_names
|
||||
]
|
||||
if not invalid:
|
||||
return None
|
||||
@@ -597,8 +616,14 @@ class DatasetValidator:
|
||||
for col_ref in column_refs:
|
||||
if col_ref.saved_metric:
|
||||
continue # Saved metrics have built-in aggregation
|
||||
if col_ref.sql_expression:
|
||||
# Custom SQL metrics bring their own aggregation expression.
|
||||
continue
|
||||
if not col_ref.aggregate:
|
||||
continue
|
||||
if col_ref.name is None:
|
||||
# Should be unreachable per validate_metric_shape; defensive.
|
||||
continue
|
||||
|
||||
# Find column info
|
||||
col_info = None
|
||||
|
||||
@@ -134,13 +134,13 @@ class RuntimeValidator:
|
||||
chart_type = config.kind if hasattr(config, "kind") else "default"
|
||||
|
||||
# Check X-axis cardinality
|
||||
if config.x is None:
|
||||
if config.x is None or config.x.name is None:
|
||||
return warnings, suggestions
|
||||
is_ok, cardinality_info = CardinalityValidator.check_cardinality(
|
||||
dataset_id=dataset_id,
|
||||
x_column=config.x.name,
|
||||
chart_type=chart_type,
|
||||
group_by_column=config.group_by[0].name if config.group_by else None,
|
||||
group_by_column=(config.group_by[0].name if config.group_by else None),
|
||||
)
|
||||
|
||||
if not is_ok and cardinality_info:
|
||||
|
||||
@@ -20,6 +20,7 @@ Chart type suggestions based on data characteristics and user intent.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
@@ -68,7 +69,7 @@ class ChartTypeSuggester:
|
||||
issues = []
|
||||
suggestions = []
|
||||
|
||||
if config.x is None:
|
||||
if config.x is None or config.x.name is None:
|
||||
return True, None
|
||||
|
||||
x_analysis = ChartTypeSuggester._analyze_x_axis(config.x.name)
|
||||
@@ -138,10 +139,15 @@ class ChartTypeSuggester:
|
||||
@staticmethod
|
||||
def _analyze_y_axis(y_columns: List[ColumnRef]) -> Dict[str, Any]:
|
||||
"""Analyze Y-axis characteristics."""
|
||||
|
||||
def _is_count(col: ColumnRef) -> bool:
|
||||
if col.aggregate in ("COUNT", "COUNT_DISTINCT"):
|
||||
return True
|
||||
expr = col.sql_expression or ""
|
||||
return bool(re.search(r"\bCOUNT\b", expr, re.IGNORECASE))
|
||||
|
||||
return {
|
||||
"has_count": any(
|
||||
col.aggregate in ["COUNT", "COUNT_DISTINCT"] for col in y_columns
|
||||
),
|
||||
"has_count": any(_is_count(col) for col in y_columns),
|
||||
"num_metrics": len(y_columns),
|
||||
}
|
||||
|
||||
@@ -283,6 +289,8 @@ class ChartTypeSuggester:
|
||||
|
||||
# Check for potential negative values
|
||||
for col in config.y:
|
||||
if not col.name:
|
||||
continue
|
||||
if any(term in col.name.lower() for term in ["loss", "debt", "negative"]):
|
||||
issues.append(
|
||||
f"Area chart with potentially negative values in '{col.name}' "
|
||||
@@ -356,8 +364,8 @@ class ChartTypeSuggester:
|
||||
suggestions = []
|
||||
|
||||
# Count different column types
|
||||
raw_columns = sum(1 for col in config.columns if not col.aggregate)
|
||||
metric_columns = sum(1 for col in config.columns if col.aggregate)
|
||||
raw_columns = sum(1 for col in config.columns if not col.is_metric)
|
||||
metric_columns = sum(1 for col in config.columns if col.is_metric)
|
||||
total_columns = len(config.columns)
|
||||
|
||||
# Check if data might be better visualized
|
||||
@@ -373,7 +381,8 @@ class ChartTypeSuggester:
|
||||
id_columns = sum(
|
||||
1
|
||||
for col in config.columns
|
||||
if any(i in col.name.lower() for i in ["id", "uuid", "guid", "key"])
|
||||
if col.name
|
||||
and any(i in col.name.lower() for i in ["id", "uuid", "guid", "key"])
|
||||
)
|
||||
if id_columns > total_columns / 2:
|
||||
suggestions.append(
|
||||
|
||||
@@ -392,22 +392,47 @@ class SchemaValidator:
|
||||
],
|
||||
error_code="INVALID_BIG_NUMBER_METRIC_TYPE",
|
||||
)
|
||||
if not metric.get("aggregate") and not metric.get("saved_metric"):
|
||||
if (
|
||||
not metric.get("aggregate")
|
||||
and not metric.get("saved_metric")
|
||||
and not metric.get("sql_expression")
|
||||
):
|
||||
return False, ChartGenerationError(
|
||||
error_type="missing_metric_aggregate",
|
||||
message="Big Number metric must include an aggregate function "
|
||||
"or reference a saved metric",
|
||||
details="The metric must have an 'aggregate' field "
|
||||
"or 'saved_metric': true",
|
||||
message="Big Number metric must include an aggregate function, "
|
||||
"a saved metric reference, or a SQL expression",
|
||||
details="The metric must have an 'aggregate' field, "
|
||||
"'saved_metric': true, or 'sql_expression'",
|
||||
suggestions=[
|
||||
"Add 'aggregate' to your metric: "
|
||||
"{'name': 'col', 'aggregate': 'SUM'}",
|
||||
"Or use a saved metric: "
|
||||
"{'name': 'total_sales', 'saved_metric': true}",
|
||||
"Or a custom SQL metric: "
|
||||
"{'sql_expression': 'SUM(a)/SUM(b)', 'label': 'Ratio'}",
|
||||
"Valid aggregates: SUM, COUNT, AVG, MIN, MAX",
|
||||
],
|
||||
error_code="MISSING_BIG_NUMBER_AGGREGATE",
|
||||
)
|
||||
# ``label`` may be any JSON type here (pre-Pydantic), so test the
|
||||
# string-ness explicitly before calling ``.strip()``.
|
||||
label = metric.get("label")
|
||||
if metric.get("sql_expression") and not (
|
||||
isinstance(label, str) and label.strip()
|
||||
):
|
||||
return False, ChartGenerationError(
|
||||
error_type="missing_sql_metric_label",
|
||||
message="Big Number metric with sql_expression requires a label",
|
||||
details=(
|
||||
"Custom SQL metrics have no column name to derive a label "
|
||||
"from, so 'label' is required for display."
|
||||
),
|
||||
suggestions=[
|
||||
"Add a 'label': "
|
||||
"{'sql_expression': 'SUM(a)/SUM(b)', 'label': 'Ratio'}",
|
||||
],
|
||||
error_code="MISSING_SQL_METRIC_LABEL",
|
||||
)
|
||||
|
||||
show_trendline = config.get("show_trendline", False)
|
||||
temporal_column = config.get("temporal_column")
|
||||
|
||||
@@ -635,3 +635,82 @@ CHART_ALL_COLUMNS: list[str] = []
|
||||
DATASET_ALL_COLUMNS: list[str] = []
|
||||
DASHBOARD_ALL_COLUMNS: list[str] = []
|
||||
DATABASE_ALL_COLUMNS: list[str] = []
|
||||
|
||||
|
||||
# CSS Template configuration
|
||||
CSS_TEMPLATE_DEFAULT_COLUMNS = [
|
||||
"id",
|
||||
"uuid",
|
||||
"template_name",
|
||||
]
|
||||
CSS_TEMPLATE_SORTABLE_COLUMNS = [
|
||||
"id",
|
||||
"template_name",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
CSS_TEMPLATE_SEARCH_COLUMNS = ["template_name"]
|
||||
CSS_TEMPLATE_FILTER_COLUMNS: dict[str, list[str]] = {
|
||||
"template_name": ["eq", "sw", "ilike"],
|
||||
"created_by_fk": ["eq"],
|
||||
}
|
||||
CSS_TEMPLATE_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
|
||||
"created_by_name": ColumnMetadata(
|
||||
name="created_by_name",
|
||||
description="Username of the creator",
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
"changed_by_name": ColumnMetadata(
|
||||
name="changed_by_name",
|
||||
description="Username of the last modifier",
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_css_template_columns() -> list[ColumnMetadata]:
|
||||
"""Get column metadata for CssTemplate model dynamically."""
|
||||
from superset.models.core import CssTemplate
|
||||
|
||||
return get_columns_from_model(
|
||||
CssTemplate,
|
||||
CSS_TEMPLATE_DEFAULT_COLUMNS,
|
||||
CSS_TEMPLATE_EXTRA_COLUMNS,
|
||||
exclude_columns=set(USER_DIRECTORY_FIELDS)
|
||||
- {"created_by_name", "changed_by_name"},
|
||||
)
|
||||
|
||||
|
||||
# Theme configuration
|
||||
THEME_DEFAULT_COLUMNS = [
|
||||
"id",
|
||||
"theme_name",
|
||||
"uuid",
|
||||
]
|
||||
THEME_SORTABLE_COLUMNS = [
|
||||
"id",
|
||||
"theme_name",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
THEME_SEARCH_COLUMNS = ["theme_name"]
|
||||
THEME_FILTER_COLUMNS: dict[str, list[str]] = {
|
||||
"theme_name": ["eq", "sw", "ilike"],
|
||||
"is_system": ["eq"],
|
||||
"is_system_default": ["eq"],
|
||||
"is_system_dark": ["eq"],
|
||||
"created_by_fk": ["eq"],
|
||||
}
|
||||
|
||||
|
||||
def get_theme_columns() -> list[ColumnMetadata]:
|
||||
"""Get column metadata for Theme model dynamically."""
|
||||
from superset.models.core import Theme
|
||||
|
||||
return get_columns_from_model(
|
||||
Theme,
|
||||
THEME_DEFAULT_COLUMNS,
|
||||
exclude_columns=set(USER_DIRECTORY_FIELDS),
|
||||
)
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
from typing import Literal
|
||||
|
||||
# Supported model types for schema discovery and MCP tools
|
||||
ModelType = Literal["chart", "dataset", "dashboard", "database"]
|
||||
ModelType = Literal[
|
||||
"chart", "dataset", "dashboard", "database", "css_template", "theme"
|
||||
]
|
||||
|
||||
# Pagination defaults
|
||||
DEFAULT_PAGE_SIZE = 10 # Default number of items per page
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,384 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Pydantic schemas for CSS template-related responses
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Dict, List, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
model_validator,
|
||||
PositiveInt,
|
||||
)
|
||||
|
||||
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
|
||||
from superset.mcp_service.common.cache_schemas import MetadataCacheControl
|
||||
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
from superset.mcp_service.system.schemas import PaginationInfo
|
||||
from superset.mcp_service.utils.sanitization import sanitize_for_llm_context
|
||||
from superset.mcp_service.utils.schema_utils import (
|
||||
parse_json_or_list,
|
||||
parse_json_or_model_list,
|
||||
)
|
||||
|
||||
|
||||
class CssTemplateFilter(ColumnOperator):
|
||||
"""
|
||||
Filter object for CSS template listing.
|
||||
col: The column to filter on. Must be one of the allowed filter fields.
|
||||
opr: The operator to use. Must be one of the supported operators.
|
||||
value: The value to filter by (type depends on col and opr).
|
||||
"""
|
||||
|
||||
col: Literal["template_name", "created_by_fk"] = Field(
|
||||
...,
|
||||
description="Column to filter on. Use get_schema(model_type='css_template') "
|
||||
"for available filter columns. To filter by creator, first call find_users "
|
||||
"to resolve a name to a user ID, then filter by created_by_fk with "
|
||||
"that integer ID.",
|
||||
)
|
||||
opr: ColumnOperatorEnum = Field(
|
||||
...,
|
||||
description="Operator to use.",
|
||||
)
|
||||
value: str | int | float | bool | List[str | int | float | bool] = Field(
|
||||
..., description="Value to filter by (type depends on col and opr)"
|
||||
)
|
||||
|
||||
|
||||
class CssTemplateInfo(BaseModel):
|
||||
id: int | None = Field(None, description="CSS template ID")
|
||||
uuid: str | None = Field(None, description="CSS template UUID")
|
||||
template_name: str | None = Field(None, description="CSS template name")
|
||||
css: str | None = Field(
|
||||
None,
|
||||
description="CSS content (can be large; request via select_columns=['css'])",
|
||||
)
|
||||
changed_on: str | datetime | None = Field(
|
||||
None, description="Last modification timestamp"
|
||||
)
|
||||
created_on: str | datetime | None = Field(None, description="Creation timestamp")
|
||||
created_by_name: str | None = Field(None, description="Username of the creator")
|
||||
changed_by_name: str | None = Field(
|
||||
None, description="Username of the last modifier"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
ser_json_timedelta="iso8601",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
|
||||
"""Filter fields based on serialization context.
|
||||
|
||||
If context contains 'select_columns', only include those fields.
|
||||
Otherwise, include all fields (default behavior).
|
||||
"""
|
||||
data = serializer(self)
|
||||
|
||||
if info.context and isinstance(info.context, dict):
|
||||
select_columns = info.context.get("select_columns")
|
||||
if select_columns:
|
||||
requested_fields = set(select_columns)
|
||||
return {k: v for k, v in data.items() if k in requested_fields}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class CssTemplateList(BaseModel):
|
||||
css_templates: List[CssTemplateInfo]
|
||||
count: int
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
has_previous: bool
|
||||
has_next: bool
|
||||
columns_requested: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Requested columns for the response",
|
||||
)
|
||||
columns_loaded: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that were actually loaded for each CSS template",
|
||||
)
|
||||
columns_available: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="All columns available for selection via select_columns parameter",
|
||||
)
|
||||
sortable_columns: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that can be used with order_column parameter",
|
||||
)
|
||||
filters_applied: List[CssTemplateFilter] = Field(
|
||||
default_factory=list,
|
||||
description="List of advanced filter dicts applied to the query.",
|
||||
)
|
||||
pagination: PaginationInfo | None = None
|
||||
timestamp: datetime | None = None
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class ListCssTemplatesRequest(MetadataCacheControl):
|
||||
"""Request schema for list_css_templates."""
|
||||
|
||||
filters: Annotated[
|
||||
List[CssTemplateFilter],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of filter objects (column, operator, value). Cannot be "
|
||||
"used together with 'search'.",
|
||||
),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of columns to select. Defaults to common columns if not "
|
||||
"specified. Use select_columns=['css'] to include the CSS content.",
|
||||
),
|
||||
]
|
||||
search: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Text search string to match against CSS template fields. "
|
||||
"Cannot be used together with 'filters'.",
|
||||
),
|
||||
]
|
||||
order_column: Annotated[
|
||||
str | None, Field(default=None, description="Column to order results by")
|
||||
]
|
||||
order_direction: Annotated[
|
||||
Literal["asc", "desc"],
|
||||
Field(
|
||||
default="desc", description="Direction to order results ('asc' or 'desc')"
|
||||
),
|
||||
]
|
||||
page: Annotated[
|
||||
PositiveInt,
|
||||
Field(default=1, description="Page number for pagination (1-based)"),
|
||||
]
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
gt=0,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("filters", mode="before")
|
||||
@classmethod
|
||||
def parse_filters(cls, v: Any) -> List[CssTemplateFilter]:
|
||||
"""Accept both JSON string and list of objects."""
|
||||
return parse_json_or_model_list(v, CssTemplateFilter, "filters")
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def parse_columns(cls, v: Any) -> List[str]:
|
||||
"""Accept JSON array, list, or comma-separated string."""
|
||||
return parse_json_or_list(v, "select_columns")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_search_and_filters(self) -> "ListCssTemplatesRequest":
|
||||
"""Prevent using both search and filters simultaneously."""
|
||||
if self.search and self.filters:
|
||||
raise ValueError(
|
||||
"Cannot use both 'search' and 'filters' parameters simultaneously. "
|
||||
"Use either 'search' for text-based searching across multiple fields, "
|
||||
"or 'filters' for precise column-based filtering, but not both."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class CssTemplateError(BaseModel):
|
||||
error: str = Field(..., description="Error message")
|
||||
error_type: str = Field(..., description="Type of error")
|
||||
timestamp: str | datetime | None = Field(None, description="Error timestamp")
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
@field_validator("error")
|
||||
@classmethod
|
||||
def sanitize_error_for_llm_context(cls, value: str) -> str:
|
||||
"""Wrap error text before it is exposed to LLM context."""
|
||||
return sanitize_for_llm_context(value, field_path=("error",))
|
||||
|
||||
@classmethod
|
||||
def create(cls, error: str, error_type: str) -> "CssTemplateError":
|
||||
"""Create a standardized CssTemplateError with timestamp."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return cls(
|
||||
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class GetCssTemplateInfoRequest(MetadataCacheControl):
|
||||
"""Request schema for get_css_template_info with support for ID or UUID."""
|
||||
|
||||
identifier: Annotated[
|
||||
int | str,
|
||||
Field(description="CSS template identifier - can be numeric ID or UUID string"),
|
||||
]
|
||||
|
||||
|
||||
def _sanitize_css_template_info_for_llm_context(
|
||||
css_template_info: CssTemplateInfo,
|
||||
) -> CssTemplateInfo:
|
||||
"""Wrap CSS template user-controlled fields before LLM exposure."""
|
||||
payload = css_template_info.model_dump(mode="python")
|
||||
for field_name in ("template_name", "css", "created_by_name", "changed_by_name"):
|
||||
payload[field_name] = sanitize_for_llm_context(
|
||||
payload.get(field_name),
|
||||
field_path=(field_name,),
|
||||
)
|
||||
return CssTemplateInfo.model_validate(payload)
|
||||
|
||||
|
||||
def serialize_css_template_object(obj: Any) -> CssTemplateInfo | None:
|
||||
if not obj:
|
||||
return None
|
||||
|
||||
return _sanitize_css_template_info_for_llm_context(
|
||||
CssTemplateInfo(
|
||||
id=getattr(obj, "id", None),
|
||||
uuid=str(getattr(obj, "uuid", "")) if getattr(obj, "uuid", None) else None,
|
||||
template_name=getattr(obj, "template_name", None),
|
||||
css=getattr(obj, "css", None),
|
||||
changed_on=getattr(obj, "changed_on", None),
|
||||
created_on=getattr(obj, "created_on", None),
|
||||
created_by_name=getattr(obj, "created_by_name", None) or None,
|
||||
changed_by_name=getattr(obj, "changed_by_name", None) or None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CreateCssTemplateRequest(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
template_name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=250,
|
||||
description="Name for the CSS template.",
|
||||
)
|
||||
css: str = Field(
|
||||
...,
|
||||
description="CSS content for the template.",
|
||||
)
|
||||
|
||||
@field_validator("template_name")
|
||||
@classmethod
|
||||
def template_name_must_not_be_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("template_name must not be empty")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class CreateCssTemplateResponse(BaseModel):
|
||||
"""Response schema for create_css_template."""
|
||||
|
||||
id: int | None = Field(
|
||||
None,
|
||||
description="ID of the created CSS template. None if creation failed.",
|
||||
)
|
||||
template_name: str | None = Field(
|
||||
None,
|
||||
description="Name of the created CSS template.",
|
||||
)
|
||||
css: str | None = Field(
|
||||
None,
|
||||
description="CSS content of the created template.",
|
||||
)
|
||||
error: str | None = Field(
|
||||
None,
|
||||
description="Error message if creation failed, otherwise null.",
|
||||
)
|
||||
|
||||
@field_validator("error")
|
||||
@classmethod
|
||||
def sanitize_error_for_llm_context(cls, value: str | None) -> str | None:
|
||||
"""Sanitize error text before it is exposed to LLM context."""
|
||||
if value is None:
|
||||
return value
|
||||
return sanitize_for_llm_context(value, field_path=("error",))
|
||||
|
||||
|
||||
class UpdateCssTemplateRequest(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: int = Field(..., description="ID of the CSS template to update.")
|
||||
template_name: str | None = Field(
|
||||
None,
|
||||
max_length=250,
|
||||
description="New name for the CSS template.",
|
||||
)
|
||||
css: str | None = Field(
|
||||
None,
|
||||
description="New CSS content for the template.",
|
||||
)
|
||||
|
||||
@field_validator("template_name")
|
||||
@classmethod
|
||||
def template_name_must_not_be_empty(cls, v: str | None) -> str | None:
|
||||
if v is not None:
|
||||
if not v.strip():
|
||||
raise ValueError("template_name must not be empty")
|
||||
return v.strip()
|
||||
return v
|
||||
|
||||
|
||||
class UpdateCssTemplateResponse(BaseModel):
|
||||
"""Response schema for update_css_template."""
|
||||
|
||||
id: int | None = Field(
|
||||
None,
|
||||
description="ID of the updated CSS template. None if update failed.",
|
||||
)
|
||||
template_name: str | None = Field(
|
||||
None,
|
||||
description="Name of the updated CSS template.",
|
||||
)
|
||||
css: str | None = Field(
|
||||
None,
|
||||
description="CSS content of the updated template.",
|
||||
)
|
||||
error: str | None = Field(
|
||||
None,
|
||||
description="Error message if update failed, otherwise null.",
|
||||
)
|
||||
|
||||
@field_validator("error")
|
||||
@classmethod
|
||||
def sanitize_error_for_llm_context(cls, value: str | None) -> str | None:
|
||||
"""Sanitize error text before it is exposed to LLM context."""
|
||||
if value is None:
|
||||
return value
|
||||
return sanitize_for_llm_context(value, field_path=("error",))
|
||||
@@ -0,0 +1,28 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .create_css_template import create_css_template
|
||||
from .get_css_template_info import get_css_template_info
|
||||
from .list_css_templates import list_css_templates
|
||||
from .update_css_template import update_css_template
|
||||
|
||||
__all__ = [
|
||||
"list_css_templates",
|
||||
"get_css_template_info",
|
||||
"create_css_template",
|
||||
"update_css_template",
|
||||
]
|
||||
@@ -0,0 +1,95 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.css_template.schemas import (
|
||||
CreateCssTemplateRequest,
|
||||
CreateCssTemplateResponse,
|
||||
)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["mutate"],
|
||||
class_permission_name="CssTemplate",
|
||||
method_permission_name="write",
|
||||
annotations=ToolAnnotations(
|
||||
title="Create CSS template",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def create_css_template(
|
||||
request: CreateCssTemplateRequest, ctx: Context
|
||||
) -> CreateCssTemplateResponse:
|
||||
"""Create a new CSS template that can be applied to dashboards.
|
||||
|
||||
Use this tool when a user wants to save a CSS stylesheet as a named
|
||||
template for reuse across multiple dashboards.
|
||||
|
||||
The returned ``id`` can be used when configuring dashboard appearance.
|
||||
"""
|
||||
await ctx.info("Creating CSS template: template_name=%r" % (request.template_name,))
|
||||
|
||||
try:
|
||||
from superset.commands.css.create import CreateCssTemplateCommand
|
||||
from superset.commands.css.exceptions import (
|
||||
CssTemplateCreateFailedError,
|
||||
CssTemplateInvalidError,
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.create_css_template.create"):
|
||||
template = CreateCssTemplateCommand(
|
||||
{
|
||||
"template_name": request.template_name,
|
||||
"css": request.css,
|
||||
}
|
||||
).run()
|
||||
|
||||
await ctx.info(
|
||||
"CSS template created: id=%s, template_name=%r"
|
||||
% (template.id, template.template_name)
|
||||
)
|
||||
|
||||
return CreateCssTemplateResponse(
|
||||
id=template.id,
|
||||
template_name=template.template_name,
|
||||
css=template.css,
|
||||
)
|
||||
|
||||
except CssTemplateInvalidError as exc:
|
||||
await ctx.warning("CSS template validation failed: %s" % (str(exc),))
|
||||
return CreateCssTemplateResponse(
|
||||
template_name=request.template_name,
|
||||
css=request.css,
|
||||
error=str(exc),
|
||||
)
|
||||
except CssTemplateCreateFailedError as exc:
|
||||
await ctx.error("CSS template creation failed: %s" % (str(exc),))
|
||||
return CreateCssTemplateResponse(
|
||||
template_name=request.template_name,
|
||||
css=request.css,
|
||||
error=f"Failed to create CSS template: {exc}",
|
||||
)
|
||||
except Exception as exc:
|
||||
await ctx.error(
|
||||
"Unexpected error creating CSS template: %s: %s"
|
||||
% (type(exc).__name__, str(exc))
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,108 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Get CSS template info FastMCP tool
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.css_template.schemas import (
|
||||
CssTemplateError,
|
||||
CssTemplateInfo,
|
||||
GetCssTemplateInfoRequest,
|
||||
serialize_css_template_object,
|
||||
)
|
||||
from superset.mcp_service.mcp_core import ModelGetInfoCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="CssTemplate",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get CSS template info",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def get_css_template_info(
|
||||
request: GetCssTemplateInfoRequest, ctx: Context
|
||||
) -> CssTemplateInfo | CssTemplateError:
|
||||
"""Get CSS template details by ID or UUID.
|
||||
|
||||
Returns the full CSS template including the css content.
|
||||
|
||||
IMPORTANT FOR LLM CLIENTS:
|
||||
- Use numeric ID (e.g., 123) or UUID string (e.g., "a1b2c3d4-...")
|
||||
- To find a CSS template ID, use the list_css_templates tool first
|
||||
|
||||
Example usage:
|
||||
```json
|
||||
{
|
||||
"identifier": 1
|
||||
}
|
||||
```
|
||||
"""
|
||||
await ctx.info(
|
||||
"Retrieving CSS template information: identifier=%s" % (request.identifier,)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.css import CssTemplateDAO
|
||||
|
||||
with event_logger.log_context(action="mcp.get_css_template_info.lookup"):
|
||||
get_tool = ModelGetInfoCore(
|
||||
dao_class=CssTemplateDAO,
|
||||
output_schema=CssTemplateInfo,
|
||||
error_schema=CssTemplateError,
|
||||
serializer=serialize_css_template_object,
|
||||
supports_slug=False,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
result = get_tool.run_tool(request.identifier)
|
||||
|
||||
if isinstance(result, CssTemplateInfo):
|
||||
await ctx.info(
|
||||
"CSS template information retrieved successfully: "
|
||||
"id=%s, template_name=%s" % (result.id, result.template_name)
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"CSS template retrieval failed: error_type=%s, error=%s"
|
||||
% (result.error_type, result.error)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"CSS template information retrieval failed: identifier=%s, error=%s, "
|
||||
"error_type=%s" % (request.identifier, str(e), type(e).__name__)
|
||||
)
|
||||
return CssTemplateError(
|
||||
error=f"Failed to get CSS template info: {str(e)}",
|
||||
error_type="InternalError",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
List CSS templates FastMCP tool
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.core import CssTemplate
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.css_template.schemas import (
|
||||
CssTemplateError,
|
||||
CssTemplateFilter,
|
||||
CssTemplateInfo,
|
||||
CssTemplateList,
|
||||
ListCssTemplatesRequest,
|
||||
serialize_css_template_object,
|
||||
)
|
||||
from superset.mcp_service.mcp_core import ModelListCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_LIST_CSS_TEMPLATES_REQUEST = ListCssTemplatesRequest()
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="CssTemplate",
|
||||
annotations=ToolAnnotations(
|
||||
title="List CSS templates",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def list_css_templates(
|
||||
request: ListCssTemplatesRequest | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> CssTemplateList | CssTemplateError:
|
||||
"""List CSS templates with filtering and search.
|
||||
|
||||
Returns CSS template metadata including name. Use select_columns=['css']
|
||||
to include the CSS content (omitted by default due to size).
|
||||
|
||||
Sortable columns for order_column: id, template_name, changed_on,
|
||||
created_on
|
||||
"""
|
||||
if ctx is None:
|
||||
raise RuntimeError("FastMCP context is required for list_css_templates")
|
||||
|
||||
request = request or _DEFAULT_LIST_CSS_TEMPLATES_REQUEST.model_copy(deep=True)
|
||||
|
||||
await ctx.info(
|
||||
"Listing CSS templates: page=%s, page_size=%s, search=%s"
|
||||
% (request.page, request.page_size, request.search)
|
||||
)
|
||||
await ctx.debug(
|
||||
"CSS template listing parameters: filters=%s, order_column=%s, "
|
||||
"order_direction=%s, select_columns=%s"
|
||||
% (
|
||||
request.filters,
|
||||
request.order_column,
|
||||
request.order_direction,
|
||||
request.select_columns,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.css import CssTemplateDAO
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
CSS_TEMPLATE_DEFAULT_COLUMNS,
|
||||
CSS_TEMPLATE_SORTABLE_COLUMNS,
|
||||
get_all_column_names,
|
||||
get_css_template_columns,
|
||||
)
|
||||
|
||||
all_columns = get_all_column_names(get_css_template_columns())
|
||||
|
||||
def _serialize_css_template(
|
||||
obj: "CssTemplate | None", cols: list[str] | None
|
||||
) -> CssTemplateInfo | None:
|
||||
return serialize_css_template_object(obj)
|
||||
|
||||
list_tool = ModelListCore(
|
||||
dao_class=CssTemplateDAO,
|
||||
output_schema=CssTemplateInfo,
|
||||
item_serializer=_serialize_css_template,
|
||||
filter_type=CssTemplateFilter,
|
||||
default_columns=CSS_TEMPLATE_DEFAULT_COLUMNS,
|
||||
search_columns=["template_name"],
|
||||
list_field_name="css_templates",
|
||||
output_list_schema=CssTemplateList,
|
||||
all_columns=all_columns,
|
||||
sortable_columns=CSS_TEMPLATE_SORTABLE_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.list_css_templates.query"):
|
||||
result = list_tool.run_tool(
|
||||
filters=request.filters,
|
||||
search=request.search,
|
||||
select_columns=request.select_columns,
|
||||
order_column=request.order_column,
|
||||
order_direction=request.order_direction,
|
||||
page=max(request.page - 1, 0),
|
||||
page_size=request.page_size,
|
||||
)
|
||||
|
||||
await ctx.info(
|
||||
"CSS templates listed successfully: count=%s, total_count=%s"
|
||||
% (
|
||||
len(result.css_templates) if hasattr(result, "css_templates") else 0,
|
||||
getattr(result, "total_count", None),
|
||||
)
|
||||
)
|
||||
|
||||
columns_to_filter = result.columns_requested
|
||||
with event_logger.log_context(action="mcp.list_css_templates.serialization"):
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
context={"select_columns": columns_to_filter},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"CSS template listing failed: page=%s, page_size=%s, error=%s, "
|
||||
"error_type=%s"
|
||||
% (request.page, request.page_size, str(e), type(e).__name__)
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,111 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.css_template.schemas import (
|
||||
UpdateCssTemplateRequest,
|
||||
UpdateCssTemplateResponse,
|
||||
)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["mutate"],
|
||||
class_permission_name="CssTemplate",
|
||||
method_permission_name="write",
|
||||
annotations=ToolAnnotations(
|
||||
title="Update CSS template",
|
||||
readOnlyHint=False,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def update_css_template(
|
||||
request: UpdateCssTemplateRequest, ctx: Context
|
||||
) -> UpdateCssTemplateResponse:
|
||||
"""Update an existing CSS template's name or CSS content.
|
||||
|
||||
Use this tool when a user wants to rename a CSS template or replace its
|
||||
CSS content. At least one of ``template_name`` or ``css`` must be provided.
|
||||
|
||||
The template is identified by its ``id``.
|
||||
"""
|
||||
await ctx.info(
|
||||
"Updating CSS template: id=%s, fields=%r"
|
||||
% (
|
||||
request.id,
|
||||
[f for f in ("template_name", "css") if getattr(request, f) is not None],
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.commands.css.exceptions import (
|
||||
CssTemplateInvalidError,
|
||||
CssTemplateNotFoundError,
|
||||
CssTemplateUpdateFailedError,
|
||||
)
|
||||
from superset.commands.css.update import UpdateCssTemplateCommand
|
||||
|
||||
properties: dict[str, Any] = {}
|
||||
if request.template_name is not None:
|
||||
properties["template_name"] = request.template_name
|
||||
if request.css is not None:
|
||||
properties["css"] = request.css
|
||||
|
||||
if not properties:
|
||||
return UpdateCssTemplateResponse(
|
||||
error="At least one of template_name or css must be provided.",
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.update_css_template.update"):
|
||||
template = UpdateCssTemplateCommand(request.id, properties).run()
|
||||
|
||||
await ctx.info(
|
||||
"CSS template updated: id=%s, template_name=%r"
|
||||
% (template.id, template.template_name)
|
||||
)
|
||||
|
||||
return UpdateCssTemplateResponse(
|
||||
id=template.id,
|
||||
template_name=template.template_name,
|
||||
css=template.css,
|
||||
)
|
||||
|
||||
except CssTemplateNotFoundError:
|
||||
await ctx.warning("CSS template not found: id=%s" % (request.id,))
|
||||
return UpdateCssTemplateResponse(
|
||||
error="CSS template not found: %s" % (request.id,),
|
||||
)
|
||||
except CssTemplateInvalidError as exc:
|
||||
await ctx.warning("CSS template validation failed: %s" % (str(exc),))
|
||||
return UpdateCssTemplateResponse(
|
||||
error=str(exc),
|
||||
)
|
||||
except CssTemplateUpdateFailedError as exc:
|
||||
await ctx.error("CSS template update failed: %s" % (str(exc),))
|
||||
return UpdateCssTemplateResponse(
|
||||
error="Failed to update CSS template: %s" % (exc,),
|
||||
)
|
||||
except Exception as exc:
|
||||
await ctx.error(
|
||||
"Unexpected error updating CSS template: %s: %s"
|
||||
% (type(exc).__name__, str(exc))
|
||||
)
|
||||
raise
|
||||
@@ -69,7 +69,6 @@ import logging
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, cast, Dict, List, Literal, TYPE_CHECKING
|
||||
|
||||
import humanize
|
||||
from pydantic import (
|
||||
AliasChoices,
|
||||
BaseModel,
|
||||
@@ -104,6 +103,7 @@ from superset.mcp_service.utils import (
|
||||
escape_llm_context_delimiters,
|
||||
sanitize_for_llm_context,
|
||||
)
|
||||
from superset.mcp_service.utils.response_utils import humanize_timestamp
|
||||
from superset.mcp_service.utils.sanitization import (
|
||||
sanitize_user_input,
|
||||
sanitize_user_input_with_changes,
|
||||
@@ -314,6 +314,17 @@ class GetDashboardInfoRequest(MetadataCacheControl):
|
||||
)
|
||||
|
||||
|
||||
class GetDashboardLayoutRequest(BaseModel):
|
||||
"""Request schema for get_dashboard_layout."""
|
||||
|
||||
identifier: Annotated[
|
||||
int | str,
|
||||
Field(
|
||||
description="Dashboard identifier - can be numeric ID, UUID string, or slug"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -648,6 +659,71 @@ class GenerateDashboardResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ChartPosition(BaseModel):
|
||||
"""Position and identity of a chart within a dashboard layout."""
|
||||
|
||||
chart_id: int | None = Field(None, description="Chart (slice) ID")
|
||||
slice_name: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Display name as configured in the layout (sliceNameOverride or sliceName)"
|
||||
),
|
||||
)
|
||||
tab_id: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"ID of the tab that contains this chart, or None for charts not nested "
|
||||
"under any TAB component."
|
||||
),
|
||||
)
|
||||
tab_path: List[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Names of ancestor tabs (outermost first) so the agent can describe "
|
||||
"where the chart lives in nested tab layouts."
|
||||
),
|
||||
)
|
||||
width: int | None = Field(None, description="Grid column width")
|
||||
height: int | None = Field(None, description="Grid row height")
|
||||
|
||||
|
||||
class DashboardTab(BaseModel):
|
||||
"""A tab in a dashboard layout."""
|
||||
|
||||
id: str = Field(..., description="Tab component ID from position_json")
|
||||
name: str | None = Field(None, description="Tab display name")
|
||||
parent_tab_id: str | None = Field(
|
||||
None,
|
||||
description=("ID of the enclosing tab when tabs are nested, otherwise None."),
|
||||
)
|
||||
chart_ids: List[int] = Field(
|
||||
default_factory=list,
|
||||
description="IDs of charts contained directly or indirectly under this tab",
|
||||
)
|
||||
|
||||
|
||||
class DashboardLayout(BaseModel):
|
||||
"""Parsed layout data for a dashboard, derived from position_json."""
|
||||
|
||||
id: int | None = Field(None, description="Dashboard ID")
|
||||
dashboard_title: str | None = Field(None, description="Dashboard title")
|
||||
uuid: str | None = Field(None, description="Dashboard UUID")
|
||||
tabs: List[DashboardTab] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Tabs declared in the dashboard layout (empty for untabbed dashboards)"
|
||||
),
|
||||
)
|
||||
charts: List[ChartPosition] = Field(
|
||||
default_factory=list,
|
||||
description="Charts placed in the dashboard layout with their tab context",
|
||||
)
|
||||
has_layout: bool = Field(
|
||||
default=False,
|
||||
description="False when position_json is missing or empty",
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_metadata(json_metadata_str: str | None) -> Dict[str, Any] | None:
|
||||
"""Parse json_metadata string into a dict, returning None on any failure.
|
||||
|
||||
@@ -719,6 +795,126 @@ def _extract_cross_filters_enabled(json_metadata_str: str | None) -> bool | None
|
||||
return None
|
||||
|
||||
|
||||
def _parse_position_json(
|
||||
position_json_str: str | None,
|
||||
) -> Dict[str, Any] | None:
|
||||
"""Parse position_json into a dict, returning None on any failure."""
|
||||
if not position_json_str:
|
||||
return None
|
||||
try:
|
||||
data = json_loads(position_json_str)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _record_tab(
|
||||
node_id: str,
|
||||
meta: Dict[str, Any],
|
||||
tab_ancestry: tuple[str, ...],
|
||||
tabs_by_id: Dict[str, DashboardTab],
|
||||
) -> None:
|
||||
"""Register a TAB node into tabs_by_id keyed by component id."""
|
||||
raw_text = meta.get("text")
|
||||
tab_name = raw_text if isinstance(raw_text, str) else None
|
||||
tabs_by_id[node_id] = DashboardTab(
|
||||
id=node_id,
|
||||
name=tab_name,
|
||||
parent_tab_id=tab_ancestry[-1] if tab_ancestry else None,
|
||||
)
|
||||
|
||||
|
||||
def _record_chart(
|
||||
meta: Dict[str, Any],
|
||||
tab_ancestry: tuple[str, ...],
|
||||
tabs_by_id: Dict[str, DashboardTab],
|
||||
charts: List[ChartPosition],
|
||||
) -> None:
|
||||
"""Record a CHART node's position and update enclosing tabs."""
|
||||
raw_chart_id = meta.get("chartId")
|
||||
chart_id = raw_chart_id if isinstance(raw_chart_id, int) else None
|
||||
display_name = meta.get("sliceNameOverride") or meta.get("sliceName")
|
||||
raw_width = meta.get("width")
|
||||
raw_height = meta.get("height")
|
||||
charts.append(
|
||||
ChartPosition(
|
||||
chart_id=chart_id,
|
||||
slice_name=display_name if isinstance(display_name, str) else None,
|
||||
tab_id=tab_ancestry[-1] if tab_ancestry else None,
|
||||
tab_path=[tabs_by_id[t].name or t for t in tab_ancestry if t in tabs_by_id],
|
||||
width=raw_width if isinstance(raw_width, int) else None,
|
||||
height=raw_height if isinstance(raw_height, int) else None,
|
||||
)
|
||||
)
|
||||
if chart_id is None:
|
||||
return
|
||||
for ancestor_id in tab_ancestry:
|
||||
tab = tabs_by_id.get(ancestor_id)
|
||||
if tab is not None and chart_id not in tab.chart_ids:
|
||||
tab.chart_ids.append(chart_id)
|
||||
|
||||
|
||||
def _extract_layout_from_position(
|
||||
position_json_str: str | None,
|
||||
) -> tuple[List[DashboardTab], List[ChartPosition]]:
|
||||
"""Walk position_json and return (tabs, chart_positions).
|
||||
|
||||
Traverses the component tree iteratively starting from ROOT_ID. Tab
|
||||
ancestry is tracked so chart placement and nested tab references stay
|
||||
accurate. Malformed or missing nodes are skipped silently — partial
|
||||
data is more useful than an exception here, since agents call this
|
||||
tool defensively after seeing the omitted_fields hint.
|
||||
"""
|
||||
position = _parse_position_json(position_json_str)
|
||||
if not position or "ROOT_ID" not in position:
|
||||
return [], []
|
||||
|
||||
tabs_by_id: Dict[str, DashboardTab] = {}
|
||||
charts: List[ChartPosition] = []
|
||||
|
||||
stack: List[tuple[str, tuple[str, ...]]] = [("ROOT_ID", ())]
|
||||
visited: set[str] = set()
|
||||
|
||||
while stack:
|
||||
node_id, tab_ancestry = stack.pop()
|
||||
if node_id in visited:
|
||||
continue
|
||||
visited.add(node_id)
|
||||
|
||||
node = position.get(node_id)
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
node_type = node.get("type")
|
||||
raw_meta = node.get("meta")
|
||||
meta: Dict[str, Any] = raw_meta if isinstance(raw_meta, dict) else {}
|
||||
next_ancestry = tab_ancestry
|
||||
|
||||
if node_type == "TAB":
|
||||
_record_tab(node_id, meta, tab_ancestry, tabs_by_id)
|
||||
next_ancestry = tab_ancestry + (node_id,)
|
||||
elif node_type == "CHART":
|
||||
_record_chart(meta, tab_ancestry, tabs_by_id, charts)
|
||||
|
||||
children = node.get("children")
|
||||
if isinstance(children, list):
|
||||
for child_id in reversed(children):
|
||||
if isinstance(child_id, str):
|
||||
stack.append((child_id, next_ancestry))
|
||||
|
||||
tab_order = [
|
||||
node_id
|
||||
for node_id in position
|
||||
if isinstance(position.get(node_id), dict)
|
||||
and position[node_id].get("type") == "TAB"
|
||||
and node_id in tabs_by_id
|
||||
]
|
||||
tabs = [tabs_by_id[node_id] for node_id in tab_order]
|
||||
return tabs, charts
|
||||
|
||||
|
||||
def _build_omitted_fields(
|
||||
json_metadata_str: str | None, position_json_str: str | None
|
||||
) -> Dict[str, str]:
|
||||
@@ -736,7 +932,8 @@ def _build_omitted_fields(
|
||||
raw_value=position_json_str,
|
||||
reason=(
|
||||
"Internal layout tree with component positions/hierarchy. "
|
||||
"Not useful for analysis or LLM context."
|
||||
"Call get_dashboard_layout(identifier) to retrieve parsed tabs "
|
||||
"and chart positions on demand."
|
||||
),
|
||||
)
|
||||
.add_extracted_field(
|
||||
@@ -927,13 +1124,6 @@ def dashboard_serializer(dashboard: "Dashboard") -> DashboardInfo:
|
||||
)
|
||||
|
||||
|
||||
def _humanize_timestamp(dt: datetime | None) -> str | None:
|
||||
"""Convert a datetime to a humanized string like '2 hours ago'."""
|
||||
if dt is None:
|
||||
return None
|
||||
return humanize.naturaltime(datetime.now() - dt)
|
||||
|
||||
|
||||
def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
|
||||
"""Simple dashboard serializer that safely handles object attributes."""
|
||||
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
||||
@@ -960,11 +1150,11 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
|
||||
url=dashboard_url,
|
||||
published=getattr(dashboard, "published", None),
|
||||
changed_on=getattr(dashboard, "changed_on", None),
|
||||
changed_on_humanized=_humanize_timestamp(
|
||||
changed_on_humanized=humanize_timestamp(
|
||||
getattr(dashboard, "changed_on", None)
|
||||
),
|
||||
created_on=getattr(dashboard, "created_on", None),
|
||||
created_on_humanized=_humanize_timestamp(
|
||||
created_on_humanized=humanize_timestamp(
|
||||
getattr(dashboard, "created_on", None)
|
||||
),
|
||||
description=getattr(dashboard, "description", None),
|
||||
@@ -1004,3 +1194,58 @@ def serialize_dashboard_object(dashboard: Any) -> DashboardInfo:
|
||||
else [],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_dashboard_layout_for_llm_context(
|
||||
layout: DashboardLayout,
|
||||
) -> DashboardLayout:
|
||||
"""Wrap layout text fields before LLM exposure."""
|
||||
payload = layout.model_dump(mode="python")
|
||||
payload["dashboard_title"] = sanitize_for_llm_context(
|
||||
payload.get("dashboard_title"),
|
||||
field_path=("dashboard_title",),
|
||||
)
|
||||
payload["tabs"] = [
|
||||
{
|
||||
**tab,
|
||||
"name": sanitize_for_llm_context(
|
||||
tab.get("name"),
|
||||
field_path=("tabs", str(index), "name"),
|
||||
),
|
||||
}
|
||||
for index, tab in enumerate(payload.get("tabs", []))
|
||||
]
|
||||
payload["charts"] = [
|
||||
{
|
||||
**chart,
|
||||
"slice_name": sanitize_for_llm_context(
|
||||
chart.get("slice_name"),
|
||||
field_path=("charts", str(index), "slice_name"),
|
||||
),
|
||||
"tab_path": [
|
||||
sanitize_for_llm_context(
|
||||
name,
|
||||
field_path=("charts", str(index), "tab_path", str(part_index)),
|
||||
)
|
||||
for part_index, name in enumerate(chart.get("tab_path", []) or [])
|
||||
],
|
||||
}
|
||||
for index, chart in enumerate(payload.get("charts", []))
|
||||
]
|
||||
return DashboardLayout.model_validate(payload)
|
||||
|
||||
|
||||
def dashboard_layout_serializer(dashboard: "Dashboard") -> DashboardLayout:
|
||||
"""Serialize a Dashboard model to a parsed DashboardLayout."""
|
||||
position_json_str = getattr(dashboard, "position_json", None)
|
||||
tabs, charts = _extract_layout_from_position(position_json_str)
|
||||
return _sanitize_dashboard_layout_for_llm_context(
|
||||
DashboardLayout(
|
||||
id=dashboard.id,
|
||||
dashboard_title=dashboard.dashboard_title or "Untitled",
|
||||
uuid=str(dashboard.uuid) if dashboard.uuid else None,
|
||||
tabs=tabs,
|
||||
charts=charts,
|
||||
has_layout=bool(position_json_str),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -18,11 +18,13 @@
|
||||
from .add_chart_to_existing_dashboard import add_chart_to_existing_dashboard
|
||||
from .generate_dashboard import generate_dashboard
|
||||
from .get_dashboard_info import get_dashboard_info
|
||||
from .get_dashboard_layout import get_dashboard_layout
|
||||
from .list_dashboards import list_dashboards
|
||||
|
||||
__all__ = [
|
||||
"list_dashboards",
|
||||
"get_dashboard_info",
|
||||
"get_dashboard_layout",
|
||||
"generate_dashboard",
|
||||
"add_chart_to_existing_dashboard",
|
||||
]
|
||||
|
||||
@@ -414,10 +414,10 @@ def generate_dashboard( # noqa: C901
|
||||
|
||||
# Convert to our response format
|
||||
from superset.mcp_service.dashboard.schemas import (
|
||||
_humanize_timestamp,
|
||||
serialize_chart_summary,
|
||||
serialize_tag_object,
|
||||
)
|
||||
from superset.mcp_service.utils.response_utils import humanize_timestamp
|
||||
|
||||
include_data_model_metadata = user_can_view_data_model_metadata()
|
||||
dashboard_info = DashboardInfo(
|
||||
@@ -428,8 +428,8 @@ def generate_dashboard( # noqa: C901
|
||||
published=dashboard.published,
|
||||
created_on=dashboard.created_on,
|
||||
changed_on=dashboard.changed_on,
|
||||
created_on_humanized=_humanize_timestamp(dashboard.created_on),
|
||||
changed_on_humanized=_humanize_timestamp(dashboard.changed_on),
|
||||
created_on_humanized=humanize_timestamp(dashboard.created_on),
|
||||
changed_on_humanized=humanize_timestamp(dashboard.changed_on),
|
||||
created_by=dashboard.created_by_name or None,
|
||||
changed_by=dashboard.changed_by_name or None,
|
||||
uuid=str(dashboard.uuid) if dashboard.uuid else None,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Get dashboard layout FastMCP tool
|
||||
|
||||
Companion to get_dashboard_info: returns the parsed dashboard layout
|
||||
(tabs and chart positions) extracted from position_json. Use this
|
||||
when get_dashboard_info's omitted_fields hint indicates position_json
|
||||
was stripped and structured layout data is needed for analysis.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.dashboard.schemas import (
|
||||
dashboard_layout_serializer,
|
||||
DashboardError,
|
||||
DashboardLayout,
|
||||
GetDashboardLayoutRequest,
|
||||
)
|
||||
from superset.mcp_service.mcp_core import ModelGetInfoCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="Dashboard",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get dashboard layout",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def get_dashboard_layout(
|
||||
request: GetDashboardLayoutRequest, ctx: Context
|
||||
) -> DashboardLayout | DashboardError:
|
||||
"""
|
||||
Get parsed dashboard layout by ID, UUID, or slug.
|
||||
|
||||
Returns the tabs and chart positions extracted from the dashboard's
|
||||
position_json. get_dashboard_info omits position_json to keep responses
|
||||
small; call this tool when you need the structured layout (e.g. to
|
||||
explain which charts live under which tab, or to locate a chart by
|
||||
its parent tab).
|
||||
|
||||
Example usage:
|
||||
```json
|
||||
{
|
||||
"identifier": 123
|
||||
}
|
||||
```
|
||||
"""
|
||||
await ctx.info("Retrieving dashboard layout: identifier=%s" % (request.identifier,))
|
||||
|
||||
try:
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
|
||||
# No eager loading: the layout serializer only reads position_json
|
||||
# (plus id/title/uuid), so Dashboard.slices is never accessed.
|
||||
with event_logger.log_context(action="mcp.get_dashboard_layout.lookup"):
|
||||
core = ModelGetInfoCore(
|
||||
dao_class=DashboardDAO,
|
||||
output_schema=DashboardLayout,
|
||||
error_schema=DashboardError,
|
||||
serializer=dashboard_layout_serializer,
|
||||
supports_slug=True,
|
||||
logger=logger,
|
||||
)
|
||||
result = core.run_tool(request.identifier)
|
||||
|
||||
if isinstance(result, DashboardLayout):
|
||||
await ctx.info(
|
||||
"Dashboard layout retrieved: id=%s, tab_count=%s, chart_count=%s, "
|
||||
"has_layout=%s"
|
||||
% (
|
||||
result.id,
|
||||
len(result.tabs),
|
||||
len(result.charts),
|
||||
result.has_layout,
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"Dashboard layout retrieval failed: error_type=%s, error=%s"
|
||||
% (result.error_type, result.error)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Dashboard layout retrieval failed: identifier=%s, error=%s, "
|
||||
"error_type=%s" % (request.identifier, str(e), type(e).__name__)
|
||||
)
|
||||
return DashboardError(
|
||||
error=f"Failed to get dashboard layout: {str(e)}",
|
||||
error_type="InternalError",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -24,7 +24,6 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, cast, Dict, List, Literal
|
||||
|
||||
import humanize
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
@@ -43,6 +42,7 @@ from superset.mcp_service.common.cache_schemas import (
|
||||
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
from superset.mcp_service.privacy import filter_user_directory_fields
|
||||
from superset.mcp_service.system.schemas import PaginationInfo
|
||||
from superset.mcp_service.utils.response_utils import humanize_timestamp
|
||||
from superset.mcp_service.utils.schema_utils import (
|
||||
parse_json_or_list,
|
||||
parse_json_or_model_list,
|
||||
@@ -305,14 +305,6 @@ def _parse_json_field(obj: Any, field_name: str) -> Dict[str, Any] | None:
|
||||
return value
|
||||
|
||||
|
||||
def _humanize_timestamp(dt: datetime | None) -> str | None:
|
||||
"""Convert a datetime to a humanized string like '2 hours ago'."""
|
||||
if dt is None:
|
||||
return None
|
||||
now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now()
|
||||
return humanize.naturaltime(now - dt)
|
||||
|
||||
|
||||
def _get_backend(database: Any) -> str | None:
|
||||
"""Safely get backend from a Database object or row proxy.
|
||||
|
||||
@@ -351,7 +343,7 @@ def serialize_database_object(database: Any) -> DatabaseInfo | None:
|
||||
external_url=getattr(database, "external_url", None),
|
||||
extra=_parse_json_field(database, "extra"),
|
||||
changed_on=getattr(database, "changed_on", None),
|
||||
changed_on_humanized=_humanize_timestamp(getattr(database, "changed_on", None)),
|
||||
changed_on_humanized=humanize_timestamp(getattr(database, "changed_on", None)),
|
||||
created_on=getattr(database, "created_on", None),
|
||||
created_on_humanized=_humanize_timestamp(getattr(database, "created_on", None)),
|
||||
created_on_humanized=humanize_timestamp(getattr(database, "created_on", None)),
|
||||
)
|
||||
|
||||
@@ -24,7 +24,6 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Dict, List, Literal
|
||||
|
||||
import humanize
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
@@ -54,6 +53,7 @@ from superset.mcp_service.utils import (
|
||||
escape_llm_context_delimiters,
|
||||
sanitize_for_llm_context,
|
||||
)
|
||||
from superset.mcp_service.utils.response_utils import humanize_timestamp
|
||||
from superset.utils import json
|
||||
|
||||
|
||||
@@ -554,13 +554,6 @@ def _parse_json_field(obj: Any, field_name: str) -> Dict[str, Any] | None:
|
||||
return value
|
||||
|
||||
|
||||
def _humanize_timestamp(dt: datetime | None) -> str | None:
|
||||
"""Convert a datetime to a humanized string like '2 hours ago'."""
|
||||
if dt is None:
|
||||
return None
|
||||
return humanize.naturaltime(datetime.now() - dt)
|
||||
|
||||
|
||||
def _sanitize_dataset_info_for_llm_context(dataset_info: DatasetInfo) -> DatasetInfo:
|
||||
"""Wrap dataset read-path descriptive fields before LLM exposure."""
|
||||
payload = dataset_info.model_dump(mode="python")
|
||||
@@ -691,11 +684,11 @@ def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
|
||||
certified_by=getattr(dataset, "certified_by", None),
|
||||
certification_details=getattr(dataset, "certification_details", None),
|
||||
changed_on=getattr(dataset, "changed_on", None),
|
||||
changed_on_humanized=_humanize_timestamp(
|
||||
changed_on_humanized=humanize_timestamp(
|
||||
getattr(dataset, "changed_on", None)
|
||||
),
|
||||
created_on=getattr(dataset, "created_on", None),
|
||||
created_on_humanized=_humanize_timestamp(
|
||||
created_on_humanized=humanize_timestamp(
|
||||
getattr(dataset, "created_on", None)
|
||||
),
|
||||
tags=[
|
||||
|
||||
@@ -26,8 +26,10 @@ HTTP responses always return generic errors per RFC 6750 Section 3.1.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import html as html_module
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -44,8 +46,9 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend
|
||||
from starlette.authentication import AuthenticationError
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
from starlette.requests import HTTPConnection
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import HTTPConnection, Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response
|
||||
|
||||
from superset.utils import json
|
||||
|
||||
@@ -61,21 +64,247 @@ _jwt_failure_reason: ContextVar[str | None] = ContextVar(
|
||||
"_jwt_failure_reason", default=None
|
||||
)
|
||||
|
||||
_HTML_STYLES = """
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: #f5f5f5;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
padding: 40px 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.card {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.12);
|
||||
padding: 40px 40px 32px;
|
||||
}
|
||||
h1 { font-size: 1.4rem; margin: 0 0 8px; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: #e8f4fd;
|
||||
color: #0070c0;
|
||||
font-size: .75rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
letter-spacing: .04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
p { margin: 0 0 20px; color: #444; }
|
||||
h2 { font-size: 1rem; margin: 24px 0 8px; color: #1a1a1a; }
|
||||
pre {
|
||||
background: #f0f0f0;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
font-size: .85rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
code {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
.note {
|
||||
font-size: .85rem;
|
||||
color: #666;
|
||||
border-left: 3px solid #ddd;
|
||||
padding-left: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.logo {
|
||||
max-height: 48px; max-width: 200px; margin-bottom: 20px; display: block;
|
||||
}"""
|
||||
|
||||
def _json_auth_error_handler(
|
||||
conn: HTTPConnection, exc: AuthenticationError
|
||||
) -> JSONResponse:
|
||||
"""JSON 401 error handler for authentication failures.
|
||||
_DEFAULT_CLIENTS = [
|
||||
"Claude Desktop",
|
||||
"Claude Code (CLI)",
|
||||
"Cursor",
|
||||
]
|
||||
|
||||
Per RFC 6750 Section 3.1, error responses MUST NOT leak server
|
||||
configuration or token claim values. Only generic error codes are
|
||||
returned to clients. Detailed failure reasons are logged server-side
|
||||
only for debugging.
|
||||
_DEFAULT_HELLO_PAGE_CONFIG: dict[str, Any] = {
|
||||
# Page heading and browser tab title
|
||||
"title": "Superset MCP Server",
|
||||
# Key name used in the mcpServers config snippet (e.g. "superset", "my-company")
|
||||
"server_key": "superset",
|
||||
# Include "transport": "streamable-http" in the config snippet.
|
||||
# Recommended: Claude Desktop defaults to SSE so the transport must be explicit.
|
||||
"show_transport": True,
|
||||
# Supported MCP clients listed on the page
|
||||
"clients": _DEFAULT_CLIENTS,
|
||||
}
|
||||
|
||||
|
||||
def _build_config_snippet(
|
||||
auth_enabled: bool, server_key: str, show_transport: bool
|
||||
) -> str:
|
||||
# superset.utils.json.dumps() ensures the key is a valid JSON string.
|
||||
from superset.utils import json as superset_json
|
||||
|
||||
key_json = superset_json.dumps(server_key)
|
||||
inner_parts = [' "url": "<this-url>"']
|
||||
if show_transport:
|
||||
inner_parts.append(' "transport": "streamable-http"')
|
||||
if auth_enabled:
|
||||
inner_parts.append(
|
||||
' "headers": {\n'
|
||||
' "Authorization": "Bearer <your-api-key>"\n'
|
||||
" }"
|
||||
)
|
||||
inner = ",\n".join(inner_parts)
|
||||
return f'{{\n "mcpServers": {{\n {key_json}: {{\n{inner}\n }}\n }}\n}}'
|
||||
|
||||
|
||||
def _build_browser_hello_html(
|
||||
auth_enabled: bool,
|
||||
page_config: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
cfg = {**_DEFAULT_HELLO_PAGE_CONFIG, **(page_config or {})}
|
||||
title: str = html_module.escape(str(cfg["title"]))
|
||||
server_key: str = cfg["server_key"]
|
||||
show_transport: bool = cfg["show_transport"]
|
||||
clients: list[str] = [html_module.escape(str(c)) for c in cfg["clients"]]
|
||||
app_name: str = html_module.escape(str(cfg.get("app_name", "Apache Superset")))
|
||||
logo_url: str | None = None
|
||||
if logo_url_raw := cfg.get("logo_url"):
|
||||
logo_url_stripped = str(logo_url_raw).strip()
|
||||
if logo_url_stripped.startswith(("http://", "https://")):
|
||||
logo_url = html_module.escape(logo_url_stripped)
|
||||
|
||||
# html.escape() ensures server_key and all other content in the snippet
|
||||
# cannot break out of the <pre><code> block (json.dumps does not escape HTML).
|
||||
config_block = html_module.escape(
|
||||
_build_config_snippet(auth_enabled, server_key, show_transport)
|
||||
)
|
||||
|
||||
if auth_enabled:
|
||||
connect_desc = (
|
||||
"Add the following to your MCP client configuration, "
|
||||
"replacing the URL and API key with your actual values:"
|
||||
)
|
||||
note = (
|
||||
"Replace <code><this-url></code> with the full URL of this page "
|
||||
"and <code><your-api-key></code> with a valid API key or JWT token."
|
||||
)
|
||||
else:
|
||||
connect_desc = (
|
||||
"Add the following to your MCP client configuration, "
|
||||
"replacing the URL with your actual server URL:"
|
||||
)
|
||||
note = "Replace <code><this-url></code> with the full URL of this page."
|
||||
|
||||
client_items = "\n".join(f" <li>{c}</li>" for c in clients)
|
||||
logo_html = (
|
||||
f'<img src="{logo_url}" alt="{title}" class="logo">\n ' if logo_url else ""
|
||||
)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{title}</title>
|
||||
<style>{_HTML_STYLES}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
{logo_html}<div class="badge">MCP API Endpoint</div>
|
||||
<h1>{title}</h1>
|
||||
<p>
|
||||
This is the <strong>Model Context Protocol (MCP)</strong> endpoint for
|
||||
{app_name}. It is an API designed for AI coding assistants —
|
||||
not a web page to browse directly.
|
||||
</p>
|
||||
<h2>How to connect</h2>
|
||||
<p>{connect_desc}</p>
|
||||
<pre><code>{config_block}</code></pre>
|
||||
<h2>Supported clients</h2>
|
||||
<p>This endpoint works with any MCP-compatible client, including:</p>
|
||||
<ul style="color:#444;margin:0 0 20px;padding-left:20px;">
|
||||
{client_items}
|
||||
<li>Any client that supports the <code>streamable-http</code> transport</li>
|
||||
</ul>
|
||||
<div class="note">
|
||||
{note}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
# Pre-built for _auth_error_handler (auth-required context, default config)
|
||||
_MCP_BROWSER_HELLO_HTML = _build_browser_hello_html(auth_enabled=True)
|
||||
|
||||
|
||||
def _prefers_browser_html(conn: HTTPConnection) -> bool:
|
||||
"""Return True when the request looks like a browser navigation.
|
||||
|
||||
Checks both the HTTP method (GET/HEAD only) and the Accept header
|
||||
(text/html present, application/json and text/event-stream absent).
|
||||
Case-insensitive to handle unusual but valid header values.
|
||||
"""
|
||||
if conn.scope.get("method") not in ("GET", "HEAD"):
|
||||
return False
|
||||
accept = conn.headers.get("accept", "").lower()
|
||||
return (
|
||||
"text/html" in accept
|
||||
and "application/json" not in accept
|
||||
and "text/event-stream" not in accept
|
||||
)
|
||||
|
||||
|
||||
class BrowserHelloMiddleware(BaseHTTPMiddleware):
|
||||
"""Starlette middleware that returns a browser-friendly hello page.
|
||||
|
||||
Intercepts GET/HEAD requests with a browser Accept header before they
|
||||
reach FastMCP's router (which returns 405 for GET). Works regardless
|
||||
of whether MCP_AUTH_ENABLED is True or False.
|
||||
|
||||
When auth_enabled=True the page includes Bearer token setup instructions.
|
||||
When auth_enabled=False the page omits the Authorization header from the
|
||||
config snippet since no credentials are required.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: Any,
|
||||
auth_enabled: bool = False,
|
||||
page_config: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
super().__init__(app)
|
||||
self._html = _build_browser_hello_html(
|
||||
auth_enabled=auth_enabled, page_config=page_config
|
||||
)
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: Callable[..., Any]
|
||||
) -> Response:
|
||||
if request.method in ("GET", "HEAD") and _prefers_browser_html(request):
|
||||
return HTMLResponse(content=self._html, status_code=200)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _auth_error_handler(conn: HTTPConnection, exc: AuthenticationError) -> Response:
|
||||
"""Auth error handler for unauthenticated MCP requests.
|
||||
|
||||
Returns a friendly HTML page for browser navigation requests so users
|
||||
who open the MCP URL in a browser see setup instructions instead of a
|
||||
raw JSON 401.
|
||||
|
||||
For all other clients (API, SSE, non-GET methods) returns a standard
|
||||
JSON 401 per RFC 6750 Section 3.1.
|
||||
|
||||
References:
|
||||
- RFC 6750 Section 3.1: https://datatracker.ietf.org/doc/html/rfc6750#section-3.1
|
||||
- CVE-2022-29266, CVE-2019-7644: verbose JWT errors led to exploits
|
||||
"""
|
||||
if _prefers_browser_html(conn):
|
||||
return HTMLResponse(status_code=200, content=_MCP_BROWSER_HELLO_HTML)
|
||||
|
||||
# Log detailed reason server-side only
|
||||
logger.warning("JWT authentication failed: %s", exc)
|
||||
|
||||
@@ -91,6 +320,28 @@ def _json_auth_error_handler(
|
||||
)
|
||||
|
||||
|
||||
class MCPJWTVerifier(JWTVerifier):
|
||||
"""JWTVerifier with Superset MCP auth error handling.
|
||||
|
||||
Provides browser-friendly HTML responses for unauthenticated browser
|
||||
navigation requests (GET/HEAD with Accept: text/html), while maintaining
|
||||
RFC 6750-compliant JSON 401 responses for API and SSE clients.
|
||||
|
||||
Use this as the base for all Superset JWT verifiers so the browser hello
|
||||
page is active regardless of which verifier variant is configured.
|
||||
"""
|
||||
|
||||
def get_middleware(self) -> list[Any]:
|
||||
return [
|
||||
Middleware(
|
||||
AuthenticationMiddleware,
|
||||
backend=BearerAuthBackend(self),
|
||||
on_error=_auth_error_handler,
|
||||
),
|
||||
Middleware(AuthContextMiddleware),
|
||||
]
|
||||
|
||||
|
||||
class DetailedBearerAuthBackend(BearerAuthBackend):
|
||||
"""
|
||||
Bearer auth backend that raises AuthenticationError with specific
|
||||
@@ -124,7 +375,7 @@ class DetailedBearerAuthBackend(BearerAuthBackend):
|
||||
return None
|
||||
|
||||
|
||||
class DetailedJWTVerifier(JWTVerifier):
|
||||
class DetailedJWTVerifier(MCPJWTVerifier):
|
||||
"""
|
||||
JWT verifier with tiered server-side logging for each validation step.
|
||||
|
||||
@@ -300,7 +551,7 @@ class DetailedJWTVerifier(JWTVerifier):
|
||||
Middleware(
|
||||
AuthenticationMiddleware,
|
||||
backend=DetailedBearerAuthBackend(self),
|
||||
on_error=_json_auth_error_handler,
|
||||
on_error=_auth_error_handler,
|
||||
),
|
||||
Middleware(AuthContextMiddleware),
|
||||
]
|
||||
|
||||
@@ -343,10 +343,10 @@ def create_default_mcp_auth_factory(app: Flask) -> Optional[Any]:
|
||||
|
||||
auth_provider = DetailedJWTVerifier(**common_kwargs)
|
||||
else:
|
||||
# Default JWTVerifier: minimal logging, generic error responses.
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
# MCPJWTVerifier: minimal logging + browser-friendly error page.
|
||||
from superset.mcp_service.jwt_verifier import MCPJWTVerifier
|
||||
|
||||
auth_provider = JWTVerifier(**common_kwargs)
|
||||
auth_provider = MCPJWTVerifier(**common_kwargs)
|
||||
|
||||
return auth_provider
|
||||
except Exception:
|
||||
|
||||
@@ -733,6 +733,7 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
|
||||
default_sort: str = "changed_on",
|
||||
default_sort_direction: Literal["asc", "desc"] = "desc",
|
||||
exclude_filter_columns: set[str] | None = None,
|
||||
filter_columns_override: dict[str, list[str]] | None = None,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -750,6 +751,9 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
|
||||
default_sort_direction: Default sort direction
|
||||
exclude_filter_columns: Column names to omit from filter discovery
|
||||
(e.g., sensitive fields like passwords or connection URIs)
|
||||
filter_columns_override: When set, use this mapping directly as the
|
||||
filter_columns output instead of querying the DAO. Use this to
|
||||
restrict advertised filters to the exact set the list tool accepts.
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
super().__init__(logger)
|
||||
@@ -770,9 +774,12 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
|
||||
# Hide user-directory columns from filter discovery, except the small
|
||||
# set callers may legitimately filter by ID (resolved via find_users).
|
||||
self.exclude_filter_columns.update(USER_DIRECTORY_FIELDS - USER_FILTER_FIELDS)
|
||||
self.filter_columns_override = filter_columns_override
|
||||
|
||||
def _get_filter_columns(self) -> Dict[str, List[str]]:
|
||||
"""Get filterable columns and operators from the DAO."""
|
||||
if self.filter_columns_override is not None:
|
||||
return self.filter_columns_override
|
||||
try:
|
||||
filterable = self.dao_class.get_filterable_columns_and_operators()
|
||||
# Defensive handling: ensure we have a valid mapping
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,293 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Pydantic schemas for query history-related responses
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Dict, List, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
model_validator,
|
||||
PositiveInt,
|
||||
)
|
||||
|
||||
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
|
||||
from superset.mcp_service.constants import MAX_PAGE_SIZE
|
||||
from superset.mcp_service.privacy import filter_user_directory_fields
|
||||
from superset.mcp_service.system.schemas import PaginationInfo
|
||||
from superset.mcp_service.utils.schema_utils import (
|
||||
parse_json_or_list,
|
||||
parse_json_or_model_list,
|
||||
)
|
||||
|
||||
DEFAULT_QUERY_COLUMNS = ["id", "status", "start_time", "database_id", "schema"]
|
||||
SORTABLE_QUERY_COLUMNS = [
|
||||
"id",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"status",
|
||||
"database_id",
|
||||
"changed_on",
|
||||
]
|
||||
ALL_QUERY_COLUMNS = [
|
||||
"id",
|
||||
"sql",
|
||||
"executed_sql",
|
||||
"status",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"rows",
|
||||
"database_id",
|
||||
"schema",
|
||||
"catalog",
|
||||
"tab_name",
|
||||
"error_message",
|
||||
"client_id",
|
||||
"limit",
|
||||
"progress",
|
||||
"changed_on",
|
||||
"user_id",
|
||||
]
|
||||
|
||||
DEFAULT_QUERY_PAGE_SIZE = 25
|
||||
|
||||
|
||||
class QueryFilter(ColumnOperator):
|
||||
"""
|
||||
Filter object for query history listing.
|
||||
col: The column to filter on. Must be one of the allowed filter fields.
|
||||
opr: The operator to use. Must be one of the supported operators.
|
||||
value: The value to filter by (type depends on col and opr).
|
||||
"""
|
||||
|
||||
col: Literal["status", "database_id", "schema", "user_id", "start_time"] = Field(
|
||||
...,
|
||||
description="Column to filter on.",
|
||||
)
|
||||
opr: ColumnOperatorEnum = Field(
|
||||
...,
|
||||
description="Operator to use.",
|
||||
)
|
||||
value: str | int | float | bool | List[str | int | float | bool] = Field(
|
||||
..., description="Value to filter by (type depends on col and opr)"
|
||||
)
|
||||
|
||||
|
||||
class QueryInfo(BaseModel):
|
||||
id: int | None = Field(None, description="Query ID")
|
||||
sql: str | None = Field(None, description="SQL query text as submitted")
|
||||
executed_sql: str | None = Field(
|
||||
None, description="Actual SQL executed after templating/CTAS rewriting"
|
||||
)
|
||||
status: str | None = Field(None, description="Query execution status")
|
||||
start_time: float | None = Field(
|
||||
None, description="Query start time (seconds since epoch)"
|
||||
)
|
||||
end_time: float | None = Field(
|
||||
None, description="Query end time (seconds since epoch)"
|
||||
)
|
||||
rows: int | None = Field(None, description="Number of rows returned or affected")
|
||||
database_id: int | None = Field(None, description="Database connection ID")
|
||||
schema: str | None = Field(None, description="Database schema name")
|
||||
catalog: str | None = Field(None, description="Database catalog name")
|
||||
tab_name: str | None = Field(None, description="SQL Lab tab name")
|
||||
error_message: str | None = Field(None, description="Error message if query failed")
|
||||
client_id: str | None = Field(None, description="Client-assigned query identifier")
|
||||
limit: int | None = Field(None, description="Row limit applied to the query")
|
||||
progress: int | None = Field(None, description="Query execution progress (0-100)")
|
||||
changed_on: str | datetime | None = Field(
|
||||
None, description="Last modification timestamp"
|
||||
)
|
||||
user_id: int | None = Field(None, description="ID of the user who ran the query")
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
ser_json_timedelta="iso8601",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
|
||||
data = filter_user_directory_fields(serializer(self))
|
||||
|
||||
if info.context and isinstance(info.context, dict):
|
||||
select_columns = info.context.get("select_columns")
|
||||
if select_columns:
|
||||
requested_fields = set(select_columns)
|
||||
return {k: v for k, v in data.items() if k in requested_fields}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class QueryList(BaseModel):
|
||||
queries: List[QueryInfo]
|
||||
count: int
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
has_previous: bool
|
||||
has_next: bool
|
||||
columns_requested: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Requested columns for the response",
|
||||
)
|
||||
columns_loaded: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that were actually loaded for each query",
|
||||
)
|
||||
columns_available: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="All columns available for selection via select_columns parameter",
|
||||
)
|
||||
sortable_columns: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that can be used with order_column parameter",
|
||||
)
|
||||
filters_applied: List[QueryFilter] = Field(
|
||||
default_factory=list,
|
||||
description="List of advanced filter dicts applied to the query.",
|
||||
)
|
||||
pagination: PaginationInfo | None = None
|
||||
timestamp: datetime | None = None
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class ListQueriesRequest(BaseModel):
|
||||
"""Request schema for list_queries."""
|
||||
|
||||
filters: Annotated[
|
||||
List[QueryFilter],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of filter objects (column, operator, value). Each "
|
||||
"filter is an object with 'col', 'opr', and 'value' "
|
||||
"properties. Cannot be used together with 'search'.",
|
||||
),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of columns to select. Defaults to common columns if not "
|
||||
"specified.",
|
||||
),
|
||||
]
|
||||
search: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Text search string to match against query fields. "
|
||||
"Cannot be used together with 'filters'.",
|
||||
),
|
||||
]
|
||||
order_column: Annotated[
|
||||
str | None,
|
||||
Field(default=None, description="Column to order results by"),
|
||||
]
|
||||
order_direction: Annotated[
|
||||
Literal["asc", "desc"],
|
||||
Field(
|
||||
default="desc",
|
||||
description="Direction to order results ('asc' or 'desc')",
|
||||
),
|
||||
]
|
||||
page: Annotated[
|
||||
PositiveInt,
|
||||
Field(default=1, description="Page number for pagination (1-based)"),
|
||||
]
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=DEFAULT_QUERY_PAGE_SIZE,
|
||||
gt=0,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("filters", mode="before")
|
||||
@classmethod
|
||||
def parse_filters(cls, v: Any) -> List[QueryFilter]:
|
||||
"""Accept both JSON string and list of objects."""
|
||||
return parse_json_or_model_list(v, QueryFilter, "filters")
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def parse_columns(cls, v: Any) -> List[str]:
|
||||
"""Accept JSON array, list, or comma-separated string."""
|
||||
return parse_json_or_list(v, "select_columns")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_search_and_filters(self) -> "ListQueriesRequest":
|
||||
"""Prevent using both search and filters simultaneously."""
|
||||
if self.search and self.filters:
|
||||
raise ValueError(
|
||||
"Cannot use both 'search' and 'filters' parameters simultaneously. "
|
||||
"Use either 'search' for text-based searching across multiple fields, "
|
||||
"or 'filters' for precise column-based filtering, but not both."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class QueryError(BaseModel):
|
||||
error: str = Field(..., description="Error message")
|
||||
error_type: str = Field(..., description="Type of error")
|
||||
timestamp: str | datetime | None = Field(None, description="Error timestamp")
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class GetQueryInfoRequest(BaseModel):
|
||||
"""Request schema for get_query_info with support for numeric ID only."""
|
||||
|
||||
identifier: Annotated[
|
||||
int,
|
||||
Field(description="Query ID (numeric)"),
|
||||
]
|
||||
|
||||
|
||||
def serialize_query_object(query: Any) -> QueryInfo | None:
|
||||
if not query:
|
||||
return None
|
||||
|
||||
return QueryInfo(
|
||||
id=getattr(query, "id", None),
|
||||
sql=getattr(query, "sql", None),
|
||||
executed_sql=getattr(query, "executed_sql", None),
|
||||
status=getattr(query, "status", None),
|
||||
start_time=getattr(query, "start_time", None),
|
||||
end_time=getattr(query, "end_time", None),
|
||||
rows=getattr(query, "rows", None),
|
||||
database_id=getattr(query, "database_id", None),
|
||||
schema=getattr(query, "schema", None),
|
||||
catalog=getattr(query, "catalog", None),
|
||||
tab_name=getattr(query, "tab_name", None),
|
||||
error_message=getattr(query, "error_message", None),
|
||||
client_id=getattr(query, "client_id", None),
|
||||
limit=getattr(query, "limit", None),
|
||||
progress=getattr(query, "progress", None),
|
||||
changed_on=getattr(query, "changed_on", None),
|
||||
user_id=getattr(query, "user_id", None),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .get_query_info import get_query_info
|
||||
from .list_queries import list_queries
|
||||
|
||||
__all__ = [
|
||||
"list_queries",
|
||||
"get_query_info",
|
||||
]
|
||||
@@ -0,0 +1,122 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Get query info FastMCP tool
|
||||
|
||||
This module contains the FastMCP tool for getting detailed information
|
||||
about a specific SQL query from the query history.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.mcp_core import ModelGetInfoCore
|
||||
from superset.mcp_service.query.schemas import (
|
||||
GetQueryInfoRequest,
|
||||
QueryError,
|
||||
QueryInfo,
|
||||
serialize_query_object,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="Query",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get query info",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def get_query_info(
|
||||
request: GetQueryInfoRequest, ctx: Context
|
||||
) -> QueryInfo | QueryError:
|
||||
"""Get SQL query history details by ID.
|
||||
|
||||
Returns query details including SQL text, execution status, timing,
|
||||
row count, and any error messages.
|
||||
|
||||
IMPORTANT FOR LLM CLIENTS:
|
||||
- Use numeric ID (e.g., 123)
|
||||
- To find a query ID, use the list_queries tool first
|
||||
|
||||
Example usage:
|
||||
```json
|
||||
{
|
||||
"identifier": 123
|
||||
}
|
||||
```
|
||||
"""
|
||||
await ctx.info(
|
||||
"Retrieving query information: identifier=%s" % (request.identifier,)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.query import QueryDAO
|
||||
|
||||
with event_logger.log_context(action="mcp.get_query_info.lookup"):
|
||||
get_tool = ModelGetInfoCore(
|
||||
dao_class=QueryDAO,
|
||||
output_schema=QueryInfo,
|
||||
error_schema=QueryError,
|
||||
serializer=serialize_query_object,
|
||||
supports_slug=False,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
result = get_tool.run_tool(request.identifier)
|
||||
|
||||
if isinstance(result, QueryInfo):
|
||||
await ctx.info(
|
||||
"Query information retrieved successfully: "
|
||||
"query_id=%s, status=%s, database_id=%s"
|
||||
% (
|
||||
result.id,
|
||||
result.status,
|
||||
result.database_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"Query retrieval failed: error_type=%s, error=%s"
|
||||
% (result.error_type, result.error)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Query information retrieval failed: identifier=%s, error=%s, "
|
||||
"error_type=%s"
|
||||
% (
|
||||
request.identifier,
|
||||
str(e),
|
||||
type(e).__name__,
|
||||
)
|
||||
)
|
||||
return QueryError(
|
||||
error="Failed to get query info",
|
||||
error_type="InternalError",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
List queries FastMCP tool
|
||||
|
||||
This module contains the FastMCP tool for listing SQL query history
|
||||
with filtering, search, and pagination.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.mcp_core import ModelListCore
|
||||
from superset.mcp_service.query.schemas import (
|
||||
DEFAULT_QUERY_COLUMNS,
|
||||
ListQueriesRequest,
|
||||
QueryError,
|
||||
QueryFilter,
|
||||
QueryInfo,
|
||||
QueryList,
|
||||
serialize_query_object,
|
||||
SORTABLE_QUERY_COLUMNS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_LIST_QUERIES_REQUEST = ListQueriesRequest()
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="Query",
|
||||
annotations=ToolAnnotations(
|
||||
title="List queries",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def list_queries(
|
||||
request: ListQueriesRequest | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> QueryList | QueryError:
|
||||
"""List SQL query history with filtering and search.
|
||||
|
||||
Returns recent queries executed by the current user (or all queries for
|
||||
admins), including SQL text, status, timing, and database information.
|
||||
Results are ordered by changed_on descending by default (start_time is not
|
||||
always populated for all query records).
|
||||
|
||||
Sortable columns for order_column: id, start_time, end_time, status,
|
||||
database_id, changed_on
|
||||
"""
|
||||
if ctx is None:
|
||||
raise RuntimeError("FastMCP context is required for list_queries")
|
||||
|
||||
request = request or _DEFAULT_LIST_QUERIES_REQUEST.model_copy(deep=True)
|
||||
|
||||
await ctx.info(
|
||||
"Listing queries: page=%s, page_size=%s, search=%s"
|
||||
% (
|
||||
request.page,
|
||||
request.page_size,
|
||||
request.search,
|
||||
)
|
||||
)
|
||||
await ctx.debug(
|
||||
"Query listing parameters: filters=%s, order_column=%s, "
|
||||
"order_direction=%s, select_columns=%s"
|
||||
% (
|
||||
request.filters,
|
||||
request.order_column,
|
||||
request.order_direction,
|
||||
request.select_columns,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.query import QueryDAO
|
||||
|
||||
def _serialize_query(obj: object, cols: list[str] | None) -> QueryInfo | None:
|
||||
return serialize_query_object(obj)
|
||||
|
||||
list_tool = ModelListCore(
|
||||
dao_class=QueryDAO,
|
||||
output_schema=QueryInfo,
|
||||
item_serializer=_serialize_query,
|
||||
filter_type=QueryFilter,
|
||||
default_columns=DEFAULT_QUERY_COLUMNS,
|
||||
search_columns=["tab_name", "sql"],
|
||||
list_field_name="queries",
|
||||
output_list_schema=QueryList,
|
||||
all_columns=list(QueryInfo.model_fields.keys()),
|
||||
sortable_columns=SORTABLE_QUERY_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.list_queries.query"):
|
||||
result = list_tool.run_tool(
|
||||
filters=request.filters,
|
||||
search=request.search,
|
||||
select_columns=request.select_columns,
|
||||
order_column=request.order_column or "changed_on",
|
||||
order_direction=request.order_direction,
|
||||
page=max(request.page - 1, 0),
|
||||
page_size=request.page_size,
|
||||
)
|
||||
|
||||
await ctx.info(
|
||||
"Queries listed successfully: count=%s, total_count=%s, total_pages=%s"
|
||||
% (
|
||||
len(result.queries) if hasattr(result, "queries") else 0,
|
||||
getattr(result, "total_count", None),
|
||||
getattr(result, "total_pages", None),
|
||||
)
|
||||
)
|
||||
|
||||
columns_to_filter = result.columns_requested
|
||||
await ctx.debug(
|
||||
"Applying field filtering via serialization context: columns=%s"
|
||||
% (columns_to_filter,)
|
||||
)
|
||||
with event_logger.log_context(action="mcp.list_queries.serialization"):
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
context={"select_columns": columns_to_filter},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Query listing failed: page=%s, page_size=%s, error=%s, error_type=%s"
|
||||
% (
|
||||
request.page,
|
||||
request.page_size,
|
||||
str(e),
|
||||
type(e).__name__,
|
||||
)
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,264 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Pydantic schemas for role-related MCP tool responses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Any, List, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
model_validator,
|
||||
PositiveInt,
|
||||
)
|
||||
|
||||
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
|
||||
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
from superset.mcp_service.system.schemas import PaginationInfo
|
||||
from superset.mcp_service.utils import sanitize_for_llm_context
|
||||
from superset.mcp_service.utils.schema_utils import (
|
||||
parse_json_or_list,
|
||||
parse_json_or_model_list,
|
||||
)
|
||||
|
||||
DEFAULT_ROLE_COLUMNS = ["id", "name"]
|
||||
|
||||
ROLE_ALL_COLUMNS = ["id", "name"]
|
||||
|
||||
ROLE_SORTABLE_COLUMNS = ["id", "name"]
|
||||
|
||||
|
||||
class RoleFilter(ColumnOperator):
|
||||
"""Filter object for role listing.
|
||||
|
||||
col: The column to filter on. Must be one of the allowed filter fields.
|
||||
opr: The operator to use. Must be one of the supported operators.
|
||||
value: The value to filter by (type depends on col and opr).
|
||||
"""
|
||||
|
||||
col: Literal["name"] = Field(
|
||||
...,
|
||||
description="Column to filter on.",
|
||||
)
|
||||
opr: ColumnOperatorEnum = Field(
|
||||
...,
|
||||
description="Operator to use.",
|
||||
)
|
||||
value: str | int | float | bool | List[str | int | float | bool] = Field(
|
||||
..., description="Value to filter by (type depends on col and opr)"
|
||||
)
|
||||
|
||||
|
||||
class RoleInfo(BaseModel):
|
||||
id: int | None = Field(None, description="Role ID")
|
||||
name: str | None = Field(None, description="Role name")
|
||||
permissions: list[str] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Permission names assigned to this role "
|
||||
"(only populated by get_role_info, not list_roles)"
|
||||
),
|
||||
)
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
ser_json_timedelta="iso8601",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _filter_fields_by_context(self, serializer: Any, info: Any) -> dict[str, Any]:
|
||||
data = serializer(self)
|
||||
if info.context and isinstance(info.context, dict):
|
||||
select_columns = info.context.get("select_columns")
|
||||
if select_columns:
|
||||
return {k: v for k, v in data.items() if k in select_columns}
|
||||
return data
|
||||
|
||||
|
||||
class RoleList(BaseModel):
|
||||
roles: List[RoleInfo]
|
||||
count: int
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
has_previous: bool
|
||||
has_next: bool
|
||||
columns_requested: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Requested columns for the response",
|
||||
)
|
||||
columns_loaded: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that were actually loaded for each role",
|
||||
)
|
||||
columns_available: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="All columns available for selection via select_columns parameter",
|
||||
)
|
||||
sortable_columns: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that can be used with order_column parameter",
|
||||
)
|
||||
filters_applied: List[RoleFilter] = Field(
|
||||
default_factory=list,
|
||||
description="List of advanced filter dicts applied to the query.",
|
||||
)
|
||||
pagination: PaginationInfo | None = None
|
||||
timestamp: datetime | None = None
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class ListRolesRequest(BaseModel):
|
||||
"""Request schema for list_roles."""
|
||||
|
||||
filters: Annotated[
|
||||
List[RoleFilter],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of filter objects (column, operator, value). Each "
|
||||
"filter is an object with 'col', 'opr', and 'value' properties. "
|
||||
"Cannot be used together with 'search'.",
|
||||
),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of columns to select. Defaults to common columns if "
|
||||
"not specified.",
|
||||
),
|
||||
]
|
||||
search: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Text search string to match against role name. Cannot be "
|
||||
"used together with 'filters'.",
|
||||
),
|
||||
]
|
||||
order_column: Annotated[
|
||||
str | None, Field(default=None, description="Column to order results by")
|
||||
]
|
||||
order_direction: Annotated[
|
||||
Literal["asc", "desc"],
|
||||
Field(
|
||||
default="asc", description="Direction to order results ('asc' or 'desc')"
|
||||
),
|
||||
]
|
||||
page: Annotated[
|
||||
PositiveInt,
|
||||
Field(default=1, description="Page number for pagination (1-based)"),
|
||||
]
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
gt=0,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("filters", mode="before")
|
||||
@classmethod
|
||||
def parse_filters(cls, v: Any) -> List[RoleFilter]:
|
||||
"""Accept both JSON string and list of objects."""
|
||||
return parse_json_or_model_list(v, RoleFilter, "filters")
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def parse_columns(cls, v: Any) -> List[str]:
|
||||
"""Accept JSON array, list, or comma-separated string."""
|
||||
return parse_json_or_list(v, "select_columns")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_search_and_filters(self) -> "ListRolesRequest":
|
||||
if self.search and self.filters:
|
||||
raise ValueError(
|
||||
"Cannot use both 'search' and 'filters' parameters simultaneously. "
|
||||
"Use either 'search' for text-based searching or 'filters' for "
|
||||
"precise column-based filtering, but not both."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class RoleError(BaseModel):
|
||||
error: str = Field(..., description="Error message")
|
||||
error_type: str = Field(..., description="Type of error")
|
||||
timestamp: str | datetime | None = Field(None, description="Error timestamp")
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
@field_validator("error")
|
||||
@classmethod
|
||||
def sanitize_error_for_llm_context(cls, value: str) -> str:
|
||||
"""Wrap error text before it is exposed to LLM context."""
|
||||
return sanitize_for_llm_context(value, field_path=("error",))
|
||||
|
||||
@classmethod
|
||||
def create(cls, error: str, error_type: str) -> "RoleError":
|
||||
"""Create a standardized RoleError with timestamp."""
|
||||
return cls(
|
||||
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class GetRoleInfoRequest(BaseModel):
|
||||
"""Request schema for get_role_info."""
|
||||
|
||||
identifier: Annotated[
|
||||
int,
|
||||
Field(description="Role ID (integer)"),
|
||||
]
|
||||
|
||||
|
||||
def serialize_role_object(
|
||||
role: Any, include_permissions: bool = False
|
||||
) -> RoleInfo | None:
|
||||
"""Serialize a FAB Role object into a RoleInfo schema.
|
||||
|
||||
Set include_permissions=True for get_role_info; leave False for list_roles
|
||||
to avoid a per-role N+1 permissions lazy-load.
|
||||
"""
|
||||
if not role:
|
||||
return None
|
||||
permissions: list[str] | None = None
|
||||
if include_permissions:
|
||||
raw_perms = getattr(role, "permissions", None)
|
||||
if raw_perms is not None:
|
||||
try:
|
||||
permissions = [p.name for p in raw_perms if hasattr(p, "name")]
|
||||
except (AttributeError, TypeError):
|
||||
permissions = None
|
||||
return RoleInfo(
|
||||
id=getattr(role, "id", None),
|
||||
name=sanitize_for_llm_context(
|
||||
getattr(role, "name", None), field_path=("name",)
|
||||
),
|
||||
permissions=[
|
||||
sanitize_for_llm_context(p, field_path=("permissions",))
|
||||
for p in permissions
|
||||
]
|
||||
if permissions is not None
|
||||
else None,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .get_role_info import get_role_info
|
||||
from .list_roles import list_roles
|
||||
|
||||
__all__ = [
|
||||
"list_roles",
|
||||
"get_role_info",
|
||||
]
|
||||
@@ -0,0 +1,97 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Get role info FastMCP tool."""
|
||||
|
||||
import logging
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.mcp_core import ModelGetInfoCore
|
||||
from superset.mcp_service.role.schemas import (
|
||||
GetRoleInfoRequest,
|
||||
RoleError,
|
||||
RoleInfo,
|
||||
serialize_role_object,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="Role",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get role info",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def get_role_info(
|
||||
request: GetRoleInfoRequest, ctx: Context
|
||||
) -> RoleInfo | RoleError:
|
||||
"""Get role details by ID. Admin only.
|
||||
|
||||
Returns role metadata including id and name.
|
||||
|
||||
Example usage:
|
||||
```json
|
||||
{
|
||||
"identifier": 1
|
||||
}
|
||||
```
|
||||
"""
|
||||
await ctx.info("Retrieving role information: identifier=%s" % (request.identifier,))
|
||||
|
||||
try:
|
||||
from superset.daos.role import RoleDAO
|
||||
|
||||
def _serializer(obj: object) -> RoleInfo | None:
|
||||
return serialize_role_object(obj, include_permissions=True)
|
||||
|
||||
with event_logger.log_context(action="mcp.get_role_info.lookup"):
|
||||
get_tool = ModelGetInfoCore(
|
||||
dao_class=RoleDAO,
|
||||
output_schema=RoleInfo,
|
||||
error_schema=RoleError,
|
||||
serializer=_serializer,
|
||||
supports_slug=False,
|
||||
logger=logger,
|
||||
)
|
||||
result = get_tool.run_tool(request.identifier)
|
||||
|
||||
if isinstance(result, RoleInfo):
|
||||
await ctx.info(
|
||||
"Role information retrieved successfully: role_id=%s, name=%s"
|
||||
% (result.id, result.name)
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"Role retrieval failed: error_type=%s, error=%s"
|
||||
% (result.error_type, result.error)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Role information retrieval failed: identifier=%s, error=%s, error_type=%s"
|
||||
% (request.identifier, str(e), type(e).__name__)
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,135 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""List roles FastMCP tool."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.mcp_core import ModelListCore
|
||||
from superset.mcp_service.role.schemas import (
|
||||
DEFAULT_ROLE_COLUMNS,
|
||||
ListRolesRequest,
|
||||
ROLE_ALL_COLUMNS,
|
||||
ROLE_SORTABLE_COLUMNS,
|
||||
RoleError,
|
||||
RoleFilter,
|
||||
RoleInfo,
|
||||
RoleList,
|
||||
serialize_role_object,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_LIST_ROLES_REQUEST = ListRolesRequest()
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="Role",
|
||||
annotations=ToolAnnotations(
|
||||
title="List roles",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def list_roles(
|
||||
request: ListRolesRequest | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> RoleList | RoleError:
|
||||
"""List roles with filtering and search. Admin only.
|
||||
|
||||
Returns role metadata including id and name.
|
||||
|
||||
Sortable columns for order_column: id, name
|
||||
"""
|
||||
if ctx is None:
|
||||
raise RuntimeError("FastMCP context is required for list_roles")
|
||||
|
||||
request = request or _DEFAULT_LIST_ROLES_REQUEST.model_copy(deep=True)
|
||||
|
||||
await ctx.info(
|
||||
"Listing roles: page=%s, page_size=%s, search=%s"
|
||||
% (request.page, request.page_size, request.search)
|
||||
)
|
||||
await ctx.debug(
|
||||
"Role listing parameters: filters=%s, order_column=%s, order_direction=%s"
|
||||
% (request.filters, request.order_column, request.order_direction)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.role import RoleDAO
|
||||
|
||||
def _serialize_role(obj: Any, _cols: list[str] | None) -> RoleInfo | None:
|
||||
return serialize_role_object(obj)
|
||||
|
||||
list_tool = ModelListCore(
|
||||
dao_class=RoleDAO,
|
||||
output_schema=RoleInfo,
|
||||
item_serializer=_serialize_role,
|
||||
filter_type=RoleFilter,
|
||||
default_columns=DEFAULT_ROLE_COLUMNS,
|
||||
search_columns=["name"],
|
||||
list_field_name="roles",
|
||||
output_list_schema=RoleList,
|
||||
all_columns=ROLE_ALL_COLUMNS,
|
||||
sortable_columns=ROLE_SORTABLE_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.list_roles.query"):
|
||||
result = list_tool.run_tool(
|
||||
filters=request.filters,
|
||||
search=request.search,
|
||||
select_columns=request.select_columns,
|
||||
order_column=request.order_column or "id",
|
||||
order_direction=request.order_direction,
|
||||
page=max(request.page - 1, 0),
|
||||
page_size=request.page_size,
|
||||
)
|
||||
|
||||
count = len(result.roles) if hasattr(result, "roles") else 0
|
||||
await ctx.info(
|
||||
"Roles listed successfully: count=%s, total_count=%s, total_pages=%s"
|
||||
% (
|
||||
count,
|
||||
getattr(result, "total_count", None),
|
||||
getattr(result, "total_pages", None),
|
||||
)
|
||||
)
|
||||
|
||||
columns_to_filter = result.columns_requested
|
||||
await ctx.debug(
|
||||
"Applying field filtering via serialization context: columns=%s"
|
||||
% (columns_to_filter,)
|
||||
)
|
||||
with event_logger.log_context(action="mcp.list_roles.serialization"):
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
context={"select_columns": columns_to_filter},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Role listing failed: page=%s, page_size=%s, error=%s, error_type=%s"
|
||||
% (request.page, request.page_size, str(e), type(e).__name__)
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,269 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Pydantic schemas for saved query-related responses
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Dict, List, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
model_validator,
|
||||
PositiveInt,
|
||||
)
|
||||
|
||||
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
|
||||
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
from superset.mcp_service.privacy import filter_user_directory_fields
|
||||
from superset.mcp_service.system.schemas import PaginationInfo
|
||||
from superset.mcp_service.utils.schema_utils import (
|
||||
parse_json_or_list,
|
||||
parse_json_or_model_list,
|
||||
)
|
||||
|
||||
DEFAULT_SAVED_QUERY_COLUMNS = ["id", "label", "db_id", "schema", "uuid"]
|
||||
SORTABLE_SAVED_QUERY_COLUMNS = [
|
||||
"id",
|
||||
"label",
|
||||
"db_id",
|
||||
"schema",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
ALL_SAVED_QUERY_COLUMNS = [
|
||||
"id",
|
||||
"label",
|
||||
"db_id",
|
||||
"schema",
|
||||
"catalog",
|
||||
"uuid",
|
||||
"sql",
|
||||
"description",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
"last_run",
|
||||
]
|
||||
|
||||
|
||||
class SavedQueryFilter(ColumnOperator):
|
||||
"""
|
||||
Filter object for saved query listing.
|
||||
col: The column to filter on. Must be one of the allowed filter fields.
|
||||
opr: The operator to use. Must be one of the supported operators.
|
||||
value: The value to filter by (type depends on col and opr).
|
||||
"""
|
||||
|
||||
col: Literal["label", "db_id", "schema", "catalog", "created_by_fk"] = Field(
|
||||
...,
|
||||
description="Column to filter on.",
|
||||
)
|
||||
opr: ColumnOperatorEnum = Field(
|
||||
...,
|
||||
description="Operator to use.",
|
||||
)
|
||||
value: str | int | float | bool | List[str | int | float | bool] = Field(
|
||||
..., description="Value to filter by (type depends on col and opr)"
|
||||
)
|
||||
|
||||
|
||||
class SavedQueryInfo(BaseModel):
|
||||
id: int | None = Field(None, description="Saved query ID")
|
||||
uuid: str | None = Field(None, description="Saved query UUID")
|
||||
label: str | None = Field(None, description="Saved query label/name")
|
||||
sql: str | None = Field(None, description="SQL query text")
|
||||
db_id: int | None = Field(None, description="Database connection ID")
|
||||
schema: str | None = Field(None, description="Database schema name")
|
||||
catalog: str | None = Field(None, description="Database catalog name")
|
||||
description: str | None = Field(None, description="User-provided description")
|
||||
changed_on: str | datetime | None = Field(
|
||||
None, description="Last modification timestamp"
|
||||
)
|
||||
created_on: str | datetime | None = Field(None, description="Creation timestamp")
|
||||
last_run: str | datetime | None = Field(
|
||||
None, description="Timestamp of last execution"
|
||||
)
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
ser_json_timedelta="iso8601",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
|
||||
data = filter_user_directory_fields(serializer(self))
|
||||
|
||||
if info.context and isinstance(info.context, dict):
|
||||
select_columns = info.context.get("select_columns")
|
||||
if select_columns:
|
||||
requested_fields = set(select_columns)
|
||||
return {k: v for k, v in data.items() if k in requested_fields}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class SavedQueryList(BaseModel):
|
||||
saved_queries: List[SavedQueryInfo]
|
||||
count: int
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
has_previous: bool
|
||||
has_next: bool
|
||||
columns_requested: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Requested columns for the response",
|
||||
)
|
||||
columns_loaded: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that were actually loaded for each saved query",
|
||||
)
|
||||
columns_available: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="All columns available for selection via select_columns parameter",
|
||||
)
|
||||
sortable_columns: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that can be used with order_column parameter",
|
||||
)
|
||||
filters_applied: List[SavedQueryFilter] = Field(
|
||||
default_factory=list,
|
||||
description="List of advanced filter dicts applied to the query.",
|
||||
)
|
||||
pagination: PaginationInfo | None = None
|
||||
timestamp: datetime | None = None
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class ListSavedQueriesRequest(BaseModel):
|
||||
"""Request schema for list_saved_queries."""
|
||||
|
||||
filters: Annotated[
|
||||
List[SavedQueryFilter],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of filter objects (column, operator, value). Each "
|
||||
"filter is an object with 'col', 'opr', and 'value' "
|
||||
"properties. Cannot be used together with 'search'.",
|
||||
),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of columns to select. Defaults to common columns if not "
|
||||
"specified.",
|
||||
),
|
||||
]
|
||||
search: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Text search string to match against saved query fields. "
|
||||
"Cannot be used together with 'filters'.",
|
||||
),
|
||||
]
|
||||
order_column: Annotated[
|
||||
str | None, Field(default=None, description="Column to order results by")
|
||||
]
|
||||
order_direction: Annotated[
|
||||
Literal["asc", "desc"],
|
||||
Field(
|
||||
default="desc", description="Direction to order results ('asc' or 'desc')"
|
||||
),
|
||||
]
|
||||
page: Annotated[
|
||||
PositiveInt,
|
||||
Field(default=1, description="Page number for pagination (1-based)"),
|
||||
]
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
gt=0,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("filters", mode="before")
|
||||
@classmethod
|
||||
def parse_filters(cls, v: Any) -> List[SavedQueryFilter]:
|
||||
"""Accept both JSON string and list of objects."""
|
||||
return parse_json_or_model_list(v, SavedQueryFilter, "filters")
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def parse_columns(cls, v: Any) -> List[str]:
|
||||
"""Accept JSON array, list, or comma-separated string."""
|
||||
return parse_json_or_list(v, "select_columns")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_search_and_filters(self) -> "ListSavedQueriesRequest":
|
||||
"""Prevent using both search and filters simultaneously."""
|
||||
if self.search and self.filters:
|
||||
raise ValueError(
|
||||
"Cannot use both 'search' and 'filters' parameters simultaneously. "
|
||||
"Use either 'search' for text-based searching across multiple fields, "
|
||||
"or 'filters' for precise column-based filtering, but not both."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class SavedQueryError(BaseModel):
|
||||
error: str = Field(..., description="Error message")
|
||||
error_type: str = Field(..., description="Type of error")
|
||||
timestamp: str | datetime | None = Field(None, description="Error timestamp")
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class GetSavedQueryInfoRequest(BaseModel):
|
||||
"""Request schema for get_saved_query_info with support for ID or UUID."""
|
||||
|
||||
identifier: Annotated[
|
||||
int | str,
|
||||
Field(description="Saved query identifier - can be numeric ID or UUID string"),
|
||||
]
|
||||
|
||||
|
||||
def serialize_saved_query_object(saved_query: Any) -> SavedQueryInfo | None:
|
||||
if not saved_query:
|
||||
return None
|
||||
|
||||
return SavedQueryInfo(
|
||||
id=getattr(saved_query, "id", None),
|
||||
uuid=str(getattr(saved_query, "uuid", ""))
|
||||
if getattr(saved_query, "uuid", None)
|
||||
else None,
|
||||
label=getattr(saved_query, "label", None),
|
||||
sql=getattr(saved_query, "sql", None),
|
||||
db_id=getattr(saved_query, "db_id", None),
|
||||
schema=getattr(saved_query, "schema", None),
|
||||
catalog=getattr(saved_query, "catalog", None),
|
||||
description=getattr(saved_query, "description", None),
|
||||
changed_on=getattr(saved_query, "changed_on", None),
|
||||
created_on=getattr(saved_query, "created_on", None),
|
||||
last_run=getattr(saved_query, "last_run", None),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .get_saved_query_info import get_saved_query_info
|
||||
from .list_saved_queries import list_saved_queries
|
||||
|
||||
__all__ = [
|
||||
"list_saved_queries",
|
||||
"get_saved_query_info",
|
||||
]
|
||||
@@ -0,0 +1,129 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Get saved query info FastMCP tool
|
||||
|
||||
This module contains the FastMCP tool for getting detailed information
|
||||
about a specific saved SQL query.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.mcp_core import ModelGetInfoCore
|
||||
from superset.mcp_service.saved_query.schemas import (
|
||||
GetSavedQueryInfoRequest,
|
||||
SavedQueryError,
|
||||
SavedQueryInfo,
|
||||
serialize_saved_query_object,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="SavedQuery",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get saved query info",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def get_saved_query_info(
|
||||
request: GetSavedQueryInfoRequest, ctx: Context
|
||||
) -> SavedQueryInfo | SavedQueryError:
|
||||
"""Get saved query details by ID or UUID.
|
||||
|
||||
Returns the full saved query including SQL text, label, database,
|
||||
schema, and timestamps.
|
||||
|
||||
IMPORTANT FOR LLM CLIENTS:
|
||||
- Use numeric ID (e.g., 42) or UUID string (e.g., "a1b2c3d4-...")
|
||||
- To find a saved query ID, use the list_saved_queries tool first
|
||||
|
||||
Example usage:
|
||||
```json
|
||||
{
|
||||
"identifier": 42
|
||||
}
|
||||
```
|
||||
|
||||
Or with UUID:
|
||||
```json
|
||||
{
|
||||
"identifier": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
|
||||
}
|
||||
```
|
||||
"""
|
||||
await ctx.info(
|
||||
"Retrieving saved query information: identifier=%s" % (request.identifier,)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.query import SavedQueryDAO
|
||||
|
||||
with event_logger.log_context(action="mcp.get_saved_query_info.lookup"):
|
||||
get_tool = ModelGetInfoCore(
|
||||
dao_class=SavedQueryDAO,
|
||||
output_schema=SavedQueryInfo,
|
||||
error_schema=SavedQueryError,
|
||||
serializer=serialize_saved_query_object,
|
||||
supports_slug=False,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
result = get_tool.run_tool(request.identifier)
|
||||
|
||||
if isinstance(result, SavedQueryInfo):
|
||||
await ctx.info(
|
||||
"Saved query information retrieved successfully: "
|
||||
"saved_query_id=%s, label=%s, db_id=%s"
|
||||
% (
|
||||
result.id,
|
||||
result.label,
|
||||
result.db_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"Saved query retrieval failed: error_type=%s, error=%s"
|
||||
% (result.error_type, result.error)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Saved query information retrieval failed: identifier=%s, error=%s, "
|
||||
"error_type=%s"
|
||||
% (
|
||||
request.identifier,
|
||||
str(e),
|
||||
type(e).__name__,
|
||||
)
|
||||
)
|
||||
return SavedQueryError(
|
||||
error="Failed to get saved query info",
|
||||
error_type="InternalError",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -0,0 +1,158 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
List saved queries FastMCP tool
|
||||
|
||||
This module contains the FastMCP tool for listing saved SQL queries
|
||||
with filtering, search, and pagination.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.mcp_core import ModelListCore
|
||||
from superset.mcp_service.saved_query.schemas import (
|
||||
DEFAULT_SAVED_QUERY_COLUMNS,
|
||||
ListSavedQueriesRequest,
|
||||
SavedQueryError,
|
||||
SavedQueryFilter,
|
||||
SavedQueryInfo,
|
||||
SavedQueryList,
|
||||
serialize_saved_query_object,
|
||||
SORTABLE_SAVED_QUERY_COLUMNS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_LIST_SAVED_QUERIES_REQUEST = ListSavedQueriesRequest()
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="SavedQuery",
|
||||
annotations=ToolAnnotations(
|
||||
title="List saved queries",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def list_saved_queries(
|
||||
request: ListSavedQueriesRequest | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> SavedQueryList | SavedQueryError:
|
||||
"""List saved SQL queries with filtering and search.
|
||||
|
||||
Returns saved queries owned by the current user, including label, SQL,
|
||||
database ID, and schema.
|
||||
|
||||
Sortable columns for order_column: id, label, db_id, schema,
|
||||
changed_on, created_on
|
||||
"""
|
||||
if ctx is None:
|
||||
raise RuntimeError("FastMCP context is required for list_saved_queries")
|
||||
|
||||
request = request or _DEFAULT_LIST_SAVED_QUERIES_REQUEST.model_copy(deep=True)
|
||||
|
||||
await ctx.info(
|
||||
"Listing saved queries: page=%s, page_size=%s, search=%s"
|
||||
% (
|
||||
request.page,
|
||||
request.page_size,
|
||||
request.search,
|
||||
)
|
||||
)
|
||||
await ctx.debug(
|
||||
"Saved query listing parameters: filters=%s, order_column=%s, "
|
||||
"order_direction=%s, select_columns=%s"
|
||||
% (
|
||||
request.filters,
|
||||
request.order_column,
|
||||
request.order_direction,
|
||||
request.select_columns,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.query import SavedQueryDAO
|
||||
|
||||
def _serialize_saved_query(
|
||||
obj: object, cols: list[str] | None
|
||||
) -> SavedQueryInfo | None:
|
||||
return serialize_saved_query_object(obj)
|
||||
|
||||
list_tool = ModelListCore(
|
||||
dao_class=SavedQueryDAO,
|
||||
output_schema=SavedQueryInfo,
|
||||
item_serializer=_serialize_saved_query,
|
||||
filter_type=SavedQueryFilter,
|
||||
default_columns=DEFAULT_SAVED_QUERY_COLUMNS,
|
||||
search_columns=["label", "description", "sql"],
|
||||
list_field_name="saved_queries",
|
||||
output_list_schema=SavedQueryList,
|
||||
all_columns=list(SavedQueryInfo.model_fields.keys()),
|
||||
sortable_columns=SORTABLE_SAVED_QUERY_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.list_saved_queries.query"):
|
||||
result = list_tool.run_tool(
|
||||
filters=request.filters,
|
||||
search=request.search,
|
||||
select_columns=request.select_columns,
|
||||
order_column=request.order_column,
|
||||
order_direction=request.order_direction,
|
||||
page=max(request.page - 1, 0),
|
||||
page_size=request.page_size,
|
||||
)
|
||||
|
||||
await ctx.info(
|
||||
"Saved queries listed successfully: count=%s, total_count=%s, "
|
||||
"total_pages=%s"
|
||||
% (
|
||||
len(result.saved_queries) if hasattr(result, "saved_queries") else 0,
|
||||
getattr(result, "total_count", None),
|
||||
getattr(result, "total_pages", None),
|
||||
)
|
||||
)
|
||||
|
||||
columns_to_filter = result.columns_requested
|
||||
await ctx.debug(
|
||||
"Applying field filtering via serialization context: columns=%s"
|
||||
% (columns_to_filter,)
|
||||
)
|
||||
with event_logger.log_context(action="mcp.list_saved_queries.serialization"):
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
context={"select_columns": columns_to_filter},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Saved query listing failed: page=%s, page_size=%s, error=%s, "
|
||||
"error_type=%s"
|
||||
% (
|
||||
request.page,
|
||||
request.page_size,
|
||||
str(e),
|
||||
type(e).__name__,
|
||||
)
|
||||
)
|
||||
raise
|
||||
@@ -32,6 +32,7 @@ from fastmcp.exceptions import ToolError
|
||||
from fastmcp.server.middleware import Middleware
|
||||
|
||||
from superset.mcp_service.app import create_mcp_app, init_fastmcp_server
|
||||
from superset.mcp_service.jwt_verifier import BrowserHelloMiddleware
|
||||
from superset.mcp_service.mcp_config import (
|
||||
get_mcp_factory_config,
|
||||
MCP_STORE_CONFIG,
|
||||
@@ -717,6 +718,53 @@ def build_middleware_list() -> list[Middleware]:
|
||||
]
|
||||
|
||||
|
||||
def _build_starlette_middleware(
|
||||
flask_app: Any | None = None, auth_provider: Any | None = None
|
||||
) -> list[Any]:
|
||||
from starlette.middleware import Middleware as StarletteMiddleware
|
||||
|
||||
if flask_app is None:
|
||||
from superset.mcp_service.flask_singleton import get_flask_app
|
||||
|
||||
flask_app = get_flask_app()
|
||||
# Auth is active only when an instantiated provider was passed in.
|
||||
# Config-flag presence is not sufficient — MCP_AUTH_FACTORY may return
|
||||
# None, and use_factory_config auth lives outside Flask config entirely.
|
||||
auth_enabled = auth_provider is not None
|
||||
app_name: str = flask_app.config.get("APP_NAME", "Superset")
|
||||
app_icon: str = flask_app.config.get("APP_ICON", "")
|
||||
base_page_config: dict[str, Any] = {
|
||||
"title": f"{app_name} MCP Server",
|
||||
"server_key": app_name.lower().replace(" ", "-"),
|
||||
"app_name": app_name,
|
||||
}
|
||||
if app_icon:
|
||||
if app_icon.startswith(("http://", "https://")):
|
||||
base_page_config["logo_url"] = app_icon
|
||||
elif app_icon.startswith("/"):
|
||||
# Relative path — combine with Superset webserver address if configured
|
||||
superset_addr = flask_app.config.get(
|
||||
"SUPERSET_WEBSERVER_ADDRESS", ""
|
||||
).rstrip("/")
|
||||
if superset_addr:
|
||||
base_page_config["logo_url"] = f"{superset_addr}{app_icon}"
|
||||
mcp_hello_page = flask_app.config.get("MCP_HELLO_PAGE")
|
||||
if mcp_hello_page is not None and not isinstance(mcp_hello_page, dict):
|
||||
logger.warning(
|
||||
"MCP_HELLO_PAGE must be a dict, ignoring value of type %s",
|
||||
type(mcp_hello_page).__name__,
|
||||
)
|
||||
mcp_hello_page = None
|
||||
page_config: dict[str, Any] = {**base_page_config, **(mcp_hello_page or {})}
|
||||
return [
|
||||
StarletteMiddleware(
|
||||
BrowserHelloMiddleware,
|
||||
auth_enabled=auth_enabled,
|
||||
page_config=page_config,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def run_server(
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 5008,
|
||||
@@ -750,6 +798,9 @@ def run_server(
|
||||
logging.info("Creating MCP app from factory configuration...")
|
||||
factory_config = get_mcp_factory_config()
|
||||
mcp_instance = create_mcp_app(**factory_config)
|
||||
# Capture the actual auth object so the hello page reflects real auth state
|
||||
auth_provider = factory_config.get("auth")
|
||||
flask_app = None
|
||||
|
||||
# Apply tool search transform if configured
|
||||
tool_search_config = MCP_TOOL_SEARCH_CONFIG
|
||||
@@ -794,6 +845,11 @@ def run_server(
|
||||
# Create EventStore for session management (Redis for multi-pod, None for in-memory)
|
||||
event_store = create_event_store(event_store_config)
|
||||
|
||||
starlette_middleware = _build_starlette_middleware(
|
||||
flask_app=flask_app,
|
||||
auth_provider=auth_provider,
|
||||
)
|
||||
|
||||
env_key = f"FASTMCP_RUNNING_{port}"
|
||||
if not os.environ.get(env_key):
|
||||
os.environ[env_key] = "1"
|
||||
@@ -807,6 +863,7 @@ def run_server(
|
||||
transport="streamable-http",
|
||||
event_store=event_store,
|
||||
stateless_http=True,
|
||||
middleware=starlette_middleware,
|
||||
)
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
else:
|
||||
@@ -817,6 +874,7 @@ def run_server(
|
||||
host=host,
|
||||
port=port,
|
||||
stateless_http=True,
|
||||
middleware=starlette_middleware,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error("FastMCP failed: %s", e)
|
||||
|
||||
@@ -30,10 +30,15 @@ from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.auth import MCPPermissionDeniedError
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
CHART_DEFAULT_COLUMNS,
|
||||
CHART_SEARCH_COLUMNS,
|
||||
CHART_SORTABLE_COLUMNS,
|
||||
CSS_TEMPLATE_DEFAULT_COLUMNS,
|
||||
CSS_TEMPLATE_FILTER_COLUMNS,
|
||||
CSS_TEMPLATE_SEARCH_COLUMNS,
|
||||
CSS_TEMPLATE_SORTABLE_COLUMNS,
|
||||
DASHBOARD_DEFAULT_COLUMNS,
|
||||
DASHBOARD_SEARCH_COLUMNS,
|
||||
DASHBOARD_SORTABLE_COLUMNS,
|
||||
@@ -44,12 +49,18 @@ from superset.mcp_service.common.schema_discovery import (
|
||||
DATASET_SEARCH_COLUMNS,
|
||||
DATASET_SORTABLE_COLUMNS,
|
||||
get_chart_columns,
|
||||
get_css_template_columns,
|
||||
get_dashboard_columns,
|
||||
get_database_columns,
|
||||
get_dataset_columns,
|
||||
get_theme_columns,
|
||||
GetSchemaRequest,
|
||||
GetSchemaResponse,
|
||||
ModelSchemaInfo,
|
||||
THEME_DEFAULT_COLUMNS,
|
||||
THEME_FILTER_COLUMNS,
|
||||
THEME_SEARCH_COLUMNS,
|
||||
THEME_SORTABLE_COLUMNS,
|
||||
)
|
||||
from superset.mcp_service.constants import ModelType
|
||||
from superset.mcp_service.mcp_core import ModelGetSchemaCore
|
||||
@@ -144,6 +155,44 @@ def _get_database_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]:
|
||||
)
|
||||
|
||||
|
||||
def _get_css_template_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]:
|
||||
"""Create CSS template schema core with dynamically extracted columns."""
|
||||
from superset.daos.css import CssTemplateDAO
|
||||
|
||||
return ModelGetSchemaCore(
|
||||
model_type="css_template",
|
||||
dao_class=CssTemplateDAO,
|
||||
output_schema=ModelSchemaInfo,
|
||||
select_columns=get_css_template_columns(),
|
||||
sortable_columns=CSS_TEMPLATE_SORTABLE_COLUMNS,
|
||||
default_columns=CSS_TEMPLATE_DEFAULT_COLUMNS,
|
||||
search_columns=CSS_TEMPLATE_SEARCH_COLUMNS,
|
||||
default_sort="changed_on",
|
||||
default_sort_direction="desc",
|
||||
filter_columns_override=CSS_TEMPLATE_FILTER_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
|
||||
def _get_theme_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]:
|
||||
"""Create theme schema core with dynamically extracted columns."""
|
||||
from superset.daos.theme import ThemeDAO
|
||||
|
||||
return ModelGetSchemaCore(
|
||||
model_type="theme",
|
||||
dao_class=ThemeDAO,
|
||||
output_schema=ModelSchemaInfo,
|
||||
select_columns=get_theme_columns(),
|
||||
sortable_columns=THEME_SORTABLE_COLUMNS,
|
||||
default_columns=THEME_DEFAULT_COLUMNS,
|
||||
search_columns=THEME_SEARCH_COLUMNS,
|
||||
default_sort="changed_on",
|
||||
default_sort_direction="desc",
|
||||
filter_columns_override=THEME_FILTER_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
|
||||
# Map model types to their core factory functions
|
||||
_SCHEMA_CORE_FACTORIES: dict[
|
||||
ModelType,
|
||||
@@ -153,12 +202,25 @@ _SCHEMA_CORE_FACTORIES: dict[
|
||||
"dataset": _get_dataset_schema_core,
|
||||
"dashboard": _get_dashboard_schema_core,
|
||||
"database": _get_database_schema_core,
|
||||
"css_template": _get_css_template_schema_core,
|
||||
"theme": _get_theme_schema_core,
|
||||
}
|
||||
|
||||
# Maps each model type to the FAB class permission name used by its tools.
|
||||
# Used for per-model-type inline RBAC checks instead of a single static
|
||||
# class_permission_name on the @tool decorator.
|
||||
_MODEL_TYPE_CLASS_PERMISSION: dict[ModelType, str] = {
|
||||
"chart": "Chart",
|
||||
"dataset": "Dataset",
|
||||
"dashboard": "Dashboard",
|
||||
"database": "Database",
|
||||
"css_template": "CssTemplate",
|
||||
"theme": "Theme",
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="Dataset",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get schema",
|
||||
readOnlyHint=True,
|
||||
@@ -182,13 +244,39 @@ async def get_schema(
|
||||
Column metadata is extracted dynamically from SQLAlchemy models.
|
||||
|
||||
Args:
|
||||
model_type: One of "chart", "dataset", "dashboard", or "database"
|
||||
model_type: One of "chart", "dataset", "dashboard", "database",
|
||||
"css_template", or "theme"
|
||||
|
||||
Returns:
|
||||
Comprehensive schema information for the requested model type
|
||||
"""
|
||||
await ctx.info(f"Getting schema for model_type={request.model_type}")
|
||||
|
||||
# Per-model-type RBAC check (replaces the static class_permission_name on @tool,
|
||||
# which wrongly gated all schema types behind Dataset permission).
|
||||
class_permission = _MODEL_TYPE_CLASS_PERMISSION.get(request.model_type)
|
||||
if class_permission:
|
||||
from flask import current_app, g
|
||||
|
||||
from superset import security_manager
|
||||
|
||||
if current_app.config.get("MCP_RBAC_ENABLED", True) and not (
|
||||
security_manager.can_access("can_read", class_permission)
|
||||
):
|
||||
user_str = getattr(getattr(g, "user", None), "username", None)
|
||||
logger.warning(
|
||||
"get_schema RBAC denied: user=%s type=%s view=%s",
|
||||
user_str,
|
||||
request.model_type,
|
||||
class_permission,
|
||||
)
|
||||
raise MCPPermissionDeniedError(
|
||||
permission_name="can_read",
|
||||
view_name=class_permission,
|
||||
user=user_str,
|
||||
tool_name="get_schema",
|
||||
)
|
||||
|
||||
can_view_data_model_metadata = user_can_view_data_model_metadata()
|
||||
if not can_view_data_model_metadata and request.model_type in {
|
||||
"dataset",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,251 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Pydantic schemas for tag-related responses
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Dict, List, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
model_validator,
|
||||
PositiveInt,
|
||||
)
|
||||
|
||||
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
|
||||
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
from superset.mcp_service.system.schemas import (
|
||||
PaginationInfo,
|
||||
TagInfo as BaseTagInfo,
|
||||
)
|
||||
from superset.mcp_service.utils.response_utils import humanize_timestamp
|
||||
from superset.mcp_service.utils.sanitization import sanitize_for_llm_context
|
||||
from superset.mcp_service.utils.schema_utils import (
|
||||
parse_json_or_list,
|
||||
parse_json_or_model_list,
|
||||
)
|
||||
|
||||
|
||||
class TagFilter(ColumnOperator):
|
||||
"""
|
||||
Filter object for tag listing.
|
||||
col: The column to filter on. Must be one of the allowed filter fields.
|
||||
opr: The operator to use. Must be one of the supported operators.
|
||||
value: The value to filter by (type depends on col and opr).
|
||||
"""
|
||||
|
||||
col: Literal["name", "type"] = Field(
|
||||
...,
|
||||
description="Column to filter on. Supported: 'name' (string match), "
|
||||
"'type' (tag type: custom, type, owner, favorited_by).",
|
||||
)
|
||||
opr: ColumnOperatorEnum = Field(
|
||||
...,
|
||||
description="Operator to use. Common operators: 'eq' (equals), "
|
||||
"'ct' (contains), 'sw' (starts with), 'ew' (ends with).",
|
||||
)
|
||||
value: str | int | float | bool | List[str | int | float | bool] = Field(
|
||||
..., description="Value to filter by (type depends on col and opr)"
|
||||
)
|
||||
|
||||
|
||||
class TagInfo(BaseTagInfo):
|
||||
"""Extends the shared BaseTagInfo with audit timestamps for MCP list/get tools."""
|
||||
|
||||
changed_on: str | datetime | None = Field(
|
||||
None, description="Last modification timestamp"
|
||||
)
|
||||
changed_on_humanized: str | None = Field(
|
||||
None, description="Humanized modification time"
|
||||
)
|
||||
created_on: str | datetime | None = Field(None, description="Creation timestamp")
|
||||
created_on_humanized: str | None = Field(
|
||||
None, description="Humanized creation time"
|
||||
)
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
ser_json_timedelta="iso8601",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
|
||||
"""Filter serialized fields to those requested via select_columns context."""
|
||||
data: Dict[str, Any] = serializer(self)
|
||||
if info.context and isinstance(info.context, dict):
|
||||
select_columns = info.context.get("select_columns")
|
||||
if select_columns:
|
||||
requested_fields = set(select_columns)
|
||||
return {k: v for k, v in data.items() if k in requested_fields}
|
||||
return data
|
||||
|
||||
|
||||
class TagList(BaseModel):
|
||||
tags: List[TagInfo]
|
||||
count: int
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
has_previous: bool
|
||||
has_next: bool
|
||||
columns_requested: List[str] = Field(default_factory=list)
|
||||
columns_loaded: List[str] = Field(default_factory=list)
|
||||
columns_available: List[str] = Field(default_factory=list)
|
||||
sortable_columns: List[str] = Field(default_factory=list)
|
||||
filters_applied: List[TagFilter] = Field(default_factory=list)
|
||||
pagination: PaginationInfo | None = None
|
||||
timestamp: datetime | None = None
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class ListTagsRequest(BaseModel):
|
||||
"""Request schema for list_tags."""
|
||||
|
||||
filters: Annotated[
|
||||
List[TagFilter],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of filter objects (column, operator, value). Each "
|
||||
"filter has 'col', 'opr', and 'value' properties. Cannot be used "
|
||||
"together with 'search'.",
|
||||
),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of columns to select. Defaults to common columns if not "
|
||||
"specified.",
|
||||
),
|
||||
]
|
||||
search: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Text search string to match against tag name. Cannot be used "
|
||||
"together with 'filters'.",
|
||||
),
|
||||
]
|
||||
order_column: Annotated[
|
||||
str | None, Field(default=None, description="Column to order results by")
|
||||
]
|
||||
order_direction: Annotated[
|
||||
Literal["asc", "desc"],
|
||||
Field(
|
||||
default="desc",
|
||||
description="Direction to order results ('asc' or 'desc')",
|
||||
),
|
||||
]
|
||||
page: Annotated[
|
||||
PositiveInt,
|
||||
Field(default=1, description="Page number for pagination (1-based)"),
|
||||
]
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
gt=0,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("filters", mode="before")
|
||||
@classmethod
|
||||
def parse_filters(cls, v: Any) -> List[TagFilter]:
|
||||
return parse_json_or_model_list(v, TagFilter, "filters")
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def parse_columns(cls, v: Any) -> List[str]:
|
||||
return parse_json_or_list(v, "select_columns")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_search_and_filters(self) -> "ListTagsRequest":
|
||||
if self.search and self.filters:
|
||||
raise ValueError(
|
||||
"Cannot use both 'search' and 'filters' parameters simultaneously. "
|
||||
"Use either 'search' for text-based searching or 'filters' for "
|
||||
"precise column-based filtering, but not both."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class TagError(BaseModel):
|
||||
error: str = Field(..., description="Error message")
|
||||
error_type: str = Field(..., description="Type of error")
|
||||
timestamp: str | datetime | None = Field(None, description="Error timestamp")
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
@classmethod
|
||||
def create(cls, error: str, error_type: str) -> "TagError":
|
||||
from datetime import timezone
|
||||
|
||||
return cls(
|
||||
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class GetTagInfoRequest(BaseModel):
|
||||
"""Request schema for get_tag_info with numeric ID."""
|
||||
|
||||
identifier: Annotated[
|
||||
int,
|
||||
Field(description="Tag identifier — numeric ID"),
|
||||
]
|
||||
|
||||
|
||||
def _sanitize_tag_info_for_llm_context(tag_info: TagInfo) -> TagInfo:
|
||||
"""Wrap user-controlled tag fields before LLM exposure."""
|
||||
payload = tag_info.model_dump(mode="python")
|
||||
for field_name in ("name", "description"):
|
||||
payload[field_name] = sanitize_for_llm_context(
|
||||
payload.get(field_name),
|
||||
field_path=(field_name,),
|
||||
)
|
||||
return TagInfo(**payload)
|
||||
|
||||
|
||||
def serialize_tag_object(tag: Any) -> TagInfo | None:
|
||||
if not tag:
|
||||
return None
|
||||
|
||||
type_str: str | None = None
|
||||
if (raw_type := getattr(tag, "type", None)) is not None:
|
||||
type_str = raw_type.name if hasattr(raw_type, "name") else str(raw_type)
|
||||
|
||||
return _sanitize_tag_info_for_llm_context(
|
||||
TagInfo(
|
||||
id=getattr(tag, "id", None),
|
||||
name=getattr(tag, "name", None),
|
||||
type=type_str,
|
||||
description=getattr(tag, "description", None),
|
||||
changed_on=getattr(tag, "changed_on", None),
|
||||
changed_on_humanized=humanize_timestamp(getattr(tag, "changed_on", None)),
|
||||
created_on=getattr(tag, "created_on", None),
|
||||
created_on_humanized=humanize_timestamp(getattr(tag, "created_on", None)),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .get_tag_info import get_tag_info
|
||||
from .list_tags import list_tags
|
||||
|
||||
__all__ = [
|
||||
"get_tag_info",
|
||||
"list_tags",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user