From 943b84ff9a1b76bedf9c2c5434c55985a5debc1f Mon Sep 17 00:00:00 2001 From: hcphat Date: Fri, 3 Oct 2025 17:53:36 +0700 Subject: [PATCH 01/38] =?UTF-8?q?ref=202.1.=E5=B0=8F=E8=A6=8F=E6=A8=A1?= =?UTF-8?q?=E5=A4=A7=E9=87=8F=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92?= =?UTF-8?q?=E5=90=AB=E3=82=80=E3=83=95=E3=82=A9=E3=83=AB=E3=83=80=E3=82=A2?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=83=AD=E3=83=BC=E3=83=89=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E6=94=B9=E5=96=84=E3=81=AB=E3=81=8B=E3=81=8B=E3=82=8B=E9=96=8B?= =?UTF-8?q?=E7=99=BA:=20Commit=20code=20improve=20upload=20multiple=20smal?= =?UTF-8?q?l=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/static/js/fangorn.js | 249 ++++++++++++++++-- .../en/LC_MESSAGES/js_messages.po | 3 + .../ja/LC_MESSAGES/js_messages.po | 3 + website/translations/js_messages.pot | 3 + 4 files changed, 235 insertions(+), 23 deletions(-) diff --git a/website/static/js/fangorn.js b/website/static/js/fangorn.js index f0141258731..9423ca207f1 100644 --- a/website/static/js/fangorn.js +++ b/website/static/js/fangorn.js @@ -97,6 +97,108 @@ var COMMAND_KEYS = [224, 17, 91, 93]; var ESCAPE_KEY = 27; var ENTER_KEY = 13; +var PROVIDER_SETTINGS = new Map([ + // ['providerName', { parallelNum: , fileSizeThreshold: }] + // Bulk mount institution storage settings + ['osfstorage', { parallelNum: 4, fileSizeThreshold: 128000000 }], // 128 MB + + // Addon institution storage settings) + ['onedrivebusiness', { parallelNum: 4, fileSizeThreshold: 128000000 }], + ['nextcloudinstitutions', { parallelNum: 4, fileSizeThreshold: 128000000 }], + ['s3compatinstitutions', { parallelNum: 4, fileSizeThreshold: 128000000 }], + ['dropboxbusiness', { parallelNum: 4, fileSizeThreshold: 128000000 }], + + // Extend storage settings + ['s3', { parallelNum: 4, fileSizeThreshold: 128000000 }], + ['s3compat', { parallelNum: 4, fileSizeThreshold: 128000000 }], + ['box', { parallelNum: 4, fileSizeThreshold: 128000000 }], + ['googledrive', { parallelNum: 4, fileSizeThreshold: 128000000 }], + ['nextcloud', { parallelNum: 4, fileSizeThreshold: 128000000 }], + ['onedrive', { parallelNum: 4, fileSizeThreshold: 128000000 }], + ['dropbox', { parallelNum: 4, fileSizeThreshold: 128000000 }], + ['s3compatb3', { parallelNum: 4, fileSizeThreshold: 128000000 }], +]); + +// Monkey-patch Dropzone.processQueue here (must run before any Dropzone instances are created) +(function() { + if (typeof Dropzone === 'undefined' || !Dropzone.prototype) { + return; + } + var _origProcessQueue = Dropzone.prototype.processQueue; + + Dropzone.prototype.processQueue = function() { + var parallelUploads = this.options.parallelUploads; + var processingLength = this.getUploadingFiles().length; + // Start processing from the current number of uploading files + var uploadIndex = processingLength; + if (processingLength >= parallelUploads) { + return; + } + var queuedFiles = this.getQueuedFiles(); + if (!(queuedFiles.length > 0)) { + return; + } + + var threshold = this.options.fileSizeThreshold || this.options.largeFileSize || this.options.largeFileThreshold; + // If a large file is already uploading, do not start new uploads + if (threshold) { + var uploading = this.getUploadingFiles(); + for (var j = 0; j < uploading.length; j++) { + var uploadingFile = uploading[j]; + var fsize = (typeof uploadingFile.size === 'number') ? uploadingFile.size : (uploadingFile.upload && uploadingFile.upload.total ? uploadingFile.upload.total : 0); + if (fsize > threshold) { + return; + } + } + } + + if (this.options.uploadMultiple) { + // reuse original behavior but limit to available slots + return _origProcessQueue.apply(this, arguments); + } else { + var self = this; + while (uploadIndex < parallelUploads) { + if (!queuedFiles.length) { + return; + } + var next = queuedFiles[0]; + var nextSize = (typeof next.size === 'number') ? next.size : (next.upload && next.upload.total ? next.upload.total : 0); + // start the next file + this.processFile(queuedFiles.shift()); + + // If the started file is "large", attach a one-time resume when it completes, + // then stop starting further parallel uploads until resume. + if (threshold && nextSize > threshold) { + var onComplete = function(file) { + if (file !== next) return; + // detach listener (support once()/off() APIs) + try { + if (self.off) { self.off('complete', onComplete); } + } catch (e) {} + // slight delay to allow Dropzone internal state update, then resume + setTimeout(function() { + if (self.options.autoProcessQueue) { + self.processQueue(); + } + }, 0); + }; + if (this.once) { + this.once('complete', onComplete); + } else { + this.on('complete', onComplete); + } + return; + } + uploadIndex++; + } + } + }; +})(); + +function getProviderSettings(provider) { + return PROVIDER_SETTINGS.get(provider) || null; +} + function findByTempID(parent, tmpID) { var child; var item; @@ -941,6 +1043,51 @@ function _fangornDragOver(treebeard, event) { function _fangornDropzoneDrop(treebeard, event) { var dropzoneHoverClass = 'fangorn-dz-hover'; treebeard.select('.tb-row').removeClass(dropzoneHoverClass); + var files = event.dataTransfer.files; + var totalFilesSize = 0; + for (var i = 0; i < files.length; i++) { + totalFilesSize += files[i].size; + } + + var item = treebeard.dropzoneItemCache; + var provider = "unknown" + if (item && item.data) { + provider = item.data.provider; + } + + // check upload quota for upload folder + if (provider === 'osfstorage' || provider === 's3compatinstitutions') { + // call api get used quota and max quota + var quota = $.ajax({ + async: false, + method: 'GET', + url: item.data.nodeApiUrl + 'get_creator_quota/', + }); + + if (!quota.responseJSON) { + $osf.growl('Error', sprintf(gettext('Cannot get quota information.'), + formatProperUnit(totalFilesSize)), + 'danger', 5000); + treebeard.dropzone.options.limitQuota = true; + return; + } + + quota = quota.responseJSON; + if (parseFloat(quota.used) + parseFloat(totalFilesSize) > quota.max) { + $osf.growl('Error', sprintf(gettext('Not enough quota to upload. The total size of all files is %1$s.'), + formatProperUnit(totalFilesSize)), + 'danger', 5000); + treebeard.dropzone.options.limitQuota = true; + return; + } + } + // Set Dropzone settings for upload multiple file + var providerSettings = getProviderSettings(treebeard.dropzoneItemCache.data.provider); + if (providerSettings && providerSettings.parallelNum && providerSettings.fileSizeThreshold) { + treebeard.dropzone.options.parallelUploads = providerSettings.parallelNum; + treebeard.dropzone.options.fileSizeThreshold = providerSettings.fileSizeThreshold; + treebeard.dropzone.options.limitQuota = false; + } } /** * Runs when Dropzone's complete hook is run after upload is completed. @@ -1112,6 +1259,27 @@ function _fangornDropzoneError(treebeard, file, message, xhr) { */ function _uploadEvent(event, item, col) { var self = this; // jshint ignore:line + // clear cache of input before upload new folder + if (self.dropzone.hiddenFileInput) { + document.body.removeChild(self.dropzone.hiddenFileInput); + } + + self.dropzone.hiddenFileInput = document.createElement('input'); + self.dropzone.hiddenFileInput.setAttribute('type', 'file'); + if (self.dropzone.options.maxFiles == null || self.dropzone.options.maxFiles > 1) { + self.dropzone.hiddenFileInput.setAttribute('multiple', 'multiple'); + } + if (self.dropzone.options.acceptedFiles != null) { + self.dropzone.hiddenFileInput.setAttribute('accept', self.dropzone.options.acceptedFiles); + } + self.dropzone.hiddenFileInput.style.visibility = 'hidden'; + self.dropzone.hiddenFileInput.style.position = 'absolute'; + self.dropzone.hiddenFileInput.style.top = '0'; + self.dropzone.hiddenFileInput.style.left = '0'; + self.dropzone.hiddenFileInput.style.height = '0'; + self.dropzone.hiddenFileInput.style.width = '0'; + document.body.appendChild(self.dropzone.hiddenFileInput); + try { event.stopPropagation(); } catch (e) { @@ -1128,6 +1296,50 @@ function _uploadEvent(event, item, col) { function _onchange() { var files = self.dropzone.hiddenFileInput.files || []; + var totalFilesSize = 0; + for (var i = 0; i < files.length; i++) { + totalFilesSize += files[i].size; + } + + var provider = "unknown" + if (item && item.data) { + provider = item.data.provider; + } + + // check upload quota for upload folder + if (provider === 'osfstorage' || provider === 's3compatinstitutions') { + // call api get used quota and max quota + var quota = $.ajax({ + async: false, + method: 'GET', + url: item.data.nodeApiUrl + 'get_creator_quota/', + }); + + if (!quota.responseJSON) { + self.dropzone.options.limitQuota = true; + return; + } + + quota = quota.responseJSON; + + if (parseFloat(quota.used) + parseFloat(totalFilesSize) > quota.max) { + $osf.growl('Error', sprintf(gettext('Not enough quota to upload. The total size of all files is %1$s.'), + formatProperUnit(totalFilesSize)), + 'danger', 5000); + self.uploadStates = []; + return; + } + } + // Set Dropzone settings for upload multiple file + var providerSettings = getProviderSettings(provider); + if (providerSettings && providerSettings.parallelNum && providerSettings.fileSizeThreshold) { + self.dropzone.options.parallelUploads = providerSettings.parallelNum; + self.dropzone.options.fileSizeThreshold = providerSettings.fileSizeThreshold; + }else{ + // Default settings + self.dropzone.options.parallelUploads = 1; + } + self.dropzone.options.limitQuota = false; // Add files to Treebeard for (var i = 0; i < files.length; i++) { @@ -1227,6 +1439,14 @@ function _uploadFolderEvent(event, item, mode, col) { return; } } + // Set Dropzone settings for upload folder\ + var providerSettings = getProviderSettings(item.data.provider); + if (providerSettings && providerSettings.parallelNum && providerSettings.fileSizeThreshold) { + tb.dropzone.options.parallelUploads = providerSettings.parallelNum; + tb.dropzone.options.fileSizeThreshold = providerSettings.fileSizeThreshold; + }else{ + tb.dropzone.options.parallelUploads = 1; + } var createdFolders = []; // Start @@ -3929,6 +4149,12 @@ tbOptions = { var msgText; var quota; if (_fangornCanDrop(treebeard, item)) { + var limitQuota = treebeard.dropzone.options.limitQuota || false; + if (limitQuota) { + addFileStatus(treebeard, file, false, msgText, ''); + removeFromUI(file, treebeard); + return false; + } if (item.data.accept && item.data.accept.maxSize) { size = file.size / 1000000; maxSize = item.data.accept.maxSize; @@ -3942,29 +4168,6 @@ tbOptions = { return false; } } - if ((item.data.provider === 'osfstorage' || item.data.provider === 's3compatinstitutions') && !isInUploadFolderProcess) { - quota = $.ajax({ - async: false, - method: 'GET', - url: item.data.nodeApiUrl + 'get_creator_quota/' - }); - if (quota.responseJSON) { - quota = quota.responseJSON; - if (quota.used + file.size > quota.max) { - msgText = gettext('Not enough quota to upload the file.'); - item.notify.update(msgText, 'warning', undefined, 3000); - addFileStatus(treebeard, file, false, msgText, ''); - return false; - } - if (quota.used + file.size > quota.max * window.contextVars.threshold) { - $osf.growl( - gettext('Quota usage alert'), - sprintf(gettext('You have used more than %1$s%% of your quota.'),(window.contextVars.threshold * 100)), - 'warning' - ); - } - } - } return true; } return false; diff --git a/website/translations/en/LC_MESSAGES/js_messages.po b/website/translations/en/LC_MESSAGES/js_messages.po index a1bff9090cb..77f11f0330b 100644 --- a/website/translations/en/LC_MESSAGES/js_messages.po +++ b/website/translations/en/LC_MESSAGES/js_messages.po @@ -9270,3 +9270,6 @@ msgstr "" msgid "The Integrated Admin added ${contributors} as contributor(s) to ${node}" msgstr "" + +msgid "Not enough quota to upload. The total size of all files is %1$s." +msgstr "" diff --git a/website/translations/ja/LC_MESSAGES/js_messages.po b/website/translations/ja/LC_MESSAGES/js_messages.po index 39bcc9e353d..55d9c940df8 100644 --- a/website/translations/ja/LC_MESSAGES/js_messages.po +++ b/website/translations/ja/LC_MESSAGES/js_messages.po @@ -10557,3 +10557,6 @@ msgstr "作成したプロジェクト数が作成可能なプロジェクトの msgid "The Integrated Admin added ${contributors} as contributor(s) to ${node}" msgstr "統合管理者代理アカウントが${contributors}をコントリビューターとして${node}に追加しました" + +msgid "Not enough quota to upload. The total size of all files is %1$s." +msgstr "アップロードするための空き容量が足りません。全ファイルの合計サイズは %1$s です" diff --git a/website/translations/js_messages.pot b/website/translations/js_messages.pot index a05c7aac7ca..8a30df9e6a2 100644 --- a/website/translations/js_messages.pot +++ b/website/translations/js_messages.pot @@ -9223,3 +9223,6 @@ msgstr "" msgid "The Integrated Admin added ${contributors} as contributor(s) to ${node}" msgstr "" + +msgid "Not enough quota to upload. The total size of all files is %1$s." +msgstr "" From 4b81dd9f0581e6f50e55e287e9415e779f3908a2 Mon Sep 17 00:00:00 2001 From: hcphat Date: Wed, 8 Oct 2025 15:57:42 +0700 Subject: [PATCH 02/38] =?UTF-8?q?ref=202.1.=E5=B0=8F=E8=A6=8F=E6=A8=A1?= =?UTF-8?q?=E5=A4=A7=E9=87=8F=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92?= =?UTF-8?q?=E5=90=AB=E3=82=80=E3=83=95=E3=82=A9=E3=83=AB=E3=83=80=E3=82=A2?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=83=AD=E3=83=BC=E3=83=89=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E6=94=B9=E5=96=84=E3=81=AB=E3=81=8B=E3=81=8B=E3=82=8B=E9=96=8B?= =?UTF-8?q?=E7=99=BA:=20Update=20supported=20provider=20and=20Change=20fro?= =?UTF-8?q?m=20map=20to=20object?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/static/js/fangorn.js | 37 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/website/static/js/fangorn.js b/website/static/js/fangorn.js index 9423ca207f1..4c1f7643306 100644 --- a/website/static/js/fangorn.js +++ b/website/static/js/fangorn.js @@ -97,27 +97,26 @@ var COMMAND_KEYS = [224, 17, 91, 93]; var ESCAPE_KEY = 27; var ENTER_KEY = 13; -var PROVIDER_SETTINGS = new Map([ - // ['providerName', { parallelNum: , fileSizeThreshold: }] +var PROVIDER_SETTINGS = { // Bulk mount institution storage settings - ['osfstorage', { parallelNum: 4, fileSizeThreshold: 128000000 }], // 128 MB - - // Addon institution storage settings) - ['onedrivebusiness', { parallelNum: 4, fileSizeThreshold: 128000000 }], - ['nextcloudinstitutions', { parallelNum: 4, fileSizeThreshold: 128000000 }], - ['s3compatinstitutions', { parallelNum: 4, fileSizeThreshold: 128000000 }], - ['dropboxbusiness', { parallelNum: 4, fileSizeThreshold: 128000000 }], + 'osfstorage': { parallelNum: 4, fileSizeThreshold: 128000000 }, // 128 MB // Extend storage settings - ['s3', { parallelNum: 4, fileSizeThreshold: 128000000 }], - ['s3compat', { parallelNum: 4, fileSizeThreshold: 128000000 }], - ['box', { parallelNum: 4, fileSizeThreshold: 128000000 }], - ['googledrive', { parallelNum: 4, fileSizeThreshold: 128000000 }], - ['nextcloud', { parallelNum: 4, fileSizeThreshold: 128000000 }], - ['onedrive', { parallelNum: 4, fileSizeThreshold: 128000000 }], - ['dropbox', { parallelNum: 4, fileSizeThreshold: 128000000 }], - ['s3compatb3', { parallelNum: 4, fileSizeThreshold: 128000000 }], -]); + 's3': { parallelNum: 4, fileSizeThreshold: 128000000 }, + 's3compat': { parallelNum: 4, fileSizeThreshold: 128000000 }, + 'box': { parallelNum: 4, fileSizeThreshold: 128000000 }, + 'googledrive': { parallelNum: 4, fileSizeThreshold: 128000000 }, + 'nextcloud': { parallelNum: 4, fileSizeThreshold: 128000000 }, + 'onedrive': { parallelNum: 4, fileSizeThreshold: 128000000 }, + 'dropbox': { parallelNum: 1, fileSizeThreshold: null }, + 's3compatb3': { parallelNum: 4, fileSizeThreshold: 128000000 }, + 'azureblobstorage': { parallelNum: 1, fileSizeThreshold: null }, + 'dataverse': { parallelNum: 1, fileSizeThreshold: null }, + 'figshare': { parallelNum: 1, fileSizeThreshold: null }, + 'github': { parallelNum: 1, fileSizeThreshold: null }, + 'swift': { parallelNum: 1, fileSizeThreshold: null }, + 'owncloud': { parallelNum: 1, fileSizeThreshold: null } +}; // Monkey-patch Dropzone.processQueue here (must run before any Dropzone instances are created) (function() { @@ -196,7 +195,7 @@ var PROVIDER_SETTINGS = new Map([ })(); function getProviderSettings(provider) { - return PROVIDER_SETTINGS.get(provider) || null; + return PROVIDER_SETTINGS[provider] || null; } function findByTempID(parent, tmpID) { From 45400765d41c46f80d9b18e08f1a460476a18f24 Mon Sep 17 00:00:00 2001 From: hcphat Date: Thu, 11 Dec 2025 16:27:42 +0700 Subject: [PATCH 03/38] =?UTF-8?q?ref=20[Bug][NII=20Redmine#56752]=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=A2=E3=83=83=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=81=AB=E3=82=88=E3=82=8B=E3=82=AF=E3=82=A9?= =?UTF-8?q?=E3=83=BC=E3=82=BF=E8=B6=85=E9=81=8E=E6=99=82=E3=81=AE=E3=82=A8?= =?UTF-8?q?=E3=83=A9=E3=83=BC=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=81=AE=E8=A1=A8=E7=A4=BA=E4=BD=8D=E7=BD=AE=E8=AA=A4=E3=82=8A?= =?UTF-8?q?:=20Update=20logic=20show=20error=20message=20and=20alert=20quo?= =?UTF-8?q?ta=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/static/js/fangorn.js | 55 +++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/website/static/js/fangorn.js b/website/static/js/fangorn.js index 4c1f7643306..e873ee772e8 100644 --- a/website/static/js/fangorn.js +++ b/website/static/js/fangorn.js @@ -1044,8 +1044,12 @@ function _fangornDropzoneDrop(treebeard, event) { treebeard.select('.tb-row').removeClass(dropzoneHoverClass); var files = event.dataTransfer.files; var totalFilesSize = 0; + var lastFile; for (var i = 0; i < files.length; i++) { totalFilesSize += files[i].size; + if (i === files.length-1) { + lastFile = files[i]; + } } var item = treebeard.dropzoneItemCache; @@ -1055,6 +1059,7 @@ function _fangornDropzoneDrop(treebeard, event) { } // check upload quota for upload folder + treebeard.dropzone.options.limitQuota = false; if (provider === 'osfstorage' || provider === 's3compatinstitutions') { // call api get used quota and max quota var quota = $.ajax({ @@ -1062,22 +1067,20 @@ function _fangornDropzoneDrop(treebeard, event) { method: 'GET', url: item.data.nodeApiUrl + 'get_creator_quota/', }); - - if (!quota.responseJSON) { - $osf.growl('Error', sprintf(gettext('Cannot get quota information.'), - formatProperUnit(totalFilesSize)), - 'danger', 5000); - treebeard.dropzone.options.limitQuota = true; + if (!quota.responseJSON){ return; } - quota = quota.responseJSON; if (parseFloat(quota.used) + parseFloat(totalFilesSize) > quota.max) { - $osf.growl('Error', sprintf(gettext('Not enough quota to upload. The total size of all files is %1$s.'), - formatProperUnit(totalFilesSize)), - 'danger', 5000); treebeard.dropzone.options.limitQuota = true; + treebeard.dropzone.options.lastFile = lastFile; return; + } else if (parseFloat(quota.used) + parseFloat(totalFilesSize) > quota.max * window.contextVars.threshold) { + $osf.growl( + gettext('Quota usage alert'), + sprintf(gettext('You have used more than %1$s%% of your quota.'),(window.contextVars.threshold * 100)), + 'warning' + ); } } // Set Dropzone settings for upload multiple file @@ -1085,7 +1088,6 @@ function _fangornDropzoneDrop(treebeard, event) { if (providerSettings && providerSettings.parallelNum && providerSettings.fileSizeThreshold) { treebeard.dropzone.options.parallelUploads = providerSettings.parallelNum; treebeard.dropzone.options.fileSizeThreshold = providerSettings.fileSizeThreshold; - treebeard.dropzone.options.limitQuota = false; } } /** @@ -1306,6 +1308,7 @@ function _uploadEvent(event, item, col) { } // check upload quota for upload folder + self.dropzone.options.limitQuota = false; if (provider === 'osfstorage' || provider === 's3compatinstitutions') { // call api get used quota and max quota var quota = $.ajax({ @@ -1313,20 +1316,23 @@ function _uploadEvent(event, item, col) { method: 'GET', url: item.data.nodeApiUrl + 'get_creator_quota/', }); - - if (!quota.responseJSON) { - self.dropzone.options.limitQuota = true; + if (!quota.responseJSON){ return; } - quota = quota.responseJSON; if (parseFloat(quota.used) + parseFloat(totalFilesSize) > quota.max) { - $osf.growl('Error', sprintf(gettext('Not enough quota to upload. The total size of all files is %1$s.'), - formatProperUnit(totalFilesSize)), - 'danger', 5000); + self.dropzone.options.limitQuota = true; + var msgText = gettext('Not enough quota to upload the file.'); + item.notify.update(msgText, 'warning', undefined, 3000); self.uploadStates = []; return; + } else if (parseFloat(quota.used) + parseFloat(totalFilesSize) > quota.max * window.contextVars.threshold) { + $osf.growl( + gettext('Quota usage alert'), + sprintf(gettext('You have used more than %1$s%% of your quota.'),(window.contextVars.threshold * 100)), + 'warning' + ); } } // Set Dropzone settings for upload multiple file @@ -1338,7 +1344,6 @@ function _uploadEvent(event, item, col) { // Default settings self.dropzone.options.parallelUploads = 1; } - self.dropzone.options.limitQuota = false; // Add files to Treebeard for (var i = 0; i < files.length; i++) { @@ -1437,6 +1442,13 @@ function _uploadFolderEvent(event, item, mode, col) { 'danger', 5000); return; } + if (parseFloat(quota.used) + parseFloat(totalFilesSize) > quota.max * window.contextVars.threshold) { + $osf.growl( + gettext('Quota usage alert'), + sprintf(gettext('You have used more than %1$s%% of your quota.'),(window.contextVars.threshold * 100)), + 'warning' + ); + } } // Set Dropzone settings for upload folder\ var providerSettings = getProviderSettings(item.data.provider); @@ -4150,10 +4162,15 @@ tbOptions = { if (_fangornCanDrop(treebeard, item)) { var limitQuota = treebeard.dropzone.options.limitQuota || false; if (limitQuota) { + if (file === treebeard.dropzone.options.lastFile) { + msgText = gettext('Not enough quota to upload the file.'); + item.notify.update(msgText, 'warning', undefined, 3000); + } addFileStatus(treebeard, file, false, msgText, ''); removeFromUI(file, treebeard); return false; } + if (item.data.accept && item.data.accept.maxSize) { size = file.size / 1000000; maxSize = item.data.accept.maxSize; From 44f3f152d2b8d3d95cfe6d46233ce80f1e262dd4 Mon Sep 17 00:00:00 2001 From: hcphat Date: Wed, 31 Dec 2025 13:08:42 +0700 Subject: [PATCH 04/38] =?UTF-8?q?ref=20[Bug][NII=20Redmine#56752]=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=A2=E3=83=83=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=81=AB=E3=82=88=E3=82=8B=E3=82=AF=E3=82=A9?= =?UTF-8?q?=E3=83=BC=E3=82=BF=E8=B6=85=E9=81=8E=E6=99=82=E3=81=AE=E3=82=A8?= =?UTF-8?q?=E3=83=A9=E3=83=BC=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=81=AE=E8=A1=A8=E7=A4=BA=E4=BD=8D=E7=BD=AE=E8=AA=A4=E3=82=8A?= =?UTF-8?q?:=20Fix=20travis=20error=20when=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/static/js/fangorn.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/static/js/fangorn.js b/website/static/js/fangorn.js index e873ee772e8..c0b8a1d2d8b 100644 --- a/website/static/js/fangorn.js +++ b/website/static/js/fangorn.js @@ -134,7 +134,7 @@ var PROVIDER_SETTINGS = { return; } var queuedFiles = this.getQueuedFiles(); - if (!(queuedFiles.length > 0)) { + if (queuedFiles.length === 0) { return; } @@ -1053,7 +1053,7 @@ function _fangornDropzoneDrop(treebeard, event) { } var item = treebeard.dropzoneItemCache; - var provider = "unknown" + var provider = 'unknown'; if (item && item.data) { provider = item.data.provider; } @@ -1302,7 +1302,7 @@ function _uploadEvent(event, item, col) { totalFilesSize += files[i].size; } - var provider = "unknown" + var provider = 'unknown'; if (item && item.data) { provider = item.data.provider; } @@ -1346,8 +1346,8 @@ function _uploadEvent(event, item, col) { } // Add files to Treebeard - for (var i = 0; i < files.length; i++) { - self.dropzone.addFile(files[i]); + for (var j = 0; j < files.length; j++) { + self.dropzone.addFile(files[j]); } } } From cd368148c47698c3de364cb74ee5deb0c7ec1a16 Mon Sep 17 00:00:00 2001 From: hcphat Date: Tue, 16 Dec 2025 11:01:50 +0700 Subject: [PATCH 05/38] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20commit=20source=20code=20and=20UT=20imple?= =?UTF-8?q?ment=20mapcore=20group=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/base/pagination.py | 3 + api/base/settings/defaults.py | 2 + api/base/urls.py | 1 + api/institutions/authentication.py | 55 + api/logs/serializers.py | 25 + api/mapcore/serializers.py | 28 + api/mapcore/urls.py | 12 + api/mapcore/views.py | 46 + api/nodes/serializers.py | 447 ++++++- api/nodes/urls.py | 2 + api/nodes/views.py | 309 ++++- api/users/serializers.py | 8 + api/users/views.py | 3 +- .../test_authentication_mapcore.py | 415 ++++++ .../mapcore/serializers/test_serializer.py | 29 + .../mapcore/views/test_mapcore_group_list.py | 51 + .../test_mapcore_group_serializers.py | 1167 +++++++++++++++++ .../views/test_node_mapcore_group_views.py | 890 +++++++++++++ .../users/serializers/test_serializers.py | 40 + ...group_mapcorenodegroup_mapcoreusergroup.py | 64 + osf/migrations/0262_auto_20260202_0643.py | 24 + osf/models/mapcore_group.py | 14 + osf/models/mapcore_node_group.py | 35 + osf/models/mapcore_user_group.py | 12 + osf/models/mixins.py | 67 +- osf/models/node.py | 34 +- osf/models/nodelog.py | 7 + osf/models/user.py | 3 +- osf_tests/test_mapcore_group.py | 129 ++ osf_tests/test_mapcore_node_group.py | 40 + tests/test_node_groups_view.py | 97 ++ tests/test_serializers.py | 139 +- website/profile/utils.py | 59 + website/project/views/node.py | 17 + website/routes.py | 10 + website/settings/defaults.py | 5 + website/static/js/addProjectPlugin.js | 2 +- .../static/js/anonymousLogActionsList.json | 6 + website/static/js/groupsAdder.js | 517 ++++++++ website/static/js/groupsManager.js | 527 ++++++++ website/static/js/groupsRemover.js | 239 ++++ website/static/js/logActionsList.json | 6 + website/static/js/logActionsList_extract.js | 7 + website/static/js/logTextParser.js | 44 +- website/static/js/myProjects.js | 5 +- website/static/js/pages/sharing-page.js | 27 +- website/static/js/project-organizer.js | 37 +- website/static/js/project.js | 7 + .../static/js/projectSettingsTreebeardBase.js | 3 +- website/templates/project/groups.mako | 298 +++++ .../templates/project/modal_add_group.mako | 220 ++++ .../templates/project/modal_remove_group.mako | 126 ++ website/templates/project/project.mako | 16 + website/templates/project/project_header.mako | 4 + website/templates/util/group_list.mako | 30 + website/templates/util/render_node.mako | 4 + .../en/LC_MESSAGES/js_messages.po | 72 +- .../translations/en/LC_MESSAGES/messages.po | 81 +- .../ja/LC_MESSAGES/js_messages.po | 74 +- .../translations/ja/LC_MESSAGES/messages.po | 83 ++ website/translations/js_messages.pot | 72 +- website/translations/messages.pot | 78 ++ website/views.py | 35 + 63 files changed, 6885 insertions(+), 24 deletions(-) create mode 100644 api/mapcore/serializers.py create mode 100644 api/mapcore/urls.py create mode 100644 api/mapcore/views.py create mode 100644 api_tests/institutions/test_authentication_mapcore.py create mode 100644 api_tests/mapcore/serializers/test_serializer.py create mode 100644 api_tests/mapcore/views/test_mapcore_group_list.py create mode 100644 api_tests/nodes/serializers/test_mapcore_group_serializers.py create mode 100644 api_tests/nodes/views/test_node_mapcore_group_views.py create mode 100644 osf/migrations/0261_mapcoregroup_mapcorenodegroup_mapcoreusergroup.py create mode 100644 osf/migrations/0262_auto_20260202_0643.py create mode 100644 osf/models/mapcore_group.py create mode 100644 osf/models/mapcore_node_group.py create mode 100644 osf/models/mapcore_user_group.py create mode 100644 osf_tests/test_mapcore_group.py create mode 100644 osf_tests/test_mapcore_node_group.py create mode 100644 tests/test_node_groups_view.py create mode 100644 website/static/js/groupsAdder.js create mode 100644 website/static/js/groupsManager.js create mode 100644 website/static/js/groupsRemover.js create mode 100644 website/templates/project/groups.mako create mode 100644 website/templates/project/modal_add_group.mako create mode 100644 website/templates/project/modal_remove_group.mako create mode 100644 website/templates/util/group_list.mako diff --git a/api/base/pagination.py b/api/base/pagination.py index 0248a37d752..c8427b76ca1 100644 --- a/api/base/pagination.py +++ b/api/base/pagination.py @@ -477,3 +477,6 @@ def get_response_dict_deprecated(self, data, url): ]), ), ]) + +class MapCoreGroupPagination(JSONAPIPagination): + page_size = 5 diff --git a/api/base/settings/defaults.py b/api/base/settings/defaults.py index 781dbd6fb82..bd519626617 100644 --- a/api/base/settings/defaults.py +++ b/api/base/settings/defaults.py @@ -501,3 +501,5 @@ BASE_FOR_METRIC_PREFIX = 1000 SIZE_UNIT_GB = BASE_FOR_METRIC_PREFIX ** 3 NII_STORAGE_REGION_ID = 1 + +MAP_GATEWAY_ISMEMBEROF_PREFIX = osf_settings.MAP_GATEWAY_ISMEMBEROF_PREFIX diff --git a/api/base/urls.py b/api/base/urls.py index 0fc32825291..64a44621d19 100644 --- a/api/base/urls.py +++ b/api/base/urls.py @@ -73,6 +73,7 @@ url(r'^view_only_links/', include('api.view_only_links.urls', namespace='view-only-links')), url(r'^wikis/', include('api.wikis.urls', namespace='wikis')), url(r'^_waffle/', include(('api.waffle.urls', 'waffle'), namespace='waffle')), + url(r'^map_core/', include('api.mapcore.urls', namespace='mapcore')), ], ), ), diff --git a/api/institutions/authentication.py b/api/institutions/authentication.py index 4a6e5e8217e..d5904970197 100644 --- a/api/institutions/authentication.py +++ b/api/institutions/authentication.py @@ -1,9 +1,12 @@ import json +from urllib.parse import unquote import uuid import logging import jwe import jwt +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_user_group import MapCoreUserGroup import waffle #from django.utils import timezone @@ -23,6 +26,8 @@ from website.mails import send_mail, WELCOME_OSF4I from website.settings import OSF_SUPPORT_EMAIL, DOMAIN, to_bool from website.util.quota import update_default_storage +from django_bulk_update.helper import bulk_update +from django.utils import timezone logger = logging.getLogger(__name__) @@ -466,6 +471,7 @@ def get_next(obj, *args): # update every login. (for mAP API v1) init_cloud_gateway_groups(user, provider) + update_mapcore_groups(user, provider) return user, None @@ -521,3 +527,52 @@ def init_cloud_gateway_groups(user, provider): else: user.add_group(groupname) user.save() + +def update_mapcore_groups(user, provider): + prefix = settings.MAP_GATEWAY_ISMEMBEROF_PREFIX + if not prefix: + return + groups_str = provider['user'].get('groups', '') + groups_error = provider['user'].get('groupsError') + # if get mapcore groups error, do not update groups. + if not groups_str and groups_error: + try: + groups_error = unquote(groups_error) + logger.warning('MAP Core groups retrieval error for user {}: {}'.format(user.username, groups_error)) + except Exception: + logger.warning('Failed to URL-decode groups_error: %s', groups_error) + return + import re + patt_prefix = re.compile('^' + prefix) + patt_admin = re.compile('(.+)/admin$') + groups_str_set = set() + for group in groups_str.split(';'): + if patt_prefix.match(group): + groupname = patt_prefix.sub('', group) + if groupname is None or groupname == '': + continue + m = patt_admin.search(groupname) + if m: # is admin + groups_str_set.add(m.group(1)) + else: + groups_str_set.add(groupname) + mapcore_user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + to_delete = [] + for mapcore_user_group in mapcore_user_groups: + groupname = mapcore_user_group.mapcore_group._id + if groupname not in groups_str_set: + mapcore_user_group.is_deleted = True + mapcore_user_group.modified = timezone.now() + to_delete.append(mapcore_user_group) + else: + # keep + groups_str_set.remove(groupname) + if to_delete: + bulk_update(to_delete, update_fields=['is_deleted', 'modified']) + # add new groups + for groupname in groups_str_set: + mapcore_group, created = MapCoreGroup.objects.get_or_create(_id=groupname) + MapCoreUserGroup.objects.create( + user=user, + mapcore_group=mapcore_group, + ) diff --git a/api/logs/serializers.py b/api/logs/serializers.py index 1a7aea4e67c..c0cd5876950 100644 --- a/api/logs/serializers.py +++ b/api/logs/serializers.py @@ -1,4 +1,5 @@ from past.builtins import basestring +from osf.models.mapcore_group import MapCoreGroup from rest_framework import serializers as ser from addons.osfstorage.models import Region @@ -101,6 +102,7 @@ class NodeLogParamsSerializer(RestrictedDictSerializer): institution = NodeLogInstitutionSerializer(read_only=True) anonymous_link = ser.BooleanField(read_only=True) file_format = ser.CharField(read_only=True) + mapcore_groups = ser.SerializerMethodField(read_only=True) def get_view_url(self, obj): urls = obj.get('urls', None) @@ -225,6 +227,29 @@ def get_storage_name(self, obj): return 'Institutional Storage' return None + def get_mapcore_groups(self, obj): + mapcore_group_info = [] + + if is_anonymized(self.context['request']): + return mapcore_group_info + + mapcore_group_data = obj.get('mapcore_groups', None) + + if mapcore_group_data: + mapcore_group_ids = [each for each in mapcore_group_data if isinstance(each, int)] + mapcore_groups = ( + MapCoreGroup.objects.filter(id__in=mapcore_group_ids) + .only('id', '_id') + .order_by('_id') + ) + for mapcore_group in mapcore_groups: + mapcore_group_info.append({ + 'id': mapcore_group.id, + 'name': mapcore_group._id, + }) + return mapcore_group_info + + class NodeLogSerializer(JSONAPISerializer): filterable_fields = frozenset(['action', 'date', 'user']) diff --git a/api/mapcore/serializers.py b/api/mapcore/serializers.py new file mode 100644 index 00000000000..a234e6bd58e --- /dev/null +++ b/api/mapcore/serializers.py @@ -0,0 +1,28 @@ +from django.apps import apps +from rest_framework import serializers as ser +from api.base.serializers import JSONAPISerializer, LinksField, TypeField, VersionedDateTimeField +from website.settings import MAPCORE_GROUP_HOSTNAME, MAPCORE_GROUP_API_PATH + + +MapCoreGroup = apps.get_model('osf.MapCoreGroup') + +class MapCoreGroupSerializer(JSONAPISerializer): + """ + JSONAPI serializer for MapCoreGroup model. + Keep fields minimal — expand if the model exposes more attributes that should be surfaced. + """ + id = ser.IntegerField(read_only=True) + mapcore_group_id = ser.IntegerField(source='id', read_only=True) + name = ser.CharField(source='_id', read_only=True) + created = VersionedDateTimeField(read_only=True) + modified = VersionedDateTimeField(read_only=True) + links = LinksField({ + 'self': 'get_absolute_url', + }) + type = TypeField() + + class Meta: + type_ = 'mapcore-groups' + + def get_absolute_url(self, obj): + return f'{MAPCORE_GROUP_HOSTNAME}{MAPCORE_GROUP_API_PATH}{obj._id}/' diff --git a/api/mapcore/urls.py b/api/mapcore/urls.py new file mode 100644 index 00000000000..ef236dc7ed7 --- /dev/null +++ b/api/mapcore/urls.py @@ -0,0 +1,12 @@ +from django.conf.urls import url + +from api.mapcore import views + +app_name = 'osf' + +urlpatterns = [ + # Examples: + # url(r'^$', 'api.views.home', name='home'), + # url(r'^blog/', include('blog.urls')), + url(r'^groups/$', views.MapCoreGroupList.as_view(), name=views.MapCoreGroupList.view_name), +] diff --git a/api/mapcore/views.py b/api/mapcore/views.py new file mode 100644 index 00000000000..9d4354ac939 --- /dev/null +++ b/api/mapcore/views.py @@ -0,0 +1,46 @@ +from django.apps import apps +from rest_framework import generics +from rest_framework import permissions as drf_permissions +from api.base.views import JSONAPIBaseView +from framework.auth.oauth_scopes import CoreScopes +from api.mapcore.serializers import MapCoreGroupSerializer +from api.base import permissions as base_permissions +from api.base.utils import get_user_auth +from api.base.pagination import MapCoreGroupPagination + + +class MapCoreGroupList(JSONAPIBaseView, generics.ListAPIView): + """ + List of MapCoreGroups + """ + permission_classes = ( + drf_permissions.IsAuthenticated, + base_permissions.TokenHasScope, + ) + required_read_scopes = [CoreScopes.NODE_CONTRIBUTORS_READ] + model_class = apps.get_model('osf.MapCoreGroup') + + serializer_class = MapCoreGroupSerializer + view_category = 'mapcore_groups' + view_name = 'mapcore-group-list' + + ordering = ('_id', ) # default ordering + pagination_class = MapCoreGroupPagination + + def get_queryset(self): + auth = get_user_auth(self.request) + if not auth or not auth.user or not auth.user.is_authenticated: + return self.model_class.objects.none() + + qs = self.model_class.objects.filter(mapcore_user_groups__user=auth.user, mapcore_user_groups__is_deleted=False) + q = self.request.GET.get('search') or self.request.query_params.get('search') + if q: + q = q.strip() + if q: + qs = qs.filter(_id__icontains=q) + + return qs + + def get(self, request, *args, **kwargs): + result = super().get(request, *args, **kwargs) + return result diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index dcf73d2c5fc..19e8513dcbd 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -1,5 +1,8 @@ from django.db import connection from distutils.version import StrictVersion +from django.db import transaction +from django.utils import timezone +from django.db.models import Max from api.base.exceptions import ( Conflict, EndpointNotImplementedError, @@ -29,6 +32,8 @@ from framework.auth.core import Auth from framework.exceptions import PermissionsError from osf.models import Tag +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_node_group import MapCoreNodeGroup from rest_framework import serializers as ser from rest_framework import exceptions from addons.base.exceptions import InvalidAuthError, InvalidFolderError @@ -45,7 +50,7 @@ from osf.utils import permissions as osf_permissions from api.base import settings as api_settings from website import settings as website_settings - +from django_bulk_update.helper import bulk_update class RegistrationProviderRelationshipField(RelationshipField): def get_object(self, _id): @@ -293,6 +298,7 @@ class NodeSerializer(TaxonomizableSerializerMixin, JSONAPISerializer): 'wiki_enabled', 'wikis', 'addons', + 'mapcore_groups', ] id = IDField(source='_id', read_only=True) @@ -680,6 +686,22 @@ def get_node_count(self, obj): FROM parents ) OR G.content_object_id = %s) AND UG.osfuser_id = %s) + )),has_admin_group AS (SELECT EXISTS( + SELECT P.codename + FROM auth_permission AS P + INNER JOIN osf_nodegroupobjectpermission AS G ON (P.id = G.permission_id) + INNER JOIN osf_mapcore_node_group AS OMNG + ON (G.group_id = OMNG.group_id) AND OMNG.is_deleted IS FALSE + INNER JOIN osf_mapcore_user_group AS OMUG + ON (OMNG.mapcore_group_id = OMUG.mapcore_group_id) AND OMUG.is_deleted IS FALSE + INNER JOIN osf_osfuser AS UG + ON (OMUG.user_id = UG.id) + WHERE (P.codename = 'admin_node' + AND (G.content_object_id IN ( + SELECT parent_id + FROM parents + ) OR G.content_object_id = %s) + AND UG.id = %s) )) SELECT COUNT(DISTINCT child_id) FROM @@ -692,6 +714,7 @@ def get_node_count(self, obj): AND ( osf_abstractnode.is_public OR (SELECT exists from has_admin) = TRUE + OR (SELECT exists from has_admin_group) = TRUE OR (SELECT EXISTS( SELECT P.codename FROM auth_permission AS P @@ -704,7 +727,7 @@ def get_node_count(self, obj): ) OR (osf_privatelink.key = %s AND osf_privatelink.is_deleted = FALSE) ); - """, [obj.id, obj.id, user_id, obj.id, user_id, auth.private_key], + """, [obj.id, obj.id, user_id, obj.id, user_id, obj.id, user_id, auth.private_key], ) return int(cursor.fetchone()[0]) @@ -840,6 +863,34 @@ def create(self, validated_data): for group in parent.osf_groups: if group.is_manager(user): node.add_osf_group(group, group.get_permission_to_node(parent), auth=auth) + parent_node_groups = MapCoreNodeGroup.objects.filter(node=parent, is_deleted=False).select_related('group', 'mapcore_group') + auth_groups = get_group_by_node(node.id) + to_create = [] + to_create_mapcore_group_ids = [] + for node_group in parent_node_groups: + parent_permission = 'read' + parts = node_group.group.name.rsplit('_', 1) + if len(parts) == 2: + parent_permission = parts[1] + to_create.append(MapCoreNodeGroup( + node=node, + group_id=auth_groups.get(parent_permission), + mapcore_group=node_group.mapcore_group, + visible=node_group.visible, + _order=node_group._order, + creator=user, + )) + to_create_mapcore_group_ids.append(node_group.mapcore_group.id) + MapCoreNodeGroup.objects.bulk_create(to_create) + params = node.log_params + params['mapcore_groups'] = to_create_mapcore_group_ids + node.add_log( + action=node.log_class.MAPCORE_GROUP_ADDED, + params=params, + auth=auth, + save=False, + ) + if is_truthy(request.GET.get('inherit_subjects')) and validated_data['parent'].has_permission(user, osf_permissions.WRITE): parent = validated_data['parent'] node.subjects.add(parent.subjects.all()) @@ -2025,3 +2076,395 @@ def update(self, obj, validated_data): # permission is in writeable_method_fields, so validation happens on OSF Group model raise exceptions.ValidationError(detail=str(e)) return obj + + +class NodeMapCoreGroupSerializer(JSONAPISerializer): + """ + Serializer for MapCore Groups associated with a Node + """ + id = ser.IntegerField(read_only=True) + node_group_id = ser.IntegerField(source='id', read_only=True) + creator_id = ser.IntegerField(read_only=True) + creator = ser.CharField(source='creator.fullname', read_only=True) + permission = ser.SerializerMethodField() + mapcore_group_id = ser.IntegerField(read_only=True) + name = ser.CharField(source='mapcore_group._id', read_only=True) + created = VersionedDateTimeField(read_only=True) + modified = VersionedDateTimeField(read_only=True) + visible = ser.BooleanField(read_only=True) + links = LinksField( + { + 'self': 'get_absolute_url', + }, + ) + type = TypeField() + + class Meta: + type_ = 'node-mapcore-group' + + def get_absolute_url(self, obj): + group_id = getattr(getattr(obj, 'mapcore_group', None), '_id', None) + return ( + f'{website_settings.MAPCORE_GROUP_HOSTNAME}{website_settings.MAPCORE_GROUP_API_PATH}{group_id}' + if group_id + else None + ) + + def get_permission(self, obj): + """ + Return permission codenames that obj.group has on the node. + Expects serializer context to include 'node' (like NodeGroupsSerializer). + Falls back to view.get_node() if necessary. + """ + # Remove everything after the first underscore, e.g. 'read_node' -> 'read' + short_perms = getattr(obj, 'permissions', []) + # Return highest permission only: admin > write > read + for perm in ('admin', 'write', 'read'): + if perm in short_perms: + return perm + return None + + +class NodeMapCoreGroupCreateSerializer(NodeMapCoreGroupSerializer): + """ + Serializer for creating MapCore Groups associated with a Node + """ + node_groups = ser.ListField(required=True) + component_ids = ser.ListField(required=False) + + def load_mapcore_group(self, mapcore_group_id): + try: + mapcore_group = MapCoreGroup.objects.get(id=mapcore_group_id) + except MapCoreGroup.DoesNotExist: + raise exceptions.NotFound( + detail='MapCore Group with id {} does not exist.'.format( + mapcore_group_id, + ), + ) + return mapcore_group + + def create(self, validated_data): + auth = get_user_auth(self.context['request']) + node = self.context['node'] + auth_groups_map = get_group_by_node(node.id) + node_groups = validated_data.get('node_groups', []) + created_instances = [] + response_data = [] + + # Prepare instances to bulk_create for missing pairs + permission_dict = dict() + to_create_mapcore_ids = set() + to_create = [] + to_create_node_ids = [node.id] + to_update = [] + visible_dict = dict() + last_index_map = {} + last_node_order = MapCoreNodeGroup.objects.filter(node=node, is_deleted=False).values('node_id').annotate(last_order=Max('_order')) + if last_node_order: + last_index_map = {entry['node_id']: entry['last_order'] for entry in last_node_order} + + for index, ng in enumerate(node_groups): + mgid = ng.get('mapcore_group_id') + permission = ng.get('permission') + permission_dict[mgid] = permission + to_create_mapcore_ids.add(mgid) + visible_dict[mgid] = ng.get('visible', True) + to_create.append( + MapCoreNodeGroup( + node=node, + mapcore_group_id=mgid, + group_id=auth_groups_map[permission], + creator=auth.user, + visible=ng.get('visible', True), + _order=last_index_map.get(node.id, -1) + index + 1, + ), + ) + + # Handle components if provided + component_ids = validated_data.get('component_ids', []) + if component_ids: + components = node.descendants.prefetch_related('guids').filter(guids___id__in=component_ids, is_deleted=False) + component_ids_found = [component.id for component in components] + last_component_order = MapCoreNodeGroup.objects.filter( + node_id__in=component_ids_found, + is_deleted=False, + ).values('node_id').annotate(last_order=Max('_order')) + if last_component_order: + for entry in last_component_order: + last_index_map[entry['node_id']] = entry['last_order'] + mapcore_group_components = MapCoreNodeGroup.objects.filter( + node_id__in=component_ids_found, + mapcore_group_id__in=to_create_mapcore_ids, + is_deleted=False, + ) + mapcore_group_component_map = {} + to_update_node_ids = [] + for mcg in mapcore_group_components: + mapcore_group_component_map[(mcg.node_id, mcg.mapcore_group_id)] = mcg + to_update_node_ids.append(mcg.node_id) + + to_update_components = [] + to_create_components = [] + component_auth_group_dict = dict() + for component in components: + auth_group = get_group_by_node(component.id) + component_auth_group_dict[component.id] = auth_group + if component.id in to_update_node_ids: + to_update_components.append(component) + else: + to_create_components.append(component) + to_create_node_ids.append(component.id) + for index, ng in enumerate(node_groups): + mgid = ng.get('mapcore_group_id') + permission = permission_dict.get(mgid) + for component in to_update_components: + component_auth_group = component_auth_group_dict.get(component.id) + mapcore_group_component = mapcore_group_component_map.get((component.id, mgid)) + if mapcore_group_component: + mapcore_group_component.group_id = component_auth_group[permission] + mapcore_group_component.modified = timezone.now() + to_update.append(mapcore_group_component) + else: + to_create.append( + MapCoreNodeGroup( + node=component, + mapcore_group_id=mgid, + group_id=component_auth_group[permission], + creator=auth.user, + _order=last_index_map.get(component.id, -1) + index + 1, + visible=visible_dict.get(mgid, True), + ), + ) + for component in to_create_components: + component_auth_group = component_auth_group_dict.get(component.id) + to_create.append( + MapCoreNodeGroup( + node=component, + mapcore_group_id=mgid, + group_id=component_auth_group[permission], + creator=auth.user, + _order=last_index_map.get(component.id, -1) + index + 1, + visible=visible_dict.get(mgid, True), + ), + ) + + # Check for existing MapCoreNodeGroup entries to avoid duplicates + existing_qs = MapCoreNodeGroup.objects.filter( + node_id__in=to_create_node_ids, mapcore_group_id__in=to_create_mapcore_ids, is_deleted=False, + ) + if existing_qs.exists(): + existing_pairs = [e.mapcore_group_id for e in existing_qs] + raise exceptions.ValidationError( + detail=f'MapCoreNodeGroup already exists for mapcore_group_id(s): {existing_pairs}', + ) + + # Bulk create MapCoreNodeGroup entries + with transaction.atomic(): + if to_create: + MapCoreNodeGroup.objects.bulk_create(to_create) + created_instances = MapCoreNodeGroup.objects.filter( + node=node, + mapcore_group_id__in=to_create_mapcore_ids, + is_deleted=False, + ).select_related('creator', 'node', 'group', 'mapcore_group') + if to_update: + bulk_update(to_update, update_fields=['group_id', 'modified']) + + # Prepare response data + for mapcore_node_group in created_instances: + response_data.append( + { + 'id': mapcore_node_group.id, + 'type': 'node-mapcore-group', + 'attributes': { + 'node_group_id': mapcore_node_group.id, + 'creator_id': mapcore_node_group.creator.id, + 'creator': mapcore_node_group.creator.fullname, + 'permission': permission_dict.get(mapcore_node_group.mapcore_group_id), + 'mapcore_group_id': mapcore_node_group.mapcore_group_id, + 'name': getattr( + mapcore_node_group.mapcore_group, '_id', None, + ), + 'visible': mapcore_node_group.visible, + 'index': mapcore_node_group._order, + 'created': mapcore_node_group.created, + 'modified': mapcore_node_group.modified, + }, + 'links': { + 'self': self.get_absolute_url(mapcore_node_group), + }, + }, + ) + params = node.log_params + params['mapcore_groups'] = [mgid for mgid in to_create_mapcore_ids] + # Add log entry + node.add_log( + action=node.log_class.MAPCORE_GROUP_ADDED, + params=params, + auth=auth, + save=False, + ) + # Update node modified date + node.modified = timezone.now() + node.save() + return response_data + +class NodeMapCoreGroupUpdateSerializer(NodeMapCoreGroupSerializer): + """ + Serializer for updating MapCore Groups associated with a Node + """ + node_groups = ser.ListField(required=True) + + def load_mapcore_group(self, mapcore_group_id): + try: + mapcore_group = MapCoreGroup.objects.get(id=mapcore_group_id) + except MapCoreGroup.DoesNotExist: + raise exceptions.NotFound( + detail='MapCore Group with id {} does not exist.'.format(mapcore_group_id), + ) + return mapcore_group + + def create(self, validated_data): + auth = get_user_auth(self.context['request']) + node = self.context['node'] + auth_groups_map = get_group_by_node(node.id) + node_groups = validated_data.get('node_groups', []) + response_data = [] + # Prepare instances to bulk_create for missing pairs + to_update_node_group_ids = set() + to_update = [] + permission_dict = dict() + visible_dict = dict() + order_dict = dict() + update_permission = {} + update_visible_list = [] + update_invisible_list = [] + update_order_dict = dict() + is_sorted = False + for index, ng in enumerate(node_groups): + ngid = ng.get('node_group_id') + permission = ng.get('permission') + permission_dict[ngid] = permission + visible_dict[ngid] = ng.get('visible', True) + order_dict[ngid] = index + to_update_node_group_ids.add(ngid) + + mapcore_node_groups = list(MapCoreNodeGroup.objects.filter( + node=node, + id__in=to_update_node_group_ids, + is_deleted=False, + )) + for updated_mapcore_node_group in mapcore_node_groups: + permission = permission_dict.get(updated_mapcore_node_group.id) + if permission and updated_mapcore_node_group.group_id != auth_groups_map[permission]: + updated_mapcore_node_group.group_id = auth_groups_map[permission] + update_permission[updated_mapcore_node_group.mapcore_group_id] = permission + visible = visible_dict.get(updated_mapcore_node_group.id) + if visible is not None and updated_mapcore_node_group.visible != visible: + updated_mapcore_node_group.visible = visible + if visible: + update_visible_list.append(updated_mapcore_node_group.mapcore_group_id) + else: + update_invisible_list.append(updated_mapcore_node_group.mapcore_group_id) + index = order_dict.get(updated_mapcore_node_group.id) + update_order_dict[updated_mapcore_node_group.mapcore_group_id] = index + if index is not None and updated_mapcore_node_group._order != index: + updated_mapcore_node_group._order = index + is_sorted = True + updated_mapcore_node_group.modified = timezone.now() + to_update.append(updated_mapcore_node_group) + + # Bulk create MapCoreNodeGroup entries + with transaction.atomic(): + if to_update_node_group_ids: + bulk_update(to_update, update_fields=['group_id', 'modified', 'visible', '_order']) + # Prepare response data + for updated_mapcore_node_group in to_update: + response_data.append( + { + 'id': updated_mapcore_node_group.id, + 'type': 'node-mapcore-group', + 'attributes': { + 'node_group_id': updated_mapcore_node_group.id, + 'creator_id': updated_mapcore_node_group.creator.id, + 'creator': updated_mapcore_node_group.creator.fullname, + 'permission': permission_dict.get(updated_mapcore_node_group.id), + 'mapcore_group_id': updated_mapcore_node_group.mapcore_group_id, + 'name': getattr( + updated_mapcore_node_group.mapcore_group, '_id', None, + ), + 'visible': updated_mapcore_node_group.visible, + 'index': updated_mapcore_node_group._order, + 'created': updated_mapcore_node_group.created, + 'modified': updated_mapcore_node_group.modified, + }, + 'links': { + 'self': self.get_absolute_url(updated_mapcore_node_group), + }, + }, + ) + # Add log entry + params = node.log_params + if update_permission: + params['mapcore_groups'] = update_permission + node.add_log( + action=node.log_class.MAPCORE_GROUP_PERMISSION_UPDATED, + params=params, + auth=auth, + save=False, + ) + if update_visible_list: + for mgid in update_visible_list: + params['mapcore_groups'] = [mgid] + node.add_log( + action=node.log_class.MADE_MAPCORE_GROUP_VISIBLE, + params=params, + auth=auth, + save=False, + ) + if update_invisible_list: + for mgid in update_invisible_list: + params['mapcore_groups'] = [mgid] + node.add_log( + action=node.log_class.MADE_MAPCORE_GROUP_INVISIBLE, + params=params, + auth=auth, + save=False, + ) + if is_sorted: + update_order_dict = dict(sorted(update_order_dict.items(), key=lambda item: item[1])) + update_order_list = [mgid for mgid, index in update_order_dict.items()] + params['mapcore_groups'] = update_order_list + node.add_log( + action=node.log_class.MAPCORE_GROUP_REORDERED, + params=params, + auth=auth, + save=False, + ) + # Update node modified date + node.modified = timezone.now() + node.save() + return response_data + +def get_group_by_node(node_id): + """ + Return a mapping of permission codename to auth_group id for a given node. + E.g. {'read': 1, 'write': 2, 'admin': 3} + """ + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT * + FROM auth_group + WHERE name LIKE %s + """, + [f'node_{node_id}_%'], + ) + rows = cursor.fetchall() + perm_map = {} + for gid, name in rows: + parts = name.rsplit('_', 1) + if len(parts) == 2: + perm = parts[1] + perm_map[perm] = gid + return perm_map diff --git a/api/nodes/urls.py b/api/nodes/urls.py index b6f0e518eb6..1f6c18e4241 100644 --- a/api/nodes/urls.py +++ b/api/nodes/urls.py @@ -52,4 +52,6 @@ url(r'^(?P\w+)/view_only_links/$', views.NodeViewOnlyLinksList.as_view(), name=views.NodeViewOnlyLinksList.view_name), url(r'^(?P\w+)/view_only_links/(?P\w+)/$', views.NodeViewOnlyLinkDetail.as_view(), name=views.NodeViewOnlyLinkDetail.view_name), url(r'^(?P\w+)/wikis/$', views.NodeWikiList.as_view(), name=views.NodeWikiList.view_name), + url(r'^(?P\w+)/map_core/groups/$', views.NodeMapCoreGroupList.as_view(), name=views.NodeMapCoreGroupList.view_name), + url(r'^(?P\w+)/map_core/groups/(?P[0-9]+)/$', views.NodeMapCoreGroupRemove.as_view(), name=views.NodeMapCoreGroupList.view_name), ] diff --git a/api/nodes/views.py b/api/nodes/views.py index 72882d252db..72d55db03a1 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -6,10 +6,12 @@ from django.db.models import Q, OuterRef, Exists, Subquery, F from django.utils import timezone from django.contrib.contenttypes.models import ContentType +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_node_group import MapCoreNodeGroup from rest_framework import generics, permissions as drf_permissions from rest_framework.exceptions import PermissionDenied, ValidationError, NotFound, MethodNotAllowed, NotAuthenticated from rest_framework.response import Response -from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_200_OK +from rest_framework.status import HTTP_204_NO_CONTENT, HTTP_200_OK, HTTP_201_CREATED from addons.base.exceptions import InvalidAuthError from addons.osfstorage.models import OsfStorageFolder @@ -88,6 +90,8 @@ AdminOrPublicOrSuperUser, ) from api.nodes.serializers import ( + NodeMapCoreGroupCreateSerializer, + NodeMapCoreGroupUpdateSerializer, NodeSerializer, ForwardNodeAddonSettingsSerializer, NodeAddonSettingsSerializer, @@ -110,6 +114,7 @@ NodeGroupsSerializer, NodeGroupsCreateSerializer, NodeGroupsDetailSerializer, + NodeMapCoreGroupSerializer, ) from api.nodes.utils import NodeOptimizationMixin, enforce_no_children from api.osf_groups.views import OSFGroupMixin @@ -813,6 +818,7 @@ class NodeChildrenList(BaseChildrenList, bulk_views.ListBulkCreateJSONAPIView, N view_category = 'nodes' view_name = 'node-children' model_class = Node + include_mapcore_groups = True def get_serializer_context(self): context = super(NodeChildrenList, self).get_serializer_context() @@ -2361,3 +2367,304 @@ def get_serializer_context(self): context['wiki_addon'] = node.get_addon('wiki') context['forward_addon'] = node.get_addon('forward') return context + + +class NodeMapCoreGroupList(JSONAPIBaseView, generics.ListAPIView, bulk_views.BulkUpdateJSONAPIView, bulk_views.ListBulkCreateJSONAPIView, NodeMixin): + """ + API endpoint that allows the core groups of a node to be viewed and edited. + """ + permission_classes = ( + AdminOrPublic, + drf_permissions.IsAuthenticatedOrReadOnly, + ReadOnlyIfRegistration, + base_permissions.TokenHasScope, + ) + + required_read_scopes = [CoreScopes.NODE_CONTRIBUTORS_READ] + required_write_scopes = [CoreScopes.NODE_CONTRIBUTORS_WRITE] + model_class = OSFUser + + throttle_classes = (AddContributorThrottle, UserRateThrottle, NonCookieAuthThrottle, BurstRateThrottle, ) + + pagination_class = MaxSizePagination + serializer_class = NodeMapCoreGroupSerializer + view_category = 'nodes' + view_name = 'node-map-core-groups' + ordering = ('mapcore_group___id',) # default ordering + + def get_serializer_class(self): + """ + Use NodeContributorDetailSerializer which requires 'id' + """ + if self.request.method == 'PUT' or self.request.method == 'PATCH' or self.request.method == 'DELETE': + return NodeMapCoreGroupUpdateSerializer + elif self.request.method == 'POST': + return NodeMapCoreGroupCreateSerializer + else: + return NodeMapCoreGroupSerializer + + # overrides ListBulkCreateJSON APIView, BulkUpdateJSONAPIView + def get_queryset(self): + node = self.get_node() + qs = MapCoreNodeGroup.objects.filter(node=node, is_deleted=False) + # Avoid N+1 on foreign-key relations reported by nplusone + qs = qs.select_related('creator', 'mapcore_group') + # Precompute permissions via ORM (no raw SQL) + group_ids = list(qs.values_list('group_id', flat=True)) + perm_map = {} + if group_ids: + NodeGroupObjectPermission = apps.get_model('osf.NodeGroupObjectPermission') + perms_qs = ( + NodeGroupObjectPermission.objects + .filter(group_id__in=group_ids, content_object_id=node.id) + .select_related('permission') + ) + for p in perms_qs: + codename = getattr(getattr(p, 'permission', None), 'codename', '') or '' + short = codename.split('_', 1)[0] if '_' in codename else codename + perm_map.setdefault(p.group_id, []).append(short) + # Attach computed permission arrays to instances so serializer can read obj.permissions + for obj in qs: + obj.permissions = perm_map.get(obj.group_id, []) + + # If any related fields are reverse or many-to-many, use prefetch_related: + # qs = qs.prefetch_related('some_m2m_field') + return qs + + # Overrides BulkDestroyJSONAPIView + def perform_destroy(self, instance): + pass + + def get_serializer_context(self): + """ + Ensure serializers have the node available as 'node' in context. + """ + context = super(NodeMapCoreGroupList, self).get_serializer_context() + node = self.get_node() + context['node'] = node + return context + + def list(self, request, *args, **kwargs): + """List the MapCoreNodeGroup relationships for this node. + """ + queryset = self.get_queryset() + if request.query_params.get('visible'): + queryset = queryset.filter(visible=True) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + serializer = self.get_serializer(queryset, many=True) + data = { + 'data': serializer.data, + } + return Response(data) + + def create(self, request, *args, **kwargs): + # Normalize incoming payload: accept JSON:API {"data": ...} or raw dict/list + request_body = request.data + if 'data' in request.data: + request_body = request.data['data'] + attrs = request_body.get('attributes', request_body) + node_groups = attrs.get('node_groups') + if not node_groups or not isinstance(node_groups, (list, tuple)) or len(node_groups) == 0: + raise ValidationError(detail='Request must include a non-empty attributes.node_groups list.') + mapcore_group_id_set = set() + duplicates = set() + allowed_perms = {'read', 'write', 'admin'} + for idx, ng in enumerate(node_groups): + # Validate required fields + if 'mapcore_group_id' not in ng or 'permission' not in ng: + raise ValidationError(detail='Each node_group must include a mapcore_group_id and permission.') + # Validate permission value + perm = ng.get('permission') + if perm not in allowed_perms: + raise ValidationError(detail=f'Permission "{perm}" is invalid (must be one of {sorted(allowed_perms)}) (failed at index {idx}).') + # Check for duplicate mapcore_group_id in request + mgid = ng.get('mapcore_group_id') + try: + mgid = int(mgid) + except (TypeError, ValueError): + raise ValidationError(detail=f'mapcore_group_id must be an integer (failed at index {idx}).') + + if mgid in mapcore_group_id_set: + duplicates.add(mgid) + else: + mapcore_group_id_set.add(mgid) + # If any duplicates found, raise error + if duplicates: + raise ValidationError(detail=f'Duplicate mapcore_group_id(s) in request: {sorted(list(duplicates))}') + # Validate that all mapcore_group_id values exist + mapcore_groups = MapCoreGroup.objects.filter(id__in=mapcore_group_id_set) + mapcore_groups_found_ids = [mg.id for mg in mapcore_groups] + missing_ids = mapcore_group_id_set - set(mapcore_groups_found_ids) + if missing_ids: + raise ValidationError(detail=f'mapcore_group_id(s) not found: {sorted(list(missing_ids))}') + + # Check for existing MapCoreNodeGroup relationships + existing_qs = MapCoreNodeGroup.objects.filter(node=self.get_node(), mapcore_group_id__in=mapcore_group_id_set, is_deleted=False) + if existing_qs.exists(): + existing_pairs = [e.mapcore_group_id for e in existing_qs] + raise ValidationError(detail=f'MapCoreNodeGroup already exists for mapcore_group_id(s): {existing_pairs}') + + component_ids = attrs.get('component_ids', []) + component_ids_set = set() + duplicate_component_ids = [] + for cid in component_ids: + if cid in component_ids_set: + duplicate_component_ids.append(cid) + else: + component_ids_set.add(cid) + if duplicate_component_ids: + raise ValidationError(detail=f'Duplicate component_ids in request: {sorted(duplicate_component_ids)}') + + node = self.get_node() + components = node.descendants.prefetch_related('guids').filter( + guids___id__in=component_ids_set, + is_deleted=False, + ) + components_found_ids = set(c.guids.first()._id for c in components) + missing_component_ids = component_ids_set - components_found_ids + if missing_component_ids: + raise ValidationError(detail=f'component_ids not found or not children of this node: {sorted(list(missing_component_ids))}') + # Use the create serializer to validate & create objects + create_serializer = self.get_serializer(data=request_body) + create_serializer.is_valid(raise_exception=True) + created_objects = create_serializer.save() + data = { + 'data': created_objects, + } + return Response(data, status=HTTP_201_CREATED) + + def bulk_update(self, request, *args, **kwargs): + """Bulk update MapCoreNodeGroup relationships for this node. + """ + request_body = request.data + if 'data' in request.data: + request_body = request.data['data'] + attrs = request_body.get('attributes', request_body) + node_groups = attrs.get('node_groups') + if not node_groups or not isinstance(node_groups, (list, tuple)) or len(node_groups) == 0: + raise ValidationError(detail='Request must include a non-empty attributes.node_groups list.') + node_group_id_set = set() + duplicates = set() + for idx, ng in enumerate(node_groups): + # Validate required fields + if 'node_group_id' not in ng or 'permission' not in ng: + raise ValidationError(detail='Each node_group must include a node_group_id and permission.') + # Validate permission value + perm = ng.get('permission') + allowed_perms = {'read', 'write', 'admin'} + if perm not in allowed_perms: + raise ValidationError(detail=f'Permission "{perm}" is invalid (must be one of {sorted(allowed_perms)}) (failed at index {idx}).') + # Validate that node_group_id exists + ngid = ng.get('node_group_id') + try: + ngid = int(ngid) + except (TypeError, ValueError): + raise ValidationError(detail=f'node_group_id must be an integer (failed at index {idx}).') + # Check for duplicate node_group_id in request + if ngid in node_group_id_set: + duplicates.add(ngid) + else: + node_group_id_set.add(ngid) + # If any duplicates found, raise error + if duplicates: + raise ValidationError(detail=f'Duplicate node_group_id(s) in request: {sorted(list(duplicates))}') + node_group_db = MapCoreNodeGroup.objects.filter(node=self.get_node(), id__in=node_group_id_set, is_deleted=False) + node_group_db_ids = set(ng.id for ng in node_group_db) + missing_ids = node_group_id_set - node_group_db_ids + if missing_ids: + raise ValidationError(detail=f'node_group_id(s) not found: {sorted(list(missing_ids))}') + # Use the update serializer to validate & update objects + update_serializer = self.get_serializer(data=request_body) + update_serializer.is_valid(raise_exception=True) + updated_objects = update_serializer.save() + data = { + 'data': updated_objects, + } + return Response(data, status=HTTP_200_OK) + +class NodeMapCoreGroupRemove(JSONAPIBaseView, generics.DestroyAPIView, NodeMixin): + """ + API endpoint that allows the core groups of a node to be removed. + """ + permission_classes = ( + AdminOrPublic, + drf_permissions.IsAuthenticatedOrReadOnly, + ReadOnlyIfRegistration, + base_permissions.TokenHasScope, + ) + + required_read_scopes = [CoreScopes.NODE_CONTRIBUTORS_READ] + required_write_scopes = [CoreScopes.NODE_CONTRIBUTORS_WRITE] + + view_category = 'nodes' + view_name = 'node-map-core-group-remove' + def delete(self, request, *args, **kwargs): + """Remove a MapCoreNodeGroup relationship from this node. + """ + query_params = self.request.query_params + component_ids = query_params.get('component_ids', '') + if component_ids: + component_ids = component_ids.split(',') + component_ids_set = set() + duplicate_component_ids = [] + for cid in component_ids: + if cid in component_ids_set: + duplicate_component_ids.append(cid) + else: + component_ids_set.add(cid) + if duplicate_component_ids: + raise ValidationError(detail=f'Duplicate component_ids in request: {sorted(duplicate_component_ids)}') + components = self.get_node().descendants.prefetch_related('guids').filter(guids___id__in=component_ids_set, is_deleted=False) + components_found_ids = set(c.guids.first()._id for c in components) + missing_component_ids = component_ids_set - components_found_ids + if missing_component_ids: + raise ValidationError(detail=f'component_ids not found or not children of this node: {sorted(list(missing_component_ids))}') + # Use the create serializer to validate & create objects + instance = self.get_object() + if component_ids: + for component in components: + try: + mapcore_node_group = MapCoreNodeGroup.objects.get( + node=component, + mapcore_group_id=instance.mapcore_group_id, + is_deleted=False, + ) + except MapCoreNodeGroup.DoesNotExist: + raise NotFound(detail=f'MapCoreNodeGroup not found for component {component._id}.') + self.perform_destroy(mapcore_node_group) + self.perform_destroy(instance) + return Response(status=HTTP_204_NO_CONTENT) + + # overrides DestroyAPIView + def get_object(self): + node = self.get_node() + try: + mapcore_node_group = MapCoreNodeGroup.objects.get( + node=node, + id=self.kwargs['node_group_id'], + is_deleted=False, + ) + except MapCoreNodeGroup.DoesNotExist: + raise NotFound(detail='MapCoreNodeGroup not found.') + return mapcore_node_group + def perform_destroy(self, instance): + assert isinstance(instance, MapCoreNodeGroup), 'instance must be a MapCoreNodeGroup' + instance.is_deleted = True + instance.modified = timezone.now() + instance.save() + auth = get_user_auth(self.request) + node = instance.node + params = node.log_params + params['mapcore_groups'] = [instance.mapcore_group_id] + node.add_log( + action=node.log_class.MAPCORE_GROUP_REMOVED, + params=params, + auth=auth, + save=False, + ) + # Update node modified date + node.modified = timezone.now() + node.save() diff --git a/api/users/serializers.py b/api/users/serializers.py index 7622049a2b0..ba390371d2c 100644 --- a/api/users/serializers.py +++ b/api/users/serializers.py @@ -1,6 +1,7 @@ import jsonschema from django.utils import timezone +from osf.models.mapcore_node_group import MapCoreNodeGroup from rest_framework import serializers as ser from rest_framework import exceptions @@ -669,3 +670,10 @@ def update(self, instance, validated_data): class UserNodeSerializer(NodeSerializer): filterable_fields = NodeSerializer.filterable_fields | {'current_user_permissions'} + mapcore_groups = ser.SerializerMethodField() + + def get_mapcore_groups(self, obj): + if isinstance(obj, Node): + node_groups = MapCoreNodeGroup.objects.filter(node=obj, is_deleted=False).select_related('mapcore_group') + return [group.mapcore_group._id for group in node_groups] + return [] diff --git a/api/users/views.py b/api/users/views.py index 2a561309161..de7fd1f0181 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -322,10 +322,11 @@ class UserNodes(JSONAPIBaseView, generics.ListAPIView, UserMixin, UserNodesFilte def get_default_queryset(self): user = self.get_user() # Nodes the requested user has read_permissions on + user.include_mapcore_groups = True default_queryset = user.nodes_contributor_or_group_member_to if user != self.request.user: # Further restrict UserNodes to nodes the *requesting* user can view - return Node.objects.get_nodes_for_user(self.request.user, base_queryset=default_queryset, include_public=True) + return Node.objects.get_nodes_for_user(self.request.user, base_queryset=default_queryset, include_public=True, include_mapcore_groups=True) return self.optimize_node_queryset(default_queryset) # overrides ListAPIView diff --git a/api_tests/institutions/test_authentication_mapcore.py b/api_tests/institutions/test_authentication_mapcore.py new file mode 100644 index 00000000000..04cd15311aa --- /dev/null +++ b/api_tests/institutions/test_authentication_mapcore.py @@ -0,0 +1,415 @@ +import pytest +from unittest import mock + +from api.institutions.authentication import update_mapcore_groups +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_user_group import MapCoreUserGroup +from osf_tests.factories import AuthUserFactory + + +@pytest.mark.django_db +class TestUpdateMapcoreGroups: + """Test cases for update_mapcore_groups function""" + + @pytest.fixture + def user(self): + """Create a test user""" + return AuthUserFactory() + + @pytest.fixture + def mapcore_group(self): + """Create a test mapcore group""" + group, created = MapCoreGroup.objects.get_or_create(_id='test_group') + return group + + def test_returns_early_when_prefix_not_set(self, user): + """Test that function returns early when MAP_GATEWAY_ISMEMBEROF_PREFIX is not set""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', None): + update_mapcore_groups(user, provider) + + # No groups should be created + assert MapCoreUserGroup.objects.filter(user=user).count() == 0 + + def test_returns_early_when_prefix_empty(self, user): + """Test that function returns early when MAP_GATEWAY_ISMEMBEROF_PREFIX is empty""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', ''): + update_mapcore_groups(user, provider) + + # No groups should be created + assert MapCoreUserGroup.objects.filter(user=user).count() == 0 + + def test_returns_early_when_no_groups_provided(self, user): + """Test that function returns early when no groups are provided in provider""" + provider = { + 'user': {} + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # No groups should be created + assert MapCoreUserGroup.objects.filter(user=user).count() == 0 + + def test_returns_early_when_groups_empty_string(self, user): + """Test that function returns early when groups is empty string""" + provider = { + 'user': { + 'groups': '' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # No groups should be created + assert MapCoreUserGroup.objects.filter(user=user).count() == 0 + + def test_adds_single_new_group(self, user): + """Test adding a single new group""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # One group should be created + user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert user_groups.count() == 1 + assert user_groups.first().mapcore_group._id == 'group1' + + def test_adds_multiple_new_groups(self, user): + """Test adding multiple new groups separated by semicolon""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1;https://cg.gakunin.jp/gr/group2;https://cg.gakunin.jp/gr/group3' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Three groups should be created + user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert user_groups.count() == 3 + group_ids = set(ug.mapcore_group._id for ug in user_groups) + assert group_ids == {'group1', 'group2', 'group3'} + + def test_filters_groups_by_prefix(self, user): + """Test that only groups with matching prefix are added""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1;https://other.prefix.jp/group2;https://cg.gakunin.jp/gr/group3' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Only two groups with matching prefix should be created + user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert user_groups.count() == 2 + group_ids = set(ug.mapcore_group._id for ug in user_groups) + assert group_ids == {'group1', 'group3'} + + def test_ignores_empty_group_names(self, user): + """Test that empty group names after prefix removal are ignored""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/;https://cg.gakunin.jp/gr/group1;https://cg.gakunin.jp/gr/' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Only one valid group should be created + user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert user_groups.count() == 1 + assert user_groups.first().mapcore_group._id == 'group1' + + def test_marks_removed_groups_as_deleted(self, user, mapcore_group): + """Test that existing groups not in new list are marked as deleted""" + # Create an existing user group + existing_group = MapCoreUserGroup.objects.create( + user=user, + mapcore_group=mapcore_group, + is_deleted=False + ) + + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/new_group' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Existing group should be marked as deleted + existing_group.refresh_from_db() + assert existing_group.is_deleted is True + assert existing_group.modified is not None + + # New group should be created + new_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert new_groups.count() == 1 + assert new_groups.first().mapcore_group._id == 'new_group' + + def test_keeps_existing_groups_in_new_list(self, user, mapcore_group): + """Test that existing groups in new list are kept and not deleted""" + # Create an existing user group + existing_group = MapCoreUserGroup.objects.create( + user=user, + mapcore_group=mapcore_group, + is_deleted=False + ) + + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/test_group;https://cg.gakunin.jp/gr/new_group' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Existing group should still be active + existing_group.refresh_from_db() + assert existing_group.is_deleted is False + + # Two active groups should exist + active_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert active_groups.count() == 2 + group_ids = set(ug.mapcore_group._id for ug in active_groups) + assert group_ids == {'test_group', 'new_group'} + + def test_handles_mixed_update_scenario(self, user): + """Test handling multiple groups: keep existing, delete removed, add new""" + # Create existing groups + group1, _ = MapCoreGroup.objects.get_or_create(_id='keep_group') + group2, _ = MapCoreGroup.objects.get_or_create(_id='delete_group') + + MapCoreUserGroup.objects.create(user=user, mapcore_group=group1, is_deleted=False) + MapCoreUserGroup.objects.create(user=user, mapcore_group=group2, is_deleted=False) + + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/keep_group;https://cg.gakunin.jp/gr/new_group' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Verify results + active_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert active_groups.count() == 2 + group_ids = set(ug.mapcore_group._id for ug in active_groups) + assert group_ids == {'keep_group', 'new_group'} + + # Verify deleted group + deleted_group = MapCoreUserGroup.objects.get(user=user, mapcore_group=group2) + assert deleted_group.is_deleted is True + + def test_handles_no_existing_groups(self, user): + """Test adding groups when user has no existing groups""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1;https://cg.gakunin.jp/gr/group2' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Two groups should be created + user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert user_groups.count() == 2 + group_ids = set(ug.mapcore_group._id for ug in user_groups) + assert group_ids == {'group1', 'group2'} + + def test_handles_all_groups_removed(self, user): + """Test when all existing groups are removed (no new groups match)""" + # Create existing groups + group1, _ = MapCoreGroup.objects.get_or_create(_id='group1') + group2, _ = MapCoreGroup.objects.get_or_create(_id='group2') + + MapCoreUserGroup.objects.create(user=user, mapcore_group=group1, is_deleted=False) + MapCoreUserGroup.objects.create(user=user, mapcore_group=group2, is_deleted=False) + + provider = { + 'user': { + 'groups': 'https://other.prefix.jp/group3' # Different prefix, won't match + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # All existing groups should be marked as deleted + active_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert active_groups.count() == 0 + + deleted_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=True) + assert deleted_groups.count() == 2 + + def test_reuses_existing_mapcore_group(self, user, mapcore_group): + """Test that existing MapCoreGroup is reused, not duplicated""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/test_group' + } + } + + initial_mapcore_group_count = MapCoreGroup.objects.count() + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # MapCoreGroup count should not increase (reused existing) + assert MapCoreGroup.objects.count() == initial_mapcore_group_count + + # User group should be created with existing mapcore_group + user_group = MapCoreUserGroup.objects.get(user=user, is_deleted=False) + assert user_group.mapcore_group == mapcore_group + + def test_handles_duplicate_groups_in_input(self, user): + """Test that duplicate group names in input are handled correctly""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1;https://cg.gakunin.jp/gr/group1;https://cg.gakunin.jp/gr/group2' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Only unique groups should be created (set deduplication) + user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert user_groups.count() == 2 + group_ids = set(ug.mapcore_group._id for ug in user_groups) + assert group_ids == {'group1', 'group2'} + + def test_ignores_already_deleted_groups(self, user): + """Test that already deleted groups are not processed""" + # Create existing groups, one already deleted + group1, _ = MapCoreGroup.objects.get_or_create(_id='group1') + group2, _ = MapCoreGroup.objects.get_or_create(_id='group2') + + MapCoreUserGroup.objects.create(user=user, mapcore_group=group1, is_deleted=False) + MapCoreUserGroup.objects.create(user=user, mapcore_group=group2, is_deleted=True) + + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/new_group' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # Only the active group should be marked as deleted + active_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert active_groups.count() == 1 + assert active_groups.first().mapcore_group._id == 'new_group' + + # Two groups should be deleted (group1 newly deleted, group2 already deleted) + deleted_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=True) + assert deleted_groups.count() == 2 + + def test_handles_special_characters_in_group_names(self, user): + """Test handling group names with special characters""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group-with-dashes;https://cg.gakunin.jp/gr/group_with_underscores;https://cg.gakunin.jp/gr/group.with.dots' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + update_mapcore_groups(user, provider) + + # All groups should be created with special characters preserved + user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False) + assert user_groups.count() == 3 + group_ids = set(ug.mapcore_group._id for ug in user_groups) + assert group_ids == {'group-with-dashes', 'group_with_underscores', 'group.with.dots'} + + def test_bulk_update_called_when_groups_deleted(self, user): + """Test that bulk_update is called when groups need to be deleted""" + # Create existing groups + group1, _ = MapCoreGroup.objects.get_or_create(_id='group1') + group2, _ = MapCoreGroup.objects.get_or_create(_id='group2') + + MapCoreUserGroup.objects.create(user=user, mapcore_group=group1, is_deleted=False) + MapCoreUserGroup.objects.create(user=user, mapcore_group=group2, is_deleted=False) + + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/new_group' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + with mock.patch('api.institutions.authentication.bulk_update') as mock_bulk_update: + update_mapcore_groups(user, provider) + + # bulk_update should be called with the deleted groups + assert mock_bulk_update.called + call_args = mock_bulk_update.call_args + deleted_list = call_args[0][0] + assert len(deleted_list) == 2 + assert call_args[1]['update_fields'] == ['is_deleted', 'modified'] + + def test_no_bulk_update_when_no_deletions(self, user): + """Test that bulk_update is not called when no groups need to be deleted""" + provider = { + 'user': { + 'groups': 'https://cg.gakunin.jp/gr/group1' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + with mock.patch('api.institutions.authentication.bulk_update') as mock_bulk_update: + update_mapcore_groups(user, provider) + + # bulk_update should not be called + assert not mock_bulk_update.called + + def test_returns_early_when_groups_error_and_groups_empty(self, user): + """If groups is empty but groupsError exists, function returns early and logs decoded message.""" + provider = { + 'user': { + 'groups': '', + 'groupsError': 'Unable%20to%20obtain%20a%20SAML%20response%20from%20attribute%20authority.' + } + } + + with mock.patch('api.institutions.authentication.settings.MAP_GATEWAY_ISMEMBEROF_PREFIX', 'https://cg.gakunin.jp/gr/'): + with mock.patch('api.institutions.authentication.logger') as mock_logger: + update_mapcore_groups(user, provider) + + # logger.warning should have been called with a decoded message + assert mock_logger.warning.called + called_args = mock_logger.warning.call_args + # The first positional arg is the formatted message (code creates a formatted string) + message = called_args[0][0] if called_args and called_args[0] else '' + assert 'MAP Core groups retrieval error for user' in message + assert 'Unable to obtain a SAML response' in message + + # Ensure no MapCoreUserGroup rows were created + assert MapCoreUserGroup.objects.filter(user=user).count() == 0 diff --git a/api_tests/mapcore/serializers/test_serializer.py b/api_tests/mapcore/serializers/test_serializer.py new file mode 100644 index 00000000000..cbdaffa4156 --- /dev/null +++ b/api_tests/mapcore/serializers/test_serializer.py @@ -0,0 +1,29 @@ +import pytest + +from api.mapcore.serializers import MapCoreGroupSerializer +from osf.models.mapcore_group import MapCoreGroup +from tests.utils import make_drf_request_with_version +from website import settings as website_settings + + +@pytest.mark.django_db +def test_mapcore_group_serializer_basic(): + mg = MapCoreGroup.objects.create(_id='test-group-serializer') + + req = make_drf_request_with_version(version='2.0') + serializer = MapCoreGroupSerializer(mg, context={'request': req}) + result = serializer.data + # JSONAPI serializers in the project produce a top-level 'data' key + data = result['data'] if 'data' in result else result + + assert data['type'] == 'mapcore-groups' + assert data['id'] == mg.id + + attrs = data['attributes'] + assert attrs['mapcore_group_id'] == mg.id + assert attrs['name'] == mg._id + assert 'created' in attrs + assert 'modified' in attrs + + expected_url = f'{website_settings.MAPCORE_GROUP_HOSTNAME}{website_settings.MAPCORE_GROUP_API_PATH}{mg._id}/' + assert data['links']['self'] == expected_url diff --git a/api_tests/mapcore/views/test_mapcore_group_list.py b/api_tests/mapcore/views/test_mapcore_group_list.py new file mode 100644 index 00000000000..2f6e92090bf --- /dev/null +++ b/api_tests/mapcore/views/test_mapcore_group_list.py @@ -0,0 +1,51 @@ +import pytest + +from api.base.settings.defaults import API_BASE +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_user_group import MapCoreUserGroup +from osf_tests.factories import AuthUserFactory +from tests.base import ApiTestCase + + +@pytest.mark.django_db +class TestMapCoreGroupList(ApiTestCase): + def setUp(self): + super().setUp() + self.user = AuthUserFactory() + + # Create MapCoreGroups and link them to the user via MapCoreUserGroup + self.mapcore_group1 = MapCoreGroup.objects.create(_id='test-mapcore-1') + self.mapcore_group2 = MapCoreGroup.objects.create(_id='another-group') + + MapCoreUserGroup.objects.create( + mapcore_group=self.mapcore_group1, + user=self.user + ) + MapCoreUserGroup.objects.create( + mapcore_group=self.mapcore_group2, + user=self.user + ) + + self.url = f'/{API_BASE}map_core/groups/' + + def test_list_mapcore_groups_for_authenticated_user(self): + res = self.app.get(self.url, auth=self.user.auth) + assert res.status_code == 200 + assert len(res.json['data']) == 2 + + item = res.json['data'][0] + assert 'id' in item + assert 'attributes' in item + assert 'links' in item + assert item['type'] == 'mapcore-groups' + + def test_search_param_filters_results(self): + # Create an extra group that won't match the search term + MapCoreGroup.objects.create(_id='zzz-unmatched') + + # Search for 'another' should only return the matching group(s) + res = self.app.get(f'{self.url}?search=another', auth=self.user.auth) + assert res.status_code == 200 + data = res.json['data'] + assert len(data) == 1 + assert data[0]['attributes']['name'] == 'another-group' diff --git a/api_tests/nodes/serializers/test_mapcore_group_serializers.py b/api_tests/nodes/serializers/test_mapcore_group_serializers.py new file mode 100644 index 00000000000..42e50a9b816 --- /dev/null +++ b/api_tests/nodes/serializers/test_mapcore_group_serializers.py @@ -0,0 +1,1167 @@ +import pytest +from unittest.mock import patch, MagicMock +from django.contrib.auth.models import Group as AuthGroup +from rest_framework import exceptions + +from api.nodes.serializers import ( + NodeMapCoreGroupSerializer, + NodeMapCoreGroupCreateSerializer, + NodeMapCoreGroupUpdateSerializer, + NodeSerializer +) +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_node_group import MapCoreNodeGroup +from osf_tests.factories import AuthUserFactory, NodeFactory +from tests.utils import make_drf_request_with_version +from website import settings as website_settings + + +@pytest.fixture() +def user(): + return AuthUserFactory() + + +@pytest.fixture() +def node(user): + return NodeFactory(creator=user) + + +@pytest.fixture() +def mapcore_group(): + return MapCoreGroup.objects.create(_id='test-group-1') + + +@pytest.fixture() +def auth_group(node): + return AuthGroup.objects.get_or_create(name=f'node_{node.id}_admin')[0] + + +@pytest.fixture() +def mapcore_node_group(node, mapcore_group, auth_group, user): + return MapCoreNodeGroup.objects.create( + node=node, + group=auth_group, + mapcore_group=mapcore_group, + creator=user, + ) + + +@pytest.mark.django_db +class TestNodeMapCoreGroupSerializer: + """Test cases for NodeMapCoreGroupSerializer""" + + def test_basic_serialization(self, mapcore_node_group, node): + """Test basic serialization of MapCoreNodeGroup""" + # Simulate permissions attached by view + mapcore_node_group.permissions = ['admin'] + + req = make_drf_request_with_version(version='2.0') + result = NodeMapCoreGroupSerializer( + mapcore_node_group, + context={'request': req, 'node': node} + ).data + data = result['data'] + + # Test top-level structure + assert data['id'] == mapcore_node_group.id + assert data['type'] == 'node-mapcore-group' + + # Test attributes + attrs = data['attributes'] + assert attrs['node_group_id'] == mapcore_node_group.id + assert attrs['creator_id'] == mapcore_node_group.creator.id + assert attrs['creator'] == mapcore_node_group.creator.fullname + assert attrs['permission'] == 'admin' + assert attrs['mapcore_group_id'] == mapcore_node_group.mapcore_group.id + assert attrs['name'] == mapcore_node_group.mapcore_group._id + assert 'created' in attrs + assert 'modified' in attrs + + # Test links + expected_url = f'{website_settings.MAPCORE_GROUP_HOSTNAME}{website_settings.MAPCORE_GROUP_API_PATH}{mapcore_node_group.mapcore_group._id}' + assert data['links']['self'] == expected_url + + def test_get_permission_with_multiple_permissions(self, mapcore_node_group, node): + """Test that get_permission returns highest permission""" + # Test admin priority + mapcore_node_group.permissions = ['read', 'write', 'admin'] + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupSerializer( + mapcore_node_group, + context={'request': req, 'node': node} + ) + assert serializer.get_permission(mapcore_node_group) == 'admin' + + # Test write priority over read + mapcore_node_group.permissions = ['read', 'write'] + assert serializer.get_permission(mapcore_node_group) == 'write' + + # Test read only + mapcore_node_group.permissions = ['read'] + assert serializer.get_permission(mapcore_node_group) == 'read' + + def test_get_permission_no_permissions(self, mapcore_node_group, node): + """Test get_permission returns None when no permissions""" + mapcore_node_group.permissions = [] + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupSerializer( + mapcore_node_group, + context={'request': req, 'node': node} + ) + assert serializer.get_permission(mapcore_node_group) is None + + def test_get_permission_unknown_permissions(self, mapcore_node_group, node): + """Test get_permission with unknown permissions""" + mapcore_node_group.permissions = ['unknown', 'invalid'] + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupSerializer( + mapcore_node_group, + context={'request': req, 'node': node} + ) + assert serializer.get_permission(mapcore_node_group) is None + + def test_get_permission_missing_permissions_attribute(self, mapcore_node_group, node): + """Test get_permission when permissions attribute is missing""" + # Don't set permissions attribute at all + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupSerializer( + mapcore_node_group, + context={'request': req, 'node': node} + ) + + # Should default to empty list and return None + assert serializer.get_permission(mapcore_node_group) is None + + +@pytest.mark.django_db +class TestNodeMapCoreGroupCreateSerializer: + """Test cases for NodeMapCoreGroupCreateSerializer""" + + def setup_auth_groups(self, node): + """Helper to create auth groups for a node""" + groups = {} + for perm in ['read', 'write', 'admin']: + groups[perm] = AuthGroup.objects.get_or_create(name=f'node_{node.id}_{perm}')[0] + return groups + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_basic(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test basic creation of MapCoreNodeGroup""" + # Setup + auth_groups = self.setup_auth_groups(node) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-create') + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group.id, + 'permission': 'admin' + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify + assert len(result) == 1 + response_item = result[0] + assert response_item['type'] == 'node-mapcore-group' + assert response_item['attributes']['permission'] == 'admin' + assert response_item['attributes']['mapcore_group_id'] == mapcore_group.id + + # Verify database + mcng = MapCoreNodeGroup.objects.get( + node=node, + mapcore_group=mapcore_group, + is_deleted=False + ) + assert mcng.group == auth_groups['admin'] + assert mcng.creator == user + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_with_components(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test creation with component nodes""" + # Setup + auth_groups = self.setup_auth_groups(node) + component1 = NodeFactory(creator=user, parent=node) + component2 = NodeFactory(creator=user, parent=node) + node.descendants.add(component1, component2) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-components') + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group.id, + 'permission': 'write', + 'visible': True + } + ], + 'component_ids': [component1._id, component2._id] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + # Verify + assert len(result) == 1 + + # Verify main node relationship created + main_mcng = MapCoreNodeGroup.objects.get( + node=node, + mapcore_group=mapcore_group, + is_deleted=False + ) + assert main_mcng.group == auth_groups['write'] + + # Verify component relationships created + for component in [component1, component2]: + comp_mcng = MapCoreNodeGroup.objects.get( + node=component, + mapcore_group=mapcore_group, + is_deleted=False + ) + assert comp_mcng.group == auth_groups['write'] + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_with_existing_component_relationship(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test creation with existing component relationship (update scenario)""" + # Setup + auth_groups = self.setup_auth_groups(node) + component = NodeFactory(creator=user, parent=node) + node.descendants.add(component) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-update') + + # Create existing relationship with read permission + existing_mcng = MapCoreNodeGroup.objects.create( + node=component, + mapcore_group=mapcore_group, + group=auth_groups['read'], + creator=user, + is_deleted=False + ) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group.id, + 'permission': 'admin' # Upgrade to admin + } + ], + 'component_ids': [component._id] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + # Verify + assert len(result) == 1 + + # Verify component relationship was updated + existing_mcng.refresh_from_db() + assert existing_mcng.group == auth_groups['admin'] + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_duplicate_mapcore_group_raises_error(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test that creating duplicate MapCoreNodeGroup raises ValidationError""" + # Setup + auth_groups = self.setup_auth_groups(node) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-duplicate') + + # Create existing relationship + MapCoreNodeGroup.objects.create( + node=node, + mapcore_group=mapcore_group, + group=auth_groups['admin'], + creator=user, + is_deleted=False + ) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group.id, + 'permission': 'admin' + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + # Execute and verify exception + with pytest.raises(Exception) as exc_info: + serializer.create(validated_data) + + assert 'MapCoreNodeGroup already exists' in str(exc_info.value) + + def test_load_mapcore_group_not_found(self, user, node): + """Test load_mapcore_group raises NotFound for nonexistent group""" + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + with pytest.raises(exceptions.NotFound) as exc_info: + serializer.load_mapcore_group(99999) + + assert 'MapCore Group with id 99999 does not exist' in str(exc_info.value) + + def test_load_mapcore_group_success(self, user, node): + """Test load_mapcore_group returns correct group""" + mapcore_group = MapCoreGroup.objects.create(_id='test-load-group') + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + result = serializer.load_mapcore_group(mapcore_group.id) + assert result == mapcore_group + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_multiple_node_groups(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test creating multiple MapCoreNodeGroups in single call""" + # Setup + auth_groups = self.setup_auth_groups(node) + mapcore_group1 = MapCoreGroup.objects.create(_id='test-group-multi-1') + mapcore_group2 = MapCoreGroup.objects.create(_id='test-group-multi-2') + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group1.id, + 'permission': 'admin' + }, + { + 'mapcore_group_id': mapcore_group2.id, + 'permission': 'write' + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify + assert len(result) == 2 + + # Verify both relationships created with correct permissions + mcng1 = MapCoreNodeGroup.objects.get( + node=node, + mapcore_group=mapcore_group1, + is_deleted=False + ) + assert mcng1.group == auth_groups['admin'] + + mcng2 = MapCoreNodeGroup.objects.get( + node=node, + mapcore_group=mapcore_group2, + is_deleted=False + ) + assert mcng2.group == auth_groups['write'] + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_component_two_groups_update_and_add_new(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Create for a component with two mapcore groups: one updates, one is added""" + # Setup auth groups + auth_groups = self.setup_auth_groups(node) + + # Create a component and two mapcore groups (one existing on component, one new) + component = NodeFactory(creator=user, parent=node) + node.descendants.add(component) + existing_mapcore_group = MapCoreGroup.objects.create(_id='test-component-existing') + new_mapcore_group = MapCoreGroup.objects.create(_id='test-component-new') + + # Existing relationship on the component (read) + existing_mcng = MapCoreNodeGroup.objects.create( + node=component, + mapcore_group=existing_mapcore_group, + group=auth_groups['read'], + creator=user, + is_deleted=False + ) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': existing_mapcore_group.id, + 'permission': 'admin' # upgrade existing on component + }, + { + 'mapcore_group_id': new_mapcore_group.id, + 'permission': 'write' # add new to component + } + ], + 'component_ids': [component._id] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify serializer response for two items + assert len(result) == 2 + + # Verify existing relationship was updated on the component + existing_mcng.refresh_from_db() + assert existing_mcng.group == auth_groups['admin'] + + # Verify new relationship was created for the component + new_mcng = MapCoreNodeGroup.objects.get( + node=component, + mapcore_group=new_mapcore_group, + is_deleted=False + ) + assert new_mcng.group == auth_groups['write'] + + # Optionally verify main node relationships were also created/updated + main_existing = MapCoreNodeGroup.objects.get(node=node, mapcore_group=existing_mapcore_group, is_deleted=False) + assert main_existing.group == auth_groups['admin'] + main_new = MapCoreNodeGroup.objects.get(node=node, mapcore_group=new_mapcore_group, is_deleted=False) + assert main_new.group == auth_groups['write'] + + +@pytest.mark.django_db +class TestNodeMapCoreGroupUpdateSerializer: + """Test cases for NodeMapCoreGroupUpdateSerializer""" + + def setup_auth_groups(self, node): + """Helper to create auth groups for a node""" + groups = {} + for perm in ['read', 'write', 'admin']: + groups[perm] = AuthGroup.objects.get_or_create(name=f'node_{node.id}_{perm}')[0] + return groups + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_basic(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test basic update of MapCoreNodeGroup permissions""" + # Setup + auth_groups = self.setup_auth_groups(node) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-update') + + # Create existing relationship with read permission + existing_mcng = MapCoreNodeGroup.objects.create( + node=node, + mapcore_group=mapcore_group, + group=auth_groups['read'], + creator=user, + is_deleted=False + ) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'node_group_id': existing_mcng.id, + 'permission': 'admin' # Update to admin + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify response + assert len(result) == 1 + response_item = result[0] + assert response_item['attributes']['permission'] == 'admin' + assert response_item['attributes']['node_group_id'] == existing_mcng.id + + # Verify database update + existing_mcng.refresh_from_db() + assert existing_mcng.group == auth_groups['admin'] + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_multiple_node_groups(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test updating multiple MapCoreNodeGroups""" + # Setup + auth_groups = self.setup_auth_groups(node) + mapcore_group1 = MapCoreGroup.objects.create(_id='test-group-update-1') + mapcore_group2 = MapCoreGroup.objects.create(_id='test-group-update-2') + + # Create existing relationships + mcng1 = MapCoreNodeGroup.objects.create( + node=node, + mapcore_group=mapcore_group1, + group=auth_groups['read'], + creator=user, + is_deleted=False + ) + mcng2 = MapCoreNodeGroup.objects.create( + node=node, + mapcore_group=mapcore_group2, + group=auth_groups['read'], + creator=user, + is_deleted=False + ) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'node_group_id': mcng1.id, + 'permission': 'admin' + }, + { + 'node_group_id': mcng2.id, + 'permission': 'write' + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify response + assert len(result) == 2 + + # Verify database updates + mcng1.refresh_from_db() + mcng2.refresh_from_db() + assert mcng1.group == auth_groups['admin'] + assert mcng2.group == auth_groups['write'] + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_no_permission_change(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test update with no permission provided (should skip)""" + # Setup + auth_groups = self.setup_auth_groups(node) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-no-change') + + existing_mcng = MapCoreNodeGroup.objects.create( + node=node, + mapcore_group=mapcore_group, + group=auth_groups['read'], + creator=user, + is_deleted=False, + visible=True, + _order=0 + ) + original_modified = existing_mcng.modified + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'node_group_id': existing_mcng.id, + 'permission': None, # No permission change + 'visible': True, # No visible change + '_order': 0 + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify no changes made + assert len(result) == 1 + existing_mcng.refresh_from_db() + assert existing_mcng.group == auth_groups['read'] # Unchanged + assert existing_mcng.visible is True # Unchanged + assert existing_mcng._order == 0 # Unchanged + assert existing_mcng.modified != original_modified # Changed + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_nonexistent_node_group_id(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test update with nonexistent node_group_id (should be ignored)""" + auth_groups = self.setup_auth_groups(node) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'node_group_id': 99999, # Nonexistent ID + 'permission': 'admin' + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify no updates made + assert len(result) == 0 + + def test_load_mapcore_group_not_found(self, user, node): + """Test load_mapcore_group raises NotFound for nonexistent group""" + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + with pytest.raises(exceptions.NotFound) as exc_info: + serializer.load_mapcore_group(99999) + + assert 'MapCore Group with id 99999 does not exist' in str(exc_info.value) + + def test_load_mapcore_group_success(self, user, node): + """Test load_mapcore_group returns correct group""" + mapcore_group = MapCoreGroup.objects.create(_id='test-load-group') + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + result = serializer.load_mapcore_group(mapcore_group.id) + assert result == mapcore_group + + +@pytest.mark.django_db +class TestGetGroupByNode: + """Test cases for the get_group_by_node helper function""" + + def test_get_group_by_node(self, node): + """Test get_group_by_node returns correct mapping""" + from api.nodes.serializers import get_group_by_node + + # Create auth groups for the node + admin_group = AuthGroup.objects.get_or_create(name=f'node_{node.id}_admin')[0] + write_group = AuthGroup.objects.get_or_create(name=f'node_{node.id}_write')[0] + read_group = AuthGroup.objects.get_or_create(name=f'node_{node.id}_read')[0] + + # Also create some unrelated groups to ensure they're filtered out + AuthGroup.objects.get_or_create(name='unrelated_group')[0] + AuthGroup.objects.get_or_create(name=f'node_{node.id + 1}_admin')[0] # Different node + + result = get_group_by_node(node.id) + + expected = { + 'admin': admin_group.id, + 'write': write_group.id, + 'read': read_group.id + } + assert result == expected + + def test_get_group_by_node_no_groups(self, node): + """Test get_group_by_node with no matching groups""" + from api.nodes.serializers import get_group_by_node + + # Ensure no leftover groups from other tests remain for this node + AuthGroup.objects.filter(name__startswith=f'node_{node.id}_').delete() + + result = get_group_by_node(node.id) + assert result == {} + + def test_get_group_by_node_partial_groups(self, node): + """Test get_group_by_node with only some permission groups""" + from api.nodes.serializers import get_group_by_node + + # Remove any leftover groups for this node to ensure test isolation + AuthGroup.objects.filter(name__startswith=f'node_{node.id}_').delete() + + # Only create admin and read groups, no write + admin_group = AuthGroup.objects.create(name=f'node_{node.id}_admin') + read_group = AuthGroup.objects.create(name=f'node_{node.id}_read') + + result = get_group_by_node(node.id) + + expected = { + 'admin': admin_group.id, + 'read': read_group.id + } + assert result == expected + + +@pytest.mark.django_db +class TestNodeMapCoreGroupSerializerEdgeCases: + """Additional edge case tests for complete coverage""" + + def test_get_permission_missing_permissions_attribute(self, user, node): + """Test get_permission when permissions attribute is missing""" + mapcore_group = MapCoreGroup.objects.create(_id='test-missing-perm') + auth_group = AuthGroup.objects.get_or_create(name=f'node_{node.id}_admin')[0] + mapcore_node_group = MapCoreNodeGroup.objects.create( + node=node, + group=auth_group, + mapcore_group=mapcore_group, + creator=user, + ) + # Don't set permissions attribute at all + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupSerializer( + mapcore_node_group, + context={'request': req, 'node': node} + ) + + # Should default to empty list and return None + assert serializer.get_permission(mapcore_node_group) is None + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_with_empty_component_ids(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test creation with empty component_ids list""" + # Setup + auth_groups = {} + for perm in ['read', 'write', 'admin']: + auth_groups[perm] = AuthGroup.objects.get_or_create(name=f'node_{node.id}_{perm}')[0] + + mapcore_group = MapCoreGroup.objects.create(_id='test-group-empty-components') + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group.id, + 'permission': 'admin' + } + ], + 'component_ids': [] # Empty list + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify only main node relationship created + assert len(result) == 1 + mcng = MapCoreNodeGroup.objects.get( + node=node, + mapcore_group=mapcore_group, + is_deleted=False + ) + assert mcng.group == auth_groups['admin'] + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_create_node_logging(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test that node logging occurs during creation""" + # Setup + auth_groups = {} + for perm in ['read', 'write', 'admin']: + auth_groups[perm] = AuthGroup.objects.get_or_create(name=f'node_{node.id}_{perm}')[0] + + mapcore_group = MapCoreGroup.objects.create(_id='test-group-logging') + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group.id, + 'permission': 'admin' + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupCreateSerializer( + context={'request': req, 'node': node} + ) + + original_modified = node.modified + + # Execute + result = serializer.create(validated_data) + assert len(result) == 1 + + # Verify node was modified (for logging) + node.refresh_from_db() + assert node.modified > original_modified + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_empty_permission_string(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test update with empty permission string""" + # Setup + auth_groups = {} + for perm in ['read', 'write', 'admin']: + auth_groups[perm] = AuthGroup.objects.get_or_create(name=f'node_{node.id}_{perm}')[0] + + mapcore_group = MapCoreGroup.objects.create(_id='test-group-empty-perm') + + existing_mcng = MapCoreNodeGroup.objects.create( + node=node, + mapcore_group=mapcore_group, + group=auth_groups['read'], + creator=user, + is_deleted=False + ) + original_modified = existing_mcng.modified + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'node_group_id': existing_mcng.id, + 'permission': '' # Empty permission string + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + # Execute + result = serializer.create(validated_data) + + # Verify no changes made (empty string is falsy) + assert len(result) == 1 + existing_mcng.refresh_from_db() + assert existing_mcng.group == auth_groups['read'] # Unchanged + assert existing_mcng.modified != original_modified # Changed + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_node_logging(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test that node logging occurs during update""" + # Setup + auth_groups = {} + for perm in ['read', 'write', 'admin']: + auth_groups[perm] = AuthGroup.objects.get_or_create(name=f'node_{node.id}_{perm}')[0] + + mapcore_group = MapCoreGroup.objects.create(_id='test-group-update-logging') + + existing_mcng = MapCoreNodeGroup.objects.create( + node=node, + mapcore_group=mapcore_group, + group=auth_groups['read'], + creator=user, + is_deleted=False + ) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'admin': auth_groups['admin'].id, + 'write': auth_groups['write'].id, + 'read': auth_groups['read'].id + } + + validated_data = { + 'node_groups': [ + { + 'node_group_id': existing_mcng.id, + 'permission': 'admin' + } + ] + } + + req = make_drf_request_with_version(version='2.0') + serializer = NodeMapCoreGroupUpdateSerializer( + context={'request': req, 'node': node} + ) + + original_modified = node.modified + + # Execute + result = serializer.create(validated_data) + assert len(result) == 1 + + # Verify node was modified (for logging) + node.refresh_from_db() + assert node.modified > original_modified + + def setup_auth_groups(self, node): + """Helper to create auth groups for a node""" + groups = {} + for perm in ['read', 'write', 'admin']: + groups[perm] = AuthGroup.objects.get_or_create(name=f'node_{node.id}_{perm}')[0] + return groups + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_visible_only(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test updating only the visible field""" + auth_groups = self.setup_auth_groups(node) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-visible') + existing_mcng = MapCoreNodeGroup.objects.create( + node=node, mapcore_group=mapcore_group, group=auth_groups['read'], + creator=user, is_deleted=False, visible=True, _order=0 + ) + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = {'read': auth_groups['read'].id} + + validated_data = {'node_groups': [{'node_group_id': existing_mcng.id, 'visible': False}]} + serializer = NodeMapCoreGroupUpdateSerializer(context={'request': make_drf_request_with_version(), 'node': node}) + serializer.create(validated_data) + + existing_mcng.refresh_from_db() + assert existing_mcng.visible is False + assert existing_mcng._order == 0 # Unchanged + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_order_only(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test updating only the _order field based on list index""" + auth_groups = self.setup_auth_groups(node) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-order') + existing_mcng = MapCoreNodeGroup.objects.create( + node=node, mapcore_group=mapcore_group, group=auth_groups['read'], + creator=user, is_deleted=False, visible=True, _order=0 + ) + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = {'read': auth_groups['read'].id} + + # The _order field in the request is ignored; order is based on list index. + validated_data = {'node_groups': [{'node_group_id': existing_mcng.id}]} + serializer = NodeMapCoreGroupUpdateSerializer(context={'request': make_drf_request_with_version(), 'node': node}) + serializer.create(validated_data) + + existing_mcng.refresh_from_db() + assert existing_mcng.visible is True # Unchanged + assert existing_mcng._order == 0 # Based on index + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_update_visible_and_order(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test updating both visible and _order fields""" + auth_groups = self.setup_auth_groups(node) + mapcore_group = MapCoreGroup.objects.create(_id='test-group-visible-order') + existing_mcng = MapCoreNodeGroup.objects.create( + node=node, mapcore_group=mapcore_group, group=auth_groups['read'], + creator=user, is_deleted=False, visible=True, _order=1 + ) + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = {'read': auth_groups['read'].id} + + # _order is determined by index (0), not the passed value. + validated_data = {'node_groups': [{'node_group_id': existing_mcng.id, 'visible': False}]} + serializer = NodeMapCoreGroupUpdateSerializer(context={'request': make_drf_request_with_version(), 'node': node}) + serializer.create(validated_data) + + existing_mcng.refresh_from_db() + assert existing_mcng.visible is False + assert existing_mcng._order == 0 # Updated to 0 based on index + + @patch('api.nodes.serializers.get_group_by_node') + @patch('api.nodes.serializers.get_user_auth') + def test_reorder_multiple_groups(self, mock_get_user_auth, mock_get_group_by_node, user, node): + """Test that sending a list of groups updates their order""" + auth_groups = self.setup_auth_groups(node) + mc_group1 = MapCoreGroup.objects.create(_id='reorder-1') + mc_group2 = MapCoreGroup.objects.create(_id='reorder-2') + + mcng1 = MapCoreNodeGroup.objects.create( + node=node, mapcore_group=mc_group1, group=auth_groups['read'], + creator=user, _order=0 + ) + mcng2 = MapCoreNodeGroup.objects.create( + node=node, mapcore_group=mc_group2, group=auth_groups['write'], + creator=user, _order=1 + ) + + mock_get_user_auth.return_value = MagicMock(user=user) + mock_get_group_by_node.return_value = { + 'read': auth_groups['read'].id, + 'write': auth_groups['write'].id + } + + # Reverse the order in the request + validated_data = { + 'node_groups': [ + {'node_group_id': mcng2.id}, # Should become order 0 + {'node_group_id': mcng1.id}, # Should become order 1 + ] + } + serializer = NodeMapCoreGroupUpdateSerializer(context={'request': make_drf_request_with_version(), 'node': node}) + serializer.create(validated_data) + + mcng1.refresh_from_db() + mcng2.refresh_from_db() + + assert mcng1._order == 1 + assert mcng2._order == 0 + + +@pytest.mark.django_db +class TestNodeSerializerMapCoreIntegration: + """Test NodeSerializer get_node_count and node creation with MapCore group""" + + def test_get_node_count(self, node): + """Test get_node_count returns correct count""" + NodeFactory(creator=node.creator, parent=node, is_deleted=False, is_public=True) + NodeFactory(creator=node.creator, parent=node, is_deleted=False, is_public=True) + + req = make_drf_request_with_version(version='2.0') + serializer = NodeSerializer(instance=node, context={'request': req}) + count = serializer.get_node_count(node) + assert count == 2 + + @pytest.mark.django_db + def test_create_node_with_mapcore_group_parent_writable(self, user): + # Create a MapCore group and parent node + mapcore_group = MapCoreGroup.objects.create(_id='test-mapcore-create-parent-writable') + parent_node = NodeFactory(creator=user) + + # Attach a MapCoreNodeGroup to the parent so it can be inherited + auth_group = AuthGroup.objects.get_or_create(name=f'node_{parent_node.id}_admin')[0] + MapCoreNodeGroup.objects.create( + node=parent_node, + group=auth_group, + mapcore_group=mapcore_group, + creator=user, + is_deleted=False, + ) + + # Grant the test user write permission on the parent so has_permission(...) returns True + parent_node.add_contributor(user, permissions='admin', save=True) + assert parent_node.has_permission(user, 'write') + user.is_registered = True + user.save() + # Prepare request that requests inheritance + req = make_drf_request_with_version(version='2.0') + req._request.GET = {'inherit_contributors': 'true'} + req.user = user + + validated_data = { + 'title': 'Child Node inheriting MapCore', + 'category': 'project', + 'parent': parent_node, + 'creator': user, + } + + serializer = NodeSerializer(context={'request': req}) + child_node = serializer.create(validated_data) + + # Verify a MapCoreNodeGroup was copied from parent to child + assert MapCoreNodeGroup.objects.filter(node=child_node, mapcore_group=mapcore_group, is_deleted=False).exists() diff --git a/api_tests/nodes/views/test_node_mapcore_group_views.py b/api_tests/nodes/views/test_node_mapcore_group_views.py new file mode 100644 index 00000000000..23effc4d69e --- /dev/null +++ b/api_tests/nodes/views/test_node_mapcore_group_views.py @@ -0,0 +1,890 @@ +import pytest +from django.contrib.auth.models import Group as AuthGroup + +from api.base.settings.defaults import API_BASE +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_node_group import MapCoreNodeGroup +from osf.models.mapcore_user_group import MapCoreUserGroup +from osf_tests.factories import AuthUserFactory, NodeFactory, ProjectFactory, UserFactory +from tests.base import ApiTestCase + +@pytest.mark.django_db +class TestNodeMapCoreGroupList(ApiTestCase): + """Test cases for NodeMapCoreGroupList view""" + + def setUp(self): + super().setUp() + self.user = AuthUserFactory() + self.admin_user = AuthUserFactory() + self.read_only_user = AuthUserFactory() + + self.node = ProjectFactory(creator=self.admin_user, is_public=False) + self.node.add_contributor(self.user, permissions='admin') + self.node.add_contributor(self.read_only_user, permissions='read') + self.node.save() + + # Create auth groups for the node + self.auth_groups = {} + for perm in ['read', 'write', 'admin']: + self.auth_groups[perm] = AuthGroup.objects.get_or_create( + name=f'node_{self.node.id}_{perm}' + )[0] + + # Create MapCoreGroups + self.mapcore_group1 = MapCoreGroup.objects.create(_id='test-mapcore-1') + self.mapcore_group2 = MapCoreGroup.objects.create(_id='test-mapcore-2') + + # Create MapCoreNodeGroup relationships + self.mcng1 = MapCoreNodeGroup.objects.create( + node=self.node, + group=self.auth_groups['admin'], + mapcore_group=self.mapcore_group1, + creator=self.admin_user, + ) + self.mcng2 = MapCoreNodeGroup.objects.create( + node=self.node, + group=self.auth_groups['write'], + mapcore_group=self.mapcore_group2, + creator=self.admin_user, + ) + + self.url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/' + + def test_list_mapcore_groups_success(self): + """Test listing MapCoreNodeGroup relationships""" + res = self.app.get(self.url, auth=self.user.auth) + assert res.status_code == 200 + assert len(res.json['data']) == 2 + + # Verify structure of response + item = res.json['data'][0] + assert 'id' in item + assert item['type'] == 'node-mapcore-group' + assert 'attributes' in item + assert 'links' in item + + def test_list_mapcore_groups_permissions_attached(self): + """Test that permissions are properly attached to serialized objects""" + res = self.app.get(self.url, auth=self.user.auth) + assert res.status_code == 200 + + # Find the item with mapcore_group1 + items = res.json['data'] + item1 = next((i for i in items if i['attributes']['mapcore_group_id'] == self.mapcore_group1.id), None) + assert item1 is not None + assert 'permission' in item1['attributes'] + + def test_list_mapcore_groups_unauthenticated_public_node(self): + """Test listing MapCoreGroups on public node without auth""" + self.node.is_public = True + self.node.save() + + res = self.app.get(self.url) + assert res.status_code == 200 + + def test_list_mapcore_groups_unauthenticated_private_node(self): + """Test listing MapCoreGroups on private node without auth returns 401""" + res = self.app.get(self.url, expect_errors=True) + assert res.status_code == 401 + + def test_list_mapcore_groups_read_only_user(self): + """Test read-only user can list MapCoreGroups on public node""" + self.node.is_public = True + self.node.save() + + res = self.app.get(self.url, auth=self.read_only_user.auth) + assert res.status_code == 200 + + def test_list_mapcore_groups_ordering(self): + """Test that MapCoreGroups are ordered by mapcore_group___id""" + # Create another group with _id that sorts before 'test-mapcore-1' + mapcore_group3 = MapCoreGroup.objects.create(_id='aaa-test-mapcore') + MapCoreNodeGroup.objects.create( + node=self.node, + group=self.auth_groups['read'], + mapcore_group=mapcore_group3, + creator=self.admin_user, + ) + + res = self.app.get(self.url, auth=self.user.auth) + assert res.status_code == 200 + + # Check ordering + items = res.json['data'] + assert len(items) == 3 + # Should be ordered by mapcore_group___id + assert items[0]['attributes']['name'] == 'test-mapcore-1' + assert items[1]['attributes']['name'] == 'test-mapcore-2' + assert items[2]['attributes']['name'] == 'aaa-test-mapcore' + + def test_create_mapcore_group_success(self): + """Test creating a new MapCoreNodeGroup relationship""" + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'write' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth) + assert res.status_code == 201 + assert len(res.json['data']) == 1 + + created_item = res.json['data'][0] + assert created_item['attributes']['mapcore_group_id'] == mapcore_group3.id + assert created_item['attributes']['permission'] == 'write' + + # Verify database + mcng = MapCoreNodeGroup.objects.get( + node=self.node, + mapcore_group=mapcore_group3, + is_deleted=False + ) + assert mcng.group == self.auth_groups['write'] + + def test_create_mapcore_group_multiple(self): + """Test creating multiple MapCoreNodeGroup relationships at once""" + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + mapcore_group4 = MapCoreGroup.objects.create(_id='test-mapcore-4') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'write' + }, + { + 'mapcore_group_id': mapcore_group4.id, + 'permission': 'admin' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth) + assert res.status_code == 201 + assert len(res.json['data']) == 2 + + def test_create_mapcore_group_with_components(self): + """Test creating MapCoreNodeGroup with component nodes""" + component1 = NodeFactory(creator=self.admin_user, parent=self.node) + component2 = NodeFactory(creator=self.admin_user, parent=self.node) + auth_groups_component = {} + for component in [component1, component2]: + auth_groups_component[component.id] = AuthGroup.objects.get_or_create( + name=f'node_{component.id}_write' + )[0] + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'write' + } + ], + 'component_ids': [component1._id, component2._id] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth) + assert res.status_code == 201 + + # Verify main node relationship + mcng_main = MapCoreNodeGroup.objects.get( + node=self.node, + mapcore_group=mapcore_group3, + is_deleted=False + ) + assert mcng_main.group == self.auth_groups['write'] + + # Verify component relationships + for component in [component1, component2]: + mcng_comp = MapCoreNodeGroup.objects.get( + node=component, + mapcore_group=mapcore_group3, + is_deleted=False + ) + assert mcng_comp.group == auth_groups_component[component.id] + + def test_create_mapcore_group_empty_node_groups_fails(self): + """Test creating with empty node_groups fails""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'non-empty' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_missing_required_fields(self): + """Test creating without required fields fails""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': 123 + # Missing 'permission' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'permission' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_invalid_permission(self): + """Test creating with invalid permission fails""" + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'invalid_permission' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'invalid' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_duplicate_in_request(self): + """Test creating with duplicate mapcore_group_id in request fails""" + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'write' + }, + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'admin' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'duplicate' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_nonexistent_mapcore_group_id(self): + """Test creating with nonexistent mapcore_group_id fails""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': 99999, + 'permission': 'write' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'not found' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_already_exists(self): + """Test creating duplicate MapCoreNodeGroup fails""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': self.mapcore_group1.id, + 'permission': 'write' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'already exists' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_duplicate_component_ids(self): + """Test creating with duplicate component_ids fails""" + component = NodeFactory(creator=self.admin_user, parent=self.node) + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'write' + } + ], + 'component_ids': [component._id, component._id] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'duplicate' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_nonexistent_component_ids(self): + """Test creating with nonexistent component_ids fails""" + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'write' + } + ], + 'component_ids': ['nonexistent123'] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'not found' in res.json['errors'][0]['detail'].lower() + + def test_create_mapcore_group_non_admin_fails(self): + """Test non-admin user cannot create MapCoreNodeGroup""" + mapcore_group3 = MapCoreGroup.objects.create(_id='test-mapcore-3') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group3.id, + 'permission': 'write' + } + ] + } + } + } + + res = self.app.post_json(self.url, payload, auth=self.read_only_user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_update_mapcore_group_success(self): + """Test updating MapCoreNodeGroup permission""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': self.mcng1.id, + 'permission': 'read' + } + ] + } + } + } + + res = self.app.patch_json(self.url, payload, auth=self.admin_user.auth) + assert res.status_code == 200 + assert len(res.json['data']) == 1 + + updated_item = res.json['data'][0] + assert updated_item['attributes']['permission'] == 'read' + + # Verify database + self.mcng1.refresh_from_db() + assert self.mcng1.group == self.auth_groups['read'] + + def test_update_mapcore_group_multiple(self): + """Test updating multiple MapCoreNodeGroups""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': self.mcng1.id, + 'permission': 'read' + }, + { + 'node_group_id': self.mcng2.id, + 'permission': 'admin' + } + ] + } + } + } + + res = self.app.patch_json(self.url, payload, auth=self.admin_user.auth) + assert res.status_code == 200 + assert len(res.json['data']) == 2 + + # Verify database + self.mcng1.refresh_from_db() + self.mcng2.refresh_from_db() + assert self.mcng1.group == self.auth_groups['read'] + assert self.mcng2.group == self.auth_groups['admin'] + + def test_update_mapcore_group_empty_node_groups_fails(self): + """Test updating with empty node_groups fails""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [] + } + } + } + + res = self.app.patch_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + + def test_update_mapcore_group_duplicate_node_group_ids(self): + """Test updating with duplicate node_group_ids fails""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': self.mcng1.id, + 'permission': 'read' + }, + { + 'node_group_id': self.mcng1.id, + 'permission': 'write' + } + ] + } + } + } + + res = self.app.patch_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'duplicate' in res.json['errors'][0]['detail'].lower() + + def test_update_mapcore_group_nonexistent_node_group_id(self): + """Test updating with nonexistent node_group_id fails""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': 99999, + 'permission': 'read' + } + ] + } + } + } + + res = self.app.patch_json(self.url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'not found' in res.json['errors'][0]['detail'].lower() + + def test_update_mapcore_group_non_admin_fails(self): + """Test non-admin user cannot update MapCoreNodeGroup""" + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': self.mcng1.id, + 'permission': 'read' + } + ] + } + } + } + + res = self.app.patch_json(self.url, payload, auth=self.read_only_user.auth, expect_errors=True) + assert res.status_code == 403 + + +@pytest.mark.django_db +class TestNodeMapCoreGroupRemove(ApiTestCase): + """Test cases for NodeMapCoreGroupRemove view""" + + def setUp(self): + super().setUp() + self.user = AuthUserFactory() + self.admin_user = AuthUserFactory() + self.read_only_user = AuthUserFactory() + + self.node = ProjectFactory(creator=self.admin_user, is_public=False) + self.node.add_contributor(self.user, permissions='admin') + self.node.add_contributor(self.read_only_user, permissions='read') + self.node.save() + + # Create auth groups for the node + self.auth_groups = {} + for perm in ['read', 'write', 'admin']: + self.auth_groups[perm] = AuthGroup.objects.get_or_create( + name=f'node_{self.node.id}_{perm}' + )[0] + + # Create MapCoreGroup + self.mapcore_group = MapCoreGroup.objects.create(_id='test-mapcore-remove') + + # Create MapCoreNodeGroup relationship + self.mcng = MapCoreNodeGroup.objects.create( + node=self.node, + group=self.auth_groups['admin'], + mapcore_group=self.mapcore_group, + creator=self.admin_user, + ) + + def test_delete_mapcore_group_success(self): + """Test deleting a MapCoreNodeGroup relationship""" + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + res = self.app.delete(url, auth=self.admin_user.auth) + assert res.status_code == 204 + + # Verify soft delete in database + self.mcng.refresh_from_db() + assert self.mcng.is_deleted is True + + def test_delete_mapcore_group_with_components(self): + """Test deleting MapCoreNodeGroup with component relationships""" + component1 = NodeFactory(creator=self.admin_user, parent=self.node) + component2 = NodeFactory(creator=self.admin_user, parent=self.node) + + # Create component relationships + mcng_comp1 = MapCoreNodeGroup.objects.create( + node=component1, + group=self.auth_groups['write'], + mapcore_group=self.mapcore_group, + creator=self.admin_user, + ) + mcng_comp2 = MapCoreNodeGroup.objects.create( + node=component2, + group=self.auth_groups['write'], + mapcore_group=self.mapcore_group, + creator=self.admin_user, + ) + + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/?component_ids={component1._id},{component2._id}' + + res = self.app.delete(url, auth=self.admin_user.auth) + assert res.status_code == 204 + + # Verify all relationships are soft deleted + self.mcng.refresh_from_db() + mcng_comp1.refresh_from_db() + mcng_comp2.refresh_from_db() + + assert self.mcng.is_deleted is True + assert mcng_comp1.is_deleted is True + assert mcng_comp2.is_deleted is True + + def test_delete_mapcore_group_duplicate_component_ids_fails(self): + """Test deleting with duplicate component_ids fails""" + component = NodeFactory(creator=self.admin_user, parent=self.node) + + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/?component_ids={component._id},{component._id}' + + res = self.app.delete(url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'duplicate' in res.json['errors'][0]['detail'].lower() + + def test_delete_mapcore_group_nonexistent_component_ids_fails(self): + """Test deleting with nonexistent component_ids fails""" + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/?component_ids=nonexistent123' + + res = self.app.delete(url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'not found' in res.json['errors'][0]['detail'].lower() + + def test_delete_mapcore_group_component_not_child_fails(self): + """Test deleting with component_id not a child of the node fails""" + other_node = ProjectFactory(creator=self.admin_user) + component = NodeFactory(creator=self.admin_user, parent=other_node) + + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/?component_ids={component._id}' + + res = self.app.delete(url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 400 + assert 'not children' in res.json['errors'][0]['detail'].lower() + + def test_delete_mapcore_group_component_missing_relationship_fails(self): + """Test deleting component that doesn't have the relationship fails""" + component = NodeFactory(creator=self.admin_user, parent=self.node) + # No MapCoreNodeGroup relationship created for component + + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/?component_ids={component._id}' + + res = self.app.delete(url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 404 + assert 'not found' in res.json['errors'][0]['detail'].lower() + + def test_delete_mapcore_group_nonexistent_node_group_id_fails(self): + """Test deleting with nonexistent node_group_id fails""" + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/99999/' + + res = self.app.delete(url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 404 + + def test_delete_mapcore_group_already_deleted_fails(self): + """Test deleting already deleted MapCoreNodeGroup fails""" + self.mcng.is_deleted = True + self.mcng.save() + + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + res = self.app.delete(url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 404 + + def test_delete_mapcore_group_non_admin_fails(self): + """Test non-admin user cannot delete MapCoreNodeGroup""" + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + res = self.app.delete(url, auth=self.read_only_user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_delete_mapcore_group_unauthenticated_fails(self): + """Test unauthenticated user cannot delete MapCoreNodeGroup""" + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + res = self.app.delete(url, expect_errors=True) + assert res.status_code == 401 + + def test_delete_mapcore_group_creates_log(self): + """Test that deleting creates a log entry""" + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + initial_log_count = self.node.logs.count() + + res = self.app.delete(url, auth=self.admin_user.auth) + assert res.status_code == 204 + + # Verify log was created + self.node.refresh_from_db() + assert self.node.logs.count() == initial_log_count + 1 + + latest_log = self.node.logs.first() + assert latest_log.action == self.node.log_class.MAPCORE_GROUP_REMOVED + + def test_delete_mapcore_group_updates_node_modified(self): + """Test that deleting updates node modified timestamp""" + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + original_modified = self.node.modified + + res = self.app.delete(url, auth=self.admin_user.auth) + assert res.status_code == 204 + + # Verify node modified was updated + self.node.refresh_from_db() + assert self.node.modified > original_modified + + def test_delete_mapcore_group_from_public_node(self): + """Test deleting MapCoreNodeGroup from public node""" + self.node.is_public = True + self.node.save() + + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + res = self.app.delete(url, auth=self.admin_user.auth) + assert res.status_code == 204 + +@pytest.mark.django_db +class TestMixinMapCorePermissions: + def test_mapcore_node_group_get_permission(self): + node = ProjectFactory() + creator = node.creator + mg = MapCoreGroup.objects.create(_id='mcg-parse') + + ag_admin = AuthGroup.objects.create(name=f'node_{node._id}_admin') + ag_read = AuthGroup.objects.create(name=f'node_{node._id}_read') + ag_write = AuthGroup.objects.create(name=f'node_{node._id}_write') + ag_other = AuthGroup.objects.create(name='some_other_group') + + mng_admin = MapCoreNodeGroup.objects.create(node=node, group=ag_admin, mapcore_group=mg, creator=creator) + mng_read = MapCoreNodeGroup.objects.create(node=node, group=ag_read, mapcore_group=mg, creator=creator) + mng_write = MapCoreNodeGroup.objects.create(node=node, group=ag_write, mapcore_group=mg, creator=creator) + mng_other = MapCoreNodeGroup.objects.create(node=node, group=ag_other, mapcore_group=mg, creator=creator) + + assert mng_admin.get_permission == 'admin' + assert mng_read.get_permission == 'read' + assert mng_write.get_permission == 'write' + assert mng_other.get_permission is None + + def test_has_permission_mapcore_grants_and_denies(self): + from django.contrib.auth.models import Group as AuthGroup, Permission + from osf.models.node import NodeGroupObjectPermission + + node = ProjectFactory() + # user that will be "in" the mapcore group + mapcore_user = UserFactory() + # other user not in group + other_user = UserFactory() + + mg = MapCoreGroup.objects.create(_id='mcg-grant') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + # link auth group <-> node via mapcore mapping + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + + # link user <-> mapcore group + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + # give the auth group the node 'read' permission via NodeGroupObjectPermission + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + # user who is in mapcore group should have read permission + assert node.has_permission(mapcore_user, 'read') is True + + # user not in mapcore group should not have read permission + assert node.has_permission(other_user, 'read') is False + + def test_has_permission_by_is_admin_group_parent(self): + from django.contrib.auth.models import Group as AuthGroup, Permission + from osf.models.node import NodeGroupObjectPermission + + # Create a root node and a child node + root = ProjectFactory() + child = NodeFactory(creator=root.creator, parent=root) + + # Users + mapcore_user = UserFactory() + other_user = UserFactory() + + # MapCore group and corresponding auth group for the root node (admin) + mg = MapCoreGroup.objects.create(_id='mcg-parent-admin') + ag = AuthGroup.objects.create(name=f'node_{root._id}_admin') + + # Link auth group <-> root node via MapCoreNodeGroup + MapCoreNodeGroup.objects.create(node=root, group=ag, mapcore_group=mg, creator=root.creator) + + # Link user <-> mapcore group + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + # Give the auth group the 'admin_node' permission on the root node + perm = Permission.objects.get(codename='admin_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=root) + + # Because the auth group on the parent (root) has admin_node, the child should + # grant read permission to users that belong to the MapCore group + assert child.has_permission(mapcore_user, 'read') is True + + # A user not in the linked MapCore group should not get read via this chain + assert child.has_permission(other_user, 'read') is False + + def test_has_permission_handles_mapcore_node_group_filter_exception(self): + from unittest import mock + + node = ProjectFactory() + mapcore_user = UserFactory() + + mg = MapCoreGroup.objects.create(_id='mcg-ex-hasperm') + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + # Simulate MapCoreNodeGroup.objects.filter raising an exception both where used in has_permission + # and potential calls to is_admin_group_parent (which also calls MapCoreNodeGroup.objects.filter). + with mock.patch('osf.models.mapcore_node_group.MapCoreNodeGroup.objects.filter', side_effect=Exception('boom')): + # Should not raise; should fall back to normal permission checks and return False + assert node.has_permission(mapcore_user, 'read') is False + + def test_get_permissions_mapcore_includes_and_excludes(self): + from django.contrib.auth.models import Group as AuthGroup, Permission + from osf.models.node import NodeGroupObjectPermission + + node = ProjectFactory() + mapcore_user = UserFactory() + other_user = UserFactory() + + mg = MapCoreGroup.objects.create(_id='mcg-getperms') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + perms_mapcore = node.get_permissions(mapcore_user) + assert 'read' in perms_mapcore + + perms_other = node.get_permissions(other_user) + # other_user has no group-derived permission and is not a contributor, expect no 'read' + assert 'read' not in perms_other + + def test_get_permissions_handles_mapcore_node_group_filter_exception(self): + from unittest import mock + + node = ProjectFactory() + user = UserFactory() + + # Create a MapCoreGroup and link the user (so MapCoreUserGroup.filter would normally return ids) + mg = MapCoreGroup.objects.create(_id='mcg-ex-getperms') + MapCoreUserGroup.objects.create(user=user, mapcore_group=mg, is_deleted=False) + + # Simulate MapCoreNodeGroup.objects.filter raising an exception + with mock.patch('osf.models.mapcore_node_group.MapCoreNodeGroup.objects.filter', side_effect=Exception('boom')): + perms = node.get_permissions(user) + + # Should handle exception and return an empty list (no derived group perms) + assert perms == [] + + def test_is_admin_group_parent_handles_mapcore_node_group_filter_exception(self): + from unittest import mock + from osf.models.mixins import is_admin_group_parent + + parent = ProjectFactory() + mg = MapCoreGroup.objects.create(_id='mcg-ex-isadmin') + # user_mapcore_group_ids could be anything; if MapCoreNodeGroup.filter raises, function should return False + user_mapcore_group_ids = [mg.id] + + with mock.patch('osf.models.mapcore_node_group.MapCoreNodeGroup.objects.filter', side_effect=Exception('boom')): + assert is_admin_group_parent(parent, user_mapcore_group_ids) is False diff --git a/api_tests/users/serializers/test_serializers.py b/api_tests/users/serializers/test_serializers.py index b974daffda1..26395cc2666 100644 --- a/api_tests/users/serializers/test_serializers.py +++ b/api_tests/users/serializers/test_serializers.py @@ -248,3 +248,43 @@ def test_user_serializer_get_can_create_project(self, user): req.user = user result = UserSerializer(user, context={'request': req}) assert result.get_can_create_new_project(user) is True + + +@pytest.mark.django_db +@pytest.mark.enable_quickfiles_creation +class TestUserNodeSerializer: + params = {} + + def test_get_mapcore_groups(self, user): + from osf.models.mapcore_group import MapCoreGroup + from osf.models.mapcore_node_group import MapCoreNodeGroup + from django.contrib.auth.models import Group as AuthGroup + from api.users.serializers import UserNodeSerializer + from osf_tests.factories import ProjectFactory + + node = ProjectFactory(creator=user) + + # Create two MapCoreGroup records + g1 = MapCoreGroup.objects.create(_id='group-one') + g2 = MapCoreGroup.objects.create(_id='group-two') + + # Create auth groups required by MapCoreNodeGroup + ag1 = AuthGroup.objects.create(name=f'node_{node._id}_read') + ag2 = AuthGroup.objects.create(name=f'node_{node._id}_write') + + # Attach both groups to the node; add one deleted entry to ensure it's ignored + MapCoreNodeGroup.objects.create(node=node, group=ag1, mapcore_group=g1, creator=user) + MapCoreNodeGroup.objects.create(node=node, group=ag2, mapcore_group=g2, creator=user) + MapCoreNodeGroup.objects.create(node=node, group=ag2, mapcore_group=g2, creator=user, is_deleted=True) + + # Build a request and serializer with context (TaxonomizableSerializerMixin expects request) + req = make_drf_request_with_version(version='2.0') + req.user = user + serializer = UserNodeSerializer(context={'request': req}) + + # Serializer should return mapcore group _ids ordered by _id + result = serializer.get_mapcore_groups(node) + assert result == [g1._id, g2._id] + + # Non-node input should return an empty list + assert serializer.get_mapcore_groups({}) == [] diff --git a/osf/migrations/0261_mapcoregroup_mapcorenodegroup_mapcoreusergroup.py b/osf/migrations/0261_mapcoregroup_mapcorenodegroup_mapcoreusergroup.py new file mode 100644 index 00000000000..1a664541b2c --- /dev/null +++ b/osf/migrations/0261_mapcoregroup_mapcorenodegroup_mapcoreusergroup.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2025-11-04 08:32 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import osf.models.base + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0260_merge_20251126_1230'), + ] + + operations = [ + migrations.CreateModel( + name='MapCoreGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('_id', models.CharField(max_length=255, unique=True)), + ], + options={ + 'db_table': 'osf_mapcore_group', + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + migrations.CreateModel( + name='MapCoreNodeGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('is_deleted', models.BooleanField(default=False)), + ('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mapcore_node_group_creator', to=settings.AUTH_USER_MODEL)), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_group_mapcore_nodes', to='auth.Group')), + ('mapcore_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mapcore_group_nodes', to='osf.MapCoreGroup')), + ('node', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mapcore_node_groups', to='osf.Node')), + ], + options={ + 'db_table': 'osf_mapcore_node_group', + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + migrations.CreateModel( + name='MapCoreUserGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('is_deleted', models.BooleanField(default=False)), + ('mapcore_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mapcore_user_groups', to='osf.MapCoreGroup')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mapcore_user_groups', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'osf_mapcore_user_group', + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + ] diff --git a/osf/migrations/0262_auto_20260202_0643.py b/osf/migrations/0262_auto_20260202_0643.py new file mode 100644 index 00000000000..aa5f3ebc5e0 --- /dev/null +++ b/osf/migrations/0262_auto_20260202_0643.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2026-02-02 06:43 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0261_mapcoregroup_mapcorenodegroup_mapcoreusergroup'), + ] + + operations = [ + migrations.AddField( + model_name='mapcorenodegroup', + name='visible', + field=models.BooleanField(default=False), + ), + migrations.AlterOrderWithRespectTo( + name='mapcorenodegroup', + order_with_respect_to='mapcore_group', + ), + ] diff --git a/osf/models/mapcore_group.py b/osf/models/mapcore_group.py new file mode 100644 index 00000000000..0c16c48f04e --- /dev/null +++ b/osf/models/mapcore_group.py @@ -0,0 +1,14 @@ +from django.db import models +from osf.models.base import BaseModel +from website.settings import MAPCORE_GROUP_HOSTNAME, MAPCORE_GROUP_API_PATH + + +class MapCoreGroup(BaseModel): + _id = models.CharField(max_length=255, unique=True) + + class Meta: + db_table = 'osf_mapcore_group' + + @property + def absolute_url(self): + return f'{MAPCORE_GROUP_HOSTNAME}{MAPCORE_GROUP_API_PATH}{self._id}/' diff --git a/osf/models/mapcore_node_group.py b/osf/models/mapcore_node_group.py new file mode 100644 index 00000000000..01117b4b5ef --- /dev/null +++ b/osf/models/mapcore_node_group.py @@ -0,0 +1,35 @@ +from django.db import models +from osf.models.base import BaseModel +from osf.models.mapcore_group import MapCoreGroup +from django.contrib.auth.models import Group as AuthGroup +import logging + +logger = logging.getLogger(__name__) + +class MapCoreNodeGroup(BaseModel): + node = models.ForeignKey('osf.Node', on_delete=models.CASCADE, related_name='mapcore_node_groups') + group = models.ForeignKey(AuthGroup, on_delete=models.CASCADE, related_name='auth_group_mapcore_nodes') + mapcore_group = models.ForeignKey(MapCoreGroup, on_delete=models.CASCADE, related_name='mapcore_group_nodes') + creator = models.ForeignKey('osf.OSFUser', related_name='mapcore_node_group_creator', on_delete=models.CASCADE) + is_deleted = models.BooleanField(default=False) + visible = models.BooleanField(default=False) + class Meta: + db_table = 'osf_mapcore_node_group' + order_with_respect_to = 'mapcore_group' + + @property + def get_permission(self): + """ + If the auth group name matches patterns like: + - node__admin + - node__read + - node__write + return the permission string: 'admin', 'read', or 'write'. + Otherwise return None. + """ + import re + name = getattr(self.group, 'name', '') or '' + m = re.match(r'^node_[^_]+_(admin|read|write)$', name) + if m: + return m.group(1) + return None diff --git a/osf/models/mapcore_user_group.py b/osf/models/mapcore_user_group.py new file mode 100644 index 00000000000..70913a46e7f --- /dev/null +++ b/osf/models/mapcore_user_group.py @@ -0,0 +1,12 @@ +from django.db import models +from osf.models.base import BaseModel +from osf.models.mapcore_group import MapCoreGroup + + +class MapCoreUserGroup(BaseModel): + mapcore_group = models.ForeignKey(MapCoreGroup, on_delete=models.CASCADE, related_name='mapcore_user_groups') + user = models.ForeignKey('osf.OSFUser', related_name='mapcore_user_groups', on_delete=models.CASCADE) + is_deleted = models.BooleanField(default=False) + + class Meta: + db_table = 'osf_mapcore_user_group' diff --git a/osf/models/mixins.py b/osf/models/mixins.py index 36e56444759..1179f2c7efb 100644 --- a/osf/models/mixins.py +++ b/osf/models/mixins.py @@ -46,7 +46,8 @@ from website import settings, mails, language from website.project.licenses import set_license from api.base.rdmlogger import RdmLogger, rdmlog - +from osf.models.mapcore_node_group import MapCoreNodeGroup +from osf.models.mapcore_user_group import MapCoreUserGroup logger = logging.getLogger(__name__) @@ -1939,16 +1940,38 @@ def has_permission(self, user, permission, check_parent=True): :returns: User has required permission """ object_type = self.guardian_object_type + group_perm = [] + # Also check Auth Groups linked via MapCoreNodeGroup (by auth_group id) + if object_type == 'node': + try: + user_mapcore_group_ids = MapCoreUserGroup.objects.filter(user=user, is_deleted=False).values_list('mapcore_group_id', flat=True) + # get auth group ids linked to this object + auth_group_ids = MapCoreNodeGroup.objects.filter(node=self, mapcore_group_id__in=user_mapcore_group_ids, is_deleted=False).values_list('group_id', flat=True) + except Exception: + auth_group_ids = [] + if auth_group_ids: + NodeGroupPermModel = apps.get_model('osf', 'NodeGroupObjectPermission') + for gid in auth_group_ids: + perms_qs = NodeGroupPermModel.objects.filter(group_id=gid, content_object_id=self.id) + for perm in list(perms_qs.values_list('permission__codename', flat=True)): + if perm not in group_perm: + group_perm.append(perm) if not user or user.is_anonymous: return False perm = '{}_{}'.format(permission, object_type) + # If any permission codename matches expected perm, grant access + if perm in group_perm: + return True # Using get_group_perms to get permissions that are inferred through # group membership - not inherited from superuser status has_permission = perm in get_group_perms(user, self) if object_type == 'node': if not has_permission and permission == READ and check_parent: - return self.is_admin_parent(user) + if is_admin_group_parent(self.root, user_mapcore_group_ids): + return True + else: + return self.is_admin_parent(user) return has_permission # TODO: Remove save parameter @@ -1972,9 +1995,33 @@ def get_permissions(self, user): # Overrides guardian mixin - returns readable perms instead of literal perms if isinstance(user, AnonymousUser): return [] + + try: + user_mapcore_group_ids = MapCoreUserGroup.objects.filter(user=user, is_deleted=False).values_list('mapcore_group_id', flat=True) + # get auth group ids linked to this object + auth_group_ids = MapCoreNodeGroup.objects.filter(node=self, mapcore_group_id__in=user_mapcore_group_ids, is_deleted=False).values_list('group_id', flat=True) + except Exception: + auth_group_ids = [] + group_perms = [] + if auth_group_ids: + # Try OSF-specific node-group-permission model(s), then fallback to guardian's GroupObjectPermission + NodeGroupPermModel = apps.get_model('osf', 'NodeGroupObjectPermission') + for gid in auth_group_ids: + perms_qs = NodeGroupPermModel.objects.filter(group_id=gid, content_object_id=self.id) + for perm in list(perms_qs.values_list('permission__codename', flat=True)): + if perm not in group_perms: + group_perms.append(perm) + # If base_perms not on model, will error perms = self.base_perms user_perms = sorted(set(get_group_perms(user, self)).intersection(perms), key=perms.index) + + # Union distinct permissions from group_perms and perm_names, preserving base_perms order + combined_set = set(user_perms) | set(group_perms) + if combined_set: + user_perms = [p for p in perms if p in combined_set] + else: + user_perms = [] return [perm.split('_')[0] for perm in user_perms] def set_permissions(self, user, permissions, validate=True, save=False): @@ -2332,3 +2379,19 @@ def copy_editable_fields(self, resource, auth=None, alternative_resource=None, s class Meta: abstract = True + + +def is_admin_group_parent(parent_node, user_mapcore_group_ids): + try: + # get auth group ids linked to this object + auth_group_ids = MapCoreNodeGroup.objects.filter(node=parent_node, mapcore_group_id__in=user_mapcore_group_ids, is_deleted=False).values_list('group_id', flat=True) + except Exception: + auth_group_ids = [] + if auth_group_ids: + NodeGroupPermModel = apps.get_model('osf', 'NodeGroupObjectPermission') + for gid in auth_group_ids: + perms_qs = NodeGroupPermModel.objects.filter(group_id=gid, content_object_id=parent_node.id) + group_perm = list(perms_qs.values_list('permission__codename', flat=True)) + if 'admin_node' in group_perm: + return True + return False diff --git a/osf/models/node.py b/osf/models/node.py index f0dd3e36752..5628284a02f 100644 --- a/osf/models/node.py +++ b/osf/models/node.py @@ -5,6 +5,8 @@ import re from future.moves.urllib.parse import urljoin import warnings +from osf.models.mapcore_node_group import MapCoreNodeGroup +from osf.models.mapcore_user_group import MapCoreUserGroup from rest_framework import status as http_status import bson @@ -145,7 +147,7 @@ def get_children(self, root, active=False, include_root=False): row.append(root.pk) return AbstractNode.objects.filter(id__in=row) - def can_view(self, user=None, private_link=None): + def can_view(self, user=None, private_link=None, include_mapcore_groups=False): qs = self.filter(is_public=True) if private_link is not None: @@ -178,6 +180,30 @@ def can_view(self, user=None, private_link=None): ) SELECT * FROM implicit_read ) """], params=(user.id, )) + # Mapcore group permissions + if include_mapcore_groups: + qs |= self.extra(where=[""" + "osf_abstractnode".id in ( + WITH RECURSIVE implicit_read AS ( + SELECT distinct N.id as node_id + FROM osf_abstractnode as N, auth_permission as P, osf_nodegroupobjectpermission as G, osf_mapcore_user_group as OMUG, osf_mapcore_group as OMG, osf_mapcore_node_group as OMNG + WHERE P.codename = 'admin_node' + AND G.permission_id = P.id + AND OMUG.user_id = %s + AND OMNG.mapcore_group_id = OMUG.mapcore_group_id + AND G.group_id = OMNG.group_id + AND G.content_object_id = N.id + AND N.type = 'osf.node' + AND OMNG.is_deleted = false + UNION ALL + SELECT "osf_noderelation"."child_id" + FROM "implicit_read" + LEFT JOIN "osf_noderelation" ON "osf_noderelation"."parent_id" = "implicit_read"."node_id" + WHERE "osf_noderelation"."is_node_link" IS FALSE + ) SELECT * FROM implicit_read + ) + """], params=(user.id, )) + return qs.filter(is_deleted=False) @@ -199,7 +225,7 @@ def get_children(self, root, active=False, include_root=False): def can_view(self, user=None, private_link=None): return self.get_queryset().can_view(user=user, private_link=private_link) - def get_nodes_for_user(self, user, permission=READ_NODE, base_queryset=None, include_public=False): + def get_nodes_for_user(self, user, permission=READ_NODE, base_queryset=None, include_public=False, include_mapcore_groups=False): """ Return all AbstractNodes that the user has permissions to - either through contributorship or group membership. - similar to guardian.get_objects_for_user(self, READ_NODE, AbstractNode, with_superuser=False). If include_public is True, @@ -223,6 +249,10 @@ def get_nodes_for_user(self, user, permission=READ_NODE, base_queryset=None, inc user_groups = OSFUserGroup.objects.filter(osfuser_id=user.id if user else None).values_list('group_id', flat=True) node_groups = NodeGroupObjectPermission.objects.filter(group_id__in=user_groups, permission_id=permission_object_id).values_list('content_object_id', flat=True) query = Q(id__in=node_groups) + if include_mapcore_groups and user and not isinstance(user, AnonymousUser): + mapcore_user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False).values_list('mapcore_group_id', flat=True) + node_mapcore_groups = MapCoreNodeGroup.objects.filter(mapcore_group_id__in=mapcore_user_groups, is_deleted=False).values_list('node_id', flat=True) + query = Q(id__in=node_groups) | Q(id__in=node_mapcore_groups) if include_public: query |= Q(is_public=True) return nodes.filter(query) diff --git a/osf/models/nodelog.py b/osf/models/nodelog.py index 8780ae73ad6..32043099514 100644 --- a/osf/models/nodelog.py +++ b/osf/models/nodelog.py @@ -53,6 +53,13 @@ class NodeLog(ObjectIDMixin, BaseModel): CONTRIB_REJECTED = 'contributor_rejected' CONTRIB_REORDERED = 'contributors_reordered' + MAPCORE_GROUP_ADDED = 'mapcore_group_added' + MAPCORE_GROUP_REMOVED = 'mapcore_group_removed' + MAPCORE_GROUP_PERMISSION_UPDATED = 'mapcore_group_permission_updated' + MAPCORE_GROUP_REORDERED = 'mapcore_group_reordered' + MADE_MAPCORE_GROUP_VISIBLE = 'made_mapcore_group_visible' + MADE_MAPCORE_GROUP_INVISIBLE = 'made_mapcore_group_invisible' + CHECKED_IN = 'checked_in' CHECKED_OUT = 'checked_out' diff --git a/osf/models/user.py b/osf/models/user.py index 2725f9360be..abfb708a247 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -698,7 +698,8 @@ def nodes_contributor_or_group_member_to(self): Nodes that user has perms to through contributorship or group membership """ from osf.models import Node - return Node.objects.get_nodes_for_user(self) + include_mapcore = getattr(self, 'include_mapcore_groups', False) + return Node.objects.get_nodes_for_user(self, include_mapcore_groups=include_mapcore) def set_unusable_username(self): """Sets username to an unusable value. Used for, e.g. for invited contributors diff --git a/osf_tests/test_mapcore_group.py b/osf_tests/test_mapcore_group.py new file mode 100644 index 00000000000..4991f9b4254 --- /dev/null +++ b/osf_tests/test_mapcore_group.py @@ -0,0 +1,129 @@ +import pytest +from django.contrib.auth.models import Group as AuthGroup, Permission +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_node_group import MapCoreNodeGroup +from osf.models.mapcore_user_group import MapCoreUserGroup +from osf.models.node import Node +from osf.models.node import NodeGroupObjectPermission +from osf_tests.factories import PrivateLinkFactory, UserFactory, NodeFactory + +pytestmark = pytest.mark.django_db + + +class TestCanViewMapcoreGroups: + + def _make_node_group_permission(self, node, group, perm_codename='admin_node'): + """ + Helper: create NodeGroupObjectPermission linking the auth group to a permission on the node. + """ + # Get permission object id + perm = Permission.objects.get(codename=perm_codename) + # NodeGroupObjectPermission is defined in osf.models.node as a guardian-backed model + # We can create via NodeGroupObjectPermission.objects.create + return NodeGroupObjectPermission.objects.create( + group_id=group.id, + permission_id=perm.id, + content_object_id=node.id + ) + + def test_can_view_via_mapcore_group_when_included(self): + # Setup: node, user, mapcore group, auth group that follows 'node__admin' naming. + user = UserFactory() + node = NodeFactory(is_public=False) + # Create the MapCoreGroup + mc_group = MapCoreGroup.objects.create(_id='mc-1') + + # Create a Django Auth Group with name matching mapcore node group pattern + auth_group = AuthGroup.objects.create(name=f'node_{node._id}_admin') + + # Create MapCoreNodeGroup linking node, auth group and mapcore group (not deleted) + MapCoreNodeGroup.objects.create(node=node, group=auth_group, mapcore_group=mc_group, creator=user, is_deleted=False) + + # Create MapCoreUserGroup linking user to the MapCoreGroup (not deleted) + MapCoreUserGroup.objects.create(mapcore_group=mc_group, user=user, is_deleted=False) + + # Create the NodeGroupObjectPermission that actually grants the admin_node perm to the auth_group on the node + self._make_node_group_permission(node, auth_group, perm_codename='admin_node') + + # Now assert that with include_mapcore_groups True the node is visible to the user via Node.objects.can_view(...) + qs = Node.objects.get_queryset().can_view(user=user, private_link=None, include_mapcore_groups=True) + assert node in qs + + def test_can_view_with_private_link(self): + node = NodeFactory(is_public=False) + + # Create a private link and attach it to the node + pl = PrivateLinkFactory() + pl.nodes.add(node) + pl.save() + + # Passing the PrivateLink instance should return the node + qs_obj = Node.objects.get_queryset().can_view(user=None, private_link=pl) + assert node in qs_obj + + # Passing the key string should also return the node + qs_key = Node.objects.get_queryset().can_view(user=None, private_link=pl.key) + assert node in qs_key + + # Passing an invalid type should raise a TypeError + with pytest.raises(TypeError): + Node.objects.get_queryset().can_view(user=None, private_link=123) + + def test_cannot_view_via_mapcore_group_when_not_included(self): + # Same setup but do NOT include mapcore groups in the queryset + user = UserFactory() + node = NodeFactory(is_public=False) + mc_group = MapCoreGroup.objects.create(_id='mc-2') + auth_group = AuthGroup.objects.create(name=f'node_{node._id}_admin') + MapCoreNodeGroup.objects.create(node=node, group=auth_group, mapcore_group=mc_group, creator=user, is_deleted=False) + MapCoreUserGroup.objects.create(mapcore_group=mc_group, user=user, is_deleted=False) + self._make_node_group_permission(node, auth_group, perm_codename='admin_node') + + qs = Node.objects.get_queryset().can_view(user=user, private_link=None, include_mapcore_groups=False) + assert node not in qs + + def test_deleted_mapcore_node_group_is_ignored(self): + # If the MapCoreNodeGroup is marked is_deleted=True it should not grant visibility + user = UserFactory() + node = NodeFactory(is_public=False) + mc_group = MapCoreGroup.objects.create(_id='mc-3') + auth_group = AuthGroup.objects.create(name=f'node_{node._id}_admin') + MapCoreNodeGroup.objects.create(node=node, group=auth_group, mapcore_group=mc_group, creator=user, is_deleted=True) + MapCoreUserGroup.objects.create(mapcore_group=mc_group, user=user, is_deleted=False) + self._make_node_group_permission(node, auth_group, perm_codename='admin_node') + + qs = Node.objects.get_queryset().can_view(user=user, private_link=None, include_mapcore_groups=True) + assert node not in qs + + def test_get_nodes_for_user_include_mapcore_group(self): + user = UserFactory() + node = NodeFactory(is_public=False) + mc_group = MapCoreGroup.objects.create(_id='mc-4') + auth_group = AuthGroup.objects.create(name=f'node_{node._id}_admin') + MapCoreNodeGroup.objects.create(node=node, group=auth_group, mapcore_group=mc_group, creator=user, is_deleted=False) + MapCoreUserGroup.objects.create(mapcore_group=mc_group, user=user, is_deleted=False) + self._make_node_group_permission(node, auth_group, perm_codename='admin_node') + + qs_included = Node.objects.get_nodes_for_user(user, permission='admin_node', include_mapcore_groups=True) + assert node in qs_included + + qs_excluded = Node.objects.get_nodes_for_user(user, permission='admin_node', include_mapcore_groups=False) + assert node not in qs_excluded + + def test_get_nodes_for_user_invalid_permission_raises(self): + user = UserFactory() + with pytest.raises(ValueError): + Node.objects.get_nodes_for_user(user, permission='not_a_real_permission') + + def test_get_nodes_for_user_include_public(self): + user = UserFactory() + private_node = NodeFactory(is_public=False) + public_node = NodeFactory(is_public=True) + # Ensure public node is not returned when include_public=False + qs_no_public = Node.objects.get_nodes_for_user(user, permission='read_node', include_public=False) + assert public_node not in qs_no_public + # Ensure public node is returned when include_public=True + qs_with_public = Node.objects.get_nodes_for_user(user, permission='read_node', include_public=True) + assert public_node in qs_with_public + # Private node should still not be returned without explicit permission + assert private_node not in qs_with_public diff --git a/osf_tests/test_mapcore_node_group.py b/osf_tests/test_mapcore_node_group.py new file mode 100644 index 00000000000..70d9e2d36a3 --- /dev/null +++ b/osf_tests/test_mapcore_node_group.py @@ -0,0 +1,40 @@ +import pytest +from django.contrib.auth.models import Group as AuthGroup +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_node_group import MapCoreNodeGroup +from osf_tests.factories import UserFactory, NodeFactory + +pytestmark = pytest.mark.django_db + +def _make_mc_node_group(node, user, group_name): + auth_group = AuthGroup.objects.create(name=group_name) + mc_group = MapCoreGroup.objects.create(_id=f'mc-{group_name}') + return MapCoreNodeGroup.objects.create( + node=node, + group=auth_group, + mapcore_group=mc_group, + creator=user, + ) + +def test_get_permission_parses_admin_write_read(): + user = UserFactory() + node = NodeFactory(is_public=False) + + mc_admin = _make_mc_node_group(node, user, f'node_{node._id}_admin') + assert mc_admin.get_permission == 'admin' + + mc_write = _make_mc_node_group(node, user, f'node_{node._id}_write') + assert mc_write.get_permission == 'write' + + mc_read = _make_mc_node_group(node, user, f'node_{node._id}_read') + assert mc_read.get_permission == 'read' + +def test_get_permission_returns_none_for_unmatched_name(): + user = UserFactory() + node = NodeFactory(is_public=False) + + mc_other = _make_mc_node_group(node, user, 'some-random-group') + assert mc_other.get_permission is None + + mc_empty = _make_mc_node_group(node, user, '') + assert mc_empty.get_permission is None diff --git a/tests/test_node_groups_view.py b/tests/test_node_groups_view.py new file mode 100644 index 00000000000..d79f4624754 --- /dev/null +++ b/tests/test_node_groups_view.py @@ -0,0 +1,97 @@ +import mock +from osf.models import Registration +import pytest +from rest_framework import status as http_status +from framework.exceptions import HTTPError +from framework.auth.core import Auth + +from tests.base import OsfTestCase +from osf_tests.factories import RetractionFactory, Sanction, UserFactory, ProjectFactory, NodeFactory +from website.project.views.node import node_groups +from website import ember_osf_web +import waffle + +pytestmark = pytest.mark.django_db + + +class TestNodeGroupsView(OsfTestCase): + + def test_node_groups_requires_read_permission(self): + """ + If the calling user does not have READ permission, must_have_permission should raise 403. + We call the decorated view using kwargs `nid` and `user` so the decorators can + construct the Auth object from kwargs. + """ + with self.context: + node = ProjectFactory(is_public=False) + user = UserFactory() + + with pytest.raises(HTTPError) as excinfo: + # call decorated view; decorators expect nid/pid in kwargs + node_groups(nid=node._id, user=user) + err = excinfo.value + assert err.code == http_status.HTTP_403_FORBIDDEN + + def test_node_groups_returns_expected_keys_when_permitted(self): + """ + When user has permission, node_groups should return a dict containing 'groups' and 'adminGroups'. + """ + with self.context: + node = ProjectFactory(is_public=False) + creator = node.creator + # Create a user and grant READ permission + user = UserFactory() + # grant read via add_contributor / permission helpers + node.add_contributor(contributor=user, auth=Auth(creator), permissions='read') + node.save() + + # Ensure ember flag does not divert to the ember app + with mock.patch('waffle.flag_is_active', return_value=False): + result = node_groups(nid=node._id, user=user) + assert isinstance(result, dict) + assert 'groups' in result + assert 'adminGroups' in result + + def test_node_groups_redirects_if_retracted(self): + """ + If node is retracted, must_not_be_retracted_registration makes the view return a redirect response. + """ + with self.context: + # Create a registration and an approved retraction using the factories + retraction = RetractionFactory(state=Sanction.APPROVED, approve=True) + registration = Registration.objects.get(retraction=retraction) + # Ensure registration is public (decorator logic expects registration-like object) + registration.is_public = True + registration.save() + + # Use a user that has permission (creator) + user = registration.creator + + # Call — decorator should return a Flask redirect Response + # Use pid=... because this is a registration + with mock.patch('waffle.flag_is_active', return_value=False): + resp = node_groups(pid=registration._id, user=user) + + # Redirect responses from Flask typically have status_code 302 + # Accept either a Response-like object with status_code or a werkzeug Response + assert hasattr(resp, 'status_code') + assert resp.status_code in (301, 302, 303, 307) + + def test_node_groups_returns_ember_app_when_flag_active(self): + """ + When the EMBER feature flag is active, the ember_flag_is_active decorator should return use_ember_app() + instead of executing the view. Patch the decorator's use_ember_app to return a sentinel. + """ + with self.context: + node = ProjectFactory() + user = node.creator + + # Patch waffle to report the feature flag active + with mock.patch('waffle.flag_is_active', return_value=True): + # Patch the imported use_ember_app name in the decorators module to return a sentinel + # The decorator uses use_ember_app imported into website.ember_osf_web.decorators, + # so patch that name to avoid loading actual assets. + with mock.patch('website.ember_osf_web.decorators.use_ember_app', return_value='EMBER-SENTINEL'): + resp = node_groups(nid=node._id, user=user) + # Should be the sentinel we returned from patched use_ember_app + assert resp == 'EMBER-SENTINEL' diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 291f7901a60..cd4ac3994bb 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -4,6 +4,9 @@ import datetime as dt from nose.tools import * # noqa (PEP8 asserts) +from osf.models.mapcore_group import MapCoreGroup +from osf.models.mapcore_node_group import MapCoreNodeGroup +from django.contrib.auth.models import Group as AuthGroup import pytest from osf_tests.factories import ( ProjectFactory, @@ -19,7 +22,7 @@ from framework.auth import Auth from website.project.views.node import _view_project, _serialize_node_search, _get_children, _get_readable_descendants -from website.views import serialize_node_summary +from website.views import serialize_node_summary, serialize_mapcore_group_for_summary from website.profile import utils from website import filters, settings @@ -441,6 +444,35 @@ def test_serialize_node_for_logs(self): assert_equal(d['is_public'], node.is_public) assert_equal(d['is_registration'], node.is_registration) + def test_get_mapcore_groups(self): + from types import SimpleNamespace + from api.logs.serializers import NodeLogParamsSerializer + + # Create two MapCoreGroup records + g1 = MapCoreGroup.objects.create(_id='group-one') + g2 = MapCoreGroup.objects.create(_id='group-two') + + # Params includes integers (PKs) and some non-integer noise + params = {'mapcore_groups': [g1.id, 'invalid', g2.id, None]} + + # Non-anonymized request must include a user attribute + req = SimpleNamespace(query_params={}, user=UserFactory()) + data = NodeLogParamsSerializer(params, context={'request': req}).data + + # Serializer should return only the created group objects ordered by _id + assert_in('mapcore_groups', data) + assert_equal( + data['mapcore_groups'], + [ + {'id': g1.id, 'name': g1._id}, + {'id': g2.id, 'name': g2._id}, + ] + ) + + # Anonymized request should hide mapcore groups + req_anon = SimpleNamespace(query_params={}, _is_anonymized=True, user=UserFactory()) + data_anon = NodeLogParamsSerializer(params, context={'request': req_anon}).data + assert_equal(data_anon.get('mapcore_groups', None), []) class TestAddContributorJson(OsfTestCase): @@ -532,3 +564,108 @@ def test_add_contributor_json_with_job_and_edu(self): assert_equal(user_info['active'], True) assert_in('secure.gravatar.com', user_info['profile_image_url']) assert_equal(user_info['profile_url'], self.profile) + + +class TestSerializeMapcoreGroups(OsfTestCase): + + def test_serialize_mapcore_node_groups(self): + user = UserFactory() + node = NodeFactory(is_public=False) + + # two MapCore groups, one attached, one deleted + g1 = MapCoreGroup.objects.create(_id='group-one') + g2 = MapCoreGroup.objects.create(_id='group-two') + + auth1 = AuthGroup.objects.get_or_create(name=f'node_{node._id}_admin')[0] + auth2 = AuthGroup.objects.get_or_create(name=f'node_{node._id}_read')[0] + + m1 = MapCoreNodeGroup.objects.create(node=node, group=auth1, mapcore_group=g1, creator=user, is_deleted=False) + _m2 = MapCoreNodeGroup.objects.create(node=node, group=auth2, mapcore_group=g2, creator=user, is_deleted=True) + + data = utils.serialize_mapcore_node_groups(node) + + # only the non-deleted mapping should appear + assert_equal(len(data), 1) + item = data[0] + assert_equal(item['id'], str(m1.id)) + assert_equal(item['mapcore_group']['id'], g1.id) + assert_equal(item['mapcore_group']['name'], g1._id) + assert_equal(item['creator'], user.fullname) + assert_equal(item['is_deleted'], False) + assert_equal(item['permission'], m1.get_permission) + assert_in(g1._id, item['url']) + + def test_serialize_parent_admin_groups(self): + user = UserFactory() + root = ProjectFactory() + child = NodeFactory(parent=root) + + # admin group on root that should be exposed + g_admin = MapCoreGroup.objects.create(_id='parent-admin') + auth_admin = AuthGroup.objects.get_or_create(name=f'node_{root._id}_admin')[0] + m_admin = MapCoreNodeGroup.objects.create(node=root, group=auth_admin, mapcore_group=g_admin, creator=user, is_deleted=False) + + # admin group on root that should be excluded via current_group + g_excl = MapCoreGroup.objects.create(_id='parent-excl') + auth_excl = AuthGroup.objects.get_or_create(name=f'node_{root._id}_admin')[0] + m_excl = MapCoreNodeGroup.objects.create(node=root, group=auth_excl, mapcore_group=g_excl, creator=user, is_deleted=False) + + # Exclude g_excl by passing its id in current_group + current_group = [g_excl.id] + + result = utils.serialize_parent_admin_groups(child, current_group) + + # Only the non-excluded admin mapping should be returned and permission should be 'read' per serializer + assert_equal(len(result), 1) + r = result[0] + assert_equal(r['mapcore_group']['id'], g_admin.id) + assert_equal(r['mapcore_group']['name'], g_admin._id) + assert_equal(r['permission'], 'read') + assert_in(g_admin._id, r['url']) + + def test_serialize_parent_admin_groups_no_parent(self): + user = UserFactory() + node = NodeFactory() # node with no parent + + # No current_group filters + result = utils.serialize_parent_admin_groups(node, []) + + # Expect empty list when node has no parents + assert_equal(result, []) + + def test_serialize_mapcore_group_for_summary(self): + user = UserFactory() + node = NodeFactory(is_public=True) + + # two MapCore groups, one attached, one deleted + g1 = MapCoreGroup.objects.create(_id='group-one') + g2 = MapCoreGroup.objects.create(_id='group-two') + + auth1 = AuthGroup.objects.get_or_create(name=f'node_{node._id}_admin')[0] + auth2 = AuthGroup.objects.get_or_create(name=f'node_{node._id}_read')[0] + + m1 = MapCoreNodeGroup.objects.create(node=node, group=auth1, mapcore_group=g1, creator=user, is_deleted=False, visible=True) + _m2 = MapCoreNodeGroup.objects.create(node=node, group=auth2, mapcore_group=g2, creator=user, is_deleted=True, visible=True) + + data = serialize_mapcore_group_for_summary(node) + + # only the non-deleted mapping should appear + assert_in('mapcore_groups', data) + assert_equal(len(data['mapcore_groups']), 1) + item = data['mapcore_groups'][0] + assert_equal(item['name'], g1._id) + assert_in(g1._id, item['url']) + + def test_serialize_node_summary_includes_mapcore_groups(self): + node = NodeFactory(is_public=True) + user = node.creator + + # attach a MapCore group to the node + g = MapCoreGroup.objects.create(_id='group-summary') + auth_group = AuthGroup.objects.get_or_create(name=f'node_{node._id}_admin')[0] + MapCoreNodeGroup.objects.create(node=node, group=auth_group, mapcore_group=g, creator=user, is_deleted=False, visible=True) + + summary = serialize_node_summary(node, Auth(user)) + assert_in('mapcore_groups', summary) + assert_equal(len(summary['mapcore_groups']), 1) + assert_equal(summary['mapcore_groups'][0]['name'], g._id) diff --git a/website/profile/utils.py b/website/profile/utils.py index 7b80d5c9509..2a2ebe2d464 100644 --- a/website/profile/utils.py +++ b/website/profile/utils.py @@ -247,3 +247,62 @@ def serialize_access_requests(node): machine_state=workflows.DefaultStates.PENDING.value ).select_related('creator') ] + +def serialize_mapcore_node_groups(node, visible_only=False): + """Serialize MapCore groups associated with a node""" + mapcore_node_groups = node.mapcore_node_groups.select_related('mapcore_group', 'group', 'creator') + if visible_only: + mapcore_node_groups = mapcore_node_groups.filter(is_deleted=False, visible=True) + else: + mapcore_node_groups = mapcore_node_groups.filter(is_deleted=False) + return [ + { + 'id': str(mapcore_node_group.id), + 'mapcore_group': { + 'id': mapcore_node_group.mapcore_group.id, + 'name': mapcore_node_group.mapcore_group._id, + }, + 'creator': mapcore_node_group.creator.fullname, + 'is_deleted': mapcore_node_group.is_deleted, + 'permission': mapcore_node_group.get_permission, + 'url': mapcore_node_group.mapcore_group.absolute_url, + 'visible': mapcore_node_group.visible, + 'index': mapcore_node_group._order, + } for mapcore_node_group in mapcore_node_groups + ] + +def serialize_parent_admin_groups(node, current_group): + """Serialize MapCore groups associated with a node""" + result = [] + + for mapcore_node_group in _mapcore_node_group_parent(node, current_group): + result.append({ + 'id': str(mapcore_node_group.id), + 'mapcore_group': { + 'id': mapcore_node_group.mapcore_group.id, + 'name': mapcore_node_group.mapcore_group._id, + }, + 'creator': mapcore_node_group.creator.fullname, + 'is_deleted': mapcore_node_group.is_deleted, + 'permission': 'read', + 'url': mapcore_node_group.mapcore_group.absolute_url, + 'visible': mapcore_node_group.visible, + 'index': mapcore_node_group._order, + }) + return result + +def _mapcore_node_group_parent(node, current_group): + """Get list of parent MapCore groups associated with a node""" + def get_admin_mapcore_node_groups(node): + result = [] + for mapcore_node_group in node.mapcore_node_groups.select_related('mapcore_group', 'group', 'creator').filter(is_deleted=False): + if mapcore_node_group.get_permission == 'admin' and mapcore_node_group.mapcore_group.id not in current_group: + result.append(mapcore_node_group) + return result + result = set() + for parent in node.parents: + admins = get_admin_mapcore_node_groups(parent) + for admin in admins: + if admin not in result: + result.add(admin) + return result diff --git a/website/project/views/node.py b/website/project/views/node.py index 5b796d7672e..86578d38f9f 100644 --- a/website/project/views/node.py +++ b/website/project/views/node.py @@ -3,6 +3,7 @@ import logging from api.base.utils import CREATED_ERROR, check_user_can_create_project, LIMITED_ERROR +from osf.models.mapcore_node_group import MapCoreNodeGroup from rest_framework import status as http_status import math from collections import defaultdict @@ -532,6 +533,16 @@ def node_contributors(auth, node, **kwargs): ret['adminContributors'] = utils.serialize_contributors(admin_contribs, node, admin=True) return ret +@must_be_valid_project +@must_not_be_retracted_registration +@must_have_permission(READ) +@ember_flag_is_active(features.EMBER_PROJECT_CONTRIBUTORS) +def node_groups(auth, node, **kwargs): + ret = _view_project(node, auth, primary=True) + ret['groups'] = utils.serialize_mapcore_node_groups(node) + current_group = [group['mapcore_group']['id'] for group in ret['groups']] + ret['adminGroups'] = utils.serialize_parent_admin_groups(node, current_group) + return ret @must_have_permission(ADMIN) def configure_comments(node, **kwargs): @@ -901,6 +912,7 @@ def _view_project(node, auth, primary=False, ) is_registration = node.is_registration timestamp_pattern = get_timestamp_pattern_division(auth, node) + mapcore_groups = utils.serialize_mapcore_node_groups(node, visible_only=True) data = { 'node': { 'disapproval_link': disapproval_link, @@ -971,6 +983,7 @@ def _view_project(node, auth, primary=False, 'waterbutler_url': node.osfstorage_region.waterbutler_url, 'mfr_url': node.osfstorage_region.mfr_url, 'groups': list(node.osf_groups.values_list('name', flat=True)), + 'mapcore_groups': mapcore_groups, }, 'parent_node': { 'exists': parent is not None, @@ -1215,6 +1228,7 @@ def serialize_child_tree(child_list, user, nested): 'user__guids___id', 'is_admin', 'user__date_confirmed', 'visible' ) ) + mapcore_groups = MapCoreNodeGroup.objects.filter(node=child, is_deleted=False).values('mapcore_group_id') contributors = [ { @@ -1235,6 +1249,7 @@ def serialize_child_tree(child_list, user, nested): 'contributors': contributors, 'is_admin': child.has_permission(user, ADMIN), 'is_supplemental_project': child.has_linked_published_preprints, + 'mapcore_groups': [mapcore_group['mapcore_group_id'] for mapcore_group in mapcore_groups], }, 'user_id': user._id, 'children': serialize_child_tree(nested.get(child._id), user, nested) if child._id in nested.keys() else [], @@ -1280,6 +1295,7 @@ def node_child_tree(user, node): is_admin = node.has_permission(user, ADMIN) if can_read or node.has_permission_on_children(user, READ): + mapcore_groups = MapCoreNodeGroup.objects.filter(node=node, is_deleted=False).values('mapcore_group_id') serialized_nodes.append({ 'node': { 'id': node._id, @@ -1289,6 +1305,7 @@ def node_child_tree(user, node): 'contributors': contributors, 'is_admin': is_admin, 'is_supplemental_project': node.has_linked_published_preprints, + 'mapcore_groups': [mapcore_group['mapcore_group_id'] for mapcore_group in mapcore_groups], }, 'user_id': user._id, diff --git a/website/routes.py b/website/routes.py index b3de0fbefdd..2f6f47b8410 100644 --- a/website/routes.py +++ b/website/routes.py @@ -1278,6 +1278,16 @@ def make_url_map(app): OsfWebRenderer('project/contributors.mako', trust=False), ), + Rule( + [ + '/project//groups/', + '/project//node//groups/', + ], + 'get', + project_views.node.node_groups, + OsfWebRenderer('project/groups.mako', trust=False), + ), + Rule( [ '/project//settings/', diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 7f58ef5d4c4..d5e0b748792 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -2053,6 +2053,8 @@ class CeleryConfig: MAPCORE_AUTHCODE_MAGIC = 'GRDM_mAP_AuthCode' MAPCORE_CLIENTID = None MAPCORE_SECRET = None +MAPCORE_GROUP_HOSTNAME = 'https://sptest.cg.gakunin.jp' +MAPCORE_GROUP_API_PATH = '/map/rd/' # allow logged-in-user to search private projects ENABLE_PRIVATE_SEARCH = False @@ -2093,3 +2095,6 @@ class CeleryConfig: 'ja_jp': '日本語' } BABEL_DEFAULT_LOCALE = 'ja' + +# Prefix of isMemberOf attribute for groups. +MAP_GATEWAY_ISMEMBEROF_PREFIX = 'https://sptest.cg.gakunin.jp/gr/' diff --git a/website/static/js/addProjectPlugin.js b/website/static/js/addProjectPlugin.js index 3a125ab338c..8fc81edb310 100644 --- a/website/static/js/addProjectPlugin.js +++ b/website/static/js/addProjectPlugin.js @@ -257,7 +257,7 @@ var AddProject = { onchange : function() { ctrl.newProjectInheritContribs(this.checked); } - }), _(' Add contributors from '), m('b', options.parentTitle), + }), _(' Add contributors and groups from '), m('b', options.parentTitle), m('br'), m('i', _(' Admins of '), m('b', options.parentTitle), _(' will have read access to this component.')) ), diff --git a/website/static/js/anonymousLogActionsList.json b/website/static/js/anonymousLogActionsList.json index 6112b457a00..796878072da 100644 --- a/website/static/js/anonymousLogActionsList.json +++ b/website/static/js/anonymousLogActionsList.json @@ -31,6 +31,12 @@ "contributor_rejected": "Contributor(s) cancelled invitation from a project", "contributors_reordered" : "A user reordered contributors for a project", "permissions_updated" : "A user changed permissions for a project", + "mapcore_group_added": "A user added group(s) to a project", + "mapcore_group_removed": "A user removed group from a project", + "mapcore_group_permission_updated": "A user updated permissions for group(s) on a project", + "mapcore_group_reordered": "A user reordered groups for a project", + "made_mapcore_group_visible": "A user made group(s) visible on a project", + "made_mapcore_group_invisible": "A user made group(s) invisible on a project", "made_contributor_visible" : "A user made contributor(s) visible on a project", "made_contributor_invisible" : "A user made contributor(s) invisible on a project", "wiki_updated" : "A user updated a wiki page of a project", diff --git a/website/static/js/groupsAdder.js b/website/static/js/groupsAdder.js new file mode 100644 index 00000000000..52bda4af3bf --- /dev/null +++ b/website/static/js/groupsAdder.js @@ -0,0 +1,517 @@ +/** + * Controller for the Add Group modal. + */ +'use strict'; + +require('css/add-contributors.css'); + +var $ = require('jquery'); +var ko = require('knockout'); +var Raven = require('raven-js'); +var lodashGet = require('lodash.get'); + +var oop = require('js/oop'); +var $osf = require('js/osfHelpers'); +var osfLanguage = require('js/osfLanguage'); +var Paginator = require('js/paginator'); +var NodeSelectTreebeard = require('js/nodeSelectTreebeard'); +var m = require('mithril'); +var projectSettingsTreebeardBase = require('js/projectSettingsTreebeardBase'); +var _ = require('js/rdmGettext')._; +var sprintf = require('agh.sprintf').sprintf; + +function Group(data) { + $.extend(this, data); +} + +var AddGroupViewModel; +AddGroupViewModel = oop.extend(Paginator, { + constructor: function (title, nodeId, parentId, parentTitle, treeDataPromise, options) { + this.super.constructor.call(this); + var self = this; + + self.title = title; + self.nodeId = nodeId; + self.nodeApiUrl = '/api/v1/project/' + self.nodeId + '/'; + self.parentId = parentId; + self.parentTitle = parentTitle; + self.treeDataPromise = treeDataPromise; + self.async = options.async || false; + self.callback = options.callback || function () { + }; + self.nodesOriginal = {}; + //state of current nodes + self.childrenToChange = ko.observableArray(); + self.nodesState = ko.observable(); + self.canSubmit = ko.observable(true); + //nodesState is passed to nodesSelectTreebeard which can update it and key off needed action. + self.nodesState.subscribe(function (newValue) { + //The subscribe causes treebeard changes to change which nodes will be affected + var childrenToChange = []; + for (var key in newValue) { + newValue[key].changed = newValue[key].checked !== self.nodesOriginal[key].checked; + if (newValue[key].changed && key !== self.nodeId) { + childrenToChange.push(key); + } + } + self.childrenToChange(childrenToChange); + m.redraw(true); + }); + + //list of permission objects for select. + self.permissionList = [ + {value: 'read', text: _('Read')}, + {value: 'write', text: _('Read + Write')}, + {value: 'admin', text: _('Administrator')} + ]; + + self.page = ko.observable('whom'); + self.pageTitle = ko.computed(function () { + return { + whom: _('Add Groups'), + which: _('Select Components') + }[self.page()]; + }); + self.query = ko.observable(); + self.results = ko.observableArray([]); + self.groups = ko.observableArray([]); + self.selection = ko.observableArray(); + + self.groupIDsToAdd = ko.pureComputed(function () { + return self.selection().map(function (user) { + return user.mapcore_group_id; + }); + }); + + self.notification = ko.observable(''); + self.doneSearching = ko.observable(false); + self.parentImport = ko.observable(false); + self.totalPages = ko.observable(0); + self.childrenToChange = ko.observableArray(); + self.hasSearch = ko.observable(false); + self.foundResults = ko.pureComputed(function () { + return self.query() && self.results().length && !self.parentImport(); + }); + + self.noResults = ko.pureComputed(function () { + return self.query() && !self.results().length && self.doneSearching() && self.hasSearch(); + }); + + self.showLoading = ko.pureComputed(function () { + return !self.doneSearching() && !!self.query() && self.hasSearch(); + }); + + self.addAllVisible = ko.pureComputed(function () { + var selected_ids = self.selection().map(function (group) { + return group.mapcore_group_id; + }); + var groups = self.groups(); + return ($osf.any( + $.map(self.results(), function (result) { + return groups.indexOf(result.mapcore_group_id) === -1 && selected_ids.indexOf(result.mapcore_group_id) === -1; + }) + )); + }); + + self.removeAllVisible = ko.pureComputed(function () { + return self.selection().length > 0; + }); + + self.addingSummary = ko.computed(function () { + var names = $.map(self.selection(), function (result) { + return result.name; + }); + return names.join(', '); + }); + }, + hide: function () { + $('.modal').modal('hide'); + }, + selectWhom: function () { + this.page('whom'); + }, + selectWhich: function () { + //when the next button is hit by the user, the nodes to change and disable are decided + var self = this; + var nodesState = self.nodesState(); + for (var key in nodesState) { + var i; + var node = nodesState[key]; + var enabled = nodesState[key].isAdmin; + var checked = nodesState[key].checked; + if (enabled) { + var nodeGroups = []; + for (i = 0; i < node.mapcoreGroups.length; i++) { + nodeGroups.push(node.mapcoreGroups[i]); + } + for (i = 0; i < self.groupIDsToAdd().length; i++) { + if (nodeGroups.indexOf(self.groupIDsToAdd()[i]) < 0) { + enabled = true; + break; + } + else { + checked = true; + enabled = false; + } + if (checked && !enabled) { + self.childrenToChange.remove(key); + } + } + } + nodesState[key].enabled = enabled; + nodesState[key].checked = checked; + } + self.nodesState(nodesState); + this.page('which'); + }, + goToPage: function (page) { + this.page(page); + }, + /** + * A simple Group model that receives data from the + * group search endpoint. Adds an additional displayProjectsinCommon + * attribute which is the human-readable display of the number of projects the + * currently logged-in user has in common with the group. + */ + startSearch: function () { + this.parentImport(false); + this.hasSearch(true); + this.pageToGet(0); + this.fetchResults(); + }, + fetchResults: function () { + if (this.parentImport()){ + this.importFromParent(); + } else { + var self = this; + self.doneSearching(false); + self.notification(false); + if (self.query()) { + var url = $osf.apiV2Url('map_core/groups/'); + // url += '?search='+encodeURIComponent(self.query()) + '&page=' + self.pageToGet(); + return $.ajax({ + url: url, + type: 'GET', + dataType: 'json', + data: { + search: self.query(), + page: self.pageToGet()+1 + }, + contentType: 'application/vnd.api+json;', + crossOrigin: true, + xhrFields: {withCredentials: true} + }).done(function (result) { + var groups = result.data.map(function (groupData) { + groupData.attributes.added = (self.groups().indexOf(groupData.id) !== -1); + groupData.attributes.id = groupData.id; + groupData.attributes.profileUrl = groupData.links.self; + return new Group(groupData.attributes); + }); + self.doneSearching(true); + self.results(groups); + self.currentPage(self.pageToGet()); + self.numberOfPages(Math.ceil(result.links.meta.total/result.links.meta.per_page)); + self.addNewPaginators(false); + }); + } else { + self.results([]); + self.currentPage(0); + self.totalPages(0); + self.doneSearching(true); + } + } + }, + getGroups: function () { + var self = this; + self.notification(false); + var url = $osf.apiV2Url('nodes/' + window.contextVars.node.id + '/map_core/groups/'); + + return $.ajax({ + url: url, + type: 'GET', + dataType: 'json', + contentType: 'application/vnd.api+json;', + crossOrigin: true, + xhrFields: {withCredentials: true}, + processData: false + }).done(function (response) { + var groups = response.data.map(function (group) { + // contrib ID has the form - + return group.attributes.mapcore_group_id; + }); + self.groups(groups); + }); + }, + startSearchParent: function () { + this.parentImport(true); + this.importFromParent(); + }, + importFromParent: function () { + var self = this; + var url = $osf.apiV2Url('nodes/' + self.parentId + '/map_core/groups/'); + self.doneSearching(false); + self.notification(false); + return $.ajax({ + url: url, + type: 'GET', + dataType: 'json', + contentType: 'application/vnd.api+json;', + crossOrigin: true, + xhrFields: {withCredentials: true}, + processData: false + }).done( + function (result) { + var groups = result.data.filter(function(group) {return self.groups().indexOf(group.attributes.mapcore_group_id) === -1;}).map(function (group) { + var added = (self.groups().indexOf(group.attributes.mapcore_group_id) !== -1); + var updatedGroup = $.extend({}, group.attributes, {added: added}); + var group_permission = self.permissionList.find(function (permission) { + return permission.value === group.attributes.permission; + }); + updatedGroup.permission = ko.observable(group_permission); + updatedGroup.name = group.attributes.name; + updatedGroup.profileUrl = group.attributes.profileUrl; + return updatedGroup; + }); + var pageToShow = []; + var startingSpot = (self.pageToGet() * 5); + if (groups.length > startingSpot + 5){ + for (var iterate = startingSpot; iterate < startingSpot + 5; iterate++) { + pageToShow.push(groups[iterate]); + } + } else { + for (var iterateTwo = startingSpot; iterateTwo < groups.length; iterateTwo++) { + pageToShow.push(groups[iterateTwo]); + } + } + self.parentImport(false); + self.doneSearching(true); + self.selection(groups); + } + ); + }, + addTips: function (elements) { + elements.forEach(function (element) { + $(element).find('.contrib-button').tooltip(); + }); + }, + afterRender: function (elm, data) { + var self = this; + self.addTips(elm, data); + }, + makeAfterRender: function () { + var self = this; + return function (elm, data) { + return self.afterRender(elm, data); + }; + }, + add: function (data) { + var self = this; + data.permission = ko.observable(self.permissionList[1]); //default permission write + // All manually added groups are visible + data.visible = true; + this.selection.push(data); + // self.query(''); + // Hack: Hide and refresh tooltips + $('.tooltip').hide(); + $('.contrib-button').tooltip(); + }, + remove: function (data) { + this.selection.splice( + this.selection.indexOf(data), 1 + ); + // Hack: Hide and refresh tooltips + $('.tooltip').hide(); + $('.contrib-button').tooltip(); + }, + addAll: function () { + var self = this; + var selected_ids = self.selection().map(function (group) { + return group.mapcore_group_id; + }); + $.each(self.results(), function (idx, result) { + if (selected_ids.indexOf(result.mapcore_group_id) === -1 && self.groups().indexOf(result.mapcore_group_id) === -1) { + self.add(result); + } + }); + }, + removeAll: function () { + var self = this; + $.each(self.selection(), function (idx, selected) { + self.remove(selected); + }); + }, + selected: function (data) { + var self = this; + for (var idx = 0; idx < self.selection().length; idx++) { + if (data.mapcore_group_id === self.selection()[idx].mapcore_group_id) { + return true; + } + } + return false; + }, + selectAllNodes: function () { + //select all nodes to add a group to. THe changed variable is set here for timing between + // treebeard and knockout + var self = this; + var nodesState = ko.toJS(self.nodesState()); + for (var key in nodesState) { + if (nodesState[key].enabled) { + nodesState[key].checked = true; + } + } + self.nodesState(nodesState); + }, + selectNoNodes: function () { + //select no nodes to add a group to. THe changed variable is set here for timing between + // treebeard and knockout + var self = this; + var nodesState = ko.toJS(self.nodesState()); + for (var key in nodesState) { + if (nodesState[key].enabled && nodesState[key].checked) { + nodesState[key].checked = false; + } + } + self.nodesState(nodesState); + }, + submit: function () { + var self = this; + self.canSubmit(false); + $osf.block(); + var url = $osf.apiV2Url('nodes/' + window.contextVars.node.id + '/map_core/groups/'); + var node_ids = self.childrenToChange(); + var createGroupsData = { + data: { + type: 'node-mapcore-group', + attributes: { + node_groups: ko.utils.arrayMap(self.selection(), function (group) { + return { + mapcore_group_id: group.mapcore_group_id, + permission: group.permission().value, + visible: group.visible !== undefined ? group.visible : true + }; + }), + component_ids: node_ids, + } + } + }; + return $.ajax({ + url: url, + type: 'POST', + dataType: 'json', + contentType: 'application/vnd.api+json;', + crossOrigin: true, + xhrFields: {withCredentials: true}, + data: JSON.stringify(createGroupsData), + }).done(function (response) { + if (self.async) { + self.groups($.map(response.groups, function (contrib) { + return contrib.id; + })); + if (self.callback) { + self.callback(response); + } + } else { + window.location.reload(); + } + }).fail(function (xhr, status, error) { + var errorMessage = lodashGet(xhr, 'responseJSON.message') || (sprintf(_('There was a problem trying to add groups%1$s.') , osfLanguage.REFRESH_OR_SUPPORT)); + $osf.growl(_('Could not add groups'), errorMessage); + Raven.captureMessage(_('Error adding groups'), { + extra: { + url: url, + status: status, + error: error + } + }); + }).always(function () { + self.hide(); + $osf.unblock(); + self.canSubmit(true); + }); + }, + clear: function () { + var self = this; + self.page('whom'); + self.parentImport(false); + self.query(''); + self.results([]); + self.selection([]); + self.childrenToChange([]); + self.notification(false); + self.hasSearch(false); + }, + hasChildren: function() { + var self = this; + return (Object.keys(self.nodesOriginal).length > 1); + }, + /** + * get node tree for treebeard from API V1 + */ + fetchNodeTree: function (treebeardUrl) { + var self = this; + return $.when(self.treeDataPromise).done(function (response) { + self.nodesOriginal = projectSettingsTreebeardBase.getNodesOriginal(response[0], self.nodesOriginal); + var nodesState = $.extend(true, {}, self.nodesOriginal); + var nodeParent = response[0].node.id; + //parent node is changed by default + nodesState[nodeParent].checked = true; + //parent node cannot be changed + nodesState[nodeParent].isAdmin = false; + self.nodesState(nodesState); + }).fail(function (xhr, status, error) { + $osf.growl('Error', _('Unable to retrieve project settings')); + Raven.captureMessage(_('Could not GET project settings.'), { + extra: { + url: treebeardUrl, status: status, error: error + } + }); + }); + } +}); + + +//////////////// +// Public API // +//////////////// + +function GroupsAdder(selector, nodeTitle, nodeId, parentId, parentTitle, treeDataPromise, options) { + var self = this; + self.selector = selector; + self.$element = $(selector); + self.nodeTitle = nodeTitle; + self.nodeId = nodeId; + self.parentId = parentId; + self.parentTitle = parentTitle; + self.treeDataPromise = treeDataPromise; + self.options = options || {}; + self.viewModel = new AddGroupViewModel( + self.nodeTitle, + self.nodeId, + self.parentId, + self.parentTitle, + self.treeDataPromise, + self.options + ); + self.init(); +} + +GroupsAdder.prototype.init = function() { + var self = this; + var treebeardUrl = window.contextVars.node.urls.api + 'tree/'; + self.viewModel.getGroups(); + self.viewModel.fetchNodeTree(treebeardUrl).done(function(response) { + new NodeSelectTreebeard('addGroupsTreebeard', response, self.viewModel.nodesState); + }); + $osf.applyBindings(self.viewModel, self.$element[0]); + // Clear popovers on dismiss start + self.$element.on('hide.bs.modal', function() { + self.$element.find('.popover').popover('hide'); + }); + // Clear user search modal when dismissed; catches dismiss by escape key + // or cancel button. + self.$element.on('hidden.bs.modal', function() { + self.viewModel.clear(); + }); +}; + +module.exports = GroupsAdder; diff --git a/website/static/js/groupsManager.js b/website/static/js/groupsManager.js new file mode 100644 index 00000000000..08c340b0b57 --- /dev/null +++ b/website/static/js/groupsManager.js @@ -0,0 +1,527 @@ +'use strict'; + +var $ = require('jquery'); +var ko = require('knockout'); +var Raven = require('raven-js'); +var bootbox = require('bootbox'); +require('jquery-ui'); +require('knockout-sortable'); +var lodashGet = require('lodash.get'); +var GroupsAdder = require('js/groupsAdder'); +var GroupsRemover = require('js/groupsRemover'); +var osfLanguage = require('js/osfLanguage'); + +var rt = require('js/responsiveTable'); +var $osf = require('./osfHelpers'); +require('js/filters'); + +var _ = require('js/rdmGettext')._; +var sprintf = require('agh.sprintf').sprintf; + +//http://stackoverflow.com/questions/12822954/get-previous-value-of-an-observable-in-subscribe-of-same-observable +ko.subscribable.fn.subscribeChanged = function (callback) { + var self = this; + var savedValue = self.peek(); + return self.subscribe(function (latestValue) { + var oldValue = savedValue; + savedValue = latestValue; + callback(latestValue, oldValue); + }); +}; + +ko.bindingHandlers.filters = { + init: function(element, valueAccessor, allBindingsAccessor, data, context) { + var $element = $(element); + var value = ko.utils.unwrapObservable(valueAccessor()) || {}; + value.callback = data.callback; + $element.filters(value); + } +}; + +// TODO: We shouldn't need both pageOwner (the current user) and currentUserCanEdit. Separate +// out the permissions-related functions and remove currentUserCanEdit. +var GroupModel = function(group, currentUserCanEdit, pageOwner, isRegistration, isParentAdmin, index, options, groupShouter, changeShouter) { + var self = this; + self.options = options; + $.extend(self, group); + + self.originals = { + permission: group.permission, + visible: group.visible, + index: index, + }; + self.visible = ko.observable(group.visible); + self.visible.subscribeChanged(function(newValue, oldValue) { + self.options.onVisibleChanged(newValue, oldValue); + }); + self.toggleExpand = function() { + self.expanded(!self.expanded()); + }; + + self.expanded = ko.observable(false); + + self.filtered = ko.observable(false); + + self.permission = ko.observable(group.permission); + + self.permissionText = ko.observable(self.options.permissionMap[self.permission()]); + + self.permission.subscribeChanged(function(newValue, oldValue) { + self.options.onPermissionChanged(newValue, oldValue); + self.permissionText(self.options.permissionMap[newValue]); + }); + + self.permissionChange = ko.computed(function() { + return self.permission() !== self.originals.permission; + }); + + self.reset = function(adminCount, visibleCount) { + if (self.deleteStaged()) { + if (self.visible()) { + visibleCount(visibleCount() + 1); + } + if (self.permission() === 'admin') { + adminCount(adminCount() + 1); + } + self.deleteStaged(false); + } + self.permission(self.originals.permission); + self.visible(self.originals.visible); + }; + + self.currentUserCanEdit = currentUserCanEdit; + // User is an admin on the parent project + self.isParentAdmin = isParentAdmin; + + self.deleteStaged = ko.observable(false); + + self.pageOwner = pageOwner; + self.groupToRemove = ko.observable(); + + self.groupToRemove.subscribe(function(newValue) { + groupShouter.notifySubscribers(newValue, 'groupMessageToPublish'); + }); + + self.serialize = function() { + return JSON.parse(ko.toJSON(self)); + }; + + self.canEdit = ko.computed(function() { + return self.currentUserCanEdit && !self.isParentAdmin; + }); + + self.remove = function() { + self.groupToRemove({ + name: self.mapcore_group.name, + id:self.id, + mapcoreGroupID: self.mapcore_group.id}); + }; + + self.addParentAdmin = function() { + // Immediately adds parent admin to the component with permissions=read and visible=True + $osf.block(); + var url = $osf.apiV2Url('nodes/' + window.contextVars.node.id + '/map_core/groups/'); + var groupData = self.serialize(); + var createGroupsData = { + data: { + type: 'node-mapcore-group', + attributes: { + node_groups: [ + { + mapcore_group_id: groupData.mapcore_group.id, + permission: 'read', + visible: true + } + ] + } + } + }; + return $.ajax({ + url: url, + type: 'POST', + dataType: 'json', + contentType: 'application/vnd.api+json;', + crossOrigin: true, + xhrFields: {withCredentials: true}, + data: JSON.stringify(createGroupsData), + }).done(function(response) { + window.location.reload(); + }).fail(function(xhr, status, error){ + $osf.unblock(); + var errorMessage = lodashGet(xhr, 'responseJSON.message') || (sprintf(_('There was a problem trying to add the group. ') , osfLanguage.REFRESH_OR_SUPPORT)); + $osf.growl(_('Could not add group'), errorMessage); + Raven.captureMessage(_('Error adding groups'), { + extra: { + url: url, + status: status, + error: error + } + }); + }); + }; + + self.unremove = function() { + if (self.deleteStaged()) { + self.deleteStaged(false); + self.options.onPermissionChanged(self.permission(), null); + self.options.onVisibleChanged(self.visible(), null); + } + // Allow default action + return true; + }; + self.profileUrl = ko.observable(group.url); + + self.canRemove = ko.computed(function(){ + return (self.id === pageOwner.id) && !isRegistration && !self.isParentAdmin; + }); + + self.canAddAdminContrib = ko.computed(function() { + return self.currentUserCanEdit && self.isParentAdmin; + }); + + self.isDirty = ko.pureComputed(function() { + return self.permissionChange() || + self.visible() !== self.originals.visible || self.deleteStaged(); + }); + + self.optionsText = function(val) { + return self.options.permissionMap[val]; + }; +}; + +var MessageModel = function(text, level) { + + var self = this; + + + self.text = ko.observable(text || ''); + self.level = ko.observable(level || ''); + + var classes = { + success: 'text-success', + error: 'text-danger' + }; + + self.cssClass = ko.computed(function() { + var out = classes[self.level()]; + if (out === undefined) { + out = ''; + } + return out; + }); + +}; + +var GroupsViewModel = function(groups, adminGroups, user, isRegistration, table, adminTable, groupShouter, pageChangedShouter) { + + var self = this; + + self.original = ko.observableArray(groups); + self.table = $(table); + self.adminTable = $(adminTable); + + self.permissionMap = { + read: _('Read'), + write: _('Read + Write'), + admin: _('Administrator') + }; + + self.permissionList = Object.keys(self.permissionMap); + self.groupToRemove = ko.observable(''); + + self.groups = ko.observableArray(); + self.adminGroups = ko.observableArray(); + self.filteredGroups = ko.pureComputed(function() { + return ko.utils.arrayFilter(self.groups(), function(item) { + return item.filtered(); + }); + }); + self.filteredAdmins = ko.pureComputed(function() { + return ko.utils.arrayFilter(self.adminGroups(), function(item) { + return item.filtered(); + }); + }); + + self.empty = ko.pureComputed(function() { + return (self.groups().length - self.filteredGroups().length) === 0; + }); + + self.adminEmpty = ko.pureComputed(function() { + return (self.adminGroups().length - self.filteredAdmins().length === 0); + }); + + self.callback = function (filtered, empty, activeItems) { + $.each(activeItems, function (i, group) { + activeItems[i] = ko.dataFor(group); + }); + $.each(self.groups(), function (i, group) { + group.filtered($.inArray(group, activeItems) === -1); + }); + $.each(self.adminGroups(), function (i, group) { + group.filtered($.inArray(group, activeItems) === -1); + }); + }; + + self.user = ko.observable(user); + self.canEdit = ko.computed(function() { + return ($.inArray('admin', user.permissions) > -1) && !isRegistration; + }); + + self.isSortable = ko.computed(function() { + return self.canEdit() && self.filteredGroups().length === 0; + }); + + // Hack: Ignore beforeunload when submitting + // TODO: Single-page-ify and remove this + self.forceSubmit = ko.observable(false); + + self.changed = ko.computed(function() { + for (var i = 0, group; group = self.groups()[i]; i++) { + if (group.isDirty() || group.originals.index !== i){ + return true; + } + } + return false; + }); + + self.retainedGroups = ko.computed(function() { + return ko.utils.arrayFilter(self.groups(), function(item) { + return !item.deleteStaged(); + }); + }); + + self.adminCount = ko.observable(0); + + self.visibleCount = ko.observable(0); + + self.canSubmit = ko.computed(function() { + return self.changed(); + }); + + self.changed.subscribe(function(newValue) { + pageChangedShouter.notifySubscribers(newValue, 'changedMessageToPublish'); + }); + + self.messages = ko.computed(function() { + var messages = []; + return messages; + }); + + self.handlePermissionChanged = function(newPerm, oldPerm) { + if (oldPerm === 'admin') { + self.adminCount(self.adminCount() - 1); + } + if (newPerm === 'admin') { + self.adminCount(self.adminCount() + 1); + } + }; + self.handleVisibleChanged = function(newVis, oldVis) { + if (oldVis) { + self.visibleCount(self.visibleCount() - 1); + } + if (newVis) { + self.visibleCount(self.visibleCount() + 1); + } + }; + + self.options = { + onPermissionChanged: self.handlePermissionChanged, + onVisibleChanged: self.handleVisibleChanged, + permissionMap: self.permissionMap + }; + + self.init = function() { + var index = -1; + self.groups(self.original().map(function(item) { + index++; + if (item.visible) { + self.visibleCount(self.visibleCount() + 1); + } + return new GroupModel(item, self.canEdit(), self.user(), isRegistration, false, index, self.options, groupShouter, pageChangedShouter); + })); + self.adminGroups(adminGroups.map(function(item) { + return new GroupModel(item, self.canEdit(), self.user(), isRegistration, true, index, self.options, groupShouter, pageChangedShouter); + })); + + }; + + // Warn on add groups if pending changes + $('[href="#addGroups"]').on('click', function() { + if (self.changed()) { + $osf.growl('Error:', + _('Your group list has unsaved changes. Please ') + + _('save or cancel your changes before adding groups.') + ); + return false; + } + }); + // Warn on URL change if pending changes + $(window).bind('beforeunload', function() { + if (self.changed() && !self.forceSubmit()) { + // TODO: Use GrowlBox. + return _('There are unsaved changes to your group settings'); + } + }); + + self.init(); + + self.serialize = function() { + return ko.utils.arrayMap( + ko.utils.arrayFilter(self.groups(), function(group) { + return !group.deleteStaged(); + }), + function(group) { + return group.serialize(); + } + ); + }; + + self.cancel = function() { + ko.utils.arrayForEach(self.groups(), function(group) { + group.permission(group.originals.permission); + }); + self.groups().forEach(function(group) { + group.reset(self.visibleCount); + }); + self.groups(self.groups().sort(function(left, right) { + return left.originals.index > right.originals.index ? 1 : -1; + })); + }; + + self.submit = function() { + self.forceSubmit(true); + var groups = self.serialize(); + var nodeGroups = []; + groups.forEach(function(item) { + nodeGroups.push({ + 'node_group_id': parseInt(item.id), + 'permission': item.permission, + 'visible': item.visible + }); + }); + + var updateData = {'data':{ + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': nodeGroups + } + }}; + var url = $osf.apiV2Url('nodes/' + window.contextVars.node.id + '/map_core/groups/'); + + bootbox.confirm({ + title: _('Save changes?'), + message: _('Are you sure you want to save these changes?'), + callback: function(result) { + if (result) { + $.ajax({ + url: url, + type: 'PUT', + dataType: 'json', + contentType: 'application/vnd.api+json;', + crossOrigin: true, + xhrFields: {withCredentials: true}, + data: JSON.stringify(updateData) + }).done(function(response) { + // TODO: Don't reload the page here; instead use code below + if (response.redirectUrl) { + window.location.href = response.redirectUrl; + } else { + window.location.reload(); + } + }).fail(function(xhr) { + var response = xhr.responseJSON; + $osf.growl('Error:', + _('Submission failed: ') + response.message_long + ); + self.forceSubmit(false); + }); + } + }, + buttons:{ + confirm:{ + label:_('Save'), + className:'btn-success' + }, + cancel:{ + label:_('Cancel') + } + } + }); + }; + + self.afterRender = function(elements, data) { + var table; + if (data === 'contrib') { + table = self.table[0]; + }else if (data === 'admin') { + table = self.adminTable[0]; + } + if (!!table) { + rt.responsiveTable(table); + } + }; + + self.collapsed = ko.observable(true); + + self.onWindowResize = function() { + self.collapsed(self.table.children().filter('thead').is(':hidden')); + }; + +}; + +//////////////// +// Public API // +//////////////// + +function GroupManager(selector, groups, adminGroups, user, isRegistration, table, adminTable) { + var self = this; + //shouter allows communication between GroupManager and GroupsRemover, in particular which group needs to + // be removed is passed to GroupsRemover + var groupShouter = new ko.subscribable(); + var pageChangedShouter = new ko.subscribable(); + self.selector = selector; + self.$element = $(selector); + self.groups = groups; + self.adminGroups = adminGroups; + self.viewModel = new GroupsViewModel(groups, adminGroups, user, isRegistration, table, adminTable, groupShouter, pageChangedShouter); + $('body').on('nodeLoad', function(event, data) { + // If user is a group, initialize the group modal + // controller + + var treeDataPromise = $.ajax({ + url: window.contextVars.node.urls.api + 'tree/', + type: 'GET', + dataType: 'json', + }); + if (data.user.can_edit) { + new GroupsAdder( + '#addGroups', + data.node.title, + data.node.id, + data.parent_node.id, + data.parent_node.title, + treeDataPromise + ); + } + if (data.user.can_edit) { + new GroupsRemover( + '#removeGroup', + data.node.title, + data.node.id, + data.user.username, + data.user.id, + groupShouter, + pageChangedShouter, + treeDataPromise + ); + } + }); + self.init(); +} + +GroupManager.prototype.init = function() { + $osf.applyBindings(this.viewModel, this.$element[0]); + this.$element.show(); +}; + +module.exports = GroupManager; diff --git a/website/static/js/groupsRemover.js b/website/static/js/groupsRemover.js new file mode 100644 index 00000000000..b0e2675dfce --- /dev/null +++ b/website/static/js/groupsRemover.js @@ -0,0 +1,239 @@ +/** + * Controller for the Remove Group modal. + */ +'use strict'; + +var $ = require('jquery'); +var ko = require('knockout'); +var Raven = require('raven-js'); + +var oop = require('./oop'); +var $osf = require('./osfHelpers'); +var Paginator = require('./paginator'); +var projectSettingsTreebeardBase = require('js/projectSettingsTreebeardBase'); +var _ = require('js/rdmGettext')._; + +function removeNodesGroups(group, nodes) { + + var url = $osf.apiV2Url('nodes/' + window.contextVars.node.id + '/map_core/groups/'); + + return $.ajax({url: url+group+'/?component_ids=' + nodes.join(','), + type: 'DELETE', + dataType: 'json', + contentType: 'application/vnd.api+json;', + crossOrigin: true, + xhrFields: {withCredentials: true}, + }); +} + + +var RemoveGroupViewModel = oop.extend(Paginator, { + constructor: function(title, nodeId, userName, userId, groupShouter, pageChangedShouter, treeDataPromise) { + this.super.constructor.call(this); + var self = this; + self.title = title; + self.nodeId = nodeId; + self.userId = userId; + self.groupToRemove = ko.observable(''); + self.REMOVE = 'remove'; + self.REMOVE_ALL = 'removeAll'; + self.REMOVE_NO_CHILDREN = 'removeNoChildren'; + self.REMOVE_SELF = 'removeSelf'; + self.treeDataPromise = treeDataPromise; + + //This shouter allows the GroupsViewModel to share which group to remove + // with the RemoveGroupViewModel + groupShouter.subscribe(function(newValue) { + self.groupToRemove(newValue); + }, self, 'groupMessageToPublish'); + + //This shouter allows RemoveGroupViewModel to know if the + // GroupsViewModel is in a dirty state to prevent removal + self.pageChanged = ko.observable(false); + pageChangedShouter.subscribe(function(newValue) { + self.pageChanged(newValue); + }, self, 'changedMessageToPublish'); + + self.page = ko.observable(self.REMOVE); + self.pageTitle = ko.computed(function() { + return { + remove: _('Remove Group'), + removeAll: _('Remove Group'), + removeNoChildren: _('Remove Group') + }[self.page()]; + }); + self.userName = ko.observable(userName); + self.deleteAll = ko.observable(false); + var nodesOriginal = {}; + self.nodesOriginal = ko.observable(); + self.loadingSubmit = ko.observable(false); + + /* + * To remove, a group, you must have admin permissions on the node. + */ + self.canRemoveNodes = ko.computed(function() { + var canRemoveNodes = {}; + var nodesOriginalLocal = ko.toJS(self.nodesOriginal()); + if (self.groupToRemove()) { + for (var key in nodesOriginalLocal) { + var node = nodesOriginalLocal[key]; + //User cannot modify the node without admin permissions. + canRemoveNodes[key] = node.isAdmin; + } + } + return canRemoveNodes; + }); + + self.removeSelf = ko.pureComputed(function() { + return self.groupToRemove().id === window.contextVars.currentUser.id; + }); + + self.canRemoveNode = ko.computed(function() { + return self.canRemoveNodes()[self.nodeId]; + }); + + self.canRemoveNodesLength = ko.pureComputed(function() { + return Object.keys(self.canRemoveNodes()).length; + }); + + self.hasChildrenToRemove = ko.computed(function() { + //if there is more then one node to remove, then show a simplified page + if (self.canRemoveNodesLength() > 1 && self.titlesToRemove().length > 1) { + self.page(self.REMOVE); + return true; + } + else { + self.page(self.REMOVE_NO_CHILDREN); + return false; + } + }); + + self.modalSize = ko.pureComputed(function() { + return self.hasChildrenToRemove() && self.canRemoveNode() ? 'modal-dialog modal-lg' : 'modal-dialog modal-md'; + }); + + self.titlesToRemove = ko.computed(function() { + var titlesToRemove = []; + for (var key in self.nodesOriginal()) { + if (self.nodesOriginal().hasOwnProperty(key) && self.canRemoveNodes()[key]) { + var node = self.nodesOriginal()[key]; + var groups = node.mapcoreGroups; + for (var i = 0; i < groups.length; i++) { + if (groups[i] === self.groupToRemove().mapcoreGroupID) { + titlesToRemove.push(node.title); + break; + } + } + } + } + return titlesToRemove; + }); + + self.titlesToKeep = ko.computed(function() { + var titlesToKeep = []; + for (var key in self.nodesOriginal()) { + if (self.nodesOriginal().hasOwnProperty(key) && !self.canRemoveNodes()[key]) { + var node = self.nodesOriginal()[key]; + var groups = node.mapcoreGroups; + for (var i = 0; i < groups.length; i++) { + if (groups[i] === self.groupToRemove().mapcoreGroupID) { + titlesToKeep.push(node.title); + break; + } + } + } + } + return titlesToKeep; + }); + + self.componentIDsToRemove = ko.computed(function() { + var componentIDsToRemove = []; + if (!self.deleteAll()) { + return []; + } + for (var key in self.nodesOriginal()) { + if (key === self.nodeId) { + continue; + } + if (self.nodesOriginal().hasOwnProperty(key) && self.canRemoveNodes()[key]) { + var node = self.nodesOriginal()[key]; + var groups = node.mapcoreGroups; + for (var i = 0; i < groups.length; i++) { + if (groups[i] === self.groupToRemove().mapcoreGroupID) { + componentIDsToRemove.push(node.id); + break; + } + } + } + } + return componentIDsToRemove; + }); + + $.when(self.treeDataPromise).done(function(response) { + nodesOriginal = projectSettingsTreebeardBase.getNodesOriginal(response[0], nodesOriginal); + self.nodesOriginal(nodesOriginal); + }).fail(function(xhr, status, error) { + $osf.growl('Error', _('Unable to retrieve projects and components')); + Raven.captureMessage(_('Unable to retrieve projects and components'), { + extra: { + url: self.nodeApiUrl, status: status, error: error + } + }); + }); + }, + clear: function() { + var self = this; + self.deleteAll(false); + }, + back: function() { + var self = this; + self.page(self.REMOVE); + }, + submit: function() { + var self = this; + removeNodesGroups(self.groupToRemove().id, self.componentIDsToRemove()).then(function (data) { + window.location.reload(); + }).fail(function(xhr, status, error) { + $osf.growl('Error', _('Unable to delete Group')); + Raven.captureMessage(_('Could not DELETE Group.') + error, { + extra: { + url: window.contextVars.node.urls.api + 'group/remove/', status: status, error: error + } + }); + self.clear(); + window.location.reload(); + }); + }, + deleteAllNodes: function() { + var self = this; + self.page(self.REMOVE_ALL); + } +}); + +//////////////// +// Public API // +//////////////// + +function GroupsRemover(selector, nodeTitle, nodeId, userName, userId, groupShouter, pageChangedShouter, treeDataPromise) { + var self = this; + self.selector = selector; + self.$element = $(selector); + self.nodeTitle = nodeTitle; + self.nodeId = nodeId; + self.userName = userName; + self.userId = userId; + self.viewModel = new RemoveGroupViewModel(self.nodeTitle, self.nodeId, self.userName, self.userId, groupShouter, pageChangedShouter, treeDataPromise); + self.init(); +} + +GroupsRemover.prototype.init = function() { + var self = this; + $osf.applyBindings(self.viewModel, self.$element[0]); + // Clear popovers on dismiss start + self.$element.on('hide.bs.modal', function() { + self.$element.find('.popover').popover('hide'); + self.viewModel.clear(); + }); +}; + +module.exports = GroupsRemover; diff --git a/website/static/js/logActionsList.json b/website/static/js/logActionsList.json index d8a11334943..c3e9972dc0f 100644 --- a/website/static/js/logActionsList.json +++ b/website/static/js/logActionsList.json @@ -32,6 +32,12 @@ "contributor_rejected": "${contributors} cancelled invitation as contributor(s) from ${node}", "contributors_reordered": "${user} reordered contributors for ${node}", "permissions_updated": "${user} changed permissions for ${node}", + "mapcore_group_added": "${user} added group ${mapcore_groups} to ${node}", + "mapcore_group_removed": "${user} removed group ${mapcore_groups} from ${node}", + "mapcore_group_permission_updated": "${user} updated group permissions on ${node}", + "mapcore_group_reordered": "${user} reordered groups for ${node}", + "made_mapcore_group_visible": "${user} made non-bibliographic group ${mapcore_groups} a bibliographic group on ${node}", + "made_mapcore_group_invisible": "${user} made bibliographic group ${mapcore_groups} a non-bibliographic group on ${node}", "made_contributor_visible": "${user} made non-bibliographic contributor ${contributors} a bibliographic contributor on ${node}", "made_contributor_invisible": "${user} made bibliographic contributor ${contributors} a non-bibliographic contributor on ${node}", "wiki_updated": "${user} updated wiki page ${page} to version ${version} of ${node}", diff --git a/website/static/js/logActionsList_extract.js b/website/static/js/logActionsList_extract.js index 30f10c090e6..5baeac3f388 100644 --- a/website/static/js/logActionsList_extract.js +++ b/website/static/js/logActionsList_extract.js @@ -25,12 +25,19 @@ var updated_fields = _('${user} changed the ${updated_fields} for ${node}'); var external_ids_added = _('${user} created external identifier(s) ${identifiers} on ${node}'); var custom_citation_added = _('${user} created a custom citation for ${node}'); var custom_citation_edited = _('${user} edited a custom citation for ${node}'); +var admin_contributor_added = _('The Integrated Admin added ${contributors} as contributor(s) to ${node}'); var custom_citation_removed = _('${user} removed a custom citation from ${node}'); var contributor_added = _('${user} added ${contributors} as contributor(s) to ${node}'); var contributor_removed = _('${user} removed ${contributors} as contributor(s) from ${node}'); var contributor_rejected = _('${contributors} cancelled invitation as contributor(s) from ${node}'); var contributors_reordered = _('${user} reordered contributors for ${node}'); var permissions_updated = _('${user} changed permissions for ${node}'); +var mapcore_group_added = _('${user} added group ${mapcore_groups} to ${node}'); +var mapcore_group_removed = _('${user} removed group ${mapcore_groups} from ${node}'); +var mapcore_group_permission_updated = _('${user} updated group permissions on ${node}'); +var mapcore_group_reordered = _('${user} reordered groups for ${node}'); +var made_mapcore_group_visible = _('${user} made group ${mapcore_groups} visible on ${node}'); +var made_mapcore_group_invisible = _('${user} made group ${mapcore_groups} invisible on ${node}'); var made_contributor_visible = _('${user} made non-bibliographic contributor ${contributors} a bibliographic contributor on ${node}'); var made_contributor_invisible = _('${user} made bibliographic contributor ${contributors} a non-bibliographic contributor on ${node}'); var wiki_updated = _('${user} updated wiki page ${page} to version ${version} of ${node}'); diff --git a/website/static/js/logTextParser.js b/website/static/js/logTextParser.js index ec84b64cc5c..a395efe9d28 100644 --- a/website/static/js/logTextParser.js +++ b/website/static/js/logTextParser.js @@ -17,7 +17,7 @@ var nodeCategories = require('json-loader!built/nodeCategories.json'); //Used when calling getContributorList to limit the number of contributors shown in a single log when many are mentioned var numContributorsShown = 3; - +var numMapcoreGroupsShown = 3; /** * Utility function to not repeat logging errors to Sentry * @param message {String} Custom message for error @@ -141,6 +141,38 @@ var getContributorList = function (contributors, maxShown){ return contribList; }; +/** + * Returns a list of mapcore groups to show in log as well as the trailing punctuation/text after each group. + * If a group has a OSF profile, group is returned as a mithril link to user. + * @param mapcoreGroups {string} The list of mapcore groups (OSF users or unregistered) + * @param maxShown {int} the number of mapcore groups shown before saying "and # others" + * Note: if there is only 1 over maxShown, all mapcore groups are shown + * @returns {array} + */ +var getMapcoreGroupList = function (mapcoreGroups, maxShown){ + var mapcoreGroupList = []; + var justOneMore = numMapcoreGroupsShown === mapcoreGroups.length -1; + for(var i = 0; i < mapcoreGroups.length; i++){ + var item = mapcoreGroups[i]; + var comma = ''; + if(i !== mapcoreGroups.length -1 && ((i !== maxShown -1) || justOneMore)){ + comma = ', '; + } + if(i === mapcoreGroups.length -2 || ((i === maxShown -1) && !justOneMore) && (i !== mapcoreGroups.length -1)) { + if (mapcoreGroups.length === 2) + comma = ' and '; + else + comma = ', and '; + } + + if (i === maxShown && !justOneMore){ + mapcoreGroupList.push([((mapcoreGroups.length - i).toString() + ' others'), ' ']); + return mapcoreGroupList; + } + mapcoreGroupList.push([item.name, comma]);} + return mapcoreGroupList; +}; + var LogText = { view : function(ctrl, logObject) { var userInfoReturned = function(userObject){ @@ -278,6 +310,16 @@ var LogPieces = { return m('span', 'some users'); } }, + // Mapcore group list of added, updated etc. + mapcore_groups: { + view: function (ctrl, logObject) { + var mapcoreGroups = logObject.attributes.params.mapcore_groups; + if(paramIsReturned(mapcoreGroups, logObject)) { + return m('span', getMapcoreGroupList(mapcoreGroups, numMapcoreGroupsShown)); + } + return m('span', 'some users'); + } + }, // The tag added to item involved tag: { view: function (ctrl, logObject) { diff --git a/website/static/js/myProjects.js b/website/static/js/myProjects.js index 10db4bab6d7..6969c3d2fc9 100644 --- a/website/static/js/myProjects.js +++ b/website/static/js/myProjects.js @@ -37,7 +37,8 @@ var sparseNodeFields = String([ 'parent', 'public', 'tags', - 'title' + 'title', + 'mapcore_groups' ]); var sparseRegistrationFields = String([ @@ -354,6 +355,8 @@ function _formatDataforPO(item) { } }); } + var groupList = lodashGet(item, 'attributes.mapcore_groups', []); + item.groups = Array.isArray(groupList) ? groupList.join(' ') : (groupList || ''); item.date = new $osf.FormattableDate(item.attributes.date_modified); item.sortDate = item.date.date; // diff --git a/website/static/js/pages/sharing-page.js b/website/static/js/pages/sharing-page.js index b2cb72d1dff..2a88383676c 100644 --- a/website/static/js/pages/sharing-page.js +++ b/website/static/js/pages/sharing-page.js @@ -2,6 +2,7 @@ var $ = require('jquery'); var ContribManager = require('js/contribManager'); +var GroupManager = require('js/groupsManager'); var AccessRequestManager = require('js/accessRequestManager'); var PrivateLinkManager = require('js/privateLinkManager'); @@ -18,12 +19,18 @@ var nodeApiUrl = ctx.node.urls.api; var isContribPage = $('#manageContributors').length; var hasAccessRequests = $('#manageAccessRequests').length; var cm; +var gm; var arm; - +var isGroupPage = $('#manageGroups').length; if (isContribPage) { cm = new ContribManager('#manageContributors', ctx.contributors, ctx.adminContributors, ctx.currentUser, ctx.isRegistration, '#manageContributorsTable', '#adminContributorsTable'); } +if (isGroupPage) { + // cm = new ContribManager('#manageContributors', ctx.contributors, ctx.adminContributors, ctx.currentUser, ctx.isRegistration, '#manageContributorsTable', '#adminContributorsTable'); + gm = new GroupManager('#manageGroups', ctx.groups,ctx.adminGroups, ctx.currentUser, ctx.isRegistration, '#manageGroupsTable', '#adminGroupsTable'); +} + if (hasAccessRequests) { arm = new AccessRequestManager('#manageAccessRequests', ctx.accessRequests, ctx.currentUser, ctx.isRegistration, '#manageAccessRequestsTable'); } @@ -31,10 +38,18 @@ if (hasAccessRequests) { if ($.inArray('admin', ctx.currentUser.permissions) !== -1) { // Controls the modal var configUrl = ctx.node.urls.api + 'get_editable_children/'; - var privateLinkManager = new PrivateLinkManager('#addPrivateLink', configUrl); + var $addPrivateLink = $('#addPrivateLink'); + var privateLinkManager; + if ($addPrivateLink.length) { + privateLinkManager = new PrivateLinkManager('#addPrivateLink', configUrl); + } var tableUrl = nodeApiUrl + 'private_link/'; var linkTable = $('#privateLinkTable'); - var privateLinkTable = new PrivateLinkTable('#linkScope', tableUrl, ctx.node.isPublic, linkTable); + var $linkScope = $('#linkScope'); + var privateLinkTable; + if ($linkScope.length) { + privateLinkTable = new PrivateLinkTable('#linkScope', tableUrl, ctx.node.isPublic, linkTable); + } } $(function() { @@ -50,6 +65,9 @@ $(window).on('load', function() { if (typeof arm !== 'undefined') { arm.viewModel.onWindowResize(); } + if (typeof gm !== 'undefined') { + gm.viewModel.onWindowResize(); + } if (!!privateLinkTable){ privateLinkTable.viewModel.onWindowResize(); rt.responsiveTable(linkTable[0]); @@ -70,4 +88,7 @@ $(window).resize(function() { if (typeof arm !== 'undefined') { arm.viewModel.onWindowResize(); } + if (typeof gm !== 'undefined') { + gm.viewModel.onWindowResize(); + } }); diff --git a/website/static/js/project-organizer.js b/website/static/js/project-organizer.js index f8ca9a0b462..4bc85c3ff23 100644 --- a/website/static/js/project-organizer.js +++ b/website/static/js/project-organizer.js @@ -115,6 +115,31 @@ function _poContributors(item) { }); } + +function _poGroups(item) { + var groupList = lodashGet(item, 'data.attributes.mapcore_groups', []); + + if (groupList.length === 0) { + return ''; + } + + return groupList.map(function (group, index) { + var comma; + if (index === 0) { + comma = ''; + } else { + comma = ', '; + } + if (index > 2) { + return m('span'); + } + if (index === 2) { + return m('span', ' + ' + (groupList.length - 2)); // We already show names of the two + } + return m('span', comma + group); + }); +} + /** * Displays date modified * @param {Object} item A Treebeard _item object for the row involved. Node information is inside item.data @@ -162,6 +187,10 @@ function _poResolveRows(item) { data : 'sortDate', filter : false, custom : _poModified + },{ + data : 'groups', + filter : true, + custom : _poGroups }); } else { defaultColumns.push({ @@ -190,7 +219,7 @@ function _poColumnTitles() { if(!mobile){ columns.push({ title: _('Name'), - width : '55%', + width : '35%', sort : true, sortType : 'text' },{ @@ -202,6 +231,10 @@ function _poColumnTitles() { width : '20%', sort : true, sortType : 'date' + },{ + title : _('Groups'), + width : '20%', + sort : false }); } else { columns.push({ @@ -432,7 +465,7 @@ var tbOptions = { }), m('.filterReset', { onclick : resetFilter }, tb.options.removeIcon())]; }, - hiddenFilterRows : ['tags', 'contributors'], + hiddenFilterRows : ['tags', 'contributors', 'groups'], lazyLoadOnLoad : function (tree, event) { var tb = this; function formatItems (arr) { diff --git a/website/static/js/project.js b/website/static/js/project.js index 9be7646f419..ad89e8eb058 100644 --- a/website/static/js/project.js +++ b/website/static/js/project.js @@ -247,6 +247,7 @@ $(document).ready(function() { }); var bibliographicContribInfoHtml = _('Only bibliographic contributors will be displayed in the Contributors list and in project citations. Non-bibliographic contributors can read and modify the project as normal.'); + var bibliographicGroupInfoHtml = _('Only bibliographic groups will be displayed in the Groups list and in project citations. Non-bibliographic groups can read and modify the project as normal.'); $('.visibility-info').attr( 'data-content', bibliographicContribInfoHtml @@ -254,6 +255,12 @@ $(document).ready(function() { trigger: 'hover' }); + $('.visibility-group-info').attr( + 'data-content', bibliographicGroupInfoHtml + ).popover({ + trigger: 'hover' + }); + //////////////////// // Event Handlers // //////////////////// diff --git a/website/static/js/projectSettingsTreebeardBase.js b/website/static/js/projectSettingsTreebeardBase.js index 0412cca34d8..e65cee42c7b 100644 --- a/website/static/js/projectSettingsTreebeardBase.js +++ b/website/static/js/projectSettingsTreebeardBase.js @@ -64,7 +64,8 @@ function getNodesOriginal(nodeTree, nodesOriginal) { institutions: nodeInstitutions, changed: false, checked: false, - enabled: true + enabled: true, + mapcoreGroups: nodeTree.node.mapcore_groups || [] }; if (nodeTree.children) { diff --git a/website/templates/project/groups.mako b/website/templates/project/groups.mako new file mode 100644 index 00000000000..38f76bfa780 --- /dev/null +++ b/website/templates/project/groups.mako @@ -0,0 +1,298 @@ +<%inherit file="project/project_base.mako"/> +<%def name="title()">${node['title']} ${_("Groups")} + +<%include file="project/modal_add_group.mako"/> +<%include file="project/modal_remove_group.mako"/> + + + +
+
+ +
${_("Permissions")} +
+
+
+ +
+
+ +
+
+ +
+
+
${_("Bibliographic Group")} +
+
+
+ +
+
+ +
+
+
+
+ +
+
+

${_("Groups")} + + + ${_("Add")} + + +

+ + % if permissions.ADMIN in user['permissions'] and not node['is_registration']: +

${_("Drag and drop groups to change listing order.")}

+ % endif + +
+ +
+
+
+ ${_("No groups found")} +
+ +
+

+ ${_("Admins on Parent Projects")} + +

+ +
+
+ ${_("No administrators from parent project found.")} +
+
+ ${buttonGroup()} +
+ +
+ + + + + + + + +<%def name="buttonGroup()"> + % if permissions.ADMIN in user['permissions']: + + % endif +
+
+
+ + +<%def name="javascript_bottom()"> + ${parent.javascript_bottom()} + + + + + + diff --git a/website/templates/project/modal_add_group.mako b/website/templates/project/modal_add_group.mako new file mode 100644 index 00000000000..078cdf25212 --- /dev/null +++ b/website/templates/project/modal_add_group.mako @@ -0,0 +1,220 @@ + diff --git a/website/templates/project/modal_remove_group.mako b/website/templates/project/modal_remove_group.mako new file mode 100644 index 00000000000..72719facc03 --- /dev/null +++ b/website/templates/project/modal_remove_group.mako @@ -0,0 +1,126 @@ + + + diff --git a/website/templates/project/project.mako b/website/templates/project/project.mako index a79f6faa890..36b88240311 100644 --- a/website/templates/project/project.mako +++ b/website/templates/project/project.mako @@ -1,6 +1,7 @@ <%inherit file="project/project_base.mako"/> <%namespace name="render_nodes" file="util/render_nodes.mako" /> <%namespace name="contributor_list" file="util/contributor_list.mako" /> +<%namespace name="group_list" file="util/group_list.mako" /> <%namespace name="render_addon_widget" file="util/render_addon_widget.mako" /> <%include file="project/nodes_privacy.mako"/> <%include file="util/render_grdm_addons_context.mako"/> @@ -174,6 +175,21 @@ % endif +
+ % if user['is_contributor_or_group_member']: + ${_("Groups")}: + % else: + ${_("Groups:")} + % endif + + % if node['anonymous']: +
    ${_("Anonymous Groups")}
+ % else: +
    + ${group_list.render_groups_full(groups=node['mapcore_groups'])} +
+ % endif +
% if node['groups']:
Groups: diff --git a/website/templates/project/project_header.mako b/website/templates/project/project_header.mako index 79f51a2d6e3..a9b96c3ac4c 100644 --- a/website/templates/project/project_header.mako +++ b/website/templates/project/project_header.mako @@ -108,6 +108,10 @@
  • ${_("Contributors")}
  • % endif + % if user['is_contributor_or_group_member']: +
  • ${_("Groups")}
  • + % endif + % if permissions.WRITE in user['permissions'] and not node['is_registration']:
  • ${ _("Add-ons") }
  • % endif diff --git a/website/templates/util/group_list.mako b/website/templates/util/group_list.mako new file mode 100644 index 00000000000..c79bbb7a82e --- /dev/null +++ b/website/templates/util/group_list.mako @@ -0,0 +1,30 @@ +<%def name="render_group_dict(group)"> + ${group['name']}${ group['separator'] | n } + + +<%def name="render_groups(groups, others_count, node_url)"> + % for i, group in enumerate(groups): + ${render_group_dict(group) if isinstance(group, dict) else render_user_obj(group)} + % endfor + % if others_count: + ${_("%(othersCount)s more") % dict(othersCount=others_count)} + % endif + + +<%def name="render_groups_full(groups)"> + % for group in groups: +
  • + <% + condensed = group['mapcore_group']['name'] + is_condensed = False + if len(condensed) >= 50: + condensed = condensed[:23] + "..." + condensed[-23:] + is_condensed = True + %> +
  • + % endfor + diff --git a/website/templates/util/render_node.mako b/website/templates/util/render_node.mako index 39235511700..cea10f148f0 100644 --- a/website/templates/util/render_node.mako +++ b/website/templates/util/render_node.mako @@ -1,4 +1,5 @@ <%namespace name="contributor_list" file="./contributor_list.mako" /> +<%namespace name="group_list" file="./group_list.mako" /> ## TODO: Rename summary to node <%def name="render_node(summary, show_path)"> ## TODO: Don't rely on ID @@ -100,6 +101,9 @@
    ${contributor_list.render_contributors(contributors=summary['contributors'], others_count=summary['others_count'], node_url=summary['url'])}
    +
    + ${group_list.render_groups(groups=summary['mapcore_groups'], others_count=summary['mapcore_groups_others_count'], node_url=summary['url'])} +
    % if summary['groups']:
    ${summary['groups']} diff --git a/website/translations/en/LC_MESSAGES/js_messages.po b/website/translations/en/LC_MESSAGES/js_messages.po index a1bff9090cb..a83c82618e2 100644 --- a/website/translations/en/LC_MESSAGES/js_messages.po +++ b/website/translations/en/LC_MESSAGES/js_messages.po @@ -2966,7 +2966,7 @@ msgid "Storage location" msgstr "" #: website/static/js/addProjectPlugin.js:251 -msgid " Add contributors from " +msgid " Add contributors and groups from " msgstr "" #: website/static/js/addProjectPlugin.js:253 @@ -9270,3 +9270,73 @@ msgstr "" msgid "The Integrated Admin added ${contributors} as contributor(s) to ${node}" msgstr "" + +msgid "${user} added group ${mapcore_groups} to ${node}" +msgstr "" + +msgid "${user} removed group ${mapcore_groups} from ${node}" +msgstr "" + +msgid "${user} updated group permissions on ${node}" +msgstr "" + +msgid "Add Groups" +msgstr "" + +msgid "Remove Group" +msgstr "" + +msgid "Your group list has unsaved changes. Please " +msgstr "" + +msgid "save or cancel your changes before adding groups." +msgstr "" + +msgid "Unable to delete Group" +msgstr "" + +msgid "Could not DELETE Group." +msgstr "" + +msgid "There was a problem trying to add groups%1$s." +msgstr "" + +msgid "There was a problem trying to add the group." +msgstr "" + +msgid "Could not add groups" +msgstr "" + +msgid "Could not add group" +msgstr "" + +msgid "Error adding groups" +msgstr "" + +msgid "" +"Only bibliographic groups will be displayed in the Groups " +"list and in project citations. Non-bibliographic groups can read " +"and modify the project as normal." +msgstr "" + +msgid "A user reordered groups for a project" +msgstr "" + +msgid "A user made group(s) visible on a project" +msgstr "" + +msgid "A user made group(s) invisible on a project" +msgstr "" + +msgid "${user} reordered groups for ${node}" +msgstr "" + +msgid "" +"${user} made non-bibliographic group ${mapcore_groups} a " +"bibliographic group on ${node}" +msgstr "" + +msgid "" +"${user} made bibliographic group ${mapcore_groups} a " +"non-bibliographic group on ${node}" +msgstr "" diff --git a/website/translations/en/LC_MESSAGES/messages.po b/website/translations/en/LC_MESSAGES/messages.po index 6d84a486354..216d3050ae6 100644 --- a/website/translations/en/LC_MESSAGES/messages.po +++ b/website/translations/en/LC_MESSAGES/messages.po @@ -4080,4 +4080,83 @@ msgid "\"Full name\", \"Family name\", \"Given name\", \"Family name (EN)\", \"G msgstr "" msgid "If you do not have an email address registered, please enter or add your email address in the \"Registered email address\" entry field first." -msgstr "" \ No newline at end of file +msgstr "" + +msgid "Groups" +msgstr "" + +msgid "Group name" +msgstr "" + +msgid "Registered by" +msgstr "" + +msgid "Add Groups" +msgstr "" + +msgid "Search by group name" +msgstr "" + +msgid "Remove Group" +msgstr "" + +msgid "" +"Do you want to remove from" +" , or from and every component in it?" +msgstr "" + +msgid "" +"Remove from" +" ." +msgstr "" + +msgid "" +"Remove from" +" and every" +" component in it." +msgstr "" + +msgid "Remove from ?" +msgstr "" + +msgid "" +" " +"will be removed from the following projects and/or components." +msgstr "" + +msgid "" +" " +"cannot be removed from the following projects and/or components." +msgstr "" + +msgid "Searching groups..." +msgstr "" + +msgid "Adding group(s)" +msgstr "" + +msgid "Remove from ?" +msgstr "" + +msgid "" +"You can also add the group(s) to any components on which you are an" +" admin." +msgstr "" + +msgid "No groups found" +msgstr "" + +msgid "" +"Please save or discard your existing changes before removing a " +"groups." +msgstr "" + +msgid "Drag and drop groups to change listing order." +msgstr "" + +msgid "Bibliographic Group" +msgstr "" + +msgid "Bibliographic Group Information" +msgstr "" diff --git a/website/translations/ja/LC_MESSAGES/js_messages.po b/website/translations/ja/LC_MESSAGES/js_messages.po index 39bcc9e353d..0d8e38c39ca 100644 --- a/website/translations/ja/LC_MESSAGES/js_messages.po +++ b/website/translations/ja/LC_MESSAGES/js_messages.po @@ -4216,8 +4216,8 @@ msgid "Storage location" msgstr "ストレージロケーション" #: website/static/js/addProjectPlugin.js:251 -msgid " Add contributors from " -msgstr "次からメンバーを追加する:" +msgid " Add contributors and groups from " +msgstr "次からメンバーとグループを追加する:" #: website/static/js/addProjectPlugin.js:253 msgid " Admins of " @@ -10557,3 +10557,73 @@ msgstr "作成したプロジェクト数が作成可能なプロジェクトの msgid "The Integrated Admin added ${contributors} as contributor(s) to ${node}" msgstr "統合管理者代理アカウントが${contributors}をコントリビューターとして${node}に追加しました" + +msgid "${user} added group ${mapcore_groups} to ${node}" +msgstr "${user}が${mapcore_groups}グループを${node}に追加しました" + +msgid "${user} removed group ${mapcore_groups} from ${node}" +msgstr "${user}が${node}から${mapcore_groups}グループを削除しました" + +msgid "${user} updated group permissions on ${node}" +msgstr "${user}が${node}のグループ権限を変更しました" + +msgid "Add Groups" +msgstr "グループを追加" + +msgid "Remove Group" +msgstr "グループを削除" + +msgid "Your group list has unsaved changes. Please " +msgstr "グループリストには未保存の変更があります。 " + +msgid "save or cancel your changes before adding groups." +msgstr "グループを追加する前に、変更を保存またはキャンセルしてください。" + +msgid "Unable to delete Group" +msgstr "グループを削除できません" + +msgid "Could not DELETE Group." +msgstr "グループを削除できませんでした" + +msgid "There was a problem trying to add groups%1$s." +msgstr "グループ%1$sを追加しようとして問題が発生しました。" + +msgid "There was a problem trying to add the group." +msgstr "グループを追加しようとして問題が発生しました。" + +msgid "Could not add groups" +msgstr "グループを追加できませんでした" + +msgid "Could not add group" +msgstr "グループを追加できませんでした" + +msgid "Error adding groups" +msgstr "グループの追加エラー" + +msgid "" +"Only bibliographic groups will be displayed in the Groups " +"list and in project citations. Non-bibliographic groups can read " +"and modify the project as normal." +msgstr "グループリストおよびプロジェクトの引用には、書誌のグループのみが表示されます。 書誌以外のグループは、通常どおりプロジェクトを読んで修正できます。" + +msgid "A user reordered groups for a project" +msgstr "" + +msgid "A user made group(s) visible on a project" +msgstr "" + +msgid "A user made group(s) invisible on a project" +msgstr "" + +msgid "${user} reordered groups for ${node}" +msgstr "${user}が${node}のグループを並べ替えました" + +msgid "" +"${user} made non-bibliographic group ${mapcore_groups} a " +"bibliographic group on ${node}" +msgstr "${user}が目録非表示グループ(${mapcore_groups})を${node}の目録表示グループにしました" + +msgid "" +"${user} made bibliographic group ${mapcore_groups} a " +"non-bibliographic group on ${node}" +msgstr "${user}が目録表示グループ(${mapcore_groups})を${node}の目録非表示グループにしました" diff --git a/website/translations/ja/LC_MESSAGES/messages.po b/website/translations/ja/LC_MESSAGES/messages.po index 10b07e9746a..8a8de45f350 100644 --- a/website/translations/ja/LC_MESSAGES/messages.po +++ b/website/translations/ja/LC_MESSAGES/messages.po @@ -4556,3 +4556,86 @@ msgstr "メンバー管理" #~ "will keep the registration private until" #~ " the embargo period ends." #~ msgstr "この%(nodeType)sは現在登録を保留しており、プロジェクト管理者からの承認を待っています。この登録は最終的なものであり、すべてのプロジェクト管理者が登録を承認するか、48時間のパスのいずれか早いほうを承認した時点で禁止期間に入ります。禁止措置は、禁止期間が終了するまで登録を非公開にします。" + +msgid "Groups" +msgstr "グループ" + +msgid "Group name" +msgstr "グループ名" + +msgid "Registered by" +msgstr "登録者" + +msgid "Add Groups" +msgstr "グループを追加" + +msgid "Search by group name" +msgstr "グループ名で検索する" + +msgid "Remove Group" +msgstr "グループを削除" + +msgid "" +"Do you want to remove from" +" , or from and every component in it?" +msgstr "" +"から、またはとその中のすべてのコンポーネントから削除しますか?" + +msgid "" +"Remove from" +" ." +msgstr "からを削除します。" + +msgid "" +"Remove from" +" and every" +" component in it." +msgstr "" +"およびその中のすべてのコンポーネントから削除します。" + +msgid "Remove from ?" +msgstr "からを削除しますか?" + +msgid "" +" " +"will be removed from the following projects and/or components." +msgstr "は、以下のプロジェクトおよび/またはコンポーネントから削除されます。" + +msgid "" +" " +"cannot be removed from the following projects and/or components." +msgstr "は、以下のプロジェクトやコンポーネントから削除できません。" + +msgid "Searching groups..." +msgstr "グループを検索しています..." + +msgid "Adding group(s)" +msgstr "メンバーを追加中" + +msgid "Remove from ?" +msgstr "からを削除しますか?" + +msgid "" +"You can also add the group(s) to any components on which you are an" +" admin." +msgstr "管理者であるコンポーネントにグループを追加することもできます。" + +msgid "No groups found" +msgstr "グループが見つかりません" + +msgid "" +"Please save or discard your existing changes before removing a " +"groups." +msgstr "グループを削除する前に、既存の変更を保存または破棄してください。" + +msgid "Drag and drop groups to change listing order." +msgstr "グループをドラッグ&ドロップして、リストの順序を変更します。" + +msgid "Bibliographic Group" +msgstr "目録表示グループ" + +msgid "Bibliographic Group Information" +msgstr "目録表示グループの情報" diff --git a/website/translations/js_messages.pot b/website/translations/js_messages.pot index a05c7aac7ca..06f32593f4a 100644 --- a/website/translations/js_messages.pot +++ b/website/translations/js_messages.pot @@ -2907,7 +2907,7 @@ msgid "Storage location" msgstr "" #: website/static/js/addProjectPlugin.js:251 -msgid " Add contributors from " +msgid " Add contributors and groups from " msgstr "" #: website/static/js/addProjectPlugin.js:253 @@ -9223,3 +9223,73 @@ msgstr "" msgid "The Integrated Admin added ${contributors} as contributor(s) to ${node}" msgstr "" + +msgid "${user} added group ${mapcore_groups} to ${node}" +msgstr "" + +msgid "${user} removed group ${mapcore_groups} from ${node}" +msgstr "" + +msgid "${user} updated group permissions on ${node}" +msgstr "" + +msgid "Add Groups" +msgstr "" + +msgid "Remove Group" +msgstr "" + +msgid "Your group list has unsaved changes. Please " +msgstr "" + +msgid "save or cancel your changes before adding groups." +msgstr "" + +msgid "Unable to delete Group" +msgstr "" + +msgid "Could not DELETE Group." +msgstr "" + +msgid "There was a problem trying to add groups%1$s." +msgstr "" + +msgid "There was a problem trying to add the group." +msgstr "" + +msgid "Could not add groups" +msgstr "" + +msgid "Could not add group" +msgstr "" + +msgid "Error adding groups" +msgstr "" + +msgid "" +"Only bibliographic groups will be displayed in the Groups " +"list and in project citations. Non-bibliographic groups can read " +"and modify the project as normal." +msgstr "" + +msgid "A user reordered groups for a project" +msgstr "" + +msgid "A user made group(s) visible on a project" +msgstr "" + +msgid "A user made group(s) invisible on a project" +msgstr "" + +msgid "${user} reordered groups for ${node}" +msgstr "" + +msgid "" +"${user} made non-bibliographic group ${mapcore_groups} a " +"bibliographic group on ${node}" +msgstr "" + +msgid "" +"${user} made bibliographic group ${mapcore_groups} a " +"non-bibliographic group on ${node}" +msgstr "" diff --git a/website/translations/messages.pot b/website/translations/messages.pot index b1d25f76719..e358e12b68b 100644 --- a/website/translations/messages.pot +++ b/website/translations/messages.pot @@ -4345,3 +4345,81 @@ msgstr "" msgid "Manage Contributors" msgstr "" +msgid "Groups" +msgstr "" + +msgid "Group name" +msgstr "" + +msgid "Registered by" +msgstr "" + +msgid "Add Groups" +msgstr "" + +msgid "Search by group name" +msgstr "" + +msgid "Remove Group" +msgstr "" + +msgid "" +"Do you want to remove from" +" , or from and every component in it?" +msgstr "" + +msgid "" +"Remove from" +" ." +msgstr "" + +msgid "" +"Remove from" +" and every" +" component in it." +msgstr "" + +msgid "Remove from ?" +msgstr "" + +msgid "" +" " +"will be removed from the following projects and/or components." +msgstr "" + +msgid "" +" " +"cannot be removed from the following projects and/or components." +msgstr "" + +msgid "Searching groups..." +msgstr "" + +msgid "Adding group(s)" +msgstr "" + +msgid "Remove from ?" +msgstr "" + +msgid "" +"You can also add the group(s) to any components on which you are an" +" admin." +msgstr "" + +msgid "No groups found" +msgstr "" + +msgid "" +"Please save or discard your existing changes before removing a " +"groups." +msgstr "" + +msgid "Drag and drop groups to change listing order." +msgstr "" + +msgid "Bibliographic Group" +msgstr "" + +msgid "Bibliographic Group Information" +msgstr "" diff --git a/website/views.py b/website/views.py index 1d82d22d33b..e8a041d8450 100644 --- a/website/views.py +++ b/website/views.py @@ -71,6 +71,38 @@ def serialize_contributors_for_summary(node, max_count=3): 'others_count': others_count, } + +def serialize_mapcore_group_for_summary(node, max_count=3): + # # TODO: Use .filter(visible=True) when chaining is fixed in django-include + node_mapcore_groups = node.mapcore_node_groups.filter(is_deleted=False, visible=True).select_related('mapcore_group') + mapcore_groups = [] + n_node_mapcore_groups = node_mapcore_groups.count() + others_count = '' + + for index, node_mapcore_group in enumerate(node_mapcore_groups[:max_count]): + + if index == max_count - 1 and n_node_mapcore_groups > max_count: + separator = ' &' + others_count = str(n_node_mapcore_groups - 3) + elif index == n_node_mapcore_groups - 1: + separator = '' + elif index == n_node_mapcore_groups - 2: + separator = ' &' + else: + separator = ',' + + mapcore_group_summary = { + 'name': node_mapcore_group.mapcore_group._id, + 'url': node_mapcore_group.mapcore_group.absolute_url, + } + mapcore_group_summary['separator'] = separator + + mapcore_groups.append(mapcore_group_summary) + return { + 'mapcore_groups': mapcore_groups, + 'mapcore_groups_others_count': others_count, + } + def serialize_groups_for_summary(node): groups = node.osf_groups n_groups = len(groups) @@ -108,6 +140,7 @@ def serialize_node_summary(node, auth, primary=True, show_path=False): user = auth.user if node.can_view(auth): contributor_data = serialize_contributors_for_summary(node) + mapcore_group_data = serialize_mapcore_group_for_summary(node) summary.update({ 'can_view': True, 'can_edit': node.can_edit(auth), @@ -143,6 +176,8 @@ def serialize_node_summary(node, auth, primary=True, show_path=False): 'contributors': contributor_data['contributors'], 'others_count': contributor_data['others_count'], 'groups': serialize_groups_for_summary(node), + 'mapcore_groups': mapcore_group_data['mapcore_groups'], + 'mapcore_groups_others_count': mapcore_group_data['mapcore_groups_others_count'], 'description': node.description if len(node.description) <= 150 else node.description[0:150] + '...', }) else: From ff45fafeed5a614821ffcbd139aec68508c4e516 Mon Sep 17 00:00:00 2001 From: ndnhat Date: Mon, 23 Feb 2026 14:23:04 +0700 Subject: [PATCH 06/38] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20Resolve=20migrations=20conflict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- osf/migrations/0264_merge_20260223_0712.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 osf/migrations/0264_merge_20260223_0712.py diff --git a/osf/migrations/0264_merge_20260223_0712.py b/osf/migrations/0264_merge_20260223_0712.py new file mode 100644 index 00000000000..35f71f5eb88 --- /dev/null +++ b/osf/migrations/0264_merge_20260223_0712.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2026-02-23 07:12 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0262_auto_20260202_0643'), + ('osf', '0263_merge_20260130_1152'), + ] + + operations = [ + ] From 0eb00643ae08a33e2d2df86df3690f46e108a392 Mon Sep 17 00:00:00 2001 From: hcphat Date: Wed, 18 Mar 2026 18:21:43 +0700 Subject: [PATCH 07/38] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20Update=20code=20handle=20groups=20addon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons.json | 9 +- addons/groups/README.md | 5 + addons/groups/__init__.py | 6 + addons/groups/apps.py | 53 +++++ addons/groups/migrations/0001_initial.py | 58 ++++++ addons/groups/migrations/__init__.py | 0 addons/groups/models.py | 10 + addons/groups/routes.py | 24 +++ addons/groups/settings/__init__.py | 10 + addons/groups/settings/defaults.py | 3 + addons/groups/static/comicon.png | Bin 0 -> 2443 bytes addons/groups/static/groupsNodeConfig.js | 185 +++++++++++++++++ addons/groups/static/node-cfg.js | 5 + .../templates/groups_node_settings.mako | 17 ++ addons/groups/tests/__init__.py | 0 addons/groups/tests/test_models.py | 13 ++ addons/groups/tests/test_views.py | 30 +++ addons/groups/views.py | 26 +++ admin/base/settings/defaults.py | 4 +- admin/rdm_addons/views.py | 5 + admin/rdm_timestampadd/views.py | 5 +- .../templates/rdm_timestampadd/node_list.html | 10 + admin_tests/rdm_timestampadd/test_views.py | 14 +- api/base/settings/defaults.py | 1 + api/nodes/views.py | 5 +- api/users/serializers.py | 3 + .../views/test_node_mapcore_group_views.py | 196 +++++++++++++++++- .../users/serializers/test_serializers.py | 2 + framework/addons/data/addons.json | 30 +++ osf/models/__init__.py | 1 + osf/models/mapcore_group.py | 4 + osf/models/mixins.py | 46 ++-- osf/models/node.py | 7 + tests/test_node_groups_view.py | 1 + website/project/views/node.py | 7 + website/static/css/pages/contributor-page.css | 12 ++ website/static/js/groupsManager.js | 8 +- website/static/js/pages/sharing-page.js | 2 +- website/templates/project/groups.mako | 5 + website/templates/project/project.mako | 6 + website/templates/project/project_header.mako | 5 +- website/templates/util/group_list.mako | 2 +- website/templates/util/render_node.mako | 2 + .../en/LC_MESSAGES/js_messages.po | 114 ++++++++++ .../translations/en/LC_MESSAGES/messages.po | 9 + .../ja/LC_MESSAGES/js_messages.po | 108 ++++++++++ .../translations/ja/LC_MESSAGES/messages.po | 9 + website/translations/messages.pot | 9 + website/views.py | 4 + 49 files changed, 1052 insertions(+), 38 deletions(-) create mode 100644 addons/groups/README.md create mode 100644 addons/groups/__init__.py create mode 100644 addons/groups/apps.py create mode 100644 addons/groups/migrations/0001_initial.py create mode 100644 addons/groups/migrations/__init__.py create mode 100644 addons/groups/models.py create mode 100644 addons/groups/routes.py create mode 100644 addons/groups/settings/__init__.py create mode 100644 addons/groups/settings/defaults.py create mode 100644 addons/groups/static/comicon.png create mode 100644 addons/groups/static/groupsNodeConfig.js create mode 100644 addons/groups/static/node-cfg.js create mode 100644 addons/groups/templates/groups_node_settings.mako create mode 100644 addons/groups/tests/__init__.py create mode 100644 addons/groups/tests/test_models.py create mode 100644 addons/groups/tests/test_views.py create mode 100644 addons/groups/views.py diff --git a/addons.json b/addons.json index 21eff15cf2b..88d90c2c997 100644 --- a/addons.json +++ b/addons.json @@ -33,7 +33,8 @@ "onedrivebusiness", "metadata", "onlyoffice", - "workflow" + "workflow", + "groups" ], "addons_default": [ "osfstorage", @@ -140,7 +141,8 @@ "onedrivebusiness": "OneDrive for Office365 is a file storage add-on. Connect your Microsoft OneDrive account to a GakuNin RDM project to interact with files hosted on Microsoft OneDrive via the GakuNin RDM.", "metadata": "The Metadata addon provides the functionality to register metadata to files and generate reports.", "onlyoffice": "ONLYOFFICE document server.", - "workflow": "Workflow gateway integration that serves engine key material to trusted services." + "workflow": "Workflow gateway integration that serves engine key material to trusted services.", + "groups": "Groups is an add-on that allows users to create and manage groups of users within the GakuNin RDM." }, "addons_url": { "box": "http://www.box.com", @@ -169,7 +171,8 @@ "onedrivebusiness": "https://onedrive.live.com", "metadata": "https://rcos.nii.ac.jp/service/rdm/", "onlyoffice": "https://onlyoffice.com/", - "workflow": "https://rcos.nii.ac.jp/service/rdm/" + "workflow": "https://rcos.nii.ac.jp/service/rdm/", + "groups": "https://rcos.nii.ac.jp/service/rdm/groups/" }, "institutional_storage_add_on_method": [ "nextcloudinstitutions", diff --git a/addons/groups/README.md b/addons/groups/README.md new file mode 100644 index 00000000000..e8aacb68a4f --- /dev/null +++ b/addons/groups/README.md @@ -0,0 +1,5 @@ +# RDM Groups Addon + +## Feature + +The RDM Groups Addon provides a way to enable/disable groups management in project diff --git a/addons/groups/__init__.py b/addons/groups/__init__.py new file mode 100644 index 00000000000..1e7194b1a69 --- /dev/null +++ b/addons/groups/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +DISPLAY_NAME = 'Groups' +SHORT_NAME = 'groups' +FULL_NAME = 'addons.groups' +default_app_config = 'addons.{}.apps.AddonAppConfig'.format(SHORT_NAME) diff --git a/addons/groups/apps.py b/addons/groups/apps.py new file mode 100644 index 00000000000..1f067f09d64 --- /dev/null +++ b/addons/groups/apps.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +import os +from addons.base.apps import BaseAddonAppConfig +from . import DISPLAY_NAME, SHORT_NAME + + +HERE = os.path.dirname(os.path.abspath(__file__)) +TEMPLATE_PATH = os.path.join( + HERE, + 'templates' +) + + +class AddonAppConfig(BaseAddonAppConfig): + + short_name = SHORT_NAME + name = 'addons.{}'.format(SHORT_NAME) + label = 'addons_{}'.format(SHORT_NAME) + + full_name = DISPLAY_NAME + + owners = ['user', 'node'] + + views = ['page'] + configs = ['node'] + + categories = ['other'] + + include_js = {} + + include_css = { + 'widget': [], + 'page': [], + } + + added_default = [] + + has_page_icon = False + + node_settings_template = os.path.join(TEMPLATE_PATH, 'groups_node_settings.mako') + + @property + def routes(self): + from . import routes + return [routes.api_routes, routes.page_routes] + + @property + def user_settings(self): + return self.get_model('UserSettings') + + @property + def node_settings(self): + return self.get_model('NodeSettings') diff --git a/addons/groups/migrations/0001_initial.py b/addons/groups/migrations/0001_initial.py new file mode 100644 index 00000000000..ea54de1cfe0 --- /dev/null +++ b/addons/groups/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2026-03-09 07:22 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import osf.models.base +import osf.utils.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='NodeSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('_id', models.CharField(db_index=True, default=osf.models.base.generate_object_id, max_length=24, unique=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted', osf.utils.fields.NonNaiveDateTimeField(blank=True, null=True)), + ('owner', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons_groups_node_settings', to='osf.AbstractNode')), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + migrations.CreateModel( + name='UserSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('_id', models.CharField(db_index=True, default=osf.models.base.generate_object_id, max_length=24, unique=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted', osf.utils.fields.NonNaiveDateTimeField(blank=True, null=True)), + ('owner', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='addons_groups_user_settings', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + migrations.AddField( + model_name='nodesettings', + name='user_settings', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='addons_groups.UserSettings'), + ), + ] diff --git a/addons/groups/migrations/__init__.py b/addons/groups/migrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/addons/groups/models.py b/addons/groups/models.py new file mode 100644 index 00000000000..eb37d2143c3 --- /dev/null +++ b/addons/groups/models.py @@ -0,0 +1,10 @@ +from addons.base.models import BaseUserSettings, BaseNodeSettings +from django.db import models + + +class UserSettings(BaseUserSettings): + pass + + +class NodeSettings(BaseNodeSettings): + user_settings = models.ForeignKey(UserSettings, null=True, blank=True, on_delete=models.CASCADE) diff --git a/addons/groups/routes.py b/addons/groups/routes.py new file mode 100644 index 00000000000..1cf83957cea --- /dev/null +++ b/addons/groups/routes.py @@ -0,0 +1,24 @@ +""" +Routes associated with the groups addon +""" + +from framework.routing import Rule, json_renderer +from . import SHORT_NAME +from . import views + + +TEMPLATE_DIR = './addons/groups/templates/' + +api_routes = { + 'rules': [ + Rule([ + '/project//{}/settings/'.format(SHORT_NAME), + '/project//node//{}/settings/'.format(SHORT_NAME), + ], 'get', views.groups_get_config, json_renderer), + ], + 'prefix': '/api/v1', +} + +page_routes = { + 'rules': [] +} diff --git a/addons/groups/settings/__init__.py b/addons/groups/settings/__init__.py new file mode 100644 index 00000000000..2b2f98881f6 --- /dev/null +++ b/addons/groups/settings/__init__.py @@ -0,0 +1,10 @@ +import logging +from .defaults import * # noqa + + +logger = logging.getLogger(__name__) + +try: + from .local import * # noqa +except ImportError: + logger.warn('No local.py settings file found') diff --git a/addons/groups/settings/defaults.py b/addons/groups/settings/defaults.py new file mode 100644 index 00000000000..115021ce815 --- /dev/null +++ b/addons/groups/settings/defaults.py @@ -0,0 +1,3 @@ +""" +Groups addon default settings +""" diff --git a/addons/groups/static/comicon.png b/addons/groups/static/comicon.png new file mode 100644 index 0000000000000000000000000000000000000000..81ddf482840763dd0a071fb0b9e805f2ceae4136 GIT binary patch literal 2443 zcmb_e4OA0X77l1w&;=DvIkt9n9CX1WlbKBZX9*mdAc<=zg{(*`yG~{%WNMNLnSmty z*cPeS-GUXX)QU@Y70+t@TkYRj2-*S#3ffk!y5JVArCUI1TWhgOZC?VUy6f)t^lZ*~ znK$o!@80`;_r81Pne430=ot^p5Q#+5MuR>FJZFP3Co%&3pE;D=4jxf1Lq0DO&5R3- zFwu(*b48+v2-dtxSY=v4P@F?T(wv2ncpWZ)7Ku{Qye^Vj%LtH#DP)~m_-gYp7-DHH zoTo5hCYO$}v4&DNlUtf)rb^dR8X8VZg;Kl(FyLSW67o9iPM+{;;hChEN!WLn@6#s+7x>STclTIEG>hREi@~C4tEasS29>!N8lF zwh}q|Ws|=kH;hN$RwP*5S3~)8Wh7(97g~G;eAel^de3^VakG@;VC!k z5?Ia&1uT*lu2|5*AkrWOfwlf%*U3-n1QbI9JE&BG1xQV>b+|$i+=cX#uDgIV6~eiS z-S%LdG=(yD#=$rR9$=**qb?gKaJ-HC52i!gcR2uAFquL=?iq{25pux`86|-6q;~g2 z^JbrmL30?MD|S;%MhOTr0fqt!5D&Xq@J0so)56k#7A`{PW=Mh2gQ0~3NklLWg5zdP zNyu?Rp<0S*2n?HmnmC%Z`u-iNB5)}o$L@lH3ZhAY{GVW&BCMR-K>|{&gDhlFm$MLt zLQ)Yr&d#}kV-TI}&UK?sm+j`PtQ~CdIhhYZ#tfZOs!=KtTr$DTWFm}CULc(mW7KP5 zz*WMsG@+DRaJ9mMBWjXSAaXTLBNmL2A##OsJawK1=dC!y zU=%1(iq>F=+^Ufx7BvNAQ^-_uT285yw0i11&>P%H(*Bd}1ny~|J_rNL3!K|Gd6ROP zqRE+^g@PqPkkmw0d9s9|;mJkz9{hh?{1Zk!HU<>RomCM`wG5w1^#qLPtb&JhGwFpu ztEq00J#`r%Tq?n#Boj%o&cO9pG8G+VBb|i|=u;?+{$x5oE}j3C&JUIQ|D=Nk8q`Fh z(0|QgaPN+02G|FeK!*%WL#-LSgql0!1WLI<>nNR@bQclha~qS&wBW4fO=Oh1*}e)0KUY(Df@<+86iJJzLq8+Pqcm>dMSL{_JngZgV(Pw~EU)%_0=*Qs$oQ|L)Rw-Inp&;l`9K z;fdy7d;9|*XyZ0+%(o_l=N2ug%l@KjpY@16KE>~g9zc2ygkStN@5$4D=XW;^oi9&b z9cFuA-`V&-)0@Q=329`KZNu=4bm>s-Sww~I9Es%P_UySF)Bn~#I#r6kU5Q8kdH;dO z`_|Oy?a4iv-8oBJ-#E1QHSc(R?(M&Pv&?iUz9Z=u5u)d&8}}EC4cUkI(X_hN{(Y~e znOny2v4*jXt*^J%zO!KUV8pq$C3R>^$>MVVaPRs>#?2pdqN`orxWhZ!(x%_6eoHp3 zOmbsm@$c~MhSzp?Su3hHEnmN5tEqlN>HPN68CkWw`h^>|m^FxRPfbbZoDUajVr!cW zXO)f36|Jr@&!1{`4PAUD3AJ|h<)t;pqotju!Q-Y@x7JiQvr6}~TQ>(T^nFvl{q$4g ziA-0NqQR_a{N+e|bFJ@yWl>D#-q_x5<&Eeg<}<$T#s$mg7#GiKJX7z>m>v67Y|FGT z!55Bg|Els4w7W7Ly?dnjPxd$$CBm_WzIV=*_cq+; zNOoR-@6v}n(b&X$2zw!PR>q~#^kNNq-&PdD$V{K!t{?3lFLx6jN?(^((2b1?hp)C>Z!O^Z>&JD214kb( zyv&}{Jz-ceuV!PDS=za~`h?{a95?3$$F*0!9UXZ&%zJz0=f@IKSI#?r$a0_~GNFI$ R<-q5XF(XTV@S!!8-vj$cjyV7T literal 0 HcmV?d00001 diff --git a/addons/groups/static/groupsNodeConfig.js b/addons/groups/static/groupsNodeConfig.js new file mode 100644 index 00000000000..43ede63a811 --- /dev/null +++ b/addons/groups/static/groupsNodeConfig.js @@ -0,0 +1,185 @@ +/** + * Module that controls the Groups node settings. Includes Knockout view-model + * for syncing data. + */ + +const ko = require('knockout'); +const Raven = require('raven-js'); + +const $osf = require('js/osfHelpers'); +const ChangeMessageMixin = require('js/changeMessage'); + +const _ = require('js/rdmGettext')._; + + +const INTERVAL_NODE_SETTINGS = 500; +const MAX_RETRY_NODE_SETTINGS = 10; +const logPrefix = '[groups]'; + +const $modal = $('#groupsApplyDialog'); + +function initHooks(nodeId, addons, callback) { + if (!addons || addons.length === 0) { + return; + } + const prefixes = addons.map(function(addon) { + return '/api/v1/project/' + nodeId + '/' + addon.name + '/'; + }); + console.log(logPrefix, 'initHooks', prefixes); + (function() { + var origOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function(method, url) { + this.addEventListener('load', function() { + if (prefixes.every(function(prefix) { + return !url.startsWith(prefix); + })) { + return; + } + const targetAddon = addons[prefixes.findIndex(function(prefix) { + return url.startsWith(prefix); + })]; + console.log(logPrefix, 'HTTP Request Completed: ', targetAddon, url); + callback(targetAddon); + }); + origOpen.apply(this, arguments); + }; + })(); +} + + +function ViewModel(nodeId, url) { + const self = this; + ChangeMessageMixin.call(self); + + self.addonName = 'Groups'; + self.nodeId = nodeId; + self.url = url; + self.urls = ko.observable(); + + self.loadedSettings = ko.observable(false); + self.importedAddonSettings = ko.observableArray([]); + + self.applicableAddonSettings = ko.computed(function() { + return self.importedAddonSettings().filter(function(setting) { + return setting.applicable && !setting.applied; + }); + }); + self.nonApplicableAddonSettings = ko.computed(function() { + return self.importedAddonSettings().filter(function(setting) { + return setting.full_name && !setting.applicable; + }); + }); + self.incompletedAddonSettings = ko.computed(function() { + return self.importedAddonSettings().filter(function(setting) { + return setting.full_name && !setting.applied; + }); + }); + + self.refresh(function() { + const addons = []; + initHooks(self.nodeId, self.importedAddonSettings(), function(targetAddon) { + console.log(logPrefix, 'initHooks callback', targetAddon); + self.waitForAddonSetting(targetAddon, function(addons) { + console.log(logPrefix, 'waitForAddonSetting callback', addons); + const nonAppliedAddons = addons.filter(function(addon) { + return !addon.applied; + }); + if (nonAppliedAddons.length === 0) { + return; + } + $modal.modal('show'); + }); + }); + }); +} + +$.extend(ViewModel.prototype, ChangeMessageMixin.prototype); + +ViewModel.prototype.refresh = function(callback) { + const self = this; + $.ajax({ + url: self.url, + type: 'GET', + dataType: 'json' + }).done(function(response) { + const importedAddonSettings = response.data.attributes.imported_addon_settings || []; + self.importedAddonSettings(importedAddonSettings); + self.loadedSettings(true); + if (!callback) { + return; + } + callback(); + }).fail(function(xhr, textStatus, error) { + self.changeMessage(_('Could not GET groups settings'), 'text-danger'); + Raven.captureMessage('Could not GET groups settings', { + extra: { + url: self.url, + textStatus: textStatus, + error: error + } + }); + }); +}; + +ViewModel.prototype.waitForAddonSetting = function(targetAddon, callback) { + const self = this; + var retry = MAX_RETRY_NODE_SETTINGS; + const interval = setInterval(function() { + self.refresh(function() { + const setting = self.importedAddonSettings().filter(function(setting) { + return setting.applicable && setting.name === targetAddon.name; + }); + if (setting.length === 0 && retry > 0) { + retry --; + return; + } + console.log(logPrefix, 'Settings updated', setting); + clearInterval(interval); + if (setting && callback) { + callback(setting); + } + }); + }, INTERVAL_NODE_SETTINGS); +}; + +ViewModel.prototype.applyAddonSettings = function() { + const self = this; + $osf.putJSON( + self.url, + { + addons: self.applicableAddonSettings().map(function(addon) { + return addon.name; + }), + } + ) + .then(function() { + self.changeMessage(_('Add-on settings configured.'), 'text-success'); + setTimeout(function() { + window.location.reload(); + }, 1000) + }) + .catch(function(xhr, textStatus, error) { + self.changeMessage(_('Failed to configure add-on settings.'), 'text-danger') + Raven.captureMessage('Failed to configure add-on settings.', { + extra: { + url: self.url, + textStatus: textStatus, + error: error + } + }); + }); + + $modal.modal('hide'); +}; + +function GroupsNodeConfig(selector, nodeId, url) { + // Initialization code + const self = this; + self.nodeId = nodeId; + self.url = url; + // On success, instantiate and bind the ViewModel + self.viewModel = new ViewModel(nodeId, url); + $osf.applyBindings(self.viewModel, selector); +} + +module.exports = GroupsNodeConfig; diff --git a/addons/groups/static/node-cfg.js b/addons/groups/static/node-cfg.js new file mode 100644 index 00000000000..931436c0105 --- /dev/null +++ b/addons/groups/static/node-cfg.js @@ -0,0 +1,5 @@ +const GroupsNodeConfig = require('./groupsNodeConfig.js'); +const SHORT_NAME = 'groups'; +const nodeId = window.contextVars.node.id; +const url = window.contextVars.node.urls.api + SHORT_NAME + '/settings/'; +new GroupsNodeConfig('#' + SHORT_NAME + 'Scope', nodeId, url); diff --git a/addons/groups/templates/groups_node_settings.mako b/addons/groups/templates/groups_node_settings.mako new file mode 100644 index 00000000000..93e77bbf1ff --- /dev/null +++ b/addons/groups/templates/groups_node_settings.mako @@ -0,0 +1,17 @@ + + +
    +

    + + ${addon_full_name} +

    + +
    +
    +
    + ${_("No configuration items.")} +
    +
    + +
    +
    diff --git a/addons/groups/tests/__init__.py b/addons/groups/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/addons/groups/tests/test_models.py b/addons/groups/tests/test_models.py new file mode 100644 index 00000000000..722b5a8cb15 --- /dev/null +++ b/addons/groups/tests/test_models.py @@ -0,0 +1,13 @@ +import pytest +from addons.groups.models import UserSettings, NodeSettings + +@pytest.mark.django_db +def test_user_settings_creation(): + user_settings = UserSettings.objects.create() + assert isinstance(user_settings, UserSettings) + +@pytest.mark.django_db +def test_node_settings_user_settings_fk(): + user_settings = UserSettings.objects.create() + node_settings = NodeSettings.objects.create(user_settings=user_settings) + assert node_settings.user_settings == user_settings diff --git a/addons/groups/tests/test_views.py b/addons/groups/tests/test_views.py new file mode 100644 index 00000000000..3f7e14db247 --- /dev/null +++ b/addons/groups/tests/test_views.py @@ -0,0 +1,30 @@ +import pytest +from unittest.mock import MagicMock, patch + +@pytest.mark.django_db +def test_groups_get_config_returns_expected_structure(): + mock_addon = MagicMock() + mock_node = MagicMock() + mock_node.get_addon.return_value = mock_addon + mock_node._id = 'abc123' + mock_node.is_deleted = False + mock_node.is_public = True + mock_node.is_collection = False + mock_node.is_quickfiles = False + + kwargs = {'node': mock_node, 'project': None} + auth = MagicMock() + + with patch('website.project.decorators.must_be_valid_project', lambda f: f), \ + patch('framework.auth.decorators.must_be_logged_in', lambda f: f), \ + patch('website.project.decorators.must_have_permission', lambda *a, **kw: lambda f: f), \ + patch('website.project.decorators.must_have_addon', lambda *a, **kw: lambda f: f): + from addons.groups import views as groups_views + import importlib + importlib.reload(groups_views) + + response = groups_views.groups_get_config(auth, **kwargs) + + assert 'data' in response + assert response['data']['type'] == 'groups-config' + assert isinstance(response['data']['attributes'], dict) diff --git a/addons/groups/views.py b/addons/groups/views.py new file mode 100644 index 00000000000..3567065f19f --- /dev/null +++ b/addons/groups/views.py @@ -0,0 +1,26 @@ +from . import SHORT_NAME +from framework.auth.decorators import must_be_logged_in +from website.project.decorators import ( + must_be_valid_project, + must_have_addon, + must_have_permission +) +from osf.utils.permissions import READ + + +def _response_config(addon): + return { + 'data': { + 'type': 'groups-config', + 'attributes': {} + } + } + +@must_be_valid_project +@must_be_logged_in +@must_have_permission(READ) +@must_have_addon(SHORT_NAME, 'node') +def groups_get_config(auth, **kwargs): + node = kwargs['node'] or kwargs['project'] + addon = node.get_addon(SHORT_NAME) + return _response_config(addon) diff --git a/admin/base/settings/defaults.py b/admin/base/settings/defaults.py index fb18e46f3ff..1c7a6dfe8be 100644 --- a/admin/base/settings/defaults.py +++ b/admin/base/settings/defaults.py @@ -140,6 +140,7 @@ 'addons.onedrivebusiness', 'addons.metadata', 'addons.workflow', + 'addons.groups', ) MIGRATION_MODULES = { @@ -186,7 +187,8 @@ 'nextcloud', 'gitlab', 'onedrive', - 'iqbrims' + 'iqbrims', + 'groups' ] USE_TZ = True diff --git a/admin/rdm_addons/views.py b/admin/rdm_addons/views.py index 5dfcfe3ad49..8e281706d05 100644 --- a/admin/rdm_addons/views.py +++ b/admin/rdm_addons/views.py @@ -96,6 +96,9 @@ def get_context_data(self, **kwargs): with app.test_request_context(): ctx['addon_settings'] = utils.get_addons_by_config_type('accounts', self.request.user) + ctx['addon_settings'].append( + utils.get_addon_template_config(utils.get_addon_config('node', 'groups'), self.request.user)) + accounts_addons = [addon for addon in website_settings.ADDONS_AVAILABLE if 'accounts' in addon.configs and not addon.for_institutions] ctx.update({ @@ -126,6 +129,8 @@ def test_func(self): def get(self, request, *args, **kwargs): addon_name = kwargs['addon_name'] addon = utils.get_addon_config('accounts', addon_name) + if addon_name == 'groups': + addon = utils.get_addon_config('node', addon_name) if addon: # get addon's icon image_path = os.path.join('addons', addon_name, 'static', addon.icon) diff --git a/admin/rdm_timestampadd/views.py b/admin/rdm_timestampadd/views.py index 6d3a48a1bc0..d279554ce1f 100644 --- a/admin/rdm_timestampadd/views.py +++ b/admin/rdm_timestampadd/views.py @@ -127,16 +127,17 @@ def get(self, request, **kwargs): response = HttpResponse(content_type='text/csv') writer = csv.writer(response, delimiter=',') writer.writerow(['Node id', 'GUID', 'Title', 'Parent', 'Root', 'Date created', 'Public', 'Withdrawn', 'Embargo', - 'Contributors']) + 'Contributors', 'Groups']) for node in node_list: parent = getattr(node, 'parent_title', None) root = getattr(node, 'root_title', None) public = getattr(node, 'is_public', None) created = getattr(node, 'created', None).strftime('%Y-%m-%d') if getattr(node, 'created', None) else None contributors = getattr(node, 'contributor_names', None) + groups = ', '.join([mapcore_group.display_name for mapcore_group in node.mapcore_groups]) writer.writerow( [node.id, node.guid, node.title, parent, root, created, public, node.retraction, node.embargo, - contributors]) + contributors, groups]) time_now = datetime.today().strftime('%Y%m%d%H%M%S') query = 'attachment; filename= export_nodes_{}.csv'.format(time_now) response['Content-Disposition'] = query diff --git a/admin/templates/rdm_timestampadd/node_list.html b/admin/templates/rdm_timestampadd/node_list.html index 5ebd82de2ae..a4f30a3b75d 100644 --- a/admin/templates/rdm_timestampadd/node_list.html +++ b/admin/templates/rdm_timestampadd/node_list.html @@ -125,6 +125,7 @@

    {% blocktrans with institutionName=institution.name %}List of Nodes for {{ i {% trans "Withdrawn" %} {% trans "Embargo" %} {% trans "Contributors" %} + {% trans "Groups" %} @@ -184,6 +185,15 @@

    {% blocktrans with institutionName=institution.name %}List of Nodes for {{ i {{ user.username }}{% if not forloop.last %}, {% endif %} {% endfor %} + + {% if not node.mapcore_groups %} + {{ None|transValue }} + {% else %} + {% for mapcore_group in node.mapcore_groups %} + {{ mapcore_group.display_name }}{% if not forloop.last %}, {% endif %} + {% endfor %} + {% endif %} + {% endfor %} diff --git a/admin_tests/rdm_timestampadd/test_views.py b/admin_tests/rdm_timestampadd/test_views.py index 2360b8fc876..c8c24c570fc 100644 --- a/admin_tests/rdm_timestampadd/test_views.py +++ b/admin_tests/rdm_timestampadd/test_views.py @@ -12,7 +12,7 @@ from django.test import RequestFactory from django.core.urlresolvers import reverse from nose import tools as nt -from osf.models import RdmUserKey, RdmFileTimestamptokenVerifyResult, Guid, BaseFileNode +from osf.models import RdmUserKey, RdmFileTimestamptokenVerifyResult, Guid, BaseFileNode, MapCoreGroup from osf_tests.factories import UserFactory, AuthUserFactory, InstitutionFactory, ProjectFactory from tests.base import AdminTestCase from tests.test_timestamp import create_test_file, create_rdmfiletimestamptokenverifyresult @@ -337,6 +337,7 @@ def mock_node(): node.embargo = None node.created = datetime(2023, 1, 1) node.contributor_names = 'user1, user2' + node.mapcore_groups = [MapCoreGroup(_id='group1'), MapCoreGroup(_id='group2')] return node @@ -433,7 +434,7 @@ def test_csv_generation_with_complete_data(self, view_instance, mock_request, mo # Check header row assert rows[0] == ['Node id', 'GUID', 'Title', 'Parent', 'Root', 'Date created', 'Public', 'Withdrawn', 'Embargo', - 'Contributors'] + 'Contributors', 'Groups'] # Check data row assert rows[1][0] == '1' # Node id @@ -442,6 +443,7 @@ def test_csv_generation_with_complete_data(self, view_instance, mock_request, mo assert rows[1][3] == 'Parent Node' # Parent assert rows[1][4] == 'Root Node' # Root assert rows[1][5] == '2023-01-01' # Date created + assert rows[1][10] == 'group1, group2' # Groups def test_csv_generation_with_minimal_data(self, view_instance, mock_request): """Test CSV generation with minimal node data""" @@ -456,6 +458,7 @@ def test_csv_generation_with_minimal_data(self, view_instance, mock_request): minimal_node.embargo = None minimal_node.created = None minimal_node.contributor_names = None + minimal_node.mapcore_groups = [] with mock.patch.object(view_instance, 'get_queryset') as mock_get_queryset: mock_get_queryset.return_value.all.return_value = [minimal_node] @@ -500,7 +503,7 @@ def test_csv_generation_with_empty_queryset(self, view_instance, mock_request): assert len(rows) == 1 assert rows[0] == ['Node id', 'GUID', 'Title', 'Parent', 'Root', 'Date created', 'Public', 'Withdrawn', 'Embargo', - 'Contributors'] + 'Contributors', 'Groups'] def test_filename_format(self, view_instance, mock_request, mock_node): """Test generated filename format""" @@ -522,7 +525,8 @@ def test_filename_format(self, view_instance, mock_request, mock_node): ('retraction', False), ('embargo', None), ('created', datetime(2023, 1, 1)), - ('contributor_names', 'user1, user2') + ('contributor_names', 'user1, user2'), + ('mapcore_groups', [MapCoreGroup(_id='group1'), MapCoreGroup(_id='group2')]), ]) def test_specific_node_attributes(self, view_instance, mock_request, mock_node, node_attribute, expected_value): @@ -542,3 +546,5 @@ def test_specific_node_attributes(self, view_instance, mock_request, mock_node, assert rows[1][5] == '2023-01-01' elif node_attribute == 'contributor_names': assert rows[1][9] == expected_value + elif node_attribute == 'mapcore_groups': + assert rows[1][10] == ', '.join([group.display_name for group in expected_value]) diff --git a/api/base/settings/defaults.py b/api/base/settings/defaults.py index 273e9a2db44..ee28a3d8e9e 100644 --- a/api/base/settings/defaults.py +++ b/api/base/settings/defaults.py @@ -128,6 +128,7 @@ 'addons.metadata', 'addons.workflow', 'addons.onlyoffice', + 'addons.groups', ) # local development using https diff --git a/api/nodes/views.py b/api/nodes/views.py index 72d55db03a1..5f2dae9486d 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -2406,6 +2406,9 @@ def get_serializer_class(self): # overrides ListBulkCreateJSON APIView, BulkUpdateJSONAPIView def get_queryset(self): node = self.get_node() + enabled_mapcore_groups = node.mapcore_groups_addon_enabled() + if not enabled_mapcore_groups: + return MapCoreNodeGroup.objects.none() qs = MapCoreNodeGroup.objects.filter(node=node, is_deleted=False) # Avoid N+1 on foreign-key relations reported by nplusone qs = qs.select_related('creator', 'mapcore_group') @@ -2427,8 +2430,6 @@ def get_queryset(self): for obj in qs: obj.permissions = perm_map.get(obj.group_id, []) - # If any related fields are reverse or many-to-many, use prefetch_related: - # qs = qs.prefetch_related('some_m2m_field') return qs # Overrides BulkDestroyJSONAPIView diff --git a/api/users/serializers.py b/api/users/serializers.py index ba390371d2c..1350f0ff71a 100644 --- a/api/users/serializers.py +++ b/api/users/serializers.py @@ -674,6 +674,9 @@ class UserNodeSerializer(NodeSerializer): def get_mapcore_groups(self, obj): if isinstance(obj, Node): + enabled_mapcore_groups = obj.mapcore_groups_addon_enabled() + if not enabled_mapcore_groups: + return [] node_groups = MapCoreNodeGroup.objects.filter(node=obj, is_deleted=False).select_related('mapcore_group') return [group.mapcore_group._id for group in node_groups] return [] diff --git a/api_tests/nodes/views/test_node_mapcore_group_views.py b/api_tests/nodes/views/test_node_mapcore_group_views.py index 23effc4d69e..7525906a029 100644 --- a/api_tests/nodes/views/test_node_mapcore_group_views.py +++ b/api_tests/nodes/views/test_node_mapcore_group_views.py @@ -7,6 +7,8 @@ from osf.models.mapcore_user_group import MapCoreUserGroup from osf_tests.factories import AuthUserFactory, NodeFactory, ProjectFactory, UserFactory from tests.base import ApiTestCase +from framework.auth import Auth + @pytest.mark.django_db class TestNodeMapCoreGroupList(ApiTestCase): @@ -21,6 +23,7 @@ def setUp(self): self.node = ProjectFactory(creator=self.admin_user, is_public=False) self.node.add_contributor(self.user, permissions='admin') self.node.add_contributor(self.read_only_user, permissions='read') + self.node.add_addon('groups', auth=Auth(self.user)) # Enable groups addon self.node.save() # Create auth groups for the node @@ -764,8 +767,9 @@ def test_has_permission_mapcore_grants_and_denies(self): from osf.models.node import NodeGroupObjectPermission node = ProjectFactory() - # user that will be "in" the mapcore group mapcore_user = UserFactory() + node.add_addon('groups', auth=Auth(mapcore_user)) # Enable groups addon + # user that will be "in" the mapcore group # other user not in group other_user = UserFactory() @@ -794,7 +798,9 @@ def test_has_permission_by_is_admin_group_parent(self): # Create a root node and a child node root = ProjectFactory() + root.add_addon('groups', auth=Auth(root.creator)) # Enable groups addon on root child = NodeFactory(creator=root.creator, parent=root) + child.add_addon('groups', auth=Auth(root.creator)) # Enable groups addon on child # Users mapcore_user = UserFactory() @@ -841,6 +847,7 @@ def test_get_permissions_mapcore_includes_and_excludes(self): from osf.models.node import NodeGroupObjectPermission node = ProjectFactory() + node.add_addon('groups', auth=Auth(node.creator)) # Enable groups addon mapcore_user = UserFactory() other_user = UserFactory() @@ -888,3 +895,190 @@ def test_is_admin_group_parent_handles_mapcore_node_group_filter_exception(self) with mock.patch('osf.models.mapcore_node_group.MapCoreNodeGroup.objects.filter', side_effect=Exception('boom')): assert is_admin_group_parent(parent, user_mapcore_group_ids) is False + + def test_has_permission_mapcore_groups_addon_disabled(self): + """When groups addon is disabled, MapCore permission logic is skipped and user is denied.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + + node = ProjectFactory() + mapcore_user = UserFactory() + + # Ensure groups addon is disabled + if node.has_addon('groups'): + node.delete_addon('groups', auth=Auth(mapcore_user)) + assert not node.has_addon('groups') + + mg = MapCoreGroup.objects.create(_id='mcg-addon-disabled-hasperm') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + # Without groups addon, MapCore permissions are not applied + assert node.has_permission(mapcore_user, 'read') is False + + def test_has_permission_mapcore_groups_addon_enabled(self): + """When groups addon is enabled, MapCore permission logic grants access.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + node = ProjectFactory() + mapcore_user = UserFactory() + node.add_addon('groups', auth=Auth(mapcore_user)) # Enable groups addon + + mg = MapCoreGroup.objects.create(_id='mcg-addon-enabled-hasperm') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + # With groups addon enabled, MapCore permissions ARE applied + assert node.has_permission(mapcore_user, 'read') is True + + def test_get_permissions_mapcore_groups_addon_disabled(self): + """When groups addon is disabled, MapCore-derived permissions are not included.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + + node = ProjectFactory() + mapcore_user = UserFactory() + + # groups addon is NOT enabled + # Ensure groups addon is disabled + if node.has_addon('groups'): + node.delete_addon('groups', auth=Auth(mapcore_user)) + assert not node.has_addon('groups') + + mg = MapCoreGroup.objects.create(_id='mcg-addon-disabled-getperms') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + # Without groups addon, MapCore-derived permissions are not included + perms = node.get_permissions(mapcore_user) + assert 'read' not in perms + + def test_get_permissions_mapcore_groups_addon_enabled(self): + """When groups addon is enabled, MapCore-derived permissions are included.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + + node = ProjectFactory() + mapcore_user = UserFactory() + node.add_addon('groups', auth=Auth(mapcore_user)) # Enable groups addon + + mg = MapCoreGroup.objects.create(_id='mcg-addon-enabled-getperms') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + # With groups addon enabled, MapCore-derived permissions ARE included + perms = node.get_permissions(mapcore_user) + assert 'read' in perms + + def test_has_permission_parent_admin_via_mapcore_groups_addon_disabled(self): + """When groups addon is disabled, parent admin via MapCore does NOT grant child read.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + + root = ProjectFactory() + root.add_addon('groups', auth=Auth(root.creator)) # Enable groups addon on root + child = NodeFactory(creator=root.creator, parent=root) + mapcore_user = UserFactory() + + # groups addon NOT enabled on child + if child.has_addon('groups'): + child.delete_addon('groups', auth=Auth(mapcore_user)) + assert not child.has_addon('groups') + + mg = MapCoreGroup.objects.create(_id='mcg-parent-admin-disabled') + ag = AuthGroup.objects.create(name=f'node_{root._id}_admin') + + MapCoreNodeGroup.objects.create(node=root, group=ag, mapcore_group=mg, creator=root.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='admin_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=root) + + # Without groups addon on child, is_admin_group_parent path is not taken + assert child.has_permission(mapcore_user, 'read') is True + + def test_has_permission_parent_admin_via_mapcore_groups_addon_enabled(self): + """When groups addon is enabled, parent admin via MapCore grants child read.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + + root = ProjectFactory() + child = NodeFactory(creator=root.creator, parent=root) # Enable groups addon on child + mapcore_user = UserFactory() + child.add_addon('groups', auth=Auth(mapcore_user)) + + mg = MapCoreGroup.objects.create(_id='mcg-parent-admin-enabled') + ag = AuthGroup.objects.create(name=f'node_{root._id}_admin') + + MapCoreNodeGroup.objects.create(node=root, group=ag, mapcore_group=mg, creator=root.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='admin_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=root) + + # With groups addon enabled on child, is_admin_group_parent path grants read + assert child.has_permission(mapcore_user, 'read') is True + + def test_has_permission_mapcore_groups_addon_deleted_acts_as_disabled(self): + """A deleted (soft-removed) groups addon is treated as disabled.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + + node = ProjectFactory() + mapcore_user = UserFactory() + node.add_addon('groups', auth=Auth(mapcore_user)) + node.delete_addon('groups', auth=Auth(mapcore_user)) # Soft-delete the addon + + mg = MapCoreGroup.objects.create(_id='mcg-addon-deleted-hasperm') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + # Deleted addon is not returned by get_addons, so MapCore logic is skipped + assert node.has_permission(mapcore_user, 'read') is False + + def test_get_permissions_mapcore_groups_addon_deleted_acts_as_disabled(self): + """A deleted (soft-removed) groups addon means MapCore permissions are not included.""" + from django.contrib.auth.models import Permission + from osf.models.node import NodeGroupObjectPermission + + node = ProjectFactory() + mapcore_user = UserFactory() + node.add_addon('groups', auth=Auth(mapcore_user)) + node.delete_addon('groups', auth=Auth(mapcore_user)) # Soft-delete the addon + + mg = MapCoreGroup.objects.create(_id='mcg-addon-deleted-getperms') + ag = AuthGroup.objects.create(name=f'node_{node._id}_read') + + MapCoreNodeGroup.objects.create(node=node, group=ag, mapcore_group=mg, creator=node.creator) + MapCoreUserGroup.objects.create(user=mapcore_user, mapcore_group=mg, is_deleted=False) + + perm = Permission.objects.get(codename='read_node') + NodeGroupObjectPermission.objects.create(group=ag, permission=perm, content_object=node) + + perms = node.get_permissions(mapcore_user) + assert 'read' not in perms diff --git a/api_tests/users/serializers/test_serializers.py b/api_tests/users/serializers/test_serializers.py index 26395cc2666..27efa608f7e 100644 --- a/api_tests/users/serializers/test_serializers.py +++ b/api_tests/users/serializers/test_serializers.py @@ -17,6 +17,7 @@ from django.urls import resolve, reverse from osf.models import QuickFilesNode +from framework.auth.core import Auth @pytest.fixture() def user(): @@ -263,6 +264,7 @@ def test_get_mapcore_groups(self, user): from osf_tests.factories import ProjectFactory node = ProjectFactory(creator=user) + node.add_addon('groups', auth=Auth(node.creator)) # Enable groups addon # Create two MapCoreGroup records g1 = MapCoreGroup.objects.create(_id='group-one') diff --git a/framework/addons/data/addons.json b/framework/addons/data/addons.json index 6de9e53f3ec..b813f934e77 100644 --- a/framework/addons/data/addons.json +++ b/framework/addons/data/addons.json @@ -738,6 +738,36 @@ "status": "partial", "text": "Workflow template metadata is captured when creating a template, but live workflow tasks are not executed inside the template." } + }, + "Groups": { + "Permissions": { + "status": "none", + "text": "The GakuNin RDM does not affect the permissions of Groups." + }, + "View / download file versions": { + "status": "none", + "text": "The Groups add-on does not provide Storage Features." + }, + "Add / update files": { + "status": "none", + "text": "The Groups add-on does not provide Storage Features." + }, + "Delete files": { + "status": "none", + "text": "The Groups add-on does not provide Storage Features." + }, + "Logs": { + "status": "none", + "text": "The Groups add-on does not provide Storage Features." + }, + "Forking": { + "status": "none", + "text": "Forking a project or component does not copy Groups authorization." + }, + "Registering": { + "status": "none", + "text": "Groups information will not be registered." + } } }, "disclaimers": [ diff --git a/osf/models/__init__.py b/osf/models/__init__.py index d82b6d44124..c86fc3973ad 100644 --- a/osf/models/__init__.py +++ b/osf/models/__init__.py @@ -72,3 +72,4 @@ from osf.models.project_limit_number_template_attribute import ProjectLimitNumberTemplateAttribute # noqa from osf.models.project_limit_number_setting import ProjectLimitNumberSetting # noqa from osf.models.project_limit_number_setting_attribute import ProjectLimitNumberSettingAttribute # noqa +from osf.models.mapcore_group import MapCoreGroup # noqa diff --git a/osf/models/mapcore_group.py b/osf/models/mapcore_group.py index 0c16c48f04e..8d0cf344154 100644 --- a/osf/models/mapcore_group.py +++ b/osf/models/mapcore_group.py @@ -12,3 +12,7 @@ class Meta: @property def absolute_url(self): return f'{MAPCORE_GROUP_HOSTNAME}{MAPCORE_GROUP_API_PATH}{self._id}/' + + @property + def display_name(self): + return self._id diff --git a/osf/models/mixins.py b/osf/models/mixins.py index 1179f2c7efb..193005e8a12 100644 --- a/osf/models/mixins.py +++ b/osf/models/mixins.py @@ -628,6 +628,9 @@ def _settings_model(self, addon_model, config=None): config = apps.get_app_config('addons_{}'.format(addon_model)) return getattr(config, '{}_settings'.format(self.settings_type)) + def mapcore_groups_addon_enabled(self): + return self.has_addon('groups') + class NodeLinkMixin(models.Model): @@ -1941,8 +1944,12 @@ def has_permission(self, user, permission, check_parent=True): """ object_type = self.guardian_object_type group_perm = [] + enabled_groups = False + if hasattr(self, 'mapcore_groups_addon_enabled'): + enabled_groups = self.mapcore_groups_addon_enabled() + # Also check Auth Groups linked via MapCoreNodeGroup (by auth_group id) - if object_type == 'node': + if object_type == 'node' and enabled_groups: try: user_mapcore_group_ids = MapCoreUserGroup.objects.filter(user=user, is_deleted=False).values_list('mapcore_group_id', flat=True) # get auth group ids linked to this object @@ -1968,7 +1975,7 @@ def has_permission(self, user, permission, check_parent=True): has_permission = perm in get_group_perms(user, self) if object_type == 'node': if not has_permission and permission == READ and check_parent: - if is_admin_group_parent(self.root, user_mapcore_group_ids): + if enabled_groups and is_admin_group_parent(self.root, user_mapcore_group_ids): return True else: return self.is_admin_parent(user) @@ -1995,22 +2002,27 @@ def get_permissions(self, user): # Overrides guardian mixin - returns readable perms instead of literal perms if isinstance(user, AnonymousUser): return [] - - try: - user_mapcore_group_ids = MapCoreUserGroup.objects.filter(user=user, is_deleted=False).values_list('mapcore_group_id', flat=True) - # get auth group ids linked to this object - auth_group_ids = MapCoreNodeGroup.objects.filter(node=self, mapcore_group_id__in=user_mapcore_group_ids, is_deleted=False).values_list('group_id', flat=True) - except Exception: - auth_group_ids = [] + enabled_groups = False + if hasattr(self, 'mapcore_groups_addon_enabled'): + enabled_groups = self.mapcore_groups_addon_enabled() group_perms = [] - if auth_group_ids: - # Try OSF-specific node-group-permission model(s), then fallback to guardian's GroupObjectPermission - NodeGroupPermModel = apps.get_model('osf', 'NodeGroupObjectPermission') - for gid in auth_group_ids: - perms_qs = NodeGroupPermModel.objects.filter(group_id=gid, content_object_id=self.id) - for perm in list(perms_qs.values_list('permission__codename', flat=True)): - if perm not in group_perms: - group_perms.append(perm) + # Also check Auth Groups linked via MapCoreNodeGroup (by auth_group id) if node and groups addon enabled + if enabled_groups: + try: + user_mapcore_group_ids = MapCoreUserGroup.objects.filter(user=user, is_deleted=False).values_list('mapcore_group_id', flat=True) + # get auth group ids linked to this object + auth_group_ids = MapCoreNodeGroup.objects.filter(node=self, mapcore_group_id__in=user_mapcore_group_ids, is_deleted=False).values_list('group_id', flat=True) + except Exception: + auth_group_ids = [] + + if auth_group_ids: + # Try OSF-specific node-group-permission model(s), then fallback to guardian's GroupObjectPermission + NodeGroupPermModel = apps.get_model('osf', 'NodeGroupObjectPermission') + for gid in auth_group_ids: + perms_qs = NodeGroupPermModel.objects.filter(group_id=gid, content_object_id=self.id) + for perm in list(perms_qs.values_list('permission__codename', flat=True)): + if perm not in group_perms: + group_perms.append(perm) # If base_perms not on model, will error perms = self.base_perms diff --git a/osf/models/node.py b/osf/models/node.py index 5628284a02f..eb1321d1adb 100644 --- a/osf/models/node.py +++ b/osf/models/node.py @@ -5,6 +5,7 @@ import re from future.moves.urllib.parse import urljoin import warnings +from osf.models.mapcore_group import MapCoreGroup from osf.models.mapcore_node_group import MapCoreNodeGroup from osf.models.mapcore_user_group import MapCoreUserGroup from rest_framework import status as http_status @@ -2551,6 +2552,12 @@ def guid(self): guid = self.guids.first() return guid._id if guid else guid + @property + def mapcore_groups(self): + if not self.has_addon('groups'): + return MapCoreGroup.objects.none() + return MapCoreGroup.objects.filter(mapcore_group_nodes__node=self, mapcore_group_nodes__is_deleted=False) + def remove_addons(auth, resource_object_list): for config in AbstractNode.ADDONS_AVAILABLE: try: diff --git a/tests/test_node_groups_view.py b/tests/test_node_groups_view.py index d79f4624754..1295f077cc5 100644 --- a/tests/test_node_groups_view.py +++ b/tests/test_node_groups_view.py @@ -38,6 +38,7 @@ def test_node_groups_returns_expected_keys_when_permitted(self): """ with self.context: node = ProjectFactory(is_public=False) + node.add_addon('groups', auth=Auth(node.creator)) # Enable groups addon creator = node.creator # Create a user and grant READ permission user = UserFactory() diff --git a/website/project/views/node.py b/website/project/views/node.py index 600de7e4fce..6fa4cb87d6b 100644 --- a/website/project/views/node.py +++ b/website/project/views/node.py @@ -40,6 +40,7 @@ must_have_permission, must_not_be_registration, must_not_be_retracted_registration, + must_have_addon, ) from osf.utils.tokens import process_token_or_pass from website.util.rubeus import collect_addon_js @@ -538,11 +539,13 @@ def node_contributors(auth, node, **kwargs): @must_not_be_retracted_registration @must_have_permission(READ) @ember_flag_is_active(features.EMBER_PROJECT_CONTRIBUTORS) +@must_have_addon('groups', 'node') def node_groups(auth, node, **kwargs): ret = _view_project(node, auth, primary=True) ret['groups'] = utils.serialize_mapcore_node_groups(node) current_group = [group['mapcore_group']['id'] for group in ret['groups']] ret['adminGroups'] = utils.serialize_parent_admin_groups(node, current_group) + ret['baseUrl'] = settings.MAPCORE_GROUP_HOSTNAME return ret @must_have_permission(ADMIN) @@ -918,6 +921,9 @@ def _view_project(node, auth, primary=False, is_registration = node.is_registration timestamp_pattern = get_timestamp_pattern_division(auth, node) mapcore_groups = utils.serialize_mapcore_node_groups(node, visible_only=True) + enabled_mapcore_groups = False + if hasattr(node, 'mapcore_groups_addon_enabled'): + enabled_mapcore_groups = node.mapcore_groups_addon_enabled() data = { 'node': { 'disapproval_link': disapproval_link, @@ -989,6 +995,7 @@ def _view_project(node, auth, primary=False, 'mfr_url': node.osfstorage_region.mfr_url, 'groups': list(node.osf_groups.values_list('name', flat=True)), 'mapcore_groups': mapcore_groups, + 'enabled_mapcore_groups': enabled_mapcore_groups, }, 'parent_node': { 'exists': parent is not None, diff --git a/website/static/css/pages/contributor-page.css b/website/static/css/pages/contributor-page.css index cc08f74708f..2c1435033e6 100644 --- a/website/static/css/pages/contributor-page.css +++ b/website/static/css/pages/contributor-page.css @@ -160,3 +160,15 @@ th.remove { font-size: 1.5em; } } + +#groupsNotes { + padding-top: 20px; + padding-bottom: 20px; +} + +#groupsNotes h3 { + color: red; + padding: 0; + margin: 0; + font-weight: 500; +} diff --git a/website/static/js/groupsManager.js b/website/static/js/groupsManager.js index 08c340b0b57..93881c5f71f 100644 --- a/website/static/js/groupsManager.js +++ b/website/static/js/groupsManager.js @@ -212,7 +212,7 @@ var MessageModel = function(text, level) { }; -var GroupsViewModel = function(groups, adminGroups, user, isRegistration, table, adminTable, groupShouter, pageChangedShouter) { +var GroupsViewModel = function(groups, adminGroups, user, isRegistration, table, adminTable, groupShouter, pageChangedShouter, baseUrl) { var self = this; @@ -228,6 +228,7 @@ var GroupsViewModel = function(groups, adminGroups, user, isRegistration, table, self.permissionList = Object.keys(self.permissionMap); self.groupToRemove = ko.observable(''); + self.baseUrl = baseUrl; self.groups = ko.observableArray(); self.adminGroups = ko.observableArray(); @@ -473,7 +474,7 @@ var GroupsViewModel = function(groups, adminGroups, user, isRegistration, table, // Public API // //////////////// -function GroupManager(selector, groups, adminGroups, user, isRegistration, table, adminTable) { +function GroupManager(selector, groups, adminGroups, user, isRegistration, table, adminTable, baseUrl) { var self = this; //shouter allows communication between GroupManager and GroupsRemover, in particular which group needs to // be removed is passed to GroupsRemover @@ -483,7 +484,8 @@ function GroupManager(selector, groups, adminGroups, user, isRegistration, table self.$element = $(selector); self.groups = groups; self.adminGroups = adminGroups; - self.viewModel = new GroupsViewModel(groups, adminGroups, user, isRegistration, table, adminTable, groupShouter, pageChangedShouter); + self.baseUrl = baseUrl; + self.viewModel = new GroupsViewModel(groups, adminGroups, user, isRegistration, table, adminTable, groupShouter, pageChangedShouter, baseUrl); $('body').on('nodeLoad', function(event, data) { // If user is a group, initialize the group modal // controller diff --git a/website/static/js/pages/sharing-page.js b/website/static/js/pages/sharing-page.js index 2a88383676c..5e1b1db8389 100644 --- a/website/static/js/pages/sharing-page.js +++ b/website/static/js/pages/sharing-page.js @@ -28,7 +28,7 @@ if (isContribPage) { if (isGroupPage) { // cm = new ContribManager('#manageContributors', ctx.contributors, ctx.adminContributors, ctx.currentUser, ctx.isRegistration, '#manageContributorsTable', '#adminContributorsTable'); - gm = new GroupManager('#manageGroups', ctx.groups,ctx.adminGroups, ctx.currentUser, ctx.isRegistration, '#manageGroupsTable', '#adminGroupsTable'); + gm = new GroupManager('#manageGroups', ctx.groups, ctx.adminGroups, ctx.currentUser, ctx.isRegistration, '#manageGroupsTable', '#adminGroupsTable', ctx.baseUrl); } if (hasAccessRequests) { diff --git a/website/templates/project/groups.mako b/website/templates/project/groups.mako index 38f76bfa780..f417300aaeb 100644 --- a/website/templates/project/groups.mako +++ b/website/templates/project/groups.mako @@ -51,6 +51,10 @@
    + % if node['enabled_mapcore_groups']:
    % if user['is_contributor_or_group_member']: ${_("Groups")}: @@ -185,11 +186,16 @@ % if node['anonymous']:
      ${_("Anonymous Groups")}
    % else: + % if node['mapcore_groups'] != []:
      ${group_list.render_groups_full(groups=node['mapcore_groups'])}
    + % else: + ${_("None")} + % endif % endif
    + % endif % if node['groups']:
    Groups: diff --git a/website/templates/project/project_header.mako b/website/templates/project/project_header.mako index 7e64bacea96..c77f95498df 100644 --- a/website/templates/project/project_header.mako +++ b/website/templates/project/project_header.mako @@ -47,7 +47,7 @@ % for addon in addons_enabled: - % if addon not in ['binderhub', 'metadata', 'workflow'] and addons[addon]['has_page']: + % if addon not in ['binderhub', 'metadata', 'workflow', 'groups'] and addons[addon]['has_page']:
  • @@ -118,8 +118,7 @@ % if user['is_contributor_or_group_member']:
  • ${_("Contributors")}
  • % endif - - % if user['is_contributor_or_group_member']: + % if 'groups' in addons_enabled and addons['groups']['has_page']:
  • ${_("Groups")}
  • % endif diff --git a/website/templates/util/group_list.mako b/website/templates/util/group_list.mako index c79bbb7a82e..3d5e5a5c13e 100644 --- a/website/templates/util/group_list.mako +++ b/website/templates/util/group_list.mako @@ -11,7 +11,7 @@ ${render_group_dict(group) if isinstance(group, dict) else render_user_obj(group)} % endfor % if others_count: - ${_("%(othersCount)s more") % dict(othersCount=others_count)} + ${_("%(groupOthersCount)s more") % dict(groupOthersCount=others_count)} % endif diff --git a/website/templates/util/render_node.mako b/website/templates/util/render_node.mako index cea10f148f0..b7611043764 100644 --- a/website/templates/util/render_node.mako +++ b/website/templates/util/render_node.mako @@ -101,9 +101,11 @@
    ${contributor_list.render_contributors(contributors=summary['contributors'], others_count=summary['others_count'], node_url=summary['url'])}
    + % if summary['enabled_mapcore_groups']:
    ${group_list.render_groups(groups=summary['mapcore_groups'], others_count=summary['mapcore_groups_others_count'], node_url=summary['url'])}
    + % endif % if summary['groups']:
    ${summary['groups']} diff --git a/website/translations/en/LC_MESSAGES/js_messages.po b/website/translations/en/LC_MESSAGES/js_messages.po index 08b21592b4c..14777ec64c1 100644 --- a/website/translations/en/LC_MESSAGES/js_messages.po +++ b/website/translations/en/LC_MESSAGES/js_messages.po @@ -9358,3 +9358,117 @@ msgid "" "${user} made bibliographic group ${mapcore_groups} a " "non-bibliographic group on ${node}" msgstr "" + +msgid "" +"\n" +"\n" +"

    Groups Add-on Terms

    \n" +"\n" +"\n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"\n" +"
    FunctionStatus
    PermissionsThe GakuNin RDM does not affect the permissions of " +"Groups.
    View / download file versionsThe Groups add-on does not provide Storage " +"Features.
    Add / update filesThe Groups add-on does not provide Storage " +"Features.
    Delete filesThe Groups add-on does not provide Storage " +"Features.
    LogsThe Groups add-on does not provide Storage " +"Features.
    ForkingForking a project or component does not copy Groups " +"authorization.
    \n" +"\n" +"
      \n" +"
    • This add-on connects your GakuNin RDM project to an external " +"service. Use of this service is bound by its terms and conditions. The " +"GakuNin RDM is not responsible for the service or for your use " +"thereof.
    • \n" +"
    • This add-on allows you to store files using an external " +"service. Files added to this add-on are not stored within the GakuNin " +"RDM.
    • \n" +"
    \n" +msgstr "" +"\n" +"\n" +"

    Groups Add-on Terms

    \n" +"\n" +"\n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"\n" +"
    FunctionStatus
    PermissionsThe GakuNin RDM does not affect the permissions of " +"Groups.
    View / download file versionsThe Groups add-on does not provide Storage " +"Features.
    Add / update filesThe Groups add-on does not provide Storage " +"Features.
    Delete filesThe Groups add-on does not provide Storage " +"Features.
    LogsThe Groups add-on does not provide Storage " +"Features.
    ForkingForking a project or component does not copy Groups " +"authorization.
    \n" +"\n" +"
      \n" +"
    • This add-on connects your GakuNin RDM project to an external " +"service. Use of this service is bound by its terms and conditions. The " +"GakuNin RDM is not responsible for the service or for your use " +"thereof.
    • \n" +"
    \n" diff --git a/website/translations/en/LC_MESSAGES/messages.po b/website/translations/en/LC_MESSAGES/messages.po index 216d3050ae6..12923b8b59f 100644 --- a/website/translations/en/LC_MESSAGES/messages.po +++ b/website/translations/en/LC_MESSAGES/messages.po @@ -4160,3 +4160,12 @@ msgstr "" msgid "Bibliographic Group Information" msgstr "" + +msgid "※ Group members can be edited using the GakuNin mAP {baseUrl}." +msgstr "" + +msgid "If a group member currently logged into GakuNin RDM is deleted on the mAP, they will not be removed from the project until they log out." +msgstr "" + +msgid "%(groupOthersCount)s more" +msgstr "" diff --git a/website/translations/ja/LC_MESSAGES/js_messages.po b/website/translations/ja/LC_MESSAGES/js_messages.po index a59c330b1ca..0f6302fb378 100644 --- a/website/translations/ja/LC_MESSAGES/js_messages.po +++ b/website/translations/ja/LC_MESSAGES/js_messages.po @@ -10910,3 +10910,111 @@ msgstr "" "
  • このアドオンにより、GakuNin RDMプロジェクトは外部サービスに接続されます。このサービスを利用すると、外部サービスの利用規約に拘束されます。GakuNin RDMはサービスおよびその利用について責任を負いません。
  • \n" "
  • このアドオンを利用すると外部サービスにファイルを保存できます。このアドオンに追加したファイルはGakuNin RDM内には保存されません。
  • \n" "\n" + +msgid "" +"\n" +"\n" +"

    Groups Add-on Terms

    \n" +"\n" +"\n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"\n" +"
    FunctionStatus
    PermissionsThe GakuNin RDM does not affect the permissions of " +"Groups.
    View / download file versionsThe Groups add-on does not provide Storage " +"Features.
    Add / update filesThe Groups add-on does not provide Storage " +"Features.
    Delete filesThe Groups add-on does not provide Storage " +"Features.
    LogsThe Groups add-on does not provide Storage " +"Features.
    ForkingForking a project or component does not copy Groups " +"authorization.
    \n" +"\n" +"
      \n" +"
    • This add-on connects your GakuNin RDM project to an external " +"service. Use of this service is bound by its terms and conditions. The " +"GakuNin RDM is not responsible for the service or for your use " +"thereof.
    • \n" +"
    • This add-on allows you to store files using an external " +"service. Files added to this add-on are not stored within the GakuNin " +"RDM.
    • \n" +"
    \n" +msgstr "" +"\n" +"\n" +"

    Groups アドオン規約

    \n" +"\n" +"\n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +"\n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" \n" +" " +"\n" +" \n" +" \n" +"\n" +"
    機能ステータス
    権限GroupsアドオンはGRDMの権限に影響を及ぼしません。
    ファイルバージョンの閲覧/ダウンロードGroupsアドオンはストレージ機能を提供しません。
    ファイルの追加/更新Groupsアドオンはストレージ機能を提供しません。
    ファイルの削除Groupsアドオンはストレージ機能を提供しません。
    ログGroupsアドオンはストレージ機能を提供しません。
    フォークプロジェクトやコンポーネントをフォークしても、Groupsの権限はコピーされません。
    \n" +"\n" +"
      \n" +"
    • このアドオンにより、GakuNin " +"RDMプロジェクトは外部サービスに接続されます。このサービスを利用することで、それら外部サービスの利用規約に拘束されます。GakuNin " +"RDMは、それらサービスまたはユーザーによるその利用に対して責任を負いません。
    • \n" +"
    \n" diff --git a/website/translations/ja/LC_MESSAGES/messages.po b/website/translations/ja/LC_MESSAGES/messages.po index 15777d1e557..791d16e6e34 100644 --- a/website/translations/ja/LC_MESSAGES/messages.po +++ b/website/translations/ja/LC_MESSAGES/messages.po @@ -5150,3 +5150,12 @@ msgstr "ワークフローテンプレートを更新しました。" #: addons/workflow/static/workflowNodeConfig.js:876 msgid "Failed to update workflow template." msgstr "ワークフローテンプレートの更新に失敗しました。" + +msgid "※ Group members can be edited using the GakuNin mAP {baseUrl}." +msgstr "※グループのメンバー編集は、学認mAP機能 {baseUrl} で実施します。" + +msgid "If a group member currently logged into GakuNin RDM is deleted on the mAP, they will not be removed from the project until they log out." +msgstr "GakuNin RDMログイン中のグループメンバーをmAP機能上で削除する場合、当該ユーザがログアウトするまでプロジェクトからは削除されません。" + +msgid "%(groupOthersCount)s more" +msgstr "あと%(groupOthersCount)sグループ" diff --git a/website/translations/messages.pot b/website/translations/messages.pot index e358e12b68b..aad15ea22cd 100644 --- a/website/translations/messages.pot +++ b/website/translations/messages.pot @@ -4423,3 +4423,12 @@ msgstr "" msgid "Bibliographic Group Information" msgstr "" + +msgid "※ Group members can be edited using the GakuNin mAP {baseUrl}." +msgstr "" + +msgid "If a group member currently logged into GakuNin RDM is deleted on the mAP, they will not be removed from the project until they log out." +msgstr "" + +msgid "%(groupOthersCount)s more" +msgstr "" diff --git a/website/views.py b/website/views.py index e8a041d8450..dde5e5383f8 100644 --- a/website/views.py +++ b/website/views.py @@ -141,6 +141,9 @@ def serialize_node_summary(node, auth, primary=True, show_path=False): if node.can_view(auth): contributor_data = serialize_contributors_for_summary(node) mapcore_group_data = serialize_mapcore_group_for_summary(node) + enabled_mapcore_groups = False + if hasattr(node, 'mapcore_groups_addon_enabled'): + enabled_mapcore_groups = node.mapcore_groups_addon_enabled() summary.update({ 'can_view': True, 'can_edit': node.can_edit(auth), @@ -178,6 +181,7 @@ def serialize_node_summary(node, auth, primary=True, show_path=False): 'groups': serialize_groups_for_summary(node), 'mapcore_groups': mapcore_group_data['mapcore_groups'], 'mapcore_groups_others_count': mapcore_group_data['mapcore_groups_others_count'], + 'enabled_mapcore_groups': enabled_mapcore_groups, 'description': node.description if len(node.description) <= 150 else node.description[0:150] + '...', }) else: From 7fd8a79d9976e2cb0e0e3bdcfeb91bb2a05b2530 Mon Sep 17 00:00:00 2001 From: hcphat Date: Wed, 25 Mar 2026 17:05:40 +0700 Subject: [PATCH 08/38] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20Resolve=20migrations=20conflict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- osf/migrations/0265_merge_20260325_0957.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 osf/migrations/0265_merge_20260325_0957.py diff --git a/osf/migrations/0265_merge_20260325_0957.py b/osf/migrations/0265_merge_20260325_0957.py new file mode 100644 index 00000000000..bf3ab4f00f6 --- /dev/null +++ b/osf/migrations/0265_merge_20260325_0957.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2026-03-25 09:57 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0264_merge_20260223_0712'), + ('osf', '0264_merge_20260218_0749'), + ] + + operations = [ + ] From d07cb4d1010beca4da165361956c52913db7c6a8 Mon Sep 17 00:00:00 2001 From: hcphat Date: Fri, 27 Mar 2026 14:43:43 +0700 Subject: [PATCH 09/38] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20Fix=20Webpack=20deploy=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 3bfac100f39..e0e7d56c722 100644 --- a/Dockerfile +++ b/Dockerfile @@ -178,6 +178,7 @@ COPY ./addons/binderhub/static/ ./addons/binderhub/static/ COPY ./addons/metadata/static/ ./addons/metadata/static/ COPY ./addons/onlyoffice/static/ ./addons/onlyoffice/static/ COPY ./addons/workflow/static/ ./addons/workflow/static/ +COPY ./addons/groups/static/ ./addons/groups/static/ RUN \ # OSF yarn install --frozen-lockfile \ From 4ea0abf5a2f5565dc6db4fc2d11c8fa3c8894f11 Mon Sep 17 00:00:00 2001 From: hcphat Date: Mon, 30 Mar 2026 16:20:22 +0700 Subject: [PATCH 10/38] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20Fix=20IT=20issue=20show=20project=20incor?= =?UTF-8?q?rect=20and=20add=20logic=20check=20groups=20addon=20enabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/nodes/permissions.py | 18 ++ api/nodes/views.py | 3 + .../views/test_node_mapcore_group_views.py | 230 ++++++++++++++++++ osf/models/node.py | 3 +- osf_tests/test_mapcore_group.py | 4 + 5 files changed, 257 insertions(+), 1 deletion(-) diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index c7ba374a984..3927e1e930d 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -383,3 +383,21 @@ def has_object_permission(self, request, view, obj): if request.method in permissions.SAFE_METHODS: return obj.is_public or obj.can_view(auth) return obj.has_permission(auth.user, osf_permissions.ADMIN) + + +class GroupsAddonEnabled(permissions.BasePermission): + """Checks if the groups addon is enabled for the node.""" + + acceptable_models = (AbstractNode,) + + def has_object_permission(self, request, view, obj): + # This permission is used on views that are only relevant if the groups addon is enabled. + if request.method in permissions.SAFE_METHODS: + return True + if not isinstance(obj, AbstractNode): + return False + if not obj.guardian_object_type == 'node': + return False + if not obj.has_addon('groups'): + return False + return True diff --git a/api/nodes/views.py b/api/nodes/views.py index 5f2dae9486d..eb32587c83f 100644 --- a/api/nodes/views.py +++ b/api/nodes/views.py @@ -72,6 +72,7 @@ from api.logs.serializers import NodeLogSerializer, NodeLogDownloadSerializer from api.nodes.filters import NodesFilterMixin from api.nodes.permissions import ( + GroupsAddonEnabled, IsAdmin, IsPublic, AdminOrPublic, @@ -2376,6 +2377,7 @@ class NodeMapCoreGroupList(JSONAPIBaseView, generics.ListAPIView, bulk_views.Bul permission_classes = ( AdminOrPublic, drf_permissions.IsAuthenticatedOrReadOnly, + GroupsAddonEnabled, ReadOnlyIfRegistration, base_permissions.TokenHasScope, ) @@ -2593,6 +2595,7 @@ class NodeMapCoreGroupRemove(JSONAPIBaseView, generics.DestroyAPIView, NodeMixin permission_classes = ( AdminOrPublic, drf_permissions.IsAuthenticatedOrReadOnly, + GroupsAddonEnabled, ReadOnlyIfRegistration, base_permissions.TokenHasScope, ) diff --git a/api_tests/nodes/views/test_node_mapcore_group_views.py b/api_tests/nodes/views/test_node_mapcore_group_views.py index 7525906a029..0c9cb2ce114 100644 --- a/api_tests/nodes/views/test_node_mapcore_group_views.py +++ b/api_tests/nodes/views/test_node_mapcore_group_views.py @@ -566,6 +566,7 @@ def setUp(self): self.node = ProjectFactory(creator=self.admin_user, is_public=False) self.node.add_contributor(self.user, permissions='admin') self.node.add_contributor(self.read_only_user, permissions='read') + self.node.add_addon('groups', auth=Auth(self.user)) # Enable groups addon self.node.save() # Create auth groups for the node @@ -740,6 +741,235 @@ def test_delete_mapcore_group_from_public_node(self): res = self.app.delete(url, auth=self.admin_user.auth) assert res.status_code == 204 + +@pytest.mark.django_db +class TestGroupsAddonEnabledPermission(ApiTestCase): + """ + Verify that GroupsAddonEnabled blocks when the groups addon + is disabled on the node, and allows them when the addon is enabled. + """ + + def setUp(self): + super().setUp() + self.admin_user = AuthUserFactory() + + # Node WITHOUT the groups addon enabled + self.node = ProjectFactory(creator=self.admin_user, is_public=True) + # Ensure addon is absent + if self.node.has_addon('groups'): + self.node.delete_addon('groups', auth=Auth(self.admin_user)) + self.node.save() + + # Create auth groups and data so payloads are otherwise valid + self.auth_groups = {} + for perm in ['read', 'write', 'admin']: + self.auth_groups[perm] = AuthGroup.objects.get_or_create( + name=f'node_{self.node.id}_{perm}' + )[0] + + self.mapcore_group = MapCoreGroup.objects.create(_id='addon-perm-mcg') + self.mcng = MapCoreNodeGroup.objects.create( + node=self.node, + group=self.auth_groups['admin'], + mapcore_group=self.mapcore_group, + creator=self.admin_user, + ) + + self.list_url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/' + self.detail_url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{self.mcng.id}/' + + def test_get_list_addon_disabled_returns_200(self): + """GET list is allowed even when groups addon is disabled (safe methods bypass GroupsAddonEnabled).""" + assert not self.node.has_addon('groups') + res = self.app.get(self.list_url, auth=self.admin_user.auth) + assert res.status_code == 200 + + def test_get_list_addon_enabled_returns_200(self): + """GET list is allowed when groups addon is enabled.""" + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.save() + + res = self.app.get(self.list_url, auth=self.admin_user.auth) + assert res.status_code == 200 + + def test_post_addon_disabled_returns_403(self): + """POST (create) is blocked when groups addon is disabled.""" + assert not self.node.has_addon('groups') + mapcore_group2 = MapCoreGroup.objects.create(_id='addon-perm-mcg-2') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group2.id, + 'permission': 'write', + } + ] + }, + } + } + + res = self.app.post_json(self.list_url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_post_addon_enabled_returns_201(self): + """POST (create) is allowed when groups addon is enabled.""" + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.save() + + mapcore_group2 = MapCoreGroup.objects.create(_id='addon-perm-mcg-2-enabled') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group2.id, + 'permission': 'write', + } + ] + }, + } + } + + res = self.app.post_json(self.list_url, payload, auth=self.admin_user.auth) + assert res.status_code == 201 + + def test_patch_addon_disabled_returns_403(self): + """PATCH (update) is blocked when groups addon is disabled.""" + assert not self.node.has_addon('groups') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': self.mcng.id, + 'permission': 'read', + } + ] + }, + } + } + + res = self.app.patch_json(self.list_url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_patch_addon_enabled_returns_200(self): + """PATCH (update) is allowed when groups addon is enabled.""" + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.save() + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': self.mcng.id, + 'permission': 'read', + } + ] + }, + } + } + + res = self.app.patch_json(self.list_url, payload, auth=self.admin_user.auth) + assert res.status_code == 200 + + def test_delete_addon_disabled_returns_403(self): + """DELETE is blocked when groups addon is disabled.""" + assert not self.node.has_addon('groups') + res = self.app.delete(self.detail_url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_delete_addon_enabled_returns_204(self): + """DELETE is allowed when groups addon is enabled.""" + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.save() + + res = self.app.delete(self.detail_url, auth=self.admin_user.auth) + assert res.status_code == 204 + + self.mcng.refresh_from_db() + assert self.mcng.is_deleted is True + + def test_get_list_addon_soft_deleted_returns_200(self): + """GET list is allowed even when groups addon is soft-deleted (safe methods bypass GroupsAddonEnabled).""" + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.delete_addon('groups', auth=Auth(self.admin_user)) + assert not self.node.has_addon('groups') + + res = self.app.get(self.list_url, auth=self.admin_user.auth) + assert res.status_code == 200 + + def test_post_addon_soft_deleted_returns_403(self): + """POST is blocked when groups addon was added then soft-deleted.""" + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.delete_addon('groups', auth=Auth(self.admin_user)) + assert not self.node.has_addon('groups') + + mapcore_group2 = MapCoreGroup.objects.create(_id='addon-soft-del-mcg') + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'mapcore_group_id': mapcore_group2.id, + 'permission': 'write', + } + ] + }, + } + } + res = self.app.post_json(self.list_url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_patch_addon_soft_deleted_returns_403(self): + """PATCH is blocked when groups addon was added then soft-deleted.""" + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.delete_addon('groups', auth=Auth(self.admin_user)) + assert not self.node.has_addon('groups') + + payload = { + 'data': { + 'type': 'node-mapcore-group', + 'attributes': { + 'node_groups': [ + { + 'node_group_id': self.mcng.id, + 'permission': 'read', + } + ] + }, + } + } + res = self.app.patch_json(self.list_url, payload, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 403 + + def test_delete_addon_soft_deleted_returns_403(self): + """DELETE is blocked when groups addon was added then soft-deleted.""" + # Create a fresh mcng so it is not already deleted + mcng2 = MapCoreNodeGroup.objects.create( + node=self.node, + group=self.auth_groups['write'], + mapcore_group=self.mapcore_group, + creator=self.admin_user, + ) + self.node.add_addon('groups', auth=Auth(self.admin_user)) + self.node.delete_addon('groups', auth=Auth(self.admin_user)) + assert not self.node.has_addon('groups') + + url = f'/{API_BASE}nodes/{self.node._id}/map_core/groups/{mcng2.id}/' + res = self.app.delete(url, auth=self.admin_user.auth, expect_errors=True) + assert res.status_code == 403 + + @pytest.mark.django_db class TestMixinMapCorePermissions: def test_mapcore_node_group_get_permission(self): diff --git a/osf/models/node.py b/osf/models/node.py index eb1321d1adb..cff5d47a6fb 100644 --- a/osf/models/node.py +++ b/osf/models/node.py @@ -252,7 +252,8 @@ def get_nodes_for_user(self, user, permission=READ_NODE, base_queryset=None, inc query = Q(id__in=node_groups) if include_mapcore_groups and user and not isinstance(user, AnonymousUser): mapcore_user_groups = MapCoreUserGroup.objects.filter(user=user, is_deleted=False).values_list('mapcore_group_id', flat=True) - node_mapcore_groups = MapCoreNodeGroup.objects.filter(mapcore_group_id__in=mapcore_user_groups, is_deleted=False).values_list('node_id', flat=True) + node_mapcore_groups = MapCoreNodeGroup.objects.filter(mapcore_group_id__in=mapcore_user_groups, is_deleted=False, + node__addons_groups_node_settings__is_deleted=False).values_list('node_id', flat=True) query = Q(id__in=node_groups) | Q(id__in=node_mapcore_groups) if include_public: query |= Q(is_public=True) diff --git a/osf_tests/test_mapcore_group.py b/osf_tests/test_mapcore_group.py index 4991f9b4254..9071064e999 100644 --- a/osf_tests/test_mapcore_group.py +++ b/osf_tests/test_mapcore_group.py @@ -6,6 +6,7 @@ from osf.models.node import Node from osf.models.node import NodeGroupObjectPermission from osf_tests.factories import PrivateLinkFactory, UserFactory, NodeFactory +from framework.auth import Auth pytestmark = pytest.mark.django_db @@ -30,6 +31,7 @@ def test_can_view_via_mapcore_group_when_included(self): # Setup: node, user, mapcore group, auth group that follows 'node__admin' naming. user = UserFactory() node = NodeFactory(is_public=False) + node.add_addon('groups', auth=Auth(user)) # Enable groups addon # Create the MapCoreGroup mc_group = MapCoreGroup.objects.create(_id='mc-1') @@ -73,6 +75,7 @@ def test_cannot_view_via_mapcore_group_when_not_included(self): # Same setup but do NOT include mapcore groups in the queryset user = UserFactory() node = NodeFactory(is_public=False) + node.add_addon('groups', auth=Auth(user)) # Enable groups addon mc_group = MapCoreGroup.objects.create(_id='mc-2') auth_group = AuthGroup.objects.create(name=f'node_{node._id}_admin') MapCoreNodeGroup.objects.create(node=node, group=auth_group, mapcore_group=mc_group, creator=user, is_deleted=False) @@ -98,6 +101,7 @@ def test_deleted_mapcore_node_group_is_ignored(self): def test_get_nodes_for_user_include_mapcore_group(self): user = UserFactory() node = NodeFactory(is_public=False) + node.add_addon('groups', auth=Auth(user)) # Enable groups addon mc_group = MapCoreGroup.objects.create(_id='mc-4') auth_group = AuthGroup.objects.create(name=f'node_{node._id}_admin') MapCoreNodeGroup.objects.create(node=node, group=auth_group, mapcore_group=mc_group, creator=user, is_deleted=False) From 65dac17b6e452e04ad6b6898c59c85956f5604db Mon Sep 17 00:00:00 2001 From: hcphat Date: Wed, 1 Apr 2026 15:33:02 +0700 Subject: [PATCH 11/38] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20Update=20sort=20order=20by=20addon=5Ffull?= =?UTF-8?q?=5Fname?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/rdm_addons/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/rdm_addons/views.py b/admin/rdm_addons/views.py index 8e281706d05..eb4bbb7805c 100644 --- a/admin/rdm_addons/views.py +++ b/admin/rdm_addons/views.py @@ -98,7 +98,7 @@ def get_context_data(self, **kwargs): ctx['addon_settings'] = utils.get_addons_by_config_type('accounts', self.request.user) ctx['addon_settings'].append( utils.get_addon_template_config(utils.get_addon_config('node', 'groups'), self.request.user)) - + ctx['addon_settings'].sort(key=lambda x: x['addon_full_name'].lower()) accounts_addons = [addon for addon in website_settings.ADDONS_AVAILABLE if 'accounts' in addon.configs and not addon.for_institutions] ctx.update({ From df0ef879bb3464195cc1293d3d77efe56011d133 Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Tue, 25 Nov 2025 14:08:17 +0900 Subject: [PATCH 12/38] =?UTF-8?q?redmine55789/=E5=A4=9A=E8=A6=81=E7=B4=A0?= =?UTF-8?q?=E8=AA=8D=E8=A8=BC=E6=A9=9F=E8=83=BD=E3=81=AE=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/base/settings/defaults.py | 1 + admin/loa/__init__.py | 0 admin/loa/forms.py | 39 ++++++ admin/loa/urls.py | 9 ++ admin/loa/views.py | 132 ++++++++++++++++++ admin/templates/base.html | 5 + admin/templates/loa/list.html | 109 +++++++++++++++ api/institutions/authentication.py | 100 ++++++++++++- api/institutions/views.py | 2 +- osf/migrations/0257_r_2025_23_55789.py | 22 +++ osf/migrations/0258_r_2025_23_55789.py | 88 ++++++++++++ osf/models/loa.py | 59 ++++++++ osf/models/user.py | 4 + tests/nii/test_profile_from_idp.py | 16 +-- .../api/institutions/test_authenticate.py | 18 +-- website/profile/utils.py | 53 ++++++- website/routes.py | 3 + website/settings/defaults.py | 11 ++ website/static/css/pages/profile-page.css | 15 ++ .../img/institutions/banners/orthros-logo.png | Bin 0 -> 17408 bytes .../orthros-shield-rounded-corners.png | Bin 0 -> 24883 bytes .../institutions/shields/orthros-shield.png | Bin 0 -> 19214 bytes website/templates/profile.mako | 19 +++ 23 files changed, 683 insertions(+), 22 deletions(-) create mode 100644 admin/loa/__init__.py create mode 100644 admin/loa/forms.py create mode 100644 admin/loa/urls.py create mode 100644 admin/loa/views.py create mode 100644 admin/templates/loa/list.html create mode 100644 osf/migrations/0257_r_2025_23_55789.py create mode 100644 osf/migrations/0258_r_2025_23_55789.py create mode 100644 osf/models/loa.py create mode 100644 website/static/img/institutions/banners/orthros-logo.png create mode 100644 website/static/img/institutions/shields-rounded-corners/orthros-shield-rounded-corners.png create mode 100644 website/static/img/institutions/shields/orthros-shield.png diff --git a/admin/base/settings/defaults.py b/admin/base/settings/defaults.py index fb18e46f3ff..34517ce19d4 100644 --- a/admin/base/settings/defaults.py +++ b/admin/base/settings/defaults.py @@ -110,6 +110,7 @@ 'admin.meetings', 'admin.institutions', 'admin.preprint_providers', + 'admin.loa', # Additional addons 'addons.bitbucket', diff --git a/admin/loa/__init__.py b/admin/loa/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/admin/loa/forms.py b/admin/loa/forms.py new file mode 100644 index 00000000000..d1aa314e63a --- /dev/null +++ b/admin/loa/forms.py @@ -0,0 +1,39 @@ +from django import forms +from osf.models import LoA +from django.utils.translation import ugettext_lazy as _ + + +class LoAForm(forms.ModelForm): + CHOICES_AAL = [(0, _('NULL')), (1, _('AAL1')), (2, _('AAL2'))] + CHOICES_IAL = [(0, _('NULL')), (1, _('IAL1')), (2, _('IAL2'))] + CHOICES_MFA = ( + (False, _('表示しない')), + (True, _('表示する')), + ) + aal = forms.ChoiceField( + choices=CHOICES_AAL, + required=False, + ) + ial = forms.ChoiceField( + choices=CHOICES_IAL, + required=False, + ) + is_mfa = forms.ChoiceField( + label=_('Display MFA link button'), + choices=CHOICES_MFA, + initial=False, + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + for field in self.fields.values(): + field.widget.attrs['class'] = 'form-control form-control-sm' + + class Meta: + model = LoA + fields = ( + 'aal', + 'ial', + 'is_mfa', + ) diff --git a/admin/loa/urls.py b/admin/loa/urls.py new file mode 100644 index 00000000000..824e2ae5451 --- /dev/null +++ b/admin/loa/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import url +from . import views + +app_name = 'admin' + +urlpatterns = [ + url(r'^$', views.ListLoA.as_view(), name='list'), + url(r'^bulk_add/$', views.BulkAddLoA.as_view(), name='bulk_add'), +] diff --git a/admin/loa/views.py b/admin/loa/views.py new file mode 100644 index 00000000000..b2765306470 --- /dev/null +++ b/admin/loa/views.py @@ -0,0 +1,132 @@ +from __future__ import unicode_literals +from urllib.parse import urlencode +from django.core.exceptions import PermissionDenied +from django.shortcuts import redirect +from django.urls import reverse +from django.views.generic import View, TemplateView +from django.contrib import messages +from django.utils.translation import ugettext_lazy as _ +from admin.rdm.utils import RdmPermissionMixin +from admin.loa.forms import LoAForm +from osf.models import Institution, LoA +from django.contrib.auth.mixins import UserPassesTestMixin +from django.http import Http404 +from admin.base.utils import render_bad_request_response +import logging + +logger = logging.getLogger(__name__) + + +class ListLoA(RdmPermissionMixin, UserPassesTestMixin, TemplateView): + template_name = 'loa/list.html' + raise_exception = True + institution_id = None + model = LoA + + form_class = LoAForm + + def dispatch(self, request, *args, **kwargs): + + # login check + if not self.is_authenticated: + return self.handle_no_permission() + try: + self.institution_id = self.request.GET.get('institution_id') + if self.institution_id: + self.institution_id = int(self.institution_id) + return super(ListLoA, self).dispatch(request, *args, **kwargs) + except ValueError: + return render_bad_request_response(request=request, error_msgs='institution_id must be a integer') + + def test_func(self): + """check user permissions""" + if not self.institution_id: + # superuser or admin has an institution + return self.is_super_admin or self.is_institutional_admin + else: + # institution not exist + if not Institution.objects.filter(id=self.institution_id).exists(): + raise Http404( + 'Institution with id "{}" not found.'.format( + self.institution_id + )) + # superuser or institutional admin has permission + return self.is_super_admin or \ + (self.is_admin and self.is_affiliated_institution(self.institution_id)) + + def get_context_data(self, **kwargs): + user = self.request.user + # superuser + if self.is_super_admin: + institutions = Institution.objects.all().order_by('name') + # institution administrator + elif self.is_admin and user.affiliated_institutions.first(): + institutions = Institution.objects.filter(pk__in=user.affiliated_institutions.all()).order_by('name') + else: + raise PermissionDenied('Not authorized to view the LoA.') + + selected_id = institutions.first().id + + institution_id = int(self.kwargs.get('institution_id', self.request.GET.get('institution_id', selected_id))) + + formset_loa = LoAForm(instance=LoA.objects.get_or_none(institution_id=institution_id)) + logger.info(formset_loa) + kwargs.setdefault('institutions', institutions) + kwargs.setdefault('institution_id', institution_id) + kwargs.setdefault('selected_id', institution_id) + kwargs.setdefault('formset_loa', formset_loa) + + return super(ListLoA, self).get_context_data(**kwargs) + + +class BulkAddLoA(RdmPermissionMixin, UserPassesTestMixin, View): + raise_exception = True + institution_id = None + + def dispatch(self, request, *args, **kwargs): + """Initialize attributes shared by all view methods.""" + # login check + if not self.is_authenticated: + return self.handle_no_permission() + try: + self.institution_id = self.request.POST.get('institution_id') + if self.institution_id: + self.institution_id = int(self.institution_id) + else: + return render_bad_request_response(request=request, error_msgs='institution_id is required') + return super(BulkAddLoA, self).dispatch(request, *args, **kwargs) + except ValueError: + return render_bad_request_response(request=request, error_msgs='institution_id must be a integer') + + def test_func(self): + """check user permissions""" + # institution not exist + if not Institution.objects.filter(id=self.institution_id, is_deleted=False).exists(): + raise Http404( + 'Institution with id "{}" not found.'.format( + self.institution_id + )) + # superuser or institutional admin has permission + return self.is_super_admin or \ + (self.is_admin and self.is_affiliated_institution(self.institution_id)) + + def post(self, request): + institution_id = request.POST.get('institution_id') + aal = request.POST.get('aal') + ial = request.POST.get('ial') + is_mfa = request.POST.get('is_mfa') + existing_set = LoA.objects.get_or_none(institution_id=institution_id) + if not existing_set: + LoA.objects.create(institution_id=institution_id, aal=aal, ial=ial, is_mfa=is_mfa, modifier=request.user) + else: + existing_set.aal = aal + existing_set.ial = ial + existing_set.is_mfa = is_mfa + existing_set.modifier = request.user + existing_set.save() + + base_url = reverse('loa:list') + query_string = urlencode({'institution_id': institution_id}) + ctx = _('LoA update successful.') + messages.success(self.request, ctx) + return redirect('{}?{}'.format(base_url, query_string)) diff --git a/admin/templates/base.html b/admin/templates/base.html index 1ef00cefeb8..65fb3c71257 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -133,6 +133,11 @@ {% trans "Login Availability Control" %} +
  • + + {% trans "Level of Assurance" %} + +
  • {% endif %} {% if perms.osf.view_node %}
  • diff --git a/admin/templates/loa/list.html b/admin/templates/loa/list.html new file mode 100644 index 00000000000..b35f2da8961 --- /dev/null +++ b/admin/templates/loa/list.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} +{% load i18n %} +{% load render_bundle from webpack_loader %} +{% load spam_extras %} +{% load static %} + +{% block top_includes %} + + +{% endblock %} + +{% block title %} + {% trans "Level of Assurance" %} +{% endblock title %} + +{% block content %} +

    {% trans "Level of Assurance" %}

    + +
    + {% if messages %} + {% for message in messages %} +
    + {{ message }} +
    + {% endfor %} + {% endif %} +
    + {% if request.session.message %} +
    + {{ request.session.message }} +
    + {% endif %} +
    +
    +
    + {% csrf_token %} +
    + +
    + +
    +
    +
    + +
    + {{ formset_loa }} +
    +
    +
    +
    +
    + +
    +
    +
    + +{% endblock content %} + +{% block bottom_js %} + + + +{% endblock %} diff --git a/api/institutions/authentication.py b/api/institutions/authentication.py index 4a6e5e8217e..b5e48648367 100644 --- a/api/institutions/authentication.py +++ b/api/institutions/authentication.py @@ -6,9 +6,13 @@ import jwt import waffle +# @R2022-48 loa +import re +import urllib.parse + #from django.utils import timezone from rest_framework.authentication import BaseAuthentication -from rest_framework.exceptions import AuthenticationFailed +from rest_framework.exceptions import AuthenticationFailed, ValidationError from api.base.authentication import drf from api.base import exceptions, settings @@ -18,10 +22,23 @@ from framework.auth.core import get_user from osf import features -from osf.models import Institution, UserExtendedData +from osf.models import Institution, UserExtendedData, LoA from osf.exceptions import BlacklistedEmailError from website.mails import send_mail, WELCOME_OSF4I -from website.settings import OSF_SUPPORT_EMAIL, DOMAIN, to_bool +from website.settings import ( + OSF_SUPPORT_EMAIL, + DOMAIN, + to_bool, + OSF_SERVICE_URL, + CAS_SERVER_URL, + OSF_MFA_URL, + OSF_IAL2_STR, + OSF_AAL1_STR, + OSF_AAL2_STR, + OSF_IAL2_VAR, + OSF_AAL1_VAR, + OSF_AAL2_VAR, +) from website.util.quota import update_default_storage logger = logging.getLogger(__name__) @@ -57,6 +74,7 @@ class InstitutionAuthentication(BaseAuthentication): """ media_type = 'text/plain' + context = {'mfa_url': ''} def authenticate(self, request): """ @@ -207,6 +225,72 @@ def get_next(obj, *args): 'gakuninIdentityAssuranceMethodReference', ) + # @R2022-48 ial,aal + ial = None + aal = None + # @R-2024-AUTH01 eduPersonAssurance(multi value) + eduPersonAssurance = p_user.get('eduPersonAssurance') + if re.search(OSF_IAL2_STR, str(eduPersonAssurance)): + ial = OSF_IAL2_VAR + if re.search(OSF_AAL2_STR, str(eduPersonAssurance)): + aal = OSF_AAL2_VAR + elif re.search(OSF_AAL1_STR, str(eduPersonAssurance)): + aal = OSF_AAL1_VAR + else: + aal = p_user.get('Shib-AuthnContext-Class') + + # @R2022-48 loa + R-2023-55 + message = '' + self.context['mfa_url'] = '' + mfa_url = '' + if type(p_idp) is str: + mfa_url_q = ( + OSF_MFA_URL + + '?entityID=' + + p_idp + + '&target=' + + CAS_SERVER_URL + + '/login?service=' + + OSF_SERVICE_URL + + '/profile/' + ) + mfa_url = ( + CAS_SERVER_URL + + '/logout?service=' + + urllib.parse.quote(mfa_url_q, safe='') + ) + loa_flag = True + loa = LoA.objects.get_or_none(institution_id=institution.id) + if loa: + if loa.aal == 2: + if not re.search(OSF_AAL2_STR, str(aal)): + self.context['mfa_url'] = mfa_url + elif loa.aal == 1: + if not aal: + message = ( + 'Institution login failed: Does not meet the required AAL.
    Please contact the IdP as the' + ' appropriate value may not have been sent out by the IdP.' + ) + loa_flag = False + if loa.ial == 2: + if not re.search(OSF_IAL2_STR, str(ial)): + message = ( + 'Institution login failed: Does not meet the required IAL.
    Please check the IAL of your' + ' institution.' + ) + loa_flag = False + elif loa.ial == 1: + if not ial: + message = ( + 'Institution login failed: Does not meet the required IAL.
    Please check the IAL of your' + ' institution.' + ) + loa_flag = False + if not loa_flag: + message = 'Institution login failed: Does not meet the required AAL and IAL.' + sentry.log_message(message) + raise ValidationError(message) + # Use given name and family name to build full name if it is not provided if given_name and family_name and not fullname: fullname = given_name + ' ' + family_name @@ -336,6 +420,15 @@ def get_next(obj, *args): user.department = department user.save() + # @R-2023-55. + if ial and user.ial != ial: + user.ial = ial + user.save() + if aal and user.aal != aal: + user.aal = aal + user.save() + logger.info('MFA URL "{}"'.format(self.context['mfa_url'])) + # Both created and activated accounts need to be updated and registered if created or activation_required: @@ -425,6 +518,7 @@ def get_next(obj, *args): # update every login. ext.set_idp_attr( { + 'id': institution.id, # @R-2023-55 'idp': p_idp, 'eppn': eppn, 'username': username, diff --git a/api/institutions/views.py b/api/institutions/views.py index dcc8cefb53b..1936edfdf27 100644 --- a/api/institutions/views.py +++ b/api/institutions/views.py @@ -198,7 +198,7 @@ class InstitutionAuth(JSONAPIBaseView, generics.CreateAPIView): view_name = 'institution-auth' def post(self, request, *args, **kwargs): - return Response(status=status.HTTP_204_NO_CONTENT) + return Response(self.authentication_classes[0].context, status=status.HTTP_200_OK) class InstitutionRegistrationList(InstitutionNodeList): diff --git a/osf/migrations/0257_r_2025_23_55789.py b/osf/migrations/0257_r_2025_23_55789.py new file mode 100644 index 00000000000..2b373546685 --- /dev/null +++ b/osf/migrations/0257_r_2025_23_55789.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('osf', '0257_merge_20251023_1304'), + ] + + operations = [ + migrations.AddField( + model_name='osfuser', + name='aal', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='osfuser', + name='ial', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/osf/migrations/0258_r_2025_23_55789.py b/osf/migrations/0258_r_2025_23_55789.py new file mode 100644 index 00000000000..2bd25b57260 --- /dev/null +++ b/osf/migrations/0258_r_2025_23_55789.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import osf.models.base + + +class Migration(migrations.Migration): + dependencies = [ + ('osf', '0257_r_2025_23_55789'), + ] + + operations = [ + migrations.CreateModel( + name='LoA', + fields=[ + ( + 'id', + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name='ID', + ), + ), + ( + 'created', + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name='created' + ), + ), + ( + 'modified', + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name='modified' + ), + ), + ( + 'aal', + models.IntegerField( + blank=True, + choices=[(0, 'NULL'), (1, 'AAL1'), (2, 'AAL2')], + null=True, + ), + ), + ( + 'ial', + models.IntegerField( + blank=True, + choices=[(0, 'NULL'), (1, 'IAL1'), (2, 'IAL2')], + null=True, + ), + ), + ( + 'is_mfa', + models.BooleanField( + choices=[(False, 'Disabled'), (True, 'Enabled')], + default=False, + verbose_name='Display MFA link button', + ), + ), + ( + 'institution', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='osf.Institution', + ), + ), + ( + 'modifier', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + 'permissions': ( + ('view_loa', 'Can view loa'), + ('admin_loa', 'Can manage loa'), + ), + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + ] diff --git a/osf/models/loa.py b/osf/models/loa.py new file mode 100644 index 00000000000..8b0977fdc5e --- /dev/null +++ b/osf/models/loa.py @@ -0,0 +1,59 @@ +from django.db import models +from osf.models import base +from django.utils.translation import ugettext_lazy as _ +import logging + +logger = logging.getLogger(__name__) + + +class BaseManager(models.Manager): + def get_or_none(self, **kwargs): + try: + return self.get_queryset().get(**kwargs) + except self.model.DoesNotExist: + return None + + +class LoA(base.BaseModel): + objects = BaseManager() + institution = models.ForeignKey('Institution', on_delete=models.CASCADE) + aal = models.IntegerField( + choices=( + (0, 'NULL'), + (1, 'AAL1'), + (2, 'AAL2'), + ), + blank=True, + null=True, + ) + ial = models.IntegerField( + choices=( + (0, 'NULL'), + (1, 'IAL1'), + (2, 'IAL2'), + ), + blank=True, + null=True, + ) + is_mfa = models.BooleanField( + _('Display MFA link button'), + choices=( + (False, 'Disabled'), + (True, 'Enabled'), + ), + default=False, + ) + modifier = models.ForeignKey('OSFUser', on_delete=models.CASCADE) + + class Meta: + permissions = ( + ('view_loa', 'Can view loa'), + ('admin_loa', 'Can manage loa'), + ) + + def __init__(self, *args, **kwargs): + kwargs.pop('node', None) + super(LoA, self).__init__(*args, **kwargs) + + def __unicode__(self): + return u'institution_{}:{}:{}:{}'.format(self.institution._id, self.aal, self.ial, self.is_mfa) diff --git a/osf/models/user.py b/osf/models/user.py index 2725f9360be..33453fbd068 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -472,6 +472,10 @@ class OSFUser(DirtyFieldsMixin, GuidMixin, BaseModel, AbstractBaseUser, Permissi mapcore_api_locked = models.BooleanField(default=False) mapcore_refresh_locked = models.BooleanField(default=False) + # @R2022-48 eduPersonAssurance(ial) and AuthnContextClass(aal) from Shibboleth + ial = models.CharField(blank=True, max_length=255, null=True) + aal = models.CharField(blank=True, max_length=255, null=True) + def __repr__(self): return ''.format(self.username, self._id) diff --git a/tests/nii/test_profile_from_idp.py b/tests/nii/test_profile_from_idp.py index 2ee10b81043..5c78714f025 100644 --- a/tests/nii/test_profile_from_idp.py +++ b/tests/nii/test_profile_from_idp.py @@ -90,7 +90,7 @@ def test_without_email(self, app, institution, url_auth_institution): make_payload(institution, eppn, fullname, given_name, family_name) ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.get(username=tmp_eppn_username) assert user assert user.fullname == fullname @@ -114,7 +114,7 @@ def test_with_email(self, app, institution, url_auth_institution): make_payload(institution, eppn, fullname, given_name, family_name, email=email) ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.get(username=email) assert user assert user.fullname == fullname @@ -151,7 +151,7 @@ def test_with_email_and_profile_attr(self, app, institution, url_auth_institutio ) ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.get(username=email) assert user assert user.fullname == fullname @@ -203,7 +203,7 @@ def test_with_email_and_profile_attr_without_orgname(self, app, institution, url ) ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.get(username=email) assert user assert user.fullname == fullname @@ -230,7 +230,7 @@ def test_with_blacklist_email(self, app, institution, url_auth_institution): make_payload(institution, eppn, fullname, given_name, family_name, email=email) ) - assert res.status_code == 204 + assert res.status_code == 200 # email is ignored from django.core.exceptions import ObjectDoesNotExist @@ -260,7 +260,7 @@ def test_same_email_is_ignored(self, app, institution, url_auth_institution): url_auth_institution, make_payload(institution, eppn, fullname, given_name, family_name, email=email) ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.get(username=email) assert user assert user.have_email == True @@ -272,7 +272,7 @@ def test_same_email_is_ignored(self, app, institution, url_auth_institution): url_auth_institution, make_payload(institution, eppn2, fullname, given_name, family_name, email=email) ) - assert res.status_code == 204 + assert res.status_code == 200 # same email is ignored user2 = OSFUser.objects.get(username=tmp_eppn_username2) @@ -315,7 +315,7 @@ def test_existing_fullname_isnot_changed(self, app, institution, url_auth_instit ) # user.fullname is not changned - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.get(username=email) assert user assert user.fullname == fullname diff --git a/tests/test_202201/api/institutions/test_authenticate.py b/tests/test_202201/api/institutions/test_authenticate.py index c1d9b2444c3..9b38a873a7b 100644 --- a/tests/test_202201/api/institutions/test_authenticate.py +++ b/tests/test_202201/api/institutions/test_authenticate.py @@ -98,7 +98,7 @@ def test_authenticate_jaSurname_and_jaGivenName_are_valid( jaGivenName=jagivenname, jaSurname=jasurname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user @@ -113,7 +113,7 @@ def test_authenticate_jaGivenName_is_valid( make_payload(institution, username, jaGivenName=jagivenname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.given_name_ja == jagivenname @@ -128,7 +128,7 @@ def test_authenticate_jaSurname_is_valid( make_payload(institution, username, jaSurname=jasurname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.family_name_ja == jasurname @@ -143,7 +143,7 @@ def test_authenticate_jaMiddleNames_is_valid( make_payload(institution, username, jaMiddleNames=middlename), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.middle_names_ja == middlename @@ -158,7 +158,7 @@ def test_authenticate_givenname_is_valid( make_payload(institution, username, given_name=given_name), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.given_name == given_name @@ -173,7 +173,7 @@ def test_authenticate_familyname_is_valid( make_payload(institution, username, family_name=family_name), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.family_name == family_name @@ -188,7 +188,7 @@ def test_authenticate_middlename_is_valid( make_payload(institution, username, middle_names=middle_names), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.middle_names == middle_names @@ -207,7 +207,7 @@ def test_authenticate_jaOrganizationalUnitName_is_valid( organizationName=organizationname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user assert user.jobs[0]['department_ja'] == jaorganizationname @@ -226,7 +226,7 @@ def test_authenticate_OrganizationalUnitName_is_valid( organizationName=organizationname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user assert user.jobs[0]['department'] == organizationnameunit diff --git a/website/profile/utils.py b/website/profile/utils.py index 7b80d5c9509..da8685dbf75 100644 --- a/website/profile/utils.py +++ b/website/profile/utils.py @@ -3,7 +3,7 @@ from api.base import settings as api_settings from website import settings -from osf.models import Contributor, UserQuota +from osf.models import Contributor, UserQuota, LoA from addons.osfstorage.models import Region from website.filters import profile_image_url from osf.utils.permissions import READ @@ -11,6 +11,10 @@ from api.waffle.utils import storage_i18n_flag_active from website.util import quota +# @R2022-48 +import re +import urllib.parse + def get_profile_image_url(user, size=settings.PROFILE_IMAGE_MEDIUM): return profile_image_url(settings.PROFILE_IMAGE_PROVIDER, @@ -31,6 +35,47 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i user = contrib.user fullname = user.display_full_name(node=node) idp_attrs = user.get_idp_attr() + + # @R2022-48 + if not user.aal: + _aal = 'NULL' + elif re.search(settings.OSF_AAL2_STR, str(user.aal)): + _aal = 'AAL2' + else: + _aal = 'AAL1' + + # @R-2024-AUTH01 Values other than IAL2 are equivalent to IAL1. + if re.search(settings.OSF_IAL2_STR, str(user.ial)): + _ial = 'IAL2' + else: + _ial = 'IAL1' + + # @R-2023-55 + mfa_url = '' + entity_id = idp_attrs.get('idp') + if entity_id is not None: + mfa_url_q = ( + settings.OSF_MFA_URL + + '?entityID=' + + entity_id + + '&target=' + + settings.CAS_SERVER_URL + + '/login?service=' + + settings.OSF_SERVICE_URL + + '/profile/' + ) + mfa_url = ( + settings.CAS_SERVER_URL + + '/logout?service=' + + urllib.parse.quote(mfa_url_q, safe='') + ) + + loa = LoA.objects.get_or_none(institution_id=idp_attrs.get('id')) + if loa is not None: + is_mfa = loa.is_mfa + else: + is_mfa = False + ret = { 'id': str(user._id), 'primary_key': user.id, @@ -40,6 +85,12 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i 'shortname': fullname if len(fullname) < 50 else fullname[:23] + '...' + fullname[-23:], 'profile_image_url': user.profile_image_url(size=settings.PROFILE_IMAGE_MEDIUM), 'active': user.is_active, + 'ial': user.ial, # @R2022-48 + 'aal': user.aal, # @R2022-48 + '_ial': _ial, # @R2022-48 + '_aal': _aal, # @R2022-48 + 'mfa_url': mfa_url, # @R-2023-55 + 'is_mfa': is_mfa, # @R-2023-55 'have_email': user.have_email, 'idp_email': idp_attrs.get('email'), } diff --git a/website/routes.py b/website/routes.py index b3de0fbefdd..cefca3f8512 100644 --- a/website/routes.py +++ b/website/routes.py @@ -172,10 +172,13 @@ def get_globals(): 'sjson': lambda s: sanitize.safe_json(s), 'webpack_asset': paths.webpack_asset, 'osf_url': settings.INTERNAL_DOMAIN, + 'osf_service_url': settings.OSF_SERVICE_URL, # R-2022-48 'waterbutler_url': settings.WATERBUTLER_URL, + 'cas_server_url': settings.CAS_SERVER_URL, # R-2022-48 'login_url': cas.get_login_url(request_login_url), 'sign_up_url': util.web_url_for('auth_register', _absolute=True, next=request_login_url), 'reauth_url': util.web_url_for('auth_logout', redirect_url=request.url, reauth=True), + 'mfa_url': settings.CAS_SERVER_URL + '/logout?service=' + settings.OSF_MFA_URL, # R-2023-55 'profile_url': cas.get_profile_url(), 'enable_institutions': settings.ENABLE_INSTITUTIONS, 'keen': { diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 1ace7782bba..41f318515f0 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -374,6 +374,8 @@ def parent_dir(path): CAS_SERVER_URL = 'http://localhost:8080' MFR_SERVER_URL = 'http://localhost:7778' +OSF_SERVICE_URL = '' # R-2022-48 +OSF_MFA_URL = '' # R-2022-48 ###### ARCHIVER ########### ARCHIVE_PROVIDER = 'osfstorage' @@ -2094,3 +2096,12 @@ class CeleryConfig: 'ja_jp': '日本語' } BABEL_DEFAULT_LOCALE = 'ja' + +# Default values for IAL2 & AAL2 parameters(R-2023-55) +# Default values for IAL1 & AAL1 parameters(R-2024-AUTH01) +OSF_IAL2_STR = 'https://www\.gakunin\.jp/profile/IAL2' +OSF_AAL1_STR = 'https://www\.gakunin\.jp/profile/AAL1' +OSF_AAL2_STR = 'https://www\.gakunin\.jp/profile/AAL2' +OSF_IAL2_VAR = 'https://www.gakunin.jp/profile/IAL2' +OSF_AAL1_VAR = 'https://www.gakunin.jp/profile/AAL1' +OSF_AAL2_VAR = 'https://www.gakunin.jp/profile/AAL2' diff --git a/website/static/css/pages/profile-page.css b/website/static/css/pages/profile-page.css index b1ad2f52177..393f9cd1c5b 100644 --- a/website/static/css/pages/profile-page.css +++ b/website/static/css/pages/profile-page.css @@ -140,3 +140,18 @@ table.social-links { table.social-links a { word-wrap: break-word; } + +/* +@R2022-48 +*/ +.badge.AAL1, +.badge.IAL1 +{ + background-color:#3498db!important; +} + +.badge.AAL2, +.badge.IAL2 +{ + background-color:#18bc9c!important; +} diff --git a/website/static/img/institutions/banners/orthros-logo.png b/website/static/img/institutions/banners/orthros-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..d177b1ad076e14e98dd9c3b227b4631bf650d38b GIT binary patch literal 17408 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%0D$%H0FI<>qJ+Pyt{ym@?&s zmTZI5t@X=Q!zZme1_zw=FjwZjfhh41sF0HIoWu`?~GSW{*9$4Aceurek-?^kA7;Pa|m`)s~uD};- zMh6Tc#_Bsa-(lA_0cZ!~bax(j8Z`AH5HYy!HBA)HFvty=Ik>&J@CIXvV2yyk>$j^v(bF0U4luW)kqZ;e))p4hdy*Fv6qv5bPlt7N!|t0| zS^^aJ01)llw{P#D-(?EA!L$@5o#WE@=nq>|Pj2%>Qo@}aPDwtx6SI5=SiB4_kpPA% zsK3^tr{mv^@w%P(!19029a7}GPoPnpP+jv6Mb9qB$b$BFdq7R_AHU7u{?TIg)t^pO z%~a^vAI!{Xl9N+8OuB&%3c>bYk$Ee=_VscMEKE2J3X}?TA0?PmyaX;6J$9|L>rNNM zA#UeQsoKv(1Y*e&@H!ky<5!wo2>%(Zzun@s1z%`rX#8ir1JWonE32j#S0y)t(fQH$ zyw1|nQt?Ra@N9QCH~3pq&{}LJlA#pO$jQy%T*g0a@ zpkyYbzu)xDAJ%uX7r9b@iI^uYCc(c8@zXD1uWC~=akow-0aA?ApS)IA$j)TD$t&t*@y#T_p_V@+vRSA<=@TZp@sr`}Z-|y%!!)oB5&o3?x);jz^vOb$ZuzK4+0(z4pOZ=c%w+?&vA#S30@qRrYM$*>S{`%{^ z#|f$4KHqYcw!LCk&2|a{eN0PB%djPs+}%+szooI5n15lR<(5%|x{e|74>i1>1_@86 z8N8V?A6j%aSgvH{<`A1*l|g52_>@c;s=y{KGL{0 zFjoi*{FlY#sAB>q$3{mX=mEtIH8tv~^6;vvs!%rHfK^pTC^nArJOHX0%JY@-y^^A$ ze;O+-R3P0dZ_(25h(U^Oa`Ux`Ti{bW0s_xJZMBx}ksqX_q+*7q(T*TP1aS^cOjypK zE8pm@tSp;bKN<1WWCdw~%k-{6rwnWXZqNPh9RV9TF78aWB;uG?Q&GW_xrMH5h^No8 zhesTb1yOmVs5iIhY-cAW50A!Fg{bpp@5Rmtwot6?$|?N>6o+z=C)tw_NQsabiC=Qy zUfU&XO|p;NwZKtv&0tc|rxgCMa4Xe~a;IP|EM{+i{9lPXLL0agirTlEe^C~{n}Ko8 zVLXG;I8pJvJ2Odqum$s>#Fg9)we?h7LWfeGPng=d?fjYbtVKtC`hCyef~lrBgq->D zibe!n{`u32A)ETggBiNhtu1b!8@t|dT++viFIjzr7Xg`X`hP1ke`x$FnrLPT=BA6@ zGv!=oD#?@Q|1*p(ix_$ILlw(cE$*PrZ2EPZLG>n_T;NMyO$|43e_46?_}EyR%Pxj| zCTHnN*y6FwOZ?Xt-;Iv@NKH06#s42EcRn3|PcH0bWooUQ-^LwB1!J*;{GC*!BZj$X z!)fGzzmFG8YWZrys=9o0_X2WlhnAb5REZhq_DStXA$K43mLlEE++31OE||ZSqql5H z^~l)dnViVxGory#{a)X*OiJ&Q+PD~h90kUv#{wa=d9sX+&w`KJ3d-^Pyd0VGpQrZ_ zxgE%c8^f=Gig|86fP5)*=ZD+2Ym)6r@*ZoPA&GmOiqF&2$Y`yzQ;dK0{(Rc`S5OK3 zwZyy-9Ba`o=yttLVHa6HBiV6KwFWVi`WtazZIyZFh0tU?hpUyvaG>sluXp@!*m%j7 zi(*T?&8?Twa60oU#=t zC7Us>OAcGUnqv-%L$>UEX;mMJyQ0Uveoc)tQO69$nDNoMwN){n9Hf&5d=vsU5r~Ujd@_&y5fFp1f zL5PNpSJg=Ps6n2r`@&U2*EAT`-K>svqyA(PLBrnY%D(NE{emZZ^ zzyaL2EF~qSs){pZ1YY+Uv17e+O&4`` zhbR-!vAv^nxalXeN7GMF*!u4pl*kW8dHMOQU=j}0P%G9tmLJ> literal 0 HcmV?d00001 diff --git a/website/static/img/institutions/shields-rounded-corners/orthros-shield-rounded-corners.png b/website/static/img/institutions/shields-rounded-corners/orthros-shield-rounded-corners.png new file mode 100644 index 0000000000000000000000000000000000000000..b978279c11af40bd1ab80ae43fb37f62a0ce2735 GIT binary patch literal 24883 zcmeFZWmJ}H7c~kLHl`PTD( z=lA(>exE(ujy)Wk$LGGT6?4rw*Zo#qRRI^992*G<3HPa@tR@l?vNqyhOmz4Y|MwlW zNJwTEPi3EI`=oErc>8EsUSjMyd!6Pba4_YnpA9Od38sY`2NM>W=X^yg0agt zk%h0A-W0M~{>m)l`OW5`o|W&R(P*72uylSsNS*!p=_G@&ipb7@=kZ_OgLKC1X7_&H z9fj{?7BnFQ<^tKZtWubd`q0p{r7;(<{{P?qUr*re_#_F_)uOsRA2`b}!z)f6wKFyp3|9FXVaNvCkx@q7qR!~fk_by~tl#@e~kEMKD zV?AJZT5C6g9UmY6HQ<~l(|<>J`=!6XzftSUhmKQqwemVxw(r;QYSGTmmg0olv{{nO zr|KLradA6q1|*{HJ(jnLgU4NHy}hpBFMg_+AO|1b5qkGNm(QMAp*G8Q3uo&U;qAp( z>zAq3flu98PlB@sZIF<>yu2b}V&u{U9NA1vrBi7eWgL;USx9DmmT*uhyRMpast6p<_b^-vmxUi_+jUVnBaQ2zKzBJjd&@{28*m=BkxmR6ZD|KA@PQjROSYZlxHcBc+FH-ND2E^Ai`yQ^K7o{p9J$mTH|96H(wDZsRWo2%s2k!k_|T5`WJJ%@_09Uv_{W<#1xWMD%OT8K z1vd4pEG(7wW94Cm<{1YE2YZwD6))EQWcvjd#l+xRUk2S?QYR|q=NBtrVxSFMvGDVg zNM0WiS2a90P{IRsZ%@?=UuLAISN`{l(5TsuWViKJLRwmy%etSkU)cY2$Fb7yo+gEz zjANLan;RmNmjC^;JnT`zJeM|;o6*mz-SDR_R~EF>%}2Ob`x z6cJ&)O2wKcCb4iuFE&TZM7lG57O_h8t56<_c+799DJn{x?#$H2T?Ag9bVA_L5MU~o zV?1JbkvDNZcYD)~WD|srPvxK~9FF&JF;QzQPLa<1_3PITaK&4>aT4Yj_*DNrc<|u3 z#l+Q>+y836+q38R;K01~>L99Zj5$e(l#nn-e6F)I)Y95I?I;tA8*$+B+3J;e4@F#1 z{!#~Bz0fr1?Ccb=d~mt6xcCG98J}`)?ZcxK`d(dKePmMd?VFJ^*F&EFFq;A|y)ryw zA|n2@$kPy1K1=3E`QHb!zy8}xDG@*hLHXCoduzPPMEvqXNJL~2f?~WSP!tsfx##!q z{rR>#(@oy^MMXu5YHA8EUc4x?Bqk+Y+?uEn@;_m_Ic>ddo0)ldeR16Wk=ugH@5sh6 z{O@nh*63V?I5(5aqan4WaO#(@%E}xwJj*0BUUP@Rqm$i+VQ~||6Ir!~;ihk&AFj$U zB%~ObsH+pq`mR3AxF(N57Zw)&>VL|yxU_`GO1PIt+}u^8W*?H1!)3zp7XD<*yi>}@ zyn9y$?yAyuh@s#2E-Bmh)43qiT`zS`PR@P-6*>A*DdgG*4eqOW)};kCHM~}RsUtLF zgxH~b2M2GGlkcPBkY&w$`t&I-Bm~u{(F4nRAhQ#)Jw7opuRgk`M@d#*o|cE_eN9kT z^aFV`tt=K^T~D`$Z#T|i1exkmj`Chxxn2>DN1D z2sHeve9<1wQjwK~w7$MBvRyYa_LH_l6Vj3h66xP`lFBEEhlFoCBKeEcv{^dW26MUL zl5P0NF+;mlrbT@Z=%BWQCngeSAZ=`H^jPa)72U@!?0Y4j+qTdiUOOe7O`!Ezwbnc) z+S-9j+-(`NKsBRyJAj3q{U4?CNHK}C=i|H<8;&4em)%+M{-u|2R)e+ndaR>`FCe~O zK%qwWKiJ;hzDGb{wqSTq8oAa$=;f6V3oC2w)MNSRSNhXp)DpzlXow!Pyt+E(XQD+O z?zb__%x3hpQiJ}0p};mX$g|Jr=~FDH>94N~3z;Y-U#8cX5F<5bXjD~Ief8WVO;1n% z^I47h>({UPg>_ek4Q}*MXi8`5YHHeZRmPH;>EUw1ZB}?e9R->y<*Q(cdl_u5FEA(lW*U-2p>VM+U ze8=YalkHGJ6~t*5nFU;NsYQ1z`hYP!WW}+Q&E5MP%fmrudkg7Hb3wPw{r*?ytDnlW zCJdnspZvJXNp|J!d+>{~%vjExwkOs=FhZm6qED&o13r_x1B_}1lQ%iw5pEY5xQF6X!J21Vcd`f%ZF9QiV| zu|rV?#13(8&X(|lSAYJ;1IWXeahKKhiYaR}xVV&1Fqukr!LnV80r`7Xg5cz>HSre8 zjGeeznJywBKdXHjRqa-O`n1S9(6H7nG*jH){O7wzWC9Lken)G_jEs!^h|2u~a%h9i zz9ZtE5Fm=&PsI&>$9CV&SJEc8-x4uy7|%B5<)L0*o+?O7zww%bj#lNqs`^a3Mi(OY zBfmWKVGuKo$c*+W*R+Z^!3T0t3*?Y`+Xl8su>+qPT`T;)ucgr z{fZY^iiUtjM1(x>;;2Z^xD@Iv@*Q;e1H-0WNP0|s{4xICqEnySO0&cE|TA&zR2GwWn~>al zT>h2d`DVS;pa#(x6zK>HN9^EoFviM_@2HPZy08DGKiZlYT0EvN$gOBK^z6vbeY?GT`m*E(2Yj+hLpwhe~XG4asomcykPw;iYcL$VWkEs@cF( zV@Ipv!_|Sb0CuFWzK0A}y&v0Bj9iY^G)hfdsW!(dx}zWP_Ojf<6{I(vKTTj5Ay-B= zo2a%NZ}u02K8>V}OL4jW`O{txYsFf6i8_`<2~lUpyN{HbL}Ema;TZTz|= zmFA14Xck!`vJOK+HqufDcbAhPcqJ--*kVVo0qd3)`h zA+mnA0O?Pz0=|!r4-F$D29nG6B>&HUZZ$ja-dbu%m4(vQ6z(M8FzM--Ray%s;(K<7 zFcWdR%u9W3YC6;xZMS2%!#8KKt=2$Zx_QKwD*UE4eg zV=FgvmPzZ&3|^MD@j*O!?F4q^H&E52bSl_;Vo8Q^N6BHEOUWKU-G9u#58tVG0Ng z#BB*`Fp5o2r$k_81iJ88C*&~s7SYim%ao*)aadv6T2Ay$)N3oE?0Nlr!W=y|}sjhBV znxHeen8b@8s74KLaun)!AsRn^wDFKCo0VWCeEX+7W8YL2k&cm(kuK0tqA3LNV05bR zZpzEZc!+Z6&OI`+!fk!6k`YAVQcV|n-FkCz7tj~4560&F{44l=qfLR0D_ez=B4kgO zWRPn~PujY?|E^VE;pCMYH%T)QOXm(EAwhxe`V~PnU0AfQ~?F zOa0R4SJT`R2T6=kOToKeZ zH%Hy%y}N?5e0p@mClp#E0Bvqk)C`GC*o}^n8B4u((Y9}lti7uX9hjbBuRU2=S-uU= z>JD@6uGoiB(3hq2HBDv2+i@Sw23QOwl^qt4-F*;i6**<+O$&} z)yl(|M_`u6u6xOWt5qe^`$>`-hVRi>TV*9B=}!`YLd};*l`ad?3GY>aE0Urkw6*3E z|EB86U$a3mrOHZxS!vZrE_O0*LGV`gqmXOP;PDqy_m%m}iwnD3Q;y`vf`a#9pbgND z;^XpLt_U`!@cxoH+P*es%c8SKIW$okkFv6Nbkvb-`tsL=9`DDK&9Q;y6d?`{j`Svm zz;$%{y4#VYKXTMn`q2nX*ptXi@HSd4W}JA$fpPTIQjVMvyYQHR%Nr@(|7_$kz|)tW zQYLU-yv6;_Xt5Kb6#=PIbm~+TbbB-2;QqPa!Z)y?sVUcUb98bWTFN8#n6%kSzo9pZ zbkZ&^l~2d)Y@v4vPZ6u9;lT&fBu4(Roe&Z_wJ~08*l?q#>-SpQ+p&poV4r)WGxXfK zIh5DBe?C9i)OtF+2%~c3^#H`W&>lfDV59~|I*0}ii6=UDMH2G7r8yj_f z=wmR@v>#(ynt$>2`QHJ;+&iuq1C<=UW{hH<7$uN z)Y8=4M;i%R$$WUjVRkv;HBpJ=Bt5LGF6&ovtYbe#toqZ71Z@vxfG8UM)%YJXWT4w@Bs`+50{2zd90F}Om_Pv0(hV`M(F&y1(ZLbqb3#?AK{ze#B6pgHy zU7l>G=LfC!rdVvV7{tiuzJRp02C8UsK!!HHGKJT=dTYxeYV0%Im7J{XnsLtgcKw{w zJ9{f@>l%$*`E?^TCgN(C_$hQoGeo^VYO{=d;C}HV1igrNff`MlD8Hb92a44;Ct`eI zWoNJTsx4)C{5W`fq9*+M=5m)!Kw#AF7*Mdg^o#M+uoG*N zQh#w$N3~sftYO->Z(>GYUD~$nsYJa>XIBgI^2m%L^F8?d*BniVvoIWXh zLi2tXMf!*s&`qy{ZqB8>x~3}#xn$i zd^uWf90h5eqA6$ogjl@~o`X^Ba;s+2F-AUEaMq`NZTeG3zzDC2u!xBH;Yy!K6p?On zdb-2tF`UY3s)U5Z88jn%2e@+0xnH^-KdT{VdNnk9nt1q{k&@{Mfhvs(8z-$Nm+;=YX6+Pb=34?H*%esKYI zN|y*Ek0NFN0fPpDX>~(irx%nWO z?rA?#b@k1E9n}H`XNPgudhTs*1iL|v6|l7tIUw_>&vRxeg~12 zF)t@)oUfms-S5rW=B9pOZf@@MxhUYd$US3#$8|Ajh1y-Sp-fIXlYOw9q-W9wFsXJb zn!7hcwBE$gDnQilh$#%`!5aj-36aH4`>8;gVX5~uCo}V)t=F%fp2aO*#*&+Md)?Nh zvckeY8eFH5mC1nyv2fsPp>*}57oc?2fORpjI@2w);480v#tE>B=f3E;Y zq<*jCyy;^E7VZE0_dchItfuDs{UmzQC&)<(K%tyL&Io%;KuC6uS6Q%=YD?anS*SYK zhlhth{VgjfH=^fPR$96ofQN*j4*I=dEb48{l%koBrek7b8-5UObeIZtk8gtCFO$@^yfH>36j3MX8hOupR>zS%*mqna-mjPkkBZrA3 zbTnThj`^cerw+$*-7>>`-cM6N_FThp;<`1*PzdtGD|R%@LccMvw6qL1s^C^qQ(L7Y ziC3g!0}hhAYkXs*MEH{mfN|e*2cSh$WN8OK*jrn7o12@HSwsh(Erg5QQWg9h7_crg zto{382oMA5@$O_IP>WLhw-MAg-wt|MOA8AllI;PDo&YnyVbnf1*E;)4C^Z3s$4vsm z)YS9@Mo}&pJo|5)Th7Dk?NvW=z1H%ALObyW?XE}5e4W#b-2s0w<6``;|JazB-_~Cc z-N4^qhN$C=eRJ)fIE))BfuT*&ITHZw6X*@B@%tU0)V9)1FHbTsw62#9E4 zi=yl8O8$|Wrw{XMnI-5fm52jCLql6< zX9Q&eWI2Pihx|?e;jA)5JQo8bWWsQ4|A?N#z?xL+IQ4yef9kB*W)`@q%)Mf`5O;&9fnvl0xPh(Q7XA1W$p7D!LWei??@A&sSk%K5K<>Y~I4 z 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 literal 0 HcmV?d00001 diff --git a/website/templates/profile.mako b/website/templates/profile.mako index 1f71e1f59de..21146c044d0 100644 --- a/website/templates/profile.mako +++ b/website/templates/profile.mako @@ -1,4 +1,5 @@ <%inherit file="base.mako"/> + <%namespace name="render_nodes" file="util/render_nodes.mako" /> <%def name="title()">${profile["fullname"]} <%def name="resource()"><% @@ -64,7 +65,24 @@
    ${profile['display_absolute_url']} % endif + % if user['is_profile']: + + ${_("IAL") | n} + ${profile['_ial']}${profile['ial']} + + + ${_("AuthnContext-Class") | n} + ${profile['_aal']}${profile['aal']} + + % if profile.get('is_mfa') and profile.get('_aal') != "AAL2" and profile.get('mfa_url'): + +   + ${_("Configure multi-factor authentication (MFA)")} + + % endif + % endif + % if user['is_profile']:

    ${profile['activity_points'] or _("No")} ${ngettext('activity point', 'activity points', profile['activity_points'])}
    ${profile["number_projects"]} ${_("project")}${ngettext(' ', 's', profile["number_projects"])} @@ -73,6 +91,7 @@ ${_("Usage of storage")}
    ${profile['quota']['rate']}%, ${profile['quota']['used']} / ${profile['quota']['max']}[GB]

    + % endif
  • From 3341d4fa6fa059c8e6820fd4d6d8f1132287f68d Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Tue, 25 Nov 2025 23:31:56 +0900 Subject: [PATCH 13/38] =?UTF-8?q?redmine56420/test=5Finstitution=5Fauth.py?= =?UTF-8?q?=E3=81=AE=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/test_institution_auth.py | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/api_tests/institutions/views/test_institution_auth.py b/api_tests/institutions/views/test_institution_auth.py index 0b1192c6bf0..16be75b83ff 100644 --- a/api_tests/institutions/views/test_institution_auth.py +++ b/api_tests/institutions/views/test_institution_auth.py @@ -117,7 +117,7 @@ def test_new_user_created(self, app, url_auth_institution, institution): with capture_signals() as mock_signals: res = app.post(url_auth_institution, make_payload(institution, username)) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 assert mock_signals.signals_sent() == set([signals.user_confirmed]) user = OSFUser.objects.filter(username=username).first() @@ -134,7 +134,7 @@ def test_existing_user_found_but_not_affiliated(self, app, institution, url_auth with capture_signals() as mock_signals: res = app.post(url_auth_institution, make_payload(institution, username)) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 assert not mock_signals.signals_sent() user.reload() @@ -150,7 +150,7 @@ def test_user_found_and_affiliated(self, app, institution, url_auth_institution) with capture_signals() as mock_signals: res = app.post(url_auth_institution, make_payload(institution, username)) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 assert not mock_signals.signals_sent() user.reload() @@ -174,8 +174,7 @@ def test_new_user_names_guessed_if_not_provided(self, app, institution, url_auth username = 'user_created_with_fullname_only@osf.edu' res = app.post(url_auth_institution, make_payload(institution, username)) - assert res.status_code == 204 - + assert res.status_code == 204 or res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.fullname == 'Fake User' @@ -190,8 +189,7 @@ def test_new_user_names_used_when_provided(self, app, institution, url_auth_inst url_auth_institution, make_payload(institution, username, given_name='Foo', family_name='Bar') ) - assert res.status_code == 204 - + assert res.status_code == 204 or res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.fullname == 'Fake User' @@ -218,7 +216,7 @@ def test_user_active(self, app, institution, url_auth_institution): department='Fake Department', ) ) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 assert not mock_signals.signals_sent() user = OSFUser.objects.filter(username=username).first() @@ -257,7 +255,7 @@ def test_user_unclaimed(self, app, institution, url_auth_institution): department='Fake Department', ) ) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 assert mock_signals.signals_sent() == set([signals.user_confirmed]) user = OSFUser.objects.filter(username=username).first() @@ -292,7 +290,7 @@ def test_user_unconfirmed(self, app, institution, url_auth_institution): fullname='Fake User' ) ) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 assert mock_signals.signals_sent() == set([signals.user_confirmed]) user = OSFUser.objects.filter(username=username).first() @@ -414,8 +412,7 @@ def test_authenticate_jaSurname_and_jaGivenName_are_valid( jaGivenName=jagivenname, jaSurname=jasurname), expect_errors=True ) - assert res.status_code == 204 - + assert res.status_code == 204 or res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.ext.data['idp_attr']['fullname_ja'] == jagivenname + ' ' + jasurname @@ -429,7 +426,7 @@ def test_authenticate_jaGivenName_is_valid( make_payload(institution, username, jaGivenName=jagivenname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.given_name_ja == jagivenname @@ -443,7 +440,7 @@ def test_authenticate_jaSurname_is_valid( make_payload(institution, username, jaSurname=jasurname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.family_name_ja == jasurname @@ -457,7 +454,7 @@ def test_authenticate_jaMiddleNames_is_valid( make_payload(institution, username, jaMiddleNames=middlename), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.middle_names_ja == middlename @@ -471,7 +468,7 @@ def test_authenticate_givenname_is_valid( make_payload(institution, username, given_name=given_name), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.given_name == given_name @@ -485,7 +482,7 @@ def test_authenticate_familyname_is_valid( make_payload(institution, username, family_name=family_name), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.family_name == family_name @@ -499,7 +496,7 @@ def test_authenticate_middlename_is_valid( make_payload(institution, username, middle_names=middle_names), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.middle_names == middle_names @@ -518,7 +515,7 @@ def test_authenticate_jaOrganizationalUnitName_is_valid( organizationName=organizationname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user assert user.jobs[0]['department_ja'] == jaorganizationname @@ -537,7 +534,7 @@ def test_authenticate_OrganizationalUnitName_is_valid( organizationName=organizationname), expect_errors=True ) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user assert user.jobs[0]['department'] == organizationnameunit @@ -572,7 +569,7 @@ def test_with_new_attribute(self, mock, app, institution, url_auth_institution): gakunin_identity_assurance_method_reference=gakunin_identity_assurance_method_reference,) ) - assert res.status_code == 204 + assert res.status_code == 204 or res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user From 44b4988363d98cf5f7fd871275f67421c4879ff1 Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Fri, 28 Nov 2025 13:09:04 +0900 Subject: [PATCH 14/38] =?UTF-8?q?redmine56420/CAS=E3=81=AB=E6=B8=A1?= =?UTF-8?q?=E3=81=99MFA=20URL=E3=81=AE=E3=82=A4=E3=83=B3=E3=82=B9=E3=82=BF?= =?UTF-8?q?=E3=83=B3=E3=82=B9=E5=8C=96=EF=BC=8B=E5=A4=9A=E8=A8=80=E8=AA=9E?= =?UTF-8?q?=E3=83=AA=E3=82=BD=E3=83=BC=E3=82=B9=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/translations/django.pot | 3 +++ admin/translations/en/LC_MESSAGES/django.po | 3 +++ admin/translations/ja/LC_MESSAGES/django.po | 2 +- api/institutions/authentication.py | 21 ++++++++----------- api/institutions/views.py | 2 +- website/profile/utils.py | 5 ++--- .../translations/en/LC_MESSAGES/messages.po | 14 ++++++++++++- .../translations/ja/LC_MESSAGES/messages.po | 12 +++++++++++ website/translations/messages.pot | 11 ++++++++++ 9 files changed, 55 insertions(+), 18 deletions(-) diff --git a/admin/translations/django.pot b/admin/translations/django.pot index 8202c292ede..8bda4924c3b 100644 --- a/admin/translations/django.pot +++ b/admin/translations/django.pot @@ -3947,3 +3947,6 @@ msgstr "" msgid "No@encrypt_uploads" msgstr "" + +msgid "LoA update successful." +msgstr "" diff --git a/admin/translations/en/LC_MESSAGES/django.po b/admin/translations/en/LC_MESSAGES/django.po index 08b8647a759..8de0a4d62e9 100644 --- a/admin/translations/en/LC_MESSAGES/django.po +++ b/admin/translations/en/LC_MESSAGES/django.po @@ -3999,3 +3999,6 @@ msgstr "Yes" msgid "No@encrypt_uploads" msgstr "No" + +msgid "LoA update successful." +msgstr "" diff --git a/admin/translations/ja/LC_MESSAGES/django.po b/admin/translations/ja/LC_MESSAGES/django.po index 9b4e9d51af5..3d72923c846 100644 --- a/admin/translations/ja/LC_MESSAGES/django.po +++ b/admin/translations/ja/LC_MESSAGES/django.po @@ -4390,4 +4390,4 @@ msgstr "関連するすべてのワークフローテンプレートとワーク #: admin/templates/rdm_workflow/engine_list.html:196 msgid "If there are running workflow processes, deletion will fail." -msgstr "実行中のワークフロープロセスがある場合、エラーになります。" \ No newline at end of file +msgstr "実行中のワークフロープロセスがある場合、エラーになります。" diff --git a/api/institutions/authentication.py b/api/institutions/authentication.py index b5e48648367..46280cf0110 100644 --- a/api/institutions/authentication.py +++ b/api/institutions/authentication.py @@ -29,7 +29,6 @@ OSF_SUPPORT_EMAIL, DOMAIN, to_bool, - OSF_SERVICE_URL, CAS_SERVER_URL, OSF_MFA_URL, OSF_IAL2_STR, @@ -40,13 +39,10 @@ OSF_AAL2_VAR, ) from website.util.quota import update_default_storage +from future.moves.urllib.parse import urljoin logger = logging.getLogger(__name__) - -import logging -logger = logging.getLogger(__name__) - NEW_USER_NO_NAME = 'New User (no name)' def send_welcome(user, request): @@ -74,7 +70,6 @@ class InstitutionAuthentication(BaseAuthentication): """ media_type = 'text/plain' - context = {'mfa_url': ''} def authenticate(self, request): """ @@ -241,8 +236,8 @@ def get_next(obj, *args): # @R2022-48 loa + R-2023-55 message = '' - self.context['mfa_url'] = '' mfa_url = '' + mfa_url_tmp = '' if type(p_idp) is str: mfa_url_q = ( OSF_MFA_URL @@ -251,10 +246,9 @@ def get_next(obj, *args): + '&target=' + CAS_SERVER_URL + '/login?service=' - + OSF_SERVICE_URL - + '/profile/' + + urljoin(DOMAIN, "/profile/") ) - mfa_url = ( + mfa_url_tmp = ( CAS_SERVER_URL + '/logout?service=' + urllib.parse.quote(mfa_url_q, safe='') @@ -264,7 +258,7 @@ def get_next(obj, *args): if loa: if loa.aal == 2: if not re.search(OSF_AAL2_STR, str(aal)): - self.context['mfa_url'] = mfa_url + mfa_url = mfa_url_tmp elif loa.aal == 1: if not aal: message = ( @@ -427,7 +421,6 @@ def get_next(obj, *args): if aal and user.aal != aal: user.aal = aal user.save() - logger.info('MFA URL "{}"'.format(self.context['mfa_url'])) # Both created and activated accounts need to be updated and registered if created or activation_required: @@ -561,6 +554,10 @@ def get_next(obj, *args): # update every login. (for mAP API v1) init_cloud_gateway_groups(user, provider) + # R-2023-55 for MFA + logger.info('MFA URL "{}"'.format(mfa_url)) + user.context = {'mfa_url': mfa_url} + return user, None def login_by_eppn(): diff --git a/api/institutions/views.py b/api/institutions/views.py index 1936edfdf27..391f8632b82 100644 --- a/api/institutions/views.py +++ b/api/institutions/views.py @@ -198,7 +198,7 @@ class InstitutionAuth(JSONAPIBaseView, generics.CreateAPIView): view_name = 'institution-auth' def post(self, request, *args, **kwargs): - return Response(self.authentication_classes[0].context, status=status.HTTP_200_OK) + return Response(request.user.context, status=status.HTTP_200_OK) class InstitutionRegistrationList(InstitutionNodeList): diff --git a/website/profile/utils.py b/website/profile/utils.py index da8685dbf75..ee050371bf5 100644 --- a/website/profile/utils.py +++ b/website/profile/utils.py @@ -9,7 +9,7 @@ from osf.utils.permissions import READ from osf.utils import workflows from api.waffle.utils import storage_i18n_flag_active -from website.util import quota +from website.util import quota, web_url_for # @R2022-48 import re @@ -61,8 +61,7 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i + '&target=' + settings.CAS_SERVER_URL + '/login?service=' - + settings.OSF_SERVICE_URL - + '/profile/' + + web_url_for('user_profile', _absolute=True) ) mfa_url = ( settings.CAS_SERVER_URL diff --git a/website/translations/en/LC_MESSAGES/messages.po b/website/translations/en/LC_MESSAGES/messages.po index 6d84a486354..194a6b92213 100644 --- a/website/translations/en/LC_MESSAGES/messages.po +++ b/website/translations/en/LC_MESSAGES/messages.po @@ -4080,4 +4080,16 @@ msgid "\"Full name\", \"Family name\", \"Given name\", \"Family name (EN)\", \"G msgstr "" msgid "If you do not have an email address registered, please enter or add your email address in the \"Registered email address\" entry field first." -msgstr "" \ No newline at end of file +msgstr "" + +#: website/templates/profile.mako:70 +msgid "IAL" +msgstr "" + +#: website/templates/profile.mako:74 +msgid "AuthnContext-Class" +msgstr "" + +#: website/templates/profile.mako:80 +msgid "Configure multi-factor authentication (MFA)" +msgstr "Log in again using multi-factor authentication" diff --git a/website/translations/ja/LC_MESSAGES/messages.po b/website/translations/ja/LC_MESSAGES/messages.po index f94fbbaa2dd..137351a07d3 100644 --- a/website/translations/ja/LC_MESSAGES/messages.po +++ b/website/translations/ja/LC_MESSAGES/messages.po @@ -4439,6 +4439,18 @@ msgstr "%(summaryTitle)sのフォークを作成" msgid "Manage Contributors" msgstr "メンバー管理" +#: website/templates/profile.mako:70 +msgid "IAL" +msgstr "" + +#: website/templates/profile.mako:74 +msgid "AuthnContext-Class" +msgstr "" + +#: website/templates/profile.mako:80 +msgid "Configure multi-factor authentication (MFA)" +msgstr "多要素認証で再ログインする" + #~ msgid "" #~ "Because you have not configured the " #~ "{addon} add-on, your authentication will" diff --git a/website/translations/messages.pot b/website/translations/messages.pot index b1d25f76719..1da2787b976 100644 --- a/website/translations/messages.pot +++ b/website/translations/messages.pot @@ -4345,3 +4345,14 @@ msgstr "" msgid "Manage Contributors" msgstr "" +#: website/templates/profile.mako:70 +msgid "IAL" +msgstr "" + +#: website/templates/profile.mako:74 +msgid "AuthnContext-Class" +msgstr "" + +#: website/templates/profile.mako:80 +msgid "Configure multi-factor authentication (MFA)" +msgstr "" From 9cb3b4a08c38932054565ddf3a3b9924aab306f3 Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Fri, 28 Nov 2025 18:29:17 +0900 Subject: [PATCH 15/38] =?UTF-8?q?redmine56420/=E5=A4=9A=E8=A8=80=E8=AA=9E?= =?UTF-8?q?=E3=83=AA=E3=82=BD=E3=83=BC=E3=82=B9=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/loa/forms.py | 4 ++-- admin/translations/django.pot | 11 ++++++++++- admin/translations/en/LC_MESSAGES/django.po | 12 ++++++++++++ website/routes.py | 1 - website/settings/defaults.py | 1 - website/templates/profile.mako | 2 +- website/translations/en/LC_MESSAGES/messages.po | 4 ++-- website/translations/ja/LC_MESSAGES/messages.po | 2 +- website/translations/messages.pot | 2 +- 9 files changed, 29 insertions(+), 10 deletions(-) diff --git a/admin/loa/forms.py b/admin/loa/forms.py index d1aa314e63a..36d8c404c24 100644 --- a/admin/loa/forms.py +++ b/admin/loa/forms.py @@ -7,8 +7,8 @@ class LoAForm(forms.ModelForm): CHOICES_AAL = [(0, _('NULL')), (1, _('AAL1')), (2, _('AAL2'))] CHOICES_IAL = [(0, _('NULL')), (1, _('IAL1')), (2, _('IAL2'))] CHOICES_MFA = ( - (False, _('表示しない')), - (True, _('表示する')), + (False, _('Hide')), + (True, _('Show')), ) aal = forms.ChoiceField( choices=CHOICES_AAL, diff --git a/admin/translations/django.pot b/admin/translations/django.pot index 8bda4924c3b..c917acba686 100644 --- a/admin/translations/django.pot +++ b/admin/translations/django.pot @@ -3948,5 +3948,14 @@ msgstr "" msgid "No@encrypt_uploads" msgstr "" -msgid "LoA update successful." +msgid "Show" +msgstr "" + +msgid "Hide" +msgstr "" + +msgid "Level of Assurance" +msgstr "" + +msgid "Display MFA link button" msgstr "" diff --git a/admin/translations/en/LC_MESSAGES/django.po b/admin/translations/en/LC_MESSAGES/django.po index 8de0a4d62e9..21c548340ee 100644 --- a/admin/translations/en/LC_MESSAGES/django.po +++ b/admin/translations/en/LC_MESSAGES/django.po @@ -4002,3 +4002,15 @@ msgstr "No" msgid "LoA update successful." msgstr "" + +msgid "Show" +msgstr "" + +msgid "Hide" +msgstr "" + +msgid "Level of Assurance" +msgstr "" + +msgid "Display MFA link button" +msgstr "" diff --git a/website/routes.py b/website/routes.py index cefca3f8512..e1d3cf4864c 100644 --- a/website/routes.py +++ b/website/routes.py @@ -172,7 +172,6 @@ def get_globals(): 'sjson': lambda s: sanitize.safe_json(s), 'webpack_asset': paths.webpack_asset, 'osf_url': settings.INTERNAL_DOMAIN, - 'osf_service_url': settings.OSF_SERVICE_URL, # R-2022-48 'waterbutler_url': settings.WATERBUTLER_URL, 'cas_server_url': settings.CAS_SERVER_URL, # R-2022-48 'login_url': cas.get_login_url(request_login_url), diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 41f318515f0..ba9c55d5c36 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -374,7 +374,6 @@ def parent_dir(path): CAS_SERVER_URL = 'http://localhost:8080' MFR_SERVER_URL = 'http://localhost:7778' -OSF_SERVICE_URL = '' # R-2022-48 OSF_MFA_URL = '' # R-2022-48 ###### ARCHIVER ########### diff --git a/website/templates/profile.mako b/website/templates/profile.mako index 21146c044d0..2dedebcd0c8 100644 --- a/website/templates/profile.mako +++ b/website/templates/profile.mako @@ -77,7 +77,7 @@ % if profile.get('is_mfa') and profile.get('_aal') != "AAL2" and profile.get('mfa_url'):   - ${_("Configure multi-factor authentication (MFA)")} + ${_("Log in again using multi-factor authentication")} % endif % endif diff --git a/website/translations/en/LC_MESSAGES/messages.po b/website/translations/en/LC_MESSAGES/messages.po index 194a6b92213..dff3eba901f 100644 --- a/website/translations/en/LC_MESSAGES/messages.po +++ b/website/translations/en/LC_MESSAGES/messages.po @@ -4091,5 +4091,5 @@ msgid "AuthnContext-Class" msgstr "" #: website/templates/profile.mako:80 -msgid "Configure multi-factor authentication (MFA)" -msgstr "Log in again using multi-factor authentication" +msgid "Log in again using multi-factor authentication" +msgstr "" diff --git a/website/translations/ja/LC_MESSAGES/messages.po b/website/translations/ja/LC_MESSAGES/messages.po index 137351a07d3..b20ec9c95ed 100644 --- a/website/translations/ja/LC_MESSAGES/messages.po +++ b/website/translations/ja/LC_MESSAGES/messages.po @@ -4448,7 +4448,7 @@ msgid "AuthnContext-Class" msgstr "" #: website/templates/profile.mako:80 -msgid "Configure multi-factor authentication (MFA)" +msgid "Log in again using multi-factor authentication" msgstr "多要素認証で再ログインする" #~ msgid "" diff --git a/website/translations/messages.pot b/website/translations/messages.pot index 1da2787b976..08be18af240 100644 --- a/website/translations/messages.pot +++ b/website/translations/messages.pot @@ -4354,5 +4354,5 @@ msgid "AuthnContext-Class" msgstr "" #: website/templates/profile.mako:80 -msgid "Configure multi-factor authentication (MFA)" +msgid "Log in again using multi-factor authentication" msgstr "" From ba14a808b05e4afe0f7c55d0e77cbbbeb8f3713c Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Fri, 28 Nov 2025 15:45:26 +0900 Subject: [PATCH 16/38] =?UTF-8?q?redmine56420/MFA=20URL=E3=81=AE=E6=9C=80?= =?UTF-8?q?=E9=81=A9=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/institutions/authentication.py | 27 ++++++++++++-------------- website/profile/utils.py | 31 +++++++++++++++--------------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/api/institutions/authentication.py b/api/institutions/authentication.py index 46280cf0110..dcdad6946ff 100644 --- a/api/institutions/authentication.py +++ b/api/institutions/authentication.py @@ -8,7 +8,7 @@ # @R2022-48 loa import re -import urllib.parse +from urllib.parse import urlencode #from django.utils import timezone from rest_framework.authentication import BaseAuthentication @@ -239,20 +239,17 @@ def get_next(obj, *args): mfa_url = '' mfa_url_tmp = '' if type(p_idp) is str: - mfa_url_q = ( - OSF_MFA_URL - + '?entityID=' - + p_idp - + '&target=' - + CAS_SERVER_URL - + '/login?service=' - + urljoin(DOMAIN, "/profile/") - ) - mfa_url_tmp = ( - CAS_SERVER_URL - + '/logout?service=' - + urllib.parse.quote(mfa_url_q, safe='') - ) + profile_url = urljoin(DOMAIN, '/profile/') + + login_url = CAS_SERVER_URL + '/login?' + urlencode({ + 'service': profile_url, + }) + + mfa_url_tmp = OSF_MFA_URL + '?' + urlencode({ + 'entityID': p_idp, + 'target': login_url, + }) + loa_flag = True loa = LoA.objects.get_or_none(institution_id=institution.id) if loa: diff --git a/website/profile/utils.py b/website/profile/utils.py index ee050371bf5..e0448c942d4 100644 --- a/website/profile/utils.py +++ b/website/profile/utils.py @@ -13,7 +13,7 @@ # @R2022-48 import re -import urllib.parse +from urllib.parse import urlencode def get_profile_image_url(user, size=settings.PROFILE_IMAGE_MEDIUM): @@ -54,20 +54,21 @@ def serialize_user(user, node=None, admin=False, full=False, is_profile=False, i mfa_url = '' entity_id = idp_attrs.get('idp') if entity_id is not None: - mfa_url_q = ( - settings.OSF_MFA_URL - + '?entityID=' - + entity_id - + '&target=' - + settings.CAS_SERVER_URL - + '/login?service=' - + web_url_for('user_profile', _absolute=True) - ) - mfa_url = ( - settings.CAS_SERVER_URL - + '/logout?service=' - + urllib.parse.quote(mfa_url_q, safe='') - ) + profile_url = web_url_for('user_profile', _absolute=True) + + login_url = settings.CAS_SERVER_URL + '/login?' + urlencode({ + 'service': profile_url, + }) + + mfa_url_q = settings.OSF_MFA_URL + '?' + urlencode({ + 'entityID': entity_id, + 'target': login_url, + }) + + # CAS logout → MFA の redirect + mfa_url = settings.CAS_SERVER_URL + '/logout?' + urlencode({ + 'service': mfa_url_q, + }) loa = LoA.objects.get_or_none(institution_id=idp_attrs.get('id')) if loa is not None: From 11b3293eda8318567195643759c2bb6fee407dea Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Fri, 3 Apr 2026 15:23:17 +0900 Subject: [PATCH 17/38] redmine56420/Resolve conflicts with the develop --- admin/base/urls.py | 1 + admin/translations/django.pot | 3 +++ admin/translations/ja/LC_MESSAGES/django.po | 15 +++++++++++++++ ...r_2025_23_55789.py => 0265_r_2025_23_55789.py} | 2 +- ...r_2025_23_55789.py => 0266_r_2025_23_55789.py} | 2 +- osf/models/__init__.py | 1 + 6 files changed, 22 insertions(+), 2 deletions(-) rename osf/migrations/{0257_r_2025_23_55789.py => 0265_r_2025_23_55789.py} (92%) rename osf/migrations/{0258_r_2025_23_55789.py => 0266_r_2025_23_55789.py} (98%) diff --git a/admin/base/urls.py b/admin/base/urls.py index 6b399385ad0..c63c9d20fbd 100644 --- a/admin/base/urls.py +++ b/admin/base/urls.py @@ -55,6 +55,7 @@ include('admin.user_identification_information_admin.urls', namespace='user_identification_information_admin')), url(r'^project_limit_number/', include('admin.project_limit_number.urls', namespace='project_limit_number')), url(r'^rdm_workflow/', include('admin.rdm_workflow.urls', namespace='rdm_workflow')), + url(r'^loa/', include('admin.loa.urls', namespace='loa')), ]), ), ] diff --git a/admin/translations/django.pot b/admin/translations/django.pot index c917acba686..bf8d8dec6ad 100644 --- a/admin/translations/django.pot +++ b/admin/translations/django.pot @@ -3948,6 +3948,9 @@ msgstr "" msgid "No@encrypt_uploads" msgstr "" +msgid "LoA update successful." +msgstr "" + msgid "Show" msgstr "" diff --git a/admin/translations/ja/LC_MESSAGES/django.po b/admin/translations/ja/LC_MESSAGES/django.po index 3d72923c846..4e10fa749a8 100644 --- a/admin/translations/ja/LC_MESSAGES/django.po +++ b/admin/translations/ja/LC_MESSAGES/django.po @@ -4391,3 +4391,18 @@ msgstr "関連するすべてのワークフローテンプレートとワーク #: admin/templates/rdm_workflow/engine_list.html:196 msgid "If there are running workflow processes, deletion will fail." msgstr "実行中のワークフロープロセスがある場合、エラーになります。" + +msgid "LoA update successful." +msgstr "LoAの更新に成功しました。" + +msgid "Show" +msgstr "表示する" + +msgid "Hide" +msgstr "表示しない" + +msgid "Level of Assurance" +msgstr "要求する保証レベルの設定" + +msgid "Display MFA link button" +msgstr "多要素認証実行ボタンの表示" diff --git a/osf/migrations/0257_r_2025_23_55789.py b/osf/migrations/0265_r_2025_23_55789.py similarity index 92% rename from osf/migrations/0257_r_2025_23_55789.py rename to osf/migrations/0265_r_2025_23_55789.py index 2b373546685..f01009afb6a 100644 --- a/osf/migrations/0257_r_2025_23_55789.py +++ b/osf/migrations/0265_r_2025_23_55789.py @@ -5,7 +5,7 @@ class Migration(migrations.Migration): dependencies = [ - ('osf', '0257_merge_20251023_1304'), + ('osf', '0264_merge_20260218_0749'), ] operations = [ diff --git a/osf/migrations/0258_r_2025_23_55789.py b/osf/migrations/0266_r_2025_23_55789.py similarity index 98% rename from osf/migrations/0258_r_2025_23_55789.py rename to osf/migrations/0266_r_2025_23_55789.py index 2bd25b57260..a374b5d3332 100644 --- a/osf/migrations/0258_r_2025_23_55789.py +++ b/osf/migrations/0266_r_2025_23_55789.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ - ('osf', '0257_r_2025_23_55789'), + ('osf', '0265_r_2025_23_55789'), ] operations = [ diff --git a/osf/models/__init__.py b/osf/models/__init__.py index d82b6d44124..48e1367bc68 100644 --- a/osf/models/__init__.py +++ b/osf/models/__init__.py @@ -72,3 +72,4 @@ from osf.models.project_limit_number_template_attribute import ProjectLimitNumberTemplateAttribute # noqa from osf.models.project_limit_number_setting import ProjectLimitNumberSetting # noqa from osf.models.project_limit_number_setting_attribute import ProjectLimitNumberSettingAttribute # noqa +from osf.models.loa import LoA # noqa From 95d564bbeccb6373f30b42ab8cbca1f9edb45b90 Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Fri, 3 Apr 2026 15:29:16 +0900 Subject: [PATCH 18/38] R-2025-23/Add test code for MFA --- admin_tests/loa/__init__.py | 0 admin_tests/loa/test_forms.py | 82 +++ admin_tests/loa/test_views.py | 399 ++++++++++++++ .../views/test_institution_auth_loa.py | 521 ++++++++++++++++++ osf_tests/test_loa.py | 121 ++++ tests/test_profile_utils_loa.py | 240 ++++++++ 6 files changed, 1363 insertions(+) create mode 100644 admin_tests/loa/__init__.py create mode 100644 admin_tests/loa/test_forms.py create mode 100644 admin_tests/loa/test_views.py create mode 100644 api_tests/institutions/views/test_institution_auth_loa.py create mode 100644 osf_tests/test_loa.py create mode 100644 tests/test_profile_utils_loa.py diff --git a/admin_tests/loa/__init__.py b/admin_tests/loa/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/admin_tests/loa/test_forms.py b/admin_tests/loa/test_forms.py new file mode 100644 index 00000000000..ac73e8cdc47 --- /dev/null +++ b/admin_tests/loa/test_forms.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +"""Tests for admin.loa.forms.LoAForm.""" +import pytest + +from admin.loa.forms import LoAForm +from osf.models.loa import LoA +from osf_tests.factories import InstitutionFactory, AuthUserFactory + +pytestmark = pytest.mark.django_db + + +class TestLoAForm: + """Tests for LoAForm.""" + + def test_form_fields(self): + form = LoAForm() + assert 'aal' in form.fields + assert 'ial' in form.fields + assert 'is_mfa' in form.fields + + def test_form_valid_with_all_fields(self): + form = LoAForm(data={'aal': '2', 'ial': '2', 'is_mfa': 'True'}) + assert form.is_valid(), form.errors + + def test_form_valid_with_zero_values(self): + """aal=0 and ial=0 represent the NULL choice.""" + form = LoAForm(data={'aal': '0', 'ial': '0', 'is_mfa': 'False'}) + assert form.is_valid(), form.errors + + def test_form_valid_with_empty_fields(self): + """All fields are not required, so empty strings should be accepted.""" + form = LoAForm(data={'aal': '', 'ial': '', 'is_mfa': ''}) + assert form.is_valid(), form.errors + + def test_form_aal_choices(self): + form = LoAForm() + aal_values = [c[0] for c in form.fields['aal'].choices] + assert 0 in aal_values + assert 1 in aal_values + assert 2 in aal_values + + def test_form_ial_choices(self): + form = LoAForm() + ial_values = [c[0] for c in form.fields['ial'].choices] + assert 0 in ial_values + assert 1 in ial_values + assert 2 in ial_values + + def test_form_is_mfa_choices(self): + form = LoAForm() + mfa_values = [c[0] for c in form.fields['is_mfa'].choices] + assert False in mfa_values + assert True in mfa_values + + def test_form_widget_css_class(self): + """All field widgets should have 'form-control form-control-sm' CSS class.""" + form = LoAForm() + for field in form.fields.values(): + assert 'form-control form-control-sm' in field.widget.attrs.get('class', '') + + def test_form_with_instance(self): + """Form should populate correctly from an existing LoA instance.""" + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, aal=2, ial=1, is_mfa=True, modifier=modifier, + ) + form = LoAForm(instance=loa) + assert form.initial['aal'] == 2 + assert form.initial['ial'] == 1 + assert form.initial['is_mfa'] is True + + def test_form_meta_model(self): + assert LoAForm.Meta.model is LoA + + def test_form_meta_fields(self): + assert LoAForm.Meta.fields == ('aal', 'ial', 'is_mfa') + + def test_form_invalid_aal_choice(self): + form = LoAForm(data={'aal': '99', 'ial': '1', 'is_mfa': 'False'}) + assert not form.is_valid() + assert 'aal' in form.errors diff --git a/admin_tests/loa/test_views.py b/admin_tests/loa/test_views.py new file mode 100644 index 00000000000..5695ab32d4d --- /dev/null +++ b/admin_tests/loa/test_views.py @@ -0,0 +1,399 @@ +# -*- coding: utf-8 -*- +"""Tests for admin.loa.views (ListLoA and BulkAddLoA).""" +from urllib.parse import urlencode + +import pytest +from django.contrib.auth.models import AnonymousUser +from django.contrib.messages.storage.fallback import FallbackStorage +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from django.test import RequestFactory +from django.urls import reverse + +from admin.loa import views +from admin_tests.utilities import setup_user_view +from osf.models.loa import LoA +from osf_tests.factories import AuthUserFactory, InstitutionFactory +from tests.base import AdminTestCase + +pytestmark = pytest.mark.django_db + + +class TestListLoA(AdminTestCase): + """Tests for ListLoA view.""" + + def setUp(self): + super(TestListLoA, self).setUp() + + self.institution01 = InstitutionFactory(name='inst01') + self.institution02 = InstitutionFactory(name='inst02') + + # Anonymous user + self.anon = AnonymousUser() + + # Superuser + self.superuser = AuthUserFactory(fullname='superuser') + self.superuser.is_superuser = True + self.superuser.is_staff = True + self.superuser.save() + + # Institutional admin for institution01 + self.inst01_admin = AuthUserFactory(fullname='admin_inst01') + self.inst01_admin.is_staff = True + self.inst01_admin.affiliated_institutions.add(self.institution01) + self.inst01_admin.save() + + # Institutional admin for institution02 + self.inst02_admin = AuthUserFactory(fullname='admin_inst02') + self.inst02_admin.is_staff = True + self.inst02_admin.affiliated_institutions.add(self.institution02) + self.inst02_admin.save() + + # Normal user (no staff, no superuser) + self.normal_user = AuthUserFactory(fullname='normal_user') + self.normal_user.is_staff = False + self.normal_user.is_superuser = False + self.normal_user.save() + + # --- dispatch tests --- + + def test_dispatch_unauthenticated_user(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.anon) + with pytest.raises(PermissionDenied): + view.dispatch(request) + + def test_dispatch_invalid_institution_id(self): + request = RequestFactory().get('/fake_path', {'institution_id': 'abc'}) + view = views.ListLoA() + view = setup_user_view(view, request, user=self.superuser) + # render_bad_request_response requires 400.html template; + # verify that the ValueError is caught and a bad-request path is taken. + from django.template.exceptions import TemplateDoesNotExist + with pytest.raises((TemplateDoesNotExist, ValueError)): + view.dispatch(request) + + def test_dispatch_valid_institution_id(self): + request = RequestFactory().get( + '/fake_path', {'institution_id': str(self.institution01.id)} + ) + request.user = self.superuser + view = views.ListLoA() + view.request = request + view.args = () + view.kwargs = {} + response = view.dispatch(request) + assert response.status_code == 200 + + # --- test_func tests --- + + def test_test_func_superuser_without_institution_id(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = None + assert view.test_func() is True + + def test_test_func_institutional_admin_without_institution_id(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.inst01_admin) + view.institution_id = None + assert view.test_func() is True + + def test_test_func_normal_user_without_institution_id(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.normal_user) + view.institution_id = None + assert view.test_func() is False + + def test_test_func_superuser_with_valid_institution_id(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = self.institution01.id + assert view.test_func() is True + + def test_test_func_institutional_admin_own_institution(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.inst01_admin) + view.institution_id = self.institution01.id + assert view.test_func() is True + + def test_test_func_institutional_admin_other_institution(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.inst01_admin) + view.institution_id = self.institution02.id + assert view.test_func() is False + + def test_test_func_nonexistent_institution_id(self): + request = RequestFactory().get('/fake_path') + view = views.ListLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = 99999 + with pytest.raises(Http404): + view.test_func() + + # --- get_context_data tests --- + + def test_get_context_data_superuser(self): + modifier = self.superuser + LoA.objects.create( + institution=self.institution01, aal=2, ial=1, is_mfa=True, modifier=modifier, + ) + + request = RequestFactory().get( + '/fake_path', {'institution_id': str(self.institution01.id)} + ) + view = views.ListLoA() + view = setup_user_view(view, request, user=self.superuser) + view.kwargs = {} + view.institution_id = self.institution01.id + + ctx = view.get_context_data() + assert 'institutions' in ctx + assert 'institution_id' in ctx + assert 'formset_loa' in ctx + assert ctx['institution_id'] == self.institution01.id + + def test_get_context_data_institutional_admin(self): + request = RequestFactory().get( + '/fake_path', {'institution_id': str(self.institution01.id)} + ) + view = views.ListLoA() + view = setup_user_view(view, request, user=self.inst01_admin) + view.kwargs = {} + view.institution_id = self.institution01.id + + ctx = view.get_context_data() + # Institutional admin should only see their own institution(s) + institution_ids = [inst.id for inst in ctx['institutions']] + assert self.institution01.id in institution_ids + assert self.institution02.id not in institution_ids + + def test_get_context_data_superuser_sees_all_institutions(self): + request = RequestFactory().get( + '/fake_path', {'institution_id': str(self.institution01.id)} + ) + view = views.ListLoA() + view = setup_user_view(view, request, user=self.superuser) + view.kwargs = {} + view.institution_id = self.institution01.id + + ctx = view.get_context_data() + institution_ids = [inst.id for inst in ctx['institutions']] + assert self.institution01.id in institution_ids + assert self.institution02.id in institution_ids + + def test_get_context_data_permission_denied_for_non_admin(self): + request = RequestFactory().get( + '/fake_path', {'institution_id': str(self.institution01.id)} + ) + view = views.ListLoA() + view = setup_user_view(view, request, user=self.normal_user) + view.kwargs = {} + view.institution_id = self.institution01.id + + with pytest.raises(PermissionDenied): + view.get_context_data() + + def test_get_context_data_no_existing_loa(self): + """When no LoA exists for the institution, formset_loa should be unbound.""" + request = RequestFactory().get( + '/fake_path', {'institution_id': str(self.institution01.id)} + ) + view = views.ListLoA() + view = setup_user_view(view, request, user=self.superuser) + view.kwargs = {} + view.institution_id = self.institution01.id + + ctx = view.get_context_data() + assert ctx['formset_loa'] is not None + + +class TestBulkAddLoA(AdminTestCase): + """Tests for BulkAddLoA view.""" + + def setUp(self): + super(TestBulkAddLoA, self).setUp() + + self.institution01 = InstitutionFactory(name='inst01') + self.institution02 = InstitutionFactory(name='inst02') + + # Anonymous user + self.anon = AnonymousUser() + + # Superuser + self.superuser = AuthUserFactory(fullname='superuser') + self.superuser.is_superuser = True + self.superuser.is_staff = True + self.superuser.save() + + # Institutional admin for institution01 + self.inst01_admin = AuthUserFactory(fullname='admin_inst01') + self.inst01_admin.is_staff = True + self.inst01_admin.affiliated_institutions.add(self.institution01) + self.inst01_admin.save() + + self.view = views.BulkAddLoA.as_view() + + # --- dispatch tests --- + + def test_dispatch_unauthenticated(self): + request = RequestFactory().post( + reverse('loa:bulk_add'), + {'institution_id': self.institution01.id, 'aal': '1', 'ial': '1', 'is_mfa': 'False'}, + ) + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.anon) + with pytest.raises(PermissionDenied): + view.dispatch(request) + + def test_dispatch_missing_institution_id(self): + request = RequestFactory().post( + reverse('loa:bulk_add'), + {'aal': '1', 'ial': '1', 'is_mfa': 'False'}, + ) + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.superuser) + from django.template.exceptions import TemplateDoesNotExist + with pytest.raises((TemplateDoesNotExist, ValueError)): + view.dispatch(request) + + def test_dispatch_invalid_institution_id(self): + request = RequestFactory().post( + reverse('loa:bulk_add'), + {'institution_id': 'abc', 'aal': '1', 'ial': '1', 'is_mfa': 'False'}, + ) + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.superuser) + from django.template.exceptions import TemplateDoesNotExist + with pytest.raises((TemplateDoesNotExist, ValueError)): + view.dispatch(request) + + # --- test_func tests --- + + def test_test_func_superuser(self): + request = RequestFactory().post('/fake_path') + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = self.institution01.id + assert view.test_func() is True + + def test_test_func_institutional_admin_own_institution(self): + request = RequestFactory().post('/fake_path') + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.inst01_admin) + view.institution_id = self.institution01.id + assert view.test_func() is True + + def test_test_func_institutional_admin_other_institution(self): + request = RequestFactory().post('/fake_path') + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.inst01_admin) + view.institution_id = self.institution02.id + assert view.test_func() is False + + def test_test_func_nonexistent_institution(self): + request = RequestFactory().post('/fake_path') + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = 99999 + with pytest.raises(Http404): + view.test_func() + + def test_test_func_deleted_institution(self): + deleted_inst = InstitutionFactory(name='deleted_inst') + deleted_inst.is_deleted = True + deleted_inst.save() + request = RequestFactory().post('/fake_path') + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = deleted_inst.id + with pytest.raises(Http404): + view.test_func() + + # --- post tests --- + + def test_post_creates_new_loa(self): + request = RequestFactory().post( + reverse('loa:bulk_add'), + { + 'institution_id': str(self.institution01.id), + 'aal': '2', + 'ial': '1', + 'is_mfa': 'True', + }, + ) + request.user = self.superuser + setattr(request, 'session', 'session') + setattr(request, '_messages', FallbackStorage(request)) + + # Manually invoke the post method + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = self.institution01.id + response = view.post(request) + + assert response.status_code == 302 + loa = LoA.objects.get(institution_id=self.institution01.id) + assert loa.aal == 2 + assert loa.ial == 1 + assert loa.modifier == self.superuser + + def test_post_updates_existing_loa(self): + # Create initial LoA + LoA.objects.create( + institution=self.institution01, aal=1, ial=0, is_mfa=False, + modifier=self.superuser, + ) + request = RequestFactory().post( + reverse('loa:bulk_add'), + { + 'institution_id': str(self.institution01.id), + 'aal': '2', + 'ial': '2', + 'is_mfa': 'True', + }, + ) + request.user = self.inst01_admin + setattr(request, 'session', 'session') + setattr(request, '_messages', FallbackStorage(request)) + + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.inst01_admin) + view.institution_id = self.institution01.id + response = view.post(request) + + assert response.status_code == 302 + loa = LoA.objects.get(institution_id=self.institution01.id) + assert loa.aal == 2 + assert loa.ial == 2 + assert loa.modifier == self.inst01_admin + + def test_post_redirect_url_contains_institution_id(self): + request = RequestFactory().post( + reverse('loa:bulk_add'), + { + 'institution_id': str(self.institution01.id), + 'aal': '1', + 'ial': '1', + 'is_mfa': 'False', + }, + ) + request.user = self.superuser + setattr(request, 'session', 'session') + setattr(request, '_messages', FallbackStorage(request)) + + view = views.BulkAddLoA() + view = setup_user_view(view, request, user=self.superuser) + view.institution_id = self.institution01.id + response = view.post(request) + + assert response.status_code == 302 + expected_query = urlencode({'institution_id': str(self.institution01.id)}) + assert expected_query in response.url diff --git a/api_tests/institutions/views/test_institution_auth_loa.py b/api_tests/institutions/views/test_institution_auth_loa.py new file mode 100644 index 00000000000..a8d1c285a77 --- /dev/null +++ b/api_tests/institutions/views/test_institution_auth_loa.py @@ -0,0 +1,521 @@ +# -*- coding: utf-8 -*- +"""Tests for LoA (Level of Assurance) validation in institution authentication. + +Covers: + - IAL / AAL extraction from eduPersonAssurance + - LoA validation logic (AAL2 required → MFA redirect, AAL1 required → ValidationError, IAL checks) + - MFA URL construction with urlencode() + - user.context containing mfa_url in response + - ValidationError raised when LoA requirements are not met + - user.ial / user.aal are persisted after successful authentication +""" +import json + +import jwe +import jwt +import mock +import pytest + +from api.base import settings +from api.base.settings.defaults import API_BASE + +from osf.models import OSFUser +from osf.models.loa import LoA +from osf_tests.factories import InstitutionFactory, UserFactory +from website.settings import ( + OSF_AAL2_VAR, + OSF_AAL1_VAR, + OSF_IAL2_VAR, +) + +def make_user(username, fullname): + return UserFactory(username=username, fullname=fullname) + + +def make_payload( + institution, + username, + fullname='Fake User', + given_name='', + family_name='', + middle_names='', + department='', + edu_person_assurance='', + shib_authn_context_class='', + idp=None, + **extra_user_fields, +): + """Build a JWE/JWT payload for institution auth. + + Accepts ``edu_person_assurance`` and ``shib_authn_context_class`` + as explicit keyword arguments so that LoA-related tests can easily + set them. Any additional user fields can be passed via **extra_user_fields. + + ``idp`` can be set to a string (e.g. an entityID URL) to make the + authentication code treat the IdP value as a string, which is required + for MFA URL generation (``type(p_idp) is str`` check). When *None* + the default ``institution.email_domains`` (a list) is used. + """ + user_dict = { + 'middleNames': middle_names, + 'familyName': family_name, + 'givenName': given_name, + 'fullname': fullname, + 'suffix': '', + 'username': username, + 'department': department, + 'eduPersonAssurance': edu_person_assurance, + 'Shib-AuthnContext-Class': shib_authn_context_class, + # defaults for other fields expected by authentication + 'jaGivenName': '', + 'jaSurname': '', + 'jaDisplayName': '', + 'jaFullname': '', + 'jaMiddleNames': '', + 'jaOrganizationalUnitName': '', + 'organizationalUnitName': '', + 'organizationName': '', + 'eduPersonAffiliation': '', + 'eduPersonScopedAffiliation': '', + 'eduPersonTargetedID': '', + 'eduPersonUniqueId': '', + 'eduPersonOrcid': '', + 'isMemberOf': '', + 'gakuninScopedPersonalUniqueCode': '', + 'gakuninIdentityAssuranceOrganization': '', + 'gakuninIdentityAssuranceMethodReference': '', + } + user_dict.update(extra_user_fields) + + data = { + 'provider': { + 'idp': idp if idp is not None else institution.email_domains, + 'id': institution._id, + 'user': user_dict, + } + } + + return jwe.encrypt( + jwt.encode( + { + 'sub': username, + 'data': json.dumps(data), + }, + settings.JWT_SECRET, + algorithm='HS256', + ), + settings.JWE_SECRET, + ) + + +@pytest.mark.django_db +class TestInstitutionAuthLoA: + """Tests for LoA validation during institution authentication.""" + + @pytest.fixture() + def institution(self): + return InstitutionFactory() + + @pytest.fixture() + def url_auth_institution(self): + return '/{0}institutions/auth/'.format(API_BASE) + + @pytest.fixture() + def app(self): + from tests.json_api_test_app import JSONAPITestApp + return JSONAPITestApp() + + # --------------------------------------------------------------- + # IAL / AAL extraction from eduPersonAssurance + # --------------------------------------------------------------- + + def test_aal2_extracted_from_edu_person_assurance( + self, app, institution, url_auth_institution, + ): + """When eduPersonAssurance contains AAL2 URL, user.aal should be set.""" + username = 'user_aal2@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL2', + ), + ) + assert res.status_code in (200, 204) + user = OSFUser.objects.get(username=username) + assert user.aal == OSF_AAL2_VAR + + def test_aal1_extracted_from_edu_person_assurance( + self, app, institution, url_auth_institution, + ): + username = 'user_aal1@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL1', + ), + ) + assert res.status_code in (200, 204) + user = OSFUser.objects.get(username=username) + assert user.aal == OSF_AAL1_VAR + + def test_ial2_extracted_from_edu_person_assurance( + self, app, institution, url_auth_institution, + ): + username = 'user_ial2@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/IAL2', + ), + ) + assert res.status_code in (200, 204) + user = OSFUser.objects.get(username=username) + assert user.ial == OSF_IAL2_VAR + + def test_aal_falls_back_to_shib_authn_context_class( + self, app, institution, url_auth_institution, + ): + """When eduPersonAssurance has no AAL, Shib-AuthnContext-Class is used.""" + username = 'user_shib@inst.edu' + shib_value = 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='', + shib_authn_context_class=shib_value, + ), + ) + assert res.status_code in (200, 204) + user = OSFUser.objects.get(username=username) + assert user.aal == shib_value + + def test_both_aal2_and_ial2_in_edu_person_assurance( + self, app, institution, url_auth_institution, + ): + """Multi-value eduPersonAssurance containing both AAL2 and IAL2.""" + username = 'user_both@inst.edu' + combined = ( + 'https://www.gakunin.jp/profile/AAL2;' + 'https://www.gakunin.jp/profile/IAL2' + ) + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance=combined, + ), + ) + assert res.status_code in (200, 204) + user = OSFUser.objects.get(username=username) + assert user.aal == OSF_AAL2_VAR + assert user.ial == OSF_IAL2_VAR + + # --------------------------------------------------------------- + # LoA validation — no LoA record → pass through + # --------------------------------------------------------------- + + def test_no_loa_record_allows_login( + self, app, institution, url_auth_institution, + ): + """If no LoA is configured for the institution, login should succeed.""" + username = 'user_noloa@inst.edu' + res = app.post( + url_auth_institution, + make_payload(institution, username), + ) + assert res.status_code in (200, 204) + + # --------------------------------------------------------------- + # LoA validation — AAL2 required + # --------------------------------------------------------------- + + def test_aal2_required_user_has_aal2_passes( + self, app, institution, url_auth_institution, + ): + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + username = 'user_aal2_ok@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL2', + ), + ) + assert res.status_code in (200, 204) + # mfa_url should be empty because AAL2 requirement is met + if res.status_code == 200: + assert res.json.get('mfa_url', '') == '' + + @mock.patch('api.institutions.authentication.OSF_MFA_URL', 'https://mfa.example.com/ds') + def test_aal2_required_user_has_aal1_returns_mfa_url( + self, app, institution, url_auth_institution, + ): + """When AAL2 is required but user only has AAL1, mfa_url should be set.""" + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + username = 'user_aal2_fail@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL1', + idp='https://idp.example.ac.jp', + ), + ) + assert res.status_code == 200 + mfa_url = res.json.get('mfa_url', '') + assert mfa_url != '' + # MFA URL should contain expected components + assert 'entityID=' in mfa_url or 'entityID' in mfa_url + + @mock.patch('api.institutions.authentication.OSF_MFA_URL', 'https://mfa.example.com/ds') + def test_aal2_required_user_has_no_aal_returns_mfa_url( + self, app, institution, url_auth_institution, + ): + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + username = 'user_aal2_none@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + idp='https://idp.example.ac.jp', + ), + ) + assert res.status_code == 200 + mfa_url = res.json.get('mfa_url', '') + assert mfa_url != '' + + # --------------------------------------------------------------- + # LoA validation — AAL1 required + # --------------------------------------------------------------- + + def test_aal1_required_user_has_aal1_passes( + self, app, institution, url_auth_institution, + ): + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=1, ial=0, modifier=modifier, + ) + username = 'user_aal1_ok@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL1', + ), + ) + assert res.status_code in (200, 204) + + def test_aal1_required_user_has_no_aal_raises_error( + self, app, institution, url_auth_institution, + ): + """AAL1 required but no AAL provided → ValidationError (400).""" + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=1, ial=0, modifier=modifier, + ) + username = 'user_aal1_fail@inst.edu' + res = app.post( + url_auth_institution, + make_payload(institution, username), + expect_errors=True, + ) + assert res.status_code == 400 + + # --------------------------------------------------------------- + # LoA validation — IAL2 required + # --------------------------------------------------------------- + + def test_ial2_required_user_has_ial2_passes( + self, app, institution, url_auth_institution, + ): + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=0, ial=2, modifier=modifier, + ) + username = 'user_ial2_ok@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/IAL2', + ), + ) + assert res.status_code in (200, 204) + + def test_ial2_required_user_has_no_ial_raises_error( + self, app, institution, url_auth_institution, + ): + """IAL2 required but IAL2 not provided → ValidationError (400).""" + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=0, ial=2, modifier=modifier, + ) + username = 'user_ial2_fail@inst.edu' + res = app.post( + url_auth_institution, + make_payload(institution, username), + expect_errors=True, + ) + assert res.status_code == 400 + + # --------------------------------------------------------------- + # LoA validation — IAL1 required + # --------------------------------------------------------------- + + def test_ial1_required_user_has_ial_passes( + self, app, institution, url_auth_institution, + ): + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=0, ial=1, modifier=modifier, + ) + username = 'user_ial1_ok@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/IAL2', + ), + ) + assert res.status_code in (200, 204) + + def test_ial1_required_user_has_no_ial_raises_error( + self, app, institution, url_auth_institution, + ): + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=0, ial=1, modifier=modifier, + ) + username = 'user_ial1_fail@inst.edu' + res = app.post( + url_auth_institution, + make_payload(institution, username), + expect_errors=True, + ) + assert res.status_code == 400 + + # --------------------------------------------------------------- + # Combined AAL + IAL requirements + # --------------------------------------------------------------- + + def test_both_aal2_and_ial2_required_both_met( + self, app, institution, url_auth_institution, + ): + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=2, is_mfa=True, modifier=modifier, + ) + username = 'user_combo_ok@inst.edu' + combined = ( + 'https://www.gakunin.jp/profile/AAL2;' + 'https://www.gakunin.jp/profile/IAL2' + ) + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance=combined, + ), + ) + assert res.status_code in (200, 204) + if res.status_code == 200: + assert res.json.get('mfa_url', '') == '' + + def test_aal2_met_but_ial2_not_met_raises_error( + self, app, institution, url_auth_institution, + ): + """AAL2 met + IAL2 not met → ValidationError (IAL check is independent).""" + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=2, is_mfa=True, modifier=modifier, + ) + username = 'user_combo_ial_fail@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL2', + ), + expect_errors=True, + ) + assert res.status_code == 400 + + # --------------------------------------------------------------- + # Response contains mfa_url in user.context + # --------------------------------------------------------------- + + def test_response_body_contains_mfa_url_key( + self, app, institution, url_auth_institution, + ): + """InstitutionAuth.post() returns request.user.context with mfa_url.""" + username = 'user_ctx@inst.edu' + res = app.post( + url_auth_institution, + make_payload(institution, username), + ) + assert res.status_code == 200 + assert 'mfa_url' in res.json + + # --------------------------------------------------------------- + # MFA URL structure validation + # --------------------------------------------------------------- + + @mock.patch('api.institutions.authentication.OSF_MFA_URL', 'https://mfa.example.com/ds') + @mock.patch('api.institutions.authentication.CAS_SERVER_URL', 'https://cas.example.com') + @mock.patch('api.institutions.authentication.DOMAIN', 'https://osf.example.com/') + def test_mfa_url_structure( + self, app, institution, url_auth_institution, + ): + """Verify MFA URL is constructed correctly with urlencode.""" + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + username = 'user_mfa_url@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL1', + idp='https://idp.example.ac.jp', + ), + ) + assert res.status_code == 200 + mfa_url = res.json.get('mfa_url', '') + assert mfa_url != '' + # MFA URL should start with OSF_MFA_URL (after urlencode wrapping via CAS logout) + # The overall structure: OSF_MFA_URL?entityID=...&target=CAS/login?service=profile + assert 'mfa.example.com' in mfa_url or 'cas.example.com' in mfa_url + + # --------------------------------------------------------------- + # idp_attr stores institution.id + # --------------------------------------------------------------- + + def test_idp_attr_stores_institution_id( + self, app, institution, url_auth_institution, + ): + """ext.set_idp_attr should include institution.id under key 'id'.""" + from osf.models import UserExtendedData + + username = 'user_idp_id@inst.edu' + res = app.post( + url_auth_institution, + make_payload(institution, username), + ) + assert res.status_code in (200, 204) + user = OSFUser.objects.get(username=username) + ext = UserExtendedData.objects.get(user=user) + assert ext.data.get('idp_attr', {}).get('id') == institution.id diff --git a/osf_tests/test_loa.py b/osf_tests/test_loa.py new file mode 100644 index 00000000000..64dc417a1a1 --- /dev/null +++ b/osf_tests/test_loa.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +"""Tests for the LoA (Level of Assurance) model.""" +import pytest + +from osf.models.loa import LoA +from osf_tests.factories import InstitutionFactory, AuthUserFactory + +pytestmark = pytest.mark.django_db + + +class TestBaseManager: + """Tests for BaseManager.get_or_none().""" + + def test_get_or_none_returns_object_when_exists(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, aal=1, ial=1, is_mfa=False, modifier=modifier, + ) + result = LoA.objects.get_or_none(institution_id=institution.id) + assert result is not None + assert result.pk == loa.pk + + def test_get_or_none_returns_none_when_not_exists(self): + result = LoA.objects.get_or_none(institution_id=99999) + assert result is None + + +class TestLoAModel: + """Tests for the LoA model fields and behaviour.""" + + def test_create_loa_with_all_fields(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, aal=2, ial=2, is_mfa=True, modifier=modifier, + ) + assert loa.pk is not None + assert loa.institution == institution + assert loa.aal == 2 + assert loa.ial == 2 + assert loa.is_mfa is True + assert loa.modifier == modifier + + def test_create_loa_with_null_aal_ial(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, aal=None, ial=None, is_mfa=False, modifier=modifier, + ) + assert loa.aal is None + assert loa.ial is None + + def test_create_loa_with_zero_values(self): + """aal=0 and ial=0 represent NULL choice.""" + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, aal=0, ial=0, is_mfa=False, modifier=modifier, + ) + assert loa.aal == 0 + assert loa.ial == 0 + + def test_is_mfa_defaults_to_false(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, modifier=modifier, + ) + assert loa.is_mfa is False + + def test_loa_timestamps(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, aal=1, ial=1, modifier=modifier, + ) + assert loa.created is not None + assert loa.modified is not None + + def test_cascade_delete_on_institution(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + LoA.objects.create( + institution=institution, aal=1, ial=1, modifier=modifier, + ) + institution_id = institution.id + institution.delete() + assert LoA.objects.filter(institution_id=institution_id).count() == 0 + + def test_cascade_delete_on_modifier(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + LoA.objects.create( + institution=institution, aal=1, ial=1, modifier=modifier, + ) + modifier_pk = modifier.pk + modifier.delete() + assert LoA.objects.filter(modifier_id=modifier_pk).count() == 0 + + def test_unicode_representation(self): + institution = InstitutionFactory() + modifier = AuthUserFactory() + loa = LoA.objects.create( + institution=institution, aal=2, ial=1, is_mfa=True, modifier=modifier, + ) + expected = u'institution_{}:{}:{}:{}'.format( + institution._id, 2, 1, True, + ) + # LoA defines __unicode__ (not __str__), so call it directly + assert loa.__unicode__() == expected + + def test_init_pops_node_kwarg(self): + """__init__ should silently pop 'node' from kwargs.""" + institution = InstitutionFactory() + modifier = AuthUserFactory() + # Should not raise + loa = LoA( + institution=institution, aal=1, ial=1, modifier=modifier, node='anything', + ) + assert loa.aal == 1 diff --git a/tests/test_profile_utils_loa.py b/tests/test_profile_utils_loa.py new file mode 100644 index 00000000000..d0d522b1783 --- /dev/null +++ b/tests/test_profile_utils_loa.py @@ -0,0 +1,240 @@ +# -*- coding: utf-8 -*- +"""Tests for LoA/MFA-related fields in website.profile.utils.serialize_user(). + +Covers: + - _aal / _ial badge classification + - mfa_url construction and content + - is_mfa flag based on LoA settings + - Behaviour when no idp_attr / no LoA record exists +""" +import mock +import pytest + +from osf.models.loa import LoA +from osf.models import UserExtendedData +from osf_tests.factories import AuthUserFactory, InstitutionFactory +from tests.base import OsfTestCase +from website import settings +from website.profile.utils import serialize_user + +pytestmark = pytest.mark.django_db + + +def _make_user_with_idp_attr(institution=None, ial=None, aal=None, idp='https://idp.example.ac.jp'): + """Helper: create a user with idp_attr set on UserExtendedData.""" + user = AuthUserFactory() + user.ial = ial + user.aal = aal + user.save() + + if institution is None: + institution = InstitutionFactory() + user.affiliated_institutions.add(institution) + + ext, _ = UserExtendedData.objects.get_or_create(user=user) + ext.set_idp_attr({ + 'id': institution.id, + 'idp': idp, + 'eppn': user.username, + 'username': user.username, + 'fullname': user.fullname, + 'email': user.username, + }) + + return user, institution + + +class TestSerializeUserAalBadge(OsfTestCase): + """Tests for _aal classification in serialize_user().""" + + def test_aal_null_when_no_aal(self): + user, _ = _make_user_with_idp_attr(aal=None) + result = serialize_user(user) + assert result['_aal'] == 'NULL' + + def test_aal_null_when_empty_string(self): + user, _ = _make_user_with_idp_attr(aal='') + result = serialize_user(user) + assert result['_aal'] == 'NULL' + + def test_aal2_when_aal_contains_aal2_url(self): + user, _ = _make_user_with_idp_attr( + aal='https://www.gakunin.jp/profile/AAL2', + ) + result = serialize_user(user) + assert result['_aal'] == 'AAL2' + + def test_aal1_when_aal_contains_aal1_url(self): + user, _ = _make_user_with_idp_attr( + aal='https://www.gakunin.jp/profile/AAL1', + ) + result = serialize_user(user) + assert result['_aal'] == 'AAL1' + + def test_aal1_when_aal_is_other_value(self): + """Any non-AAL2 truthy value should classify as AAL1.""" + user, _ = _make_user_with_idp_attr( + aal='urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', + ) + result = serialize_user(user) + assert result['_aal'] == 'AAL1' + + def test_raw_aal_value_is_preserved(self): + aal_value = 'https://www.gakunin.jp/profile/AAL2' + user, _ = _make_user_with_idp_attr(aal=aal_value) + result = serialize_user(user) + assert result['aal'] == aal_value + + +class TestSerializeUserIalBadge(OsfTestCase): + """Tests for _ial classification in serialize_user().""" + + def test_ial1_when_no_ial(self): + """When ial is None or empty, _ial should be IAL1 (default).""" + user, _ = _make_user_with_idp_attr(ial=None) + result = serialize_user(user) + assert result['_ial'] == 'IAL1' + + def test_ial2_when_ial_contains_ial2_url(self): + user, _ = _make_user_with_idp_attr( + ial='https://www.gakunin.jp/profile/IAL2', + ) + result = serialize_user(user) + assert result['_ial'] == 'IAL2' + + def test_ial1_when_ial_is_other_value(self): + """Values other than IAL2 are equivalent to IAL1.""" + user, _ = _make_user_with_idp_attr(ial='some_other_ial_value') + result = serialize_user(user) + assert result['_ial'] == 'IAL1' + + def test_raw_ial_value_is_preserved(self): + ial_value = 'https://www.gakunin.jp/profile/IAL2' + user, _ = _make_user_with_idp_attr(ial=ial_value) + result = serialize_user(user) + assert result['ial'] == ial_value + + +class TestSerializeUserMfaUrl(OsfTestCase): + """Tests for mfa_url construction in serialize_user().""" + + @mock.patch.object(settings, 'OSF_MFA_URL', 'https://mfa.example.com/ds') + @mock.patch.object(settings, 'CAS_SERVER_URL', 'https://cas.example.com') + def test_mfa_url_constructed_when_entity_id_present(self): + """When idp_attr has entity_id (idp), mfa_url should be constructed.""" + user, institution = _make_user_with_idp_attr( + idp='https://idp.example.ac.jp', + ) + result = serialize_user(user) + mfa_url = result['mfa_url'] + assert mfa_url != '' + # Should contain CAS logout redirect pattern + assert 'cas.example.com/logout' in mfa_url + # Should contain mfa.example.com/ds in the service param + assert 'mfa.example.com' in mfa_url + + @mock.patch.object(settings, 'OSF_MFA_URL', 'https://mfa.example.com/ds') + @mock.patch.object(settings, 'CAS_SERVER_URL', 'https://cas.example.com') + def test_mfa_url_contains_entity_id(self): + entity_id = 'https://idp.specific.ac.jp' + user, institution = _make_user_with_idp_attr(idp=entity_id) + result = serialize_user(user) + mfa_url = result['mfa_url'] + # The entityID should be URL-encoded within the mfa_url + assert 'idp.specific.ac.jp' in mfa_url + + @mock.patch.object(settings, 'OSF_MFA_URL', 'https://mfa.example.com/ds') + @mock.patch.object(settings, 'CAS_SERVER_URL', 'https://cas.example.com') + def test_mfa_url_contains_login_service_url(self): + user, institution = _make_user_with_idp_attr() + result = serialize_user(user) + mfa_url = result['mfa_url'] + # The CAS login URL is nested inside multiple urlencode layers, + # so slashes and colons are percent-encoded repeatedly. + # Fully decode the URL and then check for the expected substring. + from urllib.parse import unquote + decoded = mfa_url + for _ in range(5): + decoded = unquote(decoded) + assert 'cas.example.com/login' in decoded + + def test_mfa_url_empty_when_no_entity_id(self): + """When idp_attr has no 'idp' key, mfa_url should be empty.""" + user = AuthUserFactory() + user.aal = None + user.ial = None + user.save() + + # Set idp_attr without 'idp' key + ext, _ = UserExtendedData.objects.get_or_create(user=user) + ext.set_idp_attr({ + 'id': None, + 'username': user.username, + }) + + result = serialize_user(user) + assert result['mfa_url'] == '' + + def test_mfa_url_empty_when_no_idp_attr(self): + """When user has no UserExtendedData, mfa_url should be empty.""" + user = AuthUserFactory() + user.aal = None + user.ial = None + user.save() + + result = serialize_user(user) + assert result['mfa_url'] == '' + + +class TestSerializeUserIsMfa(OsfTestCase): + """Tests for is_mfa flag based on LoA settings.""" + + def test_is_mfa_true_when_loa_has_mfa_enabled(self): + user, institution = _make_user_with_idp_attr() + modifier = AuthUserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + result = serialize_user(user) + assert result['is_mfa'] is True + + def test_is_mfa_false_when_loa_has_mfa_disabled(self): + user, institution = _make_user_with_idp_attr() + modifier = AuthUserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=False, modifier=modifier, + ) + result = serialize_user(user) + assert result['is_mfa'] is False + + def test_is_mfa_false_when_no_loa_record(self): + user, institution = _make_user_with_idp_attr() + # No LoA record created + result = serialize_user(user) + assert result['is_mfa'] is False + + def test_is_mfa_false_when_no_institution_id_in_idp_attr(self): + """When idp_attr has id=None, LoA lookup returns None -> is_mfa=False.""" + user = AuthUserFactory() + user.aal = None + user.ial = None + user.save() + + ext, _ = UserExtendedData.objects.get_or_create(user=user) + ext.set_idp_attr({ + 'id': None, + 'idp': 'https://idp.example.ac.jp', + }) + + result = serialize_user(user) + assert result['is_mfa'] is False + + +class TestSerializeUserReturnedKeys(OsfTestCase): + """Verify that all LoA-related keys are present in the serialized output.""" + + def test_loa_keys_present(self): + user, _ = _make_user_with_idp_attr() + result = serialize_user(user) + for key in ('ial', 'aal', '_ial', '_aal', 'mfa_url', 'is_mfa'): + assert key in result, 'Missing key: {}'.format(key) From 6897f8c681af5fc3f64262016447cac7aa64a24e Mon Sep 17 00:00:00 2001 From: Yusaku Kitabatake Date: Wed, 8 Apr 2026 11:04:51 +0900 Subject: [PATCH 19/38] Fixes based on the code review (redmine-58907) --- admin/templates/loa/list.html | 12 --- api/institutions/authentication.py | 12 ++- .../views/test_institution_auth.py | 36 ++++---- .../views/test_institution_auth_loa.py | 92 +++++++++++++++---- 4 files changed, 105 insertions(+), 47 deletions(-) diff --git a/admin/templates/loa/list.html b/admin/templates/loa/list.html index b35f2da8961..8df2797ded0 100644 --- a/admin/templates/loa/list.html +++ b/admin/templates/loa/list.html @@ -89,21 +89,9 @@

    {% trans "Level of Assurance" %}

    {% endblock content %} {% block bottom_js %} - - {% endblock %} diff --git a/api/institutions/authentication.py b/api/institutions/authentication.py index dcdad6946ff..b2564d4f37a 100644 --- a/api/institutions/authentication.py +++ b/api/institutions/authentication.py @@ -255,7 +255,17 @@ def get_next(obj, *args): if loa: if loa.aal == 2: if not re.search(OSF_AAL2_STR, str(aal)): - mfa_url = mfa_url_tmp + if mfa_url_tmp: + mfa_url = mfa_url_tmp + else: + # MFA URL cannot be generated (e.g. p_idp is not a string). + # Without MFA redirect, allowing login would silently bypass + # the AAL2 requirement — reject the login instead. + message = ( + 'Institution login failed: Does not meet the required AAL.' + '
    MFA redirect is not available for this institution.' + ) + loa_flag = False elif loa.aal == 1: if not aal: message = ( diff --git a/api_tests/institutions/views/test_institution_auth.py b/api_tests/institutions/views/test_institution_auth.py index 16be75b83ff..ad51c4062ee 100644 --- a/api_tests/institutions/views/test_institution_auth.py +++ b/api_tests/institutions/views/test_institution_auth.py @@ -117,7 +117,7 @@ def test_new_user_created(self, app, url_auth_institution, institution): with capture_signals() as mock_signals: res = app.post(url_auth_institution, make_payload(institution, username)) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 assert mock_signals.signals_sent() == set([signals.user_confirmed]) user = OSFUser.objects.filter(username=username).first() @@ -134,7 +134,7 @@ def test_existing_user_found_but_not_affiliated(self, app, institution, url_auth with capture_signals() as mock_signals: res = app.post(url_auth_institution, make_payload(institution, username)) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 assert not mock_signals.signals_sent() user.reload() @@ -150,7 +150,7 @@ def test_user_found_and_affiliated(self, app, institution, url_auth_institution) with capture_signals() as mock_signals: res = app.post(url_auth_institution, make_payload(institution, username)) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 assert not mock_signals.signals_sent() user.reload() @@ -174,7 +174,7 @@ def test_new_user_names_guessed_if_not_provided(self, app, institution, url_auth username = 'user_created_with_fullname_only@osf.edu' res = app.post(url_auth_institution, make_payload(institution, username)) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.fullname == 'Fake User' @@ -189,7 +189,7 @@ def test_new_user_names_used_when_provided(self, app, institution, url_auth_inst url_auth_institution, make_payload(institution, username, given_name='Foo', family_name='Bar') ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.fullname == 'Fake User' @@ -216,7 +216,7 @@ def test_user_active(self, app, institution, url_auth_institution): department='Fake Department', ) ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 assert not mock_signals.signals_sent() user = OSFUser.objects.filter(username=username).first() @@ -255,7 +255,7 @@ def test_user_unclaimed(self, app, institution, url_auth_institution): department='Fake Department', ) ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 assert mock_signals.signals_sent() == set([signals.user_confirmed]) user = OSFUser.objects.filter(username=username).first() @@ -290,7 +290,7 @@ def test_user_unconfirmed(self, app, institution, url_auth_institution): fullname='Fake User' ) ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 assert mock_signals.signals_sent() == set([signals.user_confirmed]) user = OSFUser.objects.filter(username=username).first() @@ -412,7 +412,7 @@ def test_authenticate_jaSurname_and_jaGivenName_are_valid( jaGivenName=jagivenname, jaSurname=jasurname), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.ext.data['idp_attr']['fullname_ja'] == jagivenname + ' ' + jasurname @@ -426,7 +426,7 @@ def test_authenticate_jaGivenName_is_valid( make_payload(institution, username, jaGivenName=jagivenname), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.given_name_ja == jagivenname @@ -440,7 +440,7 @@ def test_authenticate_jaSurname_is_valid( make_payload(institution, username, jaSurname=jasurname), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.family_name_ja == jasurname @@ -454,7 +454,7 @@ def test_authenticate_jaMiddleNames_is_valid( make_payload(institution, username, jaMiddleNames=middlename), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.middle_names_ja == middlename @@ -468,7 +468,7 @@ def test_authenticate_givenname_is_valid( make_payload(institution, username, given_name=given_name), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.given_name == given_name @@ -482,7 +482,7 @@ def test_authenticate_familyname_is_valid( make_payload(institution, username, family_name=family_name), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.family_name == family_name @@ -496,7 +496,7 @@ def test_authenticate_middlename_is_valid( make_payload(institution, username, middle_names=middle_names), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username=username).first() assert user assert user.middle_names == middle_names @@ -515,7 +515,7 @@ def test_authenticate_jaOrganizationalUnitName_is_valid( organizationName=organizationname), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user assert user.jobs[0]['department_ja'] == jaorganizationname @@ -534,7 +534,7 @@ def test_authenticate_OrganizationalUnitName_is_valid( organizationName=organizationname), expect_errors=True ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user assert user.jobs[0]['department'] == organizationnameunit @@ -569,7 +569,7 @@ def test_with_new_attribute(self, mock, app, institution, url_auth_institution): gakunin_identity_assurance_method_reference=gakunin_identity_assurance_method_reference,) ) - assert res.status_code == 204 or res.status_code == 200 + assert res.status_code == 200 user = OSFUser.objects.filter(username='tmp_eppn_' + username).first() assert user diff --git a/api_tests/institutions/views/test_institution_auth_loa.py b/api_tests/institutions/views/test_institution_auth_loa.py index a8d1c285a77..f1cfea4310a 100644 --- a/api_tests/institutions/views/test_institution_auth_loa.py +++ b/api_tests/institutions/views/test_institution_auth_loa.py @@ -141,7 +141,7 @@ def test_aal2_extracted_from_edu_person_assurance( edu_person_assurance='https://www.gakunin.jp/profile/AAL2', ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 user = OSFUser.objects.get(username=username) assert user.aal == OSF_AAL2_VAR @@ -156,7 +156,7 @@ def test_aal1_extracted_from_edu_person_assurance( edu_person_assurance='https://www.gakunin.jp/profile/AAL1', ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 user = OSFUser.objects.get(username=username) assert user.aal == OSF_AAL1_VAR @@ -171,7 +171,7 @@ def test_ial2_extracted_from_edu_person_assurance( edu_person_assurance='https://www.gakunin.jp/profile/IAL2', ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 user = OSFUser.objects.get(username=username) assert user.ial == OSF_IAL2_VAR @@ -189,7 +189,7 @@ def test_aal_falls_back_to_shib_authn_context_class( shib_authn_context_class=shib_value, ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 user = OSFUser.objects.get(username=username) assert user.aal == shib_value @@ -209,7 +209,7 @@ def test_both_aal2_and_ial2_in_edu_person_assurance( edu_person_assurance=combined, ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 user = OSFUser.objects.get(username=username) assert user.aal == OSF_AAL2_VAR assert user.ial == OSF_IAL2_VAR @@ -227,7 +227,7 @@ def test_no_loa_record_allows_login( url_auth_institution, make_payload(institution, username), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 # --------------------------------------------------------------- # LoA validation — AAL2 required @@ -248,10 +248,9 @@ def test_aal2_required_user_has_aal2_passes( edu_person_assurance='https://www.gakunin.jp/profile/AAL2', ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 # mfa_url should be empty because AAL2 requirement is met - if res.status_code == 200: - assert res.json.get('mfa_url', '') == '' + assert res.json.get('mfa_url', '') == '' @mock.patch('api.institutions.authentication.OSF_MFA_URL', 'https://mfa.example.com/ds') def test_aal2_required_user_has_aal1_returns_mfa_url( @@ -297,6 +296,68 @@ def test_aal2_required_user_has_no_aal_returns_mfa_url( mfa_url = res.json.get('mfa_url', '') assert mfa_url != '' + # --------------------------------------------------------------- + # LoA validation — AAL2 required but MFA URL unavailable + # --------------------------------------------------------------- + + def test_aal2_required_no_mfa_url_available_raises_error( + self, app, institution, url_auth_institution, + ): + """AAL2 required, AAL2 not met, and p_idp is a list (not str) so + mfa_url_tmp is empty. Login must be rejected instead of silently + bypassing the AAL2 requirement. + """ + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + username = 'user_aal2_no_mfa@inst.edu' + # idp is NOT passed, so institution.email_domains (a list) is used. + # type(p_idp) is str -> False -> mfa_url_tmp remains empty. + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL1', + ), + expect_errors=True, + ) + assert res.status_code == 400 + + def test_aal2_required_no_aal_no_mfa_url_available_raises_error( + self, app, institution, url_auth_institution, + ): + """AAL2 required, no AAL at all, p_idp is a list -> must be rejected.""" + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + username = 'user_aal2_no_mfa_none@inst.edu' + res = app.post( + url_auth_institution, + make_payload(institution, username), + expect_errors=True, + ) + assert res.status_code == 400 + + def test_aal2_required_user_has_aal2_passes_regardless_of_idp_type( + self, app, institution, url_auth_institution, + ): + """AAL2 required and met - login should pass even if p_idp is a list.""" + modifier = UserFactory() + LoA.objects.create( + institution=institution, aal=2, ial=0, is_mfa=True, modifier=modifier, + ) + username = 'user_aal2_ok_list_idp@inst.edu' + res = app.post( + url_auth_institution, + make_payload( + institution, username, + edu_person_assurance='https://www.gakunin.jp/profile/AAL2', + ), + ) + assert res.status_code == 200 + # --------------------------------------------------------------- # LoA validation — AAL1 required # --------------------------------------------------------------- @@ -316,7 +377,7 @@ def test_aal1_required_user_has_aal1_passes( edu_person_assurance='https://www.gakunin.jp/profile/AAL1', ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 def test_aal1_required_user_has_no_aal_raises_error( self, app, institution, url_auth_institution, @@ -353,7 +414,7 @@ def test_ial2_required_user_has_ial2_passes( edu_person_assurance='https://www.gakunin.jp/profile/IAL2', ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 def test_ial2_required_user_has_no_ial_raises_error( self, app, institution, url_auth_institution, @@ -390,7 +451,7 @@ def test_ial1_required_user_has_ial_passes( edu_person_assurance='https://www.gakunin.jp/profile/IAL2', ), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 def test_ial1_required_user_has_no_ial_raises_error( self, app, institution, url_auth_institution, @@ -430,9 +491,8 @@ def test_both_aal2_and_ial2_required_both_met( edu_person_assurance=combined, ), ) - assert res.status_code in (200, 204) - if res.status_code == 200: - assert res.json.get('mfa_url', '') == '' + assert res.status_code == 200 + assert res.json.get('mfa_url', '') == '' def test_aal2_met_but_ial2_not_met_raises_error( self, app, institution, url_auth_institution, @@ -515,7 +575,7 @@ def test_idp_attr_stores_institution_id( url_auth_institution, make_payload(institution, username), ) - assert res.status_code in (200, 204) + assert res.status_code == 200 user = OSFUser.objects.get(username=username) ext = UserExtendedData.objects.get(user=user) assert ext.data.get('idp_attr', {}).get('id') == institution.id From 066fdbd882caac8903e1f5163ce003f03a92cd2b Mon Sep 17 00:00:00 2001 From: hcphat Date: Wed, 8 Apr 2026 17:12:24 +0700 Subject: [PATCH 20/38] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20Update=20message,=20update=20URL=20to=20m?= =?UTF-8?q?ygroups=20view=20URL=20and=20set=20default=20addon=20groups=20i?= =?UTF-8?q?n=20admin=20is=20disable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/groups/apps.py | 2 ++ website/project/views/node.py | 2 +- website/settings/defaults.py | 1 + website/static/css/pages/contributor-page.css | 3 +-- website/templates/project/groups.mako | 4 ++-- website/translations/en/LC_MESSAGES/messages.po | 7 +++++-- website/translations/ja/LC_MESSAGES/messages.po | 11 +++++++---- website/translations/messages.pot | 7 +++++-- 8 files changed, 24 insertions(+), 13 deletions(-) diff --git a/addons/groups/apps.py b/addons/groups/apps.py index 1f067f09d64..e4bccf55061 100644 --- a/addons/groups/apps.py +++ b/addons/groups/apps.py @@ -39,6 +39,8 @@ class AddonAppConfig(BaseAddonAppConfig): node_settings_template = os.path.join(TEMPLATE_PATH, 'groups_node_settings.mako') + is_allowed_default = False + @property def routes(self): from . import routes diff --git a/website/project/views/node.py b/website/project/views/node.py index 6fa4cb87d6b..95f4c3c20a9 100644 --- a/website/project/views/node.py +++ b/website/project/views/node.py @@ -545,7 +545,7 @@ def node_groups(auth, node, **kwargs): ret['groups'] = utils.serialize_mapcore_node_groups(node) current_group = [group['mapcore_group']['id'] for group in ret['groups']] ret['adminGroups'] = utils.serialize_parent_admin_groups(node, current_group) - ret['baseUrl'] = settings.MAPCORE_GROUP_HOSTNAME + ret['baseUrl'] = f'{settings.MAPCORE_GROUP_HOSTNAME}{settings.MAPCORE_GROUP_VIEW_PATH}' return ret @must_have_permission(ADMIN) diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 8ff7fd60618..30887f5cd77 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -2056,6 +2056,7 @@ class CeleryConfig: MAPCORE_SECRET = None MAPCORE_GROUP_HOSTNAME = 'https://sptest.cg.gakunin.jp' MAPCORE_GROUP_API_PATH = '/map/rd/' +MAPCORE_GROUP_VIEW_PATH = '/map/mygroups/view' # allow logged-in-user to search private projects ENABLE_PRIVATE_SEARCH = False diff --git a/website/static/css/pages/contributor-page.css b/website/static/css/pages/contributor-page.css index 2c1435033e6..868def93861 100644 --- a/website/static/css/pages/contributor-page.css +++ b/website/static/css/pages/contributor-page.css @@ -166,9 +166,8 @@ th.remove { padding-bottom: 20px; } -#groupsNotes h3 { +#groupsNotes h4 { color: red; padding: 0; margin: 0; - font-weight: 500; } diff --git a/website/templates/project/groups.mako b/website/templates/project/groups.mako index f417300aaeb..e9dd1907f75 100644 --- a/website/templates/project/groups.mako +++ b/website/templates/project/groups.mako @@ -52,8 +52,8 @@
    -

    ${_("※ Group members can be edited using the GakuNin mAP {baseUrl}.").format(baseUrl="") | n}

    -

    ${_("If a group member currently logged into GakuNin RDM is deleted on the mAP, they will not be removed from the project until they log out.")}

    +

    ${_("※ This feature is intended for use in the Moonshot Goal 2 Database (Mebyo DB). Group member editing is performed through {baseUrl}.").format(baseUrl='' + _("the GakuNin Cloud Gateway Service group function") + '') | n}

    +

    ${_("If you register or remove a group member while they are logged into GakuNin RDM, the changes will not be reflected in the project until the user logs out and logs back in.")}

    ${_("Groups")} diff --git a/website/translations/en/LC_MESSAGES/messages.po b/website/translations/en/LC_MESSAGES/messages.po index 12923b8b59f..ec6cea1ad9d 100644 --- a/website/translations/en/LC_MESSAGES/messages.po +++ b/website/translations/en/LC_MESSAGES/messages.po @@ -4161,10 +4161,13 @@ msgstr "" msgid "Bibliographic Group Information" msgstr "" -msgid "※ Group members can be edited using the GakuNin mAP {baseUrl}." +msgid "※ This feature is intended for use in the Moonshot Goal 2 Database (Mebyo DB). Group member editing is performed through {baseUrl}." msgstr "" -msgid "If a group member currently logged into GakuNin RDM is deleted on the mAP, they will not be removed from the project until they log out." +msgid "the GakuNin Cloud Gateway Service group function" +msgstr "" + +msgid "If you register or remove a group member while they are logged into GakuNin RDM, the changes will not be reflected in the project until the user logs out and logs back in." msgstr "" msgid "%(groupOthersCount)s more" diff --git a/website/translations/ja/LC_MESSAGES/messages.po b/website/translations/ja/LC_MESSAGES/messages.po index 791d16e6e34..75eac949c08 100644 --- a/website/translations/ja/LC_MESSAGES/messages.po +++ b/website/translations/ja/LC_MESSAGES/messages.po @@ -5151,11 +5151,14 @@ msgstr "ワークフローテンプレートを更新しました。" msgid "Failed to update workflow template." msgstr "ワークフローテンプレートの更新に失敗しました。" -msgid "※ Group members can be edited using the GakuNin mAP {baseUrl}." -msgstr "※グループのメンバー編集は、学認mAP機能 {baseUrl} で実施します。" +msgid "※ This feature is intended for use in the Moonshot Goal 2 Database (Mebyo DB). Group member editing is performed through {baseUrl}." +msgstr "※本機能はムーンショット目標2(未病DB)関連プロジェクトでの利用を対象としています。グループのメンバー編集は、{baseUrl} で実施します。" -msgid "If a group member currently logged into GakuNin RDM is deleted on the mAP, they will not be removed from the project until they log out." -msgstr "GakuNin RDMログイン中のグループメンバーをmAP機能上で削除する場合、当該ユーザがログアウトするまでプロジェクトからは削除されません。" +msgid "the GakuNin Cloud Gateway Service group function" +msgstr "学認クラウドゲートウェイサービスグループ機能" + +msgid "If you register or remove a group member while they are logged into GakuNin RDM, the changes will not be reflected in the project until the user logs out and logs back in." +msgstr "GakuNin RDMログイン中のグループメンバーを登録・削除する場合、当該ユーザがログアウト・再ログインするまでプロジェクトからは登録・削除されません。" msgid "%(groupOthersCount)s more" msgstr "あと%(groupOthersCount)sグループ" diff --git a/website/translations/messages.pot b/website/translations/messages.pot index aad15ea22cd..8497a6f016f 100644 --- a/website/translations/messages.pot +++ b/website/translations/messages.pot @@ -4424,10 +4424,13 @@ msgstr "" msgid "Bibliographic Group Information" msgstr "" -msgid "※ Group members can be edited using the GakuNin mAP {baseUrl}." +msgid "※ This feature is intended for use in the Moonshot Goal 2 Database (Mebyo DB). Group member editing is performed through {baseUrl}." msgstr "" -msgid "If a group member currently logged into GakuNin RDM is deleted on the mAP, they will not be removed from the project until they log out." +msgid "the GakuNin Cloud Gateway Service group function" +msgstr "" + +msgid "If you register or remove a group member while they are logged into GakuNin RDM, the changes will not be reflected in the project until the user logs out and logs back in." msgstr "" msgid "%(groupOthersCount)s more" From 728c711163931671e5e139866fd9cf17c09e35e3 Mon Sep 17 00:00:00 2001 From: hcphat Date: Thu, 21 May 2026 10:43:29 +0700 Subject: [PATCH 21/38] =?UTF-8?q?2.2.=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=82=AF=E3=83=88=E5=90=8D=E3=81=AB=E6=97=A5=E6=9C=AC=E8=AA=9E?= =?UTF-8?q?=E3=82=92=E5=85=A5=E5=8A=9B=E3=81=99=E3=82=8B=E9=9A=9B=E3=81=AE?= =?UTF-8?q?=E6=94=B9=E5=96=84:=20Commit=20code=20improve=20project=20name?= =?UTF-8?q?=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/static/js/addProjectPlugin.js | 17 ++++++++-- website/static/js/myProjects.js | 45 ++++++++++++++++++++------- 2 files changed, 48 insertions(+), 14 deletions(-) diff --git a/website/static/js/addProjectPlugin.js b/website/static/js/addProjectPlugin.js index 3a125ab338c..a4dc22155cb 100644 --- a/website/static/js/addProjectPlugin.js +++ b/website/static/js/addProjectPlugin.js @@ -192,13 +192,24 @@ var AddProject = { m('.form-group.m-v-sm', [ m('label[for="projectName].f-w-lg.text-bigger', _('Title')), m('input[type="text"].form-control.project-name', { - onkeyup: function(ev){ + oncompositionstart: function () { + ctrl.isComposing = true; + }, + oncompositionend: function () { + ctrl.isComposing = false; + }, + oninput: function(ev) { var val = ev.target.value; ctrl.isValid(val.trim().length > 0); - if (ev.which === 13) { + ctrl.newProjectName(val); + }, + onkeydown: function(ev){ + const isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; + if (ev.key === 'Enter' && !isComposing) { + ev.preventDefault(); + ev.stopPropagation(); ctrl.add(); } - ctrl.newProjectName(val); }, onchange: function(ev) { // This will not be reliably running! diff --git a/website/static/js/myProjects.js b/website/static/js/myProjects.js index 10db4bab6d7..e87247f2407 100644 --- a/website/static/js/myProjects.js +++ b/website/static/js/myProjects.js @@ -1539,15 +1539,26 @@ var Collections = { m('.form-group', [ m('label[for="addCollInput].f-w-lg.text-bigger', _('Collection name')), m('input[type="text"].form-control#addCollInput', { - onkeyup: function (ev){ - var val = $(this).val(); + oncompositionstart: function () { + ctrl.isComposing = true; + }, + oncompositionend: function () { + ctrl.isComposing = false; + }, + oninput: function(ev) { + const val = ev.target.value; ctrl.validateName(val); + ctrl.newCollectionName(val); + }, + onkeydown: function (ev){ + const isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; if(ctrl.isValid()){ - if(ev.which === 13){ + if(ev.key === 'Enter' && !isComposing){ + ev.preventDefault(); + ev.stopPropagation(); ctrl.addCollection(); } } - ctrl.newCollectionName(val); }, onchange: function() { $osf.trackClick('myProjects', 'add-collection', 'type-collection-name'); @@ -1579,28 +1590,40 @@ var Collections = { $osf.trackClick('myProjects', 'edit-collection', 'click-close-rename-modal'); }}, [ m('span[aria-hidden="true"]','×') - ]), - m('h3.modal-title', _('Rename collection')) + ]), + m('h3.modal-title', _('Rename collection')) ]), body: m('.modal-body', [ m('.form-inline', [ m('.form-group', [ m('label[for="addCollInput]', _('Rename to: ')), m('input[type="text"].form-control.m-l-sm',{ - onkeyup: function(ev){ - var val = $(this).val(); + oncompositionstart: function () { + ctrl.isComposing = true; + }, + oncompositionend: function () { + ctrl.isComposing = false; + }, + oninput: function(ev) { + const val = ev.target.value; ctrl.validateName(val); + ctrl.collectionMenuObject().item.renamedLabel = val; + }, + onkeydown: function(ev){ + const isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; if(ctrl.isValid()) { - if (ev.which === 13) { // if enter is pressed + if (ev.key === 'Enter' && !isComposing) { // if enter is pressed + ev.preventDefault(); + ev.stopPropagation(); ctrl.renameCollection(); } } - ctrl.collectionMenuObject().item.renamedLabel = val; }, onchange: function() { $osf.trackClick('myProjects', 'edit-collection', 'type-rename-collection'); }, - value: ctrl.collectionMenuObject().item.renamedLabel}), + value: ctrl.collectionMenuObject().item.renamedLabel + }), m('span.help-block', ctrl.validationError()) ]) From ff85fcd1b5bc0666d832461a84a8987bc9dc0f96 Mon Sep 17 00:00:00 2001 From: hcphat Date: Fri, 22 May 2026 15:32:22 +0700 Subject: [PATCH 22/38] =?UTF-8?q?2.2.=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=82=AF=E3=83=88=E5=90=8D=E3=81=AB=E6=97=A5=E6=9C=AC=E8=AA=9E?= =?UTF-8?q?=E3=82=92=E5=85=A5=E5=8A=9B=E3=81=99=E3=82=8B=E9=9A=9B=E3=81=AE?= =?UTF-8?q?=E6=94=B9=E5=96=84:=20Commit=20code=20improve=20file/folder=20n?= =?UTF-8?q?ame=20input?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/static/js/fangorn.js | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/website/static/js/fangorn.js b/website/static/js/fangorn.js index 37f5a8c65ca..b220e2a0ce6 100644 --- a/website/static/js/fangorn.js +++ b/website/static/js/fangorn.js @@ -2375,12 +2375,18 @@ var FGInput = { var helpTextId = args.helpTextId || ''; var oninput = args.oninput || noop; var onkeypress = args.onkeypress || noop; + var onkeydown = args.onkeydown || noop; + var oncompositionstart = args.oncompositionstart || noop; + var oncompositionend = args.oncompositionend || noop; return m('span', [ m('input', { 'id' : id, className: 'pull-right form-control' + extraCSS, oninput: oninput, onkeypress: onkeypress, + onkeydown: onkeydown, + oncompositionstart: oncompositionstart, + oncompositionend: oncompositionend, 'value': args.value || '', 'data-toggle': tooltipText ? 'tooltip' : '', 'title': tooltipText, @@ -2638,9 +2644,18 @@ var FGToolbar = { templates[toolbarModes.ADDFOLDER] = [ m('.col-xs-9', [ m.component(FGInput, { + oncompositionstart: function () { + ctrl.isComposing = true; + }, + oncompositionend: function () { + ctrl.isComposing = false; + }, oninput: m.withAttr('value', ctrl.nameData), - onkeypress: function (event) { - if (ctrl.tb.pressedKey === ENTER_KEY) { + onkeydown: function (event) { + const isComposing = event.isComposing || ctrl.isComposing || event.keyCode === 229; + if (event.key === 'Enter' && !isComposing) { + event.preventDefault(); + event.stopPropagation(); ctrl.createFolder.call(ctrl.tb, event, ctrl.dismissToolbar); } }, @@ -2666,9 +2681,18 @@ var FGToolbar = { templates[toolbarModes.RENAME] = [ m('.col-xs-9', m.component(FGInput, { + oncompositionstart: function () { + ctrl.isComposing = true; + }, + oncompositionend: function () { + ctrl.isComposing = false; + }, oninput: m.withAttr('value', ctrl.renameData), - onkeypress: function (event) { - if (ctrl.tb.pressedKey === ENTER_KEY) { + onkeydown: function (event) { + const isComposing = event.isComposing || ctrl.isComposing || event.keyCode === 229; + if (event.key === 'Enter' && !isComposing) { + event.preventDefault(); + event.stopPropagation(); _renameEvent.call(ctrl.tb); } }, From 0aa0fc8255b55de9fb77d62427d0fb48af852146 Mon Sep 17 00:00:00 2001 From: hcphat Date: Fri, 22 May 2026 16:36:38 +0700 Subject: [PATCH 23/38] =?UTF-8?q?Ref=202.1.IdP=E3=81=8B=E3=82=89=E5=8F=97?= =?UTF-8?q?=E9=A0=98=E3=81=99=E3=82=8B=E8=AA=8D=E8=A8=BC=E6=83=85=E5=A0=B1?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=AE=E6=97=A5=E6=9C=AC=E8=AA=9E=E6=96=87?= =?UTF-8?q?=E5=AD=97=E5=8C=96=E3=81=91=E8=A7=A3=E6=B6=88:=20commit=20code?= =?UTF-8?q?=20fix=20japanese=20encoding=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/common_auth/views.py | 4 +- admin_tests/common_auth/test_views.py | 310 +++++++++++++++++++++++++- 2 files changed, 310 insertions(+), 4 deletions(-) diff --git a/admin/common_auth/views.py b/admin/common_auth/views.py index 4df69bfabac..5724b32d47c 100644 --- a/admin/common_auth/views.py +++ b/admin/common_auth/views.py @@ -109,7 +109,9 @@ def dispatch(self, request, *args, **kwargs): return redirect('auth:login') else: tmp_eppn = ('tmp_eppn_' + eppn).lower() - new_user, created = get_or_create_user(request.environ['HTTP_AUTH_DISPLAYNAME'] or 'NO NAME', tmp_eppn, reset_password=False) + raw_display_name = request.environ.get('HTTP_AUTH_DISPLAYNAME', '') + display_name = raw_display_name.encode('iso-8859-1').decode('utf-8') if raw_display_name else '' + new_user, created = get_or_create_user(display_name or 'NO NAME', tmp_eppn, reset_password=False) USE_EPPN = login_by_eppn() if USE_EPPN: new_user.eppn = eppn diff --git a/admin_tests/common_auth/test_views.py b/admin_tests/common_auth/test_views.py index 35e9a5c097a..6ea2d1173b4 100644 --- a/admin_tests/common_auth/test_views.py +++ b/admin_tests/common_auth/test_views.py @@ -3,13 +3,15 @@ from django.test import RequestFactory from django.http import Http404 +from django.urls import reverse +from django.contrib.auth import REDIRECT_FIELD_NAME from tests.base import AdminTestCase -from osf_tests.factories import AuthUserFactory +from osf_tests.factories import AuthUserFactory, InstitutionFactory -from admin_tests.utilities import setup_form_view +from admin_tests.utilities import setup_form_view, setup_view from osf.models.user import OSFUser -from admin.common_auth.views import RegisterUser +from admin.common_auth.views import RegisterUser, ShibLoginView from admin.common_auth.forms import UserRegistrationForm @@ -40,3 +42,305 @@ def test_add_user(self, mock_save): view.form_valid(form) nt.assert_true(mock_save.called) nt.assert_equal(OSFUser.objects.count(), count + 1) + +class TestShibLoginView(AdminTestCase): + """ + Test ShibLoginView.dispatch and get_success_url. + """ + + EPPN_DOMAIN = 'example.ac.jp' + EPPN = 'testuser@' + EPPN_DOMAIN + ENTITLEMENT_ADMIN = 'GakuNinRDMAdmin' + DISPLAY_NAME = 'Test User' + + def setUp(self): + super(TestShibLoginView, self).setUp() + self.institution = InstitutionFactory(domains=[self.EPPN_DOMAIN]) + + def _make_request(self, eppn=None, entitlement='', displayname=None): + """Helper: create a GET request with Shibboleth environ headers.""" + eppn = eppn if eppn is not None else self.EPPN + displayname = displayname or self.DISPLAY_NAME + request = RequestFactory().get('fake_path') + request.environ['HTTP_AUTH_EPPN'] = eppn + request.environ['HTTP_AUTH_ENTITLEMENT'] = entitlement + request.environ['HTTP_AUTH_DISPLAYNAME'] = displayname + return request + + # ------------------------------------------------------------------ + # No institution found for the eppn domain + # ------------------------------------------------------------------ + def test_no_institution_redirects_to_login(self): + request = self._make_request(eppn='user@unknown.domain.jp') + view = setup_view(ShibLoginView(), request) + response = view.dispatch(request) + nt.assert_equal(response.status_code, 302) + nt.assert_in('login', response.url) + + # ------------------------------------------------------------------ + # eppn is empty string but institution lookup is mocked to + # return a result (otherwise empty domain fails at branch A) + # ------------------------------------------------------------------ + @mock.patch('admin.common_auth.views.messages.error') + def test_empty_eppn_redirects_to_login(self, mock_error): + request = self._make_request(eppn='') + with mock.patch('admin.common_auth.views.Institution') as mock_inst_cls: + mock_inst_cls.objects.filter.return_value.first.return_value = self.institution + view = setup_view(ShibLoginView(), request) + response = view.dispatch(request) + nt.assert_equal(response.status_code, 302) + nt.assert_in('login', response.url) + nt.assert_true(mock_error.called) + + # ------------------------------------------------------------------ + # Existing user + GakuNinRDMAdmin entitlement → login + # ------------------------------------------------------------------ + @mock.patch('admin.common_auth.views.userkey_generation') + @mock.patch('admin.common_auth.views.userkey_generation_check', return_value=False) + @mock.patch('admin.common_auth.views.login') + def test_existing_user_with_admin_entitlement_logs_in(self, mock_login, mock_check, mock_keygen): + user = AuthUserFactory() + user.eppn = self.EPPN + user.save() + request = self._make_request(entitlement=self.ENTITLEMENT_ADMIN) + view = setup_view(ShibLoginView(), request) + response = view.dispatch(request) + user.refresh_from_db() + nt.assert_true(user.is_staff) + nt.assert_true(mock_login.called) + nt.assert_true(mock_keygen.called) + nt.assert_equal(response.status_code, 302) + + # ------------------------------------------------------------------ + # Existing user + no admin entitlement → redirect error + # ------------------------------------------------------------------ + @mock.patch('admin.common_auth.views.messages.error') + def test_existing_user_without_admin_entitlement_redirects(self, mock_error): + user = AuthUserFactory() + user.eppn = self.EPPN + user.save() + request = self._make_request(entitlement='SomeOtherEntitlement') + view = setup_view(ShibLoginView(), request) + response = view.dispatch(request) + user.refresh_from_db() + nt.assert_false(user.is_staff) + nt.assert_true(mock_error.called) + nt.assert_equal(response.status_code, 302) + nt.assert_in('login', response.url) + + # ------------------------------------------------------------------ + # New user (no eppn match) + no admin entitlement → redirect error + # ------------------------------------------------------------------ + @mock.patch('admin.common_auth.views.messages.error') + def test_new_user_without_admin_entitlement_redirects(self, mock_error): + request = self._make_request( + eppn='nouser@' + self.EPPN_DOMAIN, + entitlement='SomeOtherEntitlement', + ) + view = setup_view(ShibLoginView(), request) + response = view.dispatch(request) + nt.assert_true(mock_error.called) + nt.assert_equal(response.status_code, 302) + nt.assert_in('login', response.url) + + # ------------------------------------------------------------------ + # New user + admin entitlement → user created, is_staff=True + # ------------------------------------------------------------------ + @mock.patch('admin.common_auth.views.userkey_generation') + @mock.patch('admin.common_auth.views.userkey_generation_check', return_value=False) + @mock.patch('admin.common_auth.views.login') + def test_new_user_with_admin_entitlement_creates_and_logs_in(self, mock_login, mock_check, mock_keygen): + new_eppn = 'brandnew@' + self.EPPN_DOMAIN + request = self._make_request(eppn=new_eppn, entitlement=self.ENTITLEMENT_ADMIN) + count_before = OSFUser.objects.count() + view = setup_view(ShibLoginView(), request) + response = view.dispatch(request) + nt.assert_equal(OSFUser.objects.count(), count_before + 1) + new_user = OSFUser.objects.get(eppn=new_eppn) + nt.assert_true(new_user.is_staff) + nt.assert_false(new_user.have_email) + nt.assert_true(mock_login.called) + nt.assert_equal(response.status_code, 302) + + # ------------------------------------------------------------------ + # Existing user → other institutions removed, current added + # ------------------------------------------------------------------ + @mock.patch('admin.common_auth.views.userkey_generation') + @mock.patch('admin.common_auth.views.userkey_generation_check', return_value=True) + @mock.patch('admin.common_auth.views.login') + def test_existing_user_institution_updated(self, mock_login, mock_check, mock_keygen): + other_institution = InstitutionFactory() + user = AuthUserFactory() + user.eppn = self.EPPN + user.affiliated_institutions.add(other_institution) + user.save() + request = self._make_request(entitlement=self.ENTITLEMENT_ADMIN) + view = setup_view(ShibLoginView(), request) + view.dispatch(request) + user.refresh_from_db() + nt.assert_false( + user.affiliated_institutions.filter(id=other_institution.id).exists() + ) + nt.assert_true( + user.affiliated_institutions.filter(id=self.institution.id).exists() + ) + + # ------------------------------------------------------------------ + # userkey_generation_check=True → userkey_generation NOT called + # ------------------------------------------------------------------ + @mock.patch('admin.common_auth.views.userkey_generation') + @mock.patch('admin.common_auth.views.userkey_generation_check', return_value=True) + @mock.patch('admin.common_auth.views.login') + def test_userkey_not_regenerated_when_exists(self, mock_login, mock_check, mock_keygen): + user = AuthUserFactory() + user.eppn = self.EPPN + user.save() + request = self._make_request(entitlement=self.ENTITLEMENT_ADMIN) + view = setup_view(ShibLoginView(), request) + view.dispatch(request) + nt.assert_false(mock_keygen.called) + + # ------------------------------------------------------------------ + # get_success_url with no param → reverse('home') + # ------------------------------------------------------------------ + def test_get_success_url_defaults_to_home(self): + request = RequestFactory().get('fake_path') + view = setup_view(ShibLoginView(), request) + nt.assert_equal(view.get_success_url(), reverse('home')) + + # '/' param also falls back to home + def test_get_success_url_with_slash_defaults_to_home(self): + request = RequestFactory().get('fake_path', {REDIRECT_FIELD_NAME: '/'}) + view = setup_view(ShibLoginView(), request) + nt.assert_equal(view.get_success_url(), reverse('home')) + + # ------------------------------------------------------------------ + # get_success_url with custom redirect param + # ------------------------------------------------------------------ + def test_get_success_url_uses_redirect_param(self): + request = RequestFactory().get('fake_path', {REDIRECT_FIELD_NAME: '/admin/nodes/'}) + view = setup_view(ShibLoginView(), request) + nt.assert_equal(view.get_success_url(), '/admin/nodes/') + + # ------------------------------------------------------------------ + # New user + admin entitlement + USE_EPPN=True + # ------------------------------------------------------------------ + @mock.patch('admin.common_auth.views.userkey_generation') + @mock.patch('admin.common_auth.views.userkey_generation_check', return_value=False) + @mock.patch('admin.common_auth.views.login') + @mock.patch('admin.common_auth.views.login_by_eppn', return_value=True) + def test_new_user_use_eppn_true(self, mock_use_eppn, mock_login, mock_check, mock_keygen): + new_eppn = 'eppntrue@' + self.EPPN_DOMAIN + request = self._make_request(eppn=new_eppn, entitlement=self.ENTITLEMENT_ADMIN) + view = setup_view(ShibLoginView(), request) + response = view.dispatch(request) + new_user = OSFUser.objects.get(eppn=new_eppn) + nt.assert_true(new_user.is_staff) + nt.assert_false(new_user.have_email) + nt.assert_equal(new_user.eppn, new_eppn) + nt.assert_equal(response.status_code, 302) + + # ------------------------------------------------------------------ + # New user + admin entitlement + USE_EPPN=False + # ------------------------------------------------------------------ + @mock.patch('admin.common_auth.views.userkey_generation') + @mock.patch('admin.common_auth.views.userkey_generation_check', return_value=False) + @mock.patch('admin.common_auth.views.login') + @mock.patch('admin.common_auth.views.login_by_eppn', return_value=False) + def test_new_user_use_eppn_false(self, mock_use_eppn, mock_login, mock_check, mock_keygen): + new_eppn = 'eppnfalse@' + self.EPPN_DOMAIN + request = self._make_request(eppn=new_eppn, entitlement=self.ENTITLEMENT_ADMIN) + view = setup_view(ShibLoginView(), request) + response = view.dispatch(request) + # Lines 120-121 override the else-branch values, so final state is same + new_user = OSFUser.objects.get(eppn=new_eppn) + nt.assert_true(new_user.is_staff) + nt.assert_false(new_user.have_email) + nt.assert_equal(new_user.eppn, new_eppn) + nt.assert_equal(response.status_code, 302) + + # ------------------------------------------------------------------ + # Japanese displayname: Apache/WSGI passes UTF-8 bytes as Latin-1 (mojibake). + # Fix encodes back to iso-8859-1 then decodes as utf-8 → original Japanese. + # ------------------------------------------------------------------ + @mock.patch('admin.common_auth.views.userkey_generation') + @mock.patch('admin.common_auth.views.userkey_generation_check', return_value=False) + @mock.patch('admin.common_auth.views.login') + @mock.patch('admin.common_auth.views.login_by_eppn', return_value=True) + def test_new_user_japanese_displayname_decoded_correctly( + self, mock_use_eppn, mock_login, mock_check, mock_keygen): + japanese_name = '山田 太郎' + # Simulate WSGI: UTF-8 bytes of the Japanese string interpreted as Latin-1 + mojibake = japanese_name.encode('utf-8').decode('latin-1') + + new_eppn = 'jauser@' + self.EPPN_DOMAIN + request = self._make_request(eppn=new_eppn, entitlement=self.ENTITLEMENT_ADMIN) + request.environ['HTTP_AUTH_DISPLAYNAME'] = mojibake + + view = setup_view(ShibLoginView(), request) + view.dispatch(request) + + new_user = OSFUser.objects.get(eppn=new_eppn) + nt.assert_equal(new_user.fullname, japanese_name) + + # ------------------------------------------------------------------ + # Multi-byte Japanese name with organization prefix (real-world case). + # ------------------------------------------------------------------ + @mock.patch('admin.common_auth.views.userkey_generation') + @mock.patch('admin.common_auth.views.userkey_generation_check', return_value=False) + @mock.patch('admin.common_auth.views.login') + @mock.patch('admin.common_auth.views.login_by_eppn', return_value=True) + def test_new_user_japanese_fullwidth_displayname_decoded_correctly( + self, mock_use_eppn, mock_login, mock_check, mock_keygen): + japanese_name = '国立情報学研究所 鈴木一郎' + mojibake = japanese_name.encode('utf-8').decode('latin-1') + + new_eppn = 'jafull@' + self.EPPN_DOMAIN + request = self._make_request(eppn=new_eppn, entitlement=self.ENTITLEMENT_ADMIN) + request.environ['HTTP_AUTH_DISPLAYNAME'] = mojibake + + view = setup_view(ShibLoginView(), request) + view.dispatch(request) + + new_user = OSFUser.objects.get(eppn=new_eppn) + nt.assert_equal(new_user.fullname, japanese_name) + + # ------------------------------------------------------------------ + # Empty displayname → falls back to 'NO NAME' + # covers: display_name = '' → get_or_create_user('' or 'NO NAME', ...) + # ------------------------------------------------------------------ + @mock.patch('admin.common_auth.views.userkey_generation') + @mock.patch('admin.common_auth.views.userkey_generation_check', return_value=False) + @mock.patch('admin.common_auth.views.login') + @mock.patch('admin.common_auth.views.login_by_eppn', return_value=True) + def test_new_user_empty_displayname_falls_back_to_no_name( + self, mock_use_eppn, mock_login, mock_check, mock_keygen): + new_eppn = 'noname@' + self.EPPN_DOMAIN + request = self._make_request(eppn=new_eppn, entitlement=self.ENTITLEMENT_ADMIN) + request.environ['HTTP_AUTH_DISPLAYNAME'] = '' # override helper default + + view = setup_view(ShibLoginView(), request) + view.dispatch(request) + + new_user = OSFUser.objects.get(eppn=new_eppn) + nt.assert_equal(new_user.fullname, 'NO NAME') + + # ------------------------------------------------------------------ + # ASCII displayname passes through unchanged + # encode('iso-8859-1').decode('utf-8') on pure ASCII is identity + # ------------------------------------------------------------------ + @mock.patch('admin.common_auth.views.userkey_generation') + @mock.patch('admin.common_auth.views.userkey_generation_check', return_value=False) + @mock.patch('admin.common_auth.views.login') + @mock.patch('admin.common_auth.views.login_by_eppn', return_value=True) + def test_new_user_ascii_displayname_passes_through( + self, mock_use_eppn, mock_login, mock_check, mock_keygen): + new_eppn = 'ascii@' + self.EPPN_DOMAIN + request = self._make_request(eppn=new_eppn, entitlement=self.ENTITLEMENT_ADMIN) + request.environ['HTTP_AUTH_DISPLAYNAME'] = 'John Smith' + + view = setup_view(ShibLoginView(), request) + view.dispatch(request) + + new_user = OSFUser.objects.get(eppn=new_eppn) + nt.assert_equal(new_user.fullname, 'John Smith') From 8890ee6b23f40c58be52ab80406751e55e87bd8d Mon Sep 17 00:00:00 2001 From: hcphat Date: Tue, 26 May 2026 18:02:56 +0700 Subject: [PATCH 24/38] =?UTF-8?q?2.2.=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=82=AF=E3=83=88=E5=90=8D=E3=81=AB=E6=97=A5=E6=9C=AC=E8=AA=9E?= =?UTF-8?q?=E3=82=92=E5=85=A5=E5=8A=9B=E3=81=99=E3=82=8B=E9=9A=9B=E3=81=AE?= =?UTF-8?q?=E6=94=B9=E5=96=84:=20Rework=20review=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/static/js/addProjectPlugin.js | 2 +- website/static/js/fangorn.js | 4 +- website/static/js/myProjects.js | 8 +- website/static/js/tests/MyProjects.test.js | 172 +++++++++++++++++- .../static/js/tests/addProjectPlugin.test.js | 144 +++++++++++++++ website/static/js/tests/fangorn.test.js | 159 ++++++++++++++++ 6 files changed, 480 insertions(+), 9 deletions(-) diff --git a/website/static/js/addProjectPlugin.js b/website/static/js/addProjectPlugin.js index a4dc22155cb..03d93edff20 100644 --- a/website/static/js/addProjectPlugin.js +++ b/website/static/js/addProjectPlugin.js @@ -204,7 +204,7 @@ var AddProject = { ctrl.newProjectName(val); }, onkeydown: function(ev){ - const isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; + var isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; if (ev.key === 'Enter' && !isComposing) { ev.preventDefault(); ev.stopPropagation(); diff --git a/website/static/js/fangorn.js b/website/static/js/fangorn.js index b220e2a0ce6..d563e6f6417 100644 --- a/website/static/js/fangorn.js +++ b/website/static/js/fangorn.js @@ -2374,7 +2374,6 @@ var FGInput = { var id = args.id || ''; var helpTextId = args.helpTextId || ''; var oninput = args.oninput || noop; - var onkeypress = args.onkeypress || noop; var onkeydown = args.onkeydown || noop; var oncompositionstart = args.oncompositionstart || noop; var oncompositionend = args.oncompositionend || noop; @@ -2383,7 +2382,6 @@ var FGInput = { 'id' : id, className: 'pull-right form-control' + extraCSS, oninput: oninput, - onkeypress: onkeypress, onkeydown: onkeydown, oncompositionstart: oncompositionstart, oncompositionend: oncompositionend, @@ -2689,7 +2687,7 @@ var FGToolbar = { }, oninput: m.withAttr('value', ctrl.renameData), onkeydown: function (event) { - const isComposing = event.isComposing || ctrl.isComposing || event.keyCode === 229; + var isComposing = event.isComposing || ctrl.isComposing || event.keyCode === 229; if (event.key === 'Enter' && !isComposing) { event.preventDefault(); event.stopPropagation(); diff --git a/website/static/js/myProjects.js b/website/static/js/myProjects.js index e87247f2407..1f428c1ff0a 100644 --- a/website/static/js/myProjects.js +++ b/website/static/js/myProjects.js @@ -1546,12 +1546,12 @@ var Collections = { ctrl.isComposing = false; }, oninput: function(ev) { - const val = ev.target.value; + var val = ev.target.value; ctrl.validateName(val); ctrl.newCollectionName(val); }, onkeydown: function (ev){ - const isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; + var isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; if(ctrl.isValid()){ if(ev.key === 'Enter' && !isComposing){ ev.preventDefault(); @@ -1605,12 +1605,12 @@ var Collections = { ctrl.isComposing = false; }, oninput: function(ev) { - const val = ev.target.value; + var val = ev.target.value; ctrl.validateName(val); ctrl.collectionMenuObject().item.renamedLabel = val; }, onkeydown: function(ev){ - const isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; + var isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; if(ctrl.isValid()) { if (ev.key === 'Enter' && !isComposing) { // if enter is pressed ev.preventDefault(); diff --git a/website/static/js/tests/MyProjects.test.js b/website/static/js/tests/MyProjects.test.js index 95c72950acc..acc794e329e 100644 --- a/website/static/js/tests/MyProjects.test.js +++ b/website/static/js/tests/MyProjects.test.js @@ -4,7 +4,7 @@ /*global describe, it, expect, example, before, after, beforeEach, afterEach, mocha, sinon*/ 'use strict'; var assert = require('chai').assert; - +var sinon = require('sinon'); var fb = require('js/myProjects.js'); var LinkObject = fb.LinkObject; @@ -36,4 +36,174 @@ describe('fileBrowser', function() { }); }); }); + + describe('Collections IME Keydown Handling', function() { + function makeMockCtrl(overrides) { + return Object.assign({ + isComposing: false, + isValid: sinon.stub().returns(true), + validateName: sinon.stub(), + newCollectionName: sinon.stub(), + addCollection: sinon.stub(), + renameCollection: sinon.stub(), + collectionMenuObject: sinon.stub().returns({ item: { renamedLabel: '' } }), + }, overrides); + } + + function makeAddCollKeydownHandler(ctrl) { + return function(ev) { + var isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; + if (ev.key === 'Enter' && !isComposing) { + ev.preventDefault(); + ev.stopPropagation(); + if (ctrl.isValid()) { + ctrl.addCollection(); + } + } + }; + } + + function makeRenameCollKeydownHandler(ctrl) { + return function(ev) { + var isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; + if (ev.key === 'Enter' && !isComposing) { + ev.preventDefault(); + ev.stopPropagation(); + if (ctrl.isValid()) { + ctrl.renameCollection(); + } + } + }; + } + + function makeEvent(overrides) { + return Object.assign({ + key: 'Enter', + isComposing: false, + keyCode: 13, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy(), + }, overrides); + } + + var ctrl; + beforeEach(function() { + ctrl = makeMockCtrl(); + }); + + describe('addCollection keydown', function() { + it('should call addCollection() on Enter when valid and not composing', function() { + var handler = makeAddCollKeydownHandler(ctrl); + handler(makeEvent()); + assert.ok(ctrl.addCollection.calledOnce, 'addCollection() should be called'); + }); + + it('should NOT call addCollection() during IME (event.isComposing=true)', function() { + var handler = makeAddCollKeydownHandler(ctrl); + handler(makeEvent({ isComposing: true })); + assert.ok(ctrl.addCollection.notCalled); + }); + + it('should NOT call addCollection() during Chrome IME race (ctrl.isComposing=true)', function() { + ctrl.isComposing = true; + var handler = makeAddCollKeydownHandler(ctrl); + handler(makeEvent({ isComposing: false })); + assert.ok(ctrl.addCollection.notCalled, + 'ctrl.isComposing=true should block addCollection() even if event.isComposing=false'); + }); + + it('should NOT call addCollection() when keyCode is 229 (legacy IME)', function() { + var handler = makeAddCollKeydownHandler(ctrl); + handler(makeEvent({ isComposing: false, keyCode: 229 })); + assert.ok(ctrl.addCollection.notCalled); + }); + + it('should call preventDefault() on Enter even when form is invalid', function() { + ctrl.isValid.returns(false); + var handler = makeAddCollKeydownHandler(ctrl); + var ev = makeEvent(); + handler(ev); + assert.ok(ev.preventDefault.calledOnce, + '[BUG] preventDefault should be called on Enter regardless of validity'); + assert.ok(ctrl.addCollection.notCalled, 'addCollection should not be called when invalid'); + }); + + it('should NOT call addCollection() on non-Enter key', function() { + var handler = makeAddCollKeydownHandler(ctrl); + handler(makeEvent({ key: 'Escape', keyCode: 27 })); + assert.ok(ctrl.addCollection.notCalled); + }); + }); + + describe('renameCollection keydown', function() { + it('should call renameCollection() on Enter when valid and not composing', function() { + var handler = makeRenameCollKeydownHandler(ctrl); + handler(makeEvent()); + assert.ok(ctrl.renameCollection.calledOnce, 'renameCollection() should be called'); + }); + + it('should NOT call renameCollection() during IME (event.isComposing=true)', function() { + var handler = makeRenameCollKeydownHandler(ctrl); + handler(makeEvent({ isComposing: true })); + assert.ok(ctrl.renameCollection.notCalled); + }); + + it('should NOT call renameCollection() when ctrl.isComposing=true (Chrome race)', function() { + ctrl.isComposing = true; + var handler = makeRenameCollKeydownHandler(ctrl); + handler(makeEvent({ isComposing: false })); + assert.ok(ctrl.renameCollection.notCalled); + }); + + it('should NOT call renameCollection() when keyCode is 229', function() { + var handler = makeRenameCollKeydownHandler(ctrl); + handler(makeEvent({ keyCode: 229, isComposing: false })); + assert.ok(ctrl.renameCollection.notCalled); + }); + }); + + describe('ctrl.isComposing flag lifecycle', function() { + it('should be set true on compositionstart', function() { + ctrl.isComposing = false; + var onCompositionStart = function() { ctrl.isComposing = true; }; + onCompositionStart(); + assert.strictEqual(ctrl.isComposing, true); + }); + + it('should be set false on compositionend', function() { + ctrl.isComposing = true; + var onCompositionEnd = function() { ctrl.isComposing = false; }; + onCompositionEnd(); + assert.strictEqual(ctrl.isComposing, false); + }); + + it('[IMPORTANT] two inputs in same ctrl share isComposing — flag pollution risk', function() { + var addHandler = makeAddCollKeydownHandler(ctrl); + var renameHandler = makeRenameCollKeydownHandler(ctrl); + + ctrl.isComposing = true; + + var ev = makeEvent({ isComposing: false }); + addHandler(ev); + + assert.ok(ctrl.addCollection.notCalled, + 'Demonstrates shared isComposing bug: addCollection blocked by rename\'s IME state'); + }); + }); + + describe('oninput handler', function() { + it('should call validateName and newCollectionName with input value', function() { + var val = 'Test Collection'; + var ev = { target: { value: val } }; + var onInput = function(ev) { + var v = ev.target.value; + ctrl.validateName(v); + ctrl.newCollectionName(v); + }; + onInput(ev); + assert.ok(ctrl.validateName.calledWith(val)); + assert.ok(ctrl.newCollectionName.calledWith(val)); + }); + }); + }); }); \ No newline at end of file diff --git a/website/static/js/tests/addProjectPlugin.test.js b/website/static/js/tests/addProjectPlugin.test.js index d4b6c9c79c8..0f2ab7de026 100644 --- a/website/static/js/tests/addProjectPlugin.test.js +++ b/website/static/js/tests/addProjectPlugin.test.js @@ -1,6 +1,7 @@ /*global describe, it, expect, example, before, after, beforeEach, afterEach, mocha, sinon*/ 'use strict'; var assert = require('chai').assert; +var sinon = require('sinon'); var Raven = require('raven-js'); var $ = require('jquery'); var m = require('mithril'); @@ -35,4 +36,147 @@ describe('AddProjectPlugin', () => { assert.equal(project.newProjectCategory(), 'project'); assert.equal(project.project.newProjectInheritContribs(), false); }); + + describe('IME Keydown Handling (onkeydown)', () => { + var ctrl; + function makeMockCtrl(overrides) { + return Object.assign({ + isComposing: false, + isValid: sinon.stub().returns(true), + newProjectName: sinon.stub(), + add: sinon.stub(), + }, overrides); + } + function makeKeydownHandler(ctrl) { + return function(ev) { + var isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; + if (ev.key === 'Enter' && !isComposing) { + ev.preventDefault(); + ev.stopPropagation(); + ctrl.add(); + } + }; + } + function makeEvent(overrides) { + return Object.assign({ + key: 'Enter', + isComposing: false, + keyCode: 13, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy(), + }, overrides); + } + + beforeEach(function() { + ctrl = makeMockCtrl(); + }); + + it('should call ctrl.add() when Enter is pressed without IME', () => { + var handler = makeKeydownHandler(ctrl); + var ev = makeEvent({ key: 'Enter', isComposing: false, keyCode: 13 }); + + handler(ev); + + assert.ok(ctrl.add.calledOnce, 'ctrl.add() should be called once'); + assert.ok(ev.preventDefault.calledOnce, 'preventDefault should be called'); + assert.ok(ev.stopPropagation.calledOnce, 'stopPropagation should be called'); + }); + + it('should NOT call ctrl.add() when a non-Enter key is pressed', () => { + var handler = makeKeydownHandler(ctrl); + var ev = makeEvent({ key: 'a', keyCode: 65 }); + + handler(ev); + + assert.ok(ctrl.add.notCalled, 'ctrl.add() should not be called for non-Enter key'); + assert.ok(ev.preventDefault.notCalled, 'preventDefault should not be called'); + }); + + it('should NOT call ctrl.add() when event.isComposing is true (standard IME)', () => { + var handler = makeKeydownHandler(ctrl); + var ev = makeEvent({ key: 'Enter', isComposing: true, keyCode: 13 }); + + handler(ev); + + assert.ok(ctrl.add.notCalled, 'ctrl.add() should not be called during IME composition'); + assert.ok(ev.preventDefault.notCalled, 'preventDefault should not be called during IME'); + }); + + it('should NOT call ctrl.add() when ctrl.isComposing is true (Chrome race condition)', () => { + ctrl.isComposing = true; + var handler = makeKeydownHandler(ctrl); + var ev = makeEvent({ key: 'Enter', isComposing: false, keyCode: 13 }); + + handler(ev); + + assert.ok(ctrl.add.notCalled, 'ctrl.add() should not be called when ctrl.isComposing is true'); + }); + + it('should NOT call ctrl.add() when keyCode is 229 (legacy IME indicator)', () => { + var handler = makeKeydownHandler(ctrl); + var ev = makeEvent({ key: 'Enter', isComposing: false, keyCode: 229 }); + + handler(ev); + + assert.ok(ctrl.add.notCalled, 'ctrl.add() should not be called when keyCode is 229'); + }); + + it('should set ctrl.isComposing to true on compositionstart', () => { + ctrl.isComposing = false; + var onCompositionStart = function() { ctrl.isComposing = true; }; + onCompositionStart(); + assert.strictEqual(ctrl.isComposing, true); + }); + + it('should set ctrl.isComposing to false on compositionend (synchronous)', () => { + ctrl.isComposing = true; + var onCompositionEnd = function() { ctrl.isComposing = false; }; + onCompositionEnd(); + assert.strictEqual(ctrl.isComposing, false); + }); + + it('[RECOMMENDED] ctrl.isComposing should still be true when keydown fires ' + + 'if compositionend uses setTimeout defer', function(done) { + ctrl.isComposing = true; + + var onCompositionEnd = function() { + setTimeout(function() { ctrl.isComposing = false; }, 0); + }; + onCompositionEnd(); + + assert.strictEqual(ctrl.isComposing, true, + 'isComposing should still be true synchronously after compositionend with setTimeout'); + + setTimeout(function() { + assert.strictEqual(ctrl.isComposing, false, + 'isComposing should be false after setTimeout resolves'); + done(); + }, 10); + }); + + it('should update newProjectName and isValid via oninput', () => { + var ev = { target: { value: 'My Project' } }; + var onInput = function(ev) { + var val = ev.target.value; + ctrl.isValid(val.trim().length > 0); + ctrl.newProjectName(val); + }; + + onInput(ev); + + assert.ok(ctrl.newProjectName.calledWith('My Project')); + assert.ok(ctrl.isValid.calledWith(true)); + }); + + it('should mark isValid false via oninput when input is whitespace only', () => { + var ev = { target: { value: ' ' } }; + var onInput = function(ev) { + var val = ev.target.value; + ctrl.isValid(val.trim().length > 0); + ctrl.newProjectName(val); + }; + onInput(ev); + assert.ok(ctrl.isValid.calledWith(false)); + }); + }); }); diff --git a/website/static/js/tests/fangorn.test.js b/website/static/js/tests/fangorn.test.js index 43afe7bc7b0..25e28db32f7 100644 --- a/website/static/js/tests/fangorn.test.js +++ b/website/static/js/tests/fangorn.test.js @@ -6,6 +6,7 @@ var $osf = require('js/osfHelpers'); var Fangorn = require('js/fangorn'); var assert = require('chai').assert; +var sinon = require('sinon'); var utils = require('tests/utils'); var faker = require('faker'); var $ = require('jquery'); @@ -423,4 +424,162 @@ describe('fangorn', () => { }); }); }); + + describe('FGToolbar IME Keydown Handling', function() { + function makeMockCtrl(overrides) { + var tb = { + pressedKey: null, + }; + return Object.assign({ + isComposing: false, + tb: tb, + nameData: sinon.stub(), + renameData: sinon.stub(), + createFolder: sinon.stub(), + dismissToolbar: sinon.stub(), + _renameEvent: sinon.stub(), + }, overrides); + } + + function makeAddFolderKeydownHandler(ctrl) { + return function(event) { + var isComposing = event.isComposing || ctrl.isComposing || event.keyCode === 229; + if (event.key === 'Enter' && !isComposing) { + event.preventDefault(); + event.stopPropagation(); + ctrl.createFolder.call(ctrl.tb, event, ctrl.dismissToolbar); + } + }; + } + + function makeRenameKeydownHandler(ctrl, renameEvent) { + return function(event) { + var isComposing = event.isComposing || ctrl.isComposing || event.keyCode === 229; + if (event.key === 'Enter' && !isComposing) { + event.preventDefault(); + event.stopPropagation(); + renameEvent.call(ctrl.tb); + } + }; + } + + function makeEvent(overrides) { + return Object.assign({ + key: 'Enter', + isComposing: false, + keyCode: 13, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy(), + }, overrides); + } + + var ctrl; + beforeEach(function() { + ctrl = makeMockCtrl(); + }); + + describe('ADDFOLDER toolbar', function() { + it('should call createFolder on Enter when not composing', function() { + var handler = makeAddFolderKeydownHandler(ctrl); + var ev = makeEvent(); + handler(ev); + assert.ok(ctrl.createFolder.calledOnce, 'createFolder should be called'); + assert.ok(ev.preventDefault.calledOnce); + assert.ok(ev.stopPropagation.calledOnce); + }); + + it('should NOT call createFolder during IME (event.isComposing=true)', function() { + var handler = makeAddFolderKeydownHandler(ctrl); + handler(makeEvent({ isComposing: true })); + assert.ok(ctrl.createFolder.notCalled, + 'createFolder should not be called during IME composition'); + }); + + it('should NOT call createFolder when ctrl.isComposing=true (Chrome race condition)', function() { + ctrl.isComposing = true; + var handler = makeAddFolderKeydownHandler(ctrl); + handler(makeEvent({ isComposing: false })); // Chrome: event.isComposing = false + assert.ok(ctrl.createFolder.notCalled, + 'createFolder should be blocked by ctrl.isComposing=true'); + }); + + it('should NOT call createFolder when keyCode is 229 (legacy IE)', function() { + var handler = makeAddFolderKeydownHandler(ctrl); + handler(makeEvent({ isComposing: false, keyCode: 229 })); + assert.ok(ctrl.createFolder.notCalled); + }); + + it('should NOT call createFolder on non-Enter key', function() { + var handler = makeAddFolderKeydownHandler(ctrl); + handler(makeEvent({ key: 'Tab', keyCode: 9 })); + assert.ok(ctrl.createFolder.notCalled); + }); + + it('should call createFolder with correct this context (ctrl.tb)', function() { + var handler = makeAddFolderKeydownHandler(ctrl); + var ev = makeEvent(); + handler(ev); + // createFolder.call(ctrl.tb, ...) → this = ctrl.tb + assert.ok(ctrl.createFolder.calledOn(ctrl.tb), + 'createFolder should be called with ctrl.tb as context'); + }); + }); + + describe('RENAME toolbar', function() { + var renameEventStub; + beforeEach(function() { + renameEventStub = sinon.stub(); + }); + + it('should call _renameEvent on Enter when not composing', function() { + var handler = makeRenameKeydownHandler(ctrl, renameEventStub); + var ev = makeEvent(); + handler(ev); + assert.ok(renameEventStub.calledOnce, '_renameEvent should be called'); + assert.ok(ev.preventDefault.calledOnce); + }); + + it('should NOT call _renameEvent during IME (event.isComposing=true)', function() { + var handler = makeRenameKeydownHandler(ctrl, renameEventStub); + handler(makeEvent({ isComposing: true })); + assert.ok(renameEventStub.notCalled); + }); + + it('should NOT call _renameEvent when ctrl.isComposing=true (Chrome race)', function() { + ctrl.isComposing = true; + var handler = makeRenameKeydownHandler(ctrl, renameEventStub); + handler(makeEvent({ isComposing: false })); + assert.ok(renameEventStub.notCalled); + }); + + it('should NOT call _renameEvent when keyCode is 229', function() { + var handler = makeRenameKeydownHandler(ctrl, renameEventStub); + handler(makeEvent({ keyCode: 229 })); + assert.ok(renameEventStub.notCalled); + }); + }); + + describe('FGToolbar isComposing flag', function() { + it('should start false (initialized)', function() { + assert.strictEqual(ctrl.isComposing, false, + 'isComposing should be initialized to false'); + }); + + it('[RECOMMENDED] compositionend with setTimeout defer keeps flag true during keydown', function(done) { + ctrl.isComposing = true; + setTimeout(function() { ctrl.isComposing = false; }, 0); + + assert.strictEqual(ctrl.isComposing, true, + 'isComposing should still be true synchronously'); + + setTimeout(function() { + assert.strictEqual(ctrl.isComposing, false, + 'isComposing should be false after defer'); + done(); + }, 10); + }); + }); + }); }); + + From 2358fd6b8b68fb8b616879dfb8f098fa5ffa351b Mon Sep 17 00:00:00 2001 From: hcphat Date: Wed, 27 May 2026 10:06:15 +0700 Subject: [PATCH 25/38] =?UTF-8?q?Ref=202.1.IdP=E3=81=8B=E3=82=89=E5=8F=97?= =?UTF-8?q?=E9=A0=98=E3=81=99=E3=82=8B=E8=AA=8D=E8=A8=BC=E6=83=85=E5=A0=B1?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=AE=E6=97=A5=E6=9C=AC=E8=AA=9E=E6=96=87?= =?UTF-8?q?=E5=AD=97=E5=8C=96=E3=81=91=E8=A7=A3=E6=B6=88:=20Update=20raw?= =?UTF-8?q?=5Fdisplay=5Fname=20and=20UT=20for=20KeyError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/common_auth/views.py | 2 +- admin_tests/common_auth/test_views.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/admin/common_auth/views.py b/admin/common_auth/views.py index 5724b32d47c..d2dc3993090 100644 --- a/admin/common_auth/views.py +++ b/admin/common_auth/views.py @@ -109,7 +109,7 @@ def dispatch(self, request, *args, **kwargs): return redirect('auth:login') else: tmp_eppn = ('tmp_eppn_' + eppn).lower() - raw_display_name = request.environ.get('HTTP_AUTH_DISPLAYNAME', '') + raw_display_name = request.environ['HTTP_AUTH_DISPLAYNAME'] display_name = raw_display_name.encode('iso-8859-1').decode('utf-8') if raw_display_name else '' new_user, created = get_or_create_user(display_name or 'NO NAME', tmp_eppn, reset_password=False) USE_EPPN = login_by_eppn() diff --git a/admin_tests/common_auth/test_views.py b/admin_tests/common_auth/test_views.py index 6ea2d1173b4..5bc23647714 100644 --- a/admin_tests/common_auth/test_views.py +++ b/admin_tests/common_auth/test_views.py @@ -344,3 +344,15 @@ def test_new_user_ascii_displayname_passes_through( new_user = OSFUser.objects.get(eppn=new_eppn) nt.assert_equal(new_user.fullname, 'John Smith') + + # ------------------------------------------------------------------ + # Missing HTTP_AUTH_DISPLAYNAME header → KeyError + # ------------------------------------------------------------------ + @mock.patch('admin.common_auth.views.login_by_eppn', return_value=True) + def test_new_user_missing_displayname_header_raises_key_error(self, mock_use_eppn): + new_eppn = 'missing@' + self.EPPN_DOMAIN + request = self._make_request(eppn=new_eppn, entitlement=self.ENTITLEMENT_ADMIN) + del request.environ['HTTP_AUTH_DISPLAYNAME'] + view = setup_view(ShibLoginView(), request) + with nt.assert_raises(KeyError): + view.dispatch(request) From 6153dd2b8a072913c7fdc9f5bc9430ff4e87ceed Mon Sep 17 00:00:00 2001 From: hcphat Date: Fri, 29 May 2026 11:43:39 +0700 Subject: [PATCH 26/38] =?UTF-8?q?Ref=202.2.=E3=83=97=E3=83=AD=E3=82=B8?= =?UTF-8?q?=E3=82=A7=E3=82=AF=E3=83=88=E5=90=8D=E3=81=AB=E6=97=A5=E6=9C=AC?= =?UTF-8?q?=E8=AA=9E=E3=82=92=E5=85=A5=E5=8A=9B=E3=81=99=E3=82=8B=E9=9A=9B?= =?UTF-8?q?=E3=81=AE=E6=94=B9=E5=96=84:=20Rework=20review=20comment=20-=20?= =?UTF-8?q?Isolate=20or=20reset=20the=20`isComposing`=20flag=20to=20preven?= =?UTF-8?q?t=20pollution=20between=20modals.=20-=20Explicitly=20initialize?= =?UTF-8?q?=20the=20`isComposing`=20property=20in=20the=20controller.=20-?= =?UTF-8?q?=20Update=20UT=20test=20to=20ensure=20tests=20accurately=20veri?= =?UTF-8?q?fy=20the=20source=20code=20behavior.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/static/js/addProjectPlugin.js | 1 + website/static/js/fangorn.js | 14 +++++----- website/static/js/myProjects.js | 30 ++++++++++++---------- website/static/js/tests/MyProjects.test.js | 24 +++++------------ website/static/js/tests/fangorn.test.js | 18 ++++++++----- 5 files changed, 42 insertions(+), 45 deletions(-) diff --git a/website/static/js/addProjectPlugin.js b/website/static/js/addProjectPlugin.js index 03d93edff20..ba4e5c74c54 100644 --- a/website/static/js/addProjectPlugin.js +++ b/website/static/js/addProjectPlugin.js @@ -17,6 +17,7 @@ var sprintf = require('agh.sprintf').sprintf; var AddProject = { controller : function (options) { var self = this; + self.isComposing = false; self.defaults = { buttonTemplate : m('.btn.btn-primary[data-toggle="modal"][data-target="#addProjectModal"]', _('Create new project')), parentID : null, diff --git a/website/static/js/fangorn.js b/website/static/js/fangorn.js index d563e6f6417..18f5d48d33d 100644 --- a/website/static/js/fangorn.js +++ b/website/static/js/fangorn.js @@ -2590,6 +2590,8 @@ var dismissToolbar = function(helpText){ var FGToolbar = { controller : function(args) { var self = this; + self.isComposingAddFolder = false; + self.isComposingRenameFolder = false; self.tb = args.treebeard; self.tb.toolbarMode = m.prop(toolbarModes.DEFAULT); self.items = args.treebeard.multiselected; @@ -2643,14 +2645,14 @@ var FGToolbar = { m('.col-xs-9', [ m.component(FGInput, { oncompositionstart: function () { - ctrl.isComposing = true; + ctrl.isComposingAddFolder = true; }, oncompositionend: function () { - ctrl.isComposing = false; + ctrl.isComposingAddFolder = false; }, oninput: m.withAttr('value', ctrl.nameData), onkeydown: function (event) { - const isComposing = event.isComposing || ctrl.isComposing || event.keyCode === 229; + const isComposing = event.isComposing || ctrl.isComposingAddFolder || event.keyCode === 229; if (event.key === 'Enter' && !isComposing) { event.preventDefault(); event.stopPropagation(); @@ -2680,14 +2682,14 @@ var FGToolbar = { m('.col-xs-9', m.component(FGInput, { oncompositionstart: function () { - ctrl.isComposing = true; + ctrl.isComposingRenameFolder = true; }, oncompositionend: function () { - ctrl.isComposing = false; + ctrl.isComposingRenameFolder = false; }, oninput: m.withAttr('value', ctrl.renameData), onkeydown: function (event) { - var isComposing = event.isComposing || ctrl.isComposing || event.keyCode === 229; + var isComposing = event.isComposing || ctrl.isComposingRenameFolder || event.keyCode === 229; if (event.key === 'Enter' && !isComposing) { event.preventDefault(); event.stopPropagation(); diff --git a/website/static/js/myProjects.js b/website/static/js/myProjects.js index 1f428c1ff0a..d01a66dcc85 100644 --- a/website/static/js/myProjects.js +++ b/website/static/js/myProjects.js @@ -1205,6 +1205,8 @@ var MyProjects = { var Collections = { controller : function(args){ var self = this; + self.isComposingAdd = false; + self.isComposingRename = false; self.collections = args.collections; self.pageSize = args.collectionsPageSize; self.newCollectionName = m.prop(''); @@ -1540,10 +1542,10 @@ var Collections = { m('label[for="addCollInput].f-w-lg.text-bigger', _('Collection name')), m('input[type="text"].form-control#addCollInput', { oncompositionstart: function () { - ctrl.isComposing = true; + ctrl.isComposingAdd = true; }, oncompositionend: function () { - ctrl.isComposing = false; + ctrl.isComposingAdd = false; }, oninput: function(ev) { var val = ev.target.value; @@ -1551,11 +1553,11 @@ var Collections = { ctrl.newCollectionName(val); }, onkeydown: function (ev){ - var isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; - if(ctrl.isValid()){ - if(ev.key === 'Enter' && !isComposing){ - ev.preventDefault(); - ev.stopPropagation(); + var isComposing = ev.isComposing || ctrl.isComposingAdd || ev.keyCode === 229; + if (ev.key === 'Enter' && !isComposing) { + ev.preventDefault(); + ev.stopPropagation(); + if (ctrl.isValid()) { ctrl.addCollection(); } } @@ -1599,10 +1601,10 @@ var Collections = { m('label[for="addCollInput]', _('Rename to: ')), m('input[type="text"].form-control.m-l-sm',{ oncompositionstart: function () { - ctrl.isComposing = true; + ctrl.isComposingRename = true; }, oncompositionend: function () { - ctrl.isComposing = false; + ctrl.isComposingRename = false; }, oninput: function(ev) { var val = ev.target.value; @@ -1610,11 +1612,11 @@ var Collections = { ctrl.collectionMenuObject().item.renamedLabel = val; }, onkeydown: function(ev){ - var isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; - if(ctrl.isValid()) { - if (ev.key === 'Enter' && !isComposing) { // if enter is pressed - ev.preventDefault(); - ev.stopPropagation(); + var isComposing = ev.isComposing || ctrl.isComposingRename || ev.keyCode === 229; + if (ev.key === 'Enter' && !isComposing) { + ev.preventDefault(); + ev.stopPropagation(); + if (ctrl.isValid()) { ctrl.renameCollection(); } } diff --git a/website/static/js/tests/MyProjects.test.js b/website/static/js/tests/MyProjects.test.js index acc794e329e..cba0a69c50f 100644 --- a/website/static/js/tests/MyProjects.test.js +++ b/website/static/js/tests/MyProjects.test.js @@ -40,7 +40,8 @@ describe('fileBrowser', function() { describe('Collections IME Keydown Handling', function() { function makeMockCtrl(overrides) { return Object.assign({ - isComposing: false, + isComposingAdd: false, + isComposingRename: false, isValid: sinon.stub().returns(true), validateName: sinon.stub(), newCollectionName: sinon.stub(), @@ -52,7 +53,7 @@ describe('fileBrowser', function() { function makeAddCollKeydownHandler(ctrl) { return function(ev) { - var isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; + var isComposing = ev.isComposing || ctrl.isComposingAdd || ev.keyCode === 229; if (ev.key === 'Enter' && !isComposing) { ev.preventDefault(); ev.stopPropagation(); @@ -65,7 +66,7 @@ describe('fileBrowser', function() { function makeRenameCollKeydownHandler(ctrl) { return function(ev) { - var isComposing = ev.isComposing || ctrl.isComposing || ev.keyCode === 229; + var isComposing = ev.isComposing || ctrl.isComposingRename || ev.keyCode === 229; if (ev.key === 'Enter' && !isComposing) { ev.preventDefault(); ev.stopPropagation(); @@ -105,7 +106,7 @@ describe('fileBrowser', function() { }); it('should NOT call addCollection() during Chrome IME race (ctrl.isComposing=true)', function() { - ctrl.isComposing = true; + ctrl.isComposingAdd = true; var handler = makeAddCollKeydownHandler(ctrl); handler(makeEvent({ isComposing: false })); assert.ok(ctrl.addCollection.notCalled, @@ -149,7 +150,7 @@ describe('fileBrowser', function() { }); it('should NOT call renameCollection() when ctrl.isComposing=true (Chrome race)', function() { - ctrl.isComposing = true; + ctrl.isComposingRename = true; var handler = makeRenameCollKeydownHandler(ctrl); handler(makeEvent({ isComposing: false })); assert.ok(ctrl.renameCollection.notCalled); @@ -176,19 +177,6 @@ describe('fileBrowser', function() { onCompositionEnd(); assert.strictEqual(ctrl.isComposing, false); }); - - it('[IMPORTANT] two inputs in same ctrl share isComposing — flag pollution risk', function() { - var addHandler = makeAddCollKeydownHandler(ctrl); - var renameHandler = makeRenameCollKeydownHandler(ctrl); - - ctrl.isComposing = true; - - var ev = makeEvent({ isComposing: false }); - addHandler(ev); - - assert.ok(ctrl.addCollection.notCalled, - 'Demonstrates shared isComposing bug: addCollection blocked by rename\'s IME state'); - }); }); describe('oninput handler', function() { diff --git a/website/static/js/tests/fangorn.test.js b/website/static/js/tests/fangorn.test.js index 25e28db32f7..10a18c56f1e 100644 --- a/website/static/js/tests/fangorn.test.js +++ b/website/static/js/tests/fangorn.test.js @@ -431,7 +431,8 @@ describe('fangorn', () => { pressedKey: null, }; return Object.assign({ - isComposing: false, + isComposingAddFolder: false, + isComposingRenameFolder: false, tb: tb, nameData: sinon.stub(), renameData: sinon.stub(), @@ -443,7 +444,7 @@ describe('fangorn', () => { function makeAddFolderKeydownHandler(ctrl) { return function(event) { - var isComposing = event.isComposing || ctrl.isComposing || event.keyCode === 229; + var isComposing = event.isComposing || ctrl.isComposingAddFolder || event.keyCode === 229; if (event.key === 'Enter' && !isComposing) { event.preventDefault(); event.stopPropagation(); @@ -454,7 +455,7 @@ describe('fangorn', () => { function makeRenameKeydownHandler(ctrl, renameEvent) { return function(event) { - var isComposing = event.isComposing || ctrl.isComposing || event.keyCode === 229; + var isComposing = event.isComposing || ctrl.isComposingRenameFolder || event.keyCode === 229; if (event.key === 'Enter' && !isComposing) { event.preventDefault(); event.stopPropagation(); @@ -496,7 +497,7 @@ describe('fangorn', () => { }); it('should NOT call createFolder when ctrl.isComposing=true (Chrome race condition)', function() { - ctrl.isComposing = true; + ctrl.isComposingAddFolder = true; var handler = makeAddFolderKeydownHandler(ctrl); handler(makeEvent({ isComposing: false })); // Chrome: event.isComposing = false assert.ok(ctrl.createFolder.notCalled, @@ -546,7 +547,7 @@ describe('fangorn', () => { }); it('should NOT call _renameEvent when ctrl.isComposing=true (Chrome race)', function() { - ctrl.isComposing = true; + ctrl.isComposingRenameFolder = true; var handler = makeRenameKeydownHandler(ctrl, renameEventStub); handler(makeEvent({ isComposing: false })); assert.ok(renameEventStub.notCalled); @@ -561,8 +562,11 @@ describe('fangorn', () => { describe('FGToolbar isComposing flag', function() { it('should start false (initialized)', function() { - assert.strictEqual(ctrl.isComposing, false, - 'isComposing should be initialized to false'); + assert.strictEqual(ctrl.isComposingAddFolder, false, + 'isComposingAddFolder should be initialized to false'); + + assert.strictEqual(ctrl.isComposingRenameFolder, false, + 'isComposingRenameFolder should be initialized to false'); }); it('[RECOMMENDED] compositionend with setTimeout defer keeps flag true during keydown', function(done) { From 63b1b7a678a26ec09967ce3402f8af58ec93a829 Mon Sep 17 00:00:00 2001 From: soum-fujimoto Date: Tue, 2 Jun 2026 03:57:10 +0000 Subject: [PATCH 27/38] Hotfix 58729 --- api/users/views.py | 6 +- api_tests/users/views/test_user_detail.py | 180 ++++++++-------------- 2 files changed, 64 insertions(+), 122 deletions(-) diff --git a/api/users/views.py b/api/users/views.py index 2a561309161..f941a33c7f2 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -35,7 +35,7 @@ from api.registrations.serializers import RegistrationSerializer from api.users.permissions import ( - CurrentUser, + CurrentUser, ReadOnlyOrCurrentUser, CurrentUserRelationship, ClaimUserPermission, ) @@ -175,8 +175,8 @@ class UserDetail(JSONAPIBaseView, generics.RetrieveUpdateAPIView, UserMixin): """The documentation for this endpoint can be found [here](https://developer.osf.io/#operation/users_read). """ permission_classes = ( - drf_permissions.IsAuthenticated, - CurrentUser, + drf_permissions.IsAuthenticatedOrReadOnly, + ReadOnlyOrCurrentUser, base_permissions.TokenHasScope, ) diff --git a/api_tests/users/views/test_user_detail.py b/api_tests/users/views/test_user_detail.py index 3d6af5cfa33..f6ef010c490 100644 --- a/api_tests/users/views/test_user_detail.py +++ b/api_tests/users/views/test_user_detail.py @@ -53,71 +53,49 @@ def test_get(self, app, user_one, user_two, project, view_only_link): # test_gets_200 url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, auth=user_one.auth) + res = app.get(url) assert res.status_code == 200 assert res.content_type == 'application/vnd.api+json' - # test_gets_401 - url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, expect_errors=True) - assert res.status_code == 401 - assert res.content_type == 'application/vnd.api+json' - # test_get_correct_pk_user url = '/{}users/{}/?version=latest'.format(API_BASE, user_one._id) - res = app.get(url, auth=user_one.auth) + res = app.get(url) user_json = res.json['data'] assert user_json['attributes']['full_name'] == user_one.fullname assert user_one.social['twitter'] == user_json['attributes']['social']['twitter'] - # test_get_correct_pk_user_not_logged_in - url = '/{}users/{}/?version=latest'.format(API_BASE, user_one._id) - res = app.get(url, expect_errors=True) - assert res.status_code == 401 - # test_get_incorrect_pk_user_logged_in - url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, auth=user_one.auth) + url = '/{}users/{}/'.format(API_BASE, user_two._id) + res = app.get(url) user_json = res.json['data'] - assert user_json['attributes']['full_name'] == user_one.fullname - - # test_get_incorrect_pk_user_not_logged_in - url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, expect_errors=True) - assert res.status_code == 401 + assert user_json['attributes']['full_name'] != user_one.fullname # test_returns_timezone_and_locale url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, auth=user_one.auth) + res = app.get(url) attributes = res.json['data']['attributes'] assert attributes['timezone'] == user_one.timezone assert attributes['locale'] == user_one.locale - # test_returns_timezone_and_locale_not_logged_in - url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, expect_errors=True) - assert res.status_code == 401 - # test_get_new_users url = '/{}users/{}/'.format(API_BASE, user_two._id) - res = app.get(url, auth=user_one.auth, expect_errors=True) - assert res.status_code == 403 - - # test_get_new_users_not_logged_in - url = '/{}users/{}/'.format(API_BASE, user_two._id) - res = app.get(url, expect_errors=True) - assert res.status_code == 401 + res = app.get(url) + assert res.status_code == 200 + assert res.json['data']['attributes']['full_name'] == user_two.fullname + assert res.json['data']['attributes']['social'] == {} # test_get_incorrect_pk_user_not_logged_in url = '/{}users/{}/'.format(API_BASE, user_two._id) - res = app.get(url, auth=user_one.auth, expect_errors=True) - assert res.status_code == 403 + res = app.get(url, auth=user_one.auth) + user_json = res.json['data'] + assert user_json['attributes']['full_name'] != user_one.fullname + assert user_json['attributes']['full_name'] == user_two.fullname # test_user_detail_takes_profile_image_size_param size = 42 url = '/{}users/{}/?profile_image_size={}'.format( API_BASE, user_one._id, size) - res = app.get(url, auth=user_one.auth) + res = app.get(url) user_json = res.json['data'] profile_image_url = user_json['links']['profile_image'] query_dict = parse_qs( @@ -126,13 +104,13 @@ def test_get(self, app, user_one, user_two, project, view_only_link): # test_profile_image_in_links url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, auth=user_one.auth) + res = app.get(url) user_json = res.json['data'] assert 'profile_image' in user_json['links'] # user_viewed_through_anonymous_link url = '/{}users/{}/?view_only={}'.format(API_BASE, user_one._id, view_only_link.key) - res = app.get(url, auth=user_one.auth) + res = app.get(url) user_json = res.json['data'] assert user_json['id'] == '' assert user_json['type'] == 'users' @@ -142,7 +120,7 @@ def test_get(self, app, user_one, user_two, project, view_only_link): def test_files_relationship_upload(self, app, user_one): url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, auth=user_one.auth) + res = app.get(url, auth=user_one) quickfiles = QuickFilesNode.objects.get(creator=user_one) user_json = res.json['data'] upload_url = user_json['relationships']['quickfiles']['links']['upload']['href'] @@ -151,69 +129,43 @@ def test_files_relationship_upload(self, app, user_one): assert upload_url == waterbutler_upload - def test_files_relationship_upload_not_logged_in(self, app, user_one): - url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, expect_errors=True) - assert res.status_code == 401 - def test_preprint_relationship(self, app, user_one): url = '/{}users/{}/'.format(API_BASE, user_one._id) preprint_url = '/{}users/{}/preprints/'.format(API_BASE, user_one._id) - res = app.get(url, auth=user_one.auth) + res = app.get(url, auth=user_one) user_json = res.json['data'] href_url = user_json['relationships']['preprints']['links']['related']['href'] assert preprint_url in href_url - def test_preprint_relationship_not_logged_in(self, app, user_one): - url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, expect_errors=True) - assert res.status_code == 401 - def test_registrations_relationship(self, app, user_one): url = '/{}users/{}/'.format(API_BASE, user_one._id) registration_url = '/{}users/{}/registrations/'.format( API_BASE, user_one._id) - res = app.get(url, auth=user_one.auth) + res = app.get(url, auth=user_one) user_json = res.json['data'] href_url = user_json['relationships']['registrations']['links']['related']['href'] assert registration_url in href_url - def test_registrations_relationship_not_logged_in(self, app, user_one): - url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, expect_errors=True) - assert res.status_code == 401 - def test_nodes_relationship_is_absent(self, app, user_one): url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, auth=user_one.auth) + res = app.get(url, auth=user_one) assert 'node' not in res.json['data']['relationships'].keys() - def test_nodes_relationship_is_absent_not_logged_in(self, app, user_one): - url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, expect_errors=True) - assert res.status_code == 401 - def test_emails_relationship(self, app, user_one): # test relationship does not show for anonymous request url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, auth=user_one.auth) - assert 'emails' in res.json['data']['relationships'].keys() - - def test_emails_relationship_not_logged_in(self, app, user_one): - # test relationship does not show for anonymous request - url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, expect_errors=True) - assert res.status_code == 401 + res = app.get(url) + assert 'emails' not in res.json['data']['relationships'].keys() def test_user_settings_relationship(self, app, user_one, user_two): # settings relationship does not show for anonymous request url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, expect_errors=True) - assert res.status_code == 401 + res = app.get(url) + assert 'settings' not in res.json['data']['relationships'].keys() # settings does not appear for a different user - res = app.get(url, auth=user_two.auth, expect_errors=True) - assert res.status_code == 403 + res = app.get(url, auth=user_two.auth) + assert 'settings' not in res.json['data']['relationships'].keys() # settings is present for the current user res = app.get(url, auth=user_one.auth) @@ -230,7 +182,7 @@ def test_social_values_old_version(self, app, user_one): user_one.social = {'twitter': [socialname], 'github': []} user_one.save() url = '/{}users/{}/?version=2.9'.format(API_BASE, user_one._id) - res = app.get(url, auth=user_one.auth) + res = app.get(url, auth=user_one) user_social_json = res.json['data']['attributes']['social'] assert user_social_json['twitter'] == socialname @@ -238,25 +190,13 @@ def test_social_values_old_version(self, app, user_one): assert 'linkedIn' not in user_social_json.keys() url = '/{}users/{}/?version=2.10'.format(API_BASE, user_one._id) - res = app.get(url, auth=user_one.auth) + res = app.get(url, auth=user_one) user_social_json = res.json['data']['attributes']['social'] assert user_social_json['twitter'] == [socialname] assert user_social_json['github'] == [] assert 'linkedIn' not in user_social_json.keys() - def test_social_values_old_version_not_logged_in(self, app, user_one): - socialname = 'ohhey' - user_one.social = {'twitter': [socialname], 'github': []} - user_one.save() - url = '/{}users/{}/?version=2.9'.format(API_BASE, user_one._id) - res = app.get(url, expect_errors=True) - assert res.status_code == 401 - - url = '/{}users/{}/?version=2.10'.format(API_BASE, user_one._id) - res = app.get(url, auth=user_one, expect_errors=True) - assert res.status_code == 401 - @pytest.mark.django_db @pytest.mark.enable_quickfiles_creation @pytest.mark.enable_bookmark_creation @@ -352,13 +292,14 @@ def test_get_200_responses( # test_get_200_path_users_user_id_no_user url = '/{}users/{}/'.format(API_BASE, user_two._id) - res = app.get(url, expect_errors=True) - assert res.status_code == 401 + res = app.get(url) + assert res.status_code == 200 # test_get_200_path_users_user_id_unauthorized_user url = '/{}users/{}/'.format(API_BASE, user_two._id) - res = app.get(url, auth=user_one.auth, expect_errors=True) - assert res.status_code == 403 + res = app.get(url, auth=user_one.auth) + assert res.status_code == 200 + assert res.json['data']['id'] == user_two._id # test_get_200_path_users_me_nodes_user_logged_in url = '/{}users/me/nodes/'.format(API_BASE, user_one._id) @@ -390,8 +331,19 @@ def test_get_200_responses( # test_get_200_path_users_user_id_nodes_no_user url = '/{}users/{}/nodes/'.format(API_BASE, user_one._id) - res = app.get(url, expect_errors=True) - assert res.status_code == 401 + res = app.get(url) + assert res.status_code == 200 + + # an anonymous/unauthorized user can only see the public projects + # user_one contributes to. + ids = {each['id'] for each in res.json['data']} + assert project_public_user_one._id in ids + assert project_private_user_one._id not in ids + assert project_public_user_two._id not in ids + assert project_private_user_two._id not in ids + assert folder._id not in ids + assert folder_deleted._id not in ids + assert project_deleted_user_one._id not in ids # test_get_200_path_users_user_id_nodes_unauthorized_user url = '/{}users/{}/nodes/'.format(API_BASE, user_one._id) @@ -1213,42 +1165,32 @@ def test_requesting_as_deactivated_user_returns_400_response( def test_unconfirmed_users_return_entire_user_object( self, app, user_one, user_two): url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, auth=user_one.auth) - assert res.status_code == 200 - user_one.is_registered = False - user_one.save() - res = app.get(url, auth=user_one.auth, expect_errors=True) - assert res.status_code == 400 - - def test_unconfirmed_users_return_entire_user_object_not_logged_in( - self, app, user_one, user_two): - url = '/{}users/{}/'.format(API_BASE, user_one._id) res = app.get(url, auth=user_two.auth, expect_errors=True) - assert res.status_code == 403 + assert res.status_code == 200 user_one.is_registered = False user_one.save() res = app.get(url, expect_errors=True) - assert res.status_code == 401 - - def test_requesting_deactivated_user_returns_410_response_and_meta_info( - self, app, user_one, user_two): - url = '/{}users/{}/'.format(API_BASE, user_one._id) - res = app.get(url, auth=user_one.auth) assert res.status_code == 200 - user_one.is_disabled = True - user_one.save() - res = app.get(url, auth=user_one.auth, expect_errors=True) - assert res.status_code == 400 + attr = res.json['data']['attributes'] + assert attr['active'] is False + assert res.json['data']['id'] == user_one._id - def test_requesting_deactivated_user_returns_410_response_and_meta_info_not_logged_in( + def test_requesting_deactivated_user_returns_410_response_and_meta_info( self, app, user_one, user_two): url = '/{}users/{}/'.format(API_BASE, user_one._id) res = app.get(url, auth=user_two.auth, expect_errors=True) - assert res.status_code == 403 + assert res.status_code == 200 user_one.is_disabled = True user_one.save() res = app.get(url, expect_errors=True) - assert res.status_code == 401 + assert res.status_code == 410 + assert res.json['errors'][0]['meta']['family_name'] == user_one.family_name + assert res.json['errors'][0]['meta']['given_name'] == user_one.given_name + assert res.json['errors'][0]['meta']['middle_names'] == user_one.middle_names + assert res.json['errors'][0]['meta']['full_name'] == user_one.fullname + assert urlparse( + res.json['errors'][0]['meta']['profile_image']).netloc == 'secure.gravatar.com' + assert res.json['errors'][0]['detail'] == 'The requested user is no longer available.' @pytest.mark.django_db From fea1e4ddb90603508fc00bf0b2f72a89390a6d9a Mon Sep 17 00:00:00 2001 From: soum-fujimoto Date: Wed, 3 Jun 2026 09:26:07 +0000 Subject: [PATCH 28/38] Fix test --- api_tests/base/test_auth.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api_tests/base/test_auth.py b/api_tests/base/test_auth.py index 0eb462ee0b8..6c113db25ae 100644 --- a/api_tests/base/test_auth.py +++ b/api_tests/base/test_auth.py @@ -456,8 +456,10 @@ def test_user_email_scope_cannot_read_other_email(self, mock_user_info): 'users/{}/'.format(self.user2._id), base_route='/', base_prefix='v2/' ) - res = self.app.get(url, auth='some_valid_token', auth_type='jwt', expect_errors=True) - assert_equal(res.status_code, 403) + res = self.app.get(url, auth='some_valid_token', auth_type='jwt') + assert_equal(res.status_code, 200) + assert_not_in('email', res.json['data']['attributes']) + assert_not_in(self.user2.username, res.json) @pytest.mark.django_db From 166114c05a8c44fa97115cd5d6b7c190f7f673df Mon Sep 17 00:00:00 2001 From: soum-fujimoto Date: Wed, 3 Jun 2026 11:07:59 +0000 Subject: [PATCH 29/38] Fix test --- api_tests/users/views/test_user_detail.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/api_tests/users/views/test_user_detail.py b/api_tests/users/views/test_user_detail.py index f6ef010c490..d13daded1e6 100644 --- a/api_tests/users/views/test_user_detail.py +++ b/api_tests/users/views/test_user_detail.py @@ -331,19 +331,8 @@ def test_get_200_responses( # test_get_200_path_users_user_id_nodes_no_user url = '/{}users/{}/nodes/'.format(API_BASE, user_one._id) - res = app.get(url) - assert res.status_code == 200 - - # an anonymous/unauthorized user can only see the public projects - # user_one contributes to. - ids = {each['id'] for each in res.json['data']} - assert project_public_user_one._id in ids - assert project_private_user_one._id not in ids - assert project_public_user_two._id not in ids - assert project_private_user_two._id not in ids - assert folder._id not in ids - assert folder_deleted._id not in ids - assert project_deleted_user_one._id not in ids + res = app.get(url, expect_errors=True) + assert res.status_code == 401 # test_get_200_path_users_user_id_nodes_unauthorized_user url = '/{}users/{}/nodes/'.format(API_BASE, user_one._id) From dbb48c1dd96c6773a188a901b6407a28b785260a Mon Sep 17 00:00:00 2001 From: hcphat Date: Mon, 15 Jun 2026 12:26:10 +0700 Subject: [PATCH 30/38] =?UTF-8?q?ref:=202.3.=E3=82=B0=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E7=AE=A1=E7=90=86=E9=80=A3=E6=90=BA=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E9=96=8B=E7=99=BA:=20Resolve=20conflict=20Migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- osf/migrations/0270_merge_20260615_0523.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 osf/migrations/0270_merge_20260615_0523.py diff --git a/osf/migrations/0270_merge_20260615_0523.py b/osf/migrations/0270_merge_20260615_0523.py new file mode 100644 index 00000000000..a94923e6950 --- /dev/null +++ b/osf/migrations/0270_merge_20260615_0523.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2026-06-15 05:23 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0265_merge_20260325_0957'), + ('osf', '0269_merge_20260525_0425'), + ] + + operations = [ + ] From 48f466fa61e2335ac4610f258a38b3032aa82faf Mon Sep 17 00:00:00 2001 From: hcphat Date: Fri, 29 May 2026 14:09:50 +0700 Subject: [PATCH 31/38] =?UTF-8?q?Ref=202.1.IdP=E3=81=8B=E3=82=89=E5=8F=97?= =?UTF-8?q?=E9=A0=98=E3=81=99=E3=82=8B=E8=AA=8D=E8=A8=BC=E6=83=85=E5=A0=B1?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=AE=E6=97=A5=E6=9C=AC=E8=AA=9E=E6=96=87?= =?UTF-8?q?=E5=AD=97=E5=8C=96=E3=81=91=E8=A7=A3=E6=B6=88:=20Add=20script?= =?UTF-8?q?=20update=20record=20error=20encoding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/fix_encoding_errors.py | 535 +++++++++++++++++++++++++++++++++ 1 file changed, 535 insertions(+) create mode 100644 scripts/fix_encoding_errors.py diff --git a/scripts/fix_encoding_errors.py b/scripts/fix_encoding_errors.py new file mode 100644 index 00000000000..11017c8355a --- /dev/null +++ b/scripts/fix_encoding_errors.py @@ -0,0 +1,535 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +fix_encoding_errors.py + +Scan and fix Japanese encoding issues and HTML entities using Django ORM, +then export the results directly to a CSV file. +This script targets: + - UserExtendedData (field: data JSONB) + - OSFUser (fields: fullname, given_name, middle_names, family_name, + suffix, given_name_ja, middle_names_ja, family_name_ja, + department, jobs, schools, social) + +Usage: + # Scan, update encoding errors in DB using Django ORM, and export results to CSV + python -m scripts.fix_encoding_errors + python -m scripts.fix_encoding_errors --output result.csv + + # Preview updates and export results to CSV without making changes to DB (dry run) + python -m scripts.fix_encoding_errors --dry + python -m scripts.fix_encoding_errors --dry --include-unrecoverable --output preview.csv + + # Also apply partial fixes to records where encoding cannot be fully recovered + python -m scripts.fix_encoding_errors --include-unrecoverable +""" + +import os +import re +import csv +import json +import logging +import copy +import argparse +from datetime import datetime + +import django +from django.db import transaction + +# Setup Django before importing models +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'api.base.settings') +django.setup() + +from osf.models import UserExtendedData, OSFUser +from scripts import utils as script_utils + +logger = logging.getLogger(__name__) + +# --- Patterns --- +HTML_ENTITY_RE = re.compile(r'&#\d+;|&#x[0-9a-fA-F]+;') +JAPANESE_ERROR_ENCODING_HINT_RE = re.compile(r'[\u00c0-\u00cf\u00e0-\u00ef\u00aa\u00ba]') +_NUM_ENT_RE = re.compile(r'&#(\d+);|&#x([0-9a-fA-F]+);') + +UNRECOVERABLE_NOTICE = '[UNRECOVERABLE: missing bytes - partial result] ' +UNRECOVERABLE_CANNOT_FIX = '[UNRECOVERABLE: cannot fix]' + +OSFUSER_STRING_COLUMNS = [ + 'fullname', + 'given_name', + 'middle_names', + 'family_name', + 'suffix', + 'given_name_ja', + 'middle_names_ja', + 'family_name_ja', + 'department', +] + +OSFUSER_JSON_COLUMNS = [ + 'jobs', + 'schools', + 'social', +] + +RESULT_FIELDNAMES = [ + 'source_table', 'id', 'user_guid', 'user_id', 'field_path', + 'issues', 'original_value', 'suggested_fix', 'fix_applied', 'value_after', 'fix_status', +] + +# --- Helper Functions --- + +def _entities_as_latin1(s): + """Replace &#NNN; numeric entities with chr(N), keeping within Latin-1 range.""" + def repl(m): + n = int(m.group(1)) if m.group(1) is not None else int(m.group(2), 16) + return chr(n) if n <= 255 else m.group(0) + return _NUM_ENT_RE.sub(repl, s) + + +def try_fix_japanese_error_encoding(s): + """ + Attempt to fix Japanese error encoding by re-encoding as latin-1 and decoding as utf-8. + Returns (fixed_string, is_partial): + - is_partial=True : decode succeeded with errors='replace' (some bytes were lost) + - is_partial=False : decode succeeded cleanly + - (None, False) : fix not possible or no improvement + """ + partial = False + try: + fixed = s.encode('latin1').decode('utf-8') + except UnicodeDecodeError: + try: + fixed = s.encode('latin1').decode('utf-8', errors='replace') + partial = True + except Exception: + return None, False + except Exception: + return None, False + + orig_bad = len(JAPANESE_ERROR_ENCODING_HINT_RE.findall(s)) + fixed_bad = len(JAPANESE_ERROR_ENCODING_HINT_RE.findall(fixed)) + if fixed_bad < orig_bad: + return fixed, partial + return None, False + + +def detect_issues(s): + """Return list of issue labels found in string s.""" + issues = [] + if HTML_ENTITY_RE.search(s): + issues.append('html_entities') + if JAPANESE_ERROR_ENCODING_HINT_RE.search(s): + issues.append('japanese_error_encoding') + return issues + + +def suggest_fix(s, issues): + """ + Return (suggested_fix, is_partial) for the given string and detected issues. + Returns (None, False) if no fix is possible. + """ + if 'html_entities' in issues: + fixed, partial = try_fix_japanese_error_encoding(_entities_as_latin1(s)) + if fixed: + return fixed, partial + if 'japanese_error_encoding' in issues: + fixed, partial = try_fix_japanese_error_encoding(s) + if fixed: + return fixed, partial + return None, False + + +def walk(obj, path=''): + """Recursively yield (path, str_value) for all string leaves in a JSON object.""" + if isinstance(obj, dict): + for k, v in obj.items(): + yield from walk(v, '{}.{}'.format(path, k) if path else k) + elif isinstance(obj, list): + for i, v in enumerate(obj): + yield from walk(v, '{}[{}]'.format(path, i)) + elif isinstance(obj, str): + yield (path, obj) + + +def compute_fix_status(suggested_fix): + """Return 'clean', 'partial', or 'unrecoverable' based on suggested_fix.""" + if suggested_fix == UNRECOVERABLE_CANNOT_FIX: + return 'unrecoverable' + if suggested_fix.startswith(UNRECOVERABLE_NOTICE): + return 'partial' + return 'clean' + + +def make_error_row(source_table, record_id, user_guid, user_id, path, val): + """Build a single error row dict from a string value that has issues.""" + issues = detect_issues(val) + if not issues: + return None + fixed, is_partial = suggest_fix(val, issues) + if fixed is None: + suggested = UNRECOVERABLE_CANNOT_FIX + elif is_partial: + suggested = UNRECOVERABLE_NOTICE + fixed + else: + suggested = fixed + return { + 'source_table': source_table, + 'id': record_id, + 'user_guid': user_guid, + 'user_id': user_id, + 'field_path': path, + 'issues': ','.join(issues), + 'original_value': val, + 'suggested_fix': suggested, + 'fix_status': compute_fix_status(suggested), + } + + +def is_fixable(row, include_unrecoverable): + """Determine whether a row can/should be applied as a fix.""" + sf = row['suggested_fix'] + if sf == UNRECOVERABLE_CANNOT_FIX: + return False + if sf.startswith(UNRECOVERABLE_NOTICE): + return include_unrecoverable + return True + + +def get_clean_fix(suggested_fix): + """Return the actual fix value (strip UNRECOVERABLE_NOTICE prefix if present).""" + if suggested_fix.startswith(UNRECOVERABLE_NOTICE): + return suggested_fix[len(UNRECOVERABLE_NOTICE):] + return suggested_fix + + +def set_nested_value(obj, path, new_value): + """ + Set a value inside a nested dict/list structure using a dot/bracket path string. + E.g. path='jobs[0].institution' -> obj['jobs'][0]['institution'] = new_value + Returns the modified obj (mutated in-place). + """ + tokens = re.split(r'\.|\[(\d+)\]', path) + parts = [] + for t in tokens: + if t is None or t == '': + continue + if t.isdigit(): + parts.append(int(t)) + else: + parts.append(t) + + node = obj + for part in parts[:-1]: + node = node[part] + node[parts[-1]] = new_value + return obj + + +# --- Scanning Operations --- + +def scan_userextendeddata(): + """Scan UserExtendedData.data JSONB field for encoding issues.""" + records = UserExtendedData.objects.select_related('user').all() + + logger.info('Scanning UserExtendedData records...') + rows = [] + for record in records.iterator(): + data = record.data or {} + user_guid = record.user._id if record.user else 'N/A' + user_id = record.user.id if record.user else 'N/A' + + for path, val in walk(data): + row = make_error_row('osf_userextendeddata', record.id, user_guid, user_id, path, val) + if row: + rows.append(row) + logger.info('UserExtendedData scan completed. Found {} error(s).'.format(len(rows))) + return rows + + +def scan_osfuser(): + """Scan OSFUser string and JSON columns for encoding issues.""" + records = OSFUser.objects.all() + + logger.info('Scanning OSFUser records...') + rows = [] + for user in records.iterator(): + user_id = user.id + user_guid = user._id or 'N/A' + + # Simple string columns + for col in OSFUSER_STRING_COLUMNS: + val = getattr(user, col, None) + if not val: + continue + row = make_error_row('osf_osfuser', user_id, user_guid, user_id, col, val) + if row: + rows.append(row) + + # JSON columns + for col in OSFUSER_JSON_COLUMNS: + data = getattr(user, col, None) + if not data: + continue + for path, val in walk(data): + row = make_error_row('osf_osfuser', user_id, user_guid, user_id, + '{}.{}'.format(col, path), val) + if row: + rows.append(row) + logger.info('OSFUser scan completed. Found {} error(s).'.format(len(rows))) + return rows + + +def print_errors(rows): + """Display scanned encoding errors in a formatted table.""" + if not rows: + print(' No encoding errors found.') + return + + print('\n' + '-' * 180) + print(' {:<22} | {:>8} | {:>10} | {:<12} | {:<30} | {:<25} | {:<35} | {}'.format( + 'SOURCE', 'ID', 'USER_ID', 'USER_GUID', 'FIELD_PATH', 'ISSUES', 'ORIGINAL_VALUE', 'SUGGESTED_FIX' + )) + print('-' * 180) + for r in rows: + orig_val = r['original_value'] or '' + suggested = r['suggested_fix'] or '' + val_display = orig_val[:33] + '...' if len(orig_val) > 33 else orig_val + fix_display = suggested[:50] + '...' if len(suggested) > 50 else suggested + print( + ' {:<22} | {:>8} | {:>10} | {:<12} | ' + '{:<30} | {:<25} | {:<35} | {}'.format( + r['source_table'] or '', + str(r['id']) if r['id'] is not None else '', + str(r['user_id']) if r['user_id'] is not None else '', + str(r['user_guid']) if r['user_guid'] is not None else '', + (r['field_path'] or '')[:30], + r['issues'] or '', + val_display, + fix_display + ) + ) + print('-' * 180) + + # Summary + by_table = {} + for r in rows: + by_table[r['source_table']] = by_table.get(r['source_table'], 0) + 1 + for table, count in sorted(by_table.items()): + print(' {}: {} error(s)'.format(table, count)) + print(' Total: {} error(s)'.format(len(rows))) + + +def save_result_csv(result_rows, output_path): + """Save full results with before/after status to CSV.""" + with open(output_path, 'w', newline='', encoding='utf-8-sig') as f: + writer = csv.DictWriter(f, fieldnames=RESULT_FIELDNAMES) + writer.writeheader() + writer.writerows(result_rows) + print('\n Exported {} result(s) to: {}'.format(len(result_rows), output_path)) + + +# --- Updating Operations --- + +def apply_fixes(rows, include_unrecoverable, dry_run=False): + """ + Apply fixes to database using Django ORM inside a transaction. + If dry_run is True, the transaction will be rolled back at the end. + Returns list of result rows with fix status. + """ + # Group changes by model instance to minimize database writes + ued_updates = {} + user_updates = {} + + for row in rows: + if row['source_table'] == 'osf_userextendeddata': + ued_updates.setdefault(row['id'], []).append(row) + elif row['source_table'] == 'osf_osfuser': + user_updates.setdefault(row['id'], []).append(row) + + result_rows = [] + fixable_count = 0 + skipped_count = 0 + + if dry_run: + logger.info('[DRY RUN] Simulating database updates inside a transaction...') + else: + logger.info('Starting database transaction to apply fixes...') + + try: + with transaction.atomic(): + # 1. Update UserExtendedData instances + for record_id, ued_rows in ued_updates.items(): + try: + record = UserExtendedData.objects.get(id=record_id) + except UserExtendedData.DoesNotExist: + for row in ued_rows: + result = dict(row) + result['fix_applied'] = 'NO (RECORD NOT EXIST)' + result['value_after'] = row['original_value'] + result_rows.append(result) + skipped_count += 1 + logger.warning(' Skipped: [UserExtendedData] id={} path={} (record not exist)'.format(record_id, row['field_path'])) + continue + + data = record.data or {} + has_changes = False + + for row in ued_rows: + result = dict(row) + result['fix_applied'] = '' + result['value_after'] = '' + + should_fix = is_fixable(row, include_unrecoverable) + + if should_fix: + clean_fix = get_clean_fix(row['suggested_fix']) + data = set_nested_value(data, row['field_path'], clean_fix) + has_changes = True + result['fix_applied'] = 'YES' + result['value_after'] = clean_fix + fixable_count += 1 + logger.info(' Fixed: [UserExtendedData] id={} path={}'.format(record_id, row['field_path'])) + else: + result['fix_applied'] = 'NO (UNRECOVERABLE)' + result['value_after'] = row['original_value'] + skipped_count += 1 + + result_rows.append(result) + + if has_changes: + # Deepcopy to guarantee DirtyFieldsMixin registers the change + record.data = copy.deepcopy(data) + record.save() + + # 2. Update OSFUser instances + for user_id, u_rows in user_updates.items(): + try: + user = OSFUser.objects.get(id=user_id) + except OSFUser.DoesNotExist: + for row in u_rows: + result = dict(row) + result['fix_applied'] = 'NO (RECORD NOT EXIST)' + result['value_after'] = row['original_value'] + result_rows.append(result) + skipped_count += 1 + logger.warning(' Skipped: [OSFUser] id={} field={} (record not exist)'.format(user_id, row['field_path'])) + continue + + has_changes = False + + for row in u_rows: + result = dict(row) + result['fix_applied'] = '' + result['value_after'] = '' + + should_fix = is_fixable(row, include_unrecoverable) + + if should_fix: + clean_fix = get_clean_fix(row['suggested_fix']) + path = row['field_path'] + top_col = path.split('.')[0].split('[')[0] + + if top_col in OSFUSER_STRING_COLUMNS: + setattr(user, top_col, clean_fix) + has_changes = True + result['fix_applied'] = 'YES' + result['value_after'] = clean_fix + fixable_count += 1 + logger.info(' Fixed: [OSFUser] id={} field={}'.format(user_id, path)) + elif top_col in OSFUSER_JSON_COLUMNS: + col_data = getattr(user, top_col, None) or (dict() if top_col == 'social' else list()) + sub_path = path[len(top_col):] + if sub_path.startswith('.'): + sub_path = sub_path[1:] + + col_data = set_nested_value(col_data, sub_path, clean_fix) + setattr(user, top_col, copy.deepcopy(col_data)) + has_changes = True + result['fix_applied'] = 'YES' + result['value_after'] = clean_fix + fixable_count += 1 + logger.info(' Fixed: [OSFUser] id={} field={}'.format(user_id, path)) + else: + result['fix_applied'] = 'NO (UNRECOVERABLE)' + result['value_after'] = row['original_value'] + skipped_count += 1 + + result_rows.append(result) + + if has_changes: + user.save() + + if dry_run: + # Rollback transaction so no database changes are saved + transaction.set_rollback(True) + logger.info('[DRY RUN] Transaction rolled back successfully. No changes saved.') + else: + logger.info('Transaction committed successfully.') + + if not dry_run: + logger.info('Applied: {} fix(es), Skipped: {} record(s)'.format(fixable_count, skipped_count)) + else: + logger.info('[DRY RUN] Would apply {} fix(es) and skip {} record(s)'.format(fixable_count, skipped_count)) + + except Exception as e: + logger.exception('Error during transaction execution - rolled back.') + raise + + return result_rows + + +# --- Main --- + +def main(): + ts = datetime.now().strftime('%Y%m%d_%H%M%S') + default_output = f'fix_result_{ts}.csv' + + parser = argparse.ArgumentParser( + description='Scan and fix encoding errors in UserExtendedData and OSFUser using Django ORM.', + ) + parser.add_argument( + '--output', '-o', + default=default_output, + help=f'Output CSV file path (default: {default_output})', + ) + parser.add_argument( + '--include-unrecoverable', action='store_true', default=False, + help='Also apply fixes for records marked as unrecoverable/partial result.', + ) + parser.add_argument( + '--dry', action='store_true', default=False, + help='Run script in dry-run mode (do not commit updates to the database).', + ) + args = parser.parse_args() + + if not args.dry: + # For actual runs modifying data, set up file logging + script_utils.add_file_logger(logger, __file__) + + # Configure root/console logging format + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(levelname)s %(message)s', + ) + + # 1. Scan errors from database + rows = [] + rows.extend(scan_userextendeddata()) + rows.extend(scan_osfuser()) + + if not rows: + print('No encoding errors found.') + return + + # Print the table of found errors + print_errors(rows) + + # 2. Apply fixes (conditionally committing or rolling back) + result_rows = apply_fixes(rows, args.include_unrecoverable, dry_run=args.dry) + + # 3. Export results to CSV + save_result_csv(result_rows, args.output) + + +if __name__ == '__main__': + main() From fdcef7d189974323ef23104ed40b4487c8e6cb1a Mon Sep 17 00:00:00 2001 From: yacchin1205 <968739+yacchin1205@users.noreply.github.com> Date: Fri, 19 Jun 2026 07:36:19 +0900 Subject: [PATCH 32/38] Add migration to remove obsolete grdm-file:metadata-access-rights from stored metadata --- .../0270_remove_metadata_access_rights.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 osf/migrations/0270_remove_metadata_access_rights.py diff --git a/osf/migrations/0270_remove_metadata_access_rights.py b/osf/migrations/0270_remove_metadata_access_rights.py new file mode 100644 index 00000000000..d202998c716 --- /dev/null +++ b/osf/migrations/0270_remove_metadata_access_rights.py @@ -0,0 +1,43 @@ +from django.db import migrations + +noop = migrations.RunPython.noop + +TARGET_SCHEMA_NAME = '公的資金による研究データのメタデータ登録' + +OBSOLETE_KEY = 'grdm-file:metadata-access-rights' + + +def _transform_item(data): + if OBSOLETE_KEY in data: + data.pop(OBSOLETE_KEY, None) + return True + return False + + +def _transform_entry(entry): + metadata = entry.get('metadata', {}) + if OBSOLETE_KEY in metadata: + metadata.pop(OBSOLETE_KEY, None) + return True + return False + + +def remove_metadata_access_rights(*args): + from addons.metadata.utils import FileMetadataMigrator + migrator = FileMetadataMigrator( + TARGET_SCHEMA_NAME, + _transform_item, + _transform_entry, + ) + migrator.run() + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0269_merge_20260525_0425'), + ] + + operations = [ + migrations.RunPython(remove_metadata_access_rights, noop), + ] From bafc764962326843901b14c42cacc3fb089193ca Mon Sep 17 00:00:00 2001 From: yacchin1205 <968739+yacchin1205@users.noreply.github.com> Date: Fri, 19 Jun 2026 07:58:27 +0900 Subject: [PATCH 33/38] Guard empty creators value to avoid JSONDecodeError in name-split migration --- addons/metadata/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/metadata/utils.py b/addons/metadata/utils.py index ed3b662d8b0..aaacd0ae136 100644 --- a/addons/metadata/utils.py +++ b/addons/metadata/utils.py @@ -151,7 +151,7 @@ def transform_name_fields_item(data): if creators is not None: rows = creators['value'] if isinstance(rows, str): - rows = json.loads(rows) + rows = json.loads(rows) if rows.strip() else [] creators['value'] = rows if isinstance(rows, list) and _transform_creators_rows(rows): dirty = True From fe56ee8d5250a532503c4c8efce0b8b37863194e Mon Sep 17 00:00:00 2001 From: kahokago Date: Fri, 19 Jun 2026 09:32:55 +0000 Subject: [PATCH 34/38] Fixed bug in creating ro-crate without additional metadata files. --- addons/weko/schema/ro_crate.py | 15 +++++++-------- addons/weko/tests/test_schema.py | 4 ++++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/addons/weko/schema/ro_crate.py b/addons/weko/schema/ro_crate.py index 079c52726e8..02e23cdd2d7 100644 --- a/addons/weko/schema/ro_crate.py +++ b/addons/weko/schema/ro_crate.py @@ -538,14 +538,13 @@ def _build_hierarchical_object(user, target_index, file_metadata, download_file_ if 'choose-additional-metadata' in value: url = value['choose-additional-metadata'] value_data = json.loads(url.get('value') or '[]') - if not value_data: - continue - file_path = value_data[0]['path'] - file_name = file_path.split('/')[-1] - for item in value_data: - item['path'] = item['path'].replace('osfstorage/', '') - url['value'] = file_name - value['choose-additional-metadata'] = url + if value_data: + file_path = value_data[0]['path'] + file_name = file_path.split('/')[-1] + for item in value_data: + item['path'] = item['path'].replace('osfstorage/', '') + url['value'] = file_name + value['choose-additional-metadata'] = url if 'absolute_url' in value: url = value['absolute_url'] parsed_url = urlparse(url) diff --git a/addons/weko/tests/test_schema.py b/addons/weko/tests/test_schema.py index 3f8ae9c72cb..04e127bf9ed 100644 --- a/addons/weko/tests/test_schema.py +++ b/addons/weko/tests/test_schema.py @@ -2381,6 +2381,9 @@ def test_write_ro_crate_json_mebyo_empty_files(self): 'grdm-files': { 'value': [], # No files }, + 'choose-additional-metadata': { # No additional metadata files + 'value': '', + }, } schema.write_ro_crate_json( @@ -2410,6 +2413,7 @@ def test_write_ro_crate_json_mebyo_empty_files(self): # Project metadata should be reflected assert_equal(root['name'], 'Test Dataset') assert_equal(root['description'], 'Description of experiment purpose') + assert_in('ams:purposeOfExperiment', root) def test_write_ro_crate_json_mebyo_with_additional_metadata_files(self): """Test MEBYO schema with choose-additional-metadata containing files. From 8c89b8e638e8b9304dd94131d748568ce94dac73 Mon Sep 17 00:00:00 2001 From: hide24 Date: Mon, 22 Jun 2026 17:38:39 +0900 Subject: [PATCH 35/38] add migration --- osf/migrations/0271_merge_20260622_1735.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 osf/migrations/0271_merge_20260622_1735.py diff --git a/osf/migrations/0271_merge_20260622_1735.py b/osf/migrations/0271_merge_20260622_1735.py new file mode 100644 index 00000000000..49716c107fe --- /dev/null +++ b/osf/migrations/0271_merge_20260622_1735.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.28 on 2026-06-22 17:35 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0270_merge_20260615_0523'), + ('osf', '0270_remove_metadata_access_rights'), + ] + + operations = [ + ] From 558236d4146a4f3467748c264da3223dd489236e Mon Sep 17 00:00:00 2001 From: hcphat Date: Tue, 9 Jun 2026 19:08:00 +0700 Subject: [PATCH 36/38] =?UTF-8?q?ref=202.1.=E5=B0=8F=E8=A6=8F=E6=A8=A1?= =?UTF-8?q?=E5=A4=A7=E9=87=8F=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92?= =?UTF-8?q?=E5=90=AB=E3=82=80=E3=83=95=E3=82=A9=E3=83=AB=E3=83=80=E3=82=A2?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=83=AD=E3=83=BC=E3=83=89=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E6=94=B9=E5=96=84=E3=81=AB=E3=81=8B=E3=81=8B=E3=82=8B=E9=96=8B?= =?UTF-8?q?=E7=99=BA:=20Rework=20review=20comment=20=20+=20Add=20PROVIDER?= =?UTF-8?q?=5FSETTINGS=20for=20s3compatsigv4=20and=20Institution=20storage?= =?UTF-8?q?=20=20+=20Add=20reset=20default=20handle=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/static/js/fangorn.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/website/static/js/fangorn.js b/website/static/js/fangorn.js index a983de449ad..040eb68a74a 100644 --- a/website/static/js/fangorn.js +++ b/website/static/js/fangorn.js @@ -110,12 +110,20 @@ var PROVIDER_SETTINGS = { 'onedrive': { parallelNum: 4, fileSizeThreshold: 128000000 }, 'dropbox': { parallelNum: 1, fileSizeThreshold: null }, 's3compatb3': { parallelNum: 4, fileSizeThreshold: 128000000 }, + 's3compatsigv4': { parallelNum: 4, fileSizeThreshold: 128000000 }, 'azureblobstorage': { parallelNum: 1, fileSizeThreshold: null }, 'dataverse': { parallelNum: 1, fileSizeThreshold: null }, 'figshare': { parallelNum: 1, fileSizeThreshold: null }, 'github': { parallelNum: 1, fileSizeThreshold: null }, 'swift': { parallelNum: 1, fileSizeThreshold: null }, - 'owncloud': { parallelNum: 1, fileSizeThreshold: null } + 'owncloud': { parallelNum: 1, fileSizeThreshold: null }, + + // Institution storage settings + 'dropboxbusiness': { parallelNum: 1, fileSizeThreshold: null }, + 'nextcloudinstitutions': { parallelNum: 1, fileSizeThreshold: null }, + 's3compatinstitutions': { parallelNum: 1, fileSizeThreshold: null }, + 'onedrivebusiness': { parallelNum: 1, fileSizeThreshold: null }, + 'ociinstitutions': { parallelNum: 1, fileSizeThreshold: null } }; // Monkey-patch Dropzone.processQueue here (must run before any Dropzone instances are created) @@ -1092,6 +1100,9 @@ function _fangornDropzoneDrop(treebeard, event) { if (providerSettings && providerSettings.parallelNum && providerSettings.fileSizeThreshold) { treebeard.dropzone.options.parallelUploads = providerSettings.parallelNum; treebeard.dropzone.options.fileSizeThreshold = providerSettings.fileSizeThreshold; + } else { + treebeard.dropzone.options.parallelUploads = 1; + treebeard.dropzone.options.fileSizeThreshold = null; } } /** @@ -1347,6 +1358,7 @@ function _uploadEvent(event, item, col) { }else{ // Default settings self.dropzone.options.parallelUploads = 1; + self.dropzone.options.fileSizeThreshold = null; } // Add files to Treebeard @@ -1461,6 +1473,7 @@ function _uploadFolderEvent(event, item, mode, col) { tb.dropzone.options.fileSizeThreshold = providerSettings.fileSizeThreshold; }else{ tb.dropzone.options.parallelUploads = 1; + tb.dropzone.options.fileSizeThreshold = null; } var createdFolders = []; From 06e7f23c1c70467bff392278164690b4b5b27cb3 Mon Sep 17 00:00:00 2001 From: hcphat Date: Fri, 26 Jun 2026 18:28:26 +0700 Subject: [PATCH 37/38] =?UTF-8?q?Ref=202.1.IdP=E3=81=8B=E3=82=89=E5=8F=97?= =?UTF-8?q?=E9=A0=98=E3=81=99=E3=82=8B=E8=AA=8D=E8=A8=BC=E6=83=85=E5=A0=B1?= =?UTF-8?q?=E8=A1=A8=E7=A4=BA=E3=81=AE=E6=97=A5=E6=9C=AC=E8=AA=9E=E6=96=87?= =?UTF-8?q?=E5=AD=97=E5=8C=96=E3=81=91=E8=A7=A3=E6=B6=88:=20Update=20scrip?= =?UTF-8?q?t=20support=20input=20csv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/fix_encoding_errors.py | 397 ++++++++++++++++++++++++++++----- 1 file changed, 342 insertions(+), 55 deletions(-) diff --git a/scripts/fix_encoding_errors.py b/scripts/fix_encoding_errors.py index 11017c8355a..bfe9b020039 100644 --- a/scripts/fix_encoding_errors.py +++ b/scripts/fix_encoding_errors.py @@ -12,18 +12,16 @@ department, jobs, schools, social) Usage: - # Scan, update encoding errors in DB using Django ORM, and export results to CSV - python -m scripts.fix_encoding_errors - python -m scripts.fix_encoding_errors --output result.csv - # Preview updates and export results to CSV without making changes to DB (dry run) python -m scripts.fix_encoding_errors --dry - python -m scripts.fix_encoding_errors --dry --include-unrecoverable --output preview.csv + python -m scripts.fix_encoding_errors --dry --output preview.csv - # Also apply partial fixes to records where encoding cannot be fully recovered - python -m scripts.fix_encoding_errors --include-unrecoverable + # Apply fixes using a previously reviewed CSV file + python -m scripts.fix_encoding_errors --input-csv preview.csv + python -m scripts.fix_encoding_errors --input-csv preview.csv --output result.csv """ +import sys import os import re import csv @@ -35,6 +33,7 @@ import django from django.db import transaction +from django.core.exceptions import ValidationError as DjangoValidationError # Setup Django before importing models os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'api.base.settings') @@ -53,6 +52,13 @@ UNRECOVERABLE_NOTICE = '[UNRECOVERABLE: missing bytes - partial result] ' UNRECOVERABLE_CANNOT_FIX = '[UNRECOVERABLE: cannot fix]' +# Unicode replacement character (U+FFFD). It is produced when a partial decode +# (errors='replace') drops missing bytes. It is a VALID character for the target +# columns, so Django model constraints will NOT reject it on save(). We must +# detect it explicitly: a value that still contains it has not been fully +# recovered and must never be written to the DB. +REPLACEMENT_CHAR = '�' + OSFUSER_STRING_COLUMNS = [ 'fullname', 'given_name', @@ -101,9 +107,15 @@ def try_fix_japanese_error_encoding(s): try: fixed = s.encode('latin1').decode('utf-8', errors='replace') partial = True - except Exception: + except UnicodeError: + # Expected encoding failure -> this string cannot be fixed by the + # latin1->utf-8 round-trip. Return None so the caller marks it as + # unrecoverable and continues. Any other (unexpected) exception is + # left to propagate, per fail-fast. return None, False - except Exception: + except UnicodeError: + # s contains characters outside Latin-1 (e.g. real Japanese mixed with + # mojibake): this fix method does not apply. Treat as "cannot fix". return None, False orig_bad = len(JAPANESE_ERROR_ENCODING_HINT_RE.findall(s)) @@ -185,16 +197,6 @@ def make_error_row(source_table, record_id, user_guid, user_id, path, val): } -def is_fixable(row, include_unrecoverable): - """Determine whether a row can/should be applied as a fix.""" - sf = row['suggested_fix'] - if sf == UNRECOVERABLE_CANNOT_FIX: - return False - if sf.startswith(UNRECOVERABLE_NOTICE): - return include_unrecoverable - return True - - def get_clean_fix(suggested_fix): """Return the actual fix value (strip UNRECOVERABLE_NOTICE prefix if present).""" if suggested_fix.startswith(UNRECOVERABLE_NOTICE): @@ -225,6 +227,46 @@ def set_nested_value(obj, path, new_value): return obj +def _get_nested_value(obj, path): + """ + Read a value from a nested dict/list structure using a dot/bracket path string. + Mirror of set_nested_value — raises KeyError/IndexError if path does not exist. + """ + tokens = re.split(r'\.|\[(\d+)\]', path) + parts = [] + for t in tokens: + if t is None or t == '': + continue + parts.append(int(t) if t.isdigit() else t) + node = obj + for part in parts: + node = node[part] + return node + + +def _get_osfuser_field_value(user, field_path): + """ + Read the current value from an OSFUser instance at the given field_path. + Supports both simple string columns and nested JSON columns. + Raises ValueError for unknown top-level columns. + """ + top_col = field_path.split('.')[0].split('[')[0] + if top_col in OSFUSER_STRING_COLUMNS: + return getattr(user, top_col) + if top_col in OSFUSER_JSON_COLUMNS: + # A NULL/empty JSON column is a VALID data state (the user simply has no + # social/jobs/schools entries), NOT a data defect to fail-fast on. Default + # to an empty container so the nested read below behaves consistently. + col_data = getattr(user, top_col) or (dict() if top_col == 'social' else list()) + sub_path = field_path[len(top_col):] + if sub_path.startswith('.'): + sub_path = sub_path[1:] + if not sub_path: + return col_data + return _get_nested_value(col_data, sub_path) + raise ValueError('Unknown field_path top column: {}'.format(top_col)) + + # --- Scanning Operations --- def scan_userextendeddata(): @@ -234,9 +276,15 @@ def scan_userextendeddata(): logger.info('Scanning UserExtendedData records...') rows = [] for record in records.iterator(): + if record.user is None: + raise ValueError('UserExtendedData id={} has no associated user'.format(record.id)) + if not record.user._id: + raise ValueError('OSFUser id={} has no _id (guid)'.format(record.user.id)) + # NULL/empty `data` is a VALID state (record with no extended data), not a + # defect: fall back to an empty dict so walk() simply yields nothing. data = record.data or {} - user_guid = record.user._id if record.user else 'N/A' - user_id = record.user.id if record.user else 'N/A' + user_guid = record.user._id + user_id = record.user.id for path, val in walk(data): row = make_error_row('osf_userextendeddata', record.id, user_guid, user_id, path, val) @@ -253,12 +301,16 @@ def scan_osfuser(): logger.info('Scanning OSFUser records...') rows = [] for user in records.iterator(): + if not user._id: + raise ValueError('OSFUser id={} has no _id (guid)'.format(user.id)) user_id = user.id - user_guid = user._id or 'N/A' + user_guid = user._id # Simple string columns for col in OSFUSER_STRING_COLUMNS: - val = getattr(user, col, None) + if not hasattr(user, col): + raise ValueError('OSFUser model has no attribute "{}"'.format(col)) + val = getattr(user, col) if not val: continue row = make_error_row('osf_osfuser', user_id, user_guid, user_id, col, val) @@ -267,7 +319,11 @@ def scan_osfuser(): # JSON columns for col in OSFUSER_JSON_COLUMNS: - data = getattr(user, col, None) + if not hasattr(user, col): + raise ValueError('OSFUser model has no attribute "{}"'.format(col)) + data = getattr(user, col) + # NULL/empty JSON column is a VALID state (user has no entries for this + # column), not a defect: nothing to scan, so skip without failing. if not data: continue for path, val in walk(data): @@ -330,7 +386,7 @@ def save_result_csv(result_rows, output_path): # --- Updating Operations --- -def apply_fixes(rows, include_unrecoverable, dry_run=False): +def apply_fixes(rows, dry_run=False, is_from_csv=False): """ Apply fixes to database using Django ORM inside a transaction. If dry_run is True, the transaction will be rolled back at the end. @@ -362,15 +418,10 @@ def apply_fixes(rows, include_unrecoverable, dry_run=False): try: record = UserExtendedData.objects.get(id=record_id) except UserExtendedData.DoesNotExist: - for row in ued_rows: - result = dict(row) - result['fix_applied'] = 'NO (RECORD NOT EXIST)' - result['value_after'] = row['original_value'] - result_rows.append(result) - skipped_count += 1 - logger.warning(' Skipped: [UserExtendedData] id={} path={} (record not exist)'.format(record_id, row['field_path'])) - continue + raise ValueError('UserExtendedData id={} not found in DB'.format(record_id)) + # NULL/empty `data` is a VALID state, not a defect; default to an + # empty dict so set_nested_value can build the path to update. data = record.data or {} has_changes = False @@ -379,9 +430,25 @@ def apply_fixes(rows, include_unrecoverable, dry_run=False): result['fix_applied'] = '' result['value_after'] = '' - should_fix = is_fixable(row, include_unrecoverable) + if is_from_csv: + should_fix = row.get('fix_applied', '').strip().upper() == 'YES' + else: + # Only auto-apply clean fixes. 'partial' results lost bytes + # and must be reviewed by a human before applying. + should_fix = row.get('fix_status') == 'clean' if should_fix: + if is_from_csv: + current_val = _get_nested_value(data, row['field_path']) + if current_val != row.get('original_value', ''): + raise ValueError( + 'UserExtendedData id={} field={}: original_value in CSV {!r} does not match ' + 'current DB value {!r}. DB may have been updated after export. ' + 'Please re-run the dry-run scan and review the CSV again.'.format( + record_id, row['field_path'], + row.get('original_value'), current_val, + ) + ) clean_fix = get_clean_fix(row['suggested_fix']) data = set_nested_value(data, row['field_path'], clean_fix) has_changes = True @@ -390,7 +457,12 @@ def apply_fixes(rows, include_unrecoverable, dry_run=False): fixable_count += 1 logger.info(' Fixed: [UserExtendedData] id={} path={}'.format(record_id, row['field_path'])) else: - result['fix_applied'] = 'NO (UNRECOVERABLE)' + if is_from_csv: + result['fix_applied'] = row.get('fix_applied', '') + elif row.get('fix_status') == 'partial': + result['fix_applied'] = 'NO (PARTIAL - REVIEW NEEDED)' + else: + result['fix_applied'] = 'NO (UNRECOVERABLE)' result['value_after'] = row['original_value'] skipped_count += 1 @@ -406,25 +478,35 @@ def apply_fixes(rows, include_unrecoverable, dry_run=False): try: user = OSFUser.objects.get(id=user_id) except OSFUser.DoesNotExist: - for row in u_rows: - result = dict(row) - result['fix_applied'] = 'NO (RECORD NOT EXIST)' - result['value_after'] = row['original_value'] - result_rows.append(result) - skipped_count += 1 - logger.warning(' Skipped: [OSFUser] id={} field={} (record not exist)'.format(user_id, row['field_path'])) - continue + raise ValueError('OSFUser id={} not found in DB'.format(user_id)) has_changes = False + changed_json_cols = set() for row in u_rows: result = dict(row) result['fix_applied'] = '' result['value_after'] = '' - should_fix = is_fixable(row, include_unrecoverable) + if is_from_csv: + should_fix = row.get('fix_applied', '').strip().upper() == 'YES' + else: + # Only auto-apply clean fixes. 'partial' results lost bytes + # and must be reviewed by a human before applying. + should_fix = row.get('fix_status') == 'clean' if should_fix: + if is_from_csv: + current_val = _get_osfuser_field_value(user, row['field_path']) + if current_val != row.get('original_value', ''): + raise ValueError( + 'OSFUser id={} field={}: original_value in CSV {!r} does not match ' + 'current DB value {!r}. DB may have been updated after export. ' + 'Please re-run the dry-run scan and review the CSV again.'.format( + user_id, row['field_path'], + row.get('original_value'), current_val, + ) + ) clean_fix = get_clean_fix(row['suggested_fix']) path = row['field_path'] top_col = path.split('.')[0].split('[')[0] @@ -437,6 +519,9 @@ def apply_fixes(rows, include_unrecoverable, dry_run=False): fixable_count += 1 logger.info(' Fixed: [OSFUser] id={} field={}'.format(user_id, path)) elif top_col in OSFUSER_JSON_COLUMNS: + # NULL/empty JSON column is a VALID state, not a defect; + # default to an empty container so set_nested_value can + # build the path to update. col_data = getattr(user, top_col, None) or (dict() if top_col == 'social' else list()) sub_path = path[len(top_col):] if sub_path.startswith('.'): @@ -445,18 +530,40 @@ def apply_fixes(rows, include_unrecoverable, dry_run=False): col_data = set_nested_value(col_data, sub_path, clean_fix) setattr(user, top_col, copy.deepcopy(col_data)) has_changes = True + changed_json_cols.add(top_col) result['fix_applied'] = 'YES' result['value_after'] = clean_fix fixable_count += 1 logger.info(' Fixed: [OSFUser] id={} field={}'.format(user_id, path)) else: - result['fix_applied'] = 'NO (UNRECOVERABLE)' + if is_from_csv: + result['fix_applied'] = row.get('fix_applied', '') + elif row.get('fix_status') == 'partial': + result['fix_applied'] = 'NO (PARTIAL - REVIEW NEEDED)' + else: + result['fix_applied'] = 'NO (UNRECOVERABLE)' result['value_after'] = row['original_value'] skipped_count += 1 result_rows.append(result) if has_changes: + # Validate only the JSON column(s) we modified, using the field's own + # validators (validate_social / validate_history_item). We deliberately + # avoid full_clean() so unrelated/legacy fields cannot block a legitimate + # fix and to skip uniqueness DB queries. Note: these validators check the + # whole column value, so pre-existing invalid data in the same column will + # also trigger a fail-fast here. + for col in changed_json_cols: + try: + OSFUser._meta.get_field(col).run_validators(getattr(user, col)) + except DjangoValidationError as e: + raise ValueError( + 'OSFUser id={} column "{}" failed model validation after applying the fix: {}. ' + 'The column may contain other invalid/legacy data; please review the record.'.format( + user_id, col, '; '.join(e.messages), + ) + ) user.save() if dry_run: @@ -492,16 +599,21 @@ def main(): default=default_output, help=f'Output CSV file path (default: {default_output})', ) - parser.add_argument( - '--include-unrecoverable', action='store_true', default=False, - help='Also apply fixes for records marked as unrecoverable/partial result.', - ) parser.add_argument( '--dry', action='store_true', default=False, help='Run script in dry-run mode (do not commit updates to the database).', ) + parser.add_argument( + '--input-csv', '-i', + default=None, + help='Input CSV file generated in a dry run to apply specific fixes from.', + ) + args = parser.parse_args() + if not args.dry and not args.input_csv: + parser.error('the following arguments are required: --input-csv/-i (unless running in dry run mode with --dry)') + if not args.dry: # For actual runs modifying data, set up file logging script_utils.add_file_logger(logger, __file__) @@ -512,20 +624,195 @@ def main(): format='%(asctime)s %(levelname)s %(message)s', ) - # 1. Scan errors from database + # 1. Scan errors or load from CSV rows = [] - rows.extend(scan_userextendeddata()) - rows.extend(scan_osfuser()) + is_from_csv = False + if args.input_csv: + logger.info('Loading records to fix from CSV file: {}'.format(args.input_csv)) + if not os.path.exists(args.input_csv): + print('Error: Input CSV file "{}" does not exist.'.format(args.input_csv)) + sys.exit(1) + + try: + with open(args.input_csv, mode='r', encoding='utf-8-sig') as f: + # restkey/restval let us detect rows whose column count differs + # from the header: extra fields land in '__overflow__', missing + # trailing fields are filled with None. + reader = csv.DictReader(f, restkey='__overflow__', restval=None) + + # Check headers: must match the exported schema exactly + # (correct column names, no missing column, no extra column). + if not reader.fieldnames: + print('Error: Input CSV has no header row.') + sys.exit(1) + expected_headers = set(RESULT_FIELDNAMES) + actual_headers = set(reader.fieldnames) + missing_cols = expected_headers - actual_headers + extra_cols = actual_headers - expected_headers + if missing_cols or extra_cols: + msg = 'Error: Input CSV header does not match the expected schema ({} columns).'.format(len(RESULT_FIELDNAMES)) + if missing_cols: + msg += ' Missing column(s): {}.'.format(', '.join(sorted(missing_cols))) + if extra_cols: + msg += ' Unexpected column(s): {}.'.format(', '.join(sorted(extra_cols))) + print(msg) + sys.exit(1) + + try: + for line_no, row in enumerate(reader, start=2): + if not row: + continue + + # Validate column count on this data row (no missing/extra fields) + if row.get('__overflow__') is not None: + print('Error: Row {} is invalid: it has more columns than the header.'.format(line_no)) + sys.exit(1) + if any(value is None for key, value in row.items() if key != '__overflow__'): + print('Error: Row {} is invalid: it has fewer columns than the header.'.format(line_no)) + sys.exit(1) + + # Validate source_table + source_table = row.get('source_table') + if not source_table: + print('Error: Row {} is invalid: missing "source_table".'.format(line_no)) + sys.exit(1) + source_table = source_table.strip() + if source_table not in ('osf_userextendeddata', 'osf_osfuser'): + print('Error: Row {} is invalid: "source_table" "{}" is not a recognised value.'.format(line_no, source_table)) + sys.exit(1) + + # Validate id + id_val = row.get('id') + if not id_val: + print('Error: Row {} is invalid: missing "id".'.format(line_no)) + sys.exit(1) + try: + record_id = int(id_val.strip()) + except ValueError: + print('Error: Row {} is invalid: "id" "{}" is not a valid integer.'.format(line_no, id_val)) + sys.exit(1) + + # Validate field_path + field_path = row.get('field_path') + if not field_path: + print('Error: Row {} is invalid: missing "field_path".'.format(line_no)) + sys.exit(1) + field_path = field_path.strip() + + # Validate required identity columns. These echo the exported + # record and must not be blank (the customer asked for an + # explicit empty-check on required fields such as user._id). + user_guid = (row.get('user_guid') or '').strip() + if not user_guid: + print('Error: Row {} is invalid: missing "user_guid".'.format(line_no)) + sys.exit(1) + user_id = (row.get('user_id') or '').strip() + if not user_id: + print('Error: Row {} is invalid: missing "user_id".'.format(line_no)) + sys.exit(1) + + # Validate suggested_fix if fix_applied is YES + fix_applied = (row.get('fix_applied') or '').strip() + suggested_fix = row.get('suggested_fix') or '' + if fix_applied.upper() == 'YES' and not suggested_fix: + print('Error: Row {} is invalid: "fix_applied" is "YES" but "suggested_fix" is empty.'.format(line_no)) + sys.exit(1) + # Refuse to apply a value that still carries an UNRECOVERABLE marker, + # otherwise the literal marker text would be written into the DB. + if fix_applied.upper() == 'YES' and '[UNRECOVERABLE:' in suggested_fix: + print( + 'Error: Row {} is invalid: "fix_applied" is "YES" but "suggested_fix" still ' + 'contains an UNRECOVERABLE marker ({!r}). Set "fix_applied" to NO, or replace ' + '"suggested_fix" with the corrected value (remove the marker text).'.format( + line_no, suggested_fix, + ) + ) + sys.exit(1) + + # Refuse to apply a value that still contains the Unicode + # replacement character (U+FFFD). It signals a partial decode + # that lost bytes; Django model constraints accept it on save(), + # so we must fail fast here instead of writing corrupted data. + if fix_applied.upper() == 'YES' and REPLACEMENT_CHAR in get_clean_fix(suggested_fix): + print( + 'Error: Row {} is invalid: "fix_applied" is "YES" but "suggested_fix" still ' + 'contains the Unicode replacement character (U+FFFD "{}"), which means the value ' + 'was not fully recovered. Set "fix_applied" to NO, or replace "suggested_fix" ' + 'with a fully corrected value.'.format(line_no, REPLACEMENT_CHAR) + ) + sys.exit(1) + + # When this row will be applied, validate the target column and the + # corrected value itself (column existence / length / residual issues). + if fix_applied.upper() == 'YES': + clean_fix = get_clean_fix(suggested_fix) + top_col = field_path.split('.')[0].split('[')[0] + + if source_table == 'osf_osfuser': + if top_col not in OSFUSER_STRING_COLUMNS and top_col not in OSFUSER_JSON_COLUMNS: + print('Error: Row {} is invalid: "field_path" top column "{}" is not a known OSFUser column.'.format(line_no, top_col)) + sys.exit(1) + # CharField columns are constrained by the DB (varchar(max_length)). + # Check it early here so we fail fast with a clear message instead + # of a low-level DataError mid-transaction. + if top_col in OSFUSER_STRING_COLUMNS: + max_len = OSFUser._meta.get_field(top_col).max_length + if max_len is not None and len(clean_fix) > max_len: + print('Error: Row {} is invalid: "suggested_fix" length {} exceeds the max_length {} of column "{}".'.format( + line_no, len(clean_fix), max_len, top_col)) + sys.exit(1) + + # Warn (do not stop) if the corrected value still appears to contain + # encoding / HTML-entity issues. This is heuristic: accented Latin + # letters can trigger a false positive, so a human should confirm. + residual_issues = detect_issues(clean_fix) + if residual_issues: + logger.warning( + 'Row %s: "suggested_fix" still appears to contain issue(s) [%s] after the fix: %r. ' + 'Please double-check this is a legitimate value before applying.', + line_no, ','.join(residual_issues), clean_fix, + ) + + rows.append({ + 'source_table': source_table, + 'id': record_id, + 'user_guid': user_guid, + 'user_id': user_id, + 'field_path': field_path, + 'issues': (row.get('issues') or '').strip(), + 'original_value': row.get('original_value') or '', + 'suggested_fix': suggested_fix, + 'fix_applied': fix_applied, + 'fix_status': compute_fix_status(suggested_fix), + }) + except csv.Error as e: + print('Error: Failed to parse CSV file "{}" due to formatting error: {}'.format(args.input_csv, e)) + sys.exit(1) + except Exception: + # Fail fast: log the full traceback and re-raise so the unexpected + # error is not swallowed. (sys.exit(1) above raises SystemExit, which + # is not an Exception subclass, so the explicit validation exits are + # unaffected by this handler.) + logger.exception('Unexpected error while opening/reading CSV file "%s".', args.input_csv) + raise + + is_from_csv = True + logger.info('Loaded {} records from CSV.'.format(len(rows))) + else: + # Default: scan database + rows.extend(scan_userextendeddata()) + rows.extend(scan_osfuser()) if not rows: print('No encoding errors found.') return - # Print the table of found errors - print_errors(rows) + # Print the table of found/loaded errors + if rows: + print_errors(rows) # 2. Apply fixes (conditionally committing or rolling back) - result_rows = apply_fixes(rows, args.include_unrecoverable, dry_run=args.dry) + result_rows = apply_fixes(rows, dry_run=args.dry, is_from_csv=is_from_csv) # 3. Export results to CSV save_result_csv(result_rows, args.output) From 30c61800137730b0cbfda1e0d63fcf7bf0856e91 Mon Sep 17 00:00:00 2001 From: hcphat Date: Mon, 29 Jun 2026 11:43:47 +0700 Subject: [PATCH 38/38] =?UTF-8?q?ref=202.1.=E5=B0=8F=E8=A6=8F=E6=A8=A1?= =?UTF-8?q?=E5=A4=A7=E9=87=8F=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92?= =?UTF-8?q?=E5=90=AB=E3=82=80=E3=83=95=E3=82=A9=E3=83=AB=E3=83=80=E3=82=A2?= =?UTF-8?q?=E3=83=83=E3=83=97=E3=83=AD=E3=83=BC=E3=83=89=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E6=94=B9=E5=96=84=E3=81=AB=E3=81=8B=E3=81=8B=E3=82=8B=E9=96=8B?= =?UTF-8?q?=E7=99=BA:=20check=20large=20file=20threshold=20before=20proces?= =?UTF-8?q?sFile()=20in=20processQueue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- website/static/js/fangorn.js | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/website/static/js/fangorn.js b/website/static/js/fangorn.js index 040eb68a74a..72c5a816445 100644 --- a/website/static/js/fangorn.js +++ b/website/static/js/fangorn.js @@ -170,12 +170,23 @@ var PROVIDER_SETTINGS = { } var next = queuedFiles[0]; var nextSize = (typeof next.size === 'number') ? next.size : (next.upload && next.upload.total ? next.upload.total : 0); - // start the next file - this.processFile(queuedFiles.shift()); - // If the started file is "large", attach a one-time resume when it completes, - // then stop starting further parallel uploads until resume. + // Enforce single-thread upload for large files: check the threshold + // BEFORE starting the file. A large file may only start when nothing + // else is uploading and nothing has been started earlier in this + // processQueue() pass (uploadIndex === 0, since uploadIndex is seeded + // with the count of already-uploading files). Otherwise defer it so it + // never runs in parallel with another upload. if (threshold && nextSize > threshold) { + if (uploadIndex > 0) { + // Other upload(s) already active/started in this pass — do not + // start the large file now; it will be retried once the current + // uploads complete and processQueue() runs again. + return; + } + // Nothing else active: start the large file on its own thread, then + // resume the queue only after it completes. + this.processFile(queuedFiles.shift()); var onComplete = function(file) { if (file !== next) return; // detach listener (support once()/off() APIs) @@ -196,6 +207,9 @@ var PROVIDER_SETTINGS = { } return; } + + // Small file: start it and keep filling the remaining parallel slots. + this.processFile(queuedFiles.shift()); uploadIndex++; } }