Compare commits

...
Author SHA1 Message Date
hainenber ffdaf9e6b8 chore(build): replace external file-related deps with native Node modules
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-08-09 23:24:12 +07:00
3b164e4270 fix(ci): remove extraneous plugins build job (#42677)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-08 17:27:51 -07:00
bf294cfa43 perf(dashboard): avoid query context N+1 queries (#42474)
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-08-08 17:27:04 -07:00
42e4030104 feat(security): add guest user attributes and get_guest_user_attribute() macro (#33924)
Co-authored-by: Yash Janoria <yash.janoria@314ecorp.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
2026-08-08 16:56:07 -07:00
8181917f79 fix: Apply timezone offset to convert local time boundaries to UTC (#37014)
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-08 16:40:31 -07:00
d14ab29dcb feat(a11y): enable ECharts aria-label (#28171)
Co-authored-by: rusackas <evan@preset.io>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-08 15:36:24 -07:00
a75665b014 fix(i18n): improve Simplified Chinese translations (#42794)
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 12:51:59 -07:00
db5cd1aa38 chore(i18n): update Italian messages.po translations (#38948)
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 12:40:08 -07:00
abb6dd6e39 fix(i18n): add Tamil (ta) locale with corrected catalog path (#38962)
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 12:31:29 -07:00
4a587b8539 feat(security): actionable, request-access-aware data permission errors (#41843)
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-MacBook-Air-2.local>
2026-08-08 11:51:15 -07:00
127a6f92a6 chore(i18n): update french po file to match latest pot file version (#40635)
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 10:43:30 -07:00
25481799f6 test(deckgl): pin GeoJSON layer against multi-row payloads (#34748) (#42734)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-08 08:06:05 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1da7a7fdb1 chore(deps-dev): update starrocks requirement from <2,>=1.3.3 to >=1.3.4,<2 (#42903)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 01:54:54 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
185b5938ba chore(deps-dev): bump pyinstrument from 5.1.2 to 5.1.3 (#42905)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-08 01:54:51 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
503309531f chore(deps-dev): update playwright requirement from <2,>=1.61.0 to >=1.62.0,<2 (#42908)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 01:54:48 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
44a86b725f chore(deps-dev): update teradatasql requirement from >=20.0.0.63 to >=20.0.0.64 (#42909)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 01:54:46 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Amin GhadersohiClaude Sonnet 5
38727906a2 chore(deps): bump react-window and @types/react-window in /superset-frontend (#42528)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 23:02:27 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>Joe LirusackasClaude Opus 4.8
2a9c5acb01 chore(deps): bump numpy from 1.26.4 to 2.4.6 (#42778)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 22:55:20 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d8b97e31fa chore(deps-dev): bump @playwright/test from 1.61.1 to 1.62.1 in /superset-frontend (#42699)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-07 21:05:56 -07:00
bd03440ac8 feat(alerts-reports): add per-schedule toggle to include/exclude the Explore in Superset link (#42494)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 21:05:16 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a6c478c59d chore(deps-dev): bump lerna from 9.0.4 to 10.0.0 in /superset-frontend (#42829)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 10:19:24 +07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fd36419ca2 chore(deps): bump nanoid from 3.3.16 to 3.3.18 in /docs (#42902)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 10:08:38 +07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Joe Li
d80f4c841d chore(deps-dev): update sqlalchemy-risingwave requirement from <2.0.0,>=1.4.1 to <3.0.0,>=1.4.1 (#42856)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-08 09:59:53 +07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Joe Li
6baa467f59 chore(deps): bump google-auth-library from 10.9.1 to 11.0.0 in /superset-frontend (#42870)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-08 09:58:51 +07:00
b18015f63b feat: Color Picker replaces the color selection drop-down list in conditional formatting (#42053)
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-08 02:00:56 +03:00
eb8592d4bf fix(sqla): don't mislabel DB errors as ColumnNotFoundException in adhoc_column_to_sqla (#42889)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 15:03:49 -07:00
55af4685a2 fix(i18n-es): correct fuzzy-matcher strandings in Spanish catalog (#42808)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 11:29:14 -07:00
c901f01693 fix(datasets): preserve metric/column uuids on dataset export (#42393)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 11:15:24 -07:00
4c894affb2 fix(models): purge_oauth2_tokens filters on wrong column (#42211)
Signed-off-by: Martin Brodeur <addressedemartin@gmail.com>
Co-authored-by: rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 11:04:11 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Joe Li
0f9049608a chore(deps): bump dompurify from 3.4.12 to 3.4.13 in /superset-frontend (#42893)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-07 11:00:19 -07:00
Gabriel Torres RuizandGitHub 91b1a6cac5 feat(theme): allow deployments to register extra theme tokens 2026-08-07 14:37:45 -03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
86a328ca9e chore(deps): bump dompurify from 3.4.12 to 3.4.13 in /docs (#42894)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-07 10:23:30 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
38ba4a69bf chore(deps): bump react-intersection-observer from 10.1.0 to 11.0.0 in /superset-frontend (#42869)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-07 10:13:31 -07:00
280253b1fb fix(deletion-retention): dedupe repeated blocked audits (#42863)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-08-07 10:11:36 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Joe Li
79c2cd367d chore(deps): update flask-sqlalchemy requirement from <3.0,>=2.5.1 to <4.0,>=2.5.1 (#42771)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-08-07 09:42:42 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
fd6a0a60a6 chore(deps): bump holidays from 0.100 to 0.102 (#42860)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-07 09:41:57 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
c2b5311f47 chore(deps-dev): bump google-cloud-bigquery from 3.42.2 to 3.43.0 (#42861)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-07 09:41:26 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
70a82a28bf chore(deps): bump redis from 8.0.1 to 8.1.0 (#42857)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-07 09:41:03 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
f348cf425d chore(deps): bump shillelagh from 1.4.4 to 1.4.5 (#42858)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-07 09:41:00 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
74196cf694 chore(deps): bump markdown from 3.10.2 to 3.10.3 (#42859)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-07 09:40:57 -07:00
fe06ebe796 feat(versioning): enable version history and capture by default (#42801)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:25:48 -03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f1411ccd6c chore(deps-dev): update pyathena requirement from <4,>=3.35.2 to >=3.35.4,<4 (#42862)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-07 06:19:46 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1770823f0a chore(deps-dev): bump eslint-plugin-jest-dom from 5.6.0 to 5.7.0 in /superset-frontend (#42868)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-07 06:18:31 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b7b873321d chore(deps-dev): bump baseline-browser-mapping from 2.11.7 to 2.11.8 in /superset-frontend (#42872)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-07 06:17:22 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d363cabec9 chore(deps): bump markdown-to-jsx from 9.9.0 to 9.10.1 in /superset-frontend (#42885)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-07 06:17:18 -07:00
120 changed files with 30264 additions and 9149 deletions
+2 -2
View File
@@ -64,7 +64,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -75,6 +75,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
category: "/language:${{matrix.language}}"
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: "temurin"
java-version: "11"
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
persist-credentials: false
submodules: recursive
- name: Setup Java
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: "temurin"
java-version: "11"
+1 -1
View File
@@ -76,7 +76,7 @@ jobs:
node-version-file: "./docs/.nvmrc"
- name: Setup Python
uses: ./.github/actions/setup-backend/
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: "zulu"
java-version: "21"
-5
View File
@@ -182,11 +182,6 @@ jobs:
docker run --rm $TAG bash -c \
"npm ls --all --package-lock-only --depth=0 --json > /dev/null"
- name: Build Plugins Packages
run: |
docker run --rm $TAG bash -c \
"npm run plugins:build"
test-storybook:
needs: frontend-build
if: needs.frontend-build.outputs.should-run == 'true'
+31 -2
View File
@@ -24,6 +24,8 @@ assists people when migrating to a new version.
## Next
- [42393](https://github.com/apache/superset/pull/42393): Exported dataset YAML now carries a `uuid` for each metric and column so that custom folder assignments (which reference metrics/columns by UUID) survive an import into another workspace. This affects any export bundle that contains datasets, not just a dataset export: chart, dashboard, database and full-asset exports all embed the same dataset YAML, so a dashboard exported from this release also fails to import into an older one even though no dataset was exported directly. As with `folders` and `currency_code_column`, the affected `datasets/` files fail schema validation (`Unknown field: uuid`) when imported into Superset releases that predate this change; regenerate or hand-edit exports for older targets in mixed-version fleets.
### Soft delete is on by default, and purging is live
`SOFT_DELETE` now ships **on** (`DEFAULT_FEATURE_FLAGS`), so deleting a
@@ -52,6 +54,33 @@ One caveat on turning soft delete back off: objects archived while it was on are
**resurrected** into normal listings, since the rows were never removed — an
emergency stop rather than a clean rollback.
### Version history is on by default
`VERSION_HISTORY` and `ENABLE_VERSIONING_CAPTURE` now both ship **on**. Every
save of a chart, dashboard, or dataset writes version rows, and the version
history panel appears on Explore and Dashboard pages. The two flip together
deliberately: a panel with capture off renders an empty "No history yet" that
misrepresents the entity as unchanged.
**What operators should expect:**
- **Storage growth.** Capture writes shadow rows per save, so the metadata
database grows with edit volume. The `version_history.prune_old_versions`
beat task removes rows whose transaction is older than
`SUPERSET_VERSION_HISTORY_RETENTION_DAYS` (default 30). A deployment that
replaces `CELERY_CONFIG` rather than inheriting it must carry both the
`superset.tasks.version_history_retention` import and the beat entry; a
startup warning names whichever is absent.
- **`PUT` responses change shape.** Entity updates now return populated
`old_version_uuid` / `new_version_uuid` fields and an `ETag` header, which
were null or absent while capture was off.
`ENABLE_VERSIONING_CAPTURE` is **retained permanently** as an operational
kill-switch — not removed with the rollout toggles. Setting it to a falsy value
stops capture within a restart, without a revert-and-redeploy. Unlike the
soft-delete toggle, turning it off is a clean stop: existing version rows remain
readable and no entity state is altered.
### Scheduled report execution now enforces one application deadline
Scheduled report (not alert) executions are now governed by a single
@@ -481,7 +510,7 @@ Entity version history (the `version_transaction` / `*_version` shadow tables th
|---|---|---|
| `SUPERSET_VERSION_HISTORY_RETENTION_DAYS` | `30` | Version rows whose owning `version_transaction.issued_at` is older than this many days are pruned. Each entity's live row (`end_transaction_id IS NULL`) is always preserved, as are the live rows of its children and associations; closed historical rows (including the baseline) age out. Set to `0` or a negative value to disable pruning. |
The task ships in the default `CeleryConfig` (both the `superset.tasks.version_history_retention` import and the beat entry). A deployment that overrides `CELERY_CONFIG` without the beat entry logs a startup warning. When the override explicitly defines `imports`, a missing retention module is also reported; an absent `imports` setting is not diagnosed because Celery may register tasks through `include`, autodiscovery, or worker startup imports. Retention only prunes whatever history exists — capture itself is gated separately by `ENABLE_VERSIONING_CAPTURE` (ships off).
The task ships in the default `CeleryConfig` (both the `superset.tasks.version_history_retention` import and the beat entry). A deployment that overrides `CELERY_CONFIG` without the beat entry logs a startup warning. When the override explicitly defines `imports`, a missing retention module is also reported; an absent `imports` setting is not diagnosed because Celery may register tasks through `include`, autodiscovery, or worker startup imports. Retention only prunes whatever history exists — capture itself is gated separately by `ENABLE_VERSIONING_CAPTURE`, which now ships on.
### Deletion retention (soft-deleted entities are eventually purged)
@@ -491,7 +520,7 @@ Purging is **live by default** (`SOFT_DELETE_PURGE_DRY_RUN=False`), so the reten
Deployments that replace the default `CELERY_CONFIG` must ensure workers register `superset.tasks.deletion_retention` and schedule the `deletion_retention.purge_soft_deleted` task themselves. The shipped Docker development config uses `imports` and includes both entries. While `SOFT_DELETE` is statically enabled, a missing beat entry logs a startup warning; when the override explicitly defines `imports`, a missing purge module is also reported.
Operators can immediately erase a specific entity for compliance (GDPR) via `superset deletion-retention force-purge --uuid <uuid>`; this applies legacy hard-delete semantics — a live chart referencing a force-purged dataset is left without a datasource until re-pointed (the chart is not modified), and it purges the named entity even when it was never soft-deleted. Every purge writes an immutable, content-free audit record to the new `purge_audit_log` table that survives the entity it names: the **scheduled** purge fails closed (an entity whose audit row cannot be written is skipped and retried next run), while **force-purge** proceeds even if the audit write fails — the operator is present and deletion outranks audit for a compliance erasure.
Operators can immediately erase a specific entity for compliance (GDPR) via `superset deletion-retention force-purge --uuid <uuid>`; this applies legacy hard-delete semantics — a live chart referencing a force-purged dataset is left without a datasource until re-pointed (the chart is not modified), and it purges the named entity even when it was never soft-deleted. Every scheduled evaluation writes a provisional, content-free record to the new `purge_audit_log` table before the cascade starts. Meaningful retained outcomes survive the entity they name. Consecutive scheduled evaluations with the same blocked outcome suppress only the redundant current provisional record; completed outcomes, outcome transitions, and every force-purge attempt remain independent and immutable. The **scheduled** purge fails closed when its provisional record cannot be written, while **force-purge** proceeds even if the audit write fails — the operator is present and deletion outranks audit for a compliance erasure. Operators can monitor `deletion_retention.blocked_audit_suppressed` and `deletion_retention.blocked_audit_dedupe_fallback` to verify suppression and fail-safe fallback behavior without changing the existing blocked-workload gauge.
### Recently Archived view and permanent delete (purge) endpoints
@@ -28,6 +28,7 @@ Alerts and reports are disabled by default. To turn them on, you'll need to chan
- Users can customize the email subject by including date code placeholders, which will automatically be replaced with the corresponding UTC date when the email is sent. To enable this functionality, activate the `"DATE_FORMAT_IN_EMAIL_SUBJECT"` [feature flag](/admin-docs/configuration/configuring-superset#feature-flags). This enables date formatting in email subjects, preventing all reporting emails from being grouped into the same thread (optional for the reporting feature).
- Use date codes from [strftime.org](https://strftime.org/) to create the email subject.
- If no date code is provided, the original string will be used as the email subject.
- Each alert/report has an "Include a link back to Superset" option (enabled by default) controlling whether the call-to-action link is included in email and Slack notifications. The link text in emails is configurable via `EMAIL_REPORTS_CTA`; the Slack message always uses "Explore in Superset". Uncheck the option when recipients should not receive a link to your Superset host, e.g. for external audiences.
##### Disable dry-run mode
@@ -315,6 +315,76 @@ Here's a concrete example:
WHERE country_code = 'US'
```
**Guest User Attributes**
The `{{ get_guest_user_attribute('attribute_name') }}` macro returns a specific attribute value from the guest user context.
This is useful when working with embedded Superset where guest tokens can contain custom attributes that need to be
accessed in SQL queries.
This macro only works when the current user is a guest user (authenticated via guest token). If the current user is
not a guest user, or if the specified attribute doesn't exist, the macro will return `None` or the provided default value.
If you have caching enabled in your Superset configuration, then by default the resolved value (whether it
came from the guest token, a null attribute, or the provided default) will be used by Superset when
calculating the cache key. A cache key is a unique identifier that determines if there's a cache hit in the
future and Superset can retrieve cached data. Including the resolved value on every branch ensures two guests
whose tokens render different SQL never share a cache entry.
You can disable the inclusion of the attribute value in the calculation of the
cache key by adding the following parameter to your Jinja code, but only do so
when the value cannot affect the query results:
```
{{ get_guest_user_attribute('department', add_to_cache_keys=False) }}
```
You can also provide a default value if the attribute is not found:
```
{{ get_guest_user_attribute('region', default='US') }}
```
Here's a concrete example of using guest user attributes in a query:
```sql
SELECT *
FROM sales_data
WHERE region = '{{ get_guest_user_attribute("user_region", default="global") }}'
AND department = '{{ get_guest_user_attribute("department") }}'
```
:::warning[Security Warning]
Guest token attributes come from the embedding application. By default,
`get_guest_user_attribute()` escapes string values — including strings nested inside
arrays and object values, and caller-supplied defaults — through the database dialect's
literal rendering (the same mechanism as `url_param()`). This covers dialect-specific
escape characters such as the backslash on MySQL/MariaDB, so the example above is safe
to interpolate directly. If you pass `escape_result=False`, or interpolate non-string
values (numbers, booleans), you are responsible for validating or allowlisting the
values, since they originate outside Superset.
If a guest attribute is an array and you plan to pipe it through the `|where_in` filter
(for example `full_name IN {{ get_guest_user_attribute('names')|where_in }}`), call
`get_guest_user_attribute('names', escape_result=False)`. `where_in` already applies its
own dialect-safe quoting, so escaping the values twice can corrupt them (a value such as
`O'Brien` would come back doubly escaped and match nothing).
Only individual string values are escaped as SQL literals. Interpolating an entire array
or object directly (rather than through `|where_in`, or by accessing a specific element)
renders Python's string form of that structure, which is not valid SQL, and object keys
are not escaped at all. Use `|where_in` for arrays, `|tojson` where you need a
JSON-stringified value, or read individual keys/elements out of the structure yourself.
The same double-escaping problem described above for `|where_in` applies to `|tojson`:
pass `escape_result=False` before piping to `|tojson` (for example
`{{ get_guest_user_attribute('profile', escape_result=False)|tojson }}`), since JSON
already handles its own quoting and re-escaping a value first would corrupt it (a nested
string such as `O'Brien` would come back as the altered `O''Brien` in the serialized
JSON).
:::
### Explicitly Including Values in Cache Key
The `{{ cache_key_wrapper() }}` function explicitly instructs Superset to add a value to the
+2 -2
View File
@@ -107,9 +107,9 @@
},
{
"name": "VERSION_HISTORY",
"default": false,
"default": true,
"lifecycle": "development",
"description": "Enables the version history panel on Explore and Dashboard pages. History only accrues while ``ENABLE_VERSIONING_CAPTURE`` is also on; with capture off the panel renders but stays empty."
"description": "Enables the version history panel on Explore and Dashboard pages. History only accrues while ``ENABLE_VERSIONING_CAPTURE`` is also on; with capture off the panel renders but stays empty, so the two ship with matching defaults and should be changed together."
}
],
"testing": [
+7
View File
@@ -11383,6 +11383,13 @@
},
"User3": {
"properties": {
"attributes": {
"additionalProperties": {
"nullable": true
},
"nullable": true,
"type": "object"
},
"first_name": {
"type": "string"
},
+6 -6
View File
@@ -8048,9 +8048,9 @@ domhandler@^5.0.2, domhandler@^5.0.3:
domelementtype "^2.3.0"
dompurify@^3.3.3, dompurify@^3.4.12:
version "3.4.12"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.12.tgz#6fa2265e9bbdce882c4ace4107626051b448ffa8"
integrity sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==
version "3.4.13"
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.13.tgz#fc28949d59f92d62e28a3a764bcbeee35897a1be"
integrity sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==
optionalDependencies:
"@types/trusted-types" "^2.0.7"
@@ -11737,9 +11737,9 @@ mz@^2.7.0:
thenify-all "^1.0.0"
nanoid@^3.3.16:
version "3.3.16"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c"
integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==
version "3.3.18"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913"
integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==
natural-compare@^1.4.0:
version "1.4.0"
+20 -15
View File
@@ -68,19 +68,19 @@ dependencies = [
# not just a connection-pool quirk. Needs dedicated investigation, not a
# driver-compat-prep bump; revisit alongside the actual SQLAlchemy 2.0
# core bump (discussion #40273, step 6).
"flask-sqlalchemy>=2.5.1, <3.0",
"flask-sqlalchemy>=2.5.1, <4.0",
"flask-wtf>=1.3.0, <2.0",
"geopy",
"greenlet<=3.5.4, >=3.5.4",
"gunicorn>=26.0.0, <27; sys_platform != 'win32'",
"hashids>=1.3.1, <2",
# holidays>=0.45 required for security fix
"holidays>=0.99, <1",
"holidays>=0.101, <1",
"humanize",
"isodate",
"jsonpath-ng>=1.8.0, <2",
"Mako>=1.2.2",
"markdown>=3.10.2",
"markdown>=3.10.3",
# marshmallow 4 compatibility: see superset/marshmallow_compatibility.py for a
# Flask-AppBuilder workaround. Tracking issue:
# https://github.com/apache/superset/issues/33162
@@ -88,7 +88,7 @@ dependencies = [
"marshmallow-union>=0.1.15.post1",
"msgpack>=1.2.0, <1.3",
"nh3>=0.3.5, <0.4",
"numpy>1.23.5, <2.3",
"numpy>=1.23.5, <2.5",
"packaging",
# --------------------------
# pandas and related (wanting pandas[performance] without numba as it's 100+MB and not needed)
@@ -108,10 +108,10 @@ dependencies = [
"pyarrow>=24.0.0, <26", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
"pyyaml>=6.0.3, <7.0.0",
"PyJWT>=2.4.0, <3.0",
"redis>=5.0.0, <9.0",
"redis>=8.1.0, <9.0",
"rison>=2.0.1, <3.0",
"selenium>=4.46.0, <5.0",
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
"shillelagh[gsheetsapi]>=1.4.5, <2.0",
"sshtunnel>=0.4.0, <0.5",
"simplejson>=4.1.1",
"slack_sdk>=3.43.0, <4",
@@ -131,7 +131,7 @@ dependencies = [
[project.optional-dependencies]
athena = ["pyathena[pandas]>=3.35.2, <4"]
athena = ["pyathena[pandas]>=3.35.4, <4"]
# No SQLAlchemy 2.0 support anywhere in this dialect's ecosystem today: our
# own preset-io/sqlalchemy-aurora-data-api fork is dead since 2021, and the
# more active community fork (cloud-utils/sqlalchemy-aurora-data-api) has an
@@ -143,7 +143,7 @@ bigquery = [
# 1.17.1 is likely the final release: googleapis/python-bigquery-sqlalchemy
# was archived 2026-05-16. Both 1.17.0 and 1.17.1 support SQLAlchemy 1.4/2.0.
"sqlalchemy-bigquery>=1.17.1",
"google-cloud-bigquery>=3.42.2",
"google-cloud-bigquery>=3.42.3",
]
clickhouse = ["clickhouse-connect>=1.6.0, <2.0"]
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
@@ -204,7 +204,7 @@ fastmcp = [
firebird = ["sqlalchemy-firebird>=0.8.0, <2.0.0"]
firebolt = ["firebolt-sqlalchemy>=1.1.2, <2"]
gevent = ["gevent>=26.7.0"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.5, <2"]
hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"]
hive = [
"pyhive[hive_pure_sasl]>=0.7.0",
@@ -234,7 +234,7 @@ ocient = [
oracle = ["oracledb>=4.0.2, <5"]
parseable = ["sqlalchemy-parseable>=0.1.6,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <10.0.0"]
playwright = ["playwright>=1.61.0, <2"]
playwright = ["playwright>=1.62.0, <2"]
postgres = ["psycopg2-binary==2.9.12"]
presto = ["pyhive[presto]>=0.6.5"]
trino = ["trino>=0.338.0"]
@@ -248,8 +248,8 @@ redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
# and 2.0 (version numbers don't track SQLAlchemy compat monotonically); pin
# to the newest 1.4-only release for now. Bump to >=2.0.0 in lockstep with
# Superset's own SQLAlchemy 2.0 core bump (discussion #40273), not before.
risingwave = ["sqlalchemy-risingwave>=1.4.1, <2.0.0"]
shillelagh = ["shillelagh[all]>=1.4.4, <2"]
risingwave = ["sqlalchemy-risingwave>=1.4.1, <3.0.0"]
shillelagh = ["shillelagh[all]>=1.4.5, <2"]
singlestore = ["sqlalchemy-singlestoredb>=1.2.1, <2"]
snowflake = ["snowflake-sqlalchemy>=1.11.0, <2"]
sqlite = ["syntaqlite>=0.7.0,<0.8.0"]
@@ -262,11 +262,11 @@ tdengine = [
"taospy>=2.8.10",
"taos-ws-py>=0.7.0"
]
teradata = ["teradatasql>=20.0.0.63"]
teradata = ["teradatasql>=20.0.0.64"]
thumbnails = [] # deprecated, will be removed in 7.0
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
netezza = ["nzalchemy>= 11.1.2, < 11.2"]
starrocks = ["starrocks>=1.3.3, <2"]
starrocks = ["starrocks>=1.3.4, <2"]
doris = ["pydoris>=1.2.0, <2.0.0"]
oceanbase = ["oceanbase_py>=0.0.1.2"]
ydb = ["ydb-sqlalchemy>=0.1.22", "ydb-sqlglot-plugin>=0.2.8"]
@@ -286,7 +286,7 @@ development = [
"progress>=1.6.1,<2",
"psutil",
"pyfakefs",
"pyinstrument>=5.1.2,<6",
"pyinstrument>=5.1.3,<6",
"pylint",
"pytest<10.0.0", # cap below the next major until validated; the earlier <8 pin (current_app proxy timing) no longer reproduces
"pytest-asyncio",
@@ -535,6 +535,11 @@ authorized_licenses = [
# Seems ok, might need legal review
# https://github.com/urschrei/pypolyline/blob/master/LICENSE.md
polyline = "2"
# NumPy 2.x reports a combined SPDX license expression covering vendored
# code (BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0), all of which
# are permissive OSI-approved licenses; the package itself remains
# BSD-3-Clause. https://github.com/numpy/numpy/blob/main/LICENSE.txt
numpy = "2"
# --------------------------------------------------------------
# TODO REMOVE THESE DEPS FROM CODEBASE
+5 -5
View File
@@ -178,7 +178,7 @@ h11==0.16.0
# via wsproto
hashids==1.3.1
# via apache-superset (pyproject.toml)
holidays==0.100
holidays==0.102
# via apache-superset (pyproject.toml)
humanize==4.12.3
# via apache-superset (pyproject.toml)
@@ -219,7 +219,7 @@ mako==1.3.12
# -r requirements/base.in
# apache-superset (pyproject.toml)
# alembic
markdown==3.10.2
markdown==3.10.3
# via apache-superset (pyproject.toml)
markdown-it-py==3.0.0
# via rich
@@ -251,7 +251,7 @@ nh3==0.3.6
# via apache-superset (pyproject.toml)
numexpr==2.10.2
# via -r requirements/base.in
numpy==1.26.4
numpy==2.4.6
# via
# apache-superset (pyproject.toml)
# bottleneck
@@ -357,7 +357,7 @@ pyyaml==6.0.3
# via
# apache-superset (pyproject.toml)
# apispec
redis==8.0.1
redis==8.1.0
# via apache-superset (pyproject.toml)
referencing==0.36.2
# via
@@ -384,7 +384,7 @@ selenium==4.46.0
# via apache-superset (pyproject.toml)
setuptools==80.9.0
# via -r requirements/base.in
shillelagh==1.4.4
shillelagh==1.4.5
# via apache-superset (pyproject.toml)
simplejson==4.1.1
# via apache-superset (pyproject.toml)
+7 -7
View File
@@ -363,7 +363,7 @@ google-auth-oauthlib==1.2.1
# via
# pandas-gbq
# pydata-google-auth
google-cloud-bigquery==3.42.2
google-cloud-bigquery==3.43.0
# via
# apache-superset
# pandas-gbq
@@ -408,7 +408,7 @@ hashids==1.3.1
# via
# -c requirements/base-constraint.txt
# apache-superset
holidays==0.100
holidays==0.102
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -521,7 +521,7 @@ mako==1.3.12
# -c requirements/base-constraint.txt
# alembic
# apache-superset
markdown==3.10.2
markdown==3.10.3
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -581,7 +581,7 @@ nh3==0.3.6
# apache-superset
nodeenv==1.8.0
# via pre-commit
numpy==1.26.4
numpy==2.4.6
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -776,7 +776,7 @@ pygments==2.20.0
# rich
pyhive==0.7.0
# via apache-superset
pyinstrument==5.1.2
pyinstrument==5.1.3
# via apache-superset
pyjwt==2.13.0
# via
@@ -874,7 +874,7 @@ pyyaml==6.0.3
# fastmcp-slim
# jsonschema-path
# pre-commit
redis==8.0.1
redis==8.1.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -948,7 +948,7 @@ setuptools==80.9.0
# pydata-google-auth
# zope-event
# zope-interface
shillelagh==1.4.4
shillelagh==1.4.5
# via
# -c requirements/base-constraint.txt
# apache-superset
+523 -1722
View File
File diff suppressed because it is too large Load Diff
+8 -12
View File
@@ -65,7 +65,6 @@
"ensure-oxc": "echo 'OXC linter is ready' && npx oxlint --version",
"lint-stats": "node ./scripts/oxlint-metrics-uploader.js",
"plugins:build": "node ./scripts/build.js",
"plugins:build-assets": "node ./scripts/copyAssets.js",
"plugins:create-conventional-version": "npm run prune && lerna version --conventional-commits --create-release github --no-private --yes --tag-version-prefix=\"plugins-and-packages-v\"",
"plugins:create-minor-version": "npm run prune && lerna version minor --no-private --yes --tag-version-prefix=\"plugins-and-packages-v\"",
"plugins:create-patch-version": "npm run prune && lerna version patch --no-private --yes --tag-version-prefix=\"plugins-and-packages-v\"",
@@ -167,8 +166,6 @@
"dom-to-image-more": "^3.10.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.4.0",
"fuse.js": "^7.5.0",
"geolib": "^3.3.14",
"geostyler": "^18.6.0",
@@ -176,7 +173,7 @@
"geostyler-openlayers-parser": "^5.7.1",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^10.9.1",
"google-auth-library": "^11.0.0",
"immer": "^11.1.15",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -186,7 +183,7 @@
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"mapbox-gl": "^3.27.0",
"markdown-to-jsx": "^9.9.0",
"markdown-to-jsx": "^9.10.1",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
"mousetrap": "^1.6.5",
@@ -203,7 +200,7 @@
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^18.3.0",
"react-google-recaptcha": "^3.1.0",
"react-intersection-observer": "^10.1.0",
"react-intersection-observer": "^11.0.0",
"react-json-tree": "^0.20.0",
"react-lines-ellipsis": "^0.16.1",
"react-loadable": "^5.5.0",
@@ -216,7 +213,7 @@
"react-table": "^7.8.0",
"react-transition-group": "^4.4.5",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "^1.8.10",
"react-window": "^2.3.0",
"redux": "^4.2.1",
"redux-localstorage": "^0.4.1",
"redux-thunk": "^2.1.0",
@@ -255,7 +252,7 @@
"@emotion/jest": "^11.14.2",
"@formatjs/intl-durationformat": "^0.10.18",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.61.1",
"@playwright/test": "^1.62.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.5",
"@storybook/addon-links": "10.5.5",
@@ -284,7 +281,6 @@
"@types/react-redux": "^7.1.10",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-window": "^1.8.8",
"@types/redux-localstorage": "^1.0.8",
"@types/redux-mock-store": "^1.0.6",
"@types/rison": "0.1.0",
@@ -296,7 +292,7 @@
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
"baseline-browser-mapping": "^2.11.7",
"baseline-browser-mapping": "^2.11.8",
"cheerio": "1.2.0",
"concurrently": "^10.0.4",
"copy-webpack-plugin": "^14.0.0",
@@ -308,7 +304,7 @@
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jest-dom": "^5.6.0",
"eslint-plugin-jest-dom": "^5.7.0",
"eslint-plugin-lodash": "^8.0.0",
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
@@ -327,7 +323,7 @@
"jest-websocket-mock": "^2.5.0",
"js-yaml-loader": "^1.2.2",
"jsdom": "^30.0.1",
"lerna": "^9.0.4",
"lerna": "^10.0.0",
"lightningcss": "^1.33.0",
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.6.1",
@@ -20,8 +20,7 @@
import { dirname, join } from 'path';
// @ts-ignore -- yeoman-test type resolution differs between local and Docker environments
import helpers from 'yeoman-test';
// @ts-ignore -- fs-extra/esm has no type declarations
import { copySync } from 'fs-extra/esm';
import { cpSync } from 'fs';
import { fileURLToPath } from 'url';
import pluginChartModule from '../generators/plugin-chart';
@@ -31,9 +30,10 @@ test('generator-superset:plugin-chart:creates files', async () => {
.onTargetDirectory((dir: string) => {
// `dir` is the path to the new temporary directory
const generatorDirname = dirname(fileURLToPath(import.meta.url));
copySync(
cpSync(
join(generatorDirname, '../generators/plugin-chart/templates'),
join(dir, 'unknown/templates'),
{ recursive: true },
);
})
.withPrompts({
@@ -109,7 +109,6 @@
"@types/lodash": "^4.17.24",
"@types/react": "*",
"@types/react-loadable": "*",
"@types/react-window": "^1.8.8",
"@types/tinycolor2": "*",
"typescript": "^5.0.0"
},
@@ -36,6 +36,7 @@ import type {
QueryResponse,
TimeFormatter,
} from '@superset-ui/core';
import { type RGBColor } from '@superset-ui/core/components';
import { GenericDataType } from '@apache-superset/core/common';
import { sharedControls, sharedControlComponents } from './shared-controls';
@@ -494,7 +495,7 @@ export type ConditionalFormattingConfig = {
targetValueLeft?: number;
targetValueRight?: number;
column?: string;
colorScheme?: string;
colorScheme?: RGBColor | string;
toAllRow?: boolean;
toTextColor?: boolean;
useGradient?: boolean;
@@ -19,7 +19,7 @@
import memoizeOne from 'memoize-one';
import { isString, isBoolean } from 'lodash-es';
import { isBlank } from '@apache-superset/core/utils';
import { addAlpha, DataRecord } from '@superset-ui/core';
import { addAlpha, DataRecord, rgbaToHex } from '@superset-ui/core';
import tinycolor from 'tinycolor2';
import {
ColorFormatters,
@@ -27,6 +27,7 @@ import {
ConditionalFormattingConfig,
MultipleValueComparators,
ResolvedColorFormatterResult,
ColorSchemeEnum,
} from '../types';
export const round = (num: number, precision = 0) =>
@@ -71,6 +72,9 @@ export const getOpacity = (
);
};
const isSpecialColor = (value: unknown): value is ColorSchemeEnum =>
Object.values(ColorSchemeEnum).includes(value as ColorSchemeEnum);
export const getColorFunction = (
{
operator,
@@ -270,19 +274,51 @@ export const getColorFunction = (
if (compareResult === false) return undefined;
const { cutoffValue, extremeValue } = compareResult;
// If useGradient is explicitly false, return solid color
if (useGradient === false) {
if (typeof colorScheme === 'string') {
if (isSpecialColor(colorScheme)) {
return colorScheme;
}
if (
useGradient === false ||
(useGradient === undefined && colorScheme.length === 9)
) {
if (alpha === false) {
return colorScheme.length === 9
? colorScheme.slice(0, 7)
: colorScheme;
}
return colorScheme;
}
const cleanHex =
colorScheme.length === 9 ? colorScheme.slice(0, 7) : colorScheme;
if (alpha === undefined || alpha) {
return addAlpha(
cleanHex,
getOpacity(value, cutoffValue, extremeValue, minOpacity, maxOpacity),
);
}
return colorScheme;
}
// If useGradient is explicitly false, return solid color
if (useGradient === false || useGradient === undefined) {
if (alpha === false) {
return rgbaToHex({ ...colorScheme, a: 1 });
}
return rgbaToHex(colorScheme);
}
const baseHexColor = rgbaToHex({ ...colorScheme, a: 1 });
// Otherwise apply gradient (default behavior for backward compatibility)
if (alpha === undefined || alpha) {
return addAlpha(
colorScheme,
baseHexColor,
getOpacity(value, cutoffValue, extremeValue, minOpacity, maxOpacity),
);
}
return colorScheme;
return baseHexColor;
};
};
@@ -952,3 +952,167 @@ test('correct column boolean config', () => {
expect(colorFormatters[3].getColorFromValue(true)).toEqual('#FF0000FF');
expect(colorFormatters[3].getColorFromValue(false)).toEqual('#FF0000FF');
});
test('should return hex color when colorScheme is an RGB object', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: { r: 255, g: 128, b: 0, a: 1 },
column: 'name',
},
strValues,
);
expect(colorFunction('Diana')).toEqual('#ff8000');
expect(colorFunction('Carlos')).toEqual('#ff8000');
expect(colorFunction('Brian')).toEqual('#ff8000');
});
test('should return token name as-is when colorScheme is a string token', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: 'Green',
column: 'name',
},
strValues,
);
expect(colorFunction('Diana')).toEqual('Green');
expect(colorFunction('Carlos')).toEqual('Green');
expect(colorFunction('Brian')).toEqual('Green');
});
test('should return solid hex color when useGradient is false or true', () => {
const columnConfig = [
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: { r: 0, g: 47, b: 255, a: 1 },
column: 'count',
useGradient: false,
},
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: { r: 255, g: 166, b: 0, a: 1 },
column: 'count',
useGradient: true,
},
];
const colorFormatters = getColorFormatters(columnConfig, mockData);
expect(colorFormatters.length).toEqual(2);
// First formatter with useGradient: false should return solid color
expect(colorFormatters[0].column).toEqual('count');
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#002fff');
// Second formatter with useGradient: true should return gradient color
expect(colorFormatters[1].column).toEqual('count');
expect(colorFormatters[1].getColorFromValue(100)).toEqual('#ffa600FF');
});
test('should return hex color without alpha for GreaterThan operator with RGB colorScheme', () => {
const config = {
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: { r: 255, g: 0, b: 0, a: 1 },
useGradient: true,
};
const columnValues = [10, 50, 100];
const alpha = false;
const colorFunction = getColorFunction(config, columnValues, alpha);
expect(colorFunction(100)).toEqual('#ff0000');
});
test('should preserve alpha from colorScheme when useGradient is false', () => {
const config = {
operator: Comparator.None,
colorScheme: { r: 255, g: 0, b: 0, a: 0.5 },
useGradient: false,
};
const colorFunction = getColorFunction(config, [10, 20, 30]);
const result = colorFunction(20);
expect(result).not.toBe('#ff0000');
expect(result).not.toBe('rgb(255, 0, 0)');
});
test('should force opaque color when useGradient is false but alpha is explicitly false', () => {
const config = {
operator: Comparator.None,
colorScheme: { r: 255, g: 0, b: 0, a: 0.5 },
useGradient: false,
};
const colorFunction = getColorFunction(config, [10, 20, 30], false);
const result = colorFunction(20);
expect(result).toBe('#ff0000');
});
test('should return colorScheme as-is when alpha is false and length is 7', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
useGradient: false,
column: 'count',
},
countValues,
false,
);
expect(colorFunction(100)).toEqual('#FF0000');
});
test('should preserve alpha when alpha is undefined and colorScheme has 9 chars', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF000080',
useGradient: false,
column: 'count',
},
countValues,
);
expect(colorFunction(100)).toEqual('#FF000080');
});
test('should preserve alpha when alpha is true and colorScheme has 9 chars', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF000080',
useGradient: false,
column: 'count',
},
countValues,
true,
);
expect(colorFunction(100)).toEqual('#FF000080');
});
test('should strip alpha channel when alpha is false and colorScheme has 9 chars', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF000080',
useGradient: false,
column: 'count',
},
countValues,
false,
);
expect(colorFunction(100)).toEqual('#FF0000');
expect(colorFunction(100)).toHaveLength(7);
});
@@ -68,7 +68,7 @@
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.12",
"dompurify": "^3.4.13",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",
@@ -123,7 +123,6 @@
"@testing-library/user-event": "*",
"@types/react": "*",
"@types/react-loadable": "*",
"@types/react-window": "^1.8.8",
"@types/tinycolor2": "*",
"antd": "^6.0.0",
"nanoid": "*",
@@ -17,6 +17,7 @@
* under the License.
*/
import tinycolor from 'tinycolor2';
import { type RGBColor } from '@superset-ui/core/components';
const rgbRegex = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/;
export function getContrastingColor(color: string, thresholds = 186) {
@@ -120,3 +121,45 @@ export function rgbToHex(red: number, green: number, blue: number) {
return `#${r}${g}${b}`;
}
export function rgbaToHex(rgb: RGBColor): string {
const { r, g, b, a = 1 } = rgb;
const clampChannel = (value: number) =>
Math.min(255, Math.max(0, Math.round(value)));
const clampAlpha = (value: number) => Math.min(1, Math.max(0, value));
const toHex = (value: number) => {
const hex = value.toString(16);
return hex.length === 1 ? `0${hex}` : hex;
};
const hexColor = `#${toHex(clampChannel(r))}${toHex(clampChannel(g))}${toHex(clampChannel(b))}`;
const clampedAlpha = clampAlpha(a);
if (clampedAlpha !== 1) {
return `${hexColor}${toHex(Math.round(clampedAlpha * 255))}`;
}
return hexColor;
}
export const forceHexAlpha = (color: string | RGBColor): string => {
if (typeof color === 'object' && color !== null) {
return rgbaToHex({ ...color, a: 0.6 });
}
let hex = color.startsWith('#') ? color : `#${color}`;
// Expand shorthand hex (#rgb, #rgba) to full length before appending or
// replacing the alpha channel, otherwise the result is not a valid 6- or
// 8-digit CSS hex color.
if (hex.length === 4 || hex.length === 5) {
hex = `#${hex
.slice(1)
.split('')
.map(char => char + char)
.join('')}`;
}
if (hex.length === 9) {
return `${hex.slice(0, -2)}99`;
}
return `${hex}99`;
};
@@ -0,0 +1,122 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, waitFor } from '@superset-ui/core/spec';
import type { ColumnsType } from 'antd/es/table';
import { Table } from './index';
// These tests exercise VirtualTable's react-window v2 `Grid` wiring
// (`cellComponent`/`cellProps`/`gridRef`), which previously had no direct
// coverage - `Table.test.tsx` only exercises the non-virtualized code path.
interface BasicData {
columnName: string;
columnType: string;
}
const testData: BasicData[] = [
{ columnName: 'Number', columnType: 'Numerical' },
{ columnName: 'String', columnType: 'Physical' },
{ columnName: 'Date', columnType: 'Virtual' },
];
const testColumns: ColumnsType<BasicData> = [
{
title: 'Column Name',
dataIndex: 'columnName',
key: 'columnName',
width: 150,
},
{
title: 'Column Type',
dataIndex: 'columnType',
key: 'columnType',
width: 150,
},
];
test('virtualized table renders headers and row content through the react-window Grid', async () => {
render(
<Table
columns={testColumns}
data={testData}
virtualize
height={200}
usePagination={false}
/>,
);
await waitFor(() =>
testColumns.forEach(column =>
expect(
screen
.getAllByText(column.title as string)
.find(el => el.closest('th')),
).toBeInTheDocument(),
),
);
testData.forEach(row => {
expect(screen.getByText(row.columnName)).toBeInTheDocument();
});
});
test('virtualized table cells keep the DOM hooks other code (cypress, downloadAsImage) relies on', async () => {
const { container } = render(
<Table
columns={testColumns}
data={testData}
virtualize
height={200}
usePagination={false}
/>,
);
await waitFor(() => {
expect(container.querySelector('.virtual-grid')).toBeInTheDocument();
expect(
container.querySelectorAll('.virtual-table-cell').length,
).toBeGreaterThan(0);
});
});
test('cell render functions receive their row data via cellProps rather than a stale closure', async () => {
const columnsWithRender: ColumnsType<BasicData> = [
{
title: 'Column Name',
dataIndex: 'columnName',
key: 'columnName',
width: 150,
render: (value: string) => `rendered:${value}`,
},
];
render(
<Table
columns={columnsWithRender}
data={testData}
virtualize
height={200}
usePagination={false}
/>,
);
await waitFor(() => {
expect(screen.getByText('rendered:Number')).toBeInTheDocument();
});
});
@@ -24,10 +24,14 @@ import {
} from 'antd/es/table';
import classNames from 'classnames';
import { useResizeDetector } from 'react-resize-detector';
import { useEffect, useRef, useState, useCallback, CSSProperties } from 'react';
import { VariableSizeGrid as Grid } from 'react-window';
import { useRef, useState, useCallback, type UIEvent } from 'react';
import {
Grid,
type CellComponentProps,
type GridImperativeAPI,
} from 'react-window';
import { safeHtmlSpan } from '@superset-ui/core';
import { useTheme, styled } from '@apache-superset/core/theme';
import { useTheme, styled, SupersetTheme } from '@apache-superset/core/theme';
import { TableSize, ETableAction } from './index';
@@ -71,6 +75,59 @@ const StyledTable = styled(AntTable)(
const SMALL = 39;
const MIDDLE = 47;
interface VirtualGridCellProps {
mergedColumns: AntTableProps<any>['columns'];
rawData: readonly object[];
cellSize: number;
allowHTML: boolean;
theme: SupersetTheme;
}
// Rendered via `cellComponent`, so it must be a stable reference (module scope)
// rather than defined inline on every render of the enclosing table -
// otherwise react-window would treat it as a new component type each render
// and remount every cell. All the data it needs is threaded through
// `cellProps` instead of being closed over.
const VirtualGridCell = ({
columnIndex,
rowIndex,
style,
mergedColumns,
rawData,
cellSize,
allowHTML,
theme,
}: CellComponentProps<VirtualGridCellProps>) => {
const data: any = rawData?.[rowIndex];
// Set default content
let content = data?.[(mergedColumns as any)?.[columnIndex]?.dataIndex];
// Check if the column has a render function
const render = mergedColumns?.[columnIndex]?.render;
if (typeof render === 'function') {
// Use render function to generate formatted content using column's render function
content = render(content, data, rowIndex);
}
if (allowHTML && typeof content === 'string') {
content = safeHtmlSpan(content);
}
return (
<StyledCell
className={classNames('virtual-table-cell', {
'virtual-table-cell-last':
columnIndex === (mergedColumns?.length ?? 0) - 1,
})}
style={style}
title={typeof content === 'string' ? content : undefined}
theme={theme}
height={cellSize}
>
{content}
</StyledCell>
);
};
const VirtualTable = <RecordType extends object>(
props: VirtualTableProps<RecordType>,
) => {
@@ -126,19 +183,15 @@ const VirtualTable = <RecordType extends object>(
(lastColumn.width as number) + Math.floor(tableWidth - totalWidth);
}
const gridRef = useRef<any>();
const gridRef = useRef<GridImperativeAPI>(null);
const [connectObject] = useState<any>(() => {
const obj = {};
Object.defineProperty(obj, 'scrollLeft', {
get: () => {
if (gridRef.current) {
return gridRef.current?.state?.scrollLeft;
}
return 0;
},
get: () => gridRef.current?.element?.scrollLeft ?? 0,
set: (scrollLeft: number) => {
if (gridRef.current) {
gridRef.current.scrollTo({ scrollLeft });
const element = gridRef.current?.element;
if (element) {
element.scrollLeft = scrollLeft;
}
},
});
@@ -146,14 +199,11 @@ const VirtualTable = <RecordType extends object>(
return obj;
});
const resetVirtualGrid = () => {
gridRef.current?.resetAfterIndices({
columnIndex: 0,
shouldForceUpdate: true,
});
};
useEffect(() => resetVirtualGrid, [tableWidth, columns, size]);
// No manual cache-reset is needed here (react-window v2 has no
// `resetAfterIndices`-style API): `columnWidth` below is a fresh inline
// closure over `mergedColumns` on every render, so react-window's internal
// size cache - which is invalidated whenever the `columnWidth`/`rowHeight`
// function reference changes - recomputes automatically.
/*
* antd Table has a runtime error when it tries to fire the onChange event triggered from a pageChange
@@ -166,7 +216,10 @@ const VirtualTable = <RecordType extends object>(
* We intentionally leave horizontal scroll where it was so user can focus on
* specific range of columns as they page through data
*/
gridRef.current?.scrollTo?.({ scrollTop: 0 });
const element = gridRef.current?.element;
if (element) {
element.scrollTop = 0;
}
onChange?.(
{
@@ -192,61 +245,31 @@ const VirtualTable = <RecordType extends object>(
const cellSize = size === TableSize.Middle ? MIDDLE : SMALL;
return (
<Grid
ref={gridRef}
gridRef={gridRef}
className="virtual-grid"
columnCount={mergedColumns.length}
columnWidth={(index: number) => {
const { width = DEFAULT_COL_WIDTH } = mergedColumns[index];
return width as number;
}}
height={height || (scroll!.y as number)}
rowCount={rawData.length}
rowHeight={() => cellSize}
width={tableWidth}
onScroll={({ scrollLeft }: { scrollLeft: number }) => {
onScroll({ scrollLeft });
style={{
height: height || (scroll!.y as number),
width: tableWidth,
}}
>
{({
columnIndex,
rowIndex,
style,
}: {
columnIndex: number;
rowIndex: number;
style: CSSProperties;
}) => {
const data: any = rawData?.[rowIndex];
// Set default content
let content =
data?.[(mergedColumns as any)?.[columnIndex]?.dataIndex];
// Check if the column has a render function
const render = mergedColumns[columnIndex]?.render;
if (typeof render === 'function') {
// Use render function to generate formatted content using column's render function
content = render(content, data, rowIndex);
}
if (allowHTML && typeof content === 'string') {
content = safeHtmlSpan(content);
}
return (
<StyledCell
className={classNames('virtual-table-cell', {
'virtual-table-cell-last':
columnIndex === mergedColumns.length - 1,
})}
style={style}
title={typeof content === 'string' ? content : undefined}
theme={theme}
height={cellSize}
>
{content}
</StyledCell>
);
cellComponent={VirtualGridCell}
cellProps={{
mergedColumns,
rawData,
cellSize,
allowHTML,
theme,
}}
</Grid>
onScroll={(event: UIEvent<HTMLDivElement>) => {
onScroll({ scrollLeft: event.currentTarget.scrollLeft });
}}
/>
);
};
@@ -22,6 +22,8 @@ import {
addAlpha,
hexToRgb,
rgbToHex,
rgbaToHex,
forceHexAlpha,
} from '@superset-ui/core';
describe('color utils', () => {
@@ -106,4 +108,51 @@ describe('color utils', () => {
expect(rgbToHex(0, 0, 0)).toBe('#000000');
});
});
describe('rgbaToHex', () => {
test('omits the alpha channel for opaque colors', () => {
expect(rgbaToHex({ r: 255, g: 0, b: 0 })).toBe('#ff0000');
expect(rgbaToHex({ r: 255, g: 0, b: 0, a: 1 })).toBe('#ff0000');
});
test('appends the alpha channel for translucent colors', () => {
expect(rgbaToHex({ r: 0, g: 150, b: 0, a: 0.2 })).toBe('#00960033');
expect(rgbaToHex({ r: 0, g: 0, b: 0, a: 0.5 })).toBe('#00000080');
});
test('fully transparent colors keep an explicit 00 alpha', () => {
expect(rgbaToHex({ r: 255, g: 255, b: 255, a: 0 })).toBe('#ffffff00');
});
test('zero-pads single-digit channels', () => {
expect(rgbaToHex({ r: 1, g: 2, b: 3 })).toBe('#010203');
});
test('rounds fractional channel values', () => {
expect(rgbaToHex({ r: 254.6, g: 0.4, b: 0 })).toBe('#ff0000');
});
test('clamps out-of-range channel and alpha values', () => {
expect(rgbaToHex({ r: 300, g: -10, b: 0 })).toBe('#ff0000');
expect(rgbaToHex({ r: 0, g: 0, b: 0, a: 1.5 })).toBe('#000000');
expect(rgbaToHex({ r: 0, g: 0, b: 0, a: -0.5 })).toBe('#00000000');
});
});
describe('forceHexAlpha', () => {
test('appends 60% alpha to a 6-digit hex string', () => {
expect(forceHexAlpha('#ff0000')).toBe('#ff000099');
});
test('adds the # prefix when missing', () => {
expect(forceHexAlpha('ff0000')).toBe('#ff000099');
});
test('replaces the existing alpha on an 8-digit hex string', () => {
expect(forceHexAlpha('#ff000033')).toBe('#ff000099');
});
test('converts an RGBColor object using 60% alpha', () => {
expect(forceHexAlpha({ r: 255, g: 0, b: 0 })).toBe('#ff000099');
});
test('overrides the alpha of a translucent RGBColor object', () => {
expect(forceHexAlpha({ r: 0, g: 150, b: 0, a: 0.2 })).toBe('#00960099');
});
test('expands a shorthand 3-digit hex string before adding alpha', () => {
expect(forceHexAlpha('#fff')).toBe('#ffffff99');
});
test('expands a shorthand 4-digit hex string before replacing alpha', () => {
expect(forceHexAlpha('#ff03')).toBe('#ffff0099');
});
});
});
@@ -707,12 +707,8 @@ const config: ControlPanelConfig = {
const extraColorChoices = hasTimeComparison
? [
{
value: ColorSchemeEnum.Green,
label: t('Green for increase, red for decrease'),
},
{
value: ColorSchemeEnum.Red,
label: t('Red for increase, green for decrease'),
label: t('Trend colors'),
colors: [ColorSchemeEnum.Green, ColorSchemeEnum.Red],
},
]
: [];
@@ -154,12 +154,8 @@ test('extraColorChoices included when time comparison is enabled', () => {
expect(result.extraColorChoices).toEqual([
{
value: ColorSchemeEnum.Green,
label: expect.stringContaining('Green for increase'),
},
{
value: ColorSchemeEnum.Red,
label: expect.stringContaining('Red for increase'),
label: expect.stringContaining('Trend colors'),
colors: [ColorSchemeEnum.Green, ColorSchemeEnum.Red],
},
]);
expect(result.columnOptions).not.toEqual(
@@ -296,7 +296,13 @@ function Echart(
}
: {};
// ECharts' built-in ARIA descriptions are off by default so behavior
// doesn't change for existing deployments; a theme or chart's options
// can opt in (or further customize aria handling) by overriding this.
const ariaDefault = { aria: { enabled: false } };
const themedEchartOptions = mergeEchartsThemeOverrides(
ariaDefault,
baseTheme,
echartOptions,
globalOverrides,
@@ -30,7 +30,11 @@ import {
TimeFormats,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { getColorFormatters } from '@superset-ui/chart-controls';
import {
ColorSchemeEnum,
ConditionalFormattingConfig,
getColorFormatters,
} from '@superset-ui/chart-controls';
import { DateFormatter, PivotTableQueryFormData, QueryData } from '../types';
import buildGroupbyCombinations, {
additiveReducerFor,
@@ -206,8 +210,17 @@ export default function transformProps(chartProps: ChartProps<QueryFormData>) {
},
{},
);
// The "Green"/"Red" trend-color tokens are resolved by the Table chart's
// own comparison-aware formatter, which this renderer does not implement.
// Filter them out so a stale config (e.g. carried over from switching viz
// types) doesn't leak the raw token name through as a literal CSS color.
const pivotConditionalFormatting = conditionalFormatting?.filter(
(config: ConditionalFormattingConfig) =>
config.colorScheme !== ColorSchemeEnum.Green &&
config.colorScheme !== ColorSchemeEnum.Red,
);
const metricColorFormatters = getColorFormatters(
conditionalFormatting,
pivotConditionalFormatting,
mainQuery.data,
theme,
);
@@ -46,6 +46,7 @@ import {
BinaryQueryObjectFilterClause,
extractTextFromHTML,
TimeGranularity,
forceHexAlpha,
} from '@superset-ui/core';
import {
styled,
@@ -1094,7 +1095,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
formatter.objectFormatting === ObjectFormattingEnum.CELL_BAR
) {
if (generalShowCellBars)
backgroundColorCellBar = formatterResult.slice(0, -2);
backgroundColorCellBar = forceHexAlpha(formatterResult);
} else {
backgroundColor = formatterResult;
valueRangeFlag = false;
@@ -1182,7 +1183,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
alignPositiveNegative,
})}%`};
background-color: ${
(backgroundColorCellBar && `${backgroundColorCellBar}99`) ||
backgroundColorCellBar ||
cellBackground({
value: value as number,
colorPositiveNegative,
@@ -757,12 +757,8 @@ const config: ControlPanelConfig = {
const extraColorChoices = hasTimeComparison
? [
{
value: ColorSchemeEnum.Green,
label: t('Green for increase, red for decrease'),
},
{
value: ColorSchemeEnum.Red,
label: t('Red for increase, green for decrease'),
label: t('Trend colors'),
colors: [ColorSchemeEnum.Green, ColorSchemeEnum.Red],
},
]
: [];
@@ -774,6 +770,7 @@ const config: ControlPanelConfig = {
(item: ConditionalFormattingConfig, index, array) => {
if (
item.colorScheme &&
typeof item.colorScheme === 'string' &&
!['Green', 'Red'].includes(item.colorScheme)
) {
if (item.columnFormatting === undefined) {
@@ -752,8 +752,15 @@ const transformProps = (
const basicColorFormatters =
comparisonColorEnabled && getBasicColorFormatter(baseQuery?.data, columns);
const columnColorFormatters =
getColorFormatters(conditionalFormatting, passedData, theme) ??
defaultColorFormatters;
getColorFormatters(
(conditionalFormatting || []).filter(
(config: ConditionalFormattingConfig) =>
config.colorScheme !== ColorSchemeEnum.Green &&
config.colorScheme !== ColorSchemeEnum.Red,
),
passedData,
theme,
) ?? defaultColorFormatters;
const basicColorColumnFormatters = getBasicColorFormatterForColumn(
baseQuery?.data,
@@ -156,12 +156,8 @@ test('extraColorChoices included when time comparison is enabled', () => {
expect(result.extraColorChoices).toEqual([
{
value: ColorSchemeEnum.Green,
label: expect.stringContaining('Green for increase'),
},
{
value: ColorSchemeEnum.Red,
label: expect.stringContaining('Red for increase'),
label: expect.stringContaining('Trend colors'),
colors: [ColorSchemeEnum.Green, ColorSchemeEnum.Red],
},
]);
expect(result.columnOptions).not.toEqual(
@@ -350,6 +350,51 @@ test('getLayer coerces free-form string radius values to numbers', () => {
expect(props.pointRadiusScale).toBe(0.25);
});
// Regression for https://github.com/apache/superset/issues/34748: pins the
// consumer/render boundary (recurseGeoJson feeding GeoJsonLayer's `data`),
// complementing the transformProps.test.ts coverage of the query->feature step.
test('getLayer forwards every feature from transformProps output to GeoJsonLayer', () => {
const multiRowPayload = {
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [2.92, 47.38] },
properties: {},
},
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [2.93, 47.39] },
properties: {},
},
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [2.94, 47.4] },
properties: {},
},
],
},
};
const layer = getLayer({
...baseLayerArgs,
formData: baseFormData,
payload: multiRowPayload,
});
expect(layer.props.data).toHaveLength(3);
expect(
(layer.props.data as Array<{ geometry: { coordinates: number[] } }>).map(
f => f.geometry.coordinates,
),
).toEqual([
[2.92, 47.38],
[2.93, 47.39],
[2.94, 47.4],
]);
});
type ControlConfig = {
default?: unknown;
validators?: unknown[];
@@ -0,0 +1,85 @@
/**
* 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, DatasourceType } from '@superset-ui/core';
import transformProps from './transformProps';
// Regression for https://github.com/apache/superset/issues/34748: reporters
// on 5.0.0/6.0.0 found that only a single row's GeoJSON Feature ever
// rendered on the map, no matter how many rows the query returned (setting
// the row limit to 1 made no visible difference).
test('transformProps builds a feature for every row, not just the first', () => {
const mockChartProps: Partial<ChartProps> = {
rawFormData: {
geojson: 'geometry',
viewport: {},
},
queriesData: [
{
data: [
{
geometry: JSON.stringify({
type: 'Feature',
geometry: { type: 'Point', coordinates: [2.92, 47.38] },
properties: {},
}),
},
{
geometry: JSON.stringify({
type: 'Feature',
geometry: { type: 'Point', coordinates: [2.93, 47.39] },
properties: {},
}),
},
{
geometry: JSON.stringify({
type: 'Feature',
geometry: { type: 'Point', coordinates: [2.94, 47.4] },
properties: {},
}),
},
],
},
],
datasource: {
type: DatasourceType.Table,
id: 1,
name: 'test_datasource',
columns: [],
metrics: [],
},
height: 400,
width: 600,
hooks: {},
filterState: {},
emitCrossFilters: false,
};
const result = transformProps(mockChartProps as ChartProps);
const features = result.payload.data.features as Array<{
geometry: { coordinates: number[] };
}>;
expect(features).toHaveLength(3);
expect(features.map(f => f.geometry.coordinates)).toEqual([
[2.92, 47.38],
[2.93, 47.39],
[2.94, 47.4],
]);
});
+11 -14
View File
@@ -26,7 +26,7 @@
process.env.PATH = `./node_modules/.bin:${process.env.PATH}`;
const { spawnSync } = require('child_process');
const fastGlob = require('fast-glob');
const { globSync } = require('fs');
const yargs = require('yargs');
const { hideBin } = require('yargs/helpers');
@@ -60,12 +60,11 @@ function getPackages(packagePattern, tsOnly = false) {
// Find packages in both @superset-ui and @apache-superset scopes
const supersetUiPackages = [
...new Set(
fastGlob
.sync([
`./node_modules/@superset-ui/${pattern}/src/**/*.${
tsOnly ? '{ts,tsx}' : '{ts,tsx,js,jsx}'
}`,
])
globSync([
`./node_modules/@superset-ui/${pattern}/src/**/*.${
tsOnly ? '{ts,tsx}' : '{ts,tsx,js,jsx}'
}`,
])
.map(x => x.split('/')[3])
.filter(x => !META_PACKAGES.has(x)),
),
@@ -73,13 +72,11 @@ function getPackages(packagePattern, tsOnly = false) {
const apachePackages = [
...new Set(
fastGlob
.sync([
`./node_modules/@apache-superset/${pattern}/src/**/*.${
tsOnly ? '{ts,tsx}' : '{ts,tsx,js,jsx}'
}`,
])
.map(x => x.split('/')[3]),
globSync([
`./node_modules/@apache-superset/${pattern}/src/**/*.${
tsOnly ? '{ts,tsx}' : '{ts,tsx,js,jsx}'
}`,
]).map(x => x.split('/')[3]),
),
];
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env node
/*
* 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.
*/
/* eslint-disable no-console */
const { sync } = require('fast-glob');
const { copy } = require('fs-extra');
const pkgGlob = process.argv[2] || '*';
const packages = sync([`{packages,plugins}/${pkgGlob}`], {
onlyDirectories: true,
});
console.log('Copying asset files from package {src} to {lib,esm}...');
packages.forEach(pkg => {
const assets = sync([`${pkg}/src/**/*.{png,gif,jpg,css,geojson}`]);
assets.forEach(filePath => {
['lib', 'esm']
.map(dir => filePath.replace(`${pkg}/src`, `${pkg}/${dir}`))
.forEach(newFilePath => {
copy(filePath, newFilePath, err => {
if (err) {
console.error(err);
}
});
});
});
if (assets.length > 0) {
console.log(
` Copied ${assets.length
.toString()
.padStart(2)} asset files for ${pkg.replace(
'packages/superset-ui-',
'',
)}`,
);
}
});
@@ -18,7 +18,6 @@
*/
import {
CSSProperties,
ReactNode,
useCallback,
useEffect,
@@ -44,7 +43,7 @@ import {
Icons,
} from '@superset-ui/core/components';
import { debounce } from 'lodash-es';
import { FixedSizeList as List } from 'react-window';
import { List, type RowComponentProps } from 'react-window';
import { InputRef } from 'antd';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
@@ -53,6 +52,34 @@ import { Dataset } from '../types';
const SUBMENU_HEIGHT = 200;
const SHOW_COLUMNS_SEARCH_THRESHOLD = 10;
interface DrillByColumnRowProps {
columns: Column[];
onSelectColumn: (event: React.MouseEvent, column: Column) => void;
}
// Rendered via `rowComponent`, so it must be a stable reference (module
// scope) rather than defined inline on every render of the submenu -
// otherwise react-window would treat it as a new component type each
// render and remount every row. All the data it needs is threaded
// through `rowProps` instead of being closed over.
function DrillByColumnRow({
index,
style,
columns,
onSelectColumn,
}: RowComponentProps<DrillByColumnRowProps>) {
const column = columns[index];
return (
<VirtualizedMenuItem
tooltipText={column.verbose_name || column.column_name}
onClick={e => onSelectColumn(e, column)}
style={style}
>
{column.verbose_name || column.column_name}
</VirtualizedMenuItem>
);
}
export interface DrillBySubmenuProps {
drillByConfig?: ContextMenuFilters['drillBy'];
formData: BaseFormData & { [key: string]: any };
@@ -190,26 +217,9 @@ export const DrillBySubmenu = ({
const isDisabled = !handlesDimensionContextMenu || !hasDrillBy;
const Row = ({
index,
data,
style,
}: {
index: number;
data: { columns: Column[] };
style: CSSProperties;
}) => {
const { columns } = data;
const column = columns[index];
return (
<VirtualizedMenuItem
tooltipText={column.verbose_name || column.column_name}
onClick={e => handleSelection(e, column)}
style={style}
>
{column.verbose_name || column.column_name}
</VirtualizedMenuItem>
);
const listRowProps: DrillByColumnRowProps = {
columns: filteredColumns,
onSelectColumn: handleSelection,
};
const popoverContent = (
@@ -260,15 +270,13 @@ export const DrillBySubmenu = ({
</div>
) : filteredColumns.length ? (
<List
width="100%"
height={SUBMENU_HEIGHT}
itemSize={35}
itemCount={filteredColumns.length}
itemData={{ columns: filteredColumns }}
style={{ width: '100%', height: SUBMENU_HEIGHT }}
rowHeight={35}
rowCount={filteredColumns.length}
rowProps={listRowProps}
rowComponent={DrillByColumnRow}
overscanCount={20}
>
{Row}
</List>
/>
) : (
<div
css={css`
@@ -45,28 +45,26 @@ jest.mock(
children({ height: 500, width: 400 }),
);
// Mock react-window VariableSizeList to render all items for testing
// Mock react-window List to render all items for testing
jest.mock('react-window', () => ({
VariableSizeList: ({
children: Row,
itemCount,
itemData,
List: ({
rowComponent: Row,
rowCount,
rowProps,
}: {
children: React.ComponentType<{
index: number;
style: React.CSSProperties;
data: unknown;
}>;
itemCount: number;
itemData: unknown;
rowComponent: React.ComponentType<
{ index: number; style: React.CSSProperties } & Record<string, unknown>
>;
rowCount: number;
rowProps: Record<string, unknown>;
}) => (
<div data-testid="virtualized-list">
{Array.from({ length: itemCount }, (_, index) => (
{Array.from({ length: rowCount }, (_, index) => (
<Row
key={index}
index={index}
style={{ height: 'auto', position: 'relative' }}
data={itemData}
{...rowProps}
/>
))}
</div>
@@ -18,7 +18,7 @@
*/
import { CSSProperties, memo } from 'react';
import type { ListChildComponentProps } from 'react-window';
import type { RowComponentProps } from 'react-window';
import { useDroppable } from '@dnd-kit/core';
import type { UniqueIdentifier } from '@dnd-kit/core';
import type { Metric, ColumnMeta } from '@superset-ui/chart-controls';
@@ -135,32 +135,34 @@ const TreeItemWrapper = memo(function TreeItemWrapper({
);
});
function VirtualizedTreeItemComponent({
// Not wrapped in `memo()` here: react-window v2 already wraps `rowComponent`
// in its own `memo()` internally (comparing `ariaAttributes`/`style`/the
// rest of the row props), so an outer `memo()` would be redundant - and
// React 19's `memo()` typings widen the wrapped component's return type to
// `ReactNode`, which react-window v2's `rowComponent` type (expecting
// `ReactElement | null`) rejects.
export function VirtualizedTreeItem({
index,
style,
data,
}: ListChildComponentProps<VirtualizedTreeItemData>) {
const {
flattenedItems,
collapsedIds,
selectedItemIds,
editingFolderId,
folderChildCounts,
itemSeparatorInfo,
visibleItemIds,
searchTerm,
metricsMap,
columnsMap,
activeId,
draggedFolderChildIds,
forbiddenDropFolderIds,
currentDropTargetId,
onToggleCollapse,
onSelect,
onStartEdit,
onFinishEdit,
} = data;
flattenedItems,
collapsedIds,
selectedItemIds,
editingFolderId,
folderChildCounts,
itemSeparatorInfo,
visibleItemIds,
searchTerm,
metricsMap,
columnsMap,
activeId,
draggedFolderChildIds,
forbiddenDropFolderIds,
currentDropTargetId,
onToggleCollapse,
onSelect,
onStartEdit,
onFinishEdit,
}: RowComponentProps<VirtualizedTreeItemData>) {
const item = flattenedItems[index];
if (!item) {
@@ -224,5 +226,3 @@ function VirtualizedTreeItemComponent({
/>
);
}
export const VirtualizedTreeItem = memo(VirtualizedTreeItemComponent);
@@ -17,8 +17,8 @@
* under the License.
*/
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { VariableSizeList as List } from 'react-window';
import { useCallback, useMemo, useRef } from 'react';
import { List, type ListImperativeAPI } from 'react-window';
import type { UniqueIdentifier } from '@dnd-kit/core';
import type { Metric, ColumnMeta } from '@superset-ui/chart-controls';
import { FoldersEditorItemType } from '../types';
@@ -82,7 +82,7 @@ export function VirtualizedTreeList({
onStartEdit,
onFinishEdit,
}: VirtualizedTreeListProps) {
const listRef = useRef<List>(null);
const listRef = useRef<ListImperativeAPI>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Custom auto-scroll during drag (replaces dnd-kit's auto-scroll which conflicts with virtualization)
@@ -93,20 +93,15 @@ export function VirtualizedTreeList({
listHeight: height,
});
// Reset list cache when items structure changes, but not during drag
// Resetting during drag causes jumping/flickering
useEffect(() => {
if (!isDragging) {
listRef.current?.resetAfterIndex(0);
}
}, [
flattenedItems,
collapsedIds,
folderChildCounts,
itemSeparatorInfo,
visibleItemIds,
isDragging,
]);
// No manual cache-reset is needed here (react-window v2 has no
// `resetAfterIndex`-style API): `getItemSize` below is a `useCallback`
// whose dependency list matches everything that can change row heights,
// so react-window's internal size cache - which is invalidated whenever
// the `rowHeight` function reference changes - recomputes automatically.
// Because `flattenedItems` is held stable during drag (see
// useDragHandlers), `getItemSize`'s reference - and therefore the cache -
// naturally stays stable during drag too, preserving the old "don't
// reset while dragging" behavior without needing an explicit gate.
// Calculate item size for react-window
const getItemSize = useCallback(
@@ -169,7 +164,7 @@ export function VirtualizedTreeList({
);
// Prepare item data for the row renderer
const itemData: VirtualizedTreeItemData = useMemo(
const rowProps: VirtualizedTreeItemData = useMemo(
() => ({
flattenedItems,
collapsedIds,
@@ -218,16 +213,14 @@ export function VirtualizedTreeList({
return (
<div ref={containerRef} style={{ width, height, position: 'relative' }}>
<List
ref={listRef}
width={width}
height={height}
itemCount={flattenedItems.length}
itemSize={getItemSize}
itemData={itemData}
listRef={listRef}
style={{ width, height }}
rowCount={flattenedItems.length}
rowHeight={getItemSize}
rowProps={rowProps}
rowComponent={VirtualizedTreeItem}
overscanCount={overscanCount}
>
{VirtualizedTreeItem}
</List>
/>
</div>
);
}
@@ -18,7 +18,7 @@
*/
import { useEffect, useRef } from 'react';
import type { VariableSizeList as List } from 'react-window';
import type { ListImperativeAPI } from 'react-window';
// Distance from edge where auto-scroll activates (in pixels)
const SCROLL_THRESHOLD = 80;
@@ -28,7 +28,7 @@ const BASE_SCROLL_SPEED = 8;
const MAX_SPEED_MULTIPLIER = 3;
interface UseAutoScrollOptions {
listRef: React.RefObject<List>;
listRef: React.RefObject<ListImperativeAPI | null>;
containerRef: React.RefObject<HTMLDivElement>;
isDragging: boolean;
listHeight: number;
@@ -110,7 +110,7 @@ export function useAutoScroll({
// Animation frame callback - uses time-based scrolling for consistent speed
const scrollFrame = (currentTime: number) => {
const list = listRef.current;
const outerElement = (list as any)?._outerRef;
const outerElement = list?.element;
if (!list || !outerElement || !state.direction) {
// Restore pointer events when scrolling stops
@@ -149,7 +149,7 @@ export function useAutoScroll({
if (Math.abs(newScroll - currentScroll) > 0.5) {
// Use direct DOM manipulation for smoother scrolling
// react-window's scrollTo triggers re-renders which can cause stutter
// react-window's scrollToRow triggers re-renders which can cause stutter
outerElement.scrollTop = newScroll;
}
@@ -0,0 +1,135 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ErrorLevel, ErrorSource, ErrorTypeEnum } from '@superset-ui/core';
import { render, screen } from 'spec/helpers/testing-library';
import { DatasourceSecurityAccessErrorMessage } from './DatasourceSecurityAccessErrorMessage';
jest.mock(
'@superset-ui/core/components/Icons/AsyncIcon',
() =>
({ fileName }: { fileName: string }) => (
<img alt={fileName.replace('_', '-')} />
),
);
const baseProps = {
error: {
error_type: ErrorTypeEnum.DATASOURCE_SECURITY_ACCESS_ERROR,
extra: {
datasource: 12,
datasource_name: 'Quarterly Sales',
owners: ['Jane Doe', 'Bob Smith'],
link: 'https://access.example.com/request?dataset=12',
issue_codes: [{ code: 1017, message: 'Permission issue' }],
},
level: 'error' as ErrorLevel,
message:
'This endpoint requires the datasource 12, database or ' +
'`all_datasource_access` permission',
},
source: 'dashboard' as ErrorSource,
subtitle: '',
};
test('shows a friendly title and names the dataset', () => {
render(<DatasourceSecurityAccessErrorMessage {...baseProps} />);
expect(
screen.getByText("You don't have access to this chart's data"),
).toBeInTheDocument();
expect(screen.getByText(/Quarterly Sales/)).toBeInTheDocument();
});
test('surfaces the chart owners to contact', () => {
render(<DatasourceSecurityAccessErrorMessage {...baseProps} />);
expect(
screen.getByText(/reach out to the chart owners: Jane Doe, Bob Smith/),
).toBeInTheDocument();
});
test('renders a Request access link to the configured URL', () => {
render(<DatasourceSecurityAccessErrorMessage {...baseProps} />);
const link = screen.getByRole('link', { name: 'Request access' });
expect(link).toHaveAttribute(
'href',
'https://access.example.com/request?dataset=12',
);
});
test('falls back to administrator guidance when no owners are known', () => {
const props = {
...baseProps,
error: {
...baseProps.error,
extra: { datasource_name: 'Quarterly Sales' },
},
};
render(<DatasourceSecurityAccessErrorMessage {...props} />);
expect(
screen.getByText(/contact your Superset administrator/),
).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Request access' })).toBeNull();
});
test('explains table access for TABLE_SECURITY_ACCESS_ERROR', () => {
const props = {
...baseProps,
error: {
...baseProps.error,
error_type: ErrorTypeEnum.TABLE_SECURITY_ACCESS_ERROR,
extra: { tables: ['public.sales', 'public.users'] },
},
};
render(<DatasourceSecurityAccessErrorMessage {...props} />);
expect(screen.getByText(/public.sales, public.users/)).toBeInTheDocument();
});
test('renders plainly when the error carries no access payload', () => {
// DATASOURCE_SECURITY_ACCESS_ERROR is reused for virtual-dataset SQL
// validation ("Only SELECT statements are allowed"); without an access
// payload the component must not show request-access guidance.
const props = {
...baseProps,
error: {
...baseProps.error,
extra: null,
message: 'Only `SELECT` statements are allowed',
},
};
render(<DatasourceSecurityAccessErrorMessage {...props} />);
expect(
screen.getByText(/Only `SELECT` statements are allowed/),
).toBeInTheDocument();
expect(screen.queryByText(/Request access/)).toBeNull();
expect(screen.queryByText(/access to this chart's data/)).toBeNull();
});
test('uses query wording for the sqllab source', () => {
const props = {
...baseProps,
source: 'sqllab' as ErrorSource,
};
render(<DatasourceSecurityAccessErrorMessage {...props} />);
expect(
screen.getByText(/This query uses the "Quarterly Sales"/),
).toBeInTheDocument();
expect(
screen.getByText("You don't have access to this data"),
).toBeInTheDocument();
});
@@ -0,0 +1,159 @@
/**
* 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 { ReactNode } from 'react';
import { t, tn } from '@apache-superset/core/translation';
import { Typography } from '@superset-ui/core/components';
import type { ErrorMessageComponentProps } from './types';
import { IssueCode } from './IssueCode';
import { ErrorAlert } from './ErrorAlert';
interface DatasourceSecurityAccessExtra {
owners?: string[];
link?: string;
datasource?: number | string;
datasource_name?: string;
tables?: string[];
issue_codes?: {
code: number;
message: string;
}[];
}
/**
* Shown when a viewer opens a chart but lacks permission to its underlying data
* (DATASOURCE_SECURITY_ACCESS_ERROR / TABLE_SECURITY_ACCESS_ERROR). Surfaces a
* plain-language explanation, who to contact, and a "Request access" link when
* the deployment configures PERMISSION_INSTRUCTIONS_LINK.
*/
export function DatasourceSecurityAccessErrorMessage({
error,
source,
closable,
compact,
}: ErrorMessageComponentProps<DatasourceSecurityAccessExtra | null>) {
const { extra, level, message } = error;
const isVisualization = ['dashboard', 'explore'].includes(source || '');
// DATASOURCE_SECURITY_ACCESS_ERROR is also raised for non-access failures
// (e.g. virtual-dataset SQL validation: "Only SELECT statements are
// allowed"). Those errors carry no access payload — render them plainly
// rather than misleading the user with request-access guidance.
const isAccessDenial = !!extra?.datasource_name || !!extra?.tables?.length;
if (!isAccessDenial) {
return (
<ErrorAlert
errorType={t('Unexpected error')}
message={message}
type={level}
closable={closable}
compact={compact}
/>
);
}
let explanation: string;
if (extra?.datasource_name) {
explanation = isVisualization
? t(
'This chart uses the "%s" dataset, which you do not have ' +
'permission to view.',
extra.datasource_name,
)
: t(
'This query uses the "%s" dataset, which you do not have ' +
'permission to view.',
extra.datasource_name,
);
} else {
explanation = isVisualization
? t(
'You do not have access to the data behind this chart ' +
'(tables: %s).',
extra?.tables?.join(', '),
)
: t(
'You do not have access to the following tables: %s.', // sqllab
extra?.tables?.join(', '),
);
}
const owners = extra?.owners;
const ownerLine =
isVisualization && owners && owners.length > 0
? tn(
'To request access, reach out to the chart owner: %s.',
'To request access, reach out to the chart owners: %s.',
owners.length,
owners.join(', '),
)
: t('To request access, contact your Superset administrator.');
// Actionable guidance stays visible (not collapsed): who to contact + link.
const description: ReactNode = (
<>
<div>{ownerLine}</div>
{extra?.link && (
<Typography.Link
href={extra.link}
target="_blank"
rel="noopener noreferrer"
>
{t('Request access')}
</Typography.Link>
)}
</>
);
// Technical detail is collapsible: issue codes and the raw backend message.
const hasIssueCodes = !!extra?.issue_codes && extra.issue_codes.length > 0;
const descriptionDetails: ReactNode =
hasIssueCodes || message ? (
<>
{hasIssueCodes && (
<p>
{t('This may be triggered by:')}
<br />
{extra?.issue_codes?.flatMap((issueCode, idx, arr) => [
<IssueCode {...issueCode} key={issueCode.code} />,
idx < arr.length - 1 ? <br key={`br-${issueCode.code}`} /> : null,
])}
</p>
)}
{message && <pre>{message}</pre>}
</>
) : undefined;
return (
<ErrorAlert
errorType={
isVisualization
? t("You don't have access to this chart's data")
: t("You don't have access to this data")
}
message={explanation}
description={description}
descriptionDetails={descriptionDetails}
descriptionPre={false}
type={level}
closable={closable}
compact={compact}
/>
);
}
@@ -19,6 +19,7 @@
export { BasicErrorAlert } from './BasicErrorAlert';
export { DatabaseErrorMessage } from './DatabaseErrorMessage';
export { DatasetNotFoundErrorMessage } from './DatasetNotFoundErrorMessage';
export { DatasourceSecurityAccessErrorMessage } from './DatasourceSecurityAccessErrorMessage';
export { ErrorAlert } from './ErrorAlert';
export { ErrorMessageWithStackTrace } from './ErrorMessageWithStackTrace';
export { getErrorMessageComponentRegistry } from './getErrorMessageComponentRegistry';
+1
View File
@@ -154,6 +154,7 @@ export const DEFAULT_COMMON_BOOTSTRAP_DATA: CommonBootstrapData = {
},
extra_categorical_color_schemes: [],
extra_sequential_color_schemes: [],
extra_theme_tokens: [],
theme: {
default: {},
dark: {},
@@ -25,7 +25,11 @@ import {
import { DatasourceType } from '@superset-ui/core';
import { sliceEntitiesForDashboard as mockSliceEntities } from 'spec/fixtures/mockSliceEntities';
import { configureStore } from '@reduxjs/toolkit';
import SliceAdder, { SliceAdderProps, sortByComparator } from './SliceAdder';
import SliceAdder, {
SliceAdderProps,
sortByComparator,
SliceListRow,
} from './SliceAdder';
// Mock the Select component to avoid debounce issues
jest.mock('@superset-ui/core', () => ({
@@ -236,4 +240,54 @@ describe('SliceAdder', () => {
// Verify the internal state was updated by checking if new charts are available
expect(screen.getByRole('checkbox')).toBeInTheDocument();
});
// Covers the react-window v2 `rowComponent`/`rowProps` wiring: SliceAdder
// hoists its row renderer to module scope (see SliceAdder.tsx) so
// react-window doesn't remount every row on each render. This verifies
// that hoisted row renders correctly from the flattened rowProps react-window
// passes it (index/style plus the custom row props), rather than through
// closures over component state.
test('SliceListRow renders a chart card for the slice at the given index', () => {
const filteredSlices = Object.values(mockSliceEntities.slices);
render(
<SliceListRow
index={0}
style={{}}
ariaAttributes={{
role: 'listitem',
'aria-posinset': 1,
'aria-setsize': filteredSlices.length,
}}
filteredSlices={filteredSlices}
selectedSliceIdsSet={new Set()}
editMode={false}
/>,
{ useDnd: true, store: mockStore },
);
expect(screen.getByTestId('chart-card')).toBeInTheDocument();
expect(screen.getByText(filteredSlices[0].slice_name)).toBeInTheDocument();
});
test('SliceListRow renders the slice at the row index passed via rowProps, not index 0', () => {
const filteredSlices = Object.values(mockSliceEntities.slices);
render(
<SliceListRow
index={1}
style={{}}
ariaAttributes={{
role: 'listitem',
'aria-posinset': 2,
'aria-setsize': filteredSlices.length,
}}
filteredSlices={filteredSlices}
selectedSliceIdsSet={new Set()}
editMode={false}
/>,
{ useDnd: true, store: mockStore },
);
expect(screen.getByText(filteredSlices[1].slice_name)).toBeInTheDocument();
expect(
screen.queryByText(filteredSlices[0].slice_name),
).not.toBeInTheDocument();
});
});
@@ -19,7 +19,7 @@
/* eslint-env browser */
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList as List } from 'react-window';
import { List, type RowComponentProps } from 'react-window';
// @ts-expect-error
import { createFilter } from 'react-search-input';
import { t } from '@apache-superset/core/translation';
@@ -167,6 +167,71 @@ function getFilteredSortedSlices(
.sort(sortByComparator(sortBy));
}
export interface SliceListRowProps {
filteredSlices: Slice[];
selectedSliceIdsSet: Set<number>;
editMode: boolean;
}
// Rendered via `rowComponent`, so it must be a stable reference (module
// scope) rather than defined inline on every render of SliceAdder -
// otherwise react-window would treat it as a new component type each
// render and remount every row (losing in-flight drag state). All the
// data it needs is threaded through `rowProps` instead of being closed
// over.
export function SliceListRow({
index,
style,
filteredSlices,
selectedSliceIdsSet,
editMode,
}: RowComponentProps<SliceListRowProps>) {
const cellData = filteredSlices[index];
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
const type = CHART_TYPE;
const id = NEW_CHART_ID;
const meta = {
chartId: cellData.slice_id,
sliceName: cellData.slice_name,
};
return (
<DragDroppable
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={index}
depth={0}
disableDragDrop={isSelected}
editMode={editMode}
// we must use a custom drag preview within the List because
// it does not seem to work within a fixed-position container
useEmptyDragPreview
// List library expect style props here
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
sliceName={cellData.slice_name}
lastModified={cellData.changed_on_humanized}
visType={cellData.viz_type}
datasourceUrl={cellData.datasource_url}
datasourceName={cellData.datasource_name}
thumbnailUrl={cellData.thumbnail_url}
isSelected={isSelected}
/>
)}
</DragDroppable>
);
}
function SliceAdder({
fetchSlices,
updateSlices,
@@ -321,53 +386,8 @@ function SliceAdder({
[fetchSlices, searchTerm, sortBy],
);
const rowRenderer = useCallback(
({ index, style }: { index: number; style: React.CSSProperties }) => {
const cellData = filteredSlices[index];
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
const type = CHART_TYPE;
const id = NEW_CHART_ID;
const meta = {
chartId: cellData.slice_id,
sliceName: cellData.slice_name,
};
return (
<DragDroppable
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={index}
depth={0}
disableDragDrop={isSelected}
editMode={editMode}
// we must use a custom drag preview within the List because
// it does not seem to work within a fixed-position container
useEmptyDragPreview
// List library expect style props here
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
sliceName={cellData.slice_name}
lastModified={cellData.changed_on_humanized}
visType={cellData.viz_type}
datasourceUrl={cellData.datasource_url}
datasourceName={cellData.datasource_name}
thumbnailUrl={cellData.thumbnail_url}
isSelected={isSelected}
/>
)}
</DragDroppable>
);
},
const listRowProps = useMemo<SliceListRowProps>(
() => ({ filteredSlices, selectedSliceIdsSet, editMode }),
[filteredSlices, selectedSliceIdsSet, editMode],
);
@@ -448,14 +468,13 @@ function SliceAdder({
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
width={width}
height={height}
itemCount={filteredSlices.length}
itemSize={DEFAULT_CELL_HEIGHT}
itemKey={index => filteredSlices[index].slice_id}
>
{rowRenderer}
</List>
style={{ width, height }}
rowCount={filteredSlices.length}
rowHeight={DEFAULT_CELL_HEIGHT}
rowProps={listRowProps}
rowComponent={SliceListRow}
rowKey={(index, data) => data.filteredSlices[index].slice_id}
/>
)}
</AutoSizer>
</ChartList>
@@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { VariableSizeList as List } from 'react-window';
import { useCallback, useMemo, useState } from 'react';
import { List } from 'react-window';
import { FlattenedItem, Folder } from './types';
import DatasourcePanelItem from './DatasourcePanelItem';
@@ -98,7 +98,6 @@ export const DatasourceItems = ({
height,
folders,
}: DatasourceItemsProps) => {
const listRef = useRef<List>(null);
const [collapsedFolderIds, setCollapsedFolderIds] = useState<Set<string>>(
new Set(
folders.filter(folder => folder.isCollapsed).map(folder => folder.id),
@@ -122,17 +121,15 @@ export const DatasourceItems = ({
});
}, []);
useEffect(() => {
// reset the list cache when flattenedItems length changes to recalculate the heights
listRef.current?.resetAfterIndex(0);
}, [flattenedItems]);
const getItemSize = useCallback(
// No manual cache-reset is needed: `rowHeight` is a useCallback tied to
// `flattenedItems`, so react-window's internal size cache recomputes
// automatically whenever its reference changes.
const rowHeight = useCallback(
(index: number) => flattenedItems[index].height,
[flattenedItems],
);
const itemData = useMemo(
const rowProps = useMemo(
() => ({
flattenedItems,
folderMap,
@@ -151,15 +148,12 @@ export const DatasourceItems = ({
return (
<List
ref={listRef}
width={width - BORDER_WIDTH}
height={height}
itemSize={getItemSize}
itemCount={flattenedItems.length}
itemData={itemData}
style={{ width: width - BORDER_WIDTH, height }}
rowHeight={rowHeight}
rowCount={flattenedItems.length}
rowProps={rowProps}
rowComponent={DatasourcePanelItem}
overscanCount={5}
>
{DatasourcePanelItem}
</List>
/>
);
};
@@ -22,12 +22,12 @@ import {
} from 'src/explore/components/DatasourcePanel/fixtures';
import { screen, userEvent, render } from 'spec/helpers/testing-library';
import DatasourcePanelItem, {
DatasourcePanelItemProps,
DatasourcePanelItemRowProps,
} from './DatasourcePanelItem';
import { FoldersEditorItemType } from 'src/components/Datasource/types';
import { MetricItem, ColumnItem } from './types';
const mockData: DatasourcePanelItemProps['data'] = {
const mockData: DatasourcePanelItemRowProps = {
flattenedItems: [
{ type: 'header', depth: 0, folderId: '1', height: 50 },
...metrics.map((m, idx) => ({
@@ -82,11 +82,22 @@ const mockData: DatasourcePanelItemProps['data'] = {
collapsedFolderIds: new Set(),
};
const setup = (data: DatasourcePanelItemProps['data'] = mockData) =>
const setup = (data: DatasourcePanelItemRowProps = mockData) =>
render(
<>
{data.flattenedItems.map((_, index) => (
<DatasourcePanelItem index={index} data={data} style={{}} />
<DatasourcePanelItem
// eslint-disable-next-line react/no-array-index-key -- test fixture has no stable id
key={index}
index={index}
style={{}}
ariaAttributes={{
role: 'listitem',
'aria-posinset': index + 1,
'aria-setsize': data.flattenedItems.length,
}}
{...data}
/>
))}
</>,
{ useDnd: true, useRedux: true, initialState: { explore: {} } },
@@ -16,7 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { CSSProperties, ReactNode, useCallback } from 'react';
import { ReactNode, useCallback } from 'react';
import type { RowComponentProps } from 'react-window';
import { t } from '@apache-superset/core/translation';
import { useCSSTextTruncation } from '@superset-ui/core';
@@ -112,30 +113,26 @@ const Divider = styled.div`
`}
`;
export interface DatasourcePanelItemProps {
index: number;
style: CSSProperties;
data: {
flattenedItems: FlattenedItem[];
folderMap: Map<string, Folder>;
width: number;
onToggleCollapse: (folderId: string) => void;
collapsedFolderIds: Set<string>;
};
export interface DatasourcePanelItemRowProps {
flattenedItems: FlattenedItem[];
folderMap: Map<string, Folder>;
width: number;
onToggleCollapse: (folderId: string) => void;
collapsedFolderIds: Set<string>;
}
export type DatasourcePanelItemProps =
RowComponentProps<DatasourcePanelItemRowProps>;
const DatasourcePanelItem = ({
index,
style,
data,
flattenedItems,
folderMap,
width,
onToggleCollapse,
collapsedFolderIds,
}: DatasourcePanelItemProps) => {
const {
flattenedItems,
folderMap,
width,
onToggleCollapse,
collapsedFolderIds,
} = data;
const item = flattenedItems[index];
const theme = useTheme();
const [labelRef, labelIsTruncated] = useCSSTextTruncation<HTMLSpanElement>({
@@ -16,7 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import {
render,
screen,
userEvent,
waitFor,
} from 'spec/helpers/testing-library';
import {
CategoricalScheme,
getCategoricalSchemeRegistry,
@@ -28,67 +33,208 @@ const defaultProps = {
onChange: jest.fn(),
};
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('ColorPickerControl', () => {
beforeAll(() => {
getCategoricalSchemeRegistry()
.registerValue(
'test',
new CategoricalScheme({
id: 'test',
colors: ['#ff0000', '#00ff00', '#0000ff'],
}),
)
.setDefaultKey('test');
beforeAll(() => {
getCategoricalSchemeRegistry()
.registerValue(
'test',
new CategoricalScheme({
id: 'test',
colors: ['#ff0000', '#00ff00', '#0000ff'],
}),
)
.setDefaultKey('test');
});
beforeEach(() => {
jest.clearAllMocks();
});
test('renders a ColorPicker component', () => {
render(<ColorPickerControl {...defaultProps} />);
// AntD ColorPicker renders a trigger element with class
const colorPickerTrigger = document.querySelector(
'.ant-color-picker-trigger',
);
expect(colorPickerTrigger).toBeInTheDocument();
});
test('displays the correct color value', () => {
render(<ColorPickerControl {...defaultProps} />);
// The color should be displayed as hex #007A87 (uppercase in AntD)
expect(screen.getByText('#007A87')).toBeInTheDocument();
});
test('calls onChange with RGB values when color changes', async () => {
const onChange = jest.fn();
render(<ColorPickerControl {...defaultProps} onChange={onChange} />);
// Open the color picker
const colorPickerTrigger = document.querySelector(
'.ant-color-picker-trigger',
);
expect(colorPickerTrigger).toBeInTheDocument();
if (colorPickerTrigger) {
await userEvent.click(colorPickerTrigger);
}
// Note: Testing actual color selection in AntD ColorPicker would require more complex mocking
// as it uses complex internal components. The main functionality is covered by the component itself.
});
test('includes preset colors from the categorical scheme', () => {
render(<ColorPickerControl {...defaultProps} />);
// The component should have access to the preset colors from the registry
// This is tested by ensuring the component renders without errors with the presets
const colorPickerTrigger = document.querySelector(
'.ant-color-picker-trigger',
);
expect(colorPickerTrigger).toBeInTheDocument();
});
test('calls onChange with string key "Green" when resolveThemeTokens is true', async () => {
const onChange = jest.fn();
render(
<ColorPickerControl
{...defaultProps}
onChange={onChange}
resolveThemeTokens
presets={[{ label: 'Special Colors', colors: ['Green', 'Red'] }]}
/>,
);
const colorPickerTrigger = document.querySelector(
'.ant-color-picker-trigger',
);
expect(colorPickerTrigger).toBeInTheDocument();
await userEvent.click(colorPickerTrigger!);
await waitFor(() => {
expect(
document.querySelector('.ant-color-picker-presets-color'),
).toBeInTheDocument();
});
const presets = document.querySelectorAll('.ant-color-picker-presets-color');
const greenPreset = presets[0];
beforeEach(() => {
jest.clearAllMocks();
expect(greenPreset).toBeInTheDocument();
await userEvent.click(greenPreset);
expect(onChange).toHaveBeenCalledWith('Green');
});
test('calls onChange with RGB object when resolveThemeTokens is false', async () => {
const onChange = jest.fn();
render(
<ColorPickerControl
{...defaultProps}
onChange={onChange}
resolveThemeTokens={false}
outputFormat="hex"
presets={[{ label: 'Special Colors', colors: ['Green', 'Red'] }]}
/>,
);
const colorPickerTrigger = document.querySelector(
'.ant-color-picker-trigger',
);
expect(colorPickerTrigger).toBeInTheDocument();
await userEvent.click(colorPickerTrigger!);
await waitFor(() => {
expect(
document.querySelector('.ant-color-picker-presets-color'),
).toBeInTheDocument();
});
const presets = document.querySelectorAll('.ant-color-picker-presets-color');
const greenPreset = presets[0];
expect(greenPreset).toBeInTheDocument();
await userEvent.click(greenPreset);
expect(onChange).toHaveBeenCalledWith('#00960033');
});
test('renders a ColorPicker component', () => {
render(<ColorPickerControl {...defaultProps} />);
test('resolves colorSuccess theme token correctly when matching color is selected', async () => {
const onChange = jest.fn();
// AntD ColorPicker renders a trigger element with class
const colorPickerTrigger = document.querySelector(
'.ant-color-picker-trigger',
);
expect(colorPickerTrigger).toBeInTheDocument();
jest
.spyOn(require('@apache-superset/core/theme'), 'useTheme')
.mockReturnValue({
colors: {
colorSuccess: 'rgba(82, 196, 26, 1)',
},
});
render(
<ColorPickerControl
{...defaultProps}
onChange={onChange}
resolveThemeTokens
presets={[{ label: 'Theme Tokens', colors: ['colorSuccess'] }]}
/>,
);
const colorPickerTrigger = document.querySelector(
'.ant-color-picker-trigger',
);
expect(colorPickerTrigger).toBeInTheDocument();
await userEvent.click(colorPickerTrigger!);
await waitFor(() => {
expect(
document.querySelector('.ant-color-picker-presets-items'),
).toBeInTheDocument();
});
const successPreset = document.querySelector(
'.ant-color-picker-presets-color [style*="82, 196, 26"]',
) as HTMLElement | null;
test('displays the correct color value', () => {
render(<ColorPickerControl {...defaultProps} />);
expect(successPreset).toBeInTheDocument();
// The color should be displayed as hex #007A87 (uppercase in AntD)
expect(screen.getByText('#007A87')).toBeInTheDocument();
});
await userEvent.click(successPreset!);
expect(onChange).toHaveBeenCalledWith('colorSuccess');
});
test('calls onChange with RGB values when color changes', async () => {
const onChange = jest.fn();
render(<ColorPickerControl {...defaultProps} onChange={onChange} />);
test('handles theme with nested colors object', () => {
jest
.spyOn(require('@apache-superset/core/theme'), 'useTheme')
.mockReturnValue({
colors: { primary: '#007bff' },
});
// Open the color picker
const colorPickerTrigger = document.querySelector(
'.ant-color-picker-trigger',
);
expect(colorPickerTrigger).toBeInTheDocument();
const { container } = render(<ColorPickerControl {...defaultProps} />);
expect(
container.querySelector('.ant-color-picker-trigger'),
).toBeInTheDocument();
});
if (colorPickerTrigger) {
await userEvent.click(colorPickerTrigger);
}
test('handles theme without colors field', () => {
jest
.spyOn(require('@apache-superset/core/theme'), 'useTheme')
.mockReturnValue({
primary: '#007bff',
});
// Note: Testing actual color selection in AntD ColorPicker would require more complex mocking
// as it uses complex internal components. The main functionality is covered by the component itself.
});
const { container } = render(<ColorPickerControl {...defaultProps} />);
expect(
container.querySelector('.ant-color-picker-trigger'),
).toBeInTheDocument();
});
test('includes preset colors from the categorical scheme', () => {
render(<ColorPickerControl {...defaultProps} />);
test('handles undefined theme gracefully', () => {
jest
.spyOn(require('@apache-superset/core/theme'), 'useTheme')
.mockReturnValue(undefined);
// The component should have access to the preset colors from the registry
// This is tested by ensuring the component renders without errors with the presets
const colorPickerTrigger = document.querySelector(
'.ant-color-picker-trigger',
);
expect(colorPickerTrigger).toBeInTheDocument();
});
expect(() => render(<ColorPickerControl {...defaultProps} />)).not.toThrow();
});
@@ -16,70 +16,215 @@
* specific language governing permissions and limitations
* under the License.
*/
import { getCategoricalSchemeRegistry } from '@superset-ui/core';
import { useMemo } from 'react';
import { getCategoricalSchemeRegistry, rgbaToHex } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import {
ColorPicker,
type RGBColor,
type ColorValue,
} from '@superset-ui/core/components';
import ControlHeader from '../ControlHeader';
import { useTheme, type SupersetTheme } from '@apache-superset/core/theme';
const SPECIAL_COLORS = {
Red: { r: 150, g: 0, b: 0, a: 0.2 },
Green: { r: 0, g: 150, b: 0, a: 0.2 },
} as const;
type SpecialColorKey = keyof typeof SPECIAL_COLORS;
export type ColorPickerValue = RGBColor | SpecialColorKey | string;
export type ColorOutputFormat = 'hex' | 'rgb';
export interface ColorPickerControlProps {
onChange?: (color: RGBColor) => void;
value?: RGBColor;
onChange?: (color: ColorPickerValue) => void;
value?: ColorPickerValue;
name?: string;
label?: string;
description?: string;
renderTrigger?: boolean;
hovered?: boolean;
warning?: string;
presets?: { label: string; colors: string[] }[];
ariaLabel?: string;
resolveThemeTokens?: boolean;
outputFormat?: ColorOutputFormat;
}
function rgbToHex(rgb: RGBColor): string {
const { r, g, b, a = 1 } = rgb;
const toHex = (value: number) => {
const hex = Math.round(value).toString(16);
return hex.length === 1 ? `0${hex}` : hex;
};
const normalizeColorToHex = (color: string): string => {
if (!color) return '';
const hexColor = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
if (a !== undefined && a !== 1) {
return `${hexColor}${toHex(Math.round(a * 255))}`;
if (color.startsWith('#')) {
return color.toLowerCase();
}
return hexColor;
const div = document.createElement('div');
div.style.color = color;
const normalized = div.style.color || '';
const match = /^rgba?\((\d+),\s+(\d+),\s+(\d+)(?:,\s*([\d.]+))?\)$/.exec(
normalized,
);
if (match) {
return rgbaToHex({
r: parseInt(match[1], 10),
g: parseInt(match[2], 10),
b: parseInt(match[3], 10),
a: match[4] !== undefined ? parseFloat(match[4]) : 1,
}).toLowerCase();
}
return color.toLowerCase();
};
const getReverseThemeColorMap = (
themeColors: Record<string, string>,
): Map<string, string> => {
const reverseMap = new Map<string, string>();
if (!themeColors) return reverseMap;
Object.entries(themeColors).forEach(([name, value]) => {
if (typeof value === 'string') {
const hex = normalizeColorToHex(value);
if (!reverseMap.has(hex)) {
reverseMap.set(hex, name);
}
}
});
return reverseMap;
};
function toDisplayHex(
value: ColorPickerValue | undefined,
themeColors: Record<string, string>,
): string | undefined {
if (!value) return undefined;
if (typeof value === 'string') {
if (value in SPECIAL_COLORS) {
return rgbaToHex(SPECIAL_COLORS[value as SpecialColorKey]).toLowerCase();
}
if (
themeColors &&
Object.prototype.hasOwnProperty.call(themeColors, value)
) {
return themeColors[value as string].toLowerCase();
}
return value.toLowerCase();
}
return rgbaToHex(value).toLowerCase();
}
const extractThemeColors = (
theme: SupersetTheme | undefined | null,
): Record<string, string> => {
if (!theme || typeof theme !== 'object') {
return {};
}
if (
'colors' in theme &&
typeof theme.colors === 'object' &&
theme.colors !== null
) {
return theme.colors as Record<string, string>;
}
return theme as unknown as Record<string, string>;
};
export default function ColorPickerControl({
onChange,
value,
presets: customPresets,
ariaLabel,
resolveThemeTokens = false,
outputFormat = 'rgb',
...headerProps
}: ColorPickerControlProps) {
const categoricalScheme = getCategoricalSchemeRegistry().get();
const presetColors = categoricalScheme?.colors.slice(0, 9) || [];
const defaultPresets = categoricalScheme?.colors.slice(0, 9) || [];
const theme = useTheme();
const themeColors = useMemo<Record<string, string>>(
() => extractThemeColors(theme),
[theme],
);
const reverseMap = useMemo(
() => getReverseThemeColorMap(themeColors),
[themeColors],
);
const presets = useMemo(() => {
if (customPresets) {
return customPresets.map(item => ({
label: item.label,
colors: item.colors.map(color => {
if (color in SPECIAL_COLORS) {
return rgbaToHex(
SPECIAL_COLORS[color as SpecialColorKey],
).toLowerCase();
}
if (
themeColors &&
Object.prototype.hasOwnProperty.call(themeColors, color as string)
) {
return themeColors[color as string].toLowerCase();
}
return String(color).toLowerCase();
}),
}));
}
return [
{
label: t('Theme colors'),
colors: defaultPresets.map(c => String(c).toLowerCase()),
},
];
}, [customPresets, themeColors, defaultPresets]);
const handleChange = (color: ColorValue) => {
if (onChange) {
const rgb = color.toRgb();
onChange({
r: rgb.r,
g: rgb.g,
b: rgb.b,
a: rgb.a,
});
if (!onChange) return;
const rgb = color.toRgb();
const hex = rgbaToHex(rgb).toLowerCase();
const specialEntry = resolveThemeTokens
? Object.entries(SPECIAL_COLORS).find(
([, rgba]) => rgbaToHex(rgba).toLowerCase() === hex,
)
: undefined;
if (specialEntry) {
onChange(specialEntry[0] as SpecialColorKey);
return;
}
if (resolveThemeTokens && reverseMap.has(hex)) {
const tokenName = reverseMap.get(hex);
if (tokenName) {
onChange(tokenName);
return;
}
}
if (outputFormat === 'rgb') onChange(rgb);
else onChange(hex);
};
const hexValue = value ? rgbToHex(value) : undefined;
const hexValue = toDisplayHex(value, themeColors);
return (
<div>
<ControlHeader {...headerProps} />
<ColorPicker
aria-label={ariaLabel}
value={hexValue}
onChangeComplete={handleChange}
presets={[{ label: 'Theme colors', colors: presetColors }]}
presets={presets}
showText
/>
</div>
@@ -21,6 +21,7 @@ import {
screen,
fireEvent,
waitFor,
userEvent,
} from 'spec/helpers/testing-library';
import { Comparator, ColorSchemeEnum } from '@superset-ui/chart-controls';
import { GenericDataType } from '@apache-superset/core/common';
@@ -51,12 +52,8 @@ const mixColumns = [
const extraColorChoices = [
{
value: ColorSchemeEnum.Green,
label: 'Green for increase, red for decrease',
},
{
value: ColorSchemeEnum.Red,
label: 'Red for increase, green for decrease',
label: 'Colors',
colors: [ColorSchemeEnum.Green, ColorSchemeEnum.Red],
},
];
@@ -117,7 +114,7 @@ test('renders the correct input fields based on the selected operator', async ()
});
test('renders None for operator when Green for increase is selected', async () => {
render(
const { container } = render(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columns}
@@ -125,15 +122,44 @@ test('renders None for operator when Green for increase is selected', async () =
/>,
);
// Select the 'Green for increase' color scheme
fireEvent.change(screen.getAllByLabelText(/color scheme/i)[0], {
target: { value: ColorSchemeEnum.Green },
const colorPickerTrigger = container.querySelector(
'.ant-color-picker-trigger',
);
expect(colorPickerTrigger).toBeInTheDocument();
await userEvent.click(colorPickerTrigger!);
await waitFor(() => {
expect(
document.querySelector('.ant-color-picker-presets-items'),
).toBeInTheDocument();
});
fireEvent.click(await screen.findByTitle(/green for increase/i));
const presets = document.querySelectorAll('.ant-color-picker-presets-color');
const greenPreset = Array.from(presets).find(preset => {
const inner = preset.querySelector('.ant-color-picker-color-block-inner');
return (
inner && inner.getAttribute('style')?.includes('rgba(0, 150, 0, 0.2)')
);
});
// Assert that the operator is set to 'None'
expect(screen.getByText(/none/i)).toBeInTheDocument();
expect(greenPreset).toBeDefined();
expect(greenPreset).toBeInTheDocument();
const safeGreenPreset = greenPreset as HTMLElement;
const innerColorBlock = safeGreenPreset.querySelector(
'.ant-color-picker-color-block-inner',
);
expect(innerColorBlock).toHaveStyle({ background: 'rgba(0, 150, 0, 0.2)' });
expect(safeGreenPreset).toBeInTheDocument();
await userEvent.click(safeGreenPreset);
const operatorInput = screen.getByLabelText('Operator');
expect(operatorInput).toBeInTheDocument();
const operatorSelect = operatorInput.closest('.ant-select-content');
expect(operatorSelect).toBeInTheDocument();
expect(operatorSelect).toHaveTextContent(/none/i);
});
test('displays the correct input fields based on the selected string type operator', async () => {
@@ -295,7 +321,7 @@ test('should hide formatting fields when allColumns is empty', async () => {
test('should hide formatting fields when color scheme is Green', async () => {
render(
<FormattingPopoverContent
config={{ colorScheme: extraColorChoices[0].value }}
config={{ colorScheme: extraColorChoices[0].colors[0] }}
columns={mixColumns}
allColumns={mixColumns}
onChange={mockOnChange}
@@ -307,3 +333,43 @@ test('should hide formatting fields when color scheme is Green', async () => {
expect(screen.queryByText('Formatting object')).not.toBeInTheDocument();
});
});
test('should not display tooltip when extraColorChoices is not provided', async () => {
const { container } = render(
<FormattingPopoverContent onChange={mockOnChange} columns={columns} />,
);
const tooltipIcon = container.querySelector('.ant-form-item-tooltip');
expect(tooltipIcon).not.toBeInTheDocument();
});
test('should display tooltip icon when extraColorChoices is provided', () => {
const { container } = render(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columns}
extraColorChoices={extraColorChoices}
/>,
);
const tooltipIcon = container.querySelector('.ant-form-item-tooltip');
expect(tooltipIcon).toBeInTheDocument();
const questionIcon = tooltipIcon?.querySelector(
'[aria-label="question-circle"]',
);
expect(questionIcon).toBeInTheDocument();
});
test('should not display tooltip icon when extraColorChoices is empty', () => {
const { container } = render(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columns}
extraColorChoices={[]}
/>,
);
const tooltipIcon = container.querySelector('.ant-form-item-tooltip');
expect(tooltipIcon).not.toBeInTheDocument();
});
@@ -44,8 +44,9 @@ import {
stringOperatorOptions,
booleanOperatorOptions,
formattingOptions,
colorSchemeOptions,
colorScheme,
} from './constants';
import ColorPickerControl from '../ColorPickerControl';
const FullWidthInputNumber = styled(InputNumber)`
width: 100%;
@@ -236,11 +237,11 @@ export const FormattingPopoverContent = ({
config?: ConditionalFormattingConfig;
onChange: (config: ConditionalFormattingConfig) => void;
columns: { label: string; value: string; dataType: GenericDataType }[];
extraColorChoices?: { label: string; value: string }[];
extraColorChoices?: { label: string; colors: string[] }[];
allColumns?: ColumnOption[];
}) => {
const [form] = Form.useForm();
const colorScheme = colorSchemeOptions();
const colors = colorScheme();
const [showOperatorFields, setShowOperatorFields] = useState(
config === undefined ||
(config?.colorScheme !== ColorSchemeEnum.Green &&
@@ -319,6 +320,7 @@ export const FormattingPopoverContent = ({
() => allColumns.filter(col => col.dataType === GenericDataType.Numeric),
[allColumns],
);
const defaultColorToken = colors[0]?.colors?.[0];
const visibleUseGradient = useMemo(
() =>
@@ -365,6 +367,14 @@ export const FormattingPopoverContent = ({
}
}, [column, columns, previousColumnType]);
const trendColorsTooltip = (
<div>
<div>{t('Trend colors are added (for time-based comparison):')}</div>
<div>{t('green — increase / red — decrease')}</div>
<div>{t('red — increase / green — decrease')}</div>
</div>
);
return (
<Form
form={form}
@@ -395,12 +405,15 @@ export const FormattingPopoverContent = ({
name="colorScheme"
label={t('Color scheme')}
rules={rulesRequired}
initialValue={colorScheme[0].value}
initialValue={defaultColorToken}
tooltip={extraColorChoices.length > 0 ? trendColorsTooltip : ''}
>
<Select
onChange={event => handleChange(event)}
<ColorPickerControl
ariaLabel={t('Color scheme')}
options={[...colorScheme, ...extraColorChoices]}
onChange={event => handleChange(event)}
presets={[...colors, ...extraColorChoices]}
resolveThemeTokens
outputFormat="hex"
/>
</FormItem>
</Col>
@@ -65,8 +65,16 @@ export const formattingOptions = [
];
// Use theme token names instead of hex values to support theme switching
export const colorSchemeOptions = () => [
{ value: 'colorSuccess', label: t('success') },
{ value: 'colorWarning', label: t('alert') },
{ value: 'colorError', label: t('error') },
export const colorScheme = () => [
{
label: t('Colors'),
colors: [
'colorSuccess',
'colorWarning',
'colorError',
'colorSuccessBg',
'colorWarningBg',
'colorErrorBg',
],
},
];
@@ -25,6 +25,7 @@ import {
ObjectFormattingEnum,
} from '@superset-ui/chart-controls';
import { GenericDataType } from '@apache-superset/core/common';
import { type RGBColor } from '@superset-ui/core/components';
export type ConditionalFormattingConfig = {
operator?: Comparator;
@@ -32,7 +33,7 @@ export type ConditionalFormattingConfig = {
targetValueLeft?: number;
targetValueRight?: number;
column?: string;
colorScheme?: string;
colorScheme?: RGBColor | string;
toAllRow?: boolean;
toTextColor?: boolean;
useGradient?: boolean;
@@ -48,7 +49,7 @@ export type ConditionalFormattingControlProps = ControlComponentProps<
verboseMap: Record<string, string>;
label: string;
description: string;
extraColorChoices?: { label: string; value: string }[];
extraColorChoices?: { label: string; colors: string[] }[];
allColumns?: ColumnOption[];
};
@@ -58,7 +59,7 @@ export type FormattingPopoverProps = PopoverProps & {
config?: ConditionalFormattingConfig;
title: string;
children: ReactNode;
extraColorChoices?: { label: string; value: string }[];
extraColorChoices?: { label: string; colors: string[] }[];
allColumns?: ColumnOption[];
};
@@ -88,6 +88,7 @@ const generateMockPayload = (dashboard = true) => {
force_screenshot: true,
grace_period: 14400,
id: 1,
include_cta: true,
last_eval_dttm: null,
last_state: 'Not triggered',
last_value: null,
@@ -308,6 +309,9 @@ afterEach(() => {
'put-dashboard-payload',
'put-report-1',
'put-no-recipients',
'put-include-cta',
'get-report-cta-false',
'get-report-cta-absent',
'tabs-99',
]) {
try {
@@ -336,6 +340,7 @@ const validAlert: AlertObject = {
dashboard_id: 0,
chart_id: 1,
force_screenshot: false,
include_cta: true,
last_state: 'Not triggered',
name: 'Test Alert',
editors: [mockEditorSubject],
@@ -729,6 +734,90 @@ test('removes ignore cache checkbox when chart is selected', async () => {
).not.toBeInTheDocument();
});
test('renders include link checkbox checked by default in create mode', async () => {
render(<AlertReportModal {...generateMockedProps(true)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('contents-panel'));
const checkbox = await screen.findByRole('checkbox', {
name: /include a link back to superset/i,
});
expect(checkbox).toBeChecked();
});
test('keeps include link checkbox when chart is selected', async () => {
render(<AlertReportModal {...generateMockedProps(false, true, true)} />, {
useRedux: true,
});
userEvent.click(screen.getByTestId('contents-panel'));
await screen.findByText(/test dashboard/i);
const contentTypeSelector = screen.getByRole('combobox', {
name: /select content type/i,
});
await comboboxSelect(
contentTypeSelector,
'Chart',
() => screen.getAllByText(/select chart/i)[0],
);
expect(
screen.getByRole('checkbox', {
name: /include a link back to superset/i,
}),
).toBeInTheDocument();
});
test('hydrates include link checkbox from a resource with include_cta false', async () => {
fetchMock.get(
'glob:*/api/v1/report/8',
{ result: { ...generateMockPayload(true), id: 8, include_cta: false } },
{ name: 'get-report-cta-false' },
);
render(
<AlertReportModal
{...generateMockedProps(false, true, true)}
alert={{ ...validAlert, id: 8 }}
/>,
{ useRedux: true },
);
userEvent.click(screen.getByTestId('contents-panel'));
await screen.findByText(/test dashboard/i);
expect(
screen.getByRole('checkbox', {
name: /include a link back to superset/i,
}),
).not.toBeChecked();
fetchMock.removeRoute('get-report-cta-false');
});
test('treats a resource without include_cta as checked', async () => {
const { include_cta: _include_cta, ...payloadWithoutCta } =
generateMockPayload(true);
fetchMock.get(
'glob:*/api/v1/report/9',
{ result: { ...payloadWithoutCta, id: 9 } },
{ name: 'get-report-cta-absent' },
);
render(
<AlertReportModal
{...generateMockedProps(false, true, true)}
alert={{ ...validAlert, id: 9 }}
/>,
{ useRedux: true },
);
userEvent.click(screen.getByTestId('contents-panel'));
await screen.findByText(/test dashboard/i);
expect(
screen.getByRole('checkbox', {
name: /include a link back to superset/i,
}),
).toBeChecked();
fetchMock.removeRoute('get-report-cta-absent');
});
test('open chart button opens explore with slice_id', async () => {
// Render with an existing alert that has a chart selected
render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
@@ -1550,6 +1639,58 @@ test('submit includes conditionNotNull without threshold in alert payload', asyn
fetchMock.removeRoute('put-condition');
}, 45000);
test('submit includes include_cta false after unchecking the checkbox', async () => {
// Mock payload returns id:1, so updateResource PUTs to /api/v1/report/1
fetchMock.put(
'glob:*/api/v1/report/1',
{ id: 1, result: {} },
{ name: 'put-include-cta' },
);
render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
useRedux: true,
});
// Wait for resource to load and all validation to pass
await waitFor(
() => {
expect(
screen.queryAllByRole('img', { name: /check-circle/i }),
).toHaveLength(5);
},
{ timeout: 10000 },
);
// Open the contents panel and uncheck the include link checkbox
userEvent.click(screen.getByTestId('contents-panel'));
const checkbox = await screen.findByRole('checkbox', {
name: /include a link back to superset/i,
});
expect(checkbox).toBeChecked();
userEvent.click(checkbox);
await waitFor(() => {
expect(checkbox).not.toBeChecked();
});
// Wait for Save to be enabled and click
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
});
userEvent.click(screen.getByRole('button', { name: /save/i }));
// Verify the PUT payload
await waitFor(() => {
const calls = fetchMock.callHistory.calls('put-include-cta');
expect(calls.length).toBeGreaterThan(0);
});
const calls = fetchMock.callHistory.calls('put-include-cta');
const body = JSON.parse(calls[calls.length - 1].options.body as string);
expect(body.include_cta).toBe(false);
fetchMock.removeRoute('put-include-cta');
}, 45000);
test('edit mode submit uses PUT and excludes read-only fields', async () => {
// Mock payload returns id:1, so updateResource PUTs to /api/v1/report/1
fetchMock.put(
@@ -721,6 +721,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
validator_config_json: {},
validator_type: '',
force_screenshot: false,
include_cta: true,
grace_period: undefined,
retry_on_failure: false,
retry_max_attempts: 3,
@@ -963,6 +964,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
...currentAlert,
type: isReport ? 'Report' : 'Alert',
force_screenshot: shouldEnableForceScreenshot || forceScreenshot,
include_cta: currentAlert?.include_cta ?? true,
validator_type: conditionNotNull ? 'not null' : 'operator',
validator_config_json: conditionNotNull
? {}
@@ -2611,6 +2613,22 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
</Checkbox>
</div>
)}
<div className="inline-container">
<Checkbox
data-test="include-cta"
checked={currentAlert?.include_cta !== false}
onChange={(e: CheckboxChangeEvent) =>
updateAlertState('include_cta', e.target.checked)
}
>
{t('Include a link back to Superset')}
</Checkbox>
<InfoTooltip
tooltip={t(
'When unchecked, the "Explore in Superset" link is omitted from the delivered notifications.',
)}
/>
</div>
</>
),
},
@@ -135,6 +135,7 @@ export type AlertObject = {
force_screenshot: boolean;
grace_period?: number;
id: number;
include_cta?: boolean;
last_eval_dttm?: number;
last_state?:
| 'Success'
@@ -24,6 +24,7 @@ import {
MarshmallowErrorMessage,
ParameterErrorMessage,
DatasetNotFoundErrorMessage,
DatasourceSecurityAccessErrorMessage,
InvalidSQLErrorMessage,
OAuth2RedirectMessage,
FrontendNetworkErrorMessage,
@@ -94,6 +95,14 @@ export default function setupErrorMessages() {
ErrorTypeEnum.QUERY_SECURITY_ACCESS_ERROR,
DatabaseErrorMessage,
);
errorMessageComponentRegistry.registerValue(
ErrorTypeEnum.DATASOURCE_SECURITY_ACCESS_ERROR,
DatasourceSecurityAccessErrorMessage,
);
errorMessageComponentRegistry.registerValue(
ErrorTypeEnum.TABLE_SECURITY_ACCESS_ERROR,
DatasourceSecurityAccessErrorMessage,
);
errorMessageComponentRegistry.registerValue(
ErrorTypeEnum.CONNECTION_INVALID_HOSTNAME_ERROR,
DatabaseErrorMessage,
@@ -22,6 +22,15 @@ import {
getAllValidTokenNames,
} from './antdTokenNames';
// Simulate a deployment that registered a custom token via EXTRA_THEME_TOKENS
// (shipped in the common bootstrap payload).
jest.mock('src/utils/getBootstrapData', () => ({
__esModule: true,
default: () => ({
common: { extra_theme_tokens: ['customDeploymentToken'] },
}),
}));
test('isValidTokenName recognizes standard Ant Design tokens', () => {
expect(isValidTokenName('colorPrimary')).toBe(true);
expect(isValidTokenName('fontSize')).toBe(true);
@@ -141,6 +150,20 @@ test('dashboard tile tokens are recognized as valid Superset custom tokens', ()
});
});
test('isValidTokenName recognizes deployment-registered EXTRA_THEME_TOKENS', () => {
expect(isValidTokenName('customDeploymentToken')).toBe(true);
});
test('isSupersetCustomToken treats EXTRA_THEME_TOKENS as custom, not Ant Design', () => {
expect(isSupersetCustomToken('customDeploymentToken')).toBe(true);
});
test('getAllValidTokenNames lists EXTRA_THEME_TOKENS under supersetTokens', () => {
const result = getAllValidTokenNames();
expect(result.supersetTokens).toContain('customDeploymentToken');
expect(result.antdTokens).not.toContain('customDeploymentToken');
});
test('label variant tokens are recognized as valid Superset custom tokens', () => {
const labelTokens = [
// Published/Draft
@@ -17,6 +17,25 @@
* under the License.
*/
import { theme } from 'antd';
import getBootstrapData from 'src/utils/getBootstrapData';
/**
* Custom token names registered by the deployment via the EXTRA_THEME_TOKENS
* config (shipped in the common bootstrap payload). Lets deployments and plugins
* validate their own theme tokens without forking this list.
*
* Snapshotted once so the cached valid-token set (isValidTokenName) and the live
* reads (isSupersetCustomToken, getAllValidTokenNames) all derive from the same
* list, rather than depending on getBootstrapData's memoization to stay in sync.
*/
let extraThemeTokensCache: readonly string[] | undefined;
function getExtraThemeTokens(): readonly string[] {
if (extraThemeTokensCache === undefined) {
extraThemeTokensCache =
getBootstrapData()?.common?.extra_theme_tokens ?? [];
}
return extraThemeTokensCache;
}
/**
* Superset-specific custom tokens that extend Ant Design's token system.
@@ -125,10 +144,11 @@ function getValidTokenNames(): Set<string> {
const antdTokens = theme.getDesignToken();
const antdTokenNames = Object.keys(antdTokens);
// Combine with Superset custom tokens
// Combine with Superset custom tokens + deployment-registered extras
validTokenNamesCache = new Set([
...antdTokenNames,
...SUPERSET_CUSTOM_TOKENS,
...getExtraThemeTokens(),
]);
}
return validTokenNamesCache;
@@ -149,7 +169,10 @@ export function isValidTokenName(tokenName: string): boolean {
* @returns true if it's a Superset-specific token
*/
export function isSupersetCustomToken(tokenName: string): boolean {
return SUPERSET_CUSTOM_TOKENS.has(tokenName);
return (
SUPERSET_CUSTOM_TOKENS.has(tokenName) ||
getExtraThemeTokens().includes(tokenName)
);
}
/**
@@ -165,7 +188,9 @@ export function getAllValidTokenNames(): {
const antdTokens = Array.from(allTokens).filter(
t => !isSupersetCustomToken(t),
);
const supersetTokens: string[] = Array.from(SUPERSET_CUSTOM_TOKENS);
const supersetTokens: string[] = Array.from(
new Set([...SUPERSET_CUSTOM_TOKENS, ...getExtraThemeTokens()]),
);
return {
antdTokens,
@@ -168,6 +168,7 @@ export interface CommonBootstrapData {
language_pack: LanguagePack;
extra_categorical_color_schemes: ColorSchemeConfig[];
extra_sequential_color_schemes: SequentialSchemeConfig[];
extra_theme_tokens: string[];
theme: BootstrapThemeDataConfig;
menu_data: MenuData;
d3_format: Partial<FormatLocaleDefinition>;
@@ -43,6 +43,7 @@ from superset.constants import SKIP_VISIBILITY_FILTER_CLASSES
from superset.daos.dataset import DatasetDAO
from superset.exceptions import SupersetSecurityException
from superset.models.core import Database
from superset.models.helpers import ChildMultipleResultsFound
from superset.sql.parse import Table
from superset.utils import json
from superset.utils.core import get_user
@@ -442,6 +443,22 @@ def import_dataset( # noqa: C901
# import recursively to include columns and metrics
try:
dataset = SqlaTable.import_from_dict(config, recursive=True, sync=sync)
except ChildMultipleResultsFound as ex:
# An ambiguous *child* (metric/column) lookup. Unlike the dataset-level
# case handled below, this is raised after the dataset's own fields were
# updated and after earlier siblings were already imported, so there is
# no unmodified row to fall back to — silently returning one would
# report success over a half-applied import (parent scalars written,
# children not synced, the ``sync`` deletion never run). Raise instead
# so the command's transaction wrapper rolls the whole thing back.
raise ImportFailedError(
f"Dataset {config['table_name']!r} (uuid {config['uuid']}) "
"could not be imported because one of its metrics or columns "
"matches two different existing ones — one by name and another by "
"UUID. The import was aborted so it is not applied partially. "
"Rename or delete the conflicting metric/column in the target "
"instance, or remove the UUID from the uploaded file, and retry."
) from ex
except MultipleResultsFound as ex:
# Finding multiple results when importing a dataset only happens because initially # noqa: E501
# datasets were imported without schemas (eg, `examples.NULL.users`), and later
+177 -5
View File
@@ -14,16 +14,18 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Write-ahead purge audit record.
"""Write-ahead purge audit records and retained purge outcomes.
Every purge time-based or force writes an immutable record that
**survives** the entity it names, on a **dedicated session** outside the
Every purge evaluation scheduled or force writes a provisional record on
a **dedicated session** outside the
purge transaction so it neither entangles with the ``DBEventLogger``
(which shares ``db.session`` and commits mid-request) nor vanishes if the
purge rolls back. The record is written ``pending`` *before* the purge and
flipped to ``confirmed`` *after* it commits, so a crash leaves at most a
``pending`` row, never a missing one. ``pending`` rows are reconciled on the
next run (the purge is convergent).
next run. Completed records are immutable. Consecutive scheduled evaluations
that remain blocked may discard only their current redundant provisional row;
force-purge and other meaningful outcomes are retained independently.
The dedicated ``purge_audit_log`` table is content-free (no name or PII; only
action, actor, UTC time, entity type, UUID, and affected referrers) and is never
@@ -39,11 +41,13 @@ removed by the purge cascade.
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, cast
from typing import Any, cast, Literal, TypeAlias
from uuid import UUID
import sqlalchemy as sa
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, sessionmaker
from superset import db
@@ -73,6 +77,17 @@ TRIGGER_FORCE = "force"
ACTOR_SYSTEM = "system"
RetentionBlockedDisposition: TypeAlias = Literal["retained", "suppressed", "fallback"]
@dataclass(frozen=True)
class _AuditRecoverySnapshot:
id: UUID
actor: str
entity_type: str
entity_uuid: str | None
created_on: datetime
def _utc_now() -> datetime:
"""Naive UTC now for the audit columns.
@@ -182,6 +197,163 @@ def block(record_id: UUID | None) -> None:
finalize(record_id, STATUS_BLOCKED)
def _capture_recovery_snapshot(record: PurgeAuditLog) -> _AuditRecoverySnapshot:
"""Capture the content-free fields needed for fail-safe recovery."""
return _AuditRecoverySnapshot(
id=cast(UUID, record.id),
actor=str(record.actor),
entity_type=str(record.entity_type),
entity_uuid=record.entity_uuid,
created_on=cast(datetime, record.created_on),
)
def _retention_predecessor(
session: Session, current: PurgeAuditLog
) -> PurgeAuditLog | None:
"""Return the latest row that could unambiguously precede ``current``."""
predecessor: PurgeAuditLog | None = session.execute(
sa.select(PurgeAuditLog)
.where(PurgeAuditLog.entity_uuid == current.entity_uuid)
.where(PurgeAuditLog.entity_type == current.entity_type)
.where(PurgeAuditLog.trigger == TRIGGER_RETENTION)
.where(PurgeAuditLog.created_on <= current.created_on)
.where(PurgeAuditLog.id != current.id)
.order_by(PurgeAuditLog.created_on.desc())
.limit(1)
).scalar_one_or_none()
if predecessor is None:
return predecessor
tied_mixed_status_exists: bool = session.execute(
sa.select(
sa.exists().where(
PurgeAuditLog.entity_uuid == current.entity_uuid,
PurgeAuditLog.entity_type == current.entity_type,
PurgeAuditLog.trigger == TRIGGER_RETENTION,
PurgeAuditLog.created_on == predecessor.created_on,
PurgeAuditLog.id != current.id,
PurgeAuditLog.status != predecessor.status,
)
)
).scalar_one()
if tied_mixed_status_exists:
return None
return predecessor
def _suppress_redundant_block(
session: Session, current: PurgeAuditLog, predecessor: PurgeAuditLog | None
) -> bool:
"""Delete only a pending row with a strictly older blocked predecessor."""
if (
predecessor is None
or predecessor.created_on >= current.created_on
or predecessor.status != STATUS_BLOCKED
):
return False
deleted_rows: int | None = session.execute(
sa.delete(PurgeAuditLog.__table__).where(
PurgeAuditLog.__table__.c.id == current.id,
PurgeAuditLog.__table__.c.status == STATUS_PENDING,
)
).rowcount
if deleted_rows not in (0, 1):
raise SQLAlchemyError(
f"indeterminate purge audit suppression rowcount: {deleted_rows}"
)
return deleted_rows == 1
def _retain_blocked(session: Session, record_id: UUID) -> None:
"""Conditionally retain the current provisional row as blocked."""
session.execute(
sa.update(PurgeAuditLog.__table__)
.where(
PurgeAuditLog.__table__.c.id == record_id,
PurgeAuditLog.__table__.c.status == STATUS_PENDING,
)
.values(status=STATUS_BLOCKED, removed_dashboard_slices=0)
)
def _recover_retention_blocked(
record_id: UUID, snapshot: _AuditRecoverySnapshot | None
) -> RetentionBlockedDisposition:
"""Retain blocked evidence on a fresh session after persistence uncertainty."""
recovery_session: Session = _dedicated_session()
try:
current: PurgeAuditLog | None = recovery_session.get(PurgeAuditLog, record_id)
if current is not None:
if current.status == STATUS_PENDING:
_retain_blocked(recovery_session, record_id)
recovery_session.commit()
return "fallback"
if snapshot is None:
return "fallback"
recovery_session.add(
PurgeAuditLog(
id=snapshot.id,
status=STATUS_BLOCKED,
trigger=TRIGGER_RETENTION,
actor=snapshot.actor,
entity_type=snapshot.entity_type,
entity_uuid=snapshot.entity_uuid,
removed_dashboard_slices=0,
created_on=snapshot.created_on,
)
)
recovery_session.commit()
except SQLAlchemyError:
recovery_session.rollback()
logger.warning(
"deletion_retention: failed to recover blocked audit row %s "
"entity_type=%s entity_uuid=%s",
record_id,
snapshot.entity_type if snapshot else None,
snapshot.entity_uuid if snapshot else None,
exc_info=True,
)
finally:
recovery_session.close()
return "fallback"
def finalize_retention_blocked(
record_id: UUID | None,
) -> RetentionBlockedDisposition:
"""Finalize a scheduled blocker, suppressing only proven redundant evidence."""
if record_id is None:
return "fallback"
session: Session = _dedicated_session()
snapshot: _AuditRecoverySnapshot | None = None
try:
current: PurgeAuditLog | None = session.get(PurgeAuditLog, record_id)
if current is None:
return "fallback"
snapshot = _capture_recovery_snapshot(current)
if current.status != STATUS_PENDING or current.trigger != TRIGGER_RETENTION:
return "retained"
predecessor: PurgeAuditLog | None = None
if current.entity_uuid is not None:
predecessor = _retention_predecessor(session, current)
suppressed: bool = _suppress_redundant_block(session, current, predecessor)
if not suppressed:
_retain_blocked(session, record_id)
session.commit()
return "suppressed" if suppressed else "retained"
except SQLAlchemyError:
session.rollback()
logger.warning(
"deletion_retention: persistence uncertainty finalizing blocked "
"audit row %s",
record_id,
exc_info=True,
)
finally:
session.close()
return _recover_retention_blocked(record_id, snapshot)
def _entity_exists(session: Session, record: PurgeAuditLog) -> bool | None:
"""Return whether the audit target exists, or None if it cannot resolve."""
# pylint: disable=import-outside-toplevel
+10 -1
View File
@@ -1243,6 +1243,8 @@ class BaseReportState:
error_text = None
header_data = self._get_log_data()
url = self._get_url(user_friendly=True)
# NULL (rows predating the include_cta column) is treated as True
include_cta = self._report_schedule.include_cta is not False
if (
feature_flag_manager.is_feature_enabled("ALERTS_ATTACH_REPORTS")
@@ -1278,6 +1280,7 @@ class BaseReportState:
text=error_text,
header_data=header_data,
url=url,
include_cta=include_cta,
)
if (
@@ -1310,6 +1313,7 @@ class BaseReportState:
xlsx=xlsx_data,
embedded_data=embedded_data,
header_data=header_data,
include_cta=include_cta,
)
def _send(
@@ -1414,7 +1418,12 @@ class BaseReportState:
self._execution_id,
)
notification_content = NotificationContent(
name=sanitize_title(name), text=message, header_data=header_data, url=url
name=sanitize_title(name),
text=message,
header_data=header_data,
url=url,
# NULL (rows predating the include_cta column) is treated as True
include_cta=self._report_schedule.include_cta is not False,
)
# filter recipients to recipients who are also editors
+32 -15
View File
@@ -551,6 +551,7 @@ LANGUAGES = {
"fi": {"flag": "fi", "name": "Finnish"},
"th": {"flag": "th", "name": "Thai"},
"tr": {"flag": "tr", "name": "Turkish"},
"ta": {"flag": "in", "name": "Tamil"},
}
# Turning off i18n by default as translation in most languages are
# incomplete and not well maintained.
@@ -723,9 +724,10 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
"TAGGING_SYSTEM": False,
# Enables the version history panel on Explore and Dashboard pages.
# History only accrues while ``ENABLE_VERSIONING_CAPTURE`` is also on;
# with capture off the panel renders but stays empty.
# with capture off the panel renders but stays empty, so the two ship
# with matching defaults and should be changed together.
# @lifecycle: development
"VERSION_HISTORY": False,
"VERSION_HISTORY": True,
# =================================================================
# IN TESTING
# =================================================================
@@ -1062,6 +1064,12 @@ COMMON_BOOTSTRAP_OVERRIDES_FUNC: Callable[ # noqa: E731
# This is merely a default
EXTRA_CATEGORICAL_COLOR_SCHEMES: list[dict[str, Any]] = []
# EXTRA_THEME_TOKENS lets a deployment register additional custom theme token
# names so they validate cleanly in the theme editor instead of being flagged as
# unknown Ant Design tokens. Deployments and plugins that consume their own
# tokens via useTheme add them here. This is merely a default.
EXTRA_THEME_TOKENS: list[str] = []
# -----------------------------------------------------------------------------
# Theme System Configuration
# -----------------------------------------------------------------------------
@@ -1668,19 +1676,19 @@ DATETIME_FORMAT_DETECTION_SAMPLE_SIZE = 1000
# The limit for the Superset Meta DB when the feature flag ENABLE_SUPERSET_META_DB is on
SUPERSET_META_DB_LIMIT: int | None = 1000
# Master switch for entity-version-history capture. Ships defaulted ``False``
# so the versioning infrastructure (schema + Continuum wiring) lands inert:
# no save writes shadow rows or a ``version_transaction``/``version_changes``
# record, while the /versions/ endpoints stay available read-only (returning
# empty). Set to ``True`` in ``superset_config.py`` (or via the env var of the
# same name) to enable the before-flush listeners that drive capture.
# Capture is activated by flipping this default to on once validated in
# production. It is an operational escape hatch — for use when a
# versioning-induced regression needs a 30-second recovery instead of
# revert-and-redeploy — not a feature flag, and remains as the permanent
# kill-switch.
# Master switch for entity-version-history capture. Capture is enabled by
# default, so saves write shadow rows and a ``version_transaction`` /
# ``version_changes`` record. Set this to a falsy value in
# ``superset_config.py`` (or via the environment variable of the same name) to
# disable the before-flush listeners while keeping the /versions/ endpoints
# available read-only.
# Capture ships on. It is an operational escape hatch — set the environment
# variable to a falsy value when a versioning-induced regression needs a
# 30-second recovery instead of revert-and-redeploy — not a feature flag,
# and it remains permanently as the kill-switch rather than being removed
# with the rollout toggles.
ENABLE_VERSIONING_CAPTURE: bool = utils.parse_boolean_string(
os.environ.get("ENABLE_VERSIONING_CAPTURE", "false")
os.environ.get("ENABLE_VERSIONING_CAPTURE", "true")
)
# Retention window (days) for entity version history. Version rows
@@ -2094,7 +2102,16 @@ TROUBLESHOOTING_LINK = ""
WTF_CSRF_TIME_LIMIT = int(timedelta(weeks=1).total_seconds())
# This link should lead to a page with instructions on how to gain access to a
# Datasource. It will be placed at the bottom of permissions errors.
# Datasource. It is surfaced as a "Request Access" link on data-permission
# errors (e.g. when a viewer opens a chart whose dataset they cannot access).
# The URL may include any of these placeholders, which are substituted with
# URL-encoded values so the link can deep-link into an access-request system:
# {datasource_id} - id of the denied dataset (datasource errors)
# {datasource_name} - name of the denied dataset (datasource errors)
# {table_names} - comma-separated denied table names (table/SQL errors)
# {username} - the requesting user's username
# A URL with no placeholders is used as-is. Example:
# "https://access.example.com/request?dataset={datasource_id}&user={username}"
PERMISSION_INSTRUCTIONS_LINK = ""
# Integrate external Blueprints to the app by passing them to your
+149 -61
View File
@@ -70,21 +70,18 @@ from sqlalchemy.types import JSON
from superset_core.common.models import Dataset as CoreDataset
from superset import db, is_feature_enabled, security_manager
from superset.commands.dataset.exceptions import DatasetNotFoundError
from superset.common.db_query_status import QueryStatus
from superset.connectors.sqla.utils import (
get_columns_description,
get_physical_table_metadata,
get_virtual_table_metadata,
)
from superset.daos.exceptions import DatasourceNotFound
from superset.db_engine_specs.base import BaseEngineSpec, TimestampExpression
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import (
ColumnNotFoundException,
DatasetInvalidPermissionEvaluationException,
QueryObjectValidationError,
SupersetGenericDBErrorException,
SupersetParseError,
SupersetSecurityException,
SupersetSyntaxErrorException,
@@ -500,6 +497,107 @@ class BaseDatasource(
"supports_drill_to_detail": self.supports_drill_to_detail,
}
@staticmethod
def _extract_query_columns(query: dict[str, Any]) -> set[str] | None:
"""Extract metadata dependencies from one serialized query object."""
column_names: set[str] = set()
# This only collects metadata dependencies, so retaining both fields is
# safer than reproducing conflicting schema/factory precedence rules.
for query_field in ("columns", "groupby"):
columns = query.get(query_field)
if not isinstance(columns, list):
continue
try:
column_names.update(
utils.get_column_name(column_) for column_ in columns
)
except (TypeError, ValueError):
return None
# QueryContextFactory may replace a temporal x-axis with granularity.
# Keeping both names is conservative and avoids dropping its metadata.
for query_field in ("granularity", "granularity_sqla"):
if isinstance(granularity := query.get(query_field), str):
column_names.add(granularity)
return column_names
@staticmethod
def _extract_form_data_columns(
form_data: Any,
currency_code_column: str | None,
has_query_columns: bool,
) -> set[str]:
"""Extract columns that QueryContextFactory adds using form data."""
if not isinstance(form_data, dict):
return set()
column_names: set[str] = set()
tooltip_contents = form_data.get("tooltip_contents")
if isinstance(tooltip_contents, list):
for item in tooltip_contents:
if isinstance(item, str):
column_names.add(item)
elif (
isinstance(item, dict)
and item.get("item_type") == "column"
and isinstance(column_name := item.get("column_name"), str)
):
column_names.add(column_name)
currency_format = form_data.get("currency_format")
if (
(has_query_columns or column_names)
and currency_code_column
and form_data.get("viz_type") == "pivot_table_v2"
and isinstance(currency_format, dict)
and currency_format.get("symbol") == "AUTO"
):
column_names.add(currency_code_column)
return column_names
@classmethod
def _extract_query_context_columns(
cls,
query_context: str | None,
currency_code_column: str | None = None,
) -> set[str] | None:
"""Extract column names from a serialized query context."""
if not query_context:
return None
try:
payload = json.loads(query_context)
except json.JSONDecodeError:
logger.exception("Malformed JSON in slice's query context")
return None
if not isinstance(payload, dict):
return None
queries = payload.get("queries")
if not isinstance(queries, list):
return None
column_names: set[str] = set()
for query in queries:
if not isinstance(query, dict):
continue
if (query_columns := cls._extract_query_columns(query)) is None:
return None
column_names.update(query_columns)
column_names.update(
cls._extract_form_data_columns(
payload.get("form_data"),
currency_code_column,
bool(column_names),
)
)
return column_names or None
def data_for_slices( # pylint: disable=too-many-locals # noqa: C901
self, slices: list[Slice]
) -> dict[str, Any]:
@@ -538,28 +636,12 @@ class BaseDatasource(
if "column" in filter_config
)
# for legacy dashboard imports which have the wrong query_context in them
try:
query_context = slc.get_query_context()
except (DatasetNotFoundError, DatasourceNotFound):
logger.warning(
"Failed to load query_context for chart '%s' (id=%s): "
"referenced datasource not found",
slc.slice_name,
slc.id,
)
query_context = None
# legacy charts don't have query_context charts
if query_context:
column_names.update(
[
utils.get_column_name(column_)
for query in query_context.queries
for column_ in query.columns
]
or []
)
query_context_column_names = self._extract_query_context_columns(
slc.query_context,
currency_code_column=getattr(self, "currency_code_column", None),
)
if query_context_column_names is not None:
column_names.update(query_context_column_names)
else:
_columns = [
(
@@ -1858,42 +1940,43 @@ class SqlaTable(
sqla_column = literal_column(expression)
if has_timegrain or force_type_check:
try:
# probe adhoc column type
# Most databases populate cursor.description from query-plan
# metadata, so WHERE FALSE (zero rows, no table scan) is
# preferred — it avoids hitting row-read limits enforced by
# engines like ClickHouse (max_rows_to_read).
# A small number of drivers (Druid, Pinot) instead build
# cursor.description by inspecting the first returned row;
# for those we fall back to LIMIT 1.
tbl, _unused_cte = self.get_from_clause(template_processor)
if self.db_engine_spec.type_probe_needs_row:
qry = sa.select(sqla_column).limit(1).select_from(tbl)
else:
qry = sa.select(sqla_column).where(sa.false()).select_from(tbl)
sql = self.database.compile_sqla_query(
qry,
catalog=self.catalog,
schema=self.schema,
)
col_desc = get_columns_description(
self.database,
self.catalog,
self.schema or None,
sql,
)
if not col_desc:
raise SupersetGenericDBErrorException("Column not found")
is_dttm = col_desc[0]["is_dttm"] # type: ignore
# ResultSet already resolves the generic type from the
# driver's cursor.description; reuse it so callers can
# coerce filter values correctly (e.g. numeric IN-lists
# stay unquoted for numeric adhoc expressions like
# CAST(... AS BIGINT)).
generic_type = col_desc[0].get("type_generic")
except SupersetGenericDBErrorException as ex:
raise ColumnNotFoundException(message=str(ex)) from ex
# probe adhoc column type
# Most databases populate cursor.description from query-plan
# metadata, so WHERE FALSE (zero rows, no table scan) is
# preferred — it avoids hitting row-read limits enforced by
# engines like ClickHouse (max_rows_to_read).
# A small number of drivers (Druid, Pinot) instead build
# cursor.description by inspecting the first returned row;
# for those we fall back to LIMIT 1.
tbl, _unused_cte = self.get_from_clause(template_processor)
if self.db_engine_spec.type_probe_needs_row:
qry = sa.select(sqla_column).limit(1).select_from(tbl)
else:
qry = sa.select(sqla_column).where(sa.false()).select_from(tbl)
sql = self.database.compile_sqla_query(
qry,
catalog=self.catalog,
schema=self.schema,
)
# A real DB/connectivity failure during the probe surfaces as a
# SupersetGenericDBErrorException from get_columns_description and
# is allowed to propagate unchanged; only a genuine empty result
# (the column truly isn't there) is a ColumnNotFoundException.
col_desc = get_columns_description(
self.database,
self.catalog,
self.schema or None,
sql,
)
if not col_desc:
raise ColumnNotFoundException(message="Column not found")
is_dttm = col_desc[0]["is_dttm"] # type: ignore
# ResultSet already resolves the generic type from the
# driver's cursor.description; reuse it so callers can
# coerce filter values correctly (e.g. numeric IN-lists
# stay unquoted for numeric adhoc expressions like
# CAST(... AS BIGINT)).
generic_type = col_desc[0].get("type_generic")
if is_dttm and has_timegrain:
sqla_column = self.db_engine_spec.get_timestamp_expr(
@@ -2199,6 +2282,11 @@ class SqlaTable(
templatable_statements += [
f.clause for f in security_manager.get_rls_filters(self)
]
if is_feature_enabled("EMBEDDED_SUPERSET"):
# Guest-token RLS clauses are templated when the query is built, so
# macros they contain (e.g. get_guest_user_attribute) must also
# trigger extra cache key extraction.
templatable_statements += security_manager.get_guest_rls_filters_str(self)
for statement in templatable_statements:
if ExtraCache.regex.search(statement):
return True
+32
View File
@@ -16,6 +16,7 @@
# under the License.
from datetime import datetime
from typing import Any
from uuid import UUID
from dateutil.parser import isoparse
from flask_babel import lazy_gettext as _
@@ -284,6 +285,7 @@ class ImportV1ColumnSchema(Schema):
description = fields.String(allow_none=True)
python_date_format = fields.String(allow_none=True)
datetime_format = fields.String(allow_none=True)
uuid = fields.UUID(allow_none=True)
class ImportMetricCurrencySchema(Schema):
@@ -326,6 +328,7 @@ class ImportV1MetricSchema(Schema):
currency = CurrencyField(ImportMetricCurrencySchema, allow_none=True)
extra = fields.Dict(allow_none=True)
warning_text = fields.String(allow_none=True)
uuid = fields.UUID(allow_none=True)
class ImportV1DatasetSchema(Schema):
@@ -348,6 +351,35 @@ class ImportV1DatasetSchema(Schema):
return data
@validates_schema
def validate_unique_child_uuids(self, data: dict[str, Any], **kwargs: Any) -> None:
"""
Reject a payload where two metrics (or two columns) share a UUID.
UUIDs are globally unique in the database, so such a payload cannot be
imported faithfully: the importer matches children within their parent
by name *or* UUID, so the second entry would match the first one and
overwrite it in place, silently collapsing two metrics/columns into one.
Only a hand-edited bundle can produce this an export never does.
"""
for key, singular in (("metrics", "metric"), ("columns", "column")):
seen: set[UUID] = set()
duplicates: set[UUID] = set()
for child in data.get(key) or []:
child_uuid = child.get("uuid")
if child_uuid is None:
continue
if child_uuid in seen:
duplicates.add(child_uuid)
seen.add(child_uuid)
if duplicates:
raise ValidationError(
f"Duplicate UUIDs found in {key}: "
f"{', '.join(sorted(str(dup) for dup in duplicates))}. "
f"Each {singular} must have a unique `uuid`.",
field_name=key,
)
table_name = fields.String(required=True)
main_dttm_col = fields.String(allow_none=True)
currency_code_column = fields.String(allow_none=True)
+138 -17
View File
@@ -61,6 +61,7 @@ if TYPE_CHECKING:
from superset.connectors.sqla.models import SqlaTable
from superset.models.core import Database
from superset.models.sql_lab import Query
from superset.security.guest_token import GuestToken
logger = logging.getLogger(__name__)
@@ -88,6 +89,11 @@ ALLOWED_TYPES = (
)
COLLECTION_TYPES = ("list", "dict", "tuple", "set")
# Type alias for JSON-native types
JsonValue = Union[
str, int, float, bool, list["JsonValue"], dict[str, "JsonValue"], None
]
@lru_cache(maxsize=LRU_CACHE_MAX_SIZE)
def context_addons() -> dict[str, Any]:
@@ -112,6 +118,25 @@ class TimeFilter:
time_range: str | None
def _normalize_postgresql_backslash_escapes(dialect: Dialect) -> None:
"""Correct a PostgreSQL dialect instance's ``_backslash_escapes`` default
in place so backslashes round-trip unchanged when the dialect is used to
render literals without a live connection.
A dialect built without a live connection (as ``Database.get_dialect()``
does) defaults ``_backslash_escapes`` to ``True``, which would double
every backslash even though every supported PostgreSQL version treats
the backslash as a plain character by default
(``standard_conforming_strings`` has been on since PostgreSQL 9.1). Left
uncorrected, a value like ``C:\\Users`` would be rewritten to
``C:\\\\Users`` and silently fail to match the original value. Other
dialects (for example MySQL/MariaDB, which do treat the backslash as an
escape character) are left untouched.
"""
if dialect.name == "postgresql":
dialect._backslash_escapes = False
class ExtraCache:
"""
Dummy class that exposes a method used to store additional values used in
@@ -128,7 +153,8 @@ class ExtraCache:
r"current_user_rls_rules\([^)]*\)|"
r"current_user_roles\([^)]*\)|"
r"cache_key_wrapper\([^)]*\)|"
r"url_param\([^)]*\)"
r"url_param\([^)]*\)|"
r"get_guest_user_attribute\([^)]*\)"
r")"
r"[^{}]*?(\}\}|\%\})"
)
@@ -295,15 +321,88 @@ class ExtraCache:
result = url_params.get(param, default)
# Escape the value regardless of its source (request args or form
# data); both are interpolated into the rendered SQL.
if result and escape_result and self.dialect:
# use the dialect specific quoting logic to escape string
result = String().literal_processor(dialect=self.dialect)(value=result)[
1:-1
]
if result and escape_result:
# use the dialect-specific literal rendering to escape the string
result = self._escape_value(result)
if add_to_cache_keys:
self.cache_key_wrapper(result)
return result
def get_guest_user_attribute(
self,
attribute_name: str,
default: JsonValue = None,
add_to_cache_keys: bool = True,
escape_result: bool = True,
) -> JsonValue:
"""
Get a specific user attribute from guest user.
This function retrieves attributes from the guest user token and supports
all JSON-native types (string, number, boolean, array, object, null).
Args:
attribute_name: Name of the attribute to retrieve
default: Default value if attribute not found (can be any JSON-native type)
add_to_cache_keys: Whether the resolved value should be included in the
cache key. The resolved value is keyed on every branch (including
the default and null) so two principals whose tokens render
different SQL never share a cache entry. Opting out is only safe
when the value cannot affect the query results.
escape_result: Escape string values (including strings nested inside
lists and object values) through the database dialect's literal
rendering so they are safe to interpolate into SQL, mirroring
``url_param``. Enabled by default; non-string JSON types are
returned unchanged. Set to False for the raw value, in which case
the template author is responsible for validating the value. Pass
``escape_result=False`` when piping a list-valued attribute
through the ``where_in`` filter: ``where_in`` applies its own
dialect-safe quoting, so leaving the default escaping on would
escape each value twice.
Returns:
The attribute value from the guest user token, or the default value.
Can be any JSON-native type: string, number, boolean, array, object, or
null.
Examples:
{{ get_guest_user_attribute('department') }} # Returns: "Engineering"
{{ get_guest_user_attribute('is_admin') }} # Returns: True
{{ get_guest_user_attribute('permissions') }} # Returns: ["read", "write"]
{{ get_guest_user_attribute('config') }} # Returns: {"theme": "dark"}
{{ get_guest_user_attribute('missing', 'default') }} # Returns: "default"
full_name IN {{ get_guest_user_attribute('names', escape_result=False)
|where_in }}
"""
result: JsonValue = default
# The macro only applies to guest users (embedded). is_guest_user()
# handles the feature-flag and request-context checks internally.
if security_manager.is_guest_user():
token: GuestToken = g.user.guest_token
user_attributes: dict[str, JsonValue] = (
token.get("user", {}).get("attributes") or {}
)
result = user_attributes.get(attribute_name, default)
if add_to_cache_keys:
# Key the resolved value on every branch (attribute, default, or
# null); a guest whose attribute is absent renders different SQL
# than one whose attribute is set, so both must contribute to the
# cache key. json.dumps gives a stable serialization for all
# JSON-native types.
cache_value = json.dumps(result, sort_keys=True)
self.cache_key_wrapper(
f"guest_user_attribute:{attribute_name}:{cache_value}"
)
# Guest attributes (and caller-supplied defaults) are interpolated into
# the rendered SQL, so escape strings with the dialect's literal
# rendering by default, mirroring url_param. Non-string JSON types pass
# through.
if escape_result:
result = self._escape_value(result)
return result
def filter_values(
self, column: str, default: str | None = None, remove_filter: bool = False
) -> list[Any]:
@@ -348,22 +447,37 @@ class ExtraCache:
def _escape_value(self, val: Any) -> Any:
"""Return a dialect-quoted form of ``val`` suitable for direct SQL
interpolation. When no dialect is configured the value is returned
unchanged so callers see the raw value as before. Strings are
passed through SQLAlchemy's ``String`` literal processor (with the
surrounding quotes stripped, mirroring ``url_param``). Lists are
processed element-wise; non-string members are left as-is.
unchanged so callers see the raw value as before.
Strings are rendered through the dialect compiler's
``render_literal_value`` (with the surrounding quotes stripped),
which applies dialect-specific escaping beyond quote doubling; in
particular, MySQL/MariaDB treat the backslash as an escape
character, so backslashes are doubled there to prevent a trailing
``\\'`` from re-opening the string literal. Dialects whose escaping
mode cannot be introspected without a live connection err on the
side of over-escaping, which can distort a backslash-containing
value but can never widen the query.
PostgreSQL is special-cased via ``_normalize_postgresql_backslash_escapes``
to restore parity with PostgreSQL's default configuration, while
MySQL/MariaDB keep the stricter, backslash-doubling behavior above.
Lists are processed element-wise and dict values recursively, so
strings nested inside JSON structures are also escaped; dict keys
are left untouched since they are used for member lookups, not
interpolation. Non-string leaf values are left as-is.
"""
if not self.dialect:
return val
if isinstance(val, str):
return String().literal_processor(dialect=self.dialect)(value=val)[1:-1]
compiler = self.dialect.statement_compiler(self.dialect, None)
_normalize_postgresql_backslash_escapes(compiler.dialect)
return compiler.render_literal_value(val, String())[1:-1]
if isinstance(val, list):
return [
String().literal_processor(dialect=self.dialect)(value=v)[1:-1]
if isinstance(v, str)
else v
for v in val
]
return [self._escape_value(v) for v in val]
if isinstance(val, dict):
return {k: self._escape_value(v) for k, v in val.items()}
return val
def get_filters(self, column: str, remove_filter: bool = False) -> list[Filter]:
@@ -672,6 +786,10 @@ def validate_template_context(
class WhereInMacro: # pylint: disable=too-few-public-methods
def __init__(self, dialect: Dialect):
# Without this, a PostgreSQL value like ``C:\Users`` would render as
# ``C:\\Users`` and silently fail to match the original value; see
# ``_normalize_postgresql_backslash_escapes`` for the full rationale.
_normalize_postgresql_backslash_escapes(dialect)
self.dialect = dialect
def __call__(
@@ -917,6 +1035,9 @@ class JinjaTemplateProcessor(BaseTemplateProcessor):
"get_filters": partial(safe_proxy, extra_cache.get_filters),
"dataset": partial(safe_proxy, dataset_macro),
"get_time_filter": partial(safe_proxy, extra_cache.get_time_filter),
"get_guest_user_attribute": partial(
safe_proxy, extra_cache.get_guest_user_attribute
),
}
)
@@ -0,0 +1,54 @@
# 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.
"""add include_cta to report_schedule
Adds a nullable ``include_cta`` column to the ``report_schedule`` table. It controls
whether the call-to-action link back to Superset (e.g. "Explore in Superset") is
included in the notifications delivered for that schedule. The column defaults to
true and NULL is treated as true, so existing schedules keep the link.
Revision ID: 2d6ad72e4af6
Revises: 1a27941d5352
Create Date: 2026-07-28 10:00:00.000000
"""
import sqlalchemy as sa
from superset.migrations.shared.utils import add_columns, drop_columns
# revision identifiers, used by Alembic.
revision = "2d6ad72e4af6"
down_revision = "b8d2f4a6c901"
def upgrade() -> None:
"""Add the nullable ``include_cta`` column to ``report_schedule``."""
add_columns(
"report_schedule",
sa.Column(
"include_cta",
sa.Boolean(),
nullable=True,
server_default=sa.true(),
),
)
def downgrade() -> None:
"""Drop the ``include_cta`` column from ``report_schedule``."""
drop_columns("report_schedule", "include_cta")
@@ -0,0 +1,63 @@
# 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.
"""Preserve MySQL audit ordering and index predecessor lookups.
Revision ID: b8d2f4a6c901
Revises: d7cecc48bd55
Create Date: 2026-08-06 18:00:00.000000
"""
from alembic import op
from sqlalchemy.dialects import mysql
from sqlalchemy.dialects.mysql.base import MySQLDialect
from superset.migrations.shared.utils import create_index, drop_index
# revision identifiers, used by Alembic.
revision: str = "b8d2f4a6c901"
down_revision: str = "d7cecc48bd55"
_INDEX_NAME: str = "ix_purge_audit_log_retention_predecessor"
def _set_mysql_created_on_precision() -> None:
"""Set fractional-second precision where plain DATETIME drops it."""
if isinstance(op.get_bind().dialect, MySQLDialect):
op.alter_column(
"purge_audit_log",
"created_on",
existing_type=mysql.DATETIME(fsp=0),
type_=mysql.DATETIME(fsp=6),
existing_nullable=False,
)
def upgrade() -> None:
_set_mysql_created_on_precision()
create_index(
"purge_audit_log",
_INDEX_NAME,
["entity_uuid", "entity_type", "trigger", "created_on"],
)
def downgrade() -> None:
# MySQL DATETIME(6) is backward-compatible with the prior application.
# Keep the expanded precision so rollback never truncates audit ordering
# evidence written after upgrade.
drop_index("purge_audit_log", _INDEX_NAME)
+1 -1
View File
@@ -1519,7 +1519,7 @@ class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin): # pylint:
scope or in the endpoints.
"""
db.session.query(DatabaseUserOAuth2Tokens).filter(
DatabaseUserOAuth2Tokens.id == self.id
DatabaseUserOAuth2Tokens.database_id == self.id
).delete()
def execute(
+163 -8
View File
@@ -40,6 +40,7 @@ from typing import (
TypedDict,
Union,
)
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import dateutil.parser
import humanize
@@ -369,6 +370,27 @@ class UUIDMixin: # pylint: disable=too-few-public-methods
return str(self.uuid)[:8]
class ChildMultipleResultsFound(MultipleResultsFound):
"""
An ambiguous lookup raised while importing a *child* object.
``ImportExportMixin.import_from_dict`` matches an incoming payload against
existing rows with a disjunction (unique-constraint match OR uuid match), so
a single payload entry can match two different DB rows one by name and one
by uuid. When that happens for a top-level object it is raised before any
mutation, and callers may legitimately recover by skipping or returning the
existing row (see ``superset/commands/importers/v1/examples.py``).
When it happens for a *child* (``parent is not None``) the parent's fields
have already been updated and earlier siblings have already been imported,
so recovering by "returning the existing row" would report success over a
half-applied import. This subclass lets callers tell the two cases apart and
abort the transaction for the child case. It intentionally subclasses
``MultipleResultsFound`` so existing ``except MultipleResultsFound`` handlers
keep working.
"""
class ImportExportMixin(UUIDMixin):
export_parent: Optional[str] = None
# The name of the attribute
@@ -506,7 +528,32 @@ class ImportExportMixin(UUIDMixin):
try:
obj_query = db.session.query(cls).filter(and_(*filters))
obj = obj_query.one_or_none()
except MultipleResultsFound:
except MultipleResultsFound as ex:
# Now that children carry a ``uuid``, this match is a disjunction
# (name-match OR uuid-match), so a payload child can match one DB row
# by name and a different one by uuid — e.g. metrics renamed/swapped
# in the UI, then an older export re-imported with overwrite.
#
# That stays a hard error on purpose. Resolving it by preferring the
# uuid match would rename the uuid-matched row while the name-matched
# row still holds that name, trading this clear failure for an opaque
# ``UNIQUE constraint failed: (table_id, <name>)`` at the next flush.
#
# The two cases are not equally recoverable, so they are raised as
# distinct types:
#
# * ``parent is None`` (top-level object) — nothing has been mutated
# yet, so a caller may legitimately skip or fall back to the
# existing row. See the legacy NULL-schema handling in
# ``superset/commands/dataset/importers/v1/utils.py`` (including
# its ``deleted_at`` rollback) and the ``continue`` in
# ``superset/commands/importers/v1/examples.py`` (issue #16051).
# * ``parent is not None`` (child object) — the parent's scalar
# fields were already updated and earlier siblings were already
# imported, so there is no "unmodified row" to fall back to.
# Raising ``ChildMultipleResultsFound`` lets the dataset importer
# fail the import atomically (the command's transaction rolls
# back) instead of reporting success over a half-applied state.
logger.error(
"Error importing %s \n %s \n %s",
cls.__name__,
@@ -514,8 +561,54 @@ class ImportExportMixin(UUIDMixin):
yaml.safe_dump(dict_rep),
exc_info=True,
)
if parent is not None:
raise ChildMultipleResultsFound(
f"{cls.__name__} matches more than one existing row under "
f"its parent {type(parent).__name__}"
) from ex
raise
# A child ``uuid`` is globally unique, but the match above is scoped to
# this ``parent`` (and also matches on name). Importing a config as a
# clone — e.g. a dataset re-imported under an edited uuid while the
# original still exists — can leave the incoming child uuid owned by a
# different row. Writing it, whether on INSERT (new obj) or on an
# overwrite UPDATE (obj matched by name), would violate the ``uuid``
# unique constraint at flush. Drop the incoming uuid whenever it belongs
# to some other row so ``UUIDMixin`` keeps/assigns a distinct one,
# reverting to the pre-uuid-export behavior for that clone.
if parent is not None and "uuid" in dict_rep:
if dict_rep["uuid"] is None:
# The child import schemas accept ``uuid: null``. On the
# overwrite UPDATE that would write a literal NULL over an
# existing child's uuid — silently, since the column is nullable
# and ``unique`` permits repeated NULLs — leaving a child no
# folder can reference and no export can round-trip.
del dict_rep["uuid"]
else:
# Known limitation: this lookup goes through the normal session
# visibility filter, so for child classes that are soft-deletable
# it cannot see a soft-deleted owner of the incoming uuid. In
# practice the children that matter here (``TableColumn`` /
# ``SqlMetric``) are not soft-deletable; the only soft-deletable
# child registered anywhere is ``SqlaTable`` via ``Database``'s
# ``export_children = ["tables"]`` (the v0 legacy path), which is
# reachable only from a hand-crafted v0 file. There a colliding
# uuid owned by a soft-deleted dataset would be kept and fail on
# the unique constraint at flush rather than being dropped here.
#
# Skip the lookup on the common idempotent re-import, where the
# name-matched ``obj`` already owns the incoming uuid: the guard
# would keep it anyway, so the query is pure waste on that path.
if obj is None or str(obj.uuid) != str(dict_rep["uuid"]):
uuid_owner = (
db.session.query(cls)
.filter(cls.uuid == dict_rep["uuid"])
.first()
)
if uuid_owner is not None and uuid_owner is not obj:
del dict_rep["uuid"]
if not obj:
is_new_obj = True
# Create new DB object
@@ -615,6 +708,7 @@ class ImportExportMixin(UUIDMixin):
recursive=recursive,
include_parent_ref=include_parent_ref,
include_defaults=include_defaults,
export_uuids=export_uuids,
)
for child in getattr(self, cld)
],
@@ -1301,6 +1395,26 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
def get_template_processor(self, **kwargs: Any) -> BaseTemplateProcessor:
raise NotImplementedError()
def get_dataset_timezone(self) -> str | None:
"""
Get the timezone configured for this dataset from the extra JSON field.
Returns an IANA timezone name (e.g., "Europe/Berlin", "America/New_York")
or None if not configured.
``extra`` is arbitrary user-supplied JSON, so the ``timezone`` key could
hold a non-string value (a number, object, list, ...). Only a string is
ever a valid IANA name, so anything else is treated as "not configured"
rather than propagating a bad value on to ``ZoneInfo``.
``extra_dict`` is provided by concrete datasources (e.g. ``SqlaTable``)
rather than this mixin, so read it defensively: subclasses without it
simply have no configured timezone.
"""
extra = getattr(self, "extra_dict", None) or {}
dataset_timezone = extra.get("timezone")
return dataset_timezone if isinstance(dataset_timezone, str) else None
def get_fetch_values_predicate(
self,
template_processor: Optional[ # pylint: disable=unused-argument
@@ -1866,11 +1980,18 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
"""
labels = self._collect_dttm_labels(query_object)
# ``get_dataset_timezone`` lives on ``ExploreMixin``; datasource doubles
# that bind only a subset of mixin methods onto a plain object (as some
# unit tests do) won't have it, so fall back to "not configured" rather
# than raising.
get_dataset_timezone = getattr(self, "get_dataset_timezone", None)
dataset_timezone = get_dataset_timezone() if get_dataset_timezone else None
dttm_cols = [
DateColumn(
timestamp_format=fmt,
offset=self.offset,
time_shift=query_object.time_shift,
timezone=dataset_timezone,
col_label=label,
)
for label, fmt in labels
@@ -1882,6 +2003,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
timestamp_format=self._python_date_format(query_object.granularity),
offset=self.offset,
time_shift=query_object.time_shift,
timezone=dataset_timezone,
)
)
@@ -3269,7 +3391,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
return f"""'{dttm.strftime("%Y-%m-%d %H:%M:%S.%f")}'"""
def get_time_filter( # pylint: disable=too-many-arguments
def get_time_filter( # pylint: disable=too-many-arguments # noqa: C901
self,
time_col: "TableColumn",
start_dttm: Optional[sa.DateTime],
@@ -3290,12 +3412,45 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
)
)
# Honor the dataset "Hour Offset". Result timestamps are displayed shifted
# by +offset hours (see normalize_df / DateColumn in superset.utils.core),
# but the time filter compares the raw stored values. Shifting the filter
# bounds by -offset keeps the filter consistent with what is displayed;
# otherwise a date selection lands on the wrong calendar day (#104810).
if offset_hours := getattr(self, "offset", 0) or 0:
# Resolve dataset-level time-boundary adjustments. A configured
# `extra.timezone` (an IANA name, DST-aware) takes precedence: naive UI
# boundaries are interpreted in that zone and converted to UTC for
# comparison with UTC-stored data. When no timezone is configured, fall
# back to the legacy "Hour Offset" field instead: displayed values are
# shifted by +offset hours (see normalize_df / DateColumn in
# superset.utils.core), but the time filter compares raw stored values, so
# bounds are shifted by -offset to stay consistent with what's displayed
# (#104810).
dataset_timezone = self.get_dataset_timezone()
if dataset_timezone and (start_dttm or end_dttm):
try:
tz = ZoneInfo(dataset_timezone)
# The datetimes from the UI are naive (no timezone info). We
# interpret them as being in the dataset's configured timezone and
# convert them to UTC for comparison with UTC-stored data.
if start_dttm is not None:
start_dttm = (
start_dttm.replace(tzinfo=tz)
.astimezone(timezone.utc)
.replace(tzinfo=None)
)
if end_dttm is not None:
end_dttm = (
end_dttm.replace(tzinfo=tz)
.astimezone(timezone.utc)
.replace(tzinfo=None)
)
except (ZoneInfoNotFoundError, TypeError):
logger.warning(
"Invalid timezone %r in dataset extra; falling back to Hour "
"Offset if configured",
dataset_timezone,
)
dataset_timezone = None
if not dataset_timezone and (offset_hours := getattr(self, "offset", 0) or 0):
if start_dttm is not None:
start_dttm = start_dttm - timedelta(hours=offset_hours)
if end_dttm is not None:
+18 -4
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.
"""The ``purge_audit_log`` table: immutable, content-free purge records.
"""The ``purge_audit_log`` table: content-free purge audit records.
The write-ahead audit protocol that populates this table lives in
:mod:`superset.commands.deletion_retention.audit`; the model is defined
here so it registers with ``Model.metadata`` at app init like every other
Superset model (Alembic autogenerate and metadata-driven tooling would
otherwise not see the table).
otherwise not see the table). Completed outcomes are immutable; a current
provisional record may be discarded when it is proven redundant.
"""
from uuid import uuid4
@@ -28,6 +29,7 @@ from uuid import uuid4
import sqlalchemy as sa
from flask_appbuilder import Model
from sqlalchemy import Column, DateTime, Integer, String, Text
from sqlalchemy.dialects import mysql
from sqlalchemy_utils import UUIDType
STATUS_PENDING = "pending"
@@ -45,13 +47,20 @@ STATUS_TARGET_ABSENT = "target_absent"
class PurgeAuditLog(Model):
"""Immutable, content-free record of a purge."""
"""Content-free provisional record or immutable retained purge outcome."""
__tablename__ = "purge_audit_log"
__table_args__ = (
# Backs reconcile_pending()'s stale-pending scan; mirrors the
# index created by migration e7d93a524ff6.
sa.Index("ix_purge_audit_log_status_created_on", "status", "created_on"),
sa.Index(
"ix_purge_audit_log_retention_predecessor",
"entity_uuid",
"entity_type",
"trigger",
"created_on",
),
)
id = Column(UUIDType(binary=True), primary_key=True, default=uuid4)
@@ -64,5 +73,10 @@ class PurgeAuditLog(Model):
# row (force-purge visibility). Free text, content-free.
affected_referrers = Column(Text, nullable=True)
removed_dashboard_slices = Column(Integer, nullable=False, default=0)
created_on = Column(DateTime, nullable=False)
created_on = Column(
DateTime()
.with_variant(mysql.DATETIME(fsp=6), "mysql")
.with_variant(mysql.DATETIME(fsp=6), "mariadb"),
nullable=False,
)
confirmed_on = Column(DateTime, nullable=True)
+2
View File
@@ -127,6 +127,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
"extra",
"force_screenshot",
"grace_period",
"include_cta",
"last_eval_dttm",
"last_state",
"last_value",
@@ -204,6 +205,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
"extra",
"force_screenshot",
"grace_period",
"include_cta",
"log_retention",
"name",
"recipients",
+4
View File
@@ -188,6 +188,10 @@ class ReportSchedule(AuditMixinNullable, ExtraJSONMixin, Model):
email_subject = Column(String(255))
# (Alerts/Reports) Include the call-to-action link back to Superset in
# notifications? NULL is treated as True.
include_cta = Column(Boolean, default=True, nullable=True)
def __repr__(self) -> str:
return str(self.name)
+1
View File
@@ -38,6 +38,7 @@ class NotificationContent:
# Populated only when this is a per-retry or final-failure notification
retry_attempt: Optional[int] = None
retry_max_attempts: Optional[int] = None
include_cta: bool = True # include the call-to-action link back to Superset
class BaseNotification: # pylint: disable=too-few-public-methods
+31 -10
View File
@@ -148,22 +148,29 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
return parseaddr(current_app.config["SMTP_MAIL_FROM"])[1].split("@")[1]
def _error_template(self, text: str) -> str:
call_to_action = self._get_call_to_action()
# The error text is derived from exception messages that can embed
# data-controlled content (e.g. crafted table/column names in a DB
# error). Strip all HTML before interpolating it into the email body,
# matching the sanitization applied to the normal content path.
# pylint: disable=no-member
safe_text = nh3.clean(text, tags=set(), attributes={})
if self._content.include_cta:
return __(
"""
<p>Your report/alert was unable to be generated because of the following error: %(text)s</p>
<p>Please check your dashboard/chart for errors.</p>
<p><b><a href="%(url)s">%(call_to_action)s</a></b></p>
""", # noqa: E501
text=safe_text,
url=self._content.url,
call_to_action=self._get_call_to_action(),
)
return __(
"""
<p>Your report/alert was unable to be generated because of the following error: %(text)s</p>
<p>Please check your dashboard/chart for errors.</p>
<p><b><a href="%(url)s">%(call_to_action)s</a></b></p>
""", # noqa: E501
text=safe_text,
url=self._content.url,
call_to_action=call_to_action,
)
def _retry_error_template(self, text: str) -> str:
@@ -173,7 +180,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
retries_remaining = (max_attempts or 0) - (attempt or 0)
# pylint: disable=no-member
safe_text = nh3.clean(text, tags=set(), attributes={})
call_to_action = self._get_call_to_action()
cta_tag = self._render_call_to_action_paragraph()
return textwrap.dedent(
f"""
@@ -199,7 +206,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
<p><b>Retry attempt:</b> {attempt} of {max_attempts}
&nbsp;&nbsp; <b>Retries remaining:</b> {retries_remaining}</p>
<p><b>Error details:</b> {safe_text}</p>
<p><b><a href="{self._content.url}">{call_to_action}</a></b></p>
{cta_tag}
</body>
</html>
"""
@@ -209,7 +216,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
"""HTML body for the final-failure email after all retries are exhausted."""
# pylint: disable=no-member
safe_text = nh3.clean(text, tags=set(), attributes={})
call_to_action = self._get_call_to_action()
cta_tag = self._render_call_to_action_paragraph()
max_attempts = self._content.retry_max_attempts
return textwrap.dedent(
@@ -246,7 +253,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
<li>Try generating the report manually to troubleshoot</li>
<li>Contact support if the issue persists</li>
</ul>
<p><b><a href="{self._content.url}">{call_to_action}</a></b></p>
{cta_tag}
</body>
</html>
"""
@@ -312,7 +319,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
"""
)
img_tag = "".join(img_tags)
call_to_action = self._get_call_to_action()
call_to_action_tag = self._render_call_to_action_tag()
body = textwrap.dedent(
f"""
<html>
@@ -333,7 +340,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
<body>
<div>{description}</div>
<br>
<b><a href="{self._content.url}">{call_to_action}</a></b><p></p>
{call_to_action_tag}
{html_table}
{img_tag}
</body>
@@ -404,6 +411,20 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
def _get_call_to_action(self) -> str:
return __(current_app.config["EMAIL_REPORTS_CTA"])
def _render_call_to_action_tag(self) -> str:
"""Anchor markup for the call-to-action link, or "" when disabled."""
if not self._content.include_cta:
return ""
call_to_action = self._get_call_to_action()
return f'<b><a href="{self._content.url}">{call_to_action}</a></b><p></p>'
def _render_call_to_action_paragraph(self) -> str:
"""CTA link wrapped in a paragraph, or "" when disabled."""
if not self._content.include_cta:
return ""
call_to_action = self._get_call_to_action()
return f'<p><b><a href="{self._content.url}">{call_to_action}</a></b></p>'
def _get_to(self) -> str:
return json.loads(self._recipient.recipient_config_json)["target"]
+15 -3
View File
@@ -31,18 +31,30 @@ class SlackMixin:
content: NotificationContent,
table: str = "",
) -> str:
return __(
"""*%(name)s*
if content.include_cta:
return __(
"""*%(name)s*
%(description)s
<%(url)s|Explore in Superset>
%(table)s
""",
name=content.name,
description=content.description or "",
url=content.url,
table=table,
)
return __(
"""*%(name)s*
%(description)s
%(table)s
""",
name=content.name,
description=content.description or "",
url=content.url,
table=table,
)
+14
View File
@@ -74,6 +74,10 @@ name_description = "The report schedule name."
# :)
description_description = "Use a nice description to give context to this Alert/Report"
email_subject_description = "The report schedule subject line"
include_cta_description = (
"Whether to include the call-to-action link back to Superset "
"(e.g. 'Explore in Superset') in the delivered notifications"
)
context_markdown_description = "Markdown description"
crontab_description = (
"A CRON expression."
@@ -269,6 +273,11 @@ class ReportSchedulePostSchema(Schema):
dump_default=None,
)
force_screenshot = fields.Boolean(dump_default=False)
include_cta = fields.Boolean(
dump_default=True,
allow_none=True,
metadata={"description": include_cta_description},
)
custom_width = fields.Integer(
metadata={
"description": _("Custom width of the screenshot in pixels"),
@@ -482,6 +491,11 @@ class ReportSchedulePutSchema(Schema):
)
extra = fields.Dict(dump_default=None)
force_screenshot = fields.Boolean(dump_default=False)
include_cta = fields.Boolean(
dump_default=True,
allow_none=True,
metadata={"description": include_cta_description},
)
custom_width = fields.Integer(
metadata={
+3
View File
@@ -69,6 +69,9 @@ class UserSchema(PermissiveSchema):
username = fields.String()
first_name = fields.String()
last_name = fields.String()
attributes = fields.Dict(
keys=fields.String(), values=fields.Raw(allow_none=True), allow_none=True
)
class ResourceSchema(PermissiveSchema):
+1
View File
@@ -116,6 +116,7 @@ class GuestTokenUser(TypedDict, total=False):
username: str
first_name: str
last_name: str
attributes: Optional[dict[str, Any]]
class GuestTokenResourceType(StrEnum):
+69 -6
View File
@@ -32,6 +32,7 @@ from typing import (
TYPE_CHECKING,
Union,
)
from urllib.parse import quote
from flask import current_app, Flask, g, has_app_context, Request, Response
from flask_appbuilder import Model
@@ -166,6 +167,42 @@ def get_extra_editor_subject_ids(resource: Model) -> list[int]:
return subject_ids
def _render_permission_instructions_link(
*,
datasource_id: str = "",
datasource_name: str = "",
table_names: str = "",
) -> Optional[str]:
"""Render the configured ``PERMISSION_INSTRUCTIONS_LINK``.
The configured URL may contain ``{datasource_id}``, ``{datasource_name}``,
``{table_names}`` and ``{username}`` placeholders, which are substituted with
URL-encoded values so the link can deep-link into an organization's access
request system. A URL with no placeholders is returned unchanged, and an
empty/unset config returns ``None`` (no link). Unsupplied placeholders are
replaced with an empty string.
"""
link = get_conf().get("PERMISSION_INSTRUCTIONS_LINK")
if not link:
return None
username = ""
user = getattr(g, "user", None)
if user is not None and not getattr(user, "is_anonymous", False):
username = getattr(user, "username", "") or ""
for token, value in (
("datasource_id", datasource_id),
("datasource_name", datasource_name),
("table_names", table_names),
("username", username),
):
placeholder = "{" + token + "}"
if placeholder in link:
link = link.replace(placeholder, quote(str(value), safe=""))
return link
DATABASE_PERM_REGEX = re.compile(r"^\[.+\]\.\(id\:(?P<id>\d+)\)$")
@@ -1935,17 +1972,23 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
)
@staticmethod
def get_datasource_access_link( # pylint: disable=unused-argument
def get_datasource_access_link(
datasource: "BaseDatasource | Explorable",
) -> Optional[str]:
"""
Return the link for the denied Superset datasource.
The configured ``PERMISSION_INSTRUCTIONS_LINK`` may template the denied
datasource's id/name (and the current username) into the access URL.
:param datasource: The denied Superset datasource
:returns: The access URL
"""
return get_conf().get("PERMISSION_INSTRUCTIONS_LINK")
return _render_permission_instructions_link(
datasource_id=str(datasource.data["id"]),
datasource_name=str(datasource.data["name"]),
)
def get_datasource_access_error_object( # pylint: disable=invalid-name
self, datasource: "BaseDatasource | Explorable"
@@ -1964,6 +2007,11 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"link": self.get_datasource_access_link(datasource),
"datasource": datasource.data["id"],
"datasource_name": datasource.data["name"],
# Owner display names give the viewer someone to contact for
# access; sorted for a deterministic payload.
"owners": sorted(
str(owner) for owner in getattr(datasource, "owners", []) or []
),
},
)
@@ -2000,17 +2048,32 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
},
)
def get_table_access_link( # pylint: disable=unused-argument
self, tables: set["Table"]
) -> Optional[str]:
def get_table_access_link(self, tables: set["Table"]) -> Optional[str]:
"""
Return the access link for the denied SQL tables.
The configured ``PERMISSION_INSTRUCTIONS_LINK`` may template the denied
table names (and the current username) into the access URL.
:param tables: The set of denied SQL tables
:returns: The access URL
"""
return get_conf().get("PERMISSION_INSTRUCTIONS_LINK")
# Build display names from the raw parts: Table.__str__ URL-encodes
# each segment, and the renderer encodes the whole value again, so
# using it here would double-encode. Sorted for deterministic links.
return _render_permission_instructions_link(
table_names=",".join(
sorted(
".".join(
part
for part in (table.catalog, table.schema, table.table)
if part
)
for table in tables
)
),
)
def get_user_datasources(self) -> list["BaseDatasource"]:
"""
+11 -1
View File
@@ -280,7 +280,17 @@ def _purge_one(
removed_dashboard_slices=result.removed_dashboard_slices,
)
elif result.blocked_reason is not None:
audit.block(record_id)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id)
)
if disposition == "suppressed":
stats_logger_manager.instance.incr(
f"{_METRIC_PREFIX}.blocked_audit_suppressed"
)
elif disposition == "fallback":
stats_logger_manager.instance.incr(
f"{_METRIC_PREFIX}.blocked_audit_dedupe_fallback"
)
else:
audit.fail(record_id)
return result
@@ -532,9 +532,9 @@ msgstr "No se pudo agregar la(s) %s vista(s) semántica(s)"
msgid "%s semantic view(s) failed to add: %s"
msgstr "Error al agregar %s vista(s) semántica(s): %s"
#, fuzzy, python-format
#, python-format
msgid "%s tab selected"
msgstr "%s seleccionado"
msgstr "Pestaña %s seleccionada"
#, python-format
msgid "%s updated"
@@ -1075,13 +1075,11 @@ msgstr ""
" Estos valores intermedios pueden basarse en el tiempo o en la "
"categoría."
#, fuzzy
msgid "A-Z"
msgstr "clave a-z"
msgstr "A-Z"
#, fuzzy
msgid "AND"
msgstr "aleatorio"
msgstr "Y"
#, fuzzy
msgid "API Key Created"
@@ -2727,9 +2725,8 @@ msgstr "Básico"
msgid "Basic conditional formatting"
msgstr "Formato condicional básico"
#, fuzzy
msgid "Basic information about the chart"
msgstr "Información básica"
msgstr "Información básica sobre el gráfico"
#, python-format
msgid "Batch editing %d filters:"
@@ -2753,9 +2750,8 @@ msgstr "Número grande con línea de tendencia"
msgid "Bins"
msgstr "Contenedores"
#, fuzzy
msgid "Blanks"
msgstr "BOOLEANO"
msgstr "En blanco"
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: fr, ru]
#, fuzzy, python-format
@@ -3799,9 +3795,8 @@ msgstr "Haz clic para ordenar de forma descendente"
msgid "Client ID"
msgstr "Anchura de la línea"
#, fuzzy
msgid "Client Secret"
msgstr "Selección de columna"
msgstr "Secreto de cliente"
msgid "Close"
msgstr "Cerrar"
@@ -4281,9 +4276,8 @@ msgstr "La conexión ha fallado; revisa la configuración de tu conexión"
msgid "Connection looks good!"
msgstr ""
#, fuzzy
msgid "Contains"
msgstr "Continuo"
msgstr "Contiene"
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
# sr_Latn]
@@ -4932,9 +4926,8 @@ msgstr "Datos"
msgid "Data Connections"
msgstr "Conexiones de la base de datos"
#, fuzzy
msgid "Data Export Options"
msgstr "Opciones del gráfico"
msgstr "Opciones de exportación de datos"
msgid "Data Table"
msgstr "Tabla de datos"
@@ -5458,9 +5451,9 @@ msgstr[1] "Retrasado (se omitieron %s actualizaciones)"
msgid "Delete"
msgstr "Eliminar"
#, fuzzy, python-format
#, python-format
msgid "Delete %s"
msgstr "Se ha eliminado%s"
msgstr "Eliminar %s"
#, python-format
msgid "Delete %s?"
@@ -5627,7 +5620,7 @@ msgstr[1] "Se han eliminado%(num)d conjuntos de datos"
#, python-format
msgid "Deleted %s"
msgstr "Se ha eliminado%s"
msgstr "Se ha eliminado %s"
#, python-format
msgid "Deleted %s item(s)"
@@ -6234,9 +6227,8 @@ msgstr "Función de agregación dinámica"
msgid "Dynamic Section Label"
msgstr "Direccional"
#, fuzzy
msgid "Dynamic group by"
msgstr "NO AGRUPADO POR"
msgstr "Agrupación dinámica"
#, fuzzy
msgid "Dynamic section description"
@@ -6444,9 +6436,8 @@ msgstr ""
msgid "Either the username or the password is wrong."
msgstr "El nombre de usuario o la contraseña son incorrectos."
#, fuzzy
msgid "Elapsed"
msgstr "Volver a cargar"
msgstr "Transcurrido"
msgid "Elevation"
msgstr "Elevación"
@@ -6611,9 +6602,8 @@ msgstr "Fecha final excluida del rango de tiempo"
msgid "End date must be after start date"
msgstr "La fecha final debe ser posterior a la fecha inicial"
#, fuzzy
msgid "Ends With"
msgstr "Anchura del borde"
msgstr "Termina con"
#, python-format
msgid "Ends with (ILIKE %x)"
@@ -6719,9 +6709,8 @@ msgstr "Tamaños de fecha iguales"
msgid "Equal to (=)"
msgstr "Igual a (=)"
#, fuzzy
msgid "Equals"
msgstr "Secuencial"
msgstr "Igual a"
msgid "Error"
msgstr "Error"
@@ -6982,13 +6971,11 @@ msgstr "Explorar el conjunto de resultados en la vista de exploración de datos"
msgid "Export"
msgstr "Exportar"
#, fuzzy
msgid "Export All Data"
msgstr "Borrar todos los datos"
msgstr "Exportar todos los datos"
#, fuzzy
msgid "Export Current View"
msgstr "Invertir página actual"
msgstr "Exportar la vista actual"
msgid "Export Data to Excel"
msgstr ""
@@ -6996,9 +6983,8 @@ msgstr ""
msgid "Export Images to Excel"
msgstr ""
#, fuzzy
msgid "Export YAML"
msgstr "Nombre del informe"
msgstr "Exportar YAML"
#, fuzzy
msgid "Export as Example"
@@ -7339,16 +7325,14 @@ msgstr ""
"Usando CSV como alternativa; la biblioteca de exportación a Excel no está"
" disponible."
#, fuzzy
msgid "False"
msgstr "Es falso"
msgstr "Falso"
msgid "Favorite"
msgstr "Favorito"
#, fuzzy
msgid "Feature Not Enabled"
msgstr "El túnel SSH no está habilitado"
msgstr "Función no habilitada"
msgid "Featured"
msgstr "Destacado"
@@ -7922,9 +7906,8 @@ msgstr "Tamaño de la cuadrícula"
msgid "Grid view"
msgstr "Tamaño de la cuadrícula"
#, fuzzy
msgid "Group"
msgstr "Agrupar por"
msgstr "Grupo"
msgid "Group By"
msgstr "Agrupar por"
@@ -8232,9 +8215,8 @@ msgstr "En"
msgid "In Progress"
msgstr "Progreso"
#, fuzzy
msgid "In Range"
msgstr "Intervalo de tiempo"
msgstr "En el intervalo"
msgid ""
"In order to connect to non-public sheets you need to either provide a "
@@ -10084,9 +10066,8 @@ msgstr "No se han conservado los ajustes del formulario"
msgid "No global filters are currently added"
msgstr "Actualmente no se han añadido filtros globales"
#, fuzzy
msgid "No groups"
msgstr "NO AGRUPADO POR"
msgstr "Sin grupos"
#, fuzzy
msgid "No groups yet"
@@ -10226,9 +10207,8 @@ msgstr "Normalizar nombres de columnas"
msgid "Normalized"
msgstr "Normalizado"
#, fuzzy
msgid "Not Contains"
msgstr "Denunciar un contenido"
msgstr "No contiene"
#, fuzzy
msgid "Not Equal"
@@ -10402,9 +10382,8 @@ msgstr "OCT"
msgid "OK"
msgstr "OK"
#, fuzzy
msgid "OR"
msgstr "o"
msgstr "O"
#. do-not-translate
msgid "OVERWRITE"
@@ -10878,9 +10857,8 @@ msgstr "Establecer intervalo de actualización automática"
msgid "Pending"
msgstr "pendiente"
#, fuzzy
msgid "Per user caching"
msgstr "Porcentaje de cambio"
msgstr "Caché por usuario"
msgid "Percent Change"
msgstr "Porcentaje de cambio"
@@ -11607,9 +11585,8 @@ msgstr "Consulta la"
msgid "Referenced columns not available in DataFrame."
msgstr "Las columnas referenciadas no están disponibles en el marco de datos."
#, fuzzy
msgid "Referrer"
msgstr "Actualizar"
msgstr "Referrer"
msgid "Refetch results"
msgstr "Volver a obtener resultados"
@@ -11709,9 +11686,8 @@ msgstr "Eliminar"
msgid "Remove System Dark Theme"
msgstr "Eliminar tema oscuro del sistema"
#, fuzzy
msgid "Remove System Default Theme"
msgstr "Actualizar los valores predeterminados"
msgstr "Eliminar tema predeterminado del sistema"
msgid "Remove cross-filter"
msgstr "Eliminar filtro cruzado"
@@ -12310,9 +12286,8 @@ msgstr "Los parámetros del túnel SSH no son válidos."
msgid "SSH Tunneling is not enabled"
msgstr "El túnel SSH no está habilitado"
#, fuzzy
msgid "SSL"
msgstr "sql"
msgstr "SSL"
msgid "SSL Mode \"require\" will be used."
msgstr "Se utilizará el modo SSL «requerido»."
@@ -12638,9 +12613,8 @@ msgstr "Seguridad adicional"
msgid "Security"
msgstr "Seguridad"
#, fuzzy
msgid "See "
msgstr "series"
msgstr "Ver "
#, python-format
msgid "See all %(tableName)s"
@@ -12668,9 +12642,8 @@ msgstr "Selecciona %s o escribe para buscar %s"
msgid "Select ..."
msgstr "Seleccionar …"
#, fuzzy
msgid "Select All"
msgstr "Deseleccionar todo"
msgstr "Seleccionar todo"
#, fuzzy
msgid "Select Database and Schema"
@@ -12787,9 +12760,8 @@ msgstr "Selecciona un tipo de visualización"
msgid "Select aggregate options"
msgstr "Selecciona las opciones de agregación"
#, fuzzy
msgid "Select all"
msgstr "Deseleccionar todo"
msgstr "Seleccionar todo"
msgid "Select all data"
msgstr "Seleccionar todos los datos"
@@ -13732,9 +13704,8 @@ msgstr "Muestra una lista de todas las series disponibles en ese momento"
msgid "Shows or hides markers for the time series"
msgstr "Muestra u oculta marcadores para la serie temporal"
#, fuzzy
msgid "Sign in"
msgstr "No está en"
msgstr "Iniciar sesión"
#, fuzzy
msgid "Sign in with"
@@ -14158,13 +14129,11 @@ msgstr ""
msgid "Started"
msgstr "Iniciado"
#, fuzzy
msgid "Starts With"
msgstr "Anchura del gráfico"
msgstr "Empieza con"
#, fuzzy
msgid "Starts with (ILIKE x%)"
msgstr "Anchura del gráfico"
msgstr "Empieza con (ILIKE x%)"
msgid "State"
msgstr "Estado"
@@ -17085,9 +17054,8 @@ msgstr "Activar alerta si..."
msgid "Trigger now"
msgstr ""
#, fuzzy
msgid "True"
msgstr "MAR"
msgstr "Verdadero"
msgid "Truncate Axis"
msgstr ""
@@ -17248,17 +17216,14 @@ msgstr ""
"estén configurados: «bigquery.readsessions.create», "
"«bigquery.readsessions.getData»."
#, fuzzy
msgid ""
"Unable to connect. Verify that the following roles are set on the service"
" account: \"Cloud Datastore Viewer\", \"Cloud Datastore User\", \"Cloud "
"Datastore Creator\""
msgstr ""
"No se ha podido conectar. Comprueba si los siguientes roles están "
"configurados en la cuenta de servicio: «BigQuery Data Viewer», «BigQuery "
"Metadata Viewer», «BigQuery Job User»; y que los siguientes permisos "
"estén configurados: «bigquery.readsessions.create», "
"«bigquery.readsessions.getData»."
"No se ha podido conectar. Comprueba que los siguientes roles estén "
"configurados en la cuenta de servicio: «Cloud Datastore Viewer», «Cloud "
"Datastore User», «Cloud Datastore Creator»"
msgid "Unable to create chart without a query id."
msgstr "No se puede crear un gráfico sin un ID de consulta."
@@ -17800,9 +17765,8 @@ msgstr "Valor"
msgid "Value Aggregation"
msgstr "Agregación"
#, fuzzy
msgid "Value Columns"
msgstr "Columnas de la tabla"
msgstr "Columnas de valores"
msgid "Value Domain"
msgstr "Dominio del valor"
@@ -19142,9 +19106,8 @@ msgstr "Su sesión ha finalizado. Por favor, inicie sesión de nuevo."
msgid "Your user information"
msgstr "Información general"
#, fuzzy
msgid "Z-A"
msgstr "clave z-a"
msgstr "Z-A"
msgid "ZIP file contains multiple file types"
msgstr "El archivo ZIP contiene varios tipos de archivos"
@@ -20139,9 +20102,8 @@ msgstr "Hora/fecha"
msgid "timestamp"
msgstr "Mostrar marca de tiempo"
#, fuzzy
msgid "to"
msgstr "arriba"
msgstr "a"
msgid "top"
msgstr "arriba"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+25 -1
View File
@@ -61,6 +61,7 @@ from typing import (
)
from urllib.parse import unquote_plus, urlparse
from zipfile import ZipFile
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import markdown as md
import nh3
@@ -1965,6 +1966,7 @@ class DateColumn:
timestamp_format: str | None = None
offset: int | None = None
time_shift: str | None = None
timezone: str | None = None # IANA timezone name
def __hash__(self) -> int:
return hash(self.col_label)
@@ -1978,11 +1980,13 @@ class DateColumn:
timestamp_format: str | None,
offset: int | None,
time_shift: str | None,
timezone: str | None = None,
) -> DateColumn:
return cls(
timestamp_format=timestamp_format,
offset=offset,
time_shift=time_shift,
timezone=timezone,
col_label=DTTM_ALIAS,
)
@@ -2080,8 +2084,28 @@ def normalize_dttm_col(
_process_datetime_column(df, _col)
if _col.offset:
if _col.timezone and isinstance(_col.timezone, str):
try:
tz = ZoneInfo(_col.timezone)
# Data is stored in UTC, convert to the dataset's configured timezone
# First make the datetime UTC-aware, then convert to target timezone
series = df[_col.col_label]
if not series.empty and series.notna().any():
# Convert UTC to target timezone
df[_col.col_label] = (
series.dt.tz_localize("UTC")
.dt.tz_convert(tz)
.dt.tz_localize(None) # Remove timezone info for display
)
except ZoneInfoNotFoundError:
logging.warning(
"Unknown timezone '%s', falling back to offset", _col.timezone
)
if _col.offset:
df[_col.col_label] += timedelta(hours=_col.offset)
elif _col.offset:
df[_col.col_label] += timedelta(hours=_col.offset)
if _col.time_shift is not None:
df[_col.col_label] += parse_human_timedelta(_col.time_shift)
@@ -48,7 +48,7 @@ NUMPY_FUNCTIONS: dict[str, Callable[..., Any]] = {
"min": np.min,
"percentile": np.percentile,
"prod": np.prod,
"product": np.product,
"product": np.prod,
"std": np.std,
"sum": np.sum,
"var": np.var,
+1
View File
@@ -636,6 +636,7 @@ def cached_common_bootstrap_data( # pylint: disable=unused-argument
"extra_categorical_color_schemes": app.config[
"EXTRA_CATEGORICAL_COLOR_SCHEMES"
],
"extra_theme_tokens": app.config["EXTRA_THEME_TOKENS"],
"menu_data": menu_data(g.user),
"pdf_compression_level": app.config["PDF_COMPRESSION_LEVEL"],
"user_subject_id": _get_user_subject_id(user_id),
@@ -178,6 +178,20 @@ class TestExportDatabasesCommand(SupersetTestCase):
metadata = yaml.safe_load(contents["datasets/examples/birth_names.yaml"]())
metadata.pop("uuid")
# Datasets in a database bundle are exported with ``export_uuids=True``,
# so every column/metric carries a ``uuid`` so that custom folder
# references survive the round trip. They are assigned dynamically, so
# build lookups by name.
birth_names = next(
table for table in example_db.tables if table.table_name == "birth_names"
)
column_uuid_map = {
column.column_name: str(column.uuid) for column in birth_names.columns
}
metric_uuid_map = {
metric.metric_name: str(metric.uuid) for metric in birth_names.metrics
}
metadata["columns"].sort(key=lambda x: x["column_name"])
expected_metadata = {
"cache_timeout": None,
@@ -194,6 +208,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
"type": ds_type,
"advanced_data_type": None,
"verbose_name": None,
"uuid": column_uuid_map["ds"],
},
{
"column_name": "gender",
@@ -207,6 +222,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
"type": "STRING" if example_db.backend == "hive" else "VARCHAR(16)",
"advanced_data_type": None,
"verbose_name": None,
"uuid": column_uuid_map["gender"],
},
{
"column_name": "name",
@@ -222,6 +238,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
),
"advanced_data_type": None,
"verbose_name": None,
"uuid": column_uuid_map["name"],
},
{
"column_name": "num",
@@ -235,6 +252,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
"type": big_int_type,
"advanced_data_type": None,
"verbose_name": None,
"uuid": column_uuid_map["num"],
},
{
"column_name": "num_california",
@@ -248,6 +266,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
"type": None,
"advanced_data_type": None,
"verbose_name": None,
"uuid": column_uuid_map["num_california"],
},
{
"column_name": "state",
@@ -261,6 +280,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
"type": "STRING" if example_db.backend == "hive" else "VARCHAR(10)",
"advanced_data_type": None,
"verbose_name": None,
"uuid": column_uuid_map["state"],
},
{
"column_name": "num_boys",
@@ -274,6 +294,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
"type": big_int_type,
"advanced_data_type": None,
"verbose_name": None,
"uuid": column_uuid_map["num_boys"],
},
{
"column_name": "num_girls",
@@ -287,6 +308,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
"type": big_int_type,
"advanced_data_type": None,
"verbose_name": None,
"uuid": column_uuid_map["num_girls"],
},
],
"database_uuid": str(db_uuid),
@@ -306,6 +328,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
"metric_type": "count",
"verbose_name": "COUNT(*)",
"warning_text": None,
"uuid": metric_uuid_map["count"],
},
{
"d3format": None,
@@ -316,6 +339,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
"metric_type": None,
"verbose_name": None,
"warning_text": None,
"uuid": metric_uuid_map["sum__num"],
},
],
"offset": 0,
@@ -95,6 +95,14 @@ class TestExportDatasetsCommand(SupersetTestCase):
type_map = {
column.column_name: str(column.type) for column in example_dataset.columns
}
# column/metric UUIDs are exported so folder references survive import;
# they are assigned dynamically, so build lookups by name.
column_uuid_map = {
column.column_name: str(column.uuid) for column in example_dataset.columns
}
metric_uuid_map = {
metric.metric_name: str(metric.uuid) for metric in example_dataset.metrics
}
assert metadata == {
"cache_timeout": None,
@@ -115,6 +123,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"advanced_data_type": None,
"verbose_name": None,
"extra": None,
"uuid": column_uuid_map["source"],
},
{
"column_name": "target",
@@ -127,6 +136,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"is_dttm": False,
"python_date_format": None,
"type": type_map["target"],
"uuid": column_uuid_map["target"],
"advanced_data_type": None,
"verbose_name": None,
"extra": None,
@@ -145,6 +155,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"advanced_data_type": None,
"verbose_name": None,
"extra": None,
"uuid": column_uuid_map["value"],
},
],
"database_uuid": str(example_db.uuid),
@@ -165,6 +176,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"metric_type": "count",
"verbose_name": "COUNT(*)",
"warning_text": None,
"uuid": metric_uuid_map["count"],
},
{
"currency": None,
@@ -176,6 +188,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
"metric_type": None,
"verbose_name": None,
"warning_text": None,
"uuid": metric_uuid_map["sum__value"],
},
],
"folders": None,
@@ -19,9 +19,13 @@
from __future__ import annotations
from datetime import datetime, timedelta
from unittest.mock import patch
from typing import Callable
from unittest.mock import MagicMock, patch
from uuid import UUID
import pytest
from sqlalchemy.orm import Session
from superset import db
from superset.commands.deletion_retention import audit
from superset.commands.deletion_retention.audit import PurgeAuditLog
@@ -32,6 +36,31 @@ from ._base import DeletionRetentionTestBase
class TestPurgeAudit(DeletionRetentionTestBase):
def _get_audit_record(self, record_id: UUID) -> PurgeAuditLog:
record: PurgeAuditLog | None = db.session.get(PurgeAuditLog, record_id)
assert record is not None
return record
def _write_retention_record(
self,
*,
entity_uuid: str | None,
entity_type: str = "slices",
created_on: datetime | None = None,
) -> UUID:
record_id: UUID | None = audit.write_ahead(
trigger=audit.TRIGGER_RETENTION,
actor=audit.ACTOR_SYSTEM,
entity_type=entity_type,
entity_uuid=entity_uuid,
)
assert record_id is not None
if created_on is not None:
record: PurgeAuditLog = self._get_audit_record(record_id)
record.created_on = created_on
db.session.commit()
return record_id
def test_write_ahead_then_confirm(self) -> None:
"""A purge writes a pending audit row up front and flips it to
confirmed after the delete commits."""
@@ -160,3 +189,336 @@ class TestPurgeAudit(DeletionRetentionTestBase):
row = db.session.query(PurgeAuditLog).filter_by(id=record_id).one()
assert row.status == audit.STATUS_BLOCKED
assert row.removed_dashboard_slices == 0
def test_first_retention_block_is_retained(self) -> None:
record_id: UUID = self._write_retention_record(entity_uuid="first-block")
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
assert disposition == "retained"
assert record.status == audit.STATUS_BLOCKED
def test_repeated_retention_block_suppresses_current_provisional(self) -> None:
first_id: UUID = self._write_retention_record(entity_uuid="repeat-block")
audit.finalize_retention_blocked(first_id)
second_id: UUID = self._write_retention_record(entity_uuid="repeat-block")
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(second_id)
)
assert disposition == "suppressed"
first: PurgeAuditLog = self._get_audit_record(first_id)
assert first.status == audit.STATUS_BLOCKED
assert db.session.get(PurgeAuditLog, second_id) is None
def test_equal_timestamp_is_ambiguous_and_retains_current(self) -> None:
timestamp: datetime = datetime.utcnow()
first_id: UUID = self._write_retention_record(
entity_uuid="equal-time", created_on=timestamp
)
audit.finalize_retention_blocked(first_id)
second_id: UUID = self._write_retention_record(
entity_uuid="equal-time", created_on=timestamp
)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(second_id)
)
assert disposition == "retained"
second: PurgeAuditLog = self._get_audit_record(second_id)
assert second.status == audit.STATUS_BLOCKED
def test_tied_mixed_predecessors_are_ambiguous_and_retain_current(self) -> None:
timestamp: datetime = datetime.utcnow()
blocked_id: UUID = self._write_retention_record(
entity_uuid="mixed-tie", created_on=timestamp
)
audit.finalize(blocked_id, audit.STATUS_BLOCKED)
failed_id: UUID = self._write_retention_record(
entity_uuid="mixed-tie", created_on=timestamp
)
audit.finalize(failed_id, audit.STATUS_FAILED)
current_id: UUID = self._write_retention_record(
entity_uuid="mixed-tie", created_on=timestamp + timedelta(seconds=1)
)
session: Session = audit._dedicated_session()
try:
current: PurgeAuditLog | None = session.get(PurgeAuditLog, current_id)
assert current is not None
predecessor: PurgeAuditLog | None = audit._retention_predecessor(
session, current
)
finally:
session.close()
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id)
)
assert predecessor is None
assert disposition == "retained"
retained: PurgeAuditLog = self._get_audit_record(current_id)
assert retained.status == audit.STATUS_BLOCKED
def test_newer_concurrent_row_does_not_become_a_predecessor(self) -> None:
current_time: datetime = datetime.utcnow()
current_id: UUID = self._write_retention_record(
entity_uuid="overlap", created_on=current_time
)
newer_id: UUID = self._write_retention_record(
entity_uuid="overlap", created_on=current_time + timedelta(seconds=1)
)
audit.finalize_retention_blocked(newer_id)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id)
)
assert disposition == "retained"
current: PurgeAuditLog = self._get_audit_record(current_id)
assert current.status == audit.STATUS_BLOCKED
def test_pending_predecessor_retains_current_block(self) -> None:
timestamp: datetime = datetime.utcnow()
self._write_retention_record(entity_uuid="pending-prior", created_on=timestamp)
current_id: UUID = self._write_retention_record(
entity_uuid="pending-prior", created_on=timestamp + timedelta(seconds=1)
)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id)
)
assert disposition == "retained"
def test_null_uuid_retains_blocked_evidence(self) -> None:
null_id: UUID = self._write_retention_record(entity_uuid=None)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(null_id)
)
record: PurgeAuditLog = self._get_audit_record(null_id)
assert disposition == "retained"
assert record.status == audit.STATUS_BLOCKED
assert record.removed_dashboard_slices == 0
def test_same_uuid_across_entity_types_does_not_suppress(self) -> None:
chart_id: UUID = self._write_retention_record(
entity_uuid="shared-type", entity_type="slices"
)
audit.finalize_retention_blocked(chart_id)
dashboard_id: UUID = self._write_retention_record(
entity_uuid="shared-type", entity_type="dashboards"
)
dashboard_disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(dashboard_id)
)
assert dashboard_disposition == "retained"
def test_completed_current_record_is_immutable(self) -> None:
record_id: UUID = self._write_retention_record(entity_uuid="completed")
audit.fail(record_id)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
assert disposition == "retained"
assert record.status == audit.STATUS_FAILED
def test_predecessor_lookup_failure_recovers_blocked_evidence(self) -> None:
record_id: UUID = self._write_retention_record(entity_uuid="lookup-failure")
with patch(
"superset.commands.deletion_retention.audit._retention_predecessor",
side_effect=audit.SQLAlchemyError("lookup failed"),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
assert disposition == "fallback"
assert record.status == audit.STATUS_BLOCKED
def test_suppression_delete_failure_recovers_blocked_evidence(self) -> None:
first_id: UUID = self._write_retention_record(entity_uuid="delete-failure")
audit.finalize_retention_blocked(first_id)
current_id: UUID = self._write_retention_record(entity_uuid="delete-failure")
with patch(
"superset.commands.deletion_retention.audit._suppress_redundant_block",
side_effect=audit.SQLAlchemyError("delete failed"),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id)
)
record: PurgeAuditLog = self._get_audit_record(current_id)
assert disposition == "fallback"
assert record.status == audit.STATUS_BLOCKED
def test_commit_failure_recovers_blocked_evidence(self) -> None:
record_id: UUID = self._write_retention_record(entity_uuid="commit-failure")
primary_session: Session = audit._dedicated_session()
recovery_session: Session = audit._dedicated_session()
with (
patch.object(
primary_session,
"commit",
side_effect=audit.SQLAlchemyError("commit failed"),
),
patch(
"superset.commands.deletion_retention.audit._dedicated_session",
side_effect=[primary_session, recovery_session],
),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
assert disposition == "fallback"
assert record.status == audit.STATUS_BLOCKED
def test_uncertain_suppression_commit_recreates_absent_evidence(self) -> None:
first_id: UUID = self._write_retention_record(entity_uuid="absent-current")
audit.finalize_retention_blocked(first_id)
current_id: UUID = self._write_retention_record(entity_uuid="absent-current")
primary_session: Session = audit._dedicated_session()
recovery_session: Session = audit._dedicated_session()
primary_commit: Callable[[], None] = primary_session.commit
def commit_then_raise() -> None:
primary_commit()
raise audit.SQLAlchemyError("commit acknowledgement lost")
with (
patch.object(primary_session, "commit", side_effect=commit_then_raise),
patch(
"superset.commands.deletion_retention.audit._dedicated_session",
side_effect=[primary_session, recovery_session],
),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id)
)
record: PurgeAuditLog = self._get_audit_record(current_id)
assert disposition == "fallback"
assert record.status == audit.STATUS_BLOCKED
def test_failed_fallback_leaves_pending_evidence_for_reconciliation(self) -> None:
record_id: UUID = self._write_retention_record(entity_uuid="fallback-failure")
primary_session: Session = audit._dedicated_session()
recovery_session: Session = audit._dedicated_session()
with (
patch.object(
primary_session,
"commit",
side_effect=audit.SQLAlchemyError("primary commit failed"),
),
patch.object(
recovery_session,
"commit",
side_effect=audit.SQLAlchemyError("recovery commit failed"),
),
patch(
"superset.commands.deletion_retention.audit._dedicated_session",
side_effect=[primary_session, recovery_session],
),
):
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(record_id)
)
record: PurgeAuditLog = self._get_audit_record(record_id)
assert disposition == "fallback"
assert record.status == audit.STATUS_PENDING
def test_indeterminate_delete_rowcount_forces_persistence_recovery(self) -> None:
timestamp: datetime = datetime.utcnow()
current: PurgeAuditLog = PurgeAuditLog(
id=UUID("00000000-0000-0000-0000-000000000002"),
status=audit.STATUS_PENDING,
trigger=audit.TRIGGER_RETENTION,
actor=audit.ACTOR_SYSTEM,
entity_type="slices",
entity_uuid="indeterminate-rowcount",
created_on=timestamp,
)
predecessor: PurgeAuditLog = PurgeAuditLog(
id=UUID("00000000-0000-0000-0000-000000000001"),
status=audit.STATUS_BLOCKED,
trigger=audit.TRIGGER_RETENTION,
actor=audit.ACTOR_SYSTEM,
entity_type="slices",
entity_uuid="indeterminate-rowcount",
created_on=timestamp - timedelta(seconds=1),
)
result: MagicMock = MagicMock(rowcount=-1)
session: MagicMock = MagicMock()
session.execute.return_value = result
with pytest.raises(audit.SQLAlchemyError, match="indeterminate"):
audit._suppress_redundant_block(session, current, predecessor)
def test_overlap_duplicates_do_not_cause_unbounded_sequential_growth(self) -> None:
timestamp: datetime = datetime.utcnow()
first_id: UUID = self._write_retention_record(
entity_uuid="bounded-overlap", created_on=timestamp
)
audit.finalize_retention_blocked(first_id)
overlap_id: UUID = self._write_retention_record(
entity_uuid="bounded-overlap", created_on=timestamp
)
audit.finalize_retention_blocked(overlap_id)
later_id: UUID = self._write_retention_record(
entity_uuid="bounded-overlap",
created_on=timestamp + timedelta(seconds=1),
)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(later_id)
)
retained_count: int = (
db.session.query(PurgeAuditLog)
.filter_by(entity_uuid="bounded-overlap")
.count()
)
assert disposition == "suppressed"
assert retained_count == 2
def test_meaningful_outcome_transitions_start_new_blocked_periods(self) -> None:
transition: tuple[int, str]
for transition in enumerate(
(
audit.STATUS_FAILED,
audit.STATUS_CONFIRMED,
audit.STATUS_TARGET_ABSENT,
)
):
index: int = transition[0]
status: str = transition[1]
entity_uuid: str = f"transition-{index}"
prior_id: UUID = self._write_retention_record(entity_uuid=entity_uuid)
audit.finalize(prior_id, status)
current_id: UUID = self._write_retention_record(entity_uuid=entity_uuid)
disposition: audit.RetentionBlockedDisposition = (
audit.finalize_retention_blocked(current_id)
)
current: PurgeAuditLog = self._get_audit_record(current_id)
assert disposition == "retained"
assert current.status == audit.STATUS_BLOCKED
@@ -24,6 +24,7 @@ from unittest.mock import patch
import pytest
from superset import db
from superset.commands.deletion_retention import audit
from superset.commands.deletion_retention.audit import PurgeAuditLog
from superset.commands.deletion_retention.force_purge import (
AmbiguousPurgeTargetError,
@@ -33,6 +34,7 @@ from superset.connectors.sqla.models import SqlaTable
from superset.models.dashboard import Dashboard
from superset.models.slice import Slice
from superset.reports.models import ReportSchedule
from superset.tasks.deletion_retention import _purge_impl
from ._base import DeletionRetentionTestBase
@@ -145,6 +147,36 @@ class TestForcePurge(DeletionRetentionTestBase):
"associated alerts or reports exist",
)
def test_force_block_does_not_change_scheduled_deduplication_stream(self) -> None:
chart: Slice = self.make_chart("independent_block_streams")
report: ReportSchedule = ReportSchedule(
type="Report",
name="retention_it_independent_block_streams",
crontab="0 0 * * *",
chart=chart,
)
db.session.add(report)
db.session.commit()
chart_uuid: str = str(chart.uuid)
self.soft_delete(chart, days_ago=90)
first_scheduled: dict[str, object] = _purge_impl(30, dry_run=False)
force_result: dict[str, object] = ForcePurgeCommand(chart_uuid).run()
second_scheduled: dict[str, object] = _purge_impl(30, dry_run=False)
records: list[PurgeAuditLog] = (
db.session.query(PurgeAuditLog).filter_by(entity_uuid=chart_uuid).all()
)
assert first_scheduled["blocked_by_reference"] == 1
assert force_result["reason"] == "blocked"
assert second_scheduled["blocked_by_reference"] == 1
assert sorted((record.trigger, record.status) for record in records) == sorted(
[
(audit.TRIGGER_RETENTION, audit.STATUS_BLOCKED),
(audit.TRIGGER_FORCE, audit.STATUS_BLOCKED),
]
)
def test_force_purge_refuses_an_ambiguous_uuid(self) -> None:
"""A UUID matching two entity types is refused, not guessed.
@@ -47,6 +47,7 @@ from superset.models.slice import Slice
from superset.models.user_attributes import UserAttribute
from superset.reports.models import ReportSchedule
from superset.tags.models import ObjectType, Tag, TaggedObject
from superset.tasks import deletion_retention as deletion_retention_task
from superset.tasks.deletion_retention import _purge_impl
from ._base import DeletionRetentionTestBase
@@ -276,6 +277,80 @@ class TestSoftDeletePurge(DeletionRetentionTestBase):
)
assert row.status == audit.STATUS_BLOCKED
def test_repeated_report_blocker_preserves_counts_and_suppresses_noise(
self,
) -> None:
chart: Slice = self.make_chart("reported_repeatedly")
report: ReportSchedule = ReportSchedule(
type="Report",
name="retention_it_repeated_report",
crontab="0 0 * * *",
chart=chart,
)
db.session.add(report)
db.session.commit()
chart_uuid: str = str(chart.uuid)
self.soft_delete(chart, days_ago=90)
incr: MagicMock
gauge: MagicMock
with (
patch.object(
deletion_retention_task.stats_logger_manager.instance, "incr"
) as incr,
patch.object(
deletion_retention_task.stats_logger_manager.instance, "gauge"
) as gauge,
):
first_result: dict[str, Any] = _purge(window=30)
second_result: dict[str, Any] = _purge(window=30)
assert first_result["blocked_by_reference"] == 1
assert second_result["blocked_by_reference"] == 1
assert (
db.session.query(audit.PurgeAuditLog)
.filter_by(
entity_uuid=chart_uuid,
trigger=audit.TRIGGER_RETENTION,
status=audit.STATUS_BLOCKED,
)
.count()
== 1
)
incr.assert_called_once_with("deletion_retention.blocked_audit_suppressed")
assert gauge.call_count == 2
gauge.assert_called_with("deletion_retention.blocked_by_reference", 1)
def test_blocked_audit_fallback_is_counted_without_changing_task_result(
self,
) -> None:
chart: Slice = self.make_chart("reported_fallback")
report: ReportSchedule = ReportSchedule(
type="Report",
name="retention_it_fallback_report",
crontab="0 0 * * *",
chart=chart,
)
db.session.add(report)
db.session.commit()
self.soft_delete(chart, days_ago=90)
incr: MagicMock
with (
patch.object(
audit,
"finalize_retention_blocked",
return_value="fallback",
),
patch.object(
deletion_retention_task.stats_logger_manager.instance, "incr"
) as incr,
):
result: dict[str, Any] = _purge(window=30)
assert result["blocked_by_reference"] == 1
incr.assert_called_once_with("deletion_retention.blocked_audit_dedupe_fallback")
def test_restrictive_fk_blocks_dashboard_without_rewriting_referrer(self) -> None:
"""A welcome-dashboard FK remains authoritative during retention."""
dashboard = self.make_dashboard("welcome")

Some files were not shown because too many files have changed in this diff Show More