mirror of
https://github.com/apache/superset.git
synced 2026-08-17 21:51:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea29d10839 | ||
|
|
01b00e6c33 | ||
|
|
e0a2e934c3 | ||
|
|
300ed1db84 | ||
|
|
9747222451 | ||
|
|
3d02f373d6 | ||
|
|
8f304064f7 | ||
|
|
f9f1aba40a | ||
|
|
9237b3d96d | ||
|
|
a34e10d18c | ||
|
|
2f996ad84f | ||
|
|
320fb9ef98 | ||
|
|
2d88c198dc | ||
|
|
048cfae595 |
@@ -51,53 +51,6 @@ jobs:
|
||||
echo "matrix_config=${MATRIX_CONFIG}" >> $GITHUB_OUTPUT
|
||||
echo $GITHUB_OUTPUT
|
||||
|
||||
# Runs unconditionally (no dependency on `changes`, and no build-preset
|
||||
# matrix restriction) so a regression in the PY_VER override logic is
|
||||
# always caught on PRs. Without this, the real docker-build job only runs
|
||||
# when the change detector flags docker/python/frontend changes (a
|
||||
# workflow-only edit like this one does not), and even then the PR build
|
||||
# matrix never includes the "py311"/"py312" presets that logic protects -
|
||||
# so a break here would otherwise first surface on a push to master.
|
||||
pyver-override-check:
|
||||
name: verify docker build PY_VER override
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup supersetbot
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
- name: Assert PY_VER override applies to every preset except py311/py312
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Asserts against the actual buildx command line `supersetbot docker
|
||||
# --dry-run` would run, not just this repo's own extra-flags helper,
|
||||
# so a regression in supersetbot itself (dropping the py311/py312
|
||||
# PY_VER pin, or reordering args so our override no longer lands
|
||||
# last) is caught here too, instead of only surfacing on master.
|
||||
assert_effective_py_ver() {
|
||||
local preset="$1" expected="$2" extra_flags command actual
|
||||
extra_flags="$(scripts/docker-build-extra-flags.sh "$preset" dummy-tag)"
|
||||
command="$(supersetbot docker --preset "$preset" --platform linux/amd64 --extra-flags "$extra_flags" --dry-run)"
|
||||
# docker buildx keeps the LAST value of a repeated --build-arg key.
|
||||
actual="$(grep -oE -- '--build-arg PY_VER=[^[:space:]]+' <<<"$command" | tail -1)"
|
||||
if [ "$actual" != "--build-arg PY_VER=$expected" ]; then
|
||||
echo "::error::preset '$preset' expected effective --build-arg PY_VER=$expected, got: ${actual:-<none>} (full command: $command)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
for preset in dev lean websocket dockerize; do
|
||||
assert_effective_py_ver "$preset" "3.11.14-slim-trixie"
|
||||
done
|
||||
assert_effective_py_ver py311 "3.11-slim-bookworm"
|
||||
assert_effective_py_ver py312 "3.12-slim-bookworm"
|
||||
echo "PY_VER override logic verified against the assembled buildx command for all build presets"
|
||||
|
||||
docker-build:
|
||||
name: docker-build
|
||||
needs: [setup_matrix, changes]
|
||||
@@ -171,21 +124,19 @@ jobs:
|
||||
# the whole job. buildx reuses the buildkit layer cache from the
|
||||
# failed attempt, so a retry mostly re-does just the failed push.
|
||||
#
|
||||
# See scripts/docker-build-extra-flags.sh for why "py311"/"py312"
|
||||
# are excluded from the PY_VER override applied to every other
|
||||
# preset; that logic is also exercised on every PR by the
|
||||
# always-on pyver-override-check job below, since this job itself
|
||||
# only runs when the change detector flags docker/python/frontend
|
||||
# changes and the PR build matrix never includes py311/py312.
|
||||
EXTRA_FLAGS="$(scripts/docker-build-extra-flags.sh "$BUILD_PRESET" "$IMAGE_TAG")"
|
||||
|
||||
# supersetbot's "dev"/"lean" presets pin their own --build-arg
|
||||
# PY_VER, which lands ahead of --extra-flags on the assembled
|
||||
# buildx command line; docker/buildx keeps the last value for a
|
||||
# repeated --build-arg key, so appending PY_VER here overrides
|
||||
# supersetbot's pin and keeps the build on the Dockerfile's own
|
||||
# supported Python version.
|
||||
for attempt in 1 2 3; do
|
||||
if supersetbot docker \
|
||||
$PUSH_OR_LOAD \
|
||||
--preset "$BUILD_PRESET" \
|
||||
--context "$EVENT" \
|
||||
--context-ref "$RELEASE" $FORCE_LATEST \
|
||||
--extra-flags "$EXTRA_FLAGS" \
|
||||
--extra-flags "--build-arg PY_VER=3.11.14-slim-trixie --build-arg INCLUDE_CHROMIUM=false --tag $IMAGE_TAG" \
|
||||
$PLATFORM_ARG; then
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
name: Frontend bundle size (nightly baseline + analyzer)
|
||||
|
||||
# Refreshes the bundle-size baseline that superset-frontend.yml's `bundle-size`
|
||||
# job compares PRs against, and publishes a browsable bundle-analyzer treemap
|
||||
# report of the same build. Deliberately NOT triggered on every push to
|
||||
# master: a day-old baseline/report is fine for catching relative
|
||||
# regressions on PRs and for browsing what's actually in the bundle, and
|
||||
# building the production bundle on every one of the many pushes master
|
||||
# gets per day would burn CI time for no benefit a nightly refresh doesn't
|
||||
# already cover.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
TAG: apache/superset:bundle-size-nightly-${{ github.run_id }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
refresh-baseline:
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_BUNDLE_ANALYZER_SITE_ID }}
|
||||
steps:
|
||||
- name: "Checkout master"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: master
|
||||
|
||||
- name: Build Docker Image
|
||||
run: |
|
||||
docker buildx build \
|
||||
-t $TAG \
|
||||
--cache-from=type=registry,ref=apache/superset-cache:3.11-slim-trixie \
|
||||
--target superset-node-ci \
|
||||
.
|
||||
|
||||
# Same cache the PR-time bundle-size job restores/writes -- webpack's
|
||||
# persistent filesystem cache turns a warm production build into ~20s
|
||||
# instead of several minutes. See superset-frontend.yml for the
|
||||
# matching restore step and why it's keyed this way.
|
||||
- name: Restore webpack build cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: superset-frontend/.temp_cache
|
||||
key: >-
|
||||
webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json',
|
||||
'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json',
|
||||
'superset-frontend/webpack.config.js') }}
|
||||
|
||||
# Only ever pull the last recorded data point off the cache, keyed by
|
||||
# run ID -- `restore-keys` prefix-matches the most recently created
|
||||
# entry. Absent on the very first run ever; benchmark-action starts a
|
||||
# fresh history in that case.
|
||||
- name: Restore bundle size history
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
bundle-size-history-
|
||||
|
||||
# BUNDLE_ANALYZER rides along in the same build as BUNDLE_SIZE_STATS --
|
||||
# they're independent env-gated additions in webpack.config.js (one
|
||||
# sets `config.stats`, the other pushes plugins), so one production
|
||||
# build produces both the numeric stats.json and the analyzer's
|
||||
# report.html. Only report.html is mounted out, not
|
||||
# BUNDLE_ANALYZER's sibling `statistics.html` sunburst -- that file is
|
||||
# documented in webpack.config.js as routinely exceeding 100MB for
|
||||
# this app (it's .gitignore'd for exactly that reason), too large to
|
||||
# publish as a static site page.
|
||||
- name: Build production bundle with stats and analyzer report
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache
|
||||
mkdir -p ${{ github.workspace }}/superset/static/assets
|
||||
docker run \
|
||||
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
|
||||
-v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \
|
||||
-v ${{ github.workspace }}/superset/static/assets:/app/superset/static/assets \
|
||||
--rm $TAG \
|
||||
bash -c \
|
||||
"npm i && BUNDLE_SIZE_STATS=true BUNDLE_ANALYZER=true npm run build -- --json=bundle-stats/stats.json"
|
||||
|
||||
- name: Summarize bundle size
|
||||
run: |
|
||||
node superset-frontend/scripts/bundle-size-summary.js \
|
||||
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
|
||||
rm -rf superset-frontend/bundle-stats
|
||||
|
||||
# No PR to comment on here, so comment-on-alert is off -- the job
|
||||
# summary (summary-always) is the only surface for this run.
|
||||
- name: Update bundle size baseline
|
||||
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
|
||||
with:
|
||||
tool: customSmallerIsBetter
|
||||
output-file-path: bundle-size-summary.json
|
||||
external-data-json-path: bundle-size-history.json
|
||||
fail-on-alert: false
|
||||
summary-always: true
|
||||
|
||||
- name: Save bundle size history
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
|
||||
# Publishes the treemap to Netlify (the same host already used for
|
||||
# superset-storybook.netlify.app and docs previews, reusing the
|
||||
# existing NETLIFY_AUTH_TOKEN). Skipped until
|
||||
# NETLIFY_BUNDLE_ANALYZER_SITE_ID exists -- create a new (free)
|
||||
# Netlify site named superset-bundle-analyzer and add its site ID as
|
||||
# that secret to turn this on; nothing else in this workflow depends
|
||||
# on it.
|
||||
- name: Publish bundle analyzer report to Netlify
|
||||
if: ${{ env.NETLIFY_SITE_ID != '' }}
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
run: |
|
||||
mkdir -p netlify-publish
|
||||
cp superset/static/assets/report.html netlify-publish/index.html
|
||||
# zizmor: ignore[adhoc-packages] - netlify-cli is a one-shot CI deploy
|
||||
# tool, not an application dependency; a global/npx install has no
|
||||
# lockfile context. Version pinned above the floor set by other
|
||||
# ad-hoc installs in this repo (bump deliberately when upgrading).
|
||||
npx --yes netlify-cli@27.0.1 deploy --prod --dir=netlify-publish
|
||||
@@ -45,8 +45,5 @@ jobs:
|
||||
- name: Run Script
|
||||
run: bash .github/workflows/github-action-validator.sh
|
||||
|
||||
- name: Test docs-deploy freshness gate
|
||||
run: bash .github/workflows/scripts/check-docs-deploy-freshness.test.sh
|
||||
|
||||
- name: Check for security issues on GHA workflows
|
||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# Shared freshness gate used by the Docs Deployment workflow
|
||||
# (superset-docs-deploy.yml) both up front (check-freshness) and again right
|
||||
# before the deploy step (recheck-freshness). Writes an output declaring
|
||||
# whether BUILD_SHA is still master's current tip, so a superseded run can
|
||||
# skip cleanly instead of racing (and clobbering, or being force-cancelled
|
||||
# by) a fresher run.
|
||||
#
|
||||
# Required env vars:
|
||||
# BUILD_SHA - the commit SHA this run is building
|
||||
# REPO - "owner/repo" to query, e.g. github.repository
|
||||
# OUTPUT_NAME - the GITHUB_OUTPUT key to write, e.g. "is-current"
|
||||
# GITHUB_OUTPUT - path to append outputs to (set by the Actions runner)
|
||||
# Optional env vars:
|
||||
# EVENT_NAME - if "workflow_dispatch", bypasses the check and always
|
||||
# reports current, since a manual dispatch is a deliberate,
|
||||
# one-off action rather than something racing other triggers
|
||||
# GH_TOKEN - passed through to `gh`, needed to call the GitHub API
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${EVENT_NAME:-}" = "workflow_dispatch" ]; then
|
||||
echo "${OUTPUT_NAME}=true" >>"$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
latest_sha="$(gh api "repos/${REPO}/commits/master" --jq .sha)"
|
||||
if [ "${latest_sha}" = "${BUILD_SHA}" ]; then
|
||||
echo "${OUTPUT_NAME}=true" >>"$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "${OUTPUT_NAME}=false" >>"$GITHUB_OUTPUT"
|
||||
echo "::notice::master has moved on to ${latest_sha} since ${BUILD_SHA} was triggered — skipping this stale run."
|
||||
fi
|
||||
@@ -1,100 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# Exercises check-docs-deploy-freshness.sh against a stubbed `gh`, covering
|
||||
# the dispatch-bypass, current-tip and stale-tip branches so the output
|
||||
# contract (is-current / still-current) can't silently regress. Run
|
||||
# directly, no extra tooling required:
|
||||
# bash .github/workflows/scripts/check-docs-deploy-freshness.test.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
script_under_test="${script_dir}/check-docs-deploy-freshness.sh"
|
||||
|
||||
failures=0
|
||||
|
||||
# Runs the script under test with a stubbed `gh` reporting $1 as master's
|
||||
# latest sha, asserting that GITHUB_OUTPUT ends up containing exactly $4.
|
||||
run_case() {
|
||||
local case_name="$1"
|
||||
local latest_sha="$2"
|
||||
local build_sha="$3"
|
||||
local event_name="$4"
|
||||
local expected_line="$5"
|
||||
|
||||
local workdir
|
||||
workdir="$(mktemp -d)"
|
||||
trap 'rm -rf "${workdir}"' RETURN
|
||||
|
||||
# Fake `gh` that just echoes back the requested "latest" sha regardless of
|
||||
# arguments, so the script under test never touches the network.
|
||||
cat >"${workdir}/gh" <<EOF
|
||||
#!/bin/bash
|
||||
echo '${latest_sha}'
|
||||
EOF
|
||||
chmod +x "${workdir}/gh"
|
||||
|
||||
local output_file="${workdir}/github_output"
|
||||
: >"${output_file}"
|
||||
|
||||
if PATH="${workdir}:${PATH}" \
|
||||
GITHUB_OUTPUT="${output_file}" \
|
||||
OUTPUT_NAME="is-current" \
|
||||
REPO="apache/superset" \
|
||||
BUILD_SHA="${build_sha}" \
|
||||
EVENT_NAME="${event_name}" \
|
||||
GH_TOKEN="fake-token" \
|
||||
bash "${script_under_test}"; then
|
||||
:
|
||||
else
|
||||
echo "FAIL (${case_name}): script exited non-zero"
|
||||
failures=$((failures + 1))
|
||||
return
|
||||
fi
|
||||
|
||||
local actual
|
||||
actual="$(cat "${output_file}")"
|
||||
if [ "${actual}" = "${expected_line}" ]; then
|
||||
echo "PASS (${case_name})"
|
||||
else
|
||||
echo "FAIL (${case_name}): expected '${expected_line}', got '${actual}'"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# `gh` prints "should-not-be-called" for the dispatch case above the trick:
|
||||
# it's never actually invoked since the bypass short-circuits before the
|
||||
# `gh api` call, but the fake still needs a body.
|
||||
run_case "workflow_dispatch bypasses the check" \
|
||||
"unused" "abc123" "workflow_dispatch" \
|
||||
"is-current=true"
|
||||
|
||||
run_case "build sha matches master's tip" \
|
||||
"abc123" "abc123" "push" \
|
||||
"is-current=true"
|
||||
|
||||
run_case "build sha is stale" \
|
||||
"def456" "abc123" "push" \
|
||||
"is-current=false"
|
||||
|
||||
if [ "${failures}" -gt 0 ]; then
|
||||
echo "${failures} case(s) failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All cases passed"
|
||||
@@ -18,6 +18,16 @@ on:
|
||||
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Serialize deploys: the action pushes to apache/superset-site without
|
||||
# rebasing, so concurrent runs race on the final push and the loser fails
|
||||
# with `! [rejected] asf-site -> asf-site (fetch first)`. Cancel any
|
||||
# in-progress run as soon as a newer one starts — the destination repo
|
||||
# isn't touched until the final push step, so canceling mid-build is safe,
|
||||
# and the freshest content always wins.
|
||||
concurrency:
|
||||
group: docs-deploy-asf-site
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -38,69 +48,17 @@ jobs:
|
||||
|
||||
env:
|
||||
SUPERSET_SITE_BUILD: ${{ (secrets.SUPERSET_SITE_BUILD != '' && secrets.SUPERSET_SITE_BUILD != '') || '' }}
|
||||
|
||||
# Master gets frequent, sometimes bursty pushes, and each one can trigger a
|
||||
# deploy attempt. Rather than let every superseded attempt get force-killed
|
||||
# by the build-deploy concurrency group below (which shows up as a
|
||||
# `cancelled` — i.e. red/failing-looking — check on that commit), have each
|
||||
# run check up front whether it's still building master's current tip and,
|
||||
# if not, skip cleanly. Deliberately outside the docs-deploy-asf-site
|
||||
# concurrency group so it runs immediately for every trigger without
|
||||
# blocking or being blocked by anything.
|
||||
check-freshness:
|
||||
runs-on: ubuntu-26.04
|
||||
outputs:
|
||||
is-current: ${{ steps.check.outputs.is-current }}
|
||||
steps:
|
||||
# Sparse checkout: this job's only job is to be fast, so it fetches
|
||||
# nothing but the freshness-check script itself.
|
||||
- name: Checkout freshness-check script
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github/workflows/scripts
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: "Check whether this is still master's current commit"
|
||||
id: check
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
OUTPUT_NAME: is-current
|
||||
run: .github/workflows/scripts/check-docs-deploy-freshness.sh
|
||||
|
||||
build-deploy:
|
||||
needs: [config, check-freshness]
|
||||
# Only the run for master's current tip proceeds; anything superseded
|
||||
# already skipped at check-freshness above instead of landing here.
|
||||
needs: config
|
||||
# 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 &&
|
||||
needs.check-freshness.outputs.is-current == 'true' &&
|
||||
(github.event_name != 'workflow_run' ||
|
||||
github.event.workflow_run.head_repository.full_name == github.repository)
|
||||
name: Build & Deploy
|
||||
runs-on: ubuntu-26.04
|
||||
# Serialize deploys: the action pushes to apache/superset-site without
|
||||
# rebasing, so concurrent runs race on the final push and the loser fails
|
||||
# with `! [rejected] asf-site -> asf-site (fetch first)`. Queue instead of
|
||||
# canceling: a run that already passed check-freshness can still be
|
||||
# sitting in the queue for a runner when a newer run starts and finishes
|
||||
# first. cancel-in-progress would let that stale, queued run kill the
|
||||
# newer run's in-progress deploy the moment it's finally scheduled, and
|
||||
# then skip itself at the re-check below — losing the deploy entirely.
|
||||
# Queuing means the stale run just waits its turn and then no-ops at the
|
||||
# re-check, so the fresher content that already deployed is never
|
||||
# clobbered or lost. The check-freshness gate above means it should be
|
||||
# rare for more than one run to reach this point, so the queue stays
|
||||
# short in practice.
|
||||
concurrency:
|
||||
group: docs-deploy-asf-site
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
@@ -172,24 +130,7 @@ jobs:
|
||||
working-directory: docs
|
||||
run: |
|
||||
yarn build
|
||||
# The check-freshness job above narrows the window but doesn't close it: an
|
||||
# older run can observe is-current=true, then sit through this build while a
|
||||
# newer run's own freshness check also passes and it deploys and finishes
|
||||
# first. If this (stale) run then wins entry into the concurrency group, it
|
||||
# would overwrite the newer content that already deployed. Re-check right
|
||||
# before the one step that actually mutates superset-site, so a stale run
|
||||
# skips deploying instead of clobbering a fresher one that already ran.
|
||||
- name: "Re-check freshness immediately before deploying"
|
||||
id: recheck-freshness
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
OUTPUT_NAME: still-current
|
||||
run: .github/workflows/scripts/check-docs-deploy-freshness.sh
|
||||
- name: deploy docs
|
||||
if: github.event_name == 'workflow_dispatch' || steps.recheck-freshness.outputs.still-current == 'true'
|
||||
uses: ./.github/actions/github-action-push-to-another-repository
|
||||
env:
|
||||
API_TOKEN_GITHUB: ${{ secrets.SUPERSET_SITE_BUILD }}
|
||||
|
||||
@@ -212,100 +212,3 @@ jobs:
|
||||
- uses: Kesin11/actions-timeline@57fc93f20c6da7fbc14063c6d24a2a5627c799ad # v3.2.0
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
# Compares a PR's own bundle size against the last nightly-recorded
|
||||
# baseline (see frontend-bundle-size-nightly.yml, which owns actually
|
||||
# persisting new baselines). PR-only: a push to master doesn't need this
|
||||
# check re-run against itself, and re-persisting the baseline on every
|
||||
# push to master -- which happens many times a day -- would burn a full
|
||||
# production build for no benefit nightly refresh doesn't already cover.
|
||||
bundle-size:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true' && github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Download Docker Image Artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: docker-image
|
||||
|
||||
- name: Load Docker Image
|
||||
run: |
|
||||
zstd -d < docker-image.tar.zst | docker load
|
||||
|
||||
# webpack's persistent filesystem cache (superset-frontend/webpack.config.js)
|
||||
# turns a warm production build into ~20s instead of several minutes,
|
||||
# but GH-hosted runners are fresh VMs with nothing carried over between
|
||||
# jobs -- without restoring it explicitly, every single PR would pay
|
||||
# the full cold-build cost. Keyed on the same files webpack's own
|
||||
# `buildDependencies` invalidates on, so a stale cache is never used.
|
||||
- name: Restore webpack build cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: superset-frontend/.temp_cache
|
||||
key: >-
|
||||
webpack-prod-cache-${{ hashFiles('superset-frontend/package-lock.json',
|
||||
'superset-frontend/babel.config.js', 'superset-frontend/tsconfig.json',
|
||||
'superset-frontend/webpack.config.js') }}
|
||||
|
||||
# Only ever pull the last recorded data point off the cache, keyed by
|
||||
# run ID -- `restore-keys` prefix-matches the most recently created
|
||||
# entry, which is always the latest nightly run. Absent before the
|
||||
# first nightly run ever happens; benchmark-action starts a fresh
|
||||
# history in that case.
|
||||
- name: Restore bundle size history
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: bundle-size-history.json
|
||||
key: bundle-size-history-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
bundle-size-history-
|
||||
|
||||
- name: Build production bundle with stats
|
||||
run: |
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/bundle-stats
|
||||
mkdir -p ${{ github.workspace }}/superset-frontend/.temp_cache
|
||||
docker run \
|
||||
-v ${{ github.workspace }}/superset-frontend/bundle-stats:/app/superset-frontend/bundle-stats \
|
||||
-v ${{ github.workspace }}/superset-frontend/.temp_cache:/app/superset-frontend/.temp_cache \
|
||||
--rm $TAG \
|
||||
bash -c \
|
||||
"npm i && BUNDLE_SIZE_STATS=true npm run build -- --json=bundle-stats/stats.json"
|
||||
|
||||
- name: Summarize bundle size
|
||||
run: |
|
||||
node superset-frontend/scripts/bundle-size-summary.js \
|
||||
superset-frontend/bundle-stats/stats.json > bundle-size-summary.json
|
||||
rm -rf superset-frontend/bundle-stats
|
||||
|
||||
# Comparison + alert only -- this job never persists. See
|
||||
# frontend-bundle-size-nightly.yml for why.
|
||||
#
|
||||
# comment-on-alert is gated to same-repo PRs: on a fork PR,
|
||||
# GITHUB_TOKEN is forced read-only regardless of the `permissions`
|
||||
# block above, so once the alert threshold is crossed the action's
|
||||
# `pulls.createReview` call 403s. That error isn't gated by
|
||||
# fail-on-alert (which only governs the deliberate alert-threshold
|
||||
# failure) -- it propagates and fails the job outright. Fork PRs
|
||||
# still get the comparison via the job summary (summary-always).
|
||||
- name: Compare bundle size against nightly baseline
|
||||
uses: benchmark-action/github-action-benchmark@52576c92bccf6ac60c8223ec7eb2565637cae9ba # v1.22.1
|
||||
with:
|
||||
tool: customSmallerIsBetter
|
||||
output-file-path: bundle-size-summary.json
|
||||
external-data-json-path: bundle-size-history.json
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
comment-on-alert: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
alert-threshold: "110%"
|
||||
fail-on-alert: false
|
||||
summary-always: true
|
||||
|
||||
@@ -31,8 +31,6 @@ under the License.
|
||||
[](https://github.com/apache/superset/pulls)
|
||||
[](https://bit.ly/join-superset-slack)
|
||||
[](https://superset.apache.org)
|
||||
[](https://superset-storybook.netlify.app)
|
||||
[](https://superset-bundle-analyzer.netlify.app)
|
||||
|
||||
<picture width="500">
|
||||
<source
|
||||
|
||||
@@ -400,7 +400,7 @@ Once enabled, each user manages their own keys from their profile page:
|
||||
1. Open the user menu (top-right) and click **Info** to navigate to the User Info page
|
||||
2. Expand the **API Keys** section
|
||||
3. Click **+ API Key**
|
||||
4. Enter a name and (optionally) an expiration date
|
||||
4. Enter a name and optionally select resource scopes
|
||||
5. Copy the generated token — it is shown only once
|
||||
|
||||
Only users with the `can_read` and `can_write` permissions on `ApiKey` (granted by default to Admins) can manage API keys.
|
||||
@@ -415,6 +415,18 @@ Authorization: Bearer <your-api-key>
|
||||
|
||||
This works for all REST API endpoints and the MCP server. The request is executed with the permissions of the user who created the key.
|
||||
|
||||
#### API Key Scopes
|
||||
|
||||
The creation dialog can restrict an API key to MCP resource actions such as
|
||||
`superset:dashboard:read` or `superset:chart:write`. A scope is an additional
|
||||
restriction: it never grants a permission that the creating user does not
|
||||
already have through Superset RBAC. Write scopes also cover update and delete
|
||||
operations for that resource; `superset:sqllab:write` covers SQL execution.
|
||||
|
||||
Keys created without scopes retain legacy RBAC-only behavior. The scoped-key
|
||||
restrictions described here are enforced by the MCP server; regular REST API
|
||||
routes continue to apply their existing Superset RBAC checks.
|
||||
|
||||
#### Use Cases
|
||||
|
||||
- **CI/CD pipelines** — automated chart/dashboard exports and imports
|
||||
|
||||
+3
-3
@@ -42,7 +42,7 @@ dependencies = [
|
||||
# ``google-auth`` 2.53+ dropped it, so Superset must declare it
|
||||
# explicitly to keep fresh ``pip install apache-superset`` working
|
||||
# without the ``base.txt`` lock file (#40962).
|
||||
"cachetools>=7.1.7, <8",
|
||||
"cachetools>=7.1.6, <8",
|
||||
"celery>=5.6.3, <6.0.0",
|
||||
"click>=8.4.2",
|
||||
"click-option-group",
|
||||
@@ -75,7 +75,7 @@ dependencies = [
|
||||
"humanize",
|
||||
"isodate",
|
||||
"jsonpath-ng>=1.8.0, <2",
|
||||
"Mako>=1.4.1",
|
||||
"Mako>=1.2.2",
|
||||
"markdown>=3.10.3",
|
||||
# marshmallow 4 compatibility: see superset/marshmallow_compatibility.py for a
|
||||
# Flask-AppBuilder workaround. Tracking issue:
|
||||
@@ -221,7 +221,7 @@ ocient = [
|
||||
# unpinned sqlalchemy>=1.4 declared, but SQLAlchemy 2.0 support is
|
||||
# unverified. Lower confidence than the other bumps in this PR.
|
||||
"sqlalchemy-ocient>=3.0.0, <4",
|
||||
"pyocient>=3.9.0, <4",
|
||||
"pyocient>=1.0.15, <4",
|
||||
"shapely",
|
||||
"geojson",
|
||||
]
|
||||
|
||||
@@ -28,7 +28,7 @@ numexpr>=2.9.0
|
||||
# Security: CVE-2026-34073 (MEDIUM) - Improper Certificate Validation
|
||||
cryptography>=50.0.0,<51.0.0
|
||||
# Security: Snyk - XSS vulnerability in Mako templates
|
||||
mako>=1.4.1,<2.0.0
|
||||
mako>=1.3.11,<2.0.0
|
||||
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers
|
||||
pyarrow>=24.0.0,<26.0.0
|
||||
# Security: CVE-2026-27459 - pyopenssl certificate validation
|
||||
|
||||
@@ -44,7 +44,7 @@ cachelib==0.13.0
|
||||
# via
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==7.1.7
|
||||
cachetools==7.1.6
|
||||
# via apache-superset (pyproject.toml)
|
||||
cattrs==25.1.1
|
||||
# via requests-cache
|
||||
@@ -207,7 +207,7 @@ kombu==5.6.2
|
||||
# via celery
|
||||
limits==5.1.0
|
||||
# via flask-limiter
|
||||
mako==1.4.1
|
||||
mako==1.3.12
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
|
||||
@@ -99,7 +99,7 @@ cachelib==0.13.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==7.1.7
|
||||
cachetools==7.1.6
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -510,7 +510,7 @@ limits==5.1.0
|
||||
# flask-limiter
|
||||
lz4==4.4.5
|
||||
# via trino
|
||||
mako==1.4.1
|
||||
mako==1.3.12
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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.
|
||||
|
||||
# Computes the `--extra-flags` value passed to `supersetbot docker` for a
|
||||
# given build preset. Factored out of .github/workflows/docker.yml so the
|
||||
# PY_VER override logic below can be exercised by an always-on CI check
|
||||
# (docker.yml's docker-build job only runs when the change detector's
|
||||
# docker/python/frontend outputs are true, and the PR build matrix never
|
||||
# includes py311/py312 at all, so a regression here would otherwise go
|
||||
# unnoticed until the fix actually runs on master) without duplicating -
|
||||
# and risking drift from - the logic used by the real build step.
|
||||
#
|
||||
# supersetbot's "py311"/"py312" presets pin their own --build-arg PY_VER,
|
||||
# which lands ahead of --extra-flags on the assembled buildx command line;
|
||||
# docker/buildx keeps the last value for a repeated --build-arg key, so
|
||||
# appending PY_VER here would override supersetbot's pin and silently make
|
||||
# "py311"/"py312" build the exact same image as "lean". Every other preset
|
||||
# gets the override so its build lands on the Dockerfile's own supported
|
||||
# Python version.
|
||||
#
|
||||
# Usage: docker-build-extra-flags.sh <build_preset> <image_tag>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_PRESET="${1:?usage: docker-build-extra-flags.sh <build_preset> <image_tag>}"
|
||||
IMAGE_TAG="${2:?usage: docker-build-extra-flags.sh <build_preset> <image_tag>}"
|
||||
|
||||
EXTRA_FLAGS="--build-arg INCLUDE_CHROMIUM=false --tag $IMAGE_TAG"
|
||||
if [ "$BUILD_PRESET" != "py311" ] && [ "$BUILD_PRESET" != "py312" ]; then
|
||||
EXTRA_FLAGS="--build-arg PY_VER=3.11.14-slim-trixie $EXTRA_FLAGS"
|
||||
fi
|
||||
|
||||
echo "$EXTRA_FLAGS"
|
||||
Generated
-506
@@ -19,7 +19,6 @@
|
||||
"@babel/preset-typescript": "7.29.7",
|
||||
"@types/node": "^25.4.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18",
|
||||
@@ -27,27 +26,6 @@
|
||||
"webpack-cli": "^5.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
||||
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/css-calc": "^2.1.3",
|
||||
"@csstools/css-color-parser": "^3.0.9",
|
||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
||||
"@csstools/css-tokenizer": "^3.0.3",
|
||||
"lru-cache": "^10.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@babel/cli": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.29.7.tgz",
|
||||
@@ -1678,121 +1656,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-calc": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
|
||||
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-color-parser": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
|
||||
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@csstools/color-helpers": "^5.1.0",
|
||||
"@csstools/css-calc": "^2.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-parser-algorithms": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
|
||||
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@csstools/css-tokenizer": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/css-tokenizer": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/csstools"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/csstools"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@discoveryjs/json-ext": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.6.tgz",
|
||||
@@ -2638,16 +2501,6 @@
|
||||
"acorn": "^8.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
@@ -3015,34 +2868,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/cssstyle": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@asamuzakjp/css-color": "^3.2.0",
|
||||
"rrweb-cssom": "^0.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -3061,13 +2886,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -3097,19 +2915,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/envinfo": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.13.0.tgz",
|
||||
@@ -3511,60 +3316,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-encoding-sniffer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
|
||||
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-encoding": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/import-local": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz",
|
||||
@@ -3687,13 +3438,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-potential-custom-element-name": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -3754,46 +3498,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
"decimal.js": "^10.5.0",
|
||||
"html-encoding-sniffer": "^4.0.0",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"is-potential-custom-element-name": "^1.0.1",
|
||||
"nwsapi": "^2.2.16",
|
||||
"parse5": "^7.2.1",
|
||||
"rrweb-cssom": "^0.8.0",
|
||||
"saxes": "^6.0.0",
|
||||
"symbol-tree": "^3.2.4",
|
||||
"tough-cookie": "^5.1.1",
|
||||
"w3c-xmlserializer": "^5.0.0",
|
||||
"webidl-conversions": "^7.0.0",
|
||||
"whatwg-encoding": "^3.1.1",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-url": "^14.1.1",
|
||||
"ws": "^8.18.0",
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"canvas": "^3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@@ -4273,13 +3977,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nwsapi": {
|
||||
"version": "2.2.24",
|
||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz",
|
||||
"integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -4336,19 +4033,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"entities": "^6.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@@ -4458,16 +4142,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -4640,33 +4314,6 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-cssom": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
|
||||
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
|
||||
@@ -4800,13 +4447,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/symbol-tree": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
@@ -4952,26 +4592,6 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tldts-core": "^6.1.86"
|
||||
},
|
||||
"bin": {
|
||||
"tldts": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts-core": {
|
||||
"version": "6.1.86",
|
||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -4985,32 +4605,6 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tldts": "^6.1.32"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tscw-config": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tscw-config/-/tscw-config-1.1.2.tgz",
|
||||
@@ -5337,19 +4931,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xml-name-validator": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
|
||||
@@ -5363,16 +4944,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/webpack": {
|
||||
"version": "5.105.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz",
|
||||
@@ -5499,44 +5070,6 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-encoding": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "14.2.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "^5.1.0",
|
||||
"webidl-conversions": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -5581,45 +5114,6 @@
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -43,7 +43,6 @@
|
||||
"@babel/preset-typescript": "7.29.7",
|
||||
"@types/node": "^25.4.0",
|
||||
"babel-loader": "^9.1.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"tscw-config": "^1.1.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18",
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Switchboard } from "@superset-ui/switchboard";
|
||||
import { embedDashboard } from "./index";
|
||||
|
||||
vi.mock("@superset-ui/switchboard");
|
||||
|
||||
function makeFakeJWT(claims: any) {
|
||||
// not a valid jwt, but close enough for this code
|
||||
const tokenifiedClaims = Buffer.from(JSON.stringify(claims)).toString(
|
||||
"base64",
|
||||
);
|
||||
return `abc.${tokenifiedClaims}.xyz`;
|
||||
}
|
||||
|
||||
describe("embedDashboard", () => {
|
||||
let mountPoint: HTMLElement;
|
||||
let mockSwitchboard: Switchboard;
|
||||
|
||||
beforeEach(() => {
|
||||
mountPoint = document.createElement("div");
|
||||
document.body.appendChild(mountPoint);
|
||||
|
||||
mockSwitchboard = {
|
||||
emit: vi.fn(),
|
||||
get: vi.fn(),
|
||||
start: vi.fn(),
|
||||
defineMethod: vi.fn(),
|
||||
} as any;
|
||||
|
||||
// Constructor mocks must use `function`, since arrow functions cannot be
|
||||
// invoked with `new`.
|
||||
vi.mocked(Switchboard).mockImplementation(function () {
|
||||
return mockSwitchboard;
|
||||
} as any);
|
||||
|
||||
// Mock MessageChannel API
|
||||
globalThis.MessageChannel = vi.fn(function (this: any) {
|
||||
this.port1 = {};
|
||||
this.port2 = {};
|
||||
}) as any;
|
||||
|
||||
// Mock iframe load event and sandbox
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation(tagName => {
|
||||
const element = originalCreateElement(tagName);
|
||||
if (tagName === "iframe") {
|
||||
// Mock sandbox DOMTokenList
|
||||
(element as any).sandbox = {
|
||||
add: vi.fn(),
|
||||
};
|
||||
// Mock contentWindow for postMessage
|
||||
Object.defineProperty(element, "contentWindow", {
|
||||
writable: true,
|
||||
value: {
|
||||
postMessage: vi.fn(),
|
||||
},
|
||||
});
|
||||
setTimeout(() => {
|
||||
element.dispatchEvent(new Event("load"));
|
||||
}, 0);
|
||||
}
|
||||
return element;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(mountPoint);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("setDataMask sends dataMask to iframe", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const testDataMask = {
|
||||
"NATIVE_FILTER-1": {
|
||||
filterState: {
|
||||
value: ["value1", "value2"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(testDataMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: testDataMask,
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask sends empty dataMask", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const emptyDataMask = {};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(emptyDataMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: emptyDataMask,
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask drops the change-trigger flags observeDataMask adds", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const observedMask = {
|
||||
"NATIVE_FILTER-1": {
|
||||
filterState: {
|
||||
value: ["CA"],
|
||||
},
|
||||
},
|
||||
crossFiltersChanged: false,
|
||||
nativeFiltersChanged: true,
|
||||
};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(observedMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: {
|
||||
"NATIVE_FILTER-1": observedMask["NATIVE_FILTER-1"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask sends complex dataMask with multiple filters", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
const complexDataMask = {
|
||||
"NATIVE_FILTER-1": {
|
||||
filterState: {
|
||||
value: ["CA", "NY"],
|
||||
},
|
||||
},
|
||||
"NATIVE_FILTER-2": {
|
||||
filterState: {
|
||||
value: [2023, 2024],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
dashboard.setDataMask(complexDataMask);
|
||||
|
||||
expect(mockSwitchboard.get).toHaveBeenCalledWith("setDataMask", {
|
||||
dataMask: complexDataMask,
|
||||
});
|
||||
});
|
||||
|
||||
test("setDataMask rejects when the embedded page does not support it", async () => {
|
||||
const fakeToken = makeFakeJWT({ exp: Date.now() / 1000 + 300 });
|
||||
const mockFetchGuestToken = vi.fn().mockResolvedValue(fakeToken);
|
||||
vi.mocked(mockSwitchboard.get).mockRejectedValue(
|
||||
new Error('Method "setDataMask" is not defined'),
|
||||
);
|
||||
|
||||
const dashboard = await embedDashboard({
|
||||
id: "test-id",
|
||||
supersetDomain: "https://superset.example.com",
|
||||
mountPoint,
|
||||
fetchGuestToken: mockFetchGuestToken,
|
||||
});
|
||||
|
||||
await expect(dashboard.setDataMask({})).rejects.toThrow(
|
||||
'Method "setDataMask" is not defined',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -115,12 +115,6 @@ export type EmbeddedDashboard = {
|
||||
getActiveTabs: () => Promise<string[]>;
|
||||
observeDataMask: (callbackFn: ObserveDataMaskCallbackFn) => void;
|
||||
getDataMask: () => Promise<Record<string, any>>;
|
||||
/**
|
||||
* Applies a data mask to the dashboard.
|
||||
* Rejects if the embedded Superset page does not support `setDataMask`,
|
||||
* so a version mismatch surfaces instead of silently doing nothing.
|
||||
*/
|
||||
setDataMask: (dataMask: Record<string, any>) => Promise<void>;
|
||||
getChartStates: () => Promise<Record<string, any>>;
|
||||
getChartDataPayloads: (params?: {
|
||||
chartId?: number;
|
||||
@@ -361,21 +355,6 @@ export async function embedDashboard({
|
||||
ourPort.get<string>("getDashboardPermalink", { anchor });
|
||||
const getActiveTabs = () => ourPort.get<string[]>("getActiveTabs");
|
||||
const getDataMask = () => ourPort.get<Record<string, any>>("getDataMask");
|
||||
// `observeDataMask` hands the host a mask with the change-trigger booleans
|
||||
// mixed in, so feeding that payload straight back into `setDataMask` is a
|
||||
// natural thing for a host to do. Keep only the entries that look like a
|
||||
// filter's mask, so those flags never reach the dashboard as filter ids.
|
||||
// Sent with `get` rather than `emit` so the iframe acknowledges the call:
|
||||
// an embedded page that predates `setDataMask` replies with an error instead
|
||||
// of dropping the message silently.
|
||||
const setDataMask = (dataMask: Record<string, any>) =>
|
||||
ourPort.get<void>("setDataMask", {
|
||||
dataMask: Object.fromEntries(
|
||||
Object.entries(dataMask).filter(
|
||||
([, mask]) => typeof mask === "object" && mask !== null,
|
||||
),
|
||||
),
|
||||
});
|
||||
const getChartStates = () =>
|
||||
ourPort.get<Record<string, any>>("getChartStates");
|
||||
const getChartDataPayloads = (params?: { chartId?: number }) =>
|
||||
@@ -417,7 +396,6 @@ export async function embedDashboard({
|
||||
getActiveTabs,
|
||||
observeDataMask,
|
||||
getDataMask,
|
||||
setDataMask,
|
||||
getChartStates,
|
||||
getChartDataPayloads,
|
||||
setThemeConfig,
|
||||
|
||||
Generated
+4
-4
@@ -33,7 +33,7 @@
|
||||
"@fontsource/fira-code": "^5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@googleapis/sheets": "^14.0.0",
|
||||
"@googleapis/sheets": "^13.0.2",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
@@ -4142,9 +4142,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@googleapis/sheets": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-14.0.0.tgz",
|
||||
"integrity": "sha512-fANEl4RQohsPYUWhcLSYyUyE8A8bRfvw/bp8h0t8VDQqTgdQ3itZBkty4nddtdqCAvNDpA+KM66OejVKDd6aFg==",
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@googleapis/sheets/-/sheets-13.0.2.tgz",
|
||||
"integrity": "sha512-b1tBlMcfvNEziM4DZCikLOc9iqSlgCK1e5bMKtNQIADRXr1CQmbkHV3ZBVvTsFsjLErgihqO58Itn/kzCnSZ0A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"googleapis-common": "^8.0.0"
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
"@fontsource/fira-code": "^5.3.0",
|
||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@googleapis/sheets": "^14.0.0",
|
||||
"@googleapis/sheets": "^13.0.2",
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
|
||||
@@ -47,10 +47,6 @@ export default defineConfig({
|
||||
// Retry logic - 2 retries in CI, 0 locally
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Disable capturing Git commit info as the project's history is increasingly dense
|
||||
// and breach Playwright's default 3-seconds `git` command timeout limit
|
||||
captureGitInfo: { commit: false, diff: false },
|
||||
|
||||
// Reporter configuration - multiple reporters for better visibility
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* 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 { SqlaFormData } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import { EchartsBubbleChartProps } from './types';
|
||||
|
||||
const baseFormData: SqlaFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'bubble_v2',
|
||||
entity: 'customer_name',
|
||||
x: 'price',
|
||||
y: 'sales',
|
||||
size: 'count',
|
||||
};
|
||||
|
||||
const baseChartProps = {
|
||||
width: 400,
|
||||
height: 400,
|
||||
hooks: {},
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ customer_name: 'A', price: 10, sales: 100, count: 5 },
|
||||
{ customer_name: 'B', price: 20, sales: 200, count: 8 },
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: { colorText: '#000' },
|
||||
};
|
||||
|
||||
test('nests xAxisLabelInterval under axisLabel rather than the axis itself', () => {
|
||||
// Regression test: xAxis.interval forces echarts' IntervalScale into a
|
||||
// fixed-tick-spacing mode that expects a number and crashes on the
|
||||
// 'auto'/'0' strings this control actually produces (observed as an
|
||||
// uncaught assertion deep in echarts' axis "nice" tick calculation,
|
||||
// reproducing on every dashboard bubble chart). The interval belongs on
|
||||
// axisLabel, where it only controls how many labels are skipped.
|
||||
const { echartOptions } = transformProps({
|
||||
...baseChartProps,
|
||||
formData: baseFormData,
|
||||
} as unknown as EchartsBubbleChartProps);
|
||||
|
||||
expect((echartOptions.xAxis as any).interval).toBeUndefined();
|
||||
expect((echartOptions.xAxis as any).axisLabel.interval).toBe('auto');
|
||||
});
|
||||
|
||||
test('honors an explicit xAxisLabelInterval override', () => {
|
||||
const { echartOptions } = transformProps({
|
||||
...baseChartProps,
|
||||
formData: { ...baseFormData, xAxisLabelInterval: '0' },
|
||||
} as unknown as EchartsBubbleChartProps);
|
||||
|
||||
expect((echartOptions.xAxis as any).axisLabel.interval).toBe('0');
|
||||
});
|
||||
@@ -212,16 +212,13 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
series,
|
||||
xAxis: {
|
||||
axisLabel: {
|
||||
formatter: xAxisFormatter,
|
||||
rotate: xAxisLabelRotation,
|
||||
interval: xAxisLabelInterval,
|
||||
},
|
||||
axisLabel: { formatter: xAxisFormatter, rotate: xAxisLabelRotation },
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
},
|
||||
},
|
||||
interval: xAxisLabelInterval,
|
||||
scale: true,
|
||||
name: bubbleXAxisTitle,
|
||||
nameLocation: 'middle',
|
||||
|
||||
@@ -35,43 +35,6 @@ if (SERVICE_ACCOUNT_KEY.client_email) {
|
||||
|
||||
const DATETIME = new Date().toISOString().replace(/T/, ' ').replace(/\..+/, '');
|
||||
|
||||
/**
|
||||
* Turn an oxlint diagnostic code into the canonical rule id used by the metrics
|
||||
* series.
|
||||
*
|
||||
* oxlint reports `<plugin>(<rule>)`, where the plugin is the linter the rule came
|
||||
* from: `eslint(no-console)`, `react-hooks(exhaustive-deps)`, `react(jsx-key)`,
|
||||
* `jest(no-conditional-expect)`, `oxc(erasing-op)`, and the legacy
|
||||
* `eslint-plugin-unicorn(no-new-array)` spelling.
|
||||
*
|
||||
* `eslint` is the implicit namespace, so its rules keep their bare name and stay
|
||||
* comparable with the rows recorded before the oxlint migration. Every other
|
||||
* plugin becomes `<plugin>/<rule>`, which is the id those rules are known by in
|
||||
* config and in the pre-migration history.
|
||||
*
|
||||
* @param {string | undefined} code the diagnostic's `code` field
|
||||
* @returns {string} the rule id to record
|
||||
*/
|
||||
function parseRuleId(code) {
|
||||
if (!code) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const match = code.match(/^([\w-]+)\(([^)]+)\)$/);
|
||||
if (!match) {
|
||||
return code;
|
||||
}
|
||||
|
||||
const [, namespace, rule] = match;
|
||||
if (namespace === 'eslint') {
|
||||
return rule;
|
||||
}
|
||||
|
||||
// `eslint-plugin-unicorn(...)` is the same rule as `unicorn/...`
|
||||
const plugin = namespace.replace(/^eslint-plugin-/, '');
|
||||
return `${plugin}/${rule}`;
|
||||
}
|
||||
|
||||
async function writeToGoogleSheet(data, range, headers, append = false) {
|
||||
if (!sheets) {
|
||||
console.log('No Google Sheets credentials, skipping upload');
|
||||
@@ -138,7 +101,17 @@ async function runOxlintAndProcess() {
|
||||
// OXC JSON format has diagnostics array
|
||||
if (results.diagnostics && Array.isArray(results.diagnostics)) {
|
||||
results.diagnostics.forEach(diagnostic => {
|
||||
const ruleId = parseRuleId(diagnostic.code);
|
||||
// Extract rule ID from code like "eslint(no-unused-vars)" or "eslint-plugin-unicorn(no-new-array)"
|
||||
const codeMatch = diagnostic.code?.match(
|
||||
/^(?:eslint(?:-plugin-(\w+))?\()([^)]+)\)$/,
|
||||
);
|
||||
let ruleId = diagnostic.code || 'unknown';
|
||||
|
||||
if (codeMatch) {
|
||||
const plugin = codeMatch[1];
|
||||
const rule = codeMatch[2];
|
||||
ruleId = plugin ? `${plugin}/${rule}` : rule;
|
||||
}
|
||||
|
||||
const file = diagnostic.filename || 'unknown';
|
||||
const line = diagnostic.labels?.[0]?.span?.line || 0;
|
||||
@@ -278,10 +251,5 @@ async function runOxlintAndProcess() {
|
||||
}
|
||||
}
|
||||
|
||||
// Run the process, unless this file was imported (e.g. by a test) rather than
|
||||
// executed, in which case nothing should be linted or uploaded on import.
|
||||
if (require.main === module) {
|
||||
runOxlintAndProcess().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = { parseRuleId };
|
||||
// Run the process
|
||||
runOxlintAndProcess().catch(console.error);
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const {
|
||||
entrypointSizeByExt,
|
||||
main,
|
||||
} = require('../../scripts/bundle-size-summary');
|
||||
|
||||
function mockStats(entrypoints) {
|
||||
jest
|
||||
.spyOn(fs, 'readFileSync')
|
||||
.mockReturnValue(JSON.stringify({ entrypoints }));
|
||||
}
|
||||
|
||||
function mockExit() {
|
||||
return jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
}
|
||||
|
||||
const originalArgv = process.argv;
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
process.argv = originalArgv;
|
||||
});
|
||||
|
||||
test('entrypointSizeByExt sums only assets matching the given extension', () => {
|
||||
const entrypoint = {
|
||||
assets: [
|
||||
{ name: 'spa.entry.js', size: 100 },
|
||||
{ name: 'spa.entry.js.map', size: 500 },
|
||||
{ name: 'spa.entry.css', size: 20 },
|
||||
],
|
||||
};
|
||||
expect(entrypointSizeByExt(entrypoint, '.js')).toBe(100);
|
||||
expect(entrypointSizeByExt(entrypoint, '.css')).toBe(20);
|
||||
});
|
||||
|
||||
test('entrypointSizeByExt returns 0 when the entrypoint has no assets', () => {
|
||||
expect(entrypointSizeByExt({}, '.js')).toBe(0);
|
||||
});
|
||||
|
||||
test('main prints byte totals for every tracked entrypoint', () => {
|
||||
mockStats({
|
||||
spa: { assets: [{ name: 'spa.js', size: 100 }] },
|
||||
embedded: { assets: [{ name: 'embedded.js', size: 50 }] },
|
||||
});
|
||||
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
|
||||
|
||||
main();
|
||||
|
||||
const printed = JSON.parse(logSpy.mock.calls[0][0]);
|
||||
expect(printed).toEqual([
|
||||
{ name: 'spa entrypoint (JS)', unit: 'bytes', value: 100 },
|
||||
{ name: 'spa entrypoint (CSS)', unit: 'bytes', value: 0 },
|
||||
{ name: 'embedded entrypoint (JS)', unit: 'bytes', value: 50 },
|
||||
{ name: 'embedded entrypoint (CSS)', unit: 'bytes', value: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('main exits with an error when a tracked entrypoint is missing from stats.json', () => {
|
||||
mockStats({ spa: { assets: [] } });
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockExit();
|
||||
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
|
||||
|
||||
expect(main).toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('missing the "embedded" entrypoint'),
|
||||
);
|
||||
});
|
||||
|
||||
test('main exits with an error when stats.json has no `entrypoints` key', () => {
|
||||
jest.spyOn(fs, 'readFileSync').mockReturnValue(JSON.stringify({}));
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockExit();
|
||||
process.argv = ['node', 'bundle-size-summary.js', 'stats.json'];
|
||||
|
||||
expect(main).toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('no `entrypoints` key'),
|
||||
);
|
||||
});
|
||||
|
||||
test('main prints a usage message and exits when no stats path is given', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mockExit();
|
||||
process.argv = ['node', 'bundle-size-summary.js'];
|
||||
|
||||
expect(main).toThrow('process.exit called');
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Usage:'));
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const { parseRuleId } = require('../../scripts/oxlint-metrics-uploader');
|
||||
|
||||
test('eslint rules keep their bare name', () => {
|
||||
expect(parseRuleId('eslint(no-console)')).toBe('no-console');
|
||||
expect(parseRuleId('eslint(prefer-destructuring)')).toBe(
|
||||
'prefer-destructuring',
|
||||
);
|
||||
});
|
||||
|
||||
test('plugin rules are recorded under plugin/rule (#42981)', () => {
|
||||
// These are the codes oxlint actually emits. They previously fell through to
|
||||
// the raw `react-hooks(exhaustive-deps)` string, so the rows no longer lined
|
||||
// up with the ids the same rules were recorded under before the migration.
|
||||
expect(parseRuleId('react-hooks(exhaustive-deps)')).toBe(
|
||||
'react-hooks/exhaustive-deps',
|
||||
);
|
||||
expect(parseRuleId('react-hooks(rules-of-hooks)')).toBe(
|
||||
'react-hooks/rules-of-hooks',
|
||||
);
|
||||
expect(parseRuleId('react(jsx-key)')).toBe('react/jsx-key');
|
||||
expect(parseRuleId('jest(no-conditional-expect)')).toBe(
|
||||
'jest/no-conditional-expect',
|
||||
);
|
||||
expect(parseRuleId('oxc(erasing-op)')).toBe('oxc/erasing-op');
|
||||
expect(parseRuleId('typescript(no-explicit-any)')).toBe(
|
||||
'typescript/no-explicit-any',
|
||||
);
|
||||
});
|
||||
|
||||
test('the legacy eslint-plugin- prefix still collapses to the plugin name', () => {
|
||||
expect(parseRuleId('eslint-plugin-unicorn(no-new-array)')).toBe(
|
||||
'unicorn/no-new-array',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unrecognized or missing code is passed through rather than dropped', () => {
|
||||
expect(parseRuleId('something-unparseable')).toBe('something-unparseable');
|
||||
expect(parseRuleId(undefined)).toBe('unknown');
|
||||
expect(parseRuleId('')).toBe('unknown');
|
||||
});
|
||||
@@ -695,49 +695,6 @@ describe('sqlLabReducer', () => {
|
||||
);
|
||||
expect(newState.queries['sync-query'].state).toBe(QueryState.Fetching);
|
||||
});
|
||||
test('should move an async query from running to success when polling reports it finished', () => {
|
||||
const asyncQuery = {
|
||||
...query,
|
||||
id: 'async-query',
|
||||
state: QueryState.Running,
|
||||
runAsync: true,
|
||||
};
|
||||
newState = sqlLabReducer(
|
||||
{
|
||||
...newState,
|
||||
queries: { 'async-query': asyncQuery },
|
||||
},
|
||||
actions.refreshQueries({
|
||||
'async-query': {
|
||||
...asyncQuery,
|
||||
state: QueryState.Success,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(newState.queries['async-query'].state).toBe(QueryState.Success);
|
||||
});
|
||||
test('should downgrade a premature poller success to fetching for a running sync query', () => {
|
||||
const syncQuery = {
|
||||
...query,
|
||||
id: 'sync-running',
|
||||
state: QueryState.Running,
|
||||
runAsync: false,
|
||||
results: null,
|
||||
};
|
||||
newState = sqlLabReducer(
|
||||
{
|
||||
...newState,
|
||||
queries: { 'sync-running': syncQuery },
|
||||
},
|
||||
actions.refreshQueries({
|
||||
'sync-running': {
|
||||
...syncQuery,
|
||||
state: QueryState.Success,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(newState.queries['sync-running'].state).toBe(QueryState.Fetching);
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('CLEAR_INACTIVE_QUERIES', () => {
|
||||
|
||||
@@ -769,15 +769,14 @@ export default function sqlLabReducer(
|
||||
}),
|
||||
// race condition:
|
||||
// because of async behavior, sql lab may still poll a couple of seconds
|
||||
// after it started fetching or finished rendering results. Guard only
|
||||
// against re-applying a redundant Success onto a state that's already at
|
||||
// or past Success (Fetching/Success) — Running is strictly before
|
||||
// Success, so an incoming Success there is new information, not a stale
|
||||
// poll, and must be allowed through (otherwise an async query can never
|
||||
// leave Running once observed there).
|
||||
// when it started fetching or finished rendering results
|
||||
state:
|
||||
currentState === QueryState.Success &&
|
||||
[QueryState.Fetching, QueryState.Success].includes(prevState)
|
||||
[
|
||||
QueryState.Fetching,
|
||||
QueryState.Success,
|
||||
QueryState.Running,
|
||||
].includes(prevState)
|
||||
? prevState
|
||||
: currentState,
|
||||
};
|
||||
|
||||
@@ -528,57 +528,6 @@ describe('PropertiesModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves certification fields on save without opening Certification section', async () => {
|
||||
// Accordion Collapse only mounts the active panel, so certifiedBy /
|
||||
// certificationDetails FormItems are unregistered until Certification is
|
||||
// opened. onFinish must use getFieldsValue(true) to read store values for
|
||||
// unregistered fields; otherwise save clears certified_by to null.
|
||||
const put = jest.spyOn(SupersetCore.SupersetClient, 'put');
|
||||
put.mockResolvedValue({
|
||||
json: {
|
||||
result: {
|
||||
dashboard_title: 'dashboard_title',
|
||||
slug: 'slug',
|
||||
json_metadata: 'json_metadata',
|
||||
editors: 'editors',
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
mockedIsFeatureEnabled.mockReturnValue(false);
|
||||
const props = createProps();
|
||||
const propsWithDashboardInfo = {
|
||||
...props,
|
||||
dashboardInfo: {
|
||||
...dashboardInfo,
|
||||
json_metadata: mockedJsonMetadata,
|
||||
},
|
||||
};
|
||||
render(<PropertiesModal {...propsWithDashboardInfo} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
await screen.findByTestId('dashboard-edit-properties-form');
|
||||
|
||||
// Only General information fields are mounted; Certification inputs are not.
|
||||
expect(screen.getAllByRole('textbox')).toHaveLength(3);
|
||||
expect(screen.queryByText('Certified by')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const submitCall = props.onSubmit.mock.calls[0][0];
|
||||
expect(submitCall.certifiedBy).toBe('John Doe');
|
||||
expect(submitCall.certificationDetails).toBe('Sample certification');
|
||||
|
||||
expect(put).toHaveBeenCalled();
|
||||
const putRequest = put.mock.calls[0][0];
|
||||
expect(typeof putRequest.body).toBe('string');
|
||||
const putBody = JSON.parse(putRequest.body as string);
|
||||
expect(putBody.certified_by).toBe('John Doe');
|
||||
expect(putBody.certification_details).toBe('Sample certification');
|
||||
});
|
||||
|
||||
test('submitting with onlyApply:true', async () => {
|
||||
mockedIsFeatureEnabled.mockReturnValue(false);
|
||||
const props = createProps();
|
||||
|
||||
@@ -307,7 +307,7 @@ const PropertiesModal = ({
|
||||
slug,
|
||||
certifiedBy,
|
||||
certificationDetails,
|
||||
} = form.getFieldsValue(true);
|
||||
} = form.getFieldsValue();
|
||||
let currentJsonMetadata = jsonMetadata;
|
||||
|
||||
// validate currentJsonMetadata
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* 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 { DataMaskStateWithId } from '@superset-ui/core';
|
||||
|
||||
// Mock factories must build their own jest.fn()s: jest.mock calls are hoisted
|
||||
// above this file's declarations, so a factory closing over a const would read
|
||||
// it before initialization.
|
||||
jest.mock('@apache-superset/core/utils', () => ({
|
||||
logging: { debug: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('../views/store', () => ({
|
||||
store: { dispatch: jest.fn(), getState: jest.fn(), subscribe: jest.fn() },
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import { embeddedApi } from './api';
|
||||
// eslint-disable-next-line import/first
|
||||
import { updateDataMask } from '../dataMask/actions';
|
||||
|
||||
const { logging: mockLogging } = jest.requireMock(
|
||||
'@apache-superset/core/utils',
|
||||
);
|
||||
const { store: mockStore } = jest.requireMock('../views/store');
|
||||
const mockDispatch = mockStore.dispatch;
|
||||
const mockGetState = mockStore.getState;
|
||||
|
||||
const nativeFilterMask = { filterState: { value: ['CA'] } };
|
||||
const crossFilterMask = { filterState: { value: [2024] } };
|
||||
|
||||
// `dashboardInfo.id` is only set once HYDRATE_DASHBOARD lands, so it doubles as
|
||||
// the "dashboard is hydrated" signal setDataMask waits for.
|
||||
function stateWithFilters(filterIds: string[]) {
|
||||
return {
|
||||
dashboardInfo: { id: 1 },
|
||||
dataMask: Object.fromEntries(filterIds.map(id => [id, { id }])),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('setDataMask dispatches an update for each known filter', () => {
|
||||
mockGetState.mockReturnValue(
|
||||
stateWithFilters(['NATIVE_FILTER-1', 'NATIVE_FILTER-2']),
|
||||
);
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
'NATIVE_FILTER-2': crossFilterMask,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
|
||||
);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-2', crossFilterMask),
|
||||
);
|
||||
expect(mockLogging.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('setDataMask ignores filter ids the dashboard does not know', () => {
|
||||
mockGetState.mockReturnValue(stateWithFilters(['NATIVE_FILTER-1']));
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
'NATIVE_FILTER-from-another-dashboard': crossFilterMask,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
|
||||
);
|
||||
expect(mockLogging.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('unknown filter ids'),
|
||||
'NATIVE_FILTER-from-another-dashboard',
|
||||
);
|
||||
});
|
||||
|
||||
test('setDataMask ignores the change-trigger flags observeDataMask emits', () => {
|
||||
mockGetState.mockReturnValue(stateWithFilters(['NATIVE_FILTER-1']));
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
crossFiltersChanged: false,
|
||||
nativeFiltersChanged: true,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
|
||||
);
|
||||
});
|
||||
|
||||
test('setDataMask dispatches nothing when no filter id is known', () => {
|
||||
mockGetState.mockReturnValue(stateWithFilters([]));
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockLogging.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('setDataMask queues the mask until the dashboard hydrates', () => {
|
||||
let notifyStoreSubscribers = () => {};
|
||||
mockStore.subscribe.mockImplementation((listener: () => void) => {
|
||||
notifyStoreSubscribers = listener;
|
||||
return jest.fn();
|
||||
});
|
||||
mockGetState.mockReturnValue({ dataMask: {} });
|
||||
|
||||
embeddedApi.setDataMask({
|
||||
dataMask: {
|
||||
'NATIVE_FILTER-1': nativeFilterMask,
|
||||
} as unknown as DataMaskStateWithId,
|
||||
});
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockLogging.warn).not.toHaveBeenCalled();
|
||||
|
||||
mockGetState.mockReturnValue(stateWithFilters(['NATIVE_FILTER-1']));
|
||||
notifyStoreSubscribers();
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
updateDataMask('NATIVE_FILTER-1', nativeFilterMask),
|
||||
);
|
||||
});
|
||||
@@ -17,15 +17,12 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { DataMaskStateWithId, JsonObject } from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
import { batch } from 'react-redux';
|
||||
import { store } from '../views/store';
|
||||
import { getDashboardPermalink as getDashboardPermalinkUtil } from '../utils/urlUtils';
|
||||
import { DashboardChartStates } from '../dashboard/types/chartState';
|
||||
import { hasStatefulCharts } from '../dashboard/util/chartStateConverter';
|
||||
import { getChartDataPayloads as getChartDataPayloadsUtil } from './utils';
|
||||
import { updateDataMask } from '../dataMask/actions';
|
||||
|
||||
const bootstrapData = getBootstrapData();
|
||||
|
||||
@@ -43,7 +40,6 @@ type EmbeddedSupersetApi = {
|
||||
getChartDataPayloads: (params?: {
|
||||
chartId?: number;
|
||||
}) => Promise<Record<string, JsonObject>>;
|
||||
setDataMask: ({ dataMask }: { dataMask: DataMaskStateWithId }) => void;
|
||||
};
|
||||
|
||||
const getScrollSize = (): Size => ({
|
||||
@@ -87,58 +83,6 @@ const getActiveTabs = () => store?.getState()?.dashboardState?.activeTabs || [];
|
||||
|
||||
const getDataMask = () => store?.getState()?.dataMask || {};
|
||||
|
||||
const isDashboardHydrated = () => Boolean(store?.getState()?.dashboardInfo?.id);
|
||||
|
||||
const applyDataMask = (dataMask: DataMaskStateWithId) => {
|
||||
// The dashboard's own data mask holds an entry for every native filter and
|
||||
// every cross-filter-capable chart, so it doubles as the set of filter ids
|
||||
// this dashboard can accept. Anything else — a filter id from a different
|
||||
// dashboard, or the change-trigger flags that `observeDataMask` emits
|
||||
// alongside the mask — would otherwise be inserted as a bogus filter and
|
||||
// treated as a globally scoped filter by the active-filter derivation.
|
||||
const knownFilterIds = new Set(Object.keys(getDataMask()));
|
||||
const entries = Object.entries(dataMask);
|
||||
const applicable = entries.filter(([id]) => knownFilterIds.has(id));
|
||||
const ignored = entries.filter(([id]) => !knownFilterIds.has(id));
|
||||
|
||||
if (ignored.length) {
|
||||
logging.warn(
|
||||
'[superset] setDataMask ignored unknown filter ids:',
|
||||
ignored.map(([id]) => id).join(', '),
|
||||
);
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
applicable.forEach(([filterId, mask]) => {
|
||||
store?.dispatch(updateDataMask(filterId, mask));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// A mask requested before the dashboard hydrates cannot be applied yet: the
|
||||
// store holds no filter entries to validate the ids against, and hydration
|
||||
// would replace anything dispatched in the meantime. Hold the request and
|
||||
// replay it once hydration lands.
|
||||
let queuedDataMask: DataMaskStateWithId | undefined;
|
||||
let unsubscribeFromHydration: (() => void) | undefined;
|
||||
|
||||
const setDataMask = ({ dataMask }: { dataMask: DataMaskStateWithId }) => {
|
||||
if (isDashboardHydrated()) {
|
||||
applyDataMask(dataMask);
|
||||
return;
|
||||
}
|
||||
|
||||
queuedDataMask = { ...queuedDataMask, ...dataMask };
|
||||
unsubscribeFromHydration ??= store?.subscribe(() => {
|
||||
if (!isDashboardHydrated()) return;
|
||||
unsubscribeFromHydration?.();
|
||||
unsubscribeFromHydration = undefined;
|
||||
const pending = queuedDataMask;
|
||||
queuedDataMask = undefined;
|
||||
if (pending) applyDataMask(pending);
|
||||
});
|
||||
};
|
||||
|
||||
const getChartStates = () =>
|
||||
store?.getState()?.dashboardState?.chartStates || {};
|
||||
|
||||
@@ -158,5 +102,4 @@ export const embeddedApi: EmbeddedSupersetApi = {
|
||||
getDataMask,
|
||||
getChartStates,
|
||||
getChartDataPayloads,
|
||||
setDataMask,
|
||||
};
|
||||
|
||||
@@ -298,7 +298,6 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
|
||||
Switchboard.defineMethod('getActiveTabs', embeddedApi.getActiveTabs);
|
||||
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
|
||||
Switchboard.defineMethod('getChartStates', embeddedApi.getChartStates);
|
||||
Switchboard.defineMethod('setDataMask', embeddedApi.setDataMask);
|
||||
Switchboard.defineMethod(
|
||||
'getChartDataPayloads',
|
||||
embeddedApi.getChartDataPayloads,
|
||||
|
||||
+1
-10
@@ -43,10 +43,6 @@ import path from 'path';
|
||||
|
||||
const chartEndpoint = 'glob:*api/v1/chart/*';
|
||||
|
||||
const EDIT_PROPERTIES_INITIAL_STATE = {
|
||||
explore: { can_overwrite: true, can_add: true },
|
||||
};
|
||||
|
||||
fetchMock.get(chartEndpoint, { json: 'foo' });
|
||||
|
||||
window.featureFlags = {
|
||||
@@ -174,10 +170,7 @@ describe('ExploreChartHeader', () => {
|
||||
|
||||
test('Cancelling changes to the properties should reset previous properties', async () => {
|
||||
const props = createProps();
|
||||
render(<ExploreHeader {...props} />, {
|
||||
useRedux: true,
|
||||
initialState: EDIT_PROPERTIES_INITIAL_STATE,
|
||||
});
|
||||
render(<ExploreHeader {...props} />, { useRedux: true });
|
||||
const newChartName = 'New chart name';
|
||||
const prevChartName = props.sliceName;
|
||||
|
||||
@@ -633,7 +626,6 @@ describe('Additional actions tests', () => {
|
||||
const props = createProps();
|
||||
render(<ExploreHeader {...props} />, {
|
||||
useRedux: true,
|
||||
initialState: EDIT_PROPERTIES_INITIAL_STATE,
|
||||
});
|
||||
|
||||
userEvent.click(screen.getByLabelText('Menu actions trigger'));
|
||||
@@ -728,7 +720,6 @@ describe('Additional actions tests', () => {
|
||||
const props = createProps();
|
||||
render(<ExploreHeader {...props} />, {
|
||||
useRedux: true,
|
||||
initialState: EDIT_PROPERTIES_INITIAL_STATE,
|
||||
});
|
||||
expect(props.actions.redirectSQLLab).toHaveBeenCalledTimes(0);
|
||||
userEvent.click(screen.getByLabelText('Menu actions trigger'));
|
||||
|
||||
@@ -279,7 +279,6 @@ interface ExploreState {
|
||||
chartStates?: Record<number, JsonObject>;
|
||||
can_export_image?: boolean;
|
||||
can_overwrite?: boolean;
|
||||
can_add?: boolean;
|
||||
};
|
||||
common?: {
|
||||
conf?: {
|
||||
@@ -336,30 +335,17 @@ export const useExploreAdditionalActionsMenu = (
|
||||
const canOverwrite = useSelector<ExploreState, boolean>(
|
||||
state => state.explore?.can_overwrite ?? false,
|
||||
);
|
||||
// Mirrors the `can_write` permission on the `Chart` view, the same
|
||||
// permission `ChartRestApi.put` (and `restore_version`) require. An editor
|
||||
// who satisfies `canOverwriteSlice` but lacks it would still be turned away
|
||||
// by the API, so the properties editor stays hidden for them too.
|
||||
const canWriteChart = useSelector<ExploreState, boolean>(
|
||||
state => state.explore?.can_add ?? false,
|
||||
);
|
||||
const user = useSelector<
|
||||
ExploreState,
|
||||
UserWithPermissionsAndRoles | undefined
|
||||
>(state => state.user);
|
||||
// `can_overwrite` alone hides version history (and edit-properties) on any
|
||||
// chart without explicit editors — every seeded chart — even from admins.
|
||||
// Same predicate SaveModal uses, so a user who can save a chart can also
|
||||
// see its history and edit its properties.
|
||||
// `can_overwrite` alone hides version history on any chart without explicit
|
||||
// editors — every seeded chart — even from admins. Same predicate SaveModal
|
||||
// uses, so a user who can save a chart can also see its history.
|
||||
const canModifySlice = useMemo(
|
||||
() => canOverwriteSlice({ slice, user, canOverwrite }),
|
||||
[slice, user, canOverwrite],
|
||||
);
|
||||
// `canModifySlice` alone governs version history, whose own read-only
|
||||
// listing needs no write permission (only its restore action does, and
|
||||
// that's gated server-side). Editing properties, however, always PUTs the
|
||||
// chart, so it additionally needs the write permission above.
|
||||
const canEditProperties = canModifySlice && canWriteChart;
|
||||
|
||||
const dataExportDisabled = !canDownloadCSV;
|
||||
const imageExportDisabled = !canExportImage;
|
||||
@@ -615,7 +601,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
const menuItems = [];
|
||||
|
||||
// Edit chart properties
|
||||
if (slice && canEditProperties) {
|
||||
if (slice) {
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.EDIT_PROPERTIES,
|
||||
label: t('Edit chart properties'),
|
||||
@@ -1098,7 +1084,6 @@ export const useExploreAdditionalActionsMenu = (
|
||||
}, [
|
||||
addDangerToast,
|
||||
canDownloadCSV,
|
||||
canEditProperties,
|
||||
canModifySlice,
|
||||
copyLink,
|
||||
dashboards,
|
||||
|
||||
+1
-68
@@ -27,7 +27,6 @@ import {
|
||||
getExportScreenshotMenuItems,
|
||||
} from './index';
|
||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||
import { Slice } from 'src/types/Chart';
|
||||
|
||||
jest.mock('src/explore/exploreUtils', () => ({
|
||||
__esModule: true,
|
||||
@@ -75,22 +74,13 @@ jest.mock('@superset-ui/core', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('src/utils/getBootstrapData', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
common: {
|
||||
user_subjects: [1],
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
latestQueryFormData: {
|
||||
datasource: '1__table',
|
||||
viz_type: 'pivot_table_v2',
|
||||
},
|
||||
canDownloadCSV: true,
|
||||
slice: { slice_id: 1, slice_name: 'Test Chart' } as unknown as Slice,
|
||||
slice: { slice_id: 1, slice_name: 'Test Chart' },
|
||||
ownState: {},
|
||||
dashboards: [],
|
||||
onOpenInEditor: jest.fn(),
|
||||
@@ -123,63 +113,6 @@ beforeEach(() => {
|
||||
mockExportChart.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test('hides Edit chart properties from a user who is not an owner/editor of the chart (regression #38884)', async () => {
|
||||
render(
|
||||
<TestComponent
|
||||
{...defaultProps}
|
||||
slice={
|
||||
{
|
||||
slice_id: 1,
|
||||
slice_name: 'Test Chart',
|
||||
editors: [2],
|
||||
} as unknown as Slice
|
||||
}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Data Export Options')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Edit chart properties')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows Edit chart properties for a chart editor with chart write permission', async () => {
|
||||
render(
|
||||
<TestComponent
|
||||
{...defaultProps}
|
||||
slice={
|
||||
{
|
||||
slice_id: 1,
|
||||
slice_name: 'Test Chart',
|
||||
editors: [1],
|
||||
} as unknown as Slice
|
||||
}
|
||||
/>,
|
||||
{ useRedux: true, initialState: { explore: { can_add: true } } },
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Data Export Options')).toBeInTheDocument();
|
||||
expect(screen.getByText('Edit chart properties')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides Edit chart properties from a chart editor lacking chart write permission', async () => {
|
||||
render(
|
||||
<TestComponent
|
||||
{...defaultProps}
|
||||
slice={
|
||||
{
|
||||
slice_id: 1,
|
||||
slice_name: 'Test Chart',
|
||||
editors: [1],
|
||||
} as unknown as Slice
|
||||
}
|
||||
/>,
|
||||
{ useRedux: true, initialState: { explore: { can_add: false } } },
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Data Export Options')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Edit chart properties')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows 413 error toast when exportCSV fails with 413', async () => {
|
||||
mockExportChart.mockRejectedValue({ status: 413 });
|
||||
|
||||
|
||||
@@ -27,8 +27,15 @@ import {
|
||||
Input,
|
||||
Button,
|
||||
Modal,
|
||||
Select,
|
||||
} from '@superset-ui/core/components';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import {
|
||||
API_KEY_SCOPE_OPTIONS,
|
||||
getApiKeyScopesHelpText,
|
||||
serializeApiKeyScopes,
|
||||
} from './apiKeyScopes';
|
||||
|
||||
interface ApiKeyCreateModalProps {
|
||||
show: boolean;
|
||||
@@ -38,6 +45,7 @@ interface ApiKeyCreateModalProps {
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
scopes?: string[];
|
||||
}
|
||||
|
||||
export function ApiKeyCreateModal({
|
||||
@@ -62,9 +70,13 @@ export function ApiKeyCreateModal({
|
||||
|
||||
const handleFormSubmit = async (values: FormValues) => {
|
||||
try {
|
||||
const scopes = serializeApiKeyScopes(values.scopes);
|
||||
const response = await SupersetClient.post({
|
||||
endpoint: '/api/v1/security/api_keys/',
|
||||
jsonPayload: values,
|
||||
jsonPayload: {
|
||||
name: values.name,
|
||||
...(scopes && { scopes }),
|
||||
},
|
||||
});
|
||||
const key = response.json?.result?.key;
|
||||
if (!key) {
|
||||
@@ -83,7 +95,7 @@ export function ApiKeyCreateModal({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey);
|
||||
await copyTextToClipboard(() => Promise.resolve(createdKey));
|
||||
setCopied(true);
|
||||
if (copyTimerRef.current) {
|
||||
clearTimeout(copyTimerRef.current);
|
||||
@@ -170,6 +182,24 @@ export function ApiKeyCreateModal({
|
||||
placeholder={t('e.g., CI/CD Pipeline, Analytics Script')}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
name="scopes"
|
||||
label={t('MCP scopes')}
|
||||
help={getApiKeyScopesHelpText()}
|
||||
>
|
||||
<Select
|
||||
name="scopes"
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
options={API_KEY_SCOPE_OPTIONS}
|
||||
placeholder={t('Select MCP resource scopes (optional)')}
|
||||
data-test="api-key-scopes-select"
|
||||
getPopupContainer={(trigger: HTMLElement) =>
|
||||
trigger.closest<HTMLElement>('.ant-modal-container')
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,6 +162,19 @@ export function ApiKeyList() {
|
||||
key: 'status',
|
||||
render: (_: unknown, record: ApiKey) => getStatusBadge(record),
|
||||
},
|
||||
{
|
||||
title: t('MCP scopes'),
|
||||
dataIndex: 'scopes',
|
||||
key: 'scopes',
|
||||
render: (scopes: string | null) =>
|
||||
scopes ? (
|
||||
<Tooltip title={scopes}>
|
||||
<Tag>{t('%s MCP scopes', scopes.split(',').length)}</Tag>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tag>{t('RBAC only')}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
key: 'actions',
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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 {
|
||||
API_KEY_SCOPE_OPTIONS,
|
||||
getApiKeyScopesHelpText,
|
||||
serializeApiKeyScopes,
|
||||
} from './apiKeyScopes';
|
||||
|
||||
test('offers read and write scopes for every supported resource', () => {
|
||||
expect(API_KEY_SCOPE_OPTIONS).toHaveLength(32);
|
||||
expect(API_KEY_SCOPE_OPTIONS).toContainEqual({
|
||||
label: 'superset:dashboard:read',
|
||||
value: 'superset:dashboard:read',
|
||||
});
|
||||
expect(API_KEY_SCOPE_OPTIONS).toContainEqual({
|
||||
label: 'superset:sqllab:write',
|
||||
value: 'superset:sqllab:write',
|
||||
});
|
||||
});
|
||||
|
||||
test('serializes selected scopes for the FAB API', () => {
|
||||
expect(
|
||||
serializeApiKeyScopes(['superset:dashboard:read', 'superset:chart:write']),
|
||||
).toBe('superset:dashboard:read,superset:chart:write');
|
||||
expect(serializeApiKeyScopes([])).toBeUndefined();
|
||||
expect(serializeApiKeyScopes()).toBeUndefined();
|
||||
});
|
||||
|
||||
test('explains that scopes apply to MCP rather than REST APIs', () => {
|
||||
expect(getApiKeyScopesHelpText()).toContain('MCP resources');
|
||||
expect(getApiKeyScopesHelpText()).toContain(
|
||||
'do not restrict REST API requests',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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 { t } from '@apache-superset/core/translation';
|
||||
|
||||
const API_KEY_SCOPE_RESOURCES = [
|
||||
'annotation',
|
||||
'chart',
|
||||
'dashboard',
|
||||
'database',
|
||||
'dataset',
|
||||
'explore',
|
||||
'query',
|
||||
'report',
|
||||
'role',
|
||||
'rls',
|
||||
'savedquery',
|
||||
'sqllab',
|
||||
'tag',
|
||||
'task',
|
||||
'theme',
|
||||
'user',
|
||||
] as const;
|
||||
|
||||
const API_KEY_SCOPE_ACTIONS = ['read', 'write'] as const;
|
||||
|
||||
export const API_KEY_SCOPE_OPTIONS = API_KEY_SCOPE_RESOURCES.flatMap(resource =>
|
||||
API_KEY_SCOPE_ACTIONS.map(action => {
|
||||
const value = `superset:${resource}:${action}`;
|
||||
return { label: value, value };
|
||||
}),
|
||||
);
|
||||
|
||||
export const serializeApiKeyScopes = (scopes?: string[]) =>
|
||||
scopes?.length ? scopes.join(',') : undefined;
|
||||
|
||||
export const getApiKeyScopesHelpText = () =>
|
||||
t(
|
||||
'Limit which MCP resources and actions this key can access. These scopes do not restrict REST API requests and never grant permissions the user does not already have. Leave empty for legacy RBAC-only behavior.',
|
||||
);
|
||||
@@ -738,14 +738,4 @@ const smp = new SpeedMeasurePlugin({
|
||||
disable: !measure,
|
||||
});
|
||||
|
||||
// Emits per-asset/entrypoint sizes via `--json` (the default `stats: 'minimal'`
|
||||
// above omits both). Not `normal`/`detailed` stats: those also serialize the
|
||||
// full ~15k-module dependency graph, which is hundreds of MB for this app --
|
||||
// large enough to exceed Node's max string length when read back with
|
||||
// `fs.readFileSync`. Used by scripts/bundle-size-summary.js in CI.
|
||||
// e.g. BUNDLE_SIZE_STATS=true npm run build -- --json=stats.json
|
||||
if (process.env.BUNDLE_SIZE_STATS) {
|
||||
config.stats = { all: false, assets: true, entrypoints: true };
|
||||
}
|
||||
|
||||
module.exports = smp.wrap(config);
|
||||
|
||||
@@ -22,7 +22,7 @@ import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, TYPE_CHECKING
|
||||
|
||||
from flask import current_app as app, make_response, request, Response
|
||||
from flask import current_app as app, g, make_response, request, Response
|
||||
from flask_appbuilder.api import expose, protect
|
||||
from flask_babel import gettext as _
|
||||
from marshmallow import ValidationError
|
||||
@@ -37,7 +37,6 @@ from superset.charts.data.dashboard_filter_context import (
|
||||
DashboardFilterContext,
|
||||
get_dashboard_filter_context,
|
||||
)
|
||||
from superset.charts.data.form_data import set_form_data
|
||||
from superset.charts.data.query_context_cache_loader import QueryContextCacheLoader
|
||||
from superset.charts.schemas import ChartDataQueryContextSchema
|
||||
from superset.commands.chart.data.create_async_job_command import (
|
||||
@@ -215,7 +214,7 @@ class ChartDataRestApi(ChartRestApi):
|
||||
# templating pulls form data from the request globally, so this
|
||||
# fallback ensures it has the filters and extra_form_data applied
|
||||
# when used in get_sqla_query which constructs the final query.
|
||||
set_form_data(json_body)
|
||||
g.form_data = json_body
|
||||
|
||||
try:
|
||||
query_context = self._create_query_context_from_form(json_body)
|
||||
@@ -411,7 +410,7 @@ class ChartDataRestApi(ChartRestApi):
|
||||
cached_data = self._load_query_context_form_from_cache(cache_key)
|
||||
# Set form_data in Flask Global as it is used as a fallback
|
||||
# for async queries with jinja context
|
||||
set_form_data(cached_data)
|
||||
g.form_data = cached_data
|
||||
query_context = self._create_query_context_from_form(cached_data)
|
||||
command = ChartDataCommand(query_context)
|
||||
command.validate()
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
# 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 __future__ import annotations
|
||||
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from flask import g
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.common.query_context import QueryContext
|
||||
from superset.common.query_object import QueryObject
|
||||
|
||||
|
||||
def set_form_data(form_data: dict[str, Any]) -> None:
|
||||
"""Expose chart data request fields to Jinja template macros."""
|
||||
g.form_data = form_data
|
||||
|
||||
|
||||
def _serialize_query(
|
||||
query: QueryObject,
|
||||
form_data: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Serialize query fields consumed by the Jinja form-data fallback."""
|
||||
query_data = dict(query.to_dict())
|
||||
query_data["filters"] = query.filter
|
||||
if query.time_range is not None:
|
||||
query_data["time_range"] = query.time_range
|
||||
if url_params := form_data.get("url_params"):
|
||||
query_data["url_params"] = url_params
|
||||
return query_data
|
||||
|
||||
|
||||
def set_query_context_form_data(
|
||||
query_context: QueryContext,
|
||||
datasource_id: int,
|
||||
datasource_type: str,
|
||||
) -> None:
|
||||
"""Expose a programmatically-created query like a chart data API request."""
|
||||
form_data = query_context.form_data or {}
|
||||
set_form_data(
|
||||
{
|
||||
"datasource": {"id": datasource_id, "type": datasource_type},
|
||||
"queries": [
|
||||
_serialize_query(query, form_data) for query in query_context.queries
|
||||
],
|
||||
"form_data": form_data,
|
||||
}
|
||||
)
|
||||
@@ -21,7 +21,6 @@ from functools import partial
|
||||
from typing import cast
|
||||
from uuid import UUID
|
||||
|
||||
from superset import db
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.database.exceptions import DatabaseNotFoundError
|
||||
from superset.daos.database import DatabaseUserOAuth2TokensDAO
|
||||
@@ -97,11 +96,6 @@ class OAuth2StoreTokenCommand(BaseCommand):
|
||||
database_id=self._state["database_id"],
|
||||
):
|
||||
DatabaseUserOAuth2TokensDAO.delete([existing])
|
||||
# flush the delete before inserting the replacement -- the unit
|
||||
# of work otherwise emits INSERTs before DELETEs within a single
|
||||
# flush, which would trip the (user_id, database_id) unique
|
||||
# index below on the old row.
|
||||
db.session.flush()
|
||||
|
||||
# store tokens
|
||||
expiration = datetime.now() + timedelta(seconds=token_response["expires_in"])
|
||||
|
||||
@@ -318,39 +318,27 @@ def import_tag(
|
||||
|
||||
for tag_name in target_tag_names:
|
||||
try:
|
||||
# Isolate each tag operation in a SAVEPOINT so a failure (e.g. a
|
||||
# concurrent unique-constraint violation) rolls back only the failed
|
||||
# tag and leaves the session usable for the remaining tags, instead
|
||||
# of poisoning the session with a pending-rollback state.
|
||||
with db_session.begin_nested():
|
||||
tag = existing_tags.get(tag_name)
|
||||
tag = existing_tags.get(tag_name)
|
||||
|
||||
# If tag does not exist, create it
|
||||
if tag is None:
|
||||
description = tag_descriptions.get(tag_name, None)
|
||||
tag = Tag(name=tag_name, description=description, type="custom")
|
||||
db_session.add(tag)
|
||||
existing_tags[tag_name] = tag # Update the existing_tags dictionary
|
||||
# If tag does not exist, create it
|
||||
if tag is None:
|
||||
description = tag_descriptions.get(tag_name, None)
|
||||
tag = Tag(name=tag_name, description=description, type="custom")
|
||||
db_session.add(tag)
|
||||
existing_tags[tag_name] = tag # Update the existing_tags dictionary
|
||||
|
||||
# Ensure the association with the object
|
||||
tagged_object = (
|
||||
db_session.query(TaggedObject)
|
||||
.filter_by(
|
||||
object_id=object_id, object_type=object_type, tag_id=tag.id
|
||||
)
|
||||
.first()
|
||||
# Ensure the association with the object
|
||||
tagged_object = (
|
||||
db_session.query(TaggedObject)
|
||||
.filter_by(object_id=object_id, object_type=object_type, tag_id=tag.id)
|
||||
.first()
|
||||
)
|
||||
if not tagged_object:
|
||||
new_tagged_object = TaggedObject(
|
||||
tag_id=tag.id, object_id=object_id, object_type=object_type
|
||||
)
|
||||
if not tagged_object:
|
||||
new_tagged_object = TaggedObject(
|
||||
tag_id=tag.id, object_id=object_id, object_type=object_type
|
||||
)
|
||||
db_session.add(new_tagged_object)
|
||||
db_session.add(new_tagged_object)
|
||||
|
||||
# Only record the tag as imported once the SAVEPOINT has been
|
||||
# released (and its pending inserts flushed) without error; the
|
||||
# nested block's own flush can still fail on a concurrent
|
||||
# unique-constraint violation, in which case this line must not
|
||||
# run.
|
||||
new_tag_ids.append(tag.id)
|
||||
|
||||
except SQLAlchemyError as err:
|
||||
@@ -361,9 +349,7 @@ def import_tag(
|
||||
object_id,
|
||||
err,
|
||||
)
|
||||
# The SAVEPOINT was rolled back by begin_nested(); the session is
|
||||
# still usable for the remaining tags.
|
||||
continue
|
||||
continue # No need for manual rollback, handled by transaction decorator
|
||||
|
||||
# Remove old tags not in the new config
|
||||
for tag in existing_assocs:
|
||||
|
||||
@@ -21,7 +21,6 @@ from typing import Any, cast
|
||||
|
||||
from flask import current_app as app
|
||||
from flask_babel import gettext as __
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from superset import db, results_backend, results_backend_use_msgpack
|
||||
from superset.commands.base import BaseCommand
|
||||
@@ -99,15 +98,6 @@ class SqlExecutionResultsCommand(BaseCommand):
|
||||
),
|
||||
status=403,
|
||||
) from ex
|
||||
except TemplateError as ex:
|
||||
raise SupersetErrorException(
|
||||
SupersetError(
|
||||
message=str(ex),
|
||||
error_type=SupersetErrorType.GENERIC_COMMAND_ERROR,
|
||||
level=ErrorLevel.ERROR,
|
||||
),
|
||||
status=400,
|
||||
) from ex
|
||||
|
||||
# Now fetch results from backend (query exists, so this is a valid request)
|
||||
read_from_results_backend_start = now_as_float()
|
||||
|
||||
@@ -34,11 +34,11 @@ from superset.commands.dashboard.exceptions import (
|
||||
DashboardUpdateFailedError,
|
||||
)
|
||||
from superset.daos.base import BaseDAO, ColumnOperator, ColumnOperatorEnum
|
||||
from superset.dashboards.filters import DashboardAccessFilter
|
||||
from superset.dashboards.filters import DashboardAccessFilter, is_uuid
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.extensions import db
|
||||
from superset.models.core import FavStar, FavStarClassName
|
||||
from superset.models.dashboard import Dashboard, id_or_slug_filter, is_uuid
|
||||
from superset.models.dashboard import Dashboard, id_or_slug_filter
|
||||
from superset.models.embedded_dashboard import EmbeddedDashboard
|
||||
from superset.models.helpers import skip_visibility_filter
|
||||
from superset.models.slice import Slice
|
||||
|
||||
+101
-18
@@ -16,26 +16,26 @@
|
||||
# under the License.
|
||||
from typing import Any
|
||||
|
||||
from flask import current_app
|
||||
from flask import current_app, g
|
||||
from flask_babel import lazy_gettext as _
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm.query import Query
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset import db, is_feature_enabled, security_manager
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.models.core import Database
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.dashboard import Dashboard, is_uuid
|
||||
from superset.models.embedded_dashboard import EmbeddedDashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.security.guest_token import GuestTokenResourceType, GuestUser
|
||||
from superset.subjects.filters import (
|
||||
EditableFilter,
|
||||
subject_relation_exists_for_current_user,
|
||||
)
|
||||
from superset.subjects.models import dashboard_editors, dashboard_viewers
|
||||
from superset.tags.filters import BaseTagIdFilter, BaseTagNameFilter
|
||||
from superset.utils.core import get_user_id
|
||||
from superset.utils.filters import (
|
||||
get_dataset_access_filters,
|
||||
guest_embedded_dashboard_filter,
|
||||
)
|
||||
from superset.utils.filters import get_dataset_access_filters
|
||||
from superset.views.base import BaseFilter
|
||||
from superset.views.base_api import BaseFavoriteFilter
|
||||
from superset.views.filters import BaseDeletedRecencyFilter, BaseDeletedStateFilter
|
||||
@@ -110,20 +110,20 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
|
||||
"""
|
||||
List dashboards with the following criteria:
|
||||
|
||||
1. Embedded guests: only the dashboards in their token, nothing else
|
||||
2. Admins: all dashboards
|
||||
3. Editors: their dashboards (published or not)
|
||||
4. Viewers: published dashboards
|
||||
5. Dashboards with no viewers → fall back to dataset-based access
|
||||
(published only)
|
||||
When ``ENABLE_VIEWERS`` is on:
|
||||
1. Those where the user is an editor (published or not)
|
||||
2. Those where the user is a viewer (published only)
|
||||
3. Those with no viewers → fall back to dataset-based access (published only)
|
||||
4. Embedded dashboard access (preserved as-is)
|
||||
|
||||
When ``ENABLE_VIEWERS`` is off (legacy):
|
||||
1. Those which the user is an editor of
|
||||
2. Those which have been published (if they have access to at least one slice)
|
||||
|
||||
If the user is an admin then show all dashboards.
|
||||
"""
|
||||
|
||||
def apply(self, query: Query, value: Any) -> Query:
|
||||
# Guests are scoped to their token's dashboards only, never widened by
|
||||
# the role paths below (mirrors ChartFilter).
|
||||
if (guest_condition := guest_embedded_dashboard_filter()) is not None:
|
||||
return query.filter(guest_condition)
|
||||
|
||||
if security_manager.is_admin():
|
||||
return query
|
||||
|
||||
@@ -192,8 +192,91 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
|
||||
if user_id:
|
||||
filters.append(Dashboard.id.in_(extra_dashboards_filter(user_id)))
|
||||
|
||||
# (D) Embedded: preserved as-is
|
||||
if is_feature_enabled("EMBEDDED_SUPERSET") and security_manager.is_guest_user(
|
||||
g.user
|
||||
):
|
||||
guest_user: GuestUser = g.user
|
||||
embedded_dashboard_ids = [
|
||||
r["id"]
|
||||
for r in guest_user.resources
|
||||
if r["type"] == GuestTokenResourceType.DASHBOARD.value
|
||||
]
|
||||
condition = (
|
||||
Dashboard.embedded.any(
|
||||
EmbeddedDashboard.uuid.in_(embedded_dashboard_ids)
|
||||
)
|
||||
if any(is_uuid(id_) for id_ in embedded_dashboard_ids)
|
||||
else Dashboard.id.in_(embedded_dashboard_ids)
|
||||
)
|
||||
filters.append(condition)
|
||||
|
||||
return query.filter(or_(*filters)) if filters else query
|
||||
|
||||
def _apply_legacy(self, query: Query) -> Query:
|
||||
datasource_perm_query = (
|
||||
db.session.query(Dashboard.id)
|
||||
.join(Dashboard.slices, isouter=True)
|
||||
.join(SqlaTable, Slice.datasource_id == SqlaTable.id)
|
||||
.join(Database, SqlaTable.database_id == Database.id)
|
||||
.filter(
|
||||
and_(
|
||||
Dashboard.published.is_(True),
|
||||
get_dataset_access_filters(
|
||||
Slice,
|
||||
security_manager.can_access_all_datasources(),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# Editors query
|
||||
editor_ids_query = db.session.query(dashboard_editors.c.dashboard_id).filter(
|
||||
subject_relation_exists_for_current_user(dashboard_editors)
|
||||
)
|
||||
|
||||
feature_flagged_filters = []
|
||||
if is_feature_enabled("EMBEDDED_SUPERSET") and security_manager.is_guest_user(
|
||||
g.user
|
||||
):
|
||||
guest_user: GuestUser = g.user
|
||||
embedded_dashboard_ids = [
|
||||
r["id"]
|
||||
for r in guest_user.resources
|
||||
if r["type"] == GuestTokenResourceType.DASHBOARD.value
|
||||
]
|
||||
|
||||
# TODO (embedded): only use uuid filter once uuids are rolled out
|
||||
condition = (
|
||||
Dashboard.embedded.any(
|
||||
EmbeddedDashboard.uuid.in_(embedded_dashboard_ids)
|
||||
)
|
||||
if any(is_uuid(id_) for id_ in embedded_dashboard_ids)
|
||||
else Dashboard.id.in_(embedded_dashboard_ids)
|
||||
)
|
||||
|
||||
feature_flagged_filters.append(condition)
|
||||
|
||||
extra_access_filters = []
|
||||
extra_filters = current_app.config.get("EXTRA_ACCESS_QUERY_FILTERS", {})
|
||||
if extra_dashboards_filter := extra_filters.get("dashboards"):
|
||||
user_id = get_user_id()
|
||||
if user_id:
|
||||
extra_access_filters.append(
|
||||
Dashboard.id.in_(extra_dashboards_filter(user_id))
|
||||
)
|
||||
|
||||
query = query.filter(
|
||||
or_(
|
||||
Dashboard.id.in_(editor_ids_query),
|
||||
Dashboard.id.in_(datasource_perm_query),
|
||||
*feature_flagged_filters,
|
||||
*extra_access_filters,
|
||||
)
|
||||
)
|
||||
|
||||
return query
|
||||
|
||||
|
||||
class DashboardEditableFilter(EditableFilter): # pylint: disable=too-few-public-methods
|
||||
"""Filter for dashboards the user can edit."""
|
||||
|
||||
@@ -41,44 +41,27 @@ params:
|
||||
filterOptionName: 2745eae5
|
||||
operator: NOT IN
|
||||
subject: country_code
|
||||
color_scheme: supersetColors
|
||||
compare_lag: '10'
|
||||
compare_suffix: o10Y
|
||||
country_fieldtype: cca3
|
||||
entity: country_name
|
||||
granularity_sqla: year
|
||||
legendOrientation: top
|
||||
legendType: scroll
|
||||
groupby: []
|
||||
limit: 0
|
||||
markup_type: markdown
|
||||
max_bubble_size: '50'
|
||||
opacity: 0.6
|
||||
order_desc: true
|
||||
row_limit: 500
|
||||
row_limit: 50000
|
||||
series: region
|
||||
show_legend: true
|
||||
size:
|
||||
aggregate: SUM
|
||||
column:
|
||||
column_name: SP_POP_TOTL
|
||||
expressionType: SIMPLE
|
||||
label: SUM(SP_POP_TOTL)
|
||||
optionName: metric_size_life_expectancy_vs_rural
|
||||
time_range: '2011-01-01 : 2011-01-02'
|
||||
tooltipSizeFormat: SMART_NUMBER
|
||||
truncateXAxis: true
|
||||
viz_type: bubble_v2
|
||||
x:
|
||||
aggregate: SUM
|
||||
column:
|
||||
column_name: SP_RUR_TOTL_ZS
|
||||
expressionType: SIMPLE
|
||||
label: SUM(SP_RUR_TOTL_ZS)
|
||||
optionName: metric_x_life_expectancy_vs_rural
|
||||
y:
|
||||
aggregate: SUM
|
||||
column:
|
||||
column_name: SP_DYN_LE00_IN
|
||||
expressionType: SIMPLE
|
||||
label: SUM(SP_DYN_LE00_IN)
|
||||
optionName: metric_y_life_expectancy_vs_rural
|
||||
show_bubbles: true
|
||||
since: '2011-01-01'
|
||||
size: sum__SP_POP_TOTL
|
||||
time_range: '2014-01-01 : 2014-01-02'
|
||||
until: '2011-01-02'
|
||||
viz_type: bubble
|
||||
x: sum__SP_RUR_TOTL_ZS
|
||||
y: sum__SP_DYN_LE00_IN
|
||||
query_context: null
|
||||
slice_name: Life Expectancy VS Rural %
|
||||
uuid: c18faec9-ec43-4d36-8b66-4c8b1372020f
|
||||
version: 1.0.0
|
||||
viz_type: bubble_v2
|
||||
viz_type: bubble
|
||||
|
||||
+101
-72
@@ -46,7 +46,6 @@ Configuration:
|
||||
|
||||
import logging
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Any, Callable, cast, Generator, TYPE_CHECKING, TypeAlias, TypeVar
|
||||
|
||||
from flask import current_app, g, has_app_context, has_request_context
|
||||
@@ -61,13 +60,17 @@ from superset.mcp_service.guest_token_verifier import GUEST_TOKEN_CLAIM
|
||||
from superset.mcp_service.mcp_config import (
|
||||
default_user_resolver,
|
||||
get_mcp_api_key_enabled,
|
||||
MCP_GUEST_ALLOWED_TOOLS,
|
||||
validate_multi_issuer_user_resolver,
|
||||
)
|
||||
from superset.mcp_service.session_scope import _mcp_session_token
|
||||
from superset.mcp_service.utils.error_sanitization import (
|
||||
sanitize_for_log as _sanitize_for_log,
|
||||
)
|
||||
from superset.security.api_key_scopes import (
|
||||
get_resource_scope,
|
||||
METHOD_PERMISSION_SCOPE_ACTION,
|
||||
RESOURCE_SCOPE_NAME as RESOURCE_SCOPE_NAME,
|
||||
)
|
||||
from superset.security.guest_token import GuestUser
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -80,15 +83,6 @@ F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The resolved user's id for the current tool call, set by
|
||||
# _setup_user_context(). g.user is only valid for the lifetime of the
|
||||
# per-call app context pushed by _get_app_context_manager() (see its
|
||||
# docstring) and is gone by the time LoggingMiddleware's on_call_tool/
|
||||
# on_message finally-blocks run after call_next() returns. This ContextVar
|
||||
# survives that context pop (mirrors _mcp_call_id_var in middleware.py) so
|
||||
# audit logging can attribute the call to the right user.
|
||||
_mcp_user_id_var: ContextVar[int | None] = ContextVar("mcp_user_id", default=None)
|
||||
|
||||
# An MCP request resolves to a real DB ``User`` or, for embedded guests, a
|
||||
# ``GuestUser`` (an AnonymousUserMixin, not a ``User`` subclass). Both are valid
|
||||
# authenticated principals for tool execution.
|
||||
@@ -126,19 +120,24 @@ class MCPNoAuthSourceError(ValueError):
|
||||
# is a privileged, write-class operation and therefore requires the write
|
||||
# scope. When introducing a new method permission, add it here.
|
||||
_METHOD_TO_REQUIRED_SCOPE = {
|
||||
"read": "superset:read",
|
||||
# "get" is the read-class permission FAB registers on its security API
|
||||
# views (User/Role) — those views have no can_read, so tools targeting
|
||||
# them declare method_permission_name="get".
|
||||
"get": "superset:read",
|
||||
"write": "superset:write",
|
||||
"delete": "superset:write",
|
||||
# SQL execution (execute_sql, get_chart_sql) runs arbitrary queries and is
|
||||
# treated as a write-class privileged operation for scope purposes.
|
||||
"execute_sql_query": "superset:write",
|
||||
method: f"superset:{action}"
|
||||
for method, action in METHOD_PERMISSION_SCOPE_ACTION.items()
|
||||
}
|
||||
|
||||
|
||||
def _required_resource_scope(
|
||||
class_permission_name: str, method_permission_name: str
|
||||
) -> str | None:
|
||||
"""Compute the ``superset:<resource>:<action>`` scope string for a tool.
|
||||
|
||||
Returns None if either the resource or the action isn't mapped — callers
|
||||
must treat that as "no per-resource scope available," not as a grant;
|
||||
the flat ``_METHOD_TO_REQUIRED_SCOPE`` fallback still applies in that case
|
||||
(see ``_token_scope_allows``).
|
||||
"""
|
||||
return get_resource_scope(class_permission_name, method_permission_name)
|
||||
|
||||
|
||||
def _get_token_scopes() -> set[str] | None:
|
||||
"""Return the set of scopes on the current JWT access token, or None.
|
||||
|
||||
@@ -154,8 +153,13 @@ def _get_token_scopes() -> set[str] | None:
|
||||
|
||||
try:
|
||||
access_token = get_access_token()
|
||||
except Exception: # noqa: BLE001 - no JWT context for this request
|
||||
return None
|
||||
except Exception: # noqa: BLE001 - fail closed on token-context errors
|
||||
logger.exception("Unable to resolve MCP access-token scopes")
|
||||
# ``None`` means that no scoped credential was presented and enables
|
||||
# legacy RBAC-only behavior. An empty set instead makes every scope
|
||||
# check fail, so an unexpected context error cannot erase restrictions
|
||||
# carried by a credential.
|
||||
return set()
|
||||
|
||||
if access_token is None:
|
||||
return None
|
||||
@@ -167,12 +171,21 @@ def _get_token_scopes() -> set[str] | None:
|
||||
return {str(s) for s in scopes}
|
||||
|
||||
|
||||
def _token_scope_allows(method_permission_name: str) -> bool:
|
||||
def _token_scope_allows(
|
||||
method_permission_name: str, class_permission_name: str | None = None
|
||||
) -> bool:
|
||||
"""Return whether the current token's scopes permit the given method.
|
||||
|
||||
Back-compat: returns True (allow) when the token carries no scopes or there
|
||||
is no JWT context, so deployments not using scopes keep RBAC-only behavior.
|
||||
Only when the token advertises scopes is the mapped required scope enforced.
|
||||
|
||||
The per-resource scope (``superset:<resource>:<action>``, derived via
|
||||
``_required_resource_scope``) is an ALTERNATIVE grant path alongside the
|
||||
flat method scope: a token carrying either the flat scope
|
||||
(e.g. ``superset:read``) or the matching per-resource scope
|
||||
(e.g. ``superset:dashboard:read``) is allowed, so already-issued
|
||||
flat-scoped tokens keep working unchanged.
|
||||
"""
|
||||
token_scopes = _get_token_scopes()
|
||||
if token_scopes is None:
|
||||
@@ -190,7 +203,15 @@ def _token_scope_allows(method_permission_name: str) -> bool:
|
||||
method_permission_name,
|
||||
)
|
||||
return False
|
||||
return required_scope in token_scopes
|
||||
if required_scope in token_scopes:
|
||||
return True
|
||||
if class_permission_name is not None:
|
||||
resource_scope = _required_resource_scope(
|
||||
class_permission_name, method_permission_name
|
||||
)
|
||||
if resource_scope is not None and resource_scope in token_scopes:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class MCPPermissionDeniedError(PermissionError):
|
||||
@@ -234,12 +255,20 @@ def _log_scope_denial(
|
||||
cyclomatic complexity in check.
|
||||
"""
|
||||
required_scope = _METHOD_TO_REQUIRED_SCOPE.get(method_permission_name)
|
||||
resource_scope = _required_resource_scope(
|
||||
class_permission_name, method_permission_name
|
||||
)
|
||||
scope_desc = (
|
||||
resource_scope
|
||||
or required_scope
|
||||
or f"unmapped method permission '{method_permission_name}'"
|
||||
)
|
||||
if log_denial:
|
||||
logger.warning(
|
||||
"Scope denied for user %s: token lacks required scope "
|
||||
"'%s' for %s on %s (tool: %s)",
|
||||
_sanitize_for_log(g.user.username),
|
||||
required_scope,
|
||||
scope_desc,
|
||||
permission_str,
|
||||
class_permission_name,
|
||||
func.__name__,
|
||||
@@ -248,16 +277,28 @@ def _log_scope_denial(
|
||||
logger.debug(
|
||||
"Tool hidden for user %s: token lacks required scope '%s' (tool: %s)",
|
||||
_sanitize_for_log(g.user.username),
|
||||
required_scope,
|
||||
scope_desc,
|
||||
func.__name__,
|
||||
)
|
||||
|
||||
|
||||
# Default-deny allow-list for embedded guests: a guest may call only these tools,
|
||||
# regardless of MCP_RBAC_ENABLED or how the guest role (PUBLIC_ROLE_LIKE) is
|
||||
# configured. Everything else is denied, including newly added tools until listed.
|
||||
# Single source of truth: MCP_GUEST_ALLOWED_TOOLS in mcp_config.py.
|
||||
_DEFAULT_GUEST_ALLOWED_TOOLS: frozenset[str] = frozenset(MCP_GUEST_ALLOWED_TOOLS)
|
||||
# configured. Everything else is denied, including newly added tools until listed
|
||||
# here. Sync with mcp_config.py.
|
||||
_DEFAULT_GUEST_ALLOWED_TOOLS: frozenset[str] = frozenset(
|
||||
{
|
||||
# Dashboard structure, scoped to the token's embedded dashboards.
|
||||
"get_dashboard_info",
|
||||
"get_dashboard_layout",
|
||||
"list_dashboards",
|
||||
# Chart read + data, scoped by ChartFilter; data-model fields redacted.
|
||||
"list_charts",
|
||||
"get_chart_info",
|
||||
"get_chart_data",
|
||||
"get_chart_preview",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _guest_allowed_tools() -> frozenset[str]:
|
||||
@@ -354,8 +395,13 @@ def check_tool_permission( # noqa: C901
|
||||
)
|
||||
return False
|
||||
|
||||
method_permission_name = getattr(func, METHOD_PERMISSION_ATTR, "read")
|
||||
class_permission_name = getattr(func, CLASS_PERMISSION_ATTR, None)
|
||||
|
||||
# Token capabilities and user RBAC are independent restrictions.
|
||||
# Disabling RBAC must not discard scopes explicitly carried by a key.
|
||||
if not current_app.config.get("MCP_RBAC_ENABLED", True):
|
||||
return True
|
||||
return _token_scope_allows(method_permission_name, class_permission_name)
|
||||
|
||||
if not hasattr(g, "user") or not g.user:
|
||||
if log_denial:
|
||||
@@ -368,7 +414,6 @@ def check_tool_permission( # noqa: C901
|
||||
)
|
||||
return False
|
||||
|
||||
class_permission_name = getattr(func, CLASS_PERMISSION_ATTR, None)
|
||||
if not class_permission_name:
|
||||
# No RBAC configured for this tool; allow by default. This is a
|
||||
# supported configuration (a protected tool may intentionally
|
||||
@@ -382,9 +427,17 @@ def check_tool_permission( # noqa: C901
|
||||
"class_permission_name; allowing access without an RBAC check",
|
||||
func.__name__,
|
||||
)
|
||||
if not _token_scope_allows(method_permission_name):
|
||||
if log_denial:
|
||||
logger.warning(
|
||||
"Scope denied for permission-less tool %s: token lacks "
|
||||
"flat scope for method %s",
|
||||
func.__name__,
|
||||
method_permission_name,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
method_permission_name = getattr(func, METHOD_PERMISSION_ATTR, "read")
|
||||
permission_str = f"{PERMISSION_PREFIX}{method_permission_name}"
|
||||
|
||||
has_permission = security_manager.can_access(
|
||||
@@ -399,7 +452,9 @@ def check_tool_permission( # noqa: C901
|
||||
# advertises scopes. Tokens/deployments that don't use scopes (API keys,
|
||||
# scope-less JWTs, dev-mode) fall through to RBAC-only behavior — see
|
||||
# ``_token_scope_allows``.
|
||||
if has_permission and not _token_scope_allows(method_permission_name):
|
||||
if has_permission and not _token_scope_allows(
|
||||
method_permission_name, class_permission_name
|
||||
):
|
||||
_log_scope_denial(
|
||||
func,
|
||||
method_permission_name,
|
||||
@@ -462,7 +517,7 @@ def is_tool_visible_to_current_user(tool: Any) -> bool:
|
||||
return False
|
||||
|
||||
if not current_app.config.get("MCP_RBAC_ENABLED", True):
|
||||
return True
|
||||
return check_tool_permission(tool_func, log_denial=False)
|
||||
|
||||
from superset.mcp_service.privacy import (
|
||||
tool_requires_data_model_metadata_access,
|
||||
@@ -475,10 +530,6 @@ def is_tool_visible_to_current_user(tool: Any) -> bool:
|
||||
):
|
||||
return False
|
||||
|
||||
class_permission_name = getattr(tool_func, CLASS_PERMISSION_ATTR, None)
|
||||
if not class_permission_name:
|
||||
return True
|
||||
|
||||
return check_tool_permission(tool_func, log_denial=False)
|
||||
|
||||
except (AttributeError, RuntimeError, ValueError):
|
||||
@@ -889,9 +940,9 @@ def _assert_user_active(user: MCPUser | None) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _resolve_user_with_retry() -> MCPUser | None:
|
||||
def _setup_user_context() -> MCPUser | None:
|
||||
"""
|
||||
Resolve the current user, retrying once on a stale DB connection.
|
||||
Set up user context for MCP tool execution.
|
||||
|
||||
Includes retry logic for stale database connections (e.g., SSL dropped
|
||||
by proxy/load balancer after idle periods). On OperationalError, the
|
||||
@@ -900,6 +951,14 @@ def _resolve_user_with_retry() -> MCPUser | None:
|
||||
Returns:
|
||||
User object with roles and groups loaded, or None if no Flask context
|
||||
"""
|
||||
# Clear stale g.user to prevent user impersonation across
|
||||
# tool calls when no per-request middleware refreshes it.
|
||||
# Only clear in app-context-only mode; preserve g.user when
|
||||
# a request context is active (external middleware set it).
|
||||
|
||||
if not has_request_context():
|
||||
g.pop("user", None)
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
user = None # Ensure defined before loop in case of unexpected exit
|
||||
@@ -916,7 +975,7 @@ def _resolve_user_with_retry() -> MCPUser | None:
|
||||
if hasattr(user, "groups"):
|
||||
user_groups = user.groups # noqa: F841
|
||||
|
||||
return user
|
||||
break
|
||||
except RuntimeError as e:
|
||||
# No Flask application context (e.g., prompts before middleware runs)
|
||||
if "application context" in str(e):
|
||||
@@ -950,38 +1009,8 @@ def _resolve_user_with_retry() -> MCPUser | None:
|
||||
g.pop("user", None)
|
||||
raise
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def _setup_user_context() -> MCPUser | None:
|
||||
"""
|
||||
Set up user context for MCP tool execution.
|
||||
|
||||
Returns:
|
||||
User object with roles and groups loaded, or None if no Flask context
|
||||
"""
|
||||
# Clear stale g.user to prevent user impersonation across
|
||||
# tool calls when no per-request middleware refreshes it.
|
||||
# Only clear in app-context-only mode; preserve g.user when
|
||||
# a request context is active (external middleware set it).
|
||||
|
||||
if not has_request_context():
|
||||
g.pop("user", None)
|
||||
# Clear any user_id left over from a previous call in this context
|
||||
# (e.g. sequential calls sharing one asyncio task) so a failed/
|
||||
# unauthenticated lookup below doesn't inherit a stale value.
|
||||
_mcp_user_id_var.set(None)
|
||||
|
||||
user = _resolve_user_with_retry()
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
_assert_user_active(user)
|
||||
g.user = user
|
||||
# GuestUser (embedded auth) has no numeric id; leave the ContextVar
|
||||
# cleared (already reset above) rather than raise.
|
||||
if (user_id := getattr(user, "id", None)) is not None:
|
||||
_mcp_user_id_var.set(user_id)
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ from superset.mcp_service.chart.schemas import (
|
||||
BigNumberChartConfig,
|
||||
BoxPlotChartConfig,
|
||||
ChartCapabilities,
|
||||
ChartConfig,
|
||||
ChartSemantics,
|
||||
ColumnRef,
|
||||
CurrencyFormat,
|
||||
@@ -53,17 +52,12 @@ from superset.mcp_service.chart.schemas import (
|
||||
WaterfallChartConfig,
|
||||
XYChartConfig,
|
||||
)
|
||||
from superset.mcp_service.chart.validation.dataset_validator import (
|
||||
is_dataset_column_temporal,
|
||||
)
|
||||
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
||||
from superset.utils import json
|
||||
from superset.utils.core import FilterOperator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MCP_DASHBOARD_TIME_FILTER_SUBJECT = "_mcp_dashboard_time_filter_subject"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatasetValidationResult:
|
||||
@@ -97,6 +91,8 @@ def validate_chart_dataset(
|
||||
Returns:
|
||||
DatasetValidationResult with validation status and any warnings
|
||||
"""
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.mcp_service.auth import has_dataset_access
|
||||
|
||||
@@ -112,12 +108,9 @@ def validate_chart_dataset(
|
||||
error="Chart has no dataset reference (datasource_id is None)",
|
||||
)
|
||||
|
||||
# Skip the DatasourceFilter base filter when not checking access, so the
|
||||
# lookup is a true existence check (it otherwise denies a guest outright).
|
||||
# Try to look up the dataset
|
||||
try:
|
||||
dataset = DatasetDAO.find_by_id(
|
||||
datasource_id, skip_base_filter=not check_access
|
||||
)
|
||||
dataset = DatasetDAO.find_by_id(datasource_id)
|
||||
|
||||
if dataset is None:
|
||||
return DatasetValidationResult(
|
||||
@@ -193,6 +186,8 @@ def generate_explore_link(
|
||||
this skips the permalink path and returns an ``/explore/?form_data_key=...``
|
||||
URL directly.
|
||||
"""
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from superset.commands.exceptions import CommandException
|
||||
from superset.commands.explore.form_data.parameters import CommandParameters
|
||||
from superset.commands.explore.permalink.create import CreateExplorePermalinkCommand
|
||||
@@ -300,6 +295,51 @@ def _find_dataset_by_id_or_uuid(dataset_id: int | str | None) -> "SqlaTable | No
|
||||
return DatasetDAO.find_by_id_or_uuid(str(dataset_id))
|
||||
|
||||
|
||||
def _is_dataset_column_temporal(
|
||||
col: Any, column_name: str, db_engine_spec: Any
|
||||
) -> bool:
|
||||
"""Decide temporality for a single dataset column, mirroring
|
||||
TableColumn.is_temporal: native temporal SQL types are always
|
||||
temporal, and is_dttm=True is otherwise trusted over the raw SQL
|
||||
type -- this is the standard, supported way to mark a non-temporal
|
||||
column (e.g. a VARCHAR "ds" partition column on Hive/Presto/Trino)
|
||||
as a date.
|
||||
|
||||
The one case guarded against is a plain NUMERIC column (e.g. an
|
||||
integer "year"/"month" column) that Superset's column-name
|
||||
heuristics may have mis-flagged as is_dttm=True with no
|
||||
python_date_format to parse it -- applying DATE_TRUNC/time_grain to
|
||||
that would fail at query time.
|
||||
"""
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
is_dttm = bool(getattr(col, "is_dttm", False))
|
||||
col_type = col.type
|
||||
if not col_type:
|
||||
return is_dttm # No type info, trust is_dttm flag
|
||||
|
||||
column_spec = db_engine_spec.get_column_spec(col_type)
|
||||
generic_type = column_spec.generic_type if column_spec else None
|
||||
|
||||
if generic_type == GenericDataType.TEMPORAL:
|
||||
return True
|
||||
if not is_dttm:
|
||||
return False
|
||||
if generic_type != GenericDataType.NUMERIC or getattr(
|
||||
col, "python_date_format", None
|
||||
):
|
||||
return True
|
||||
|
||||
logger.debug(
|
||||
"Column '%s' is marked is_dttm=True but has numeric type '%s' with "
|
||||
"no python_date_format; treating as non-temporal to avoid an "
|
||||
"invalid DATE_TRUNC on a numeric column",
|
||||
column_name,
|
||||
col_type,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def is_column_truly_temporal(
|
||||
column_name: str,
|
||||
dataset_id: int | str | None,
|
||||
@@ -307,7 +347,7 @@ def is_column_truly_temporal(
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a column is truly temporal, mirroring TableColumn.is_temporal
|
||||
using the shared dataset temporal predicate.
|
||||
(see ``_is_dataset_column_temporal`` for the precedence rules).
|
||||
|
||||
Args:
|
||||
column_name: Name of the column to check
|
||||
@@ -333,7 +373,7 @@ def is_column_truly_temporal(
|
||||
for col in dataset.columns:
|
||||
if col.column_name.lower() == column_lower:
|
||||
db_engine_spec = dataset.database.db_engine_spec
|
||||
return is_dataset_column_temporal(col, column_name, db_engine_spec)
|
||||
return _is_dataset_column_temporal(col, column_name, db_engine_spec)
|
||||
|
||||
return True # Default if column not found
|
||||
|
||||
@@ -348,7 +388,13 @@ def is_column_truly_temporal(
|
||||
|
||||
|
||||
def map_config_to_form_data(
|
||||
config: ChartConfig,
|
||||
config: TableChartConfig
|
||||
| XYChartConfig
|
||||
| PieChartConfig
|
||||
| PivotTableChartConfig
|
||||
| MixedTimeseriesChartConfig
|
||||
| HandlebarsChartConfig
|
||||
| BigNumberChartConfig,
|
||||
dataset_id: int | str | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Map chart config to Superset form_data via the plugin registry.
|
||||
@@ -388,7 +434,6 @@ def map_config_to_form_data(
|
||||
parts.append("Suggestions: " + "; ".join(error.suggestions))
|
||||
raise ValueError(" ".join(parts))
|
||||
|
||||
_bind_dashboard_time_range_filter(form_data, config, dataset_id)
|
||||
return form_data
|
||||
|
||||
|
||||
@@ -757,98 +802,6 @@ def _ensure_temporal_adhoc_filter(form_data: Dict[str, Any], column: str) -> Non
|
||||
form_data["adhoc_filters"] = existing
|
||||
|
||||
|
||||
def _has_generated_temporal_filter(form_data: Dict[str, Any], column: str) -> bool:
|
||||
"""Return whether form data contains the neutral generated time binding."""
|
||||
return any(
|
||||
isinstance(filter_, dict)
|
||||
and filter_.get("operator") == FilterOperator.TEMPORAL_RANGE.value
|
||||
and filter_.get("subject") == column
|
||||
and filter_.get("comparator") == NO_TIME_RANGE
|
||||
for filter_ in form_data.get("adhoc_filters", [])
|
||||
)
|
||||
|
||||
|
||||
def _ensure_generated_temporal_binding(form_data: Dict[str, Any], column: str) -> None:
|
||||
"""Add a neutral time filter and record its generated provenance."""
|
||||
_ensure_temporal_adhoc_filter(form_data, column)
|
||||
if _has_generated_temporal_filter(form_data, column):
|
||||
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = column
|
||||
|
||||
|
||||
def _bind_dashboard_time_range_filter(
|
||||
form_data: Dict[str, Any],
|
||||
config: ChartConfig,
|
||||
dataset_id: int | str | None,
|
||||
) -> None:
|
||||
"""Bind charts without time configuration to a temporal filter subject."""
|
||||
if temporal_column := getattr(config, "temporal_column", None):
|
||||
if _is_temporal_for_dashboard_binding(temporal_column, dataset_id):
|
||||
granularity = form_data.get("granularity_sqla")
|
||||
if isinstance(granularity, str) and granularity != temporal_column:
|
||||
# QueryContextFactory gives granularity precedence over a temporal
|
||||
# filter, so a different granularity would bind both columns.
|
||||
form_data["granularity_sqla"] = None
|
||||
_ensure_temporal_adhoc_filter(form_data, temporal_column)
|
||||
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = temporal_column
|
||||
return
|
||||
|
||||
dataset = None
|
||||
if dataset_id:
|
||||
try:
|
||||
dataset = _find_dataset_by_id_or_uuid(dataset_id)
|
||||
except (AttributeError, RuntimeError, ValueError, SQLAlchemyError) as ex:
|
||||
logger.debug(
|
||||
"Could not resolve dataset %s for dashboard time binding: %s",
|
||||
dataset_id,
|
||||
ex,
|
||||
)
|
||||
return
|
||||
|
||||
granularity = form_data.get("granularity_sqla")
|
||||
if isinstance(granularity, str) and _is_temporal_for_dashboard_binding(
|
||||
granularity, dataset_id, dataset
|
||||
):
|
||||
# Temporal XY mappers create the neutral filter before this binding pass.
|
||||
# Record its provenance so preview updates can replace it if the subject
|
||||
# changes, without treating user-authored temporal ranges as generated.
|
||||
if _has_generated_temporal_filter(form_data, granularity):
|
||||
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = granularity
|
||||
return
|
||||
|
||||
x_axis = form_data.get("x_axis")
|
||||
if isinstance(x_axis, str) and _is_temporal_for_dashboard_binding(
|
||||
x_axis, dataset_id, dataset
|
||||
):
|
||||
_ensure_temporal_adhoc_filter(form_data, x_axis)
|
||||
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = x_axis
|
||||
return
|
||||
|
||||
main_dttm_col = getattr(dataset, "main_dttm_col", None)
|
||||
if isinstance(main_dttm_col, str) and _is_temporal_for_dashboard_binding(
|
||||
main_dttm_col, dataset_id, dataset
|
||||
):
|
||||
_ensure_temporal_adhoc_filter(form_data, main_dttm_col)
|
||||
form_data[MCP_DASHBOARD_TIME_FILTER_SUBJECT] = main_dttm_col
|
||||
|
||||
|
||||
def _is_temporal_for_dashboard_binding(
|
||||
column: str,
|
||||
dataset_id: int | str | None,
|
||||
dataset: "SqlaTable | None" = None,
|
||||
) -> bool:
|
||||
"""Check temporal metadata without making chart mapping fail on lookup errors."""
|
||||
try:
|
||||
return is_column_truly_temporal(column, dataset_id, dataset=dataset)
|
||||
except (AttributeError, RuntimeError, ValueError, SQLAlchemyError) as ex:
|
||||
logger.debug(
|
||||
"Could not validate temporal column %s for dataset %s: %s",
|
||||
column,
|
||||
dataset_id,
|
||||
ex,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_default_x_axis(
|
||||
config: XYChartConfig, dataset_id: int | str | None
|
||||
) -> tuple[XYChartConfig, "SqlaTable | None"]:
|
||||
@@ -955,10 +908,7 @@ def map_xy_config( # noqa: C901
|
||||
|
||||
_add_adhoc_filters(form_data, config.filters)
|
||||
|
||||
# A shared explicit temporal_column is the dashboard binding source of truth.
|
||||
# Defer to _bind_dashboard_time_range_filter instead of also binding the
|
||||
# temporal x-axis, which would apply the dashboard range to both columns.
|
||||
if x_is_temporal and not config.temporal_column:
|
||||
if x_is_temporal:
|
||||
_ensure_temporal_adhoc_filter(form_data, config.x.name)
|
||||
|
||||
_add_xy_limits(form_data, config)
|
||||
@@ -1154,7 +1104,7 @@ def map_big_number_config(
|
||||
# panel, which exposes an `adhoc_filters` control even though there's no
|
||||
# dedicated time-column control for the total variant.
|
||||
if temporal_column := _resolve_big_number_temporal_column(config, dataset_id):
|
||||
_ensure_generated_temporal_binding(form_data, temporal_column)
|
||||
_ensure_temporal_adhoc_filter(form_data, temporal_column)
|
||||
|
||||
return form_data
|
||||
|
||||
|
||||
@@ -248,8 +248,6 @@ def get_chart_configs_resource() -> str:
|
||||
],
|
||||
"general": [
|
||||
"Always verify column names with get_dataset_info before charting",
|
||||
"Set temporal_column when dashboard time filters should use a column "
|
||||
"other than the dataset's main temporal column",
|
||||
"Use generate_explore_link for preview, generate_chart for saving",
|
||||
"Each column label must be unique across the entire configuration",
|
||||
"Column names must match: ^[a-zA-Z0-9_][a-zA-Z0-9_ \\-\\.]*$",
|
||||
|
||||
@@ -772,33 +772,6 @@ class UnknownFieldCheckMixin(BaseModel):
|
||||
return _check_unknown_fields(data, cls)
|
||||
|
||||
|
||||
class BaseChartConfig(UnknownFieldCheckMixin):
|
||||
"""Fields shared by every MCP chart configuration."""
|
||||
|
||||
temporal_column: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Temporal column used to bind dashboard time-range filters. "
|
||||
"When omitted, charts without a temporal axis use the dataset's "
|
||||
"main temporal column."
|
||||
),
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
@field_validator("temporal_column")
|
||||
@classmethod
|
||||
def sanitize_temporal_column(cls, v: str | None) -> str | None:
|
||||
"""Sanitize temporal column names to prevent SQL injection."""
|
||||
return sanitize_user_input(
|
||||
v,
|
||||
"Temporal column",
|
||||
max_length=255,
|
||||
check_sql_keywords=True,
|
||||
allow_empty=True,
|
||||
)
|
||||
|
||||
|
||||
class ColumnRef(UnknownFieldCheckMixin):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
@@ -1051,7 +1024,7 @@ class SortByConfig(UnknownFieldCheckMixin):
|
||||
|
||||
|
||||
# Actual chart types
|
||||
class PieChartConfig(BaseChartConfig):
|
||||
class PieChartConfig(UnknownFieldCheckMixin):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
chart_type: Literal["pie"] = "pie"
|
||||
@@ -1125,7 +1098,7 @@ class PieChartConfig(BaseChartConfig):
|
||||
return self
|
||||
|
||||
|
||||
class PivotTableChartConfig(BaseChartConfig):
|
||||
class PivotTableChartConfig(UnknownFieldCheckMixin):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
chart_type: Literal["pivot_table"] = "pivot_table"
|
||||
@@ -1189,7 +1162,7 @@ class PivotTableChartConfig(BaseChartConfig):
|
||||
return self
|
||||
|
||||
|
||||
class MixedTimeseriesChartConfig(BaseChartConfig):
|
||||
class MixedTimeseriesChartConfig(UnknownFieldCheckMixin):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
chart_type: Literal["mixed_timeseries"] = "mixed_timeseries"
|
||||
@@ -1282,7 +1255,7 @@ class MixedTimeseriesChartConfig(BaseChartConfig):
|
||||
return self
|
||||
|
||||
|
||||
class HandlebarsChartConfig(BaseChartConfig):
|
||||
class HandlebarsChartConfig(UnknownFieldCheckMixin):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
chart_type: Literal["handlebars"] = Field(
|
||||
@@ -1398,7 +1371,7 @@ class HandlebarsChartConfig(BaseChartConfig):
|
||||
return self
|
||||
|
||||
|
||||
class BigNumberChartConfig(BaseChartConfig):
|
||||
class BigNumberChartConfig(UnknownFieldCheckMixin):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
chart_type: Literal["big_number"] = Field(
|
||||
@@ -1418,6 +1391,17 @@ class BigNumberChartConfig(BaseChartConfig):
|
||||
"Must include an aggregate function (e.g., SUM, COUNT)."
|
||||
),
|
||||
)
|
||||
temporal_column: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Temporal column for the trendline x-axis. Required when "
|
||||
"show_trendline is True. Also used (whether or not a trendline is "
|
||||
"shown) to bind the chart's dashboard time-range filter; when "
|
||||
"omitted, the dataset's default temporal column is used instead."
|
||||
),
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
)
|
||||
time_grain: TimeGrain | None = Field(
|
||||
None,
|
||||
description=(
|
||||
@@ -1511,6 +1495,18 @@ class BigNumberChartConfig(BaseChartConfig):
|
||||
description="Filters to apply",
|
||||
)
|
||||
|
||||
@field_validator("temporal_column")
|
||||
@classmethod
|
||||
def sanitize_temporal_column(cls, v: str | None) -> str | None:
|
||||
"""Sanitize temporal column name to prevent SQL injection."""
|
||||
return sanitize_user_input(
|
||||
v,
|
||||
"Temporal column",
|
||||
max_length=255,
|
||||
check_sql_keywords=True,
|
||||
allow_empty=True,
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_trendline_fields(self) -> Self:
|
||||
"""Validate trendline requires temporal column."""
|
||||
@@ -1580,7 +1576,7 @@ class TableColumnConfig(UnknownFieldCheckMixin):
|
||||
)
|
||||
|
||||
|
||||
class TableChartConfig(BaseChartConfig):
|
||||
class TableChartConfig(UnknownFieldCheckMixin):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
chart_type: Literal["table"] = "table"
|
||||
@@ -1711,7 +1707,7 @@ def _metric_display_label(col: ColumnRef) -> str:
|
||||
return col.label or col.name or ""
|
||||
|
||||
|
||||
class XYChartConfig(BaseChartConfig):
|
||||
class XYChartConfig(UnknownFieldCheckMixin):
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
|
||||
chart_type: Literal["xy"] = "xy"
|
||||
@@ -1882,7 +1878,7 @@ class XYChartConfig(BaseChartConfig):
|
||||
return self
|
||||
|
||||
|
||||
class HistogramChartConfig(BaseChartConfig):
|
||||
class HistogramChartConfig(UnknownFieldCheckMixin):
|
||||
"""Config for histogram charts (viz_type ``histogram_v2``)."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
@@ -1925,7 +1921,7 @@ class HistogramChartConfig(BaseChartConfig):
|
||||
return self
|
||||
|
||||
|
||||
class BoxPlotChartConfig(BaseChartConfig):
|
||||
class BoxPlotChartConfig(UnknownFieldCheckMixin):
|
||||
"""Config for box plot charts (viz_type ``box_plot``)."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
@@ -2040,7 +2036,7 @@ class BoxPlotChartConfig(BaseChartConfig):
|
||||
return self
|
||||
|
||||
|
||||
class WaterfallChartConfig(BaseChartConfig):
|
||||
class WaterfallChartConfig(UnknownFieldCheckMixin):
|
||||
"""Config for waterfall charts (viz_type ``waterfall``)."""
|
||||
|
||||
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
||||
@@ -2578,16 +2574,6 @@ class DataColumn(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ChartQueryResult(BaseModel):
|
||||
"""Data returned by one query in a chart's query context."""
|
||||
|
||||
query_index: int = Field(description="Zero-based query position")
|
||||
columns: list[str] = Field(description="Column names returned by the query")
|
||||
data: list[dict[str, Any]] = Field(description="Actual data rows")
|
||||
row_count: int = Field(description="Rows returned")
|
||||
total_rows: int | None = Field(None, description="Total available rows")
|
||||
|
||||
|
||||
class ChartData(BaseModel):
|
||||
"""Rich chart data response with statistical insights."""
|
||||
|
||||
@@ -2599,13 +2585,6 @@ class ChartData(BaseModel):
|
||||
# Enhanced data description
|
||||
columns: List[DataColumn] = Field(description="Rich column metadata")
|
||||
data: List[Dict[str, Any]] = Field(description="Actual data rows")
|
||||
query_results: list[ChartQueryResult] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"All query results for multi-query charts. The top-level columns and data "
|
||||
"fields remain aliases for the first query for backward compatibility."
|
||||
),
|
||||
)
|
||||
|
||||
# Data insights
|
||||
row_count: int = Field(description="Rows returned")
|
||||
|
||||
@@ -326,7 +326,7 @@ async def generate_chart( # noqa: C901
|
||||
|
||||
# Persist the chart only when explicitly requested (save_chart=False by default)
|
||||
if request.save_chart:
|
||||
await ctx.report_progress(2, 5, "Validating chart query")
|
||||
await ctx.report_progress(2, 5, "Creating chart in database")
|
||||
from superset.commands.chart.create import CreateChartCommand
|
||||
|
||||
# Find the dataset to get its numeric ID
|
||||
@@ -414,64 +414,6 @@ async def generate_chart( # noqa: C901
|
||||
)
|
||||
await ctx.debug("Chart name: chart_name=%s" % (chart_name,))
|
||||
|
||||
# Compile before persisting. A failed query must not leave a broken
|
||||
# chart row behind and then rely on a later transaction to delete it.
|
||||
with event_logger.log_context(action="mcp.generate_chart.compile_check"):
|
||||
compile_result = _compile_chart(form_data, dataset.id)
|
||||
if not compile_result.success:
|
||||
logger.warning(
|
||||
"Compile check failed before chart creation: %s",
|
||||
compile_result.error,
|
||||
)
|
||||
await ctx.warning(
|
||||
"Chart compile check failed: error=%s" % (compile_result.error,)
|
||||
)
|
||||
from superset.mcp_service.common.error_schemas import (
|
||||
ChartGenerationError,
|
||||
)
|
||||
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
error = compile_result.error_obj or ChartGenerationError(
|
||||
error_type="compile_error",
|
||||
message="Chart query failed to execute. The chart was not saved.",
|
||||
details=str(compile_result.error) or "",
|
||||
suggestions=[
|
||||
"Check that all columns exist in the dataset",
|
||||
"Verify aggregate functions are compatible with column types",
|
||||
"Ensure filters reference valid columns",
|
||||
"Try simplifying the chart configuration",
|
||||
],
|
||||
error_code="CHART_COMPILE_FAILED",
|
||||
)
|
||||
return GenerateChartResponse.model_validate(
|
||||
{
|
||||
"chart": None,
|
||||
"error": error.model_dump(),
|
||||
"form_data": (
|
||||
_sanitize_generate_chart_form_data_for_llm_context(
|
||||
form_data
|
||||
)
|
||||
),
|
||||
"performance": {
|
||||
"query_duration_ms": execution_time,
|
||||
"cache_status": "error",
|
||||
"optimization_suggestions": [],
|
||||
},
|
||||
"warnings": (
|
||||
sanitization_warnings
|
||||
+ runtime_warnings
|
||||
+ response_warnings
|
||||
+ compile_result.warnings
|
||||
),
|
||||
"success": False,
|
||||
"schema_version": "2.0",
|
||||
"api_version": "v1",
|
||||
}
|
||||
)
|
||||
response_warnings.extend(compile_result.warnings)
|
||||
|
||||
await ctx.report_progress(3, 5, "Creating chart in database")
|
||||
|
||||
try:
|
||||
with event_logger.log_context(action="mcp.generate_chart.db_write"):
|
||||
command = CreateChartCommand(
|
||||
@@ -547,6 +489,66 @@ async def generate_chart( # noqa: C901
|
||||
# Add any validation warnings (e.g., virtual dataset warnings)
|
||||
response_warnings.extend(dataset_check.warnings)
|
||||
|
||||
# Compile check: execute the chart query to catch runtime errors
|
||||
await ctx.report_progress(3, 5, "Running compile check (test query)")
|
||||
with event_logger.log_context(
|
||||
action="mcp.generate_chart.compile_check"
|
||||
):
|
||||
compile_result = _compile_chart(form_data, dataset.id)
|
||||
if not compile_result.success:
|
||||
# Query failed — delete the broken chart and return an error
|
||||
logger.warning(
|
||||
"Compile check failed for chart %s: %s",
|
||||
chart_id,
|
||||
compile_result.error,
|
||||
)
|
||||
await ctx.warning(
|
||||
"Chart compile check failed: error=%s" % (compile_result.error,)
|
||||
)
|
||||
from superset.daos.chart import ChartDAO
|
||||
|
||||
ChartDAO.delete([chart])
|
||||
from superset.mcp_service.common.error_schemas import (
|
||||
ChartGenerationError,
|
||||
)
|
||||
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
error = compile_result.error_obj or ChartGenerationError(
|
||||
error_type="compile_error",
|
||||
message=(
|
||||
"Chart query failed to execute. The chart was not saved."
|
||||
),
|
||||
details=str(compile_result.error) or "",
|
||||
suggestions=[
|
||||
"Check that all columns exist in the dataset",
|
||||
"Verify aggregate functions are compatible "
|
||||
"with column types",
|
||||
"Ensure filters reference valid columns",
|
||||
"Try simplifying the chart configuration",
|
||||
],
|
||||
error_code="CHART_COMPILE_FAILED",
|
||||
)
|
||||
return GenerateChartResponse.model_validate(
|
||||
{
|
||||
"chart": None,
|
||||
"error": error.model_dump(),
|
||||
"form_data": (
|
||||
_sanitize_generate_chart_form_data_for_llm_context(
|
||||
form_data
|
||||
)
|
||||
),
|
||||
"performance": {
|
||||
"query_duration_ms": execution_time,
|
||||
"cache_status": "error",
|
||||
"optimization_suggestions": [],
|
||||
},
|
||||
"success": False,
|
||||
"schema_version": "2.0",
|
||||
"api_version": "v1",
|
||||
}
|
||||
)
|
||||
response_warnings.extend(compile_result.warnings)
|
||||
|
||||
except CommandException as e:
|
||||
logger.error("Chart creation failed: %s", e)
|
||||
await ctx.error("Chart creation failed: error=%s" % (str(e),))
|
||||
|
||||
@@ -32,7 +32,6 @@ from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.slice import Slice
|
||||
|
||||
from superset.charts.data.form_data import set_query_context_form_data
|
||||
from superset.commands.exceptions import CommandException
|
||||
from superset.exceptions import OAuth2Error, OAuth2RedirectError, SupersetException
|
||||
from superset.extensions import event_logger
|
||||
@@ -48,7 +47,6 @@ from superset.mcp_service.chart.chart_utils import validate_chart_dataset
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
ChartData,
|
||||
ChartError,
|
||||
ChartQueryResult,
|
||||
DataColumn,
|
||||
GetChartDataRequest,
|
||||
PerformanceMetadata,
|
||||
@@ -114,14 +112,12 @@ _MAX_RECOMMENDATIONS = 4
|
||||
|
||||
def _coerce_row_limit(value: Any, default: int) -> int:
|
||||
"""Coerce a row_limit (which may arrive as a str from chart.params) to int,
|
||||
falling back to ``default`` when it is missing, non-numeric, or non-positive.
|
||||
A non-positive limit would otherwise flow through apply_max_row_limit as-is
|
||||
and emit ``LIMIT -1`` (unbounded on some engines, an error on others)."""
|
||||
falling back to ``default`` when it is missing or non-numeric — downstream
|
||||
apply_max_row_limit compares it against an int."""
|
||||
try:
|
||||
coerced = int(value)
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return coerced if coerced > 0 else default
|
||||
|
||||
|
||||
def _recommend_visualizations(
|
||||
@@ -268,12 +264,6 @@ def _sanitize_chart_data_for_llm_context(chart_data: ChartData) -> ChartData:
|
||||
field_path=("data",),
|
||||
excluded_field_names=frozenset(),
|
||||
)
|
||||
for query_index, query_result in enumerate(payload.get("query_results") or []):
|
||||
query_result["data"] = sanitize_for_llm_context(
|
||||
query_result.get("data", []),
|
||||
field_path=("query_results", str(query_index), "data"),
|
||||
excluded_field_names=frozenset(),
|
||||
)
|
||||
payload["columns"] = [
|
||||
{
|
||||
**column,
|
||||
@@ -289,29 +279,6 @@ def _sanitize_chart_data_for_llm_context(chart_data: ChartData) -> ChartData:
|
||||
return ChartData.model_validate(payload)
|
||||
|
||||
|
||||
def _build_query_results(
|
||||
query_results: list[dict[str, Any]], limit: int | None
|
||||
) -> list[ChartQueryResult] | None:
|
||||
"""Preserve every result when a chart executes more than one query."""
|
||||
if len(query_results) <= 1:
|
||||
return None
|
||||
|
||||
results = []
|
||||
for index, query_result in enumerate(query_results):
|
||||
data = query_result.get("data", [])
|
||||
returned_data = data[:limit] if limit else data
|
||||
results.append(
|
||||
ChartQueryResult(
|
||||
query_index=index,
|
||||
columns=query_result.get("colnames", []),
|
||||
data=returned_data,
|
||||
row_count=len(returned_data),
|
||||
total_rows=query_result.get("rowcount"),
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["data"],
|
||||
class_permission_name="Chart",
|
||||
@@ -368,13 +335,6 @@ async def get_chart_data( # noqa: C901
|
||||
|
||||
# Handle unsaved chart (form_data_key only, no identifier)
|
||||
if not request.identifier and request.form_data_key:
|
||||
# The unsaved-chart cache is not dashboard-scoped, so guests can't
|
||||
# use it.
|
||||
if guest_scope.is_guest_read():
|
||||
return ChartError(
|
||||
error="No accessible chart found for this request.",
|
||||
error_type="NotFound",
|
||||
)
|
||||
with event_logger.log_context(
|
||||
action="mcp.get_chart_data.unsaved_chart_from_cache"
|
||||
):
|
||||
@@ -473,30 +433,30 @@ async def get_chart_data( # noqa: C901
|
||||
)
|
||||
logger.info("Getting data for chart %s: %s", chart.id, chart.slice_name)
|
||||
|
||||
# Guests skip the RBAC check (authorize_query covers it) but keep the
|
||||
# existence check, so a deleted dataset still returns
|
||||
# DatasetNotAccessible.
|
||||
validation_result = validate_chart_dataset(
|
||||
chart.datasource_id, check_access=not guest_scope.is_guest_read()
|
||||
)
|
||||
if not validation_result.is_valid:
|
||||
await ctx.warning(
|
||||
"Chart found but dataset is not accessible: %s"
|
||||
% (validation_result.error,)
|
||||
# Skip the dataset RBAC pre-check for guests (see guest_scope.is_guest_read).
|
||||
if not guest_scope.is_guest_read():
|
||||
validation_result = validate_chart_dataset(
|
||||
chart.datasource_id, check_access=True
|
||||
)
|
||||
logger.warning(
|
||||
"get_chart_data: dataset not accessible for chart_id=%s: %s",
|
||||
chart.id,
|
||||
validation_result.error,
|
||||
)
|
||||
return ChartError(
|
||||
error=validation_result.error
|
||||
or "Chart's dataset is not accessible. Dataset may have been deleted.",
|
||||
error_type="DatasetNotAccessible",
|
||||
)
|
||||
# Log any warnings (e.g., virtual dataset warnings)
|
||||
for warning in validation_result.warnings:
|
||||
await ctx.warning("Dataset warning: %s" % (warning,))
|
||||
if not validation_result.is_valid:
|
||||
await ctx.warning(
|
||||
"Chart found but dataset is not accessible: %s"
|
||||
% (validation_result.error,)
|
||||
)
|
||||
logger.warning(
|
||||
"get_chart_data: dataset not accessible for chart_id=%s: %s",
|
||||
chart.id,
|
||||
validation_result.error,
|
||||
)
|
||||
return ChartError(
|
||||
error=validation_result.error
|
||||
or "Chart's dataset is not accessible. "
|
||||
"Dataset may have been deleted.",
|
||||
error_type="DatasetNotAccessible",
|
||||
)
|
||||
# Log any warnings (e.g., virtual dataset warnings)
|
||||
for warning in validation_result.warnings:
|
||||
await ctx.warning("Dataset warning: %s" % (warning,))
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
@@ -509,10 +469,8 @@ async def get_chart_data( # noqa: C901
|
||||
from superset.charts.schemas import ChartDataQueryContextSchema
|
||||
from superset.commands.chart.data.get_data_command import ChartDataCommand
|
||||
|
||||
# Guests always read the saved chart config: the cache isn't
|
||||
# dashboard-scoped and its payload could point the query at another
|
||||
# datasource.
|
||||
if request.form_data_key and not guest_scope.is_guest_read():
|
||||
# Check if form_data_key is provided - use cached form_data instead
|
||||
if request.form_data_key:
|
||||
with event_logger.log_context(
|
||||
action="mcp.get_chart_data.unsaved_state_override"
|
||||
):
|
||||
@@ -555,13 +513,11 @@ async def get_chart_data( # noqa: C901
|
||||
|
||||
# If using cached form_data, we need to build query_context from it
|
||||
if using_unsaved_state and cached_form_data_dict is not None:
|
||||
# row_limit may arrive as a str. The trailing fallback keeps a
|
||||
# falsy 0 resolving to ROW_LIMIT.
|
||||
row_limit = _coerce_row_limit(
|
||||
# Build query context from cached form_data (unsaved state)
|
||||
row_limit = (
|
||||
request.limit
|
||||
or cached_form_data_dict.get("row_limit")
|
||||
or current_app.config["ROW_LIMIT"],
|
||||
current_app.config["ROW_LIMIT"],
|
||||
or current_app.config["ROW_LIMIT"]
|
||||
)
|
||||
|
||||
query_context = build_query_context_from_form_data(
|
||||
@@ -672,8 +628,8 @@ async def get_chart_data( # noqa: C901
|
||||
# Apply request overrides to the saved query_context
|
||||
query_context_json["force"] = request.force_refresh
|
||||
|
||||
# Ignore a non-positive limit so it can't emit LIMIT -1 downstream.
|
||||
if request.limit and request.limit > 0:
|
||||
# Apply row limit if specified (respects chart's configured limits)
|
||||
if request.limit:
|
||||
for query in query_context_json.get("queries", []):
|
||||
query["row_limit"] = request.limit
|
||||
|
||||
@@ -707,12 +663,6 @@ async def get_chart_data( # noqa: C901
|
||||
if guest_dashboard_id is not None:
|
||||
guest_scope.authorize_query(query_context, guest_dashboard_id, chart)
|
||||
|
||||
set_query_context_form_data(
|
||||
query_context,
|
||||
chart.datasource_id,
|
||||
chart.datasource_type,
|
||||
)
|
||||
|
||||
# Execute the query
|
||||
with event_logger.log_context(action="mcp.get_chart_data.query_execution"):
|
||||
command = ChartDataCommand(query_context)
|
||||
@@ -753,7 +703,7 @@ async def get_chart_data( # noqa: C901
|
||||
)
|
||||
|
||||
# Check if we have data to work with
|
||||
if not any(query.get("data") for query in result["queries"]):
|
||||
if not data:
|
||||
await ctx.warning("No data in query results: chart_id=%s" % (chart.id,))
|
||||
logger.warning(
|
||||
"get_chart_data: no data in query results for chart_id=%s",
|
||||
@@ -933,9 +883,6 @@ async def get_chart_data( # noqa: C901
|
||||
chart_type=chart.viz_type or "unknown",
|
||||
columns=columns,
|
||||
data=data[: request.limit] if request.limit else data,
|
||||
query_results=_build_query_results(
|
||||
result["queries"], request.limit
|
||||
),
|
||||
row_count=len(data),
|
||||
total_rows=query_result.get("rowcount"),
|
||||
summary=summary,
|
||||
@@ -1047,11 +994,8 @@ async def _query_from_form_data(
|
||||
error_type="InvalidFormData",
|
||||
)
|
||||
|
||||
# row_limit may arrive as a str. The trailing fallback keeps a falsy 0
|
||||
# resolving to ROW_LIMIT.
|
||||
row_limit = _coerce_row_limit(
|
||||
request.limit or form_data.get("row_limit") or current_app.config["ROW_LIMIT"],
|
||||
current_app.config["ROW_LIMIT"],
|
||||
row_limit = (
|
||||
request.limit or form_data.get("row_limit") or current_app.config["ROW_LIMIT"]
|
||||
)
|
||||
viz_type = form_data.get("viz_type", "unknown")
|
||||
|
||||
@@ -1085,7 +1029,7 @@ async def _query_from_form_data(
|
||||
data = query_result.get("data", [])
|
||||
raw_columns = query_result.get("colnames", [])
|
||||
|
||||
if not any(query.get("data") for query in result["queries"]):
|
||||
if not data:
|
||||
logger.warning(
|
||||
"get_chart_data: no data for unsaved chart (form_data_key=%s)",
|
||||
request.form_data_key,
|
||||
@@ -1134,7 +1078,6 @@ async def _query_from_form_data(
|
||||
chart_type=viz_type,
|
||||
columns=columns,
|
||||
data=data[: request.limit] if request.limit else data,
|
||||
query_results=_build_query_results(result["queries"], request.limit),
|
||||
row_count=len(data),
|
||||
total_rows=query_result.get("rowcount"),
|
||||
summary=summary,
|
||||
|
||||
@@ -29,7 +29,6 @@ from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
from superset.commands.exceptions import CommandException
|
||||
from superset.exceptions import OAuth2Error, OAuth2RedirectError, SupersetException
|
||||
from superset.extensions import db, event_logger
|
||||
from superset.mcp_service import guest_scope
|
||||
from superset.mcp_service.chart.ascii_charts import (
|
||||
generate_ascii_chart,
|
||||
generate_ascii_table,
|
||||
@@ -277,6 +276,8 @@ class PreviewFormatStrategy:
|
||||
def _authorize_guest_query(self, query_context: Any) -> None:
|
||||
"""For a guest, attach the dashboard context so raise_for_access
|
||||
authorizes the preview query."""
|
||||
from superset.mcp_service import guest_scope
|
||||
|
||||
if (dashboard_id := guest_scope.guest_dashboard_id(self.chart)) is not None:
|
||||
guest_scope.authorize_query(query_context, dashboard_id, self.chart)
|
||||
|
||||
@@ -1292,11 +1293,13 @@ async def _get_chart_preview_internal( # noqa: C901
|
||||
logger.info("Generating preview for chart %s", getattr(chart, "id", "NO_ID"))
|
||||
logger.info("Chart datasource_id: %s", getattr(chart, "datasource_id", "NONE"))
|
||||
|
||||
# Transient charts have a falsy id of 0, so skip the pre-check for them.
|
||||
# Guests keep the existence check but skip RBAC (dashboard-authorized).
|
||||
if getattr(chart, "id", None):
|
||||
# Skip the dataset pre-check for transient charts (no ID) and for guests
|
||||
# (authorized via the dashboard context, not dataset RBAC).
|
||||
from superset.mcp_service import guest_scope
|
||||
|
||||
if getattr(chart, "id", None) is not None and not guest_scope.is_guest_read():
|
||||
validation_result = validate_chart_dataset(
|
||||
chart.datasource_id, check_access=not guest_scope.is_guest_read()
|
||||
chart.datasource_id, check_access=True
|
||||
)
|
||||
if not validation_result.is_valid:
|
||||
await ctx.warning(
|
||||
|
||||
@@ -102,8 +102,7 @@ async def list_charts(
|
||||
|
||||
Sortable columns for ``order_column``:
|
||||
``id``, ``slice_name``, ``viz_type``, ``description``,
|
||||
``changed_on``, ``changed_on_delta_humanized`` (alias for ``changed_on``),
|
||||
``created_on``
|
||||
``changed_on``, ``created_on``
|
||||
|
||||
To filter by a person, call find_users to resolve the name to a user ID,
|
||||
then pass it as a filter: filters=[{"col": "created_by_fk", "opr": "eq",
|
||||
|
||||
@@ -38,9 +38,7 @@ from superset.mcp_service.chart.chart_utils import (
|
||||
generate_chart_name,
|
||||
generate_explore_link,
|
||||
map_config_to_form_data,
|
||||
MCP_DASHBOARD_TIME_FILTER_SUBJECT,
|
||||
merge_table_column_config,
|
||||
NO_TIME_RANGE,
|
||||
)
|
||||
from superset.mcp_service.chart.compile import validate_and_compile
|
||||
from superset.mcp_service.chart.preview_utils import (
|
||||
@@ -105,50 +103,6 @@ def _get_previous_form_data(form_data_key: str) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _preserve_previous_adhoc_filters(
|
||||
new_form_data: dict[str, Any], previous_form_data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Preserve cached filters without dropping mapper-generated bindings."""
|
||||
previous_filters = previous_form_data.get("adhoc_filters")
|
||||
if not isinstance(previous_filters, list) or not previous_filters:
|
||||
return
|
||||
|
||||
generated_filters = new_form_data.get("adhoc_filters", [])
|
||||
previous_binding = previous_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
|
||||
new_binding = new_form_data.get(MCP_DASHBOARD_TIME_FILTER_SUBJECT)
|
||||
merged_filters = [
|
||||
filter_
|
||||
for filter_ in previous_filters
|
||||
if not (
|
||||
previous_binding
|
||||
and previous_binding != new_binding
|
||||
and isinstance(filter_, dict)
|
||||
and filter_.get("operator") == "TEMPORAL_RANGE"
|
||||
and filter_.get("subject") == previous_binding
|
||||
and filter_.get("comparator") == NO_TIME_RANGE
|
||||
)
|
||||
]
|
||||
for generated_filter in generated_filters:
|
||||
if not isinstance(generated_filter, dict):
|
||||
if generated_filter not in merged_filters:
|
||||
merged_filters.append(generated_filter)
|
||||
continue
|
||||
|
||||
is_same_filter = any(
|
||||
isinstance(previous_filter, dict)
|
||||
and previous_filter.get("clause") == generated_filter.get("clause")
|
||||
and previous_filter.get("expressionType")
|
||||
== generated_filter.get("expressionType")
|
||||
and previous_filter.get("subject") == generated_filter.get("subject")
|
||||
and previous_filter.get("operator") == generated_filter.get("operator")
|
||||
for previous_filter in merged_filters
|
||||
)
|
||||
if not is_same_filter:
|
||||
merged_filters.append(generated_filter)
|
||||
|
||||
new_form_data["adhoc_filters"] = merged_filters
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["mutate"],
|
||||
class_permission_name="Chart",
|
||||
@@ -230,10 +184,9 @@ def update_chart_preview( # noqa: C901
|
||||
# Preserve adhoc filters from the previous cached form_data
|
||||
# when the new config doesn't explicitly specify filters
|
||||
if getattr(config, "filters", None) is None and previous_form_data:
|
||||
_preserve_previous_adhoc_filters(
|
||||
new_form_data,
|
||||
previous_form_data,
|
||||
)
|
||||
old_adhoc_filters = previous_form_data.get("adhoc_filters")
|
||||
if old_adhoc_filters:
|
||||
new_form_data["adhoc_filters"] = old_adhoc_filters
|
||||
if previous_form_data:
|
||||
merge_table_column_config(previous_form_data, new_form_data)
|
||||
|
||||
|
||||
@@ -39,37 +39,6 @@ _C = TypeVar("_C", bound=ChartConfig)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_dataset_column_temporal(
|
||||
column: Any, column_name: str, db_engine_spec: Any
|
||||
) -> bool:
|
||||
"""Return whether a dataset column is safe for temporal operations."""
|
||||
from superset.utils.core import GenericDataType
|
||||
|
||||
is_dttm = bool(getattr(column, "is_dttm", False))
|
||||
column_type = column.type
|
||||
if not column_type:
|
||||
return is_dttm
|
||||
|
||||
column_spec = db_engine_spec.get_column_spec(column_type)
|
||||
generic_type = column_spec.generic_type if column_spec else None
|
||||
if generic_type == GenericDataType.TEMPORAL:
|
||||
return True
|
||||
if not is_dttm:
|
||||
return False
|
||||
if generic_type != GenericDataType.NUMERIC or getattr(
|
||||
column, "python_date_format", None
|
||||
):
|
||||
return True
|
||||
|
||||
logger.debug(
|
||||
"Column '%s' is marked is_dttm=True but has numeric type '%s' with "
|
||||
"no python_date_format; treating it as non-temporal",
|
||||
column_name,
|
||||
column_type,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def build_dataset_context_from_orm(dataset: Any) -> DatasetContext | None:
|
||||
"""Construct a :class:`DatasetContext` from an already-fetched ORM dataset.
|
||||
|
||||
@@ -79,19 +48,13 @@ def build_dataset_context_from_orm(dataset: Any) -> DatasetContext | None:
|
||||
if dataset is None:
|
||||
return None
|
||||
|
||||
database = getattr(dataset, "database", None)
|
||||
db_engine_spec = getattr(database, "db_engine_spec", None)
|
||||
columns: List[Dict[str, Any]] = []
|
||||
for col in getattr(dataset, "columns", []) or []:
|
||||
columns.append(
|
||||
{
|
||||
"name": col.column_name,
|
||||
"type": str(col.type) if col.type else "UNKNOWN",
|
||||
"is_temporal": (
|
||||
is_dataset_column_temporal(col, col.column_name, db_engine_spec)
|
||||
if db_engine_spec
|
||||
else getattr(col, "is_temporal", False)
|
||||
),
|
||||
"is_temporal": getattr(col, "is_temporal", False),
|
||||
"is_numeric": getattr(col, "is_numeric", False),
|
||||
}
|
||||
)
|
||||
@@ -106,6 +69,7 @@ def build_dataset_context_from_orm(dataset: Any) -> DatasetContext | None:
|
||||
}
|
||||
)
|
||||
|
||||
database = getattr(dataset, "database", None)
|
||||
database_name = getattr(database, "database_name", None) or ""
|
||||
return DatasetContext(
|
||||
id=dataset.id,
|
||||
@@ -159,12 +123,6 @@ class DatasetValidator:
|
||||
|
||||
return False, ChartErrorBuilder.dataset_not_found_error(dataset_id)
|
||||
|
||||
temporal_error = DatasetValidator._validate_temporal_column(
|
||||
config, dataset_context
|
||||
)
|
||||
if temporal_error:
|
||||
return False, temporal_error
|
||||
|
||||
# Collect all column references
|
||||
column_refs = DatasetValidator._extract_column_references(config)
|
||||
|
||||
@@ -195,51 +153,6 @@ class DatasetValidator:
|
||||
|
||||
return True, None
|
||||
|
||||
@staticmethod
|
||||
def _validate_temporal_column(
|
||||
config: ChartConfig, dataset_context: DatasetContext
|
||||
) -> ChartGenerationError | None:
|
||||
"""Require an explicitly selected dashboard time column to be temporal."""
|
||||
temporal_column = getattr(config, "temporal_column", None)
|
||||
if not temporal_column:
|
||||
return None
|
||||
|
||||
matching_column = next(
|
||||
(
|
||||
column
|
||||
for column in dataset_context.available_columns
|
||||
if column["name"].lower() == temporal_column.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if matching_column is None:
|
||||
return ChartGenerationError(
|
||||
error_type="missing_temporal_column",
|
||||
message=f"Temporal column '{temporal_column}' does not exist",
|
||||
details="The temporal_column must reference a physical dataset column.",
|
||||
suggestions=[
|
||||
"Choose a temporal column from the dataset",
|
||||
"Remove temporal_column to use the dataset's default time column",
|
||||
],
|
||||
error_code="MISSING_TEMPORAL_COLUMN",
|
||||
)
|
||||
if matching_column.get("is_temporal", False):
|
||||
return None
|
||||
|
||||
return ChartGenerationError(
|
||||
error_type="invalid_temporal_column",
|
||||
message=f"Column '{temporal_column}' is not temporal",
|
||||
details=(
|
||||
"The temporal_column must reference a dataset column marked as "
|
||||
"temporal so dashboard time-range filters can bind to the chart."
|
||||
),
|
||||
suggestions=[
|
||||
"Choose a temporal column from the dataset",
|
||||
"Remove temporal_column to use the dataset's default time column",
|
||||
],
|
||||
error_code="NON_TEMPORAL_COLUMN",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_columns_exist( # noqa: C901
|
||||
column_refs: List[ColumnRef], dataset_context: DatasetContext
|
||||
@@ -377,16 +290,7 @@ class DatasetValidator:
|
||||
logger.warning("No plugin registered for chart_type=%r", chart_type)
|
||||
return []
|
||||
|
||||
refs = plugin.extract_column_refs(config)
|
||||
temporal_column = getattr(config, "temporal_column", None)
|
||||
if temporal_column and not any(
|
||||
not ref.saved_metric
|
||||
and ref.name
|
||||
and ref.name.lower() == temporal_column.lower()
|
||||
for ref in refs
|
||||
):
|
||||
refs.append(ColumnRef(name=temporal_column))
|
||||
return refs
|
||||
return plugin.extract_column_refs(config)
|
||||
|
||||
@staticmethod
|
||||
def _column_exists(column_name: str, dataset_context: DatasetContext) -> bool:
|
||||
@@ -520,16 +424,7 @@ class DatasetValidator:
|
||||
)
|
||||
return config
|
||||
|
||||
normalized_config = plugin.normalize_column_refs(config, dataset_context)
|
||||
if temporal_column := getattr(normalized_config, "temporal_column", None):
|
||||
canonical_temporal_column = DatasetValidator.get_canonical_column_name(
|
||||
temporal_column, dataset_context
|
||||
)
|
||||
if canonical_temporal_column != temporal_column:
|
||||
normalized_config = normalized_config.model_copy(
|
||||
update={"temporal_column": canonical_temporal_column}
|
||||
)
|
||||
return normalized_config
|
||||
return plugin.normalize_column_refs(config, dataset_context)
|
||||
|
||||
@staticmethod
|
||||
def _get_column_suggestions(
|
||||
|
||||
@@ -257,7 +257,6 @@ CHART_SORTABLE_COLUMNS = [
|
||||
"viz_type",
|
||||
"description",
|
||||
"changed_on",
|
||||
"changed_on_delta_humanized",
|
||||
"created_on",
|
||||
]
|
||||
CHART_SEARCH_COLUMNS = ["slice_name", "description"]
|
||||
@@ -355,7 +354,6 @@ DATASET_SORTABLE_COLUMNS = [
|
||||
"table_name",
|
||||
"schema",
|
||||
"changed_on",
|
||||
"changed_on_delta_humanized",
|
||||
"created_on",
|
||||
]
|
||||
DATASET_SEARCH_COLUMNS = ["table_name", "description"]
|
||||
@@ -452,7 +450,6 @@ DASHBOARD_SORTABLE_COLUMNS = [
|
||||
"slug",
|
||||
"published",
|
||||
"changed_on",
|
||||
"changed_on_delta_humanized",
|
||||
"created_on",
|
||||
]
|
||||
DASHBOARD_SEARCH_COLUMNS = ["dashboard_title", "slug"]
|
||||
|
||||
@@ -113,15 +113,19 @@ class CompositeTokenVerifier(TokenVerifier):
|
||||
)
|
||||
self._api_key_prefixes = tuple(valid)
|
||||
|
||||
def _validate_api_key_sync(self, token: str) -> str | None:
|
||||
"""Validate an API key against FAB and return the user's username.
|
||||
def _validate_api_key_sync(self, token: str) -> tuple[str, list[str]] | None:
|
||||
"""Validate an API key against FAB and return (username, scopes).
|
||||
|
||||
Runs synchronously inside a thread executor. Pushes a fresh Flask
|
||||
app context so that FAB's SecurityManager can access the database.
|
||||
|
||||
Returns the username on success, or ``None`` if the key is invalid,
|
||||
FAB does not support ``validate_api_key``, or an unexpected error
|
||||
occurs (fail closed).
|
||||
``scopes`` is the key's own ``ApiKey.scopes`` column, parsed from
|
||||
FAB's comma-separated string storage format into a list (empty list
|
||||
if the key has no scopes set, matching the "no scopes advertised"
|
||||
convention used elsewhere in this module and in ``auth.py``).
|
||||
|
||||
Returns ``None`` if the key is invalid, FAB does not support
|
||||
``validate_api_key``, or an unexpected error occurs (fail closed).
|
||||
"""
|
||||
if self._app is None:
|
||||
return None
|
||||
@@ -135,12 +139,21 @@ class CompositeTokenVerifier(TokenVerifier):
|
||||
)
|
||||
return None
|
||||
user = sm.validate_api_key(token)
|
||||
username = user.username if user else None
|
||||
# Unbind the local reference so this frame no longer points at
|
||||
# the raw token (defense-in-depth). Python does not zero the
|
||||
# underlying string memory on rebind.
|
||||
token = "" # noqa: S105
|
||||
return username
|
||||
if user is None:
|
||||
return None
|
||||
username = user.username
|
||||
scopes_str = (
|
||||
sm.get_api_key_scopes(token)
|
||||
if hasattr(sm, "get_api_key_scopes")
|
||||
else None
|
||||
)
|
||||
scopes = (
|
||||
[s.strip() for s in scopes_str.split(",") if s.strip()]
|
||||
if scopes_str
|
||||
else []
|
||||
)
|
||||
token = "" # noqa: S105 -- unbind raw token, defense-in-depth
|
||||
return username, scopes
|
||||
except Exception: # noqa: BLE001 — catch-all: DB errors, FAB internals, etc.
|
||||
logger.warning(
|
||||
"API key transport validation failed unexpectedly; rejecting token",
|
||||
@@ -168,21 +181,25 @@ class CompositeTokenVerifier(TokenVerifier):
|
||||
if any(token.startswith(prefix) for prefix in self._api_key_prefixes):
|
||||
if self._app is not None:
|
||||
loop = asyncio.get_running_loop()
|
||||
username = await loop.run_in_executor(
|
||||
result = await loop.run_in_executor(
|
||||
None, self._validate_api_key_sync, token
|
||||
)
|
||||
if username is None:
|
||||
if result is None:
|
||||
logger.debug(
|
||||
"API key rejected at transport layer (invalid or expired)"
|
||||
)
|
||||
return None
|
||||
username, key_scopes = result
|
||||
logger.debug(
|
||||
"API key validated at transport layer for user=%s", username
|
||||
)
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="api_key",
|
||||
scopes=list(self.required_scopes or []),
|
||||
# Preserve the key's own scopes exactly. An empty list
|
||||
# means "no scopes advertised" and therefore retains the
|
||||
# RBAC-only behavior for existing unscoped API keys.
|
||||
scopes=key_scopes,
|
||||
claims={
|
||||
API_KEY_PASSTHROUGH_CLAIM: True,
|
||||
API_KEY_VALIDATED_USERNAME_CLAIM: username,
|
||||
@@ -190,10 +207,11 @@ class CompositeTokenVerifier(TokenVerifier):
|
||||
)
|
||||
|
||||
# No app configured: fall back to prefix-only pass-through so
|
||||
# ``_resolve_user_from_api_key`` handles DB validation.
|
||||
# NOTE: ``MCP_REQUIRED_SCOPES`` is intentionally not enforced for
|
||||
# API-key auth — FAB API keys do not carry scopes. Authorization is
|
||||
# enforced downstream via ``check_tool_permission`` (RBAC).
|
||||
# ``_resolve_user_from_api_key`` handles DB validation. Without an
|
||||
# app there is no DB access here, so the key's own ApiKey.scopes
|
||||
# cannot be read — the verifier-global required_scopes are used
|
||||
# instead. Authorization is still enforced downstream via
|
||||
# ``check_tool_permission`` (RBAC).
|
||||
logger.debug("API key token detected (prefix match), passing through")
|
||||
return AccessToken(
|
||||
token=token,
|
||||
|
||||
@@ -56,6 +56,15 @@ DEFAULT_DASHBOARD_COLUMNS = [
|
||||
"changed_on_humanized",
|
||||
]
|
||||
|
||||
SORTABLE_DASHBOARD_COLUMNS = [
|
||||
"id",
|
||||
"dashboard_title",
|
||||
"slug",
|
||||
"published",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
|
||||
_DEFAULT_LIST_DASHBOARDS_REQUEST = ListDashboardsRequest()
|
||||
|
||||
|
||||
@@ -94,8 +103,7 @@ async def list_dashboards(
|
||||
|
||||
Sortable columns for ``order_column``:
|
||||
``id``, ``dashboard_title``, ``slug``, ``published``,
|
||||
``changed_on``, ``changed_on_delta_humanized`` (alias for ``changed_on``),
|
||||
``created_on``
|
||||
``changed_on``, ``created_on``
|
||||
|
||||
To filter by a person (e.g. "dashboards Maxime is working on"), do NOT pass
|
||||
the name as the search parameter — search matches titles and slugs only.
|
||||
|
||||
@@ -66,6 +66,14 @@ DEFAULT_DATASET_COLUMNS = [
|
||||
"changed_on_humanized",
|
||||
]
|
||||
|
||||
SORTABLE_DATASET_COLUMNS = [
|
||||
"id",
|
||||
"table_name",
|
||||
"schema",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
|
||||
_DEFAULT_LIST_DATASETS_REQUEST = ListDatasetsRequest()
|
||||
|
||||
|
||||
@@ -107,8 +115,7 @@ async def list_datasets(
|
||||
``created_by_fk``, ``changed_by_fk``
|
||||
|
||||
Sortable columns for ``order_column``:
|
||||
``id``, ``table_name``, ``schema``, ``changed_on``,
|
||||
``changed_on_delta_humanized`` (alias for ``changed_on``), ``created_on``
|
||||
``id``, ``table_name``, ``schema``, ``changed_on``, ``created_on``
|
||||
|
||||
To filter by a person, call find_users to resolve the name to a user ID,
|
||||
then pass it as a filter: filters=[{"col": "created_by_fk", "opr": "eq",
|
||||
|
||||
@@ -31,7 +31,6 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import joinedload, subqueryload
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.charts.data.form_data import set_query_context_form_data
|
||||
from superset.commands.exceptions import CommandException
|
||||
from superset.exceptions import OAuth2Error, OAuth2RedirectError, SupersetException
|
||||
from superset.extensions import event_logger
|
||||
@@ -311,8 +310,6 @@ async def query_dataset( # noqa: C901
|
||||
custom_cache_timeout=request.cache_timeout,
|
||||
)
|
||||
|
||||
set_query_context_form_data(query_context, dataset.id, "table")
|
||||
|
||||
command = ChartDataCommand(query_context)
|
||||
command.validate()
|
||||
result = command.run()
|
||||
|
||||
@@ -653,10 +653,9 @@ def _build_composite_verifier(
|
||||
if api_key_enabled:
|
||||
if required_scopes := app.config.get("MCP_REQUIRED_SCOPES", []):
|
||||
logger.warning(
|
||||
"MCP_REQUIRED_SCOPES is configured but API key tokens bypass "
|
||||
"scope enforcement. API key holders gain access regardless of "
|
||||
"MCP_REQUIRED_SCOPES=%r. Enforce per-key authorization via FAB "
|
||||
"roles/RBAC instead.",
|
||||
"MCP_REQUIRED_SCOPES=%r is configured, but API key tokens use "
|
||||
"the scopes stored on each key instead. Unscoped API keys "
|
||||
"retain legacy RBAC-only behavior.",
|
||||
required_scopes,
|
||||
)
|
||||
raw_prefixes: str | Sequence[str] = app.config.get(
|
||||
@@ -790,7 +789,6 @@ def get_mcp_config(app_config: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"MCP_CHART_PLUGIN_ENABLED_FUNC": MCP_CHART_PLUGIN_ENABLED_FUNC,
|
||||
"MCP_EMBEDDED_GUEST_AUTH_ENABLED": MCP_EMBEDDED_GUEST_AUTH_ENABLED,
|
||||
"MCP_GUEST_ALLOWED_TOOLS": set(MCP_GUEST_ALLOWED_TOOLS),
|
||||
"MCP_RESTRICTED_TOOL_POLICY": MCP_RESTRICTED_TOOL_POLICY,
|
||||
**MCP_SESSION_CONFIG,
|
||||
**MCP_CSRF_CONFIG,
|
||||
}
|
||||
|
||||
@@ -70,20 +70,6 @@ F = TypeVar("F", bound=BaseModel) # For filter types
|
||||
L = TypeVar("L", bound=BaseModel) # For list response schemas
|
||||
|
||||
|
||||
# Humanized/computed columns accepted as order_column aliases, mapped to the
|
||||
# real sortable column that backs them. Superset's own REST APIs (see the
|
||||
# `@renders("changed_on")` binding on `changed_on_delta_humanized` in
|
||||
# models/helpers.py, and each ModelRestApi's `order_columns`) already sort
|
||||
# by the underlying timestamp when asked to order by the humanized string,
|
||||
# since the humanized value is derived from it and isn't itself a queryable
|
||||
# column. Mirrored here so DAO.list() (which does a plain
|
||||
# `getattr(model, order_column)`) receives an actual column, not a Python
|
||||
# property/method.
|
||||
_ORDER_COLUMN_ALIASES: dict[str, str] = {
|
||||
"changed_on_delta_humanized": "changed_on",
|
||||
}
|
||||
|
||||
|
||||
class BaseCore(ABC):
|
||||
"""
|
||||
Abstract base class for all MCP Core classes.
|
||||
@@ -402,14 +388,6 @@ class ModelListCore(BaseCore, Generic[L]):
|
||||
columns_to_load.append(dependency)
|
||||
|
||||
self._validate_order_column(order_column)
|
||||
# Resolve humanized/computed aliases (e.g. changed_on_delta_humanized)
|
||||
# to the real column they're derived from. Must happen after
|
||||
# validation (which checks against the advertised sortable_columns,
|
||||
# including the alias) and before the DAO call, since DAO.list()
|
||||
# sorts via `getattr(model, order_column)` and would receive a
|
||||
# Python property/method instead of a SQL column otherwise.
|
||||
if order_column is not None and order_column in self._sortable_columns:
|
||||
order_column = _ORDER_COLUMN_ALIASES.get(order_column, order_column)
|
||||
|
||||
deleted_state_bound = self._build_deleted_state_filter(deleted_state)
|
||||
if deleted_state_bound is not None:
|
||||
|
||||
@@ -41,7 +41,6 @@ from superset.exceptions import SupersetException, SupersetSecurityException
|
||||
from superset.extensions import event_logger, stats_logger_manager
|
||||
from superset.mcp_service.auth import (
|
||||
_get_app_context_manager,
|
||||
_mcp_user_id_var,
|
||||
get_user_from_request,
|
||||
is_tool_visible_to_current_user,
|
||||
MCPNoAuthSourceError,
|
||||
@@ -626,24 +625,6 @@ class LoggingMiddleware(Middleware):
|
||||
success = False
|
||||
raise
|
||||
finally:
|
||||
# user_id was captured before call_next() ran the tool, i.e.
|
||||
# before the @tool auth decorator (superset/mcp_service/auth.py)
|
||||
# resolves the user. It sets g.user on a per-call app context
|
||||
# that _get_app_context_manager() pushes and pops around the
|
||||
# tool's execution (see its docstring), so g.user/get_user_id()
|
||||
# are back to their pre-call state by the time we get here —
|
||||
# re-reading get_user_id() would still yield the stale value.
|
||||
# _mcp_user_id_var is a plain ContextVar (not tied to that Flask
|
||||
# app-context lifecycle) that _setup_user_context() sets before
|
||||
# the context pops, so it survives to this point.
|
||||
resolved_user_id = _mcp_user_id_var.get(None)
|
||||
if resolved_user_id is not None:
|
||||
user_id = resolved_user_id
|
||||
# Reset so a later on_call_tool/on_message in the same asyncio
|
||||
# task (e.g. an unprotected tool or resource/prompt read that
|
||||
# never calls _setup_user_context()) doesn't inherit this call's
|
||||
# resolved user id.
|
||||
_mcp_user_id_var.set(None)
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
self._log_call_tool_result(
|
||||
context=context,
|
||||
@@ -685,44 +666,34 @@ class LoggingMiddleware(Middleware):
|
||||
self._extract_context_info(context)
|
||||
)
|
||||
try:
|
||||
return await call_next(context)
|
||||
finally:
|
||||
# See the matching comment in on_call_tool: g.user/get_user_id()
|
||||
# are stale here because the per-call app context has already
|
||||
# been popped. _mcp_user_id_var survives it.
|
||||
resolved_user_id = _mcp_user_id_var.get(None)
|
||||
if resolved_user_id is not None:
|
||||
user_id = resolved_user_id
|
||||
# See the matching reset in on_call_tool.
|
||||
_mcp_user_id_var.set(None)
|
||||
try:
|
||||
with _get_app_context_manager():
|
||||
event_logger.log(
|
||||
user_id=user_id,
|
||||
action="mcp_message",
|
||||
dashboard_id=dashboard_id,
|
||||
duration_ms=None,
|
||||
slice_id=slice_id,
|
||||
referrer=None,
|
||||
curated_payload={
|
||||
"tool": getattr(context.message, "name", None),
|
||||
"agent_id": agent_id,
|
||||
"params": _sanitize_params(params),
|
||||
"method": context.method,
|
||||
"dashboard_id": dashboard_id,
|
||||
"slice_id": slice_id,
|
||||
"dataset_id": dataset_id,
|
||||
},
|
||||
)
|
||||
except Exception as log_error: # noqa: BLE001
|
||||
logger.warning("Failed to log mcp_message event: %s", log_error)
|
||||
logger.info(
|
||||
"MCP message: tool=%s, agent_id=%s, user_id=%s, method=%s",
|
||||
getattr(context.message, "name", None),
|
||||
agent_id,
|
||||
user_id,
|
||||
context.method,
|
||||
)
|
||||
with _get_app_context_manager():
|
||||
event_logger.log(
|
||||
user_id=user_id,
|
||||
action="mcp_message",
|
||||
dashboard_id=dashboard_id,
|
||||
duration_ms=None,
|
||||
slice_id=slice_id,
|
||||
referrer=None,
|
||||
curated_payload={
|
||||
"tool": getattr(context.message, "name", None),
|
||||
"agent_id": agent_id,
|
||||
"params": _sanitize_params(params),
|
||||
"method": context.method,
|
||||
"dashboard_id": dashboard_id,
|
||||
"slice_id": slice_id,
|
||||
"dataset_id": dataset_id,
|
||||
},
|
||||
)
|
||||
except Exception as log_error: # noqa: BLE001
|
||||
logger.warning("Failed to log mcp_message event: %s", log_error)
|
||||
logger.info(
|
||||
"MCP message: tool=%s, agent_id=%s, user_id=%s, method=%s",
|
||||
getattr(context.message, "name", None),
|
||||
agent_id,
|
||||
user_id,
|
||||
context.method,
|
||||
)
|
||||
return await call_next(context)
|
||||
|
||||
|
||||
class StructuredContentStripperMiddleware(Middleware):
|
||||
|
||||
@@ -30,7 +30,7 @@ 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.auth import _token_scope_allows, MCPPermissionDeniedError
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
CHART_DEFAULT_COLUMNS,
|
||||
CHART_SEARCH_COLUMNS,
|
||||
@@ -235,9 +235,10 @@ async def get_schema(
|
||||
|
||||
from superset import security_manager
|
||||
|
||||
if current_app.config.get("MCP_RBAC_ENABLED", True) and not (
|
||||
security_manager.can_access("can_read", class_permission)
|
||||
):
|
||||
rbac_allows = not current_app.config.get(
|
||||
"MCP_RBAC_ENABLED", True
|
||||
) or security_manager.can_access("can_read", class_permission)
|
||||
if not (rbac_allows and _token_scope_allows("read", class_permission)):
|
||||
user_str = getattr(getattr(g, "user", None), "username", None)
|
||||
logger.warning(
|
||||
"get_schema RBAC denied: user=%s type=%s view=%s",
|
||||
|
||||
@@ -802,64 +802,6 @@ def _truncate_rows_field(
|
||||
return None
|
||||
|
||||
|
||||
def _truncate_chart_query_results(
|
||||
data: Dict[str, Any], token_limit: int, advice: str
|
||||
) -> list[str] | None:
|
||||
"""Apply one response-wide row cap to every result of a multi-query chart."""
|
||||
from superset.utils import json as utils_json
|
||||
|
||||
query_results = data.get("query_results")
|
||||
if not isinstance(query_results, list) or not query_results:
|
||||
return None
|
||||
|
||||
row_lists = [data.get("data", [])]
|
||||
row_lists.extend(
|
||||
result.get("data", [])
|
||||
for result in query_results
|
||||
if isinstance(result, dict) and isinstance(result.get("data"), list)
|
||||
)
|
||||
originals = [list(rows) for rows in row_lists]
|
||||
if not any(originals):
|
||||
return None
|
||||
|
||||
original_count = sum(len(rows) for rows in originals[1:])
|
||||
data["_response_truncated"] = True
|
||||
data["_truncation_notes"] = [
|
||||
f"Result truncated: {original_count} of {original_count} rows returned "
|
||||
f"across multiple queries "
|
||||
f"(limit ~{token_limit:,} tokens). {advice}"
|
||||
]
|
||||
|
||||
lo, hi = 0, max(len(rows) for rows in originals)
|
||||
while lo < hi:
|
||||
cap = (lo + hi + 1) // 2
|
||||
for rows, original in zip(row_lists, originals, strict=False):
|
||||
rows[:] = original[:cap]
|
||||
if estimate_token_count(utils_json.dumps(data)) <= token_limit:
|
||||
lo = cap
|
||||
else:
|
||||
hi = cap - 1
|
||||
|
||||
cap = max(lo, 1)
|
||||
for rows, original in zip(row_lists, originals, strict=False):
|
||||
rows[:] = original[:cap]
|
||||
kept_count = sum(len(rows) for rows in row_lists[1:])
|
||||
if kept_count >= original_count:
|
||||
del data["_response_truncated"]
|
||||
del data["_truncation_notes"]
|
||||
return None
|
||||
|
||||
data["row_count"] = len(row_lists[0])
|
||||
for result in query_results:
|
||||
if isinstance(result, dict) and isinstance(result.get("data"), list):
|
||||
result["row_count"] = len(result["data"])
|
||||
data["_truncation_notes"] = [
|
||||
f"Result truncated: {kept_count} of {original_count} rows returned "
|
||||
f"across multiple queries (limit ~{token_limit:,} tokens). {advice}"
|
||||
]
|
||||
return data["_truncation_notes"]
|
||||
|
||||
|
||||
def _truncate_csv_data_field(
|
||||
data: Dict[str, Any],
|
||||
token_limit: int,
|
||||
@@ -958,9 +900,7 @@ def truncate_query_result(
|
||||
if estimate_token_count(utils_json.dumps(data)) <= token_limit:
|
||||
return data, False, []
|
||||
|
||||
notes = _truncate_chart_query_results(data, token_limit, advice)
|
||||
if notes is None:
|
||||
notes = _truncate_rows_field(data, row_field, token_limit, advice)
|
||||
notes = _truncate_rows_field(data, row_field, token_limit, advice)
|
||||
if notes is None:
|
||||
notes = _truncate_csv_data_field(data, token_limit, advice)
|
||||
|
||||
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
# 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.
|
||||
"""Enforce one OAuth2 token per (user_id, database_id).
|
||||
|
||||
`OAuth2StoreTokenCommand` always deletes any existing token for a
|
||||
user+database pair before storing a new one, so the table is only ever
|
||||
meant to carry a single live row per pair -- but that invariant was only
|
||||
enforced in application code, via a plain (non-unique) lookup index. A
|
||||
race between two concurrent OAuth2 callbacks for the same user+database
|
||||
can leave duplicate rows behind, and nothing downstream picks a
|
||||
deterministic one of them. Flagged as a follow-up during review of #42211
|
||||
(which fixed an unrelated `purge_oauth2_tokens` filter bug on this same
|
||||
table).
|
||||
|
||||
Pre-flight: deletes any pre-existing duplicate rows, keeping the
|
||||
highest-id row per (user_id, database_id) pair, since
|
||||
`OAuth2StoreTokenCommand` always deletes-then-inserts and a higher id is
|
||||
therefore the more recently issued token.
|
||||
|
||||
Revision ID: da0e3f0081bf
|
||||
Revises: b8d2f4a6c901
|
||||
Create Date: 2026-08-07 09:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from superset.migrations.shared.utils import create_index, drop_index
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "da0e3f0081bf"
|
||||
down_revision: str = "b8d2f4a6c901"
|
||||
|
||||
TABLE_NAME = "database_user_oauth2_tokens"
|
||||
INDEX_NAME = "idx_user_id_database_id"
|
||||
# Temporary name for the unique index while it and the old non-unique index
|
||||
# briefly coexist -- see the comment in ``upgrade`` below.
|
||||
TMP_INDEX_NAME = "idx_user_id_database_id_tmp_unique"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
metadata = sa.MetaData()
|
||||
table = sa.Table(TABLE_NAME, metadata, autoload_with=bind)
|
||||
|
||||
# Find the highest id per (user_id, database_id) pair by reading into
|
||||
# Python first, rather than deleting via a subquery on the same table:
|
||||
# MySQL rejects a DELETE whose WHERE clause subqueries the target table
|
||||
# (error 1093).
|
||||
rows = bind.execute(
|
||||
sa.select(table.c.id, table.c.user_id, table.c.database_id)
|
||||
).fetchall()
|
||||
max_id_by_pair: dict[tuple[int, int], int] = {}
|
||||
for row in rows:
|
||||
pair = (row.user_id, row.database_id)
|
||||
if row.id > max_id_by_pair.get(pair, -1):
|
||||
max_id_by_pair[pair] = row.id
|
||||
keep_ids = set(max_id_by_pair.values())
|
||||
dupe_ids = [row.id for row in rows if row.id not in keep_ids]
|
||||
if dupe_ids:
|
||||
bind.execute(table.delete().where(table.c.id.in_(dupe_ids)))
|
||||
|
||||
# MySQL's InnoDB won't drop an index that's still needed to satisfy a
|
||||
# foreign key (error 1553) -- the existing (user_id, database_id) index
|
||||
# is the only one covering the `user_id` FK. Create the new unique
|
||||
# index (which covers the same leading column) before dropping the old
|
||||
# one, so an FK-satisfying index always exists, then swap it into its
|
||||
# final name.
|
||||
create_index(TABLE_NAME, TMP_INDEX_NAME, ["user_id", "database_id"], unique=True)
|
||||
drop_index(TABLE_NAME, INDEX_NAME)
|
||||
create_index(TABLE_NAME, INDEX_NAME, ["user_id", "database_id"], unique=True)
|
||||
drop_index(TABLE_NAME, TMP_INDEX_NAME)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The pre-flight dedupe above is not reversible -- any rows it removed
|
||||
# stay removed -- but that only ever discards rows that violated the
|
||||
# single-token-per-pair invariant the application already assumed.
|
||||
create_index(TABLE_NAME, TMP_INDEX_NAME, ["user_id", "database_id"])
|
||||
drop_index(TABLE_NAME, INDEX_NAME)
|
||||
create_index(TABLE_NAME, INDEX_NAME, ["user_id", "database_id"])
|
||||
drop_index(TABLE_NAME, TMP_INDEX_NAME)
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
# 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.
|
||||
"""merge oauth2 token uniqueness with report_schedule include_cta
|
||||
|
||||
Revision ID: 1072de5ed955
|
||||
Revises: ('da0e3f0081bf', '2d6ad72e4af6')
|
||||
Create Date: 2026-08-15 01:39:00.000000
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "1072de5ed955"
|
||||
down_revision = ("da0e3f0081bf", "2d6ad72e4af6")
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
||||
@@ -1607,9 +1607,7 @@ class DatabaseUserOAuth2Tokens(Model, AuditMixinNullable):
|
||||
"""
|
||||
|
||||
__tablename__ = "database_user_oauth2_tokens"
|
||||
__table_args__ = (
|
||||
sqla.Index("idx_user_id_database_id", "user_id", "database_id", unique=True),
|
||||
)
|
||||
__table_args__ = (sqla.Index("idx_user_id_database_id", "user_id", "database_id"),)
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
|
||||
@@ -181,24 +181,6 @@ OFFSET_JOIN_COLUMN_SUFFIX = "__offset_join_column_"
|
||||
R_SUFFIX = "__right_suffix"
|
||||
|
||||
|
||||
def _normalize_mssql_virtual_dataset_sql(
|
||||
sql: str, parsed_script: SQLScript, engine: str
|
||||
) -> str:
|
||||
"""Remove SQL Server ordering that is invalid inside a derived table."""
|
||||
if engine != "mssql" or not parsed_script.statements:
|
||||
return sql
|
||||
|
||||
statement = parsed_script.statements[0]
|
||||
if not isinstance(statement, SQLStatement):
|
||||
return sql
|
||||
|
||||
return (
|
||||
parsed_script.format()
|
||||
if statement.remove_unbounded_top_level_order_by()
|
||||
else sql
|
||||
)
|
||||
|
||||
|
||||
def _as_wall_clock(series: pd.Series) -> pd.Series:
|
||||
"""
|
||||
Return a datetime series as local wall-clock readings, dropping any
|
||||
@@ -3192,10 +3174,6 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
ex,
|
||||
)
|
||||
|
||||
from_sql = _normalize_mssql_virtual_dataset_sql(
|
||||
from_sql, parsed_script, self.db_engine_spec.engine
|
||||
)
|
||||
|
||||
cte = self.db_engine_spec.get_cte_query(from_sql)
|
||||
from_clause = (
|
||||
sa.table(self.db_engine_spec.cte_alias)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# 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.
|
||||
|
||||
"""Canonical resource and action mappings for scoped API keys."""
|
||||
|
||||
# Map FAB method permissions used by MCP tools to the coarser actions supported
|
||||
# by API-key scopes. Keep this explicit so an unknown permission fails closed.
|
||||
METHOD_PERMISSION_SCOPE_ACTION: dict[str, str] = {
|
||||
"read": "read",
|
||||
"get": "read",
|
||||
"write": "write",
|
||||
"update": "write",
|
||||
"delete": "write",
|
||||
"execute_sql_query": "write",
|
||||
}
|
||||
|
||||
# Map MCP/FAB class permission names to stable public resource slugs. These
|
||||
# cannot be derived by lowercasing because several names contain spaces or use
|
||||
# public spellings that differ from their internal class names.
|
||||
RESOURCE_SCOPE_NAME: dict[str, str] = {
|
||||
"Annotation": "annotation",
|
||||
"Chart": "chart",
|
||||
"Dashboard": "dashboard",
|
||||
"Database": "database",
|
||||
"Dataset": "dataset",
|
||||
"Explore": "explore",
|
||||
"Query": "query",
|
||||
"ReportSchedule": "report",
|
||||
"Role": "role",
|
||||
"Row Level Security": "rls",
|
||||
"SavedQuery": "savedquery",
|
||||
"SQLLab": "sqllab",
|
||||
"Tag": "tag",
|
||||
"Task": "task",
|
||||
"Theme": "theme",
|
||||
"User": "user",
|
||||
}
|
||||
|
||||
RESOURCE_SCOPE_CLASS: dict[str, str] = {
|
||||
resource: class_name for class_name, resource in RESOURCE_SCOPE_NAME.items()
|
||||
}
|
||||
RESOURCE_SCOPE_ACTIONS: frozenset[str] = frozenset(
|
||||
METHOD_PERMISSION_SCOPE_ACTION.values()
|
||||
)
|
||||
SCOPE_ACTION_METHOD_PERMISSIONS: dict[str, tuple[str, ...]] = {
|
||||
action: tuple(
|
||||
method
|
||||
for method, mapped_action in METHOD_PERMISSION_SCOPE_ACTION.items()
|
||||
if mapped_action == action
|
||||
)
|
||||
for action in RESOURCE_SCOPE_ACTIONS
|
||||
}
|
||||
|
||||
|
||||
def get_resource_scope(
|
||||
class_permission_name: str, method_permission_name: str
|
||||
) -> str | None:
|
||||
"""Return the resource scope required by a FAB class/method permission."""
|
||||
resource = RESOURCE_SCOPE_NAME.get(class_permission_name)
|
||||
action = METHOD_PERMISSION_SCOPE_ACTION.get(method_permission_name)
|
||||
if resource is None or action is None:
|
||||
return None
|
||||
return f"superset:{resource}:{action}"
|
||||
@@ -17,6 +17,7 @@
|
||||
# pylint: disable=too-many-lines
|
||||
"""A set of constants and methods to manage permissions and security"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
@@ -4926,6 +4927,123 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
raw_token, secret, algorithms=[algo], audience=audience
|
||||
)
|
||||
|
||||
def get_api_key_scopes(self, api_key_string: str) -> Optional[str]:
|
||||
"""Return the ``scopes`` value for a validated API key.
|
||||
|
||||
FAB's ``validate_api_key`` resolves the matching ``ApiKey`` row
|
||||
internally (by lookup hash) but only returns the associated
|
||||
``User`` — the row's ``scopes`` column is otherwise unreachable by
|
||||
callers. This repeats the same cheap, indexed lookup so MCP's
|
||||
``CompositeTokenVerifier`` can propagate per-key scopes instead of
|
||||
silently falling back to verifier-global scopes. Call only after
|
||||
``validate_api_key`` has already succeeded for this token — this
|
||||
method does not itself verify the key hash or active status.
|
||||
"""
|
||||
lookup = self._compute_lookup_hash(api_key_string) # type: ignore[attr-defined]
|
||||
api_key = (
|
||||
self.session.query(self.api_key_model) # type: ignore[attr-defined]
|
||||
.filter(self.api_key_model.lookup_hash == lookup)
|
||||
.one_or_none()
|
||||
)
|
||||
return api_key.scopes if api_key else None
|
||||
|
||||
def _validate_requested_api_key_scopes(
|
||||
self, user: Any, scopes: Optional[str]
|
||||
) -> None:
|
||||
"""Raise if ``scopes`` would grant a user more than their own RBAC.
|
||||
|
||||
Enforces the "intersection, never broader" rule confirmed for this
|
||||
feature: a user must never be able to mint a token scoped beyond
|
||||
what their own role already permits, even if they hand-author the
|
||||
scopes string themselves at issuance time.
|
||||
|
||||
Per-resource scopes (``superset:<resource>:<action>``) are checked
|
||||
against the user's actual ``can_<method>`` RBAC grant for that
|
||||
resource. Flat scopes (``superset:read``/``superset:write``, the
|
||||
pre-per-resource form) can only be self-issued by Admins — a flat
|
||||
scope grants a method across every resource, and there's no single
|
||||
RBAC check that soundly proves a non-Admin has that for "every
|
||||
resource," so it's rejected for anyone else rather than guessed at.
|
||||
Unrecognized scope strings are rejected outright (fail closed).
|
||||
|
||||
NOTE: this only prevents the request from being honored; it does
|
||||
not (yet) produce a clean 400 response, since FAB's ``ApiKeyApi``
|
||||
has no validation hook this can plug into without replacing the API
|
||||
registration entirely. Raising here surfaces as a 500 via FAB's
|
||||
``@safe`` decorator until that's addressed — tracked as a known
|
||||
follow-up, not silently accepted.
|
||||
"""
|
||||
if not scopes:
|
||||
return
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from superset.security.api_key_scopes import (
|
||||
RESOURCE_SCOPE_ACTIONS,
|
||||
RESOURCE_SCOPE_CLASS,
|
||||
SCOPE_ACTION_METHOD_PERMISSIONS,
|
||||
)
|
||||
|
||||
admin_role_name = get_conf()["AUTH_ROLE_ADMIN"]
|
||||
is_admin = any(
|
||||
role.name == admin_role_name for role in getattr(user, "roles", [])
|
||||
)
|
||||
for raw_scope in scopes.split(","):
|
||||
scope = raw_scope.strip()
|
||||
if not scope:
|
||||
continue
|
||||
parts = scope.split(":")
|
||||
if len(parts) == 3 and parts[0] == "superset":
|
||||
_, resource_slug, action = parts
|
||||
class_permission_name = RESOURCE_SCOPE_CLASS.get(resource_slug)
|
||||
if class_permission_name is None:
|
||||
raise ValueError(
|
||||
f"Requested scope '{scope}' names an unrecognized "
|
||||
f"resource '{resource_slug}'"
|
||||
)
|
||||
if action not in RESOURCE_SCOPE_ACTIONS:
|
||||
raise ValueError(
|
||||
f"Requested scope '{scope}' names an unrecognized "
|
||||
f"action '{action}'"
|
||||
)
|
||||
if any(
|
||||
self._has_view_access(user, f"can_{method}", class_permission_name)
|
||||
for method in SCOPE_ACTION_METHOD_PERMISSIONS[action]
|
||||
):
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Requested scope '{scope}' exceeds the issuing user's "
|
||||
"own permissions"
|
||||
)
|
||||
if (
|
||||
len(parts) == 2
|
||||
and parts[0] == "superset"
|
||||
and parts[1] in RESOURCE_SCOPE_ACTIONS
|
||||
and is_admin
|
||||
):
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Requested scope '{scope}' is not a recognized "
|
||||
"superset:<resource>:<action> scope, or requires Admin to "
|
||||
"self-issue as a flat scope"
|
||||
)
|
||||
|
||||
def create_api_key(
|
||||
self,
|
||||
user: Any,
|
||||
name: str,
|
||||
scopes: Optional[str] = None,
|
||||
expires_on: Optional[datetime.datetime] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Create a new API key, enforcing the scope-intersection rule.
|
||||
|
||||
Thin wrapper around FAB's ``SecurityManager.create_api_key`` — see
|
||||
``_validate_requested_api_key_scopes`` for the actual check. FAB's
|
||||
base implementation is otherwise unchanged.
|
||||
"""
|
||||
self._validate_requested_api_key_scopes(user, scopes)
|
||||
return super().create_api_key( # type: ignore[misc]
|
||||
user=user, name=name, scopes=scopes, expires_on=expires_on
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def is_guest_user(user: Optional[Any] = None) -> bool:
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
@@ -1451,18 +1451,6 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
|
||||
"""
|
||||
return bool(self._parsed.args.get("with_"))
|
||||
|
||||
def remove_unbounded_top_level_order_by(self) -> bool:
|
||||
"""Drop ordering that becomes invalid when this query is embedded."""
|
||||
if (
|
||||
self._parsed.args.get("order")
|
||||
and not self._parsed.args.get("limit")
|
||||
and not self._parsed.args.get("offset")
|
||||
and not self._parsed.args.get("for_")
|
||||
):
|
||||
self._parsed.set("order", None)
|
||||
return True
|
||||
return False
|
||||
|
||||
def as_cte(self, alias: str = "__cte") -> SQLStatement:
|
||||
"""
|
||||
Rewrite the statement as a CTE.
|
||||
|
||||
@@ -21,11 +21,10 @@ import logging
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from flask import current_app
|
||||
from flask import current_app, g
|
||||
from flask_appbuilder.security.sqla.models import User
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset.charts.data.form_data import set_form_data
|
||||
from superset.charts.schemas import ChartDataQueryContextSchema
|
||||
from superset.exceptions import (
|
||||
SupersetErrorException,
|
||||
@@ -48,6 +47,10 @@ query_timeout = current_app.config[
|
||||
] # TODO: new config key
|
||||
|
||||
|
||||
def set_form_data(form_data: dict[str, Any]) -> None:
|
||||
g.form_data = form_data
|
||||
|
||||
|
||||
def _create_query_context_from_form(form_data: dict[str, Any]) -> QueryContext:
|
||||
"""
|
||||
Create the query context from the form data.
|
||||
|
||||
@@ -1081,15 +1081,19 @@ msgstr "A-Z"
|
||||
msgid "AND"
|
||||
msgstr "Y"
|
||||
|
||||
#, fuzzy
|
||||
msgid "API Key Created"
|
||||
msgstr "Clave API creada"
|
||||
msgstr "se ha creado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "API Keys"
|
||||
msgstr "Claves API"
|
||||
msgstr "Clave privada"
|
||||
|
||||
#, fuzzy
|
||||
msgid "API key created successfully"
|
||||
msgstr "Clave API creada correctamente"
|
||||
msgstr "El informe se ha creado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "API key name is required"
|
||||
msgstr "El nombre de la clave API es obligatorio"
|
||||
|
||||
@@ -1169,9 +1173,9 @@ msgstr "Formato adaptativo"
|
||||
msgid "Add"
|
||||
msgstr "Añadir"
|
||||
|
||||
#, python-format
|
||||
#, fuzzy, python-format
|
||||
msgid "Add %s view(s)"
|
||||
msgstr "Añadir %s vista(s)"
|
||||
msgstr "%s opción(es)"
|
||||
|
||||
msgid "Add BCC Recipients"
|
||||
msgstr "Añadir destinatarios CCO"
|
||||
@@ -1210,8 +1214,9 @@ msgstr "Añadir rol"
|
||||
msgid "Add Rule"
|
||||
msgstr "Añadir regla"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Add Semantic View"
|
||||
msgstr "Añadir vista semántica"
|
||||
msgstr "Añadir un elemento"
|
||||
|
||||
msgid "Add Tag"
|
||||
msgstr "Añadir etiqueta"
|
||||
@@ -1788,8 +1793,9 @@ msgstr "Se ha producido un error al acceder al valor."
|
||||
msgid "An error occurred while accessing the value."
|
||||
msgstr "Se ha producido un error al acceder al valor."
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while adding semantic views"
|
||||
msgstr "Se ha producido un error al añadir las vistas semánticas"
|
||||
msgstr "Se ha producido un error al cargar el SQL"
|
||||
|
||||
msgid ""
|
||||
"An error occurred while collapsing the table schema. Please contact your "
|
||||
@@ -1812,8 +1818,9 @@ msgstr "Se ha producido un error al crear la fuente de datos"
|
||||
msgid "An error occurred while creating the extension."
|
||||
msgstr "Se ha producido un error al crear el valor."
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while creating the semantic layer"
|
||||
msgstr "Se ha producido un error al crear la capa semántica"
|
||||
msgstr "Se ha producido un error al crear el valor."
|
||||
|
||||
msgid "An error occurred while creating the value."
|
||||
msgstr "Se ha producido un error al crear el valor."
|
||||
@@ -1863,8 +1870,9 @@ msgstr "Se ha producido un error al recuperar las plantillas CSS disponibles"
|
||||
msgid "An error occurred while fetching available themes"
|
||||
msgstr "Se ha producido un error al recuperar las plantillas CSS disponibles"
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching available views"
|
||||
msgstr "Se ha producido un error al recuperar las vistas disponibles"
|
||||
msgstr "Se ha producido un error al recuperar las plantillas CSS disponibles"
|
||||
|
||||
#, python-format
|
||||
msgid "An error occurred while fetching chart editor values: %s"
|
||||
@@ -1948,8 +1956,9 @@ msgstr ""
|
||||
msgid "An error occurred while fetching schema values: %s"
|
||||
msgstr "Se ha producido un error al recuperar los valores del esquema: %s"
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching semantic layer types"
|
||||
msgstr "Se ha producido un error al recuperar los tipos de capa semántica"
|
||||
msgstr "Se ha producido un error al recuperar las plantillas CSS disponibles"
|
||||
|
||||
msgid "An error occurred while fetching semantic layers"
|
||||
msgstr "Se ha producido un error al recuperar las capas semánticas"
|
||||
@@ -1961,18 +1970,21 @@ msgstr "Se ha producido un error al recuperar el estado de la pestaña"
|
||||
msgid "An error occurred while fetching table metadata for %s"
|
||||
msgstr "Se ha producido un error al recuperar los metadatos de la tabla de %s"
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching the configuration schema"
|
||||
msgstr "Se ha producido un error al recuperar el esquema de configuración"
|
||||
msgstr "Se ha producido un error al recuperar los nombres de las funciones."
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching the runtime schema"
|
||||
msgstr "Se ha producido un error al recuperar el esquema de ejecución"
|
||||
msgstr "Se ha producido un error al recuperar los nombres de las funciones."
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching the semantic layer"
|
||||
msgstr "Se ha producido un error al recuperar la capa semántica"
|
||||
msgstr "Se ha producido un error al recuperar el estado de la pestaña"
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while fetching the semantic view structure"
|
||||
msgstr ""
|
||||
"Se ha producido un error al recuperar la estructura de la vista semántica"
|
||||
msgstr "Se ha producido un error al recuperar el estado de la pestaña"
|
||||
|
||||
#, fuzzy, python-format
|
||||
msgid "An error occurred while fetching theme datasource values: %s"
|
||||
@@ -2045,8 +2057,9 @@ msgstr ""
|
||||
msgid "An error occurred while rendering the visualization: %s"
|
||||
msgstr "Se ha producido un error al renderizar la visualización: %s"
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while saving the semantic view"
|
||||
msgstr "Se ha producido un error al guardar la vista semántica"
|
||||
msgstr "Se ha producido un error al cargar el SQL"
|
||||
|
||||
msgid "An error occurred while starring this chart"
|
||||
msgstr "Se ha producido un error al destacar este gráfico"
|
||||
@@ -2071,8 +2084,9 @@ msgstr ""
|
||||
msgid "An error occurred while updating the extension."
|
||||
msgstr "Se ha producido un error al actualizar el valor."
|
||||
|
||||
#, fuzzy
|
||||
msgid "An error occurred while updating the semantic layer"
|
||||
msgstr "Se ha producido un error al actualizar la capa semántica"
|
||||
msgstr "Se ha producido un error al actualizar el valor."
|
||||
|
||||
msgid "An error occurred while updating the value."
|
||||
msgstr "Se ha producido un error al actualizar el valor."
|
||||
@@ -2440,11 +2454,11 @@ msgstr ""
|
||||
"¿Está seguro de que desea eliminar el tema predeterminado del sistema? La"
|
||||
" aplicación volverá al predeterminado del archivo de configuración."
|
||||
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"Are you sure you want to revoke this API key? This action cannot be "
|
||||
"undone."
|
||||
msgstr ""
|
||||
"¿Seguro que quieres revocar esta clave API? Esta acción no se puede deshacer."
|
||||
msgstr "¿Seguro que quieres eliminar las anotaciones seleccionadas?"
|
||||
|
||||
msgid "Are you sure you want to save and apply changes?"
|
||||
msgstr "¿Seguro que quieres guardar y aplicar los cambios?"
|
||||
@@ -3308,11 +3322,13 @@ msgstr "No se permite cambiar esta fuente de datos"
|
||||
msgid "Changing this report is forbidden"
|
||||
msgstr "No se permite cambiar este informe"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Changing this semantic layer is forbidden"
|
||||
msgstr "No se permite cambiar esta capa semántica"
|
||||
msgstr "No se permite cambiar este gráfico"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Changing this semantic view is forbidden"
|
||||
msgstr "No se permite cambiar esta vista semántica"
|
||||
msgstr "No se permite cambiar este conjunto de datos"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Changing this task is forbidden"
|
||||
@@ -3775,8 +3791,9 @@ msgstr "Haz clic para ordenar de forma ascendente"
|
||||
msgid "Click to sort descending"
|
||||
msgstr "Haz clic para ordenar de forma descendente"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Client ID"
|
||||
msgstr "ID de cliente"
|
||||
msgstr "Anchura de la línea"
|
||||
|
||||
msgid "Client Secret"
|
||||
msgstr "Secreto de cliente"
|
||||
@@ -4439,8 +4456,9 @@ msgstr "Mapa del país"
|
||||
msgid "Create"
|
||||
msgstr "Crear"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Create API Key"
|
||||
msgstr "Crear clave API"
|
||||
msgstr "Creado por"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Create Tag"
|
||||
@@ -4786,8 +4804,9 @@ msgstr "No se ha encontrado el panel de control %(dashboard_id)s"
|
||||
msgid "Dashboard Filter"
|
||||
msgstr "Titulo del panel de control"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Dashboard Id"
|
||||
msgstr "ID del panel de control"
|
||||
msgstr "panel de control"
|
||||
|
||||
#, python-format
|
||||
msgid "Dashboard [%s] just got created and chart [%s] was added to it"
|
||||
@@ -5417,8 +5436,9 @@ msgstr ""
|
||||
"Define si el paso debe aparecer al principio, en el medio o al final "
|
||||
"entre dos puntos de datos"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Definition"
|
||||
msgstr "Definición"
|
||||
msgstr "desviación"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, ja, sr,
|
||||
# sr_Latn]
|
||||
@@ -5458,11 +5478,13 @@ msgstr "¿Eliminar el informe?"
|
||||
msgid "Delete Role?"
|
||||
msgstr "¿Eliminar el rol?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Semantic Layer?"
|
||||
msgstr "¿Eliminar la capa semántica?"
|
||||
msgstr "¿Eliminar la capa?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Semantic View?"
|
||||
msgstr "¿Eliminar la vista semántica?"
|
||||
msgstr "¿Eliminar la plantilla?"
|
||||
|
||||
msgid "Delete Template?"
|
||||
msgstr "¿Eliminar la plantilla?"
|
||||
@@ -5956,8 +5978,9 @@ msgstr "Dominio"
|
||||
msgid "Don't refresh"
|
||||
msgstr "Datos actualizados"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Done"
|
||||
msgstr "Listo"
|
||||
msgstr "Ninguno"
|
||||
|
||||
msgid "Donut"
|
||||
msgstr "Dónut"
|
||||
@@ -6188,8 +6211,9 @@ msgstr "Duración en ms (10500 => 0:00:10.5)"
|
||||
msgid "Duration in ms (66000 => 1m 6s)"
|
||||
msgstr "Duración en ms (66 000 => 1 m 6 s)"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Duration in seconds"
|
||||
msgstr "Duración en segundos"
|
||||
msgstr "Introduce la duración en segundos"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: ru]
|
||||
#, fuzzy
|
||||
@@ -6930,8 +6954,9 @@ msgstr ""
|
||||
msgid "Experimental"
|
||||
msgstr "Experimental"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Expired"
|
||||
msgstr "Caducado"
|
||||
msgstr "Explorar"
|
||||
|
||||
msgid "Explore"
|
||||
msgstr "Explorar"
|
||||
@@ -7054,8 +7079,9 @@ msgstr "Exponer base de datos en SQL Lab"
|
||||
msgid "Expose in SQL Lab"
|
||||
msgstr "Exponer en SQL Lab"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Expression"
|
||||
msgstr "Expresión"
|
||||
msgstr "Expresión SQL"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Expression cannot be empty"
|
||||
@@ -7145,15 +7171,17 @@ msgstr "No se han podido recuperar los resultados"
|
||||
msgid "Failed to apply theme: Invalid JSON"
|
||||
msgstr "Error al aplicar el tema: JSON no válido"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to copy API key to clipboard"
|
||||
msgstr "No se ha podido copiar la clave API al portapapeles"
|
||||
msgstr "Copiar consulta de partición al portapapeles"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to copy stack trace to clipboard"
|
||||
msgstr "Copiar al portapapeles"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to create API key"
|
||||
msgstr "No se ha podido crear la clave API"
|
||||
msgstr "No se ha podido generar el informe"
|
||||
|
||||
msgid "Failed to create report"
|
||||
msgstr "No se ha podido generar el informe"
|
||||
@@ -7179,8 +7207,9 @@ msgid ""
|
||||
"administrator."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to fetch API keys"
|
||||
msgstr "No se han podido recuperar las claves API"
|
||||
msgstr "No se han podido etiquetar los elementos"
|
||||
|
||||
msgid "Failed to generate chart edit URL"
|
||||
msgstr "No se ha podido generar la URL de edición del gráfico"
|
||||
@@ -7228,8 +7257,9 @@ msgstr ""
|
||||
msgid "Failed to retrieve advanced type"
|
||||
msgstr "No se ha podido recuperar el tipo avanzado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to revoke API key"
|
||||
msgstr "No se ha podido revocar la clave API"
|
||||
msgstr "No se ha podido detener la consulta."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to save chart customization"
|
||||
@@ -7836,8 +7866,9 @@ msgstr "Nombre y URL de la hoja de cálculo de Google"
|
||||
msgid "Grace period"
|
||||
msgstr "Periodo de gracia"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Grain"
|
||||
msgstr "Granularidad"
|
||||
msgstr "Granularidad temporal"
|
||||
|
||||
msgid "Graph Chart"
|
||||
msgstr "Gráfico"
|
||||
@@ -8199,8 +8230,9 @@ msgstr ""
|
||||
msgid "In this view you can preview the first 25 rows. "
|
||||
msgstr "En esta vista puede obtener una vista previa de las primeras 25 filas. "
|
||||
|
||||
#, fuzzy
|
||||
msgid "Inactive"
|
||||
msgstr "Inactivo"
|
||||
msgstr "Activo"
|
||||
|
||||
msgid "Include Series"
|
||||
msgstr "Incluir series"
|
||||
@@ -8622,8 +8654,9 @@ msgstr "Continuar editando"
|
||||
msgid "Key"
|
||||
msgstr "Clave"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Key Prefix"
|
||||
msgstr "Prefijo de la clave"
|
||||
msgstr "Prefijo"
|
||||
|
||||
msgid "Keyboard shortcuts"
|
||||
msgstr "Accesos rápidos de teclado"
|
||||
@@ -8744,8 +8777,9 @@ msgstr "Última actualización %s"
|
||||
msgid "Last Updated %s by %s"
|
||||
msgstr "Última actualización %s por %s"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Last Used"
|
||||
msgstr "Último uso"
|
||||
msgstr "Listar usuarios"
|
||||
|
||||
#, python-format
|
||||
msgid "Last available value seen on %s"
|
||||
@@ -9258,8 +9292,9 @@ msgstr ""
|
||||
msgid "Mapbox"
|
||||
msgstr "Mapbox"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Mapbox (API key required)"
|
||||
msgstr "Mapbox (requiere clave API)"
|
||||
msgstr "El correo electrónico es obligatorio"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
@@ -9826,8 +9861,9 @@ msgstr "Nombre de la columna que contiene el ID del nodo primario"
|
||||
msgid "Name of the id column"
|
||||
msgstr "Nombre de la columna de ID"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Name of the semantic layer"
|
||||
msgstr "Nombre de la capa semántica"
|
||||
msgstr "Nombre de la columna de ID"
|
||||
|
||||
msgid "Name of the source nodes"
|
||||
msgstr "Nombre de los nodos de origen"
|
||||
@@ -9878,9 +9914,9 @@ msgstr "Error de red."
|
||||
msgid "New"
|
||||
msgstr "Ahora"
|
||||
|
||||
#, -ERR:PROP-NOT-FOUND-
|
||||
#, -ERR:PROP-NOT-FOUND-, fuzzy
|
||||
msgid "New Semantic Layer"
|
||||
msgstr "Nueva capa semántica"
|
||||
msgstr "No hay capas de anotación"
|
||||
|
||||
msgid "New chart"
|
||||
msgstr "Nuevo gráfico"
|
||||
@@ -11958,11 +11994,13 @@ msgstr "Lat. y long. inversas"
|
||||
msgid "Reverse lat/long "
|
||||
msgstr "Lat./long. inversa "
|
||||
|
||||
#, fuzzy
|
||||
msgid "Revoke"
|
||||
msgstr "Revocar"
|
||||
msgstr "Eliminar"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Revoke API Key"
|
||||
msgstr "Revocar clave API"
|
||||
msgstr "Clave de grupo"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, ja, sr,
|
||||
# sr_Latn]
|
||||
@@ -11970,8 +12008,9 @@ msgstr "Revocar clave API"
|
||||
msgid "Revoke this API key"
|
||||
msgstr "Revocar esta clave API"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Revoked"
|
||||
msgstr "Revocado"
|
||||
msgstr "Trazado"
|
||||
|
||||
msgid "Rich Tooltip"
|
||||
msgstr "Información sobre herramientas mejorada"
|
||||
@@ -12689,11 +12728,13 @@ msgstr "Selecciona un esquema"
|
||||
msgid "Select a schema if the database supports this"
|
||||
msgstr "Selecciona un esquema si la base de datos lo admite"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select a semantic layer"
|
||||
msgstr "Selecciona una capa semántica"
|
||||
msgstr "Selecciona un esquema"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select a semantic layer type"
|
||||
msgstr "Selecciona un tipo de capa semántica"
|
||||
msgstr "Seleccionar tipo de visualización"
|
||||
|
||||
msgid "Select a sheet name from the uploaded file"
|
||||
msgstr "Seleccione un nombre de hoja del archivo subido"
|
||||
@@ -12931,8 +12972,9 @@ msgstr "Selecciona un esquema"
|
||||
msgid "Select scheme"
|
||||
msgstr "Seleccionar esquema"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select semantic views"
|
||||
msgstr "Selecciona las vistas semánticas"
|
||||
msgstr "Seleccionar esquema"
|
||||
|
||||
msgid ""
|
||||
"Select shape for computing values. \"FIXED\" sets all zoom levels to the "
|
||||
@@ -13087,11 +13129,13 @@ msgstr "La selección de una base de datos es obligatoria"
|
||||
msgid "Selection method"
|
||||
msgstr "Selecciona el método de entrega"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic"
|
||||
msgstr "Semántico"
|
||||
msgstr "Correo electrónico"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic Layer"
|
||||
msgstr "Capa semántica"
|
||||
msgstr "Capa de anotación"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
@@ -13105,47 +13149,61 @@ msgstr "Vista semántica"
|
||||
msgid "Semantic Views"
|
||||
msgstr "Vistas semánticas"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer"
|
||||
msgstr "Capa semántica"
|
||||
msgstr "Capa de anotación"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer could not be created."
|
||||
msgstr "No se ha podido crear la capa semántica."
|
||||
msgstr "No se ha podido crear la capa de anotación."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer could not be deleted."
|
||||
msgstr "No se ha podido eliminar la capa semántica."
|
||||
msgstr "No se han podido eliminar las capas de anotación."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer could not be updated."
|
||||
msgstr "No se ha podido actualizar la capa semántica."
|
||||
msgstr "No se ha podido actualizar la capa de anotación."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer created"
|
||||
msgstr "Capa semántica creada"
|
||||
msgstr "Se ha creado la plantilla de anotación"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer does not exist"
|
||||
msgstr "La capa semántica no existe"
|
||||
msgstr "El gráfico no existe"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer parameters are invalid."
|
||||
msgstr "Los parámetros de la capa semántica no son válidos."
|
||||
msgstr "Los parámetros de la capa de anotación no son válidos."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer type"
|
||||
msgstr "Tipo de capa semántica"
|
||||
msgstr "Tipo de la capa de anotación"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic layer updated"
|
||||
msgstr "Capa semántica actualizada"
|
||||
msgstr "Se ha actualizado la plantilla de anotación"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic view could not be created."
|
||||
msgstr "No se ha podido crear la vista semántica."
|
||||
msgstr "No se ha podido crear el conjunto de datos."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic view could not be deleted."
|
||||
msgstr "No se ha podido eliminar la vista semántica."
|
||||
msgstr "No se han podido eliminar las plantillas CSS."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic view could not be updated."
|
||||
msgstr "No se ha podido actualizar la vista semántica."
|
||||
msgstr "No se ha podido actualizar el conjunto de datos."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic view does not exist"
|
||||
msgstr "La vista semántica no existe"
|
||||
msgstr "El conjunto de datos no existe"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Semantic view parameters are invalid."
|
||||
msgstr "Los parámetros de la vista semántica no son válidos."
|
||||
msgstr "Los parámetros del conjunto de datos no son válidos."
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
@@ -13714,8 +13772,9 @@ msgstr "Omitir espacios después del delimitador"
|
||||
msgid "Skipped %d system themes that cannot be deleted"
|
||||
msgstr "Se omitieron %d temas del sistema que no se pueden eliminar"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Slice Id"
|
||||
msgstr "ID del gráfico"
|
||||
msgstr "Anchura de la línea"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Slider"
|
||||
@@ -13958,8 +14017,9 @@ msgstr "Fuente SQL"
|
||||
msgid "Source category"
|
||||
msgstr "Categoría de fuente"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Source location"
|
||||
msgstr "Ubicación de origen"
|
||||
msgstr "Categoría de fuente"
|
||||
|
||||
msgid "Sparkline"
|
||||
msgstr "Minigráfica"
|
||||
@@ -18806,8 +18866,9 @@ msgstr "No tienes acceso a este conjunto de datos."
|
||||
msgid "You don't have access to this embedded dashboard config."
|
||||
msgstr "No tienes acceso a la configuración de este panel de control incrustado."
|
||||
|
||||
#, fuzzy
|
||||
msgid "You don't have access to this semantic view."
|
||||
msgstr "No tienes acceso a esta vista semántica."
|
||||
msgstr "No tienes acceso a este conjunto de datos."
|
||||
|
||||
#, fuzzy
|
||||
msgid "You don't have permission to copy to clipboard"
|
||||
|
||||
@@ -76,11 +76,9 @@ def guest_embedded_dashboard_filter() -> Optional[ColumnElement[bool]]:
|
||||
# TODO (embedded): only use the uuid filter once uuids are rolled out
|
||||
# A guest token may mix uuid and int dashboard ids during the uuid rollout.
|
||||
# Route each id kind to its own column and OR them — a plain int sent to the
|
||||
# uuid-typed column would raise a bind/type error. Match only the id kinds
|
||||
# has_guest_access authorizes (uuid, decimal id); a slug is fail-closed on
|
||||
# the data path, so matching it here would be moot.
|
||||
# uuid-typed column would raise a bind/type error.
|
||||
uuid_ids = [id_ for id_ in ids if is_uuid(id_)]
|
||||
int_ids = [id_ for id_ in ids if not is_uuid(id_) and str(id_).isdigit()]
|
||||
int_ids = [id_ for id_ in ids if not is_uuid(id_)]
|
||||
conditions: list[Any] = []
|
||||
if uuid_ids:
|
||||
conditions.append(Dashboard.embedded.any(EmbeddedDashboard.uuid.in_(uuid_ids)))
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
# isort:skip_file
|
||||
"""Unit tests for Superset"""
|
||||
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
from unittest.mock import Mock, patch
|
||||
@@ -606,7 +605,10 @@ class TestSavedQueryApi(SupersetTestCase):
|
||||
db.session.query(SavedQuery).filter(SavedQuery.label == "label1").all()[0]
|
||||
)
|
||||
self.login(ADMIN_USERNAME)
|
||||
with freeze_time(datetime.now()):
|
||||
# Freeze relative to the persisted timestamp so database-specific
|
||||
# timestamp precision cannot make the humanized value age into the
|
||||
# next bucket while the request is being handled.
|
||||
with freeze_time(saved_query.changed_on):
|
||||
uri = f"api/v1/saved_query/{saved_query.id}"
|
||||
rv = self.get_assert_metric(uri, "get")
|
||||
assert rv.status_code == 200
|
||||
|
||||
@@ -21,7 +21,6 @@ import pandas as pd
|
||||
import pytest
|
||||
from flask import current_app
|
||||
from flask_babel import gettext as __
|
||||
from jinja2.exceptions import TemplateError, TemplateSyntaxError
|
||||
|
||||
from superset import db, sql_lab
|
||||
from superset.commands.sql_lab import estimate, export, results
|
||||
@@ -406,29 +405,6 @@ class TestSqlExecutionResultsCommand(SupersetTestCase):
|
||||
)
|
||||
assert ex_info.value.status == 403
|
||||
|
||||
@pytest.mark.usefixtures("create_database_and_query")
|
||||
@patch("superset.commands.sql_lab.results.results_backend_use_msgpack", False)
|
||||
def test_validation_malformed_jinja(self) -> None:
|
||||
# ``raise_for_access`` re-parses the query's unrendered Jinja via
|
||||
# ``process_jinja_sql`` and can raise a raw ``TemplateError`` (e.g. an
|
||||
# unclosed ``{% if %}``). ``TemplateSyntaxError`` is a subclass of
|
||||
# ``TemplateError``. It must surface as a 400, not an opaque 500.
|
||||
assert issubclass(TemplateSyntaxError, TemplateError)
|
||||
|
||||
command = results.SqlExecutionResultsCommand("abc_query", 1000)
|
||||
|
||||
with mock.patch(
|
||||
"superset.models.sql_lab.Query.raise_for_access",
|
||||
side_effect=TemplateSyntaxError("unexpected end of template", lineno=1),
|
||||
):
|
||||
with pytest.raises(SupersetErrorException) as ex_info:
|
||||
command.run()
|
||||
assert (
|
||||
ex_info.value.error.error_type
|
||||
== SupersetErrorType.GENERIC_COMMAND_ERROR
|
||||
)
|
||||
assert ex_info.value.status == 400
|
||||
|
||||
@pytest.mark.usefixtures("create_database_and_query")
|
||||
@patch("superset.commands.sql_lab.results.results_backend_use_msgpack", False)
|
||||
def test_run_succeeds(self) -> None:
|
||||
|
||||
@@ -19,14 +19,12 @@
|
||||
import copy
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from flask_appbuilder.security.sqla.models import Role, User
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from superset import security_manager
|
||||
@@ -508,71 +506,3 @@ def test_import_tag_logic_for_charts(session_with_schema: Session):
|
||||
.all()
|
||||
)
|
||||
assert len(associated_tags) == 0
|
||||
|
||||
|
||||
def test_import_tag_savepoint_keeps_session_usable(
|
||||
mocker: MockerFixture, session_with_schema: Session
|
||||
) -> None:
|
||||
"""
|
||||
When a single tag operation fails with a SQLAlchemyError (e.g. a unique
|
||||
constraint violation from a concurrent import), the per-tag SAVEPOINT
|
||||
isolates the failure so the session is not left in a pending-rollback
|
||||
state, and the remaining tags still import successfully.
|
||||
"""
|
||||
contents = {
|
||||
"tags.yaml": yaml.dump(
|
||||
{
|
||||
"tags": [
|
||||
{"tag_name": "tag_1", "description": "Description for tag_1"},
|
||||
{"tag_name": "tag_2", "description": "Description for tag_2"},
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
object_id = 1
|
||||
object_type = "chart"
|
||||
|
||||
# Simulate a unique-constraint violation discovered when the first
|
||||
# TaggedObject's SAVEPOINT is flushed (e.g. a concurrent import already
|
||||
# created the same association) -- not synchronously from Session.add().
|
||||
# `import_tag`'s own pre-insert existence check would normally catch a
|
||||
# real duplicate row, so the failure is injected at the point SQLAlchemy
|
||||
# actually persists the pending row: `begin_nested()`'s implicit flush
|
||||
# on a successful `with` exit, which calls `Session.flush()` directly
|
||||
# (see `SessionTransaction._prepare_impl`).
|
||||
pending_tagged_objects: list[TaggedObject] = []
|
||||
original_add = session_with_schema.add
|
||||
|
||||
def tracking_add(obj: object) -> None:
|
||||
if isinstance(obj, TaggedObject):
|
||||
pending_tagged_objects.append(obj)
|
||||
original_add(obj)
|
||||
|
||||
original_flush = session_with_schema.flush
|
||||
|
||||
def flaky_flush(*args: Any, **kwargs: Any) -> None:
|
||||
# Only the first TaggedObject ever added should fail, and only while
|
||||
# it's still pending -- once its SAVEPOINT rolls back, SQLAlchemy
|
||||
# expunges it from the session, so this does not also fail tag_2's
|
||||
# flush.
|
||||
if (
|
||||
pending_tagged_objects
|
||||
and pending_tagged_objects[0] is not None
|
||||
and pending_tagged_objects[0] in session_with_schema.new
|
||||
):
|
||||
raise SQLAlchemyError("UNIQUE constraint failed: tagged_object")
|
||||
original_flush(*args, **kwargs)
|
||||
|
||||
mocker.patch.object(session_with_schema, "add", side_effect=tracking_add)
|
||||
mocker.patch.object(session_with_schema, "flush", side_effect=flaky_flush)
|
||||
|
||||
with patch.object(feature_flag_manager, "is_feature_enabled", return_value=True):
|
||||
new_tag_ids = import_tag(
|
||||
["tag_1", "tag_2"], contents, object_id, object_type, session_with_schema
|
||||
)
|
||||
|
||||
# tag_1 failed on the unique violation, but tag_2 succeeded.
|
||||
assert len(new_tag_ids) == 1
|
||||
# The session is still usable — no PendingRollbackError.
|
||||
assert session_with_schema.query(TaggedObject).count() == 1
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# 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 flask import current_app, g
|
||||
|
||||
from superset.charts.data.form_data import set_form_data
|
||||
|
||||
|
||||
def test_set_form_data_exposes_payload_on_flask_global() -> None:
|
||||
"""The shared helper publishes form data for request-independent queries."""
|
||||
payload: dict[str, Any] = {"queries": [{"filters": []}]}
|
||||
|
||||
with current_app.test_request_context():
|
||||
set_form_data(payload)
|
||||
|
||||
assert g.form_data is payload
|
||||
@@ -434,7 +434,6 @@ class TestMapBigNumberConfig:
|
||||
|
||||
assert form_data["adhoc_filters"][0]["subject"] == "order_date"
|
||||
assert form_data["adhoc_filters"][0]["operator"] == "TEMPORAL_RANGE"
|
||||
assert form_data["_mcp_dashboard_time_filter_subject"] == "order_date"
|
||||
mock_find_by_id_or_uuid.assert_called_once_with("42")
|
||||
|
||||
@patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal")
|
||||
|
||||
@@ -2049,25 +2049,6 @@ class TestValidateChartDataset:
|
||||
assert result.dataset_id == 7
|
||||
assert result.dataset_name == "my_table"
|
||||
assert result.warnings == []
|
||||
# check_access=True keeps the DatasourceFilter base filter and the RBAC check.
|
||||
mock_find.assert_called_once_with(7, skip_base_filter=False)
|
||||
mock_access.assert_called_once_with(dataset)
|
||||
|
||||
@patch("superset.mcp_service.auth.has_dataset_access")
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
def test_validate_chart_dataset_no_access_check_is_existence_only(
|
||||
self, mock_find: MagicMock, mock_access: MagicMock
|
||||
) -> None:
|
||||
"""check_access=False (embedded guests) resolves the dataset with the base
|
||||
filter skipped and never runs the RBAC check. DatasetDAO's DatasourceFilter
|
||||
returns nothing for a principal without datasource grants, so leaving it on
|
||||
would turn the existence check into an access check and deny every guest."""
|
||||
dataset = MagicMock(table_name="orders", sql=None)
|
||||
mock_find.return_value = dataset
|
||||
result = validate_chart_dataset(7, check_access=False)
|
||||
assert result.is_valid
|
||||
mock_find.assert_called_once_with(7, skip_base_filter=True)
|
||||
mock_access.assert_not_called()
|
||||
|
||||
@patch("superset.mcp_service.auth.has_dataset_access", return_value=True)
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
|
||||
@@ -1,567 +0,0 @@
|
||||
# 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.
|
||||
|
||||
"""Regression tests for MCP chart dashboard time-range binding."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.common.query_context_factory import QueryContextFactory
|
||||
from superset.common.query_object import QueryObject
|
||||
from superset.mcp_service.chart.chart_utils import (
|
||||
_bind_dashboard_time_range_filter,
|
||||
adhoc_filters_to_query_filters,
|
||||
map_config_to_form_data,
|
||||
)
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
BigNumberChartConfig,
|
||||
BoxPlotChartConfig,
|
||||
ChartConfig,
|
||||
ColumnRef,
|
||||
HandlebarsChartConfig,
|
||||
HistogramChartConfig,
|
||||
MixedTimeseriesChartConfig,
|
||||
PieChartConfig,
|
||||
PivotTableChartConfig,
|
||||
TableChartConfig,
|
||||
WaterfallChartConfig,
|
||||
XYChartConfig,
|
||||
)
|
||||
from superset.mcp_service.chart.validation.dataset_validator import (
|
||||
build_dataset_context_from_orm,
|
||||
DatasetValidator,
|
||||
)
|
||||
from superset.mcp_service.common.error_schemas import DatasetContext
|
||||
from superset.utils.core import GenericDataType, merge_extra_form_data
|
||||
|
||||
METRIC = ColumnRef(name="revenue", aggregate="SUM")
|
||||
CATEGORY = ColumnRef(name="region")
|
||||
|
||||
|
||||
def _chart_configs() -> list[ChartConfig]:
|
||||
return [
|
||||
BigNumberChartConfig(chart_type="big_number", metric=METRIC),
|
||||
BoxPlotChartConfig(metrics=[METRIC], distribute_across=[CATEGORY]),
|
||||
HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="{{#each data}}{{region}}{{/each}}",
|
||||
groupby=[CATEGORY],
|
||||
metrics=[METRIC],
|
||||
),
|
||||
HistogramChartConfig(column=ColumnRef(name="duration")),
|
||||
MixedTimeseriesChartConfig(
|
||||
x=CATEGORY,
|
||||
y=[METRIC],
|
||||
y_secondary=[ColumnRef(name="orders", aggregate="COUNT")],
|
||||
),
|
||||
PieChartConfig(dimension=CATEGORY, metric=METRIC),
|
||||
PivotTableChartConfig(rows=[CATEGORY], metrics=[METRIC]),
|
||||
TableChartConfig(columns=[CATEGORY, METRIC]),
|
||||
WaterfallChartConfig(
|
||||
x_axis=ColumnRef(name="event_time"),
|
||||
metric=METRIC,
|
||||
),
|
||||
XYChartConfig(x=CATEGORY, y=[METRIC]),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config", _chart_configs())
|
||||
def test_every_chart_config_accepts_temporal_column(config: ChartConfig) -> None:
|
||||
updated = type(config).model_validate(
|
||||
{**config.model_dump(), "temporal_column": "created_at"}
|
||||
)
|
||||
|
||||
assert updated.temporal_column == "created_at"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "expected_subject"),
|
||||
[
|
||||
(
|
||||
BoxPlotChartConfig(metrics=[METRIC], distribute_across=[CATEGORY]),
|
||||
"order_date",
|
||||
),
|
||||
(
|
||||
HandlebarsChartConfig(
|
||||
chart_type="handlebars",
|
||||
handlebars_template="{{region}}",
|
||||
groupby=[CATEGORY],
|
||||
metrics=[METRIC],
|
||||
),
|
||||
"order_date",
|
||||
),
|
||||
(HistogramChartConfig(column=ColumnRef(name="duration")), "order_date"),
|
||||
(
|
||||
MixedTimeseriesChartConfig(
|
||||
x=CATEGORY,
|
||||
y=[METRIC],
|
||||
y_secondary=[ColumnRef(name="orders", aggregate="COUNT")],
|
||||
),
|
||||
"order_date",
|
||||
),
|
||||
(PieChartConfig(dimension=CATEGORY, metric=METRIC), "order_date"),
|
||||
(PivotTableChartConfig(rows=[CATEGORY], metrics=[METRIC]), "order_date"),
|
||||
(TableChartConfig(columns=[CATEGORY, METRIC]), "order_date"),
|
||||
(
|
||||
WaterfallChartConfig(
|
||||
x_axis=ColumnRef(name="event_time"),
|
||||
metric=METRIC,
|
||||
),
|
||||
"event_time",
|
||||
),
|
||||
(XYChartConfig(x=CATEGORY, y=[METRIC]), "order_date"),
|
||||
],
|
||||
)
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid")
|
||||
@patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal")
|
||||
def test_unbound_charts_get_dashboard_temporal_filter(
|
||||
mock_is_temporal: MagicMock,
|
||||
mock_find_dataset: MagicMock,
|
||||
config: ChartConfig,
|
||||
expected_subject: str,
|
||||
) -> None:
|
||||
mock_find_dataset.return_value = SimpleNamespace(main_dttm_col="order_date")
|
||||
mock_is_temporal.side_effect = lambda column, dataset_id, dataset=None: column in {
|
||||
"created_at",
|
||||
"event_time",
|
||||
"order_date",
|
||||
}
|
||||
|
||||
form_data = map_config_to_form_data(config, dataset_id=42)
|
||||
|
||||
temporal_filters = [
|
||||
filter_
|
||||
for filter_ in form_data["adhoc_filters"]
|
||||
if filter_["operator"] == "TEMPORAL_RANGE"
|
||||
]
|
||||
assert temporal_filters == [
|
||||
{
|
||||
"clause": "WHERE",
|
||||
"expressionType": "SIMPLE",
|
||||
"subject": expected_subject,
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"comparator": "No filter",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
TableChartConfig(
|
||||
columns=[CATEGORY, METRIC],
|
||||
temporal_column="created_at",
|
||||
),
|
||||
WaterfallChartConfig(
|
||||
x_axis=CATEGORY,
|
||||
metric=METRIC,
|
||||
time_grain="P1D",
|
||||
temporal_column="created_at",
|
||||
),
|
||||
BigNumberChartConfig(
|
||||
chart_type="big_number",
|
||||
metric=METRIC,
|
||||
temporal_column="created_at",
|
||||
),
|
||||
],
|
||||
)
|
||||
@patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal")
|
||||
def test_explicit_temporal_column_takes_precedence(
|
||||
mock_is_temporal: MagicMock,
|
||||
config: ChartConfig,
|
||||
) -> None:
|
||||
mock_is_temporal.return_value = True
|
||||
|
||||
form_data = map_config_to_form_data(config, dataset_id=42)
|
||||
|
||||
assert form_data["adhoc_filters"][0]["subject"] == "created_at"
|
||||
|
||||
|
||||
@patch(
|
||||
"superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
|
||||
return_value=True,
|
||||
)
|
||||
@patch(
|
||||
"superset.mcp_service.chart.chart_utils._find_dataset_by_id_or_uuid",
|
||||
return_value=SimpleNamespace(main_dttm_col="event_time"),
|
||||
)
|
||||
def test_temporal_xy_binding_records_generated_subject(
|
||||
mock_find_dataset: MagicMock,
|
||||
mock_is_temporal: MagicMock,
|
||||
) -> None:
|
||||
form_data = map_config_to_form_data(
|
||||
XYChartConfig(x=ColumnRef(name="event_time"), y=[METRIC]),
|
||||
dataset_id=42,
|
||||
)
|
||||
|
||||
assert form_data["_mcp_dashboard_time_filter_subject"] == "event_time"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
XYChartConfig(
|
||||
x=ColumnRef(name="event_time"),
|
||||
y=[METRIC],
|
||||
temporal_column="created_at",
|
||||
),
|
||||
MixedTimeseriesChartConfig(
|
||||
x=ColumnRef(name="event_time"),
|
||||
y=[METRIC],
|
||||
y_secondary=[ColumnRef(name="orders", aggregate="COUNT")],
|
||||
temporal_column="created_at",
|
||||
),
|
||||
WaterfallChartConfig(
|
||||
x_axis=CATEGORY,
|
||||
metric=METRIC,
|
||||
time_grain="P1D",
|
||||
temporal_column="created_at",
|
||||
),
|
||||
],
|
||||
)
|
||||
@patch(
|
||||
"superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
|
||||
return_value=True,
|
||||
)
|
||||
def test_explicit_temporal_column_overrides_temporal_granularity(
|
||||
mock_is_temporal: MagicMock,
|
||||
config: ChartConfig,
|
||||
) -> None:
|
||||
form_data = map_config_to_form_data(config, dataset_id=42)
|
||||
|
||||
# QueryContextFactory gives granularity precedence over temporal filters, so
|
||||
# retaining event_time here would silently bind the range to both columns.
|
||||
assert form_data["granularity_sqla"] is None
|
||||
assert form_data["adhoc_filters"] == [
|
||||
{
|
||||
"clause": "WHERE",
|
||||
"expressionType": "SIMPLE",
|
||||
"subject": "created_at",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"comparator": "No filter",
|
||||
}
|
||||
]
|
||||
|
||||
query_object = QueryObject(
|
||||
columns=[form_data.get("x_axis") or "event_time"],
|
||||
filters=cast(Any, adhoc_filters_to_query_filters(form_data["adhoc_filters"])),
|
||||
granularity=form_data["granularity_sqla"],
|
||||
time_range="Last week",
|
||||
)
|
||||
datasource = SimpleNamespace(
|
||||
columns=[
|
||||
SimpleNamespace(column_name="event_time", is_dttm=True),
|
||||
SimpleNamespace(column_name="created_at", is_dttm=True),
|
||||
],
|
||||
main_dttm_col="event_time",
|
||||
currency_code_column=None,
|
||||
)
|
||||
|
||||
processed = QueryContextFactory()._process_query_object(
|
||||
datasource, form_data, query_object
|
||||
)
|
||||
|
||||
assert processed.granularity is None
|
||||
assert processed.filter == [
|
||||
{"col": "created_at", "op": "TEMPORAL_RANGE", "val": "Last week"}
|
||||
]
|
||||
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid")
|
||||
@patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal")
|
||||
def test_non_temporal_waterfall_granularity_falls_back_to_dataset_time_column(
|
||||
mock_is_temporal: MagicMock,
|
||||
mock_find_dataset: MagicMock,
|
||||
) -> None:
|
||||
mock_find_dataset.return_value = SimpleNamespace(main_dttm_col="order_date")
|
||||
mock_is_temporal.side_effect = (
|
||||
lambda column, dataset_id, dataset=None: column == "order_date"
|
||||
)
|
||||
config = WaterfallChartConfig(
|
||||
x_axis=CATEGORY,
|
||||
metric=METRIC,
|
||||
time_grain="P1D",
|
||||
)
|
||||
|
||||
form_data = map_config_to_form_data(config, dataset_id=42)
|
||||
|
||||
assert form_data["granularity_sqla"] == "region"
|
||||
assert form_data["adhoc_filters"] == [
|
||||
{
|
||||
"clause": "WHERE",
|
||||
"expressionType": "SIMPLE",
|
||||
"subject": "order_date",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"comparator": "No filter",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
TableChartConfig(
|
||||
columns=[CATEGORY, METRIC],
|
||||
temporal_column="fiscal_year",
|
||||
),
|
||||
BigNumberChartConfig(
|
||||
chart_type="big_number",
|
||||
metric=METRIC,
|
||||
temporal_column="fiscal_year",
|
||||
),
|
||||
],
|
||||
)
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid")
|
||||
@patch("superset.mcp_service.chart.chart_utils.is_column_truly_temporal")
|
||||
def test_explicit_non_temporal_column_does_not_fall_back(
|
||||
mock_is_temporal: MagicMock,
|
||||
mock_find_dataset: MagicMock,
|
||||
config: ChartConfig,
|
||||
) -> None:
|
||||
mock_find_dataset.return_value = SimpleNamespace(main_dttm_col="order_date")
|
||||
mock_is_temporal.return_value = False
|
||||
|
||||
form_data = map_config_to_form_data(config, dataset_id=42)
|
||||
|
||||
assert "adhoc_filters" not in form_data
|
||||
mock_find_dataset.assert_not_called()
|
||||
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid")
|
||||
def test_dataset_without_main_temporal_column_remains_unbound(
|
||||
mock_find_dataset: MagicMock,
|
||||
) -> None:
|
||||
mock_find_dataset.return_value = SimpleNamespace(main_dttm_col=None)
|
||||
|
||||
form_data = map_config_to_form_data(
|
||||
TableChartConfig(columns=[CATEGORY, METRIC]),
|
||||
dataset_id=42,
|
||||
)
|
||||
|
||||
assert "adhoc_filters" not in form_data
|
||||
|
||||
|
||||
@patch(
|
||||
"superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
|
||||
return_value=True,
|
||||
)
|
||||
def test_explicit_temporal_column_binds_alongside_different_temporal_filter(
|
||||
mock_is_temporal: MagicMock,
|
||||
) -> None:
|
||||
form_data = {
|
||||
"adhoc_filters": [
|
||||
{
|
||||
"clause": "WHERE",
|
||||
"comparator": "Last year",
|
||||
"expressionType": "SIMPLE",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"subject": "processed_at",
|
||||
}
|
||||
]
|
||||
}
|
||||
config = TableChartConfig(
|
||||
columns=[CATEGORY, METRIC],
|
||||
temporal_column="created_at",
|
||||
)
|
||||
|
||||
_bind_dashboard_time_range_filter(form_data, config, dataset_id=42)
|
||||
|
||||
assert [filter_["subject"] for filter_ in form_data["adhoc_filters"]] == [
|
||||
"processed_at",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id_or_uuid")
|
||||
@patch(
|
||||
"superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
|
||||
return_value=True,
|
||||
)
|
||||
def test_dashboard_time_range_updates_generated_filter(
|
||||
mock_is_temporal: MagicMock,
|
||||
mock_find_dataset: MagicMock,
|
||||
) -> None:
|
||||
mock_find_dataset.return_value = SimpleNamespace(main_dttm_col="order_date")
|
||||
form_data = map_config_to_form_data(
|
||||
TableChartConfig(columns=[CATEGORY, METRIC]),
|
||||
dataset_id=42,
|
||||
)
|
||||
form_data["extra_form_data"] = {"time_range": "Last week"}
|
||||
|
||||
merge_extra_form_data(form_data)
|
||||
|
||||
assert form_data["adhoc_filters"][0]["subject"] == "order_date"
|
||||
assert form_data["adhoc_filters"][0]["comparator"] == "Last week"
|
||||
assert adhoc_filters_to_query_filters(form_data["adhoc_filters"]) == [
|
||||
{
|
||||
"col": "order_date",
|
||||
"op": "TEMPORAL_RANGE",
|
||||
"val": "Last week",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_temporal_column_is_included_in_dataset_validation() -> None:
|
||||
config = TableChartConfig(
|
||||
columns=[CATEGORY, METRIC],
|
||||
temporal_column="created_at",
|
||||
)
|
||||
|
||||
refs = DatasetValidator._extract_column_references(config)
|
||||
|
||||
assert [ref.name for ref in refs].count("created_at") == 1
|
||||
|
||||
|
||||
def test_dataset_validation_rejects_non_temporal_time_column() -> None:
|
||||
config = TableChartConfig(
|
||||
columns=[CATEGORY, METRIC],
|
||||
temporal_column="fiscal_year",
|
||||
)
|
||||
dataset_context = DatasetContext(
|
||||
id=42,
|
||||
table_name="orders",
|
||||
schema="public",
|
||||
database_name="examples",
|
||||
available_columns=[
|
||||
{"name": "region", "type": "VARCHAR", "is_temporal": False},
|
||||
{"name": "revenue", "type": "NUMERIC", "is_temporal": False},
|
||||
{"name": "fiscal_year", "type": "INTEGER", "is_temporal": False},
|
||||
],
|
||||
available_metrics=[],
|
||||
)
|
||||
|
||||
is_valid, error = DatasetValidator.validate_against_dataset(
|
||||
config,
|
||||
dataset_id=42,
|
||||
dataset_context=dataset_context,
|
||||
)
|
||||
|
||||
assert not is_valid
|
||||
assert error is not None
|
||||
assert error.error_code == "NON_TEMPORAL_COLUMN"
|
||||
assert "fiscal_year" in error.message
|
||||
|
||||
|
||||
def test_dataset_validation_rejects_missing_explicit_time_column() -> None:
|
||||
config = TableChartConfig(
|
||||
columns=[CATEGORY, METRIC],
|
||||
temporal_column="missing_at",
|
||||
)
|
||||
dataset_context = DatasetContext(
|
||||
id=42,
|
||||
table_name="orders",
|
||||
schema="public",
|
||||
database_name="examples",
|
||||
available_columns=[
|
||||
{"name": "region", "type": "VARCHAR", "is_temporal": False},
|
||||
{"name": "revenue", "type": "NUMERIC", "is_temporal": False},
|
||||
],
|
||||
available_metrics=[],
|
||||
)
|
||||
|
||||
is_valid, error = DatasetValidator.validate_against_dataset(
|
||||
config, dataset_id=42, dataset_context=dataset_context
|
||||
)
|
||||
|
||||
assert not is_valid
|
||||
assert error is not None
|
||||
assert error.error_code == "MISSING_TEMPORAL_COLUMN"
|
||||
|
||||
|
||||
def test_saved_metric_name_does_not_hide_explicit_temporal_column_reference() -> None:
|
||||
config = BigNumberChartConfig(
|
||||
chart_type="big_number",
|
||||
metric=ColumnRef(name="created_at", saved_metric=True),
|
||||
temporal_column="created_at",
|
||||
)
|
||||
|
||||
refs = DatasetValidator._extract_column_references(config)
|
||||
|
||||
assert [(ref.name, ref.saved_metric) for ref in refs] == [
|
||||
("created_at", True),
|
||||
("created_at", False),
|
||||
]
|
||||
|
||||
|
||||
def test_dataset_context_uses_binding_temporal_predicate() -> None:
|
||||
engine_spec = MagicMock()
|
||||
engine_spec.get_column_spec.return_value = SimpleNamespace(
|
||||
generic_type=GenericDataType.NUMERIC
|
||||
)
|
||||
column = SimpleNamespace(
|
||||
column_name="fiscal_year",
|
||||
type="INTEGER",
|
||||
is_dttm=True,
|
||||
is_temporal=True,
|
||||
is_numeric=True,
|
||||
python_date_format=None,
|
||||
)
|
||||
dataset = SimpleNamespace(
|
||||
id=42,
|
||||
table_name="orders",
|
||||
schema="public",
|
||||
columns=[column],
|
||||
metrics=[],
|
||||
database=SimpleNamespace(database_name="examples", db_engine_spec=engine_spec),
|
||||
)
|
||||
|
||||
context = build_dataset_context_from_orm(dataset)
|
||||
|
||||
assert context is not None
|
||||
assert context.available_columns[0]["is_temporal"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
TableChartConfig(
|
||||
columns=[CATEGORY, METRIC],
|
||||
temporal_column="created_at",
|
||||
),
|
||||
BigNumberChartConfig(
|
||||
chart_type="big_number",
|
||||
metric=METRIC,
|
||||
temporal_column="created_at",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_temporal_column_is_normalized_to_dataset_casing(
|
||||
config: ChartConfig,
|
||||
) -> None:
|
||||
dataset_context = DatasetContext(
|
||||
id=42,
|
||||
table_name="orders",
|
||||
schema="public",
|
||||
database_name="examples",
|
||||
available_columns=[
|
||||
{"name": "region", "type": "VARCHAR"},
|
||||
{"name": "revenue", "type": "NUMERIC"},
|
||||
{"name": "Created_At", "type": "TIMESTAMP"},
|
||||
],
|
||||
available_metrics=[],
|
||||
)
|
||||
|
||||
normalized = DatasetValidator.normalize_column_names(
|
||||
config,
|
||||
dataset_id=42,
|
||||
dataset_context=dataset_context,
|
||||
)
|
||||
|
||||
assert normalized.temporal_column == "Created_At"
|
||||
@@ -475,8 +475,7 @@ class _DetachableSlice:
|
||||
|
||||
async def _generate_saved_chart(
|
||||
refetch: Any,
|
||||
compile_result: CompileResult | None = None,
|
||||
) -> tuple[Any, _DetachableSlice, Mock]:
|
||||
) -> tuple[Any, _DetachableSlice]:
|
||||
"""Run generate_chart(save_chart=True) with a chart that detaches on commit.
|
||||
|
||||
``refetch`` is used as the ``ChartDAO.find_by_id`` behaviour of the
|
||||
@@ -504,7 +503,6 @@ async def _generate_saved_chart(
|
||||
# The instance is detached right after the commit, before any of the reads
|
||||
# that build the response.
|
||||
session.refresh.side_effect = lambda _chart: chart.detach()
|
||||
create_command = Mock(return_value=Mock(run=Mock(return_value=chart)))
|
||||
|
||||
with (
|
||||
patch(
|
||||
@@ -526,12 +524,12 @@ async def _generate_saved_chart(
|
||||
patch("superset.mcp_service.auth.has_dataset_access", return_value=True),
|
||||
patch(
|
||||
"superset.commands.chart.create.CreateChartCommand",
|
||||
create_command,
|
||||
return_value=Mock(run=Mock(return_value=chart)),
|
||||
),
|
||||
patch("superset.db.session", session),
|
||||
patch(
|
||||
"superset.mcp_service.chart.tool.generate_chart._compile_chart",
|
||||
return_value=compile_result or CompileResult(success=True, warnings=[]),
|
||||
return_value=CompileResult(success=True, warnings=[]),
|
||||
),
|
||||
patch("superset.daos.chart.ChartDAO", Mock(find_by_id=refetch)),
|
||||
patch(
|
||||
@@ -545,7 +543,7 @@ async def _generate_saved_chart(
|
||||
):
|
||||
result = await generate_chart(request, ctx=ctx)
|
||||
|
||||
return result, chart, create_command
|
||||
return result, chart
|
||||
|
||||
|
||||
class TestGenerateChartDetachedInstance:
|
||||
@@ -562,7 +560,7 @@ class TestGenerateChartDetachedInstance:
|
||||
"""A detached instance no longer turns a committed chart into an error."""
|
||||
refetched = _make_mock_chart()
|
||||
|
||||
result, chart, _create_command = await _generate_saved_chart(
|
||||
result, chart = await _generate_saved_chart(
|
||||
refetch=Mock(return_value=refetched)
|
||||
)
|
||||
|
||||
@@ -577,7 +575,7 @@ class TestGenerateChartDetachedInstance:
|
||||
@pytest.mark.asyncio
|
||||
async def test_detached_chart_falls_back_to_captured_scalars(self) -> None:
|
||||
"""The minimal fallback response never reads the detached instance."""
|
||||
result, chart, _create_command = await _generate_saved_chart(
|
||||
result, chart = await _generate_saved_chart(
|
||||
refetch=Mock(side_effect=SQLAlchemyError("session is gone"))
|
||||
)
|
||||
|
||||
@@ -589,25 +587,6 @@ class TestGenerateChartDetachedInstance:
|
||||
assert result.chart.slice_name == "Concurrent chart"
|
||||
assert result.chart.viz_type == "table"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compile_failure_does_not_create_a_chart(self) -> None:
|
||||
result, chart, create_command = await _generate_saved_chart(
|
||||
refetch=Mock(),
|
||||
compile_result=CompileResult(
|
||||
success=False,
|
||||
error="column category must appear in GROUP BY",
|
||||
error_code="CHART_COMPILE_FAILED",
|
||||
tier="compile",
|
||||
warnings=["Database returned partial metadata"],
|
||||
),
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.chart is None
|
||||
assert result.warnings == ["Database returned partial metadata"]
|
||||
assert chart._detached is False
|
||||
create_command.assert_not_called()
|
||||
|
||||
|
||||
class TestChartSerializationEagerLoading:
|
||||
"""Tests for eager loading fix in generate_chart serialization path."""
|
||||
|
||||
@@ -23,19 +23,16 @@ import importlib
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
ChartData,
|
||||
ChartError,
|
||||
DataColumn,
|
||||
GetChartDataRequest,
|
||||
PerformanceMetadata,
|
||||
)
|
||||
from superset.mcp_service.chart.tool.get_chart_data import (
|
||||
_build_query_results,
|
||||
_coerce_row_limit,
|
||||
_GENERIC_TYPE_MAP,
|
||||
_MAX_RECOMMENDATIONS,
|
||||
@@ -135,37 +132,6 @@ def _extract_metrics_and_groupby(
|
||||
return metrics, groupby_columns
|
||||
|
||||
|
||||
def test_query_context_form_data_supports_request_dependent_jinja_macros() -> None:
|
||||
"""Chart queries expose filters, URL parameters, and the datasource to Jinja."""
|
||||
from flask import current_app
|
||||
|
||||
from superset.charts.data.form_data import set_query_context_form_data
|
||||
from superset.common.query_object import QueryObject
|
||||
from superset.jinja_context import ExtraCache, get_dataset_id_from_context
|
||||
|
||||
query = QueryObject(
|
||||
filters=[{"col": "region", "op": "IN", "val": ["North"]}],
|
||||
time_range="Last week",
|
||||
)
|
||||
query_context: Any = SimpleNamespace(
|
||||
queries=[query],
|
||||
form_data={"url_params": {"tenant": "acme"}},
|
||||
)
|
||||
|
||||
with current_app.test_request_context():
|
||||
set_query_context_form_data(query_context, 7, "table")
|
||||
extra_cache = ExtraCache()
|
||||
|
||||
assert extra_cache.filter_values("region") == ["North"]
|
||||
assert extra_cache.get_filters("region") == [
|
||||
{"col": "region", "op": "IN", "val": ["North"]}
|
||||
]
|
||||
assert extra_cache.url_param("tenant") == "acme"
|
||||
assert extra_cache.get_time_filter().time_range == "Last week"
|
||||
# metric() without an explicit dataset ID performs this lookup.
|
||||
assert get_dataset_id_from_context("count") == 7
|
||||
|
||||
|
||||
class TestBigNumberChartFallback:
|
||||
"""Tests for big_number chart fallback query construction."""
|
||||
|
||||
@@ -1081,6 +1047,7 @@ class TestChartDataCommandValidation:
|
||||
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.mcp_service.chart.schemas import ChartError
|
||||
|
||||
security_error = SupersetSecurityException(
|
||||
SupersetError(
|
||||
@@ -1411,7 +1378,7 @@ class TestOAuthErrorRouting:
|
||||
|
||||
class QueryContextFactory:
|
||||
def create(self, **kwargs: Any) -> object:
|
||||
return SimpleNamespace(queries=[], form_data={})
|
||||
return object()
|
||||
|
||||
class RaisingChartDataCommand:
|
||||
def __init__(self, query_context: object) -> None:
|
||||
@@ -1711,149 +1678,14 @@ def test_bool_isinstance_check_before_int():
|
||||
(None, 500, 500), # missing -> default
|
||||
("", 500, 500), # empty string -> default
|
||||
("abc", 500, 500), # non-numeric -> default
|
||||
(0, 500, 500), # non-positive zero -> default (no LIMIT 0)
|
||||
(-1, 500, 500), # negative -> default (no LIMIT -1 downstream)
|
||||
("-1", 500, 500), # negative string -> default
|
||||
(0, 500, 0), # explicit zero preserved
|
||||
],
|
||||
)
|
||||
def test_coerce_row_limit(value: Any, default: int, expected: int) -> None:
|
||||
"""_coerce_row_limit tolerates str/None and rejects non-positive row_limits."""
|
||||
"""_coerce_row_limit tolerates str/None row_limits from chart.params."""
|
||||
assert _coerce_row_limit(value, default) == expected
|
||||
|
||||
|
||||
def test_mixed_timeseries_preserves_both_query_results(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Mixed Timeseries data includes both its primary and secondary queries."""
|
||||
from superset.mcp_service.chart.chart_helpers import (
|
||||
build_query_dicts_from_form_data,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"superset.mcp_service.chart.chart_helpers.resolve_datasource_engine",
|
||||
lambda *_args: "sqlite",
|
||||
)
|
||||
|
||||
form_data = {
|
||||
"viz_type": "mixed_timeseries",
|
||||
"metrics": ["primary_metric"],
|
||||
"metrics_b": ["secondary_metric"],
|
||||
"groupby": [],
|
||||
"groupby_b": [],
|
||||
}
|
||||
queries = build_query_dicts_from_form_data(form_data, 1, "table")
|
||||
assert len(queries) == 2
|
||||
|
||||
results = _build_query_results(
|
||||
[
|
||||
{"colnames": ["primary"], "data": [{"primary": 1}], "rowcount": 1},
|
||||
{
|
||||
"colnames": ["secondary"],
|
||||
"data": [{"secondary": 2}],
|
||||
"rowcount": 1,
|
||||
},
|
||||
],
|
||||
limit=None,
|
||||
)
|
||||
|
||||
assert results is not None
|
||||
assert [result.query_index for result in results] == [0, 1]
|
||||
assert results[0].data == [{"primary": 1}]
|
||||
assert results[1].data == [{"secondary": 2}]
|
||||
|
||||
|
||||
def test_single_query_chart_keeps_legacy_shape() -> None:
|
||||
"""Single-query charts do not gain a redundant query_results payload."""
|
||||
assert (
|
||||
_build_query_results([{"colnames": ["metric"], "data": [{"metric": 1}]}], None)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_multi_query_row_count_reflects_limit() -> None:
|
||||
"""Nested row counts describe returned rows rather than source rows."""
|
||||
results = _build_query_results(
|
||||
[
|
||||
{"colnames": ["metric"], "data": [{"metric": 1}, {"metric": 2}]},
|
||||
{"colnames": ["metric"], "data": [{"metric": 3}, {"metric": 4}]},
|
||||
],
|
||||
limit=1,
|
||||
)
|
||||
|
||||
assert results is not None
|
||||
assert [result.row_count for result in results] == [1, 1]
|
||||
assert [result.data for result in results] == [
|
||||
[{"metric": 1}],
|
||||
[{"metric": 3}],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsaved_mixed_timeseries_returns_nonempty_secondary_query(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The tool response must not collapse a multi-query command result."""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
get_data_command_module = importlib.import_module(
|
||||
"superset.commands.chart.data.get_data_command"
|
||||
)
|
||||
chart_data_module = importlib.import_module(
|
||||
"superset.mcp_service.chart.tool.get_chart_data"
|
||||
)
|
||||
|
||||
class MultiQueryChartDataCommand:
|
||||
def __init__(self, query_context: object) -> None:
|
||||
self.query_context = query_context
|
||||
|
||||
def validate(self) -> None:
|
||||
pass
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
return {
|
||||
"queries": [
|
||||
{
|
||||
"colnames": ["primary"],
|
||||
"data": [],
|
||||
"rowcount": 0,
|
||||
},
|
||||
{
|
||||
"colnames": ["secondary"],
|
||||
"data": [{"secondary": 2}],
|
||||
"rowcount": 1,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
chart_data_module,
|
||||
"build_query_context_from_form_data",
|
||||
lambda *_args, **_kwargs: object(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
get_data_command_module, "ChartDataCommand", MultiQueryChartDataCommand
|
||||
)
|
||||
request = GetChartDataRequest(form_data_key="mixed-chart")
|
||||
response = await _query_from_form_data(
|
||||
{
|
||||
"datasource_id": 1,
|
||||
"datasource_type": "table",
|
||||
"viz_type": "mixed_timeseries",
|
||||
"row_limit": 10,
|
||||
},
|
||||
request,
|
||||
AsyncMock(),
|
||||
)
|
||||
|
||||
assert isinstance(response, ChartData)
|
||||
assert response.data == []
|
||||
assert response.query_results is not None
|
||||
assert [result.data for result in response.query_results] == [
|
||||
[],
|
||||
[{"secondary": 2}],
|
||||
]
|
||||
|
||||
|
||||
def _make_chart_data(**overrides: Any) -> ChartData:
|
||||
"""Build a minimal valid ChartData for testing."""
|
||||
from superset.mcp_service.common.cache_schemas import CacheStatus
|
||||
@@ -1910,279 +1742,3 @@ class TestChartDataTotalRowsCoercion:
|
||||
chart_data = _make_chart_data(total_rows=5.9)
|
||||
assert chart_data.total_rows == 5
|
||||
assert isinstance(chart_data.total_rows, int)
|
||||
|
||||
|
||||
class TestGuestScoping:
|
||||
"""Tool-level guest coverage for get_chart_data (the highest-value guest
|
||||
tool): the data query is pinned to the token's dashboard, the dataset
|
||||
existence check runs without the RBAC access check, and requests that a
|
||||
guest cannot be scoped for are denied cleanly."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guest_query_pinned_to_dashboard_with_existence_only_check(
|
||||
self, mcp_server, mock_auth
|
||||
) -> None:
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastmcp import Client
|
||||
|
||||
module = importlib.import_module(
|
||||
"superset.mcp_service.chart.tool.get_chart_data"
|
||||
)
|
||||
chart = SimpleNamespace(
|
||||
id=9,
|
||||
slice_name="Sales",
|
||||
viz_type="table",
|
||||
datasource_id=1,
|
||||
datasource_type="table",
|
||||
query_context='{"queries": []}',
|
||||
params=None,
|
||||
)
|
||||
validate_calls: dict[str, Any] = {}
|
||||
|
||||
def fake_validate(datasource_id: Any, check_access: bool = True) -> Any:
|
||||
validate_calls["check_access"] = check_access
|
||||
return SimpleNamespace(is_valid=True, warnings=[], error=None)
|
||||
|
||||
class _Command:
|
||||
def __init__(self, query_context: Any) -> None: ...
|
||||
def validate(self) -> None: ...
|
||||
def run(self) -> dict[str, Any]:
|
||||
return {
|
||||
"queries": [{"data": [{"a": 1}], "colnames": ["a"], "rowcount": 1}]
|
||||
}
|
||||
|
||||
mock_authorize = MagicMock()
|
||||
with (
|
||||
patch.object(module, "find_chart_by_identifier", return_value=chart),
|
||||
patch.object(module, "validate_chart_dataset", side_effect=fake_validate),
|
||||
patch.object(module.guest_scope, "is_guest_read", return_value=True),
|
||||
patch.object(module.guest_scope, "guest_dashboard_id", return_value=6),
|
||||
patch.object(module.guest_scope, "authorize_query", mock_authorize),
|
||||
patch(
|
||||
"superset.commands.chart.data.get_data_command.ChartDataCommand",
|
||||
_Command,
|
||||
),
|
||||
patch(
|
||||
"superset.charts.schemas.ChartDataQueryContextSchema.load",
|
||||
lambda self, data: object(),
|
||||
),
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
await client.call_tool(
|
||||
"get_chart_data", {"request": {"identifier": "9"}}
|
||||
)
|
||||
|
||||
# F: the dataset existence check runs, but without the RBAC access check.
|
||||
assert validate_calls["check_access"] is False
|
||||
# Scoping: the data query is pinned to the token's dashboard, so
|
||||
# raise_for_access authorizes against a dashboard the guest can see.
|
||||
mock_authorize.assert_called_once()
|
||||
assert mock_authorize.call_args.args[1] == 6
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guest_out_of_scope_chart_is_not_found(
|
||||
self, mcp_server, mock_auth
|
||||
) -> None:
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastmcp import Client
|
||||
|
||||
from superset.utils import json
|
||||
|
||||
module = importlib.import_module(
|
||||
"superset.mcp_service.chart.tool.get_chart_data"
|
||||
)
|
||||
# ChartFilter scopes an out-of-scope chart out, so the lookup returns None.
|
||||
with (
|
||||
patch.object(module, "find_chart_by_identifier", return_value=None),
|
||||
patch.object(module.guest_scope, "is_guest_read", return_value=True),
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"get_chart_data", {"request": {"identifier": "123"}}
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["error_type"] == "NotFound"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guest_form_data_key_only_path_is_denied(
|
||||
self, mcp_server, mock_auth
|
||||
) -> None:
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastmcp import Client
|
||||
|
||||
from superset.utils import json
|
||||
|
||||
module = importlib.import_module(
|
||||
"superset.mcp_service.chart.tool.get_chart_data"
|
||||
)
|
||||
# A valid cached blob is available, so without the guest-denial branch
|
||||
# the code would proceed to query rather than 404 on a cache miss. The
|
||||
# NotFound here therefore pins the denial itself, not a cache miss.
|
||||
with (
|
||||
patch.object(module.guest_scope, "is_guest_read", return_value=True),
|
||||
patch.object(
|
||||
module,
|
||||
"get_cached_form_data",
|
||||
return_value='{"datasource_id": 1, "datasource_type": "table"}',
|
||||
),
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"get_chart_data", {"request": {"form_data_key": "cached-key"}}
|
||||
)
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["error_type"] == "NotFound"
|
||||
assert "No accessible chart found for this request." in data["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guest_form_data_key_ignored_when_identifier_present(
|
||||
self, mcp_server, mock_auth
|
||||
) -> None:
|
||||
"""A guest supplying identifier + form_data_key never reads the cache: the
|
||||
cached payload could point the query at a foreign datasource, so a guest
|
||||
always falls through to the saved chart config."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastmcp import Client
|
||||
|
||||
module = importlib.import_module(
|
||||
"superset.mcp_service.chart.tool.get_chart_data"
|
||||
)
|
||||
chart = SimpleNamespace(
|
||||
id=9,
|
||||
slice_name="Sales",
|
||||
viz_type="table",
|
||||
datasource_id=1,
|
||||
datasource_type="table",
|
||||
query_context='{"queries": []}',
|
||||
params=None,
|
||||
)
|
||||
|
||||
class _Command:
|
||||
def __init__(self, query_context: Any) -> None: ...
|
||||
def validate(self) -> None: ...
|
||||
def run(self) -> dict[str, Any]:
|
||||
return {
|
||||
"queries": [{"data": [{"a": 1}], "colnames": ["a"], "rowcount": 1}]
|
||||
}
|
||||
|
||||
cached_spy = MagicMock(
|
||||
return_value='{"datasource_id": 999, "datasource_type": "table"}'
|
||||
)
|
||||
with (
|
||||
patch.object(module, "find_chart_by_identifier", return_value=chart),
|
||||
patch.object(
|
||||
module,
|
||||
"validate_chart_dataset",
|
||||
return_value=SimpleNamespace(is_valid=True, warnings=[], error=None),
|
||||
),
|
||||
patch.object(module.guest_scope, "is_guest_read", return_value=True),
|
||||
patch.object(module.guest_scope, "guest_dashboard_id", return_value=6),
|
||||
patch.object(module.guest_scope, "authorize_query", MagicMock()),
|
||||
patch.object(module, "get_cached_form_data", cached_spy),
|
||||
patch(
|
||||
"superset.commands.chart.data.get_data_command.ChartDataCommand",
|
||||
_Command,
|
||||
),
|
||||
patch(
|
||||
"superset.charts.schemas.ChartDataQueryContextSchema.load",
|
||||
lambda self, data: object(),
|
||||
),
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
await client.call_tool(
|
||||
"get_chart_data",
|
||||
{"request": {"identifier": "9", "form_data_key": "foreign-key"}},
|
||||
)
|
||||
|
||||
# The gate short-circuits before the cache is ever consulted for a guest.
|
||||
cached_spy.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_from_form_data_zero_row_limit_falls_back_to_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A falsy int 0 hits the ``or ROW_LIMIT`` fallback and resolves to the
|
||||
configured default before coercion runs, so the coercion leaves the cached
|
||||
0 case unchanged."""
|
||||
from flask import current_app
|
||||
|
||||
module = importlib.import_module("superset.mcp_service.chart.tool.get_chart_data")
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_build(form_data: Any, **kwargs: Any) -> Any:
|
||||
captured["row_limit"] = kwargs.get("row_limit")
|
||||
return object()
|
||||
|
||||
class _Command:
|
||||
def __init__(self, query_context: Any) -> None: ...
|
||||
def validate(self) -> None: ...
|
||||
def run(self) -> dict[str, Any]:
|
||||
return {"queries": [{"data": [], "colnames": [], "rowcount": 0}]}
|
||||
|
||||
monkeypatch.setattr(module, "build_query_context_from_form_data", fake_build)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"event_logger",
|
||||
SimpleNamespace(log_context=lambda **kwargs: nullcontext()),
|
||||
)
|
||||
get_data_command_module = importlib.import_module(
|
||||
"superset.commands.chart.data.get_data_command"
|
||||
)
|
||||
monkeypatch.setattr(get_data_command_module, "ChartDataCommand", _Command)
|
||||
|
||||
await _query_from_form_data(
|
||||
{"datasource_id": 1, "datasource_type": "table", "row_limit": 0},
|
||||
GetChartDataRequest(form_data_key="k"),
|
||||
_AsyncContext(),
|
||||
)
|
||||
|
||||
assert captured["row_limit"] == current_app.config["ROW_LIMIT"]
|
||||
assert captured["row_limit"] != 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_from_form_data_string_row_limit_is_coerced(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A row_limit arriving as a str (as it can from chart.params) is coerced to
|
||||
int before it reaches build_query_context_from_form_data (which compares it
|
||||
against an int downstream). Deleting the _coerce_row_limit call fails this."""
|
||||
module = importlib.import_module("superset.mcp_service.chart.tool.get_chart_data")
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_build(form_data: Any, **kwargs: Any) -> Any:
|
||||
captured["row_limit"] = kwargs.get("row_limit")
|
||||
return object()
|
||||
|
||||
class _Command:
|
||||
def __init__(self, query_context: Any) -> None: ...
|
||||
def validate(self) -> None: ...
|
||||
def run(self) -> dict[str, Any]:
|
||||
return {"queries": [{"data": [], "colnames": [], "rowcount": 0}]}
|
||||
|
||||
monkeypatch.setattr(module, "build_query_context_from_form_data", fake_build)
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"event_logger",
|
||||
SimpleNamespace(log_context=lambda **kwargs: nullcontext()),
|
||||
)
|
||||
get_data_command_module = importlib.import_module(
|
||||
"superset.commands.chart.data.get_data_command"
|
||||
)
|
||||
monkeypatch.setattr(get_data_command_module, "ChartDataCommand", _Command)
|
||||
|
||||
await _query_from_form_data(
|
||||
{"datasource_id": 1, "datasource_type": "table", "row_limit": "250"},
|
||||
GetChartDataRequest(form_data_key="k"),
|
||||
_AsyncContext(),
|
||||
)
|
||||
|
||||
assert captured["row_limit"] == 250
|
||||
assert isinstance(captured["row_limit"], int)
|
||||
|
||||
@@ -25,7 +25,6 @@ from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
|
||||
from superset.mcp_service.app import mcp
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
@@ -420,42 +419,3 @@ async def test_list_charts_certified_filter(
|
||||
expected in actual
|
||||
for expected, actual in zip(expected_names, actual_names, strict=False)
|
||||
)
|
||||
|
||||
|
||||
@patch("superset.daos.chart.ChartDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_charts_changed_on_delta_humanized_order_column(
|
||||
mock_list, mcp_server
|
||||
):
|
||||
"""Regression test: order_column='changed_on_delta_humanized' is the
|
||||
"Last modified" column name used by Superset's own REST API and list
|
||||
views. Production chatbot calls pass it when asked to sort by "most
|
||||
recently modified" and must not be rejected. It resolves to 'changed_on'
|
||||
for the DAO, matching REST API sort behaviour (see
|
||||
models/helpers.py:changed_on_delta_humanized, @renders("changed_on"))."""
|
||||
mock_list.return_value = ([], 0)
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"list_charts",
|
||||
{"request": {"order_column": "changed_on_delta_humanized"}},
|
||||
)
|
||||
mock_list.assert_called_once()
|
||||
call_args = mock_list.call_args[1]
|
||||
assert call_args["order_column"] == "changed_on"
|
||||
data = json.loads(result.content[0].text)
|
||||
assert "charts" in data
|
||||
|
||||
|
||||
@patch("superset.daos.chart.ChartDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_charts_invalid_order_column_raises_tool_error(
|
||||
mock_list, mcp_server
|
||||
):
|
||||
"""A genuinely unknown order_column must still be rejected."""
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError) as excinfo: # noqa: PT012
|
||||
await client.call_tool(
|
||||
"list_charts", {"request": {"order_column": "random"}}
|
||||
)
|
||||
assert "Invalid order_column" in str(excinfo.value)
|
||||
mock_list.assert_not_called()
|
||||
|
||||
@@ -20,17 +20,14 @@ Unit tests for update_chart_preview MCP tool
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
from superset.mcp_service.app import mcp
|
||||
from superset.mcp_service.chart.chart_utils import map_big_number_config
|
||||
from superset.mcp_service.chart.schemas import (
|
||||
AxisConfig,
|
||||
BigNumberChartConfig,
|
||||
ColumnRef,
|
||||
FilterConfig,
|
||||
LegendConfig,
|
||||
@@ -589,268 +586,6 @@ class TestUpdateChartPreview:
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_preserves_generated_temporal_filter_with_cached_filters(self) -> None:
|
||||
"""Cached filters are merged without replacing the temporal binding."""
|
||||
new_form_data = {
|
||||
"adhoc_filters": [
|
||||
{
|
||||
"clause": "WHERE",
|
||||
"comparator": "No filter",
|
||||
"expressionType": "SIMPLE",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"subject": "ds",
|
||||
}
|
||||
]
|
||||
}
|
||||
previous_form_data = {
|
||||
"adhoc_filters": [
|
||||
{
|
||||
"clause": "WHERE",
|
||||
"comparator": "North",
|
||||
"expressionType": "SIMPLE",
|
||||
"operator": "==",
|
||||
"subject": "region",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
update_chart_preview_module._preserve_previous_adhoc_filters(
|
||||
new_form_data,
|
||||
previous_form_data,
|
||||
)
|
||||
|
||||
assert [filter_["subject"] for filter_ in new_form_data["adhoc_filters"]] == [
|
||||
"region",
|
||||
"ds",
|
||||
]
|
||||
|
||||
def test_cached_temporal_filter_takes_precedence_over_generated_default(
|
||||
self,
|
||||
) -> None:
|
||||
"""A cached chart-specific time range is not duplicated or reset."""
|
||||
new_form_data = {
|
||||
"adhoc_filters": [
|
||||
{
|
||||
"clause": "WHERE",
|
||||
"comparator": "No filter",
|
||||
"expressionType": "SIMPLE",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"subject": "ds",
|
||||
}
|
||||
]
|
||||
}
|
||||
cached_temporal_filter = {
|
||||
"clause": "WHERE",
|
||||
"comparator": "Last month",
|
||||
"expressionType": "SIMPLE",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"subject": "ds",
|
||||
}
|
||||
|
||||
update_chart_preview_module._preserve_previous_adhoc_filters(
|
||||
new_form_data,
|
||||
{"adhoc_filters": [cached_temporal_filter]},
|
||||
)
|
||||
|
||||
assert new_form_data["adhoc_filters"] == [cached_temporal_filter]
|
||||
|
||||
def test_replaces_cached_temporal_filter_when_column_changes(self) -> None:
|
||||
"""A newly selected temporal column replaces the cached binding."""
|
||||
new_temporal_filter = {
|
||||
"clause": "WHERE",
|
||||
"comparator": "No filter",
|
||||
"expressionType": "SIMPLE",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"subject": "created_at",
|
||||
}
|
||||
region_filter = {
|
||||
"clause": "WHERE",
|
||||
"comparator": "North",
|
||||
"expressionType": "SIMPLE",
|
||||
"operator": "==",
|
||||
"subject": "region",
|
||||
}
|
||||
previous_temporal_filter = {
|
||||
"clause": "WHERE",
|
||||
"comparator": "No filter",
|
||||
"expressionType": "SIMPLE",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"subject": "ds",
|
||||
}
|
||||
new_form_data: dict[str, Any] = {"adhoc_filters": [new_temporal_filter]}
|
||||
new_form_data["_mcp_dashboard_time_filter_subject"] = "created_at"
|
||||
|
||||
update_chart_preview_module._preserve_previous_adhoc_filters(
|
||||
new_form_data,
|
||||
{
|
||||
"adhoc_filters": [region_filter, previous_temporal_filter],
|
||||
"_mcp_dashboard_time_filter_subject": "ds",
|
||||
},
|
||||
)
|
||||
|
||||
assert new_form_data["adhoc_filters"] == [
|
||||
region_filter,
|
||||
new_temporal_filter,
|
||||
]
|
||||
|
||||
def test_replaces_temporal_xy_binding_when_subject_changes(self) -> None:
|
||||
"""A temporal XY binding does not survive rebinding to a new subject."""
|
||||
previous_binding = {
|
||||
"clause": "WHERE",
|
||||
"comparator": "No filter",
|
||||
"expressionType": "SIMPLE",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"subject": "event_time",
|
||||
}
|
||||
new_binding = {
|
||||
**previous_binding,
|
||||
"subject": "created_at",
|
||||
}
|
||||
new_form_data = {
|
||||
"adhoc_filters": [new_binding],
|
||||
"_mcp_dashboard_time_filter_subject": "created_at",
|
||||
}
|
||||
|
||||
update_chart_preview_module._preserve_previous_adhoc_filters(
|
||||
new_form_data,
|
||||
{
|
||||
"adhoc_filters": [previous_binding],
|
||||
"_mcp_dashboard_time_filter_subject": "event_time",
|
||||
},
|
||||
)
|
||||
|
||||
assert new_form_data["adhoc_filters"] == [new_binding]
|
||||
|
||||
def test_replaces_big_number_fallback_binding_when_subject_changes(self) -> None:
|
||||
"""A Big Number fallback binding is replaced by a selected subject."""
|
||||
dataset = Mock(
|
||||
main_dttm_col=None,
|
||||
columns=[Mock(column_name="order_date")],
|
||||
)
|
||||
config = BigNumberChartConfig(
|
||||
chart_type="big_number",
|
||||
metric=ColumnRef(name="revenue", aggregate="SUM"),
|
||||
)
|
||||
rebound_config = config.model_copy(update={"temporal_column": "created_at"})
|
||||
|
||||
with (
|
||||
patch(
|
||||
"superset.daos.dataset.DatasetDAO.find_by_id_or_uuid",
|
||||
return_value=dataset,
|
||||
),
|
||||
patch(
|
||||
"superset.mcp_service.chart.chart_utils.is_column_truly_temporal",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
previous_form_data = map_big_number_config(config, dataset_id=42)
|
||||
new_form_data = map_big_number_config(rebound_config, dataset_id=42)
|
||||
|
||||
update_chart_preview_module._preserve_previous_adhoc_filters(
|
||||
new_form_data,
|
||||
previous_form_data,
|
||||
)
|
||||
|
||||
assert previous_form_data["_mcp_dashboard_time_filter_subject"] == "order_date"
|
||||
assert new_form_data["_mcp_dashboard_time_filter_subject"] == "created_at"
|
||||
assert [filter_["subject"] for filter_ in new_form_data["adhoc_filters"]] == [
|
||||
"created_at"
|
||||
]
|
||||
|
||||
def test_removes_cached_temporal_filter_without_new_binding(self) -> None:
|
||||
"""A mapping without a temporal subject drops the cached binding."""
|
||||
region_filter = {
|
||||
"clause": "WHERE",
|
||||
"comparator": "North",
|
||||
"expressionType": "SIMPLE",
|
||||
"operator": "==",
|
||||
"subject": "region",
|
||||
}
|
||||
previous_temporal_filter = {
|
||||
"clause": "WHERE",
|
||||
"comparator": "No filter",
|
||||
"expressionType": "SIMPLE",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"subject": "ds",
|
||||
}
|
||||
new_form_data: dict[str, Any] = {}
|
||||
|
||||
update_chart_preview_module._preserve_previous_adhoc_filters(
|
||||
new_form_data,
|
||||
{
|
||||
"adhoc_filters": [region_filter, previous_temporal_filter],
|
||||
"_mcp_dashboard_time_filter_subject": "ds",
|
||||
},
|
||||
)
|
||||
|
||||
assert new_form_data["adhoc_filters"] == [region_filter]
|
||||
|
||||
def test_preserves_user_temporal_filter_on_generated_subject(self) -> None:
|
||||
"""A user-authored range on the binding subject is not generated state."""
|
||||
generated_binding = {
|
||||
"clause": "WHERE",
|
||||
"comparator": "No filter",
|
||||
"expressionType": "SIMPLE",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"subject": "ds",
|
||||
}
|
||||
user_filter = {
|
||||
"clause": "WHERE",
|
||||
"comparator": "Last month",
|
||||
"expressionType": "SIMPLE",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"subject": "ds",
|
||||
}
|
||||
|
||||
new_form_data: dict[str, Any] = {}
|
||||
update_chart_preview_module._preserve_previous_adhoc_filters(
|
||||
new_form_data,
|
||||
{
|
||||
"adhoc_filters": [generated_binding, user_filter],
|
||||
"_mcp_dashboard_time_filter_subject": "ds",
|
||||
},
|
||||
)
|
||||
|
||||
assert new_form_data["adhoc_filters"] == [user_filter]
|
||||
|
||||
def test_rebinding_preserves_unrelated_cached_temporal_filter(self) -> None:
|
||||
"""Only the generated binding is replaced; user filters retain provenance."""
|
||||
previous_binding = {
|
||||
"expressionType": "SIMPLE",
|
||||
"clause": "WHERE",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"subject": "ds",
|
||||
"comparator": "No filter",
|
||||
}
|
||||
unrelated_filter = {
|
||||
"expressionType": "SIMPLE",
|
||||
"clause": "WHERE",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"subject": "processed_at",
|
||||
"comparator": "Last year",
|
||||
}
|
||||
new_binding = {
|
||||
"expressionType": "SIMPLE",
|
||||
"clause": "WHERE",
|
||||
"operator": "TEMPORAL_RANGE",
|
||||
"subject": "created_at",
|
||||
"comparator": "No filter",
|
||||
}
|
||||
new_form_data = {
|
||||
"adhoc_filters": [new_binding],
|
||||
"_mcp_dashboard_time_filter_subject": "created_at",
|
||||
}
|
||||
|
||||
update_chart_preview_module._preserve_previous_adhoc_filters(
|
||||
new_form_data,
|
||||
{
|
||||
"adhoc_filters": [previous_binding, unrelated_filter],
|
||||
"_mcp_dashboard_time_filter_subject": "ds",
|
||||
},
|
||||
)
|
||||
|
||||
assert new_form_data["adhoc_filters"] == [unrelated_filter, new_binding]
|
||||
|
||||
@patch.object(update_chart_preview_module, "validate_and_compile")
|
||||
@patch.object(update_chart_preview_module, "has_dataset_access", return_value=True)
|
||||
@patch("superset.daos.dataset.DatasetDAO.find_by_id")
|
||||
|
||||
@@ -1457,22 +1457,22 @@ class TestDashboardSortableColumns:
|
||||
|
||||
def test_dashboard_sortable_columns_definition(self):
|
||||
"""Test that dashboard sortable columns are properly defined."""
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
DASHBOARD_SORTABLE_COLUMNS,
|
||||
from superset.mcp_service.dashboard.tool.list_dashboards import (
|
||||
SORTABLE_DASHBOARD_COLUMNS,
|
||||
)
|
||||
|
||||
assert DASHBOARD_SORTABLE_COLUMNS == [
|
||||
assert SORTABLE_DASHBOARD_COLUMNS == [
|
||||
"id",
|
||||
"dashboard_title",
|
||||
"slug",
|
||||
"published",
|
||||
"changed_on",
|
||||
"changed_on_delta_humanized",
|
||||
"created_on",
|
||||
]
|
||||
# Ensure unsupported computed properties are excluded
|
||||
assert "changed_by_name" not in DASHBOARD_SORTABLE_COLUMNS
|
||||
assert "uuid" not in DASHBOARD_SORTABLE_COLUMNS
|
||||
# Ensure no computed properties are included
|
||||
assert "changed_on_delta_humanized" not in SORTABLE_DASHBOARD_COLUMNS
|
||||
assert "changed_by_name" not in SORTABLE_DASHBOARD_COLUMNS
|
||||
assert "uuid" not in SORTABLE_DASHBOARD_COLUMNS
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
@@ -1501,59 +1501,18 @@ class TestDashboardSortableColumns:
|
||||
|
||||
def test_sortable_columns_in_docstring(self):
|
||||
"""Test that sortable columns are documented in tool docstring."""
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
DASHBOARD_SORTABLE_COLUMNS,
|
||||
from superset.mcp_service.dashboard.tool.list_dashboards import (
|
||||
list_dashboards,
|
||||
SORTABLE_DASHBOARD_COLUMNS,
|
||||
)
|
||||
from superset.mcp_service.dashboard.tool.list_dashboards import list_dashboards
|
||||
|
||||
# Check list_dashboards docstring for sortable columns documentation
|
||||
assert list_dashboards.__doc__ is not None
|
||||
assert "Sortable columns for" in list_dashboards.__doc__
|
||||
assert "order_column" in list_dashboards.__doc__
|
||||
for col in DASHBOARD_SORTABLE_COLUMNS:
|
||||
for col in SORTABLE_DASHBOARD_COLUMNS:
|
||||
assert col in list_dashboards.__doc__
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dashboards_changed_on_delta_humanized_order_column(
|
||||
self, mock_list, mcp_server
|
||||
):
|
||||
"""Regression test: order_column='changed_on_delta_humanized' is the
|
||||
"Last modified" column name used by Superset's own REST API and list
|
||||
views. Production chatbot calls pass it when asked to sort dashboards
|
||||
by "most recently modified" and must not be rejected. It resolves to
|
||||
'changed_on' for the DAO, matching REST API sort behaviour (see
|
||||
dashboards/api.py's order_columns and
|
||||
models/helpers.py:changed_on_delta_humanized)."""
|
||||
mock_list.return_value = ([], 0)
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
request = ListDashboardsRequest(order_column="changed_on_delta_humanized")
|
||||
result = await client.call_tool(
|
||||
"list_dashboards", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
mock_list.assert_called_once()
|
||||
call_args = mock_list.call_args[1]
|
||||
assert call_args["order_column"] == "changed_on"
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["dashboards"] == []
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dashboards_invalid_order_column_raises_tool_error(
|
||||
self, mock_list, mcp_server
|
||||
):
|
||||
"""A genuinely unknown order_column must still be rejected."""
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError) as excinfo: # noqa: PT012
|
||||
await client.call_tool(
|
||||
"list_dashboards", {"request": {"order_column": "random"}}
|
||||
)
|
||||
assert "Invalid order_column" in str(excinfo.value)
|
||||
mock_list.assert_not_called()
|
||||
|
||||
|
||||
@patch("superset.daos.dashboard.DashboardDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1758,22 +1758,22 @@ class TestDatasetSortableColumns:
|
||||
|
||||
def test_dataset_sortable_columns_definition(self):
|
||||
"""Test that dataset sortable columns are properly defined."""
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
DATASET_SORTABLE_COLUMNS,
|
||||
from superset.mcp_service.dataset.tool.list_datasets import (
|
||||
SORTABLE_DATASET_COLUMNS,
|
||||
)
|
||||
|
||||
assert DATASET_SORTABLE_COLUMNS == [
|
||||
assert SORTABLE_DATASET_COLUMNS == [
|
||||
"id",
|
||||
"table_name",
|
||||
"schema",
|
||||
"changed_on",
|
||||
"changed_on_delta_humanized",
|
||||
"created_on",
|
||||
]
|
||||
# Ensure unsupported computed properties are excluded
|
||||
assert "changed_by_name" not in DATASET_SORTABLE_COLUMNS
|
||||
assert "database_name" not in DATASET_SORTABLE_COLUMNS
|
||||
assert "uuid" not in DATASET_SORTABLE_COLUMNS
|
||||
# Ensure no computed properties are included
|
||||
assert "changed_on_delta_humanized" not in SORTABLE_DATASET_COLUMNS
|
||||
assert "changed_by_name" not in SORTABLE_DATASET_COLUMNS
|
||||
assert "database_name" not in SORTABLE_DATASET_COLUMNS
|
||||
assert "uuid" not in SORTABLE_DATASET_COLUMNS
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
@@ -1805,59 +1805,18 @@ class TestDatasetSortableColumns:
|
||||
|
||||
def test_sortable_columns_in_docstring(self):
|
||||
"""Test that sortable columns are documented in tool docstring."""
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
DATASET_SORTABLE_COLUMNS,
|
||||
from superset.mcp_service.dataset.tool.list_datasets import (
|
||||
list_datasets,
|
||||
SORTABLE_DATASET_COLUMNS,
|
||||
)
|
||||
from superset.mcp_service.dataset.tool.list_datasets import list_datasets
|
||||
|
||||
# Check list_datasets docstring for sortable columns documentation
|
||||
assert list_datasets.__doc__ is not None
|
||||
assert "Sortable columns for" in list_datasets.__doc__
|
||||
assert "order_column" in list_datasets.__doc__
|
||||
for col in DATASET_SORTABLE_COLUMNS:
|
||||
for col in SORTABLE_DATASET_COLUMNS:
|
||||
assert col in list_datasets.__doc__
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_datasets_changed_on_delta_humanized_order_column(
|
||||
self, mock_dataset_list, mcp_server
|
||||
):
|
||||
"""Regression test: order_column='changed_on_delta_humanized' is the
|
||||
"Last modified" column name used by Superset's own REST API and list
|
||||
views. Production chatbot calls pass it when asked to sort datasets
|
||||
by "most recently modified" and must not be rejected. It resolves to
|
||||
'changed_on' for the DAO, matching REST API sort behaviour (see
|
||||
daos/datasource.py's sort_col_map and
|
||||
models/helpers.py:changed_on_delta_humanized)."""
|
||||
mock_dataset_list.return_value = ([], 0)
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
request = ListDatasetsRequest(order_column="changed_on_delta_humanized")
|
||||
result = await client.call_tool(
|
||||
"list_datasets", {"request": request.model_dump()}
|
||||
)
|
||||
|
||||
mock_dataset_list.assert_called_once()
|
||||
call_args = mock_dataset_list.call_args[1]
|
||||
assert call_args["order_column"] == "changed_on"
|
||||
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["datasets"] == []
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_datasets_invalid_order_column_raises_tool_error(
|
||||
self, mock_dataset_list, mcp_server
|
||||
):
|
||||
"""A genuinely unknown order_column must still be rejected."""
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError) as excinfo: # noqa: PT012
|
||||
await client.call_tool(
|
||||
"list_datasets", {"request": {"order_column": "random"}}
|
||||
)
|
||||
assert "Invalid order_column" in str(excinfo.value)
|
||||
mock_dataset_list.assert_not_called()
|
||||
|
||||
@patch("superset.daos.dataset.DatasetDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_ordering(self, mock_dataset_list, mcp_server):
|
||||
|
||||
@@ -21,7 +21,6 @@ from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from collections.abc import Generator
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
@@ -182,73 +181,6 @@ async def test_query_dataset_success(mcp_server: FastMCP) -> None:
|
||||
assert data["data"][0]["category"] == "Electronics"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_dataset_exposes_filters_to_jinja_macros(
|
||||
mcp_server: FastMCP,
|
||||
) -> None:
|
||||
"""The MCP query path populates the form data read by dataset Jinja macros."""
|
||||
from superset.common.query_object import QueryObject
|
||||
|
||||
dataset = _make_dataset()
|
||||
query = QueryObject(
|
||||
filters=[{"col": "category", "op": "IN", "val": ["Electronics"]}],
|
||||
columns=["category"],
|
||||
metrics=["count"],
|
||||
)
|
||||
query_context = SimpleNamespace(queries=[query], form_data={})
|
||||
observed: dict[str, Any] = {}
|
||||
|
||||
def run_query() -> dict[str, Any]:
|
||||
from superset.jinja_context import ExtraCache, get_dataset_id_from_context
|
||||
|
||||
extra_cache = ExtraCache()
|
||||
observed["filter_values"] = extra_cache.filter_values("category")
|
||||
observed["get_filters"] = extra_cache.get_filters("category")
|
||||
# metric() without an explicit dataset ID uses this same context lookup.
|
||||
observed["metric_dataset_id"] = get_dataset_id_from_context("count")
|
||||
return _mock_command_result()
|
||||
|
||||
with (
|
||||
patch.object(query_dataset_module, "resolve_dataset", return_value=dataset),
|
||||
patch(
|
||||
"superset.common.query_context_factory.QueryContextFactory.create",
|
||||
return_value=query_context,
|
||||
),
|
||||
patch(
|
||||
"superset.commands.chart.data.get_data_command.ChartDataCommand.validate",
|
||||
),
|
||||
patch(
|
||||
"superset.commands.chart.data.get_data_command.ChartDataCommand.run",
|
||||
side_effect=run_query,
|
||||
),
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"query_dataset",
|
||||
{
|
||||
"request": {
|
||||
"dataset_id": 1,
|
||||
"metrics": ["count"],
|
||||
"columns": ["category"],
|
||||
"filters": [
|
||||
{
|
||||
"col": "category",
|
||||
"op": "IN",
|
||||
"val": ["Electronics"],
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert not result.is_error
|
||||
assert observed["filter_values"] == ["Electronics"]
|
||||
assert observed["get_filters"] == [
|
||||
{"col": "category", "op": "IN", "val": ["Electronics"]}
|
||||
]
|
||||
assert observed["metric_dataset_id"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_dataset_not_found(mcp_server: FastMCP) -> None:
|
||||
"""Dataset ID that doesn't exist returns error."""
|
||||
|
||||
@@ -66,7 +66,7 @@ def mock_auth():
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def allow_data_model_metadata():
|
||||
def allow_data_model_metadata(): # noqa: PT004
|
||||
"""Keep the standalone get_schema suite in the unrestricted default path."""
|
||||
with patch.object(
|
||||
get_schema_module,
|
||||
@@ -606,3 +606,40 @@ class TestGetSchemaPermissionMap:
|
||||
factories = set(get_schema_module._SCHEMA_CORE_FACTORIES.keys())
|
||||
perms = set(get_schema_module._MODEL_TYPE_CLASS_PERMISSION.keys())
|
||||
assert factories == perms
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_scope_is_enforced(self, app, mcp_server):
|
||||
"""RBAC access alone cannot bypass a scoped token's resource limit."""
|
||||
with (
|
||||
patch.dict(app.config, {"MCP_RBAC_ENABLED": True}),
|
||||
patch("superset.security_manager.can_access", return_value=True),
|
||||
patch.object(
|
||||
get_schema_module, "_token_scope_allows", return_value=False
|
||||
) as scope_allows,
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError, match="Permission denied"):
|
||||
await client.call_tool(
|
||||
"get_schema", {"request": {"model_type": "chart"}}
|
||||
)
|
||||
|
||||
scope_allows.assert_called_once_with("read", "Chart")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resource_scope_is_enforced_when_rbac_disabled(self, app, mcp_server):
|
||||
"""The RBAC feature flag does not disable credential scopes."""
|
||||
with (
|
||||
patch.dict(app.config, {"MCP_RBAC_ENABLED": False}),
|
||||
patch("superset.security_manager.can_access") as can_access,
|
||||
patch.object(
|
||||
get_schema_module, "_token_scope_allows", return_value=False
|
||||
) as scope_allows,
|
||||
):
|
||||
async with Client(mcp_server) as client:
|
||||
with pytest.raises(ToolError, match="Permission denied"):
|
||||
await client.call_tool(
|
||||
"get_schema", {"request": {"model_type": "chart"}}
|
||||
)
|
||||
|
||||
can_access.assert_not_called()
|
||||
scope_allows.assert_called_once_with("read", "Chart")
|
||||
|
||||
@@ -276,43 +276,9 @@ def test_model_list_tool_rejects_private_order_column():
|
||||
tool.run_tool(order_column="created_by_fk")
|
||||
|
||||
|
||||
def test_model_list_tool_resolves_changed_on_delta_humanized_alias():
|
||||
"""order_column='changed_on_delta_humanized' is accepted when declared in
|
||||
sortable_columns and is translated to the real 'changed_on' column before
|
||||
reaching the DAO — the humanized value is a Python property (rendered via
|
||||
FAB's @renders("changed_on")), not a queryable SQLAlchemy column, so
|
||||
passing it straight through would break DAO.list()'s
|
||||
`getattr(model, order_column)` sort.
|
||||
"""
|
||||
captured: dict = {}
|
||||
|
||||
class CapturingDAO:
|
||||
@classmethod
|
||||
def list(cls, order_column=None, **kwargs):
|
||||
captured["order_column"] = order_column
|
||||
return [], 0
|
||||
|
||||
tool = ModelListCore(
|
||||
dao_class=CapturingDAO,
|
||||
output_schema=DummyOutputSchema,
|
||||
item_serializer=dummy_serializer,
|
||||
filter_type=None,
|
||||
default_columns=["id", "name"],
|
||||
search_columns=["name"],
|
||||
list_field_name="items",
|
||||
output_list_schema=DummyListSchema,
|
||||
sortable_columns=["id", "name", "changed_on", "changed_on_delta_humanized"],
|
||||
)
|
||||
|
||||
# Should not raise, and the DAO must receive the real column name.
|
||||
tool.run_tool(order_column="changed_on_delta_humanized")
|
||||
|
||||
assert captured["order_column"] == "changed_on"
|
||||
|
||||
|
||||
def test_model_list_tool_rejects_unknown_order_column_even_with_alias_declared():
|
||||
"""A genuinely unknown order_column must still raise, even when the tool
|
||||
also declares the changed_on_delta_humanized alias as sortable."""
|
||||
def test_model_list_tool_allows_order_column_when_sortable_columns_not_declared():
|
||||
"""When sortable_columns is not provided, order_column is passed through to the DAO
|
||||
without validation (backward-compatible behaviour)."""
|
||||
tool = ModelListCore(
|
||||
dao_class=DummyDAO,
|
||||
output_schema=DummyOutputSchema,
|
||||
@@ -322,39 +288,10 @@ def test_model_list_tool_rejects_unknown_order_column_even_with_alias_declared()
|
||||
search_columns=["name"],
|
||||
list_field_name="items",
|
||||
output_list_schema=DummyListSchema,
|
||||
sortable_columns=["id", "name", "changed_on", "changed_on_delta_humanized"],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid order_column 'random'"):
|
||||
tool.run_tool(order_column="random")
|
||||
|
||||
|
||||
def test_model_list_tool_allows_order_column_when_sortable_columns_not_declared():
|
||||
"""When sortable_columns is not provided, order_column is passed through to the DAO
|
||||
without validation (backward-compatible behaviour)."""
|
||||
captured: dict = {}
|
||||
|
||||
class CapturingDAO:
|
||||
@classmethod
|
||||
def list(cls, order_column=None, **kwargs):
|
||||
captured["order_column"] = order_column
|
||||
return [], 0
|
||||
|
||||
tool = ModelListCore(
|
||||
dao_class=CapturingDAO,
|
||||
output_schema=DummyOutputSchema,
|
||||
item_serializer=dummy_serializer,
|
||||
filter_type=None,
|
||||
default_columns=["id", "name"],
|
||||
search_columns=["name"],
|
||||
list_field_name="items",
|
||||
output_list_schema=DummyListSchema,
|
||||
# sortable_columns intentionally omitted
|
||||
)
|
||||
# The no-allowlist path preserves the order column without alias resolution.
|
||||
tool.run_tool(order_column="changed_on_delta_humanized")
|
||||
|
||||
assert captured["order_column"] == "changed_on_delta_humanized"
|
||||
# Should not raise even though "name" is not in the (empty) sortable list
|
||||
tool.run_tool(order_column="name")
|
||||
|
||||
|
||||
def test_model_list_tool_injects_current_user_id_for_created_by_me():
|
||||
|
||||
@@ -23,12 +23,14 @@ import pytest
|
||||
from flask import g
|
||||
|
||||
from superset.mcp_service.auth import (
|
||||
_required_resource_scope,
|
||||
check_tool_permission,
|
||||
CLASS_PERMISSION_ATTR,
|
||||
is_tool_visible_to_current_user,
|
||||
MCPPermissionDeniedError,
|
||||
METHOD_PERMISSION_ATTR,
|
||||
PERMISSION_PREFIX,
|
||||
RESOURCE_SCOPE_NAME,
|
||||
)
|
||||
|
||||
|
||||
@@ -108,6 +110,17 @@ def test_check_tool_permission_no_class_permission_allows(app_context) -> None:
|
||||
assert check_tool_permission(func) is True
|
||||
|
||||
|
||||
def test_scoped_token_constrains_permissionless_tool(app_context) -> None:
|
||||
"""Resource-only scopes do not grant permission-less tools."""
|
||||
g.user = MagicMock(username="admin")
|
||||
func = _make_tool_func()
|
||||
|
||||
with _patch_token_scopes(["superset:dashboard:read"]):
|
||||
assert check_tool_permission(func) is False
|
||||
with _patch_token_scopes(["superset:read"]):
|
||||
assert check_tool_permission(func) is True
|
||||
|
||||
|
||||
def test_check_tool_permission_no_user_denies(app_context) -> None:
|
||||
"""If no g.user, permission check should deny."""
|
||||
g.user = None
|
||||
@@ -170,6 +183,19 @@ def test_check_tool_permission_disabled_via_config(app_context, app) -> None:
|
||||
app.config["MCP_RBAC_ENABLED"] = True
|
||||
|
||||
|
||||
def test_disabled_rbac_still_enforces_token_scopes(app_context, app) -> None:
|
||||
"""Disabling user RBAC does not disable credential restrictions."""
|
||||
func = _make_tool_func(class_perm="Chart", method_perm="write")
|
||||
app.config["MCP_RBAC_ENABLED"] = False
|
||||
try:
|
||||
with _patch_token_scopes(["superset:dashboard:read"]):
|
||||
assert check_tool_permission(func) is False
|
||||
with _patch_token_scopes(["superset:chart:write"]):
|
||||
assert check_tool_permission(func) is True
|
||||
finally:
|
||||
app.config["MCP_RBAC_ENABLED"] = True
|
||||
|
||||
|
||||
# -- Permission constants --
|
||||
|
||||
|
||||
@@ -289,6 +315,19 @@ def test_visibility_public_tool_no_class_permission(app_context) -> None:
|
||||
assert is_tool_visible_to_current_user(tool) is True
|
||||
|
||||
|
||||
def test_visibility_hides_permissionless_tool_from_resource_scoped_token(
|
||||
app_context,
|
||||
) -> None:
|
||||
"""Permission-less tools require a flat scope in tools/list too."""
|
||||
g.user = MagicMock(username="viewer")
|
||||
tool = _make_mock_tool(fn=_make_tool_func())
|
||||
|
||||
with _patch_token_scopes(["superset:dashboard:read"]):
|
||||
assert is_tool_visible_to_current_user(tool) is False
|
||||
with _patch_token_scopes(["superset:read"]):
|
||||
assert is_tool_visible_to_current_user(tool) is True
|
||||
|
||||
|
||||
def test_visibility_allowed_tool(app_context) -> None:
|
||||
"""Tools where security_manager grants access are visible."""
|
||||
g.user = MagicMock(username="admin")
|
||||
@@ -431,6 +470,23 @@ def test_scope_falls_back_to_rbac_when_no_jwt_context(app_context) -> None:
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_scope_context_error_fails_closed(app_context) -> None:
|
||||
"""An unexpected token lookup failure cannot erase token restrictions."""
|
||||
g.user = MagicMock(username="editor")
|
||||
func = _make_tool_func(class_perm="Chart", method_perm="read")
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.can_access = MagicMock(return_value=True)
|
||||
with (
|
||||
patch("superset.mcp_service.auth.security_manager", mock_sm),
|
||||
patch(
|
||||
"fastmcp.server.dependencies.get_access_token",
|
||||
side_effect=TypeError("invalid token context"),
|
||||
),
|
||||
):
|
||||
assert check_tool_permission(func) is False
|
||||
|
||||
|
||||
def test_scope_read_denied_when_token_lacks_read_scope(app_context) -> None:
|
||||
"""A read tool is denied when the token only carries an unrelated scope."""
|
||||
g.user = MagicMock(username="viewer")
|
||||
@@ -447,7 +503,9 @@ def test_scope_read_denied_when_token_lacks_read_scope(app_context) -> None:
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_scope_denies_unmapped_method_for_scoped_token(app_context) -> None:
|
||||
def test_scope_denies_unmapped_method_for_scoped_token(
|
||||
app_context, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A scoped token presented for a method permission that is NOT in the
|
||||
scope map fails closed (denied), even when RBAC grants, so an unmapped
|
||||
custom permission cannot silently bypass scope enforcement."""
|
||||
@@ -463,6 +521,8 @@ def test_scope_denies_unmapped_method_for_scoped_token(app_context) -> None:
|
||||
result = check_tool_permission(func)
|
||||
|
||||
assert result is False
|
||||
assert "unmapped method permission 'some_custom_perm'" in caplog.text
|
||||
assert "required scope 'None'" not in caplog.text
|
||||
|
||||
|
||||
def test_scope_execute_sql_query_requires_write_scope(app_context) -> None:
|
||||
@@ -480,6 +540,103 @@ def test_scope_execute_sql_query_requires_write_scope(app_context) -> None:
|
||||
assert check_tool_permission(func) is True
|
||||
|
||||
|
||||
# -- Per-resource scopes (superset:<resource>:<action>) --
|
||||
|
||||
|
||||
def test_required_resource_scope_special_names() -> None:
|
||||
"""The explicit resource map handles names a naive lower() would break:
|
||||
'Row Level Security' (spaces) and 'ReportSchedule'/'SQLLab' (misnames)."""
|
||||
assert _required_resource_scope("Row Level Security", "read") == "superset:rls:read"
|
||||
assert _required_resource_scope("ReportSchedule", "write") == (
|
||||
"superset:report:write"
|
||||
)
|
||||
assert _required_resource_scope("SQLLab", "execute_sql_query") == (
|
||||
"superset:sqllab:write"
|
||||
)
|
||||
assert _required_resource_scope("Chart", "update") == "superset:chart:write"
|
||||
|
||||
|
||||
def test_required_resource_scope_unmapped_returns_none() -> None:
|
||||
"""An unmapped resource or method yields None (no per-resource scope),
|
||||
which callers must NOT treat as a grant."""
|
||||
assert _required_resource_scope("NotAResource", "read") is None
|
||||
assert _required_resource_scope("Chart", "not_a_method") is None
|
||||
|
||||
|
||||
def test_resource_scope_name_covers_all_tool_resource_classes() -> None:
|
||||
"""RESOURCE_SCOPE_NAME must cover every class_permission_name declared by
|
||||
MCP tools. If a new resource class is added, add it to the map."""
|
||||
assert set(RESOURCE_SCOPE_NAME.keys()) == {
|
||||
"Annotation",
|
||||
"Chart",
|
||||
"Dashboard",
|
||||
"Database",
|
||||
"Dataset",
|
||||
"Explore",
|
||||
"Query",
|
||||
"ReportSchedule",
|
||||
"Role",
|
||||
"Row Level Security",
|
||||
"SavedQuery",
|
||||
"SQLLab",
|
||||
"Tag",
|
||||
"Task",
|
||||
"Theme",
|
||||
"User",
|
||||
}
|
||||
|
||||
|
||||
def test_per_resource_scope_grants_matching_tool(app_context) -> None:
|
||||
"""A token scoped ONLY to superset:chart:write (no flat superset:write)
|
||||
still grants a Chart/write tool via the per-resource grant path."""
|
||||
g.user = MagicMock(username="editor")
|
||||
func = _make_tool_func(class_perm="Chart", method_perm="write")
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.can_access = MagicMock(return_value=True)
|
||||
with (
|
||||
patch("superset.mcp_service.auth.security_manager", mock_sm),
|
||||
_patch_token_scopes(["superset:chart:write"]),
|
||||
):
|
||||
result = check_tool_permission(func)
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_per_resource_scope_does_not_leak_across_resources(app_context) -> None:
|
||||
"""A token scoped to superset:chart:write does NOT grant a Dashboard/write
|
||||
tool (resource isolation)."""
|
||||
g.user = MagicMock(username="editor")
|
||||
func = _make_tool_func(class_perm="Dashboard", method_perm="write")
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.can_access = MagicMock(return_value=True)
|
||||
with (
|
||||
patch("superset.mcp_service.auth.security_manager", mock_sm),
|
||||
_patch_token_scopes(["superset:chart:write"]),
|
||||
):
|
||||
result = check_tool_permission(func)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_per_resource_scope_enforces_action(app_context) -> None:
|
||||
"""A token scoped to superset:chart:read does NOT grant a Chart/write tool
|
||||
(action still enforced within the resource)."""
|
||||
g.user = MagicMock(username="editor")
|
||||
func = _make_tool_func(class_perm="Chart", method_perm="write")
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.can_access = MagicMock(return_value=True)
|
||||
with (
|
||||
patch("superset.mcp_service.auth.security_manager", mock_sm),
|
||||
_patch_token_scopes(["superset:chart:read"]),
|
||||
):
|
||||
result = check_tool_permission(func)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User/Role tools must request a permission FAB actually registers.
|
||||
#
|
||||
|
||||
@@ -606,69 +606,6 @@ def test_setup_user_context_allows_active_user(app) -> None:
|
||||
assert g.user is active_user
|
||||
|
||||
|
||||
# -- _mcp_user_id_var (ContextVar surviving the per-call app context pop) --
|
||||
#
|
||||
# g.user is only valid for the lifetime of the per-call app context that
|
||||
# _get_app_context_manager() pushes around tool execution; it's popped
|
||||
# before LoggingMiddleware's finally block runs, so get_user_id() there
|
||||
# always sees a stale/cleared g. _mcp_user_id_var is a plain ContextVar,
|
||||
# not tied to that app-context lifecycle, set here so it survives to be
|
||||
# read later for audit logging.
|
||||
|
||||
|
||||
def test_setup_user_context_sets_contextvar_for_active_user(app) -> None:
|
||||
"""_mcp_user_id_var carries the resolved user's id past this call."""
|
||||
from superset.mcp_service.auth import _mcp_user_id_var, _setup_user_context
|
||||
|
||||
active_user = _make_mock_user("active_user")
|
||||
active_user.is_active = True
|
||||
active_user.active = True
|
||||
active_user.id = 321
|
||||
|
||||
with app.test_request_context():
|
||||
with patch(
|
||||
"superset.mcp_service.auth.get_user_from_request",
|
||||
return_value=active_user,
|
||||
):
|
||||
_setup_user_context()
|
||||
assert _mcp_user_id_var.get() == 321
|
||||
|
||||
|
||||
def test_setup_user_context_clears_stale_contextvar_on_failure(app) -> None:
|
||||
"""A previous call's user_id must not leak into a call that fails to
|
||||
resolve a user (e.g. sequential calls sharing one asyncio task)."""
|
||||
from superset.mcp_service.auth import _mcp_user_id_var, _setup_user_context
|
||||
|
||||
with app.test_request_context():
|
||||
_mcp_user_id_var.set(999)
|
||||
with patch(
|
||||
"superset.mcp_service.auth.get_user_from_request",
|
||||
side_effect=ValueError("no user"),
|
||||
):
|
||||
with pytest.raises(ValueError, match="no user"):
|
||||
_setup_user_context()
|
||||
assert _mcp_user_id_var.get() is None
|
||||
|
||||
|
||||
def test_setup_user_context_leaves_contextvar_unset_for_guest_user(app) -> None:
|
||||
"""GuestUser (embedded auth) has no numeric id -- the ContextVar must
|
||||
stay cleared rather than store a bogus value."""
|
||||
from superset.mcp_service.auth import _mcp_user_id_var, _setup_user_context
|
||||
|
||||
guest_user = _make_mock_user("guest_user")
|
||||
guest_user.is_active = True
|
||||
guest_user.active = True
|
||||
guest_user.id = None
|
||||
|
||||
with app.test_request_context():
|
||||
with patch(
|
||||
"superset.mcp_service.auth.get_user_from_request",
|
||||
return_value=guest_user,
|
||||
):
|
||||
_setup_user_context()
|
||||
assert _mcp_user_id_var.get() is None
|
||||
|
||||
|
||||
# -- Multi-issuer binding guard --
|
||||
|
||||
|
||||
|
||||
@@ -233,13 +233,22 @@ async def test_api_key_passthrough_propagates_required_scopes() -> None:
|
||||
# -- Transport-layer DB validation (app configured) --
|
||||
|
||||
|
||||
def _make_app_with_api_key(username: str | None) -> MagicMock:
|
||||
"""Return a mock Flask app whose SecurityManager validates to ``username``."""
|
||||
def _make_app_with_api_key(
|
||||
username: str | None, scopes: str | None = None
|
||||
) -> MagicMock:
|
||||
"""Return a mock Flask app whose SecurityManager validates to ``username``.
|
||||
|
||||
``scopes`` is what ``get_api_key_scopes`` returns (FAB stores scopes as a
|
||||
comma-separated string, or None). It must be configured explicitly — an
|
||||
unconfigured MagicMock return value would raise on ``.split(",")`` inside
|
||||
the verifier's broad except-block and silently read as a rejected key.
|
||||
"""
|
||||
mock_user = MagicMock()
|
||||
mock_user.username = username
|
||||
|
||||
mock_sm = MagicMock()
|
||||
mock_sm.validate_api_key = MagicMock(return_value=mock_user if username else None)
|
||||
mock_sm.get_api_key_scopes = MagicMock(return_value=scopes)
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.app_context.return_value.__enter__ = MagicMock(return_value=None)
|
||||
@@ -264,6 +273,41 @@ async def test_transport_validation_valid_key_returns_access_token() -> None:
|
||||
assert result.claims.get(API_KEY_VALIDATED_USERNAME_CLAIM) == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transport_validation_uses_keys_own_scopes() -> None:
|
||||
"""A key with its own ApiKey.scopes carries them on the AccessToken,
|
||||
parsed from FAB's comma-separated storage format."""
|
||||
mock_app = _make_app_with_api_key(
|
||||
"alice", scopes="superset:dashboard:read, superset:chart:read"
|
||||
)
|
||||
verifier = CompositeTokenVerifier(
|
||||
jwt_verifier=None, api_key_prefixes=["sst_"], app=mock_app
|
||||
)
|
||||
|
||||
result = await verifier.verify_token("sst_valid_key")
|
||||
|
||||
assert result is not None
|
||||
assert result.scopes == ["superset:dashboard:read", "superset:chart:read"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transport_validation_no_key_scopes_remains_unscoped() -> None:
|
||||
"""A key without scopes remains unscoped despite global JWT requirements."""
|
||||
mock_app = _make_app_with_api_key("alice", scopes=None)
|
||||
jwt_verifier = MagicMock()
|
||||
jwt_verifier.required_scopes = ["superset:read"]
|
||||
jwt_verifier.verify_token = AsyncMock()
|
||||
|
||||
verifier = CompositeTokenVerifier(
|
||||
jwt_verifier=jwt_verifier, api_key_prefixes=["sst_"], app=mock_app
|
||||
)
|
||||
|
||||
result = await verifier.verify_token("sst_valid_key")
|
||||
|
||||
assert result is not None
|
||||
assert result.scopes == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transport_validation_invalid_key_returns_none() -> None:
|
||||
"""An invalid API key is rejected at transport (returns None → HTTP 401)."""
|
||||
|
||||
@@ -334,6 +334,13 @@ def test_tool_denied_for_principal_helper(app: SupersetApp) -> None:
|
||||
assert _tool_denied_for_principal(allowed) is False
|
||||
|
||||
|
||||
def test_default_allow_list_matches_config() -> None:
|
||||
"""The auth.py default and the mcp_config.py default must stay in sync."""
|
||||
from superset.mcp_service.mcp_config import MCP_GUEST_ALLOWED_TOOLS
|
||||
|
||||
assert set(_DEFAULT_GUEST_ALLOWED_TOOLS) == set(MCP_GUEST_ALLOWED_TOOLS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool_name", sorted(_DEFAULT_GUEST_ALLOWED_TOOLS))
|
||||
def test_allow_listed_tools_permitted_for_guest(
|
||||
app: SupersetApp, tool_name: str
|
||||
|
||||
@@ -45,8 +45,6 @@ from superset.mcp_service.middleware import (
|
||||
ResponseSizeGuardMiddleware,
|
||||
StructuredContentStripperMiddleware,
|
||||
)
|
||||
from superset.mcp_service.utils.token_utils import estimate_token_count
|
||||
from superset.utils import json as utils_json
|
||||
from superset.utils.log import DBEventLogger
|
||||
|
||||
|
||||
@@ -473,71 +471,6 @@ class TestResponseSizeGuardMiddleware:
|
||||
assert result["_response_truncated"] is True
|
||||
assert len(result["data"]) < 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_truncates_multi_query_chart_rows_across_whole_response(self) -> None:
|
||||
"""All query results share the response's token budget."""
|
||||
middleware = ResponseSizeGuardMiddleware(token_limit=500)
|
||||
context = MagicMock()
|
||||
context.message.name = "get_chart_data"
|
||||
context.message.arguments = {}
|
||||
row = {f"col_{i}": f"value_{i}" for i in range(10)}
|
||||
large_response = {
|
||||
"chart_id": 1,
|
||||
"data": [row] * 200,
|
||||
"row_count": 200,
|
||||
"query_results": [
|
||||
{"query_index": 0, "data": [row] * 200, "row_count": 200},
|
||||
{"query_index": 1, "data": [row] * 200, "row_count": 200},
|
||||
],
|
||||
}
|
||||
call_next = AsyncMock(return_value=large_response)
|
||||
|
||||
with (
|
||||
patch("superset.mcp_service.middleware.get_user_id", return_value=1),
|
||||
patch("superset.mcp_service.middleware.event_logger"),
|
||||
):
|
||||
result = await middleware.on_call_tool(context, call_next)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert result["_response_truncated"] is True
|
||||
assert all(query["data"] for query in result["query_results"])
|
||||
returned_counts = [len(query["data"]) for query in result["query_results"]]
|
||||
assert len(set(returned_counts)) == 1
|
||||
assert returned_counts[0] < 200
|
||||
assert [
|
||||
query["row_count"] for query in result["query_results"]
|
||||
] == returned_counts
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_query_truncation_result_fits_budget(self) -> None:
|
||||
"""The final multi-query truncation note stays within the token budget."""
|
||||
middleware = ResponseSizeGuardMiddleware(token_limit=700)
|
||||
context = MagicMock()
|
||||
context.message.name = "get_chart_data"
|
||||
context.message.arguments = {}
|
||||
row = {f"col_{i}": f"value_{i}" for i in range(10)}
|
||||
response = {
|
||||
"chart_id": 1,
|
||||
"data": [row] * 200,
|
||||
"row_count": 200,
|
||||
"query_results": [
|
||||
{"query_index": 0, "data": [row] * 200, "row_count": 200},
|
||||
{"query_index": 1, "data": [row] * 200, "row_count": 200},
|
||||
],
|
||||
}
|
||||
|
||||
with (
|
||||
patch("superset.mcp_service.middleware.get_user_id", return_value=1),
|
||||
patch("superset.mcp_service.middleware.event_logger"),
|
||||
):
|
||||
result = await middleware.on_call_tool(
|
||||
context, AsyncMock(return_value=response)
|
||||
)
|
||||
|
||||
assert isinstance(result, dict)
|
||||
assert estimate_token_count(utils_json.dumps(result)) <= 700
|
||||
assert " of 400 rows returned" in result["_truncation_notes"][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_data_query_truncation_updates_row_count(self) -> None:
|
||||
"""row_count should reflect the truncated count, not the original."""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user