diff --git a/.github/workflows/publish-release-images.yml b/.github/workflows/publish-release-images.yml index c27980680c..9f05d971b6 100644 --- a/.github/workflows/publish-release-images.yml +++ b/.github/workflows/publish-release-images.yml @@ -79,14 +79,16 @@ jobs: AMD64=${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/backend:${{ env.CI_TESTED_TAG_SUFFIX }} ARM64=${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/backend:${{ env.CI_TESTED_TAG_SUFFIX }}-arm64 TARGET=${{ env.RELEASE_DOCKER_REPOSITORY }}/backend:${{ github.ref_name }} + LATEST=${{ env.RELEASE_DOCKER_REPOSITORY }}/backend:latest echo "Creating multi-arch image:" echo " $TARGET" + echo " $LATEST" echo "from:" echo " $AMD64" echo " $ARM64" - docker buildx imagetools create -t "$TARGET" "$AMD64" "$ARM64" + docker buildx imagetools create -t "$TARGET" -t "$LATEST" "$AMD64" "$ARM64" build-webfrontend-arm64-release-image: name: Build web-frontend arm64 image (GHCR) @@ -152,14 +154,16 @@ jobs: AMD64=${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/web-frontend:${{ env.CI_TESTED_TAG_SUFFIX }} ARM64=${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/web-frontend:${{ env.CI_TESTED_TAG_SUFFIX }}-arm64 TARGET=${{ env.RELEASE_DOCKER_REPOSITORY }}/web-frontend:${{ github.ref_name }} + LATEST=${{ env.RELEASE_DOCKER_REPOSITORY }}/web-frontend:latest echo "Creating multi-arch image:" echo " $TARGET" + echo " $LATEST" echo "from:" echo " $AMD64" echo " $ARM64" - docker buildx imagetools create --debug -t "$TARGET" "$AMD64" "$ARM64" + docker buildx imagetools create --debug -t "$TARGET" -t "$LATEST" "$AMD64" "$ARM64" build-all-in-one-arm64-release-image: name: Build baserow (all-in-one) arm64 image (GHCR) @@ -231,14 +235,16 @@ jobs: AMD64=${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/baserow:${{ env.CI_TESTED_TAG_SUFFIX }} ARM64=${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/baserow:${{ env.CI_TESTED_TAG_SUFFIX }}-arm64 TARGET=${{ env.RELEASE_DOCKER_REPOSITORY }}/baserow:${{ github.ref_name }} + LATEST=${{ env.RELEASE_DOCKER_REPOSITORY }}/baserow:latest echo "Creating multi-arch image:" echo " $TARGET" + echo " $LATEST" echo "from:" echo " $AMD64" echo " $ARM64" - docker buildx imagetools create -t "$TARGET" "$AMD64" "$ARM64" + docker buildx imagetools create -t "$TARGET" -t "$LATEST" "$AMD64" "$ARM64" build-cloudron-arm64-release-image: name: Build cloudron arm64 image (GHCR) diff --git a/backend/email_compiler/baserowEmailCompiler.js b/backend/email_compiler/baserowEmailCompiler.js index 5ed55d8ab4..b4dae63c4c 100644 --- a/backend/email_compiler/baserowEmailCompiler.js +++ b/backend/email_compiler/baserowEmailCompiler.js @@ -4,7 +4,7 @@ const mjml2html = require('mjml') const fs = require('fs') const Eta = require('eta') const path = require('path') -const glob = require('glob') +const { globSync } = require('glob') const chokidar = require('chokidar') const BASEROW_BACKEND_SRC_DIR = path.join(__dirname, '..', 'src') @@ -52,10 +52,9 @@ function compileEtaAndMjml(mjmlEtaFile) { function recompileAllEtaAndMjmlFilesAfterLayoutFileChanges(layoutFile) { console.log(`Layout file changed (${layoutFile})`) - glob(MJML_ETA_FILE_GLOB, {}, function (er, files) { - files.forEach((file) => { - compileEtaAndMjml(file) - }) + const files = globSync(MJML_ETA_FILE_GLOB) + files.forEach((file) => { + compileEtaAndMjml(file) }) } diff --git a/backend/email_compiler/package.json b/backend/email_compiler/package.json index 2217923fd9..d4bf4fe997 100644 --- a/backend/email_compiler/package.json +++ b/backend/email_compiler/package.json @@ -7,9 +7,9 @@ "dependencies": { "chalk": "^5.0.0", "chokidar": "^3.5.3", - "eta": "^1.12.3", - "glob": "^7.2.0", - "mjml": "^4.12.0" + "eta": "^2.0.0", + "glob": "^9.0.0", + "mjml": "^4.15.0" }, "author": "Bram Wiepjes (Baserow)", "license": "MIT", diff --git a/backend/email_compiler/yarn.lock b/backend/email_compiler/yarn.lock index 3a80933f80..e85bc1dbc4 100644 --- a/backend/email_compiler/yarn.lock +++ b/backend/email_compiler/yarn.lock @@ -2,28 +2,53 @@ # yarn lockfile v1 -"@babel/runtime@^7.14.6": - version "7.17.0" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.0.tgz#b8d142fc0f7664fb3d9b5833fd40dcbab89276c0" - integrity sha512-etcO/ohMNaNA2UBdaXBBSX/3aEzFMRrVfaPv8Ptc0k+cWpWW0QFiGZ2XnVqQZI1Cf734LbPGmqBKWESfW4x/dQ== - dependencies: - regenerator-runtime "^0.13.4" - -abbrev@1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +"@babel/runtime@^7.28.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326" + integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@one-ini/wasm@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@one-ini/wasm/-/wasm-0.1.1.tgz#6013659736c9dbfccc96e8a9c2b3de317df39323" + integrity sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw== + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +abbrev@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf" + integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== ansi-colors@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" - integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + version "4.1.3" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" + integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== +ansi-regex@^6.0.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + ansi-styles@^4.0.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" @@ -31,10 +56,15 @@ ansi-styles@^4.0.0: dependencies: color-convert "^2.0.1" +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + anymatch@~3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" - integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== dependencies: normalize-path "^3.0.0" picomatch "^2.0.4" @@ -45,71 +75,71 @@ balanced-match@^1.0.0: integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== binary-extensions@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" - integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== + version "2.3.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" + integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" - integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== +brace-expansion@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== dependencies: balanced-match "^1.0.0" - concat-map "0.0.1" braces@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" - integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== dependencies: - fill-range "^7.0.1" + fill-range "^7.1.1" camel-case@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73" - integrity sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M= + integrity sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w== dependencies: no-case "^2.2.0" upper-case "^1.1.1" chalk@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.0.0.tgz#bd96c6bb8e02b96e08c0c3ee2a9d90e050c7b832" - integrity sha512-/duVOqst+luxCQRKEo4bNxinsOQtMP80ZYm7mMqzuh5PociNL0PvmHFvREJ9ueYL2TxlHjBcmLCdmocx9Vg+IQ== + version "5.6.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== -cheerio-select@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-1.5.0.tgz#faf3daeb31b17c5e1a9dabcee288aaf8aafa5823" - integrity sha512-qocaHPv5ypefh6YNxvnbABM07KMxExbtbfuJoIie3iZXX1ERwYmJcIiRrr9H05ucQP1k28dav8rpdDgjQd8drg== - dependencies: - css-select "^4.1.3" - css-what "^5.0.1" - domelementtype "^2.2.0" - domhandler "^4.2.0" - domutils "^2.7.0" - -cheerio@1.0.0-rc.10, cheerio@^1.0.0-rc.3: - version "1.0.0-rc.10" - resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.10.tgz#2ba3dcdfcc26e7956fc1f440e61d51c643379f3e" - integrity sha512-g0J0q/O6mW8z5zxQ3A8E8J1hUgp4SMOvEoW/x84OwyHKe/Zccz83PVT4y5Crcr530FV6NgmKI1qvGTKVl9XXVw== +cheerio-select@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4" + integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g== dependencies: - cheerio-select "^1.5.0" - dom-serializer "^1.3.2" - domhandler "^4.2.0" - htmlparser2 "^6.1.0" - parse5 "^6.0.1" - parse5-htmlparser2-tree-adapter "^6.0.1" - tslib "^2.2.0" + boolbase "^1.0.0" + css-select "^5.1.0" + css-what "^6.1.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + +cheerio@1.0.0-rc.12: + version "1.0.0-rc.12" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.12.tgz#788bf7466506b1c6bf5fae51d24a2c4d62e47683" + integrity sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q== + dependencies: + cheerio-select "^2.1.0" + dom-serializer "^2.0.0" + domhandler "^5.0.3" + domutils "^3.0.1" + htmlparser2 "^8.0.1" + parse5 "^7.0.0" + parse5-htmlparser2-tree-adapter "^7.0.0" chokidar@^3.0.0, chokidar@^3.5.3: - version "3.5.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" - integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== dependencies: anymatch "~3.1.2" braces "~3.0.2" @@ -128,13 +158,13 @@ clean-css@^4.2.1: dependencies: source-map "~0.6.0" -cliui@^7.0.2: - version "7.0.4" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" - integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== dependencies: string-width "^4.2.0" - strip-ansi "^6.0.0" + strip-ansi "^6.0.1" wrap-ansi "^7.0.0" color-convert@^2.0.1: @@ -149,22 +179,22 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +commander@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + commander@^2.19.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -commander@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" - integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== +commander@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= - -config-chain@^1.1.12: +config-chain@^1.1.13: version "1.1.13" resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== @@ -172,56 +202,81 @@ config-chain@^1.1.12: ini "^1.3.4" proto-list "~1.2.1" -css-select@^4.1.3: - version "4.2.1" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.2.1.tgz#9e665d6ae4c7f9d65dbe69d0316e3221fb274cdd" - integrity sha512-/aUslKhzkTNCQUB2qTX84lVmfia9NyjP3WpDGtj/WxhwBzWBYUV3DgUpurHTme8UTPcPlAD1DJ+b0nN/t50zDQ== +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +css-select@^5.1.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.2.2.tgz#01b6e8d163637bb2dd6c982ca4ed65863682786e" + integrity sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw== dependencies: boolbase "^1.0.0" - css-what "^5.1.0" - domhandler "^4.3.0" - domutils "^2.8.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" nth-check "^2.0.1" -css-what@^5.0.1, css-what@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.1.0.tgz#3f7b707aadf633baf62c2ceb8579b545bb40f7fe" - integrity sha512-arSMRWIIFY0hV8pIxZMEfmMI47Wj3R/aWpZDDxWYCPEiOMv6tfOrnpDtgxBYPEQD4V0Y/958+1TdC3iWTFcUPw== +css-what@^6.1.0: + version "6.2.2" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.2.2.tgz#cdcc8f9b6977719fdfbd1de7aec24abf756b9dea" + integrity sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA== -detect-node@2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c" - integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw== +detect-node@2.1.0, detect-node@^2.0.4: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" + integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== -dom-serializer@^1.0.1, dom-serializer@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91" - integrity sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig== +dom-serializer@^1.0.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" + integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== dependencies: domelementtype "^2.0.1" domhandler "^4.2.0" entities "^2.0.0" -domelementtype@^2.0.1, domelementtype@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57" - integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A== +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" -domhandler@^3.0.0: +domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.3.0.tgz#6db7ea46e4617eb15cf875df68b2b8524ce0037a" integrity sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA== dependencies: domelementtype "^2.0.1" -domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.0.tgz#16c658c626cf966967e306f966b431f77d4a5626" - integrity sha512-fC0aXNQXqKSFTr2wDNZDhsEYjCiYsDWl3D01kwt25hm1YIPyDGHvvi3rw+PLqHAl/m71MaiF7d5zvBr0p5UB2g== +domhandler@^4.2.0: + version "4.3.1" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" + integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== dependencies: domelementtype "^2.2.0" -domutils@^2.0.0, domutils@^2.5.2, domutils@^2.7.0, domutils@^2.8.0: +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^2.4.2: version "2.8.0" resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== @@ -230,57 +285,94 @@ domutils@^2.0.0, domutils@^2.5.2, domutils@^2.7.0, domutils@^2.8.0: domelementtype "^2.2.0" domhandler "^4.2.0" -editorconfig@^0.15.3: - version "0.15.3" - resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-0.15.3.tgz#bef84c4e75fb8dcb0ce5cee8efd51c15999befc5" - integrity sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g== +domutils@^3.0.1, domutils@^3.1.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" + integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== dependencies: - commander "^2.19.0" - lru-cache "^4.1.5" - semver "^5.6.0" - sigmund "^1.0.1" + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +editorconfig@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-1.0.4.tgz#040c9a8e9a6c5288388b87c2db07028aa89f53a3" + integrity sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q== + dependencies: + "@one-ini/wasm" "0.1.1" + commander "^10.0.0" + minimatch "9.0.1" + semver "^7.5.3" emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + entities@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== +entities@^4.2.0, entities@^4.4.0, entities@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +entities@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694" + integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== + escalade@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" - integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== escape-goat@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-3.0.0.tgz#e8b5fb658553fe8a3c4959c316c6ebb8c842b19c" integrity sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw== -eta@^1.12.3: - version "1.12.3" - resolved "https://registry.yarnpkg.com/eta/-/eta-1.12.3.tgz#2982d08adfbef39f9fa50e2fbd42d7337e7338b1" - integrity sha512-qHixwbDLtekO/d51Yr4glcaUJCIjGVJyTzuqV4GPlgZo1YpgOKG+avQynErZIYrfM6JIJdtiG2Kox8tbb+DoGg== +eta@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/eta/-/eta-2.2.0.tgz#eb8b5f8c4e8b6306561a455e62cd7492fe3a9b8a" + integrity sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g== -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== dependencies: to-regex-range "^5.0.1" +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== fsevents@~2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== get-caller-file@^2.0.5: version "2.0.5" @@ -294,17 +386,27 @@ glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" -glob@^7.1.1, glob@^7.1.3, glob@^7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" - integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== +glob@^10.3.10, glob@^10.4.2: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +glob@^9.0.0: + version "9.3.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-9.3.5.tgz#ca2ed8ca452781a3009685607fdf025a899dfe21" + integrity sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q== dependencies: fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" + minimatch "^8.0.2" + minipass "^4.2.4" + path-scurry "^1.6.1" he@^1.2.0: version "1.2.0" @@ -324,38 +426,35 @@ html-minifier@^4.0.0: relateurl "^0.2.7" uglify-js "^3.5.1" -htmlparser2@^4.0.0, htmlparser2@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-4.1.0.tgz#9a4ef161f2e4625ebf7dfbe6c0a2f52d18a59e78" - integrity sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q== +htmlparser2@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-5.0.1.tgz#7daa6fc3e35d6107ac95a4fc08781f091664f6e7" + integrity sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ== dependencies: domelementtype "^2.0.1" - domhandler "^3.0.0" - domutils "^2.0.0" + domhandler "^3.3.0" + domutils "^2.4.2" entities "^2.0.0" -htmlparser2@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" - integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A== +htmlparser2@^8.0.1: + version "8.0.2" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" + integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== dependencies: - domelementtype "^2.0.1" - domhandler "^4.0.0" - domutils "^2.5.2" - entities "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + entities "^4.4.0" -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= +htmlparser2@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.1.0.tgz#cdb498d8a75a51f739b61d3f718136c369bc8c23" + integrity sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ== dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.1.0" + entities "^4.5.0" ini@^1.3.4: version "1.3.8" @@ -372,7 +471,7 @@ is-binary-path@~2.1.0: is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== is-fullwidth-code-point@^3.0.0: version "3.0.0" @@ -391,28 +490,48 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + js-beautify@^1.6.14: - version "1.14.0" - resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.14.0.tgz#2ce790c555d53ce1e3d7363227acf5dc69024c2d" - integrity sha512-yuck9KirNSCAwyNJbqW+BxJqJ0NLJ4PwBUzQQACl5O3qHMBXVkXb/rD0ilh/Lat/tn88zSZ+CAHOlk0DsY7GuQ== + version "1.15.4" + resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.15.4.tgz#f579f977ed4c930cef73af8f98f3f0a608acd51e" + integrity sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA== dependencies: - config-chain "^1.1.12" - editorconfig "^0.15.3" - glob "^7.1.3" - nopt "^5.0.0" + config-chain "^1.1.13" + editorconfig "^1.0.4" + glob "^10.4.2" + js-cookie "^3.0.5" + nopt "^7.2.1" -juice@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/juice/-/juice-7.0.0.tgz#509bed6adbb6e4bbaa7fbfadac4e2e83e8c89ba3" - integrity sha512-AjKQX31KKN+uJs+zaf+GW8mBO/f/0NqSh2moTMyvwBY+4/lXIYTU8D8I2h6BAV3Xnz6GGsbalUyFqbYMe+Vh+Q== +js-cookie@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" + integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== + +juice@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/juice/-/juice-10.0.1.tgz#a1492091ef739e4771b9f60aad1a608b5a8ea3ba" + integrity sha512-ZhJT1soxJCkOiO55/mz8yeBKTAJhRzX9WBO+16ZTqNTONnnVlUPyVBIzQ7lDRjaBdTbid+bAnyIon/GM3yp4cA== dependencies: - cheerio "^1.0.0-rc.3" - commander "^5.1.0" + cheerio "1.0.0-rc.12" + commander "^6.1.0" mensch "^0.3.4" slick "^1.12.2" - web-resource-inliner "^5.0.0" + web-resource-inliner "^6.0.1" -lodash@^4.17.15, lodash@^4.17.21: +lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -420,15 +539,12 @@ lodash@^4.17.15, lodash@^4.17.21: lower-case@^1.1.1: version "1.1.4" resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac" - integrity sha1-miyr0bno4K6ZOkv31YdcOcQujqw= + integrity sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA== -lru-cache@^4.1.5: - version "4.1.5" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" - integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== - dependencies: - pseudomap "^1.0.2" - yallist "^2.1.2" +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== mensch@^0.3.4: version "0.3.4" @@ -440,344 +556,369 @@ mime@^2.4.6: resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== -minimatch@^3.0.4: - version "3.0.5" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.5.tgz#4da8f1290ee0f0f8e83d60ca69f8f134068604a3" - integrity sha512-tUpxzX0VAzJHjLu0xUfFv1gwVp9ba3IOuRAVH2EGuRW8a5emA2FlACLqiT/lDVtS1W+TGNwqz3sWaNyLgDJWuw== +minimatch@9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253" + integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^8.0.2: + version "8.0.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-8.0.4.tgz#847c1b25c014d4e9a7f68aaf63dedd668a626229" + integrity sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA== dependencies: - brace-expansion "^1.1.7" + brace-expansion "^2.0.1" -mjml-accordion@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-accordion/-/mjml-accordion-4.12.0.tgz#8fa8a6777fc12caeac9aa8f21b77ac6a3e9b9261" - integrity sha512-vqBk4NhXN+w6F3c5vnLxkvgneREpkwTzZpbxtMzpNqkUW2yei0oSQ26j/wLgXYTaX+4Czp+oVr0cnNxjyCZHjA== +minimatch@^9.0.3, minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== dependencies: - "@babel/runtime" "^7.14.6" + brace-expansion "^2.0.1" + +minipass@^4.2.4: + version "4.2.8" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.2.8.tgz#f0010f64393ecfc1d1ccb5f582bcaf45f48e1a3a" + integrity sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +mjml-accordion@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-accordion/-/mjml-accordion-4.17.1.tgz#6ee3c016ea78a5a0ed29f3ec28c17d181028994f" + integrity sha512-xl9oUbMp8aju6b1OZYqv3orE287ofGNEv09h2mFmzRTJxug7nJBFXs2I9v7dUVuWIBRk940PjnIVSuW+9bPvCA== + dependencies: + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-body@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-body/-/mjml-body-4.12.0.tgz#97feb40e556ceb6444c6af3d79db3d96e9bc9549" - integrity sha512-IQBAHhdRKsNUXat+oxvRTjVJ1qzTRkNjFe/mtD/Pbn9olUnQmV+RKxnkqRZf7QtiTxVIOGC4kU9VLPjNymsFXQ== +mjml-body@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-body/-/mjml-body-4.17.1.tgz#a4e4d2ee34abfbb45b74999ee49356b35830a0dc" + integrity sha512-EfvVVfutARRjJ1jsOxxf2DY/ufqWswv9JKjbwu/Fu8h4havAcdmw2BDmX3vwXEzatqpL1l//YWOKlqUe9ZEs+A== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-button@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-button/-/mjml-button-4.12.0.tgz#c89103f702181f0722787ab9905affe98f326c73" - integrity sha512-XJfLP+mHvCr6Ky16ooYz5+8ODkf10+ATyvENCKyrof+rietr5WxN2FxWCZA9Orq20OE74/hvaOeZZdkxwtsXig== +mjml-button@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-button/-/mjml-button-4.17.1.tgz#1cbdf444802690329ea59ea524d088607666fa5f" + integrity sha512-A9xQwgccPzrwr11izorBsA92THpkrviWkCwlYMxL9V3wgt5YJYDrt4r023HCveqN7b6iTkvqkXeDoIPX/kEDDQ== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-carousel@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-carousel/-/mjml-carousel-4.12.0.tgz#0fdd9954c53108aa8c35cf9f6c2a3af9626566f8" - integrity sha512-vQ5Aqvix9mbAE0GspxIDpKK4dVMRuKFO3qV6N/CkrIAOe4+2CKV4AMn2fWUvQEx6hA6CGxayeLkI7E0hNOWcZA== +mjml-carousel@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-carousel/-/mjml-carousel-4.17.1.tgz#c395842741f55420dd7a3c08a76cd3d3a73e49ba" + integrity sha512-pWo/aIgRL3XduckOBVEvbpph3vR4f9maRrbJ8Jfu4NVI6Ws3PQ6wt7HPXHmJlzJlK0gTiAF9f4+I076RVHPG7A== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-cli@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-cli/-/mjml-cli-4.12.0.tgz#1911b8fa9925ae59e760714ba1c3a404c2c23393" - integrity sha512-//Y4XsN6aFgpZtDbQZRu4qe+CQzGWV3i5K3rC1dwPcdtpDMsXBPKiwIZFrQxpRVBwxs0hU4ZBQOMtvYZkoicdQ== +mjml-cli@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-cli/-/mjml-cli-4.17.1.tgz#0bc278762bc2391b6c61d16784156ee429cd104f" + integrity sha512-1cMWP+yDDBUIjDYnjiKhIPW3NYJrt/W5rqOiB3zOTZQBT722Uh055S3BoLUikKxc+1sQPww4d9dH371zX2HaXA== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" chokidar "^3.0.0" - glob "^7.1.1" + glob "^10.3.10" html-minifier "^4.0.0" js-beautify "^1.6.14" lodash "^4.17.21" - mjml-core "4.12.0" - mjml-migrate "4.12.0" - mjml-parser-xml "4.12.0" - mjml-validator "4.12.0" - yargs "^16.1.0" - -mjml-column@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-column/-/mjml-column-4.12.0.tgz#8b88423478c5499f845b04701a961195eb88a69b" - integrity sha512-Ub/7ov2B1T2jfSpxvF61o3UCU4gGDFUqIelr7ghuazLc2KvTwdHYeR8mWt8l8RBM6zZiWjkYEFMP22ty7WXztg== - dependencies: - "@babel/runtime" "^7.14.6" + minimatch "^9.0.3" + mjml-core "4.17.1" + mjml-migrate "4.17.1" + mjml-parser-xml "4.17.1" + mjml-validator "4.17.1" + yargs "^17.7.2" + +mjml-column@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-column/-/mjml-column-4.17.1.tgz#3aa64972bb827fd96b596c71ce583e9ef720aa41" + integrity sha512-S+oNZaWFP1/TCEgVwVcwqYIyHwwVZWSKLKj4fcWIMUCaHWKuojYrexOGfULDAwTjYEDhZaRDrrq96ulF12wJeQ== + dependencies: + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-core@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-core/-/mjml-core-4.12.0.tgz#acb6268cd9cd31f7bdfcf54a6dcb10708f976b48" - integrity sha512-B3gUkV3kFN1IlzIV3GnpWBmE21XHH5ARyydMxacR75iC53PvJ9c50hr6DWLGdrrDCC6Fdud8jTmgD9dnWPmJhQ== +mjml-core@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-core/-/mjml-core-4.17.1.tgz#a303e8e4f94f5124284843bebd70c1d91a9085a6" + integrity sha512-u2aHbBxFA2uJdS6T1A1ZTGYryPNebHMByRrMPCbe5W8Os+sGiC5gKLhZgjavZteKiMS+09swkvfneNLGYwzBKg== dependencies: - "@babel/runtime" "^7.14.6" - cheerio "1.0.0-rc.10" - detect-node "2.0.4" + "@babel/runtime" "^7.28.4" + cheerio "1.0.0-rc.12" + detect-node "^2.0.4" html-minifier "^4.0.0" js-beautify "^1.6.14" - juice "^7.0.0" + juice "^10.0.0" lodash "^4.17.21" - mjml-migrate "4.12.0" - mjml-parser-xml "4.12.0" - mjml-validator "4.12.0" + mjml-migrate "4.17.1" + mjml-parser-xml "4.17.1" + mjml-validator "4.17.1" -mjml-divider@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-divider/-/mjml-divider-4.12.0.tgz#04baa6096a8da4460aa53a7c930e72a4a792937e" - integrity sha512-L87iqrhVS+PnUInYbXK4lcTQcHfWMTL7ZqDL9XEMBywzX8cCfviLNMbqmLCO2HD8nMPVMRbcE32H04T6LyZ2qw== +mjml-divider@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-divider/-/mjml-divider-4.17.1.tgz#1219c4f25d9e6f963de9438a5adf85064c7e9f01" + integrity sha512-KUWvcx1cIDwkN/gDuY37e9Vv+0U5U+xOVOfXRGloSnapYcP0IvmLtLTJeBwvGhwoN30wBiHDGwO8p/7B6CyxqQ== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-group@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-group/-/mjml-group-4.12.0.tgz#ccef836fd7d16166f8f73128d0707fdad3139614" - integrity sha512-Rl7Iydd7M2SnbH1ItIi07hYY+FrEai5c6kYMKbcFWAuNupCuvUThuhx1AphMPCZFMLbbPSKNWMarBkWhepS7cw== +mjml-group@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-group/-/mjml-group-4.17.1.tgz#38b4da7e67151c2c8c84378ef176432a135e11e2" + integrity sha512-0vOcLm7l4ptLM5rqC6DhCafxIw5+WUrSYLcdUSJxO3AZMGJMxU7fkWeWGowE+PQdgqh6ee1/4RYc2qJDWtHW5A== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-head-attributes@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-head-attributes/-/mjml-head-attributes-4.12.0.tgz#21a31fe824f451d95a8750c12f5b7f8dcdaca164" - integrity sha512-tRwKUzIrtcw1FGy8Xpy4vrFo0u2daZgqx3X0cM5WWrGFcKe7ZdjNEAkU/3w+WsFjeMcb0fHdKvd+sxBjPJ6fpA== +mjml-head-attributes@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-head-attributes/-/mjml-head-attributes-4.17.1.tgz#198ca06a6a9b9148af1b6508aab38a41c2cf9b30" + integrity sha512-p+g33eI4xyHb9Yv28zIXnNdsXQYvoGex/GvoGumwyxu6O8OOvPk1mIV87SjDISQHosJJMcTiZVd/RfBlwnZpGA== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-head-breakpoint@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-head-breakpoint/-/mjml-head-breakpoint-4.12.0.tgz#f71f4ddd8ca0b97c5de864b83aee879345d962cb" - integrity sha512-BVVbvAIcIu49P1EJkEPPIY8Gu4GleyzpkdddqD3ihAPn3Pz07SEsFlHvI35eCszuaJeeMbSSxLrsF4m+aQQlvw== +mjml-head-breakpoint@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-head-breakpoint/-/mjml-head-breakpoint-4.17.1.tgz#4fafa9cc176c052a2796d904ff84172290826adb" + integrity sha512-vjsNAgdLnwqmkVlIENbH6odK6ZARiNQvsm+1o8CLo9ymw82WhIEbOnAeCfoddumZ6h2ywbZuBZzS23jJi13MUQ== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-head-font@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-head-font/-/mjml-head-font-4.12.0.tgz#698af765ef785d353a42d127296e742df15a68f5" - integrity sha512-ja5sWbGOIr1gF/7IIPzrgOlWYiKk57BC8JWYRANV7CxNKa635sd6aBJHbzXv1A6Ph+zH5KtE0MSQCK8n49BIsw== +mjml-head-font@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-head-font/-/mjml-head-font-4.17.1.tgz#0984862ebae07fbf2427e61c3d997ac155d56bd2" + integrity sha512-Xeih/vqocR1BoBLbh8Sn67kNkfLsyHeZ7Z/3nyNz7TriZ//TGAR/PGTFFghQlXyX1BCtSx/eFoxMkKKswLYReA== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-head-html-attributes@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-head-html-attributes/-/mjml-head-html-attributes-4.12.0.tgz#3c6a6e927ee314a0afd29a08bd103f68ac53a2e1" - integrity sha512-XJesJuW9uzlNN5w/S7t5ZquSVDay7BehOKmIZKMwKn1y0SJBXiakcwt9M9hhF0HB189Bew0gpGt3m7QYvTez8g== +mjml-head-html-attributes@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-head-html-attributes/-/mjml-head-html-attributes-4.17.1.tgz#cb4b74210257d9bb7ba9b23ca5cc409516456b8b" + integrity sha512-O7YzEAFtSELB7wVYV808g6JcxXrzHk5glDdzzCEhDR4bjPHewSUpkrYOqvt0BdfdFsvqH4zm4vsJImUMW692HQ== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-head-preview@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-head-preview/-/mjml-head-preview-4.12.0.tgz#34b8f7797a2170de6f2be4c42b170aa9351340f2" - integrity sha512-pr02ZkxwU6/LWhrL3xP/hLrUXx27I1FnfgaYjgvMjh6pMURuy7W+W8BrNJKeyXZo685b2A5lNFDJV7rCJ6HrEQ== +mjml-head-preview@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-head-preview/-/mjml-head-preview-4.17.1.tgz#63e52bae35b43bdc43da838a0c3e85f3131439ef" + integrity sha512-XL+8N9yrADJSw4gX9lvDcp31ghGy8WavenVO8UhxPyhLu/sMJ9lFXLbTB4z5JU1z4t/HPEp/GtgMGxAbr+QrQQ== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-head-style@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-head-style/-/mjml-head-style-4.12.0.tgz#d86dd9553dd3f9a057f70c193dd15ef0cf934bd3" - integrity sha512-64IVdJ2Xl000SrwLt4cebl+MiZcino/ywMkuLQ/c48XeR6pkvbjXYAInWsdlMG1y041n1bOZICNnQQc4xhNJrw== +mjml-head-style@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-head-style/-/mjml-head-style-4.17.1.tgz#29a1cb440ae36e0029af6cf1adc9eeb181ada09f" + integrity sha512-YTjtqZAG0hD0aYwk02/8hS1W+T4nDUhVCBFmcxL/aTSrRbJQew0dSVtCvqNpAsbIJCUg/mUxx6pKKzRPdN+FtA== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-head-title@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-head-title/-/mjml-head-title-4.12.0.tgz#d21ee32b2b929bac36140f92ea5b447e039d03af" - integrity sha512-c7thJUmNLIdVy1ftLbYUjchHwrIfAb9SHdbuVQHdtQz45a3Ni2nie4AWxF/srn90k8q/uEKtQq1taOa4f71Zug== +mjml-head-title@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-head-title/-/mjml-head-title-4.17.1.tgz#def969e9ac58e975bc686fc95273ddc2d23925f1" + integrity sha512-cUO4b7tDuX1BLu6XYnPgG40o3pBUCkT+Yzu5DGsvRxvCWougJFN68ocF6zcc7OOanmLgBYlJevQKUyT6W5Rp0g== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-head@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-head/-/mjml-head-4.12.0.tgz#fe0167cd4d8a16edc0ba1be0e9f73b78ca7be79e" - integrity sha512-LcI4ykOB6nMV5W//tF9S1unlXxexfNZUnnyZ2OOzP1V7J5poLXdKXqB8XATN2YGGTsDZ5Q/5V1KO+NnjpW7zSw== +mjml-head@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-head/-/mjml-head-4.17.1.tgz#516d6039e103424d05ec5b55202b79a2b9a440f4" + integrity sha512-+DBJ6UvkpYkKJGJKqo8luucDGbg9+rQZKytl/4VOGTE8bmbrKFixY3lkfmBrSkQ7/t6L4dDVSXywl6H91JsL+g== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-hero@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-hero/-/mjml-hero-4.12.0.tgz#a1a9e10a0c8693d504d26866c862b17ef554d639" - integrity sha512-j87DgSAyLzMMuNtVqR1okkI/orKnvZoR7i+RsA1yueNql9dZtnw3Ezy8cas8MJaAoGOmqIy9AqGRJIr82w4mxQ== +mjml-hero@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-hero/-/mjml-hero-4.17.1.tgz#d4f7ad9e29cb11107843f68a906f9389acb6a230" + integrity sha512-WDmNVJ4+xHLrkYOrGrq23hUYDVG3iFSyk/vIC/KlcG5Kebu5vVWbe6n3ZEucatPuYn/EUVV1ofIJM6dnXXfkGQ== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-image@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-image/-/mjml-image-4.12.0.tgz#3ac0fd5917aa51c55b3619fa16e460489b0c11c8" - integrity sha512-P77M+PLLNn7QvGhL8sx+6yzkQbEMxIQO3yxqUC+x8Ie8kXS8phSNGcqx8qfhdN7p7sQ3CZdOIZSXkG7RRAF94w== +mjml-image@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-image/-/mjml-image-4.17.1.tgz#9a427d719caf664b3a60b8f6cfb10e91dabdcb5d" + integrity sha512-ZIFXmP2Fb77vvX8SBQYbrAPPvkqx5GqJ7AqVWteQk4iz6nJf8GspZiotWyL4LvgZzf0B81aQCB11y7+RvAfVvw== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-migrate@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-migrate/-/mjml-migrate-4.12.0.tgz#b0b2ff7f7b799f4255f13946d8ec6c63a84b2bfd" - integrity sha512-KDdPkuOzL9CAekY0CslM0Yqiomk4TubNMszw6UFfylp5xRA3CfBo0HdGcnewHBkZ8+isjPlzDWf3n+NkU11OiA== +mjml-migrate@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-migrate/-/mjml-migrate-4.17.1.tgz#d50dd85f5f964d2e860741e657da03078209eeb4" + integrity sha512-Rb66BdvuV8fGYdQJzvLK0naWGI8G9smzm1OJDjdhcCrQU3BfTW/BiTS9FP5G0W73kFJe//vlHCDZ3uBIr6REAA== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" js-beautify "^1.6.14" lodash "^4.17.21" - mjml-core "4.12.0" - mjml-parser-xml "4.12.0" - yargs "^16.1.0" + mjml-core "4.17.1" + mjml-parser-xml "4.17.1" + yargs "^17.7.2" + +mjml-navbar@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-navbar/-/mjml-navbar-4.17.1.tgz#215e1dc8546dc9658af59770113ac0f9b3eae47e" + integrity sha512-SWtovALlb+tM2lu2stlsKItrM/Tc/YxWiCm+UtLuOvkBmouBX/vASufaFab3VPAq/pGJKF9nFGX2eWoJCGA4rA== + dependencies: + "@babel/runtime" "^7.28.4" + lodash "^4.17.21" + mjml-core "4.17.1" -mjml-navbar@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-navbar/-/mjml-navbar-4.12.0.tgz#2b5c965c83fae83a38c5e4534f325450faf5bc86" - integrity sha512-TWKV5lFgwUvRbG+FNz6Uo7mGPJRU/BK1v0BeQr1e5Ykft4052iYIuv2XNwRkeoORmLT+7AN8FbkP+TVBpflbWw== +mjml-parser-xml@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-parser-xml/-/mjml-parser-xml-4.17.1.tgz#7b497c20bf1bb343fe49e2c79b24aa5ae926a4a8" + integrity sha512-8cc1+cI1+ymeKmiaioZMaIzg8K9SmCErr0WOdS0n90pnt5eLqGQEh3RQJv7VoucO5aoJXgAnCSGeCstVXvZykg== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" + detect-node "2.1.0" + htmlparser2 "^9.1.0" lodash "^4.17.21" - mjml-core "4.12.0" - -mjml-parser-xml@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-parser-xml/-/mjml-parser-xml-4.12.0.tgz#5db2285ca4625c443ce369c8de576687db3453aa" - integrity sha512-cmCcvoiirH0kuCglGAjwBVfDrlnqS3e83uBwPN6wDN6IfxSgsPT6IV0vRfcJERsr2ThpFjvoSq4GmYi9oCUSMw== - dependencies: - "@babel/runtime" "^7.14.6" - detect-node "2.0.4" - htmlparser2 "^4.1.0" - lodash "^4.17.15" - -mjml-preset-core@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-preset-core/-/mjml-preset-core-4.12.0.tgz#93af147b2f37817e74ef889fc45748b2077ae52c" - integrity sha512-zoiCKcl/bK43ltr2J8dY9Qg5fcB3TbhaWcTG84oGYWdii5WEkKTXj5hpP1ss1XqdOGMNLij/HVwmli+xQCo6FQ== - dependencies: - "@babel/runtime" "^7.14.6" - mjml-accordion "4.12.0" - mjml-body "4.12.0" - mjml-button "4.12.0" - mjml-carousel "4.12.0" - mjml-column "4.12.0" - mjml-divider "4.12.0" - mjml-group "4.12.0" - mjml-head "4.12.0" - mjml-head-attributes "4.12.0" - mjml-head-breakpoint "4.12.0" - mjml-head-font "4.12.0" - mjml-head-html-attributes "4.12.0" - mjml-head-preview "4.12.0" - mjml-head-style "4.12.0" - mjml-head-title "4.12.0" - mjml-hero "4.12.0" - mjml-image "4.12.0" - mjml-navbar "4.12.0" - mjml-raw "4.12.0" - mjml-section "4.12.0" - mjml-social "4.12.0" - mjml-spacer "4.12.0" - mjml-table "4.12.0" - mjml-text "4.12.0" - mjml-wrapper "4.12.0" - -mjml-raw@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-raw/-/mjml-raw-4.12.0.tgz#3fb7525d630911e2e25eb5dcf7f4904f0e0bec0d" - integrity sha512-vQUmrEZEgu0DCca7tiPdQ/vf8GM5QyeaabbLd1rX3XCt5Mid47LCdszmVcrk1WxqNuExIw1fNyEGCCDeP2qCJg== - dependencies: - "@babel/runtime" "^7.14.6" + +mjml-preset-core@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-preset-core/-/mjml-preset-core-4.17.1.tgz#7826184b7ca57383e47597c1593e492a9a5b4102" + integrity sha512-cFfelKeRJNG+WZv+kGWjjHrQam5PiHH8JaC3vvjl1eEwLcR2nbaYArlnLTIzgG+M3+cBlIl0Ru3Say5ZqWAcxw== + dependencies: + "@babel/runtime" "^7.28.4" + mjml-accordion "4.17.1" + mjml-body "4.17.1" + mjml-button "4.17.1" + mjml-carousel "4.17.1" + mjml-column "4.17.1" + mjml-divider "4.17.1" + mjml-group "4.17.1" + mjml-head "4.17.1" + mjml-head-attributes "4.17.1" + mjml-head-breakpoint "4.17.1" + mjml-head-font "4.17.1" + mjml-head-html-attributes "4.17.1" + mjml-head-preview "4.17.1" + mjml-head-style "4.17.1" + mjml-head-title "4.17.1" + mjml-hero "4.17.1" + mjml-image "4.17.1" + mjml-navbar "4.17.1" + mjml-raw "4.17.1" + mjml-section "4.17.1" + mjml-social "4.17.1" + mjml-spacer "4.17.1" + mjml-table "4.17.1" + mjml-text "4.17.1" + mjml-wrapper "4.17.1" + +mjml-raw@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-raw/-/mjml-raw-4.17.1.tgz#0422013a4b8c6f35afdc624e56b47039c6c174f2" + integrity sha512-CnfgXh+c8u/jOuVjmv9N6Hxal5U4PPJFVY1JFRRJr/7Tcxl8aJUF03mBjqW9zAzoYO1bRcgyG3clchyEwwXQ8g== + dependencies: + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-section@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-section/-/mjml-section-4.12.0.tgz#9d67bd52bb8418a76765d556f60467a506ed79b9" - integrity sha512-5BdHrAghS/XJ40t3qtLHpY3rIVuBnJXv8dGm8U+oMVAzw3L4ySk5WI+FulRkchdPFCKpeXQZjXZaX0C7pmNaIw== +mjml-section@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-section/-/mjml-section-4.17.1.tgz#10339354719e7e2c02911e56510811fb5bf9fa5b" + integrity sha512-YrkvcBgJw2NBnPirjuVU4AoqwySZzOovm5sfryID9I59EmmG+lbBJOnv/v/5wXQSlw2a4n1+VX2sCUcH5/O5sA== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-social@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-social/-/mjml-social-4.12.0.tgz#7423acc02a180c91b186806ba7e522861dcbc8bf" - integrity sha512-eTsqJoKP65Imawh+WEX2dv4N34ItUmvIbsCeSQPhC/NG6klxDjzg5oDA1F2tZk+CPIuXVmJiauQ5/vPHLzUiVw== +mjml-social@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-social/-/mjml-social-4.17.1.tgz#52e9f28799a1992ae291b1b7000b7c2b58cb23be" + integrity sha512-Agp6CHJn7SwD+cckCxibZ/32luTzAiDJDlKH0SjQ+9NvSoGskkhii3yOqtYnJ+t3NmQkxpRkXOnUN4GEbupghA== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-spacer@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-spacer/-/mjml-spacer-4.12.0.tgz#129662cfc02ef777973517eadd99cfdf6c3ab215" - integrity sha512-YB+VCixcuWXDzICrGLFw7PJDkL166e4OG8IUUB2yhvd5VHtFFBc0iRksaEAumOL1r6MnXVCRq4Wcmxlzj7zOfQ== +mjml-spacer@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-spacer/-/mjml-spacer-4.17.1.tgz#5a81281872f3f2556c1828bf24e8df475ac71463" + integrity sha512-TxXDosuRzuoQNdceG47TKy+NWbwIGZmVDV/4XRtkcPHEvlsHpIIzn2+zzj+xrA6qh5Z+zlXL+x8ZpWMqrUoKfQ== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-table@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-table/-/mjml-table-4.12.0.tgz#e8682786a43a144e96d43c2891affab8facc3dde" - integrity sha512-IuLvyiJOsM6RgobuIfZuM36fJcoH8pK/A4awCLTEme0HCxEkkjzDkl4RBMK/KX53Cpor0U6oR6RlQfZcducpLg== +mjml-table@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-table/-/mjml-table-4.17.1.tgz#0955b75ff86eb80a511cbcd7a37befca8c41101c" + integrity sha512-AcAcsNrpzTOsNc0X0i0+5+iNNGEnYjwn9qodF/413yuWDSH9p7SL8vFuI3Snmgv9s1dR+BKDiF8uPt4XTOMlzA== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-text@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-text/-/mjml-text-4.12.0.tgz#615af59c8932433b82dd8c7f5d132ec7625f397f" - integrity sha512-AFcXiQBC48ZfKKgAdU0NRS2nqftc8zLGxBtPwHNgFkuh5Lf2rWgPK6JRubNi7qhb8Sd7M8stU+LIRA5sxM1nRQ== +mjml-text@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-text/-/mjml-text-4.17.1.tgz#77e1598c1e4d98c10490d242c9928ec3aa6e3663" + integrity sha512-pOrz8tRU3hReKd+K69dJmiVndC0+gB5IfVKIK3fdvYMb9laZBAstkXW0j5wn/0Af4FZSlJkDRLM7Ylxbh1+fqQ== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" + mjml-core "4.17.1" -mjml-validator@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-validator/-/mjml-validator-4.12.0.tgz#f359f89f8ca6bfe955af2bf351a48055d59db903" - integrity sha512-EmOScfcJJ4LdIyHnE+K4FdkryQ+c6QRV7qp+zlunAHE5AUPaBS0OrHPHuNo1sOu7g1tc+bVl7eHR4FIb0Wkzwg== +mjml-validator@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-validator/-/mjml-validator-4.17.1.tgz#d73fb08bc368763f6bf0898a88b6b8452573b2d2" + integrity sha512-0Au5L5fIfAzOJpQG4PkpFeV0mbzCgjCTu5XbG7pJX4Wup72TGYwrA6Aq2yAdlx17kFPWThSZxeB3Xpd3/kwqOg== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" -mjml-wrapper@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml-wrapper/-/mjml-wrapper-4.12.0.tgz#8f2b6ee108ed5cc49dd7fbe75c09a77351221d03" - integrity sha512-u0pq+A9QBLwpeF/hdv2uWZIv3Qp4wwf+CMaHZsUpb3YfOJD/6YKwLvkeA7ngE+YxwwzgtgjmIEs4eDae1evlgQ== +mjml-wrapper@4.17.1: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml-wrapper/-/mjml-wrapper-4.17.1.tgz#8832cd7da08a32478189041b8b1c6538f204022a" + integrity sha512-c0bCgXCwffI4krnQYU0Zp8ifGkYMgE7a65NAWXlV3AWEfVmjDlhCcD8LBfZ8UfY8zR3Che8pnunowPZfwh0Nxg== dependencies: - "@babel/runtime" "^7.14.6" + "@babel/runtime" "^7.28.4" lodash "^4.17.21" - mjml-core "4.12.0" - mjml-section "4.12.0" + mjml-core "4.17.1" + mjml-section "4.17.1" -mjml@^4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/mjml/-/mjml-4.12.0.tgz#bcf5c508075c5b05d84f611180234f4a2b3e13b0" - integrity sha512-uWDu1pPQVyoX4iKIrM02J6qOBN6PC1rSMP64DKi2qGU4dpOztVgvTBh6JttIbINV4ZiALtpeGu+jeEUqp2ROXA== +mjml@^4.15.0: + version "4.17.1" + resolved "https://registry.yarnpkg.com/mjml/-/mjml-4.17.1.tgz#fe77de5258f31b42532f601ccb10058a2d95bdee" + integrity sha512-aqy5EVZuwXIINl+d7vC1Fn+MzMfIU4qxCx2TUHnGJxYONrtNIgSQEDlgB2ns2oK8a8WgPuEJCZBYwRE+5ZFcng== dependencies: - "@babel/runtime" "^7.14.6" - mjml-cli "4.12.0" - mjml-core "4.12.0" - mjml-migrate "4.12.0" - mjml-preset-core "4.12.0" - mjml-validator "4.12.0" + "@babel/runtime" "^7.28.4" + mjml-cli "4.17.1" + mjml-core "4.17.1" + mjml-migrate "4.17.1" + mjml-preset-core "4.17.1" + mjml-validator "4.17.1" no-case@^2.2.0: version "2.3.2" @@ -787,18 +928,18 @@ no-case@^2.2.0: lower-case "^1.1.1" node-fetch@^2.6.0: - version "2.6.7" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" - integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== dependencies: whatwg-url "^5.0.0" -nopt@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" - integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== +nopt@^7.2.1: + version "7.2.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7" + integrity sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w== dependencies: - abbrev "1" + abbrev "^2.0.0" normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" @@ -806,42 +947,51 @@ normalize-path@^3.0.0, normalize-path@~3.0.0: integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== nth-check@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.0.1.tgz#2efe162f5c3da06a28959fbd3db75dbeea9f0fc2" - integrity sha512-it1vE95zF6dTT9lBsYbxvqh0Soy4SPowchj0UBGj/V6cTPnXXtQOPUbhZ6CmGzAD/rW22LQK6E96pcdJXk4A4w== + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== dependencies: boolbase "^1.0.0" -once@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= - dependencies: - wrappy "1" +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== param-case@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247" - integrity sha1-35T9jPZTHs915r75oIWPvHK+Ikc= + integrity sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w== dependencies: no-case "^2.2.0" -parse5-htmlparser2-tree-adapter@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" - integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== +parse5-htmlparser2-tree-adapter@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz#b5a806548ed893a43e24ccb42fbb78069311e81b" + integrity sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g== dependencies: - parse5 "^6.0.1" + domhandler "^5.0.3" + parse5 "^7.0.0" -parse5@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" - integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== +parse5@^7.0.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05" + integrity sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw== + dependencies: + entities "^6.0.0" -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-scurry@^1.11.1, path-scurry@^1.6.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" picomatch@^2.0.4, picomatch@^2.2.1: version "2.3.1" @@ -851,12 +1001,7 @@ picomatch@^2.0.4, picomatch@^2.2.1: proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" - integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk= - -pseudomap@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" - integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= + integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== readdirp@~3.6.0: version "3.6.0" @@ -865,42 +1010,49 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" -regenerator-runtime@^0.13.4: - version "0.13.9" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" - integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== - relateurl@^0.2.7: version "0.2.7" resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" - integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= + integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog== require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== -semver@^5.6.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== +semver@^7.5.3: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== -sigmund@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" - integrity sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA= +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== slick@^1.12.2: version "1.12.2" resolved "https://registry.yarnpkg.com/slick/-/slick-1.12.2.tgz#bd048ddb74de7d1ca6915faa4a57570b3550c2d7" - integrity sha1-vQSN23TefRymkV+qSldXCzVQwtc= + integrity sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A== source-map@~0.6.0: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -string-width@^4.1.0, string-width@^4.2.0: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -909,13 +1061,29 @@ string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: ansi-regex "^5.0.1" +strip-ansi@^7.0.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + dependencies: + ansi-regex "^6.0.1" + to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" @@ -926,36 +1094,31 @@ to-regex-range@^5.0.1: tr46@~0.0.3: version "0.0.3" resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= - -tslib@^2.2.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" - integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== uglify-js@^3.5.1: - version "3.15.1" - resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.15.1.tgz#9403dc6fa5695a6172a91bc983ea39f0f7c9086d" - integrity sha512-FAGKF12fWdkpvNJZENacOH0e/83eG6JyVQyanIJaBXCN1J11TUQv1T1/z8S+Z0CG0ZPk1nPcreF/c7lrTd0TEQ== + version "3.19.3" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f" + integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== upper-case@^1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598" - integrity sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg= + integrity sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA== valid-data-url@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/valid-data-url/-/valid-data-url-3.0.1.tgz#826c1744e71b5632e847dd15dbd45b9fb38aa34f" integrity sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA== -web-resource-inliner@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/web-resource-inliner/-/web-resource-inliner-5.0.0.tgz#ac30db8096931f20a7c1b3ade54ff444e2e20f7b" - integrity sha512-AIihwH+ZmdHfkJm7BjSXiEClVt4zUFqX4YlFAzjL13wLtDuUneSaFvDBTbdYRecs35SiU7iNKbMnN+++wVfb6A== +web-resource-inliner@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/web-resource-inliner/-/web-resource-inliner-6.0.1.tgz#df0822f0a12028805fe80719ed52ab6526886e02" + integrity sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A== dependencies: ansi-colors "^4.1.1" escape-goat "^3.0.0" - htmlparser2 "^4.0.0" + htmlparser2 "^5.0.0" mime "^2.4.6" node-fetch "^2.6.0" valid-data-url "^3.0.0" @@ -963,17 +1126,24 @@ web-resource-inliner@^5.0.0: webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE= + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" - integrity sha1-lmRU6HZUYuN2RNNib2dCzotwll0= + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== dependencies: tr46 "~0.0.3" webidl-conversions "^3.0.0" -wrap-ansi@^7.0.0: +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -982,35 +1152,34 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== -yallist@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" - integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= - -yargs-parser@^20.2.2: - version "20.2.9" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" - integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== -yargs@^16.1.0: - version "16.2.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" - integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== dependencies: - cliui "^7.0.2" + cliui "^8.0.1" escalade "^3.1.1" get-caller-file "^2.0.5" require-directory "^2.1.1" - string-width "^4.2.0" + string-width "^4.2.3" y18n "^5.0.5" - yargs-parser "^20.2.2" + yargs-parser "^21.1.1" diff --git a/backend/src/baserow/contrib/database/api/rows/serializers.py b/backend/src/baserow/contrib/database/api/rows/serializers.py index c7c49fa032..0cf763f8eb 100644 --- a/backend/src/baserow/contrib/database/api/rows/serializers.py +++ b/backend/src/baserow/contrib/database/api/rows/serializers.py @@ -508,10 +508,32 @@ class MoveRowQueryParamsSerializer(serializers.Serializer): class CreateRowQueryParamsSerializer(serializers.Serializer): before = serializers.IntegerField(required=False) + view = serializers.IntegerField(required=False) class BatchCreateRowsQueryParamsSerializer(serializers.Serializer): before = serializers.IntegerField(required=False) + view = serializers.IntegerField(required=False) + + +class GetRowQueryParamsSerializer(serializers.Serializer): + view = serializers.IntegerField(required=False) + + +class UpdateRowQueryParamsSerializer(serializers.Serializer): + view = serializers.IntegerField(required=False) + + +class BatchUpdateRowsQueryParamsSerializer(serializers.Serializer): + view = serializers.IntegerField(required=False) + + +class DeleteRowQueryParamsSerializer(serializers.Serializer): + view = serializers.IntegerField(required=False) + + +class BatchDeleteRowsQueryParamsSerializer(serializers.Serializer): + view = serializers.IntegerField(required=False) class ListRowsQueryParamsSerializer( diff --git a/backend/src/baserow/contrib/database/api/rows/views.py b/backend/src/baserow/contrib/database/api/rows/views.py index 24ef8db8b0..915c569e3c 100644 --- a/backend/src/baserow/contrib/database/api/rows/views.py +++ b/backend/src/baserow/contrib/database/api/rows/views.py @@ -56,7 +56,10 @@ ERROR_ROW_IDS_NOT_UNIQUE, ) from baserow.contrib.database.api.rows.exceptions import InvalidJoinParameterException -from baserow.contrib.database.api.rows.serializers import GetRowAdjacentSerializer +from baserow.contrib.database.api.rows.serializers import ( + GetRowAdjacentSerializer, + GetRowQueryParamsSerializer, +) from baserow.contrib.database.api.tables.errors import ERROR_TABLE_DOES_NOT_EXIST from baserow.contrib.database.api.tokens.authentications import TokenAuthentication from baserow.contrib.database.api.tokens.errors import ( @@ -110,7 +113,6 @@ from baserow.contrib.database.table.handler import TableHandler from baserow.contrib.database.table.models import Table from baserow.contrib.database.table.operations import ( - CreateRowDatabaseTableOperationType, ListRowNamesDatabaseTableOperationType, ListRowsDatabaseTableOperationType, ) @@ -137,12 +139,16 @@ from .schemas import row_names_response_schema from .serializers import ( BatchCreateRowsQueryParamsSerializer, + BatchDeleteRowsQueryParamsSerializer, BatchDeleteRowsSerializer, + BatchUpdateRowsQueryParamsSerializer, CreateRowQueryParamsSerializer, + DeleteRowQueryParamsSerializer, ListRowsQueryParamsSerializer, MoveRowQueryParamsSerializer, RowHistorySerializer, RowSerializer, + UpdateRowQueryParamsSerializer, get_batch_row_serializer_class, get_example_batch_rows_serializer_class, get_example_row_serializer_class, @@ -481,6 +487,13 @@ def get(self, request, table_id, query_params): description="If provided then the newly created row will be " "positioned before the row with the provided id.", ), + OpenApiParameter( + name="view", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + description="Provide if the row is created in a view. This can result " + "in different permission checking and default values.", + ), OpenApiParameter( name="user_field_names", location=OpenApiParameter.QUERY, @@ -547,6 +560,7 @@ def get(self, request, table_id, query_params): { UserNotInWorkspace: ERROR_USER_NOT_IN_GROUP, TableDoesNotExist: ERROR_TABLE_DOES_NOT_EXIST, + ViewDoesNotExist: ERROR_VIEW_DOES_NOT_EXIST, NoPermissionToTable: ERROR_NO_PERMISSION_TO_TABLE, RowDoesNotExist: ERROR_ROW_DOES_NOT_EXIST, CannotCreateRowsInTable: ERROR_CANNOT_CREATE_ROWS_IN_TABLE, @@ -567,13 +581,6 @@ def post(self, request: Request, table_id: int, query_params) -> Response: TokenHandler().check_table_permissions(request, "create", table, False) - CoreHandler().check_permissions( - request.user, - CreateRowDatabaseTableOperationType.type, - workspace=table.database.workspace, - context=table, - ) - user_field_names = extract_user_field_names_from_params(request.GET) send_webhook_events = extract_send_webhook_events_from_params(request.GET) @@ -591,6 +598,9 @@ def post(self, request: Request, table_id: int, query_params) -> Response: else None ) + view_id = query_params.get("view") + view = ViewHandler().get_view(view_id) if view_id else None + try: row = action_type_registry.get_by_type(CreateRowActionType).do( request.user, @@ -598,6 +608,7 @@ def post(self, request: Request, table_id: int, query_params) -> Response: data, model=model, before_row=before_row, + view=view, user_field_names=user_field_names, send_webhook_events=send_webhook_events, ) @@ -740,6 +751,13 @@ class RowView(APIView): type=OpenApiTypes.INT, description="Returns the row related the provided value.", ), + OpenApiParameter( + name="view", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + description="Provide if the row if fetched in a view. This can result " + "in different permission checking and default values.", + ), OpenApiParameter( name="user_field_names", location=OpenApiParameter.QUERY, @@ -800,7 +818,8 @@ class RowView(APIView): } ) @allowed_includes("metadata") - def get(self, request, table_id, row_id, metadata): + @validate_query_parameters(GetRowQueryParamsSerializer) + def get(self, request, table_id, row_id, metadata, query_params: dict): """ Responds with a serializer version of the row related to the provided row_id and table_id. @@ -815,9 +834,12 @@ def get(self, request, table_id, row_id, metadata): raise TokenCannotIncludeRowMetadata() token_handler.check_table_permissions(db_token, "read", table) + view_id = query_params.get("view") + view = ViewHandler().get_view(view_id) if view_id else None + user_field_names = extract_user_field_names_from_params(request.GET) model = table.get_model() - row = RowHandler().get_row(request.user, table, row_id, model) + row = RowHandler().get_row(request.user, table, row_id, model, view=view) serializer_class = get_row_serializer_class( model, RowSerializer, is_response=True, user_field_names=user_field_names ) @@ -846,6 +868,13 @@ def get(self, request, table_id, row_id, metadata): type=OpenApiTypes.INT, description="Updates the row related to the value.", ), + OpenApiParameter( + name="view", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + description="Provide if the row is updated in a view. This can result " + "in different permission checking and default values.", + ), OpenApiParameter( name="user_field_names", location=OpenApiParameter.QUERY, @@ -917,7 +946,10 @@ def get(self, request, table_id, row_id, metadata): ) @atomic_with_retry_on_deadlock() @require_request_data_type(dict) - def patch(self, request: Request, table_id: int, row_id: int) -> Response: + @validate_query_parameters(UpdateRowQueryParamsSerializer) + def patch( + self, request: Request, table_id: int, row_id: int, query_params + ) -> Response: """ Updates the row with the given row_id for the table with the given table_id. Also the post data is validated according to the tables field types. @@ -935,6 +967,10 @@ def patch(self, request: Request, table_id: int, row_id: int) -> Response: user_field_names = extract_user_field_names_from_params(request.GET) send_webhook_events = extract_send_webhook_events_from_params(request.GET) + + view_id = query_params.get("view") + view = ViewHandler().get_view(view_id) if view_id else None + field_ids, field_names = None, None if user_field_names: @@ -959,6 +995,7 @@ def patch(self, request: Request, table_id: int, row_id: int) -> Response: table, [data], model=model, + view=view, send_webhook_events=send_webhook_events, ) .updated_rows[0] @@ -980,6 +1017,13 @@ def patch(self, request: Request, table_id: int, row_id: int) -> Response: type=OpenApiTypes.INT, description="Deletes the row in the table related to the value.", ), + OpenApiParameter( + name="view", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + description="Provide if the row is deleted in a view. This can result " + "in different permission checking and default values.", + ), OpenApiParameter( name="row_id", location=OpenApiParameter.PATH, @@ -1028,7 +1072,8 @@ def patch(self, request: Request, table_id: int, row_id: int) -> Response: } ) @atomic_with_retry_on_deadlock() - def delete(self, request, table_id, row_id): + @validate_query_parameters(DeleteRowQueryParamsSerializer) + def delete(self, request, table_id, row_id, query_params): """ Deletes an existing row with the given row_id for table with the given table_id. @@ -1039,8 +1084,15 @@ def delete(self, request, table_id, row_id): table = TableHandler().get_table(table_id) TokenHandler().check_table_permissions(request, "delete", table, False) + view_id = query_params.get("view") + view = ViewHandler().get_view(view_id) if view_id else None + action_type_registry.get_by_type(DeleteRowActionType).do( - request.user, table, row_id, send_webhook_events=send_webhook_events + request.user, + table, + row_id, + view=view, + send_webhook_events=send_webhook_events, ) return Response(status=204) @@ -1184,6 +1236,13 @@ class BatchRowsView(APIView): description="If provided then the newly created rows will be " "positioned before the row with the provided id.", ), + OpenApiParameter( + name="view", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + description="Provide if the rows are created in a view. This can result " + "in different permission checking and default values.", + ), OpenApiParameter( name="user_field_names", location=OpenApiParameter.QUERY, @@ -1283,6 +1342,9 @@ def post(self, request: Request, table_id: int, query_params) -> Response: else None ) + view_id = query_params.get("view") + view = ViewHandler().get_view(view_id) if view_id else None + row_validation_serializer = get_row_serializer_class( model, user_field_names=user_field_names ) @@ -1299,6 +1361,7 @@ def post(self, request: Request, table_id: int, query_params) -> Response: table, data["items"], before_row, + view=view, model=model, send_webhook_events=send_webhook_events, ) @@ -1327,6 +1390,13 @@ def post(self, request: Request, table_id: int, query_params) -> Response: type=OpenApiTypes.INT, description="Updates the rows in the table.", ), + OpenApiParameter( + name="view", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + description="Provide if the rows are updated in a view. This can " + "result in different permission checking and default values.", + ), OpenApiParameter( name="user_field_names", location=OpenApiParameter.QUERY, @@ -1407,7 +1477,8 @@ def post(self, request: Request, table_id: int, query_params) -> Response: } ) @atomic_with_retry_on_deadlock() - def patch(self, request, table_id): + @validate_query_parameters(BatchUpdateRowsQueryParamsSerializer) + def patch(self, request, table_id, query_params): """ Updates all provided rows at once for the table with the given table_id. @@ -1421,6 +1492,9 @@ def patch(self, request, table_id): user_field_names = extract_user_field_names_from_params(request.GET) send_webhook_events = extract_send_webhook_events_from_params(request.GET) + view_id = query_params.get("view") + view = ViewHandler().get_view(view_id) if view_id else None + row_validation_serializer = get_row_serializer_class( model, user_field_names=user_field_names, @@ -1440,6 +1514,7 @@ def patch(self, request, table_id): table, data["items"], model=model, + view=view, send_webhook_events=send_webhook_events, ) rows = updated_data.updated_rows @@ -1474,6 +1549,13 @@ class BatchDeleteRowsView(APIView): type=OpenApiTypes.INT, description="Deletes the rows in the table related to the value.", ), + OpenApiParameter( + name="view", + location=OpenApiParameter.QUERY, + type=OpenApiTypes.INT, + description="Provide if the rows are deleted in a view. This can " + "result in different permission checking and default values.", + ), OpenApiParameter( name="send_webhook_events", location=OpenApiParameter.QUERY, @@ -1524,7 +1606,10 @@ class BatchDeleteRowsView(APIView): } ) @atomic_with_retry_on_deadlock() - def post(self, request: Request, table_id: int, data: Dict[str, Any]) -> Response: + @validate_query_parameters(BatchDeleteRowsQueryParamsSerializer) + def post( + self, request: Request, table_id: int, data: Dict[str, Any], query_params + ) -> Response: """ Batch deletes existing rows based on provided row ids for the table with the given table_id. @@ -1535,10 +1620,14 @@ def post(self, request: Request, table_id: int, data: Dict[str, Any]) -> Respons send_webhook_events = extract_send_webhook_events_from_params(request.GET) + view_id = query_params.get("view") + view = ViewHandler().get_view(view_id) if view_id else None + action_type_registry.get_by_type(DeleteRowsActionType).do( request.user, table, row_ids=data["items"], + view=view, send_webhook_events=send_webhook_events, ) diff --git a/backend/src/baserow/contrib/database/api/views/gallery/views.py b/backend/src/baserow/contrib/database/api/views/gallery/views.py index 3099a3a768..6931e167b2 100644 --- a/backend/src/baserow/contrib/database/api/views/gallery/views.py +++ b/backend/src/baserow/contrib/database/api/views/gallery/views.py @@ -219,6 +219,7 @@ def get( model = view.table.get_model() queryset = view_handler.get_queryset( + request.user, view, search, model, diff --git a/backend/src/baserow/contrib/database/api/views/grid/views.py b/backend/src/baserow/contrib/database/api/views/grid/views.py index c5d8b25404..6e88bf12bb 100644 --- a/backend/src/baserow/contrib/database/api/views/grid/views.py +++ b/backend/src/baserow/contrib/database/api/views/grid/views.py @@ -248,7 +248,7 @@ def get(self, request, view_id, field_options, row_metadata, query_params): ) queryset = get_view_filtered_queryset( - view, adhoc_filters, order_by, query_params + request.user, view, adhoc_filters, order_by, query_params ) model = queryset.model diff --git a/backend/src/baserow/contrib/database/api/views/serializers.py b/backend/src/baserow/contrib/database/api/views/serializers.py index 6d93267972..1bcd997c97 100644 --- a/backend/src/baserow/contrib/database/api/views/serializers.py +++ b/backend/src/baserow/contrib/database/api/views/serializers.py @@ -420,13 +420,32 @@ class Meta: "owned_by_id": {"read_only": True}, } - def __init__(self, *args, **kwargs): + def __init__(self, instance=None, *args, **kwargs): context = kwargs.setdefault("context", {}) context["include_filters"] = kwargs.pop("filters", False) context["include_sortings"] = kwargs.pop("sortings", False) context["include_decorations"] = kwargs.pop("decorations", False) context["include_group_bys"] = kwargs.pop("group_bys", False) - super().__init__(*args, **kwargs) + enhance_objects_by_view_ownership = kwargs.pop( + "enhance_objects_by_view_ownership", True + ) + # If the provided view object(s) must be enhanced by the view type, then + # correctly call those methods. The view ownership type can be responsible for + # adding or omitting data about the view. Making this call in the serializer + # makes sure that the user only receives data about the view that they are + # permitted to see, according to the ownership type. + if enhance_objects_by_view_ownership and "user" in context: + if isinstance(instance, list): + instance = view_ownership_type_registry.prepare_views_of_different_types_for_user( + context["user"], instance + ) + else: + instance = ( + view_ownership_type_registry.prepare_views_of_different_types_for_user( + context["user"], [instance] + ) + )[0] + super().__init__(instance, *args, **kwargs) def to_representation(self, instance): # We remove the fields in to_representation rather than __init__ as otherwise diff --git a/backend/src/baserow/contrib/database/api/views/utils.py b/backend/src/baserow/contrib/database/api/views/utils.py index 9a92b9d864..505296a296 100644 --- a/backend/src/baserow/contrib/database/api/views/utils.py +++ b/backend/src/baserow/contrib/database/api/views/utils.py @@ -49,6 +49,7 @@ def get_public_view_authorization_token(request: Request) -> Optional[str]: def get_view_filtered_queryset( + user: AbstractUser, view: Type[View], filters: Optional[AdHocFilters] = None, order_by: Optional[str] = None, @@ -59,6 +60,8 @@ def get_view_filtered_queryset( Returns a queryset that is filtered based on the provided view, adhoc filters, and query parameters (i.e. search value). + :param user: The user on whose behalf the filtered queryset is requested. This is + needed for permission checks. :param view: The view to filter the queryset by. :param filters: The adhoc filters to apply to the queryset. :param order_by: The order by string to apply to the queryset. @@ -79,6 +82,7 @@ def get_view_filtered_queryset( search_mode = query_params.get("search_mode") queryset = ViewHandler().get_queryset( + user, view, apply_sorts=not has_adhoc_sorts, apply_filters=not has_adhoc_filters, diff --git a/backend/src/baserow/contrib/database/apps.py b/backend/src/baserow/contrib/database/apps.py index de8df567ed..90c20feded 100755 --- a/backend/src/baserow/contrib/database/apps.py +++ b/backend/src/baserow/contrib/database/apps.py @@ -829,12 +829,14 @@ def ready(self): CreateViewFilterOperationType, CreateViewGroupByOperationType, CreateViewOperationType, + CreateViewRowOperationType, CreateViewSortOperationType, DeleteViewDecorationOperationType, DeleteViewFilterGroupOperationType, DeleteViewFilterOperationType, DeleteViewGroupByOperationType, DeleteViewOperationType, + DeleteViewRowOperationType, DeleteViewSortOperationType, DuplicateViewOperationType, ListAggregationsViewOperationType, @@ -851,6 +853,7 @@ def ready(self): ReadViewFilterOperationType, ReadViewGroupByOperationType, ReadViewOperationType, + ReadViewRowOperationType, ReadViewsOrderOperationType, ReadViewSortOperationType, RestoreViewOperationType, @@ -860,6 +863,7 @@ def ready(self): UpdateViewGroupByOperationType, UpdateViewOperationType, UpdateViewPublicOperationType, + UpdateViewRowOperationType, UpdateViewSlugOperationType, UpdateViewSortOperationType, ) @@ -872,6 +876,10 @@ def ready(self): UpdateWebhookOperationType, ) + operation_type_registry.register(ReadViewRowOperationType()) + operation_type_registry.register(CreateViewRowOperationType()) + operation_type_registry.register(UpdateViewRowOperationType()) + operation_type_registry.register(DeleteViewRowOperationType()) operation_type_registry.register(CreateTableDatabaseTableOperationType()) operation_type_registry.register(ListTablesDatabaseTableOperationType()) operation_type_registry.register(OrderTablesDatabaseTableOperationType()) @@ -1088,10 +1096,21 @@ def ready(self): operation_type_registry.register(SetFieldRuleOperationType()) operation_type_registry.register(ReadFieldRuleOperationType()) + action_type_registry.register(CreateFieldRuleActionType()) action_type_registry.register(UpdateFieldRuleActionType()) action_type_registry.register(DeleteFieldRuleActionType()) + from baserow.contrib.database.ws.public.rows.view_realtime_rows import ( + PublicViewRealtimeRowsType, + ) + from baserow.contrib.database.ws.views.rows.registries import ( + view_realtime_rows_registry, + ) + + if not settings.DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS: + view_realtime_rows_registry.register(PublicViewRealtimeRowsType()) + # The signals must always be imported last because they use the registries # which need to be filled first. import baserow.contrib.database.data_sync.signals # noqa: F403, F401 diff --git a/backend/src/baserow/contrib/database/export/file_writer.py b/backend/src/baserow/contrib/database/export/file_writer.py index 42bc9fa31f..760be663e4 100644 --- a/backend/src/baserow/contrib/database/export/file_writer.py +++ b/backend/src/baserow/contrib/database/export/file_writer.py @@ -222,7 +222,7 @@ def for_view(cls, view, visible_field_ids_in_order=None) -> "QuerysetSerializer" for field_id in visible_field_ids_in_order if field_id in field_map ] - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(None, view, model=model) return cls(qs, fields), visible_field_objects_in_view def add_ad_hoc_filters_dict_to_queryset(self, filters_dict, only_by_field_ids=None): diff --git a/backend/src/baserow/contrib/database/fields/field_types.py b/backend/src/baserow/contrib/database/fields/field_types.py index 197b30890e..f14325f2f5 100755 --- a/backend/src/baserow/contrib/database/fields/field_types.py +++ b/backend/src/baserow/contrib/database/fields/field_types.py @@ -7345,7 +7345,7 @@ def update_rows_with_field_sequence( order_bys = (not_trashed_first, "order", "id") if view is not None: - queryset = ViewHandler().get_queryset(view).values("id") + queryset = ViewHandler().get_queryset(None, view).values("id") filters = queryset.query.where filtered_first = Case(When(filters, then=Value(0)), default=1).asc() diff --git a/backend/src/baserow/contrib/database/migrations/0201_increase_pendingsearchvalueupdate_statistics.py b/backend/src/baserow/contrib/database/migrations/0201_increase_pendingsearchvalueupdate_statistics.py new file mode 100644 index 0000000000..ba9c6a7b61 --- /dev/null +++ b/backend/src/baserow/contrib/database/migrations/0201_increase_pendingsearchvalueupdate_statistics.py @@ -0,0 +1,23 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("database", "0200_fix_to_timestamptz_formula"), + ] + + operations = [ + # Increase the statistics value of the field_id so that when `field_id IN (...)` + # is used on a table with many entries in the + # `database_pendingsearchvalueupdate`, it will remain performant. + migrations.RunSQL( + sql=""" + ALTER TABLE database_pendingsearchvalueupdate + ALTER COLUMN field_id SET STATISTICS 500; + """, + reverse_sql=""" + ALTER TABLE database_pendingsearchvalueupdate + ALTER COLUMN field_id SET STATISTICS 100; + """ + ), + ] diff --git a/backend/src/baserow/contrib/database/rows/actions.py b/backend/src/baserow/contrib/database/rows/actions.py index 376ad8e1b7..42f75f910f 100755 --- a/backend/src/baserow/contrib/database/rows/actions.py +++ b/backend/src/baserow/contrib/database/rows/actions.py @@ -28,6 +28,7 @@ GeneratedTableModel, Table, ) +from baserow.contrib.database.views.models import View from baserow.core.action.models import Action from baserow.core.action.registries import ( ActionScopeStr, @@ -96,6 +97,8 @@ class Params: row_id: int fields_metadata: dict[str, Any] row_values: Dict[str, Any] + view_id: Optional[int] = None + view_name: Optional[str] = None @classmethod def do( @@ -105,6 +108,7 @@ def do( values: Optional[Dict[str, Any]] = None, model: Optional[Type[GeneratedTableModel]] = None, before_row: Optional[GeneratedTableModel] = None, + view: Optional[View] = None, user_field_names: bool = False, send_webhook_events: bool = True, ) -> GeneratedTableModel: @@ -123,6 +127,8 @@ def do( having to generate the model again. :param before_row: If provided the new row will be placed right before that row instance. + :param view: Optionally provide view, if the row was created in the view. + This can result in different permissions checks. :param user_field_names: Whether or not the values are keyed by the internal Baserow field name (field_1,field_2 etc) or by the user field names. :param send_webhook_events: If set the false then the webhooks will not be @@ -141,6 +147,7 @@ def do( values=values, model=model, before_row=before_row, + view=view, user_field_names=user_field_names, send_webhook_events=send_webhook_events, ) @@ -165,6 +172,8 @@ def do( row.id, fields_metadata=fields_metadata, row_values=row_values, + view_id=view.id if view else None, + view_name=view.name if view else None, ) cls.register_action( user, params, scope=cls.scope(table.id), workspace=workspace @@ -210,6 +219,8 @@ class Params: fields_metadata: dict[int, dict[str, Any]] rows_values: List[Dict[str, Any]] trashed_rows_entry_id: Optional[int] = None + view_id: Optional[int] = None + view_name: Optional[str] = None @classmethod def do( @@ -218,6 +229,7 @@ def do( table: Table, rows_values: List[Dict[str, Any]], before_row: Optional[GeneratedTableModel] = None, + view: Optional[View] = None, model: Optional[Type[GeneratedTableModel]] = None, send_webhook_events: bool = True, ) -> List[GeneratedTableModel]: @@ -233,6 +245,8 @@ def do( :param rows_values: List of rows values for rows that need to be created. :param before_row: If provided the new rows will be placed right before the row with this id. + :param view: Optionally provide view, if the rows were created in the view. + This can result in different permissions checks. :param model: If the correct model has already been generated it can be provided so that it does not have to be generated for a second time. :param send_webhook_events: If set the false then the webhooks will not be @@ -250,6 +264,7 @@ def do( table, rows_values, before_row=before_row, + view=view, model=model, send_webhook_events=send_webhook_events, ) @@ -277,6 +292,8 @@ def do( row_ids=[row.id for row in rows], fields_metadata=fields_metadata, rows_values=values, + view_id=view.id if view else None, + view_name=view.name if view else None, ) cls.register_action( user, params, scope=cls.scope(table.id), workspace=workspace @@ -417,6 +434,8 @@ class Params: row_id: int values: dict[str, Any] fields_metadata: dict[str, Any] + view_id: Optional[int] = None + view_name: Optional[str] = None @classmethod def do( @@ -425,6 +444,7 @@ def do( table: Table, row_id: int, model: Optional[Type[GeneratedTableModel]] = None, + view: Optional[View] = None, send_webhook_events: bool = True, ): """ @@ -438,6 +458,8 @@ def do( :param row_id: The id of the row that must be deleted. :param model: If the correct model has already been generated, it can be provided so that it does not have to be generated for a second time. + :param view: Optionally provide view, if the row is deleted in the view. + This can result in different permissions checks. :param send_webhook_events: If set the false then the webhooks will not be triggered. Defaults to true. :raises RowDoesNotExist: When the row with the provided id does not exist. @@ -450,7 +472,12 @@ def do( rh = RowHandler() row = rh.delete_row_by_id( - user, table, row_id, model=model, send_webhook_events=send_webhook_events + user, + table, + row_id, + model=model, + view=view, + send_webhook_events=send_webhook_events, ) database = table.database @@ -468,6 +495,8 @@ def do( row_id, values=get_row_values(row, fields), fields_metadata=fields_metadata, + view_id=view.id if view else None, + view_name=view.name if view else None, ) cls.register_action( user, params, scope=cls.scope(table.id), workspace=database.workspace @@ -512,6 +541,8 @@ class Params: trashed_rows_entry_id: int rows_values: list[dict[str, Any]] fields_metadata: dict[str, [dict[str, Any]]] + view_id: Optional[int] = None + view_name: Optional[str] = None @classmethod def do( @@ -520,6 +551,7 @@ def do( table: Table, row_ids: List[int], model: Optional[Type[GeneratedTableModel]] = None, + view: Optional[View] = None, send_webhook_events: bool = True, ): """ @@ -533,6 +565,8 @@ def do( :param row_ids: The id of the row that must be deleted. :param model: If the correct model has already been generated, it can be provided so that it does not have to be generated for a second time. + :param view: Optionally provide view, if the row are deleted in the view. + This can result in different permissions checks. :param send_webhook_events: If set the false then the webhooks will not be triggered. Defaults to true. :raises RowDoesNotExist: When the row with the provided id does not exist. @@ -545,7 +579,12 @@ def do( rh = RowHandler() trashed_rows_entry = rh.delete_rows( - user, table, row_ids, model=model, send_webhook_events=send_webhook_events + user, + table, + row_ids, + model=model, + view=view, + send_webhook_events=send_webhook_events, ) workspace = table.database.workspace @@ -565,6 +604,8 @@ def do( trashed_rows_entry_id=trashed_rows_entry.id, fields_metadata=fields_metadata, rows_values=rows_values, + view_id=view.id if view else None, + view_name=view.name if view else None, ) cls.register_action( user, params, scope=cls.scope(table.id), workspace=workspace @@ -795,6 +836,8 @@ class Params: row_id: int row_values: Dict[str, Any] original_row_values: Dict[str, Any] + view_id: Optional[int] = None + view_name: Optional[str] = None @classmethod def do( @@ -804,6 +847,7 @@ def do( row_id: int, values: Dict[str, Any], model: Optional[Type[GeneratedTableModel]] = None, + view: Optional["View"] = None, user_field_names: bool = False, ) -> GeneratedTableModelForUpdate: """ @@ -819,6 +863,8 @@ def do( :param values: The values that must be updated. The keys must be the field ids. :param model: If the correct model has already been generated it can be provided so that it does not have to be generated for a second time. + :param view: Optionally provide view, if the row is updated in the view. + This can result in different permissions checks. :param user_field_names: Whether or not the values are keyed by the internal Baserow field names (field_1,field_2 etc) or by the user field names. :raises RowDoesNotExist: When the row with the provided id does not exist. @@ -841,7 +887,14 @@ def do( field_ids = set(row_handler.extract_field_ids_from_keys(values.keys())) original_row_values = row_handler.get_internal_values_for_fields(row, field_ids) - updated_row = row_handler.update_row(user, table, row, values, model=model) + updated_row = row_handler.update_row( + user, + table, + row, + values, + model=model, + view=view, + ) row_values = row_handler.get_internal_values_for_fields(row, field_ids) workspace = table.database.workspace @@ -853,6 +906,8 @@ def do( row.id, row_values, original_row_values, + view_id=view.id if view else None, + view_name=view.name if view else None, ) cls.register_action( user, params, scope=cls.scope(table.id), workspace=workspace @@ -902,6 +957,8 @@ class Params: row_values: List[Dict[str, Any]] original_rows_values_by_id: Dict[int, Dict[str, Any]] updated_fields_metadata_by_row_id: Dict[int, Dict[str, Any]] + view_id: Optional[int] = None + view_name: Optional[str] = None @classmethod def do( @@ -910,6 +967,7 @@ def do( table: Table, rows_values: List[Dict[str, Any]], model: Optional[Type[GeneratedTableModel]] = None, + view: Optional[View] = None, send_webhook_events: bool = True, ) -> UpdatedRowsData: """ @@ -925,6 +983,8 @@ def do( field ids plus the id of the row. :param model: If the correct model has already been generated it can be provided so that it does not have to be generated for a second time. + :param view: Optionally provide view, if the rows are updated in the view. + This can result in different permissions checks. :param send_webhook_events: If set the false then the webhooks will not be triggered. Defaults to true. :return: The updated rows. @@ -937,6 +997,7 @@ def do( table, rows_values, model=model, + view=view, send_webhook_events=send_webhook_events, ) updated_rows = result.updated_rows @@ -951,6 +1012,8 @@ def do( result.updated_rows_values, result.original_rows_values_by_id, result.updated_fields_metadata_by_row_id, + view_id=view.id if view else None, + view_name=view.name if view else None, ) cls.register_action(user, params, cls.scope(table.id), workspace=workspace) diff --git a/backend/src/baserow/contrib/database/rows/handler.py b/backend/src/baserow/contrib/database/rows/handler.py index dd71d6d6e8..cb318283ae 100644 --- a/backend/src/baserow/contrib/database/rows/handler.py +++ b/backend/src/baserow/contrib/database/rows/handler.py @@ -62,14 +62,26 @@ ) from baserow.contrib.database.table.signals import table_updated from baserow.contrib.database.trash.models import TrashedRows +from baserow.contrib.database.views.operations import ( + CreateViewRowOperationType, + DeleteViewRowOperationType, + ReadViewRowOperationType, + UpdateViewRowOperationType, +) +from baserow.contrib.database.views.registries import view_ownership_type_registry from baserow.core.db import ( get_highest_order_of_queryset, get_unique_orders_before_item, recalculate_full_orders, ) -from baserow.core.exceptions import CannotCalculateIntermediateOrder, PermissionDenied +from baserow.core.exceptions import ( + CannotCalculateIntermediateOrder, + PermissionDenied, + PermissionException, +) from baserow.core.handler import CoreHandler from baserow.core.psycopg import is_unique_violation_error, sql +from baserow.core.registries import OperationType from baserow.core.telemetry.utils import baserow_trace_methods from baserow.core.trash.handler import TrashHandler from baserow.core.trash.registries import trash_item_type_registry @@ -108,6 +120,7 @@ from django.db.backends.utils import CursorWrapper from baserow.contrib.database.fields.models import Field + from baserow.contrib.database.views.models import View tracer = trace.get_tracer(__name__) @@ -468,6 +481,7 @@ def get_row( row_id: int, model: Optional[Type[GeneratedTableModel]] = None, base_queryset: Optional[QuerySet] = None, + view: Optional["View"] = None, ) -> GeneratedTableModel: """ Fetches a single row from the provided table. @@ -479,24 +493,27 @@ def get_row( provided so that it does not have to be generated for a second time. :param base_queryset: A queryset that can be used to already pre-filter the results. + :param view: Optionally provide view, if the row is fetched in the view. + This can result in different permissions checks. :raises RowDoesNotExist: When the row with the provided id does not exist. :return: The requested row instance. """ + self._check_permissions_with_view_fallback( + ReadDatabaseRowOperationType.type, + ReadViewRowOperationType.type, + user, + table, + view, + [row_id], + ) + if model is None: model = table.get_model() if base_queryset is None: base_queryset = model.objects - workspace = table.database.workspace - CoreHandler().check_permissions( - user, - ReadDatabaseRowOperationType.type, - workspace=workspace, - context=table, - ) - try: row = base_queryset.get(id=row_id) except model.DoesNotExist: @@ -536,7 +553,7 @@ def get_adjacent_row( from baserow.contrib.database.views.handler import ViewHandler if view is not None: - queryset = ViewHandler().get_queryset(view, model=table_model) + queryset = ViewHandler().get_queryset(None, view, model=table_model) else: queryset = table_model.objects.all().enhance_by_fields() @@ -695,6 +712,88 @@ def has_row(self, user, table, row_id, raise_error=False, model=None): else: return row_exists + def _check_permissions_with_view_fallback( + self, + table_operation: OperationType, + view_operation: OperationType, + user: AbstractUser, + table: Table, + view: Optional["View"], + row_ids: Optional[List[int]] = None, + ): + """ + Checks if the user has permission to the provided table object. If not, it will + fall back to the view permissions, if the view ownership type allows modifying + rows, the check if it has permissions to the view. + + :param table_operation: The permission on table level to check. If this check + passes, then no exception will be raised. + :param view_operation: The permission on view level to check. If this both + this check succeed and the view ownership type allows modifying rows, then + no exception will be raised. + :param user: The user on whose behalf the permissions are checked. + :param table: The table where to check the permissions for. + :param view: Optionally provide the view where to check permissions for as + fallback. + :param row_ids: Optionally the row ids that are modified. + :raises PermissionDenied: If the user does not have access to both the table + and view. + :return: + """ + + table_check = PermissionCheck( + user, + table_operation, + context=table, + ) + view_check = PermissionCheck( + user, + view_operation, + context=view, + ) + + checks = [table_check] + if view is not None: + checks.append(view_check) + + # Check multiple permissions regardless because if a view is provided, we don't + # want to execute multiple queries in order to check if the permission check + # should fall back on the view. + check_results = CoreHandler().check_multiple_permissions( + checks, + workspace=table.database.workspace, + return_permissions_exceptions=True, + ) + + if check_results[table_check] is True: + return + + if ( + view is not None + # Because the user wants to change rows in a specific table, we must make + # sure that the provided view belongs to that table. Otherwise, it would + # result in a security bug. + and view.table_id == table.id + # The view ownership type should also allow modifying rows directly in + # the view. The rows are provided because some additional permission + # checks might need to be done in order to make sure that the user is + # allowed to modify the provided rows. + and view_ownership_type_registry.get(view.ownership_type).can_modify_rows( + view, + row_ids, + ) + and check_results[view_check] is True + ): + return + + if isinstance(check_results[table_check], PermissionException): + raise check_results[table_check] + + if isinstance(check_results[view_check], PermissionException): + raise check_results[view_check] + + raise PermissionDenied(actor=user) + def create_row( self, user: AbstractUser, @@ -702,6 +801,7 @@ def create_row( values: Optional[Dict[str, Any]] = None, model: Optional[Type[GeneratedTableModel]] = None, before_row: Optional[GeneratedTableModel] = None, + view: Optional["View"] = None, user_field_names: bool = False, values_already_prepared: bool = False, send_webhook_events: bool = True, @@ -718,6 +818,8 @@ def create_row( having to generate the model again. :param before_row: If provided the new row will be placed right before that row instance. + :param view: Optionally provide view, if the row is created in the view. + This can result in different permissions checks. :param user_field_names: Whether or not the values are keyed by the internal Baserow field name (field_1,field_2 etc) or by the user field names. :param values_already_prepared: Whether or not the values are already sanitized @@ -731,11 +833,12 @@ def create_row( if model is None: model = table.get_model() - CoreHandler().check_permissions( - user, + self._check_permissions_with_view_fallback( CreateRowDatabaseTableOperationType.type, - workspace=table.database.workspace, - context=table, + CreateViewRowOperationType.type, + user, + table, + view, ) if not values: @@ -942,6 +1045,7 @@ def update_row_by_id( row_id: int, values: Dict[str, Any], model: Optional[Type[GeneratedTableModel]] = None, + view: Optional["View"] = None, values_already_prepared: bool = False, ) -> GeneratedTableModelForUpdate: """ @@ -953,6 +1057,8 @@ def update_row_by_id( :param values: The values that must be updated. The keys must be the field ids. :param model: If the correct model has already been generated it can be provided so that it does not have to be generated for a second time. + :param view: Optionally provide view, if the row is updated in the view. + This can result in different permissions checks. :param values_already_prepared: Whether or not the values are already sanitized and validated for every field and can be used directly by the handler without any further check. @@ -973,6 +1079,7 @@ def update_row_by_id( row, values, model=model, + view=view, values_already_prepared=values_already_prepared, ) @@ -983,6 +1090,7 @@ def update_row( row: GeneratedTableModelForUpdate, values: Dict[str, Any], model: Optional[Type[GeneratedTableModel]] = None, + view: Optional["View"] = None, values_already_prepared: bool = False, ) -> GeneratedTableModelForUpdate: """ @@ -994,6 +1102,8 @@ def update_row( :param values: The values that must be updated. The keys must be the field ids. :param model: If the correct model has already been generated it can be provided so that it does not have to be generated for a second time. + :param view: Optionally provide view, if the row is updated in the view. + This can result in different permissions checks. :param values_already_prepared: Whether or not the values are already sanitized and validated for every field and can be used directly by the handler without any further check. @@ -1002,12 +1112,13 @@ def update_row( :return: The updated row instance. """ - workspace = table.database.workspace - CoreHandler().check_permissions( - user, + self._check_permissions_with_view_fallback( UpdateDatabaseRowOperationType.type, - workspace=workspace, - context=table, + UpdateViewRowOperationType.type, + user, + table, + view, + [row.id], ) if model is None: @@ -1390,6 +1501,7 @@ def create_rows( table: Table, rows_values: List[Dict[str, Any]], before_row: Optional[GeneratedTableModel] = None, + view: Optional["View"] = None, model: Optional[Type[GeneratedTableModel]] = None, send_realtime_update: bool = True, send_webhook_events: bool = True, @@ -1406,6 +1518,8 @@ def create_rows( :param rows_values: List of rows values for rows that need to be created. :param before_row: If provided the new rows will be placed right before the before_row. + :param view: Optionally provide view, if the rows were created in the view. + This can result in different permissions checks. :param model: If the correct model has already been generated it can be provided so that it does not have to be generated for a second time. :param send_realtime_update: If set to false then it is up to the caller to @@ -1421,12 +1535,12 @@ def create_rows( """ - workspace = table.database.workspace - CoreHandler().check_permissions( - user, + self._check_permissions_with_view_fallback( CreateRowDatabaseTableOperationType.type, - workspace=workspace, - context=table, + CreateViewRowOperationType.type, + user, + table, + view, ) if model is None: @@ -2418,6 +2532,7 @@ def update_rows( table: Table, rows_values: List[Dict[str, Any]], model: Optional[Type[GeneratedTableModel]] = None, + view: Optional["View"] = None, rows_to_update: Optional[RowsForUpdate] = None, send_realtime_update: bool = True, send_webhook_events: bool = True, @@ -2434,6 +2549,8 @@ def update_rows( :param rows_values: The list of rows with new values that should be set. :param model: If the correct model has already been generated it can be provided so that it does not have to be generated for a second time. + :param view: Optionally provide view, if the rows were updated in the view. + This can result in different permissions checks. :param rows_to_update: If the rows to update have already been generated it can be provided so that it does not have to be generated for a second time. @@ -2453,11 +2570,13 @@ def update_rows( instances, the original row values and the updated fields metadata. """ - CoreHandler().check_permissions( - user, + self._check_permissions_with_view_fallback( UpdateDatabaseRowOperationType.type, - workspace=table.database.workspace, - context=table, + UpdateViewRowOperationType.type, + user, + table, + view, + [row["id"] for row in rows_values], ) if model is None: @@ -2639,6 +2758,7 @@ def delete_row_by_id( table: Table, row_id: int, model: Optional[Type[GeneratedTableModel]] = None, + view: Optional["View"] = None, send_realtime_update: bool = True, send_webhook_events: bool = True, ) -> GeneratedTableModel: @@ -2662,12 +2782,13 @@ def delete_row_by_id( model = table.get_model() with transaction.atomic(): - row = self.get_row(user, table, row_id, model=model) + row = self.get_row(user, table, row_id, model=model, view=view) self.delete_row( user, table, row, model=model, + view=view, send_realtime_update=send_realtime_update, send_webhook_events=send_webhook_events, ) @@ -2679,6 +2800,7 @@ def delete_row( table: Table, row: GeneratedTableModelForUpdate, model: Optional[Type[GeneratedTableModel]] = None, + view: Optional["View"] = None, send_realtime_update: bool = True, send_webhook_events: bool = True, ) -> GeneratedTableModelForUpdate: @@ -2690,6 +2812,8 @@ def delete_row( :param row: The row that must be deleted. :param model: If the correct model has already been generated, it can be provided so that it does not have to be generated for a second time. + :param view: Optionally provide view, if the rows is deleted in the view. + This can result in different permissions checks. :param send_realtime_update: If set to false then it is up to the caller to send the rows_deleted or similar signal. Defaults to True. :param send_webhook_events: If set the false then the webhooks will not be @@ -2697,12 +2821,13 @@ def delete_row( :returns GeneratedTableModelForUpdate: removed row """ - workspace = table.database.workspace - CoreHandler().check_permissions( - user, + self._check_permissions_with_view_fallback( DeleteDatabaseRowOperationType.type, - workspace=workspace, - context=table, + DeleteViewRowOperationType.type, + user, + table, + view, + [row.id], ) if model is None: @@ -2712,6 +2837,7 @@ def delete_row( self, rows=[row], user=user, table=table, model=model ) + workspace = table.database.workspace TrashHandler.trash(user, workspace, table.database, row) rows_deleted_counter.add(1) @@ -2793,6 +2919,7 @@ def delete_rows( table: Table, row_ids: List[int], model: Optional[Type[GeneratedTableModel]] = None, + view: Optional["View"] = None, send_realtime_update: bool = True, send_webhook_events: bool = True, permanently_delete: bool = False, @@ -2806,7 +2933,9 @@ def delete_rows( :param row_ids: The ids of the rows that must be deleted. :param model: If the correct model has already been generated, it can be provided so that it does not have to be generated for a second time. - :param send_realtime_update: If set to false then it is up to the caller to + :param view: Optionally provide view, if the rows are deleted in the view. + This can result in different permissions checks. + :param send_realtime_update: If set to false then it is up to the caller to send the rows_created or similar signal. Defaults to True. :param send_webhook_events: If set the false then the webhooks will not be triggered. Defaults to true. @@ -2815,12 +2944,13 @@ def delete_rows( :param signal_params: Additional parameters that are added to the signal. """ - workspace = table.database.workspace - CoreHandler().check_permissions( - user, + self._check_permissions_with_view_fallback( DeleteDatabaseRowOperationType.type, - workspace=workspace, - context=table, + DeleteViewRowOperationType.type, + user, + table, + view, + row_ids, ) return self.force_delete_rows( user, diff --git a/backend/src/baserow/contrib/database/views/handler.py b/backend/src/baserow/contrib/database/views/handler.py index 115bc16131..ef3e2fc761 100644 --- a/backend/src/baserow/contrib/database/views/handler.py +++ b/backend/src/baserow/contrib/database/views/handler.py @@ -4,7 +4,6 @@ import traceback from collections import defaultdict, namedtuple from copy import deepcopy -from dataclasses import dataclass from hashlib import shake_128 from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Type, Union @@ -607,7 +606,9 @@ def list_views( views, table.database.workspace, ) - views = views.select_related("content_type", "table") + views = views.select_related( + "content_type", "table", "table__database__workspace" + ) if _type: view_type = view_type_registry.get(_type) @@ -637,6 +638,7 @@ def list_views( ).enhance_queryset(queryset) ), ) + return views def before_field_type_change(self, field: Field): @@ -2776,6 +2778,7 @@ def delete_decoration( def get_queryset( self, + user: Optional[AbstractUser], view: View, search: Optional[str] = None, model: Optional[GeneratedTableModel] = None, @@ -2789,6 +2792,8 @@ def get_queryset( Returns a queryset for the provided view which is appropriately sorted, filtered and searched according to the view type and its settings. + :param user: The user on whose behalf the queryset is requested. This is needed + for permission checks. :param search: A search term to apply to the resulting queryset. :param model: The model for this views table to generate the queryset from, if not specified then the model will be generated automatically. @@ -2820,6 +2825,14 @@ def get_queryset( f"The view type {view_type.type} does not support listing rows." ) + # Check if the view ownership type is enforcing the filters to be applied. If + # so, then regardless of what argument is provided, the filters are applied to + # the queryset. This can be useful if the view restricts access to rows that + # don't match the filters. + view_ownership_type = view_ownership_type_registry.get(view.ownership_type) + if view_ownership_type.enforce_apply_filters(user, view): + apply_filters = True + if view_type.can_filter and apply_filters: queryset = self.apply_filters(view, queryset) if view_type.can_sort and apply_sorts: @@ -3396,40 +3409,6 @@ def submit_form_view( ) return created_row - def get_public_views_row_checker( - self, - table, - model, - only_include_views_which_want_realtime_events, - updated_field_ids=None, - ): - """ - Returns a CachingPublicViewRowChecker object which will have precalculated - information about the public views in the provided table to aid with quickly - checking which views a row in that table is visible in. If you will be updating - the row and reusing the checker you must provide an iterable of the field ids - that you will be updating in the row, otherwise the checker will cache the - first check per view/row. - - :param table: The table the row is in. - :param model: The model of the table including all fields. - :param only_include_views_which_want_realtime_events: If True will only look - for public views where - ViewType.when_shared_publicly_requires_realtime_events is True. - :param updated_field_ids: An optional iterable of field ids which will be - updated on rows passed to the checker. If the checker is used on the same - row multiple times and that row has been updated it will return invalid - results unless you have correctly populated this argument. - :return: A list of non-specific public view instances. - """ - - return CachingPublicViewRowChecker( - table, - model, - only_include_views_which_want_realtime_events, - updated_field_ids, - ) - def restrict_row_for_view( self, view: View, serialized_row: Dict[str, Any] ) -> Dict[Any, Any]: @@ -3731,178 +3710,6 @@ def _get_prepared_values_for_data( } -@dataclass -class PublicViewRows: - """ - Keeps track of which rows are allowed to be sent as a public signal - for a particular view. - - When no row ids are set it is assumed that any row id is allowed. - """ - - ALL_ROWS_ALLOWED = None - - view: View - allowed_row_ids: Optional[Set[int]] - - def all_allowed(self): - return self.allowed_row_ids is PublicViewRows.ALL_ROWS_ALLOWED - - def __iter__(self): - return iter((self.view, self.allowed_row_ids)) - - -class CachingPublicViewRowChecker: - """ - A helper class to check which public views a row is visible in. Will pre-calculate - upfront for a specific table which public views are always visible, which public - views can have row check results cached for and finally will pre-construct and - reuse querysets for performance reasons. - """ - - def __init__( - self, - table: Table, - model: GeneratedTableModel, - only_include_views_which_want_realtime_events: bool, - updated_field_ids: Optional[Iterable[int]] = None, - ): - self._public_views = ( - table.view_set.filter(public=True) - .prefetch_related("viewfilter_set", "filter_groups") - .all() - ) - self._updated_field_ids = updated_field_ids - self._views_with_filters = [] - self._always_visible_views = [] - self._view_row_check_cache = defaultdict(dict) - handler = ViewHandler() - for view in specific_iterator( - self._public_views, - per_content_type_queryset_hook=( - lambda model, queryset: view_type_registry.get_by_model( - model - ).enhance_queryset(queryset) - ), - ): - if only_include_views_which_want_realtime_events: - view_type = view_type_registry.get_by_model(view.specific_class) - if not view_type.when_shared_publicly_requires_realtime_events: - continue - - if len(view.viewfilter_set.all()) == 0: - # If there are no view filters for this view then any row must always - # be visible in this view - self._always_visible_views.append(view) - else: - filter_qs = handler.apply_filters(view, model.objects) - self._views_with_filters.append( - ( - view, - filter_qs, - self._view_row_checks_can_be_cached(view), - ) - ) - - def get_public_views_where_row_is_visible(self, row): - """ - WARNING: If you are reusing the same checker and calling this method with the - same row multiple times you must have correctly set which fields in the row - might be updated in the checkers initials `updated_field_ids` attribute. This - is because for a given view, if we know none of the fields it filters on - will be updated we can cache the first check of if that row exists as any - further changes to the row wont be affecting filtered fields. Hence - `updated_field_ids` needs to be set if you are ever changing the row and - reusing the same CachingPublicViewRowChecker instance. - - :param row: A row in the checkers table. - :return: A list of views where the row is visible for this checkers table. - """ - - views = [] - for view, filter_qs, can_use_cache in self._views_with_filters: - if can_use_cache: - if row.id not in self._view_row_check_cache[view.id]: - self._view_row_check_cache[view.id][ - row.id - ] = self._check_row_visible(filter_qs, row) - if self._view_row_check_cache[view.id][row.id]: - views.append(view) - elif self._check_row_visible(filter_qs, row): - views.append(view) - - return views + self._always_visible_views - - def get_public_views_where_rows_are_visible(self, rows) -> List[PublicViewRows]: - """ - WARNING: If you are reusing the same checker and calling this method with the - same rows multiple times you must have correctly set which fields in the rows - might be updated in the checkers initials `updated_field_ids` attribute. This - is because for a given view, if we know none of the fields it filters on - will be updated we can cache the first check of if that rows exist as any - further changes to the rows wont be affecting filtered fields. Hence - `updated_field_ids` needs to be set if you are ever changing the rows and - reusing the same CachingPublicViewRowChecker instance. - - :param rows: Rows in the checkers table. - :return: A list of PublicViewRows with view and a list of row ids where the rows - are visible for this checkers table. - """ - - visible_views_rows = [] - row_ids = {row.id for row in rows} - for view, filter_qs, can_use_cache in self._views_with_filters: - if can_use_cache: - for id in row_ids: - if id not in self._view_row_check_cache[view.id]: - visible_ids = set(self._check_rows_visible(filter_qs, rows)) - for visible_id in visible_ids: - self._view_row_check_cache[view.id][visible_id] = True - break - else: - visible_ids = row_ids - - if len(visible_ids) > 0: - visible_views_rows.append(PublicViewRows(view, visible_ids)) - - else: - visible_ids = set(self._check_rows_visible(filter_qs, rows)) - if len(visible_ids) > 0: - visible_views_rows.append(PublicViewRows(view, visible_ids)) - - for visible_view in self._always_visible_views: - visible_views_rows.append( - PublicViewRows(visible_view, PublicViewRows.ALL_ROWS_ALLOWED) - ) - - return visible_views_rows - - # noinspection PyMethodMayBeStatic - def _check_row_visible(self, filter_qs, row): - return filter_qs.filter(id=row.id).exists() - - # noinspection PyMethodMayBeStatic - def _check_rows_visible(self, filter_qs, rows): - return filter_qs.filter(id__in=[row.id for row in rows]).values_list( - "id", flat=True - ) - - def _view_row_checks_can_be_cached(self, view): - if self._updated_field_ids is None: - return True - for view_filter in view.viewfilter_set.all(): - if view_filter.field_id in self._updated_field_ids: - # We found a view filter for a field which will be updated hence we - # need to check both before and after a row update occurs - return False - # Every single updated field does not have a filter on it, hence - # we only need to check if a given row is visible in the view once - # as any changes to the fields in said row wont be for fields with - # filters and so the result of the first check will be still - # valid for any subsequent checks. - return True - - class ViewSubscriptionHandler: @classmethod def subscribe_to_views(cls, subscriber: django_models.Model, views: list[View]): diff --git a/backend/src/baserow/contrib/database/views/models.py b/backend/src/baserow/contrib/database/views/models.py index 5a1d8ad3d5..5d920b9676 100644 --- a/backend/src/baserow/contrib/database/views/models.py +++ b/backend/src/baserow/contrib/database/views/models.py @@ -998,7 +998,7 @@ def create_missing_for_views(cls, views: list[View], model=None): view = view_map[view_id] row_ids = ( ViewHandler() - .get_queryset(view, model=model, apply_sorts=False) + .get_queryset(None, view, model=model, apply_sorts=False) .values_list("id", flat=True) ) view_rows.append(ViewRows(view=view, row_ids=list(row_ids))) @@ -1013,7 +1013,9 @@ def get_diff(self, model=None): from baserow.contrib.database.views.handler import ViewHandler - rows = ViewHandler().get_queryset(self.view, model=model, apply_sorts=False) + rows = ViewHandler().get_queryset( + None, self.view, model=model, apply_sorts=False + ) previous_row_ids = set(self.row_ids) new_row_ids = set(rows.order_by().values_list("id", flat=True)) diff --git a/backend/src/baserow/contrib/database/views/operations.py b/backend/src/baserow/contrib/database/views/operations.py index 65e371ad53..7c1da3d24f 100644 --- a/backend/src/baserow/contrib/database/views/operations.py +++ b/backend/src/baserow/contrib/database/views/operations.py @@ -28,6 +28,26 @@ class ViewSortOperationType(OperationType, abc.ABC): context_scope_name = DatabaseViewSortObjectScopeType.type +class ViewRowOperationType(OperationType, abc.ABC): + context_scope_name = DatabaseViewObjectScopeType.type + + +class ReadViewRowOperationType(ViewRowOperationType): + type = "database.table.view.read_row" + + +class CreateViewRowOperationType(ViewRowOperationType): + type = "database.table.view.create_row" + + +class UpdateViewRowOperationType(ViewRowOperationType): + type = "database.table.view.update_row" + + +class DeleteViewRowOperationType(ViewRowOperationType): + type = "database.table.view.delete_row" + + class CreateViewSortOperationType(ViewOperationType): type = "database.table.view.create_sort" diff --git a/backend/src/baserow/contrib/database/views/registries.py b/backend/src/baserow/contrib/database/views/registries.py index 780ae035e4..f179e62c71 100644 --- a/backend/src/baserow/contrib/database/views/registries.py +++ b/backend/src/baserow/contrib/database/views/registries.py @@ -1370,7 +1370,7 @@ def get_trashed_item_owner(self, view): def should_broadcast_signal_to( self, view: "View" - ) -> Tuple[Literal["table", "users", ""], Optional[List[int]]]: + ) -> Tuple[Literal["table", "users", "refresh", ""], Optional[List[int]]]: """ Returns a tuple that represent the kind of signaling that must be done for the given view. @@ -1378,7 +1378,8 @@ def should_broadcast_signal_to( :param view: the view we want to send the signal for. :return: The first element of the tuple must be "" if no signaling is needed, "users" if signal has to be send to a list of users and "table" if the - signal can be send to all the users of the view table. + signal can be send to all the users of the view table. "refresh" if the + view and table data must be refreshed. The second member of the tuple can be any object necessary for the signal depending of the type. If the signal type is "users", it must be a list of user ids. @@ -1453,6 +1454,52 @@ def view_created(self, user: AbstractUser, view: "View", workspace: Workspace): :param workspace: The workspace where the view was created in. """ + def enforce_apply_filters(self, user: Optional[AbstractUser], view: "View") -> bool: + """ + A hook that if it returns `True`, enforces the filters of the view to be + applied, regardless any other settings like adhoc filters. This can be used to + ensure that unfiltered rows are never exposed. + + :param user: The user on whose behalf the rows are requested. + :param view: The related view. + :return: Boolean indicating whether the apply filters should be enforced. + """ + + return False + + def prepare_views_for_user( + self, user: Optional[AbstractUser], views: List["View"] + ) -> List["View"]: + """ + A hook that can be used to make changes to the provided view objects `views` if + needed for the view ownership type. Only views of the related type are provided + here. This can be used to add or remove properties from the object if needed. + + :param user: The user on whose behalf the view objects are enhanced. Can be + used for permission checking. Note that it's not always provided. + :param views: The views to enhance. + :return: The enhanced views. + """ + + return views + + def can_modify_rows( + self, view: "View", row_ids: Optional[List[int]] = None + ) -> bool: + """ + Indicates whether it's possible to modify rows in the view, even if the user + does not have permissions to the table. The role that the user has on view + level must of course include the `CreateViewRowOperationType`, + `UpdateViewRowOperationType`, and `DeleteViewRowOperationType` operations. + + :param view: The view where to check the permissions for. + :param row_ids: Optionally a list of row ids that are modified. This way, an + extra check can be performed. + :return: Returns true if it's possible to modify rows in the view. + """ + + return False + class ViewOwnershipTypeRegistry(Registry): """ @@ -1462,6 +1509,38 @@ class ViewOwnershipTypeRegistry(Registry): name = "view_ownership_type" does_not_exist_exception_class = ViewOwnershipTypeDoesNotExist + def prepare_views_of_different_types_for_user( + self, user: AbstractUser, views: List["View"] + ) -> List["View"]: + """ + Loops over the provided views and per ownership type, calls the + `enhance_view_objects` method. + + :param user: The user on whose behalf the views are requested. Can be used for + permission checks. + :param views: The views that must be enhanced. + :return: The enhanced views. + """ + + for view_ownership_type in self.get_all(): + views_of_type = [ + view + for view in views + if view.ownership_type == view_ownership_type.type + ] + views_of_type = view_ownership_type.prepare_views_for_user( + user, views_of_type + ) + # Put the enhanced view back into the original list at the right index so + # that the order is not changed. + for view_of_type in views_of_type: + for index, view in enumerate(views): + if view.id == view_of_type.id: + views[index] = view_of_type + break + + return views + # A default view type registry is created here, this is the one that is used # throughout the whole Baserow application to add a new view type. diff --git a/backend/src/baserow/contrib/database/views/row_checker.py b/backend/src/baserow/contrib/database/views/row_checker.py new file mode 100644 index 0000000000..1f8ca28934 --- /dev/null +++ b/backend/src/baserow/contrib/database/views/row_checker.py @@ -0,0 +1,257 @@ +from collections import defaultdict, namedtuple +from dataclasses import dataclass +from typing import Dict, Iterable, List, Optional, Set + +from django.db.models.expressions import Exists, OuterRef +from django.db.models.query import QuerySet + +from baserow.contrib.database.table.models import GeneratedTableModel +from baserow.core.db import specific_iterator + +from .handler import ViewHandler +from .models import View +from .registries import view_type_registry + +FilterCheck = namedtuple( + "FilterCheck", + ["view", "filter_qs", "can_cache", "is_fully_cached"], +) + + +@dataclass +class FilteredViewRows: + """ + Keeps track of which rows are allowed to be sent as a public signal + for a particular view. + + When no row ids are set it is assumed that any row id is allowed. + """ + + ALL_ROWS_ALLOWED = None + + view: View + allowed_row_ids: Optional[Set[int]] + + def all_allowed(self): + return self.allowed_row_ids is FilteredViewRows.ALL_ROWS_ALLOWED + + def __iter__(self): + return iter((self.view, self.allowed_row_ids)) + + +class FilteredViewRowChecker: + """ + A helper class to check in which views a row is visible. It will pre-calculate + upfront for a specific table which views are always visible, which public + views can have row check results cached for and finally will pre-construct and + reuse querysets for performance reasons. + """ + + def __init__( + self, + model: GeneratedTableModel, + views_queryset: QuerySet, + only_include_views_which_want_realtime_events: bool, + updated_field_ids: Optional[Iterable[int]] = None, + ): + """ + :param model: The model of the table including all fields. + :param views_queryset: The queryset to fetch the views where to check the row + in. + :param only_include_views_which_want_realtime_events: If True will only look + for public views where + ViewType.when_shared_publicly_requires_realtime_events is True. + :param updated_field_ids: An optional iterable of field ids which will be + updated on rows passed to the checker. If the checker is used on the same + row multiple times and that row has been updated it will return invalid + results unless you have correctly populated this argument. + """ + + self._model = model + self._views_queryset = views_queryset + self._updated_field_ids = updated_field_ids + self._views_with_filters = [] + self._views_without_filters = [] + self._view_row_check_cache = defaultdict(dict) + handler = ViewHandler() + for view in specific_iterator( + self._views_queryset, + per_content_type_queryset_hook=( + lambda model, queryset: view_type_registry.get_by_model( + model + ).enhance_queryset(queryset) + ), + ): + if only_include_views_which_want_realtime_events: + view_type = view_type_registry.get_by_model(view.specific_class) + if not view_type.when_shared_publicly_requires_realtime_events: + continue + + if len(view.viewfilter_set.all()) == 0: + # If there are no view filters for this view then any row must always + # be visible in this view. + self._views_without_filters.append(view) + else: + filter_qs = handler.apply_filters(view, model.objects) + self._views_with_filters.append( + ( + view, + filter_qs, + self._view_row_checks_can_be_cached(view), + ) + ) + + def _view_row_checks_can_be_cached(self, view): + if self._updated_field_ids is None: + # If the updated field_ids are `None`, then we assume that all the cell + # values of all fields have been updated. + return False + for view_filter in view.viewfilter_set.all(): + if view_filter.field_id in self._updated_field_ids: + # We found a view filter for a field which will be updated hence we + # need to check both before and after a row update occurs + return False + # There is no filter on any of the updated fields, hence we only need to + # check if a given row is visible in the view once, because any changes to the + # fields in said row won't be for fields with filters and so the result of + # the first check will be still valid for any subsequent checks. + return True + + def _rows_with_visibility_flags(self, row_ids, views_with_filters): + """ + Single query over the row model for the given ids, annotated with a boolean + per view indicating if that row is visible in that view. + + :param row_ids: + :param views_with_filters: + """ + + base = self._model.objects.filter(id__in=row_ids) + + annotations = {} + for view, filter_qs, _ in views_with_filters: + annotations[f"visible_v{view.id}"] = Exists( + filter_qs.filter(pk=OuterRef("pk")) + ) + + return base.annotate(**annotations).values("id", *annotations.keys()) + + def get_filtered_views_where_row_is_visible(self, row): + return [ + filtered_view_rows.view + for filtered_view_rows in self.get_filtered_views_where_rows_are_visible( + [row] + ) + ] + + def get_filtered_views_where_rows_are_visible( + self, rows: List[GeneratedTableModel] + ) -> List[FilteredViewRows]: + """ + Checks if the provided rows match the filters of all the views provided in the + `views_queryset` argument as constructor, using one single query when needed. + + We cache per (view_id, row_id) when filters do not involve updated fields. + Both positive and negative results are cached in that case to avoid future + reads. + + :param rows: List of rows that must be checked in all the views of the provided + `_views_queryset` views. + """ + + result_for_views: List[FilteredViewRows] = [] + input_row_ids: List[int] = [row.id for row in rows] + + # Plan which views need querying and which are already fully decided by cache. + view_checks: List[FilterCheck] = [] + row_ids_to_check: Set[int] = set() + + for view, filter_qs, can_use_cache in self._views_with_filters: + if can_use_cache: + cache_for_view: Dict[int, bool] = self._view_row_check_cache[view.id] + # Fully cached means every row_id has a cached bool (True or False). + missing_row_ids = [ + rid for rid in input_row_ids if rid not in cache_for_view + ] + is_fully_cached = len(missing_row_ids) == 0 + if not is_fully_cached: + row_ids_to_check.update(missing_row_ids) + view_checks.append( + FilterCheck( + view=view, + filter_qs=filter_qs, + can_cache=True, + is_fully_cached=is_fully_cached, + ) + ) + else: + row_ids_to_check.update(input_row_ids) + view_checks.append( + FilterCheck( + view=view, + filter_qs=filter_qs, + can_cache=False, + is_fully_cached=False, + ) + ) + + # Run one annotated query for all outstanding (view,row) combinations. + checks_needing_query = [ + (vc.view, vc.filter_qs, vc.can_cache) + for vc in view_checks + if not vc.is_fully_cached + ] + visible_row_ids_by_view_id: Dict[int, Set[int]] = { + vc.view.id: set() for vc in view_checks + } + + if checks_needing_query and row_ids_to_check: + # For each row in the batch, each view contributes a visible_v{view.id} + # boolean. + for row_record in self._rows_with_visibility_flags( + row_ids_to_check, checks_needing_query + ): + row_id = row_record["id"] + for view, _filter_qs, can_cache in checks_needing_query: + key = f"visible_v{view.id}" + is_visible = bool(row_record[key]) + if is_visible: + visible_row_ids_by_view_id[view.id].add(row_id) + if can_cache: + # Cache both outcomes to allow zero queries later. + self._view_row_check_cache[view.id][row_id] = is_visible + + # Emit results in the same order as _views_with_filters. + for view_check in view_checks: + cache_for_view: Dict[int, bool] = self._view_row_check_cache[ + view_check.view.id + ] + + if view_check.is_fully_cached: + # All rows in this batch are in cache: only return those cached as True. + visible_ids_for_view = { + rid for rid in input_row_ids if cache_for_view.get(rid, False) + } + else: + visible_ids_for_view = set( + visible_row_ids_by_view_id[view_check.view.id] + ) + if view_check.can_cache: + visible_ids_for_view.update( + rid for rid in input_row_ids if cache_for_view.get(rid, False) + ) + + if visible_ids_for_view: + result_for_views.append( + FilteredViewRows(view_check.view, visible_ids_for_view) + ) + + # Views without filters allow all rows, so they must be added. + for view_without_filters in self._views_without_filters: + result_for_views.append( + FilteredViewRows( + view_without_filters, FilteredViewRows.ALL_ROWS_ALLOWED + ) + ) + + return result_for_views diff --git a/backend/src/baserow/contrib/database/views/usage_types.py b/backend/src/baserow/contrib/database/views/usage_types.py index c476710d79..313aed4271 100755 --- a/backend/src/baserow/contrib/database/views/usage_types.py +++ b/backend/src/baserow/contrib/database/views/usage_types.py @@ -1,6 +1,8 @@ -from django.db.models import Q, Sum +from django.db.models import F, Sum from django.db.models.functions import Coalesce +from django_cte import With + from baserow.contrib.database.views.models import FormView from baserow.core.usage.registries import ( USAGE_UNIT_MB, @@ -18,15 +20,20 @@ def calculate_storage_usage_workspace(self, workspace_id: int) -> UsageInMB: table__database__workspace_id=workspace_id, table__trashed=False, table__database__trashed=False, + ).order_by() + cover_files = form_views.exclude(cover_image_id__isnull=True).values( + file_id=F("cover_image_id") ) - - usage = ( - UserFile.objects.filter( - Q(id__in=form_views.values("cover_image")) - | Q(id__in=form_views.values("logo_image")) - ) - .values("size") - .aggregate(sum=Coalesce(Sum("size") / USAGE_UNIT_MB, 0))["sum"] + logo_files = form_views.exclude(logo_image_id__isnull=True).values( + file_id=F("logo_image_id") ) + file_ids_cte = With(cover_files.union(logo_files)) + + user_files_qs = file_ids_cte.join( + UserFile, id=file_ids_cte.col.file_id + ).with_cte(file_ids_cte) + usage = user_files_qs.aggregate(sum=Coalesce(Sum("size") / USAGE_UNIT_MB, 0))[ + "sum" + ] return usage or 0 diff --git a/backend/src/baserow/contrib/database/views/view_types.py b/backend/src/baserow/contrib/database/views/view_types.py index e0b966ef8d..8e5952940f 100644 --- a/backend/src/baserow/contrib/database/views/view_types.py +++ b/backend/src/baserow/contrib/database/views/view_types.py @@ -326,14 +326,13 @@ def get_hidden_fields( view: GridView, field_ids_to_check: Optional[List[int]] = None, ) -> Set[int]: - if field_ids_to_check is None: - field_ids_to_check = view.table.field_set.values_list("id", flat=True) - fields_with_options = view.gridviewfieldoptions_set.all() field_ids_with_options = {o.field_id for o in fields_with_options} hidden_field_ids = {o.field_id for o in fields_with_options if o.hidden} # Hide fields in shared views by default if they don't have field_options. if view.public: + if field_ids_to_check is None: + field_ids_to_check = [f.id for f in view.table.field_set.all()] additional_hidden_field_ids = { f_id for f_id in field_ids_to_check diff --git a/backend/src/baserow/contrib/database/ws/public/rows/signals.py b/backend/src/baserow/contrib/database/ws/public/rows/signals.py deleted file mode 100644 index 7e440bc051..0000000000 --- a/backend/src/baserow/contrib/database/ws/public/rows/signals.py +++ /dev/null @@ -1,283 +0,0 @@ -from typing import Any, Dict, List, Optional - -from django.db import transaction -from django.dispatch import receiver - -from opentelemetry import trace - -from baserow.contrib.database.api.constants import PUBLIC_PLACEHOLDER_ENTITY_ID -from baserow.contrib.database.api.rows.serializers import serialize_rows_for_response -from baserow.contrib.database.rows import signals as row_signals -from baserow.contrib.database.table.models import GeneratedTableModel -from baserow.contrib.database.views.handler import PublicViewRows, ViewHandler -from baserow.contrib.database.views.registries import view_type_registry -from baserow.contrib.database.ws.rows.signals import ( - RealtimeRowMessages, - serialize_rows_values, -) -from baserow.core.telemetry.utils import baserow_trace -from baserow.ws.registries import page_registry - -tracer = trace.get_tracer(__name__) - - -@baserow_trace(tracer) -def _send_rows_created_event_to_views( - serialized_rows: List[Dict[Any, Any]], - before: Optional[GeneratedTableModel], - public_views: List[PublicViewRows], -): - view_page_type = page_registry.get("view") - handler = ViewHandler() - - for public_view, visible_row_ids in public_views: - view_type = view_type_registry.get_by_model(public_view.specific_class) - if not view_type.when_shared_publicly_requires_realtime_events: - continue - - restricted_serialized_rows = handler.restrict_rows_for_view( - public_view, serialized_rows, visible_row_ids - ) - view_page_type.broadcast( - RealtimeRowMessages.rows_created( - table_id=PUBLIC_PLACEHOLDER_ENTITY_ID, - serialized_rows=restricted_serialized_rows, - metadata={}, - before=before, - ), - slug=public_view.slug, - ) - - -@baserow_trace(tracer) -def _send_rows_deleted_event_to_views( - serialized_deleted_rows: List[Dict[Any, Any]], - public_views: List[PublicViewRows], -): - view_page_type = page_registry.get("view") - handler = ViewHandler() - for public_view, deleted_row_ids in public_views: - view_type = view_type_registry.get_by_model(public_view.specific_class) - if not view_type.when_shared_publicly_requires_realtime_events: - continue - - restricted_serialized_deleted_rows = handler.restrict_rows_for_view( - public_view, serialized_deleted_rows, deleted_row_ids - ) - view_page_type.broadcast( - RealtimeRowMessages.rows_deleted( - table_id=PUBLIC_PLACEHOLDER_ENTITY_ID, - serialized_rows=restricted_serialized_deleted_rows, - ), - slug=public_view.slug, - ) - - -@receiver(row_signals.rows_created) -@baserow_trace(tracer) -def public_rows_created( - sender, - rows, - before, - user, - table, - model, - send_realtime_update=True, - send_webhook_events=True, - **kwargs, -): - if not send_realtime_update: - return - - row_checker = ViewHandler().get_public_views_row_checker( - table, model, only_include_views_which_want_realtime_events=True - ) - transaction.on_commit( - lambda: _send_rows_created_event_to_views( - serialize_rows_for_response(rows, model), - before, - row_checker.get_public_views_where_rows_are_visible(rows), - ), - ) - - -@receiver(row_signals.before_rows_delete) -@baserow_trace(tracer) -def public_before_rows_delete(sender, rows, user, table, model, **kwargs): - row_checker = ViewHandler().get_public_views_row_checker( - table, model, only_include_views_which_want_realtime_events=True - ) - return { - "deleted_rows_public_views": ( - row_checker.get_public_views_where_rows_are_visible(rows) - ), - "deleted_rows": serialize_rows_for_response(rows, model), - } - - -@receiver(row_signals.rows_deleted) -@baserow_trace(tracer) -def public_rows_deleted( - sender, rows, user, table, model, before_return, send_realtime_update=True, **kwargs -): - if not send_realtime_update: - return - - public_views = dict(before_return)[public_before_rows_delete][ - "deleted_rows_public_views" - ] - serialized_deleted_rows = dict(before_return)[public_before_rows_delete][ - "deleted_rows" - ] - transaction.on_commit( - lambda: _send_rows_deleted_event_to_views(serialized_deleted_rows, public_views) - ) - - -@receiver(row_signals.before_rows_update) -@baserow_trace(tracer) -def public_before_rows_update( - sender, rows, user, table, model, updated_field_ids, **kwargs -): - row_checker = ViewHandler().get_public_views_row_checker( - table, - model, - only_include_views_which_want_realtime_events=True, - updated_field_ids=updated_field_ids, - ) - return { - "old_rows_public_views": row_checker.get_public_views_where_rows_are_visible( - rows - ), - "caching_row_checker": row_checker, - } - - -@receiver(row_signals.rows_updated) -@baserow_trace(tracer) -def public_rows_updated( - sender, - rows, - user, - table, - model, - before_return, - updated_field_ids, - send_realtime_update=True, - **kwargs, -): - if not send_realtime_update: - return - - before_return_dict = dict(before_return)[public_before_rows_update] - serialized_old_rows = dict(before_return)[serialize_rows_values] - serialized_updated_rows = serialize_rows_for_response(rows, model) - - old_row_public_views: List[PublicViewRows] = before_return_dict[ - "old_rows_public_views" - ] - existing_checker = before_return_dict["caching_row_checker"] - public_view_rows: List[ - PublicViewRows - ] = existing_checker.get_public_views_where_rows_are_visible(rows) - - view_slug_to_updated_public_view_rows = { - view.view.slug: view for view in public_view_rows - } - - # When a row is updated from the point of view of a public view it might not always - # result in a `rows_updated` event. For example if a row was previously not visible - # in the public view due to its filters, but the row update makes it now match - # the filters we want to send a `rows_created` event to that views page as the - # clients won't know anything about the row and hence a `rows_updated` event makes - # no sense for them. - public_views_where_rows_were_created: List[PublicViewRows] = [] - public_views_where_rows_were_updated: List[PublicViewRows] = [] - public_views_where_rows_were_deleted: List[PublicViewRows] = [] - - for old_public_view_rows in old_row_public_views: - (old_row_view, old_visible_ids) = old_public_view_rows - - updated_public_view_rows = view_slug_to_updated_public_view_rows.pop( - old_row_view.slug, None - ) - - if updated_public_view_rows is None: - public_views_where_rows_were_deleted.append( - PublicViewRows(old_row_view, None) - ) - else: - new_visible_ids = updated_public_view_rows.allowed_row_ids - - if ( - old_visible_ids == PublicViewRows.ALL_ROWS_ALLOWED - and new_visible_ids == PublicViewRows.ALL_ROWS_ALLOWED - ): - public_views_where_rows_were_updated.append( - PublicViewRows(old_row_view, PublicViewRows.ALL_ROWS_ALLOWED) - ) - continue - - if old_visible_ids == PublicViewRows.ALL_ROWS_ALLOWED: - old_visible_ids = new_visible_ids - - if new_visible_ids == PublicViewRows.ALL_ROWS_ALLOWED: - new_visible_ids = old_visible_ids - - deleted_ids = old_visible_ids - new_visible_ids - if len(deleted_ids) > 0: - public_views_where_rows_were_deleted.append( - PublicViewRows(old_row_view, deleted_ids) - ) - - created_ids = new_visible_ids - old_visible_ids - if len(created_ids) > 0: - public_views_where_rows_were_created.append( - PublicViewRows(old_row_view, created_ids) - ) - - updated_ids = new_visible_ids - created_ids - deleted_ids - if len(updated_ids) > 0: - public_views_where_rows_were_updated.append( - PublicViewRows(old_row_view, updated_ids) - ) - - # Any remaining views in the updated_rows_public_views dict are views which - # previously didn't show the old row, but now show the new row, so we want created. - public_views_where_rows_were_created = public_views_where_rows_were_created + list( - view_slug_to_updated_public_view_rows.values() - ) - - @baserow_trace(tracer) - def _send_created_updated_deleted_row_signals_to_views(): - _send_rows_deleted_event_to_views( - serialized_old_rows, public_views_where_rows_were_deleted - ) - _send_rows_created_event_to_views( - serialized_updated_rows, - before=None, - public_views=public_views_where_rows_were_created, - ) - - view_page_type = page_registry.get("view") - handler = ViewHandler() - - for public_view, visible_row_ids in public_views_where_rows_were_updated: - visible_fields_only_updated_rows = handler.restrict_rows_for_view( - public_view, serialized_updated_rows, visible_row_ids - ) - visible_fields_only_old_rows = handler.restrict_rows_for_view( - public_view, serialized_old_rows, visible_row_ids - ) - view_page_type.broadcast( - RealtimeRowMessages.rows_updated( - table_id=PUBLIC_PLACEHOLDER_ENTITY_ID, - serialized_rows_before_update=visible_fields_only_old_rows, - serialized_rows=visible_fields_only_updated_rows, - updated_field_ids=list(updated_field_ids), - metadata={}, - ), - slug=public_view.slug, - ) - - transaction.on_commit(_send_created_updated_deleted_row_signals_to_views) diff --git a/backend/src/baserow/contrib/database/ws/public/rows/view_realtime_rows.py b/backend/src/baserow/contrib/database/ws/public/rows/view_realtime_rows.py new file mode 100644 index 0000000000..a7ce3901f0 --- /dev/null +++ b/backend/src/baserow/contrib/database/ws/public/rows/view_realtime_rows.py @@ -0,0 +1,23 @@ +from copy import deepcopy + +from django.db.models import Q + +from baserow.contrib.database.api.constants import PUBLIC_PLACEHOLDER_ENTITY_ID +from baserow.contrib.database.ws.views.rows.registries import ViewRealtimeRowsType +from baserow.ws.registries import page_registry + + +class PublicViewRealtimeRowsType(ViewRealtimeRowsType): + type = "public_view" + + def get_views_filter(self) -> Q: + return Q(public=True) + + def broadcast(self, view, payload): + view_page_type = page_registry.get("view") + payload = deepcopy(payload) + payload["table_id"] = PUBLIC_PLACEHOLDER_ENTITY_ID + view_page_type.broadcast( + payload, + slug=view.slug, + ) diff --git a/backend/src/baserow/contrib/database/ws/signals.py b/backend/src/baserow/contrib/database/ws/signals.py index 6969b7686f..b23396d8b7 100644 --- a/backend/src/baserow/contrib/database/ws/signals.py +++ b/backend/src/baserow/contrib/database/ws/signals.py @@ -8,19 +8,19 @@ from .fields.signals import field_created, field_deleted, field_updated from .rows.signals import rows_created, rows_deleted, rows_updated from .table.signals import table_created, table_deleted, table_updated +from .views.rows.signals import ( + views_before_rows_delete, + views_before_rows_update, + views_rows_created, + views_rows_deleted, + views_rows_updated, +) from .views.signals import view_created, view_deleted, view_updated, views_reordered if settings.DISABLE_ANONYMOUS_PUBLIC_VIEW_WS_CONNECTIONS: PUBLIC_SIGNALS = [] else: # isort: off - # noinspection PyUnresolvedReferences - from .public.rows.signals import ( # noqa: F401 - public_rows_created, - public_rows_deleted, - public_rows_updated, - ) - # noinspection PyUnresolvedReferences from .public.views.signals import ( # noqa: F401 public_view_field_options_updated, @@ -41,9 +41,6 @@ # isort: on PUBLIC_SIGNALS = [ - "public_rows_created", - "public_rows_deleted", - "public_rows_updated", "public_view_filter_updated", "public_view_filter_deleted", "public_view_filter_created", @@ -72,5 +69,10 @@ "on_field_rule_created", "on_field_rule_updated", "on_field_rule_deleted", + "views_rows_created", + "views_rows_updated", + "views_rows_deleted", + "views_before_rows_update", + "views_before_rows_delete", *PUBLIC_SIGNALS, ] diff --git a/backend/src/baserow/contrib/database/ws/views/rows/__init__.py b/backend/src/baserow/contrib/database/ws/views/rows/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/baserow/contrib/database/ws/views/rows/handler.py b/backend/src/baserow/contrib/database/ws/views/rows/handler.py new file mode 100644 index 0000000000..c7d9c8c16b --- /dev/null +++ b/backend/src/baserow/contrib/database/ws/views/rows/handler.py @@ -0,0 +1,83 @@ +from typing import Dict, List, Optional + +from django.db.models import BooleanField, Case, Q, Value, When + +from baserow.contrib.database.table.models import GeneratedTableModel, Table +from baserow.contrib.database.views.models import View +from baserow.contrib.database.views.row_checker import FilteredViewRowChecker + +from .registries import view_realtime_rows_registry + + +class ViewRealtimeRowsHandler: + def _is_name(self, name): + return f"_is_{name}" + + def get_views_row_checker( + self, + table: Table, + model: GeneratedTableModel, + only_include_views_which_want_realtime_events: bool, + updated_field_ids: Optional[List[int]] = None, + ) -> FilteredViewRowChecker: + """ + Returns a FilteredViewRowChecker object which will have precalculated + information about the public views in the provided table to aid with quickly + checking which views a row in that table is visible in. If you will be updating + the row and reusing the checker you must provide an iterable of the field ids + that you will be updating in the row, otherwise the checker will cache the + first check per view/row. + + :param table: The table the row is in. + :param model: The model of the table including all fields. + :param only_include_views_which_want_realtime_events: If True will only look + for public views where + ViewType.when_shared_publicly_requires_realtime_events is True. + :param updated_field_ids: An optional iterable of field ids which will be + updated on rows passed to the checker. If the checker is used on the same + row multiple times and that row has been updated it will return invalid + results unless you have correctly populated this argument. + """ + + filters = { + t.type: t.get_views_filter() for t in view_realtime_rows_registry.get_all() + } + combined_q = Q() + for q in filters.values(): + combined_q |= q + + queryset = ( + table.view_set.filter(combined_q) + .annotate( + **{ + self._is_name(name): Case( + When(q, then=Value(True)), + default=Value(False), + output_field=BooleanField(), + ) + for name, q in filters.items() + } + ) + .select_related("table") + .prefetch_related("viewfilter_set", "filter_groups", "table__field_set") + ) + + return FilteredViewRowChecker( + model, + queryset, + only_include_views_which_want_realtime_events, + updated_field_ids, + ) + + def broadcast_to_types(self, view: View, payload: Dict): + """ + Helper method that broadcasts the provided payload using the ViewRealtimeRows + type, if the view matches the filter. + + :param view: The view object where to broadcast the payload to. + :param payload: The payload that must be broadcasted. + """ + + for t in view_realtime_rows_registry.get_all(): + if getattr(view, self._is_name(t.type), False): + t.broadcast(view, payload) diff --git a/backend/src/baserow/contrib/database/ws/views/rows/registries.py b/backend/src/baserow/contrib/database/ws/views/rows/registries.py new file mode 100644 index 0000000000..ceb4da3f56 --- /dev/null +++ b/backend/src/baserow/contrib/database/ws/views/rows/registries.py @@ -0,0 +1,53 @@ +from django.db.models import Q + +from baserow.core.registries import Instance, Registry + + +class ViewRealtimeRowsType(Instance): + """ + Registering a new `ViewRealtimeRowsType` can be used to efficiently broadcast a row + related realtime event of the views matching the query. It will be query and + performance efficient because the process doesn't have to be repeated N number of + times. + """ + + def get_views_filter(self) -> Q: + """ + Should return a Q object that is applied to the queryset to fetch the views + related to the type within the table. + + :return: A Q object containing the filter to get the views related to this type. + """ + + raise NotImplementedError( + "Must implement the `get_views_filter` for each `ViewRealtimeRowsType` " + "instance." + ) + + def broadcast(self, view, payload): + """ + Called when a payload must be broadcasted to a view. The code should look like: + + ``` + view_page_type = page_registry.get("view") + view_page_type.broadcast( + payload, + ...{'kwarg_for_the_page', view.slug}, + ) + ``` + + :param view: The view where to broadcast to. + :param payload: The row created, updated, or deleted payload that must be + broadcasted. + """ + + raise NotImplementedError( + "Must implement the `broadcast` for each `ViewRealtimeRowsType` instance." + ) + + +class ViewRealtimeRowsRegistry(Registry): + name = "view_realtime_rows" + + +view_realtime_rows_registry = ViewRealtimeRowsRegistry() diff --git a/backend/src/baserow/contrib/database/ws/views/rows/signals.py b/backend/src/baserow/contrib/database/ws/views/rows/signals.py new file mode 100644 index 0000000000..40d7f35925 --- /dev/null +++ b/backend/src/baserow/contrib/database/ws/views/rows/signals.py @@ -0,0 +1,265 @@ +from typing import Any, Dict, List, Optional + +from django.db import transaction +from django.dispatch import receiver + +from opentelemetry import trace + +from baserow.contrib.database.api.rows.serializers import serialize_rows_for_response +from baserow.contrib.database.rows import signals as row_signals +from baserow.contrib.database.table.models import GeneratedTableModel +from baserow.contrib.database.views.registries import view_type_registry +from baserow.contrib.database.views.row_checker import FilteredViewRows, ViewHandler +from baserow.contrib.database.ws.rows.signals import ( + RealtimeRowMessages, + serialize_rows_values, +) +from baserow.contrib.database.ws.views.rows.handler import ViewRealtimeRowsHandler +from baserow.core.telemetry.utils import baserow_trace + +tracer = trace.get_tracer(__name__) + + +@baserow_trace(tracer) +def _send_rows_created_event_to_views( + serialized_rows: List[Dict[Any, Any]], + before: Optional[GeneratedTableModel], + views: List[FilteredViewRows], +): + view_handler = ViewHandler() + view_realtime_rows_handler = ViewRealtimeRowsHandler() + + for view, visible_row_ids in views: + view_type = view_type_registry.get_by_model(view.specific_class) + if not view_type.when_shared_publicly_requires_realtime_events: + continue + + restricted_serialized_rows = view_handler.restrict_rows_for_view( + view, serialized_rows, visible_row_ids + ) + payload = RealtimeRowMessages.rows_created( + table_id=view.table_id, + serialized_rows=restricted_serialized_rows, + metadata={}, + before=before, + ) + view_realtime_rows_handler.broadcast_to_types(view, payload) + + +@baserow_trace(tracer) +def _send_rows_deleted_event_to_views( + serialized_deleted_rows: List[Dict[Any, Any]], + views: List[FilteredViewRows], +): + view_handler = ViewHandler() + view_realtime_rows_handler = ViewRealtimeRowsHandler() + + for view, deleted_row_ids in views: + view_type = view_type_registry.get_by_model(view.specific_class) + if not view_type.when_shared_publicly_requires_realtime_events: + continue + + restricted_serialized_deleted_rows = view_handler.restrict_rows_for_view( + view, serialized_deleted_rows, deleted_row_ids + ) + payload = RealtimeRowMessages.rows_deleted( + table_id=view.table_id, + serialized_rows=restricted_serialized_deleted_rows, + ) + view_realtime_rows_handler.broadcast_to_types(view, payload) + + +@receiver(row_signals.rows_created) +@baserow_trace(tracer) +def views_rows_created( + sender, + rows, + before, + user, + table, + model, + send_realtime_update=True, + send_webhook_events=True, + **kwargs, +): + if not send_realtime_update: + return + + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( + table, model, only_include_views_which_want_realtime_events=True + ) + transaction.on_commit( + lambda: _send_rows_created_event_to_views( + serialize_rows_for_response(rows, model), + before, + row_checker.get_filtered_views_where_rows_are_visible(rows), + ), + ) + + +@receiver(row_signals.before_rows_delete) +@baserow_trace(tracer) +def views_before_rows_delete(sender, rows, user, table, model, **kwargs): + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( + table, model, only_include_views_which_want_realtime_events=True + ) + return { + "deleted_rows_views": ( + row_checker.get_filtered_views_where_rows_are_visible(rows) + ), + "deleted_rows": serialize_rows_for_response(rows, model), + } + + +@receiver(row_signals.rows_deleted) +@baserow_trace(tracer) +def views_rows_deleted( + sender, rows, user, table, model, before_return, send_realtime_update=True, **kwargs +): + if not send_realtime_update: + return + + views = dict(before_return)[views_before_rows_delete]["deleted_rows_views"] + serialized_deleted_rows = dict(before_return)[views_before_rows_delete][ + "deleted_rows" + ] + transaction.on_commit( + lambda: _send_rows_deleted_event_to_views(serialized_deleted_rows, views) + ) + + +@receiver(row_signals.before_rows_update) +@baserow_trace(tracer) +def views_before_rows_update( + sender, rows, user, table, model, updated_field_ids, **kwargs +): + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( + table, + model, + only_include_views_which_want_realtime_events=True, + updated_field_ids=updated_field_ids, + ) + return { + "old_rows_views": row_checker.get_filtered_views_where_rows_are_visible(rows), + "caching_row_checker": row_checker, + } + + +@receiver(row_signals.rows_updated) +@baserow_trace(tracer) +def views_rows_updated( + sender, + rows, + user, + table, + model, + before_return, + updated_field_ids, + send_realtime_update=True, + **kwargs, +): + if not send_realtime_update: + return + + before_return_dict = dict(before_return)[views_before_rows_update] + serialized_old_rows = dict(before_return)[serialize_rows_values] + serialized_updated_rows = serialize_rows_for_response(rows, model) + + old_row_views: List[FilteredViewRows] = before_return_dict["old_rows_views"] + existing_checker = before_return_dict["caching_row_checker"] + view_rows: List[ + FilteredViewRows + ] = existing_checker.get_filtered_views_where_rows_are_visible(rows) + + view_slug_to_updated_view_rows = {view.view.slug: view for view in view_rows} + + # When a row is updated from the point of view of a view it might not always + # result in a `rows_updated` event. For example if a row was previously not visible + # in the view due to its filters, but the row update makes it now match the + # filters we want to send a `rows_created` event to that views page as the + # clients won't know anything about the row and hence a `rows_updated` event makes + # no sense for them. + views_where_rows_were_created: List[FilteredViewRows] = [] + views_where_rows_were_updated: List[FilteredViewRows] = [] + views_where_rows_were_deleted: List[FilteredViewRows] = [] + + for old_view_rows in old_row_views: + (old_row_view, old_visible_ids) = old_view_rows + + updated_view_rows = view_slug_to_updated_view_rows.pop(old_row_view.slug, None) + + if updated_view_rows is None: + views_where_rows_were_deleted.append(FilteredViewRows(old_row_view, None)) + else: + new_visible_ids = updated_view_rows.allowed_row_ids + + if ( + old_visible_ids == FilteredViewRows.ALL_ROWS_ALLOWED + and new_visible_ids == FilteredViewRows.ALL_ROWS_ALLOWED + ): + views_where_rows_were_updated.append( + FilteredViewRows(old_row_view, FilteredViewRows.ALL_ROWS_ALLOWED) + ) + continue + + if old_visible_ids == FilteredViewRows.ALL_ROWS_ALLOWED: + old_visible_ids = new_visible_ids + + if new_visible_ids == FilteredViewRows.ALL_ROWS_ALLOWED: + new_visible_ids = old_visible_ids + + deleted_ids = old_visible_ids - new_visible_ids + if len(deleted_ids) > 0: + views_where_rows_were_deleted.append( + FilteredViewRows(old_row_view, deleted_ids) + ) + + created_ids = new_visible_ids - old_visible_ids + if len(created_ids) > 0: + views_where_rows_were_created.append( + FilteredViewRows(old_row_view, created_ids) + ) + + updated_ids = new_visible_ids - created_ids - deleted_ids + if len(updated_ids) > 0: + views_where_rows_were_updated.append( + FilteredViewRows(old_row_view, updated_ids) + ) + + # Any remaining views in the updated_rows_views dict are views which + # previously didn't show the old row, but now show the new row, so we want created. + views_where_rows_were_created = views_where_rows_were_created + list( + view_slug_to_updated_view_rows.values() + ) + + @baserow_trace(tracer) + def _send_created_updated_deleted_row_signals_to_views(): + _send_rows_deleted_event_to_views( + serialized_old_rows, views_where_rows_were_deleted + ) + _send_rows_created_event_to_views( + serialized_updated_rows, + before=None, + views=views_where_rows_were_created, + ) + + view_handler = ViewHandler() + view_realtime_rows_handler = ViewRealtimeRowsHandler() + + for view, visible_row_ids in views_where_rows_were_updated: + visible_fields_only_updated_rows = view_handler.restrict_rows_for_view( + view, serialized_updated_rows, visible_row_ids + ) + visible_fields_only_old_rows = view_handler.restrict_rows_for_view( + view, serialized_old_rows, visible_row_ids + ) + payload = RealtimeRowMessages.rows_updated( + table_id=view.table_id, + serialized_rows_before_update=visible_fields_only_old_rows, + serialized_rows=visible_fields_only_updated_rows, + updated_field_ids=list(updated_field_ids), + metadata={}, + ) + view_realtime_rows_handler.broadcast_to_types(view, payload) + + transaction.on_commit(_send_created_updated_deleted_row_signals_to_views) diff --git a/backend/src/baserow/core/migrations/0108_alter_userfile_unique.py b/backend/src/baserow/core/migrations/0108_alter_userfile_unique.py new file mode 100644 index 0000000000..3dd07bf559 --- /dev/null +++ b/backend/src/baserow/core/migrations/0108_alter_userfile_unique.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.14 on 2025-12-02 15:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0107_twofactorauthprovidermodel_totpauthprovidermodel_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="userfile", + name="unique", + field=models.CharField(db_index=True, max_length=32), + ), + ] diff --git a/backend/src/baserow/core/templates/baserow/core/notifications_summary.html b/backend/src/baserow/core/templates/baserow/core/notifications_summary.html index 8b933197ad..4498b8bcc0 100644 --- a/backend/src/baserow/core/templates/baserow/core/notifications_summary.html +++ b/backend/src/baserow/core/templates/baserow/core/notifications_summary.html @@ -1,10 +1,9 @@ {% load i18n %} - + - - + @@ -99,7 +98,7 @@ } - -
+
@@ -132,7 +129,7 @@ @@ -184,33 +181,25 @@
- +
- {% for notification in notifications %} - - {% if notification.url %} - + {% for notification in notifications %}{% if notification.url %}
{{ notification.title }}
- {% else %} - + {% else %}
{{ notification.title }}
- {% endif %} - - {% if notification.description %} - + {% endif %}{% if notification.description %}
{{ notification.description|linebreaksbr }}
- {% endif %} - + {% endif %}

@@ -219,26 +208,19 @@ - {% endfor %} - - {% if unlisted_notifications_count %} - + {% endfor %}{% if unlisted_notifications_count %}

{% blocktrans trimmed count counter=unlisted_notifications_count %} Plus {{ counter }} more notification. {% plural %} Plus {{ counter }} more notifications. {% endblocktrans %}
- {% endif %} - - {% if show_baserow_description %} - + {% endif %}{% if show_baserow_description %}
{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}
- {% endif %} - + {% endif %}
diff --git a/backend/src/baserow/core/templates/baserow/core/user/account_deleted.html b/backend/src/baserow/core/templates/baserow/core/user/account_deleted.html index 2bd8557c89..98f2dfcbb6 100644 --- a/backend/src/baserow/core/templates/baserow/core/user/account_deleted.html +++ b/backend/src/baserow/core/templates/baserow/core/user/account_deleted.html @@ -1,10 +1,9 @@ {% load i18n %} - + - - + @@ -99,7 +98,7 @@ } - -
+
@@ -132,7 +129,7 @@ @@ -181,15 +178,13 @@
{% blocktrans trimmed with baserow_embedded_share_hostname as baserow_embedded_share_hostname %} Your account ({{ username }}) on Baserow ({{ baserow_embedded_share_hostname }}) has been permanently deleted. {% endblocktrans %}
- {% if show_baserow_description %} - + {% if show_baserow_description %} - {% endif %} - + {% endif %}
- +
{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}
diff --git a/backend/src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html b/backend/src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html index dfdcebe01f..9624bd8769 100644 --- a/backend/src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html +++ b/backend/src/baserow/core/templates/baserow/core/user/account_deletion_cancelled.html @@ -1,10 +1,9 @@ {% load i18n %} - + - - + @@ -99,7 +98,7 @@ } - -
+
@@ -132,7 +129,7 @@ @@ -181,15 +178,13 @@
{% blocktrans trimmed with user.username as username and baserow_embedded_share_hostname as baserow_embedded_share_hostname %} Your account ({{ username }}) on Baserow ({{ baserow_embedded_share_hostname }}) was pending deletion, but you've logged in so this operation has been cancelled. {% endblocktrans %}
- {% if show_baserow_description %} - + {% if show_baserow_description %} - {% endif %} - + {% endif %}
- +
{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}
diff --git a/backend/src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html b/backend/src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html index bf792010e4..f452733279 100644 --- a/backend/src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html +++ b/backend/src/baserow/core/templates/baserow/core/user/account_deletion_scheduled.html @@ -1,10 +1,9 @@ {% load i18n %} - + - - + @@ -99,7 +98,7 @@ } - -
+
@@ -132,7 +129,7 @@ @@ -186,15 +183,13 @@
{% blocktrans trimmed %} If you've changed your mind and want to cancel your account deletion, you just have to login again. {% endblocktrans %}
- {% if show_baserow_description %} - + {% if show_baserow_description %} - {% endif %} - + {% endif %}
- +
{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}
diff --git a/backend/src/baserow/core/templates/baserow/core/user/change_email_confirmation.html b/backend/src/baserow/core/templates/baserow/core/user/change_email_confirmation.html index 2edf70aff1..1c72900236 100644 --- a/backend/src/baserow/core/templates/baserow/core/user/change_email_confirmation.html +++ b/backend/src/baserow/core/templates/baserow/core/user/change_email_confirmation.html @@ -1,10 +1,9 @@ {% load i18n %} - + - - + @@ -99,7 +98,7 @@ } - -
+
@@ -132,7 +129,7 @@ @@ -187,7 +184,7 @@ -
- +
+ @@ -204,15 +201,13 @@
{{ confirmation_url }}
- {% if show_baserow_description %} - + {% if show_baserow_description %} - {% endif %} - + {% endif %}
{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}
diff --git a/backend/src/baserow/core/templates/baserow/core/user/email_pending_verification.html b/backend/src/baserow/core/templates/baserow/core/user/email_pending_verification.html index 84478db6ca..b57ac559cd 100644 --- a/backend/src/baserow/core/templates/baserow/core/user/email_pending_verification.html +++ b/backend/src/baserow/core/templates/baserow/core/user/email_pending_verification.html @@ -1,10 +1,9 @@ {% load i18n %} - + - - + @@ -99,7 +98,7 @@ } - -
+
@@ -132,7 +129,7 @@ @@ -187,7 +184,7 @@ -
- +
+ diff --git a/backend/src/baserow/core/templates/baserow/core/user/reset_password.html b/backend/src/baserow/core/templates/baserow/core/user/reset_password.html index 985f0d46ab..b91bd32939 100644 --- a/backend/src/baserow/core/templates/baserow/core/user/reset_password.html +++ b/backend/src/baserow/core/templates/baserow/core/user/reset_password.html @@ -1,10 +1,9 @@ {% load i18n %} - + - - + @@ -99,7 +98,7 @@ } - -
+
@@ -132,7 +129,7 @@ @@ -187,7 +184,7 @@ -
- +
+ @@ -204,15 +201,13 @@
{{ reset_url }}
- {% if show_baserow_description %} - + {% if show_baserow_description %} - {% endif %} - + {% endif %}
{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}
diff --git a/backend/src/baserow/core/templates/baserow/core/workspace_invitation.html b/backend/src/baserow/core/templates/baserow/core/workspace_invitation.html index b9c96003c2..0b4106c71e 100644 --- a/backend/src/baserow/core/templates/baserow/core/workspace_invitation.html +++ b/backend/src/baserow/core/templates/baserow/core/workspace_invitation.html @@ -1,10 +1,9 @@ {% load i18n %} - + - - + @@ -99,7 +98,7 @@ } - -
+
@@ -132,7 +129,7 @@ @@ -181,17 +178,15 @@
{% blocktrans trimmed with invitation.invited_by.first_name as first_name and invitation.workspace.name as workspace_name %} {{ first_name }} has invited you to collaborate on {{ workspace_name }}. {% endblocktrans %}
- {% if invitation.message %} - + {% if invitation.message %} - {% endif %} - + {% endif %} -
- +
"{{ invitation.message }}"
+ @@ -208,15 +203,13 @@
{{ public_accept_url }}
- {% if show_baserow_description %} - + {% if show_baserow_description %} - {% endif %} - + {% endif %}
{% blocktrans trimmed %} Baserow is an open source no-code database tool which allows you to collaborate on projects, customers and more. It gives you the powers of a developer without leaving your browser. {% endblocktrans %}
diff --git a/backend/src/baserow/core/user_files/managers.py b/backend/src/baserow/core/user_files/managers.py index 854649b054..f85632020e 100644 --- a/backend/src/baserow/core/user_files/managers.py +++ b/backend/src/baserow/core/user_files/managers.py @@ -1,8 +1,10 @@ from django.db import models from django.db.models import Q +from django_cte import CTEQuerySet -class UserFileQuerySet(models.QuerySet): + +class UserFileQuerySet(CTEQuerySet, models.QuerySet): def name(self, *names): if len(names) == 0: raise ValueError("At least one name must be provided.") diff --git a/backend/src/baserow/core/user_files/models.py b/backend/src/baserow/core/user_files/models.py index 0f671bfae6..ff5e260565 100644 --- a/backend/src/baserow/core/user_files/models.py +++ b/backend/src/baserow/core/user_files/models.py @@ -13,7 +13,7 @@ class UserFile(models.Model): original_name = models.CharField(max_length=255) original_extension = models.CharField(max_length=64) - unique = models.CharField(max_length=32) + unique = models.CharField(max_length=32, db_index=True) size = models.PositiveBigIntegerField() mime_type = models.CharField(max_length=127, blank=True) is_image = models.BooleanField(default=False) diff --git a/backend/src/baserow/ws/tasks.py b/backend/src/baserow/ws/tasks.py index a7ef44cf79..5ffa90a22b 100644 --- a/backend/src/baserow/ws/tasks.py +++ b/backend/src/baserow/ws/tasks.py @@ -203,7 +203,7 @@ def broadcast_many_to_channel_group( Broadcasts a list of JSON payloads to all the users within the channel workspace having the provided name for each payload. - :param payload: A list of pairs: channel workspace and payload dictionary + :param payloads: A list of pairs: channel workspace and payload dictionary containing data that must be broadcast. Each pair can be sent to a different channel group. :param ignore_web_socket_id: The web socket id to which messages must not be diff --git a/backend/tests/baserow/contrib/database/field/test_autonumber_field_type.py b/backend/tests/baserow/contrib/database/field/test_autonumber_field_type.py index 4c4c7391ed..cc51fb6f82 100644 --- a/backend/tests/baserow/contrib/database/field/test_autonumber_field_type.py +++ b/backend/tests/baserow/contrib/database/field/test_autonumber_field_type.py @@ -552,37 +552,37 @@ def test_autonumber_field_view_filters(data_fixture): view=view, field=autonumber_field, type="equal", value=1 ) - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(user, view, model=model) assert list(qs.values_list("id", flat=True)) == [row_1.id] view_filter.type = "not_equal" view_filter.save(update_fields=["type"]) - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(user, view, model=model) assert list(qs.values_list("id", flat=True)) == [row_2.id] view_filter.type = "lower_than" view_filter.save(update_fields=["type"]) - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(user, view, model=model) assert list(qs.values_list("id", flat=True)) == [] view_filter.type = "higher_than" view_filter.save(update_fields=["type"]) - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(user, view, model=model) assert list(qs.values_list("id", flat=True)) == [row_2.id] view_filter.type = "contains" view_filter.save(update_fields=["type"]) - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(user, view, model=model) assert list(qs.values_list("id", flat=True)) == [row_1.id] view_filter.type = "contains_not" view_filter.save(update_fields=["type"]) - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(user, view, model=model) assert list(qs.values_list("id", flat=True)) == [row_2.id] diff --git a/backend/tests/baserow/contrib/database/field/test_duration_field_type.py b/backend/tests/baserow/contrib/database/field/test_duration_field_type.py index 4d823d91c5..2afea589c7 100644 --- a/backend/tests/baserow/contrib/database/field/test_duration_field_type.py +++ b/backend/tests/baserow/contrib/database/field/test_duration_field_type.py @@ -805,19 +805,19 @@ def test_duration_field_view_filters(data_fixture): view=view, field=field, type="equal", value="0:0:1.123" ) - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(user, view, model=model) assert list(qs.values_list("id", flat=True)) == [rows[1].id, rows[2].id] view_filter.value = "1.123" # it will be considered as a number of seconds view_filter.save(update_fields=["value"]) - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(user, view, model=model) assert list(qs.values_list("id", flat=True)) == [rows[1].id, rows[2].id] view_filter.type = "not_equal" view_filter.save(update_fields=["type"]) - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(user, view, model=model) assert list(qs.values_list("id", flat=True)) == [ rows[0].id, rows[3].id, @@ -830,13 +830,13 @@ def test_duration_field_view_filters(data_fixture): view_filter.type = "empty" view_filter.save(update_fields=["type"]) - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(user, view, model=model) assert list(qs.values_list("id", flat=True)) == [rows[0].id] view_filter.type = "not_empty" view_filter.save(update_fields=["type"]) - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(user, view, model=model) assert list(qs.values_list("id", flat=True)) == [ rows[1].id, rows[2].id, @@ -851,7 +851,7 @@ def test_duration_field_view_filters(data_fixture): view_filter.value = "3600" # 1 hour view_filter.save() - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(user, view, model=model) assert list(qs.values_list("id", flat=True)) == [ rows[4].id, rows[5].id, @@ -862,7 +862,7 @@ def test_duration_field_view_filters(data_fixture): view_filter.value = "1:00:00" view_filter.save() - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(user, view, model=model) assert list(qs.values_list("id", flat=True)) == [ rows[4].id, rows[5].id, @@ -872,7 +872,7 @@ def test_duration_field_view_filters(data_fixture): view_filter.type = "lower_than" view_filter.save(update_fields=["type"]) - qs = ViewHandler().get_queryset(view, model=model) + qs = ViewHandler().get_queryset(user, view, model=model) assert list(qs.values_list("id", flat=True)) == [ rows[1].id, rows[2].id, diff --git a/backend/tests/baserow/contrib/database/field/test_duration_formula_field_filters.py b/backend/tests/baserow/contrib/database/field/test_duration_formula_field_filters.py index ded72748e7..413ef799e0 100644 --- a/backend/tests/baserow/contrib/database/field/test_duration_formula_field_filters.py +++ b/backend/tests/baserow/contrib/database/field/test_duration_formula_field_filters.py @@ -87,7 +87,7 @@ def duration_formula_filter_proc( send_realtime_update=False, ) - q = t.view_handler.get_queryset(t.grid_view) + q = t.view_handler.get_queryset(t.user, t.grid_view) actual_names = [getattr(r, refname) for r in q] actual_duration_values = [getattr(r, t.data_source_field.db_column) for r in q] actual_formula_values = [getattr(r, t.formula_field.db_column) for r in q] diff --git a/backend/tests/baserow/contrib/database/field/test_formula_field_type.py b/backend/tests/baserow/contrib/database/field/test_formula_field_type.py index 8aa8a9ceda..1a17129ca2 100644 --- a/backend/tests/baserow/contrib/database/field/test_formula_field_type.py +++ b/backend/tests/baserow/contrib/database/field/test_formula_field_type.py @@ -119,7 +119,7 @@ def test_changing_type_of_other_field_still_results_in_working_filter(data_fixtu # filter on the referencing formula field is now and invalid and should be deleted FieldHandler().update_field(user, first_formula_field, formula="1") - queryset = ViewHandler().get_queryset(grid_view) + queryset = ViewHandler().get_queryset(user, grid_view) assert not queryset.exists() assert queryset.count() == 0 @@ -142,7 +142,7 @@ def test_can_use_complex_date_filters_on_formula_field(data_fixture): value="Europe/London", ) - queryset = ViewHandler().get_queryset(grid_view) + queryset = ViewHandler().get_queryset(user, grid_view) assert not queryset.exists() assert queryset.count() == 0 @@ -167,7 +167,7 @@ def test_can_use_complex_date_filters_on_formula_field_with_lookup(data_fixture) table.get_model() - queryset = ViewHandler().get_queryset(grid_view) + queryset = ViewHandler().get_queryset(user, grid_view) assert not queryset.exists() assert queryset.count() == 0 @@ -197,7 +197,7 @@ def test_can_use_complex_contains_filters_on_formula_field(data_fixture): value="23", ) - queryset = ViewHandler().get_queryset(grid_view) + queryset = ViewHandler().get_queryset(user, grid_view) assert not queryset.exists() assert queryset.count() == 0 diff --git a/backend/tests/baserow/contrib/database/field/test_number_lookup_field_filters.py b/backend/tests/baserow/contrib/database/field/test_number_lookup_field_filters.py index f0edd87bec..9e3e48fcf0 100644 --- a/backend/tests/baserow/contrib/database/field/test_number_lookup_field_filters.py +++ b/backend/tests/baserow/contrib/database/field/test_number_lookup_field_filters.py @@ -92,7 +92,7 @@ def get_linked_rows(*indexes) -> list[int]: t.row_handler.create_rows(user=t.user, table=t.table, rows_values=rows) - clean_query = t.view_handler.get_queryset(t.grid_view) + clean_query = t.view_handler.get_queryset(t.user, t.grid_view) t.view_handler.create_filter( t.user, @@ -102,7 +102,7 @@ def get_linked_rows(*indexes) -> list[int]: value=test_value, ) - q = t.view_handler.get_queryset(t.grid_view) + q = t.view_handler.get_queryset(t.user, t.grid_view) print(f"filter {filter_type_name} with value: {(test_value,)}") print(f"expected: {expected_rows}") print(f"filtered: {[getattr(item, row_name) for item in q]}") diff --git a/backend/tests/baserow/contrib/database/field/test_url_lookup_field_filters.py b/backend/tests/baserow/contrib/database/field/test_url_lookup_field_filters.py index 8b92660967..1fa2119609 100644 --- a/backend/tests/baserow/contrib/database/field/test_url_lookup_field_filters.py +++ b/backend/tests/baserow/contrib/database/field/test_url_lookup_field_filters.py @@ -47,7 +47,7 @@ def url_formula_field_filter_proc( send_realtime_update=False, ) - q = view_handler.get_queryset(grid_view) + q = view_handler.get_queryset(user, grid_view) assert len(q) == len(expected_rows) assert set([getattr(r, path_field.db_column) for r in q]) == set(expected_rows) diff --git a/backend/tests/baserow/contrib/database/test_cachalot.py b/backend/tests/baserow/contrib/database/test_cachalot.py index f75a783cf5..f71438d12d 100644 --- a/backend/tests/baserow/contrib/database/test_cachalot.py +++ b/backend/tests/baserow/contrib/database/test_cachalot.py @@ -59,7 +59,7 @@ def get_mocked_query_cache_key(compiler): table_model = table_a.get_model() table_model.objects.create() - queryset = ViewHandler().get_queryset(view=grid_view) + queryset = ViewHandler().get_queryset(user=user, view=grid_view) def assert_cachalot_cache_queryset_count_of(expected_count): # count() should save the result of the query in the cache diff --git a/backend/tests/baserow/contrib/database/view/test_view_array_filters.py b/backend/tests/baserow/contrib/database/view/test_view_array_filters.py index 4e0eb400c3..32c3687efb 100644 --- a/backend/tests/baserow/contrib/database/view/test_view_array_filters.py +++ b/backend/tests/baserow/contrib/database/view/test_view_array_filters.py @@ -153,7 +153,7 @@ def boolean_lookup_filter_proc( type_name=filter_type_name, value=test_value, ) - q = test_setup.view_handler.get_queryset(test_setup.grid_view) + q = test_setup.view_handler.get_queryset(test_setup.user, test_setup.grid_view) assert len(q) == len(selected) assert set([r.id for r in q]) == set([r.id for r in selected]) diff --git a/backend/tests/baserow/contrib/database/view/test_view_filters.py b/backend/tests/baserow/contrib/database/view/test_view_filters.py index 31b254f522..fdb15fdd07 100644 --- a/backend/tests/baserow/contrib/database/view/test_view_filters.py +++ b/backend/tests/baserow/contrib/database/view/test_view_filters.py @@ -6912,7 +6912,7 @@ def test_all_view_filters_can_accept_strings_as_filter_value(data_fixture): # We should be able to load the view without any errors handler = ViewHandler() try: - handler.get_queryset(view) + handler.get_queryset(user, view) except Exception as e: pytest.fail(f"Exception raised: {e}") diff --git a/backend/tests/baserow/contrib/database/view/test_view_handler.py b/backend/tests/baserow/contrib/database/view/test_view_handler.py index dc31035809..875df488b9 100755 --- a/backend/tests/baserow/contrib/database/view/test_view_handler.py +++ b/backend/tests/baserow/contrib/database/view/test_view_handler.py @@ -43,11 +43,7 @@ ViewTypeDoesNotExist, ) from baserow.contrib.database.views.filters import AdHocFilters -from baserow.contrib.database.views.handler import ( - PublicViewRows, - ViewHandler, - ViewIndexingHandler, -) +from baserow.contrib.database.views.handler import ViewHandler, ViewIndexingHandler from baserow.contrib.database.views.models import ( DEFAULT_SORT_TYPE_KEY, OWNERSHIP_TYPE_COLLABORATIVE, @@ -65,11 +61,13 @@ view_filter_type_registry, view_type_registry, ) +from baserow.contrib.database.views.row_checker import FilteredViewRows from baserow.contrib.database.views.signals import view_loaded from baserow.contrib.database.views.view_ownership_types import ( CollaborativeViewOwnershipType, ) from baserow.contrib.database.views.view_types import GridViewType +from baserow.contrib.database.ws.views.rows.handler import ViewRealtimeRowsHandler from baserow.core.db import get_collation_name from baserow.core.exceptions import PermissionDenied, UserNotInWorkspace from baserow.core.trash.handler import TrashHandler @@ -1808,14 +1806,14 @@ def test_get_public_views_which_include_row(data_fixture, django_assert_num_quer ) model = table.get_model() - checker = ViewHandler().get_public_views_row_checker( + checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True ) - assert checker.get_public_views_where_row_is_visible(row) == [ + assert checker.get_filtered_views_where_row_is_visible(row) == [ public_view1.view_ptr.specific, public_view3.view_ptr.specific, ] - assert checker.get_public_views_where_row_is_visible(row2) == [ + assert checker.get_filtered_views_where_row_is_visible(row2) == [ public_view2.view_ptr.specific, public_view3.view_ptr.specific, ] @@ -1885,22 +1883,22 @@ def test_get_public_views_which_include_rows(data_fixture): ) model = table.get_model() - checker = ViewHandler().get_public_views_row_checker( + checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True ) - assert checker.get_public_views_where_rows_are_visible([row, row2]) == [ - PublicViewRows( + assert checker.get_filtered_views_where_rows_are_visible([row, row2]) == [ + FilteredViewRows( view=ViewHandler().get_view_as_user(user, public_view1.id).specific, allowed_row_ids={1}, ), - PublicViewRows( + FilteredViewRows( view=ViewHandler().get_view_as_user(user, public_view2.id).specific, allowed_row_ids={2}, ), - PublicViewRows( + FilteredViewRows( view=ViewHandler().get_view_as_user(user, public_view3.id).specific, - allowed_row_ids=PublicViewRows.ALL_ROWS_ALLOWED, + allowed_row_ids=FilteredViewRows.ALL_ROWS_ALLOWED, ), ] @@ -1937,25 +1935,25 @@ def test_public_view_row_checker_caches_when_only_unfiltered_fields_updated( f"field_{unfiltered_field.id}": "any", } ) - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True, updated_field_ids=[unfiltered_field.id], ) - assert row_checker.get_public_views_where_row_is_visible(visible_row) == [ + assert row_checker.get_filtered_views_where_row_is_visible(visible_row) == [ public_grid_view.view_ptr.specific ] - assert row_checker.get_public_views_where_row_is_visible(invisible_row) == [] + assert row_checker.get_filtered_views_where_row_is_visible(invisible_row) == [] # Because we've already checked these rows and we've told the checker we'll only # be changing unfiltered_field it knows it can cache the results with django_assert_num_queries(0): - assert row_checker.get_public_views_where_row_is_visible(visible_row) == [ + assert row_checker.get_filtered_views_where_row_is_visible(visible_row) == [ public_grid_view.view_ptr.specific ] - assert row_checker.get_public_views_where_row_is_visible(invisible_row) == [] + assert row_checker.get_filtered_views_where_row_is_visible(invisible_row) == [] @pytest.mark.django_db @@ -1987,7 +1985,7 @@ def test_public_view_row_checker_includes_public_views_with_no_filters_with_no_q f"field_{unfiltered_field.id}": "any", } ) - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True, @@ -1997,10 +1995,10 @@ def test_public_view_row_checker_includes_public_views_with_no_filters_with_no_q view_ptr_specific = public_grid_view.view_ptr.specific # It should precalculate that this view is always visible. with django_assert_num_queries(0): - assert row_checker.get_public_views_where_row_is_visible(visible_row) == [ + assert row_checker.get_filtered_views_where_row_is_visible(visible_row) == [ view_ptr_specific ] - assert row_checker.get_public_views_where_row_is_visible(other_row) == [ + assert row_checker.get_filtered_views_where_row_is_visible(other_row) == [ view_ptr_specific ] @@ -2037,17 +2035,17 @@ def test_public_view_row_checker_does_not_cache_when_any_filtered_fields_updated f"field_{unfiltered_field.id}": "any", } ) - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True, updated_field_ids=[filtered_field.id, unfiltered_field.id], ) - assert row_checker.get_public_views_where_row_is_visible(visible_row) == [ + assert row_checker.get_filtered_views_where_row_is_visible(visible_row) == [ public_grid_view.view_ptr.specific ] - assert row_checker.get_public_views_where_row_is_visible(invisible_row) == [] + assert row_checker.get_filtered_views_where_row_is_visible(invisible_row) == [] # Now update the rows so they swap and the invisible one becomes visible and vice # versa @@ -2056,10 +2054,10 @@ def test_public_view_row_checker_does_not_cache_when_any_filtered_fields_updated setattr(visible_row, f"field_{filtered_field.id}", "NotFilterValue") visible_row.save() - assert row_checker.get_public_views_where_row_is_visible(invisible_row) == [ + assert row_checker.get_filtered_views_where_row_is_visible(invisible_row) == [ public_grid_view.view_ptr.specific ] - assert row_checker.get_public_views_where_row_is_visible(visible_row) == [] + assert row_checker.get_filtered_views_where_row_is_visible(visible_row) == [] @pytest.mark.django_db @@ -2080,10 +2078,10 @@ def test_public_view_row_checker_runs_expected_queries_on_init( view=public_grid_view, field=filtered_field, type="equal", value="FilterValue" ) model = table.get_model() - num_queries = 8 + num_queries = 9 with django_assert_num_queries(num_queries): # First query to get the public views, second query to get their filters. - ViewHandler().get_public_views_row_checker( + ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True, @@ -2104,7 +2102,7 @@ def test_public_view_row_checker_runs_expected_queries_on_init( # Adding another view shouldn't result in more queries with django_assert_num_queries(num_queries): # First query to get the public views, second query to get their filters. - ViewHandler().get_public_views_row_checker( + ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True, @@ -2143,7 +2141,7 @@ def test_public_view_row_checker_runs_expected_queries_when_checking_rows( f"field_{unfiltered_field.id}": "any", } ) - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True, @@ -2154,13 +2152,13 @@ def test_public_view_row_checker_runs_expected_queries_when_checking_rows( with django_assert_num_queries(1): # Only should run a single exists query to check if the row is in the single # public view - assert row_checker.get_public_views_where_row_is_visible(visible_row) == [ + assert row_checker.get_filtered_views_where_row_is_visible(visible_row) == [ view_ptr_specific ] with django_assert_num_queries(1): # Only should run a single exists query to check if the row is in the single # public view - assert row_checker.get_public_views_where_row_is_visible(invisible_row) == [] + assert row_checker.get_filtered_views_where_row_is_visible(invisible_row) == [] another_public_grid_view = data_fixture.create_grid_view( user, table=table, public=True, order=1 @@ -2172,22 +2170,22 @@ def test_public_view_row_checker_runs_expected_queries_when_checking_rows( value="FilterValue", ) - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True, updated_field_ids=[filtered_field.id, unfiltered_field.id], ) specific_another_view = another_public_grid_view.view_ptr.specific - with django_assert_num_queries(2): + with django_assert_num_queries(1): # Now should run two queries, one per public view - assert row_checker.get_public_views_where_row_is_visible(visible_row) == [ + assert row_checker.get_filtered_views_where_row_is_visible(visible_row) == [ view_ptr_specific, specific_another_view, ] - with django_assert_num_queries(2): + with django_assert_num_queries(1): # Now should run two queries, one per public view - assert row_checker.get_public_views_where_row_is_visible(invisible_row) == [] + assert row_checker.get_filtered_views_where_row_is_visible(invisible_row) == [] @pytest.mark.django_db @@ -2423,14 +2421,15 @@ def test_get_public_rows_raises_with_form_view(data_fixture): @pytest.mark.django_db def test_get_rows_raises_with_form_view(data_fixture): - form_view = data_fixture.create_form_view(public=True) + user = data_fixture.create_user() + form_view = data_fixture.create_form_view(public=True, user=user) field = data_fixture.create_number_field(table=form_view.table) model = form_view.table.get_model() model.objects.create(**{f"field_{field.id}": 1}) with pytest.raises(ViewDoesNotSupportListingRows): - ViewHandler().get_queryset(form_view) + ViewHandler().get_queryset(user, form_view) @pytest.mark.django_db @@ -4403,13 +4402,13 @@ def test_get_queryset_apply_sorts(data_fixture): ) # Don't apply view sorting - rows = view_handler.get_queryset(grid_view, apply_sorts=False) + rows = view_handler.get_queryset(user, grid_view, apply_sorts=False) row_ids = [row.id for row in rows] assert row_ids == [row_1.id, row_2.id, row_3.id] # Apply view sorting - rows = view_handler.get_queryset(grid_view, apply_sorts=True) + rows = view_handler.get_queryset(user, grid_view, apply_sorts=True) row_ids = [row.id for row in rows] assert row_ids == [row_3.id, row_2.id, row_1.id] @@ -4442,14 +4441,14 @@ def test_can_duplicate_views_with_multiple_collaborator_has_filter(data_fixture) .created_rows ) - results = ViewHandler().get_queryset(grid) + results = ViewHandler().get_queryset(user_1, grid) assert len(results) == 1 assert list(getattr(results[0], field.db_column).values_list("id", flat=True)) == [ user_1.id ] new_grid = ViewHandler().duplicate_view(user_1, grid) - new_results = ViewHandler().get_queryset(new_grid) + new_results = ViewHandler().get_queryset(user_1, new_grid) assert len(new_results) == 1 assert list( getattr(new_results[0], field.db_column).values_list("id", flat=True) diff --git a/backend/tests/baserow/contrib/database/ws/public/test_public_ws_rows_signals.py b/backend/tests/baserow/contrib/database/ws/public/test_public_ws_rows_signals.py index 44010cb674..46ed8a1b80 100644 --- a/backend/tests/baserow/contrib/database/ws/public/test_public_ws_rows_signals.py +++ b/backend/tests/baserow/contrib/database/ws/public/test_public_ws_rows_signals.py @@ -8,7 +8,9 @@ from baserow.contrib.database.api.constants import PUBLIC_PLACEHOLDER_ENTITY_ID from baserow.contrib.database.rows.handler import RowHandler from baserow.contrib.database.trash.models import TrashedRows -from baserow.contrib.database.views.handler import PublicViewRows, ViewHandler +from baserow.contrib.database.views.handler import ViewHandler +from baserow.contrib.database.views.row_checker import FilteredViewRows +from baserow.contrib.database.ws.views.rows.handler import ViewRealtimeRowsHandler from baserow.core.trash.handler import TrashHandler @@ -747,10 +749,12 @@ def test_given_row_not_visible_in_public_view_when_updated_to_be_visible_event_s ) # Double check the row isn't visible in any views to begin with - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True ) - assert row_checker.get_public_views_where_row_is_visible(initially_hidden_row) == [] + assert ( + row_checker.get_filtered_views_where_row_is_visible(initially_hidden_row) == [] + ) RowHandler().update_row_by_id( user, @@ -841,11 +845,11 @@ def test_batch_update_rows_not_visible_in_public_view_to_be_visible_event_sent( ) # Double check the row isn't visible in any views to begin with - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True ) assert ( - row_checker.get_public_views_where_rows_are_visible( + row_checker.get_filtered_views_where_rows_are_visible( [initially_hidden_row, initially_hidden_row2] ) == [] @@ -955,11 +959,11 @@ def test_batch_update_rows_some_not_visible_in_public_view_to_be_visible_event_s ) # Double check the row isn't visible in any views to begin with - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True ) assert ( - row_checker.get_public_views_where_rows_are_visible([initially_hidden_row]) + row_checker.get_filtered_views_where_rows_are_visible([initially_hidden_row]) == [] ) @@ -1086,13 +1090,13 @@ def test_batch_update_rows_visible_in_public_view_to_some_not_be_visible_event_s ) # Double check the row isn't visible in any views to begin with - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True ) - assert row_checker.get_public_views_where_rows_are_visible( + assert row_checker.get_filtered_views_where_rows_are_visible( [initially_visible_row, initially_visible_row2] ) == [ - PublicViewRows( + FilteredViewRows( ViewHandler() .get_view_as_user( user, public_view_with_filters_initially_hiding_all_rows.id @@ -1215,12 +1219,12 @@ def test_given_row_visible_in_public_view_when_updated_to_be_not_visible_event_s ) # Double check the row is visible in the view to start with - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True ) - assert row_checker.get_public_views_where_row_is_visible(initially_visible_row) == [ - public_view_with_row_showing.view_ptr.specific - ] + assert row_checker.get_filtered_views_where_row_is_visible( + initially_visible_row + ) == [public_view_with_row_showing.view_ptr.specific] # Update the row so it is no longer visible RowHandler().update_row_by_id( @@ -1313,10 +1317,10 @@ def test_batch_update_rows_visible_in_public_view_to_be_not_visible_event_sent( ) # Double check the row is visible in any views to begin with - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True ) - public_views = row_checker.get_public_views_where_rows_are_visible( + public_views = row_checker.get_filtered_views_where_rows_are_visible( [initially_visible_row, initially_visible_row2] ) assert len(public_views) == 1 @@ -1422,12 +1426,12 @@ def test_given_row_visible_in_public_view_when_updated_to_still_be_visible_event ) # Double check the row is visible in the view to start with - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True ) - assert row_checker.get_public_views_where_row_is_visible(initially_visible_row) == [ - public_view_with_row_showing.view_ptr.specific - ] + assert row_checker.get_filtered_views_where_row_is_visible( + initially_visible_row + ) == [public_view_with_row_showing.view_ptr.specific] # Update the row so it is still visible but changed RowHandler().update_row_by_id( @@ -1526,10 +1530,10 @@ def test_batch_update_rows_visible_in_public_view_still_be_visible_event_sent( ) # Double check the rows are visible in the view to start with - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True ) - public_views = row_checker.get_public_views_where_rows_are_visible( + public_views = row_checker.get_filtered_views_where_rows_are_visible( [initially_visible_row, initially_visible_row2] ) assert len(public_views) == 1 @@ -1628,14 +1632,14 @@ def test_batch_update_subset_rows_visible_in_public_view_no_filters( ) # Double check the rows are visible in the view to start with - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True ) - public_views = row_checker.get_public_views_where_rows_are_visible( + public_views = row_checker.get_filtered_views_where_rows_are_visible( [initially_visible_row, initially_visible_row2] ) assert len(public_views) == 1 - assert public_views[0].allowed_row_ids == PublicViewRows.ALL_ROWS_ALLOWED + assert public_views[0].allowed_row_ids == FilteredViewRows.ALL_ROWS_ALLOWED assert public_views[0].view.id == public_view_with_row_showing.id # Update the row so that they are still visible but changed @@ -1995,10 +1999,10 @@ def test_given_row_visible_in_public_view_when_moved_row_updated_sent( ) # Double check the row is visible in the view to start with - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True ) - assert row_checker.get_public_views_where_row_is_visible(visible_moving_row) == [ + assert row_checker.get_filtered_views_where_row_is_visible(visible_moving_row) == [ public_view.view_ptr.specific ] @@ -2095,10 +2099,12 @@ def test_given_row_invisible_in_public_view_when_moved_no_update_sent( ) # Double check the row is visible in the view to start with - row_checker = ViewHandler().get_public_views_row_checker( + row_checker = ViewRealtimeRowsHandler().get_views_row_checker( table, model, only_include_views_which_want_realtime_events=True ) - assert row_checker.get_public_views_where_row_is_visible(invisible_moving_row) == [] + assert ( + row_checker.get_filtered_views_where_row_is_visible(invisible_moving_row) == [] + ) # Move the invisible row with transaction.atomic(): diff --git a/backend/tests/baserow/performance/test_public_sharing_performance.py b/backend/tests/baserow/performance/test_public_sharing_performance.py index 27837fc6e2..4e5c768986 100644 --- a/backend/tests/baserow/performance/test_public_sharing_performance.py +++ b/backend/tests/baserow/performance/test_public_sharing_performance.py @@ -151,7 +151,7 @@ def test_updating_many_rows_in_public_filtered_views( │ │ │ │ [18 frames hidden] rest_framework, django, copy, │ │ │ └─ 0.001 get_row_serializer_class baserow/contrib/database/api/rows/ │ │ │ └─ 0.001 get_response_serializer_field baserow/contrib/database/fi - │ │ └─ 0.002 get_public_views_where_row_is_visible baserow/contrib/database/ + │ │ └─ 0.002 get_filtered_views_where_row_is_visible baserow/contrib/databas │ │ └─ 0.002 _check_row_visible baserow/contrib/database/views/handler.py │ │ └─ 0.002 exists django/db/models/query.py:806 │ │ [19 frames hidden] django, copy diff --git a/changelog/entries/unreleased/bug/fix_bug_in_helm_chart_where_assistant_llm_always_set.json b/changelog/entries/unreleased/bug/fix_bug_in_helm_chart_where_assistant_llm_always_set.json new file mode 100644 index 0000000000..0b5ac2853a --- /dev/null +++ b/changelog/entries/unreleased/bug/fix_bug_in_helm_chart_where_assistant_llm_always_set.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fix bug in the Helm chart where the AI-assistant LLM model was always set.", + "issue_origin": "core", + "issue_number": null, + "domain": "builder", + "bullet_points": [], + "created_at": "2025-11-25" +} diff --git a/changelog/entries/unreleased/bug/improve_pendingsearchvalueupdate_performance.json b/changelog/entries/unreleased/bug/improve_pendingsearchvalueupdate_performance.json new file mode 100644 index 0000000000..12deec02a3 --- /dev/null +++ b/changelog/entries/unreleased/bug/improve_pendingsearchvalueupdate_performance.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Improve performance in the `database_pendingsearchvalueupdate` table with many entries.", + "issue_origin": "github", + "issue_number": null, + "domain": "database", + "bullet_points": [], + "created_at": "2025-12-02" +} diff --git a/changelog/entries/unreleased/refactor/improved_storage_usage_update_performance.json b/changelog/entries/unreleased/refactor/improved_storage_usage_update_performance.json new file mode 100644 index 0000000000..37404fc247 --- /dev/null +++ b/changelog/entries/unreleased/refactor/improved_storage_usage_update_performance.json @@ -0,0 +1,8 @@ +{ + "type": "refactor", + "message": "Improved storage usage performance.", + "domain": "database", + "issue_number": null, + "bullet_points": [], + "created_at": "2025-12-02" +} diff --git a/changelog/entries/unreleased/refactor/refactored_the_element_theme_override_form_so_that_it_works_.json b/changelog/entries/unreleased/refactor/refactored_the_element_theme_override_form_so_that_it_works_.json new file mode 100644 index 0000000000..cbe87340a9 --- /dev/null +++ b/changelog/entries/unreleased/refactor/refactored_the_element_theme_override_form_so_that_it_works_.json @@ -0,0 +1,9 @@ +{ + "type": "refactor", + "message": "Refactored the element theme override form so that it works better on smaller screens.", + "issue_origin": "github", + "issue_number": null, + "domain": "builder", + "bullet_points": [], + "created_at": "2025-12-02" +} \ No newline at end of file diff --git a/changelog/entries/unreleased/refactor/update_email_compiler_dependencies.json b/changelog/entries/unreleased/refactor/update_email_compiler_dependencies.json new file mode 100644 index 0000000000..bca0c51630 --- /dev/null +++ b/changelog/entries/unreleased/refactor/update_email_compiler_dependencies.json @@ -0,0 +1,9 @@ +{ + "type": "refactor", + "message": "Update email compiler dependencies", + "issue_origin": "github", + "issue_number": null, + "domain": "core", + "bullet_points": [], + "created_at": "2025-12-03" +} diff --git a/deploy/helm/baserow/values.yaml b/deploy/helm/baserow/values.yaml index 81df5ad01a..241ef84ce3 100644 --- a/deploy/helm/baserow/values.yaml +++ b/deploy/helm/baserow/values.yaml @@ -59,7 +59,7 @@ global: domain: cluster.local backendDomain: api.cluster.local objectsDomain: objects.cluster.local - assistantLLMModel: "groq/openai/gpt-oss-120b" + assistantLLMModel: "" securityContext: enabled: false diff --git a/enterprise/backend/src/baserow_enterprise/apps.py b/enterprise/backend/src/baserow_enterprise/apps.py index 82142e2751..f25db7bfb9 100755 --- a/enterprise/backend/src/baserow_enterprise/apps.py +++ b/enterprise/backend/src/baserow_enterprise/apps.py @@ -351,14 +351,30 @@ def ready(self): assistant_tool_registry.register(ListWorkflowsToolType()) assistant_tool_registry.register(WorkflowToolFactoryToolType()) + from baserow_enterprise.views.operations import ( + ListenToAllRestrictedViewEventsOperationType, + ) + + operation_type_registry.register(ListenToAllRestrictedViewEventsOperationType()) + from baserow.contrib.database.views.registries import ( view_ownership_type_registry, ) + from baserow.contrib.database.ws.views.rows.registries import ( + view_realtime_rows_registry, + ) from baserow.core.feature_flags import feature_flag_is_enabled + from baserow.ws.registries import page_registry from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType + from baserow_enterprise.ws.pages import RestrictedViewPageType + from baserow_enterprise.ws.restricted_view.rows.view_realtime_rows import ( + RestrictedViewRealtimeRowsType, + ) if feature_flag_is_enabled("view_permissions"): view_ownership_type_registry.register(RestrictedViewOwnershipType()) + page_registry.register(RestrictedViewPageType()) + view_realtime_rows_registry.register(RestrictedViewRealtimeRowsType()) # The signals must always be imported last because they use the registries # which need to be filled first. diff --git a/enterprise/backend/src/baserow_enterprise/date_dependency/tasks.py b/enterprise/backend/src/baserow_enterprise/date_dependency/tasks.py index 58a73c8946..93098dba04 100644 --- a/enterprise/backend/src/baserow_enterprise/date_dependency/tasks.py +++ b/enterprise/backend/src/baserow_enterprise/date_dependency/tasks.py @@ -147,10 +147,10 @@ def date_dependency_recalculate_rows(rule_id, table_id): before_values.append(old_row) after_values.append(new_row) cursor.execute(validation_query) - from baserow.contrib.database.ws.public.rows.signals import ( - public_before_rows_update, - ) from baserow.contrib.database.ws.rows.signals import serialize_rows_values + from baserow.contrib.database.ws.views.rows.signals import ( + views_before_rows_update, + ) before_return_values = { serialize_rows_values: serialize_rows_values( @@ -162,7 +162,7 @@ def date_dependency_recalculate_rows(rule_id, table_id): [rule.duration_field.id], serialize_only_updated_fields=True, ), - public_before_rows_update: public_before_rows_update( + views_before_rows_update: views_before_rows_update( None, before_values, None, diff --git a/enterprise/backend/src/baserow_enterprise/migrations/0056_role_hidden.py b/enterprise/backend/src/baserow_enterprise/migrations/0057_role_hidden.py similarity index 86% rename from enterprise/backend/src/baserow_enterprise/migrations/0056_role_hidden.py rename to enterprise/backend/src/baserow_enterprise/migrations/0057_role_hidden.py index 717e961745..b24088d0b6 100644 --- a/enterprise/backend/src/baserow_enterprise/migrations/0056_role_hidden.py +++ b/enterprise/backend/src/baserow_enterprise/migrations/0057_role_hidden.py @@ -5,7 +5,7 @@ class Migration(migrations.Migration): dependencies = [ - ("baserow_enterprise", "0055_assistantchatmessage_action_group_id_and_more"), + ("baserow_enterprise", "0056_alter_knowledgebasedocument_type"), ] operations = [ diff --git a/enterprise/backend/src/baserow_enterprise/role/default_roles.py b/enterprise/backend/src/baserow_enterprise/role/default_roles.py index 7b32d95c31..e0556f0b6c 100755 --- a/enterprise/backend/src/baserow_enterprise/role/default_roles.py +++ b/enterprise/backend/src/baserow_enterprise/role/default_roles.py @@ -161,12 +161,14 @@ CreateViewFilterOperationType, CreateViewGroupByOperationType, CreateViewOperationType, + CreateViewRowOperationType, CreateViewSortOperationType, DeleteViewDecorationOperationType, DeleteViewFilterGroupOperationType, DeleteViewFilterOperationType, DeleteViewGroupByOperationType, DeleteViewOperationType, + DeleteViewRowOperationType, DeleteViewSortOperationType, DuplicateViewOperationType, ListAggregationsViewOperationType, @@ -183,6 +185,7 @@ ReadViewFilterOperationType, ReadViewGroupByOperationType, ReadViewOperationType, + ReadViewRowOperationType, ReadViewsOrderOperationType, ReadViewSortOperationType, RestoreViewOperationType, @@ -193,6 +196,7 @@ UpdateViewGroupByOperationType, UpdateViewOperationType, UpdateViewPublicOperationType, + UpdateViewRowOperationType, UpdateViewSlugOperationType, UpdateViewSortOperationType, ) @@ -302,6 +306,9 @@ RestoreTeamOperationType, UpdateTeamOperationType, ) +from baserow_enterprise.views.operations import ( + ListenToAllRestrictedViewEventsOperationType, +) default_roles = { ADMIN_ROLE_UID: [], @@ -345,7 +352,6 @@ ReadApplicationOperationType, ReadDatabaseTableOperationType, ListRowsDatabaseTableOperationType, - ReadDatabaseRowOperationType, ReadViewOperationType, ReadFieldOperationType, ListViewSortOperationType, @@ -360,7 +366,6 @@ ReadAdjacentRowDatabaseRowOperationType, ListRowNamesDatabaseTableOperationType, ReadViewFilterOperationType, - ListenToAllDatabaseTableEventsOperationType, ReadViewsOrderOperationType, ReadViewSortOperationType, ListViewGroupByOperationType, @@ -377,6 +382,8 @@ default_roles[VIEWER_ROLE_UID].extend( default_roles[READ_ONLY_ROLE_UID] + [ + ListenToAllRestrictedViewEventsOperationType, + ListenToAllDatabaseTableEventsOperationType, ReadMCPEndpointOperationType, CreateMCPEndpointOperationType, UpdateMCPEndpointOperationType, @@ -385,6 +392,8 @@ ReadFieldRuleOperationType, ExportTableOperationType, DispatchDashboardDataSourceOperationType, + ReadDatabaseRowOperationType, + ReadViewRowOperationType, ] ) default_roles[COMMENTER_ROLE_UID].extend( @@ -411,6 +420,9 @@ ListTeamSubjectsOperationType, ReadTeamSubjectOperationType, CanReceiveNotificationOnSubmitFormViewOperationType, + CreateViewRowOperationType, + UpdateViewRowOperationType, + DeleteViewRowOperationType, ] ) default_roles[BUILDER_ROLE_UID].extend( diff --git a/enterprise/backend/src/baserow_enterprise/view_ownership_types.py b/enterprise/backend/src/baserow_enterprise/view_ownership_types.py index 577910ca58..b681de7325 100644 --- a/enterprise/backend/src/baserow_enterprise/view_ownership_types.py +++ b/enterprise/backend/src/baserow_enterprise/view_ownership_types.py @@ -2,10 +2,14 @@ from baserow_premium.license.handler import LicenseHandler +from baserow.contrib.database.views.handler import ViewHandler from baserow.contrib.database.views.models import View +from baserow.contrib.database.views.operations import CreateViewFilterOperationType from baserow.contrib.database.views.registries import ViewOwnershipType from baserow.core.exceptions import PermissionDenied +from baserow.core.handler import CoreHandler from baserow.core.models import Workspace +from baserow.core.types import PermissionCheck from baserow_enterprise.features import RBAC @@ -26,3 +30,62 @@ def change_ownership_type(self, user: AbstractUser, view: View) -> View: def view_created(self, user: AbstractUser, view: "View", workspace: Workspace): LicenseHandler.raise_if_user_doesnt_have_feature(RBAC, user, workspace) + + def enforce_apply_filters(self, user, view): + # If the user does not have permissions to create filters in the view, then it + # means that the user has the editor role or lower. In that case, the user might + # not have access to the full table, so the view filters are enforced. The user + # can't change the view filters and can't see them, so they will only have + # access to the filtered data. This allows giving the user only access to the + # desired rows. + return user is not None and not CoreHandler().check_permissions( + user, + CreateViewFilterOperationType.type, + workspace=view.table.database.workspace, + context=view, + raise_permission_exceptions=False, + ) + + def prepare_views_for_user(self, user, views): + if len(views) == 0 or user is None: + return views + + permission_checks = {} + for view in views: + permission_checks[view.id] = PermissionCheck( + user, + CreateViewFilterOperationType.type, + context=view, + ) + + check_results = CoreHandler().check_multiple_permissions( + permission_checks.values(), workspace=views[0].table.database.workspace + ) + + for view in views: + check_result = check_results[permission_checks[view.id]] + # If the user does not have create view filter permissions for the provided + # view, then the filters are omitted because the they're forcefully applied + # so that the user can only see the rows that match the filter. + if not check_result: + if not hasattr(view, "_prefetched_objects_cache"): + view._prefetched_objects_cache = {} + view._prefetched_objects_cache["viewfilter_set"] = [] + view._prefetched_objects_cache["filter_groups"] = [] + + return views + + def can_modify_rows(self, view, row_ids=None): + if not row_ids: + return True + + # Check if all the provided row_ids actually exist in the filtered queryset. + # We don't want to allow modifying rows that are outside the filters because + # that is not where the user has access to. + model = view.table.get_model() + filter_qs = ViewHandler().apply_filters(view, model.objects) + rows_in_view = filter_qs.filter(id__in=row_ids).values("id") + rows_outside_view = model.objects.filter(id__in=row_ids).exclude( + id__in=rows_in_view + ) + return not rows_outside_view.exists() diff --git a/enterprise/backend/src/baserow_enterprise/views/__init__.py b/enterprise/backend/src/baserow_enterprise/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/enterprise/backend/src/baserow_enterprise/views/operations.py b/enterprise/backend/src/baserow_enterprise/views/operations.py new file mode 100644 index 0000000000..f5c8b4f4c7 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/views/operations.py @@ -0,0 +1,5 @@ +from baserow.contrib.database.views.operations import ViewOperationType + + +class ListenToAllRestrictedViewEventsOperationType(ViewOperationType): + type = "database.table.view.listen_to_all_restricted_view" diff --git a/enterprise/backend/src/baserow_enterprise/ws/pages.py b/enterprise/backend/src/baserow_enterprise/ws/pages.py new file mode 100644 index 0000000000..82fcb79a61 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/ws/pages.py @@ -0,0 +1,51 @@ +from baserow.contrib.database.views.exceptions import ViewDoesNotExist +from baserow.contrib.database.views.handler import ViewHandler +from baserow.core.exceptions import PermissionDenied, UserNotInWorkspace +from baserow.core.handler import CoreHandler +from baserow.ws.registries import PageType +from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType +from baserow_enterprise.views.operations import ( + ListenToAllRestrictedViewEventsOperationType, +) + + +class RestrictedViewPageType(PageType): + """ + This page is specifically made for the restricted view ownership type. When the + user opens the restricted view, and they don't have permissions to listen for all + the table events, then they will use this page to receive real-time events. + + If a row is updated in the table, then it only broadcasts the updates if it + matches the filter to make sure the user only receives data that it's supposed to + see in the view. + """ + + type = "restricted_view" + parameters = ["restricted_view_id"] + + def can_add(self, user, web_socket_id, restricted_view_id, **kwargs): + try: + handler = ViewHandler() + view = handler.get_view(restricted_view_id) + + if view.ownership_type != RestrictedViewOwnershipType.type: + return False + + # Check if the user has any permissions to access the view. If so, + # we'll allow the user to listen for events. + CoreHandler().check_permissions( + user, + ListenToAllRestrictedViewEventsOperationType.type, + workspace=view.table.database.workspace, + context=view, + ) + except (UserNotInWorkspace, ViewDoesNotExist, PermissionDenied): + return False + + return True + + def get_group_name(self, restricted_view_id, **kwargs): + return f"restricted-view-{restricted_view_id}" + + def get_permission_channel_group_name(self, restricted_view_id, **kwargs): + return f"permissions-restricted-view-{restricted_view_id}" diff --git a/enterprise/backend/src/baserow_enterprise/ws/restricted_view/__init__.py b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/enterprise/backend/src/baserow_enterprise/ws/restricted_view/fields/__init__.py b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/fields/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/enterprise/backend/src/baserow_enterprise/ws/restricted_view/fields/signals.py b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/fields/signals.py new file mode 100644 index 0000000000..d03c914d6b --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/fields/signals.py @@ -0,0 +1,89 @@ +from typing import Any, Dict + +from django.contrib.auth.models import AbstractUser +from django.db import transaction +from django.dispatch import receiver + +from baserow.contrib.database.fields import signals as field_signals +from baserow.contrib.database.views.models import View +from baserow.contrib.database.ws.fields.signals import RealtimeFieldMessages +from baserow.ws.registries import page_registry +from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType + + +def _broadcast_payload_to_all_restricted_views( + user: AbstractUser, + table_id: int, + payload: Dict[str, Any], +): + views = View.objects.filter( + table_id=table_id, + ownership_type=RestrictedViewOwnershipType.type, + ).values_list("id", flat=True) + + view_page_type = page_registry.get("restricted_view") + for view_id in views: + view_page_type.broadcast( + payload, + getattr(user, "web_socket_id", None), + restricted_view_id=view_id, + ) + + +@receiver(field_signals.field_created) +def field_created(sender, field, related_fields, user, **kwargs): + transaction.on_commit( + lambda: _broadcast_payload_to_all_restricted_views( + user, + field.table_id, + RealtimeFieldMessages.field_created( + field, + related_fields, + ), + ) + ) + + +@receiver(field_signals.field_restored) +def field_restored(sender, field, related_fields, user, **kwargs): + transaction.on_commit( + lambda: _broadcast_payload_to_all_restricted_views( + user, + field.table_id, + RealtimeFieldMessages.field_restored( + field, + related_fields, + ), + ) + ) + + +@receiver(field_signals.field_updated) +def field_updated(sender, field, related_fields, user, **kwargs): + transaction.on_commit( + lambda: _broadcast_payload_to_all_restricted_views( + user, + field.table_id, + RealtimeFieldMessages.field_updated( + field, + related_fields, + ), + ) + ) + + +@receiver(field_signals.field_deleted) +def field_deleted( + sender, field_id, field, related_fields, user, before_return, **kwargs +): + transaction.on_commit( + lambda: _broadcast_payload_to_all_restricted_views( + user, + field.table_id, + RealtimeFieldMessages.field_deleted( + field.table_id, + field_id, + related_fields, + ), + ) + ) diff --git a/enterprise/backend/src/baserow_enterprise/ws/restricted_view/rows/__init__.py b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/rows/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/enterprise/backend/src/baserow_enterprise/ws/restricted_view/rows/view_realtime_rows.py b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/rows/view_realtime_rows.py new file mode 100644 index 0000000000..5acf7125c1 --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/rows/view_realtime_rows.py @@ -0,0 +1,19 @@ +from django.db.models import Q + +from baserow.contrib.database.ws.views.rows.registries import ViewRealtimeRowsType +from baserow.ws.registries import page_registry +from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType + + +class RestrictedViewRealtimeRowsType(ViewRealtimeRowsType): + type = "restricted_view" + + def get_views_filter(self) -> Q: + return Q(ownership_type=RestrictedViewOwnershipType.type) + + def broadcast(self, view, payload): + view_page_type = page_registry.get("restricted_view") + view_page_type.broadcast( + payload, + restricted_view_id=view.id, + ) diff --git a/enterprise/backend/src/baserow_enterprise/ws/restricted_view/views/__init__.py b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/enterprise/backend/src/baserow_enterprise/ws/restricted_view/views/signals.py b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/views/signals.py new file mode 100644 index 0000000000..a6e1b1bdbe --- /dev/null +++ b/enterprise/backend/src/baserow_enterprise/ws/restricted_view/views/signals.py @@ -0,0 +1,41 @@ +from django.db import transaction +from django.dispatch import receiver + +from baserow.contrib.database.views import signals as view_signals +from baserow.contrib.database.views.registries import view_type_registry +from baserow.ws.registries import page_registry +from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType + + +def _send_force_rows_refresh_if_view_restricted(view): + view_page_type = page_registry.get("restricted_view") + view_type = view_type_registry.get_by_model(view.specific_class) + if ( + view.ownership_type == RestrictedViewOwnershipType.type + and + # This will make sure that the form view is excluded because there is no need + # for real-time updates of a row in the form view. + view_type.can_filter + ): + transaction.on_commit( + lambda: view_page_type.broadcast( + {"type": "force_view_rows_refresh", "view_id": view.id}, + None, + restricted_view_id=view.id, + ) + ) + + +@receiver(view_signals.view_filter_created) +def restricted_view_filter_created(sender, view_filter, user, **kwargs): + _send_force_rows_refresh_if_view_restricted(view_filter.view) + + +@receiver(view_signals.view_filter_updated) +def restricted_view_filter_updated(sender, view_filter, user, **kwargs): + _send_force_rows_refresh_if_view_restricted(view_filter.view) + + +@receiver(view_signals.view_filter_deleted) +def restricted_view_filter_deleted(sender, view_filter_id, view_filter, user, **kwargs): + _send_force_rows_refresh_if_view_restricted(view_filter.view) diff --git a/enterprise/backend/src/baserow_enterprise/ws/signals.py b/enterprise/backend/src/baserow_enterprise/ws/signals.py index a9a2c5b3bb..da227488b0 100644 --- a/enterprise/backend/src/baserow_enterprise/ws/signals.py +++ b/enterprise/backend/src/baserow_enterprise/ws/signals.py @@ -1 +1,21 @@ -__all__ = [] +from .restricted_view.fields.signals import ( + field_created, + field_deleted, + field_restored, + field_updated, +) +from .restricted_view.views.signals import ( + restricted_view_filter_created, + restricted_view_filter_deleted, + restricted_view_filter_updated, +) + +__all__ = [ + "restricted_view_filter_created", + "restricted_view_filter_updated", + "restricted_view_filter_deleted", + "field_created", + "field_restored", + "field_updated", + "field_deleted", +] diff --git a/enterprise/backend/tests/baserow_enterprise_tests/api/views/test_enterprise_view_views.py b/enterprise/backend/tests/baserow_enterprise_tests/api/views/test_enterprise_view_views.py index 421f587270..080f10caaa 100644 --- a/enterprise/backend/tests/baserow_enterprise_tests/api/views/test_enterprise_view_views.py +++ b/enterprise/backend/tests/baserow_enterprise_tests/api/views/test_enterprise_view_views.py @@ -4,12 +4,16 @@ import pytest from rest_framework.status import ( HTTP_200_OK, + HTTP_204_NO_CONTENT, HTTP_401_UNAUTHORIZED, HTTP_402_PAYMENT_REQUIRED, ) +from baserow.contrib.database.views.models import View from baserow.core.subjects import UserSubjectType +from baserow_enterprise.role.handler import RoleAssignmentHandler from baserow_enterprise.role.models import Role +from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType @pytest.mark.django_db @@ -136,3 +140,877 @@ def test_cannot_create_view_if_user_has_only_permissions_to_view( HTTP_AUTHORIZATION=f"JWT {token2}", ) assert response.status_code == HTTP_401_UNAUTHORIZED + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_get_row_with_only_view_permissions(api_client, enterprise_data_fixture): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + normal_view = enterprise_data_fixture.create_grid_view(table=table) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + + # Create a row to fetch + model = table.get_model() + row = model.objects.create(**{f"field_{text_field.id}": "Visible value"}) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + workspace = table.database.workspace + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=restricted_view.id), + ) + # This normally never happens, but for testing purposes, we want to make sure that + # if a user has access to a non-restricted view, they still cannot get a row + # via that view when they don't have table permissions. + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=normal_view.id), + ) + + base_url = reverse( + "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row.id} + ) + + # Expect permission denied when trying to get a row without view parameter + response = api_client.get( + base_url, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + # Expect permission denied when trying to get a row via a non-restricted view + response = api_client.get( + base_url + f"?view={normal_view.id}", + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + # Should succeed when using view parameter with restricted view + response = api_client.get( + base_url + f"?view={restricted_view.id}", + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_200_OK + response_json = response.json() + assert response_json["id"] == row.id + assert response_json[f"field_{text_field.id}"] == "Visible value" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_cannot_get_row_outside_of_restricted_view(api_client, enterprise_data_fixture): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + enterprise_data_fixture.create_grid_view(table=table) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + enterprise_data_fixture.create_view_filter( + view=restricted_view, field=text_field, type="equal", value="ABC" + ) + + # Create rows: one visible in the restricted view, one not + model = table.get_model() + row_visible = model.objects.create(**{f"field_{text_field.id}": "ABC"}) + row_hidden = model.objects.create(**{f"field_{text_field.id}": "DEF"}) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=restricted_view.id), + ) + + # Should succeed when getting a row that is visible in the restricted view + url_visible = reverse( + "api:database:rows:item", + kwargs={"table_id": table.id, "row_id": row_visible.id}, + ) + response = api_client.get( + url_visible + f"?view={restricted_view.id}", + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_200_OK + + # Should fail when trying to get a row that is not visible in the restricted view + url_hidden = reverse( + "api:database:rows:item", + kwargs={"table_id": table.id, "row_id": row_hidden.id}, + ) + response = api_client.get( + url_hidden + f"?view={restricted_view.id}", + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_row_with_only_view_permissions(api_client, enterprise_data_fixture): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + normal_view = enterprise_data_fixture.create_grid_view(table=table) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + workspace = table.database.workspace + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=restricted_view.id), + ) + # This normally never happens, but for testing purposes, we want to make sure that + # if a user has access to a view, that they cannot create a row because it's not a + # restricted view. + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=normal_view.id), + ) + + url = reverse("api:database:rows:list", kwargs={"table_id": table.id}) + + # Expect permission denied when trying to create a row in the table because the + # user does not have access to the table. + response = api_client.post( + url, + {f"field_{text_field.id}": "Test 1"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + # Expect permission denied when trying to create a row in the table because this + # view ownership type does not allow a user to create a row. + response = api_client.post( + url + f"?view={normal_view.id}", + {f"field_{text_field.id}": "Test 1"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + # Should come through because the user has access to the view. + response = api_client.post( + url + f"?view={restricted_view.id}", + {f"field_{text_field.id}": "Test 1"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_200_OK + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_create_rows_with_only_view_permissions(api_client, enterprise_data_fixture): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + normal_view = enterprise_data_fixture.create_grid_view(table=table) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + workspace = table.database.workspace + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=restricted_view.id), + ) + + url = reverse("api:database:rows:batch", kwargs={"table_id": table.id}) + + # Expect permission denied when trying to batch create rows without view parameter + response = api_client.post( + url, + { + "items": [ + {f"field_{text_field.id}": "Test 1"}, + {f"field_{text_field.id}": "Test 2"}, + ] + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + response = api_client.post( + url + f"?view={normal_view.id}", + { + "items": [ + {f"field_{text_field.id}": "Test 1"}, + {f"field_{text_field.id}": "Test 2"}, + ] + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + # Should succeed when using view parameter with restricted view + response = api_client.post( + url + f"?view={restricted_view.id}", + { + "items": [ + {f"field_{text_field.id}": "Test 1"}, + {f"field_{text_field.id}": "Test 2"}, + ] + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_200_OK + assert len(response.json()["items"]) == 2 + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_update_row_with_only_view_permissions(api_client, enterprise_data_fixture): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + normal_view = enterprise_data_fixture.create_grid_view(table=table) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + + # Create a row to update + model = table.get_model() + row = model.objects.create(**{f"field_{text_field.id}": "Original Value"}) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=restricted_view.id), + ) + + # Expect permission denied when trying to update row without view parameter + response = api_client.patch( + reverse( + "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row.id} + ), + {f"field_{text_field.id}": "Updated Value"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + response = api_client.patch( + reverse( + "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row.id} + ) + + f"?view={normal_view.id}", + {f"field_{text_field.id}": "Updated Value"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + # Should succeed when using view parameter with restricted view + response = api_client.patch( + reverse( + "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row.id} + ) + + f"?view={restricted_view.id}", + {f"field_{text_field.id}": "Updated Value"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_200_OK + assert response.json()[f"field_{text_field.id}"] == "Updated Value" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_cannot_update_row_outside_of_restricted_view( + api_client, enterprise_data_fixture +): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + enterprise_data_fixture.create_grid_view(table=table) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + enterprise_data_fixture.create_view_filter( + view=restricted_view, field=text_field, type="equal", value="ABC" + ) + + # Create a row to update + model = table.get_model() + # This row is visible in the view. + row1 = model.objects.create(**{f"field_{text_field.id}": "ABC"}) + # This row is not visible in the view because it does not match the filters. + row2 = model.objects.create(**{f"field_{text_field.id}": "DEF"}) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=restricted_view.id), + ) + + # Should succeed when using view parameter with restricted view + response = api_client.patch( + reverse( + "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row1.id} + ) + + f"?view={restricted_view.id}", + {f"field_{text_field.id}": "Updated Value"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_200_OK + + response = api_client.patch( + reverse( + "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row2.id} + ) + + f"?view={restricted_view.id}", + {f"field_{text_field.id}": "Updated Value"}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_update_rows_with_only_view_permissions(api_client, enterprise_data_fixture): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + normal_view = enterprise_data_fixture.create_grid_view(table=table) + + # Create rows to update + model = table.get_model() + row1 = model.objects.create(**{f"field_{text_field.id}": "Original 1"}) + row2 = model.objects.create(**{f"field_{text_field.id}": "Original 2"}) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=restricted_view.id), + ) + + url = reverse("api:database:rows:batch", kwargs={"table_id": table.id}) + + # Expect permission denied when trying to batch update rows without view parameter + response = api_client.patch( + url, + { + "items": [ + {"id": row1.id, f"field_{text_field.id}": "Updated 1"}, + {"id": row2.id, f"field_{text_field.id}": "Updated 2"}, + ] + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + response = api_client.patch( + url + f"?view={normal_view.id}", + { + "items": [ + {"id": row1.id, f"field_{text_field.id}": "Updated 1"}, + {"id": row2.id, f"field_{text_field.id}": "Updated 2"}, + ] + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + # Should succeed when using view parameter with restricted view + response = api_client.patch( + url + f"?view={restricted_view.id}", + { + "items": [ + {"id": row1.id, f"field_{text_field.id}": "Updated 1"}, + {"id": row2.id, f"field_{text_field.id}": "Updated 2"}, + ] + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_200_OK + assert len(response.json()["items"]) == 2 + assert response.json()["items"][0][f"field_{text_field.id}"] == "Updated 1" + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_cannot_update_rows_outside_of_restricted_view_filters( + api_client, enterprise_data_fixture +): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + enterprise_data_fixture.create_view_filter( + view=restricted_view, field=text_field, type="equal", value="ABC" + ) + + # Create rows to update + model = table.get_model() + # This row is visible in the view. + row1 = model.objects.create(**{f"field_{text_field.id}": "ABC"}) + # This row is not visible in the view because it does not match the filters. + row2 = model.objects.create(**{f"field_{text_field.id}": "DEF"}) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=restricted_view.id), + ) + + url = reverse("api:database:rows:batch", kwargs={"table_id": table.id}) + + response = api_client.patch( + url + f"?view={restricted_view.id}", + { + "items": [ + {"id": row1.id, f"field_{text_field.id}": "Updated 1"}, + ] + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_200_OK + + response = api_client.patch( + url + f"?view={restricted_view.id}", + { + "items": [ + {"id": row2.id, f"field_{text_field.id}": "Updated 2"}, + ] + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_delete_row_with_only_view_permissions(api_client, enterprise_data_fixture): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + normal_view = enterprise_data_fixture.create_grid_view(table=table) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + + # Create a row to delete + model = table.get_model() + row = model.objects.create(**{f"field_{text_field.id}": "Delete Me"}) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=restricted_view.id), + ) + + # Expect permission denied when trying to delete row without view parameter + response = api_client.delete( + reverse( + "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row.id} + ), + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + response = api_client.delete( + reverse( + "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row.id} + ) + + f"?view={normal_view.id}", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + # Should succeed when using view parameter with restricted view + response = api_client.delete( + reverse( + "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row.id} + ) + + f"?view={restricted_view.id}", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_204_NO_CONTENT + + # Verify row was soft deleted (trashed) + row.refresh_from_db() + assert getattr(row, "trashed") is True + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_cannot_delete_row_outside_of_restricted_view_filters( + api_client, enterprise_data_fixture +): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + enterprise_data_fixture.create_view_filter( + view=restricted_view, field=text_field, type="equal", value="ABC" + ) + + # Create a row to delete + model = table.get_model() + # This row is visible in the view. + row1 = model.objects.create(**{f"field_{text_field.id}": "ABC"}) + # This row is not visible in the view because it does not match the filters. + row2 = model.objects.create(**{f"field_{text_field.id}": "DEF"}) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=restricted_view.id), + ) + + response = api_client.delete( + reverse( + "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row1.id} + ) + + f"?view={restricted_view.id}", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_204_NO_CONTENT + + # Should succeed when using view parameter with restricted view + response = api_client.delete( + reverse( + "api:database:rows:item", kwargs={"table_id": table.id, "row_id": row2.id} + ) + + f"?view={restricted_view.id}", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_delete_rows_with_only_view_permissions(api_client, enterprise_data_fixture): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + normal_view = enterprise_data_fixture.create_grid_view(table=table) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + + # Create rows to delete + model = table.get_model() + row1 = model.objects.create(**{f"field_{text_field.id}": "Delete 1"}) + row2 = model.objects.create(**{f"field_{text_field.id}": "Delete 2"}) + row3 = model.objects.create(**{f"field_{text_field.id}": "Keep 3"}) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=restricted_view.id), + ) + + url = reverse("api:database:rows:batch-delete", kwargs={"table_id": table.id}) + + # Expect permission denied when trying to batch delete rows without view parameter + response = api_client.post( + url, + {"items": [row1.id, row2.id]}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + response = api_client.post( + url + f"?view={normal_view.id}", + {"items": [row1.id, row2.id]}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + assert response.json()["error"] == "PERMISSION_DENIED" + + # Should succeed when using view parameter with restricted view + response = api_client.post( + url + f"?view={restricted_view.id}", + {"items": [row1.id, row2.id]}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_204_NO_CONTENT + + # Verify rows were soft deleted (trashed) + row1.refresh_from_db() + row2.refresh_from_db() + row3.refresh_from_db() + assert getattr(row1, "trashed") is True + assert getattr(row2, "trashed") is True + assert getattr(row3, "trashed") is False + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_cannot_delete_rows_outside_of_restricted_view_filters( + api_client, enterprise_data_fixture +): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + enterprise_data_fixture.create_grid_view(table=table) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + enterprise_data_fixture.create_view_filter( + view=restricted_view, field=text_field, type="equal", value="ABC" + ) + + # Create rows to delete + model = table.get_model() + # This row is visible in the view. + row1 = model.objects.create(**{f"field_{text_field.id}": "ABC"}) + # This row is not visible in the view because it does not match the filters. + row2 = model.objects.create(**{f"field_{text_field.id}": "DEF"}) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=restricted_view.id), + ) + + url = reverse("api:database:rows:batch-delete", kwargs={"table_id": table.id}) + + response = api_client.post( + url + f"?view={restricted_view.id}", + {"items": [row1.id, row2.id]}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED + + response = api_client.post( + url + f"?view={restricted_view.id}", + {"items": [row1.id]}, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_204_NO_CONTENT + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_cannot_update_rows_in_table_using_unrelated_view( + api_client, enterprise_data_fixture +): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + table2 = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table2, ownership_type=RestrictedViewOwnershipType.type + ) + + model = table.get_model() + row1 = model.objects.create(**{f"field_{text_field.id}": "ABC"}) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=restricted_view.id), + ) + + url = reverse("api:database:rows:batch", kwargs={"table_id": table.id}) + + # The user does have access to the view, but the view does not belong to the + # table, so it should result in an unauthorized error. + response = api_client.patch( + url + f"?view={restricted_view.id}", + { + "items": [ + {"id": row1.id, f"field_{text_field.id}": "Updated 1"}, + ] + }, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_401_UNAUTHORIZED diff --git a/enterprise/backend/tests/baserow_enterprise_tests/views/test_restricted_view.py b/enterprise/backend/tests/baserow_enterprise_tests/views/test_restricted_view.py new file mode 100644 index 0000000000..5759d4f9c1 --- /dev/null +++ b/enterprise/backend/tests/baserow_enterprise_tests/views/test_restricted_view.py @@ -0,0 +1,429 @@ +from datetime import datetime +from unittest.mock import ANY, call, patch + +from django.test.utils import override_settings +from django.urls import reverse + +import pytest +from baserow_premium.views.view_types import ( + CalendarViewType, + KanbanViewType, + TimelineViewType, +) +from starlette.status import HTTP_200_OK + +from baserow.contrib.database.api.constants import PUBLIC_PLACEHOLDER_ENTITY_ID +from baserow.contrib.database.fields.models import DateField +from baserow.contrib.database.rows.handler import RowHandler +from baserow.contrib.database.views.models import View +from baserow.contrib.database.views.registries import view_type_registry +from baserow.contrib.database.views.view_ownership_types import ( + CollaborativeViewOwnershipType, +) +from baserow.contrib.database.views.view_types import GalleryViewType, GridViewType +from baserow.contrib.database.ws.views.rows.handler import ViewRealtimeRowsHandler +from baserow.core.utils import get_value_at_path +from baserow_enterprise.role.handler import RoleAssignmentHandler +from baserow_enterprise.role.models import Role +from baserow_enterprise.view_ownership_types import RestrictedViewOwnershipType + + +@pytest.mark.django_db +def test_get_public_views_which_include_row( + enterprise_data_fixture, django_assert_num_queries +): + """ + One test to check if the restricted view is included in the + `get_filtered_views_where_row_is_visible` is enough because we already have + plenty of tests related to the public view, which reuses the same code, in + `tests/baserow/contrib/database/view/test_view_handler.py` + """ + + user = enterprise_data_fixture.create_user() + table = enterprise_data_fixture.create_database_table(user=user) + visible_field = enterprise_data_fixture.create_text_field(table=table) + restricted_view = enterprise_data_fixture.create_grid_view( + user, table=table, order=0, ownership_type=RestrictedViewOwnershipType.type + ) + enterprise_data_fixture.create_grid_view( + user, + table=table, + order=0, + ) + # Should not appear in any results + enterprise_data_fixture.create_form_view( + user, table=table, ownership_type=RestrictedViewOwnershipType.type + ) + enterprise_data_fixture.create_grid_view(user, table=table) + + # Public View 1 has filters which match row 1 + enterprise_data_fixture.create_view_filter( + view=restricted_view, field=visible_field, type="equal", value="Visible" + ) + + row = RowHandler().create_row( + user=user, + table=table, + values={ + f"field_{visible_field.id}": "Visible", + }, + ) + row2 = RowHandler().create_row( + user=user, + table=table, + values={ + f"field_{visible_field.id}": "Not Visible", + }, + ) + + model = table.get_model() + checker = ViewRealtimeRowsHandler().get_views_row_checker( + table, model, only_include_views_which_want_realtime_events=True + ) + assert checker.get_filtered_views_where_row_is_visible(row) == [ + restricted_view, + ] + assert checker.get_filtered_views_where_row_is_visible(row2) == [] + + +@pytest.mark.django_db(transaction=True) +@patch("baserow.ws.registries.broadcast_to_channel_group") +def test_when_row_created_restricted_views_receive_restricted_row_ws_event( + mock_broadcast_to_channel_group, + enterprise_data_fixture, +): + """ + One test to check if correct payload is broadcasted is enough because we already + have plenty of tests related to the public view, which reuses the same code, in + `tests/baserow/contrib/database/ws/public/test_public_ws_rows_signals.py` + """ + + user = enterprise_data_fixture.create_user() + table = enterprise_data_fixture.create_database_table(user=user) + visible_field = enterprise_data_fixture.create_text_field(table=table) + # Only restricted event should be sent to this view. + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, + ownership_type=RestrictedViewOwnershipType.type, + public=False, + ) + # Both public and restricted event should be sent to this view. + public_and_restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type, public=True + ) + # No event should be sent to this view. + enterprise_data_fixture.create_form_view( + table=table, + ownership_type=RestrictedViewOwnershipType.type, + public=True, + ) + enterprise_data_fixture.create_form_view( + table=table, + ownership_type=CollaborativeViewOwnershipType.type, + public=False, + ) + + row = RowHandler().create_row( + user=user, + table=table, + values={ + f"field_{visible_field.id}": "Visible", + }, + ) + + assert mock_broadcast_to_channel_group.delay.mock_calls == ( + [ + call(f"table-{table.id}", ANY, ANY, None), + call( + f"restricted-view-{restricted_view.id}", + { + "type": "rows_created", + "table_id": table.id, + "rows": [ + { + "id": row.id, + "order": "1.00000000000000000000", + f"field_{visible_field.id}": "Visible", + } + ], + "metadata": {}, + "before_row_id": None, + }, + None, + None, + ), + call( + f"view-{public_and_restricted_view.slug}", + { + "type": "rows_created", + "table_id": PUBLIC_PLACEHOLDER_ENTITY_ID, + "rows": [ + { + "id": row.id, + "order": "1.00000000000000000000", + f"field_{visible_field.id}": "Visible", + } + ], + "metadata": {}, + "before_row_id": None, + }, + None, + None, + ), + call( + f"restricted-view-{public_and_restricted_view.id}", + { + "type": "rows_created", + "table_id": table.id, + "rows": [ + { + "id": row.id, + "order": "1.00000000000000000000", + f"field_{visible_field.id}": "Visible", + } + ], + "metadata": {}, + "before_row_id": None, + }, + None, + None, + ), + ] + ) + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_filters_are_visible_for_builders_and_up(enterprise_data_fixture, api_client): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + table = enterprise_data_fixture.create_database_table(user=user) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + enterprise_data_fixture.create_view_filter( + view=restricted_view, type="equal", field=text_field + ) + enterprise_data_fixture.create_view_filter_group(view=restricted_view) + + response = api_client.get( + reverse("api:database:views:list", kwargs={"table_id": table.id}) + + "?include=filters", + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + assert response.status_code == HTTP_200_OK + response_json = response.json() + assert len(response_json) + assert len(response_json[0]["filters"]) == 1 + assert len(response_json[0]["filter_groups"]) == 1 + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_filters_are_invisible_for_editors_and_down( + enterprise_data_fixture, api_client +): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + restricted_view = enterprise_data_fixture.create_grid_view( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + enterprise_data_fixture.create_view_filter( + view=restricted_view, type="equal", field=text_field + ) + enterprise_data_fixture.create_view_filter_group(view=restricted_view) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + workspace = table.database.workspace + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=restricted_view.id), + ) + + response = api_client.get( + reverse("api:database:views:list", kwargs={"table_id": table.id}) + + "?include=filters", + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + assert response.status_code == HTTP_200_OK + response_json = response.json() + assert len(response_json) + assert len(response_json[0]["filters"]) == 0 + assert len(response_json[0]["filter_groups"]) == 0 + + +view_type_url_mapping = { + GridViewType.type: ("api:database:views:grid:list", "create_grid_view", "results"), + GalleryViewType.type: ( + "api:database:views:gallery:list", + "create_gallery_view", + "results", + ), + KanbanViewType.type: ( + "api:database:views:kanban:list", + "create_kanban_view", + "rows.null.results", + ), + CalendarViewType.type: ( + "api:database:views:calendar:list", + "create_calendar_view", + "rows.2021-01-01.results", + ), + TimelineViewType.type: ( + "api:database:views:timeline:list", + "create_timeline_view", + "results", + ), +} + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_filters_are_not_forcefully_applied_to_all_views_types_for_builders_and_up( + enterprise_data_fixture, premium_data_fixture, api_client +): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + table = enterprise_data_fixture.create_database_table(user=user) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + + RowHandler().create_row(user, table, values={f"field_{text_field.id}": "a"}) + RowHandler().create_row(user, table, values={f"field_{text_field.id}": "b"}) + + for view_type in view_type_registry.get_all(): + if not view_type.can_filter: + continue + + if view_type.type not in view_type_url_mapping: + assert False, f"{view_type.type} must be added to `view_type_url_mapping`" + + view_path, fixture_create, response_path = view_type_url_mapping[view_type.type] + + view = getattr(premium_data_fixture, fixture_create)( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + enterprise_data_fixture.create_view_filter( + view=view, type="equal", value="a", field=text_field + ) + + for field in table.field_set.all(): + if field.specific_class == DateField: + table.get_model().objects.all().update( + **{f"field_{field.id}": datetime(2021, 1, 1)} + ) + + # Adding a filter to the query params should enable the adhoc filtering, + # if the user is builder or higher, which results in not applying the + # original view filters. We therefore expect both row_1 and row_2 in the + # response. + query_param = ( + '?filters={"filter_type":"AND","filters":[' + '{"type":"not_equal","field":' + str(text_field.id) + ',"value":"c"}' + '],"groups":[]}' + "&from_timestamp=2021-01-01" + "&to_timestamp=2021-02-01" + ) + response = api_client.get( + reverse(view_path, kwargs={"view_id": view.id}) + query_param, + format="json", + HTTP_AUTHORIZATION=f"JWT {token}", + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + # We expect both row_1 and row_2 when applying the query params. + assert len(get_value_at_path(response_json, response_path)) == 2, view_type.type + + +@pytest.mark.django_db +@override_settings(DEBUG=True) +def test_filters_are_forcefully_applied_to_all_views_types_for_editors_and_down( + enterprise_data_fixture, + premium_data_fixture, + api_client, +): + enterprise_data_fixture.enable_enterprise() + + user, token = enterprise_data_fixture.create_user_and_token() + user2, token2 = enterprise_data_fixture.create_user_and_token() + workspace = enterprise_data_fixture.create_workspace(user=user, members=[user2]) + database = enterprise_data_fixture.create_database_application(workspace=workspace) + table = enterprise_data_fixture.create_database_table(database=database) + text_field = enterprise_data_fixture.create_text_field(table=table, primary=True) + + editor_role = Role.objects.get(uid="EDITOR") + no_access_role = Role.objects.get(uid="NO_ACCESS") + workspace = table.database.workspace + RoleAssignmentHandler().assign_role( + user2, workspace, role=no_access_role, scope=workspace + ) + + RowHandler().create_row(user, table, values={f"field_{text_field.id}": "a"}) + RowHandler().create_row(user, table, values={f"field_{text_field.id}": "b"}) + + for view_type in view_type_registry.get_all(): + if not view_type.can_filter: + continue + + if view_type.type not in view_type_url_mapping: + assert False, f"{view_type.type} must be added to `view_type_url_mapping`" + + view_path, fixture_create, response_path = view_type_url_mapping[view_type.type] + + view = getattr(premium_data_fixture, fixture_create)( + table=table, ownership_type=RestrictedViewOwnershipType.type + ) + enterprise_data_fixture.create_view_filter( + view=view, type="equal", value="a", field=text_field + ) + + RoleAssignmentHandler().assign_role( + user2, + workspace, + role=editor_role, + scope=View.objects.get(id=view.id), + ) + + for field in table.field_set.all(): + if field.specific_class == DateField: + table.get_model().objects.all().update( + **{f"field_{field.id}": datetime(2021, 1, 1)} + ) + + # Adding a filter to the query params should not enable the adhoc filtering, + # if the user is editor or lower, so the view filters are forcefully applied. + # We therefore expect only row_1 in the response. + query_param = ( + '?filters={"filter_type":"AND","filters":[' + '{"type":"not_equal","field":' + str(text_field.id) + ',"value":"c"}' + '],"groups":[]}' + "&from_timestamp=2021-01-01" + "&to_timestamp=2021-02-01" + ) + response = api_client.get( + reverse(view_path, kwargs={"view_id": view.id}) + query_param, + format="json", + HTTP_AUTHORIZATION=f"JWT {token2}", + ) + response_json = response.json() + assert response.status_code == HTTP_200_OK + # We expect only row_1 to be in there because user2 only has editor permissions + # to the view and should therefore not be able to see row 2 because it does not + # match the filters of the view. + assert len(get_value_at_path(response_json, response_path)) == 1, view_type.type diff --git a/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElementForm.vue b/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElementForm.vue index a434769b4e..d874cc7dc3 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElementForm.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/builder/components/elements/AuthFormElementForm.vue @@ -1,6 +1,6 @@