Compare commits

...

13 Commits

Author SHA1 Message Date
Alexandru Soare
6d08e79259 feat(security): Add extension hooks for custom access control, ownership, and asset lifecycle (#40707) 2026-06-16 15:25:03 +03:00
Geidō
01ed81785e fix(dashboard): required filters reliably apply default + Apply enables on change (#40470) 2026-06-16 11:23:05 +03:00
Vighnesh Tule
7b4efacbc2 fix(charts): add default padding to match other charts (#36895)
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-06-15 21:05:17 -07:00
Amin Ghadersohi
7cb4990403 feat(mcp): add create_dataset tool to register physical tables as datasets (#40340)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:25:29 -04:00
dependabot[bot]
c90b2571d7 chore(deps-dev): bump xlrd from 2.0.1 to 2.0.2 (#41083)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:19:43 -07:00
dependabot[bot]
1a4941eee5 chore(deps-dev): bump hdbcli from 2.28.20 to 2.28.21 (#41084)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:19:33 -07:00
dependabot[bot]
d839cca995 chore(deps-dev): update pyocient requirement from <2,>=1.0.15 to >=1.0.15,<4 (#40941)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-15 18:18:25 -07:00
dependabot[bot]
0ec7e7df99 chore(deps): bump dompurify from 3.4.8 to 3.4.9 in /superset-frontend (#41089)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:16:36 -07:00
dependabot[bot]
9d8287e1bd chore(deps-dev): bump @typescript-eslint/parser from 8.60.1 to 8.61.0 in /superset-websocket (#41090)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:16:21 -07:00
dependabot[bot]
0c696cea7e chore(deps): bump google-auth-library from 10.6.2 to 10.7.0 in /superset-frontend (#41091)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:16:05 -07:00
dependabot[bot]
fe625a917e chore(deps-dev): bump @typescript-eslint/parser from 8.60.1 to 8.61.0 in /docs (#41093)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:14:51 -07:00
dependabot[bot]
a69f9eb00d chore(deps-dev): bump oxlint from 1.68.0 to 1.69.0 in /superset-frontend (#41094)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 18:14:27 -07:00
Evan Rusackas
1311d040ba feat(deckgl): add point radius controls for GeoJSON layer (#33247)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-06-15 17:38:43 -07:00
41 changed files with 2010 additions and 165 deletions

View File

@@ -101,7 +101,7 @@
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.60.1",
"@typescript-eslint/parser": "^8.61.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.6",

View File

@@ -4936,7 +4936,7 @@
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@typescript-eslint/parser@8.60.1", "@typescript-eslint/parser@^8.60.1":
"@typescript-eslint/parser@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.60.1.tgz#a9d7f30850384d34b41f4687dd8944823c09e289"
integrity sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==
@@ -4947,6 +4947,17 @@
"@typescript-eslint/visitor-keys" "8.60.1"
debug "^4.4.3"
"@typescript-eslint/parser@^8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.61.0.tgz#1afe73c9ccce16b7a26d6b95f9400b0ccc34af87"
integrity sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==
dependencies:
"@typescript-eslint/scope-manager" "8.61.0"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/typescript-estree" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
debug "^4.4.3"
"@typescript-eslint/project-service@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.60.1.tgz#eb29712f58d72c222fc727162e92f2ab4670971b"
@@ -4956,6 +4967,15 @@
"@typescript-eslint/types" "^8.60.1"
debug "^4.4.3"
"@typescript-eslint/project-service@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.61.0.tgz#417a2feac32e8ebd336d63f068c3b42b736ea1ac"
integrity sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.61.0"
"@typescript-eslint/types" "^8.61.0"
debug "^4.4.3"
"@typescript-eslint/scope-manager@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz#2f875962eaad0a0789cc3c36aea9b4ddeb2dd9c8"
@@ -4964,16 +4984,29 @@
"@typescript-eslint/types" "8.60.1"
"@typescript-eslint/visitor-keys" "8.60.1"
"@typescript-eslint/scope-manager@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz#93c2520d05653fe65eb9ee98efc74fd0134a7852"
integrity sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==
dependencies:
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
"@typescript-eslint/tsconfig-utils@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz#bee8b942a13679a878101c9c74577d732062ed93"
integrity sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==
"@typescript-eslint/tsconfig-utils@^8.60.1":
"@typescript-eslint/tsconfig-utils@8.61.0", "@typescript-eslint/tsconfig-utils@^8.60.1":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz#05d6e3ff20001674ebcd22d03dac29ee448043ba"
integrity sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==
"@typescript-eslint/tsconfig-utils@^8.61.0":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz#ca88080e0cf191d49516d7f300b67aa090d2254f"
integrity sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==
"@typescript-eslint/type-utils@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz#1ae45f0f2a701354beea4a58c2161e40a5e3c379"
@@ -4990,11 +5023,16 @@
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.60.1.tgz#ccdc482ba9e17f9723a10ce240b5e67dad3046c4"
integrity sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==
"@typescript-eslint/types@^8.60.1":
"@typescript-eslint/types@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.0.tgz#0ddb46e012a4288292950bdd253db42f278ce64d"
integrity sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==
"@typescript-eslint/types@^8.60.1", "@typescript-eslint/types@^8.61.0":
version "8.61.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.1.tgz#0c51f518e4e6848371a1c988e859d59eb7522d5a"
integrity sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==
"@typescript-eslint/typescript-estree@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz#016630b119228bf483ddc652703a6a038f3fdd74"
@@ -5010,6 +5048,21 @@
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/typescript-estree@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz#98ca47260bbf627fc28f018b3a0abf00e3090690"
integrity sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==
dependencies:
"@typescript-eslint/project-service" "8.61.0"
"@typescript-eslint/tsconfig-utils" "8.61.0"
"@typescript-eslint/types" "8.61.0"
"@typescript-eslint/visitor-keys" "8.61.0"
debug "^4.4.3"
minimatch "^10.2.2"
semver "^7.7.3"
tinyglobby "^0.2.15"
ts-api-utils "^2.5.0"
"@typescript-eslint/utils@8.60.1":
version "8.60.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.60.1.tgz#31cf566095602d9fe8ad91837d2eb520b8de762b"
@@ -5028,6 +5081,14 @@
"@typescript-eslint/types" "8.60.1"
eslint-visitor-keys "^5.0.0"
"@typescript-eslint/visitor-keys@8.61.0":
version "8.61.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz#39b4e1ab8936d23bea973d39fd092f9aa21f275e"
integrity sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==
dependencies:
"@typescript-eslint/types" "8.61.0"
eslint-visitor-keys "^5.0.0"
"@ungap/structured-clone@^1.0.0":
version "1.3.0"
resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz"

View File

@@ -144,7 +144,7 @@ dynamodb = ["pydynamodb>=0.4.2"]
solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
exasol = ["sqlalchemy-exasol>=2.4.0, <8.0"]
excel = ["xlrd>=1.2.0, <1.3"]
excel = ["xlrd>=2.0.2, <2.1"]
fastmcp = [
"fastmcp>=3.2.4,<4.0",
# tiktoken backs the response-size-guard token estimator. Without
@@ -156,7 +156,7 @@ firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=26.4.0"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
hana = ["hdbcli==2.28.20", "sqlalchemy_hana==0.4.0"]
hana = ["hdbcli==2.28.21", "sqlalchemy_hana==0.4.0"]
hive = [
"pyhive[hive]>=0.6.5;python_version<'3.11'",
"pyhive[hive_pure_sasl]>=0.7.0",
@@ -173,7 +173,7 @@ motherduck = ["apache-superset[duckdb]"]
mysql = ["mysqlclient>=2.1.0, <3"]
ocient = [
"sqlalchemy-ocient>=1.0.0",
"pyocient>=1.0.15, <2",
"pyocient>=1.0.15, <4",
"shapely",
"geojson",
]

View File

@@ -102,7 +102,7 @@
"geostyler-openlayers-parser": "^5.7.0",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^10.6.2",
"google-auth-library": "^10.7.0",
"immer": "^11.1.8",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -261,7 +261,7 @@
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.68.0",
"oxlint": "^1.69.0",
"po2json": "^0.4.5",
"prettier": "3.8.3",
"prettier-plugin-packagejson": "^3.0.2",
@@ -8324,9 +8324,9 @@
]
},
"node_modules/@oxlint/binding-android-arm-eabi": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.68.0.tgz",
"integrity": "sha512-wEdsIspexXLLMCPAEOcCuFLMt6aE3AzTuA/nQKLPRnoJ+EQTturmGheDkhHuuVHx0GbutjQ3JKmEn+Gz6Ag28Q==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.69.0.tgz",
"integrity": "sha512-DKQQbD5cZ/MYfDgDI7YGyGD9FSxABlsBsYFo5p26lloob543tP9+4N3guwdXIYJN+7HSZxLe8YJuwcOWw5qnHg==",
"cpu": [
"arm"
],
@@ -8341,9 +8341,9 @@
}
},
"node_modules/@oxlint/binding-android-arm64": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.68.0.tgz",
"integrity": "sha512-6aZRNNXQTsYtgaus8HTb9nuCcsrQTlKXGnktwvwW0n/SooRWNxNb3925grDkC63aEYZuCIyOVLV16IdYIoC2aQ==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.69.0.tgz",
"integrity": "sha512-lEhb+I5pr4inux+JFwfCa1HRq3Os7NirEFQ0H1I35SVEHPm6byX0Ah47xmRha3qi6LAkxUcxViL8o/9PivjzBg==",
"cpu": [
"arm64"
],
@@ -8358,9 +8358,9 @@
}
},
"node_modules/@oxlint/binding-darwin-arm64": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.68.0.tgz",
"integrity": "sha512-lVTbsE3kO4bLpZELgjRZuAJc8kP98wb83yMXWH8gaPaFZ+cM2IDeZto4ByoUAYj0Mxv2rvw+A1ssZequSepVSg==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.69.0.tgz",
"integrity": "sha512-GY2YE8lOZW59BW1Ia1y+1gR0XyjrZRvVWHAr8LGeGhYHE0OQJ/7cRKXTkx1P+E9/6awEc3SX8a68SFTjh/E//A==",
"cpu": [
"arm64"
],
@@ -8375,9 +8375,9 @@
}
},
"node_modules/@oxlint/binding-darwin-x64": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.68.0.tgz",
"integrity": "sha512-nCmw2XrmQskjBUh/sfP5yKs93V68LijQgjd1cuuZ/q4SCARngLYs60/qqyzuMsg8QQ9KArDI98hxs/RDGE4KRQ==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.69.0.tgz",
"integrity": "sha512-ax1oZnOjHX3LB7myQyHEaQkDwfLb6str3/nSP6O7EVUviQGNkEGzGV0EqcBJWK+Ufwx0l4xPgyYayurvhAdl2Q==",
"cpu": [
"x64"
],
@@ -8392,9 +8392,9 @@
}
},
"node_modules/@oxlint/binding-freebsd-x64": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.68.0.tgz",
"integrity": "sha512-TI4ovQJliYE9V6e06cEv+qEI9uj7Ao65fmif4er4HD+aouyYyh0P31q2jh3KtqsOHHcQqv2PZ61TjJFLpBDGWQ==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.69.0.tgz",
"integrity": "sha512-kHWeHv4g2h8NY+mpCxzCtY4uerMJWTN/TSnNj1CPbakFpHEJ6cTya2wWV0pDSYWOJ2+0UiEbhn3AtXxHtsnKjg==",
"cpu": [
"x64"
],
@@ -8409,9 +8409,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-gnueabihf": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.68.0.tgz",
"integrity": "sha512-LcNnEi9g71Cmry5ZpLbKT+oVv+/zYG3hYVAbBBB5X85nOQZSk8l92CnDkxJMcxUg0NCnMCOFZuaVDlMyv4tYJw==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.69.0.tgz",
"integrity": "sha512-gq84vM1a1oEehXo27YCDzGVcxPsZDI1yswZwz2Da1/cbnWtrL16XZZnz0G/+gIU8edtHpfjxq5c+vWEHqJfWoQ==",
"cpu": [
"arm"
],
@@ -8426,9 +8426,9 @@
}
},
"node_modules/@oxlint/binding-linux-arm-musleabihf": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.68.0.tgz",
"integrity": "sha512-OovHahL3FX4UaK+hgSf11llUx2vszqjSdQQ61Ck9InOEI/ptZoC4XSQJurITqItVvd53JSlmkLMeaNjM1PoQew==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.69.0.tgz",
"integrity": "sha512-kIqEa98JQ0VRyrcncxA417m2AzasqTlD+FyVT1AksjvjkqQcvm7pBWYvoW3/mpyOP2XYvi5nSCCTIe6De1yu5g==",
"cpu": [
"arm"
],
@@ -8443,13 +8443,16 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-gnu": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.68.0.tgz",
"integrity": "sha512-YbzTglnHLzzi9zv5or8Ztz5fykAoZE8W9iM42/bOrF4HBSB6rJTqdLQWuoP76EHQw9DuKl76K1QmFlG29sPJXQ==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.69.0.tgz",
"integrity": "sha512-j+xYiXozxGWx2cpjCrwwGR4awTxPFsRv3JZrv23RCogEPMc4R7UqjHW47p/RG0aRlbWiROCJ8coUfCwy0dvzHA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8460,13 +8463,16 @@
}
},
"node_modules/@oxlint/binding-linux-arm64-musl": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.68.0.tgz",
"integrity": "sha512-qVKtCZNic+OoNnOr/hCQAu22HSQzflI7Fsq/Blzkw02SnLuv163k3kfmrVpZjSBlUHgsRKj6WgQiw30d3SX02Q==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.69.0.tgz",
"integrity": "sha512-xEPpNppTfN1l/nM7gYSf9iocscu/as+p/7vxkLeLEKnYU+09Dm+5V6IhDYDh+Uz6FajEupWwCLt5SOG0y1PCKg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8477,13 +8483,16 @@
}
},
"node_modules/@oxlint/binding-linux-ppc64-gnu": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.68.0.tgz",
"integrity": "sha512-zExyZ8ZOUuAyQ0y9jpTcyjKUz62YY9JhKPyVxzvjTpXzZ3ujdqiVwfPWDdnA1SsIOrxdtxHn7KErDHLWskFjXg==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.69.0.tgz",
"integrity": "sha512-Ug0+eU7HJBlek+SjklYH62IlOMirEJsdxpihH0kSqX0XdrDD4NdHpQc10fK1JC35yn6KrrcN+uYzlHD38XAf8Q==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8494,13 +8503,16 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-gnu": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.68.0.tgz",
"integrity": "sha512-6C4MPuwewyDavA7sxM14wzgRi5GGL68HPIxRCdVyS75U4MDbpFVYzKO9WNR6KLKTMPq2pcz3THwo1sK2uiqngw==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.69.0.tgz",
"integrity": "sha512-iEyI3GIg0l/s3G4qy2TlaaWKdzj4PJJStwtlocpDTC00PY9hZueotf6OKUj9+yfQh0lrpBW/pLMgTztbAHKJEg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8511,13 +8523,16 @@
}
},
"node_modules/@oxlint/binding-linux-riscv64-musl": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.68.0.tgz",
"integrity": "sha512-bnZooVeHAcvA+dH0EDLgx+7HY/DRi6e0hFszg3P+OBatuUjV6EvfIyNIzWOusmqAVh4L6r21GGTZtiKE4iqM4Q==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.69.0.tgz",
"integrity": "sha512-NjHjpiI4WIKSMwuoJSZi5VToPeoYOS1FR52HLIDG6lidMdqquusgtODb4iLk0+lb1q3Z0nv2/aPRcC/olmpQGg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8528,13 +8543,16 @@
}
},
"node_modules/@oxlint/binding-linux-s390x-gnu": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.68.0.tgz",
"integrity": "sha512-dIqnZnJSmHCMOUpUcWQOiV14o3DDPVx1DSsMaSzvdhNjC1tB1iEPZbdiMSCIEYbkgbsYznHXWqFdKL8WUB3F8g==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.69.0.tgz",
"integrity": "sha512-Ai/prDewoItkDXbp38gwGZi41DycZbUTZJ3UidwoHgQC0/DaqC2TGdtBTQLJ6hSD+SAxASzh8+/eSBPmxfOacA==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8545,13 +8563,16 @@
}
},
"node_modules/@oxlint/binding-linux-x64-gnu": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.68.0.tgz",
"integrity": "sha512-zc9lEnfV/HreDTY6gdMlZe+irkwHSxQ4/B1pS9GyK7RVaA5LxhoZY/w6/o2vIwLLEYiXQ5ujGxOM1ZazeFAAIA==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.69.0.tgz",
"integrity": "sha512-Gt3KHgp46mRKz4sJeaASmKvD8ayXookRw07RMf+NowhEztGGDZ7VrXpoW96XuKJLjFukWizOFVNjmYb/u7caNQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8562,13 +8583,16 @@
}
},
"node_modules/@oxlint/binding-linux-x64-musl": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.68.0.tgz",
"integrity": "sha512-Dl5QEX0TCo/40Cdh1o1JdPS//+YiWqjC+Hrrya5OQmStZZr4svAFtdlqcpCrU9yq2Mo3vRVyO9B3h0dzD8s36Q==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.69.0.tgz",
"integrity": "sha512-7tQhJ2+p/oHv1zcfnjYI7YVzC/7iBaVOfIvFYtxdJ5F45mWgEdrCyXZXZGfiLey5t/5JhOhsaMnnv1kAzckd7g==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -8579,9 +8603,9 @@
}
},
"node_modules/@oxlint/binding-openharmony-arm64": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.68.0.tgz",
"integrity": "sha512-/qy6dOvi4S3/LeXq0l5BT5pRKPYA7oj3uKwJOAZOr5HRLL+HK6jdBynvWuXIA2wwfE01RzNYmbBdM7vwYx00sA==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.69.0.tgz",
"integrity": "sha512-vmWz6TKp/3hfA4lksR0zHBv/6xuX1jhym6eqOjdH2DXsDDHZWcp2f0KG0VCAnlVbIrjk29G4wAWMXb/Hn1YobA==",
"cpu": [
"arm64"
],
@@ -8596,9 +8620,9 @@
}
},
"node_modules/@oxlint/binding-win32-arm64-msvc": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.68.0.tgz",
"integrity": "sha512-fHNtVqPHSYE7UFDSLVFUjxQjnSVXxseNJmRW+XuP4pXXDwePdPda43NL7/BBCFTxHjycOc44JNDaOPtFDNui9A==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.69.0.tgz",
"integrity": "sha512-9RExaLgmaw6IoIkU9cTpT71mLfI0xZ86iZH8x518LVsOkjquJMYqb9P7KpC8lgd1t0Dxs41p2pxynq4XR3Ttzw==",
"cpu": [
"arm64"
],
@@ -8613,9 +8637,9 @@
}
},
"node_modules/@oxlint/binding-win32-ia32-msvc": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.68.0.tgz",
"integrity": "sha512-NnKXr4Wgo4nps3erhrE0f8shBvBPZMHg72nDsvX0JyrRvsNiP3f1JNvbCKh+A6VFvpF7ZoJxu904P3cKMhvZnA==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.69.0.tgz",
"integrity": "sha512-1907kRPF8/PrcIw1E7LMs9JbVrpgnt/MvFdss3an8oDkYNAACXzTntV3t3869ZZhMZxb2AzRGbz1pA/jdFatXA==",
"cpu": [
"ia32"
],
@@ -8630,9 +8654,9 @@
}
},
"node_modules/@oxlint/binding-win32-x64-msvc": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.68.0.tgz",
"integrity": "sha512-zg5pA+84AlU6XHJ3ruiRxziO71QTrz8nLsk6u01JGS5+tL9/bnlakFiklFrcy4R1/V7ktWtaNitN3JZWmKnf6g==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.69.0.tgz",
"integrity": "sha512-w8SOXv3mT9Fi6jY8OXdXCfnvX/3KNLXGNr4HEz2TA7S4Mv/PYAOmpB8y/ge40mxvBMgGNaSaaDwZpAsQn7HtWA==",
"cpu": [
"x64"
],
@@ -21856,9 +21880,9 @@
}
},
"node_modules/google-auth-library": {
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz",
"integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
@@ -32587,9 +32611,9 @@
}
},
"node_modules/oxlint": {
"version": "1.68.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.68.0.tgz",
"integrity": "sha512-dXcbq+xsmLrMy6T8d0euf3IYUfLmjHIE11pOxiUSi5LHkFZaYPv568R6sEjcavVpUxoaQe66UBuK4HEi74NxpA==",
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.69.0.tgz",
"integrity": "sha512-ypZkK/aDc5NQV8zIR6s2H2Tl3aNW8FmJ1m9+2qsaYuRenl8vgnHNCGwTHviWJdUQzglOlHFchgopdtGhSy17Rw==",
"dev": true,
"license": "MIT",
"bin": {
@@ -32602,25 +32626,25 @@
"url": "https://github.com/sponsors/Boshen"
},
"optionalDependencies": {
"@oxlint/binding-android-arm-eabi": "1.68.0",
"@oxlint/binding-android-arm64": "1.68.0",
"@oxlint/binding-darwin-arm64": "1.68.0",
"@oxlint/binding-darwin-x64": "1.68.0",
"@oxlint/binding-freebsd-x64": "1.68.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.68.0",
"@oxlint/binding-linux-arm-musleabihf": "1.68.0",
"@oxlint/binding-linux-arm64-gnu": "1.68.0",
"@oxlint/binding-linux-arm64-musl": "1.68.0",
"@oxlint/binding-linux-ppc64-gnu": "1.68.0",
"@oxlint/binding-linux-riscv64-gnu": "1.68.0",
"@oxlint/binding-linux-riscv64-musl": "1.68.0",
"@oxlint/binding-linux-s390x-gnu": "1.68.0",
"@oxlint/binding-linux-x64-gnu": "1.68.0",
"@oxlint/binding-linux-x64-musl": "1.68.0",
"@oxlint/binding-openharmony-arm64": "1.68.0",
"@oxlint/binding-win32-arm64-msvc": "1.68.0",
"@oxlint/binding-win32-ia32-msvc": "1.68.0",
"@oxlint/binding-win32-x64-msvc": "1.68.0"
"@oxlint/binding-android-arm-eabi": "1.69.0",
"@oxlint/binding-android-arm64": "1.69.0",
"@oxlint/binding-darwin-arm64": "1.69.0",
"@oxlint/binding-darwin-x64": "1.69.0",
"@oxlint/binding-freebsd-x64": "1.69.0",
"@oxlint/binding-linux-arm-gnueabihf": "1.69.0",
"@oxlint/binding-linux-arm-musleabihf": "1.69.0",
"@oxlint/binding-linux-arm64-gnu": "1.69.0",
"@oxlint/binding-linux-arm64-musl": "1.69.0",
"@oxlint/binding-linux-ppc64-gnu": "1.69.0",
"@oxlint/binding-linux-riscv64-gnu": "1.69.0",
"@oxlint/binding-linux-riscv64-musl": "1.69.0",
"@oxlint/binding-linux-s390x-gnu": "1.69.0",
"@oxlint/binding-linux-x64-gnu": "1.69.0",
"@oxlint/binding-linux-x64-musl": "1.69.0",
"@oxlint/binding-openharmony-arm64": "1.69.0",
"@oxlint/binding-win32-arm64-msvc": "1.69.0",
"@oxlint/binding-win32-ia32-msvc": "1.69.0",
"@oxlint/binding-win32-x64-msvc": "1.69.0"
},
"peerDependencies": {
"oxlint-tsgolint": ">=0.22.1",
@@ -44462,7 +44486,7 @@
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.8",
"dompurify": "^3.4.9",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",
@@ -44583,9 +44607,9 @@
}
},
"packages/superset-ui-core/node_modules/dompurify": {
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz",
"integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -44946,7 +44970,7 @@
"dependencies": {
"d3": "^3.5.17",
"d3-tip": "^0.9.1",
"dompurify": "^3.4.8",
"dompurify": "^3.4.9",
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.18.1",
"nvd3-fork": "^2.0.5",
@@ -44962,9 +44986,9 @@
}
},
"plugins/legacy-preset-chart-nvd3/node_modules/dompurify": {
"version": "3.4.8",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz",
"integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==",
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.9.tgz",
"integrity": "sha512-4dPSRMRDqHvs0V4YDFCsaIZo4if5u0xM+llyxiM2fwuZFdKArUBAF3VtI2+n8NKg9P870WMdYk0UhqQNoWXbfQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"

View File

@@ -185,7 +185,7 @@
"geostyler-openlayers-parser": "^5.7.0",
"geostyler-style": "11.0.2",
"geostyler-wfs-parser": "^3.0.1",
"google-auth-library": "^10.6.2",
"google-auth-library": "^10.7.0",
"immer": "^11.1.8",
"interweave": "^13.1.1",
"jquery": "^4.0.0",
@@ -344,7 +344,7 @@
"lightningcss": "^1.32.0",
"mini-css-extract-plugin": "^2.10.2",
"open-cli": "^9.0.0",
"oxlint": "^1.68.0",
"oxlint": "^1.69.0",
"po2json": "^0.4.5",
"prettier": "3.8.3",
"prettier-plugin-packagejson": "^3.0.2",

View File

@@ -43,7 +43,7 @@
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.21",
"dompurify": "^3.4.8",
"dompurify": "^3.4.9",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
"jed": "^1.1.1",

View File

@@ -34,7 +34,7 @@
"fast-safe-stringify": "^2.1.1",
"lodash": "^4.18.1",
"nvd3-fork": "^2.0.5",
"dompurify": "^3.4.8",
"dompurify": "^3.4.9",
"prop-types": "^15.8.1",
"urijs": "^1.19.11"
},

