diff --git a/.github/workflows/mysql.yml b/.github/workflows/mysql.yml index 11d2a19bd6..740bd53d1f 100644 --- a/.github/workflows/mysql.yml +++ b/.github/workflows/mysql.yml @@ -42,7 +42,7 @@ jobs: # Try to retrieve the gems from the cache - name: 'Cache Gems' - uses: actions/cache@v1 + uses: actions/cache@v2.1.5 with: path: vendor/bundle key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} @@ -62,7 +62,7 @@ jobs: # Try to retrieve the yarn JS dependencies from the cache - name: 'Cache Yarn Packages' - uses: actions/cache@v1 + uses: actions/cache@v2.1.5 with: path: node_modules/ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} diff --git a/.github/workflows/postgres.yml b/.github/workflows/postgres.yml index 29454be8a5..5681a97364 100644 --- a/.github/workflows/postgres.yml +++ b/.github/workflows/postgres.yml @@ -59,7 +59,7 @@ jobs: # Try to retrieve the gems from the cache - name: 'Cache Gems' - uses: actions/cache@v1 + uses: actions/cache@v2.1.5 with: path: vendor/bundle key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} @@ -79,7 +79,7 @@ jobs: # Try to retrieve the yarn JS dependencies from the cache - name: 'Cache Yarn Packages' - uses: actions/cache@v1 + uses: actions/cache@v2.1.5 with: path: node_modules/ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} diff --git a/app/assets/images/spinner.gif b/app/assets/images/spinner.gif new file mode 100644 index 0000000000..0b3ba6284d Binary files /dev/null and b/app/assets/images/spinner.gif differ diff --git a/app/assets/stylesheets/blocks/_spinner.scss b/app/assets/stylesheets/blocks/_spinner.scss new file mode 100644 index 0000000000..a812084c90 --- /dev/null +++ b/app/assets/stylesheets/blocks/_spinner.scss @@ -0,0 +1,10 @@ +.spinner-border { + position: fixed; + top: 48%; + left: 43%; +} + +.spinner-border img { + height: 80px; + width: 80px; +} \ No newline at end of file diff --git a/app/controllers/api/v0/plans_controller.rb b/app/controllers/api/v0/plans_controller.rb index 6841e1592a..5e30069bda 100644 --- a/app/controllers/api/v0/plans_controller.rb +++ b/app/controllers/api/v0/plans_controller.rb @@ -67,7 +67,9 @@ def index params[:per_page] = max_pages if params[:per_page].to_i > max_pages end - @plans = @user.org.plans.includes([{ roles: :user }, { answers: :question_options }, + # Get all the Org Admin plans + org_admin_plans = @user.org.org_admin_plans + @plans = org_admin_plans.includes([{ roles: :user }, { answers: :question_options }, template: [{ phases: { sections: { questions: %i[question_format themes] } } }, :org]]) diff --git a/app/controllers/concerns/paginable.rb b/app/controllers/concerns/paginable.rb index 738d6af215..aea376ed12 100644 --- a/app/controllers/concerns/paginable.rb +++ b/app/controllers/concerns/paginable.rb @@ -37,7 +37,7 @@ module Paginable # one approach to just include everything in the double splat `**options` param # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists - def paginable_renderise(partial: nil, controller: nil, action: nil, + def paginable_renderise(partial: nil, template: nil, controller: nil, action: nil, path_params: {}, query_params: {}, scope: nil, locals: {}, **options) unless scope.is_a?(ActiveRecord::Relation) @@ -50,6 +50,7 @@ def paginable_renderise(partial: nil, controller: nil, action: nil, # Default options @paginable_options = {}.merge(options) @paginable_options[:view_all] = options.fetch(:view_all, true) + @paginable_options[:remote] = options.fetch(:remote, true) # Assignment for paginable_params based on arguments passed to the method @args = paginable_params.to_h @args[:controller] = controller if controller @@ -72,14 +73,17 @@ def paginable_renderise(partial: nil, controller: nil, action: nil, locals = locals.merge( scope: @refined_scope, paginable_params: @args, - search_term: @args[:search] + search_term: @args[:search], + remote: @paginable_options[:remote] ) # If this was an ajax call then render as JSON if options[:format] == :json render json: { html: render_to_string(layout: "/layouts/paginable", partial: partial, locals: locals) } - else + elsif partial.present? render(layout: "/layouts/paginable", partial: partial, locals: locals) + else + render(template: template, locals: locals) end end end @@ -104,7 +108,7 @@ def paginable_sort_link(sort_field) sort_link_name(sort_field), sort_link_url(sort_field), class: "paginable-action", - data: { remote: true }, + data: { remote: @paginable_options[:remote] }, aria: { label: sort_field } ) end diff --git a/app/controllers/org_admin/plans_controller.rb b/app/controllers/org_admin/plans_controller.rb index bdb5723ffa..d04739b5fc 100644 --- a/app/controllers/org_admin/plans_controller.rb +++ b/app/controllers/org_admin/plans_controller.rb @@ -15,7 +15,7 @@ def index @super_admin = current_user.can_super_admin? @clicked_through = params[:click_through].present? - @plans = @super_admin ? Plan.all.page(1) : current_user.org.plans.page(1) + @plans = @super_admin ? Plan.all.page(1) : current_user.org.org_admin_plans.page(1) end # GET org_admin/plans/:id/feedback_complete @@ -61,7 +61,7 @@ def download_plans plans = CSV.generate do |csv| csv << header_cols - org.plans.includes(template: :org).order(updated_at: :desc).each do |plan| + org.org_admin_plans.includes(template: :org).order(updated_at: :desc).each do |plan| csv << [ plan.title.to_s, plan.template.title.to_s, diff --git a/app/controllers/paginable/plans_controller.rb b/app/controllers/paginable/plans_controller.rb index e143caea2e..e29d26bdf9 100644 --- a/app/controllers/paginable/plans_controller.rb +++ b/app/controllers/paginable/plans_controller.rb @@ -34,12 +34,10 @@ def organisationally_or_publicly_visible # GET /paginable/plans/publicly_visible/:page def publicly_visible - paginable_renderise( - partial: "publicly_visible", - scope: Plan.publicly_visible.includes(:template), - query_params: { sort_field: "plans.updated_at", sort_direction: :desc }, - format: :json - ) + # We want the pagination/sort/search to be retained in the URL so redirect instead + # of processing this as a JSON + paginable_params = params.permit(:page, :search, :sort_field, :sort_direction) + redirect_to public_plans_path(paginable_params.to_h) end # GET /paginable/plans/org_admin/:page @@ -49,7 +47,7 @@ def org_admin # check if current user if super_admin @super_admin = current_user.can_super_admin? @clicked_through = params[:click_through].present? - plans = @super_admin ? Plan.all : current_user.org.plans + plans = @super_admin ? Plan.all : current_user.org.org_admin_plans plans = plans.joins(:template, roles: [user: :org]).where(Role.creator_condition) paginable_renderise( diff --git a/app/controllers/paginable/templates_controller.rb b/app/controllers/paginable/templates_controller.rb index 0457842868..680e7eb8ca 100644 --- a/app/controllers/paginable/templates_controller.rb +++ b/app/controllers/paginable/templates_controller.rb @@ -85,18 +85,10 @@ def customisable # GET /paginable/templates/publicly_visible/:page (AJAX) # ----------------------------------------------------- def publicly_visible - templates = Template.live(Template.families(Org.funder.pluck(:id)).pluck(:family_id)) - .publicly_visible.pluck(:id) << - Template.where(is_default: true).unarchived.published.pluck(:id) - paginable_renderise( - partial: "publicly_visible", - scope: Template.joins(:org) - .includes(:org) - .where(id: templates.uniq.flatten) - .published, - query_params: { sort_field: "templates.title", sort_direction: :asc }, - format: :json - ) + # We want the pagination/sort/search to be retained in the URL so redirect instead + # of processing this as a JSON + paginable_params = params.permit(:page, :search, :sort_field, :sort_direction) + redirect_to public_templates_path(paginable_params.to_h) end # GET /paginable/templates/:id/history/:page (AJAX) diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb index 75c7f5be98..e6061c984b 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -124,7 +124,7 @@ def create elsif !@plan.template.customization_of.nil? # We used a customized version of the the funder template # rubocop:disable Layout/LineLength - msg += " #{_('This plan is based on the')} #{@plan.funder&.name}: '#{@plan.template.title}' #{_('template with customisations by the')} #{plan_params[:org_name]}" + msg += " #{_('This plan is based on the')} #{@plan.funder&.name}: '#{@plan.template.title}' #{_('template with customisations by the')} #{@plan.template.org.name}" # rubocop:enable Layout/LineLength else # We used the specified org's or funder's template @@ -219,7 +219,16 @@ def show # doing this when we refactor the Plan editing UI # GET /plans/:plan_id/phases/:id/edit def edit - plan = Plan.includes({ template: { phases: { sections: :questions } } }, { answers: :notes }) + plan = Plan.includes( + { template: { + phases: { + sections: { + questions: %i[question_format question_options annotations themes] + } + } + } }, + { answers: :notes } + ) .find(params[:id]) authorize plan phase_id = params[:phase_id].to_i @@ -258,7 +267,7 @@ def update if @plan.update(attrs) # _attributes(attrs) format.html do - redirect_to plan_contributors_path(@plan), + redirect_to plan_path(@plan), notice: success_message(@plan, _("saved")) end format.json do @@ -293,7 +302,7 @@ def share @plan = Plan.find(params[:id]) if @plan.present? authorize @plan - @plan_roles = @plan.roles + @plan_roles = @plan.roles.where(active: true) else redirect_to(plans_path) end @@ -306,7 +315,7 @@ def request_feedback @plan = Plan.find(params[:id]) if @plan.present? authorize @plan - @plan_roles = @plan.roles + @plan_roles = @plan.roles.where(active: true) else redirect_to(plans_path) end diff --git a/app/controllers/public_pages_controller.rb b/app/controllers/public_pages_controller.rb index 2ab686e2a8..d6dd911315 100644 --- a/app/controllers/public_pages_controller.rb +++ b/app/controllers/public_pages_controller.rb @@ -4,14 +4,23 @@ class PublicPagesController < ApplicationController # GET template_index # ----------------------------------------------------- + # rubocop:disable Metrics/AbcSize def template_index + @templates_query_params = { + page: paginable_params.fetch(:page, 1), + search: paginable_params.fetch(:search, ""), + sort_field: paginable_params.fetch(:sort_field, "templates.title"), + sort_direction: paginable_params.fetch(:sort_direction, "asc") + } + templates = Template.live(Template.families(Org.funder.pluck(:id)).pluck(:family_id)) .publicly_visible.pluck(:id) << Template.where(is_default: true).unarchived.published.pluck(:id) @templates = Template.includes(:org) .where(id: templates.uniq.flatten) - .unarchived.published.order(title: :asc).page(1) + .unarchived.published end + # rubocop:enable Metrics/AbcSize # GET template_export/:id # ----------------------------------------------------- @@ -79,13 +88,21 @@ def template_export # GET /plans_index # ------------------------------------------------------------------------------------ def plan_index - @plans = Plan.publicly_visible.includes(:template).page(1) + @plans = Plan.publicly_visible.includes(:template) render "plan_index", locals: { query_params: { - sort_field: "plans.updated_at", - sort_direction: "desc" + page: paginable_params.fetch(:page, 1), + search: paginable_params.fetch(:search, ""), + sort_field: paginable_params.fetch(:sort_field, "plans.updated_at"), + sort_direction: paginable_params.fetch(:sort_direction, "desc") } } end + private + + def paginable_params + params.permit(:page, :search, :sort_field, :sort_direction) + end + end diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 0773f8b4a5..298525d6b1 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -66,12 +66,12 @@ def create existing_user = User.where_case_insensitive("email", sign_up_params[:email]).first if existing_user.present? if existing_user.invitation_token.present? && !existing_user.accept_terms? - # Destroys the existing user since the accept terms are nil/false. and they - # have an invitation Note any existing role for that user will be deleted too. - # Added to accommodate issue at: https://github.com/DMPRoadmap/roadmap/issues/322 - # when invited user creates an account outside the invite workflow + # If the user is creating an account but they have an outstanding invitation, remember + # any plans that were shared with the invitee so we can attach them to the new User record + shared_plans = existing_user.roles + .select(&:active?) + .map { |role| { plan_id: role.plan_id, access: role.access } } existing_user.destroy - else redirect_to after_sign_up_error_path_for(resource), alert: _("That email address is already registered.") @@ -88,6 +88,17 @@ def create build_resource(attrs) + # If the user is creating an account but they have an outstanding invitation, attach the shared + # plan(s) to their new User record + if shared_plans.present? && shared_plans.any? + shared_plans.each do |role_hash| + plan = Plan.find_by(id: role_hash[:plan_id]) + next unless plan.present? + + Role.create(plan: plan, user: resource, access: role_hash[:access], active: true) + end + end + # Determine if reCAPTCHA is enabled and if so verify it use_recaptcha = Rails.configuration.x.application.use_recaptcha || false if (!use_recaptcha || verify_recaptcha(model: resource)) && resource.save diff --git a/app/controllers/roles_controller.rb b/app/controllers/roles_controller.rb index 8e6fe7aa57..58e583eb65 100644 --- a/app/controllers/roles_controller.rb +++ b/app/controllers/roles_controller.rb @@ -17,7 +17,10 @@ def create authorize @role message = "" - if role_params[:user].present? && plan.present? + if role_params[:user].present? && + role_params[:user].key?(:email) && + role_params[:user][:email].present? && plan.present? + if @role.plan.owner.present? && @role.plan.owner.email == role_params[:user][:email] # rubocop:disable Layout/LineLength flash[:notice] = _("Cannot share plan with %{email} since that email matches with the owner of the plan.") % { @@ -26,7 +29,11 @@ def create # rubocop:enable Layout/LineLength else user = User.where_case_insensitive("email", role_params[:user][:email]).first - if Role.find_by(plan: @role.plan, user: user) # role already exists + if user.present? && + Role.where(plan: @role.plan, user: user, active: true) + .count + .positive? # role already exists + flash[:notice] = _("Plan is already shared with %{email}.") % { email: role_params[:user][:email] } diff --git a/app/controllers/super_admin/notifications_controller.rb b/app/controllers/super_admin/notifications_controller.rb index e6b9686c31..fba40c042c 100644 --- a/app/controllers/super_admin/notifications_controller.rb +++ b/app/controllers/super_admin/notifications_controller.rb @@ -36,7 +36,7 @@ def create @notification.notification_type = "global" if @notification.save flash.now[:notice] = success_message(@notification, _("created")) - render :edit + redirect_to edit_super_admin_notification_path(@notification) else flash.now[:alert] = failure_message(@notification, _("create")) render :new @@ -49,6 +49,7 @@ def update authorize(Notification) if @notification.update(notification_params) flash.now[:notice] = success_message(@notification, _("updated")) + return redirect_to edit_super_admin_notification_path(@notification) else flash.now[:alert] = failure_message(@notification, _("update")) end diff --git a/app/controllers/super_admin/orgs_controller.rb b/app/controllers/super_admin/orgs_controller.rb index e578acbb41..6cecf152a2 100644 --- a/app/controllers/super_admin/orgs_controller.rb +++ b/app/controllers/super_admin/orgs_controller.rb @@ -99,6 +99,51 @@ def destroy end end + # POST /super_admin/:id/merge_analyze + def merge_analyze + @org = Org.includes(:templates, :tracker, :annotations, + :departments, :token_permission_types, :funded_plans, + identifiers: [:identifier_scheme], + guidance_groups: [guidances: [:themes]], + users: [identifiers: [:identifier_scheme]]) + .find(params[:id]) + authorize @org + + lookup = OrgSelection::HashToOrgService.to_org( + hash: JSON.parse(merge_params[:id]), allow_create: false + ) + @target_org = Org.includes(:templates, :tracker, :annotations, + :departments, :token_permission_types, :funded_plans, + identifiers: [:identifier_scheme], + guidance_groups: [guidances: [:themes]], + users: [identifiers: [:identifier_scheme]]) + .find(lookup.id) + end + + # POST /super_admin/:id/merge_commit + def merge_commit + @org = Org.find(params[:id]) + authorize @org + + @target_org = Org.find_by(id: merge_params[:target_org]) + + if @target_org.present? + if @target_org.merge!(to_be_merged: @org) + msg = "Successfully merged '#{@org.name}' into '#{@target_org.name}'" + redirect_to super_admin_orgs_path, notice: msg + else + msg = _("An error occurred while trying to merge the Organisations.") + redirect_to admin_edit_org_path(@org), alert: msg + end + else + msg = _("Unable to merge the two Organisations at this time.") + redirect_to admin_edit_org_path(@org), alert: msg + end + rescue JSON::ParserError + msg = _("Unable to determine what records need to be merged.") + redirect_to admin_edit_org_path(@org), alert: msg + end + private def org_params @@ -110,6 +155,10 @@ def org_params :org_id, :org_name, :org_crosswalk) end + def merge_params + params.require(:org).permit(:org_name, :org_sources, :org_crosswalk, :id, :target_org) + end + end end diff --git a/app/controllers/super_admin/users_controller.rb b/app/controllers/super_admin/users_controller.rb index 1f3e4f6b5a..933ac6e5c5 100644 --- a/app/controllers/super_admin/users_controller.rb +++ b/app/controllers/super_admin/users_controller.rb @@ -64,16 +64,18 @@ def update def merge @user = User.find(params[:id]) authorize @user - remove = User.find(params[:merge_id]) - if @user.merge(remove) - flash.now[:notice] = success_message(@user, _("merged")) + if params[:id] != params[:merge_id] + merge_accounts else - flash.now[:alert] = failure_message(@user, _("merge")) + flash.now[:alert] = _("You attempted to merge 2 accounts with the same email address. + Please merge with a different email address.") end + # After merge attempt get departments and plans @departments = @user.org.departments.order(:name) @plans = Plan.active(@user).page(1) + render :edit end @@ -121,6 +123,15 @@ def user_params :other_organisation) end + def merge_accounts + remove = User.find(params[:merge_id]) + if @user.merge(remove) + flash.now[:notice] = success_message(@user, _("merged")) + else + flash.now[:alert] = failure_message(@user, _("merge")) + end + end + end end diff --git a/app/helpers/conditions_helper.rb b/app/helpers/conditions_helper.rb index 5e31d66e5b..a674017fdc 100644 --- a/app/helpers/conditions_helper.rb +++ b/app/helpers/conditions_helper.rb @@ -71,9 +71,9 @@ def num_section_answers(plan, section) count = 0 plan_remove_list = remove_list(plan) plan.answers.each do |answer| - next unless answer.question.section.id == section.id && - !plan_remove_list.include?(answer.question.id) && - section.answered_questions(plan).include?(answer) && + next unless answer.question.section_id == section.id && + !plan_remove_list.include?(answer.question_id) && + section.question_ids.include?(answer.question_id) && answer.answered? count += 1 @@ -82,28 +82,27 @@ def num_section_answers(plan, section) end # number of questions in a section after update with conditions - # rubocop:disable Metrics/AbcSize def num_section_questions(plan, section, phase = nil) # when section and phase are a hash in exports if section.is_a?(Hash) && !phase.nil? && plan.is_a?(Plan) - phase_id = plan.phases.select { |ph| ph.number == phase[:number] }.first.id - section = plan.sections - .select { |s| s.phase_id == phase_id && s.title == section[:title] } - .first + + phase = plan.template + .phases + .select { |ph| ph.number == phase[:number] } + .first + section = phase.sections + .select { |s| s.phase_id == phase.id && s.title == section[:title] } + .first end count = 0 plan_remove_list = remove_list(plan) - plan.questions.each do |question| - if question.section.id == section.id && - !plan_remove_list.include?(question.id) - count += 1 - end + section.questions.each do |question| + count += 1 unless plan_remove_list.include?(question.id) end count end - # rubocop:enable Metrics/AbcSize # returns an array of hashes of section_id, number of section questions, and # number of section answers diff --git a/app/helpers/super_admin/orgs/merge_helper.rb b/app/helpers/super_admin/orgs/merge_helper.rb new file mode 100644 index 0000000000..1c2051fdda --- /dev/null +++ b/app/helpers/super_admin/orgs/merge_helper.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module SuperAdmin + + module Orgs + + module MergeHelper + + def org_column_content(attributes:) + return "No mergeable attributes" unless attributes.present? && attributes.keys.any? + + html = "
The following %{object_types} will be moved over to '%{org_name}':
") % { + object_types: entries.first.class.name.pluralize, + org_name: to_org_name + } + html + column_content(entries: entries, orcid: orcid) + end + + end + + end + +end diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 23254c1822..1c83822daa 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -37,6 +37,7 @@ import '../src/utils/tooltipHelper'; // Specific functions from the Utilities files that will be made available to // the js.erb templates in the `window.x` statements below import { renderAlert, renderNotice } from '../src/utils/notificationHelper'; +import toggleSpinner from '../src/utils/spinner'; // View specific JS import '../src/answers/conditions'; @@ -101,3 +102,4 @@ window.jQuery = jQuery; // Allow js.erb files to access the notificationHelper functions window.renderAlert = renderAlert; window.renderNotice = renderNotice; +window.toggleSpinner = toggleSpinner; diff --git a/app/javascript/src/orgs/adminEdit.js b/app/javascript/src/orgs/adminEdit.js index 49e90ed95c..ac27b03499 100644 --- a/app/javascript/src/orgs/adminEdit.js +++ b/app/javascript/src/orgs/adminEdit.js @@ -56,4 +56,7 @@ $(() => { e.preventDefault(); $(e.target).parent('a').tooltip('toggle'); }); + + initAutocomplete('#org-merge-controls .autocomplete'); + scrubOrgSelectionParamsOnSubmit('form.edit_org'); }); diff --git a/app/javascript/src/utils/spinner.js b/app/javascript/src/utils/spinner.js new file mode 100644 index 0000000000..9298e9f009 --- /dev/null +++ b/app/javascript/src/utils/spinner.js @@ -0,0 +1,39 @@ +// Will display a spinner at the start of any UJS/Ajax call and then hide it after the +// controller responds. +const toggleSpinner = (visible) => { + const spinnerBlock = $('.spinner-border'); + + if (spinnerBlock.length > 0) { + if (visible) { + spinnerBlock.removeClass('hidden'); + } else { + spinnerBlock.addClass('hidden'); + } + } +}; + +$(() => { + $('body').on('ajax:beforeSend', () => { + toggleSpinner(true); + }); + + $('body').on('ajax:complete', () => { + toggleSpinner(false); + }); + + $('body').on('ajax:error', () => { + toggleSpinner(false); + }); + + $('body').on('ajax:stopped', () => { + toggleSpinner(false); + }); + + $('body').on('ajax:success', () => { + toggleSpinner(false); + }); + + toggleSpinner(false); +}); + +export default (visible) => toggleSpinner(visible); diff --git a/app/models/concerns/identifiable.rb b/app/models/concerns/identifiable.rb index 73f72d28a4..3593d8a061 100644 --- a/app/models/concerns/identifiable.rb +++ b/app/models/concerns/identifiable.rb @@ -58,7 +58,7 @@ def identifier_for_scheme(scheme:) # Combines the existing identifiers with the new ones def consolidate_identifiers!(array:) - return false unless array.present? && array.is_a?(Array) + return false unless array.present? && array.any? array.each do |id| next unless id.is_a?(Identifier) && id.value.present? diff --git a/app/models/contributor.rb b/app/models/contributor.rb index 0c103c8392..f177935ad9 100644 --- a/app/models/contributor.rb +++ b/app/models/contributor.rb @@ -67,6 +67,7 @@ class Contributor < ApplicationRecord has_flags 1 => :data_curation, 2 => :investigation, 3 => :project_administration, + 4 => :other, column: "roles" # ========== @@ -91,7 +92,7 @@ class << self # returns the default role def default_role - "investigation" + "other" end end diff --git a/app/models/guidance_group.rb b/app/models/guidance_group.rb index da753920dc..c4c2ba04c7 100644 --- a/app/models/guidance_group.rb +++ b/app/models/guidance_group.rb @@ -122,8 +122,7 @@ def self.all_viewable(user) all_viewable_groups = default_org_groups + funder_groups + organisation_groups - all_viewable_groups = all_viewable_groups.flatten.uniq - all_viewable_groups + all_viewable_groups.flatten.uniq end def self.create_org_default(org) @@ -134,4 +133,32 @@ def self.create_org_default(org) ) end + # ==================== + # = Instance methods = + # ==================== + + def merge!(to_be_merged:) + return self unless to_be_merged.is_a?(GuidanceGroup) + + GuidanceGroup.transaction do + # Reassociate any Plan-GuidanceGroup connections + to_be_merged.plans.each do |plan| + plan.guidance_groups << self + plan.save + end + # Reassociate the Guidances + to_be_merged.guidances.update_all(guidance_group_id: id) + to_be_merged.plans.delete_all + + # Terminate the transaction if the resulting Org is not valid + raise ActiveRecord::Rollback unless save + + # Reload and then drop the specified Org. The reload prevents ActiveRecord + # from also destroying associations that we've already reassociated above + raise ActiveRecord::Rollback unless to_be_merged.reload.destroy.present? + + reload + end + end + end diff --git a/app/models/org.rb b/app/models/org.rb index ddf3b384c7..141f0edfd6 100644 --- a/app/models/org.rb +++ b/app/models/org.rb @@ -289,14 +289,10 @@ def org_admins User.joins(:perms).where("users.org_id = ? AND perms.name IN (?)", id, admin_perms) end - def plans - Rails.cache.fetch("org[#{id}].plans", expires_in: 2.seconds) do - plan_ids = Role.administrator - .where(user_id: users.pluck(:id), active: true) - .pluck(:plan_id).uniq - Plan.includes(:template, :phases, :roles, :users) - .where(id: plan_ids) - end + # This replaces the old plans method. We now use the native plans method and this. + def org_admin_plans + combined_plan_ids = (native_plan_ids + affiliated_plan_ids).flatten.uniq + Plan.includes(:template, :phases, :roles, :users).where(id: combined_plan_ids) end def grant_api!(token_permission_type) @@ -304,6 +300,48 @@ def grant_api!(token_permission_type) token_permission_types.include? token_permission_type end + # Merges the specified Org into this Org + # rubocop:disable Metrics/AbcSize + def merge!(to_be_merged:) + return self unless to_be_merged.is_a?(Org) + + transaction do + merge_attributes!(to_be_merged: to_be_merged) + + # Merge all of the associated objects + to_be_merged.annotations.update_all(org_id: id) + merge_departments!(to_be_merged: to_be_merged) + to_be_merged.funded_plans.update_all(funder_id: id) + merge_guidance_groups!(to_be_merged: to_be_merged) + consolidate_identifiers!(array: to_be_merged.identifiers) + + # TODO: Use this commented out version once we've stopped overriding the + # default association method called `plans` on this model + # to_be_merged.plans.update_all(org_id: id) + Plan.where(org_id: to_be_merged.id).update_all(org_id: id) + + to_be_merged.templates.update_all(org_id: id) + merge_token_permission_types!(to_be_merged: to_be_merged) + self.tracker = to_be_merged.tracker unless tracker.present? + to_be_merged.users.update_all(org_id: id) + + # Terminate the transaction if the resulting Org is not valid + raise ActiveRecord::Rollback unless save + + # Remove all of the remaining identifiers and token_permission_types + # that were not merged + to_be_merged.identifiers.delete_all + to_be_merged.token_permission_types.delete_all + + # Reload and then drop the specified Org. The reload prevents ActiveRecord + # from also destroying associations that we've already reassociated above + raise ActiveRecord::Rollback unless to_be_merged.reload.destroy.present? + + reload + end + end + # rubocop:enable Metrics/AbcSize + private ## @@ -315,6 +353,66 @@ def resize_image self.logo = logo.thumb("x100") # resize height and maintain aspect ratio end + # rubocop:disable Metrics/AbcSize + def merge_attributes!(to_be_merged:) + return false unless to_be_merged.is_a?(Org) + + self.target_url = to_be_merged.target_url unless target_url.present? + self.managed = true if !managed? && to_be_merged.managed? + self.links = to_be_merged.links unless links.nil? || links == "{\"org\":[]}" + self.logo_uid = to_be_merged.logo_uid unless logo.present? + self.logo_name = to_be_merged.logo_name unless logo.present? + self.contact_email = to_be_merged.contact_email unless contact_email.present? + self.contact_name = to_be_merged.contact_name unless contact_name.present? + self.feedback_enabled = to_be_merged.feedback_enabled unless feedback_enabled? + self.feedback_email_msg = to_be_merged.feedback_email_msg unless feedback_email_msg.present? + # rubocop:disable Layout/LineLength + self.feedback_email_subject = to_be_merged.feedback_email_subject unless feedback_email_subject.present? + # rubocop:enable Layout/LineLength + end + # rubocop:enable Metrics/AbcSize + + def merge_departments!(to_be_merged:) + return false unless to_be_merged.is_a?(Org) && to_be_merged.departments.any? + + existing = departments.collect { |dpt| dpt.name.downcase.strip } + # Do not attach departments if we already have an existing one + to_be_merged.departments.each do |department| + department.destroy if existing.include?(department.name.downcase) + department.update(org_id: id) unless existing.include?(department.name.downcase) + end + end + + def merge_guidance_groups!(to_be_merged:) + return false unless to_be_merged.is_a?(Org) && to_be_merged.guidance_groups.any? + + to_gg = guidance_groups.first + # Create a new GuidanceGroup if one does not already exist + to_gg = GuidanceGroup.create(org: self, name: abbreviation) unless to_gg.present? + + # Merge the GuidanceGroups + to_be_merged.guidance_groups.each { |from_gg| to_gg.merge!(to_be_merged: from_gg) } + end + + def merge_token_permission_types!(to_be_merged:) + return false unless to_be_merged.is_a?(Org) && to_be_merged.token_permission_types.any? + + to_be_merged.token_permission_types.each do |perm_type| + token_permission_types << perm_type unless token_permission_types.include?(perm_type) + end + end + + def affiliated_plan_ids + Rails.cache.fetch("org[#{id}].plans", expires_in: 2.seconds) do + Role.administrator.where(user_id: users.pluck(:id), active: true) + .pluck(:plan_id).uniq + end + end + + def native_plan_ids + plans.map(&:id) + end + # Ensure that the Org has all of the available token permission types prior to save def ensure_api_access TokenPermissionType.all.each do |perm| diff --git a/app/models/plan.rb b/app/models/plan.rb index 07343e9b58..8ac2ed1a32 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -160,7 +160,7 @@ class Plan < ApplicationRecord # Retrieves any plan organisationally or publicly visible for a given org id scope :organisationally_or_publicly_visible, lambda { |user| - plan_ids = user.org.plans.where(complete: true).pluck(:id).uniq + plan_ids = user.org.org_admin_plans.where(complete: true).pluck(:id).uniq includes(:template, roles: :user) .where(id: plan_ids, visibility: [ visibilities[:organisationally_visible], diff --git a/app/models/question.rb b/app/models/question.rb index c7ccfa0187..2deb90a5a1 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -166,9 +166,10 @@ def guidance_for_org(org) # # Returns ActiveRecord::Relation def example_answers(org_ids) - annotations.where(org_id: Array(org_ids), - type: Annotation.types[:example_answer]) - .order(:created_at) + org_ids = Array(org_ids) + annotations.select { |a| org_ids.include?(a.org_id) } + .select(&:example_answer?) + .sort { |a, b| a.created_at <=> b.created_at } end alias get_example_answers example_answers @@ -183,7 +184,9 @@ def example_answers(org_ids) # # Returns Annotation def guidance_annotation(org_id) - annotations.where(org_id: org_id, type: Annotation.types[:guidance]).first + annotations.select { |a| a.org_id == org_id } + .select(&:guidance?) + .first end alias get_guidance_annotation guidance_annotation diff --git a/app/models/section.rb b/app/models/section.rb index eaa2697bc5..4017e7438b 100644 --- a/app/models/section.rb +++ b/app/models/section.rb @@ -113,8 +113,8 @@ def num_answered_questions(plan) def answered_questions(plan) return [] if plan.nil? - plan.answers.includes({ question: :question_format }, :question_options) - .where(question_id: question_ids) + plan.answers + .select { |answer| question_ids.include?(answer.question_id) } .to_a end diff --git a/app/policies/org_policy.rb b/app/policies/org_policy.rb index f133cb2d10..8a927ced14 100644 --- a/app/policies/org_policy.rb +++ b/app/policies/org_policy.rb @@ -51,4 +51,12 @@ def templates? true end + def merge_analyze? + user.can_super_admin? + end + + def merge_commit? + user.can_super_admin? + end + end diff --git a/app/presenters/api/v1/contributor_presenter.rb b/app/presenters/api/v1/contributor_presenter.rb index 0f4c22c61e..6970433bd7 100644 --- a/app/presenters/api/v1/contributor_presenter.rb +++ b/app/presenters/api/v1/contributor_presenter.rb @@ -11,6 +11,7 @@ class << self # Convert the specified role into a CRediT Taxonomy URL 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('_', '-')}" end diff --git a/app/presenters/contributor_presenter.rb b/app/presenters/contributor_presenter.rb index 73d721fa04..91ce18bab2 100644 --- a/app/presenters/contributor_presenter.rb +++ b/app/presenters/contributor_presenter.rb @@ -43,8 +43,10 @@ def role_symbol_to_string(symbol:) "Data Manager" when :project_administration "Project Administrator" - else + when :investigation "Principal Investigator" + else + "Other" end end diff --git a/app/presenters/guidance_presenter.rb b/app/presenters/guidance_presenter.rb index e3c128e090..c2acdd6b9a 100644 --- a/app/presenters/guidance_presenter.rb +++ b/app/presenters/guidance_presenter.rb @@ -113,9 +113,9 @@ def guidance_annotations?(org: nil, question: nil) # structure: # { guidance_group: { theme: [guidance, ...], ... }, ... } def guidance_groups_by_theme(org: nil, question: nil) - raise ArgumentError unless question.respond_to?(:themes) + raise ArgumentError unless question.is_a?(Question) + raise ArgumentError unless org.is_a?(Org) - question = Question.includes(:themes).find(question.id) return {} unless hashified_guidance_groups.key?(org) hashified_guidance_groups[org].each_key.each_with_object({}) do |gg, acc| diff --git a/app/presenters/super_admin/orgs/merge_presenter.rb b/app/presenters/super_admin/orgs/merge_presenter.rb new file mode 100644 index 0000000000..e3bc57cf96 --- /dev/null +++ b/app/presenters/super_admin/orgs/merge_presenter.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +module SuperAdmin + + module Orgs + + class MergePresenter + + attr_accessor :from_org, :to_org, :from_org_name, :to_org_name, + :from_org_entries, :to_org_entries, :mergeable_entries, + :categories, :from_org_attributes, :to_org_attributes, + :mergeable_attributes + + def initialize(from_org:, to_org:) + @from_org = from_org + @to_org = to_org + + # Abbreviated Org names for display in tables + @from_org_name = @from_org.name.split(" ")[0..2].join(" ") + @to_org_name = @to_org.name.split(" ")[0..2].join(" ") + + # Association records + @from_org_entries = prepare_org(org: @from_org) + @to_org_entries = prepare_org(org: @to_org) + @mergeable_entries = prepare_mergeables + @categories = @from_org_entries.keys.sort { |a, b| a <=> b } + + # Specific Org columns + @from_org_attributes = org_attributes(org: @from_org) + @to_org_attributes = org_attributes(org: @to_org) + @mergeable_attributes = mergeable_columns + end + + private + + # rubocop:disable Metrics/AbcSize + def prepare_org(org:) + return {} unless org.present? && org.is_a?(Org) + + { + annotations: org.annotations.sort { |a, b| a.text <=> b.text }, + departments: org.departments.sort { |a, b| a.name <=> b.name }, + funded_plans: org.funded_plans.sort { |a, b| a.title <=> b.title }, + guidances: org.guidance_groups.collect(&:guidances).flatten, + identifiers: org.identifiers.sort { |a, b| a.value <=> b.value }, + # TODO: Org.plans is overridden and does not clearly identify Orgs that 'own' + # the plan (i.e. the one the user selected as the 'Research Org') + # Loading them directly here until issue #2724 is resolved + plans: Plan.where(org: org).sort { |a, b| a.title <=> b.title }, + templates: org.templates.sort { |a, b| a.title <=> b.title }, + token_permission_types: org.token_permission_types.sort { |a, b| a.to_s <=> b.to_s }, + tracker: [org.tracker].compact, + users: org.users.sort { |a, b| a.email <=> b.email } + } + end + # rubocop:enable Metrics/AbcSize + + def prepare_mergeables + return {} unless @from_org_entries.any? && @to_org_entries.any? + + { + annotations: diff_from_and_to(category: :annotations), + departments: diff_from_and_to(category: :departments), + funded_plans: diff_from_and_to(category: :funded_plans), + guidances: diff_from_and_to(category: :guidances), + identifiers: diff_from_and_to(category: :identifiers), + plans: diff_from_and_to(category: :plans), + templates: diff_from_and_to(category: :templates), + token_permission_types: diff_from_and_to(category: :token_permission_types), + tracker: @to_org_entries[:tracker].any? ? [] : @from_org_entries[:tracker], + users: diff_from_and_to(category: :users) + } + end + + # rubocop:disable Metrics/AbcSize + def diff_from_and_to(category:) + return [] unless category.present? && @from_org_entries.fetch(category, []).any? + + case category + when :departments + # Merge only the unique departments + existing = @to_org_entries[category].map { |e| e.name.downcase } + @from_org_entries[category].reject { |e| existing.include?(e.name.downcase) } + when :identifiers + # Merge identifiers with no identifier_scheme + # Retain the to_org's identifiers for specific identifier_schemes + schemes = @to_org_entries[category].collect(&:identifier_scheme) + @from_org_entries[category].reject do |entry| + entry.identifier_scheme.present? && schemes.include?(entry.identifier_scheme) + end + when :token_permission_types + # Merge only the unique token_permission_types + existing = @to_org_entries[category].map { |e| e.token_type.downcase } + @from_org_entries[category].reject { |e| existing.include?(e.token_type.downcase) } + when :users + # Merge only the unique users + existing = @to_org_entries[category].map { |e| e.email.downcase } + @from_org_entries[category].reject { |e| existing.include?(e.email.downcase) } + else + @from_org_entries[category] + end + end + # rubocop:enable Metrics/AbcSize + + def org_attributes(org:) + return {} unless org.is_a?(Org) + + { + contact_email: org.contact_email, + contact_name: org.contact_name, + feedback_email_msg: org.feedback_email_msg, + feedback_email_subject: org.feedback_email_subject, + feedback_enabled: org.feedback_enabled, + managed: org.managed, + links: org.links, + logo_name: org.logo_name, + logo_uid: org.logo_uid, + target_url: org.target_url + } + end + + def mergeable_columns + out = {} + out[:target_url] = @from_org.target_url if mergeable_column?(column: :target_url) + out[:managed] = @from_org.managed if mergeable_column?(column: :managed) + out[:links] = @from_org.links if mergeable_column?(column: :links) + + if mergeable_column?(column: :logo) + out[:logo_uid] = @from_org.logo_uid + out[:logo_name] = @from_org.logo_name + end + if mergeable_column?(column: :contact_email) + out[:contact_email] = @from_org.contact_email + out[:contact_name] = @from_org.contact_name + end + if mergeable_column?(column: :feedback_enabled) + out[:feedback_enabled] = @from_org.feedback_enabled + out[:feedback_email_subject] = @from_org.feedback_email_subject + out[:feedback_email_msg] = @from_org.feedback_email_msg + end + out + end + + def mergeable_column?(column:) + case column + when :links + (@to_org.links.nil? || @to_org.links.fetch("org", []).empty?) && + (@from_org.links.present? || @from_org.links.fetch("org", [].any?)) + when :managed + !@to_org.managed? && @from_org.managed? + when :feedback_enabled + !@to_org.feedback_enabled? && @from_org.feedback_enabled? + else + @to_org.send(column).nil? && + @from_org.send(column).present? && + @to_org != @from_org + end + end + + end + + end + +end diff --git a/app/views/api/v0/plans/index.json.jbuilder b/app/views/api/v0/plans/index.json.jbuilder index 8e5ec1d94a..a2f2aec735 100644 --- a/app/views/api/v0/plans/index.json.jbuilder +++ b/app/views/api/v0/plans/index.json.jbuilder @@ -30,11 +30,14 @@ json.array! @plans.each do |plan| end data_contact = plan.contributors.data_curation.first || plan.owner - json.data_contact do - json.name data_contact.is_a?(Contributor) ? data_contact.name : data_contact.name(false) - json.email data_contact.email - json.phone data_contact.phone if data_contact.is_a?(Contributor) + if data_contact.present? + json.data_contact do + json.name data_contact.is_a?(Contributor) ? data_contact.name : data_contact.name(false) + json.email data_contact.email + json.phone data_contact.phone if data_contact.is_a?(Contributor) + end end + json.users plan.roles.each do |role| json.email role.user.email end diff --git a/app/views/contributors/_form.html.erb b/app/views/contributors/_form.html.erb index 58269ebedb..f199b2c90e 100644 --- a/app/views/contributors/_form.html.erb +++ b/app/views/contributors/_form.html.erb @@ -42,7 +42,7 @@ roles_tooltip = _("Select each role that applies to the contributor.")<%= sanitize(_("Please select the Organisation that will replace the current organisation: '%{org_name}'.") % { org_name: org.name }) %>
+<%= _(" Then click the 'analyze' button to review the proposed changes. Note that no changes will take place until you have reviewed and approve of the changes.") %>
+ <%= form_for org, url: merge_analyze_super_admin_org_path(org), method: :post, remote: true do |form| %> + <%= render partial: "shared/org_selectors/local_only", + locals: { + form: form, + id_field: :id, + default_org: nil, + orgs: Org.includes(identifiers: :identifier_scheme).all.reject { |o| o == org }, + required: true + } %> + <%= form.button(_('Analyze'), class: "btn btn-primary", type: "submit") %> + <% end %> +<%= _('Invite specific people to read, edit, or administer your plan. Invitees will receive an email notification that they have access to this plan.') %>
-<% if @plan.roles.any? then %> +<% if @plan_roles.any? then %>| <%= _("Record Type") %> | +<%= _("'%{to_org_name}' Count - before merge") % { to_org_name: presenter.to_org_name } %> | +<%= _("'%{to_org_name}' Count - after merge") % { to_org_name: presenter.to_org_name } %> | +
|---|---|---|
| <%= category.capitalize %> | +<%= to_entries.length %> | +<%= to_entries.length + mergeable_entries.length %> | +
<%= _("The table below shows the specific changes that will occur during the merge process. Once the merge is complete, '%{from_org_name}' will be deleted. You can then consolidate/correct any merge issues (e.g. remove any departments that were duplicated)") % { from_org_name: org.name } %>
+| <%= _('Record Type') %> | +<%= _("%{from_org_name} - Before") % { from_org_name: presenter.from_org_name } %> | +<%= _("%{to_org_name} - Before") % { to_org_name: presenter.to_org_name } %> | +<%= _("Action") %> | +
|---|---|---|---|
| <%= _("Org") %> | +<%= sanitize(org_column_content(attributes: org_from_cols)) %> | +<%= sanitize(org_column_content(attributes: org_to_cols)) %> | +
+ <%= _("The following attributes will be updated") if org_merge_cols.any? %> + <%= sanitize(org_column_content(attributes: org_merge_cols)) %> + |
+
| <%= category.capitalize %> | +<%= sanitize(column_content(entries: from_entries, orcid: orcid)) %> | +<%= sanitize(column_content(entries: to_entries, orcid: orcid)) %> | ++ <%= sanitize(merge_column_content( + entries: mergeable_entries, orcid: orcid, to_org_name: presenter.to_org_name) + ) %> + | +
<%= _("Warning: The changes proposed above will be committed to the database. '%{org_name}' will then be deleted.") % { org_name: org.name } %>
+<%= _("These changes cannot be undone! Do not proceed unless you are certain.") %>
+<%= _("You may find it helpful to make a note of the expected number of records for each category that will be merged before continuing.") %>
+ <% if org.plans.select(&:feedback_requested?).any? %> +<%= _("Warning: '%{from_org_name}' has Plans that are currently in the review process. The Org admins may not be able to see these plans after the merge!") % { from_org_name: org.name } %>
+ <% end %> + <%= form.button(_('Merge records'), class: "btn btn-primary", type: "submit") %> +<% end %> diff --git a/app/views/super_admin/orgs/index.html.erb b/app/views/super_admin/orgs/index.html.erb index ef42981c60..dd6d521cbc 100644 --- a/app/views/super_admin/orgs/index.html.erb +++ b/app/views/super_admin/orgs/index.html.erb @@ -4,7 +4,7 @@