Hello %{user_name}.
"\ - "Your plan \"%{plan_name}\" has been submitted for feedback from an - administrator at your organisation. "\ - "If you have questions pertaining to this action, please contact us - at %{organisation_email}.
") + _("Dear %{user_name},
"\ + "\"%{plan_name}\" has been sent to your %{application_name} account administrator for feedback.
"\ + "Please email %{organisation_email} with any questions about this process.
") end def feedback_constant_to_text(text, user, plan, org) diff --git a/app/helpers/identifier_helper.rb b/app/helpers/identifier_helper.rb index 300b43a50e..8315b29bdd 100644 --- a/app/helpers/identifier_helper.rb +++ b/app/helpers/identifier_helper.rb @@ -4,12 +4,26 @@ module IdentifierHelper def id_for_display(id:, with_scheme_name: true) return _("None defined") if id.new_record? || id.value.blank? + # Sandbox DOIs do not resolve so swap in the direct URL to the Minting service + return sandbox_dmp_id(id: id) unless Rails.env.production? && + id.identifier_scheme == DmpIdService.identifier_scheme without = id.value_without_scheme_prefix - prefix = with_scheme_name ? id.identifier_scheme.description + ": " : "" + prefix = with_scheme_name ? "#{id.identifier_scheme.description}: " : "" return prefix + id.value unless without != id.value && !without.starts_with?("http") link_to "#{prefix} #{without}", id.value, class: "has-new-window-popup-info" end + def sandbox_dmp_id(id:, with_domain: false) + return _("None defined") if id.new_record? || id.value.blank? + + url = DmpIdService.landing_page_url + without = id.value_without_scheme_prefix + + return id.value unless url.present? && without != id.value && !without.starts_with?("http") + + link_to(with_domain ? id.value : without, "#{url}#{without}", class: "has-new-window-popup-info") + end + end diff --git a/app/helpers/super_admin/api_client_helper.rb b/app/helpers/super_admin/api_client_helper.rb new file mode 100644 index 0000000000..51661ac2d3 --- /dev/null +++ b/app/helpers/super_admin/api_client_helper.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module SuperAdmin + + module ApiClientHelper + + # Helper that gives human readable context to Doorkeeper OAuth scopes + + def label_for_scope(scope) + case scope + when "read_dmps" + _("Read and download PDF copies of plans") + when "edit_dmps" + _("Edit plans") + when "create_dmps" + _("Create plans") + when "public" + _("Retrieve a list of templates and plans") + else + scope.humanize + end + end + + def tooltip_for_scope(scope) + case scope + when "read_dmps" + _("Access to all publicly visible plans and, if associated with an org, the organisationally visible plans. They can also access a user's plans through OAuth autorization.") + when "edit_dmps" + _("Edit any plans created through the API and edit a user's plan after gaining OAuth authorization from the user") + when "create_dmps" + _("Create a plan (will be associated with the Org defined here if applicable) and create plans on behalf of a user once OAuth auuthorization has been granted") + else + "" + end + end + + # This one is used on the app/views/doorkeeper/authorizations/new.html.erb for user's authorizing + def user_label_for_scope(scope) + case scope + when "read_dmps" + _("Read and download your DMPs") + when "edit_dmps" + _("Edit your DMPs") + when "create_dmps" + _("Create DMPs on your behalf") + when "public" + _("Retrieve a list of your DMPs") + else + scope.humanize + end + end + end + +end diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 1c83822daa..4a972f72eb 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -26,6 +26,7 @@ import 'bootstrap-select'; import '../src/utils/accordion'; import '../src/utils/autoComplete'; import '../src/utils/externalLink'; +import '../src/utils/modalSearch'; import '../src/utils/outOfFocus'; import '../src/utils/paginable'; import '../src/utils/panelHeading'; @@ -38,6 +39,7 @@ import '../src/utils/tooltipHelper'; // the js.erb templates in the `window.x` statements below import { renderAlert, renderNotice } from '../src/utils/notificationHelper'; import toggleSpinner from '../src/utils/spinner'; +import { Tinymce } from '../src/utils/tinymce.js.erb'; // View specific JS import '../src/answers/conditions'; @@ -48,16 +50,26 @@ import '../src/devise/invitations/edit'; import '../src/devise/passwords/edit'; import '../src/devise/registrations/edit'; import '../src/devise/registrations/new'; +import '../src/doorkeeper/authorizations/new'; import '../src/guidances/newEdit'; import '../src/notes/index'; import '../src/orgs/adminEdit'; -import '../src/orgs/shibbolethDs'; +// ---------------------------------------- +// START DMPTool customization +// ---------------------------------------- +// import '../src/orgs/shibbolethDs'; +// ---------------------------------------- +// END DMPTool customization +// ---------------------------------------- import '../src/plans/download'; import '../src/plans/editDetails'; import '../src/plans/index.js.erb'; import '../src/plans/new'; +import '../src/plans/publish'; import '../src/plans/share'; import '../src/publicTemplates/show'; +import '../src/relatedIdentifiers/edit'; +import '../src/researchOutputs/form'; import '../src/roles/edit'; import '../src/shared/createAccountForm'; import '../src/shared/signInForm'; @@ -83,6 +95,16 @@ import '../src/superAdmin/notifications/edit'; import '../src/superAdmin/themes/newEdit'; import '../src/superAdmin/users/edit'; +// ---------------------------------------- +// START DMPTool customization +// ---------------------------------------- +import '../src/dmptool/home/index'; +import '../src/dmptool/shared/orgBranding'; +import '../src/dmptool/shared/signinCreateForm'; +// ---------------------------------------- +// END DMPTool customization +// ---------------------------------------- + // Since we're using Webpacker to manage JS we need to startup Rails' Unobtrusive JS // and Turbolinks. ActiveStorage and ActionCable would also need to be in here // if we decide to implement either before Rails 6 @@ -103,3 +125,4 @@ window.jQuery = jQuery; window.renderAlert = renderAlert; window.renderNotice = renderNotice; window.toggleSpinner = toggleSpinner; +window.Tinymce = Tinymce; diff --git a/app/javascript/src/dmptool/home/index.js b/app/javascript/src/dmptool/home/index.js new file mode 100644 index 0000000000..0d8dfec49b --- /dev/null +++ b/app/javascript/src/dmptool/home/index.js @@ -0,0 +1,30 @@ +/* eslint-env browser */ // This allows us to reference 'window' below + +import getConstant from '../../utils/constants'; + +$(() => { + // Rotate through the news items every 8 seconds + const articles = $('#home-news-array').val(); + if (articles) { + const news = JSON.parse(`${articles.replace(/\\"/g, '"').replace(/\\'/g, '\'')}`); + const updateNews = (item) => { + const text = $('#home-news-link'); + const span = `${getConstant('OPENS_IN_A_NEW_WINDOW_TEXT')}`; + text.hide(); + text.html(`${news[item].title} ${span}`); + text.fadeIn(100); + }; + const startNewsTimer = (item) => { + setTimeout(() => { + updateNews(item); + startNewsTimer((item >= news.length - 1) ? 0 : item + 1); + }, 8000); + }; + updateNews(0); + startNewsTimer(1); + } + + $('#get-started-options a').click((e) => { + $(e.target).closest('.modal').modal('hide'); + }); +}); diff --git a/app/javascript/src/dmptool/shared/orgBranding.js b/app/javascript/src/dmptool/shared/orgBranding.js new file mode 100644 index 0000000000..35929c21f3 --- /dev/null +++ b/app/javascript/src/dmptool/shared/orgBranding.js @@ -0,0 +1,22 @@ +// This is the branded Org sign in/create account page +$(() => { + const orgControls = $('#create-account-org-controls'); + + // We already know what org to use, so hide the selector and pre-populate + // the field with the org id the user selected in the prior page + if (orgControls.length > 0) { + const orgId = orgControls.find('#new_user_org_id'); + + if (orgId.length > 0) { + const id = $('#default_org_id'); + const name = $('#default_org_name'); + + if (id.length > 0 && name.length > 0) { + if (id.val().length > 0 && name.val().length > 0) { + orgId.val(JSON.stringify({ id: id.val(), name: name.val() })); + orgControls.hide(); + } + } + } + } +}); diff --git a/app/javascript/src/dmptool/shared/signinCreateForm.js b/app/javascript/src/dmptool/shared/signinCreateForm.js new file mode 100644 index 0000000000..8226f83cf9 --- /dev/null +++ b/app/javascript/src/dmptool/shared/signinCreateForm.js @@ -0,0 +1,149 @@ +/* eslint-env browser */ // This allows us to reference 'window' below +import * as Cookies from 'js-cookie'; +import { initAutocomplete } from '../../utils/autoComplete'; +import { isObject, isString } from '../../utils/isType'; +import getConstant from '../../utils/constants'; + +$(() => { + initAutocomplete('#create-account-org-controls .autocomplete'); + initAutocomplete('#shib-ds-org-controls .autocomplete'); + const email = Cookies.get('dmproadmap_email'); + + // Signin remember me + // ----------------------------------------------------- + // If the user's email was stored in the browser's cookies the pre-populate the field + if (email && email !== '') { + $('#signin_create_form #remember_email').attr('checked', 'checked'); + $('#signin_create_form #user_email').val(email); + } + + // When the user checks the 'remember email' box store the value in the browser storage + $('#signin_create_form #remember_email').click((e) => { + if ($(e.currentTarget).is(':checked')) { + Cookies.set('dmproadmap_email', $('#signin_create_form #user_email').val(), { expires: 14 }); + } else { + Cookies.remove('dmproadmap_email'); + } + }); + + // If the email is changed and the user has asked to remember it update the browser storage + $('#signin_create_form #user_email').change((e) => { + if ($('#signin_create_form #remember_email').is(':checked')) { + Cookies.set('dmproadmap_email', $(e.currentTarget).val(), { expires: 14 }); + } + }); + + // Signin / Create Account form toggle + // ----------------------------------------------------- + // handle toggling between shared signin/create account forms + const toggleSignInCreateAccount = (signin = true) => { + const signinTab = $('a[href="#sign-in-panel"]').closest('li'); + const createTab = $('a[href="#create-account-panel"]').closest('li'); + const signinPanel = $('#sign-in-panel'); + const createAccountPanel = $('#create-account-panel'); + + if (signin) { + signinTab.addClass('active'); + signinPanel.addClass('active'); + + createTab.removeClass('active'); + createAccountPanel.removeClass('active'); + } else { + signinTab.removeClass('active'); + signinPanel.removeClass('active'); + + createTab.addClass('active'); + createAccountPanel.addClass('active'); + } + }; + + const clearLogo = () => { + $('#org-sign-in-logo').html(''); + $('#user_org_id').val(''); + }; + + $('#show-create-account-via-shib-ds, #show-create-account-form').click(() => { + clearLogo(); + toggleSignInCreateAccount(false); + }); + $('#show-sign-in-form').click(() => { + clearLogo(); + toggleSignInCreateAccount(true); + }); + + // Shibboleth DS + // ----------------------------------------------------- + const logoSuccess = (data) => { + // Render the html in the org-sign-in modal + if (isObject(data) && isObject(data.org) && isString(data.org.html)) { + $('#org-sign-in-logo').html(data.org.html); + $('#signin_user_org_id').val(data.org.id); + $('#new_user_org_id').val(data.org.id); + toggleSignInCreateAccount(true); + $('#sign-in-create-account').modal('show'); + } + }; + const logoError = () => { + // There was an ajax error so just route the user to the sign-in modal + // and let them sign in as a Non-Partner Institution + $('#access-control-tabs a[data-target="#sign-in-form"]').tab('show'); + }; + + // Toggles the full Org list on/off + + $('#show_list').click((e) => { + e.preventDefault(); + const target = $('#full_list'); + if (target.is('.hidden')) { + target.removeClass('hidden').attr('aria-hidden', 'false'); + $(e.currentTarget).html(getConstant('SHIBBOLETH_DISCOVERY_SERVICE_HIDE_LIST')); + } else { + target.addClass('hidden').attr('aria-hidden', 'true'); + $(e.currentTarget).html(getConstant('SHIBBOLETH_DISCOVERY_SERVICE_SHOW_LIST')); + } + }); + + // Only enable the Institutional Signin 'Go' Button if the user selected a + // value from the list + $('#shib-ds-org-controls').on('change', '#org_id', (e) => { + const id = $(e.target); + const json = JSON.parse(id.val()); + const button = $('#org-select-go'); + clearLogo(); + if (json !== undefined) { + if (json.id !== undefined) { + button.prop('disabled', false); + } else { + button.prop('disable', true); + } + } + }).on('ajax:success', (data) => { + logoSuccess(data); + }).on('ajax:error', () => { + logoError(); + }); + + // When the user selects an Org from the autocomplete and clicks 'Go' + // Update the form's target with the selected org id before submission + $('#org-select-go').on('click', (e) => { + const json = JSON.parse($('#shib-ds-org-controls #org_id').val()); + if (json !== undefined && json.id !== undefined) { + const go = $(e.target); + const form = go.closest('form'); + form.attr('action', `${form.attr('action')}/${json.id}`); + } else { + e.preventDefault(); + } + }); + + // Hide the vanilla Roadmap 'Sign in with your institutional credentials' button + $('#sign_in_form h4').addClass('hide'); + $('#sign_in_form a[href="/orgs/shibboleth"]').addClass('hide'); + + // Get Started button click + // ----------------------------------------------------- + $('#get-started').click((e) => { + e.preventDefault(); + $('#header-signin').dropdown('toggle'); + }); +}); diff --git a/app/javascript/src/doorkeeper/authorizations/new.js b/app/javascript/src/doorkeeper/authorizations/new.js new file mode 100644 index 0000000000..ebfc8b1cee --- /dev/null +++ b/app/javascript/src/doorkeeper/authorizations/new.js @@ -0,0 +1,5 @@ +import { initAutocomplete } from '../../utils/autoComplete'; + +$(() => { + initAutocomplete('#shib-ds-org-controls .autocomplete'); +}); diff --git a/app/javascript/src/orgs/adminEdit.js b/app/javascript/src/orgs/adminEdit.js index c393c39c27..dae022deb4 100644 --- a/app/javascript/src/orgs/adminEdit.js +++ b/app/javascript/src/orgs/adminEdit.js @@ -59,4 +59,17 @@ $(() => { initAutocomplete('#org-merge-controls .autocomplete'); scrubOrgSelectionParamsOnSubmit('form.edit_org'); + + Tinymce.init({ selector: '#org_api_create_plan_email_body' }); + + // JS to update the email preview as the user edits the email body field + const emailBodyControl = Tinymce.findEditorById('org_api_create_plan_email_body'); + const emailPreview = $('.replaceable-api-email-content'); + + // Add handlers to the TinyMCE editor so that changes update the preview section + if (emailBodyControl && emailPreview) { + emailBodyControl.on('keyup', (e) => { + emailPreview.html($(e.target).html()); + }); + } }); diff --git a/app/javascript/src/orgs/shibbolethDs.js b/app/javascript/src/orgs/shibbolethDs.js index 02ccc4566d..20d7679df1 100644 --- a/app/javascript/src/orgs/shibbolethDs.js +++ b/app/javascript/src/orgs/shibbolethDs.js @@ -1,4 +1,5 @@ import getConstant from '../utils/constants'; +import { initAutocomplete, scrubOrgSelectionParamsOnSubmit } from '../utils/autoComplete'; $(() => { $('#show_list').click((e) => { @@ -11,4 +12,11 @@ $(() => { $(e.currentTarget).html(getConstant('SHIBBOLETH_DISCOVERY_SERVICE_SHOW_LIST')); } }); + + if ($('#shibboleth-ds-org-controls').length > 0) { + initAutocomplete('#shibboleth-ds-org-controls .autocomplete'); + // Scrub out the large arrays of data used for the Org Selector JS so that they + // are not a part of the form submissiomn + scrubOrgSelectionParamsOnSubmit('#shibboleth_ds'); + } }); diff --git a/app/javascript/src/plans/editDetails.js b/app/javascript/src/plans/editDetails.js index f32b817240..f973a002f5 100644 --- a/app/javascript/src/plans/editDetails.js +++ b/app/javascript/src/plans/editDetails.js @@ -2,6 +2,7 @@ import { initAutocomplete, scrubOrgSelectionParamsOnSubmit } from '../utils/auto import { Tinymce } from '../utils/tinymce.js.erb'; import toggleConditionalFields from '../utils/conditionalFields'; import getConstant from '../utils/constants'; +import toggleSpinner from '../utils/spinner'; $(() => { const grantIdField = $('.grant-id-typeahead'); @@ -150,5 +151,9 @@ $(() => { toggleCheckboxes($('#priority-guidance-orgs input[type="checkbox"]:checked').map((i, el) => $(el).val()).get()); setUpTypeahead(); + + form.on('submit', () => { + toggleSpinner(true); + }); } }); diff --git a/app/javascript/src/plans/publish.js b/app/javascript/src/plans/publish.js new file mode 100644 index 0000000000..73fb2e1734 --- /dev/null +++ b/app/javascript/src/plans/publish.js @@ -0,0 +1,12 @@ +import getConstant from '../utils/constants'; + +$(() => { + // Clear out the existing message/response when the user clicks the 'Register' a DMP ID button + $('body').on('click', 'input.mint-dmp-id', () => { + const mintMessage = $('.dmp-id-minter-response'); + + if (mintMessage.length > 0) { + mintMessage.html(getConstant('ACQUIRING_DMP_ID')); + } + }); +}); diff --git a/app/javascript/src/plans/share.js b/app/javascript/src/plans/share.js index e230cd5972..10b0e15127 100644 --- a/app/javascript/src/plans/share.js +++ b/app/javascript/src/plans/share.js @@ -1,22 +1,7 @@ import * as notifier from '../utils/notificationHelper'; -import { isObject, isString } from '../utils/isType'; +import { isObject } from '../utils/isType'; $(() => { - $('#set_visibility').on('ajax:success', (e) => { - const data = e.detail[0]; - if (isObject(data) && isString(data.msg)) { - notifier.renderNotice(data.msg); - } - }); - $('#set_visibility').on('ajax:error', (e) => { - const xhr = e.detail[2]; - if (isObject(xhr.responseJSON)) { - notifier.renderAlert(xhr.responseJSON.msg); - } else { - notifier.renderAlert(`${xhr.statusCode} - ${xhr.statusText}`); - } - }); - $('.toggle-existing-user-access') .on('ajax:success', (e) => { const data = e.detail[0]; diff --git a/app/javascript/src/relatedIdentifiers/edit.js b/app/javascript/src/relatedIdentifiers/edit.js new file mode 100644 index 0000000000..1273b4ef24 --- /dev/null +++ b/app/javascript/src/relatedIdentifiers/edit.js @@ -0,0 +1,65 @@ +// JS to handle the '+ Add a related work' link +$(() => { + const relatedIdentifierBlock = $('.related-works'); + + if (relatedIdentifierBlock.length > 0) { + const addRowLink = relatedIdentifierBlock.siblings('.add-related-work'); + + // Replace the unique record identifier on the :id and :for attributes + const replaceId = (element, id) => { + const regExp = /_[0-9]+_/; + if (element.attr('for')) { + element.attr('for', element.attr('for').replace(regExp, `_${id}_`)); + } else { + element.attr('id', element.attr('id').replace(regExp, `_${id}_`)); + } + }; + + // Replace the unique record identifier on the :name attributes + const replaceName = (element, id) => { + const regExp = /\[[0-9]\]\[/; + if (element.attr('name')) { + element.attr('name', element.attr('name').replace(regExp, `[${id}][`)); + } + }; + + // Replace the unique record identifier for each label and input/select + const replaceIdsAndNames = (row, id) => { + row.find('label').each((_idx, label) => { + replaceId($(label), id); + }); + row.find('input, select').each((_idx, field) => { + replaceId($(field), id); + replaceName($(field), id); + }); + }; + + const addNewRow = () => { + // Find the hidden empty row which will be used to clone the new row + const emptyRow = relatedIdentifierBlock.find('.related-work-row.hidden'); + if (emptyRow.length > 0) { + const newRow = emptyRow.clone(); + + // Set the the new row's id + replaceIdsAndNames(newRow, new Date().getTime()); + + newRow.removeClass('hidden'); + relatedIdentifierBlock.append(newRow[0].outerHTML); + } + }; + + // Add a new row if the user clicks the '+ add a related work' link + if (addRowLink.length > 0) { + addRowLink.on('click', (e) => { + e.preventDefault(); + addNewRow(); + }); + } + + // Remove the entire row if the user clicks the 'X' delete link + relatedIdentifierBlock.on('click', '.remove-related-work', (e) => { + e.preventDefault(); + $(e.target).closest('.citation').remove(); + }); + } +}); diff --git a/app/javascript/src/researchOutputs/form.js b/app/javascript/src/researchOutputs/form.js new file mode 100644 index 0000000000..b454b9eb3d --- /dev/null +++ b/app/javascript/src/researchOutputs/form.js @@ -0,0 +1,45 @@ +import getConstant from '../utils/constants'; +import { isUndefined, isObject } from '../utils/isType'; +import { Tinymce } from '../utils/tinymce.js.erb'; + +$(() => { + const form = $('.research_output_form'); + + if (!isUndefined(form) && isObject(form)) { + Tinymce.init({ selector: '#research_output_description' }); + } + + // Expands/Collapses the search results 'More info'/'Less info' section + $('body').on('click', '.modal-search-result .more-info a.more-info-link', (e) => { + e.preventDefault(); + const link = $(e.target); + + if (link.length > 0) { + const info = $(link).siblings('div.info'); + + if (info.length > 0) { + if (info.hasClass('hidden')) { + info.removeClass('hidden'); + link.text(`${getConstant('LESS_INFO')}`); + } else { + info.addClass('hidden'); + link.text(`${getConstant('MORE_INFO')}`); + } + } + } + }); + + // Put the facet text into the modal search window's search box when the user + // clicks on one + $('body').on('click', '.modal-search-result a.facet', (e) => { + const link = $(e.target); + + if (link.length > 0) { + const textField = link.closest('.modal-body').find('input.autocomplete'); + + if (textField.length > 0) { + textField.val(link.text()); + } + } + }); +}); diff --git a/app/javascript/src/superAdmin/apiClients/form.js b/app/javascript/src/superAdmin/apiClients/form.js index d9b50d2171..fbfe05d159 100644 --- a/app/javascript/src/superAdmin/apiClients/form.js +++ b/app/javascript/src/superAdmin/apiClients/form.js @@ -1,7 +1,36 @@ -import { initAutocomplete } from '../../utils/autoComplete'; +import { initAutocomplete, scrubOrgSelectionParamsOnSubmit } from '../../utils/autoComplete'; $(() => { if ($('#api-client-org-controls').length > 0) { initAutocomplete('#api-client-org-controls .autocomplete'); + scrubOrgSelectionParamsOnSubmit('form.api_client'); + scrubOrgSelectionParamsOnSubmit('#new_api_client'); + + // Toggle the visibility of the Scopes sections based on the status of the 'Trusted' checkbox + const toggleScopesBlocks = (context) => { + const scopesBlocks = $('.oauth-scopes'); + + if (scopesBlocks.length > 0) { + scopesBlocks.each((_idx, el) => { + // If the API Client is 'trusted' then hide the Scopes and check them all + if (context.prop('checked')) { + $(el).addClass('hidden'); + $(el).find('input[type="checkbox"]').prop('checked', true); + } else { + $(el).removeClass('hidden'); + } + }); + } + }; + + // If the 'trusted' checkbox is checked then hide the scopes blocks and auto-check all scopes + const trusted = $('#api_client_trusted'); + if (trusted.length > 0) { + toggleScopesBlocks(trusted); + + trusted.on('click', (e) => { + toggleScopesBlocks($(e.target)); + }); + } } }); diff --git a/app/javascript/src/utils/modalSearch.js b/app/javascript/src/utils/modalSearch.js new file mode 100644 index 0000000000..2bc8bf1888 --- /dev/null +++ b/app/javascript/src/utils/modalSearch.js @@ -0,0 +1,39 @@ +$(() => { + // Add the selected item to the selections section + $('body').on('click', 'a.modal-search-result-selector', (e) => { + e.preventDefault(); + const link = $(e.target); + + if (link.length > 0) { + const selectedBlock = $(e.target).closest('.modal-search-result'); + const resultsBlock = $(e.target).closest('.modal-search-results'); + + if (resultsBlock.length > 0 && selectedBlock.length > 0) { + const selectionsBlockId = resultsBlock.attr('id').replace('-results', '-selections'); + + if (selectionsBlockId !== undefined) { + const selectionsBlock = $(`#${selectionsBlockId}`); + + if (selectionsBlock.length > 0) { + const clone = selectedBlock.clone(); + clone.find('.modal-search-result-selector').addClass('hidden'); + clone.find('.modal-search-result-unselector').removeClass('hidden'); + clone.find('.tags').remove(); + selectionsBlock.append(clone); + selectedBlock.remove(); + } + } + } + } + }); + + // Remove the selected item + $('body').on('click', 'a.modal-search-result-unselector', (e) => { + e.preventDefault(); + const selection = $(e.target).closest('.modal-search-result'); + + if (selection.length > 0) { + selection.remove(); + } + }); +}); diff --git a/app/javascript/src/utils/panelHeading.js b/app/javascript/src/utils/panelHeading.js index 534cad03c7..ae7ae93ca6 100644 --- a/app/javascript/src/utils/panelHeading.js +++ b/app/javascript/src/utils/panelHeading.js @@ -1,5 +1,5 @@ $(() => { - $('body').on('click', '.heading-button', (e) => { + $('body').on('click', '.heading-button, .panel-title', (e) => { $(e.currentTarget) .find('i.fa-plus, i.fa-minus') .toggleClass('fa-plus') diff --git a/app/javascript/src/utils/passwordHelper.js b/app/javascript/src/utils/passwordHelper.js index 6285937a9b..94d85096fb 100644 --- a/app/javascript/src/utils/passwordHelper.js +++ b/app/javascript/src/utils/passwordHelper.js @@ -62,7 +62,6 @@ export const togglisePasswords = (options) => { if (isObject(options) && isString(options.selector)) { const toggle = $(`${options.selector} .passwords_toggle`); const pwds = $(`${options.selector} input[type="password"]`); - if (pwds && toggle) { toggle.on('change', () => { if (isArray(pwds)) { diff --git a/app/javascript/src/utils/tinymce.js.erb b/app/javascript/src/utils/tinymce.js.erb index ce1ba13742..ead275a956 100644 --- a/app/javascript/src/utils/tinymce.js.erb +++ b/app/javascript/src/utils/tinymce.js.erb @@ -29,7 +29,7 @@ export const defaultOptions = { target_list: false, elementpath: false, resize: true, - min_height: 230, + autoresize_min_height: 130, autoresize_bottom_margin: 10, branding: false, extended_valid_elements: 'iframe[tooltip] , a[href|target=_blank]', @@ -43,6 +43,7 @@ export const defaultOptions = { table_default_attributes: { border: 1, }, + // skin: false, // editorManager.baseURL is not resolved properly for IE since document.currentScript // is not supported, see issue https://github.com/tinymce/tinymce/issues/358 skin_url: '/tinymce/skins/lightgray', diff --git a/app/jobs/notify_subscriber_job.rb b/app/jobs/notify_subscriber_job.rb new file mode 100644 index 0000000000..23194738f5 --- /dev/null +++ b/app/jobs/notify_subscriber_job.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# This Job sends a notification (the JSON version of the Plan) out to the specified +# subscriber. +class NotifySubscriberJob < ApplicationJob + + queue_as :default + + def perform(subscription) + # TODO: We're currently only 'subscribing' the DMP ID service to plans. + # We can build out the rest of this if we add other subscriber types + # e.g. allowing an api_client associated with an Org's internal + # data curation or research project management systems + case subscription.subscriber_type + when "ApiClient" + notify_api_client(subscription: subscription) + else + # Maybe just use HTTParty for this if we ever want to subscribe a different model + # like a User or Org + true + end + + subscription.update(last_notified: Time.now) + rescue StandardError => e + # Something went terribly wrong, so note it in the logs since this runs outside the + # regular Rails thread that the application is using + Rails.logger.error "NotifySubscriberJob.perform failed for \ + Subscription: #{subscription.inspect}" + Rails.logger.error "NotifySubscriberJob.perform - #{e.message}" + Rails.logger.error e.backtrace + end + + private + + def notify_api_client(subscription:) + return false unless subscription.present? && subscription.subscriber.present? + + api_client = subscription.subscriber + dmp_id_svc = api_client.name.downcase == DmpIdService.identifier_scheme&.name&.downcase + + # If the ApiClient is the DMP ID service then update the DMP ID metadata + if DmpIdService.minting_service_defined? && dmp_id_svc + Rails.logger.info "Sending #{api_client.name} the updated DMP ID metadata \ + for Plan #{subscription.plan.id}" + + DmpIdService.update_dmp_id(plan: subscription.plan) + + elsif !dmp_id_svc + # As long as this isn't the DMP ID service, send the update directly to the callback + # Maybe just use HTTParty for this + true + end + end + +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 86a52ad1dc..032796e436 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -8,7 +8,7 @@ class UserMailer < ActionMailer::Base helper MailerHelper helper FeedbacksHelper - default from: Rails.configuration.x.organisation.email + default from: Rails.configuration.x.organisation.do_not_reply_email || Rails.configuration.x.organisation.email def welcome_notification(user) @user = user @@ -108,7 +108,7 @@ def feedback_notification(recipient, plan, requestor) I18n.with_locale I18n.default_locale do mail(to: @recipient.email, - subject: _("%{user_name} has requested feedback on a %{tool_name} plan") % + subject: _("A new DMP is awaiting your feedback") % { tool_name: tool_name, user_name: @user.name(false) }) @@ -205,11 +205,12 @@ def admin_privileges(user) end end + # Sent out to the API contact when the Super Admin creates a record or refreshes the secret def api_credentials(api_client) @api_client = api_client return unless @api_client.contact_email.present? - @api_docs = Rails.configuration.x.application.api_documentation_urls[:v1] + @api_docs = Rails.configuration.x.application.api_documentation_overview_url @name = @api_client.contact_name.present? ? @api_client.contact_name : @api_client.contact_email @@ -222,4 +223,69 @@ def api_credentials(api_client) end end + # Sent out to admins when a user self registers for the API via the Developer Tools' tab on Profile page + def new_api_client(api_client) + @api_client = api_client + + @name = @api_client.contact_name.present? ? @api_client.contact_name : @api_client.contact_email + @name = @api_client.user.name(false) unless @name.present? + @email = @api_client.contact_email || @api_client.user.email + + I18n.with_locale I18n.default_locale do + mail(to: Rails.configuration.x.application.admin_emails, + subject: _("%{tool_name} new API registration") % { tool_name: tool_name}) + end + end + + # Sends the error message out to the administrators + def notify_administrators(message) + administrators = Rails.configuration.x.application.admin_emails + return false unless administrators.present? + + @message = message + + I18n.with_locale I18n.default_locale do + mail(to: administrators, + subject: _("%{tool_name} error occurred") % { tool_name: tool_name }) + end + end + + # Sends an email to the Plan's owner letting them know that the Plan was created by the ApiClient + def new_plan_via_api(recipient:, plan:, api_client:) + return false unless recipient.is_a?(User) && plan.is_a?(Plan) && api_client.is_a?(ApiClient) + + default_subject = _("A new data management plan (DMP) has been started for you by %{external_system_name}") % { + external_system_name: api_client.description + } + subject = plan.template&.org&.api_create_plan_email_subject || default_subject + + @message = plan.template&.org&.api_create_plan_email_body + @api_client = api_client + @user = recipient + @plan = plan + I18n.with_locale I18n.default_locale do + mail( + to: Rails.env.production? ? recipient.email : api_client.contact_email, + cc: plan.template.org&.contact_email, + subject: subject + ) + end + end + + # Sends an email to the recipient notifying them of the new Plan created for them by + # the sender + def new_plan_via_template(recipient:, sender:, plan:) + return false unless recipient.is_a?(User) && sender.is_a?(User) && plan.is_a?(Plan) + + subject = plan.template.email_subject + + @message = plan.template.email_body + @user = recipient + @plan = plan + @sender = sender + I18n.with_locale I18n.default_locale do + mail(to: recipient.email, cc: sender.email, subject: subject) + end + end + end diff --git a/app/models/annotation.rb b/app/models/annotation.rb index 3552a246f5..644ea4c65a 100644 --- a/app/models/annotation.rb +++ b/app/models/annotation.rb @@ -5,8 +5,8 @@ # Table name: annotations # # id :integer not null, primary key -# text :text -# type :integer default(0), not null +# text :text(65535) +# type :integer default("example_answer"), not null # created_at :datetime # updated_at :datetime # org_id :integer @@ -19,11 +19,6 @@ # index_annotations_on_question_id (question_id) # index_annotations_on_versionable_id (versionable_id) # -# Foreign Keys -# -# fk_rails_... (org_id => orgs.id) -# fk_rails_... (question_id => questions.id) -# class Annotation < ApplicationRecord diff --git a/app/models/answer.rb b/app/models/answer.rb index c994391b04..eb9ccfac61 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -6,7 +6,7 @@ # # id :integer not null, primary key # lock_version :integer default(0) -# text :text +# text :text(65535) # created_at :datetime # updated_at :datetime # plan_id :integer diff --git a/app/models/api_client.rb b/app/models/api_client.rb index ad521b497c..3ae79dfa54 100644 --- a/app/models/api_client.rb +++ b/app/models/api_client.rb @@ -2,46 +2,72 @@ # == Schema Information # -# Table name: api_clients +# Table name: oauth_applications # -# id :integer not null, primary key -# name :string, not null -# homepage :string -# contact_name :string -# contact_email :string, not null -# client_id :string, not null -# client_secret :string, not null -# last_access :datetime -# created_at :datetime -# updated_at :datetime -# org_id :integer +# id :integer not null, primary key +# callback_method :integer default(0) +# callback_uri :string(255) +# confidential :boolean default(TRUE) +# contact_email :string(255) +# contact_name :string(255) +# description :string(255) +# homepage :string(255) +# last_access :datetime +# logo_name :string(255) +# logo_uid :string(255) +# name :string(255) not null +# redirect_uri :text(65535) +# scopes :string(255) default(""), not null +# secret :string(255) default(""), not null +# trusted :boolean default(FALSE), not null +# uid :string(255) default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# org_id :integer +# user_id :bigint(8) # # Indexes # -# index_api_clients_on_name (name) +# index_oauth_applications_on_name (name) +# index_oauth_applications_on_uid (uid) UNIQUE +# index_oauth_applications_on_user_id (user_id) # -# Foreign Keys -# -# fk_rails_... (org_id => orgs.id) class ApiClient < ApplicationRecord + self.table_name = "oauth_applications" + include DeviseInvitable::Inviter + include Subscribable + include ::Doorkeeper::Orm::ActiveRecord::Mixins::Application + include ::Doorkeeper::Models::Scopes + extend Dragonfly::Model::Validations extend UniqueRandom + # Allows an ApiClient to invite a new user via the 'create_dmps' scope + devise :invitable + + enum callback_methods: %i[put post patch] + + LOGO_FORMATS = %w[jpeg png gif jpg bmp svg].freeze + + dragonfly_accessor :logo + # ================ # = Associations = # ================ belongs_to :org, optional: true - has_many :plans + # TODO: Make this required once we've transitioned away from the old :contact_name + :contact_email + belongs_to :user, optional: true - # If the Client_id or client_secret are nil generate them - attribute :client_id, :string, default: -> { unique_random(field_name: "client_id") } - attribute :client_secret, :string, - default: -> { unique_random(field_name: "client_secret") } + # Access Tokens are created when an ApiClient authenticates themselves and is then used instead + # of credentials when calling the API. + has_many :access_tokens, class_name: "::Doorkeeper::AccessToken", + foreign_key: :application_id, + dependent: :delete_all # =============== # = Validations = @@ -54,8 +80,26 @@ class ApiClient < ApplicationRecord validates :contact_email, presence: { message: PRESENCE_MESSAGE }, email: { allow_nil: false } - validates :client_id, presence: { message: PRESENCE_MESSAGE } - validates :client_secret, presence: { message: PRESENCE_MESSAGE } + validates_property :format, of: :logo, in: LOGO_FORMATS, + message: _("must be one of the following formats: %{formats}") % { + formats: LOGO_FORMATS.join(", ") + } + + validates_size_of :logo, maximum: 500.kilobytes, message: _("can't be larger than 500KB") + + # ============= + # = Callbacks = + # ============= + + before_validation :ensure_scopes + + # ================= + # = Compatibility = + # ================= + + # These aliases provide backward compatibility for API V1 + alias_attribute :client_id, :uid + alias_attribute :client_secret, :secret # ========================= # = Custom Accessor Logic = @@ -76,15 +120,26 @@ def to_s name end - # Verify that the incoming secret matches - def authenticate(secret:) - client_secret == secret + # Returns the scopes defined in the Doorkeeper config + def available_scopes + (default_scopes << Doorkeeper.config.optional_scopes.to_a).flatten.uniq + end + + # Shortcut to fetch all of the plans the client subscribes to + def plans + subscriptions.map(&:plan) + end + + # Returns the default scopes as defined in the Doorkeeper config + def default_scopes + Doorkeeper.config.default_scopes.to_a end - # Generate UUIDs for the client_id and client_secret - def generate_credentials - self.client_id = ApiClient.unique_random(field_name: "client_id") - self.client_secret = ApiClient.unique_random(field_name: "client_secret") + private + + # Set the scopes + def ensure_scopes + self.scopes = default_scopes.sort { |a, b| a <=> b }.join(" ") unless scopes.present? end end diff --git a/app/models/api_log.rb b/app/models/api_log.rb new file mode 100644 index 0000000000..1141d7ef0c --- /dev/null +++ b/app/models/api_log.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: api_logs +# +# id :integer not null, primary key +# oauth_application_id :integer not null +# change_type :integer not null +# activity :text not null +# logable_id :integer not null +# logable_type :string not null +# +# Indexes +# +# index_api_logs_on_api_client_id (api_client_id) +# index_api_logs_on_change_type (change_type) +# index_api_logs_on_logable_and_change_type (logable_id, logable_type, change_type) +# +class ApiLog < ApplicationRecord + enum change_type: %i[added removed modified] + + # ================ + # = Associations = + # ================ + + belongs_to :logable, polymorphic: true + + belongs_to :api_client + + # =============== + # = Validations = + # =============== + + validates :activity, presence: { message: PRESENCE_MESSAGE } + + validates :change_type, presence: { message: PRESENCE_MESSAGE } + + validates :logable, presence: { message: PRESENCE_MESSAGE } + + validates :api_client, presence: { message: PRESENCE_MESSAGE } +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 0059011d4d..fb6fbc9b8f 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -8,6 +8,33 @@ class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + class << self + + # Indicates whether the underlying DB is MySQL + def mysql_db? + ActiveRecord::Base.connection.adapter_name == "Mysql2" + end + + def postgres_db? + ActiveRecord::Base.connection.adapter_name == "PostgreSQL" + end + + # Generates the appropriate where clause for a JSON field based on the DB type + def safe_json_where_clause(column:, hash_key:) + return "(#{column}->>'#{hash_key}' LIKE ?)" unless mysql_db? + + "(#{column}->>'$.#{hash_key}' LIKE ?)" + end + + # Generates the appropriate where clause for a regular expression based on the DB type + def safe_regexp_where_clause(column:) + return "#{column} ~* ?" unless mysql_db? + + "#{column} REGEXP ?" + end + + end + def sanitize_fields(*attrs) attrs.each do |attr| send("#{attr}=", ActionController::Base.helpers.sanitize(send(attr))) diff --git a/app/models/concerns/acts_as_sortable.rb b/app/models/concerns/acts_as_sortable.rb index 637e50f6df..9e9c47e95d 100644 --- a/app/models/concerns/acts_as_sortable.rb +++ b/app/models/concerns/acts_as_sortable.rb @@ -11,12 +11,10 @@ def update_numbers!(ids, parent:) ids = ids.map(&:to_i) & parent.public_send("#{model_name.singular}_ids") return if ids.empty? - case connection.adapter_name - when "PostgreSQL" then update_numbers_postgresql!(ids) - when "Mysql2" then update_numbers_mysql2!(ids) - else - update_numbers_sequentially!(ids) - end + update_numbers_postgresql!(ids) if ApplicationRecord.postgres_db? + update_numbers_mysql2!(ids) if ApplicationRecord.mysql_db? + update_numbers_sequentially!(ids) unless ApplicationRecord.postgres_db? || + ApplicationRecord.mysql_db? end private diff --git a/app/models/concerns/dmptool_org.rb b/app/models/concerns/dmptool_org.rb new file mode 100644 index 0000000000..2ee658f0f0 --- /dev/null +++ b/app/models/concerns/dmptool_org.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module DmptoolOrg + + extend ActiveSupport::Concern + + included do + # DMPTool participating institution helpers + def self.participating + includes(identifiers: :identifier_scheme).where(managed: true) + end + + def shibbolized? + managed? && identifier_for_scheme(scheme: "shibboleth").present? + end + end + +end diff --git a/app/models/concerns/exportable_plan.rb b/app/models/concerns/exportable_plan.rb index 5d8d66a1b8..6a90876030 100644 --- a/app/models/concerns/exportable_plan.rb +++ b/app/models/concerns/exportable_plan.rb @@ -95,15 +95,10 @@ def prepare(user, coversheet = false) # rubocop:disable Metrics/AbcSize def prepare_coversheet hash = {} - # name of owner and any co-owners - attribution = owner.present? ? [owner.name(false)] : [] - roles.administrator.not_creator.each do |role| - attribution << role.user.name(false) - end hash[:attribution] = attribution # Org name of plan owner's org - hash[:affiliation] = owner.present? ? owner.org.name : "" + hash[:affiliation] = (owner.present? && owner.org.present?) ? owner.org.name : "" # set the funder name hash[:funder] = funder.name if funder.present? @@ -127,11 +122,7 @@ def prepare_coversheet # rubocop:disable Metrics/MethodLength, Metrics/AbcSize def prepare_coversheet_for_csv(csv, _headings, hash) - csv << [if hash[:attribution].many? - _("Creators: ") - else - _("Creator:") - end, _("%{authors}") % { authors: hash[:attribution].join(", ") }] + csv << [_("Creator:"), _("%{authors}") % { authors: hash[:attribution].join(", ") }] csv << ["Affiliation: ", _("%{affiliation}") % { affiliation: hash[:affiliation] }] csv << if hash[:funder].present? [_("Template: "), _("%{funder}") % { funder: hash[:funder] }] @@ -223,5 +214,23 @@ def sanitize_text(text) ActionView::Base.full_sanitizer.sanitize(text.to_s.gsub(/ /i, "")) end + # Use the name of the DMP owner/creator OR the first Co-owner if there is no + # owner for some reason + def attribution + user = roles.creator.first&.user + user = roles.administrator.not_creator.first&.user unless user.present? + return "" unless user.present? + + text = user&.name(false) + orcid = user.identifier_for_scheme(scheme: "orcid") + if orcid.present? + text += " - ORCID: %{orcid}" % { + orcid_url: orcid.value, + orcid: orcid.value_without_scheme_prefix + } + end + text + end + end # rubocop:enable Metrics/ModuleLength diff --git a/app/models/concerns/identifiable.rb b/app/models/concerns/identifiable.rb index 3aea3fc227..aa7305bd60 100644 --- a/app/models/concerns/identifiable.rb +++ b/app/models/concerns/identifiable.rb @@ -6,6 +6,7 @@ module Identifiable # rubocop:disable Metrics/BlockLength included do + # ================ # = Associations = # ================ diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb new file mode 100644 index 0000000000..64a593c771 --- /dev/null +++ b/app/models/concerns/subscribable.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Subscribable + + extend ActiveSupport::Concern + + included do + # ================ + # = Associations = + # ================ + + has_many :subscriptions, as: :subscriber, dependent: :destroy + + # ===================== + # = Nested Attributes = + # ===================== + + accepts_nested_attributes_for :subscriptions + + # ==================== + # = Instance Methods = + # ==================== + + # Returns the Subscription for the specified subscriber or nil if none exists + def subscriptions_for(plan:) + plan = plan.is_a?(Plan) ? plan.id : plan + subscriptions.select { |subscription| subscription.plan_id == plan } + end + end + +end diff --git a/app/models/condition.rb b/app/models/condition.rb index 6fd4859b6d..2f0d438659 100644 --- a/app/models/condition.rb +++ b/app/models/condition.rb @@ -4,15 +4,15 @@ # # Table name: conditions # -# id :integer not null, primary key -# question_id :integer -# number :integer -# action_type :integer -# option_list :text -# remove_data :text -# webhook_data :text -# created_at :datetime not null -# updated_at :datetime not null +# id :integer not null, primary key +# action_type :integer +# number :integer +# option_list :text(65535) +# remove_data :text(65535) +# webhook_data :text(65535) +# created_at :datetime not null +# updated_at :datetime not null +# question_id :integer # # Indexes # @@ -20,8 +20,7 @@ # # Foreign Keys # -# fk_rails_... (question_id => question.id) -# +# fk_rails_... (question_id => questions.id) # class Condition < ApplicationRecord diff --git a/app/models/contributor.rb b/app/models/contributor.rb index f177935ad9..d55506dec7 100644 --- a/app/models/contributor.rb +++ b/app/models/contributor.rb @@ -4,27 +4,23 @@ # # Table name: contributors # -# id :integer not null, primary key -# firstname :string -# surname :string -# email :string -# phone :string -# roles :integer -# org_id :integer -# plan_id :integer -# created_at :datetime -# updated_at :datetime +# id :integer not null, primary key +# email :string(255) +# name :string(255) +# phone :string(255) +# roles :integer not null +# created_at :datetime +# updated_at :datetime +# org_id :integer +# plan_id :integer not null # # Indexes # -# index_contributors_on_id (id) -# index_contributors_on_email (email) -# index_contributors_on_org_id (org_id) +# index_contributors_on_email (email) +# index_contributors_on_org_id (org_id) +# index_contributors_on_plan_id (plan_id) +# index_contributors_on_roles (roles) # -# Foreign Keys -# -# fk_rails_... (org_id => orgs.id) -# fk_rails_... (plan_id => plans.id) class Contributor < ApplicationRecord @@ -38,7 +34,7 @@ class Contributor < ApplicationRecord belongs_to :org, optional: true - belongs_to :plan, optional: true + belongs_to :plan, optional: true, touch: true # ===================== # = Nested attributes = @@ -51,10 +47,14 @@ class Contributor < ApplicationRecord # =============== validates :roles, presence: { message: PRESENCE_MESSAGE } + validates :roles, numericality: { greater_than: 0, + message: _("You must specify at least one role.") } validates :roles, numericality: { greater_than: 0, message: _("You must specify at least one role.") } + validates :email, uniqueness: { scope: :plan_id } + validate :name_or_email_presence ONTOLOGY_NAME = "CRediT - Contributor Roles Taxonomy" diff --git a/app/models/department.rb b/app/models/department.rb index 8733cf0174..70166b691d 100644 --- a/app/models/department.rb +++ b/app/models/department.rb @@ -5,8 +5,8 @@ # Table name: departments # # id :integer not null, primary key -# code :string -# name :string +# code :string(255) +# name :string(255) # created_at :datetime not null # updated_at :datetime not null # org_id :integer diff --git a/app/models/exported_plan.rb b/app/models/exported_plan.rb index f26d2b140b..a1614a3e4a 100644 --- a/app/models/exported_plan.rb +++ b/app/models/exported_plan.rb @@ -5,7 +5,7 @@ # Table name: exported_plans # # id :integer not null, primary key -# format :string +# format :string(255) # created_at :datetime not null # updated_at :datetime not null # phase_id :integer @@ -159,14 +159,14 @@ def as_csv(sections, unanswered_questions, question_headings) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def as_txt(sections, unanswered_questions, question_headings, details) output = "#{plan.title}\n\n#{plan.template.title}\n" - output += "\n" + _("Details") + "\n\n" + output += "\n#{_("Details")}\n\n" if details admin_details.each do |at| value = send(at) output += if value.present? - admin_field_t(at.to_s) + ": " + value + "\n" + "#{admin_field_t(at.to_s)}: #{value}\n" else - admin_field_t(at.to_s) + ": " + _("-") + "\n" + "#{admin_field_t(at.to_s)}: -\n" end end end @@ -183,7 +183,7 @@ def as_txt(sections, unanswered_questions, question_headings, details) output += "\n* #{qtext}" end if answer.nil? - output += _("Question not answered.") + "\n" + output += "#{_("Question not answered.")}\n" else q_format = question.question_format if q_format.option_based? diff --git a/app/models/external_api_access_token.rb b/app/models/external_api_access_token.rb new file mode 100644 index 0000000000..0ba441e679 --- /dev/null +++ b/app/models/external_api_access_token.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: external_api_access_tokens +# +# id :bigint(8) not null, primary key +# access_token :string(255) not null +# expires_at :datetime +# external_service_name :string(255) not null +# refresh_token :string(255) +# revoked_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# user_id :bigint(8) not null +# +# Indexes +# +# index_external_api_access_tokens_on_expires_at (expires_at) +# index_external_api_access_tokens_on_external_service_name (external_service_name) +# index_external_api_access_tokens_on_user_id (user_id) +# index_external_tokens_on_user_and_service (user_id,external_service_name) +# + +class ExternalApiAccessToken < ApplicationRecord + # This class works in conjunction with Devise OmniAuth providers. If a provider returns an + # acess token along with the :uid, then the access token gets stored in this table. It expects + # the following to be passed back as part of the "omniauth.auth" response: + # + # "credentials": { + # "token": "c805b0b6-d66f-46ed-b2f2-250b7610c78b", + # "refresh_token": "6de08c52-74dd-4a7b-aae7-c2a6795dbb3d", + # "expires_at": 2250680850, + # "expires": true + # } + # + # Note that the app/controllers/users/omniauth_callbacks_controller.rb creates these records. They + # are 'revoked' when the User 'disconnects' themselves from the integration on their Profile page. + # + # The lib/tasks/utils/housekeeping.rb has a task called "cleanup_external_api_access_tokens" that + # will delete any revoked or expired tokens + # + + include ValidationMessages + + # ================ + # = Associations = + # ================ + + belongs_to :user + + # =============== + # = Validations = + # =============== + + validates :user, :external_service_name, :access_token, presence: { message: PRESENCE_MESSAGE } + + # A User may only have one active token per external service! + validate :one_active_token, on: %i[create] + + # ================= + # = Class Methods = + # ================= + + class << self + + # Fetched the active access token for the specified User and External API service + def for_user_and_service(user:, service:) + where(user: user, external_service_name: service) + .where("revoked_at IS NULL OR revoked_at > ?", Time.now) + .where("expires_at IS NULL OR expires_at > ?", Time.now) + .first + end + + # Generates an instance based on the contents of an OmniAuth hash + def from_omniauth(user:, service:, hash:) + return nil unless user.is_a?(User) && + service.present? && + hash.present? + + token_hash = hash.fetch(:credentials, {}) + return nil unless token_hash[:token].present? + + # revoke any existing tokens for the user + scheme + where(user: user, external_service_name: service.downcase).each(&:revoke!) + + # add the token for the user + scheme + expiry_time = (Time.now + token_hash[:expires_at].to_i.seconds).utc if token_hash[:expires_at].present? + new( + user: user, + external_service_name: service.downcase, + access_token: token_hash[:token], + refresh_token: token_hash[:refresh_token], + expires_at: expiry_time + ) + end + + end + + # ==================== + # = Instance Methods = + # ==================== + + def revoke! + update(revoked_at: Time.now) + end + + def active? + (revoked_at.nil? || revoked_at > Time.now) && expires_at > Time.now + end + + private + + # Validator to prevent multiple active access tokens for a user + service + def one_active_token + return true if self.class.for_user_and_service(user: user, service: external_service_name).nil? + + errors.add(:access_token, _("only one active access token allowed per user / service")) + end + +end diff --git a/app/models/guidance.rb b/app/models/guidance.rb index 9443e522c0..493c6a4615 100644 --- a/app/models/guidance.rb +++ b/app/models/guidance.rb @@ -10,7 +10,7 @@ # # id :integer not null, primary key # published :boolean -# text :text +# text :text(65535) # created_at :datetime not null # updated_at :datetime not null # guidance_group_id :integer diff --git a/app/models/guidance_group.rb b/app/models/guidance_group.rb index c4c2ba04c7..982b8d5dde 100644 --- a/app/models/guidance_group.rb +++ b/app/models/guidance_group.rb @@ -8,8 +8,8 @@ # Table name: guidance_groups # # id :integer not null, primary key -# name :string -# optional_subset :boolean default(FALSE), not null +# name :string(255) +# optional_subset :boolean default(TRUE), not null # published :boolean default(FALSE), not null # created_at :datetime not null # updated_at :datetime not null @@ -26,7 +26,7 @@ class GuidanceGroup < ApplicationRecord - attribute :optional_subset, :boolean, default: true + attribute :optional_subset, :boolean, default: false attribute :published, :boolean, default: false # ================ diff --git a/app/models/identifier.rb b/app/models/identifier.rb index bd300f7004..2d8aa97eed 100644 --- a/app/models/identifier.rb +++ b/app/models/identifier.rb @@ -5,17 +5,19 @@ # Table name: identifiers # # id :integer not null, primary key -# attrs :text -# identifiable_type :string -# value :string not null +# attrs :text(65535) +# identifiable_type :string(255) +# value :string(255) not null # created_at :datetime # updated_at :datetime # identifiable_id :integer -# identifier_scheme_id :integer not null +# identifier_scheme_id :integer # # Indexes # # index_identifiers_on_identifiable_type_and_identifiable_id (identifiable_type,identifiable_id) +# index_identifiers_on_identifier_scheme_id_and_value (identifier_scheme_id,value) +# index_identifiers_on_scheme_and_type_and_id (identifier_scheme_id,identifiable_id,identifiable_type) # class Identifier < ApplicationRecord @@ -23,7 +25,7 @@ class Identifier < ApplicationRecord # = Associations = # ================ - belongs_to :identifiable, polymorphic: true + belongs_to :identifiable, polymorphic: true, touch: true belongs_to :identifier_scheme, optional: true diff --git a/app/models/identifier_scheme.rb b/app/models/identifier_scheme.rb index 3be538fe68..c9aa8b43ba 100644 --- a/app/models/identifier_scheme.rb +++ b/app/models/identifier_scheme.rb @@ -4,20 +4,22 @@ # # Table name: identifier_schemes # -# id :integer not null, primary key -# active :boolean -# description :string -# context :integer -# logo_url :text -# name :string -# user_landing_url :string -# created_at :datetime -# updated_at :datetime +# id :integer not null, primary key +# active :boolean +# context :integer +# description :string(255) +# external_service :string(255) +# identifier_prefix :string(255) +# logo_url :string(255) +# name :string(255) +# created_at :datetime +# updated_at :datetime # class IdentifierScheme < ApplicationRecord include FlagShihTzu + include Subscribable ## # The maximum length for a name @@ -46,11 +48,18 @@ class IdentifierScheme < ApplicationRecord ## # Define Bit Field values for the scheme's context # These are used to determine when and where an identifier scheme is applicable - has_flags 1 => :for_authentication, - 2 => :for_orgs, - 3 => :for_plans, - 4 => :for_users, - 5 => :for_contributors, + # for_authentication => identifies which schemes can be used for user auth + # for_orgs => identifies which ids will be displayed on Org pages + # for_plans => identifies which ids will be displayed on Plans pages + # for_contributors => identifies which ids will be displayed on Contributor pages + # for_identification => identifies which ids are object identifiers (e.g. ROR, ARK, etc.) + has_flags 1 => :for_authentication, + 2 => :for_orgs, + 3 => :for_plans, + 4 => :for_users, + 5 => :for_contributors, + 6 => :for_identification, + 7 => :for_research_outputs, column: "context" # ========================= diff --git a/app/models/language.rb b/app/models/language.rb index a62b03cfd7..91045dd2d1 100644 --- a/app/models/language.rb +++ b/app/models/language.rb @@ -5,10 +5,10 @@ # Table name: languages # # id :integer not null, primary key -# abbreviation :string +# abbreviation :string(255) # default_language :boolean -# description :string -# name :string +# description :string(255) +# name :string(255) # class Language < ApplicationRecord diff --git a/app/models/license.rb b/app/models/license.rb new file mode 100644 index 0000000000..cc8bd067da --- /dev/null +++ b/app/models/license.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: licenses +# +# id :bigint not null, primary key +# deprecated :boolean default(FALSE) +# identifier :string not null +# name :string not null +# osi_approved :boolean default(FALSE) +# uri :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_license_on_identifier_and_criteria (identifier,osi_approved,deprecated) +# index_licenses_on_identifier (identifier) +# index_licenses_on_uri (uri) +# +class License < ApplicationRecord + + # ================ + # = Associations = + # ================ + + has_many :research_outputs + + # ========== + # = Scopes = + # ========== + + scope :selectable, lambda { + where(deprecated: false) + } + + scope :preferred, lambda { + # Fetch the list of preferred license from the config. + preferences = Rails.configuration.x.madmp.preferred_licenses || [] + return selectable unless preferences.is_a?(Array) && preferences.any? + + licenses = preferences.map do |preference| + # If `%{latest}` was specified then grab the most current version + pref = preference.gsub("%{latest}", "[0-9\\.]+$") + where_clause = safe_regexp_where_clause(column: "identifier") + rslts = preference.include?("%{latest}") ? where(where_clause, pref) : where(identifier: pref) + rslts.order(:identifier).last + end + # Remove any preferred licenses that could not be found in the table + licenses.compact + } + +end diff --git a/app/models/metadata_standard.rb b/app/models/metadata_standard.rb new file mode 100644 index 0000000000..d8a3f1e756 --- /dev/null +++ b/app/models/metadata_standard.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: metadata_standards +# +# id :bigint not null, primary key +# description :text +# locations :json +# related_entities :json +# title :string +# uri :string +# created_at :datetime not null +# updated_at :datetime not null +# rdamsc_id :string +# +class MetadataStandard < ApplicationRecord + + # ================ + # = Associations = + # ================ + + has_and_belongs_to_many :research_outputs + + # ========== + # = Scopes = + # ========== + + scope :search, lambda { |term| + term = term.downcase + where("LOWER(title) LIKE ?", "%#{term}%").or(where("LOWER(description) LIKE ?", "%#{term}%")) + } + +end diff --git a/app/models/mime_type.rb b/app/models/mime_type.rb deleted file mode 100644 index 7c35a15b27..0000000000 --- a/app/models/mime_type.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -# == Schema Information -# -# Table name: mime_types -# -# id :bigint not null, primary key -# category :string not null -# description :string not null -# value :string not null -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_mime_types_on_value (value) -# -class MimeType < ApplicationRecord - - include ValidationMessages - - # ================ - # = Associations = - # ================ - - has_many :research_outputs - - # =============== - # = Validations = - # =============== - - validates :category, :description, :value, presence: { message: PRESENCE_MESSAGE } - - # ========== - # = Scopes = - # ========== - - # Retrieves the unique list of categories - scope :categories, -> { pluck(:category).uniq.sort { |a, b| a <=> b } } - -end diff --git a/app/models/note.rb b/app/models/note.rb index 6e4b3cffd9..b18a2ff88d 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -7,7 +7,7 @@ # id :integer not null, primary key # archived :boolean default(FALSE), not null # archived_by :integer -# text :text +# text :text(65535) # created_at :datetime # updated_at :datetime # answer_id :integer diff --git a/app/models/notification.rb b/app/models/notification.rb index 4ec71198a9..6e1595857a 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -5,14 +5,14 @@ # Table name: notifications # # id :integer not null, primary key -# body :text +# body :text(65535) # dismissable :boolean +# enabled :boolean default(TRUE) # expires_at :date # level :integer # notification_type :integer # starts_at :date -# title :string -# enabled :boolean +# title :string(255) # created_at :datetime not null # updated_at :datetime not null # diff --git a/app/models/org.rb b/app/models/org.rb index de2526de38..7156809c81 100644 --- a/app/models/org.rb +++ b/app/models/org.rb @@ -15,30 +15,50 @@ # logo_name :string # logo_uid :string # managed :boolean default(FALSE), not null -# name :string +# name :string(255) # org_type :integer default(0), not null -# sort_name :string -# target_url :string +# sort_name :string(255) +# target_url :string(255) +# users_count :integer # created_at :datetime not null # updated_at :datetime not null # language_id :integer # region_id :integer -# managed :boolean default(false), not null +# +# Indexes +# +# fk_rails_5640112cab (language_id) +# fk_rails_5a6adf6bab (region_id) # # Foreign Keys # # fk_rails_... (language_id => languages.id) +# fk_rails_... (region_id => regions.id) # class Org < ApplicationRecord extend FeedbacksHelper include FlagShihTzu include Identifiable + include Subscribable + + # ---------------------------------------- + # Start DMPTool Customization + # ---------------------------------------- + include DmptoolOrg + + # Allows an Org to invite a user via the 'Email template' link on the Templates page + devise :invitable + + has_many :plans_sponsors, dependent: :destroy + # ---------------------------------------- + # End DMPTool Customization + # ---------------------------------------- extend Dragonfly::Model::Validations validates_with OrgLinksValidator - LOGO_FORMATS = %w[jpeg png gif jpg bmp].freeze + LOGO_FORMATS = %w[jpeg png gif jpg bmp svg].freeze HUMANIZED_ATTRIBUTES = { feedback_msg: _("Feedback email message") @@ -120,16 +140,16 @@ class Org < ApplicationRecord validates_property :format, of: :logo, in: LOGO_FORMATS, message: _("must be one of the following formats: " \ - "jpeg, jpg, png, gif, bmp") + "jpeg, jpg, png, gif, bmp svg") validates_size_of :logo, maximum: 500.kilobytes, message: _("can't be larger than 500KB") - # allow validations for logo upload - dragonfly_accessor :logo do - after_assign :resize_image - end + dragonfly_accessor :logo + + validates_property :format, of: :logo, in: ['jpeg', 'png', 'gif', 'jpg', 'bmp', 'svg'], message: _("must be one of the following formats: jpeg, jpg, png, gif, bmp, svg") + validates_size_of :logo, maximum: 500.kilobytes, message: _("can't be larger than 500KB") # ============= # = Callbacks = @@ -203,6 +223,14 @@ def self.default_orgs count(users.id) as user_count") } + # Returns all Org's with a Shibboleth entityID stored in the Identifiers table + # This is used on the app/views/shared/_shib_sign_in_form.html.erb partial which + # is only used if you have `shibboleth.use_filtered_discovery_service` enabled. + scope :shibbolized, lambda { + org_ids = Identifier.by_scheme_name("shibboleth", "Org").pluck(:identifiable_id) + where(managed: true, id: org_ids) + } + # EVALUATE CLASS AND INSTANCE METHODS BELOW # # What do they do? do they do it efficiently, and do we need them? diff --git a/app/models/perm.rb b/app/models/perm.rb index 78462904ed..6c0d288a1a 100644 --- a/app/models/perm.rb +++ b/app/models/perm.rb @@ -5,7 +5,7 @@ # Table name: perms # # id :integer not null, primary key -# name :string +# name :string(255) # created_at :datetime not null # updated_at :datetime not null # diff --git a/app/models/phase.rb b/app/models/phase.rb index f45e2294fc..5e3879aa62 100644 --- a/app/models/phase.rb +++ b/app/models/phase.rb @@ -5,10 +5,10 @@ # Table name: phases # # id :integer not null, primary key -# description :text +# description :text(65535) # modifiable :boolean # number :integer -# title :string +# title :string(255) # created_at :datetime # updated_at :datetime # template_id :integer diff --git a/app/models/plan.rb b/app/models/plan.rb index ddbf275cd8..fe131ece37 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -16,10 +16,9 @@ # visibility :integer default(3), not null # created_at :datetime # updated_at :datetime -# template_id :integer -# org_id :integer # funder_id :integer # grant_id :integer +# api_client_id :integer # research_domain_id :bigint # funding_status :integer # ethical_issues :boolean @@ -28,18 +27,19 @@ # # Indexes # -# index_plans_on_template_id (template_id) -# index_plans_on_funder_id (funder_id) -# index_plans_on_grant_id (grant_id) -# index_plans_on_api_client_id (api_client_id) +# index_plans_on_funder_id (funder_id) +# index_plans_on_grant_id (grant_id) +# index_plans_on_org_id (org_id) +# index_plans_on_template_id (template_id) # # Foreign Keys # -# fk_rails_... (template_id => templates.id) # fk_rails_... (org_id => orgs.id) +# fk_rails_... (api_client_id => api_clients.id) # fk_rails_... (research_domain_id => research_domains.id) # +# rubocop:disable Metrics/ClassLength class Plan < ApplicationRecord include ConditionalUserMailer @@ -84,6 +84,8 @@ class Plan < ApplicationRecord belongs_to :funder, class_name: "Org", optional: true + belongs_to :api_client, optional: true + belongs_to :research_domain, optional: true has_many :phases, through: :template @@ -111,10 +113,18 @@ class Plan < ApplicationRecord has_and_belongs_to_many :guidance_groups, join_table: :plans_guidance_groups - has_many :exported_plans + has_many :exported_plans, dependent: :destroy has_many :contributors, dependent: :destroy + has_one :grant, as: :identifiable, dependent: :destroy, class_name: "Identifier" + + has_many :research_outputs, dependent: :destroy + + has_many :subscriptions, dependent: :destroy + + has_many :related_identifiers, as: :identifiable, dependent: :destroy + # ===================== # = Nested Attributes = # ===================== @@ -125,6 +135,10 @@ class Plan < ApplicationRecord accepts_nested_attributes_for :contributors + accepts_nested_attributes_for :research_outputs + + accepts_nested_attributes_for :related_identifiers + # =============== # = Validations = # =============== @@ -139,6 +153,13 @@ class Plan < ApplicationRecord validate :end_date_after_start_date + # ============= + # = Callbacks = + # ============= + + after_update :notify_subscribers!, if: :versionable_change? + after_touch :notify_subscribers! + # ========== # = Scopes = # ========== @@ -427,9 +448,7 @@ def latest_update # Returns User # Returns nil def owner - r = roles.select { |rr| rr.active && rr.administrator } - .min { |a, b| a.created_at <=> b.created_at } - r.nil? ? nil : r.user + roles.administrator.where(active: true).order(:created_at).first&.user end # Creates a role for the specified user (will update the user's @@ -568,6 +587,42 @@ def landing_page identifiers.select { |i| %w[doi ark].include?(i.identifier_format) }.first end + # Retrieves the Plan's most recent DOI + def dmp_id + return nil unless Rails.configuration.x.madmp.enable_dmp_id_registration + + id = identifiers.select { |i| i.identifier_scheme == DmpIdService.identifier_scheme } + .last + return id if id.present? + + # This is here in the event that the DmpIdService has changed over time and the + # Plan's DMP ID was generated by an older service + identifiers.select { |i| %w[ark doi].include?(i.identifier_format) }.last + end + + # Returns whether or not minting is allowed for the current plan + def registration_allowed? + orcid_scheme = IdentifierScheme.where(name: "orcid").first + return false unless Rails.configuration.x.madmp.enable_dmp_id_registration && + orcid_scheme.present? + + # The owner must have an orcid, a funder and :visibility_allowed? (aka :complete) + orcid = owner.identifier_for_scheme(scheme: orcid_scheme).present? + visibility_allowed? && orcid.present? && funder.present? + end + + # Returns whether or not minting is allowed for the current plan + def minting_allowed? + orcid_scheme = IdentifierScheme.where(name: "orcid").first + return false unless orcid_scheme.present? + + # The owner must have an orcid and have authorized us to add to their record + orcid = owner.identifier_for_scheme(scheme: orcid_scheme).present? + token = ExternalApiAccessToken.for_user_and_service(user: owner, service: "orcid") + + visibility_allowed? && orcid.present? && token.present? && funder.present? + end + # Since the Grant is not a normal AR association, override the getter and setter def grant Identifier.find_by(id: grant_id) @@ -591,14 +646,106 @@ def grant=(params) self.grant_id = current.id end + # Return the citation for the DMP. For example: + # + # Jane Doe. (2021). "My DMP" [Data Management Plan]. DMPRoadmap. https://doi.org/10.12/a1.b2 + # + def citation + return nil unless owner.present? && dmp_id.is_a?(Identifier) + + # authors = owner_and_coowners.map { |author| author.name(false) } + # .uniq + # .sort { |a, b| a <=> b } + # .join(", ") + # TODO: display all authors once we determine the appropriate way to handle on the ORCID side + authors = owner.name(false) + pub_year = updated_at.strftime("%Y") + app_name = ApplicationService.application_name + link = dmp_id.value + "#{authors}. (#{pub_year}). \"#{title}\" [Data Management Plan]. #{app_name}. #{link}" + end + + # Returns the Subscription for the specified subscriber or nil if none exists + def subscription_for(subscriber:) + subscriptions.select { |subscription| subscription.subscriber == subscriber } + end + + # Helper method to convert related_identifier entries from standard form params into + # RelatedIdentifier objects. + # + # Expecting the hash to look like the following, where the initial key is the + # RelatedIdentifier.id or "0" if its an empty entry or an absurdly long value + # indicating that its a new entry. + # The form's JS makes a copy of the "0" entry and generate a long value for an id + # when the user clicks the '+add a related identifier' link. We need to do this so + # that the user is able to add multiple entries at one time. + # + # { + # "56": { + # "work_type": "software", "value": "https://doi.org/10.48321/D1MP4Z" + # }, + # "0": { + # "work_type": "article", "value": "" + # }, + # "1632773961597": { + # "work_type": "dataset", "value": "http://foo.bar" + # } + # } + def related_identifiers_attributes=(params) + # Remove any that the user may have deleted + related_identifiers.reject { |r_id| params.keys.include?(r_id.id.to_s) } + .each { |r_id| r_id.destroy } + + # Update existing or add new + params.each do |id, related_identifier_hash| + next unless id.present? && id != "0" && related_identifier_hash[:value].present? + + related = RelatedIdentifier.find_by(id: id) + related = RelatedIdentifier.new(identifiable: self) unless related.present? + related.work_type = related_identifier_hash[:work_type] + related.value = related_identifier_hash[:value].strip + related_identifiers << related + end + end + private + # Determines whether or not the attributes that were updated constitute a versionable change + # for example a user requesting feedback will change the :feedback_requested flag but that + # should not create a new version or notify any subscribers! + # + # Note that some associated models :touch the plan when they are updated so that a change + # to a contributor for example will constitute a new version of the plan + # + # TODO: We will likely need to change this or break it up into different methods based + # on the use cases we uncover when deciding how to version plans + def versionable_change? + saved_change_to_title? || saved_change_to_description? || saved_change_to_identifier? || + saved_change_to_visibility? || saved_change_to_complete? || saved_change_to_template_id? || + saved_change_to_org_id? || saved_change_to_funder_id? || saved_change_to_grant_id? || + saved_change_to_start_date? || saved_change_to_end_date? || + saved_change_to_research_domain_id? || saved_change_to_ethical_issues? || + saved_change_to_ethical_issues_description? || saved_change_to_ethical_issues_report? + end + + # Sends notifications to the Subscribers of the specified subscription types + def notify_subscribers!(subscription_types: [:updates]) + targets = subscription_types.map do |typ| + subscriptions.select { |sub| sub.selected_subscription_types.include?(typ.to_sym) } + end + targets = targets.flatten.uniq if targets.any? + targets.each(&:notify!) + true + end + # Validation to prevent end date from coming before the start date def end_date_after_start_date # allow nil values - return true if end_date.blank? || start_date.blank? + return true if end_date.blank? || start_date.blank? || end_date > start_date errors.add(:end_date, _("must be after the start date")) if end_date < start_date + start_date < end_date end end +# rubocop:enable Metrics/ClassLength diff --git a/app/models/pref.rb b/app/models/pref.rb index 7edd20b938..5b7acddf49 100644 --- a/app/models/pref.rb +++ b/app/models/pref.rb @@ -5,7 +5,7 @@ # Table name: prefs # # id :integer not null, primary key -# settings :text +# settings :text(65535) # user_id :integer # diff --git a/app/models/question.rb b/app/models/question.rb index 2deb90a5a1..86ac22ac21 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -5,11 +5,11 @@ # Table name: questions # # id :integer not null, primary key -# default_value :text +# default_value :text(65535) # modifiable :boolean # number :integer # option_comment_display :boolean default(TRUE) -# text :text +# text :text(65535) # created_at :datetime # updated_at :datetime # question_format_id :integer diff --git a/app/models/question_format.rb b/app/models/question_format.rb index 6847309d51..6ce4364635 100644 --- a/app/models/question_format.rb +++ b/app/models/question_format.rb @@ -5,10 +5,10 @@ # Table name: question_formats # # id :integer not null, primary key -# description :text -# formattype :integer default(0) +# description :text(65535) +# formattype :integer default("textarea") # option_based :boolean default(FALSE) -# title :string +# title :string(255) # created_at :datetime not null # updated_at :datetime not null # diff --git a/app/models/question_option.rb b/app/models/question_option.rb index cc0787723e..54c1802995 100644 --- a/app/models/question_option.rb +++ b/app/models/question_option.rb @@ -4,17 +4,19 @@ # # Table name: question_options # -# id :integer not null, primary key -# is_default :boolean -# number :integer -# text :string -# created_at :datetime -# updated_at :datetime -# question_id :integer +# id :integer not null, primary key +# is_default :boolean +# number :integer +# text :string(255) +# created_at :datetime +# updated_at :datetime +# question_id :integer +# versionable_id :string(36) # # Indexes # -# index_question_options_on_question_id (question_id) +# index_question_options_on_question_id (question_id) +# index_question_options_on_versionable_id (versionable_id) # # Foreign Keys # diff --git a/app/models/region.rb b/app/models/region.rb index 3c970f05d2..d1a1e4874b 100644 --- a/app/models/region.rb +++ b/app/models/region.rb @@ -4,12 +4,11 @@ # # Table name: regions # -# id :integer not null, primary key -# abbreviation :string -# description :string -# name :string not null -# created_at :datetime not null -# updated_at :datetime not null +# id :integer not null, primary key +# abbreviation :string(255) +# description :string(255) +# name :string(255) +# super_region_id :integer # class Region < ApplicationRecord diff --git a/app/models/related_identifier.rb b/app/models/related_identifier.rb new file mode 100644 index 0000000000..65d572de38 --- /dev/null +++ b/app/models/related_identifier.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: related_identifiers +# +# id :bigint(8) not null, primary key +# identifiable_type :string(255) +# identifier_type :integer not null +# relation_type :integer not null +# work_type :integer not null +# value :string(255) not null +# citation :text +# created_at :datetime not null +# updated_at :datetime not null +# identifiable_id :bigint(8) +# identifier_scheme_id :bigint(8) +# +# Indexes +# +# index_related_identifiers_on_identifier_scheme_id (identifier_scheme_id) +# index_related_identifiers_on_identifier_type (identifier_type) +# index_related_identifiers_on_relation_type (relation_type) +# index_relateds_on_identifiable_and_relation_type (identifiable_id,identifiable_type,relation_type) +# +class RelatedIdentifier < ApplicationRecord + + include Uc3Citation + + URL_REGEX = %r{^http}.freeze + DOI_REGEX = %r{(doi:)?10\.[0-9]+\/[a-zA-Z0-9\.\-\/]+}.freeze + ARK_REGEX = %r{ark:[a-zA-Z0-9]+\/[a-zA-Z0-9]+}.freeze + + # ================ + # = Associations = + # ================ + + belongs_to :identifiable, polymorphic: true, touch: true + + belongs_to :identifier_scheme, optional: true + + # =============== + # = Validations = + # =============== + + validates :value, presence: { message: PRESENCE_MESSAGE } + + validates :identifiable, presence: { message: PRESENCE_MESSAGE } + + # ========= + # = Enums = + # ========= + + # Broad categories to identify the type of work the related identifier represents + enum work_type: %i[article dataset preprint software supplemental_information paper book] + + # The type of identifier based on the DataCite metadata schema + enum identifier_type: %i[ark arxiv bibcode doi ean13 eissn handle igsn isbn issn istc + lissn lsid pmid purl upc url urn w3id other] + + # The relationship type between the related item and the Plan + # Note that the 'references' value is changed to 'does_reference' in this list + # because 'references' conflicts with an ActiveRecord method + enum relation_type: %i[is_cited_by cites + is_supplement_to is_supplemented_by + is_continued_by continues + is_described_by describes + has_metadata is_metadata_for + has_version is_version_of is_new_version_of is_previous_version_of + is_part_of has_part + is_referenced_by does_reference + is_documented_by documents + is_compiled_by compiles + is_variant_form_of is_original_form_of is_identical_to + is_reviewed_by reviews + is_derived_from is_source_of + is_required_by requires + is_obsoleted_by obsoletes] + + # ============= + # = CALLBACKS = + # ============= + + before_validation :ensure_defaults + + # If we've enabled citation lookups, then try to fetch the citation after its created + # or the value has changed + after_save :load_citation + + # Returns the value sans the identifier scheme's prefix. + # For example: + # value 'https://orcid.org/0000-0000-0000-0001' + # becomes '0000-0000-0000-0001' + def value_without_scheme_prefix + return value unless identifier_scheme.present? && + identifier_scheme.identifier_prefix.present? + + base = identifier_scheme.identifier_prefix + value.gsub(base, "").sub(%r{^/}, "") + end + + private + + def ensure_defaults + self.identifier_type = detect_identifier_type + self.relation_type = detect_relation_type + end + + def detect_identifier_type + return "ark" unless (value =~ ARK_REGEX).nil? + return "doi" unless (value =~ DOI_REGEX).nil? + return "url" unless (value =~ URL_REGEX).nil? + + "other" + end + + def detect_relation_type + relation_type.present? ? relation_type : "cites" + end + + def load_citation + # Only attempt to load the citation if that functionality has been enabled in the + # config, this is a DOI and its either a new record or the value has changed + if Rails.configuration.x.madmp.enable_citation_lookup && identifier_type == "doi" && + citation.nil? + wrk_type = work_type == "supplemental_information" ? "" : work_type + # Use the UC3Citation service to fetch the citation for the DOI + self.citation = fetch_citation(doi: value, work_type: wrk_type) #, debug: true) + end + end + +end diff --git a/app/models/repository.rb b/app/models/repository.rb new file mode 100644 index 0000000000..06ffad7588 --- /dev/null +++ b/app/models/repository.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: repositories +# +# id :bigint not null, primary key +# contact :string +# description :text not null +# info :json +# name :string not null +# homepage :string +# created_at :datetime not null +# updated_at :datetime not null +# uri :string not null +# +# Indexes +# +# index_repositories_on_name (name) +# index_repositories_on_homepage (homepage) +# index_repositories_on_uri (uri) +# + +class Repository < ApplicationRecord + + # ================ + # = Associations = + # ================ + + has_and_belongs_to_many :research_outputs + + # ========== + # = Scopes = + # ========== + + scope :by_type, lambda { |type| + where(safe_json_where_clause(column: "info", hash_key: "types"), "%#{type}%") + } + + scope :by_subject, lambda { |subject| + where(safe_json_where_clause(column: "info", hash_key: "subjects"), "%#{subject}%") + } + + scope :search, lambda { |term| + term = term.downcase + where("LOWER(name) LIKE ?", "%#{term}%") + .or(where(safe_json_where_clause(column: "info", hash_key: "keywords"), "%#{term}%")) + .or(where(safe_json_where_clause(column: "info", hash_key: "subjects"), "%#{term}%")) + } + + # A very specific keyword search (e.g. 'gene', 'DNA', etc.) + scope :by_facet, lambda { |facet| + where(safe_json_where_clause(column: "info", hash_key: "keywords"), "%#{facet}%") + } + +end diff --git a/app/models/research_output.rb b/app/models/research_output.rb index 858ef06d10..dbc6730d21 100644 --- a/app/models/research_output.rb +++ b/app/models/research_output.rb @@ -6,26 +6,31 @@ # # id :bigint not null, primary key # abbreviation :string -# access :integer default(0), not null +# access :integer default("open"), not null # byte_size :bigint # description :text # display_order :integer -# is_default :boolean default("false") -# output_type :integer default(3), not null +# is_default :boolean +# output_type :integer default("dataset"), not null # output_type_description :string # personal_data :boolean # release_date :datetime # sensitive_data :boolean -# title :string not null +# title :string(255) not null # created_at :datetime not null # updated_at :datetime not null -# mime_type_id :integer +# license_id :bigint # plan_id :integer # # Indexes # +# index_research_outputs_on_license_id (license_id) # index_research_outputs_on_output_type (output_type) -# index_research_outputs_on_plan_id (plan_id) +# +# Foreign Keys +# +# fk_rails_... (plan_id => plans.id) +# fk_rails_... (license_id => licenses.id) # class ResearchOutput < ApplicationRecord @@ -42,14 +47,22 @@ class ResearchOutput < ApplicationRecord # = Associations = # ================ - belongs_to :plan, optional: true + belongs_to :plan, optional: true, touch: true + belongs_to :license, optional: true + + has_and_belongs_to_many :metadata_standards + has_and_belongs_to_many :repositories # =============== # = Validations = # =============== validates_presence_of :output_type, :access, :title, message: PRESENCE_MESSAGE - validates_uniqueness_of :title, :abbreviation, scope: :plan_id + validates_uniqueness_of :title, { case_sensitive: false, scope: :plan_id, + message: UNIQUENESS_MESSAGE } + validates_uniqueness_of :abbreviation, { case_sensitive: false, scope: :plan_id, + allow_nil: true, allow_blank: true, + message: UNIQUENESS_MESSAGE } # Ensure presence of the :output_type_description if the user selected 'other' validates_presence_of :output_type_description, if: -> { other? }, message: PRESENCE_MESSAGE @@ -58,37 +71,18 @@ class ResearchOutput < ApplicationRecord # = Instance methods = # ==================== - # TODO: placeholders for once the License, Repository, Metadata Standard and - # Resource Type Lookups feature is built. - # - # Be sure to add the scheme in the appropriate upgrade task (and to the - # seed.rb as well) - def licenses - # scheme = IdentifierScheme.find_by(name: '[name of license scheme]') - # return [] unless scheme.present? - # identifiers.select { |id| id.identifier_scheme = scheme } - [] - end - - def repositories - # scheme = IdentifierScheme.find_by(name: '[name of repository scheme]') - # return [] unless scheme.present? - # identifiers.select { |id| id.identifier_scheme = scheme } - [] - end - - def metadata_standards - # scheme = IdentifierScheme.find_by(name: '[name of openaire scheme]') - # return [] unless scheme.present? - # identifiers.select { |id| id.identifier_scheme = scheme } - [] + # Helper method to convert selected repository form params into Repository objects + def repositories_attributes=(params) + params.each do |_i, repository_params| + repositories << Repository.find_by(id: repository_params[:id]) + end end - def resource_types - # scheme = IdentifierScheme.find_by(name: '[name of resource_type scheme]') - # return [] unless scheme.present? - # identifiers.select { |id| id.identifier_scheme = scheme } - [] + # Helper method to convert selected metadata standard form params into MetadataStandard objects + def metadata_standards_attributes=(params) + params.each do |_i, metadata_standard_params| + metadata_standards << MetadataStandard.find_by(id: metadata_standard_params[:id]) + end end end diff --git a/app/models/section.rb b/app/models/section.rb index 4017e7438b..62f6661826 100644 --- a/app/models/section.rb +++ b/app/models/section.rb @@ -5,10 +5,10 @@ # Table name: sections # # id :integer not null, primary key -# description :text +# description :text(65535) # modifiable :boolean # number :integer -# title :string +# title :string(255) # created_at :datetime # updated_at :datetime # phase_id :integer diff --git a/app/models/settings/template.rb b/app/models/settings/template.rb index 2a05c58b64..f9f331e086 100644 --- a/app/models/settings/template.rb +++ b/app/models/settings/template.rb @@ -5,9 +5,9 @@ # Table name: settings # # id :integer not null, primary key -# target_type :string not null -# value :text -# var :string not null +# target_type :string(255) +# value :text(65535) +# var :string(255) # created_at :datetime not null # updated_at :datetime not null # target_id :integer not null @@ -31,13 +31,35 @@ class Template < RailsSettings::SettingObject VALID_FORMATS = %w[csv html pdf text docx json].freeze + # ================================= + # Start DMPTool Customization + # Update margins to 25mm default + # ================================= + # DEFAULT_SETTINGS = { + # formatting: { + # margin: { + # top: 25, + # bottom: 20, + # left: 12, + # right: 12 + # }, + # font_face: 'Arial, Helvetica, Sans-Serif', + # font_size: 10 # pt + # }, + # max_pages: 3, + # fields: { + # admin: VALID_ADMIN_FIELDS, + # questions: :all + # }, + # title: "" + # } DEFAULT_SETTINGS = { formatting: { margin: { top: 25, - bottom: 20, - left: 12, - right: 12 + bottom: 25, + left: 25, + right: 25 }, font_face: "Arial, Helvetica, Sans-Serif", font_size: 10 # pt @@ -49,6 +71,9 @@ class Template < RailsSettings::SettingObject }, title: "" }.freeze + # ================================= + # End DMPTool Customization + # ================================= # rubocop:disable Metrics/BlockLength, Metrics/BlockNesting validate do diff --git a/app/models/stat.rb b/app/models/stat.rb index 5f16bae257..a5217e186a 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -7,9 +7,9 @@ # id :integer not null, primary key # count :bigint(8) default(0) # date :date not null -# details :text +# details :text(65535) # filtered :boolean default(FALSE) -# type :string not null +# type :string(255) not null # created_at :datetime not null # updated_at :datetime not null # org_id :integer diff --git a/app/models/stat_created_plan.rb b/app/models/stat_created_plan.rb index 0bd760bdbe..8df188164d 100644 --- a/app/models/stat_created_plan.rb +++ b/app/models/stat_created_plan.rb @@ -7,9 +7,9 @@ # id :integer not null, primary key # count :bigint(8) default(0) # date :date not null -# details :text +# details :text(65535) # filtered :boolean default(FALSE) -# type :string not null +# type :string(255) not null # created_at :datetime not null # updated_at :datetime not null # org_id :integer diff --git a/app/models/stat_exported_plan.rb b/app/models/stat_exported_plan.rb index 2f9e518cf8..be7dfcf60f 100644 --- a/app/models/stat_exported_plan.rb +++ b/app/models/stat_exported_plan.rb @@ -7,9 +7,9 @@ # id :integer not null, primary key # count :bigint(8) default(0) # date :date not null -# details :text +# details :text(65535) # filtered :boolean default(FALSE) -# type :string not null +# type :string(255) not null # created_at :datetime not null # updated_at :datetime not null # org_id :integer diff --git a/app/models/stat_joined_user.rb b/app/models/stat_joined_user.rb index 2910e8b476..65dbd3dd0d 100644 --- a/app/models/stat_joined_user.rb +++ b/app/models/stat_joined_user.rb @@ -7,15 +7,16 @@ # id :integer not null, primary key # count :bigint(8) default(0) # date :date not null -# details :text +# details :text(65535) # filtered :boolean default(FALSE) -# type :string not null +# type :string(255) not null # created_at :datetime not null # updated_at :datetime not null # org_id :integer # class StatJoinedUser < Stat + extend OrgDateRangeable class << self diff --git a/app/models/stat_shared_plan.rb b/app/models/stat_shared_plan.rb index 96230bab20..2b6504b2ba 100644 --- a/app/models/stat_shared_plan.rb +++ b/app/models/stat_shared_plan.rb @@ -7,9 +7,9 @@ # id :integer not null, primary key # count :bigint(8) default(0) # date :date not null -# details :text +# details :text(65535) # filtered :boolean default(FALSE) -# type :string not null +# type :string(255) not null # created_at :datetime not null # updated_at :datetime not null # org_id :integer diff --git a/app/models/subscription.rb b/app/models/subscription.rb new file mode 100644 index 0000000000..6e45680e6a --- /dev/null +++ b/app/models/subscription.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: subscriptions +# +# id :bigint not null, primary key +# callback_uri :string +# last_notified :datetime +# subscriber_type :string +# subscription_types :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# plan_id :bigint +# subscriber_id :bigint +# +# Indexes +# +# index_subscribers_on_identifiable_and_plan_id (subscriber_id,subscriber_type,plan_id) +# index_subscriptions_on_plan_id (plan_id) +# +class Subscription < ApplicationRecord + + include FlagShihTzu + + # ================ + # = Associations = + # ================ + + belongs_to :plan + belongs_to :subscriber, polymorphic: true + + ## + # Define Bit Field values for subscription_types + has_flags 1 => :updates, + 2 => :deletions, + 3 => :creations, + column: "subscription_types" + + # ==================== + # = Instance Methods = + # ==================== + + def notify! + # Do not notify anyone if this is a new record + return false if new_record? + # Do not notify if there is no callback or they've already been notified + return false unless callback_uri.present? && + (last_notified.nil? || last_notified < plan.updated_at) + + NotifySubscriberJob.perform_later(self) + true + end + +end diff --git a/app/models/template.rb b/app/models/template.rb index fb56f8591f..8a7768e0ee 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -7,12 +7,12 @@ # id :integer not null, primary key # archived :boolean # customization_of :integer -# description :text +# description :text(65535) # is_default :boolean -# links :text -# locale :string +# links :text(65535) +# locale :string(255) # published :boolean -# title :string +# title :string(255) # version :integer # visibility :integer # created_at :datetime @@ -85,6 +85,15 @@ class Template < ApplicationRecord has_many :conditions, through: :questions + # ---------------------------------------- + # Start DMPTool Customization + # ---------------------------------------- + has_one :sponsor, class_name: 'Org', foreign_key: 'id', primary_key: 'sponsor_id', + required: false + # ---------------------------------------- + # End DMPTool Customization + # ---------------------------------------- + # =============== # = Validations = # =============== diff --git a/app/models/theme.rb b/app/models/theme.rb index 3764ee558d..d8ed938eda 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -5,9 +5,9 @@ # Table name: themes # # id :integer not null, primary key -# description :text -# locale :string -# title :string +# description :text(65535) +# locale :string(255) +# title :string(255) # created_at :datetime not null # updated_at :datetime not null # diff --git a/app/models/token_permission_type.rb b/app/models/token_permission_type.rb index 57b558f81f..ff40c1be85 100644 --- a/app/models/token_permission_type.rb +++ b/app/models/token_permission_type.rb @@ -5,8 +5,8 @@ # Table name: token_permission_types # # id :integer not null, primary key -# text_description :text -# token_type :string +# text_description :text(65535) +# token_type :string(255) # created_at :datetime # updated_at :datetime # diff --git a/app/models/tracker.rb b/app/models/tracker.rb index 39628c5389..4726b4d88f 100644 --- a/app/models/tracker.rb +++ b/app/models/tracker.rb @@ -1,5 +1,23 @@ # frozen_string_literal: true +# == Schema Information +# +# Table name: trackers +# +# id :integer not null, primary key +# code :string(255) +# created_at :datetime not null +# updated_at :datetime not null +# org_id :integer +# +# Indexes +# +# index_trackers_on_org_id (org_id) +# +# Foreign Keys +# +# fk_rails_... (org_id => orgs.id) +# class Tracker < ApplicationRecord belongs_to :org diff --git a/app/models/user.rb b/app/models/user.rb index ff4ac5043a..d178bcde2c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -7,29 +7,30 @@ # id :integer not null, primary key # accept_terms :boolean # active :boolean default(TRUE) -# api_token :string +# api_token :string(255) # confirmation_sent_at :datetime -# confirmation_token :string +# confirmation_token :string(255) # confirmed_at :datetime # current_sign_in_at :datetime -# current_sign_in_ip :string +# current_sign_in_ip :string(255) # email :string(80) default(""), not null -# encrypted_password :string -# firstname :string +# encrypted_password :string(255) +# firstname :string(255) # invitation_accepted_at :datetime # invitation_created_at :datetime # invitation_sent_at :datetime -# invitation_token :string -# invited_by_type :string +# invitation_token :string(255) +# invited_by_type :string(255) +# last_api_access :datetime # last_sign_in_at :datetime # last_sign_in_ip :string # other_organisation :string # recovery_email :string # remember_created_at :datetime # reset_password_sent_at :datetime -# reset_password_token :string +# reset_password_token :string(255) # sign_in_count :integer default(0) -# surname :string +# surname :string(255) # created_at :datetime not null # updated_at :datetime not null # department_id :integer @@ -101,6 +102,29 @@ class User < ApplicationRecord has_and_belongs_to_many :notifications, dependent: :destroy, join_table: "notification_acknowledgements" + # ================================ + # = Dookeeper OAuth Associations = + # ================================ + + # Access Grants are created when a user authorizes an ApiClient to access their data via the + # OAuth workflow. They are sent back to the ApiClient as 'code' which is in turn used to + # retrieve an AccessToken + has_many :access_grants, class_name: 'Doorkeeper::AccessGrant', + foreign_key: :resource_owner_id, + dependent: :delete_all + + # Access Tokens are created when an ApiClient authenticates a User via an access grant code. + # The access token is then used instead of credentials in calls to the API. These tokens can be revoked + # by a user on their profile page. + has_many :access_tokens, class_name: 'Doorkeeper::AccessToken', + foreign_key: :resource_owner_id, + dependent: :delete_all + + # Table that stores OAuth access tokens for other external systems like ORCID + has_many :external_api_access_tokens, dependent: :destroy + accepts_nested_attributes_for :external_api_access_tokens + accepts_nested_attributes_for :plans + # =============== # = Validations = # =============== @@ -141,7 +165,7 @@ class User < ApplicationRecord # MySQL does not support standard string concatenation and since concat_ws # or concat functions do not exist for sqlite, we have to come up with this # conditional - if ActiveRecord::Base.connection.adapter_name == "Mysql2" + if mysql_db? where("lower(concat_ws(' ', firstname, surname)) LIKE lower(?) OR " \ "lower(email) LIKE lower(?)", search_pattern, search_pattern) @@ -184,6 +208,7 @@ def self.from_omniauth(auth) def self.to_csv(users) User::AtCsv.new(users).to_csv end + # =========================== # = Public instance methods = # =========================== @@ -375,8 +400,9 @@ def get_preferences(key) end # rubocop:enable Metrics/AbcSize - # Override devise_invitable email title + # Override to Devise invitation emails def deliver_invitation(options = {}) + # Always override the devise_invitable email title super(options.merge(subject: _("A Data Management Plan in " \ "%{application_name} has been shared with you") % { application_name: ApplicationService.application_name }) @@ -450,6 +476,16 @@ def merge(to_be_merged) to_be_merged.destroy end + # Fetch the access token for the specified service + def access_token_for(external_service_name:) + return nil unless external_service_name.present? && external_api_access_tokens.any? + + tokens = external_api_access_tokens.select do |token| + token.external_service_name == external_service_name && token.active? + end + tokens.first + end + private # ============================ diff --git a/app/policies/api/v2/plans_policy.rb b/app/policies/api/v2/plans_policy.rb new file mode 100644 index 0000000000..1a7b6fcee7 --- /dev/null +++ b/app/policies/api/v2/plans_policy.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Api + + module V2 + + class PlansPolicy < ApplicationPolicy + + attr_reader :client, :plan + + def initialize(client, resource_owner, plan) + @client = client + @resource_owner = resource_owner + @plan = plan + end + + class Scope + + attr_reader :client + + def initialize(client, resource_owner, result_scope) + @client = client + @resource_owner = resource_owner + @result_scope = result_scope + end + + ## Return the visible plans (via the API) to a given client depending on the context + # + # If @resource_owner is present then this is a request on behalf of a User + # - return the Plans specific for the User (resource_owner) + # + # If no @resource_owner is present then this is a 'direct' request so adhere to the following rules: + # - ALL can view: public + # - when @client is an ApiClient can view: + # - anything created by the API client + # - anything belonging to the ApiClient's Org (if applicable - api_clients.org_id) + # - when @client is a User can view: + # - (when a non-admin) any privately_visible or organisationally_visible Plans + # - (when an admin) all Plans from users of their organisation + # + def resolve + return plans_for_public if @result_scope == "public" + + # If this is a :trusted ApiClient then return all plans + return Plan.where.not(visibility: Plan.visibilities[:is_test]) if @client.trusted? + + # If the caller specified that they want both public and user plans + public_plans = @result_scope == "both" ? plans_for_public : [] + + # If the resource_owner is present then return their specific Plans + plans = plans_for_user(user: @resource_owner, complete: true, mine: true) if @resource_owner.present? + return (plans + public_plans).flatten.uniq if plans.present? + + # If the Client is an Org Admin then get all of the Org's plans + plans = plans_for_org_admin + plans_for_user(user: @client.user) if @client.user&.can_org_admin? + return (plans + public_plans).flatten.uniq if plans.present? + + # Otherwise just return the User's plans + plans_for_user(user: @client.user, complete: false) + end + + private + + def plans_for_public + plans = Plan.publicly_visible.order(updated_at: :desc) + end + + # Fetch all of the User's Plans + def plans_for_user(user:, complete: false, mine: false) + plans = Plan.active(user) + plans = plans.select { |plan| plan.complete? && !plan.is_test? } if complete + plans += user.org.plans.organisationally_visible unless mine + plans.to_a.flatten.compact.uniq + end + + # Fetch all of the Plans that belong to the Admin's Org + def plans_for_org_admin + # TODO: Update this to use the new method created by @john_pinto + @client.user.can_org_admin? ? Plan.where(org: @client.user.org).reject { |p| p.is_test? } : [] + end + + end + + end + + end + +end diff --git a/app/policies/api/v2/templates_policy.rb b/app/policies/api/v2/templates_policy.rb new file mode 100644 index 0000000000..62d6aad8ef --- /dev/null +++ b/app/policies/api/v2/templates_policy.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Api + + module V2 + + class TemplatesPolicy < ApplicationPolicy + + attr_reader :client, :plan + + def initialize(client, plan) + @client = client + @plan = plan + end + + class Scope + + attr_reader :client + + def initialize(client) + @client = client + end + + ## Return the templates to a given client depending on the context + # + # - ALL can view: public + # - when @client is a User and an Org Admin can view: + # - (when an admin) all Templates for their organisation + # + def resolve + # Only return publicly visible Templates if the caller is an ApiClient + templates = public_templates + return templates unless @client.respond_to?(:user) && @client.user&.org&.present? + + org_templates(templates: templates).flatten.compact.uniq + end + + private + + # Fetch all of the User's Plans + def public_templates + Template.includes(org: :identifiers) + .joins(:org) + .published + .publicly_visible + .where(customization_of: nil) + .order(:title) + end + + # Fetch all of the Org's templates along with their customizations + def org_templates(templates: []) + + org_templates = Template.latest_version_per_org(@client.user.org).published + custs = Template.latest_customized_version_per_org(@client.user.org).published + return (templates + org_templates).sort{ |a, b| a.title <=> b.title } unless custs.any? + + # Remove any templates that were customized by the org, we will use their customization + templates.reject { |t| custs.map { |c| c.customization_of }.include?(t.family_id) } + + (org_templates + custs + templates).sort{ |a, b| a.title <=> b.title } + end + + end + + end + + end + +end diff --git a/app/policies/api_client_policy.rb b/app/policies/api_client_policy.rb index 8334fdee2a..bf241bd053 100644 --- a/app/policies/api_client_policy.rb +++ b/app/policies/api_client_policy.rb @@ -2,10 +2,11 @@ class ApiClientPolicy < ApplicationPolicy - def initialize(user, *_args) + def initialize(user, api_client) raise Pundit::NotAuthorizedError, _("must be logged in") unless user @user = user + @api_client = api_client end def index? @@ -17,7 +18,8 @@ def new? end def create? - @user.can_super_admin? + # Super admin or the user can do this for themselves + @user.can_super_admin? || @user.id == @api_client.user_id end def edit? @@ -25,7 +27,8 @@ def edit? end def update? - @user.can_super_admin? + # Super admin or the user can do this for themselves + @user.can_super_admin? || @user.id == @api_client.user_id end def destroy? @@ -33,7 +36,8 @@ def destroy? end def refresh_credentials? - @user.can_super_admin? + # Super admin or the user can do this for themselves + @user.can_super_admin? || @user.id == @api_client.user_id end def email_credentials? diff --git a/app/policies/org_policy.rb b/app/policies/org_policy.rb index 8a927ced14..a1fe23ea52 100644 --- a/app/policies/org_policy.rb +++ b/app/policies/org_policy.rb @@ -59,4 +59,13 @@ def merge_commit? user.can_super_admin? end +# --------------------------------------------------------- +# Start DMPTool customization +# --------------------------------------------------------- + def public? + true + end +# --------------------------------------------------------- +# End DMPTool customization +# --------------------------------------------------------- end diff --git a/app/policies/plan_policy.rb b/app/policies/plan_policy.rb index 3ea2637022..1d38431e0f 100644 --- a/app/policies/plan_policy.rb +++ b/app/policies/plan_policy.rb @@ -20,7 +20,7 @@ def show? @plan.readable_by?(@user.id) end - def share? + def publish? @plan.editable_by?(@user.id) || (@user.can_org_admin? && @user.org.plans.include?(@plan)) @@ -82,4 +82,12 @@ def update_guidances_list? @plan.editable_by?(@user.id) end + def mint? + @plan.owner == @user || @user.can_super_admin? + end + + def add_orcid_work? + @plan.administerable_by?(@user.id) + end + end diff --git a/app/policies/research_output_policy.rb b/app/policies/research_output_policy.rb new file mode 100644 index 0000000000..8b79ddf0bb --- /dev/null +++ b/app/policies/research_output_policy.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class ResearchOutputPolicy < ApplicationPolicy + + attr_reader :user, :research_output + + def initialize(user, research_output) + raise Pundit::NotAuthorizedError, _("must be logged in") unless user + + unless research_output.present? + raise Pundit::NotAuthorizedError, _("are not authorized to view that plan") + end + + @user = user + @research_output = research_output + super + end + + def index? + @research_output.plan.readable_by?(@user.id) + end + + def new? + @research_output.plan.administerable_by?(@user.id) + end + + def edit? + @research_output.plan.administerable_by?(@user.id) + end + + def create? + @research_output.plan.administerable_by?(@user.id) + end + + def update? + @research_output.plan.administerable_by?(@user.id) + end + + def destroy? + @research_output.plan.administerable_by?(@user.id) + end + + def select_output_type? + @research_output.plan.administerable_by?(@user.id) + end + + def select_license? + @research_output.plan.administerable_by?(@user.id) + end + + def repository_search? + @research_output.plan.administerable_by?(@user.id) + end + + def metadata_standard_search? + @research_output.plan.administerable_by?(@user.id) + end + +end diff --git a/app/policies/template_policy.rb b/app/policies/template_policy.rb index 7a30916a23..546fccb26e 100644 --- a/app/policies/template_policy.rb +++ b/app/policies/template_policy.rb @@ -88,4 +88,14 @@ def template_options? user.present? end + # DMPTool customizations to allow Org Admins to create a plan from one of their + # templates on behalf of a user + def email? + user.can_super_admin? || (user.can_modify_templates? && template.org_id == user.org_id) + end + + def invite? + user.can_super_admin? || (user.can_modify_templates? && template.org_id == user.org_id) + end + end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 0a404c34a5..217cf5930c 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -80,6 +80,19 @@ def org_admin_other_user? signed_in_user.can_super_admin? || signed_in_user.can_org_admin? end + def revoke_oauth_access_token? + # An OauthToken can be revoked by a SuperAdmin or the Current User (for themself) + signed_in_user.can_super_admin? || signed_in_user == user + end + + def third_party_apps? + signed_in_user == user + end + + def developer_tools? + signed_in_user == user + end + class Scope < Scope def resolve diff --git a/app/presenters/api/v1/api_presenter.rb b/app/presenters/api/v1/api_presenter.rb new file mode 100644 index 0000000000..272b3f5d61 --- /dev/null +++ b/app/presenters/api/v1/api_presenter.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Api + + module V1 + + class ApiPresenter + + class << self + + def boolean_to_yes_no_unknown(value:) + return "unknown" unless value.present? + + value ? "yes" : "no" + end + + end + + end + + end + +end diff --git a/app/presenters/api/v1/contributor_presenter.rb b/app/presenters/api/v1/contributor_presenter.rb index 6970433bd7..4be9b5ba87 100644 --- a/app/presenters/api/v1/contributor_presenter.rb +++ b/app/presenters/api/v1/contributor_presenter.rb @@ -13,11 +13,11 @@ def role_as_uri(role:) return nil unless role.present? return "other" if role.to_s.downcase == "other" - "#{Contributor::ONTOLOGY_BASE_URL}/#{role.to_s.downcase.gsub('_', '-')}" + "#{Contributor::ONTOLOGY_BASE_URL}#{role.to_s.downcase.gsub('_', '-')}" end def contributor_id(identifiers:) - identifiers.select { |id| id.identifier_scheme.name == "orcid" }.first + identifiers.select { |id| id.identifier_scheme&.name == "orcid" }.first end end diff --git a/app/presenters/api/v1/org_presenter.rb b/app/presenters/api/v1/org_presenter.rb index aa430c96fc..32c543c915 100644 --- a/app/presenters/api/v1/org_presenter.rb +++ b/app/presenters/api/v1/org_presenter.rb @@ -8,11 +8,11 @@ class OrgPresenter class << self - def affiliation_id(identifiers:) - ident = identifiers.select { |id| id.identifier_scheme&.name == "ror" }.first - return ident if ident.present? + def affiliation_id(identifiers:, fundref: false) + ident = identifiers.select { |id| id.identifier_scheme&.name == "fundref" }.first if fundref + return ident if ident.present? && fundref - identifiers.select { |id| id.identifier_scheme&.name == "fundref" }.first + identifiers.select { |id| id.identifier_scheme&.name == "ror" }.first end end diff --git a/app/presenters/api/v1/plan_presenter.rb b/app/presenters/api/v1/plan_presenter.rb index a8e2510390..c83a176c18 100644 --- a/app/presenters/api/v1/plan_presenter.rb +++ b/app/presenters/api/v1/plan_presenter.rb @@ -6,9 +6,7 @@ module V1 class PlanPresenter - attr_reader :data_contact - attr_reader :contributors - attr_reader :costs + attr_reader :data_contact, :contributors, :costs def initialize(plan:) @contributors = [] @@ -30,15 +28,34 @@ def initialize(plan:) # Extract the ARK or DOI for the DMP OR use its URL if none exists def identifier - doi = @plan.identifiers.select do |id| - %w[ark doi].include?(id.identifier_format) - end - return doi.first if doi.first.present? + return @plan.dmp_id if @plan.dmp_id.present? # if no DOI then use the URL for the API's 'show' method Identifier.new(value: Rails.application.routes.url_helpers.api_v1_plan_url(@plan)) end + # Related identifiers for the Plan + def links + { + download: Rails.application.routes.url_helpers.plan_export_url(@plan, format: :pdf, "export[form]": true) + } + end + + # Subscribers of the Plan + def subscriptions + @plan.subscriptions.map do |subscription| + { + actions: ["PUT"], + name: subscription.subscriber&.name, + callback: subscription.callback_uri + } + end + end + + def visibility + @plan.visibility == "publicly_visible" ? "public" : "private" + end + private # Retrieve the answers that have the Budget theme diff --git a/app/presenters/api/v1/research_output_presenter.rb b/app/presenters/api/v1/research_output_presenter.rb new file mode 100644 index 0000000000..851e5837da --- /dev/null +++ b/app/presenters/api/v1/research_output_presenter.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Api + + module V1 + + class ResearchOutputPresenter + + attr_reader :dataset_id, :preservation_statement, :security_and_privacy, :license_start_date, + :data_quality_assurance, :distributions, :metadata, :technical_resources + + def initialize(output:) + @research_output = output + return unless output.is_a?(ResearchOutput) + + @plan = output.plan + @dataset_id = identifier + + load_narrative_content + + @license_start_date = determine_license_start_date(output: output) + end + + private + + def identifier + Identifier.new(identifiable: @research_output, value: @research_output.id) + end + + def determine_license_start_date(output:) + return nil unless output.present? + return output.release_date.to_formatted_s(:iso8601) if output.release_date.present? + + output.created_at.to_formatted_s(:iso8601) + end + + def load_narrative_content + @preservation_statement = "" + @security_and_privacy = [] + @data_quality_assurance = "" + + # Disabling rubocop here since a guard clause would make the line too long + # rubocop:disable Style/GuardClause + if Rails.configuration.x.madmp.extract_preservation_statements_from_themed_questions + @preservation_statement = fetch_q_and_a_as_single_statement(themes: %w[Preservation]) + end + if Rails.configuration.x.madmp.extract_security_privacy_statements_from_themed_questions + @security_and_privacy = fetch_q_and_a(themes: ["Ethics & privacy", "Storage & security"]) + end + if Rails.configuration.x.madmp.extract_data_quality_statements_from_themed_questions + @data_quality_assurance = fetch_q_and_a_as_single_statement(themes: ["Data Collection"]) + end + # rubocop:enable Style/GuardClause + end + + def fetch_q_and_a_as_single_statement(themes:) + fetch_q_and_a(themes: themes).collect { |item| item[:description] }.join("This is going to be great!!!
", + # "personal_data": "unknown", + # "sensitive_data": "yes", + # "issued": "2022-05-13T00:00:00Z", + # "preservation_statement": "Question: Which data are of long-term value and should be retained, shared, and/or preserved?I don't know.
\r\neebetbet
We will definitely do something.
\r\neebetbet
", + # "security_and_privacy": [ + # { + # "title": "Ethics & privacy", + # "description": [ + # "Question: Will your project involve sensitive data? Examples include: traditional knowledge, archeological artifacts, endangered species, medical data, and human subject research.Probably.
\r\nTime will tell.
", + # "Question: How will you manage access and security?Very carefully.
", + # ] + # }, + # ], + # "data_quality_assurance": "Question: How will the data be collected or created?Through various instruments.
Only the best.
", + # "dataset_id": { "type": "other", "identifier": "1" }, + # "distribution": [ + # { + # "title": "Anticipated distribution for My first test dataset", + # "byte_size": 60129542144, + # "data_access": "open", + # "host": { + # "title": "Example Repository", + # "description": "The example repository is for DMPTool testing", + # "url": "https://example.org/repo", + # "dmproadmap_host_id": { "type": "url", "identifier": "https://www.re3data.org/api/v1/repository/r3d10000XXXX" } + # }, + # "license": [ + # { + # "license_ref": "http://spdx.org/licenses/Artistic-1.0.json", + # "start_date": "2022-05-13T00:00:00Z" + # } + # ] + # } + # ], + # "metadata": [ + # { + # "description": "Dublin Core - A basic, domain-agnostic standard which can be easily understood ...", + # "metadata_standard_id": { "type": "url", "identifier": "https://rdamsc.bath.ac.uk/api2/m15" } + # } + # ], + # "technical_resource": [] + # } + def deserialize(plan:, json: {}) + return nil unless Api::V2::JsonValidationService.dataset_valid?(json: json) + + json = json.with_indifferent_access + # Try to find the Dataset or initialize a new one + research_output = find_by_identifier(plan: plan, json: json[:dataset_id]) + # TODO: remove this once we support versioning and are not storing these as RelatedIdentifiers + return research_output if research_output.is_a?(RelatedIdentifier) + + research_output = find_or_initialize(plan: plan, json: json) unless research_output.present? + return nil unless research_output.present? && research_output.title.present? + + research_output.description = json[:description] if json[:description].present? + research_output.personal_data = Api::V2::ConversionService.yes_no_unknown_to_boolean(json[:personal_data]) + research_output.sensitive_data = Api::V2::ConversionService.yes_no_unknown_to_boolean(json[:sensitive_data]) + research_output.release_date = Api::V2::DeserializationService.safe_date(value: json.fetch(:issued, Time.now)) + + research_output = attach_metadata(research_output: research_output, json: json[:metadata]) + deserialize_distribution(research_output: research_output, json: json[:distribution]) + end + + private + + def find_by_identifier(plan:, json:) + return nil unless json.is_a?(Hash) && json[:identifier].present? + + # Find by identifier if its available + id = json[:identifier] + if id.present? + if Api::V2::DeserializationService.dmp_id?(value: id) + # Find by the DOI or ARK + # TODO: Swap this out once we support versioning which will allow us to update + # the actual ResearchOutput metadata. For now we will record it as a RelatedIdentifier + # + # research_output = Api::V2::DeserializationService.object_from_identifier( + # class_name: "ResearchOutput", json: json + # ) + id = id.start_with?("http") ? id : "http://doi.org/#{id.gsub("doi:", "")}" + research_output = RelatedIdentifier.find_or_initialize_by( + identifiable: plan, + identifier_type: "DOI", + relation_type: "IsReferencedBy", + value: id + ) + else + research_output = ::ResearchOutput.find_by(plan: plan, id: id) + end + end + research_output + end + + # Find the dateset by ID or title + plan + def find_or_initialize(plan:, json: {}) + return nil unless json.present? + + research_output = ::ResearchOutput.find_or_initialize_by(title: json[:title], plan: plan) + research_output.output_type = json[:type] || "dataset" if research_output.new_record? + + Api::V2::DeserializationService.attach_identifier(object: research_output, json: json[:dataset_id]) + end + + # Add any metadata standards + def attach_metadata(research_output:, json:) + return research_output unless json.is_a?(Array) + + json.select { |h| h.fetch(:metadata_standard_id, {})[:identifier].present? }.each do |hash| + # Try to find the MetadataStandard by the identifier + metadata_standard = ::MetadataStandard.find_by( + uri: hash[:metadata_standard_id][:identifier], description: hash[:description] + ) + next if metadata_standard.nil? || research_output.metadata_standards.include?(metadata_standard) + + research_output.metadata_standards << metadata_standard + end + research_output + end + + # Add any distribution level data to the research output + def deserialize_distribution(research_output:, json:) + return research_output unless research_output.present? && json.is_a?(Array) + + json.each do |distribution| + # Try to locate the hosts from our list of Repositories + research_output = attach_repositories(research_output: research_output, json: distribution[:host]) + research_output = attach_licenses(research_output: research_output, json: distribution[:license]) + research_output.byte_size = distribution[:byte_size] + research_output.access = distribution[:data_access] + end + research_output + end + + def attach_repositories(research_output:, json:) + return research_output unless research_output.present? && json.is_a?(Hash) + + uri = json.fetch(:dmproadmap_host_id, {})[:identifier] + if json[:url].present? || uri.present? + repository = ::Repository.find_by(uri: uri) if uri.present? + repository = ::Repository.find_by(homepage: json[:url]) unless repository.present? + return research_output if repository.nil? || + research_output.repositories.include?(repository) + + research_output.repositories << repository + end + research_output + end + + def attach_licenses(research_output:, json:) + return research_output unless research_output.present? && json.is_a?(Array) + + # Attempt to grab the current license + licenses = json.sort { |a, b| a[:start_date] <=> b[:start_date] } + prior_licenses = licenses.select do |license| + date = Api::V2::DeserializationService.safe_date(value: license[:start_date]) + date <= Time.now + end + + # If there are no current licenses then just grab the first one + license = prior_licenses.any? ? prior_licenses.last : json.first + license = License.find_by(uri: license[:license_ref]) + + research_output.license = license if license.present? + research_output + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v2/deserialization/funding.rb b/app/services/api/v2/deserialization/funding.rb new file mode 100644 index 0000000000..b043d3cca7 --- /dev/null +++ b/app/services/api/v2/deserialization/funding.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Api + + module V2 + + module Deserialization + + class Funding + + class << self + + # Convert the funding information and attach to the Plan + # { + # "$ref": "SEE Org.deserialize! for details", + # "grant_id": { + # "$ref": "SEE Identifier.deserialize for details" + # }, + # "funding_status": "granted" + # } + def deserialize(plan:, json: {}) + return nil unless plan.present? + return plan unless Api::V2::JsonValidationService.funding_valid?(json: json) + + # Attach the Funder + plan.funder = Api::V2::Deserialization::Org.deserialize(json: json) + + opportunity = json.fetch(:dmproadmap_funding_opportunity_id, {}) + plan.identifier = opportunity[:identifier] if opportunity[:identifier].present? + + plan.funding_status = Api::V2::DeserializationService.translate_funding_status( + status: json[:funding_status] + ) + return plan unless json[:grant_id].present? + + # Attach the grant Identifier to the Plan if present + # Attach the identifier + plan.grant = Api::V2::Deserialization::Identifier.deserialize( + class_name: plan.class.name, json: json[:grant_id] + ) + plan + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v2/deserialization/identifier.rb b/app/services/api/v2/deserialization/identifier.rb new file mode 100644 index 0000000000..25a23d1e86 --- /dev/null +++ b/app/services/api/v2/deserialization/identifier.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Api + + module V2 + + module Deserialization + + class Identifier + + class << self + + # Convert the incoming JSON into an Identifier + # { + # "type": "ror", + # "identifier": "https://ror.org/43y4g4" + # } + def deserialize(class_name:, json: {}) + return nil unless class_name.present? && + Api::V2::JsonValidationService.identifier_valid?(json: json) + + json = json.with_indifferent_access + scheme = ::IdentifierScheme.by_name(json[:type].downcase).first + + # If the scheme is present then this is a identifier that must be + # unique (e.g. ROR, ORCID) so try to find it + if scheme.present? + val = json[:identifier] if json[:identifier].start_with?(scheme.identifier_prefix) + val = "#{scheme.identifier_prefix}#{json[:identifier]}" unless val.present? + identifier = ::Identifier.by_scheme_name(scheme, class_name).where(value: val).first + return identifier if identifier.present? + end + + ::Identifier.new(identifier_scheme: scheme, value: json[:identifier]) + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v2/deserialization/org.rb b/app/services/api/v2/deserialization/org.rb new file mode 100644 index 0000000000..d88f429dc3 --- /dev/null +++ b/app/services/api/v2/deserialization/org.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Api + + module V2 + + module Deserialization + + class Org + + class << self + + # Convert the incoming JSON into an Org + # { + # "name": "University of Somewhere", + # "abbreviation": "UofS", + # "affiliation_id": { + # "type": "ror", + # "identifier": "https://ror.org/43y4g4" + # } + # } + def deserialize(json: {}) + return nil unless Api::V2::JsonValidationService.org_valid?(json: json) + + json = json.with_indifferent_access + + # Try to find the Org by the identifier + id_json = json.fetch(:affiliation_id, json.fetch(:funder_id, {})) + org = Api::V2::DeserializationService.object_from_identifier( + class_name: "Org", json: id_json + ) + return org if org.present? + + # Try to find the Org by name + org = find_by_name(json: json) + return org if org.present? && !org.new_record? + + # Org model requires a language so just use the default for now + org.language = Language.default + org.abbreviation = json[:abbreviation] if json[:abbreviation].present? + return nil unless org.valid? + return org unless id_json[:identifier].present? + + # Attach the identifier + Api::V2::DeserializationService.attach_identifier(object: org, json: id_json) + end + + # =================== + # = PRIVATE METHODS = + # =================== + + private + + # Search for an Org locally and then externally if not found + def find_by_name(json: {}) + return nil unless json.present? && json[:name].present? + + name = json[:name] + + # Search the DB + org = ::Org.where("LOWER(name) = ?", name.downcase).first + return org if org.present? + + # External ROR search + results = OrgSelection::SearchService.search_externally( + search_term: name + ) + + # Grab the closest match - only caring about results that 'contain' + # the name with preference to those that start with the name + result = results.select { |r| %i[0 1].include?(r[:weight]) }.first + + # If no good result was found just use the specified name + result ||= { name: name } + OrgSelection::HashToOrgService.to_org(hash: result) + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v2/deserialization/plan.rb b/app/services/api/v2/deserialization/plan.rb new file mode 100644 index 0000000000..88e7807248 --- /dev/null +++ b/app/services/api/v2/deserialization/plan.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +module Api + + module V2 + + module Deserialization + + class Plan + + class << self + + # Convert the incoming JSON into a Plan + # { + # "dmp": { + # "created": "2020-03-26T11:52:00Z", + # "title": "Brain impairment caused by COVID-19", + # "description": "DMP for COVID-19 Brain analysis", + # "language": "eng", + # "ethical_issues_exist": "yes", + # "ethical_issues_description": "We will need to anonymize data", + # "ethical_issues_report": "https://university.edu/ethics/policy.pdf", + # "contact": { + # "$ref": "SEE Contributor.deserialize! for details" + # }, + # "contributor": [{ + # "$ref": "SEE Contributor.deserialize! for details" + # }], + # "project": [{ + # "title": "Brain impairment caused by COVID-19", + # "description": "Brain stem comparisons of COVID-19 patients", + # "start": "2020-03-01T12:33:44Z", + # "end": "2023-03-31T12:33:44Z", + # "funding": [{ + # "$ref": "SEE Funding.deserialize! for details" + # }] + # }], + # "dataset": [{ + # "$ref": "SEE Dataset.deserialize! for details" + # }], + # "extension": [{ + # "dmproadmap": { + # "template": { + # "id": 123, + # "title": "Generic Data Management Plan" + # } + # } + # }] + # } + # } + def deserialize(json: {}) + return nil unless Api::V2::JsonValidationService.plan_valid?(json: json) + + json = json.with_indifferent_access + # Try to find the Contributor or initialize a new one + id_json = json.fetch(:dmp_id, {}) + plan = find_or_initialize(id_json: id_json, json: json) + return nil unless plan.present? && plan.template.present? + + plan.description = json[:description] if json[:description].present? + issues = Api::V2::ConversionService.yes_no_unknown_to_boolean( + json[:ethical_issues_exist] + ) + plan.ethical_issues = issues + plan.ethical_issues_description = json[:ethical_issues_description] + plan.ethical_issues_report = json[:ethical_issues_report] + + # TODO: Handle ethical issues when the Question is in place + + # Process Project, Contributors and Data Contact and Datsets + plan = deserialize_project(plan: plan, json: json) + # The contact is handled from within the controller since the Plan.add_user! method + # requires that the Plan has been persisted to the DB + plan = deserialize_contributors(plan: plan, json: json) + deserialize_datasets(plan: plan, json: json) + end + + # =================== + # = PRIVATE METHODS = + # =================== + + private + + def find_or_initialize(id_json:, json: {}) + return nil unless json.present? + + id = id_json[:identifier] if id_json.is_a?(Hash) + schm = IdentifierScheme.find_by(name: id_json[:type].downcase) if id.present? + + if id.present? + # If the identifier is a DOI/ARK or the api client's internal id for the DMP + if Api::V2::DeserializationService.dmp_id?(value: id) + # Find by the DOI or ARK + plan = Api::V2::DeserializationService.object_from_identifier( + class_name: "Plan", json: id_json + ) + elsif schm.present? + value = id.start_with?(schm.identifier_prefix) ? id : "#{schm.identifier_prefix}#{id}" + identifier = ::Identifier.find_by( + identifiable_type: "Plan", identifier_scheme: schm, value: value + ) + plan = identifier.identifiable if identifier.present? + else + # For URL based identifiers + begin + plan = ::Plan.find_by(id: id.split("/").last.to_i) if id.start_with?("http") + rescue StandardError => e + # Catches scenarios where the dmp_id is NOT one of our URLs + plan = nil + end + end + end + return plan if plan.present? + + template = find_template(json: json) + plan = ::Plan.new(title: json[:title], template: template) + return plan unless id.present? && schm.present? + + # If the external system provided an identifier and they have an IdentifierScheme + Api::V2::DeserializationService.attach_identifier(object: plan, json: id_json) + end + + # Deserialize the datasets and attach to plan + def deserialize_datasets(plan:, json: {}) + return plan unless json.present? && json[:dataset].present? && json[:dataset].is_a?(Array) + + research_outputs = json[:dataset].map do |dataset| + Api::V2::Deserialization::Dataset.deserialize(plan: plan, json: dataset) + end + + # TODO: remove this once we support versioning and are not storing outputs with DOIs as + # RelatedIdentifiers. Once versioning is in place we can update the existing ResearchOutputs + research_outputs.each do |output| + plan.research_outputs << output if output.is_a?(ResearchOutput) + plan.related_identifiers << output if output.is_a?(RelatedIdentifier) + end + plan + end + + # Deserialize the project information and attach to Plan + def deserialize_project(plan:, json: {}) + return plan unless json.present? && + json[:project].present? && + json[:project].is_a?(Array) + + project = json.fetch(:project, [{}]).first + plan.start_date = Api::V2::DeserializationService.safe_date(value: project[:start]) + plan.end_date = Api::V2::DeserializationService.safe_date(value: project[:end]) + return plan unless project[:funding].present? + + funding = project.fetch(:funding, []).first + return plan unless funding.present? + + Api::V2::Deserialization::Funding.deserialize(plan: plan, json: funding) + end + # rubocop:enable + + # Deserialize the contact as a Contributor + def deserialize_contact(plan:, json: {}) + return plan unless json.present? && json[:contact].present? + + contact = Api::V2::Deserialization::Contributor.deserialize( + json: json[:contact], is_contact: true + ) + return plan unless contact.present? + + plan.contributors << contact + plan.org = contact.org + plan + end + + # Deserialize each Contributor and then add to Plan + def deserialize_contributors(plan:, json: {}) + contributors = json.fetch(:contributor, []).map do |hash| + Api::V2::Deserialization::Contributor.deserialize(json: hash) + end + plan.contributors << contributors.compact.uniq if contributors.any? + plan + end + + # Lookup the Template + def find_template(json: {}) + default = Template.find_by(is_default: true) + return default unless json.present? && json.fetch(:dmproadmap_template, {})[:id].present? + + template = Template.published(json.fetch(:dmproadmap_template, {})[:id].to_i).last + template.present? ? template : default + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v2/deserialization/related_identifier.rb b/app/services/api/v2/deserialization/related_identifier.rb new file mode 100644 index 0000000000..7605b25d6f --- /dev/null +++ b/app/services/api/v2/deserialization/related_identifier.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Api + + module V2 + + module Deserialization + + class RelatedIdentifier + + class << self + + # Convert the incoming JSON into an Identifier + # { + # "descriptor": "documents", + # "type": "doi", + # "work_type": "dataset", + # "identifier": "https://doi.org/10.1234/abcd123" + # } + def deserialize(plan:, json: {}) + return nil unless plan.present? && + Api::V2::JsonValidationService.related_identifier_valid?( + json: json + ) + + json = json.with_indifferent_access + r_id = ::RelatedIdentifier.find_or_initialize_by(identifiable: plan, + value: json[:identifier]) + + relation_type = json[:descriptor] + # Note that the 'references' value is changed to 'does_reference' in this list + # because 'references' conflicts with an ActiveRecord method + relation_type = "does_reference" if relation_type == "references" + + work_type = json[:work_type].downcase if valid_work_type?(json: json) + # Default to dataset + work_type = "dataset" unless work_type.present? + + r_id.relation_type = relation_type + r_id.work_type = json[:work_type] if work_type + r_id.identifier_type = json[:type].underscore + r_id + end + + private + + def valid_work_type?(json:) + return false unless json.present? && json[:work_type].present? + + ::RelatedIdentifier.work_types.keys.include?(json[:work_type].downcase) + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v2/deserialization_service.rb b/app/services/api/v2/deserialization_service.rb new file mode 100644 index 0000000000..c326ed4c7e --- /dev/null +++ b/app/services/api/v2/deserialization_service.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module Api + + module V2 + + class DeserializationService + + class << self + + # Retrieves the Plan based on a DMP ID value (either a DMP ID or API URL) + def plan_from_dmp_id(dmp_id:) + return nil unless dmp_id.present? && dmp_id[:type].present? && + dmp_id[:identifier].present? + + if %w[ark doi].include?(dmp_id[:type].downcase) + ::Identifier.find_by(identifiable_type: "Plan", value: dmp_id[:identifier]) + &.identifiable + else + ::Plan.find_by(id: dmp_id[:identifier].split("/").last) + end + end + + # Finds the object by the specified identifier + def object_from_identifier(class_name:, json:) + return nil unless class_name.present? && json.present? && + json[:type].present? && json[:identifier].present? + + clazz = "::#{class_name.capitalize}".constantize + return nil unless clazz.respond_to?(:from_identifiers) + + clazz.from_identifiers( + array: [{ name: json[:type], value: json[:identifier] }] + ) + rescue NameError + nil + end + + # Attach the identifier to the object if it does not already exist + def attach_identifier(object:, json:) + return object unless object.present? && object.respond_to?(:identifiers) && + json.present? && + json[:type].present? && json[:identifier].present? + + existing = object.identifiers.select do |id| + id.identifier_scheme&.name&.downcase == json[:type].downcase + end + return object if existing.present? + + object.identifiers << Api::V2::Deserialization::Identifier.deserialize( + class_name: object.class.name, json: json + ) + object + end + + # Translates the role in the json to a Contributor role + def translate_role(role:) + default = ::Contributor.default_role + return default unless role.present? + + role = role.to_s unless role.is_a?(String) + + # Strip off the URL if present + url = ::Contributor::ONTOLOGY_BASE_URL + role = role.gsub(url, "").downcase if role.include?(url) + role = role.gsub("-", "_") + + # Return the role if its a valid one otherwise defualt + return role if ::Contributor.new.all_roles.include?(role.downcase.to_sym) + + default + end + + # Translates the RDA Common Standard for the funding status + def translate_funding_status(status:) + case status + when "rejected" + "denied" + when "granted" + "funded" + else + "planned" + end + end + + # Retrieve any JSON schema extensions for this application + def app_extensions(json: {}) + return {} unless json.present? && json[:extension].present? + + app = ::ApplicationService.application_name.split("-").first.downcase + ext = json[:extension].select { |item| item[app.to_sym].present? } + ext.first.present? ? ext.first[app.to_sym] : {} + end + + # Determines whether or not the value is a DOI/ARK + def dmp_id?(value:) + return false unless value.present? + + # The format must match a DOI or ARK and a DOI IdentifierScheme + # must also be present! + identifier = ::Identifier.new(value: value) + scheme = DmpIdService.identifier_scheme + scheme.present? && + (identifier.identifier_format.include?("ark") || identifier.identifier_format.include?("doi")) + end + + # Converts the string into a UTC Time string + def safe_date(value:) + return nil unless value.is_a?(String) + + Time.parse(value).utc + rescue ArgumentError + value.to_s + end + + end + + end + + end + +end diff --git a/app/services/api/v2/json_validation_service.rb b/app/services/api/v2/json_validation_service.rb new file mode 100644 index 0000000000..7d7e451526 --- /dev/null +++ b/app/services/api/v2/json_validation_service.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +module Api + + module V2 + + # Service used to validate incoming JSON + class JsonValidationService + + # rubocop:disable Layout/LineLength + BAD_PLAN_MSG = _(":title and the contact's :mbox are both required fields").freeze + BAD_ID_MSG = _(":type and :identifier are required for all ids").freeze + BAD_ORG_MSG = _(":name is required for every :affiliation and :funding").freeze + BAD_CONTRIB_MSG = _(":role and either the :name or :email are required for each :contributor").freeze + BAD_FUNDING_MSG = _(":name, :funder_id or :grant_id are required for each funding").freeze + BAD_DATASET_MSSG = _(":title is required for each :dataset").freeze + BAD_HOST_MSG = _(":host must include either a :url or :dmproadmap_host_id").freeze + BAD_RELATED_IDENTIFIER_MSG = _(":descriptor, :type and :identifier are required for all dmproadmap_related_identifiers").freeze + # rubocop:enable Layout/LineLength + + class << self + + def plan_valid?(json:) + json.present? && json[:title].present? && json[:contact].present? && + json[:contact][:mbox].present? + end + + def identifier_valid?(json:) + json.present? && json[:type].present? && json[:identifier].present? + end + + def org_valid?(json:) + json.present? && (json[:name].present? || json[:affiliation_id].present? || json[:funder_id].present?) + end + + def contributor_valid?(json:, is_contact: false) + return false unless json.present? + return false unless json[:name].present? || json[:mbox].present? + + is_contact ? true : json[:role].present? + end + + def funding_valid?(json:) + return false unless json.present? + + funder_id = json.fetch(:funder_id, {})[:identifier] + grant_id = json.fetch(:grant_id, {})[:identifier] + json[:name].present? || funder_id.present? || grant_id.present? + end + + def dataset_valid?(json:) + return false unless json.present? + + dataset_id = json.fetch(:dataset_id, {})[:identifier] + json[:title].present? || dataset_id.present? + end + + def host_valid?(json:) + return false unless json.present? + + host_id = json.fetch(:dmproadmap_host_id, {})[:identifier] + json[:url].present? || host_id.present? + end + + def related_identifier_valid?(json:) + json.present? && json[:descriptor].present? && json[:type].present? && + json[:identifier].present? + end + + # rubocop:disable Metrics/AbcSize + # Scans the entire JSON document for invalid metadata and returns + # friendly errors to help the caller resolve the issue + def validation_errors(json:) + errs = [] + return [_("invalid JSON")] unless json.present? + + errs << BAD_PLAN_MSG unless plan_valid?(json: json) + if json[:dmp_id].present? + errs << BAD_ID_MSG unless identifier_valid?(json: json[:dmp_id]) + end + + # Handle Contact + errs << contributor_validation_errors(json: json[:contact]) + + # Handle Contributors + errs << json.fetch(:contributor, []).map do |contributor| + contributor_validation_errors(json: contributor) + end + + # Handle the Project and Fundings + json.fetch(:project, []).each do |project| + errs << project.fetch(:funding, []).map do |funding| + funding_validation_errors(json: funding) + end + end + + # Handle Datasets (eventually) + errs << json.fetch(:dataset, []).map do |dataset| + dataset_validation_errors(json: dataset) + end + + errs.flatten.compact.uniq + end + # rubocop:enable Metrics/AbcSize + + def contributor_validation_errors(json:) + errs = [] + if json.present? + errs << BAD_CONTRIB_MSG unless contributor_valid?(json: json, + is_contact: true) + errs << org_validation_errors(json: json[:affiliation]) if json[:affiliation].present? + id = json.fetch(:contributor_id, json[:contact_id]) + if id.present? + errs << BAD_ID_MSG unless identifier_valid?(json: id) + end + end + errs + end + + def dataset_validation_errors(json:) + errs = [] + return errs unless json.present? + + errs << BAD_DATASET_MSG unless dataset_valid?(json: json) + json.fetch(:distribution, []).each do |distribution| + errs << BAD_HOST_MSG unless host_valid?(json: distribution.fetch(:host, {})) + end + errs + end + + def funding_validation_errors(json:) + errs = [] + return errs unless json.present? + + errs << BAD_FUNDING_MSG unless funding_valid?(json: json) + errs << org_validation_errors(json: json) + if json[:grant_id].present? + errs << BAD_ID_MSG unless identifier_valid?(json: json[:grant_id]) + end + errs + end + + def org_validation_errors(json:) + errs = [] + return errs unless json.present? + + errs << BAD_ORG_MSG unless org_valid?(json: json) + id = json.fetch(:affiliation_id, json[:funder_id]) + if id.present? + errs << BAD_ID_MSG unless identifier_valid?(json: id) + end + errs + end + + def related_identifiers_errors(json:) + errs = [] + return errs unless json.present? + + json.each do |related_identifier| + next if related_identifier_valid?(json: related_identifier) + + errs << BAD_RELATED_IDENTIFIER_MSG + end + errs + end + + end + + end + + end + +end diff --git a/app/services/api/v2/persistence_service.rb b/app/services/api/v2/persistence_service.rb new file mode 100644 index 0000000000..6667ac4aa4 --- /dev/null +++ b/app/services/api/v2/persistence_service.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Api + + module V2 + + # Service used to ensure the entire DMP stack is saved + class PersistenceService + + class << self + + def safe_save(plan:) + return nil unless plan.is_a?(Plan) && plan.valid? + + plan.contributors = deduplicate_contributors(contributors: plan.contributors) + + Plan.transaction do + plan.funder = safe_save_org(org: plan.funder) + plan.grant_id = safe_save_identifier(identifier: plan.grant)&.id + + plan.save + + plan.identifiers.each do |id| + id.identifiable = plan.reload + safe_save_identifier(identifier: id) + end + plan.contributors.each do |contributor| + contributor.plan = plan.reload + safe_save_contributor(contributor: contributor) + end + + plan.reload + end + end + + private + + def safe_save_identifier(identifier:) + return nil unless identifier.is_a?(Identifier) + + Identifier.transaction do + identifier.save if identifier.valid? + return identifier unless identifier.new_record? + end + + Identifier.where(identifier_scheme: identifier.identifier_scheme, + value: identifier.value, + identifiable: identifier.identifiable).first + end + + def safe_save_org(org:) + return nil unless org.is_a?(Org) + + Org.transaction do + organization = Org.find_or_initialize_by(name: org.name) + if organization.new_record? + # Now that we know its a new record make sure its valid first + return nil unless org.valid? + + organization.update(saveable_attributes(attrs: org.attributes)) + org.identifiers.each do |id| + id.identifiable = organization.reload + safe_save_identifier(identifier: id) + end + end + organization.reload + end + end + + def safe_save_contributor(contributor:) + return nil unless contributor.is_a?(Contributor) && contributor.valid? + + Contributor.transaction do + contrib = Contributor.find_or_initialize_by(email: contributor.email) + + if contrib.new_record? + contrib.update(saveable_attributes(attrs: contributor.attributes)) + contrib.update(org: safe_save_org(org: contributor.org)) if contributor.org.present? + + contributor.identifiers.each do |id| + id.identifiable = contrib.reload + safe_save_identifier(identifier: id) + end + end + contrib.reload + end + end + + # Consolidate the contributors so that we don't end up trying to insert + # duplicate records! + def deduplicate_contributors(contributors:) + out = [] + return out unless contributors.respond_to?(:any?) && contributors.any? + + contributors.each do |contributor| + next unless contributor.is_a?(Contributor) + + # See if we've already processed this contributor + existing = out.select { |c| c == contributor }.first + out << contributor unless existing.present? + next unless existing.present? + + existing.merge(contributor) + end + out.flatten.compact.uniq + end + + def id_for(model, scheme) + return nil unless model.respond_to?(:identifier_for_scheme) && scheme.present? + + model.identifier_for_scheme(scheme: scheme) + end + + def saveable_attributes(attrs:) + %w[id created_at updated_at].each { |key| attrs.delete(key) } + attrs + end + + end + + end + + end + +end diff --git a/app/services/dmp_id_service.rb b/app/services/dmp_id_service.rb new file mode 100644 index 0000000000..4173455cd3 --- /dev/null +++ b/app/services/dmp_id_service.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +# Simple proxy service that determines which DMP ID minter to use +class DmpIdService + + class << self + + # Registers a DMP ID for the specified plan. + def mint_dmp_id(plan:) + # plan must exist and not already have a DMP ID! + return nil unless minting_service_defined? && plan.present? && plan.is_a?(Plan) + return plan.dmp_id if plan.dmp_id.present? + + svc = minter + return nil unless svc.present? + + dmp_id = svc.mint_dmp_id(plan: plan) + return nil unless dmp_id.present? + + dmp_id = "#{svc.landing_page_url}#{dmp_id}" unless dmp_id.downcase.start_with?("http") + Identifier.new(identifier_scheme: identifier_scheme, identifiable: plan, value: dmp_id) + rescue StandardError => e + p e.message + Rails.logger.error "DmpIdService.mint_dmp_id for Plan #{plan&.id} resulted in: #{e.message}" + nil + end + + # Updates the DMP ID metadata + def update_dmp_id(plan:) + # plan must exist and have a DMP ID + unless minting_service_defined? && plan.present? && plan.is_a?(Plan) && plan.dmp_id.present? + return nil + end + + svc = minter + return nil unless svc.present? + + dmp_id = svc.update_dmp_id(plan: plan) + return nil unless dmp_id.present? + rescue StandardError => e + Rails.logger.error "DmpIdService.update_dmp_id for Plan #{plan&.id} resulted in: #{e.message}" + Rails.logger.error e.backtrace + nil + end + + # Returns whether or not there is an active DMP ID minting service + def minting_service_defined? + Rails.configuration.x.madmp.enable_dmp_id_registration && minter.present? && + minter.api_base_url.present? + end + + # Retrieves the corresponding IdentifierScheme associated with the + def identifier_scheme + svc = minter + return nil unless svc.present? && svc.name.present? + + # Add the DMP ID service as an IdentifierScheme if it doesn't already exist + scheme = IdentifierScheme.find_or_create_by(name: svc.name.downcase) + if scheme.new_record? + scheme.update(description: svc.description, active: true, for_plans: true) + end + scheme + end + + # Return the inheriting service's :callback_path (defined in their config) + def scheme_callback_uri + svc = minter + return nil unless svc.present? + + svc.respond_to?(:callback_path) ? svc.callback_path : nil + end + + # Return the inheriting service's :landing_page_url (defined in their config) + def landing_page_url + svc = minter + return nil unless svc.present? + + svc.respond_to?(:landing_page_url) ? svc.landing_page_url : nil + end + + private + + # Fetch the active DMP ID minting service + def minter + # Use Datacite if it has been activated + return ExternalApis::DataciteService if ExternalApis::DataciteService.active? + # Use the DMPHub if it has been activated + return ExternalApis::DmphubService if ExternalApis::DmphubService.active? + + # Place additional DMP ID services here + + nil + end + + end + +end diff --git a/app/services/external_apis/base_dmp_id_service.rb b/app/services/external_apis/base_dmp_id_service.rb new file mode 100644 index 0000000000..45ed4ffefe --- /dev/null +++ b/app/services/external_apis/base_dmp_id_service.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module ExternalApis + + # This service provides an interface to minting/registering DOIs + # To enable the feature you will need to: + # - Identify a DMP ID minting authority (e.g. Datacite, Crossref, etc.) + # - Create an account with them and gain access to their API + # - Add a `config/initializers/external_apis/[service_name].rb`. Copy one of the + # existing ones as reference. + # - Create a new service in this directory that inherits from this class. + # Then define use the service's API documentation to build mint/update/delete functions + # - Also make sure that the `madmp.enable_dmp_id_registration` is set to true in + # config/initializers/_dmproadmap.rb + class BaseDmpIdService < BaseService + + class << self + + # The API endpoint to call to authenticate and receive an auth token to be used + # with all subsequent communications + def auth_path + nil + end + + # The API endpoint to call to register the Plan with the service and mint a + # new DMP ID (aka DOI, ARK, etc) + def mint_path + nil + end + + # The callback_path is the API endpoint to send updates to once the Plan has changed + # or been versioned. Use the `%{dmp_id}` markup to have the Plan's DOI appended to the path. + # For example: `update_dmp/%{dmp_id}` would become: `updated_dmp/10.123/1234.ABC` + def callback_path + nil + end + + # The HTTP method to be used when using the callback_path + def callback_method + :put + end + + # The name of the associated ApiClient + def api_client + nil + end + + # Ping the DOI API to determine if it is online + # + # @return true/false + def ping + return true unless active? && heartbeat_path.present? + + resp = http_get(uri: "#{api_base_url}#{heartbeat_path}") + resp.is_a?(Net::HTTPSuccess) + end + + # Implement the authentication for the DOI API + def auth + true + + # You should implement any necessary authentication step required by the + # DOI API + end + + # Implement the call to retrieve/mint a new DOI + # rubocop:disable Lint/UnusedMethodArgument + def mint_dmp_id(plan:) + SecureRandom.uuid + + # Minted DOIs should be stored as an Identifier. For example: + # val = "#{landing_page_url}#{dmp_id}" + # Identifier.new(identifiable: plan, value: val) + + # When this service is active and the above identifier is available, + # the link to the DOI will appear on the Project Details page, in plan + # exports and will become the `dmp_id` in this system's API responses + end + # rubocop:enable Lint/UnusedMethodArgument + + # Implement the call to register an associated ApiClient as a Subscriber to the Plan + # rubocop:disable Lint/UnusedMethodArgument + def add_subscription(plan:, dmp_id:) + true + end + # rubocop:enable Lint/UnusedMethodArgument + + # Implement the call to update the DOI + # rubocop:disable Lint/UnusedMethodArgument + def update_dmp_id(plan:) + true + end + # rubocop:enable Lint/UnusedMethodArgument + + # Implement the call to delete the DOI + # rubocop:disable Lint/UnusedMethodArgument + def delete_dmp_id(plan:) + true + end + # rubocop:enable Lint/UnusedMethodArgument + + end + + end + +end diff --git a/app/services/external_apis/base_service.rb b/app/services/external_apis/base_service.rb index bd13298aa8..6cf7862c24 100644 --- a/app/services/external_apis/base_service.rb +++ b/app/services/external_apis/base_service.rb @@ -78,6 +78,29 @@ def log_error(method:, error:) Rails.logger.error error.backtrace end + # Emails the error and response to the administrators + # rubocop:disable Metrics/AbcSize + def notify_administrators(obj:, response: nil, error: nil) + return false unless obj.present? && response.present? + + message = "#{obj.class.name} - #{obj.respond_to?(:id) ? obj.id : ''}" + message += "The Access to Biological Collections Data (ABCD) Schema
", + # "keywords": [ + # "http://vocabularies.unesco.org/thesaurus/concept4011", + # "http://vocabularies.unesco.org/thesaurus/concept230", + # "http://rdamsc.bath.ac.uk/thesaurus/subdomain235", + # "http://vocabularies.unesco.org/thesaurus/concept223", + # "http://vocabularies.unesco.org/thesaurus/concept159", + # "http://vocabularies.unesco.org/thesaurus/concept162", + # "http://vocabularies.unesco.org/thesaurus/concept235" + # ], + # "locations": [ + # { "type": "document", "url": "http://www.tdwg.org/standards/115/" }, + # { "type": "website", "url": "http://wiki.tdwg.org/ABCD" } + # ], + # "mscid": "msc:m1", + # "relatedEntities": [ + # { "id": "msc:m42", "role": "child scheme" }, + # { "id": "msc:m43", "role": "child scheme" }, + # { "id": "msc:m64", "role": "child scheme" }, + # { "id": "msc:c1", "role": "input to mapping" }, + # { "id": "msc:c3", "role": "output from mapping" }, + # { "id": "msc:c14", "role": "output from mapping" }, + # { "id": "msc:c18", "role": "output from mapping" }, + # { "id": "msc:c23", "role": "output from mapping" }, + # { "id": "msc:g11", "role": "user" }, + # { "id": "msc:g44", "role": "user" }, + # { "id": "msc:g45", "role": "user" } + # ], + # "slug": "abcd-access-biological-collection-data", + # "title": "ABCD (Access to Biological Collection Data)", + # "uri": "https://rdamsc.bath.ac.uk/api2/m1" + # } + # ] + # } + # } + def query_schemes(path:) + json = query_api(path: path) + return false unless json.present? + + process_scheme_entries(json: json) + return true unless json.fetch("data", {})["nextLink"].present? + + query_schemes(path: json["data"]["nextLink"]) + end + + def query_api(path:) + return nil unless path.present? + + # Call the API and log any errors + resp = http_get(uri: "#{api_base_url}#{path}", additional_headers: {}, debug: false) + unless resp.present? && resp.code == 200 + handle_http_failure(method: "RDAMSC API query - path: '#{path}' -- ", http_response: resp) + return nil + end + + JSON.parse(resp.body) + rescue JSON::ParserError => e + log_error(method: "RDAMSC API query - path: '#{path}' -- ", error: e) + nil + end + + def process_scheme_entries(json:) + return false unless json.is_a?(Hash) + + json = json.with_indifferent_access + return false unless json["data"].present? && json["data"].fetch("items", []).any? + + json["data"]["items"].each do |item| + standard = MetadataStandard.find_or_create_by(uri: item["uri"], title: item["title"]) + standard.update(description: item["description"], locations: item["locations"], + related_entities: item["relatedEntities"], rdamsc_id: item["mscid"]) + end + end + + end + + end + +end diff --git a/app/services/external_apis/re3data_service.rb b/app/services/external_apis/re3data_service.rb new file mode 100644 index 0000000000..f37bbc212f --- /dev/null +++ b/app/services/external_apis/re3data_service.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +module ExternalApis + + # This service provides an interface to the Registry of Research Data + # Repositories (re3data.org) API. + # For more information: https://www.re3data.org/api/doc + class Re3dataService < BaseService + + class << self + + # Retrieve the config settings from the initializer + def landing_page_url + Rails.configuration.x.re3data&.landing_page_url || super + end + + def api_base_url + Rails.configuration.x.re3data&.api_base_url || super + end + + def max_pages + Rails.configuration.x.re3data&.max_pages || super + end + + def max_results_per_page + Rails.configuration.x.re3data&.max_results_per_page || super + end + + def max_redirects + Rails.configuration.x.re3data&.max_redirects || super + end + + def active? + Rails.configuration.x.re3data&.active || super + end + + def list_path + Rails.configuration.x.re3data&.list_path + end + + def repository_path + Rails.configuration.x.re3data&.repository_path + end + + # Retrieves the full list of repositories from the re3data API as XML. + # For example: + #
+ <%= sanitize _('You can find out more about us on our website (new window). %{application_name} is provided by the %{organisation_name}.
If you would like to contact us about %{application_name}, please fill out the form below.') % {
+ organisation_name: Rails.configuration.x.organisation.name,
+ organisation_url: Rails.configuration.x.organisation.url,
+ application_name: Rails.configuration.x.application.name,
+ open_in_new_window_text: _('Opens in new window') },
+ tags: %w( a br span em ) %>
+
<%= _("Users") %>
+<%= _('Plans')%>
+ +<%= _('Participating Institutions')%>
+ <% sr_text = _("View the list of participating institutions") %> + +<%= _('News is currently unavailable') %>
+ <% end %> +| <%= _('Institution') %> <%= paginable_sort_link('orgs.name') %> | +<%= _('Institutional Signin Enabled') %> | +
|---|---|
| <%= org.name %> | ++ |
+ <%= _("Create a new plan for the specified user based off of %{template_name}. If the user does not already have an account, they will receive an invitation to sign up.") % { + template_name: template&.title + } %> +
+ +<%= form_with model: plan, url: org_admin_plans_path, method: :post, local: true do |f| %> ++ <%= _("You can use the default email subject and body below or replace them with your own message. Refer to ther preview section below to see what the email will look like.") %> +
++ Hello jane.doe@example.org, +
++ <%= sanitize(plan.template.email_body) %> +
++ <%= sanitize(_("%{click_here} to setup your account (or copy %{link} into your browser). Once you have signed in, you can begin filling out your DMP.") % { + click_here: link_to(_('Click here'), '#'), + link: "#{accept_user_invitation_url}/abc123" + }) %> +
+<%= _("Thank you,") %>
+<%= _("The %{org_name} DMPTool team") % { org_name: template.org.name } %>
+<%= _("Please do not reply to this email. If you have any questions or need help, please contact us at %{org_admin_email}") % { + org_admin_email: link_to(template.org.contact_email, template.org.contact_email) + } %>
+| <%= _('Template Name') %> <%= paginable_sort_link('templates.title') %> | + <% if action_name == 'organisational' %> +<%= _('Description') %> <%= paginable_sort_link('templates.description') %> | + <% else %> +<%= (action_name == 'customizable' ? _('Funder') : _('Organisation')) %> <%= paginable_sort_link('orgs.name') %> | + <% end %> +<%= _('Status') %> | +<%= _('Edited Date') %> <%= paginable_sort_link('templates.updated_at') %> | + <% if action_name != 'index' %> ++ <% end %> + |
|---|---|---|---|---|---|
| + <%= link_to "#{template.title}", edit_org_admin_template_path(template.id), + class: 'c-template-title' %> + | ++ <%= action_name == 'organisational' ? sanitize(template.description) : template.org.name %> + | ++ <%# Leaving this line here as a placeholder for determining how to notify user of changes now that dirty flag is removed %> + <%# if template.dirty? %> + <%# _('Unpublished changes') %> + + <% if template.published? %> + <%= _('Published') %> + <% elsif template.draft? %> + <% tooltip = _('You have unpublished changes! Select "Publish changes" in the Actions menu when you are ready to make them available to users.') %> + <%= _('Published') %> <%= tooltip %> + + <% else %> + <%= _('Unpublished') %> + <% end %> + | ++ <% last_temp_updated = template.updated_at %> + <%= l last_temp_updated.to_date, formats: :short %> + | + <% if action_name != 'index' %> +
+
+
+
+
+ |
+ <% end %>
+
<%= raw _("Participating institutions/organizations can configure the tool to point to their resources and services, provide customized help, and provide suggested answers to the questions asked by funding agencies. DMPTool users affiliated with participating institutions can login with their own institutional accounts. For more information visit the %{about_us_link} page.") % { about_us_link: link_to(_('About'), about_us_path) } %>
+ + <% if @orgs.count > 0 %> + <%= paginable_renderise( + partial: '/paginable/orgs/public', + controller: 'paginable/orgs', + action: 'public', + scope: @orgs, + query_params: { sort_field: "orgs.name", sort_direction: :asc }) %> + <% end %> +<%= _('Templates for data management plans are based on the specific requirements listed in funder policy documents. The DMPTool maintains these templates, however, researchers should always consult the program officers and policy documents directly for authoritative guidance. Sample plans are provided by a funder or another trusted party.') %>
+ <% else %> +<%= _('There are currently no public Templates.')%>
+ <% end %> +<%= _('- or - ') %>
+ <%else%> + <%= f.hidden_field :shibboleth_id, :value => session[:shibboleth_data][:uid] %> + <%end%> + <% end %> + +<%= _('- or -') %>
+<%= _("The DMPTool is a free, open-source, online application that helps researchers create data management plans. These plans, or DMPs, are now required by many funding agencies as part of the grant proposal submission process. The DMPTool provides a click-through wizard for creating a DMP that complies with funder requirements. It also has direct links to funder websites, help text for answering questions, and resources for best practices surrounding data management.") %>
+<%= sanitize _("The original DMPTool was a grassroots effort, beginning in 2011 with eight institutions partnering to provide in-kind contributions of personnel and development. The effort was in direct response to demands from funding agencies, such as the National Science Foundation and the National Institutes of Health, that researchers plan for managing their research data. By joining forces the contributing institutions were able to consolidate expertise and reduce costs in addressing data management needs. Representatives from these institutions participate on the DMPTool Steering Committee that maintains monthly calls.") % { steering_committee_url: 'https://github.com/CDLUC3/dmptool/wiki/Steering-Committee' } %>
+ +<%= sanitize _("The original contributing institutions were: %{uc3_url} at the %{cdl_url}, %{data_one_url}, %{dcc_url}, %{smithsonian_url}, %{ucla_url}, %{ucsd_url}, %{uoi_url}, and %{uva_url}. Given the success of the first version of the DMPTool, the founding partners obtained funding from the %{sloan_url} to create a second version of the tool, released in 2014.") % { uc3_url: 'University of California Curation Center (UC3)', cdl_url: 'California Digital Library', data_one_url: 'DataONE', dcc_url: 'Digital Curation Centre (DCC-UK)', smithsonian_url: 'Smithsonian Institution', ucla_url: 'University of California, Los Angeles Library', ucsd_url: 'University of California, San Diego Libraries', uoi_url: 'University of Illinois, Urbana-Champaign Library', uva_url: 'University of Virginia Library', sloan_url: 'Alfred P. Sloan Foundation' } %>
+ +<%= _("More recently the proliferation of open data policies across the globe has led to an explosion of interest in the DMPTool and the UK-based version, DMPonline. In 2016 UC3 and DCC decided to formalize our partnership to codevelop and maintain a single open-source platform. The new platform—DMPRoadmap—is separate from the services each of our organizations runs on top of it. By providing a core infrastructure for DMPs we can extend our reach and move best practices forward, allowing us to participate in a truly global open science ecosystem.") %>
+ +<%= sanitize _("Future enhancements will focus on making DMPs machine actionable so please continue sharing your use cases. We invite you to peruse the DMPRoadmap GitHub wiki to learn how to get involved in the project.") % { get_involved_url: 'https://github.com/DMPRoadmap/roadmap/wiki/get-involved' } %>
+<%= sanitize _("DMPTool participants are institutions, profit and nonprofit organizations, individuals, or other groups that leverage the DMPTool as an effective and efficient way to create data management plans. Our community of participating organizations helps to sustain and support the DMPTool in the following ways:
<%= sanitize _("See a current list of DMPTool participants.") % { participants_url: public_orgs_path } %> + <%= sanitize _("Use the contact form below if you are interested in adding your organization.") %>
+<%= sanitize _("Our work on the DMPTool is guided by the following principles. Organizations that participate in the DMPTool community are expected to understand and abide by these principles.
<%= _("The DMPTool Editorial Board works to ensure the tool provides current information about grant requirements and corresponding guidance. Our Board includes representation across disciplines with varied areas of expertise, from a wide range of institutions committed to supporting effective research data management.") %>
+<%= sanitize _("Can't find what you’re looking for? Contact us") % { contact_us_url: contact_us_path } %>
+ +<%= _("A: A data management plan is a formal document that outlines what you will do with your data during and after a research project. Most researchers collect data with some form of plan in mind, but it's often inadequately documented and incompletely thought out. Many data management issues can be handled easily or avoided entirely by planning ahead. With the right process and framework it doesn't take too long and can pay off enormously in the long run.") %>
+ +<%= sanitize _('Read our Data Management General Guidance for more information about data management plans.') % { general_guidance_url: general_guidance_path } %>
+<%= sanitize _('A: The DMPTool helps researchers create data management plans (DMPs). It provides guidance from specific funders who require DMPs, but the tool can be used by anyone interested in developing generic DMPs to help facilitate their research. The tool also offers resources and services available at participating institutions to help fulfill data management requirements.') % { participating_url: public_orgs_path } %>
+ +<%= sanitize _('Use our Quick Start Guide to begin creating a plan.') % { help_url: help_path } %>
+<%= _('A: The DMPTool is FREE. Anyone can create data management plans using the DMPTool.') %>
+ +<%=sanitize _('A login is required to access the DMPTool. If you are a researcher from a participating institutions, you can log in as a user from your institution. If your institution does not participate, you can create your own account.') % { participating_url: public_orgs_path } %>
+ +<%= sanitize _('Use our Quick Start Guide to begin creating a plan.') % { help_url: help_path} %>
+<%= sanitize _('A: Anyone can create data management plans. If you are a researcher from one of the participating institutions, you can log in as a user from your institution and you will be presented with local guidance to help you complete your plan.') % {participating_url: public_orgs_path } %>
+ +<%= _('If your institution does not participate, you can create your own account.') %>
+ +<%= sanitize _('Use our Quick Start Guide to begin creating a plan.') % { help_url: help_path } %>
+<%= sanitize _('A: First, check this participating institutions to make sure your institution is not already participating. Learn more about becoming a participating institution.') % { participating_url: public_orgs_path, about_url: about_us_path } %>
+ +<%= sanitize _('If you are a researcher or potential user, we suggest you talk to a librarian at your institution. If you are an administrator (librarian or otherwise) and interested in joining, please contact us.') % { contact_url: contact_us_path } %>
+<%= _('A: Participating institutions can incorporate information about their resources and services to aid researchers with data management. Participating institutions can also provide customized help and suggest answers to the questions asked by funding agencies. Users from particpating institutions that have configured the tool with Shibboleth can log in with their own institutional accounts.') %>
+ +<%= sanitize _('For more information, see About participating.') % { about_url: about_us_path } %>
+<%= _('A: No funders have endorsed the use of the DMPTool, although some provide links to the tool or resources within the tool in their public access plans (e.g., NEH, DOT).') %>
+ +<%= _('Despite the lack of formal endorsements, the DMPTool templates incorporate specific data management planning requirements from a range of funders including foundations and government agencies. We are in close contact with some funders as we create templates and for all funders we monitor public notices and websites for changes.') %>
+<%= sanitize _('A: Data management plans are the intellectual property of their creators. The California Digital Library makes no claim of copyright or ownership to the data management plans created using the DMPTool. You can, however, choose to share your plan publicly and it will appear in our library of public plans on the DMPTool website. This will benefit other DMPTool users and promote open research.') % { public_plans_url: public_plans_path } %>
+ +<%= sanitize _('See the Quick Start Guide for more information on setting your plan\'s visibility.') % { help_url: help_path } %>
+<%= _('A: There are multiple actions that generate automatic email notifications from the DMPTool. Users can turn these notifications on/off on the profile page. Navigate to "Edit profile" by clicking your name in the upper right dropdown menu, then select the "Notification preferences" tab, check/uncheck the appropriate boxes, and click the button to save your changes.') %>
+<%= sanitize _('Yes! The Quick Start Guide is available as a website or a PDF you can download.') % { help_url: help_path } %>
+<%= sanitize _('A: The Funder Requirements page provides direct links to funder guidelines, as well as sample plans if provided. You do not have to be logged into the DMPTool to access this page.') % { public_templates_url: public_templates_path } %>
+<%= sanitize _('A: The DMPTool hosts a collection of public plans. The collection contains actual plans created by DMPTool users who have opted to share their plans publicly. Please note that these plans have not been vetted for quality. Some funders provide sample plans on their websites; links to these plans are available on the Funder Requirements page.') % { public_plans_url: public_plans_path, public_templates_url: public_templates_path } %>
+<%= sanitize _('A: The sample plans on the Funder Requirements page are created by funders and offered as guidance on their websites. The Public Plans are actual plans created by users of the DMPTool (please note that these have not been vetted for quality). Both provide helpful examples for researchers creating their own data management plans.') % { public_plans_url: public_plans_path, public_templates_url: public_templates_path } %>
+<%= sanitize _('A: There are three visibility options for each plan you create:
<%= sanitize _('A: Follow these steps to remove your test plan:
<%= _('A: We do not plan to delete any plans created with the DMPTool. As a plan owner, however, you can delete plans by going to “My Dashboard” and selecting “Remove” from the Actions menu next to the plan name.') %>
+<%= _('A: Since the DMPTool account is tied to an email address, the information will not automatically follow a user if they change institutions. However, we can connect users to their plans from previous institutions if they contact us. Users who change institutions and assume new institutional credentials must create a new DMPTool account.') %>
+<%= sanitize _('A: If your institution participates in the DMPTool, you can log into the tool and click on the “Contact” link in the top banner. Your email will be sent directly to an expert on your campus who can follow up with you. If your institution does not participate in the DMPTool, check with a librarian to see if someone on campus can help.') % { participating_url: public_orgs_path } %>
+<%= _('A: First you must create an account in the DMPTool and begin creating a plan. Then you can add your collaborator(s) to your plan as co-owner(s), or grant editor or read only permissions. You can do this on the “Share” tab for your plan. Enter an email address in the field to "Invite collaborators,” select the desired level of permissions, and click "Submit" to send an email invitation.') %>
+ +<%= sanitize _('For more information, see the Quick Start Guide.') % { help_url: help_path } %>
+<%= _('A: The request feedback functionality of the DMPTool is an optional feature that institutions may configure to help researchers create data management plans. Submitting a plan for feedback is within-institution only. If this feature is enabled, a button to "Request feedback" will be displayed on the "Share" tab when writing a plan. If a user clicks the "Request feedback" button, the DMPTool will send an email to the plan owner and the institutional administrator contact. The institutional administrator will be granted read only permissions and be able to provide comments on the plan within the tool.') %>
+ +<%= sanitize _('Administrators: if you are interested in enabling this functionality for users at your institution, see the "Request feedback" section of the help for administrators wiki.') % { admin_help_url: 'https://github.com/cdluc3/dmptool/wiki/Help-for-Administrators' } %>
+<%= sanitize _('A: There is an extensive list of help topics for administrators customizing the DMPTool located on GitHub. You will find detailed instructions for:
<%= _('A: Yes! To view guidance created by others, create a new plan in the tool. On the "Project details" tab you will see "Plan guidance configuration" on the right. Click to "See the full list" and you can select up to 6 different organizations at a time. You will see the selected guidance when you navigate to the "Write plan" tab.') %>
+ +<%= _('To view templates created by others (if available), create a new plan in the tool and choose any organization for the second step: "Select the primary research organization." For the third create plan step, if you tick the box "No funder associated with this plan" you will be presented with any available organizational templates. If the field remains gray and no templates with organizational names appear, then the selected organization has not created any of their own templates and you will be presented with the default DMPTool template after clicking the button to "Create plan."') %>
+<%= sanitize _('A: DMPTool administrators can delete templates. This option is only available if no plans have been created using that template. If you cannot or do not want to delete a template, you can "Unpublish" the template so that it will not appear to users. See our help documentation on how to do this.') % { admin_help_url: 'https://github.com/cdluc3/dmptool/wiki/Help-for-Administrators' } %>
+<%= sanitize _('A: The help menu for administrators contains detailed instructions for creating themed guidance. The basic steps include:
<%= _('There are 14 themes that represent the most common topics addressed in data management plans (e.g., Data format, Metadata). Themes work like tags to associate questions and guidance. Questions within a template can be tagged with one or more themes, and guidance can be written by theme to allow organizations to apply their advice over all templates at once. This also alleviates the need to update guidance each time a new template is released.') %>
+<%= sanitize _("Can't find the answer you’re looking for? Contact us") % { contact_url: contact_us_path } %>
+<%= _('A data management plan is a formal document that outlines what you will do with your data during and after a research project. Most researchers collect data with some form of plan in mind, but it\'s often inadequately documented and incompletely thought out. Many data management issues can be handled easily or avoided entirely by planning ahead. With the right process and framework it doesn\'t take too long and can pay off enormously in the long run.') %>
+ +<%= sanitize _('In February of 2013, the White House Office of Science and Technology Policy (OSTP) issued a %{memorandum_url} directing Federal agencies that provide significant research funding to develop a plan to expand public access to research. Among other requriments, the plans must:
"Ensure that all extramural researchers receiving Federal grants and contracts for scientific research and intramural researchers develop data management plans, as appropriate, describing how they will provide for long-term preservation of, and access to, scientific data in digital formats resulting from federally funded research, or explaining why long-term preservation and access cannot be justified"') % { memorandum_url: 'memorandum' } %> + +
<%= _('The National Science Foundation (NSF) requires a 2-page plan as part of the funding proposal process. Most or all US Federally funded grants will eventually require some form of data management plan.') %>
+ +<%= _('We have been working with internal and external partners to make data management plan development less complicated. By getting to know your research and data, we can match your specific needs with data management best practices in your field to develop a data management plan that works for you. If you do this work at the beginning of your research process, you will have a far easier time following through and complying with funding agency and publisher requirements.') %>
+ +<%= _('We recommend that those applying for funding from US Federal agencies, such as the NSF, use the DMPTool. The DMPTool provides guidance for many of the NSF Directorate and Division requirements, along with links to additional resources, services, and help.') %>
+<%= _('Research projects generate and collect countless varieties of data. To formulate a data management plan, it\'s useful to categorize your data in four ways: by source, format, stability, and volume.') %>
+ +<%= _('Although data comes from many different sources, they can be grouped into four main categories. The category(ies) your data comes from will affect the choices that you make throughout your data management plan.') %>
+ +<%= sanitize _('Observational
<%= sanitize _('Experimental
<%= sanitize _('Simulation
<%= sanitize _('Derived / Compiled
<%= sanitize _('Data can come in many forms, including:
<%= sanitize _('Data can also be fixed or changing over the course of the project (and perhaps beyond the project\'s end). Do the data ever change? Do they grow? Is previously recorded data subject to correction? Will you need to keep track of data versions? With respect to time, the common categories of dataset are:
<%= _('The answer to this question affects how you organize the data as well as the level of versioning you will need to undertake. Keeping track of rapidly changing datasets can be a challenge so it is imperative that you begin with a plan to carry you through the entire data management process.') %>
+ +<%= _('For instance, image data typically requires a lot of storage space, so you\'ll want to decide whether to retain all your images (and, if not, how you will decide which to discard) and where such large data can be housed. Be sure to know your archiving organization\'s capacity for storage and backups.') %>
+ +<%= sanitize _('To avoid being under-prepared, estimate the growth rate of your data. Some questions to consider are:
<%= _('The file format you choose for your data is a primary factor in someone else\'s ability to access it in the future. Think carefully about what file format will be best to manage, share, and preserve your data. Technology continually changes and all contemporary hardware and software should be expected to become obsolete. Consider how your data will be read if the software used to produce it becomes unavailable. Although any file format you choose today may become unreadable in the future, some formats are more likely to be readable than others.') %>
+ +<%= _('If you find it necessary or convenient to work with data in a proprietary/discouraged file format, do so, but consider saving your work in a more archival format when you are finished.') %>
+ +<%= sanitize _('For more information on recommended formats, see the UK Data Service guidance on recommended formats.') % { recommended_formats_url: 'https://www.ukdataservice.ac.uk/manage-data/format/recommended-formats' } %>
+ +<%= sanitize _('Tabular data warrants special mention because it is so common across disciplines, mostly as Excel spreadsheets. If you do your analysis in Excel, you should use the "Save As..." command to export your work to .csv format when you are done. Your spreadsheets will be easier to understand and to export if you follow best practices when you set them up, such as:
<%= sanitize _('These are rough guidelines to follow to help manage your data files in case you don\'t already have your own internal conventions. When organizing files, the top-level directory/folder should include:
<%= _('The sub-directory structure should have clear, documented naming conventions. Separate files or directories could apply, for example, to each run of an experiment, each version of a dataset, and/or each person in the group.') %>
+ +<%= _('Tools to help you:') %>
+<%= _('Clear and detailed documentation is essential for data to be understood, interpreted, and used. Data documentation describes the content, formats, and internal relationships of your data in detail and will enable other researchers to find, use, and properly cite your data.') %>
+ +<%= _('Begin to document your data at the very beginning of your research project and continue throughout the project. Doing so will make the process much easier. If you have to construct the documentation at the end of the project, the process will be painful and important details will have been lost or forgotten. Don\'t wait to document your data!') %>
+ +<%= sanitize _('Research Project Documentation
<%= sanitize _('Dataset documentation
<%= sanitize _('Data documentation is commonly called metadata – "data about data". Researchers can document their data according to various metadata standards. Some metadata standards are designed for the purpose of documenting the contents of files, others for documenting the technical characteristics of files, and yet others for expressing relationships between files within a set of data. If you want to be able to share or publish your data, the DataCite metadata standard is of particular signficiance.') % { datacite_standards_url: 'https://schema.datacite.org/' } %>
+ +<%= _('Below are some general aspects of your data that you should document, regardless of your discipline. At minimum, store this documentation in a "readme.txt" file, or the equivalent, with the data itself.') %>
+ +<%= _('General Overview') %>
+<%= _('Content Description') %>
+<%= _('Technical Description') %>
+<%= _('Access') %>
+<%= sanitize _('If you want to be able to share or cite your dataset, you\'ll want to assign a public persistent unique identifier to it. There are a variety of public identifier schemes, but common properties of good schemes are that they are:
<%= _('Here are some identifier schemes:') %>
+<%= sanitize _('Data security is the protection of data from unauthorized access, use, change, disclosure, and destruction. Make sure your data is safe in regards to:
<%= sanitize _('Unencrypted data will be more easily read by you and others in the future, but you may need to encrypt sensitive data.
<%= sanitize _('Uncompressed data will be also be easier to read in the future, but you may need to compress files to conserve disk space.
<%= sanitize _('Making regular backups is an integral part of data management. You can backup data to your personal computer, external hard drives, or departmental or university servers. Software that makes backups for you automatically can simplify this process considerably. The UK Data Archive provides additional guidelines on data storage, backup, and security.') % { storage_guidelines_url: 'https://www.ukdataservice.ac.uk/manage-data/store' } %>
+ +<%= _('Backup Your Data') %>
+<%= _('Test your backup system') %>
+<%= _('Who is responsible for managing and controlling the data?') %>
+<%= _('For what or whom are the data intended?') %>
+<%= _('How long should the data be retained?') %>
+<%= _('Beyond any externally imposed requirments, think about the long-term usefulness of the data. If the data is from an experiment that you anticipate will be repeatable more quickly, inexpensively, and accurately as technology progresses, you may want to store it for a relatively brief period. If the data consists of observations made outside the laborartory that can never be repeated, you may wish to store it indefinitely.') %>
+<%= sanitize _('
<%= sanitize _('
<%= sanitize _('While the first three options above are valid ways to share data, a repository is much better able to provide long-term access. Data deposited in a repository can be supplemented with a "data paper"—a relatively new type of publication that describes a dataset, but does not analyze it or dsanitize any conclusions—published in a journal such as Nature Scientific Data or Geoscience Data Journal.') % { nature_url: 'https://www.nature.com/sdata/', geoscience_url: 'http://rmets.onlinelibrary.wiley.com/hub/journal/10.1002/(ISSN)2049-6060/' } %>
+ +<%= sanitize _('You should select a repository or archive for your data based on the long-term security offered and the ease of discovery and access by colleagues in your field. There are two common types of repository to look for:
<%= sanitize _('A searchable and browsable list of repositories can be found at these websites:
<%= sanitize _('Citing data is important in order to:
<%= sanitize _('A dataset should be cited formally in an article\'s reference list, not just informally in the text. Many data repositories and publishers provide explicit instructions for citing their contents. If no citation information is provided, you can still construct a citation following generally agreed-upon guidelines from sources such as the Force 11 Joint Declaration of Data Citation Principles and the current DataCite Metadata Schema.') % {force11_citation_url: 'https://www.force11.org/datacitationprinciples', datacite_standards_url: 'https://schema.datacite.org/' } %>
+ +<%= _('Core elements') %>
+<%= _('Common additional elements') %>
+<%= _('Example citations') %>
+ <%= sanitize _('<%= _('If you are uncertain as to your rights to disseminate data, UC researchers can consult with your campus Office of General Council. Note: Laws about data vary outside the U.S.') %>
+ +<%= sanitize _('For a general discussion about publishing your data, applicable to many disciplines, see the ICPSR Guide to Social Science Data Preparation and Archiving.') % { icpsr_url: 'https://www.icpsr.umich.edu/files/ICPSR/access/dataprep.pdf' } %>
+ +<%= _('It is vital to maintain the confidentiality of research subjects both as an ethical matter and to ensure continuing participation in research. Researchers need to understand and manage tensions between confidentiality requirements and the potential benefits of archiving and publishing the data.') %>
+ +<%= sanitize _('To ethically share confidential data, you may be able to:
<%= sanitize _('DMPTool is free for anyone to create data management plans. As a user, you can:
<%= _('Click on Sign in at the top-right of the home page. You can also click the white “Get started” button.') %>
+ +<%= sanitize _('If your institution/organization is affiliated with DMPTool:
+ <%= _('When you log in you will be directed to “My dashboard.” From here you can create, edit, share, download, copy, or remove any of your plans. You will also see plans that have been shared with you by others.') %>
+ +<%= sanitize _('If others at your institution/organization have chose to share their plans internally, you will see a second table of organizational plans. This allows you to download a PDF and view their plans as samples or to discover new research data. Additional samples are available in the list of public plans.') % { public_plans_url: public_plans_path } %>
+ +
+ <%= _('To create a plan, click the “Create plan” button on My dashboard or the top menu. This will take you to a wizard that helps you select the appropriate template:') %>
+ +
+
+ <%= sanitize _('
<%= _('If you are just testing the tool or taking a course on data management, check the box “Mock project for test, practice, or educational purposes.” Marking your plans as a test will be reflected in usage statistics and prevent public or organizational sharing; this allows other users to find real sample plans more easily.') %>
+ +<%= _('Once you have made your selections, click “Create plan.”') %>
+ +<%= _('You can also make a copy of an existing plan (from the Actions menu next to the plan on My dashboard) and update it for a new research project and/or grant proposal.') %>
+ +<%= sanitize _('The tabbed interface allows you to navigate through different functions when editing your plan.
<%= _('Input the email address(es) of any collaborators you would like to invite to read or edit your plan. Set their permissions via the radio buttons and click to "Add collaborator." Adjust permissions or remove collaborators at any time via the drop-down options in the table.') %>
+ +<%= sanitize _('The "Share" tab is also where you can set your plan visibility.
<%= _('By default all new and test plans will be set to Private visibility. Public and Organizational visibility are intended for finished plans. You must answer at least 50% of the questions to enable these options.') %>
+ +
+ <%= _('After logging in, you will find an email address and URL for help at the top of the page.') %>
+ +<%= _('There may also be an option to request feedback on your plan (on the “Share” tab). This is available when research support staff at your institution have enabled the service. Click to “Request feedback” and your local administrators will be alerted to your request. Their comments will be visible in the “Comments” field adjacent to each question. You will receive an email notification when an administrator provides feedback.') %>
+<%= _('Help spread the word about the DMPTool! Use the materials below to inform researchers, librarians, administrators and others about the tool. All materials are available under a CC-Zero license.') %>
+<%= sanitize _('Advertise the DMPTool to students and researchers at your institution.
') % { advertise_pdf1_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/postcard/DMPTool_postcard1.pdf', advertise_pdf2_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/postcard/DMPTool_postcard2.pdf' } %> +<%= sanitize _('Need to get others on your campus interested in the DMPTool? Use our Talking Points to guide your discussions. In general, a talking points document is designed to help you stay on track during meetings with those outside of your department. It ensures that your major points are at hand and helps you make progress towards the goals of the meeting.
<%= sanitize _('We created a generic slide deck that you can use to introduce researchers and others to the DMPTool.
') % { dmptool_slides_ppt_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/genericslides/DMPTool-Generic-Slides.pptx' } %> +<%= sanitize _('The California Digital Library (CDL) is supported by the University of California (UC). Our primary constituency is the UC research community; in addition, we provide services to the United States and international higher education sector.') % { cdlib_url: 'http://www.cdlib.org' } %>
+<%= sanitize _("DMPTool ('the tool', 'the system') is a tool developed by the CDL and the Digital Curation Centre (DCC) as a shared resource for the research community. It is hosted at CDL by the University of California Curation Center (UC3).") % { dcc_url: 'http://www.dcc.ac.uk/' } %>
+<%= _('In order to help identify and administer your account with the DMPTool, we need to store your email address. We may also use it to contact you to obtain feedback on your use of the tool, or to inform you of the latest developments or releases. The information may be transferred between the CDL and DCC partner organizations but only for legitimate CDL purposes. We will not sell, rent, or trade any personal information you provide to us.') %>
+<%= sanitize _('The information you enter into this system can be seen by you, people you have chosen to share access with, and—solely for the purposes of maintaining the service—system administrators at the CDL. We compile anonymized, automated, and aggregated information from plans, but we will not directly access, make use of, or share your content with anyone beyond CDL and your home institution without your permission. Authorized users at your home institution may access your plans for specific purposes—for example, to track compliance with funder/institutional requirements, to calculate storage requirements, or to assess demand for data management services across disciplines. For a detailed description of what information (other than the plans) we collect from visitors to this website and how it is used and managed, please see the CDL Privacy Policy and Baseline Supporting Practices listed at %{policies_url}') % {policies_url: 'https://cdlib.org/about/policies-and-guidelines/privacy-policy/' } %>
+<%= _('The CDL holds your plans on your behalf, but they are your property and responsibility. Any FOIA applicants will be referred back to your home institution.') %>
+<%= _('Your password is stored in encrypted form and cannot be retrieved. If forgotten it has to be reset.') %>
+<%= sanitize _('As noted in the CDL privacy policy, this website uses Google Analytics to capture and analyze usage statistics. You may choose to opt-out of having your website activity tracked by Google Analytics. To do so, visit the Google Analytics opt-out page and install the add-on for your browser.') % { opt_out_url: 'https://tools.google.com/dlpage/gaoptout' }%>
+<%= _('Certain features on this website utilize third party services and APIs such as InCommon/Shibboleth or third party hosting of common JavaScript libraries or web fonts. Information used by an external service is governed by the privacy policy of that service. CDL does not control how information may be used by these services.') %>
+<%= _('This statement was last revised on October 5, 2017 and may be revised at any time. Use of the tool indicates that you understand and agree to these terms and conditions.') %>
+<%= _("Please list the project’s Principal Investigator(s) and those responsible for data management.") %>
@@ -29,10 +30,23 @@<%= _("No contributors have been defined.") %>
<% end %> - <%= link_to _("Add a contributor"), new_plan_contributor_path(@plan), - class: "btn btn-primary" %> + <% if @plan.administerable_by?(current_user.id) %> + <%= link_to _("Add a contributor"), new_plan_contributor_path(@plan), + class: "btn btn-primary" %> + <% end %><%= _('Hello %{user_name}') %{ :user_name => user_name } %>
- <%= _("Your colleague %{inviter_name} has invited you to contribute to "\ - " their Data Management Plan in %{tool_name}") % { - tool_name: tool_name, - inviter_name: inviter_name - } %> -
-- <%= sanitize(_('%{click_here} to accept the invitation, (or copy %{link} into your browser). If you don\'t want to accept the invitation, please ignore this email.') % { - click_here: link_to(_('Click here'), link), link: link - }) %> -
-
- <%= _('All the best') %>
-
- <%= _('The %{tool_name} team') %{:tool_name => tool_name} %>
-
- <%= _('Please do not reply to this email.') %> - <%= sanitize(_('If you have any questions or need help, please contact us at %{helpdesk_email} or visit %{contact_us_url}') % { - helpdesk_email: mail_to(helpdesk_email, helpdesk_email, - subject: email_subject), - contact_us_url: link_to(contact_us, contact_us) - }) %> + <% if inviter.is_a?(Org) %> + <%# This was an inivitation generated by an OrgAdmin using 'Email template' %> + <%= sanitize(plan.template.email_body % { + dmp_title: plan.title, + org_name: inviter.name, + org_admin_email: link_to(inviter.contact_email, inviter.contact_email) + }) %> + + <% elsif inviter.is_a?(ApiClient) %> + <%# This was an inivitation generated by a 'create_dmps' scope in API V2+ %> + <%= sanitize(plan.template.org.api_create_plan_email_body % { + external_system_name: inviter.description + }) %> + + <% else %> + <%# This was an inivitation on the Collaborator section of the Plan pages %> + <%= _("Your colleague %{inviter_name} has invited you to contribute to "\ + " their Data Management Plan in %{tool_name}") % { + tool_name: tool_name, + inviter_name: inviter&.name(false) + } %> + <% end %>
+ + <% if inviter.is_a?(User) %> ++ <%= sanitize(_('%{click_here} to accept the invitation, (or copy %{link} into your browser). If you don\'t want to accept the invitation, please ignore this email.') % { + click_here: link_to(_('Click here'), link), link: link + }) %> +
+ <%= render partial: 'user_mailer/email_signature', + locals: { + tool_name: ApplicationService.application_name, + helpdesk_email: Rails.configuration.x.helpdesk_email, + allow_change_prefs: false + } %> + <% else %> ++ <%= sanitize(_("%{click_here} to setup your account (or copy %{link} into your browser). Once you have signed in, you can begin filling out your DMP.") % { + click_here: link_to(_('Click here'), link), link: link + }) %> +
+<%= _("Thank you,") %>
+<%= _("The %{org_name} DMPTool team") % { org_name: org&.name } %>
+<%= _("Please do not reply to this email. If you have any questions or need help, please contact us at %{org_admin_email}") % { + org_admin_email: link_to(org&.contact_email, org&.contact_email) + } %>
+ <% end %> <% end %> diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb index b658a735bd..392ad7497b 100644 --- a/app/views/devise/passwords/edit.html.erb +++ b/app/views/devise/passwords/edit.html.erb @@ -24,7 +24,7 @@ <%= _('Show passwords') %><%= _('The email address you entered is not registered.') %>
<% end %> - +<%= _('Please enter your email below and we will send you instructions on how to reset your password.') %>
diff --git a/app/views/devise/registrations/_api_client_form.html.erb b/app/views/devise/registrations/_api_client_form.html.erb new file mode 100644 index 0000000000..eb6d4d116d --- /dev/null +++ b/app/views/devise/registrations/_api_client_form.html.erb @@ -0,0 +1,123 @@ +<%# locals: api_client, user %> + +<%= _("The DMPTool now allows you to add your DMPs to the works section of your ORCID record. Please disconnect your account by clicking the 'x' below and then reconnect your account to enable this functionality.") %>
+ <% end %> <%= link_to id.value, id: 'orcid-id', target: '_blank', diff --git a/app/views/devise/registrations/_personal_details.html.erb b/app/views/devise/registrations/_personal_details.html.erb index 6e2ab54c67..5db4f0f66d 100644 --- a/app/views/devise/registrations/_personal_details.html.erb +++ b/app/views/devise/registrations/_personal_details.html.erb @@ -66,26 +66,6 @@<%= @pre_auth.error_response.body[:error_description] %>+
+ <%= sanitize(_("%{application} wants to access your DMPs. Please confirm this action.") % { application: "#{@pre_auth.client.name.humanize}" }) %> +
+ <% else %> ++ <%= sanitize(_("%{application} wants to access your DMPs. Please sign in to verify your account.") % { application: "#{@pre_auth.client.name.humanize}" }) %> +
+ <% end %> + + <% if @current_resource_owner.present? %> + <% if @pre_auth.scopes.count > 0 %> +<%= t('.able_to') %>:
+ +- <%= _('Or if your institution is not listed') %> -
+ <% end %> + + <%= render partial: "shared/sign_in_form", locals: { resource: User.new } %> + +<%= flash[:alert] %>
+ <% end %> +<%= _("The following email will be sent to users when an external system creates a new DMP (via the API) using one of your templates.") %>
+ +<%= _("Note that you can use the '%{external_system_name}' variable in the subject and/or body. The system will insert the name of the external system before sending the email (for example 'Example University - RDMS').") %>
++ Hello jane.doe@example.org, +
++ <%= sanitize(org.api_create_plan_email_body) % { + external_system_name: "[placeholder for external system name]" + } %> +
++ <%= sanitize(_("%{click_here} to setup your account (or copy %{link} into your browser). Once you have signed in, you can begin filling out your DMP.") % { + click_here: link_to(_('Click here'), '#'), + link: "#{accept_user_invitation_url}/abc123" + }) %> +
+<%= _("Thank you,") %>
+<%= _("The %{org_name} DMPTool team") % { org_name: org.name } %>
+<%= _("Please do not reply to this email. If you have any questions or need help, please contact us at %{org_admin_email}") % { + org_admin_email: link_to(org.contact_email, org.contact_email) + } %>
+
- <%= _('or') %> -
diff --git a/app/views/paginable/api_clients/_index.html.erb b/app/views/paginable/api_clients/_index.html.erb
index 798cff9e7e..b9098f43ae 100644
--- a/app/views/paginable/api_clients/_index.html.erb
+++ b/app/views/paginable/api_clients/_index.html.erb
@@ -14,6 +14,9 @@
| + <%= _('Client') %> <%= paginable_sort_link('api_clients.name') %> + | ++ <%= _('Date') %> <%= paginable_sort_link('api_logs.created_at') %> + | ++ <%= _('Type') %> <%= paginable_sort_link('api_logs.change_type') %> + | ++ <%= _('Subject') %> <%= paginable_sort_link('api_logs.logable_id') %> + | ++ <%= _('Details') %> + | +
|---|---|---|---|---|
| <%= entry.api_client&.name %> | +<%= l(entry.created_at.to_date, formats: :short) %> | +<%= entry.change_type %> | +
+ <% case entry.logable_type %>
+ <% when 'Plan' %>
+ <%= link_to(_('Plan: % |
+
+ <% entry.activity.split(' ').each do |part| %> + <%= part %> + + <% end %> + |
+
<%= _(<<-TEXT @@ -22,10 +23,14 @@
| <%= _('Owner') %> | <%= _('Updated') %> <%= paginable_sort_link('plans.updated_at') %> | <%= _('Visibility') %> | + <% if has_dmp_id %> +<%= _('DMP ID') %> | + <% end %> <% scope.each do |plan| %> + <% id_presenter = IdentifierPresenter.new(identifiable: plan) %>||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| <% if plan.readable_by?(current_user.id) %> @@ -21,12 +27,21 @@ <% end %> | <%= plan.template.title %> | -<%= plan.owner.org.name %> | -<%= plan.owner.name(false) %> | +<%= plan.owner&.org&.name %> | +<%= plan.owner&.name(false) %> | <%= l(plan.updated_at.to_date, formats: :short) %> | <%= plan.visibility === 'is_test' ? _('Test') : sanitize(display_visibility(plan.visibility)) %> | + <% if has_dmp_id %> ++ <% if plan.dmp_id.present? %> + + <%= id_for_display(id: plan.dmp_id, with_scheme_name: false).html_safe %> + + <% end %> + | + <% end %><%= _('DMP ID') %> | + <% end %><%= _('Download') %> | <% scope.each do |plan| %> + <% id_presenter = IdentifierPresenter.new(identifiable: plan) %>|||||||||||||||||||||||||||
| <%= truncate plan.title, length: 40 %> @@ -30,6 +36,15 @@ | <%= plan.template.title %> | <%= plan.owner.present? ? plan.owner.name : _('Unknown') %> | <%= l(plan.updated_at.to_date, formats: :short) %> | + <% if has_dmp_id %> ++ <% if plan.dmp_id.present? %> + + <%= id_for_display(id: plan.dmp_id, with_scheme_name: false).html_safe %> + + <% end %> + | + <% end %>
<%= link_to plan_export_path(plan, format: :pdf, export: { question_headings: true }),
class: 'has-new-window-popup-info',
diff --git a/app/views/paginable/plans/_privately_visible.html.erb b/app/views/paginable/plans/_privately_visible.html.erb
index 59d7858e27..0aa81c897f 100644
--- a/app/views/paginable/plans/_privately_visible.html.erb
+++ b/app/views/paginable/plans/_privately_visible.html.erb
@@ -1,3 +1,5 @@
+<% has_dmp_id = scope.select { |plan| plan.dmp_id.present? }.any? %>
+
|