View File

@@ -21,6 +21,7 @@ import { css, styled } from '@apache-superset/core/theme';
export default styled.div`
${({ theme }) => css`
/* Base table styles */
padding: ${theme.sizeUnit * 5}px;
table {
width: 100%;
min-width: auto;

View File

@@ -1613,8 +1613,8 @@ export default function TableChart<D extends DataRecord = DataRecord>(
pageSize={pageSize}
serverPaginationData={serverPaginationData}
pageSizeOptions={pageSizeOptions}
width={widthFromState}
height={heightFromState}
width={Math.max(0, widthFromState - theme.sizeUnit * 10)}
height={Math.max(0, heightFromState - theme.sizeUnit * 10)}
serverPagination={serverPagination}
onServerPaginationChange={handleServerPaginationChange}
onColumnOrderChange={() => setColumnOrderToggle(!columnOrderToggle)}

View File

@@ -17,7 +17,11 @@
* under the License.
*/
import type { ReactElement } from 'react';
import type { ControlPanelSectionConfig } from '@superset-ui/chart-controls';
import type {
ControlPanelSectionConfig,
CustomControlItem,
} from '@superset-ui/chart-controls';
import { isCustomControlItem } from '@superset-ui/chart-controls';
// eslint-disable-next-line import/no-extraneous-dependencies
import { render } from '@testing-library/react';
import { SqlaFormData } from '@superset-ui/core';
@@ -28,6 +32,7 @@ import DeckGLGeoJson, {
computeGeoJsonIconOptionsFromJsOutput,
computeGeoJsonIconOptionsFromFormData,
getPoints,
getLayer,
} from './Geojson';
import controlPanel from './controlPanel';
@@ -295,3 +300,158 @@ test('DeckGLGeoJson falls back to legacy map_style when provider-specific style
}),
);
});
const baseFormData: SqlaFormData = {
datasource: 'test_datasource',
viz_type: 'deck_geojson',
slice_id: 1,
fill_color_picker: { r: 0, g: 0, b: 255, a: 1 },
stroke_color_picker: { r: 0, g: 0, b: 0, a: 1 },
};
const baseLayerArgs = {
onContextMenu: jest.fn(),
filterState: undefined,
setDataMask: jest.fn(),
payload: { data: { type: 'FeatureCollection', features: [] } },
setTooltip: jest.fn(),
emitCrossFilters: false,
};
test('getLayer preserves rendering for existing charts without new point radius fields', () => {
// Simulate form data from an existing chart that only has point_radius_scale
const legacyFormData = {
...baseFormData,
point_radius_scale: 200,
// point_radius and point_radius_units intentionally absent
};
const layer = getLayer({ formData: legacyFormData, ...baseLayerArgs });
const { props } = layer;
// Should match deck.gl defaults, NOT the new control panel defaults
expect(props.getPointRadius).toBe(1); // deck.gl default, not 10
expect(props.pointRadiusUnits).toBe('meters'); // deck.gl default, not 'pixels'
expect(props.pointRadiusScale).toBe(200); // user's saved value preserved
});
test('getLayer uses control panel defaults for new charts', () => {
const newChartFormData = {
...baseFormData,
point_radius: 10,
point_radius_units: 'pixels',
point_radius_scale: 1,
};
const layer = getLayer({ formData: newChartFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.getPointRadius).toBe(10);
expect(props.pointRadiusUnits).toBe('pixels');
expect(props.pointRadiusScale).toBe(1);
});
test('getLayer falls back to defaults when legacy fields are null', () => {
// The old point_radius_scale control had `default: null`, so legacy charts
// can have null persisted; it must fall back to 1, not coerce to 0.
const nullFormData = {
...baseFormData,
point_radius: null,
point_radius_scale: null,
};
const layer = getLayer({ formData: nullFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.getPointRadius).toBe(1);
expect(props.pointRadiusScale).toBe(1);
});
test('getLayer preserves an explicit zero radius scale', () => {
const zeroFormData = {
...baseFormData,
point_radius_scale: 0,
};
const layer = getLayer({ formData: zeroFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.pointRadiusScale).toBe(0);
});
test('getLayer coerces free-form string radius values to numbers', () => {
// Free-form SelectControls can store user-typed values as strings
const stringFormData = {
...baseFormData,
point_radius: '3',
point_radius_scale: '0.25',
};
const layer = getLayer({ formData: stringFormData, ...baseLayerArgs });
const { props } = layer;
expect(props.getPointRadius).toBe(3);
expect(props.pointRadiusScale).toBe(0.25);
});
type ControlConfig = {
default?: unknown;
validators?: unknown[];
choices?: [unknown, unknown][];
renderTrigger?: boolean;
};
const controlItems = controlPanel.controlPanelSections
.filter(
(s: ControlPanelSectionConfig | null): s is ControlPanelSectionConfig =>
s !== null,
)
.flatMap((section: ControlPanelSectionConfig) => section.controlSetRows)
.flat();
const findControlConfig = (name: string): ControlConfig | undefined =>
(controlItems.filter(isCustomControlItem) as CustomControlItem[]).find(
(item: CustomControlItem) => item.name === name,
)?.config as ControlConfig | undefined;
test('controlPanel exposes a Point Radius control defaulting to 10', () => {
const config = findControlConfig('point_radius');
expect(config).toBeDefined();
expect(config?.default).toBe(10);
expect(config?.renderTrigger).toBe(true);
expect(config?.validators).toHaveLength(1);
expect(config?.choices).toEqual(
expect.arrayContaining([
[1, '1'],
[10, '10'],
[100, '100'],
]),
);
});
test('controlPanel Point Radius Scale defaults to 1 with fractional choices', () => {
const config = findControlConfig('point_radius_scale');
expect(config).toBeDefined();
expect(config?.default).toBe(1);
expect(config?.renderTrigger).toBe(true);
expect(config?.validators).toHaveLength(1);
expect(config?.choices).toEqual(
expect.arrayContaining([
[0.1, '0.1'],
[1, '1'],
[10, '10'],
]),
);
});
test('controlPanel Point Radius Units defaults to pixels', () => {
const config = findControlConfig('point_radius_units');
expect(config).toBeDefined();
expect(config?.default).toBe('pixels');
expect(config?.renderTrigger).toBe(true);
expect(config?.choices?.map(([value]) => value)).toEqual([
'pixels',
'meters',
'common',
]);
});

View File

@@ -254,6 +254,15 @@ export const computeGeoJsonIconOptionsFromFormData = (
iconSizeUnits: fd.icon_size_unit,
});
// Free-form SelectControls can yield string values, and legacy charts may have
// null persisted for these fields, so coerce to a number (falling back to the
// provided default for null/undefined/NaN input, while preserving an explicit 0)
// before handing them to deck.gl's numeric layer props.
const toNumber = (value: unknown, fallback: number) => {
const num = Number(value ?? fallback);
return Number.isFinite(num) ? num : fallback;
};
export const getLayer: GetLayerType<GeoJsonLayer> = function ({
formData,
onContextMenu,
@@ -328,7 +337,11 @@ export const getLayer: GetLayerType<GeoJsonLayer> = function ({
getFillColor(feature, filterState?.value),
getLineColor,
getLineWidth: fd.line_width || 1,
pointRadiusScale: fd.point_radius_scale,
// Use deck.gl defaults as fallbacks for backward compatibility with existing charts.
// New charts will get control panel defaults (point_radius=10, units='pixels', scale=1).
getPointRadius: toNumber(fd.point_radius, 1),
pointRadiusUnits: fd.point_radius_units ?? 'meters',
pointRadiusScale: toNumber(fd.point_radius_scale, 1),
lineWidthUnits: fd.line_width_unit,
pointType,
...labelOpts,

View File

@@ -22,6 +22,8 @@ import {
legacyValidateInteger,
isFeatureEnabled,
FeatureFlag,
validateNumber,
validateInteger,
} from '@superset-ui/core';
import { formatSelectOptions } from '../../utilities/utils';
import {
@@ -352,15 +354,56 @@ const config: ControlPanelConfig = {
},
],
[
{
name: 'point_radius',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Point Radius'),
description: t(
'The radius of point features, in the units specified below. ' +
'The final rendered size is this value multiplied by Point Radius Scale.',
),
validators: [validateInteger],
default: 10,
choices: formatSelectOptions([1, 5, 10, 20, 50, 100]),
renderTrigger: true,
},
},
{
name: 'point_radius_scale',
config: {
type: 'SelectControl',
freeForm: true,
label: t('Point Radius Scale'),
validators: [legacyValidateInteger],
default: null,
choices: formatSelectOptions([0, 100, 200, 300, 500]),
description: t(
'A multiplier applied to the point radius. ' +
'Use this to uniformly scale all points.',
),
validators: [validateNumber],
default: 1,
choices: formatSelectOptions([0.1, 0.5, 1, 2, 5, 10]),
renderTrigger: true,
},
},
],
[
{
name: 'point_radius_units',
config: {
type: 'SelectControl',
label: t('Point Radius Units'),
description: t(
'The unit for point radius. Use "pixels" for consistent ' +
'screen-space sizing regardless of zoom level.',
),
default: 'pixels',
choices: [
['pixels', t('Pixels')],
['meters', t('Meters')],
['common', t('Common (unit per pixel at zoom 0)')],
],
renderTrigger: true,
},
},
],

View File

@@ -295,6 +295,7 @@ export interface ListViewProps<T extends object = any> {
name: ReactNode;
onSelect: (rows: any[]) => any;
type?: 'primary' | 'secondary' | 'danger';
hidden?: (rows: any[]) => boolean;
}>;
bulkSelectEnabled?: boolean;
disableBulkSelect?: () => void;
@@ -509,7 +510,16 @@ export function ListView<T extends object = any>({
{t('Deselect all')}
</span>
<div className="divider" />
{bulkActions.map(action => (
{bulkActions
.filter(
action =>
!action.hidden?.(
selectedFlatRows.map(
(r: any) => r.original,
),
),
)
.map(action => (
<Button
data-test="bulk-select-action"
data-test-action-key={action.key}

View File

@@ -299,7 +299,10 @@ test('checkIsApplyDisabled returns true when required filter is missing value in
);
});
test('checkIsApplyDisabled handles filter count mismatch', () => {
test('checkIsApplyDisabled enables Apply when Selected has a filter value not yet in Applied', () => {
// Regression: when a required filter's default isn't applied (Applied missing
// the entry) and the user types a value, Selected gains an entry Applied
// doesn't have. Apply must be enabled so the user can commit the value.
const dataMaskSelected: DataMaskStateWithId = {
'filter-1': {
id: 'filter-1',
@@ -322,7 +325,7 @@ test('checkIsApplyDisabled handles filter count mismatch', () => {
const filters = [createFilter('filter-1'), createFilter('filter-2')];
expect(checkIsApplyDisabled(dataMaskSelected, dataMaskApplied, filters)).toBe(
true,
false,
);
});

View File

@@ -74,13 +74,9 @@ export const checkIsApplyDisabled = (
const selectedExtraFormData = getOnlyExtraFormData(dataMaskSelected);
const appliedExtraFormData = getOnlyExtraFormData(dataMaskApplied);
// Check counts first
const selectedCount = Object.keys(selectedExtraFormData).length;
const appliedCount = Object.keys(appliedExtraFormData).length;
if (selectedCount !== appliedCount) return true;
// Check for changes
// Check for changes. ignoreUndefined drops empty keys on both sides so that
// a filter present in Selected with a real value but absent (or undefined)
// in Applied is correctly detected as a change.
const dataEqual = areObjectsEqual(
selectedExtraFormData,
appliedExtraFormData,

View File

@@ -42,7 +42,9 @@ const isUserDashboardOwner = (
user: UserWithPermissionsAndRoles | UndefinedUser,
) =>
isUserWithPermissionsAndRoles(user) &&
dashboard.owners.some(owner => owner.id === user.userId);
[...dashboard.owners, ...(dashboard.extra_owners ?? [])].some(
owner => owner.id === user.userId,
);
export const canUserEditDashboard = (
dashboard: Dashboard,

View File

@@ -121,6 +121,38 @@ test('when user edits a filter without changing targets, their selection is pres
);
});
test('when a required range filter was cleared to [null, null], modifying it applies the new default instead of the cleared state', () => {
// Regression for the PR #40470 review: [null, null] is a range filter's
// canonical "cleared" value. It must count as "no value" so the empty state
// does not wipe a newly-defined default — consistent with `loadedHasValue`
// in fillNativeFilters.
const initialState: DataMaskStateWithId = {
'NATIVE_FILTER-1': {
id: 'NATIVE_FILTER-1',
...getInitialDataMask('NATIVE_FILTER-1'),
filterState: { value: [null, null] },
},
};
const oldFilters = {
'NATIVE_FILTER-1': createFilter('NATIVE_FILTER-1', 'col_a', {
enableEmptyFilter: true,
}),
};
const modifiedFilter: Filter = {
...createFilter('NATIVE_FILTER-1', 'col_a', { enableEmptyFilter: true }),
defaultDataMask: { filterState: { value: [10, 20] } },
};
const action = createModifyAction(modifiedFilter, oldFilters);
const result = reducer(initialState, action);
// The cleared [null, null] state must not be preserved; the new default wins.
expect(result['NATIVE_FILTER-1']?.filterState?.value).toEqual([10, 20]);
});
// Runtime data from the server can contain null entries in
// chart_customization_config even though the TS type does not include | null
// yet. These helpers build HYDRATE_DASHBOARD actions that mirror that reality.

View File

@@ -109,10 +109,50 @@ function fillNativeFilters(
) {
filterConfig.forEach((filter: Filter) => {
const dataMask = initialDataMask || {};
const loaded = dataMask[filter.id];
// The shallow spread of `loaded` would override `filter.defaultDataMask`
// even when the loaded mask is incomplete (e.g. a permalink captured
// mid-initialization), wiping out a valid default. For REQUIRED filters
// with a default, fall back to the default when the loaded mask carries
// no real value OR is missing the extraFormData needed to filter charts.
// Non-required filters keep current behavior so a user's explicit clear
// isn't undone.
const isRequired = !!filter.controlValues?.enableEmptyFilter;
const loadedValue = loaded?.filterState?.value;
const loadedHasValue =
loadedValue !== undefined &&
loadedValue !== null &&
!(
// Treat all-null arrays (range filters use [null, null] as their
// canonical cleared value) and empty arrays as "no value".
(Array.isArray(loadedValue) && loadedValue.every(v => v === null))
);
const loadedHasExtraFormData =
!!loaded?.extraFormData && Object.keys(loaded.extraFormData).length > 0;
const defaultHasExtraFormData =
!!filter.defaultDataMask?.extraFormData &&
Object.keys(filter.defaultDataMask.extraFormData).length > 0;
// Restore when:
// (1) loaded value is empty — classic "default wiped by stale permalink", OR
// (2) loaded has a value but no extraFormData and the default does — the
// "value present in UI but not applied to charts" gap-window case where
// a permalink was captured before FilterValue produced extraFormData.
const shouldRestoreDefault =
isRequired &&
!!filter.defaultDataMask &&
(!loadedHasValue || (!loadedHasExtraFormData && defaultHasExtraFormData));
mergedDataMask[filter.id] = {
...getInitialDataMask(filter.id), // take initial data
...filter.defaultDataMask, // if something new came from BE - take it
...dataMask[filter.id],
...loaded,
...(shouldRestoreDefault
? {
filterState: filter.defaultDataMask?.filterState,
extraFormData: filter.defaultDataMask?.extraFormData,
}
: {}),
};
if (
currentFilters &&
@@ -155,12 +195,28 @@ function updateDataMaskForFilterChanges(
// Check if targets are equal
const areTargetsEqual = isEqual(prevFilterDef?.targets, filter?.targets);
// Preserve state only if filter exists, has enableEmptyFilter=true and targets match
// For required filters, only preserve existing state when it actually has
// a value — otherwise the empty existing state would wipe the (possibly
// newly-defined) default. defaultToFirstItem filters keep the old behavior:
// FilterValue re-resolves the first item at runtime, so preserving the
// mid-init empty state is fine.
const isRequired = !!filter.controlValues?.enableEmptyFilter;
const isFirstItem = !!filter.controlValues?.defaultToFirstItem;
const existingValue = existingFilter?.filterState?.value;
const hasExistingValue =
existingValue !== undefined &&
existingValue !== null &&
// Treat all-null arrays (range filters use [null, null] as their
// canonical cleared value) and empty arrays as "no value", consistent
// with `loadedHasValue` in fillNativeFilters above. `[].every()` is
// true, so this also covers the empty-array case.
!(Array.isArray(existingValue) && existingValue.every(v => v === null));
const shouldPreserveState =
existingFilter &&
areTargetsEqual &&
(filter.controlValues?.enableEmptyFilter ||
filter.controlValues?.defaultToFirstItem);
(isRequired || isFirstItem) &&
(isFirstItem || hasExistingValue);
mergedDataMask[filter.id] = {
...getInitialDataMask(filter.id),

View File

@@ -177,9 +177,11 @@ export const hydrateExplore =
can_copy_clipboard: granularExport
? findPermission('can_copy_clipboard', 'Superset', user?.roles)
: findPermission('can_csv', 'Superset', user?.roles),
can_overwrite: ensureIsArray(slice?.owners).includes(
user?.userId as number,
),
can_overwrite:
ensureIsArray(slice?.owners).includes(user?.userId as number) ||
ensureIsArray(metadata?.extra_owners).some(
(o: { id: number }) => o.id === user?.userId,
),
isDatasourceMetaLoading: false,
isStarred: false,
triggerRender: false,

View File

@@ -75,6 +75,7 @@ interface SaveModalProps extends RouteComponentProps {
alert?: string;
sliceName?: string;
slice?: Record<string, any>;
can_overwrite?: boolean;
datasource?: Record<string, any>;
dashboardId: '' | number | null;
isVisible: boolean;
@@ -128,7 +129,8 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
canOverwriteSlice(): boolean {
return (
(isUserAdmin(this.props.user) ||
(this.props.can_overwrite ||
isUserAdmin(this.props.user) ||
this.props.slice?.owners?.includes(this.props.user.userId)) &&
!this.props.slice?.is_managed_externally
);
@@ -819,6 +821,7 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
interface StateProps {
datasource: any;
slice: any;
can_overwrite: boolean;
user: UserWithPermissionsAndRoles;
dashboards: any;
alert: any;
@@ -833,6 +836,7 @@ function mapStateToProps({
return {
datasource: explore.datasource,
slice: explore.slice,
can_overwrite: explore.can_overwrite,
user,
dashboards: saveModal.dashboards,
alert: saveModal.saveModalAlert,

View File

@@ -88,6 +88,7 @@ export interface ExplorePageInitialData {
created_on_humanized: string;
changed_on_humanized: string;
owners: string[];
extra_owners?: { id: number; first_name: string; last_name: string }[];
created_by?: string;
changed_by?: string;
color_namespace?: string;

View File

@@ -34,6 +34,7 @@ export interface Dashboard {
changed_on: string;
charts: string[]; // just chart names, unfortunately...
owners: Owner[];
extra_owners?: Owner[];
roles: Role[];
theme?: {
id: number;

View File

@@ -26,7 +26,7 @@
"@types/node": "^25.9.2",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"@typescript-eslint/parser": "^8.61.0",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",
@@ -1883,16 +1883,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
"integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.60.1",
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/typescript-estree": "8.60.1",
"@typescript-eslint/visitor-keys": "8.60.1",
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"debug": "^4.4.3"
},
"engines": {
@@ -1907,6 +1907,175 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.61.0",
"@typescript-eslint/types": "^8.61.0",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
"integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
"integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.61.0",
"@typescript-eslint/tsconfig-utils": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
"integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.61.0",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz",
@@ -6211,6 +6380,31 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
"integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.60.1",
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/typescript-estree": "8.60.1",
"@typescript-eslint/visitor-keys": "8.60.1",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
@@ -7951,16 +8145,109 @@
}
},
"@typescript-eslint/parser": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
"integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz",
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
"dev": true,
"requires": {
"@typescript-eslint/scope-manager": "8.60.1",
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/typescript-estree": "8.60.1",
"@typescript-eslint/visitor-keys": "8.60.1",
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/typescript-estree": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"debug": "^4.4.3"
},
"dependencies": {
"@typescript-eslint/project-service": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz",
"integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==",
"dev": true,
"requires": {
"@typescript-eslint/tsconfig-utils": "^8.61.0",
"@typescript-eslint/types": "^8.61.0",
"debug": "^4.4.3"
}
},
"@typescript-eslint/scope-manager": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz",
"integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0"
}
},
"@typescript-eslint/tsconfig-utils": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz",
"integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==",
"dev": true,
"requires": {}
},
"@typescript-eslint/types": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz",
"integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==",
"dev": true
},
"@typescript-eslint/typescript-estree": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz",
"integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==",
"dev": true,
"requires": {
"@typescript-eslint/project-service": "8.61.0",
"@typescript-eslint/tsconfig-utils": "8.61.0",
"@typescript-eslint/types": "8.61.0",
"@typescript-eslint/visitor-keys": "8.61.0",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.5.0"
}
},
"@typescript-eslint/visitor-keys": {
"version": "8.61.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz",
"integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==",
"dev": true,
"requires": {
"@typescript-eslint/types": "8.61.0",
"eslint-visitor-keys": "^5.0.0"
}
},
"balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true
},
"brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"requires": {
"balanced-match": "^4.0.2"
}
},
"eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true
},
"minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"requires": {
"brace-expansion": "^5.0.5"
}
}
}
},
"@typescript-eslint/project-service": {
@@ -11030,6 +11317,21 @@
"@typescript-eslint/parser": "8.60.1",
"@typescript-eslint/typescript-estree": "8.60.1",
"@typescript-eslint/utils": "8.60.1"
},
"dependencies": {
"@typescript-eslint/parser": {
"version": "8.60.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz",
"integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==",
"dev": true,
"requires": {
"@typescript-eslint/scope-manager": "8.60.1",
"@typescript-eslint/types": "8.60.1",
"@typescript-eslint/typescript-estree": "8.60.1",
"@typescript-eslint/visitor-keys": "8.60.1",
"debug": "^4.4.3"
}
}
}
},
"uglify-js": {

View File

@@ -34,7 +34,7 @@
"@types/node": "^25.9.2",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"@typescript-eslint/parser": "^8.61.0",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-lodash": "^8.0.0",

View File

@@ -21,7 +21,7 @@ from io import BytesIO
from typing import Any, cast, Optional
from zipfile import is_zipfile, ZipFile
from flask import redirect, request, Response, send_file, url_for
from flask import current_app, redirect, request, Response, send_file, url_for
from flask_appbuilder.api import expose, protect, rison as parse_rison, safe
from flask_appbuilder.hooks import before_request
from flask_appbuilder.models.sqla.interface import SQLAInterface
@@ -310,6 +310,8 @@ class ChartRestApi(BaseSupersetModelRestApi):
try:
dash = ChartDAO.get_by_id_or_uuid(id_or_uuid)
result = self.chart_get_response_schema.dump(dash)
if resolver := current_app.config.get("EXTRA_OWNERS_RESOLVER"):
result["extra_owners"] = resolver(dash)
return self.response(200, result=result)
except ChartNotFoundError:
return self.response_404()

View File

@@ -16,6 +16,7 @@
# under the License.
from typing import Any
from flask import current_app
from flask_babel import lazy_gettext as _
from sqlalchemy import and_, or_
from sqlalchemy.orm import aliased
@@ -109,7 +110,22 @@ class ChartFilter(BaseFilter): # pylint: disable=too-few-public-methods
query = query.join(
models.Database, table_alias.database_id == models.Database.id
)
return query.filter(get_dataset_access_filters(self.model))
extra_access_filters = []
extra_filters = current_app.config.get("EXTRA_ACCESS_QUERY_FILTERS", {})
if extra_charts_filter := extra_filters.get("charts"):
user_id = get_user_id()
if user_id:
extra_access_filters.append(
self.model.id.in_(extra_charts_filter(user_id))
)
return query.filter(
or_(
get_dataset_access_filters(self.model),
*extra_access_filters,
)
)
class ChartHasCreatedByFilter(BaseFilter): # pylint: disable=too-few-public-methods

View File

@@ -19,7 +19,7 @@ from datetime import datetime
from functools import partial
from typing import Any, Optional
from flask import g
from flask import current_app, g
from flask_appbuilder.models.sqla import Model
from marshmallow import ValidationError
@@ -58,7 +58,10 @@ class CreateChartCommand(CreateMixin, BaseCommand):
self.validate()
self._properties["last_saved_at"] = datetime.now()
self._properties["last_saved_by"] = g.user
return ChartDAO.create(attributes=self._properties)
chart = ChartDAO.create(attributes=self._properties)
if after_create := current_app.config.get("AFTER_ASSET_CREATE"):
after_create(chart, "chart")
return chart
def validate(self) -> None:
exceptions = []

View File

@@ -18,6 +18,7 @@ import logging
from functools import partial
from typing import Any, Optional
from flask import current_app
from flask_appbuilder.models.sqla import Model
from marshmallow import ValidationError
@@ -52,6 +53,8 @@ class CreateDashboardCommand(CreateMixin, BaseCommand):
dashboard,
data=json.loads(json_metadata),
)
if after_create := current_app.config.get("AFTER_ASSET_CREATE"):
after_create(dashboard, "dashboard")
return dashboard
def validate(self) -> None:

View File

@@ -19,7 +19,7 @@ import logging
from abc import ABC
from typing import Any, cast, Optional
from flask import request
from flask import current_app, request
from flask_babel import lazy_gettext as _
from sqlalchemy.exc import SQLAlchemyError
@@ -160,10 +160,15 @@ class GetExploreCommand(BaseCommand, ABC):
metadata = None
if slc:
extra_owners = []
if resolver := current_app.config.get("EXTRA_OWNERS_RESOLVER"):
extra_owners = resolver(slc)
metadata = {
"created_on_humanized": slc.created_on_humanized,
"changed_on_humanized": slc.changed_on_humanized,
"owners": [owner.get_full_name() for owner in slc.owners],
"extra_owners": extra_owners,
"dashboards": [
{"id": dashboard.id, "dashboard_title": dashboard.dashboard_title}
for dashboard in slc.dashboards

View File

@@ -19,7 +19,7 @@ from __future__ import annotations
from collections import Counter
from typing import Any, Optional, TYPE_CHECKING
from flask import g
from flask import current_app, g
from flask_appbuilder.security.sqla.models import Role, User
from superset import security_manager
@@ -58,8 +58,11 @@ def populate_owner_list(
if not owner_ids and default_to_user:
return [g.user]
if not (security_manager.is_admin() or get_user_id() in owner_ids):
# make sure non-admins can't remove themselves as owner by mistake
owners.append(g.user)
# Make sure non-admins can't remove themselves as owner by mistake.
# Skip auto-add when an EXTRA_OWNERS_RESOLVER is configured — the
# resolver handles access independently of the owners list.
if not current_app.config.get("EXTRA_OWNERS_RESOLVER"):
owners.append(g.user)
for owner_id in owner_ids:
owner = security_manager.get_user_by_id(owner_id)
if not owner:

View File

@@ -2632,6 +2632,27 @@ class ExtraDynamicQueryFilters(TypedDict, total=False):
EXTRA_DYNAMIC_QUERY_FILTERS: ExtraDynamicQueryFilters = {}
# Extra access query filters inject additional OR conditions into
# ChartFilter and DashboardAccessFilter, enabling external systems
# (e.g. folder permissions) to grant asset visibility.
# The callable receives the current user ID and returns a subquery of asset IDs.
class ExtraAccessQueryFilters(TypedDict, total=False):
charts: Callable[[int], Query]
dashboards: Callable[[int], Query]
# Extension hooks for deployments to plug in custom access logic.
# Additional query filters for chart/dashboard list views.
EXTRA_ACCESS_QUERY_FILTERS: ExtraAccessQueryFilters = {}
# Bypass raise_for_access for specific assets. Return True to skip checks.
EXTRA_RAISE_FOR_ACCESS_BYPASS: Callable[..., bool] | None = None
# Resolve extra owners for a resource. Also used for ownership checks and
# to skip auto-adding the current user to owners on create.
EXTRA_OWNERS_RESOLVER: Callable[..., list[Any]] | None = None
# Post-create hook for charts/dashboards. Receives (model, asset_type).
AFTER_ASSET_CREATE: Callable[[Any, str], None] | None = None
# The migrations that add catalog permissions might take a considerably long time
# to execute as it has to create permissions to all schemas and catalogs from all
# other catalogs accessible by the credentials. This flag allows to skip the

View File

@@ -519,6 +519,8 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
schema = self.dashboard_get_response_schema
result = schema.dump(dash)
if resolver := current_app.config.get("EXTRA_OWNERS_RESOLVER"):
result["extra_owners"] = resolver(dash)
add_extra_log_payload(
dashboard_id=dash.id, action=f"{self.__class__.__name__}.get"
)

View File

@@ -16,7 +16,7 @@
# under the License.
from typing import Any, Optional
from flask import g
from flask import current_app, g
from flask_appbuilder.security.sqla.models import Role
from flask_babel import lazy_gettext as _
from sqlalchemy import and_, or_
@@ -182,11 +182,21 @@ class DashboardAccessFilter(BaseFilter): # pylint: disable=too-few-public-metho
feature_flagged_filters.append(condition)
extra_access_filters = []
extra_filters = current_app.config.get("EXTRA_ACCESS_QUERY_FILTERS", {})
if extra_dashboards_filter := extra_filters.get("dashboards"):
user_id = get_user_id()
if user_id:
extra_access_filters.append(
Dashboard.id.in_(extra_dashboards_filter(user_id))
)
query = query.filter(
or_(
Dashboard.id.in_(owner_ids_query),
Dashboard.id.in_(datasource_perm_query),
*feature_flagged_filters,
*extra_access_filters,
)
)

View File

@@ -162,6 +162,7 @@ Alerts & Reports:
Dataset Management:
- list_datasets: List datasets with advanced filters (1-based pagination)
- get_dataset_info: Get detailed dataset information by ID (includes columns/metrics)
- create_dataset: Register a physical table as a dataset against an existing DB connection (requires write access)
- create_virtual_dataset: Save a SQL query as a virtual dataset for charting (requires write access)
- query_dataset: Query a dataset using its semantic layer (saved metrics, dimensions, filters) without needing a saved chart
@@ -422,7 +423,7 @@ Input format:
{_feature_availability}Permission Awareness:
{_instance_info_role_bullet}- ALWAYS check the user's roles BEFORE suggesting write operations (creating datasets,
charts, or dashboards). SQL execution is a separate permission — see execute_sql below.
- Write tools (generate_chart, generate_dashboard, update_chart, create_virtual_dataset,
- Write tools (generate_chart, generate_dashboard, update_chart, create_dataset, create_virtual_dataset,
save_sql_query, add_chart_to_existing_dashboard, update_chart_preview) require write
permissions. These tools are only listed for users who have the necessary access.
If a write tool does not appear in the tool list, the current user lacks write access.
@@ -631,9 +632,9 @@ def create_mcp_app(
# Create default MCP instance for backward compatibility
mcp = create_mcp_app()
# Initialize MCP dependency injection BEFORE importing tools/prompts
# This replaces the abstract @tool and @prompt decorators in superset_core.api.mcp
# with concrete implementations that can register with the mcp instance
# Initialize MCP dependency injection BEFORE importing tools/prompts.
# Replaces the stub @tool/@prompt decorators in superset_core.mcp.decorators
# with concrete implementations bound to this mcp instance.
from superset.core.mcp.core_mcp_injection import ( # noqa: E402
initialize_core_mcp_dependencies,
)
@@ -658,6 +659,7 @@ warnings.filterwarnings(
module=r"google\..*",
)
# Import all MCP tools to register them with the mcp instance
# NOTE: Always add new tool imports here when creating new MCP tools.
# Tools use the @tool decorator from `superset-core` and register automatically
@@ -698,6 +700,7 @@ from superset.mcp_service.database.tool import ( # noqa: F401, E402
list_databases,
)
from superset.mcp_service.dataset.tool import ( # noqa: F401, E402
create_dataset,
create_virtual_dataset,
get_dataset_info,
list_datasets,
@@ -835,8 +838,9 @@ def init_fastmcp_server(
Returns:
The global FastMCP instance configured with the provided settings
"""
# Read branding from Flask config's APP_NAME
from superset.mcp_service.flask_singleton import app as flask_app
# circular import: flask_singleton imports from superset.extensions which
# re-enters mcp_service during startup; must stay lazy inside the function.
from superset.mcp_service.flask_singleton import app as flask_app # noqa: PLC0415
# Derive branding from Superset's APP_NAME config (defaults to "Superset")
app_name = flask_app.config.get("APP_NAME", "Superset")

View File

@@ -21,7 +21,7 @@ Pydantic schemas for dataset-related responses
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timezone
from typing import Annotated, Any, Dict, List, Literal
from pydantic import (
@@ -325,8 +325,6 @@ class DatasetError(BaseModel):
@classmethod
def create(cls, error: str, error_type: str) -> "DatasetError":
"""Create a standardized DatasetError with timestamp."""
from datetime import datetime, timezone
return cls(
error=error,
error_type=error_type,
@@ -410,6 +408,76 @@ class GetDatasetInfoRequest(MetadataCacheControl):
return parsed
class CreateDatasetRequest(BaseModel):
"""Request schema for create_dataset to register a physical table as a dataset."""
model_config = ConfigDict(populate_by_name=True)
database_id: Annotated[
int,
Field(
description="ID of the database connection to register the table against"
),
]
schema_: Annotated[
str | None,
Field(
default=None,
alias="schema",
serialization_alias="schema",
max_length=250,
description="Schema (namespace) where the table lives, e.g. 'public'. "
"Omit or pass None for databases without schema namespaces (e.g. SQLite).",
),
]
catalog: Annotated[
str | None,
Field(
default=None,
max_length=250,
description="Catalog where the table lives. Omit for databases without "
"catalog support.",
),
]
table_name: Annotated[
str,
Field(
min_length=1,
max_length=250,
description="Name of the physical table to register as a dataset",
),
]
owners: Annotated[
List[int] | None,
Field(
default=None,
description="Optional list of owner user IDs. "
"Defaults to the calling user.",
),
]
@field_validator("schema_", "catalog", mode="before")
@classmethod
def _normalize_optional_str(cls, v: object) -> object:
"""Strip whitespace and convert blank strings to None.
Non-string values pass through unchanged so Pydantic's type validation
rejects them, rather than silently treating a malformed value (e.g. an
int or dict) as an omitted namespace.
"""
if isinstance(v, str):
return v.strip() or None
return v
@field_validator("table_name", mode="before")
@classmethod
def _strip_table_name(cls, v: object) -> object:
"""Strip leading/trailing whitespace from table_name."""
if isinstance(v, str):
return v.strip()
return v
class CreateVirtualDatasetRequest(BaseModel):
"""Request schema for create_virtual_dataset."""
@@ -734,7 +802,7 @@ def serialize_dataset_object(dataset: Any) -> DatasetInfo | None:
if isinstance(params, str):
try:
params = json.loads(params)
except Exception:
except (json.JSONDecodeError, TypeError):
params = None
columns = [
TableColumnInfo(

View File

@@ -15,14 +15,16 @@
# specific language governing permissions and limitations
# under the License.
from .create_dataset import create_dataset
from .create_virtual_dataset import create_virtual_dataset
from .get_dataset_info import get_dataset_info
from .list_datasets import list_datasets
from .query_dataset import query_dataset
__all__ = [
"create_dataset",
"create_virtual_dataset",
"list_datasets",
"get_dataset_info",
"list_datasets",
"query_dataset",
]

View File

@@ -0,0 +1,171 @@
# 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.
"""
Create dataset FastMCP tool
Registers a physical table as a Superset dataset against an existing
database connection — the programmatic equivalent of Data → Datasets → +Dataset.
Returns the same DatasetInfo shape as get_dataset_info so the caller can feed
the resulting dataset_id directly into generate_chart.
"""
import logging
from fastmcp import Context
from superset_core.mcp.decorators import tool, ToolAnnotations
from superset.commands.dataset.create import CreateDatasetCommand
from superset.commands.dataset.exceptions import (
DatasetCreateFailedError,
DatasetInvalidError,
)
from superset.extensions import event_logger
from superset.mcp_service.dataset.schemas import (
CreateDatasetRequest,
DatasetError,
DatasetInfo,
serialize_dataset_object,
)
logger = logging.getLogger(__name__)
def _classify_invalid_error(exc: DatasetInvalidError) -> DatasetError:
"""Map DatasetInvalidError sub-exceptions to typed DatasetError responses."""
classnames = exc.get_list_classnames()
messages = exc.normalized_messages()
if "DatabaseNotFoundValidationError" in classnames:
return DatasetError.create(
error="Database not found", error_type="DatabaseNotFoundError"
)
if "DatasetDataAccessIsNotAllowed" in classnames:
return DatasetError.create(
error="Access denied", error_type="AccessDeniedError"
)
if "DatasetExistsValidationError" in classnames:
return DatasetError.create(error=str(messages), error_type="DatasetExistsError")
if "TableNotFoundValidationError" in classnames:
return DatasetError.create(error=str(messages), error_type="TableNotFoundError")
# Other DatasetInvalidError sub-types are returned as generic ValidationError.
# Add explicit branches here when callers need to distinguish them.
return DatasetError.create(error=str(messages), error_type="ValidationError")
@tool(
tags=["mutate"],
class_permission_name="Dataset",
method_permission_name="write",
annotations=ToolAnnotations(
title="Register physical table as dataset",
readOnlyHint=False,
destructiveHint=False,
),
)
async def create_dataset(
request: CreateDatasetRequest, ctx: Context
) -> DatasetInfo | DatasetError:
"""Register a physical table as a Superset dataset.
Wraps POST /api/v1/dataset/ — the same endpoint the UI uses when you click
Data → Datasets → +Dataset. Returns full dataset metadata (same shape as
get_dataset_info) so you can pass the resulting dataset_id straight into
generate_chart.
Required fields:
- database_id: ID of the existing database connection
- table_name: Exact name of the physical table to register
Optional fields:
- schema: Schema/namespace where the table lives (e.g. "public"). Omit for
databases without schema namespaces (e.g. SQLite).
- catalog: Catalog where the table lives. Omit for databases without catalog
support.
- owners: List of user IDs to set as owners (defaults to calling user)
Example:
```json
{
"database_id": 1,
"schema": "public",
"table_name": "orders"
}
```
Returns DatasetInfo on success or DatasetError on failure.
Use list_databases to find the correct database_id.
"""
schema = request.schema_
table_name = request.table_name
catalog = request.catalog
await ctx.info(
"Registering physical table as dataset: database_id=%s, table=%s"
% (request.database_id, f"{schema}.{table_name}" if schema else table_name)
)
try:
dataset_properties: dict[str, object] = {
"database": request.database_id,
"table_name": table_name,
}
if schema is not None:
dataset_properties["schema"] = schema
if catalog is not None:
dataset_properties["catalog"] = catalog
if request.owners is not None:
dataset_properties["owners"] = request.owners
with event_logger.log_context(action="mcp.create_dataset.create"):
dataset = CreateDatasetCommand(dataset_properties).run()
result = serialize_dataset_object(dataset)
if result is None:
return DatasetError.create(
error="Dataset was created but could not be serialized",
error_type="SerializationError",
)
await ctx.info(
"Dataset registered: id=%s, table=%s"
% (
dataset.id,
f"{schema}.{table_name}" if schema else table_name,
)
)
return result
except DatasetInvalidError as exc:
# CreateDatasetCommand.validate() collects validation errors into
# DatasetInvalidError.exceptions, never raising them directly.
# Inspect the wrapped class names for a typed response.
error_response = _classify_invalid_error(exc)
await ctx.warning(
"Dataset validation failed (%s): %s"
% (error_response.error_type, error_response.error)
)
return error_response
except DatasetCreateFailedError:
logger.exception("Dataset creation failed")
await ctx.error("Dataset creation failed")
return DatasetError.create(
error="Dataset creation failed", error_type="CreateFailedError"
)
except Exception as exc:
logger.exception("Unexpected error in create_dataset")
await ctx.error("Unexpected error: %s" % (type(exc).__name__,))
raise

View File

@@ -21,6 +21,7 @@ from typing import Any, TYPE_CHECKING
from urllib import parse
import sqlalchemy as sqla
from flask import current_app
from flask_appbuilder import Model
from flask_appbuilder.models.decorators import renders
from markupsafe import escape, Markup
@@ -225,6 +226,11 @@ class Slice( # pylint: disable=too-many-public-methods
"query_context": self.query_context,
"modified": self.modified(),
"owners": [owner.id for owner in self.owners],
"extra_owners": (
[u["id"] for u in resolver(self)]
if (resolver := current_app.config.get("EXTRA_OWNERS_RESOLVER"))
else []
),
"slice_id": self.id,
"slice_name": self.slice_name,
"slice_url": self.slice_url,

View File

@@ -3153,6 +3153,8 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
:raises SupersetSecurityException: If the user cannot access the resource
"""
# pylint: disable=import-outside-toplevel
from flask import current_app
from superset import is_feature_enabled
from superset.connectors.sqla.models import SqlaTable
from superset.models.dashboard import Dashboard
@@ -3160,6 +3162,22 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
from superset.models.sql_lab import Query
from superset.utils.core import shortid
# Extension hook: bypass all permission checks if an external system
# (e.g. folder permissions) grants access to this resource.
if bypass := current_app.config.get("EXTRA_RAISE_FOR_ACCESS_BYPASS"):
if bypass(
user_id=get_user_id(),
dashboard=dashboard,
chart=chart,
datasource=datasource,
query_context=query_context,
):
logger.info(
"EXTRA_RAISE_FOR_ACCESS_BYPASS granted access for user %s",
get_user_id(),
)
return
if sql and database:
query = Query(
database=database,
@@ -4147,6 +4165,17 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
owners = orig_resource.owners if hasattr(orig_resource, "owners") else []
if g.user.is_anonymous or g.user not in owners:
# Extension hook: check if the user is an extra owner
resolver = current_app.config.get("EXTRA_OWNERS_RESOLVER")
if resolver and not g.user.is_anonymous:
extra_owners = resolver(orig_resource)
user_id = g.user.id
if any(
(u.id if hasattr(u, "id") else u.get("id")) == user_id
for u in extra_owners
):
return
raise SupersetSecurityException(
SupersetError(
error_type=SupersetErrorType.MISSING_OWNERSHIP_ERROR,

View File

@@ -0,0 +1,585 @@
# 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.
"""Unit tests for create_dataset MCP tool."""
import importlib
from unittest.mock import MagicMock, Mock, patch
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
from superset.commands.dataset.exceptions import (
DatabaseNotFoundValidationError,
DatasetCreateFailedError,
DatasetDataAccessIsNotAllowed,
DatasetExistsValidationError,
DatasetInvalidError,
TableNotFoundValidationError,
)
from superset.mcp_service.app import mcp
from superset.sql.parse import Table
from superset.utils import json
# Use importlib to get the module object directly, bypassing the __init__.py
# attribute binding that shadows the module name with the exported function.
# This ensures patch.object resolves to the module in all Python versions.
create_dataset_module = importlib.import_module(
"superset.mcp_service.dataset.tool.create_dataset"
)
def _make_mock_dataset(
dataset_id: int = 42,
table_name: str = "orders",
schema: str = "public",
database_name: str = "main_db",
) -> MagicMock:
dataset = MagicMock()
dataset.id = dataset_id
dataset.table_name = table_name
dataset.schema = schema
dataset.description = None
dataset.certified_by = None
dataset.certification_details = None
dataset.changed_by_name = "admin"
dataset.changed_on = None
dataset.changed_on_humanized = None
dataset.created_by_name = "admin"
dataset.created_on = None
dataset.created_on_humanized = None
dataset.tags = []
dataset.owners = []
dataset.is_virtual = False
dataset.is_favorite = None
dataset.database_id = 1
dataset.schema_perm = f"[{database_name}].[{schema}]"
dataset.url = f"/tablemodelview/edit/{dataset_id}"
dataset.database = MagicMock()
dataset.database.database_name = database_name
dataset.sql = None
dataset.main_dttm_col = None
dataset.offset = 0
dataset.cache_timeout = 0
dataset.params = {}
dataset.template_params = {}
dataset.extra = {}
dataset.uuid = f"dataset-uuid-{dataset_id}"
dataset.columns = []
dataset.metrics = []
return dataset
@pytest.fixture
def mcp_server():
return mcp
@pytest.fixture(autouse=True)
def mock_auth():
with patch("superset.mcp_service.auth.get_user_from_request") as mock_get_user:
mock_user = Mock()
mock_user.id = 1
mock_user.username = "admin"
mock_get_user.return_value = mock_user
yield mock_get_user
class TestCreateDataset:
"""Tests for the create_dataset MCP tool."""
@patch.object(create_dataset_module, "CreateDatasetCommand")
@pytest.mark.asyncio
async def test_create_dataset_success(self, mock_command_class, mcp_server) -> None:
"""Happy path: tool creates dataset and returns DatasetInfo."""
mock_dataset = _make_mock_dataset()
mock_command = MagicMock()
mock_command.run.return_value = mock_dataset
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
result = await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 1,
"schema": "public",
"table_name": "orders",
}
},
)
assert result.content is not None
data = json.loads(result.content[0].text)
assert data["id"] == 42
assert data["table_name"] == "orders"
assert data["schema"] == "public"
# Verify the command was called with the right properties
call_kwargs = mock_command_class.call_args[0][0]
assert call_kwargs["database"] == 1
assert call_kwargs["schema"] == "public"
assert call_kwargs["table_name"] == "orders"
assert "owners" not in call_kwargs
@patch.object(create_dataset_module, "CreateDatasetCommand")
@pytest.mark.asyncio
async def test_create_dataset_with_owners(
self, mock_command_class, mcp_server
) -> None:
"""Owners list is forwarded to the command when supplied."""
mock_dataset = _make_mock_dataset()
mock_command = MagicMock()
mock_command.run.return_value = mock_dataset
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
result = await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 2,
"schema": "sales",
"table_name": "transactions",
"owners": [5, 10],
}
},
)
data = json.loads(result.content[0].text)
assert data["id"] == 42
call_kwargs = mock_command_class.call_args[0][0]
assert call_kwargs["owners"] == [5, 10]
@patch.object(create_dataset_module, "CreateDatasetCommand")
@pytest.mark.asyncio
async def test_create_dataset_already_exists(
self, mock_command_class, mcp_server
) -> None:
"""Returns DatasetExistsError when the table is already registered.
CreateDatasetCommand.validate() wraps DatasetExistsValidationError inside
DatasetInvalidError. The tool must inspect get_list_classnames() to surface
the typed error response.
"""
mock_command = MagicMock()
mock_command.run.side_effect = DatasetInvalidError(
exceptions=[DatasetExistsValidationError(Table("orders", "public", None))]
)
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
result = await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 1,
"schema": "public",
"table_name": "orders",
}
},
)
data = json.loads(result.content[0].text)
assert data["error_type"] == "DatasetExistsError"
assert "error" in data
@patch.object(create_dataset_module, "CreateDatasetCommand")
@pytest.mark.asyncio
async def test_create_dataset_table_not_found(
self, mock_command_class, mcp_server
) -> None:
"""Returns TableNotFoundError when the physical table does not exist in the DB.
CreateDatasetCommand.validate() wraps TableNotFoundValidationError inside
DatasetInvalidError. The tool must inspect get_list_classnames() to surface
the typed error response.
"""
mock_command = MagicMock()
mock_command.run.side_effect = DatasetInvalidError(
exceptions=[
TableNotFoundValidationError(Table("missing_table", "public", None))
]
)
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
result = await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 1,
"schema": "public",
"table_name": "missing_table",
}
},
)
data = json.loads(result.content[0].text)
assert data["error_type"] == "TableNotFoundError"
@patch.object(create_dataset_module, "CreateDatasetCommand")
@pytest.mark.asyncio
async def test_create_dataset_with_catalog(
self, mock_command_class, mcp_server
) -> None:
"""Catalog field is normalized and forwarded to the command when supplied."""
mock_dataset = _make_mock_dataset()
mock_command = MagicMock()
mock_command.run.return_value = mock_dataset
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 1,
"catalog": " hive ",
"schema": "default",
"table_name": "events",
}
},
)
call_kwargs = mock_command_class.call_args[0][0]
assert call_kwargs["catalog"] == "hive"
@patch.object(create_dataset_module, "CreateDatasetCommand")
@pytest.mark.asyncio
async def test_create_dataset_invalid_error(
self, mock_command_class, mcp_server
) -> None:
"""DatasetInvalidError is returned as ValidationError type."""
mock_command = MagicMock()
mock_command.run.side_effect = DatasetInvalidError()
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
result = await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 1,
"schema": "public",
"table_name": "orders",
}
},
)
data = json.loads(result.content[0].text)
assert data["error_type"] == "ValidationError"
assert "error" in data
@patch.object(create_dataset_module, "CreateDatasetCommand")
@pytest.mark.asyncio
async def test_create_dataset_create_failed_error(
self, mock_command_class, mcp_server
) -> None:
"""DatasetCreateFailedError is returned as CreateFailedError type."""
mock_command = MagicMock()
mock_command.run.side_effect = DatasetCreateFailedError()
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
result = await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 1,
"schema": "public",
"table_name": "orders",
}
},
)
data = json.loads(result.content[0].text)
assert data["error_type"] == "CreateFailedError"
assert "Dataset creation failed" in data["error"]
@patch.object(create_dataset_module, "CreateDatasetCommand")
@pytest.mark.asyncio
async def test_create_dataset_database_not_found(
self, mock_command_class, mcp_server
) -> None:
"""Returns DatabaseNotFoundError when CreateDatasetCommand raises it."""
mock_command = MagicMock()
mock_command.run.side_effect = DatasetInvalidError(
exceptions=[DatabaseNotFoundValidationError()]
)
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
result = await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 999,
"table_name": "orders",
}
},
)
data = json.loads(result.content[0].text)
assert data["error_type"] == "DatabaseNotFoundError"
assert "Database not found" in data["error"]
@patch.object(create_dataset_module, "CreateDatasetCommand")
@pytest.mark.asyncio
async def test_create_dataset_access_denied(
self, mock_command_class, mcp_server
) -> None:
"""Returns AccessDeniedError when CreateDatasetCommand raises it."""
mock_command = MagicMock()
mock_command.run.side_effect = DatasetInvalidError(
exceptions=[DatasetDataAccessIsNotAllowed("Access is Denied")]
)
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
result = await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 1,
"schema": "secret",
"table_name": "restricted_table",
}
},
)
data = json.loads(result.content[0].text)
assert data["error_type"] == "AccessDeniedError"
assert "Access denied" in data["error"]
@patch.object(create_dataset_module, "CreateDatasetCommand")
@pytest.mark.asyncio
async def test_create_dataset_unexpected_error(
self, mock_command_class, mcp_server
) -> None:
"""Unexpected exceptions are re-raised as ToolError (handled by middleware)."""
mock_command = MagicMock()
mock_command.run.side_effect = RuntimeError("DB connection lost")
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
with pytest.raises(ToolError):
await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 1,
"schema": "public",
"table_name": "orders",
}
},
)
@pytest.mark.asyncio
async def test_create_dataset_missing_required_fields(self, mcp_server) -> None:
"""Missing required fields raise a validation error before the tool runs."""
async with Client(mcp_server) as client:
with pytest.raises(ToolError):
await client.call_tool(
"create_dataset",
{
"request": {
# database_id and table_name are omitted intentionally
"schema": "public",
}
},
)
@patch.object(create_dataset_module, "CreateDatasetCommand")
@pytest.mark.asyncio
async def test_create_dataset_returns_full_dataset_info(
self, mock_command_class, mcp_server
) -> None:
"""The returned DatasetInfo includes columns, metrics, and all core fields."""
mock_dataset = _make_mock_dataset(
dataset_id=99, table_name="sales", schema="dw"
)
col = MagicMock()
col.column_name = "amount"
col.verbose_name = "Amount"
col.type = "NUMERIC"
col.is_dttm = False
col.groupby = True
col.filterable = True
col.description = "Sale amount"
mock_dataset.columns = [col]
metric = MagicMock()
metric.metric_name = "total_sales"
metric.verbose_name = "Total Sales"
metric.expression = "SUM(amount)"
metric.description = "Sum of amounts"
metric.d3format = None
mock_dataset.metrics = [metric]
mock_command = MagicMock()
mock_command.run.return_value = mock_dataset
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
result = await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 1,
"schema": "dw",
"table_name": "sales",
}
},
)
data = json.loads(result.content[0].text)
assert data["id"] == 99
assert data["table_name"] == "sales"
assert data["schema"] == "dw"
assert data["is_virtual"] is False
assert len(data["columns"]) == 1
assert data["columns"][0]["column_name"] == "amount"
assert len(data["metrics"]) == 1
assert data["metrics"][0]["metric_name"] == "total_sales"
@patch.object(create_dataset_module, "CreateDatasetCommand")
@pytest.mark.asyncio
async def test_create_dataset_table_name_whitespace_normalized(
self, mock_command_class, mcp_server
) -> None:
"""Whitespace in table_name is stripped before forwarding to the command."""
mock_dataset = _make_mock_dataset()
mock_command = MagicMock()
mock_command.run.return_value = mock_dataset
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 1,
"schema": "public",
"table_name": " orders ",
}
},
)
call_kwargs = mock_command_class.call_args[0][0]
assert call_kwargs["table_name"] == "orders"
@patch.object(create_dataset_module, "CreateDatasetCommand")
@pytest.mark.asyncio
async def test_create_dataset_blank_schema_normalized_to_none(
self, mock_command_class, mcp_server
) -> None:
"""Blank schema string is treated as absent: not forwarded to the command."""
mock_dataset = _make_mock_dataset(schema="")
mock_command = MagicMock()
mock_command.run.return_value = mock_dataset
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 1,
"schema": "",
"table_name": "orders",
}
},
)
call_kwargs = mock_command_class.call_args[0][0]
assert "schema" not in call_kwargs
@patch.object(create_dataset_module, "CreateDatasetCommand")
@pytest.mark.asyncio
async def test_create_dataset_blank_catalog_normalized_to_none(
self, mock_command_class, mcp_server
) -> None:
"""Blank catalog string is treated as absent: not forwarded to the command."""
mock_dataset = _make_mock_dataset()
mock_command = MagicMock()
mock_command.run.return_value = mock_dataset
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 1,
"catalog": "",
"table_name": "orders",
}
},
)
call_kwargs = mock_command_class.call_args[0][0]
assert "catalog" not in call_kwargs
@pytest.mark.asyncio
async def test_create_dataset_non_string_namespace_rejected(
self, mcp_server
) -> None:
"""Non-string schema/catalog values fail validation, not silently dropped."""
async with Client(mcp_server) as client:
for field, value in (("schema", 123), ("catalog", {"name": "hive"})):
with pytest.raises(ToolError):
await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 1,
"table_name": "orders",
field: value,
}
},
)
@patch.object(create_dataset_module, "CreateDatasetCommand")
@patch.object(create_dataset_module, "serialize_dataset_object", return_value=None)
@pytest.mark.asyncio
async def test_create_dataset_when_serialize_returns_none(
self, mock_serialize, mock_command_class, mcp_server
) -> None:
"""Returns SerializationError when serialize_dataset_object returns None."""
mock_dataset = _make_mock_dataset()
mock_command = MagicMock()
mock_command.run.return_value = mock_dataset
mock_command_class.return_value = mock_command
async with Client(mcp_server) as client:
result = await client.call_tool(
"create_dataset",
{
"request": {
"database_id": 1,
"schema": "public",
"table_name": "orders",
}
},
)
data = json.loads(result.content[0].text)
assert data["error_type"] == "SerializationError"

