Compare commits

...

28 Commits

Author SHA1 Message Date
Evan
c32284d56a chore(ci): correct setup-python pin comment to match v6.2.0
The pinned SHA a309ff8 resolves to tag v6.2.0, but the inline comment
read "# v6", which zizmor flags as a ref-version-mismatch. Update the
comment to the precise version, matching the rest of the workflows that
use full semver in their pin comments.

Resolves code-scanning alert #2550

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:56:35 -07:00
Imad Helal
6bc77fecc2 feat(country-map): add cross-filters support (#35859)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-24 00:54:47 -07:00
dependabot[bot]
420a74b01e chore(deps): bump actions/checkout from 6.0.3 to 7.0.0 (#41358)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 00:52:16 -07:00
dependabot[bot]
7ba59c2d79 chore(deps): bump @jsonforms/vanilla-renderers from 3.7.0 to 3.8.0 in /superset-frontend (#41367)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 00:51:53 -07:00
dependabot[bot]
b77c525d4b chore(deps-dev): bump storybook from 10.4.5 to 10.4.6 in /superset-frontend (#41368)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 00:51:22 -07:00
dependabot[bot]
41ce9ca7d3 chore(deps-dev): bump @swc/plugin-emotion from 14.12.0 to 14.13.0 in /superset-frontend (#41377)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 00:51:06 -07:00
Abdul Rehman
c2fb94cedf perf(filters): cache column-values endpoint to skip DB on repeat requests (#40839) 2026-06-23 23:41:26 -07:00
yousoph
1d0866556f fix(sql_lab): serialize dict/list cell values as valid JSON strings (#41099)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 20:39:23 -07:00
Evan Rusackas
b4dfeef2fd fix(reports): add network timeouts so schedules can't hang forever (#41250)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-06-23 18:01:03 -07:00
Dinesh M
0ec6cae45d feat(Boxplot): Allow configuration of y-axis range (#24380)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: dinesh-zemoso <dinesh.mandava@zemosolabs.com>
2026-06-23 17:48:06 -07:00
Lukas Biermann
d6ede99861 fix(tags): tags api change tag_get_objects method to be aligned with api documentation (#29338)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:12:33 -07:00
Hans Yu
9b6d3ce775 fix(models): make naive datetime object timezone-aware before converting to unix timestamp (#39782)
Co-authored-by: Hans Yu <hans.yu@digits.schwarz>
2026-06-23 14:09:26 -07:00
yousoph
c1f4062af6 fix(sql-lab): normalize tabViewId in QUERY_EDITOR_SET_SQL reducer (#40983)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 13:28:20 -07:00
crabulous
3bc3f47d67 fix(dataset): import/export jinja template bug (#28790)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:25:49 -07:00
Durgaprasad M L
acb996a324 feat(mcp): support virtual dataset metrics and improve adhoc SQL metric discoverability (#40935)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:19:44 -07:00
innovark
c1d08bf27c fix(table): respect row limit with server pagination (#41024)
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-23 12:17:12 -07:00
Ayush Sharaf
d3d5297025 fix(reports): preserve dashboard state in tab permalinks (#39708)
Co-authored-by: Ayush Kumar Sharaf <sharaf@Ayushs-MacBook-Air.local>
Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: Ayush Kumar Sharaf <ayush.sharaf@314ecorp.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 12:15:41 -07:00
sofiankhalfi-kosmos
b1470bd5a5 fix(i18n): correct french translations causing build errors (#34563)
Co-authored-by: sofiankhalfi-kosmos <sofiankhalfi-kosmos@users.noreply.github.com>
Co-authored-by: Sam Firke <sfirke@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:15:23 -07:00
peng weikang
18fea37e84 fix(SavedQueries): allow other admin users see "saved queries" (#20604) (#21769) 2026-06-23 12:14:48 -07:00
Evan Rusackas
1b71c105b7 docs(meta-db): warn that SUPERSET_META_DB_LIMIT truncates tables before joins (#41302)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:29:44 -04:00
Ville Brofeldt
b061b5d317 chore: fix lint on untouched files (#41333) 2026-06-23 11:29:19 -07:00
Evan Rusackas
386893f9f2 feat(security): record audit metadata on guest token issuance (#41305)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-23 11:25:44 -07:00
Evan Rusackas
c1787a67aa fix(extensions): log extension-init failures via the logger, not print() (#41304)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-23 11:25:33 -07:00
Evan Rusackas
dee5859599 fix(rls): reject empty or whitespace-only RLS clauses (#41297)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-23 11:24:38 -07:00
Evan Rusackas
1d3daf2ac8 fix(security): return generic error and log internally in RoleRestAPI.get_list (#41295)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-23 11:24:26 -07:00
Elizabeth Thompson
9d56b1721d fix(models): use Series.iloc for positional access in post_process_df (#41344) 2026-06-23 11:22:22 -07:00
Ayush Anand
67182e255c fix(dashboard): prevent undo crash on new dashboard opened in edit mode (#41252) 2026-06-23 11:22:03 -07:00
Joe Li
e2c6dc3e1a fix(sqllab): shrink Template Parameters editor height and add outline (#41128)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 10:44:11 -07:00
117 changed files with 2846 additions and 409 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -375,7 +375,6 @@ select = [
ignore = [
"S101",
"PT004", # Fixtures that don't return values - underscore prefix conflicts with pytest usage
"PT006",
"T201",
"N999",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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>
),

View File

@@ -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,
},
);
},
);

View File

@@ -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());
}
};

View File

@@ -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) {

View File

@@ -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';

View File

@@ -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];

View File

@@ -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 {

View File

@@ -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,
};
}

View File

@@ -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',
});
});
});

View File

@@ -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();
});

View File

@@ -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,
}));
}

View File

@@ -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.',
),
},
},
],
],
},
],

View File

@@ -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 {

View File

@@ -30,6 +30,7 @@ export type BoxPlotQueryFormData = QueryFormData & {
numberFormat?: string;
whiskerOptions?: BoxPlotFormDataWhiskerOptions;
xTickLayout?: BoxPlotFormXTickLayout;
yAxisSlider?: boolean;
} & TitleFormData;
export type BoxPlotFormDataWhiskerOptions =

View File

@@ -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] }),
]),
);
});
});

View File

@@ -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);
}

View File

@@ -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,
});
});
});
});
});

View File

@@ -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'));

View File

@@ -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>
);

View File

@@ -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 = {

View File

@@ -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,
),
};
},

View File

@@ -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);
});

View File

@@ -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;

View File

@@ -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],
);

View File

@@ -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"],
)
)

View File

@@ -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"],
)
)

View File

@@ -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",

View File

@@ -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.

View File

@@ -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)

View File

@@ -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(

View File

@@ -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:
"""

View File

@@ -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)

View File

@@ -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"},
],
},
],

View File

@@ -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

View File

@@ -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

View File

@@ -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():

View File

@@ -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)

View File

@@ -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"}

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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 dexécution"
#, fuzzy, python-format
#, python-format
msgid "Running block %(block_num)s out of %(block_count)s"
msgstr "Exécution de linstruction %(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"

View File

@@ -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]:

View File

@@ -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

View File

@@ -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)

View File

@@ -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):

View File

@@ -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)

View File

@@ -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(

View File

@@ -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:

View File

@@ -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(

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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):
"""

View File

@@ -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())

View File

@@ -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)

View File

@@ -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

View File

@@ -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