mirror of
https://github.com/apache/superset.git
synced 2026-06-26 18:09:21 +00:00
Compare commits
28 Commits
superset-h
...
chore/ci/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c32284d56a | ||
|
|
6bc77fecc2 | ||
|
|
420a74b01e | ||
|
|
7ba59c2d79 | ||
|
|
b77c525d4b | ||
|
|
41ce9ca7d3 | ||
|
|
c2fb94cedf | ||
|
|
1d0866556f | ||
|
|
b4dfeef2fd | ||
|
|
0ec6cae45d | ||
|
|
d6ede99861 | ||
|
|
9b6d3ce775 | ||
|
|
c1f4062af6 | ||
|
|
3bc3f47d67 | ||
|
|
acb996a324 | ||
|
|
c1d08bf27c | ||
|
|
d3d5297025 | ||
|
|
b1470bd5a5 | ||
|
|
18fea37e84 | ||
|
|
1b71c105b7 | ||
|
|
b061b5d317 | ||
|
|
386893f9f2 | ||
|
|
c1787a67aa | ||
|
|
dee5859599 | ||
|
|
1d3daf2ac8 | ||
|
|
9d56b1721d | ||
|
|
67182e255c | ||
|
|
e2c6dc3e1a |
4
.github/workflows/bump-python-package.yml
vendored
4
.github/workflows/bump-python-package.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
checks: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: true
|
||||
ref: master
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
|
||||
2
.github/workflows/check-python-deps.yml
vendored
2
.github/workflows/check-python-deps.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check and notify
|
||||
|
||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
4
.github/workflows/dependency-review.yml
vendored
4
.github/workflows/dependency-review.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: "Dependency Review"
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
6
.github/workflows/docker.yml
vendored
6
.github/workflows/docker.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
docker: ${{ steps.check.outputs.docker }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Free up disk space
|
||||
|
||||
2
.github/workflows/embedded-sdk-release.yml
vendored
2
.github/workflows/embedded-sdk-release.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
run:
|
||||
working-directory: superset-embedded-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Note: registry-url is intentionally omitted. When set, actions/setup-node
|
||||
|
||||
2
.github/workflows/embedded-sdk-test.yml
vendored
2
.github/workflows/embedded-sdk-test.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
run:
|
||||
working-directory: superset-embedded-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
|
||||
2
.github/workflows/generate-FOSSA-report.yml
vendored
2
.github/workflows/generate-FOSSA-report.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/issue_creation.yml
vendored
2
.github/workflows/issue_creation.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/latest-release-tag.yml
vendored
2
.github/workflows/latest-release-tag.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/license-check.yml
vendored
2
.github/workflows/license-check.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/pr-lint.yml
vendored
2
.github/workflows/pr-lint.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/pre-commit.yml
vendored
2
.github/workflows/pre-commit.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# pulls all commits (needed for lerna / semantic release to correctly version)
|
||||
|
||||
2
.github/workflows/showtime-trigger.yml
vendored
2
.github/workflows/showtime-trigger.yml
vendored
@@ -152,7 +152,7 @@ jobs:
|
||||
|
||||
- name: Checkout PR code (only if build needed)
|
||||
if: steps.auth.outputs.authorized == 'true' && steps.check.outputs.build_needed == 'true'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ steps.check.outputs.target_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/superset-app-cli.yml
vendored
2
.github/workflows/superset-app-cli.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/superset-docs-deploy.yml
vendored
2
.github/workflows/superset-docs-deploy.yml
vendored
@@ -60,7 +60,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.event.workflow_run.head_sha || github.sha }}"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
6
.github/workflows/superset-docs-verify.yml
vendored
6
.github/workflows/superset-docs-verify.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
name: Link Checking
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Do not bump this linkinator-action version without opening
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
working-directory: docs
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
working-directory: docs
|
||||
steps:
|
||||
- name: "Checkout PR head: ${{ github.event.workflow_run.head_sha }}"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
persist-credentials: false
|
||||
|
||||
14
.github/workflows/superset-e2e.yml
vendored
14
.github/workflows/superset-e2e.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -97,21 +97,21 @@ jobs:
|
||||
# Conditional checkout based on context
|
||||
- name: Checkout for push or pull_request event
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Checkout using ref (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
submodules: recursive
|
||||
- name: Checkout using PR ID (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
@@ -207,21 +207,21 @@ jobs:
|
||||
# Conditional checkout based on context (same as Cypress workflow)
|
||||
- name: Checkout for push or pull_request event
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Checkout using ref (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
submodules: recursive
|
||||
- name: Checkout using PR ID (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
working-directory: superset-extensions-cli
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
4
.github/workflows/superset-frontend.yml
vendored
4
.github/workflows/superset-frontend.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
should-run: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
2
.github/workflows/superset-helm-lint.yml
vendored
2
.github/workflows/superset-helm-lint.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/superset-helm-release.yml
vendored
2
.github/workflows/superset-helm-release.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref_name }}
|
||||
persist-credentials: true
|
||||
|
||||
8
.github/workflows/superset-playwright.yml
vendored
8
.github/workflows/superset-playwright.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
frontend: ${{ steps.check.outputs.frontend }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -83,21 +83,21 @@ jobs:
|
||||
# Conditional checkout based on context (same as Cypress workflow)
|
||||
- name: Checkout for push or pull_request event
|
||||
if: github.event_name == 'push' || github.event_name == 'pull_request'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
- name: Checkout using ref (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
submodules: recursive
|
||||
- name: Checkout using PR ID (workflow_dispatch)
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check for file changes
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
4
.github/workflows/superset-translations.yml
vendored
4
.github/workflows/superset-translations.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
|
||||
2
.github/workflows/superset-websocket.yml
vendored
2
.github/workflows/superset-websocket.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install dependencies
|
||||
|
||||
2
.github/workflows/supersetbot.yml
vendored
2
.github/workflows/supersetbot.yml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
});
|
||||
|
||||
- name: "Checkout ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
# zizmor: ignore[artipacked] - required persisted credentials to push synced requirement changes back to remote
|
||||
- name: Checkout source code
|
||||
if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'pip' }}
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: true
|
||||
|
||||
4
.github/workflows/tag-release.yml
vendored
4
.github/workflows/tag-release.yml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
2
.github/workflows/tech-debt.yml
vendored
2
.github/workflows/tech-debt.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
name: Generate Reports
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -1808,6 +1808,10 @@ If you enable DML in the meta database users will be able to run DML queries on
|
||||
|
||||
Second, you might want to change the value of `SUPERSET_META_DB_LIMIT`. The default value is 1000, and defines how many are read from each database before any aggregations and joins are executed. You can also set this value `None` if you only have small tables.
|
||||
|
||||
:::warning
|
||||
`SUPERSET_META_DB_LIMIT` is applied to **each** underlying table *before* the in-memory join runs, not to the final result. If any table involved in a join has more rows than the limit, the meta database will read only the first `SUPERSET_META_DB_LIMIT` rows of that table, which means matching rows can be silently dropped and the join can return **incomplete or even empty** results with no error. If you join tables larger than the limit, raise `SUPERSET_META_DB_LIMIT` to comfortably exceed your largest joined table, or set it to `None` when working only with small tables, to get correct results.
|
||||
:::
|
||||
|
||||
Additionally, you might want to restrict the databases to with the meta database has access to. This can be done in the database configuration, under "Advanced" -> "Other" -> "ENGINE PARAMETERS" and adding:
|
||||
|
||||
```json
|
||||
|
||||
@@ -375,7 +375,6 @@ select = [
|
||||
|
||||
ignore = [
|
||||
"S101",
|
||||
"PT004", # Fixtures that don't return values - underscore prefix conflicts with pytest usage
|
||||
"PT006",
|
||||
"T201",
|
||||
"N999",
|
||||
|
||||
46
superset-frontend/package-lock.json
generated
46
superset-frontend/package-lock.json
generated
@@ -36,7 +36,7 @@
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
"@jsonforms/vanilla-renderers": "^3.7.0",
|
||||
"@jsonforms/vanilla-renderers": "^3.8.0",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
"@luma.gl/engine": "~9.2.5",
|
||||
@@ -187,7 +187,7 @@
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.41",
|
||||
"@swc/plugin-emotion": "^14.12.0",
|
||||
"@swc/plugin-emotion": "^14.13.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -272,7 +272,7 @@
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.4.5",
|
||||
"storybook": "10.4.6",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
@@ -5387,41 +5387,41 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jsonforms/core": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.7.0.tgz",
|
||||
"integrity": "sha512-CE9viWtwi9QWLqlWLeOul1/R1GRAyOA9y6OoUpsCc0FhyR+g5p29F3k0fUExHWxL0Sf4KHcXYkfhtqfRBPS8ww==",
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/core/-/core-3.8.0.tgz",
|
||||
"integrity": "sha512-XSvaZuQSs/MceG5nDDcrE879onPHkGBy0xEuLeZMUkSM/M8wc1dEUrJtMOZVNSITocm9YXFY1qQ5gnsPP38zAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.3",
|
||||
"ajv": "^8.6.1",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^2.1.0",
|
||||
"lodash": "^4.17.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsonforms/react": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-3.7.0.tgz",
|
||||
"integrity": "sha512-HkY7qAx8vW97wPEgZ7GxCB3iiXG1c95GuObxtcDHGPBJWMwnxWBnVYJmv5h7nthrInKsQKHZL5OusnC/sj/1GQ==",
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/react/-/react-3.8.0.tgz",
|
||||
"integrity": "sha512-k81+yWLpCQl+3XizS1bLjXoBwYhW1OAkjSXFA8W5qNtfPZjSOXDgtiuMOGYDv4b60tu2e9RB8h2P2O7QhfkhiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@jsonforms/core": "3.7.0",
|
||||
"@jsonforms/core": "3.8.0",
|
||||
"react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsonforms/vanilla-renderers": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/vanilla-renderers/-/vanilla-renderers-3.7.0.tgz",
|
||||
"integrity": "sha512-RdXQGsheARUJVbaTe6SqGw9W4/yrm0BgUok6OKUj8krp1NF4fqXc5UbYGHFksMR/p7LCuoYHCtQzKLXEfxJbDw==",
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@jsonforms/vanilla-renderers/-/vanilla-renderers-3.8.0.tgz",
|
||||
"integrity": "sha512-s75TG4hSYgYLN9IRVhYtGjijqyhVXijgDhb2WnMqY+Ki7MQkLn9U7yg/l89NEpwzWS1sv0DxKUxriqVUq382og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@jsonforms/core": "3.7.0",
|
||||
"@jsonforms/react": "3.7.0",
|
||||
"@jsonforms/core": "3.8.0",
|
||||
"@jsonforms/react": "3.8.0",
|
||||
"react": "^16.12.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
@@ -10819,9 +10819,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/plugin-emotion": {
|
||||
"version": "14.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.12.0.tgz",
|
||||
"integrity": "sha512-lyAQgTeDkowq/4+8JYaviVOL4jXSdObz+uuk84DjM0z4qoiMpI6xoDVp7/tjWeVjmLc2U6Qp3hDuwWMZ5xe88Q==",
|
||||
"version": "14.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@swc/plugin-emotion/-/plugin-emotion-14.13.0.tgz",
|
||||
"integrity": "sha512-UT1l9tr934HjnktUiMGbw1rWrIXUhAByTB0DwZJwHmS8KWox+wNBIK4ZkJ2tKVU/PnQZRni+R9e6xklFkmgSYg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -39527,9 +39527,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.5.tgz",
|
||||
"integrity": "sha512-QZuv1gS9Tf9RMCjDw5JOfv1XSB5IhU0uhSKQNS7l/N9zDpmSydirCspkCNT9e0zkFfPkZ9vmQUTzHY/BA07saA==",
|
||||
"version": "10.4.6",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-10.4.6.tgz",
|
||||
"integrity": "sha512-6wkA6LxfDSSilloITsrFOJfsnw0mDUP2h8Ls+lRt8oRsudtz2RWFhLv+Toiwg6NW7hUpdTDc2hzR7DztJid6+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -39540,7 +39540,7 @@
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@webcontainer/env": "^1.1.1",
|
||||
"esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0",
|
||||
"esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0",
|
||||
"open": "^10.2.0",
|
||||
"oxc-parser": "^0.127.0",
|
||||
"oxc-resolver": "^11.19.1",
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
|
||||
"@jsonforms/core": "^3.7.0",
|
||||
"@jsonforms/react": "^3.7.0",
|
||||
"@jsonforms/vanilla-renderers": "^3.7.0",
|
||||
"@jsonforms/vanilla-renderers": "^3.8.0",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
"@luma.gl/engine": "~9.2.5",
|
||||
@@ -270,7 +270,7 @@
|
||||
"@storybook/test-runner": "0.24.4",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.41",
|
||||
"@swc/plugin-emotion": "^14.12.0",
|
||||
"@swc/plugin-emotion": "^14.13.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -355,7 +355,7 @@
|
||||
"source-map": "^0.7.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"speed-measure-webpack-plugin": "^1.6.0",
|
||||
"storybook": "10.4.5",
|
||||
"storybook": "10.4.6",
|
||||
"style-loader": "^4.0.0",
|
||||
"swc-loader": "^0.2.7",
|
||||
"terser-webpack-plugin": "^5.6.1",
|
||||
|
||||
@@ -74,7 +74,10 @@ export function transformLinkUri(uri: string): string {
|
||||
// "java\tscript:" or "java\x01script:") are ignored by browsers, so strip
|
||||
// them before comparing against the blocklist.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const scheme = url.slice(0, colon).replace(/[\u0000-\u0020]/g, '').toLowerCase();
|
||||
const scheme = url
|
||||
.slice(0, colon)
|
||||
.replace(/[\u0000-\u0020]/g, '')
|
||||
.toLowerCase();
|
||||
return DANGEROUS_LINK_PROTOCOLS.includes(scheme) ? '' : url;
|
||||
}
|
||||
|
||||
|
||||
@@ -519,7 +519,8 @@ const Select = forwardRef(
|
||||
handleSelectAll();
|
||||
}}
|
||||
>
|
||||
{t('Select all')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
|
||||
{t('Select all')}{' '}
|
||||
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.selectable)})`}
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
@@ -536,7 +537,8 @@ const Select = forwardRef(
|
||||
handleDeselectAll();
|
||||
}}
|
||||
>
|
||||
{t('Clear')} {`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
|
||||
{t('Clear')}{' '}
|
||||
{`(${formatNumber('SMART_NUMBER', bulkSelectCounts.deselectable)})`}
|
||||
</Button>
|
||||
</StyledBulkActionsContainer>
|
||||
),
|
||||
|
||||
@@ -97,8 +97,11 @@ testWithAssets(
|
||||
});
|
||||
|
||||
// At least one list item should contain a DD.MM.YYYY formatted date.
|
||||
await expect(panel.locator('li').first()).toHaveText(/\d{2}\.\d{2}\.\d{4}/, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
await expect(panel.locator('li').first()).toHaveText(
|
||||
/\d{2}\.\d{2}\.\d{4}/,
|
||||
{
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -182,10 +182,7 @@ testWithAssets(
|
||||
// Now track POST /api/v1/chart/data requests around Clear All
|
||||
const postsAfterClearAll: string[] = [];
|
||||
const handler = (req: any) => {
|
||||
if (
|
||||
req.url().includes('/api/v1/chart/data') &&
|
||||
req.method() === 'POST'
|
||||
) {
|
||||
if (req.url().includes('/api/v1/chart/data') && req.method() === 'POST') {
|
||||
postsAfterClearAll.push(req.url());
|
||||
}
|
||||
};
|
||||
|
||||
@@ -109,7 +109,12 @@ testWithAssets(
|
||||
id: chartLayoutKey,
|
||||
children: [],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
|
||||
meta: { chartId, width: 8, height: 60, sliceName: 'mixed_filter_repro' },
|
||||
meta: {
|
||||
chartId,
|
||||
width: 8,
|
||||
height: 60,
|
||||
sliceName: 'mixed_filter_repro',
|
||||
},
|
||||
},
|
||||
};
|
||||
const jsonMetadata = {
|
||||
@@ -130,9 +135,7 @@ testWithAssets(
|
||||
defaultDataMask: {
|
||||
filterState: { value: [FILTER_VALUE] },
|
||||
extraFormData: {
|
||||
filters: [
|
||||
{ col: FILTER_COLUMN, op: 'IN', val: [FILTER_VALUE] },
|
||||
],
|
||||
filters: [{ col: FILTER_COLUMN, op: 'IN', val: [FILTER_VALUE] }],
|
||||
},
|
||||
},
|
||||
cascadeParentIds: [],
|
||||
@@ -158,15 +161,14 @@ testWithAssets(
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
await apiPut(page, `api/v1/chart/${chartId}`, { dashboards: [dashboardId] });
|
||||
await apiPut(page, `api/v1/chart/${chartId}`, {
|
||||
dashboards: [dashboardId],
|
||||
});
|
||||
|
||||
// Capture the Mixed chart's data request (the one with two queries).
|
||||
const twoQueryPayloads: any[] = [];
|
||||
page.on('request', req => {
|
||||
if (
|
||||
req.url().includes('/api/v1/chart/data') &&
|
||||
req.method() === 'POST'
|
||||
) {
|
||||
if (req.url().includes('/api/v1/chart/data') && req.method() === 'POST') {
|
||||
try {
|
||||
const body = req.postDataJSON();
|
||||
if (body?.queries?.length === 2) {
|
||||
|
||||
@@ -50,7 +50,10 @@ import {
|
||||
getGuestToken,
|
||||
} from '../../helpers/api/embedded';
|
||||
import { apiPost, apiPut } from '../../helpers/api/requests';
|
||||
import { apiPostDashboard, apiDeleteDashboard } from '../../helpers/api/dashboard';
|
||||
import {
|
||||
apiPostDashboard,
|
||||
apiDeleteDashboard,
|
||||
} from '../../helpers/api/dashboard';
|
||||
import { apiDeleteChart } from '../../helpers/api/chart';
|
||||
import { EmbeddedPage } from '../../pages/EmbeddedPage';
|
||||
import { EMBEDDED } from '../../utils/constants';
|
||||
|
||||
@@ -21,10 +21,12 @@
|
||||
import d3 from 'd3';
|
||||
import { extent as d3Extent } from 'd3-array';
|
||||
import {
|
||||
ValueFormatter,
|
||||
getNumberFormatter,
|
||||
getSequentialSchemeRegistry,
|
||||
BinaryQueryObjectFilterClause,
|
||||
CategoricalColorNamespace,
|
||||
ContextMenuFilters,
|
||||
DataMask,
|
||||
ValueFormatter,
|
||||
getSequentialSchemeRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import countries, { countryOptions } from './countries';
|
||||
|
||||
@@ -65,9 +67,28 @@ interface CountryMapProps {
|
||||
formatter: ValueFormatter;
|
||||
colorScheme: string;
|
||||
sliceId: number;
|
||||
onContextMenu?: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
data: ContextMenuFilters,
|
||||
) => void;
|
||||
emitCrossFilters?: boolean;
|
||||
setDataMask?: (dataMask: DataMask) => void;
|
||||
filterState?: {
|
||||
selectedValues?: string[];
|
||||
extraFormData?: {
|
||||
filters?: BinaryQueryObjectFilterClause[];
|
||||
};
|
||||
};
|
||||
entity?: string;
|
||||
}
|
||||
|
||||
const maps: Record<string, GeoData> = {};
|
||||
// Store zoom state per chart instance using element as key to enable garbage collection
|
||||
const zoomStates = new WeakMap<
|
||||
HTMLElement,
|
||||
{ scale: number; translate: [number, number] }
|
||||
>();
|
||||
|
||||
function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
const {
|
||||
@@ -75,10 +96,15 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
width,
|
||||
height,
|
||||
country,
|
||||
entity,
|
||||
linearColorScheme,
|
||||
formatter,
|
||||
colorScheme,
|
||||
sliceId,
|
||||
filterState,
|
||||
emitCrossFilters,
|
||||
onContextMenu,
|
||||
setDataMask,
|
||||
} = props;
|
||||
|
||||
const container = element;
|
||||
@@ -99,7 +125,15 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
? colorScale(d.country_id, sliceId)
|
||||
: (linearColorScale(d.metric) ?? '');
|
||||
});
|
||||
const colorFn = (d: GeoFeature) => colorMap[d.properties.ISO] || 'none';
|
||||
|
||||
const colorFn = (feature: GeoFeature): string => {
|
||||
if (!feature?.properties) return '#d9d9d9';
|
||||
const iso = feature.properties.ISO;
|
||||
return colorMap[iso] || '#d9d9d9';
|
||||
};
|
||||
|
||||
// Check if dashboard is in edit mode
|
||||
const isEditMode = container.closest('.dashboard--editing') !== null;
|
||||
|
||||
const path = d3.geo.path();
|
||||
const div = d3.select(container);
|
||||
@@ -112,6 +146,11 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
.attr('width', width)
|
||||
.attr('height', height)
|
||||
.attr('preserveAspectRatio', 'xMidYMid meet');
|
||||
|
||||
// Only set grab cursor if not in edit mode
|
||||
if (!isEditMode) {
|
||||
svg.style('cursor', 'grab');
|
||||
}
|
||||
const backgroundRect = svg
|
||||
.append('rect')
|
||||
.attr('class', 'background')
|
||||
@@ -119,39 +158,64 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
.attr('height', height);
|
||||
const g = svg.append('g');
|
||||
const mapLayer = g.append('g').classed('map-layer', true);
|
||||
// Add hover popup for tooltip
|
||||
const hoverPopup = div.append('div').attr('class', 'hover-popup');
|
||||
|
||||
let centered: GeoFeature | null;
|
||||
// Track mouse position to distinguish clicks from drags
|
||||
let mousedownPos: { x: number; y: number } | null = null;
|
||||
|
||||
const clicked = function clicked(d: GeoFeature) {
|
||||
const hasCenter = d && centered !== d;
|
||||
let x: number;
|
||||
let y: number;
|
||||
let k: number;
|
||||
const halfWidth = width / 2;
|
||||
const halfHeight = height / 2;
|
||||
// Cross-filter support
|
||||
const getCrossFilterDataMask = (
|
||||
source: GeoFeature,
|
||||
): { dataMask: DataMask; isCurrentValueSelected: boolean } | undefined => {
|
||||
if (!entity) return undefined;
|
||||
|
||||
if (hasCenter) {
|
||||
const centroid = path.centroid(d);
|
||||
[x, y] = centroid;
|
||||
k = 4;
|
||||
centered = d;
|
||||
} else {
|
||||
x = halfWidth;
|
||||
y = halfHeight;
|
||||
k = 1;
|
||||
centered = null;
|
||||
}
|
||||
const selected = filterState?.selectedValues || [];
|
||||
const iso = source?.properties?.ISO;
|
||||
if (!iso) return undefined;
|
||||
|
||||
g.transition()
|
||||
.duration(750)
|
||||
.attr(
|
||||
'transform',
|
||||
`translate(${halfWidth},${halfHeight})scale(${k})translate(${-x},${-y})`,
|
||||
);
|
||||
const isSelected = selected.includes(iso);
|
||||
const values = isSelected ? [] : [iso];
|
||||
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters: values.length
|
||||
? [{ col: entity, op: 'IN', val: values }]
|
||||
: [],
|
||||
},
|
||||
filterState: {
|
||||
value: values.length ? values : null,
|
||||
selectedValues: values.length ? values : null,
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected: isSelected,
|
||||
};
|
||||
};
|
||||
|
||||
backgroundRect.on('click', clicked);
|
||||
// Handle right-click context menu
|
||||
const handleContextMenu = (feature: GeoFeature): void => {
|
||||
const pointerEvent = d3.event;
|
||||
|
||||
if (typeof onContextMenu === 'function') {
|
||||
pointerEvent?.preventDefault();
|
||||
}
|
||||
|
||||
const iso = feature?.properties?.ISO;
|
||||
if (!iso || typeof onContextMenu !== 'function' || !entity) return;
|
||||
|
||||
const drillVal = iso;
|
||||
const drillToDetailFilters = [
|
||||
{ col: entity, op: '==', val: drillVal, formattedVal: drillVal },
|
||||
];
|
||||
const drillByFilters = [{ col: entity, op: '==', val: drillVal }];
|
||||
|
||||
onContextMenu(pointerEvent.clientX, pointerEvent.clientY, {
|
||||
drillToDetail: drillToDetailFilters,
|
||||
crossFilter: getCrossFilterDataMask(feature),
|
||||
drillBy: { filters: drillByFilters, groupbyFieldName: 'entity' },
|
||||
});
|
||||
};
|
||||
|
||||
const getNameOfRegion = function getNameOfRegion(
|
||||
feature: GeoFeature,
|
||||
@@ -165,7 +229,7 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
return '';
|
||||
};
|
||||
|
||||
const updatePopupPosition = () => {
|
||||
const updatePopupPosition = (): void => {
|
||||
const svgHeight = svg.node().getBoundingClientRect().height;
|
||||
const [x, y] = d3.mouse(svg.node());
|
||||
hoverPopup
|
||||
@@ -175,34 +239,135 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
.classed('popup-at-bottom', y > (svgHeight * 2) / 3);
|
||||
};
|
||||
|
||||
const mouseenter = function mouseenter(this: SVGPathElement, d: GeoFeature) {
|
||||
const mouseenter = function mouseenter(
|
||||
this: SVGPathElement,
|
||||
d: GeoFeature,
|
||||
): void {
|
||||
// Darken color
|
||||
let c: string = colorFn(d);
|
||||
if (c !== 'none') {
|
||||
if (c) {
|
||||
c = d3.rgb(c).darker().toString();
|
||||
}
|
||||
d3.select(this).style('fill', c);
|
||||
|
||||
// Display information popup
|
||||
const result = data.filter(
|
||||
region => region.country_id === d.properties.ISO,
|
||||
);
|
||||
|
||||
hoverPopup.style('display', 'block').html(
|
||||
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
|
||||
);
|
||||
const result = data.filter(r => r.country_id === d?.properties?.ISO);
|
||||
const regionName = escapeHtml(getNameOfRegion(d));
|
||||
const metricValue =
|
||||
result.length > 0 ? escapeHtml(String(formatter(result[0].metric))) : '';
|
||||
hoverPopup
|
||||
.style('display', 'block')
|
||||
.html(`<div><strong>${regionName}</strong><br>${metricValue}</div>`);
|
||||
updatePopupPosition();
|
||||
};
|
||||
|
||||
const mousemove = function mousemove() {
|
||||
// Mouse move handler to update tooltip position
|
||||
const mousemove = function mousemove(): void {
|
||||
updatePopupPosition();
|
||||
};
|
||||
|
||||
const mouseout = function mouseout(this: SVGPathElement) {
|
||||
d3.select(this).style('fill', colorFn);
|
||||
const mouseout = function mouseout(this: SVGPathElement): void {
|
||||
d3.select(this).style('fill', (d: GeoFeature) => colorFn(d));
|
||||
hoverPopup.style('display', 'none');
|
||||
};
|
||||
|
||||
function drawMap(mapData: GeoData) {
|
||||
// Only enable zoom if not in edit mode
|
||||
if (!isEditMode) {
|
||||
// Zoom with panning bounds
|
||||
const zoom = d3.behavior
|
||||
.zoom()
|
||||
.scaleExtent([1, 4])
|
||||
.on('zoomstart', () => {
|
||||
svg.style('cursor', 'grabbing');
|
||||
})
|
||||
.on('zoom', () => {
|
||||
const { translate, scale } = d3.event;
|
||||
let [tx, ty] = translate;
|
||||
|
||||
const scaledW = width * scale;
|
||||
const scaledH = height * scale;
|
||||
const minX = Math.min(0, width - scaledW);
|
||||
const maxX = 0;
|
||||
const minY = Math.min(0, height - scaledH);
|
||||
const maxY = 0;
|
||||
|
||||
tx = Math.max(Math.min(tx, maxX), minX);
|
||||
ty = Math.max(Math.min(ty, maxY), minY);
|
||||
|
||||
// Sync D3's internal translate state with the clamped values so the
|
||||
// next wheel/zoom event starts from the constrained position rather
|
||||
// than the unclamped one (otherwise the view jumps).
|
||||
zoom.translate([tx, ty]);
|
||||
|
||||
g.attr('transform', `translate(${tx}, ${ty}) scale(${scale})`);
|
||||
const prev = zoomStates.get(element);
|
||||
const changed =
|
||||
!prev ||
|
||||
prev.scale !== scale ||
|
||||
prev.translate[0] !== tx ||
|
||||
prev.translate[1] !== ty;
|
||||
if (changed) {
|
||||
zoomStates.set(element, { scale, translate: [tx, ty] });
|
||||
}
|
||||
})
|
||||
.on('zoomend', () => {
|
||||
svg.style('cursor', 'grab');
|
||||
});
|
||||
|
||||
d3.select(svg.node()).call(zoom);
|
||||
|
||||
// Restore previous zoom state if it exists
|
||||
const savedZoom = zoomStates.get(element);
|
||||
if (savedZoom) {
|
||||
const { scale, translate } = savedZoom;
|
||||
zoom.scale(scale).translate(translate);
|
||||
g.attr(
|
||||
'transform',
|
||||
`translate(${translate[0]}, ${translate[1]}) scale(${scale})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Visual highlighting for selected regions
|
||||
function highlightSelectedRegion(
|
||||
selectedValues: string[] | null = null,
|
||||
): void {
|
||||
const selected = selectedValues || filterState?.selectedValues || [];
|
||||
|
||||
mapLayer
|
||||
.selectAll('path.region')
|
||||
.style('fill-opacity', (d: GeoFeature) => {
|
||||
const iso = d?.properties?.ISO;
|
||||
return selected.length === 0 || selected.includes(iso) ? 1 : 0.3;
|
||||
})
|
||||
.style('stroke', (d: GeoFeature) => {
|
||||
const iso = d?.properties?.ISO;
|
||||
return selected.includes(iso) ? '#222' : null;
|
||||
})
|
||||
.style('stroke-width', (d: GeoFeature) => {
|
||||
const iso = d?.properties?.ISO;
|
||||
return selected.includes(iso) ? '1.5px' : '0.5px';
|
||||
});
|
||||
}
|
||||
|
||||
// Click handler for cross-filters
|
||||
const handleClick = (feature: GeoFeature): void => {
|
||||
if (!entity || !emitCrossFilters || typeof setDataMask !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = getCrossFilterDataMask(feature);
|
||||
if (!result) return;
|
||||
|
||||
const { dataMask, isCurrentValueSelected } = result;
|
||||
setDataMask(dataMask);
|
||||
|
||||
const iso = feature?.properties?.ISO;
|
||||
const newSelection = isCurrentValueSelected || !iso ? [] : [iso];
|
||||
highlightSelectedRegion(newSelection);
|
||||
};
|
||||
|
||||
function drawMap(mapData: GeoData): void {
|
||||
const { features } = mapData;
|
||||
const center = d3.geo.centroid(mapData);
|
||||
const scale = 100;
|
||||
@@ -213,13 +378,11 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
.translate([width / 2, height / 2]);
|
||||
path.projection(projection);
|
||||
|
||||
// Compute scale that fits container.
|
||||
const bounds = path.bounds(mapData);
|
||||
const hscale = (scale * width) / (bounds[1][0] - bounds[0][0]);
|
||||
const vscale = (scale * height) / (bounds[1][1] - bounds[0][1]);
|
||||
const newScale = hscale < vscale ? hscale : vscale;
|
||||
const newScale = Math.min(hscale, vscale);
|
||||
|
||||
// Compute bounds and offset using the updated scale.
|
||||
projection.scale(newScale);
|
||||
const newBounds = path.bounds(mapData);
|
||||
projection.translate([
|
||||
@@ -227,20 +390,45 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
height - (newBounds[0][1] + newBounds[1][1]) / 2,
|
||||
]);
|
||||
|
||||
// Draw each province as a path
|
||||
mapLayer
|
||||
.selectAll('path')
|
||||
.data(features)
|
||||
const sel = mapLayer.selectAll('path.region').data(features);
|
||||
|
||||
sel
|
||||
.enter()
|
||||
.append('path')
|
||||
.attr('d', path)
|
||||
.attr('class', 'region')
|
||||
.attr('vector-effect', 'non-scaling-stroke')
|
||||
.attr('vector-effect', 'non-scaling-stroke');
|
||||
|
||||
// Apply attributes and event handlers to all elements (enter + update)
|
||||
mapLayer
|
||||
.selectAll('path.region')
|
||||
.attr('d', path)
|
||||
.style('fill', colorFn)
|
||||
.on('mouseenter', mouseenter)
|
||||
.on('mousemove', mousemove)
|
||||
.on('mouseout', mouseout)
|
||||
.on('click', clicked);
|
||||
.on('contextmenu', handleContextMenu)
|
||||
.on('mousedown', function mousedown() {
|
||||
const pos = d3.mouse(svg.node());
|
||||
mousedownPos = { x: pos[0], y: pos[1] };
|
||||
})
|
||||
.on('click', function click(feature: GeoFeature) {
|
||||
if (mousedownPos) {
|
||||
const pos = d3.mouse(svg.node());
|
||||
const dx = Math.abs(pos[0] - mousedownPos.x);
|
||||
const dy = Math.abs(pos[1] - mousedownPos.y);
|
||||
const dragThreshold = 5;
|
||||
|
||||
if (dx < dragThreshold && dy < dragThreshold) {
|
||||
handleClick(feature);
|
||||
}
|
||||
|
||||
mousedownPos = null;
|
||||
}
|
||||
});
|
||||
|
||||
sel.exit().remove();
|
||||
|
||||
highlightSelectedRegion();
|
||||
}
|
||||
|
||||
const map = maps[country];
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ChartMetadata, ChartPlugin } from '@superset-ui/core';
|
||||
import { ChartMetadata, ChartPlugin, Behavior } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
import exampleUsa from './images/exampleUsa.jpg';
|
||||
import exampleUsaDark from './images/exampleUsa-dark.jpg';
|
||||
@@ -49,6 +49,11 @@ const metadata = new ChartMetadata({
|
||||
thumbnail,
|
||||
thumbnailDark,
|
||||
useLegacyApi: true,
|
||||
behaviors: [
|
||||
Behavior.InteractiveChart,
|
||||
Behavior.DrillToDetail,
|
||||
Behavior.DrillBy,
|
||||
],
|
||||
});
|
||||
|
||||
export default class CountryMapChartPlugin extends ChartPlugin {
|
||||
|
||||
@@ -19,8 +19,18 @@
|
||||
import { ChartProps, getValueFormatter } from '@superset-ui/core';
|
||||
|
||||
export default function transformProps(chartProps: ChartProps) {
|
||||
const { width, height, formData, queriesData, datasource } = chartProps;
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
formData,
|
||||
queriesData,
|
||||
datasource,
|
||||
hooks = {},
|
||||
filterState,
|
||||
emitCrossFilters,
|
||||
} = chartProps;
|
||||
const {
|
||||
entity,
|
||||
linearColorScheme,
|
||||
numberFormat,
|
||||
currencyFormat,
|
||||
@@ -49,6 +59,8 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
detectedCurrency,
|
||||
);
|
||||
|
||||
const { onContextMenu, setDataMask } = hooks;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
@@ -59,5 +71,10 @@ export default function transformProps(chartProps: ChartProps) {
|
||||
colorScheme,
|
||||
sliceId,
|
||||
formatter,
|
||||
entity,
|
||||
onContextMenu,
|
||||
setDataMask,
|
||||
emitCrossFilters,
|
||||
filterState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,10 +133,11 @@ describe('CountryMap (legacy d3)', () => {
|
||||
expect(popup!).toHaveStyle({ display: 'none' });
|
||||
});
|
||||
|
||||
test('shows tooltip on mouseenter/mousemove/mouseout', async () => {
|
||||
test('emits a cross-filter data mask when a region is clicked', () => {
|
||||
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
|
||||
cb(null, mockMapData),
|
||||
);
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
render(
|
||||
<ReactCountryMap
|
||||
@@ -147,19 +148,101 @@ describe('CountryMap (legacy d3)', () => {
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
entity="country_code"
|
||||
emitCrossFilters
|
||||
setDataMask={setDataMask}
|
||||
filterState={{ selectedValues: [] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const region = document.querySelector('path.region');
|
||||
expect(region).not.toBeNull();
|
||||
|
||||
const popup = document.querySelector('.hover-popup');
|
||||
expect(popup).not.toBeNull();
|
||||
// A click is only treated as a selection when it follows a mousedown
|
||||
// without dragging beyond the threshold (d3.mouse is mocked to a fixed
|
||||
// position, so the down/up positions match).
|
||||
fireEvent.mouseDown(region!);
|
||||
fireEvent.click(region!);
|
||||
|
||||
fireEvent.mouseEnter(region!);
|
||||
expect(popup!).toHaveStyle({ display: 'block' });
|
||||
expect(setDataMask).toHaveBeenCalledTimes(1);
|
||||
expect(setDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraFormData: {
|
||||
filters: [{ col: 'country_code', op: 'IN', val: ['CAN'] }],
|
||||
},
|
||||
filterState: expect.objectContaining({ value: ['CAN'] }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.mouseOut(region!);
|
||||
expect(popup!).toHaveStyle({ display: 'none' });
|
||||
test('does not emit a cross-filter when emitCrossFilters is disabled', () => {
|
||||
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
|
||||
cb(null, mockMapData),
|
||||
);
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
render(
|
||||
<ReactCountryMap
|
||||
width={500}
|
||||
height={300}
|
||||
data={[{ country_id: 'CAN', metric: 100 }]}
|
||||
country="canada"
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
entity="country_code"
|
||||
emitCrossFilters={false}
|
||||
setDataMask={setDataMask}
|
||||
filterState={{ selectedValues: [] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const region = document.querySelector('path.region');
|
||||
fireEvent.mouseDown(region!);
|
||||
fireEvent.click(region!);
|
||||
|
||||
expect(setDataMask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('opens the context menu with drill-by keyed on the entity control', () => {
|
||||
d3Any.json.mockImplementation((_url: string, cb: D3JsonCallback) =>
|
||||
cb(null, mockMapData),
|
||||
);
|
||||
const onContextMenu = jest.fn();
|
||||
|
||||
render(
|
||||
<ReactCountryMap
|
||||
width={500}
|
||||
height={300}
|
||||
data={[{ country_id: 'CAN', metric: 100 }]}
|
||||
country="canada"
|
||||
linearColorScheme="bnbColors"
|
||||
colorScheme=""
|
||||
formatter={jest.fn().mockReturnValue('100')}
|
||||
entity="country_code"
|
||||
onContextMenu={onContextMenu}
|
||||
filterState={{ selectedValues: [] }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const region = document.querySelector('path.region');
|
||||
expect(region).not.toBeNull();
|
||||
|
||||
fireEvent.contextMenu(region!, { clientX: 123, clientY: 45 });
|
||||
|
||||
expect(onContextMenu).toHaveBeenCalledTimes(1);
|
||||
const [[clientX, clientY, payload]] = onContextMenu.mock.calls;
|
||||
expect(clientX).toBe(123);
|
||||
expect(clientY).toBe(45);
|
||||
expect(payload.drillToDetail).toEqual([
|
||||
{ col: 'country_code', op: '==', val: 'CAN', formattedVal: 'CAN' },
|
||||
]);
|
||||
// groupbyFieldName must be the form-data control key ('entity'), not the
|
||||
// selected column value ('country_code'), so DrillByModal can map the
|
||||
// selection back to the chart control.
|
||||
expect(payload.drillBy).toEqual({
|
||||
filters: [{ col: 'country_code', op: '==', val: 'CAN' }],
|
||||
groupbyFieldName: 'entity',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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 { ChartProps } from '@superset-ui/core';
|
||||
import transformProps from '../src/transformProps';
|
||||
|
||||
const onContextMenu = jest.fn();
|
||||
const setDataMask = jest.fn();
|
||||
|
||||
const createProps = (formDataOverrides = {}, chartPropsOverrides = {}) =>
|
||||
({
|
||||
width: 800,
|
||||
height: 600,
|
||||
formData: {
|
||||
entity: 'country_code',
|
||||
linearColorScheme: 'bnbColors',
|
||||
numberFormat: '.2f',
|
||||
selectCountry: 'France',
|
||||
colorScheme: '',
|
||||
sliceId: 1,
|
||||
metric: 'count',
|
||||
...formDataOverrides,
|
||||
},
|
||||
queriesData: [{ data: [{ country_id: 'FRA', metric: 10 }] }],
|
||||
datasource: { currencyFormats: {}, columnFormats: {} },
|
||||
hooks: { onContextMenu, setDataMask },
|
||||
filterState: { selectedValues: ['FRA'] },
|
||||
emitCrossFilters: true,
|
||||
...chartPropsOverrides,
|
||||
}) as unknown as ChartProps;
|
||||
|
||||
test('forwards cross-filter hooks and state to the chart', () => {
|
||||
const transformed = transformProps(createProps());
|
||||
|
||||
expect(transformed).toMatchObject({
|
||||
width: 800,
|
||||
height: 600,
|
||||
entity: 'country_code',
|
||||
onContextMenu,
|
||||
setDataMask,
|
||||
emitCrossFilters: true,
|
||||
filterState: { selectedValues: ['FRA'] },
|
||||
data: [{ country_id: 'FRA', metric: 10 }],
|
||||
});
|
||||
});
|
||||
|
||||
test('lowercases the selected country for map lookup', () => {
|
||||
const transformed = transformProps(createProps());
|
||||
expect(transformed.country).toBe('france');
|
||||
});
|
||||
|
||||
test('passes a null country when none is selected', () => {
|
||||
const transformed = transformProps(createProps({ selectCountry: undefined }));
|
||||
expect(transformed.country).toBeNull();
|
||||
});
|
||||
|
||||
test('defaults hooks to an empty object when none are provided', () => {
|
||||
const transformed = transformProps(createProps({}, { hooks: undefined }));
|
||||
expect(transformed.onContextMenu).toBeUndefined();
|
||||
expect(transformed.setDataMask).toBeUndefined();
|
||||
});
|
||||
@@ -164,7 +164,8 @@ function WorldMap(element: HTMLElement, props: WorldMapProps): void {
|
||||
processedData = filteredData.map(d => ({
|
||||
...d,
|
||||
radius: radiusScale(Math.sqrt(d.m2)),
|
||||
fillColor: d.m1 != null ? colorFn(d.m1) ?? theme.colorBorder : theme.colorBorder,
|
||||
fillColor:
|
||||
d.m1 != null ? (colorFn(d.m1) ?? theme.colorBorder) : theme.colorBorder,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,20 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
],
|
||||
['zoomable'],
|
||||
[
|
||||
{
|
||||
name: 'y_axis_slider',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Y-axis range slider'),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Show a draggable slider to control the visible range of the Y-axis.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -74,6 +74,7 @@ export default function transformProps(
|
||||
yAxisTitlePosition,
|
||||
sliceId,
|
||||
zoomable,
|
||||
yAxisSlider,
|
||||
} = formData as BoxPlotQueryFormData;
|
||||
const refs: Refs = {};
|
||||
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
|
||||
@@ -257,6 +258,28 @@ export default function transformProps(
|
||||
convertInteger(yAxisTitleMargin),
|
||||
convertInteger(xAxisTitleMargin),
|
||||
);
|
||||
const dataZoom = [
|
||||
...(zoomable
|
||||
? [
|
||||
{
|
||||
type: 'inside',
|
||||
zoomOnMouseWheel: false,
|
||||
moveOnMouseWheel: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(yAxisSlider
|
||||
? [
|
||||
{
|
||||
type: 'slider',
|
||||
show: true,
|
||||
yAxisIndex: [0],
|
||||
// Adjust the axis window without dropping data points outside the range.
|
||||
filterMode: 'none',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const echartOptions: EChartsCoreOption = {
|
||||
grid: {
|
||||
...defaultGrid,
|
||||
@@ -298,15 +321,7 @@ export default function transformProps(
|
||||
},
|
||||
},
|
||||
},
|
||||
dataZoom: zoomable
|
||||
? [
|
||||
{
|
||||
type: 'inside',
|
||||
zoomOnMouseWheel: false,
|
||||
moveOnMouseWheel: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
dataZoom,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -30,6 +30,7 @@ export type BoxPlotQueryFormData = QueryFormData & {
|
||||
numberFormat?: string;
|
||||
whiskerOptions?: BoxPlotFormDataWhiskerOptions;
|
||||
xTickLayout?: BoxPlotFormXTickLayout;
|
||||
yAxisSlider?: boolean;
|
||||
} & TitleFormData;
|
||||
|
||||
export type BoxPlotFormDataWhiskerOptions =
|
||||
|
||||
@@ -71,6 +71,15 @@ describe('BoxPlot transformProps', () => {
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
const buildChartProps = (formDataOverrides: Partial<SqlaFormData> = {}) =>
|
||||
new ChartProps({
|
||||
formData: { ...formData, ...formDataOverrides },
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: chartProps.queriesData,
|
||||
theme: supersetTheme,
|
||||
}) as EchartsBoxPlotChartProps;
|
||||
|
||||
test('should transform chart props for viz', () => {
|
||||
expect(transformProps(chartProps as EchartsBoxPlotChartProps)).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -125,4 +134,41 @@ describe('BoxPlot transformProps', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should add a vertical Y-axis slider to dataZoom when yAxisSlider is enabled', () => {
|
||||
const { echartOptions } = transformProps(
|
||||
buildChartProps({ yAxisSlider: true }),
|
||||
);
|
||||
expect((echartOptions as any).dataZoom).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'slider',
|
||||
show: true,
|
||||
yAxisIndex: [0],
|
||||
filterMode: 'none',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('should not add a Y-axis slider when yAxisSlider is disabled', () => {
|
||||
const { echartOptions } = transformProps(
|
||||
buildChartProps({ yAxisSlider: false }),
|
||||
);
|
||||
expect((echartOptions as any).dataZoom).not.toContainEqual(
|
||||
expect.objectContaining({ type: 'slider' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('should combine zoomable and yAxisSlider dataZoom entries', () => {
|
||||
const { echartOptions } = transformProps(
|
||||
buildChartProps({ zoomable: true, yAxisSlider: true }),
|
||||
);
|
||||
expect((echartOptions as any).dataZoom).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'inside' }),
|
||||
expect.objectContaining({ type: 'slider', yAxisIndex: [0] }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,7 +54,7 @@ export function getQueryMode(formData: TableChartFormData) {
|
||||
return hasRawColumns ? QueryMode.Raw : QueryMode.Aggregate;
|
||||
}
|
||||
|
||||
const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
export const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
formData: TableChartFormData,
|
||||
options,
|
||||
) => {
|
||||
@@ -217,6 +217,17 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
|
||||
const moreProps: Partial<QueryObject> = {};
|
||||
const ownState = options?.ownState ?? {};
|
||||
// Server pagination sizing, shared between the per-page request below and
|
||||
// the filter-change reset further down.
|
||||
const pageSize =
|
||||
Number(ownState.pageSize ?? formDataCopy.server_page_length) || 0;
|
||||
const configuredRowLimit = Number(formDataCopy.row_limit) || 0;
|
||||
// row_limit for the first page, capped by the configured row limit. Used
|
||||
// when a filter change resets pagination back to page 0.
|
||||
const firstPageRowLimit =
|
||||
configuredRowLimit > 0
|
||||
? Math.min(pageSize, configuredRowLimit)
|
||||
: pageSize;
|
||||
// Build Query flag to check if its for either download as csv, excel or json
|
||||
const isDownloadQuery =
|
||||
['csv', 'xlsx'].includes(formData?.result_format || '') ||
|
||||
@@ -229,11 +240,24 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
}
|
||||
|
||||
if (!isDownloadQuery && formDataCopy.server_pagination) {
|
||||
const pageSize = ownState.pageSize ?? formDataCopy.server_page_length;
|
||||
const currentPage = ownState.currentPage ?? 0;
|
||||
// Never page past the configured row limit. Clamping the page to the last
|
||||
// one that still falls within the limit keeps the request inside the cap
|
||||
// and avoids emitting row_limit: 0, which the backend treats as
|
||||
// "no limit" rather than "no rows" (see helpers.py get_sqla_query).
|
||||
const lastPage =
|
||||
configuredRowLimit > 0 && pageSize > 0
|
||||
? Math.max(Math.ceil(configuredRowLimit / pageSize) - 1, 0)
|
||||
: Number(ownState.currentPage) || 0;
|
||||
const currentPage = Math.min(Number(ownState.currentPage) || 0, lastPage);
|
||||
const rowOffset = currentPage * pageSize;
|
||||
const remainingRows =
|
||||
configuredRowLimit > 0
|
||||
? Math.max(configuredRowLimit - rowOffset, 0)
|
||||
: pageSize;
|
||||
|
||||
moreProps.row_limit = pageSize;
|
||||
moreProps.row_offset = currentPage * pageSize;
|
||||
moreProps.row_limit =
|
||||
configuredRowLimit > 0 ? Math.min(pageSize, remainingRows) : pageSize;
|
||||
moreProps.row_offset = rowOffset;
|
||||
}
|
||||
|
||||
// getting sort by in case of server pagination from own state
|
||||
@@ -263,11 +287,19 @@ const buildQuery: BuildQuery<TableChartFormData> = (
|
||||
JSON.stringify(options?.extras?.cachedChanges?.[formData.slice_id]) !==
|
||||
JSON.stringify(queryObject.filters)
|
||||
) {
|
||||
queryObject = { ...queryObject, row_offset: 0 };
|
||||
// Reset to the first page: restore the full first-page row_limit rather
|
||||
// than carrying over the last page's capped value.
|
||||
queryObject = {
|
||||
...queryObject,
|
||||
row_offset: 0,
|
||||
row_limit: firstPageRowLimit,
|
||||
};
|
||||
const modifiedOwnState = {
|
||||
...options?.ownState,
|
||||
currentPage: 0,
|
||||
pageSize: queryObject.row_limit ?? 0,
|
||||
// Persist the user-selected page size, not the per-request row_limit,
|
||||
// which may be capped to the remaining rows on the last page.
|
||||
pageSize,
|
||||
};
|
||||
updateTableOwnState(options?.hooks?.setDataMask, modifiedOwnState);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { QueryMode, TimeGranularity, VizType } from '@superset-ui/core';
|
||||
import buildQuery from '../src/buildQuery';
|
||||
import buildQuery, {
|
||||
buildQuery as buildQueryUncached,
|
||||
} from '../src/buildQuery';
|
||||
import { TableChartFormData } from '../src/types';
|
||||
|
||||
const basicFormData: TableChartFormData = {
|
||||
@@ -278,6 +280,172 @@ describe('plugin-chart-table', () => {
|
||||
|
||||
expect(queries[0].filters?.some(f => f.op === 'ILIKE')).toBeFalsy();
|
||||
});
|
||||
|
||||
test('uses user row limit when it is lower than server page size', () => {
|
||||
const { queries } = buildQuery(
|
||||
{
|
||||
...baseFormDataWithServerPagination,
|
||||
row_limit: 10,
|
||||
server_page_length: 20,
|
||||
slice_id: 101,
|
||||
},
|
||||
{
|
||||
ownState: {
|
||||
currentPage: 0,
|
||||
pageSize: 20,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(queries[0]).toMatchObject({
|
||||
row_limit: 10,
|
||||
row_offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('limits server page size by remaining rows inside user row limit', () => {
|
||||
const { queries } = buildQuery(
|
||||
{
|
||||
...baseFormDataWithServerPagination,
|
||||
row_limit: 120,
|
||||
server_page_length: 50,
|
||||
slice_id: 102,
|
||||
},
|
||||
{
|
||||
ownState: {
|
||||
currentPage: 2,
|
||||
pageSize: 50,
|
||||
sortBy: [{ key: 'category', desc: true }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(queries[0]).toMatchObject({
|
||||
orderby: [['category', false]],
|
||||
row_limit: 20,
|
||||
row_offset: 100,
|
||||
});
|
||||
expect(queries[1]).toMatchObject({
|
||||
is_rowcount: true,
|
||||
row_limit: 120,
|
||||
row_offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('clamps pages beyond the row limit instead of emitting row_limit: 0', () => {
|
||||
const { queries } = buildQuery(
|
||||
{
|
||||
...baseFormDataWithServerPagination,
|
||||
row_limit: 120,
|
||||
server_page_length: 50,
|
||||
slice_id: 103,
|
||||
},
|
||||
{
|
||||
ownState: {
|
||||
// Page 5 is well past the cap; offset would be 250 > 120, which
|
||||
// previously made row_limit collapse to 0 ("no limit").
|
||||
currentPage: 5,
|
||||
pageSize: 50,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(queries[0].row_limit).not.toBe(0);
|
||||
expect(queries[0]).toMatchObject({
|
||||
row_limit: 20,
|
||||
row_offset: 100,
|
||||
});
|
||||
});
|
||||
|
||||
test('restores the full first-page row limit after a filter change reset', () => {
|
||||
// Uncached export lets us seed cachedChanges directly; the default
|
||||
// export overrides extras with its own closure.
|
||||
const { queries } = buildQueryUncached(
|
||||
{
|
||||
...baseFormDataWithServerPagination,
|
||||
row_limit: 120,
|
||||
server_page_length: 50,
|
||||
slice_id: 104,
|
||||
},
|
||||
{
|
||||
// User was on the capped last page (row_limit would be 20)...
|
||||
ownState: {
|
||||
currentPage: 2,
|
||||
pageSize: 50,
|
||||
},
|
||||
// ...then an external filter changed, so the cached filters differ
|
||||
// from the current ones and pagination resets to page 0.
|
||||
extras: {
|
||||
cachedChanges: {
|
||||
104: [{ col: 'category', op: '==', val: 'previous' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(queries[0].row_limit).not.toBe(0);
|
||||
expect(queries[0]).toMatchObject({
|
||||
row_limit: 50,
|
||||
row_offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('persists the user page size, not the capped limit, on filter reset', () => {
|
||||
const setDataMask = jest.fn();
|
||||
buildQueryUncached(
|
||||
{
|
||||
...baseFormDataWithServerPagination,
|
||||
row_limit: 120,
|
||||
server_page_length: 50,
|
||||
slice_id: 106,
|
||||
},
|
||||
{
|
||||
// On the capped last page, the per-request row_limit is 20.
|
||||
ownState: {
|
||||
currentPage: 2,
|
||||
pageSize: 50,
|
||||
},
|
||||
extras: {
|
||||
cachedChanges: {
|
||||
106: [{ col: 'category', op: '==', val: 'previous' }],
|
||||
},
|
||||
},
|
||||
hooks: { setDataMask, setCachedChanges: jest.fn() },
|
||||
},
|
||||
);
|
||||
|
||||
// The persisted page size must stay 50, not collapse to the capped 20.
|
||||
expect(setDataMask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownState: expect.objectContaining({
|
||||
currentPage: 0,
|
||||
pageSize: 50,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to the page size when no row limit is configured', () => {
|
||||
const { queries } = buildQuery(
|
||||
{
|
||||
...baseFormDataWithServerPagination,
|
||||
row_limit: undefined,
|
||||
server_page_length: 50,
|
||||
slice_id: 105,
|
||||
},
|
||||
{
|
||||
ownState: {
|
||||
currentPage: 3,
|
||||
pageSize: 50,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(queries[0]).toMatchObject({
|
||||
row_limit: 50,
|
||||
row_offset: 150,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,8 +39,10 @@ jest.mock('@superset-ui/core/components/Select/AsyncSelect', () => () => (
|
||||
<div data-test="mock-async-select" />
|
||||
));
|
||||
jest.mock('src/core/editors', () => ({
|
||||
EditorHost: ({ value }: { value: string }) => (
|
||||
<div data-test="mock-async-ace-editor">{value}</div>
|
||||
EditorHost: ({ value, height }: { value: string; height: string }) => (
|
||||
<div data-test="mock-async-ace-editor" data-height={height}>
|
||||
{value}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -79,6 +81,18 @@ describe('TemplateParamsEditor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('renders the editor with a bounded height to avoid overflowing the popover', async () => {
|
||||
const { container, getByTestId } = setup();
|
||||
fireEvent.click(getByText(container, 'Parameters'));
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('mock-async-ace-editor')).toBeInTheDocument();
|
||||
});
|
||||
expect(getByTestId('mock-async-ace-editor')).toHaveAttribute(
|
||||
'data-height',
|
||||
'360px',
|
||||
);
|
||||
});
|
||||
|
||||
test('renders templateParams', async () => {
|
||||
const { container, getByTestId } = setup();
|
||||
fireEvent.click(getByText(container, 'Parameters'));
|
||||
|
||||
@@ -30,10 +30,9 @@ import {
|
||||
import { EditorHost } from 'src/core/editors';
|
||||
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
|
||||
|
||||
const StyledEditorHost = styled(EditorHost)`
|
||||
&.ace_editor {
|
||||
border: 1px solid ${({ theme }) => theme.colorBorder};
|
||||
}
|
||||
const EditorOutline = styled.div`
|
||||
border: 1px solid ${({ theme }) => theme.colorBorder};
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
`;
|
||||
|
||||
const StyledParagraph = styled.p`
|
||||
@@ -87,14 +86,16 @@ const TemplateParamsEditor = ({
|
||||
</a>{' '}
|
||||
{t('syntax.')}
|
||||
</StyledParagraph>
|
||||
<StyledEditorHost
|
||||
id={`template-params-${queryEditorId}`}
|
||||
height="800px"
|
||||
onChange={debounce(onChange, Constants.FAST_DEBOUNCE)}
|
||||
language={language === 'yaml' ? 'yaml' : 'json'}
|
||||
width="100%"
|
||||
value={code}
|
||||
/>
|
||||
<EditorOutline>
|
||||
<EditorHost
|
||||
id={`template-params-${queryEditorId}`}
|
||||
height="360px"
|
||||
onChange={debounce(onChange, Constants.FAST_DEBOUNCE)}
|
||||
language={language === 'yaml' ? 'yaml' : 'json'}
|
||||
width="100%"
|
||||
value={code}
|
||||
/>
|
||||
</EditorOutline>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -200,6 +200,27 @@ describe('sqlLabReducer', () => {
|
||||
expect(newState.unsavedQueryEditor.sql).toBe(sql);
|
||||
expect(newState.unsavedQueryEditor.id).toBe(qe!.id);
|
||||
});
|
||||
test('should set Sql when dispatched with tabViewId (backend persistence)', () => {
|
||||
// Simulate SqllabBackendPersistence: queryEditor gets a tabViewId after save
|
||||
const tabViewId = 'tab-view-42';
|
||||
const migrateAction = {
|
||||
type: actions.MIGRATE_QUERY_EDITOR,
|
||||
oldQueryEditor: qe,
|
||||
newQueryEditor: { ...qe!, tabViewId, inLocalStorage: false },
|
||||
};
|
||||
newState = sqlLabReducer(newState, migrateAction as SqlLabAction);
|
||||
|
||||
// Restore SQL using tabViewId (as restoreSql in QueryTable does)
|
||||
const sql = 'SELECT restored_query FROM history';
|
||||
const restoreAction = {
|
||||
type: actions.QUERY_EDITOR_SET_SQL,
|
||||
queryEditor: { id: tabViewId },
|
||||
sql,
|
||||
};
|
||||
newState = sqlLabReducer(newState, restoreAction);
|
||||
expect(newState.unsavedQueryEditor.sql).toBe(sql);
|
||||
expect(newState.unsavedQueryEditor.id).toBe(qe!.id);
|
||||
});
|
||||
test('should not fail while setting queryLimit', () => {
|
||||
const queryLimit = 101;
|
||||
const action = {
|
||||
|
||||
@@ -604,8 +604,20 @@ export default function sqlLabReducer(
|
||||
},
|
||||
[actions.QUERY_EDITOR_SET_SQL]() {
|
||||
const { unsavedQueryEditor } = state;
|
||||
const actionId = action.queryEditor!.id!;
|
||||
// Skip the O(n) tabViewId scan on the common path (keystroke: actionId already
|
||||
// matches the active editor's client-side id). Only scan when ids differ, which
|
||||
// happens when restoring from history with a backend-assigned tabViewId.
|
||||
const normalizedId =
|
||||
unsavedQueryEditor?.id === actionId
|
||||
? actionId
|
||||
: ((
|
||||
getFromArr(state.queryEditors, actionId, 'tabViewId') as
|
||||
| QueryEditor
|
||||
| undefined
|
||||
)?.id ?? actionId);
|
||||
if (
|
||||
unsavedQueryEditor?.id === action.queryEditor!.id &&
|
||||
unsavedQueryEditor?.id === normalizedId &&
|
||||
unsavedQueryEditor.sql === action.sql
|
||||
) {
|
||||
return state;
|
||||
@@ -618,7 +630,7 @@ export default function sqlLabReducer(
|
||||
sql: action.sql ?? undefined,
|
||||
...(action.queryId && { latestQueryId: action.queryId }),
|
||||
},
|
||||
action.queryEditor!.id!,
|
||||
normalizedId,
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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 type { AnyAction } from 'redux';
|
||||
// eslint-disable-next-line import/named
|
||||
import {
|
||||
ActionCreators as UndoActionCreators,
|
||||
StateWithHistory,
|
||||
} from 'redux-undo';
|
||||
|
||||
import undoableLayoutReducer from 'src/dashboard/reducers/undoableDashboardLayout';
|
||||
import { UPDATE_COMPONENTS } from 'src/dashboard/actions/dashboardLayout';
|
||||
import { HYDRATE_DASHBOARD } from 'src/dashboard/actions/hydrate';
|
||||
import type { DashboardLayout } from 'src/dashboard/types';
|
||||
import {
|
||||
DASHBOARD_ROOT_ID,
|
||||
DASHBOARD_GRID_ID,
|
||||
DASHBOARD_HEADER_ID,
|
||||
} from 'src/dashboard/util/constants';
|
||||
import {
|
||||
DASHBOARD_ROOT_TYPE,
|
||||
DASHBOARD_GRID_TYPE,
|
||||
DASHBOARD_HEADER_TYPE,
|
||||
CHART_TYPE,
|
||||
} from 'src/dashboard/util/componentTypes';
|
||||
|
||||
const reducer = undoableLayoutReducer;
|
||||
|
||||
// A minimal but valid dashboard layout always contains the root component.
|
||||
const makeValidLayout = (
|
||||
title = '[ untitled dashboard ]',
|
||||
): DashboardLayout => ({
|
||||
[DASHBOARD_ROOT_ID]: {
|
||||
id: DASHBOARD_ROOT_ID,
|
||||
type: DASHBOARD_ROOT_TYPE,
|
||||
children: [DASHBOARD_GRID_ID],
|
||||
meta: {},
|
||||
},
|
||||
[DASHBOARD_GRID_ID]: {
|
||||
id: DASHBOARD_GRID_ID,
|
||||
type: DASHBOARD_GRID_TYPE,
|
||||
parents: [DASHBOARD_ROOT_ID],
|
||||
children: [],
|
||||
meta: {},
|
||||
},
|
||||
[DASHBOARD_HEADER_ID]: {
|
||||
id: DASHBOARD_HEADER_ID,
|
||||
type: DASHBOARD_HEADER_TYPE,
|
||||
children: [],
|
||||
meta: { text: title },
|
||||
},
|
||||
});
|
||||
|
||||
// The frontend locks redux-undo to 1.1.0, whose `clearHistory()` under
|
||||
// `ignoreInitialState` resets `_latestUnfiltered` to null. That makes a rootless
|
||||
// layout impossible to push onto `past` through normal layout actions, so the
|
||||
// guard's corrupt-history precondition is seeded directly. `makeHistory` mirrors
|
||||
// redux-undo's `StateWithHistory` shape — `past`/`present`/`future` is all that
|
||||
// `undo()` needs to compute the previous state.
|
||||
const makeHistory = (
|
||||
past: DashboardLayout[],
|
||||
present: DashboardLayout,
|
||||
future: DashboardLayout[] = [],
|
||||
): StateWithHistory<DashboardLayout> => ({ past, present, future });
|
||||
|
||||
const hydrate = (present: DashboardLayout): AnyAction => ({
|
||||
type: HYDRATE_DASHBOARD,
|
||||
data: { dashboardLayout: { present } },
|
||||
});
|
||||
|
||||
test('hydrating a dashboard leaves an empty, disabled undo history', () => {
|
||||
const initial = reducer(undefined, { type: '@@INIT' });
|
||||
const state = reducer(initial, hydrate(makeValidLayout()));
|
||||
|
||||
expect(state.present[DASHBOARD_ROOT_ID]).toBeDefined();
|
||||
// Hydration is not a user edit, so Undo (past) and Redo (future) start empty.
|
||||
expect(state.past).toHaveLength(0);
|
||||
expect(state.future).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a layout edit is applied through the wrapped reducer', () => {
|
||||
const hydrated = reducer(
|
||||
reducer(undefined, { type: '@@INIT' }),
|
||||
hydrate(makeValidLayout()),
|
||||
);
|
||||
|
||||
const update: AnyAction = {
|
||||
type: UPDATE_COMPONENTS,
|
||||
payload: {
|
||||
nextComponents: {
|
||||
'CHART-1': { id: 'CHART-1', type: CHART_TYPE, children: [], meta: {} },
|
||||
},
|
||||
},
|
||||
};
|
||||
const state = reducer(hydrated, update);
|
||||
|
||||
expect(state.present['CHART-1']).toBeDefined();
|
||||
expect(state.present[DASHBOARD_ROOT_ID]).toBeDefined();
|
||||
});
|
||||
|
||||
test('re-hydrating a different dashboard clears the previous dashboard from the undo stack', () => {
|
||||
// Simulates SPA navigation: dashboard A already has undo history when B opens.
|
||||
const dashboardA = makeHistory(
|
||||
[makeValidLayout('A v1')],
|
||||
makeValidLayout('A v2'),
|
||||
);
|
||||
|
||||
const state = reducer(dashboardA, hydrate(makeValidLayout('B')));
|
||||
|
||||
expect(state.present[DASHBOARD_ROOT_ID]).toBeDefined();
|
||||
expect(state.past).toHaveLength(0);
|
||||
expect(state.future).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('undo never reverts the layout to an invalid (rootless) state', () => {
|
||||
// A rootless `{}` baseline sits at the head of `past`; a plain redux-undo
|
||||
// undo() here would move it into `present` and crash rendering with
|
||||
// `Cannot read properties of undefined (reading 'type')`.
|
||||
const corrupt = makeHistory([{}], makeValidLayout());
|
||||
const before = corrupt.present;
|
||||
|
||||
const state = reducer(corrupt, UndoActionCreators.undo());
|
||||
|
||||
// The guard rejects the transition: the valid layout is kept unchanged...
|
||||
expect(state.present[DASHBOARD_ROOT_ID]).toBeDefined();
|
||||
expect(state.present).toBe(before);
|
||||
// ...and history is left intact, so undoLayoutAction() won't misread an
|
||||
// emptied stack as a fully-reverted, clean dashboard.
|
||||
expect(state.past).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('the guard does not interfere with a normal undo between valid layouts', () => {
|
||||
const previous = makeValidLayout('previous');
|
||||
const current = makeValidLayout('current');
|
||||
|
||||
const state = reducer(
|
||||
makeHistory([previous], current),
|
||||
UndoActionCreators.undo(),
|
||||
);
|
||||
|
||||
// A valid -> valid undo proceeds normally.
|
||||
expect(state.present).toBe(previous);
|
||||
expect(state.past).toHaveLength(0);
|
||||
expect(state.future).toHaveLength(1);
|
||||
});
|
||||
@@ -18,8 +18,11 @@
|
||||
*/
|
||||
import { AnyAction, Reducer } from 'redux';
|
||||
// eslint-disable-next-line import/named
|
||||
import undoable, { StateWithHistory } from 'redux-undo';
|
||||
import { UNDO_LIMIT } from '../util/constants';
|
||||
import undoable, {
|
||||
ActionCreators as UndoActionCreators,
|
||||
StateWithHistory,
|
||||
} from 'redux-undo';
|
||||
import { DASHBOARD_ROOT_ID, UNDO_LIMIT } from '../util/constants';
|
||||
import {
|
||||
UPDATE_COMPONENTS,
|
||||
DELETE_COMPONENT,
|
||||
@@ -97,7 +100,7 @@ const layoutOnlyReducer: Reducer<DashboardLayout, AnyAction> = (
|
||||
return dashboardLayout(state || {}, action);
|
||||
};
|
||||
|
||||
const undoableReducer: Reducer<
|
||||
const baseUndoableReducer: Reducer<
|
||||
StateWithHistory<DashboardLayout>,
|
||||
AnyAction
|
||||
> = undoable(layoutOnlyReducer, {
|
||||
@@ -107,4 +110,53 @@ const undoableReducer: Reducer<
|
||||
ignoreInitialState: true,
|
||||
});
|
||||
|
||||
/*
|
||||
* A valid dashboard layout always contains the root component. Undo/redo must
|
||||
* never leave `present` without it: a rootless layout renders the dashboard
|
||||
* with no components and throws
|
||||
* `TypeError: Cannot read properties of undefined (reading 'type')`. Such a
|
||||
* state can arise whenever a rootless or empty layout reaches the undo history —
|
||||
* e.g. an empty or partial hydration, or a tracked layout action dispatched
|
||||
* before the dashboard has hydrated.
|
||||
*/
|
||||
const isValidLayout = (layout?: DashboardLayout): boolean =>
|
||||
Boolean(layout && layout[DASHBOARD_ROOT_ID]);
|
||||
|
||||
/*
|
||||
* Wraps the redux-undo reducer to keep the dashboard layout undo history sound:
|
||||
*
|
||||
* 1. Hydration establishes the baseline for the dashboard being opened. It is
|
||||
* not a user edit and must never be undoable, so the history is reset on
|
||||
* every HYDRATE_DASHBOARD. Doing this in the reducer — rather than relying
|
||||
* solely on a follow-up clearDashboardHistory() dispatch from the page
|
||||
* component — guarantees the Undo control starts disabled and that no layout
|
||||
* from a previously edited dashboard lingers in the stack after navigation.
|
||||
* 2. As defense in depth, undo/redo is never allowed to replace a valid layout
|
||||
* with an invalid (rootless) one. Such a transition is rejected and the
|
||||
* current valid layout is kept, so clicking Undo can never crash the
|
||||
* dashboard. History is left untouched on rejection so callers that inspect
|
||||
* it (e.g. undoLayoutAction) don't misread an emptied stack as a clean,
|
||||
* fully-reverted dashboard and silently drop the unsaved-changes guard.
|
||||
*/
|
||||
const undoableReducer: Reducer<StateWithHistory<DashboardLayout>, AnyAction> = (
|
||||
state,
|
||||
action,
|
||||
) => {
|
||||
const nextState = baseUndoableReducer(state, action);
|
||||
|
||||
if (action.type === HYDRATE_DASHBOARD) {
|
||||
return baseUndoableReducer(nextState, UndoActionCreators.clearHistory());
|
||||
}
|
||||
|
||||
if (
|
||||
state &&
|
||||
isValidLayout(state.present) &&
|
||||
!isValidLayout(nextState.present)
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return nextState;
|
||||
};
|
||||
|
||||
export default undoableReducer;
|
||||
|
||||
@@ -62,6 +62,7 @@ import { TagTypeEnum } from 'src/components/Tag/TagType';
|
||||
import { loadTags } from 'src/components/Tag/utils';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import type Owner from 'src/types/Owner';
|
||||
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
import SavedQueryPreviewModal from 'src/features/queries/SavedQueryPreviewModal';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
@@ -91,6 +92,15 @@ interface SavedQueryListProps {
|
||||
};
|
||||
}
|
||||
|
||||
type SavedQueryCellProps = {
|
||||
row: {
|
||||
original: SavedQueryObject & {
|
||||
changed_by?: Owner | null;
|
||||
created_by?: Owner | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const StyledTableLabel = styled.div`
|
||||
.count {
|
||||
margin-left: 5px;
|
||||
@@ -435,12 +445,30 @@ function SavedQueryList({
|
||||
changed_on_delta_humanized: changedOn,
|
||||
},
|
||||
},
|
||||
}: any) => <ModifiedInfo user={changedBy} date={changedOn} />,
|
||||
}: SavedQueryCellProps) => (
|
||||
<ModifiedInfo user={changedBy ?? undefined} date={changedOn} />
|
||||
),
|
||||
Header: t('Last modified'),
|
||||
accessor: 'changed_on_delta_humanized',
|
||||
size: 'xl',
|
||||
id: 'changed_on_delta_humanized',
|
||||
},
|
||||
{
|
||||
accessor: 'created_by.first_name',
|
||||
Header: t('Created by'),
|
||||
disableSortBy: true,
|
||||
size: 'xl',
|
||||
Cell: ({
|
||||
row: {
|
||||
original: { created_by: createdBy },
|
||||
},
|
||||
}: SavedQueryCellProps) =>
|
||||
createdBy ? `${createdBy.first_name} ${createdBy.last_name}` : '',
|
||||
},
|
||||
{
|
||||
accessor: 'created_by',
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
Cell: ({ row: { original } }: any) => {
|
||||
const handlePreview = () => {
|
||||
@@ -589,6 +617,28 @@ function SavedQueryList({
|
||||
),
|
||||
paginate: true,
|
||||
},
|
||||
{
|
||||
Header: t('Created by'),
|
||||
key: 'created_by',
|
||||
id: 'created_by',
|
||||
input: 'select',
|
||||
operator: FilterOperator.RelationOneMany,
|
||||
unfilteredLabel: t('All'),
|
||||
fetchSelects: createFetchRelated(
|
||||
'saved_query',
|
||||
'created_by',
|
||||
createErrorHandler(errMsg =>
|
||||
addDangerToast(
|
||||
t(
|
||||
'An error occurred while fetching created by values: %s',
|
||||
errMsg,
|
||||
),
|
||||
),
|
||||
),
|
||||
user,
|
||||
),
|
||||
paginate: true,
|
||||
},
|
||||
],
|
||||
[addDangerToast],
|
||||
);
|
||||
|
||||
@@ -66,9 +66,9 @@ def cidr_func(req: AdvancedDataTypeRequest) -> AdvancedDataTypeResponse:
|
||||
else:
|
||||
resp["display_value"] = ", ".join(
|
||||
map( # noqa: C417
|
||||
lambda x: f"{x['start']} - {x['end']}"
|
||||
if isinstance(x, dict)
|
||||
else str(x),
|
||||
lambda x: (
|
||||
f"{x['start']} - {x['end']}" if isinstance(x, dict) else str(x)
|
||||
),
|
||||
resp["values"],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -95,9 +95,9 @@ def port_translation_func(req: AdvancedDataTypeRequest) -> AdvancedDataTypeRespo
|
||||
else:
|
||||
resp["display_value"] = ", ".join(
|
||||
map( # noqa: C417
|
||||
lambda x: f"{x['start']} - {x['end']}"
|
||||
if isinstance(x, dict)
|
||||
else str(x),
|
||||
lambda x: (
|
||||
f"{x['start']} - {x['end']}" if isinstance(x, dict) else str(x)
|
||||
),
|
||||
resp["values"],
|
||||
)
|
||||
)
|
||||
|
||||
@@ -402,7 +402,6 @@ class BaseReportState:
|
||||
merged_params = self._merge_native_filters_into_url_params(
|
||||
base_state.get("urlParams"), native_filter_params
|
||||
)
|
||||
|
||||
return [
|
||||
self._get_tab_url(
|
||||
{
|
||||
@@ -525,7 +524,11 @@ class BaseReportState:
|
||||
self._update_query_context()
|
||||
|
||||
try:
|
||||
csv_data = get_chart_csv_data(chart_url=url, auth_cookies=auth_cookies)
|
||||
csv_data = get_chart_csv_data(
|
||||
chart_url=url,
|
||||
auth_cookies=auth_cookies,
|
||||
timeout=app.config["ALERT_REPORTS_CSV_REQUEST_TIMEOUT"],
|
||||
)
|
||||
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
||||
logger.info(
|
||||
"CSV data generation from %s as user %s took %.2fs - execution_id: %s",
|
||||
@@ -575,7 +578,11 @@ class BaseReportState:
|
||||
self._update_query_context()
|
||||
|
||||
try:
|
||||
dataframe = get_chart_dataframe(url, auth_cookies)
|
||||
dataframe = get_chart_dataframe(
|
||||
url,
|
||||
auth_cookies,
|
||||
timeout=app.config["ALERT_REPORTS_CSV_REQUEST_TIMEOUT"],
|
||||
)
|
||||
elapsed_seconds = (datetime.utcnow() - start_time).total_seconds()
|
||||
logger.info(
|
||||
"DataFrame generation from %s as user %s took %.2fs - execution_id: %s",
|
||||
|
||||
@@ -1153,6 +1153,12 @@ SCREENSHOT_LOCATE_WAIT = int(timedelta(seconds=10).total_seconds())
|
||||
# Time before selenium times out after waiting for all DOM class elements named
|
||||
# "loading" are gone.
|
||||
SCREENSHOT_LOAD_WAIT = int(timedelta(minutes=1).total_seconds())
|
||||
# Maximum time (in seconds) selenium waits for an initial page navigation
|
||||
# (driver.get) to complete. Without it the navigation blocks indefinitely when
|
||||
# the target page never finishes loading (e.g. an unreachable WEBDRIVER_BASEURL),
|
||||
# which leaves the report schedule stuck in the WORKING state. Set to None to
|
||||
# disable (not recommended).
|
||||
SCREENSHOT_PAGE_LOAD_WAIT = int(timedelta(minutes=2).total_seconds())
|
||||
# Selenium destroy retries
|
||||
SCREENSHOT_SELENIUM_RETRIES = 5
|
||||
# Give selenium an headstart, in seconds
|
||||
@@ -1736,6 +1742,11 @@ SMTP_MAIL_FROM = "superset@superset.com"
|
||||
# If True creates a default SSL context with ssl.Purpose.CLIENT_AUTH using the
|
||||
# default system root CA certificates.
|
||||
SMTP_SSL_SERVER_AUTH = False
|
||||
# Socket timeout (in seconds) for the SMTP connection used when sending
|
||||
# alert/report emails. Without a timeout the underlying socket blocks
|
||||
# indefinitely if the SMTP server becomes unreachable, which leaves report
|
||||
# schedules stuck in the WORKING state. Set to None to disable (not recommended).
|
||||
SMTP_TIMEOUT = 30
|
||||
ENABLE_CHUNK_ENCODING = False
|
||||
|
||||
# Whether to bump the logging level to ERROR on the flask_appbuilder package
|
||||
@@ -2084,6 +2095,12 @@ ALERT_REPORTS_NOTIFICATION_DRY_RUN = False
|
||||
# Max tries to run queries to prevent false errors caused by transient errors
|
||||
# being returned to users. Set to a value >1 to enable retries.
|
||||
ALERT_REPORTS_QUERY_EXECUTION_MAX_TRIES = 1
|
||||
# Socket timeout (in seconds) for the HTTP request that fetches chart data when
|
||||
# generating CSV/dataframe report attachments. Without a timeout the request
|
||||
# blocks indefinitely if the Superset webserver is unreachable from the worker,
|
||||
# which leaves the report schedule stuck in the WORKING state. Set to None to
|
||||
# disable (not recommended).
|
||||
ALERT_REPORTS_CSV_REQUEST_TIMEOUT = 60
|
||||
# Custom width for screenshots
|
||||
ALERT_REPORTS_MIN_CUSTOM_SCREENSHOT_WIDTH = 600
|
||||
ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH = 2400
|
||||
@@ -2128,6 +2145,12 @@ SLACK_CACHE_TIMEOUT = int(timedelta(days=1).total_seconds())
|
||||
# For workspaces with 10k+ channels, consider increasing to 10
|
||||
SLACK_API_RATE_LIMIT_RETRY_COUNT = 2
|
||||
|
||||
# Timeout (in seconds) for outbound Slack API calls. The Slack SDK defaults to 30s;
|
||||
# exposing it here lets operators grant more time for large file uploads (multi-MB
|
||||
# CSVs, PDFs, screenshot sets) to congested or rate-limited Slack endpoints without
|
||||
# patching code, consistent with the SMTP/CSV/screenshot timeouts.
|
||||
SLACK_API_TIMEOUT = 30
|
||||
|
||||
# The webdriver to use for generating reports when using Selenium (not Playwright).
|
||||
# This setting is ignored when PLAYWRIGHT_REPORTS_AND_THUMBNAILS is enabled, as
|
||||
# Playwright always uses Chromium regardless of this value.
|
||||
|
||||
@@ -294,6 +294,20 @@ class ImportV1MetricSchema(Schema):
|
||||
|
||||
return data
|
||||
|
||||
@pre_load
|
||||
def fix_template_params(
|
||||
self, data: dict[str, Any], **kwargs: Any
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Fix for template_params initially being exported as an empty string.
|
||||
"""
|
||||
if (
|
||||
isinstance(data.get("template_params"), str)
|
||||
and data["template_params"].strip() == ""
|
||||
):
|
||||
data["template_params"] = None
|
||||
return data
|
||||
|
||||
metric_name = fields.String(required=True)
|
||||
verbose_name = fields.String(allow_none=True)
|
||||
metric_type = fields.String(allow_none=True)
|
||||
|
||||
@@ -31,7 +31,12 @@ from superset.exceptions import SupersetSecurityException
|
||||
from superset.extensions import cache_manager
|
||||
from superset.superset_typing import FlaskResponse
|
||||
from superset.utils import json
|
||||
from superset.utils.core import apply_max_row_limit, DatasourceType, SqlExpressionType
|
||||
from superset.utils.core import (
|
||||
apply_max_row_limit,
|
||||
DatasourceType,
|
||||
parse_boolean_string,
|
||||
SqlExpressionType,
|
||||
)
|
||||
from superset.views.base_api import BaseSupersetApi, statsd_metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -51,8 +56,9 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
|
||||
f".get_column_values",
|
||||
action=lambda self, *args, **kwargs: (
|
||||
f"{self.__class__.__name__}.get_column_values"
|
||||
),
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def get_column_values(
|
||||
@@ -124,13 +130,63 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
|
||||
row_limit = apply_max_row_limit(app.config["FILTER_SELECT_ROW_LIMIT"])
|
||||
denormalize_column = not datasource.normalize_columns
|
||||
|
||||
# Cache distinct column-value results so a dashboard with many filters
|
||||
# backed by the same (often heavy) virtual dataset doesn't re-execute
|
||||
# the wrapping query per filter (#39342).
|
||||
#
|
||||
# Key fields:
|
||||
# - ``rls`` — full RLS fingerprint via
|
||||
# ``security_manager.get_rls_cache_key`` (the canonical helper used
|
||||
# by viz.py and query_context_processor.py). This is the sole
|
||||
# security-isolation field — two users with identical effective
|
||||
# RLS share a cache entry (intentional: they would see identical
|
||||
# filtered values anyway), while users with different RLS, guest
|
||||
# sessions with different guest-token RLS, and anonymous sessions
|
||||
# with no RLS each get their own partition. We deliberately do
|
||||
# NOT include the raw user id; doing so would defeat the
|
||||
# intended cross-user cache sharing without adding any real
|
||||
# security boundary beyond what the RLS fingerprint already
|
||||
# provides.
|
||||
# - ``changed_on`` — auto-busts cached entries when the dataset's
|
||||
# underlying SQL is edited.
|
||||
# - ``uid`` / ``col`` / ``limit`` / ``denorm`` — basic query-shape
|
||||
# isolation so different inputs never collide.
|
||||
force = parse_boolean_string(request.args.get("force"))
|
||||
cache_key = (
|
||||
"col_values:"
|
||||
+ hashlib.sha256(
|
||||
json.dumps(
|
||||
{
|
||||
"uid": datasource.uid,
|
||||
"col": column_name,
|
||||
"limit": row_limit,
|
||||
"denorm": denormalize_column,
|
||||
"rls": security_manager.get_rls_cache_key(datasource),
|
||||
"changed_on": str(getattr(datasource, "changed_on", "")),
|
||||
},
|
||||
sort_keys=True,
|
||||
).encode()
|
||||
).hexdigest()
|
||||
)
|
||||
|
||||
if (
|
||||
not force
|
||||
and (cached := cache_manager.data_cache.get(cache_key)) is not None
|
||||
):
|
||||
logger.debug(
|
||||
"column-values cache HIT: uid=%s col=%s", datasource.uid, column_name
|
||||
)
|
||||
response = self.response(200, result=cached)
|
||||
response.headers["X-Cache-Status"] = "HIT"
|
||||
return response
|
||||
|
||||
try:
|
||||
payload = datasource.values_for_column(
|
||||
column_name=column_name,
|
||||
limit=row_limit,
|
||||
denormalize_column=denormalize_column,
|
||||
)
|
||||
return self.response(200, result=payload)
|
||||
except KeyError:
|
||||
return self.response(
|
||||
400, message=f"Column name {column_name} does not exist"
|
||||
@@ -144,6 +200,31 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
),
|
||||
)
|
||||
|
||||
# Warn before caching very large payloads (high-cardinality columns)
|
||||
# so operators can spot cache-memory pressure before Redis OOMs.
|
||||
# Threshold is operator-tunable; defaults to 100k rows.
|
||||
warn_threshold = app.config.get("FILTER_VALUES_CACHE_WARN_THRESHOLD", 100_000)
|
||||
if (payload_size := len(payload)) > warn_threshold:
|
||||
logger.warning(
|
||||
"column-values payload exceeds cache-warn threshold: "
|
||||
"uid=%s col=%s rows=%d threshold=%d",
|
||||
datasource.uid,
|
||||
column_name,
|
||||
payload_size,
|
||||
warn_threshold,
|
||||
)
|
||||
|
||||
timeout = datasource.cache_timeout or app.config.get(
|
||||
"CACHE_DEFAULT_TIMEOUT", 300
|
||||
)
|
||||
cache_manager.data_cache.set(cache_key, payload, timeout=timeout)
|
||||
logger.debug(
|
||||
"column-values cache MISS: uid=%s col=%s", datasource.uid, column_name
|
||||
)
|
||||
response = self.response(200, result=payload)
|
||||
response.headers["X-Cache-Status"] = "MISS"
|
||||
return response
|
||||
|
||||
@expose(
|
||||
"/<datasource_type>/<int:datasource_id>/validate_expression/",
|
||||
methods=("POST",),
|
||||
@@ -152,8 +233,9 @@ class DatasourceRestApi(BaseSupersetApi):
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
|
||||
f".validate_expression",
|
||||
action=lambda self, *args, **kwargs: (
|
||||
f"{self.__class__.__name__}.validate_expression"
|
||||
),
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def validate_expression(
|
||||
|
||||
@@ -610,9 +610,14 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
|
||||
with extension_context(extension.manifest):
|
||||
eager_import(backend.entrypoint)
|
||||
|
||||
except Exception as ex: # pylint: disable=broad-except # noqa: S110
|
||||
# Surface exceptions during initialization of extensions
|
||||
print(ex)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Surface extension-initialization failures through the
|
||||
# configured logger (with traceback) so they reach log
|
||||
# aggregation, rather than being written to stdout.
|
||||
logger.exception(
|
||||
"Failed to initialize extension '%s'",
|
||||
extension.manifest.id,
|
||||
)
|
||||
|
||||
def init_app_in_ctx(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -139,6 +139,16 @@ Example table config:
|
||||
## Available Aggregations
|
||||
SUM, COUNT, AVG, MIN, MAX, COUNT_DISTINCT, STDDEV, VAR, MEDIAN
|
||||
|
||||
## Custom SQL Metrics
|
||||
For ratio metrics, weighted averages, and conditional aggregates,
|
||||
use `sql_expression` with a `label`:
|
||||
`{{"sql_expression": "SUM(revenue) / COUNT(*)", "label": "Avg Revenue"}}`
|
||||
Do NOT combine `sql_expression` with `name` or `aggregate`.
|
||||
|
||||
## Saved Metrics
|
||||
If a metric is already defined on the dataset, use `saved_metric=True`:
|
||||
`{{"name": "avg_revenue", "saved_metric": true}}`
|
||||
|
||||
## Time Grain Options (for temporal x-axis)
|
||||
PT1H (hourly), P1D (daily), P1W (weekly), P1M (monthly), P3M (quarterly), P1Y (yearly)
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ _CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
|
||||
"columns": [
|
||||
{"name": "customer_name"},
|
||||
{"name": "revenue", "aggregate": "SUM"},
|
||||
{"sql_expression": "SUM(revenue) / COUNT(*)", "label": "Avg per Order"},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -120,8 +120,10 @@ class TableColumnInfo(BaseModel):
|
||||
class SqlMetricInfo(BaseModel):
|
||||
metric_name: str = Field(
|
||||
...,
|
||||
description="Saved metric name. In chart configs, reference as "
|
||||
'{"name": "<metric_name>", "saved_metric": true}.',
|
||||
description=(
|
||||
"Saved metric name. In chart configs, reference as "
|
||||
'{"name": "<metric_name>", "saved_metric": true}.'
|
||||
),
|
||||
)
|
||||
verbose_name: str | None = Field(None, description="Verbose name")
|
||||
expression: str | None = Field(None, description="SQL expression")
|
||||
@@ -408,6 +410,30 @@ class GetDatasetInfoRequest(MetadataCacheControl):
|
||||
return parsed
|
||||
|
||||
|
||||
class CreateDatasetMetric(BaseModel):
|
||||
"""Metric definition for dataset creation."""
|
||||
|
||||
metric_name: str = Field(..., description="Name of the metric")
|
||||
expression: str = Field(..., description="SQL expression for the metric")
|
||||
verbose_name: str | None = None
|
||||
description: str | None = None
|
||||
metric_type: str | None = None
|
||||
d3format: str | None = None
|
||||
warning_text: str | None = None
|
||||
|
||||
|
||||
class CreateDatasetCalculatedColumn(BaseModel):
|
||||
"""Calculated column definition for dataset creation."""
|
||||
|
||||
column_name: str = Field(..., description="Name of the calculated column")
|
||||
expression: str = Field(..., description="SQL expression for the column")
|
||||
verbose_name: str | None = None
|
||||
description: str | None = None
|
||||
type: str | None = None
|
||||
advanced_data_type: str | None = None
|
||||
is_dttm: bool | None = None
|
||||
|
||||
|
||||
class CreateDatasetRequest(BaseModel):
|
||||
"""Request schema for create_dataset to register a physical table as a dataset."""
|
||||
|
||||
@@ -512,6 +538,16 @@ class CreateVirtualDatasetRequest(BaseModel):
|
||||
None,
|
||||
description="Human-readable description of the dataset (optional).",
|
||||
)
|
||||
metrics: list[CreateDatasetMetric] | None = Field(
|
||||
None,
|
||||
description="Optional list of saved metrics to create. Each metric "
|
||||
"must have 'metric_name' and 'expression'.",
|
||||
)
|
||||
calculated_columns: list[CreateDatasetCalculatedColumn] | None = Field(
|
||||
None,
|
||||
description="Optional list of calculated columns to create. Each column "
|
||||
"must have 'column_name' and 'expression'.",
|
||||
)
|
||||
|
||||
@field_validator("sql")
|
||||
@classmethod
|
||||
|
||||
@@ -30,6 +30,55 @@ from superset.mcp_service.dataset.schemas import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_update_props(
|
||||
request: CreateVirtualDatasetRequest, dataset: Any
|
||||
) -> dict[str, Any]:
|
||||
update_props: dict[str, Any] = {}
|
||||
if request.metrics:
|
||||
# Merge existing metrics with new ones
|
||||
existing_metrics = [
|
||||
{"id": m.id, "metric_name": m.metric_name} for m in dataset.metrics
|
||||
]
|
||||
update_props["metrics"] = existing_metrics + [
|
||||
m.model_dump(exclude_none=True) for m in request.metrics
|
||||
]
|
||||
if request.calculated_columns:
|
||||
# Merge existing columns with new ones
|
||||
existing_cols = [
|
||||
{"id": c.id, "column_name": c.column_name} for c in dataset.columns
|
||||
]
|
||||
update_props["columns"] = existing_cols + [
|
||||
c.model_dump(exclude_none=True) for c in request.calculated_columns
|
||||
]
|
||||
return update_props
|
||||
|
||||
|
||||
def _cleanup_failed_dataset(dataset_id: int) -> None:
|
||||
from superset.commands.dataset.delete import DeleteDatasetCommand
|
||||
|
||||
try:
|
||||
DeleteDatasetCommand([dataset_id]).run()
|
||||
except Exception as cleanup_exc:
|
||||
logger.error(
|
||||
"Failed to clean up dataset %s after update error: %s",
|
||||
dataset_id,
|
||||
cleanup_exc,
|
||||
)
|
||||
|
||||
|
||||
def _update_virtual_dataset(dataset_id: int, update_props: dict[str, Any]) -> Any:
|
||||
from superset.commands.dataset.exceptions import DatasetUpdateFailedError
|
||||
from superset.commands.dataset.update import UpdateDatasetCommand
|
||||
|
||||
try:
|
||||
return UpdateDatasetCommand(dataset_id, update_props).run()
|
||||
except Exception as exc:
|
||||
_cleanup_failed_dataset(dataset_id)
|
||||
if not isinstance(exc, DatasetUpdateFailedError):
|
||||
raise DatasetUpdateFailedError() from exc
|
||||
raise
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["mutate"],
|
||||
class_permission_name="Dataset",
|
||||
@@ -56,8 +105,8 @@ async def create_virtual_dataset(
|
||||
3. Use the returned ``columns`` list to pick columns for the chart config
|
||||
"""
|
||||
await ctx.info(
|
||||
"Creating virtual dataset: database_id=%s, dataset_name=%r"
|
||||
% (request.database_id, request.dataset_name)
|
||||
f"Creating virtual dataset: database_id={request.database_id}, "
|
||||
f"dataset_name={request.dataset_name!r}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -65,6 +114,7 @@ async def create_virtual_dataset(
|
||||
from superset.commands.dataset.exceptions import (
|
||||
DatasetCreateFailedError,
|
||||
DatasetInvalidError,
|
||||
DatasetUpdateFailedError,
|
||||
)
|
||||
from superset.mcp_service.utils.url_utils import get_superset_base_url
|
||||
|
||||
@@ -85,6 +135,14 @@ async def create_virtual_dataset(
|
||||
|
||||
dataset = CreateDatasetCommand(properties).run()
|
||||
|
||||
if request.metrics or request.calculated_columns:
|
||||
update_props = _build_update_props(request, dataset)
|
||||
|
||||
with event_logger.log_context(
|
||||
action="mcp.create_virtual_dataset.update"
|
||||
):
|
||||
dataset = _update_virtual_dataset(dataset.id, update_props)
|
||||
|
||||
# Build response
|
||||
columns = [col.column_name for col in dataset.columns]
|
||||
dataset_url = (
|
||||
@@ -93,8 +151,8 @@ async def create_virtual_dataset(
|
||||
)
|
||||
|
||||
await ctx.info(
|
||||
"Virtual dataset created: id=%s, dataset_name=%r, columns=%s"
|
||||
% (dataset.id, dataset.table_name, columns)
|
||||
f"Virtual dataset created: id={dataset.id}, "
|
||||
f"dataset_name={dataset.table_name!r}, columns={columns}"
|
||||
)
|
||||
|
||||
return CreateVirtualDatasetResponse(
|
||||
@@ -108,7 +166,7 @@ async def create_virtual_dataset(
|
||||
|
||||
except DatasetInvalidError as exc:
|
||||
messages = exc.normalized_messages()
|
||||
await ctx.warning("Virtual dataset validation failed: %s" % (messages,))
|
||||
await ctx.warning(f"Virtual dataset validation failed: {messages}")
|
||||
return CreateVirtualDatasetResponse(
|
||||
id=None,
|
||||
dataset_name=request.dataset_name,
|
||||
@@ -119,7 +177,7 @@ async def create_virtual_dataset(
|
||||
error=str(messages),
|
||||
)
|
||||
except DatasetCreateFailedError as exc:
|
||||
await ctx.error("Virtual dataset creation failed: %s" % (str(exc),))
|
||||
await ctx.error(f"Virtual dataset creation failed: {exc}")
|
||||
return CreateVirtualDatasetResponse(
|
||||
id=None,
|
||||
dataset_name=request.dataset_name,
|
||||
@@ -129,9 +187,19 @@ async def create_virtual_dataset(
|
||||
url=None,
|
||||
error=f"Failed to create dataset: {exc}",
|
||||
)
|
||||
except DatasetUpdateFailedError as exc:
|
||||
await ctx.error(f"Virtual dataset update failed: {exc}")
|
||||
return CreateVirtualDatasetResponse(
|
||||
id=None,
|
||||
dataset_name=request.dataset_name,
|
||||
sql=request.sql,
|
||||
database_id=request.database_id,
|
||||
columns=[],
|
||||
url=None,
|
||||
error=f"Failed to update dataset metadata (creation rolled back): {exc}",
|
||||
)
|
||||
except Exception as exc:
|
||||
await ctx.error(
|
||||
"Unexpected error creating virtual dataset: %s: %s"
|
||||
% (type(exc).__name__, str(exc))
|
||||
f"Unexpected error creating virtual dataset: {type(exc).__name__}: {exc}"
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -704,7 +704,7 @@ class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin): # pylint:
|
||||
return (
|
||||
not df_series.empty
|
||||
and isinstance(df_series, pd.Series)
|
||||
and isinstance(df_series[0], (list, dict))
|
||||
and isinstance(df_series.iloc[0], (list, dict))
|
||||
)
|
||||
|
||||
for col, coltype in df.dtypes.to_dict().items():
|
||||
|
||||
@@ -27,7 +27,7 @@ import re
|
||||
import uuid
|
||||
from collections.abc import Hashable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
@@ -2765,7 +2765,15 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
|
||||
if tf:
|
||||
if tf in {"epoch_ms", "epoch_s"}:
|
||||
seconds_since_epoch = int(dttm.timestamp())
|
||||
# In general, Superset works with timezone-naive datetime objects
|
||||
# internally. However, timestamp() applies local timezone to
|
||||
# timezone-naive datetime objects. Therefore, we have to be explicit
|
||||
# about UTC before calling timestamp().
|
||||
dttm_tz_aware = dttm
|
||||
if dttm_tz_aware.tzinfo is None:
|
||||
dttm_tz_aware = dttm_tz_aware.replace(tzinfo=timezone.utc)
|
||||
|
||||
seconds_since_epoch = int(dttm_tz_aware.timestamp())
|
||||
if tf == "epoch_s":
|
||||
return str(seconds_since_epoch)
|
||||
return str(seconds_since_epoch * 1000)
|
||||
|
||||
@@ -183,10 +183,12 @@ class SavedQueryRestApi(BaseSupersetModelRestApi):
|
||||
related_field_filters = {
|
||||
"database": "database_name",
|
||||
"changed_by": RelatedFieldFilter("first_name", FilterRelatedOwners),
|
||||
"created_by": RelatedFieldFilter("first_name", FilterRelatedOwners),
|
||||
}
|
||||
base_related_field_filters = {
|
||||
"database": [["id", DatabaseFilter, lambda: []]],
|
||||
"changed_by": [["id", BaseFilterRelatedUsers, lambda: []]],
|
||||
"created_by": [["id", BaseFilterRelatedUsers, lambda: []]],
|
||||
}
|
||||
allowed_rel_fields = {"database", "changed_by", "created_by"}
|
||||
allowed_distinct_fields = {"catalog", "schema"}
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
# under the License.
|
||||
from typing import Any
|
||||
|
||||
from flask import g
|
||||
from flask import g, has_request_context, request
|
||||
from flask_babel import lazy_gettext as _
|
||||
from flask_sqlalchemy import BaseQuery
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm.query import Query
|
||||
|
||||
from superset import security_manager
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
from superset.tags.filters import BaseTagIdFilter, BaseTagNameFilter
|
||||
from superset.views.base import BaseFilter
|
||||
@@ -82,10 +83,16 @@ class SavedQueryTagIdFilter(BaseTagIdFilter): # pylint: disable=too-few-public-
|
||||
class SavedQueryFilter(BaseFilter): # pylint: disable=too-few-public-methods
|
||||
def apply(self, query: BaseQuery, value: Any) -> BaseQuery:
|
||||
"""
|
||||
Filter saved queries to only those created by current user.
|
||||
Filter saved queries to current user's queries unless this is a read
|
||||
request and the user can access all queries.
|
||||
|
||||
:returns: flask-sqlalchemy query
|
||||
"""
|
||||
return query.filter(
|
||||
SavedQuery.created_by == g.user # pylint: disable=comparison-with-callable
|
||||
can_access_all_queries = security_manager.can_access_all_queries() and (
|
||||
not has_request_context() or request.method == "GET"
|
||||
)
|
||||
if not can_access_all_queries:
|
||||
query = query.filter(
|
||||
SavedQuery.created_by == g.user # pylint: disable=comparison-with-callable
|
||||
)
|
||||
return query
|
||||
|
||||
@@ -73,9 +73,21 @@ def stringify_values(array: NDArray[Any]) -> NDArray[Any]:
|
||||
obj[na_obj] = None
|
||||
else:
|
||||
try:
|
||||
# for simple string conversions
|
||||
# this handles odd character types better
|
||||
obj[...] = obj.astype(str)
|
||||
val = obj.item()
|
||||
if isinstance(val, (dict, list)):
|
||||
try:
|
||||
# Use json.dumps for valid double-quoted JSON.
|
||||
# str() gives single-quoted repr like {'a': 1}
|
||||
# which breaks the frontend cell viewer.
|
||||
obj[...] = stringify(val)
|
||||
except TypeError:
|
||||
# Non-JSON-serializable value (e.g. bytes, custom
|
||||
# objects): fall back to str() to avoid crashing.
|
||||
obj[...] = str(val)
|
||||
else:
|
||||
# for simple string conversions
|
||||
# this handles odd character types better
|
||||
obj[...] = obj.astype(str)
|
||||
except ValueError:
|
||||
obj[...] = stringify(obj)
|
||||
|
||||
|
||||
@@ -16,13 +16,25 @@
|
||||
# under the License.
|
||||
|
||||
|
||||
from marshmallow import fields, Schema
|
||||
from marshmallow import fields, Schema, ValidationError
|
||||
from marshmallow.validate import Length, OneOf
|
||||
|
||||
from superset.connectors.sqla.models import RowLevelSecurityFilter
|
||||
from superset.dashboards.schemas import UserSchema
|
||||
from superset.utils.core import RowLevelSecurityFilterType
|
||||
|
||||
|
||||
def validate_non_blank_clause(value: str) -> None:
|
||||
"""Reject empty or whitespace-only RLS clauses.
|
||||
|
||||
An empty clause produces a non-restrictive predicate, which silently
|
||||
disables the control when used as a base filter. Require a non-blank clause
|
||||
on both the create and update paths.
|
||||
"""
|
||||
if not value or not value.strip():
|
||||
raise ValidationError("clause cannot be empty or whitespace-only.")
|
||||
|
||||
|
||||
id_description = "Unique if of rls filter"
|
||||
name_description = "Name of rls filter"
|
||||
description_description = "Detailed description"
|
||||
@@ -140,7 +152,10 @@ class RLSPostSchema(Schema):
|
||||
allow_none=True,
|
||||
)
|
||||
clause = fields.String(
|
||||
metadata={"description": "clause_description"}, required=True, allow_none=False
|
||||
metadata={"description": "clause_description"},
|
||||
required=True,
|
||||
allow_none=False,
|
||||
validate=validate_non_blank_clause,
|
||||
)
|
||||
|
||||
|
||||
@@ -182,5 +197,8 @@ class RLSPutSchema(Schema):
|
||||
allow_none=True,
|
||||
)
|
||||
clause = fields.String(
|
||||
metadata={"description": "clause_description"}, required=False, allow_none=False
|
||||
metadata={"description": "clause_description"},
|
||||
required=False,
|
||||
allow_none=False,
|
||||
validate=validate_non_blank_clause,
|
||||
)
|
||||
|
||||
@@ -34,7 +34,11 @@ from superset.commands.dashboard.embedded.exceptions import (
|
||||
from superset.commands.exceptions import ForbiddenError
|
||||
from superset.exceptions import SupersetGenericErrorException
|
||||
from superset.extensions import db, event_logger
|
||||
from superset.security.guest_token import GuestTokenResourceType
|
||||
from superset.security.guest_token import (
|
||||
build_guest_token_audit_payload,
|
||||
GuestTokenResourceType,
|
||||
)
|
||||
from superset.utils.core import get_user_id
|
||||
from superset.views.base_api import (
|
||||
BaseSupersetApi,
|
||||
BaseSupersetModelRestApi,
|
||||
@@ -204,6 +208,15 @@ class SecurityRestApi(BaseSupersetApi):
|
||||
body["rls"],
|
||||
**({"datasets": body["datasets"]} if "datasets" in body else {}),
|
||||
)
|
||||
logger.info(
|
||||
"Guest token issued: %s",
|
||||
build_guest_token_audit_payload(
|
||||
issuer_user_id=get_user_id(),
|
||||
source_ip=request.remote_addr,
|
||||
body=body,
|
||||
token=token,
|
||||
),
|
||||
)
|
||||
return self.response(200, token=token)
|
||||
except EmbeddedDashboardNotFoundError as error:
|
||||
return self.response_400(message=error.message)
|
||||
@@ -358,8 +371,12 @@ class RoleRestAPI(BaseSupersetApi):
|
||||
)
|
||||
except ForbiddenError as e:
|
||||
return self.response_403(message=str(e))
|
||||
except Exception as e:
|
||||
return self.response_500(message=str(e))
|
||||
except Exception:
|
||||
# Log the full error server-side for operator visibility, but return
|
||||
# a generic message so internal details (ORM/driver error text, SQL
|
||||
# fragments, schema names) are not echoed back to the caller.
|
||||
logger.exception("Unexpected error in RoleRestAPI.get_list")
|
||||
return self.response_500(message="An unexpected error occurred")
|
||||
|
||||
|
||||
class UserRegistrationsRestAPI(BaseSupersetModelRestApi):
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Optional, TypedDict, Union
|
||||
from typing import Any, Optional, TypedDict, Union
|
||||
|
||||
from flask_appbuilder.security.sqla.models import Group, Role
|
||||
from flask_login import AnonymousUserMixin
|
||||
@@ -24,6 +25,38 @@ from superset.utils.backports import StrEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_guest_token_audit_payload(
|
||||
issuer_user_id: Optional[int],
|
||||
source_ip: Optional[str],
|
||||
body: dict[str, Any],
|
||||
token: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Build security-relevant metadata for a guest-token issuance event.
|
||||
|
||||
Captures who issued the token, from where, what it grants, and a hash of
|
||||
the issued token (never the raw token) so a later investigation into a
|
||||
misissued or over-scoped token can be scoped from the audit log.
|
||||
"""
|
||||
resources = body.get("resources") or []
|
||||
rls = body.get("rls") or []
|
||||
return {
|
||||
"issuer_user_id": issuer_user_id,
|
||||
"source_ip": source_ip,
|
||||
"resources": [
|
||||
f"{resource.get('type')}:{resource.get('id')}" for resource in resources
|
||||
],
|
||||
"datasets": body.get("datasets"),
|
||||
# RLS clauses can carry data values; record only the datasets in scope
|
||||
# and the rule count, not the clause text.
|
||||
"rls_datasets": [rule.get("dataset") for rule in rls],
|
||||
"rls_rule_count": len(rls),
|
||||
# Hash, not the raw token, so the log can be correlated without
|
||||
# becoming a credential store.
|
||||
"token_sha256": hashlib.sha256(token.encode("utf-8")).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
# JWT claim that carries the revocation version a token was minted with.
|
||||
GUEST_TOKEN_REVOCATION_CLAIM = "rev" # noqa: S105
|
||||
|
||||
|
||||
@@ -543,11 +543,28 @@ class TagRestApi(BaseSupersetModelRestApi):
|
||||
---
|
||||
get:
|
||||
summary: Get all objects associated with a tag
|
||||
description: >-
|
||||
Get all objects associated with a tag.
|
||||
If tagIds is set, tags will be ignored.
|
||||
parameters:
|
||||
- in: path
|
||||
- in: query
|
||||
name: tagIds
|
||||
schema:
|
||||
type: integer
|
||||
name: tag_id
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
- in: query
|
||||
name: tags
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
- in: query
|
||||
name: types
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: List of tagged objects associated with a Tag
|
||||
|
||||
@@ -204,16 +204,16 @@ msgid "\"%s\" is now the system default theme"
|
||||
msgstr "\"%s\" est maintenant le thème par défaut du système"
|
||||
|
||||
#, python-format
|
||||
msgid "% calculation"
|
||||
msgstr "% calcul"
|
||||
msgid "%% calculation"
|
||||
msgstr "%% calcul"
|
||||
|
||||
#, python-format
|
||||
msgid "% of parent"
|
||||
msgstr "% de parent"
|
||||
msgid "%% of parent"
|
||||
msgstr "%% du parent"
|
||||
|
||||
#, python-format
|
||||
msgid "% of total"
|
||||
msgstr "% du total"
|
||||
msgid "%% of total"
|
||||
msgstr "%% du total"
|
||||
|
||||
#, python-format
|
||||
msgid "%(dialect)s cannot be used as a data source for security reasons."
|
||||
@@ -1292,8 +1292,8 @@ msgstr[1] ""
|
||||
#, python-format
|
||||
msgid "Added to 1 dashboard"
|
||||
msgid_plural "Added to %s dashboards"
|
||||
msgstr[0] "Ajouté à %s tableau de bord"
|
||||
msgstr[1] "Ajoutés à %s tableaux de bords"
|
||||
msgstr[0] "Ajouté à 1 tableau de bord"
|
||||
msgstr[1] "Ajouté à %s tableaux de bord"
|
||||
|
||||
msgid "Additional Parameters"
|
||||
msgstr "Paramètres supplémentaires"
|
||||
@@ -2113,7 +2113,7 @@ msgstr "Filtres appliqués (%d)"
|
||||
|
||||
#, python-format
|
||||
msgid "Applied filters (%s)"
|
||||
msgstr "Filtres appliqués (%d)"
|
||||
msgstr "Filtres appliqués (%s)"
|
||||
|
||||
#, python-format
|
||||
msgid "Applied filters: %s"
|
||||
@@ -5695,9 +5695,9 @@ msgstr "Nom(s) de colonne dupliqué : %(columns)s"
|
||||
msgid "Duplicate role"
|
||||
msgstr "Dupliquer le rôle"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "Duplicate role %(name)s"
|
||||
msgstr "Nom(s) de colonne dupliqué : %(columns)s"
|
||||
msgstr "Dupliquer le rôle %(name)s"
|
||||
|
||||
msgid "Duplicate tab"
|
||||
msgstr "Dupliquer l'onglet"
|
||||
@@ -7656,7 +7656,7 @@ msgstr "Inclure une description qui sera envoyée avec votre rapport"
|
||||
|
||||
#, fuzzy, python-format
|
||||
msgid "Include description to be sent with %s"
|
||||
msgstr "Inclure une description qui sera envoyée avec votre rapport"
|
||||
msgstr "Inclure une description à envoyer avec %s"
|
||||
|
||||
msgid "Include series name as an axis"
|
||||
msgstr "Inclure le nom de la série comme axe"
|
||||
@@ -7911,9 +7911,9 @@ msgstr "Type de résultat invalide : %(result_type)s"
|
||||
msgid "Invalid rolling_type: %(type)s"
|
||||
msgstr "Le rolling_type est invalide: %(type)s"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "Invalid spatial point encountered: %(latlong)s"
|
||||
msgstr "Point géographique invalide : %s"
|
||||
msgstr "Point géographique invalide : %(latlong)s"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Invalid state."
|
||||
@@ -11522,9 +11522,9 @@ msgstr "Exécuter la sélection"
|
||||
msgid "Running"
|
||||
msgstr "En cours d’exécution"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "Running block %(block_num)s out of %(block_count)s"
|
||||
msgstr "Exécution de l’instruction %(block_num)s sur %(block_count)s"
|
||||
msgstr "Exécution du bloc %(block_num)s sur %(block_count)s"
|
||||
|
||||
msgid "SAT"
|
||||
msgstr "SAT"
|
||||
@@ -11844,9 +11844,9 @@ msgstr ""
|
||||
msgid "Search"
|
||||
msgstr "Rechercher"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "Search %s records"
|
||||
msgstr "Registres bruts"
|
||||
msgstr "Rechercher dans %s enregistrements"
|
||||
|
||||
msgid "Search / Filter"
|
||||
msgstr "Rechercher / Filtrer"
|
||||
@@ -12709,9 +12709,9 @@ msgstr ""
|
||||
msgid "Show"
|
||||
msgstr "Afficher"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "Show %s entries"
|
||||
msgstr "Afficher la mesure"
|
||||
msgstr "Afficher %s entrées"
|
||||
|
||||
msgid "Show Bubbles"
|
||||
msgstr "Afficher les bulles"
|
||||
@@ -13614,13 +13614,13 @@ msgstr "Nom du tableau"
|
||||
msgid "Table V2"
|
||||
msgstr "Tableau V2"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid ""
|
||||
"Table [%(table)s] could not be found, please double check your database "
|
||||
"connection, schema, and table name"
|
||||
msgstr ""
|
||||
"La tableau [%(table_name)s] n'a pu être trouvé, vérifiez à nouveau votre "
|
||||
"connexion à votre base de données, le schéma et le nom du tableau"
|
||||
"La table [%(table)s] n'a pu être trouvée, veuillez revérifier votre "
|
||||
"connexion à la base de données, le schéma et le nom de la table"
|
||||
|
||||
msgid ""
|
||||
"Table already exists. You can change your 'if table already exists' "
|
||||
@@ -14867,9 +14867,9 @@ msgstr "Les thèmes n'ont pas pu être supprimés."
|
||||
msgid "There are associated alerts or reports"
|
||||
msgstr "Il y a des alertes ou des rapports associés"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "There are associated alerts or reports: %(report_names)s"
|
||||
msgstr "Il y a des alertes ou des rapports associés : %s,"
|
||||
msgstr "Il y a des alertes ou des rapports associés : %(report_names)s"
|
||||
|
||||
msgid "There are no charts added to this dashboard"
|
||||
msgstr "Il n'y a pas de graphiques ajouté dans ce tableau de bord"
|
||||
@@ -16225,9 +16225,9 @@ msgstr "Erreur inattendue :"
|
||||
msgid "Unexpected no file extension found"
|
||||
msgstr "Aucune expression sauvegardée n'a été trouvée"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "Unexpected time range: %(error)s"
|
||||
msgstr "Intervalle de temps inattendu: %s"
|
||||
msgstr "Intervalle de temps inattendu : %(error)s"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Ungroup By"
|
||||
@@ -18435,25 +18435,25 @@ msgstr "devrait être un nombre entier"
|
||||
msgid "is false"
|
||||
msgstr "est faux"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid ""
|
||||
"is linked to %s charts that appear on %s dashboards and users have %s SQL"
|
||||
" Lab tabs using this database open. Are you sure you want to continue? "
|
||||
"Deleting the database will break those objects."
|
||||
msgstr ""
|
||||
"La base de données %s est liée à %s graphiques qui apparaissent sur %s "
|
||||
"tableaux de bord et les utilisateurs ont des onglets %s SQL Lab ouverts "
|
||||
"qui utilisent cette base de données. Êtes-vous sûr de vouloir continuer? "
|
||||
"Supprimer la base de données brisera ces objets."
|
||||
"est liée à %s graphiques qui apparaissent sur %s tableaux de bord et les "
|
||||
"utilisateurs ont %s onglets SQL Lab ouverts qui utilisent cette base de "
|
||||
"données. Êtes-vous sûr de vouloir continuer ? La suppression de la base de"
|
||||
" données brisera ces objets."
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid ""
|
||||
"is linked to %s charts that appear on %s dashboards. Are you sure you "
|
||||
"want to continue? Deleting the dataset will break those objects."
|
||||
msgstr ""
|
||||
"L'ensemble de données %s est lié aux graphiques %s qui apparaissent dans "
|
||||
"%s tableaux de bord. Êtes-vous sûr de vouloir continuer? La suppression "
|
||||
"de l'ensemble de données brisera ces objets."
|
||||
"est lié à %s graphiques qui apparaissent sur %s tableaux de bord. Êtes-vous"
|
||||
" sûr de vouloir continuer ? La suppression de l'ensemble de données "
|
||||
"brisera ces objets."
|
||||
|
||||
msgid "is not"
|
||||
msgstr "n'est pas"
|
||||
|
||||
@@ -938,6 +938,11 @@ def send_mime_email(
|
||||
smtp_starttls = config["SMTP_STARTTLS"]
|
||||
smtp_ssl = config["SMTP_SSL"]
|
||||
smtp_ssl_server_auth = config["SMTP_SSL_SERVER_AUTH"]
|
||||
# A missing timeout means the socket blocks forever when the SMTP server is
|
||||
# unreachable, wedging the report schedule in the WORKING state. Fall back to
|
||||
# the key being absent for backwards compatibility with custom configs.
|
||||
# Keep this fallback in sync with the SMTP_TIMEOUT default in config.py.
|
||||
smtp_timeout = config.get("SMTP_TIMEOUT", 30)
|
||||
|
||||
if dryrun:
|
||||
logger.info("Dryrun enabled, email notification content is below:")
|
||||
@@ -948,17 +953,27 @@ def send_mime_email(
|
||||
# root CA certificates
|
||||
ssl_context = ssl.create_default_context() if smtp_ssl_server_auth else None
|
||||
smtp = (
|
||||
smtplib.SMTP_SSL(smtp_host, smtp_port, context=ssl_context)
|
||||
smtplib.SMTP_SSL(
|
||||
smtp_host, smtp_port, context=ssl_context, timeout=smtp_timeout
|
||||
)
|
||||
if smtp_ssl
|
||||
else smtplib.SMTP(smtp_host, smtp_port)
|
||||
else smtplib.SMTP(smtp_host, smtp_port, timeout=smtp_timeout)
|
||||
)
|
||||
if smtp_starttls:
|
||||
smtp.starttls(context=ssl_context)
|
||||
if smtp_user and smtp_password:
|
||||
smtp.login(smtp_user, smtp_password)
|
||||
logger.debug("Sent an email to %s", str(e_to))
|
||||
smtp.sendmail(e_from, e_to, mime_msg.as_string())
|
||||
smtp.quit()
|
||||
try:
|
||||
if smtp_starttls:
|
||||
smtp.starttls(context=ssl_context)
|
||||
if smtp_user and smtp_password:
|
||||
smtp.login(smtp_user, smtp_password)
|
||||
logger.debug("Sent an email to %s", str(e_to))
|
||||
smtp.sendmail(e_from, e_to, mime_msg.as_string())
|
||||
finally:
|
||||
# Always release the socket; the new timeout means starttls/login/
|
||||
# sendmail can raise, and a skipped quit() would leak connections in
|
||||
# the long-lived worker process.
|
||||
try:
|
||||
smtp.quit()
|
||||
except smtplib.SMTPException:
|
||||
pass
|
||||
|
||||
|
||||
def recipients_string_to_list(address_string: str | None) -> list[str]:
|
||||
|
||||
@@ -90,14 +90,18 @@ def df_to_escaped_csv(df: pd.DataFrame, **kwargs: Any) -> Any:
|
||||
|
||||
|
||||
def get_chart_csv_data(
|
||||
chart_url: str, auth_cookies: Optional[dict[str, str]] = None
|
||||
chart_url: str,
|
||||
auth_cookies: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[float] = None,
|
||||
) -> Optional[bytes]:
|
||||
content = None
|
||||
if auth_cookies:
|
||||
opener = urllib.request.build_opener()
|
||||
cookie_str = ";".join([f"{key}={val}" for key, val in auth_cookies.items()])
|
||||
opener.addheaders.append(("Cookie", cookie_str))
|
||||
response = opener.open(chart_url)
|
||||
# A missing timeout means the socket blocks forever when the Superset
|
||||
# webserver is unreachable, wedging the report schedule in WORKING.
|
||||
response = opener.open(chart_url, timeout=timeout)
|
||||
content = response.read()
|
||||
if response.getcode() != 200:
|
||||
raise URLError(response.getcode())
|
||||
@@ -107,11 +111,13 @@ def get_chart_csv_data(
|
||||
|
||||
|
||||
def get_chart_dataframe(
|
||||
chart_url: str, auth_cookies: Optional[dict[str, str]] = None
|
||||
chart_url: str,
|
||||
auth_cookies: Optional[dict[str, str]] = None,
|
||||
timeout: Optional[float] = None,
|
||||
) -> Optional[pd.DataFrame]:
|
||||
# Disable all the unnecessary-lambda violations in this function
|
||||
# pylint: disable=unnecessary-lambda
|
||||
content = get_chart_csv_data(chart_url, auth_cookies)
|
||||
content = get_chart_csv_data(chart_url, auth_cookies, timeout)
|
||||
if content is None:
|
||||
return None
|
||||
|
||||
|
||||
@@ -94,9 +94,9 @@ def apply_column_types(
|
||||
# if the number is too large, convert it to a string
|
||||
# Excel does not support numbers larger than 10^15
|
||||
df[column] = df[column].apply(
|
||||
lambda x: str(x)
|
||||
if isinstance(x, (int, float)) and abs(x) > 10**15
|
||||
else x
|
||||
lambda x: (
|
||||
str(x) if isinstance(x, (int, float)) and abs(x) > 10**15 else x
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
df[column] = df[column].astype(str)
|
||||
|
||||
@@ -122,8 +122,11 @@ def get_type_generator( # pylint: disable=too-many-return-statements,too-many-b
|
||||
sqlalchemy.sql.sqltypes.DateTime,
|
||||
),
|
||||
):
|
||||
return lambda: datetime.fromordinal(MINIMUM_DATE.toordinal()) + timedelta(
|
||||
seconds=random.randrange(days_range * 86400) # noqa: S311
|
||||
return lambda: (
|
||||
datetime.fromordinal(MINIMUM_DATE.toordinal())
|
||||
+ timedelta(
|
||||
seconds=random.randrange(days_range * 86400) # noqa: S311
|
||||
)
|
||||
)
|
||||
|
||||
if isinstance(sqltype, sqlalchemy.sql.sqltypes.Numeric):
|
||||
|
||||
@@ -48,7 +48,11 @@ def get_slack_client() -> WebClient:
|
||||
token: str = app.config["SLACK_API_TOKEN"]
|
||||
if callable(token):
|
||||
token = token()
|
||||
client = WebClient(token=token, proxy=app.config["SLACK_PROXY"])
|
||||
client = WebClient(
|
||||
token=token,
|
||||
proxy=app.config["SLACK_PROXY"],
|
||||
timeout=app.config["SLACK_API_TIMEOUT"],
|
||||
)
|
||||
|
||||
max_retry_count = app.config.get("SLACK_API_RATE_LIMIT_RETRY_COUNT", 2)
|
||||
rate_limit_handler = RateLimitErrorRetryHandler(max_retry_count=max_retry_count)
|
||||
|
||||
@@ -502,9 +502,23 @@ class WebDriverSelenium(WebDriverProxy):
|
||||
self._driver = self._create()
|
||||
if not self._driver:
|
||||
raise RuntimeError("WebDriver creation failed")
|
||||
self._driver.set_window_size(*self._window)
|
||||
if self._user:
|
||||
self._auth(self._user)
|
||||
try:
|
||||
self._driver.set_window_size(*self._window)
|
||||
# Bound driver.get() so an unreachable page raises a
|
||||
# TimeoutException instead of blocking the worker (and the
|
||||
# report schedule) forever.
|
||||
page_load_wait = app.config["SCREENSHOT_PAGE_LOAD_WAIT"]
|
||||
if page_load_wait is not None:
|
||||
self._driver.set_page_load_timeout(page_load_wait)
|
||||
if self._user:
|
||||
self._auth(self._user)
|
||||
except Exception:
|
||||
# A failure mid-setup (e.g. the new page-load timeout or auth
|
||||
# raising) would otherwise leave a partially initialized,
|
||||
# unauthenticated driver cached for reuse. Tear it down so the
|
||||
# next access recreates it cleanly.
|
||||
self._destroy()
|
||||
raise
|
||||
return self._driver
|
||||
|
||||
def _create_firefox_driver(
|
||||
|
||||
@@ -108,8 +108,9 @@ class LogRestApi(LogMixin, BaseSupersetModelRestApi):
|
||||
@statsd_metrics
|
||||
@parse_rison(get_recent_activity_schema)
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}"
|
||||
f".recent_activity",
|
||||
action=lambda self, *args, **kwargs: (
|
||||
f"{self.__class__.__name__}.recent_activity"
|
||||
),
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def recent_activity(self, **kwargs: Any) -> FlaskResponse:
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
import pytest
|
||||
@@ -23,12 +24,21 @@ from sqlalchemy.sql.elements import TextClause
|
||||
from superset import db, security_manager
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.daos.exceptions import DatasourceTypeNotSupportedError
|
||||
from superset.extensions import cache_manager
|
||||
from superset.utils import json
|
||||
from tests.integration_tests.base_tests import SupersetTestCase
|
||||
from tests.integration_tests.constants import ADMIN_USERNAME, GAMMA_USERNAME
|
||||
|
||||
|
||||
class TestDatasourceApi(SupersetTestCase):
|
||||
def setUp(self):
|
||||
# Clear the column-values cache before every test so that
|
||||
# ``get_column_values`` always re-runs ``values_for_column`` rather
|
||||
# than returning a payload populated by a previous test. Prevents
|
||||
# order-dependent flakes now that the endpoint caches its result.
|
||||
super().setUp()
|
||||
cache_manager.data_cache.clear()
|
||||
|
||||
def get_virtual_dataset(self):
|
||||
return (
|
||||
db.session.query(SqlaTable)
|
||||
@@ -205,6 +215,123 @@ class TestDatasourceApi(SupersetTestCase):
|
||||
response = json.loads(rv.data.decode("utf-8"))
|
||||
assert response["result"] == []
|
||||
|
||||
@pytest.mark.usefixtures("app_context", "virtual_dataset")
|
||||
@patch("superset.models.helpers.ExploreMixin.values_for_column")
|
||||
def test_get_column_values_cache_hit_skips_query(self, values_for_column_mock):
|
||||
"""Regression test for #39342.
|
||||
|
||||
Two identical requests for the same column on the same datasource
|
||||
should hit ``values_for_column`` exactly once — the second request
|
||||
returns the cached payload.
|
||||
"""
|
||||
cache_manager.data_cache.clear()
|
||||
values_for_column_mock.return_value = ["a", "b", "c"]
|
||||
self.login(ADMIN_USERNAME)
|
||||
table = self.get_virtual_dataset()
|
||||
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
|
||||
|
||||
rv1 = self.client.get(url)
|
||||
rv2 = self.client.get(url)
|
||||
|
||||
assert rv1.status_code == 200
|
||||
assert rv2.status_code == 200
|
||||
assert json.loads(rv2.data.decode("utf-8"))["result"] == ["a", "b", "c"]
|
||||
assert values_for_column_mock.call_count == 1
|
||||
|
||||
@pytest.mark.usefixtures("app_context", "virtual_dataset")
|
||||
@patch("superset.models.helpers.ExploreMixin.values_for_column")
|
||||
def test_get_column_values_force_bypasses_cache(self, values_for_column_mock):
|
||||
"""``?force=true`` should bypass the cache and re-query the source."""
|
||||
cache_manager.data_cache.clear()
|
||||
values_for_column_mock.return_value = ["a", "b"]
|
||||
self.login(ADMIN_USERNAME)
|
||||
table = self.get_virtual_dataset()
|
||||
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
|
||||
|
||||
self.client.get(url)
|
||||
self.client.get(f"{url}?force=true")
|
||||
|
||||
assert values_for_column_mock.call_count == 2
|
||||
|
||||
@pytest.mark.usefixtures("app_context", "virtual_dataset")
|
||||
@patch("superset.models.helpers.ExploreMixin.values_for_column")
|
||||
def test_get_column_values_cache_isolated_per_column(self, values_for_column_mock):
|
||||
"""Different columns on the same datasource must not share a cache
|
||||
entry — otherwise filter values would be silently swapped."""
|
||||
cache_manager.data_cache.clear()
|
||||
values_for_column_mock.return_value = ["x"]
|
||||
self.login(ADMIN_USERNAME)
|
||||
table = self.get_virtual_dataset()
|
||||
|
||||
self.client.get(f"api/v1/datasource/table/{table.id}/column/col1/values/")
|
||||
self.client.get(f"api/v1/datasource/table/{table.id}/column/col2/values/")
|
||||
|
||||
assert values_for_column_mock.call_count == 2
|
||||
|
||||
@pytest.mark.usefixtures("app_context", "virtual_dataset")
|
||||
@patch("superset.models.helpers.ExploreMixin.values_for_column")
|
||||
def test_get_column_values_cache_busts_on_changed_on(self, values_for_column_mock):
|
||||
"""Editing the underlying virtual dataset SQL bumps ``changed_on``,
|
||||
which is part of the cache key — so the next request must miss the
|
||||
cache and re-run the query."""
|
||||
cache_manager.data_cache.clear()
|
||||
values_for_column_mock.return_value = ["v"]
|
||||
self.login(ADMIN_USERNAME)
|
||||
table = self.get_virtual_dataset()
|
||||
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
|
||||
|
||||
self.client.get(url)
|
||||
# Simulate an edit to the dataset; ``changed_on`` is what the cache
|
||||
# key reads, so any new value forces a miss.
|
||||
table.changed_on = datetime(2030, 1, 1)
|
||||
db.session.flush()
|
||||
self.client.get(url)
|
||||
|
||||
assert values_for_column_mock.call_count == 2
|
||||
|
||||
@pytest.mark.usefixtures("app_context", "virtual_dataset")
|
||||
@patch("superset.datasource.api.security_manager.get_rls_cache_key")
|
||||
@patch("superset.models.helpers.ExploreMixin.values_for_column")
|
||||
def test_get_column_values_cache_isolated_per_rls_context(
|
||||
self, values_for_column_mock, get_rls_cache_key_mock
|
||||
):
|
||||
"""RLS safety for guest/embedded sessions. ``get_user_id()`` returns
|
||||
``None`` for guest users, so two embedded dashboards with different
|
||||
guest-token RLS would otherwise collide on ``user=None``. Including
|
||||
the RLS fingerprint in the cache key keeps them separate."""
|
||||
cache_manager.data_cache.clear()
|
||||
values_for_column_mock.return_value = ["v"]
|
||||
self.login(ADMIN_USERNAME)
|
||||
table = self.get_virtual_dataset()
|
||||
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
|
||||
|
||||
get_rls_cache_key_mock.return_value = ["dept='A'"]
|
||||
self.client.get(url)
|
||||
get_rls_cache_key_mock.return_value = ["dept='B'"]
|
||||
self.client.get(url)
|
||||
|
||||
assert values_for_column_mock.call_count == 2
|
||||
|
||||
@pytest.mark.usefixtures("app_context", "virtual_dataset")
|
||||
@patch("superset.models.helpers.ExploreMixin.values_for_column")
|
||||
def test_get_column_values_response_advertises_cache_status(
|
||||
self, values_for_column_mock
|
||||
):
|
||||
"""The ``X-Cache-Status`` response header should advertise MISS on
|
||||
the populating request and HIT on the next identical request, so
|
||||
operators can debug cache behavior from logs or browser devtools."""
|
||||
cache_manager.data_cache.clear()
|
||||
values_for_column_mock.return_value = ["v"]
|
||||
self.login(ADMIN_USERNAME)
|
||||
table = self.get_virtual_dataset()
|
||||
url = f"api/v1/datasource/table/{table.id}/column/col2/values/"
|
||||
|
||||
rv_miss = self.client.get(url)
|
||||
rv_hit = self.client.get(url)
|
||||
|
||||
assert rv_miss.headers.get("X-Cache-Status") == "MISS"
|
||||
assert rv_hit.headers.get("X-Cache-Status") == "HIT"
|
||||
|
||||
@patch("superset.datasource.api.security_manager.can_access")
|
||||
@patch("superset.datasource.api.GetCombinedDatasourceListCommand.run")
|
||||
def test_combined_list_invalid_order_column(
|
||||
|
||||
@@ -82,8 +82,8 @@ class TestPrestoDbEngineSpec(SupersetTestCase):
|
||||
def verify_presto_column(self, column, expected_results):
|
||||
inspector = mock.Mock()
|
||||
preparer = inspector.engine.dialect.identifier_preparer
|
||||
preparer.quote_identifier = preparer.quote = preparer.quote_schema = (
|
||||
lambda x: f'"{x}"'
|
||||
preparer.quote_identifier = preparer.quote = preparer.quote_schema = lambda x: (
|
||||
f'"{x}"'
|
||||
)
|
||||
row = mock.Mock()
|
||||
row.Column, row.Type, row.Null = column
|
||||
@@ -828,8 +828,8 @@ class TestPrestoDbEngineSpec(SupersetTestCase):
|
||||
def test_show_columns(self):
|
||||
inspector = mock.MagicMock()
|
||||
preparer = inspector.engine.dialect.identifier_preparer
|
||||
preparer.quote_identifier = preparer.quote = preparer.quote_schema = (
|
||||
lambda x: f'"{x}"'
|
||||
preparer.quote_identifier = preparer.quote = preparer.quote_schema = lambda x: (
|
||||
f'"{x}"'
|
||||
)
|
||||
inspector.bind.execute.return_value.fetchall = mock.MagicMock(
|
||||
return_value=["a", "b"]
|
||||
@@ -845,8 +845,8 @@ class TestPrestoDbEngineSpec(SupersetTestCase):
|
||||
def test_show_columns_with_schema(self):
|
||||
inspector = mock.MagicMock()
|
||||
preparer = inspector.engine.dialect.identifier_preparer
|
||||
preparer.quote_identifier = preparer.quote = preparer.quote_schema = (
|
||||
lambda x: f'"{x}"'
|
||||
preparer.quote_identifier = preparer.quote = preparer.quote_schema = lambda x: (
|
||||
f'"{x}"'
|
||||
)
|
||||
inspector.bind.execute.return_value.fetchall = mock.MagicMock(
|
||||
return_value=["a", "b"]
|
||||
|
||||
@@ -193,7 +193,9 @@ class TestEmailSmtp(SupersetTestCase):
|
||||
msg = MIMEMultipart()
|
||||
utils.send_mime_email("from", "to", msg, current_app.config, dryrun=False)
|
||||
mock_smtp.assert_called_with(
|
||||
current_app.config["SMTP_HOST"], current_app.config["SMTP_PORT"]
|
||||
current_app.config["SMTP_HOST"],
|
||||
current_app.config["SMTP_PORT"],
|
||||
timeout=current_app.config["SMTP_TIMEOUT"],
|
||||
)
|
||||
assert mock_smtp.return_value.starttls.called
|
||||
mock_smtp.return_value.login.assert_called_with(
|
||||
@@ -218,6 +220,7 @@ class TestEmailSmtp(SupersetTestCase):
|
||||
current_app.config["SMTP_HOST"],
|
||||
current_app.config["SMTP_PORT"],
|
||||
context=None,
|
||||
timeout=current_app.config["SMTP_TIMEOUT"],
|
||||
)
|
||||
|
||||
@mock.patch("smtplib.SMTP_SSL")
|
||||
@@ -235,6 +238,7 @@ class TestEmailSmtp(SupersetTestCase):
|
||||
current_app.config["SMTP_HOST"],
|
||||
current_app.config["SMTP_PORT"],
|
||||
context=mock.ANY,
|
||||
timeout=current_app.config["SMTP_TIMEOUT"],
|
||||
)
|
||||
called_context = mock_smtp_ssl.call_args.kwargs["context"]
|
||||
assert called_context.verify_mode == ssl.CERT_REQUIRED
|
||||
@@ -266,7 +270,9 @@ class TestEmailSmtp(SupersetTestCase):
|
||||
)
|
||||
assert not mock_smtp_ssl.called
|
||||
mock_smtp.assert_called_with(
|
||||
current_app.config["SMTP_HOST"], current_app.config["SMTP_PORT"]
|
||||
current_app.config["SMTP_HOST"],
|
||||
current_app.config["SMTP_PORT"],
|
||||
timeout=current_app.config["SMTP_TIMEOUT"],
|
||||
)
|
||||
assert not mock_smtp.login.called
|
||||
current_app.config["SMTP_USER"] = smtp_user
|
||||
@@ -281,6 +287,36 @@ class TestEmailSmtp(SupersetTestCase):
|
||||
assert not mock_smtp.called
|
||||
assert not mock_smtp_ssl.called
|
||||
|
||||
@mock.patch("smtplib.SMTP_SSL")
|
||||
@mock.patch("smtplib.SMTP")
|
||||
def test_send_mime_respects_custom_timeout(
|
||||
self, mock_smtp: mock.Mock, mock_smtp_ssl: mock.Mock
|
||||
) -> None:
|
||||
"""A configured SMTP_TIMEOUT must reach the smtplib client.
|
||||
|
||||
A missing timeout would block the worker forever when the SMTP server
|
||||
is unreachable, wedging the report schedule in WORKING (issue #40047).
|
||||
"""
|
||||
config = {**current_app.config, "SMTP_TIMEOUT": 7, "SMTP_SSL": False}
|
||||
mock_smtp.return_value = mock.Mock()
|
||||
utils.send_mime_email("from", ["to"], MIMEMultipart(), config, dryrun=False)
|
||||
assert mock_smtp.call_args.kwargs["timeout"] == 7
|
||||
|
||||
@mock.patch("smtplib.SMTP_SSL")
|
||||
@mock.patch("smtplib.SMTP")
|
||||
def test_send_mime_timeout_defaults_when_unset(
|
||||
self, mock_smtp: mock.Mock, mock_smtp_ssl: mock.Mock
|
||||
) -> None:
|
||||
"""An absent SMTP_TIMEOUT key falls back to the 30s default.
|
||||
|
||||
Custom configs predating SMTP_TIMEOUT must still get a finite timeout.
|
||||
"""
|
||||
config = {k: v for k, v in current_app.config.items() if k != "SMTP_TIMEOUT"}
|
||||
config["SMTP_SSL"] = False
|
||||
mock_smtp.return_value = mock.Mock()
|
||||
utils.send_mime_email("from", ["to"], MIMEMultipart(), config, dryrun=False)
|
||||
assert mock_smtp.call_args.kwargs["timeout"] == 30
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -14,13 +14,14 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# pylint: disable=consider-using-transaction
|
||||
# isort:skip_file
|
||||
"""Unit tests for Superset"""
|
||||
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
from zipfile import is_zipfile, ZipFile
|
||||
|
||||
import yaml
|
||||
@@ -201,10 +202,7 @@ class TestSavedQueryApi(SupersetTestCase):
|
||||
"""
|
||||
Saved Query API: Test get list saved query
|
||||
"""
|
||||
admin = self.get_user("admin")
|
||||
saved_queries = (
|
||||
db.session.query(SavedQuery).filter(SavedQuery.created_by == admin).all()
|
||||
)
|
||||
saved_queries = db.session.query(SavedQuery).all()
|
||||
|
||||
self.login(ADMIN_USERNAME)
|
||||
uri = "api/v1/saved_query/"
|
||||
@@ -250,12 +248,9 @@ class TestSavedQueryApi(SupersetTestCase):
|
||||
"""
|
||||
Saved Query API: Test get list and sort saved query
|
||||
"""
|
||||
admin = self.get_user("admin")
|
||||
saved_queries = (
|
||||
db.session.query(SavedQuery)
|
||||
.filter(SavedQuery.created_by == admin)
|
||||
.order_by(SavedQuery.schema.asc())
|
||||
).all()
|
||||
db.session.query(SavedQuery).order_by(SavedQuery.schema.asc()).all()
|
||||
)
|
||||
self.login(ADMIN_USERNAME)
|
||||
query_string = {"order_column": "schema", "order_direction": "asc"}
|
||||
uri = f"api/v1/saved_query/?q={rison.dumps(query_string)}"
|
||||
@@ -306,13 +301,8 @@ class TestSavedQueryApi(SupersetTestCase):
|
||||
Saved Query API: Test get list and database saved query
|
||||
"""
|
||||
example_db = get_example_database()
|
||||
admin_user = self.get_user("admin")
|
||||
|
||||
all_db_queries = (
|
||||
db.session.query(SavedQuery)
|
||||
.filter(SavedQuery.db_id == example_db.id)
|
||||
.filter(SavedQuery.created_by_fk == admin_user.id)
|
||||
.all()
|
||||
db.session.query(SavedQuery).filter(SavedQuery.db_id == example_db.id).all()
|
||||
)
|
||||
|
||||
self.login(ADMIN_USERNAME)
|
||||
@@ -401,12 +391,8 @@ class TestSavedQueryApi(SupersetTestCase):
|
||||
Saved Query API: Test get list and custom filter (sql) saved query
|
||||
"""
|
||||
self.login(ADMIN_USERNAME)
|
||||
admin = self.get_user("admin")
|
||||
all_queries = (
|
||||
db.session.query(SavedQuery)
|
||||
.filter(SavedQuery.created_by == admin)
|
||||
.filter(SavedQuery.sql.ilike("%table%"))
|
||||
.all()
|
||||
db.session.query(SavedQuery).filter(SavedQuery.sql.ilike("%table%")).all()
|
||||
)
|
||||
query_string = {
|
||||
"filters": [{"col": "label", "opr": "all_text", "value": "table"}],
|
||||
@@ -423,10 +409,8 @@ class TestSavedQueryApi(SupersetTestCase):
|
||||
Saved Query API: Test get list and custom filter (description) saved query
|
||||
"""
|
||||
self.login(ADMIN_USERNAME)
|
||||
admin = self.get_user("admin")
|
||||
all_queries = (
|
||||
db.session.query(SavedQuery)
|
||||
.filter(SavedQuery.created_by == admin)
|
||||
.filter(SavedQuery.description.ilike("%cool%"))
|
||||
.all()
|
||||
)
|
||||
@@ -520,12 +504,7 @@ class TestSavedQueryApi(SupersetTestCase):
|
||||
# Test not favorite saves queries
|
||||
expected_models = (
|
||||
db.session.query(SavedQuery)
|
||||
.filter(
|
||||
and_(
|
||||
~SavedQuery.id.in_(users_favorite_query),
|
||||
SavedQuery.created_by == admin,
|
||||
)
|
||||
)
|
||||
.filter(and_(~SavedQuery.id.in_(users_favorite_query)))
|
||||
.order_by(SavedQuery.label.asc())
|
||||
.all()
|
||||
)
|
||||
@@ -591,9 +570,11 @@ class TestSavedQueryApi(SupersetTestCase):
|
||||
"""
|
||||
SavedQuery API: Test distinct schemas
|
||||
"""
|
||||
admin = self.get_user("admin")
|
||||
saved_queries = (
|
||||
db.session.query(SavedQuery).filter(SavedQuery.created_by == admin).all()
|
||||
schemas = (
|
||||
db.session.query(SavedQuery.schema)
|
||||
.distinct()
|
||||
.order_by(SavedQuery.schema)
|
||||
.all()
|
||||
)
|
||||
|
||||
self.login(ADMIN_USERNAME)
|
||||
@@ -602,11 +583,8 @@ class TestSavedQueryApi(SupersetTestCase):
|
||||
assert rv.status_code == 200
|
||||
data = json.loads(rv.data.decode("utf-8"))
|
||||
expected_response = {
|
||||
"count": len(saved_queries),
|
||||
"result": [
|
||||
{"text": f"schema{i}", "value": f"schema{i}"}
|
||||
for i in range(len(saved_queries))
|
||||
],
|
||||
"count": len(schemas),
|
||||
"result": [{"text": schema, "value": schema} for (schema,) in schemas],
|
||||
}
|
||||
assert data == expected_response
|
||||
|
||||
@@ -818,6 +796,28 @@ class TestSavedQueryApi(SupersetTestCase):
|
||||
rv = self.delete_assert_metric(uri, "bulk_delete")
|
||||
assert rv.status_code == 400
|
||||
|
||||
@pytest.mark.usefixtures("create_saved_queries")
|
||||
@patch(
|
||||
"superset.queries.saved_queries.filters.security_manager.can_access_all_queries"
|
||||
)
|
||||
def test_delete_bulk_saved_query_all_query_access_keeps_owner_filter(
|
||||
self, mock_can_access_all_queries: Mock
|
||||
) -> None:
|
||||
"""
|
||||
Saved Query API: Test all_query_access does not bypass ownership for delete
|
||||
"""
|
||||
mock_can_access_all_queries.return_value = True
|
||||
admin = self.get_user("admin")
|
||||
sample_query = (
|
||||
db.session.query(SavedQuery).filter(SavedQuery.created_by == admin).first()
|
||||
)
|
||||
|
||||
self.login(GAMMA_SQLLAB_USERNAME)
|
||||
uri = f"api/v1/saved_query/?q={rison.dumps([sample_query.id])}"
|
||||
rv = self.delete_assert_metric(uri, "bulk_delete")
|
||||
assert rv.status_code == 404
|
||||
assert db.session.query(SavedQuery).get(sample_query.id) is not None
|
||||
|
||||
@pytest.mark.usefixtures("create_saved_queries")
|
||||
def test_delete_bulk_saved_query_not_found(self):
|
||||
"""
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# pylint: disable=consider-using-transaction
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
@@ -57,9 +58,11 @@ class TestExportSavedQueriesCommand(SupersetTestCase):
|
||||
db.session.commit()
|
||||
super().tearDown()
|
||||
|
||||
@patch("superset.queries.saved_queries.filters.g")
|
||||
def test_export_query_command(self, mock_g):
|
||||
mock_g.user = security_manager.find_user("admin")
|
||||
@patch(
|
||||
"superset.queries.saved_queries.filters.security_manager.can_access_all_queries"
|
||||
)
|
||||
def test_export_query_command(self, mock_can_access_all_queries: Mock) -> None:
|
||||
mock_can_access_all_queries.return_value = True
|
||||
|
||||
command = ExportSavedQueriesCommand([self.example_query.id])
|
||||
contents = dict(command.run())
|
||||
@@ -85,12 +88,14 @@ class TestExportSavedQueriesCommand(SupersetTestCase):
|
||||
"database_uuid": str(self.example_database.uuid),
|
||||
}
|
||||
|
||||
@patch("superset.queries.saved_queries.filters.g")
|
||||
def test_export_query_command_no_related(self, mock_g):
|
||||
@patch(
|
||||
"superset.queries.saved_queries.filters.security_manager.can_access_all_queries"
|
||||
)
|
||||
def test_export_query_command_no_related(self, mock_can_access_all_queries):
|
||||
"""
|
||||
Test that only the query is exported when export_related=False.
|
||||
"""
|
||||
mock_g.user = security_manager.find_user("admin")
|
||||
mock_can_access_all_queries.return_value = True
|
||||
|
||||
command = ExportSavedQueriesCommand(
|
||||
[self.example_query.id], export_related=False
|
||||
@@ -103,30 +108,40 @@ class TestExportSavedQueriesCommand(SupersetTestCase):
|
||||
]
|
||||
assert expected == list(contents.keys())
|
||||
|
||||
@patch(
|
||||
"superset.queries.saved_queries.filters.security_manager.can_access_all_queries"
|
||||
)
|
||||
@patch("superset.queries.saved_queries.filters.g")
|
||||
def test_export_query_command_no_access(self, mock_g):
|
||||
def test_export_query_command_no_access(
|
||||
self, mock_filter_g, mock_can_access_all_queries
|
||||
):
|
||||
"""Test that users can't export datasets they don't have access to"""
|
||||
mock_g.user = security_manager.find_user("gamma")
|
||||
mock_can_access_all_queries.return_value = False
|
||||
mock_filter_g.user = security_manager.find_user("gamma")
|
||||
|
||||
command = ExportSavedQueriesCommand([self.example_query.id])
|
||||
contents = command.run()
|
||||
with self.assertRaises(SavedQueryNotFoundError): # noqa: PT027
|
||||
next(contents)
|
||||
|
||||
@patch("superset.queries.saved_queries.filters.g")
|
||||
def test_export_query_command_invalid_dataset(self, mock_g):
|
||||
@patch(
|
||||
"superset.queries.saved_queries.filters.security_manager.can_access_all_queries"
|
||||
)
|
||||
def test_export_query_command_invalid_dataset(self, mock_can_access_all_queries):
|
||||
"""Test that an error is raised when exporting an invalid dataset"""
|
||||
mock_g.user = security_manager.find_user("admin")
|
||||
mock_can_access_all_queries.return_value = True
|
||||
|
||||
command = ExportSavedQueriesCommand([-1])
|
||||
contents = command.run()
|
||||
with self.assertRaises(SavedQueryNotFoundError): # noqa: PT027
|
||||
next(contents)
|
||||
|
||||
@patch("superset.queries.saved_queries.filters.g")
|
||||
def test_export_query_command_key_order(self, mock_g):
|
||||
@patch(
|
||||
"superset.queries.saved_queries.filters.security_manager.can_access_all_queries"
|
||||
)
|
||||
def test_export_query_command_key_order(self, mock_can_access_all_queries):
|
||||
"""Test that they keys in the YAML have the same order as export_fields"""
|
||||
mock_g.user = security_manager.find_user("admin")
|
||||
mock_can_access_all_queries.return_value = True
|
||||
|
||||
command = ExportSavedQueriesCommand([self.example_query.id])
|
||||
contents = dict(command.run())
|
||||
|
||||
@@ -2007,7 +2007,11 @@ def test_slack_token_callable_chart_report(
|
||||
TEST_ID, create_report_slack_chart.id, datetime.utcnow()
|
||||
).run()
|
||||
slack_token_mock.assert_called()
|
||||
slack_client_mock_class.assert_called_with(token="cool_code", proxy=None) # noqa: S106
|
||||
slack_client_mock_class.assert_called_with(
|
||||
token="cool_code", # noqa: S106
|
||||
proxy=None,
|
||||
timeout=30,
|
||||
)
|
||||
assert_log(ReportState.SUCCESS)
|
||||
|
||||
|
||||
|
||||
@@ -260,8 +260,9 @@ class TestSecurityGuestTokenApiTokenValidator(SupersetTestCase):
|
||||
|
||||
@with_config(
|
||||
{
|
||||
"GUEST_TOKEN_VALIDATOR_HOOK": lambda x: len(x["rls"]) == 1
|
||||
and "tenant_id=" in x["rls"][0]["clause"]
|
||||
"GUEST_TOKEN_VALIDATOR_HOOK": lambda x: (
|
||||
len(x["rls"]) == 1 and "tenant_id=" in x["rls"][0]["clause"]
|
||||
)
|
||||
}
|
||||
)
|
||||
def test_guest_validator_hook_real_world_example_positive(self):
|
||||
@@ -276,8 +277,9 @@ class TestSecurityGuestTokenApiTokenValidator(SupersetTestCase):
|
||||
|
||||
@with_config(
|
||||
{
|
||||
"GUEST_TOKEN_VALIDATOR_HOOK": lambda x: len(x["rls"]) == 1
|
||||
and "tenant_id=" in x["rls"][0]["clause"]
|
||||
"GUEST_TOKEN_VALIDATOR_HOOK": lambda x: (
|
||||
len(x["rls"]) == 1 and "tenant_id=" in x["rls"][0]["clause"]
|
||||
)
|
||||
}
|
||||
)
|
||||
def test_guest_validator_hook_real_world_example_negative(self):
|
||||
@@ -350,6 +352,33 @@ class TestSecurityRolesApi(SupersetTestCase):
|
||||
)
|
||||
self.assert403(response)
|
||||
|
||||
def test_show_roles_unexpected_error_returns_generic_message(self):
|
||||
"""
|
||||
Security API: an unexpected error in role listing returns a generic 500
|
||||
body (no raw exception text) and is logged server-side.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
|
||||
self.login(ADMIN_USERNAME)
|
||||
error_detail = "raw-driver-detail-should-not-leak"
|
||||
# Patch a symbol used only inside get_list's query construction so the
|
||||
# failure happens within the handler's try/except, not in the @protect()
|
||||
# auth check (which also touches db.session.query).
|
||||
with (
|
||||
patch(
|
||||
"superset.security.api.selectinload",
|
||||
side_effect=Exception(error_detail),
|
||||
),
|
||||
patch("superset.security.api.logger") as mock_logger,
|
||||
):
|
||||
response = self.client.get(self.show_uri)
|
||||
|
||||
assert response.status_code == 500
|
||||
body = response.data.decode("utf-8")
|
||||
assert error_detail not in body
|
||||
assert "An unexpected error occurred" in body
|
||||
mock_logger.exception.assert_called_once()
|
||||
|
||||
def test_show_roles_admin(self):
|
||||
"""
|
||||
Security API: Admin should be able to show roles with permissions and users
|
||||
|
||||
@@ -38,7 +38,11 @@ def test_export_assets_command(mocker: MockerFixture) -> None:
|
||||
ExportDatabasesCommand.return_value.run.return_value = [
|
||||
(
|
||||
"metadata.yaml",
|
||||
lambda: "version: 1.0.0\ntype: Database\ntimestamp: '2022-01-01T00:00:00+00:00'\n", # noqa: E501
|
||||
lambda: (
|
||||
"version: 1.0.0\n"
|
||||
"type: Database\n"
|
||||
"timestamp: '2022-01-01T00:00:00+00:00'\n"
|
||||
),
|
||||
),
|
||||
("databases/example.yaml", lambda: "<DATABASE CONTENTS>"),
|
||||
]
|
||||
@@ -48,7 +52,11 @@ def test_export_assets_command(mocker: MockerFixture) -> None:
|
||||
ExportDatasetsCommand.return_value.run.return_value = [
|
||||
(
|
||||
"metadata.yaml",
|
||||
lambda: "version: 1.0.0\ntype: Dataset\ntimestamp: '2022-01-01T00:00:00+00:00'\n", # noqa: E501
|
||||
lambda: (
|
||||
"version: 1.0.0\n"
|
||||
"type: Dataset\n"
|
||||
"timestamp: '2022-01-01T00:00:00+00:00'\n"
|
||||
),
|
||||
),
|
||||
("datasets/example/dataset.yaml", lambda: "<DATASET CONTENTS>"),
|
||||
]
|
||||
@@ -58,7 +66,9 @@ def test_export_assets_command(mocker: MockerFixture) -> None:
|
||||
ExportChartsCommand.return_value.run.return_value = [
|
||||
(
|
||||
"metadata.yaml",
|
||||
lambda: "version: 1.0.0\ntype: Slice\ntimestamp: '2022-01-01T00:00:00+00:00'\n", # noqa: E501
|
||||
lambda: (
|
||||
"version: 1.0.0\ntype: Slice\ntimestamp: '2022-01-01T00:00:00+00:00'\n"
|
||||
),
|
||||
),
|
||||
("charts/pie.yaml", lambda: "<CHART CONTENTS>"),
|
||||
]
|
||||
@@ -68,7 +78,11 @@ def test_export_assets_command(mocker: MockerFixture) -> None:
|
||||
ExportDashboardsCommand.return_value.run.return_value = [
|
||||
(
|
||||
"metadata.yaml",
|
||||
lambda: "version: 1.0.0\ntype: Dashboard\ntimestamp: '2022-01-01T00:00:00+00:00'\n", # noqa: E501
|
||||
lambda: (
|
||||
"version: 1.0.0\n"
|
||||
"type: Dashboard\n"
|
||||
"timestamp: '2022-01-01T00:00:00+00:00'\n"
|
||||
),
|
||||
),
|
||||
("dashboards/sales.yaml", lambda: "<DASHBOARD CONTENTS>"),
|
||||
]
|
||||
@@ -78,7 +92,11 @@ def test_export_assets_command(mocker: MockerFixture) -> None:
|
||||
ExportSavedQueriesCommand.return_value.run.return_value = [
|
||||
(
|
||||
"metadata.yaml",
|
||||
lambda: "version: 1.0.0\ntype: SavedQuery\ntimestamp: '2022-01-01T00:00:00+00:00'\n", # noqa: E501
|
||||
lambda: (
|
||||
"version: 1.0.0\n"
|
||||
"type: SavedQuery\n"
|
||||
"timestamp: '2022-01-01T00:00:00+00:00'\n"
|
||||
),
|
||||
),
|
||||
("queries/example/metric.yaml", lambda: "<SAVED QUERY CONTENTS>"),
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user