View File

@@ -0,0 +1,204 @@
# 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.
"""Tests for config-based permission extension hooks.
Follows the same pattern as EXTRA_DYNAMIC_QUERY_FILTERS tests in
tests/unit_tests/databases/api_test.py.
"""
from unittest.mock import Mock
import pytest
from superset import db
@pytest.fixture
def sample_chart(app_context: None):
"""Create a minimal chart for testing."""
from superset.models.slice import Slice
chart = Slice(
slice_name="test_permission_hooks_chart",
datasource_type="table",
viz_type="table",
params="{}",
)
db.session.add(chart)
db.session.flush()
yield chart
db.session.delete(chart)
db.session.flush()
@pytest.fixture
def sample_user(app_context: None):
"""Create a minimal user for testing."""
from flask_appbuilder.security.sqla.models import User
user = User(
first_name="test",
last_name="hooks",
username="test_permission_hooks_user",
email="test_hooks@example.com",
)
db.session.add(user)
db.session.flush()
yield user
db.session.delete(user)
db.session.flush()
def test_extra_owners_resolver_injects_into_extra_owners(sample_chart, monkeypatch):
"""EXTRA_OWNERS_RESOLVER populates Slice.data['extra_owners'], not 'owners'."""
from flask import current_app
original_owner_ids = [o.id for o in sample_chart.owners]
# Without config — extra_owners is empty, owners unchanged
monkeypatch.setitem(current_app.config, "EXTRA_OWNERS_RESOLVER", None)
data = sample_chart.data
assert data["owners"] == original_owner_ids
assert data["extra_owners"] == []
# With config — extra_owners populated, owners unchanged
def _resolver(resource):
return [{"id": 99999, "first_name": "Folder", "last_name": "Editor"}]
resolver_mock = Mock(side_effect=_resolver)
monkeypatch.setitem(current_app.config, "EXTRA_OWNERS_RESOLVER", resolver_mock)
data = sample_chart.data
assert data["owners"] == original_owner_ids
assert 99999 in data["extra_owners"]
assert resolver_mock.call_count == 1
def test_extra_owners_resolver_empty_returns_unchanged(sample_chart, monkeypatch):
"""EXTRA_OWNERS_RESOLVER returning empty list leaves extra_owners empty."""
from flask import current_app
resolver_mock = Mock(return_value=[])
monkeypatch.setitem(current_app.config, "EXTRA_OWNERS_RESOLVER", resolver_mock)
data = sample_chart.data
original_owner_ids = [o.id for o in sample_chart.owners]
assert data["owners"] == original_owner_ids
assert data["extra_owners"] == []
assert resolver_mock.call_count == 1
def test_raise_for_access_bypass_skips_checks(app_context: None, monkeypatch):
"""EXTRA_RAISE_FOR_ACCESS_BYPASS returning True skips all permission checks."""
from flask import current_app
from superset import security_manager
bypass_mock = Mock(return_value=True)
monkeypatch.setitem(
current_app.config, "EXTRA_RAISE_FOR_ACCESS_BYPASS", bypass_mock
)
security_manager.raise_for_access(dashboard=None, chart=None)
assert bypass_mock.call_count == 1
def test_raise_for_access_no_bypass_without_config(app_context: None, monkeypatch):
"""Without EXTRA_RAISE_FOR_ACCESS_BYPASS, normal checks proceed."""
from flask import current_app
from superset import security_manager
monkeypatch.setitem(current_app.config, "EXTRA_RAISE_FOR_ACCESS_BYPASS", None)
security_manager.raise_for_access(dashboard=None, chart=None)
def test_ownership_check_allows_non_owner(sample_chart, sample_user, monkeypatch):
"""EXTRA_OWNERS_RESOLVER returning the user allows a non-owner to pass."""
from flask import current_app, g
from superset import security_manager
resolver_mock = Mock(return_value=[sample_user])
monkeypatch.setitem(current_app.config, "EXTRA_OWNERS_RESOLVER", resolver_mock)
monkeypatch.setattr(g, "user", sample_user, raising=False)
security_manager.raise_for_ownership(sample_chart)
resolver_mock.assert_called_once()
def test_owner_auto_add_skipped_with_resolver(
sample_user, app_context: None, monkeypatch
):
"""When EXTRA_OWNERS_RESOLVER is set, current user is NOT auto-added."""
from flask import current_app, g
from flask_appbuilder.security.sqla.models import User
from superset.commands.utils import populate_owner_list
other_user = User(
first_name="other",
last_name="skip",
username="test_skip_other",
email="skip_other@example.com",
)
db.session.add(other_user)
db.session.flush()
resolver = Mock(return_value=[])
monkeypatch.setitem(current_app.config, "EXTRA_OWNERS_RESOLVER", resolver)
monkeypatch.setattr(g, "user", sample_user, raising=False)
try:
owners = populate_owner_list([other_user.id], default_to_user=False)
owner_ids = [o.id for o in owners]
# Only the explicitly passed owner — current user NOT auto-added
assert owner_ids == [other_user.id]
assert sample_user.id not in owner_ids
finally:
db.session.delete(other_user)
db.session.flush()
def test_owner_auto_add_without_resolver(sample_user, app_context: None, monkeypatch):
"""Without EXTRA_OWNERS_RESOLVER, current user IS auto-added."""
from flask import current_app, g
from flask_appbuilder.security.sqla.models import User
from superset.commands.utils import populate_owner_list
other_user = User(
first_name="other",
last_name="user",
username="test_other_user",
email="other@example.com",
)
db.session.add(other_user)
db.session.flush()
monkeypatch.setitem(current_app.config, "EXTRA_OWNERS_RESOLVER", None)
monkeypatch.setattr(g, "user", sample_user, raising=False)
try:
owners = populate_owner_list([other_user.id], default_to_user=False)
owner_ids = [o.id for o in owners]
# Both the passed owner AND current user auto-added
assert sample_user.id in owner_ids
assert other_user.id in owner_ids
finally:
db.session.delete(other_user)
db.session.flush()