mirror of
https://github.com/apache/superset.git
synced 2026-06-16 13:09:20 +00:00
Compare commits
13 Commits
fix/report
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d08e79259 | ||
|
|
01ed81785e | ||
|
|
7b4efacbc2 | ||
|
|
7cb4990403 | ||
|
|
c90b2571d7 | ||
|
|
1a4941eee5 | ||
|
|
d839cca995 | ||
|
|
0ec7e7df99 | ||
|
|
9d8287e1bd | ||
|
|
0c696cea7e | ||
|
|
fe625a917e | ||
|
|
a69f9eb00d | ||
|
|
1311d040ba |
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
208
superset-frontend/package-lock.json
generated
208
superset-frontend/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
332
superset-websocket/package-lock.json
generated
332
superset-websocket/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
171
superset/mcp_service/dataset/tool/create_dataset.py
Normal file
171
superset/mcp_service/dataset/tool/create_dataset.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
585
tests/unit_tests/mcp_service/dataset/tool/test_create_dataset.py
Normal file
585
tests/unit_tests/mcp_service/dataset/tool/test_create_dataset.py
Normal 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"
|
||||
204
tests/unit_tests/security/test_permission_hooks.py
Normal file
204
tests/unit_tests/security/test_permission_hooks.py
Normal 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()
|
||||
Reference in New Issue
Block a user