From b3aa730cb89111567639216a4bab847d71451510 Mon Sep 17 00:00:00 2001 From: Jake Rosenberg Date: Thu, 2 Jun 2022 15:15:24 -0500 Subject: [PATCH] task/FP-1131: Add Member role to shared workspace (frontend) (#597) * Add API endpoints to update project/system roles for users * Additional backend changes to support project/system role queries * Decouple project/system role selectors in the Manage Team Members modal * use single-column view that only displays the Tapis role * Add new users as USER instead of ADMIN * fix cutoff for ownership transfer text * Restore memoization of table data * Hide roles column for USER/MEMBER since they can't see others' roles * python linting * Fix memoization loop causing infinite query refetch * hide role column while transferring ownership * fix(FP-1670): button text overflow (workspaces) (#641) * merge main * show spinner while mutating Co-authored-by: Sal Tijerina Co-authored-by: Wesley B <62723358+wesleyboar@users.noreply.github.com> --- client/package-lock.json | 207 +++++++++++++++--- client/package.json | 1 + .../DataFilesManageProjectModal.jsx | 1 + .../tests/DataFilesManageProjectModal.test.js | 1 + .../DataFilesProjectMembers.jsx | 60 ++++- .../DataFilesProjectMembers.module.scss | 11 +- .../_cells/ProjectRoleSelector.jsx | 86 ++++++++ .../_cells/SystemRoleSelector.jsx | 100 +++++++++ .../_cells/_tests/ProjectRoleSelector.test.js | 38 ++++ .../_cells/_tests/SystemRoleSelector.test.js | 33 +++ .../DataFilesProjectMembers/_cells/index.js | 2 + client/src/index.jsx | 15 +- client/src/utils/testing.jsx | 25 ++- server/portal/apps/projects/managers/base.py | 21 ++ .../apps/projects/managers/unit_test.py | 10 + server/portal/apps/projects/models/base.py | 49 ++++- .../portal/apps/projects/models/unit_test.py | 35 +++ server/portal/apps/projects/urls.py | 2 + server/portal/apps/projects/views.py | 59 ++++- .../portal/apps/projects/views_unit_test.py | 37 +++- .../portal/libs/agave/models/systems/roles.py | 4 +- 21 files changed, 733 insertions(+), 64 deletions(-) create mode 100644 client/src/components/DataFiles/DataFilesProjectMembers/_cells/ProjectRoleSelector.jsx create mode 100644 client/src/components/DataFiles/DataFilesProjectMembers/_cells/SystemRoleSelector.jsx create mode 100644 client/src/components/DataFiles/DataFilesProjectMembers/_cells/_tests/ProjectRoleSelector.test.js create mode 100644 client/src/components/DataFiles/DataFilesProjectMembers/_cells/_tests/SystemRoleSelector.test.js create mode 100644 client/src/components/DataFiles/DataFilesProjectMembers/_cells/index.js diff --git a/client/package-lock.json b/client/package-lock.json index 580df66e8..f565d24ff 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -24,6 +24,7 @@ "react-dom": "^17.0.2", "react-dropzone": "^10.2.1", "react-google-recaptcha": "^2.1.0", + "react-query": "^3.34.15", "react-redux": "^7.2.5", "react-resize-detector": "^6.1.0", "react-router-dom": "^5.3.0", @@ -5198,8 +5199,15 @@ "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/big-integer": { + "version": "1.6.51", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", + "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==", + "engines": { + "node": ">=0.6" + } }, "node_modules/binary-extensions": { "version": "2.2.0", @@ -5225,7 +5233,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5243,6 +5250,21 @@ "node": ">=8" } }, + "node_modules/broadcast-channel": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz", + "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==", + "dependencies": { + "@babel/runtime": "^7.7.2", + "detect-node": "^2.1.0", + "js-sha3": "0.8.0", + "microseconds": "0.2.0", + "nano-time": "1.0.0", + "oblivious-set": "1.0.0", + "rimraf": "3.0.2", + "unload": "2.2.0" + } + }, "node_modules/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -5594,8 +5616,7 @@ "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "node_modules/convert-source-map": { "version": "1.8.0", @@ -6451,6 +6472,11 @@ "node": ">=8" } }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" + }, "node_modules/diff-sequences": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", @@ -7943,8 +7969,7 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "node_modules/fsevents": { "version": "2.3.2", @@ -8087,7 +8112,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -8642,7 +8666,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -8651,8 +8674,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ini": { "version": "1.3.8", @@ -10914,6 +10936,11 @@ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==" }, + "node_modules/js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11430,6 +11457,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/match-sorter": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz", + "integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "remove-accents": "0.4.2" + } + }, "node_modules/mathml-tag-names": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", @@ -11523,6 +11559,11 @@ "node": ">=8.6" } }, + "node_modules/microseconds": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz", + "integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==" + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -11633,6 +11674,14 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/nano-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz", + "integrity": "sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=", + "dependencies": { + "big-integer": "^1.6.16" + } + }, "node_modules/nanoid": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz", @@ -11943,11 +11992,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oblivious-set": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz", + "integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, "dependencies": { "wrappy": "1" } @@ -12069,7 +12122,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -15079,6 +15131,31 @@ "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.0.tgz", "integrity": "sha512-kzmNjIgU32mO4mmH5+iUyrqlpFQhF8K2k7eZ4fdLSOPFrD1XgEuSBv9LDEgxRXTMBqMd8ppT0x6TIzqE5pdGdw==" }, + "node_modules/react-query": { + "version": "3.34.15", + "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.34.15.tgz", + "integrity": "sha512-dOhGLB5RT3p+wWj0rVdAompSg+R9t6oMRk+JhU8DP0tpJM2UyIv3r4Kk0zUkHSxT+QG34hFdrgdqxVWxgeNq4g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "broadcast-channel": "^3.4.1", + "match-sorter": "^6.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-redux": { "version": "7.2.6", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz", @@ -15578,6 +15655,11 @@ "jsesc": "bin/jsesc" } }, + "node_modules/remove-accents": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", + "integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U=" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -15690,7 +15772,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, "dependencies": { "glob": "^7.1.3" }, @@ -17184,8 +17265,7 @@ "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "node_modules/write-file-atomic": { "version": "3.0.3", @@ -21048,8 +21128,12 @@ "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "big-integer": { + "version": "1.6.51", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz", + "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==" }, "binary-extensions": { "version": "2.2.0", @@ -21072,7 +21156,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -21087,6 +21170,21 @@ "fill-range": "^7.0.1" } }, + "broadcast-channel": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz", + "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==", + "requires": { + "@babel/runtime": "^7.7.2", + "detect-node": "^2.1.0", + "js-sha3": "0.8.0", + "microseconds": "0.2.0", + "nano-time": "1.0.0", + "oblivious-set": "1.0.0", + "rimraf": "3.0.2", + "unload": "2.2.0" + } + }, "browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -21354,8 +21452,7 @@ "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "dev": true + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "convert-source-map": { "version": "1.8.0", @@ -22019,6 +22116,11 @@ "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", "dev": true }, + "detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==" + }, "diff-sequences": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", @@ -23046,8 +23148,7 @@ "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", - "dev": true + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" }, "fsevents": { "version": "2.3.2", @@ -23144,7 +23245,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", - "dev": true, "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -23572,7 +23672,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", - "dev": true, "requires": { "once": "^1.3.0", "wrappy": "1" @@ -23581,8 +23680,7 @@ "inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "ini": { "version": "1.3.8", @@ -25259,6 +25357,11 @@ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==" }, + "js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==" + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -25691,6 +25794,15 @@ "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", "dev": true }, + "match-sorter": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz", + "integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==", + "requires": { + "@babel/runtime": "^7.12.5", + "remove-accents": "0.4.2" + } + }, "mathml-tag-names": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", @@ -25764,6 +25876,11 @@ "picomatch": "^2.2.3" } }, + "microseconds": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz", + "integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==" + }, "mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -25846,6 +25963,14 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "nano-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz", + "integrity": "sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=", + "requires": { + "big-integer": "^1.6.16" + } + }, "nanoid": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz", @@ -26074,11 +26199,15 @@ "es-abstract": "^1.19.1" } }, + "oblivious-set": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz", + "integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "dev": true, "requires": { "wrappy": "1" } @@ -26166,8 +26295,7 @@ "path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", - "dev": true + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" }, "path-key": { "version": "3.1.1", @@ -28401,6 +28529,16 @@ "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.0.tgz", "integrity": "sha512-kzmNjIgU32mO4mmH5+iUyrqlpFQhF8K2k7eZ4fdLSOPFrD1XgEuSBv9LDEgxRXTMBqMd8ppT0x6TIzqE5pdGdw==" }, + "react-query": { + "version": "3.34.15", + "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.34.15.tgz", + "integrity": "sha512-dOhGLB5RT3p+wWj0rVdAompSg+R9t6oMRk+JhU8DP0tpJM2UyIv3r4Kk0zUkHSxT+QG34hFdrgdqxVWxgeNq4g==", + "requires": { + "@babel/runtime": "^7.5.5", + "broadcast-channel": "^3.4.1", + "match-sorter": "^6.0.2" + } + }, "react-redux": { "version": "7.2.6", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz", @@ -28795,6 +28933,11 @@ } } }, + "remove-accents": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz", + "integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U=" + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -28882,7 +29025,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, "requires": { "glob": "^7.1.3" } @@ -30068,8 +30210,7 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "dev": true + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "write-file-atomic": { "version": "3.0.3", diff --git a/client/package.json b/client/package.json index cb5f57066..2c717bbf0 100644 --- a/client/package.json +++ b/client/package.json @@ -19,6 +19,7 @@ "react-dom": "^17.0.2", "react-dropzone": "^10.2.1", "react-google-recaptcha": "^2.1.0", + "react-query": "^3.34.15", "react-redux": "^7.2.5", "react-resize-detector": "^6.1.0", "react-router-dom": "^5.3.0", diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesManageProjectModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesManageProjectModal.jsx index f365171fe..cfb80b5f5 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesManageProjectModal.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesManageProjectModal.jsx @@ -116,6 +116,7 @@ const DataFilesManageProjectModal = () => { state.users.search.users); + const authenticatedUser = useSelector( + (state) => state.authenticatedUser.user.username + ); + const { query: authenticatedUserQuery } = !projectId + ? {} + : useSystemRole(projectId, authenticatedUser); const [selectedUser, setSelectedUser] = useState(''); @@ -67,6 +76,17 @@ const DataFilesProjectMembers = ({ ); }; + const mapAccessToRoles = (access) => { + switch (access) { + case 'owner': + return { projectRole: 'PI', systemRole: 'OWNER' }; + case 'edit': + return { projectRole: 'Member', systemRole: 'USER' }; + default: + return { projectRole: 'N/A', systemRole: 'N/A' }; + } + }; + const memberColumn = { Header: 'Members', headerStyle: { textAlign: 'left' }, @@ -81,15 +101,36 @@ const DataFilesProjectMembers = ({ ), }; + const roleColumn = + mode !== 'transfer' && + (!projectId || + ['OWNER', 'ADMIN'].includes(authenticatedUserQuery?.data?.role)) + ? [ + { + Header: 'Role', + accessor: 'user.username', + id: 'role', + className: 'project-members__cell', + show: false, + Cell: projectId + ? (el) => ( + + ) + : (el) => ( + + {mapAccessToRoles(el.row.original.access).systemRole} + + ), + }, + ] + : []; const columns = [ memberColumn, - { - Header: 'Access', - accessor: 'access', - className: 'project-members__cell', - Cell: (el) => {el.value}, - }, + ...roleColumn, { Header: loading ? ( ); diff --git a/client/src/components/DataFiles/DataFilesProjectMembers/DataFilesProjectMembers.module.scss b/client/src/components/DataFiles/DataFilesProjectMembers/DataFilesProjectMembers.module.scss index 69a5c074a..f0a5b6866 100644 --- a/client/src/components/DataFiles/DataFilesProjectMembers/DataFilesProjectMembers.module.scss +++ b/client/src/components/DataFiles/DataFilesProjectMembers/DataFilesProjectMembers.module.scss @@ -43,12 +43,11 @@ /* owner */ th:nth-child(2), td:nth-child(2) { - width: 10%; - } - /* id */ + width: 20%; + } /* owner */ th:nth-child(3), td:nth-child(3) { - width: 30%; + width: 20%; } } @@ -56,12 +55,12 @@ /* owner */ th:nth-child(1), td:nth-child(1) { - width: 60%; + width: 50%; } /* confirmation */ th:nth-child(2), td:nth-child(2) { - width: 40%; + width: 50%; } } diff --git a/client/src/components/DataFiles/DataFilesProjectMembers/_cells/ProjectRoleSelector.jsx b/client/src/components/DataFiles/DataFilesProjectMembers/_cells/ProjectRoleSelector.jsx new file mode 100644 index 000000000..734fef876 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesProjectMembers/_cells/ProjectRoleSelector.jsx @@ -0,0 +1,86 @@ +import React, { useState, useEffect, useContext, createContext } from 'react'; +import { useQuery, useMutation, useQueryClient } from 'react-query'; +import Cookies from 'js-cookie'; +import fetch from 'cross-fetch'; +import DropdownSelector from '_common/DropdownSelector'; +import { Button } from 'reactstrap'; +import LoadingSpinner from '_common/LoadingSpinner'; + +const getProjectRole = async (projectId, username) => { + const url = `/api/projects/${projectId}/project-role/${username}/`; + const request = await fetch(url, { + headers: { 'X-CSRFToken': Cookies.get('csrftoken') }, + credentials: 'same-origin', + }); + const data = await request.json(); + return data; +}; + +const setProjectRole = async (projectId, username, oldRole, newRole) => { + const url = `/api/projects/${projectId}/members/`; + const request = await fetch(url, { + headers: { 'X-CSRFToken': Cookies.get('csrftoken') }, + credentials: 'same-origin', + method: 'PATCH', + body: JSON.stringify({ + action: 'change_project_role', + username, + oldRole, + newRole, + }), + }); + const data = await request.json(); + return data; +}; + +const useProjectRole = (projectId, username) => { + const queryClient = useQueryClient(); + const query = useQuery(['project-role', projectId, username], () => + getProjectRole(projectId, username) + ); + const mutation = useMutation(async ({ oldRole, newRole }) => { + await setProjectRole(projectId, username, oldRole, newRole); + query.refetch(); + // Invalidate the system role query to keep it up to date. + queryClient.invalidateQueries(['system-role', projectId, username]); + }); + return { query, mutation }; +}; + +const ProjectRoleSelector = ({ projectId, username }) => { + const { + query: { data, isLoading, error, isFetching }, + mutation: { mutate: setProjectRole, isLoading: isMutating }, + } = useProjectRole(projectId, username); + + const [selectedRole, setSelectedRole] = useState(data?.role); + useEffect(() => setSelectedRole(data?.role), [data?.role]); + + if (isLoading) return ; + if (error) return Error; + if (data?.role == 'pi') return PI; + return ( +
+ setSelectedRole(e.target.value)} + > + + + + {data.role !== selectedRole && !isFetching && ( + + )} +
+ ); +}; + +export default ProjectRoleSelector; diff --git a/client/src/components/DataFiles/DataFilesProjectMembers/_cells/SystemRoleSelector.jsx b/client/src/components/DataFiles/DataFilesProjectMembers/_cells/SystemRoleSelector.jsx new file mode 100644 index 000000000..59d6283a6 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesProjectMembers/_cells/SystemRoleSelector.jsx @@ -0,0 +1,100 @@ +import React, { useState, useEffect, createContext, useContext } from 'react'; +import { useQuery, useMutation } from 'react-query'; +import { useSelector } from 'react-redux'; +import Cookies from 'js-cookie'; +import fetch from 'cross-fetch'; +import DropdownSelector from '_common/DropdownSelector'; +import { Button } from 'reactstrap'; +import styles from '../DataFilesProjectMembers.module.scss'; +import LoadingSpinner from '_common/LoadingSpinner'; + +const getSystemRole = async (projectId, username) => { + const url = `/api/projects/${projectId}/system-role/${username}/`; + const request = await fetch(url, { + headers: { 'X-CSRFToken': Cookies.get('csrftoken') }, + credentials: 'same-origin', + }); + const data = await request.json(); + return data; +}; + +const setSystemRole = async (projectId, username, role) => { + const url = `/api/projects/${projectId}/members/`; + const request = await fetch(url, { + headers: { 'X-CSRFToken': Cookies.get('csrftoken') }, + credentials: 'same-origin', + method: 'PATCH', + body: JSON.stringify({ + action: 'change_system_role', + username, + newRole: role, + }), + }); + const data = await request.json(); + return data; +}; + +export const useSystemRole = (projectId, username) => { + const query = useQuery(['system-role', projectId, username], () => + getSystemRole(projectId, username) + ); + const mutation = useMutation(async (role) => { + await setSystemRole(projectId, username, role); + query.refetch(); + }); + return { query, mutation }; +}; + +const SystemRoleSelector = ({ projectId, username }) => { + const authenticatedUser = useSelector( + (state) => state.authenticatedUser.user.username + ); + const { query: authenticatedUserQuery } = useSystemRole( + projectId, + authenticatedUser + ); + const currentUserRole = authenticatedUserQuery.data?.role; + + const { + query: { data, isLoading, isFetching, error }, + mutation: { mutate: setSystemRole, isLoading: isMutating }, + } = useSystemRole(projectId, username); + const [selectedRole, setSelectedRole] = useState(data?.role); + useEffect(() => setSelectedRole(data?.role), [data?.role]); + + if (isLoading || authenticatedUserQuery.isLoading || isMutating) + return ; + if (error) return Error; + //Only owners/admins can change roles; + // owner roles cannot be changed except using the Transfer mechanism; + // users cannot change their own roles. + if ( + data.role === 'OWNER' || + username === authenticatedUser || + !['OWNER', 'ADMIN'].includes(currentUserRole) + ) + return {data.role}; + return ( +
+ setSelectedRole(e.target.value)} + > + {username !== authenticatedUser && } + + + + {data.role !== selectedRole && !isFetching && ( + + )} +
+ ); +}; + +export default SystemRoleSelector; diff --git a/client/src/components/DataFiles/DataFilesProjectMembers/_cells/_tests/ProjectRoleSelector.test.js b/client/src/components/DataFiles/DataFilesProjectMembers/_cells/_tests/ProjectRoleSelector.test.js new file mode 100644 index 000000000..21b00e8d9 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesProjectMembers/_cells/_tests/ProjectRoleSelector.test.js @@ -0,0 +1,38 @@ +import React from 'react'; +import fetchMock from 'fetch-mock'; +import configureStore from 'redux-mock-store'; +import '@testing-library/jest-dom/extend-expect'; +import renderComponent from 'utils/testing'; +import ProjectRoleSelector from '../ProjectRoleSelector'; +import { waitFor, screen, fireEvent } from '@testing-library/react'; + +import fetch from 'cross-fetch'; +jest.mock('cross-fetch'); +const mockStore = configureStore(); + +describe('ProjectRoleSelector', () => { + it('renders AppRouter and dispatches events', async () => { + const fm = fetchMock + .sandbox() + .get(`/api/projects/CEP-000/project-role/testuser/`, { + status: 200, + body: { role: 'co_pi' }, + }); + fetch.mockImplementation(fm); + + renderComponent( + , + mockStore({}) + ); + expect(await screen.findByTestId('loading-spinner')).toBeDefined(); + + await waitFor(async () => { + expect(await screen.findByDisplayValue('Co-PI')).toBeDefined(); + const selector = await screen.findByTestId('selector'); + + fireEvent.change(selector, { target: { value: 'team_member' } }); + expect(await screen.findByDisplayValue('Member')).toBeDefined(); + expect(await screen.findByText('Update')).toBeDefined(); + }); + }); +}); diff --git a/client/src/components/DataFiles/DataFilesProjectMembers/_cells/_tests/SystemRoleSelector.test.js b/client/src/components/DataFiles/DataFilesProjectMembers/_cells/_tests/SystemRoleSelector.test.js new file mode 100644 index 000000000..f6f56ed4f --- /dev/null +++ b/client/src/components/DataFiles/DataFilesProjectMembers/_cells/_tests/SystemRoleSelector.test.js @@ -0,0 +1,33 @@ +import React from 'react'; +import fetchMock from 'fetch-mock'; +import configureStore from 'redux-mock-store'; +import '@testing-library/jest-dom/extend-expect'; +import renderComponent from 'utils/testing'; +import SystemRoleSelector from '../SystemRoleSelector'; +import { waitFor, screen, fireEvent } from '@testing-library/react'; + +import fetch from 'cross-fetch'; +jest.mock('cross-fetch'); +const mockStore = configureStore(); + +describe('SystemRoleSelector', () => { + it('Loads and displays system role', async () => { + const fm = fetchMock + .sandbox() + .mock(`/api/projects/CEP-000/system-role/testuser/`, { + status: 200, + body: { role: 'GUEST' }, + }); + fetch.mockImplementation(fm); + + renderComponent( + , + mockStore({ authenticatedUser: { user: { username: 'testuser' } } }) + ); + expect(await screen.findByTestId('loading-spinner')).toBeDefined(); + await waitFor(async () => { + const query = await screen.findByText('GUEST'); + expect(query).toBeDefined(); + }); + }); +}); diff --git a/client/src/components/DataFiles/DataFilesProjectMembers/_cells/index.js b/client/src/components/DataFiles/DataFilesProjectMembers/_cells/index.js new file mode 100644 index 000000000..f5390797c --- /dev/null +++ b/client/src/components/DataFiles/DataFilesProjectMembers/_cells/index.js @@ -0,0 +1,2 @@ +export { default as SystemRoleSelector } from './SystemRoleSelector'; +export { default as ProjectRoleSelector } from './ProjectRoleSelector'; diff --git a/client/src/index.jsx b/client/src/index.jsx index 3e2a6abe6..33889381c 100644 --- a/client/src/index.jsx +++ b/client/src/index.jsx @@ -2,15 +2,20 @@ import React, { Suspense } from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; import LoadingSpinner from '_common/LoadingSpinner'; +import { QueryClient, QueryClientProvider } from 'react-query'; const AppRouter = React.lazy(() => import('./components/Workbench')); import './index.css'; import store from './redux/store'; +const queryClient = new QueryClient(); + ReactDOM.render( - - }> - - - , + + + }> + + + + , document.getElementById('react-root') ); diff --git a/client/src/utils/testing.jsx b/client/src/utils/testing.jsx index 37f5823d1..4c9f1745c 100644 --- a/client/src/utils/testing.jsx +++ b/client/src/utils/testing.jsx @@ -2,18 +2,31 @@ import React from 'react'; import { BrowserRouter, Router } from 'react-router-dom'; import { render } from '@testing-library/react'; import { Provider } from 'react-redux'; +import { QueryClient, QueryClientProvider } from 'react-query'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); export default function renderComponent(component, store, history) { if (history) { return render( - - {component} - + + + {component} + + ); } return render( - - {component} - + + + {component} + + ); } diff --git a/server/portal/apps/projects/managers/base.py b/server/portal/apps/projects/managers/base.py index 9738b0dd3..e7fd776c8 100644 --- a/server/portal/apps/projects/managers/base.py +++ b/server/portal/apps/projects/managers/base.py @@ -294,6 +294,18 @@ def remove_member(self, project_id, member_type, username): self.apply_permissions(prj, username, 'remove') return prj + def change_system_role(self, project_id, username, new_role): + user = get_user_model().objects.get(username=username) + prj = self.get_project(project_id) + prj.change_storage_system_role(user, new_role) + return prj + + def change_project_role(self, project_id, username, old_role, new_role): + user = get_user_model().objects.get(username=username) + prj = self.get_project(project_id) + prj.change_project_role(user, old_role, new_role) + return prj + def _update_meta(self, project, **data): # pylint: disable=no-self-use """Update project metadata. @@ -352,3 +364,12 @@ def update_prj(self, project_id=None, system_id=None, **data): self._update_meta(prj, **data) self._update_storage(prj, **data) return prj + + def role_for_user(self, project_id, username): + """ + Get a user's role for a project. + returns one of 'pi', 'co_pi', 'team_member', None + """ + prj = self.get_project(project_id) + + return prj.get_project_role(username) diff --git a/server/portal/apps/projects/managers/unit_test.py b/server/portal/apps/projects/managers/unit_test.py index 5e2c168af..2448ecb06 100644 --- a/server/portal/apps/projects/managers/unit_test.py +++ b/server/portal/apps/projects/managers/unit_test.py @@ -183,6 +183,16 @@ def test_remove_member(authenticated_user, project_manager, service_account): ) +def test_change_project_role(authenticated_user, project_manager, service_account): + project_manager.change_project_role('PRJ-123', 'username', 'co_pi', 'member') + project_manager.get_project().change_project_role.assert_called_with(authenticated_user, 'co_pi', 'member') + + +def test_change_system_role(authenticated_user, project_manager, service_account): + project_manager.change_system_role('PRJ-123', 'username', 'USER') + project_manager.get_project().change_storage_system_role.assert_called_with(authenticated_user, 'USER') + + def test_project_manager_create(mocker, authenticated_user, project_manager, portal_project, mock_project_save_signal): mock_get_latest_project_directory = mocker.patch('portal.apps.projects.managers.base.get_latest_project_directory') mock_get_latest_project_storage = mocker.patch('portal.apps.projects.managers.base.get_latest_project_storage') diff --git a/server/portal/apps/projects/models/base.py b/server/portal/apps/projects/models/base.py index 742638780..0f2681da4 100644 --- a/server/portal/apps/projects/models/base.py +++ b/server/portal/apps/projects/models/base.py @@ -352,7 +352,11 @@ def _can_edit_member(self, username): return False def transfer_pi(self, old_pi, new_pi): - self.remove_co_pi(new_pi) + new_pi_role = self.get_project_role(new_pi) + if new_pi_role == 'team_member': + self.remove_member(new_pi) + elif new_pi_role == 'co_pi': + self.remove_co_pi(new_pi) self.add_pi(new_pi) self.add_co_pi(old_pi) return self @@ -458,6 +462,42 @@ def remove_member(self, user): self.save_metadata() return self + def change_project_role(self, user, old_role, new_role): + # account for difference between role name (team_member) and method + # names (add_member, remove_member) + if old_role == 'team_member': + old_role = 'member' + if new_role == 'team_member': + new_role = 'member' + add_new_role = getattr(self, "add_{}".format(new_role)) + remove_old_role = getattr(self, "remove_{}".format(old_role)) + remove_old_role(user) + add_new_role(user) + + def get_project_role(self, username): + """ + Get a user's role for the project. + returns one of 'pi', 'co_pi', 'team_member', None + """ + role = None + + if self.metadata.pi.username == username: + role = 'pi' + + try: + self.metadata.co_pis.get(username=username) + role = 'co_pi' + except get_user_model().DoesNotExist: + pass + + try: + self.metadata.team_members.get(username=username) + role = 'team_member' + except get_user_model().DoesNotExist: + pass + + return role + def save_metadata(self): """Help method to save metadata object. @@ -481,6 +521,13 @@ def save_storage(self): set_storage_auth(self.storage) self.storage.update() + def change_storage_system_role(self, user, new_role): + self.storage.roles.add( + user.username, + new_role + ) + self.storage.roles.save() + def __repr__(self): """Repr.""" return 'Project({project_id}, {metadata}, {storage})'.format( diff --git a/server/portal/apps/projects/models/unit_test.py b/server/portal/apps/projects/models/unit_test.py index a6cea9006..f41e906a3 100644 --- a/server/portal/apps/projects/models/unit_test.py +++ b/server/portal/apps/projects/models/unit_test.py @@ -85,3 +85,38 @@ def test_metadata_create_on_project_load(agave_client, mock_owner, mock_project_ ) assert ProjectMetadata.objects.all().count() == 1 assert ProjectMetadata.objects.last().pi == mock_owner + + +def test_project_change_system_role(agave_client, mock_owner, mock_project_save_signal): + agave_client.systems.listRoles.return_value = [{'username': 'username', 'role': 'ADMIN'}] + sys = StorageSystem(agave_client, 'cep.test.PRJ-123') + sys.last_modified = '1234' + sys.description = 'PRJ-123' + prj = Project( + agave_client, + 'PRJ-123', + storage=sys + ) + prj.change_storage_system_role(mock_owner, 'USER') + agave_client.systems.updateRole.assert_called_with( + body={'role': 'USER', 'username': 'username'}, + systemId='cep.test.PRJ-123') + + +def test_project_change_project_role(agave_client, mock_owner, mock_project_save_signal, mocker): + mock_remove = mocker.patch('portal.apps.projects.models.base.Project.remove_co_pi') + mock_add = mocker.patch('portal.apps.projects.models.base.Project.add_member') + + sys = StorageSystem(agave_client, 'cep.test.PRJ-123') + sys.last_modified = '1234' + sys.description = 'PRJ-123' + + prj = Project( + agave_client, + 'PRJ-123', + storage=sys + ) + + prj.change_project_role(mock_owner, 'co_pi', 'member') + mock_remove.assert_called_with(mock_owner) + mock_add.assert_called_with(mock_owner) diff --git a/server/portal/apps/projects/urls.py b/server/portal/apps/projects/urls.py index 205df1d49..542df1510 100644 --- a/server/portal/apps/projects/urls.py +++ b/server/portal/apps/projects/urls.py @@ -7,6 +7,8 @@ urlpatterns = [ path('system//', views.ProjectInstanceApiView.as_view(), name='project_sys'), path('/members/', views.ProjectMembersApiView.as_view()), + path('/project-role//', views.get_project_role), + path('/system-role//', views.get_system_role), path('/', views.ProjectInstanceApiView.as_view(), name='project'), path('', views.ProjectsApiView.as_view(), name='projects_api') ] diff --git a/server/portal/apps/projects/views.py b/server/portal/apps/projects/views.py index c9ba5adc8..a5004e4b6 100644 --- a/server/portal/apps/projects/views.py +++ b/server/portal/apps/projects/views.py @@ -116,7 +116,7 @@ def post(self, request): # pylint: disable=no-self-use if member['access'] == 'owner': access = 'pi' elif member['access'] == 'edit': - access = 'co_pi' + access = 'team_member' else: raise ApiException("Unsupported access level") mgr.add_member( @@ -266,7 +266,7 @@ def add_member(self, request, project_id, **data): username = data.get('username') res = ProjectsManager(request.user).add_member( project_id, - 'co_pi', + 'team_member', username ) return JsonResponse( @@ -285,9 +285,10 @@ def remove_member(self, request, project_id, **data): :param dict data: Data. """ username = data.get('username') + role = ProjectsManager(request.user).role_for_user(project_id, username) prj = ProjectsManager(request.user).remove_member( project_id=project_id, - member_type='co_pi', + member_type=role, username=username ) return JsonResponse( @@ -297,3 +298,55 @@ def remove_member(self, request, project_id, **data): }, encoder=ProjectsManager.meta_serializer_cls ) + + def change_project_role(self, request, project_id, **data): + username = data.get('username') + old_role = data.get('oldRole') + new_role = data.get('newRole') + prj = ProjectsManager(request.user).change_project_role( + project_id, + username, + old_role, + new_role + ) + + return JsonResponse( + { + 'status': 200, + 'response': prj.metadata, + }, + encoder=ProjectsManager.meta_serializer_cls + ) + + def change_system_role(self, request, project_Id, **data): + username = data.get('username') + new_role = data.get('newRole') + prj = ProjectsManager(request.user).change_system_role( + project_Id, + username, + new_role) + + return JsonResponse( + { + 'status': 200, + 'response': prj.metadata, + }, + encoder=ProjectsManager.meta_serializer_cls + ) + + +@login_required +def get_project_role(request, project_id, username): + role = None + mgr = ProjectsManager(request.user) + role = mgr.role_for_user(project_id, username) + + return JsonResponse({'username': username, 'role': role}) + + +@login_required +def get_system_role(request, project_id, username): + mgr = ProjectsManager(request.user) + prj = mgr.get_project(project_id) + role = prj.storage.roles.for_user(username).role + return JsonResponse({'username': username, 'role': role}) diff --git a/server/portal/apps/projects/views_unit_test.py b/server/portal/apps/projects/views_unit_test.py index dd7dca2e3..05a498d04 100644 --- a/server/portal/apps/projects/views_unit_test.py +++ b/server/portal/apps/projects/views_unit_test.py @@ -1,4 +1,3 @@ - import pytest from portal.apps.projects.managers.base import ProjectsManager from mock import MagicMock @@ -14,6 +13,9 @@ def mock_project_mgr(mocker): mocker.patch('portal.apps.projects.views.ProjectsManager.update_prj') mocker.patch('portal.apps.projects.views.ProjectsManager.add_member') mocker.patch('portal.apps.projects.views.ProjectsManager.remove_member') + mocker.patch('portal.apps.projects.views.ProjectsManager.change_project_role') + mocker.patch('portal.apps.projects.views.ProjectsManager.change_system_role') + mocker.patch('portal.apps.projects.views.ProjectsManager.role_for_user') return ProjectsManager @@ -111,6 +113,36 @@ def test_project_instance_patch(regular_user, client, mock_project_mgr): } +def test_project_change_role(regular_user, client, mock_project_mgr): + mock_project_mgr.change_project_role.return_value = MagicMock(metadata={'projectId': 'PRJ-123'}) + client.force_login(regular_user) + + patch_body = {'action': 'change_project_role', 'username': 'test_user', 'oldRole': 'co_pi', 'newRole': 'team_member'} + + response = client.patch('/api/projects/PRJ-123/members/', json.dumps(patch_body)) + + mock_project_mgr.change_project_role.assert_called_with('PRJ-123', 'test_user', 'co_pi', 'team_member') + assert response.json() == { + 'status': 200, + 'response': {'projectId': 'PRJ-123'} + } + + +def test_project_change_system_role(regular_user, client, mock_project_mgr): + mock_project_mgr.change_system_role.return_value = MagicMock(metadata={'projectId': 'PRJ-123'}) + client.force_login(regular_user) + + patch_body = {'action': 'change_system_role', 'username': 'test_user', 'newRole': 'USER'} + + response = client.patch('/api/projects/PRJ-123/members/', json.dumps(patch_body)) + + mock_project_mgr.change_system_role.assert_called_with('PRJ-123', 'test_user', 'USER') + assert response.json() == { + 'status': 200, + 'response': {'projectId': 'PRJ-123'} + } + + def test_members_view_add(regular_user, client, mock_project_mgr): mock_project_mgr.add_member.return_value = MagicMock(metadata={'projectId': 'PRJ-123'}) client.force_login(regular_user) @@ -121,7 +153,7 @@ def test_members_view_add(regular_user, client, mock_project_mgr): # All new members now have co_pi status since we no longer have distinctions # between members and co_pis, and an individual may not become a pi # until they have "edit" access (co_pi status) - mock_project_mgr.add_member.assert_called_with('PRJ-123', 'co_pi', 'test_user') + mock_project_mgr.add_member.assert_called_with('PRJ-123', 'team_member', 'test_user') assert response.json() == { 'status': 200, 'response': {'projectId': 'PRJ-123'} @@ -130,6 +162,7 @@ def test_members_view_add(regular_user, client, mock_project_mgr): def test_members_view_remove(regular_user, client, mock_project_mgr): mock_project_mgr.remove_member.return_value = MagicMock(metadata={'projectId': 'PRJ-123'}) + mock_project_mgr.role_for_user.return_value = 'co_pi' client.force_login(regular_user) patch_body = {'action': 'remove_member', 'username': 'test_user'} diff --git a/server/portal/libs/agave/models/systems/roles.py b/server/portal/libs/agave/models/systems/roles.py index 7cb48c36d..4c50b3951 100644 --- a/server/portal/libs/agave/models/systems/roles.py +++ b/server/portal/libs/agave/models/systems/roles.py @@ -18,11 +18,13 @@ class Role(object): PUBLISHER = 'PUBLISHER' ADMIN = 'ADMIN' OWNER = 'OWNER' + GUEST = 'GUEST' ALL_ROLES = [ USER, PUBLISHER, ADMIN, - OWNER + OWNER, + GUEST ] def __init__(self, role):