diff --git a/Gemfile b/Gemfile
index ce079fdbaa..06c6fabd24 100644
--- a/Gemfile
+++ b/Gemfile
@@ -213,6 +213,8 @@ gem 'httparty'
# Autoload dotenv in Rails. (https://github.com/bkeepers/dotenv)
gem 'dotenv-rails'
+gem 'activerecord_json_validator'
+
# ================================= #
# ENVIRONMENT SPECIFIC DEPENDENCIES #
# ================================= #
diff --git a/Gemfile.lock b/Gemfile.lock
index d1a8d1dba1..0fdc757d62 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -33,6 +33,9 @@ GEM
activemodel (= 5.2.6.2)
activesupport (= 5.2.6.2)
arel (>= 9.0)
+ activerecord_json_validator (2.1.0)
+ activerecord (>= 4.2.0, < 8)
+ json_schemer (~> 0.2.18)
activestorage (5.2.6.2)
actionpack (= 5.2.6.2)
activerecord (= 5.2.6.2)
@@ -130,6 +133,8 @@ GEM
dragonfly-s3_data_store (1.3.0)
dragonfly (~> 1.0)
fog-aws
+ ecma-re-validator (0.4.0)
+ regexp_parser (~> 2.2)
erubi (1.10.0)
excon (0.91.0)
execjs (2.8.1)
@@ -207,6 +212,7 @@ GEM
guard (~> 2.1)
guard-compat (~> 1.1)
rspec (>= 2.99.0, < 4.0)
+ hana (1.3.7)
hashdiff (1.0.1)
hashie (5.0.0)
highline (2.0.3)
@@ -223,6 +229,11 @@ GEM
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
json (2.6.1)
+ json_schemer (0.2.19)
+ ecma-re-validator (~> 0.3)
+ hana (~> 1.3)
+ regexp_parser (~> 2.0)
+ uri_template (~> 0.7)
jwt (2.3.0)
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -474,6 +485,7 @@ GEM
thread_safe (~> 0.1)
unicode-display_width (2.1.0)
uniform_notifier (1.14.2)
+ uri_template (0.7.0)
warden (1.2.9)
rack (>= 2.0.9)
web-console (3.7.0)
@@ -515,6 +527,7 @@ PLATFORMS
x86_64-linux
DEPENDENCIES
+ activerecord_json_validator
annotate
annotate_gem
api-pagination
diff --git a/app/assets/stylesheets/blocks/_modal_search.scss b/app/assets/stylesheets/blocks/_modal_search.scss
new file mode 100644
index 0000000000..75e87c828e
--- /dev/null
+++ b/app/assets/stylesheets/blocks/_modal_search.scss
@@ -0,0 +1,75 @@
+.modal-search-block {
+ border: 1px solid $color-grey;
+ margin-bottom: 10px;
+ padding: 10px 5px;
+}
+
+.modal-search .modal-dialog {
+ /* Make the dialog 80% of the screen height/width */
+ width: 80%;
+ // height: 80%;
+}
+.modal-search .modal-body {
+ /* 100% = dialog height, 50px = header (27.5px) + footer (21px) */
+ // max-height: calc(80% - 50px);
+ max-height: 450px;
+ overflow-y: scroll;
+}
+
+.modal-search-results-pagination {
+ margin-bottom: 10px;
+}
+
+.modal-search-result {
+ margin-top: 5px;
+ padding-bottom: 5px;
+
+ .modal-search-result-label {
+ font-size: 1.6rem;
+ font-weight: 500;
+ }
+
+ .tags > .tag {
+ display: inline-block;
+ margin: 5px 2px;
+ }
+ .tags .facet {
+ border: 1px solid $color-blue;
+ border-radius: 25px;
+ padding: 2px 5px;
+ }
+
+ div {
+ margin-bottom: 5px;
+ }
+
+ dl {
+ margin-left: 20px;
+
+ dd {
+ margin-bottom: 5px;
+ }
+ }
+}
+
+.modal-search-results .modal-search-result {
+ border-bottom: 1px solid $color-grey;
+}
+
+/* the 'Select' button displayed in the modal dialog */
+.modal-search-result .modal-search-result-selector,
+.modal-search-result .modal-search-result-unselector {
+ display: inline-block;
+ background-color: $color-white;
+ border-radius: 25px;
+ padding: 2px 5px;
+ font-size: 1.3rem;
+}
+.modal-search-result .modal-search-result-selector {
+ background-color: $color-green;
+ color: $color-white;
+}
+.modal-search-result .modal-search-result-unselector {
+ border: 1px solid $color-red;
+ color: $color-red;
+}
diff --git a/app/assets/stylesheets/variables/_colours.scss b/app/assets/stylesheets/variables/_colours.scss
index 17cc9034aa..c10b0e676c 100644
--- a/app/assets/stylesheets/variables/_colours.scss
+++ b/app/assets/stylesheets/variables/_colours.scss
@@ -5,6 +5,7 @@
$color-black: #000;
$color-white: #FFF;
$color-red: #b94a48;
+$color-green: #4c8d3f;
$color-grey: #4F5253;
$color-grey-darkest: #222;
$color-grey-darker: #333;
diff --git a/app/controllers/plan_exports_controller.rb b/app/controllers/plan_exports_controller.rb
index 504b307952..41edc38212 100644
--- a/app/controllers/plan_exports_controller.rb
+++ b/app/controllers/plan_exports_controller.rb
@@ -6,7 +6,8 @@ class PlanExportsController < ApplicationController
include ConditionsHelper
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
+ # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
+ # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
def show
@plan = Plan.includes(:answers, { template: { phases: { sections: :questions } } })
.find(params[:plan_id])
@@ -17,6 +18,7 @@ def show
@show_sections_questions = export_params[:question_headings].present?
@show_unanswered = export_params[:unanswered_questions].present?
@show_custom_sections = export_params[:custom_sections].present?
+ @show_research_outputs = export_params[:research_outputs].present?
@public_plan = false
elsif publicly_authorized?
@@ -25,6 +27,7 @@ def show
@show_sections_questions = true
@show_unanswered = true
@show_custom_sections = true
+ @show_research_outputs = @plan.research_outputs&.any? || false
@public_plan = true
else
@@ -49,7 +52,8 @@ def show
format.json { show_json }
end
end
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity
+ # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
+ # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
private
@@ -124,8 +128,9 @@ def privately_authorized?
end
def export_params
- params.require(:export).permit(:form, :project_details, :question_headings,
- :unanswered_questions, :custom_sections,
- :formatting)
+ params.require(:export)
+ .permit(:form, :project_details, :question_headings, :unanswered_questions,
+ :custom_sections, :research_outputs,
+ formatting: [:font_face, :font_size, { margin: %i[top right bottom left] }])
end
end
diff --git a/app/controllers/plans_controller.rb b/app/controllers/plans_controller.rb
index 08bd798d0f..6025ea3f6a 100644
--- a/app/controllers/plans_controller.rb
+++ b/app/controllers/plans_controller.rb
@@ -270,9 +270,7 @@ def update
funder_attrs[:org_id] = plan_params[:funder][:id]
funder = org_from_params(params_in: funder_attrs)
@plan.funder_id = funder&.id
- attrs.delete(:funder)
-
- process_grant(grant_params: plan_params[:grant])
+ @plan.grant = plan_params[:grant]
attrs.delete(:grant)
attrs = remove_org_selection_params(params_in: attrs)
@@ -537,29 +535,5 @@ def render_phases_edit(plan, phase, guidance_groups)
guidance_presenter: GuidancePresenter.new(plan)
})
end
-
- # Update, destroy or add the grant
- # rubocop:disable Metrics/AbcSize
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
- def process_grant(grant_params:)
- return false unless grant_params.present?
-
- grant = @plan.grant
-
- # delete it if it has been blanked out
- if grant_params[:value].blank? && grant.present?
- grant.destroy
- @plan.grant = nil
- elsif grant_params[:value] != grant&.value
- if grant.present?
- grant.update(value: grant_params[:value])
- elsif grant_params[:value].present?
- @plan.grant = Identifier.new(identifier_scheme: nil, identifiable: @plan,
- value: grant_params[:value])
- end
- end
- end
- # rubocop:enable Metrics/AbcSize
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
end
# rubocop:enable Metrics/ClassLength
diff --git a/app/controllers/research_outputs_controller.rb b/app/controllers/research_outputs_controller.rb
new file mode 100644
index 0000000000..c05927b20c
--- /dev/null
+++ b/app/controllers/research_outputs_controller.rb
@@ -0,0 +1,219 @@
+# frozen_string_literal: true
+
+# Controller to handle CRUD operations for the Research Outputs tab
+class ResearchOutputsController < ApplicationController
+ helper PaginableHelper
+
+ before_action :fetch_plan, except: %i[select_output_type select_license repository_search
+ metadata_standard_search]
+ before_action :fetch_research_output, only: %i[edit update destroy]
+
+ after_action :verify_authorized
+
+ # GET /plans/:plan_id/research_outputs
+ def index
+ @research_outputs = ResearchOutput.includes(:repositories)
+ .where(plan_id: @plan.id)
+ authorize @research_outputs.first || ResearchOutput.new(plan_id: @plan.id)
+ end
+
+ # GET /plans/:plan_id/research_outputs/new
+ def new
+ @research_output = ResearchOutput.new(plan_id: @plan.id, output_type: '')
+ authorize @research_output
+ end
+
+ # GET /plans/:plan_id/research_outputs/:id/edit
+ def edit
+ authorize @research_output
+ end
+
+ # POST /plans/:plan_id/research_outputs
+ def create
+ args = process_byte_size.merge({ plan_id: @plan.id })
+ args = process_nillable_values(args: args)
+ @research_output = ResearchOutput.new(args)
+ authorize @research_output
+
+ if @research_output.save
+ redirect_to plan_research_outputs_path(@plan),
+ notice: success_message(@research_output, _('added'))
+ else
+ flash[:alert] = failure_message(@research_output, _('add'))
+ render 'research_outputs/new'
+ end
+ end
+
+ # PATCH/PUT /plans/:plan_id/research_outputs/:id
+ # rubocop:disable Metrics/AbcSize
+ def update
+ args = process_byte_size.merge({ plan_id: @plan.id })
+ args = process_nillable_values(args: args)
+ authorize @research_output
+
+ # Clear any existing repository and metadata_standard selections.
+ @research_output.repositories.clear
+ @research_output.metadata_standards.clear
+
+ if @research_output.update(args)
+ redirect_to plan_research_outputs_path(@plan),
+ notice: success_message(@research_output, _('saved'))
+ else
+ redirect_to edit_plan_research_output_path(@plan, @research_output),
+ alert: failure_message(@research_output, _('save'))
+ end
+ end
+ # rubocop:enable Metrics/AbcSize
+
+ # DELETE /plans/:plan_id/research_outputs/:id
+ def destroy
+ authorize @research_output
+
+ if @research_output.destroy
+ redirect_to plan_research_outputs_path(@plan),
+ notice: success_message(@research_output, _('removed'))
+ else
+ redirect_to plan_research_outputs_path(@plan),
+ alert: failure_message(@research_output, _('remove'))
+ end
+ end
+
+ # ============================
+ # = Rails UJS remote methods =
+ # ============================
+
+ # GET /plans/:id/output_type_selection
+ def select_output_type
+ @plan = Plan.find_by(id: params[:plan_id])
+ @research_output = ResearchOutput.new(
+ plan: @plan, output_type: output_params[:output_type]
+ )
+ authorize @research_output
+ end
+
+ # GET /plans/:id/license_selection
+ def select_license
+ @plan = Plan.find_by(id: params[:plan_id])
+ @research_output = ResearchOutput.new(
+ plan: @plan, license_id: output_params[:license_id]
+ )
+ authorize @research_output
+ end
+
+ # GET /plans/:id/repository_search
+ # rubocop:disable Metrics/AbcSize
+ def repository_search
+ @plan = Plan.find_by(id: params[:plan_id])
+ @research_output = ResearchOutput.new(plan: @plan)
+ authorize @research_output
+
+ @search_results = Repository.by_type(repo_search_params[:type_filter])
+ @search_results = @search_results.by_subject(repo_search_params[:subject_filter])
+ @search_results = @search_results.search(repo_search_params[:search_term])
+
+ @search_results = @search_results.order(:name).page(params[:page])
+ end
+ # rubocop:enable Metrics/AbcSize
+
+ # PUT /plans/:id/repository_select
+ def repository_select
+ @plan = Plan.find_by(id: params[:plan_id])
+ @research_output = ResearchOutput.new(plan: @plan)
+ authorize @research_output
+
+ @research_output
+ end
+
+ # PUT /plans/:id/repository_unselect
+ def repository_unselect
+ @plan = Plan.find_by(id: params[:plan_id])
+ @research_output = ResearchOutput.new(plan: @plan)
+ authorize @research_output
+ end
+
+ # GET /plans/:id/metadata_standard_search
+ def metadata_standard_search
+ @plan = Plan.find_by(id: params[:plan_id])
+ @research_output = ResearchOutput.new(plan: @plan)
+ authorize @research_output
+
+ @search_results = MetadataStandard.search(metadata_standard_search_params[:search_term])
+ .order(:title)
+ .page(params[:page])
+ end
+
+ private
+
+ def output_params
+ params.require(:research_output)
+ .permit(%i[title abbreviation description output_type output_type_description
+ sensitive_data personal_data file_size file_size_unit mime_type_id
+ release_date access coverage_start coverage_end coverage_region
+ mandatory_attribution license_id],
+ repositories_attributes: %i[id], metadata_standards_attributes: %i[id])
+ end
+
+ def repo_search_params
+ params.require(:research_output).permit(%i[search_term subject_filter type_filter])
+ end
+
+ def metadata_standard_search_params
+ params.require(:research_output).permit(%i[search_term])
+ end
+
+ # rubocop:disable Metrics/AbcSize
+ def process_byte_size
+ args = output_params
+
+ if args[:file_size].present?
+ byte_size = 0.bytes + case args[:file_size_unit]
+ when 'pb'
+ args[:file_size].to_f.petabytes
+ when 'tb'
+ args[:file_size].to_f.terabytes
+ when 'gb'
+ args[:file_size].to_f.gigabytes
+ when 'mb'
+ args[:file_size].to_f.megabytes
+ else
+ args[:file_size].to_i
+ end
+
+ args[:byte_size] = byte_size
+ end
+
+ args.delete(:file_size)
+ args.delete(:file_size_unit)
+ args
+ end
+ # rubocop:enable Metrics/AbcSize
+
+ # There are certain fields on the form that are visible based on the selected output_type. If the
+ # ResearchOutput previously had a value for any of these and the output_type then changed making
+ # one of these arguments invisible, then we need to blank it out here since the Rails form will
+ # not send us the value
+ def process_nillable_values(args:)
+ args[:byte_size] = nil unless args[:byte_size].present?
+ args
+ end
+
+ # =============
+ # = Callbacks =
+ # =============
+
+ def fetch_plan
+ @plan = Plan.find_by(id: params[:plan_id])
+ return true if @plan.present?
+
+ redirect_to root_path, alert: _('plan not found')
+ end
+
+ def fetch_research_output
+ @research_output = ResearchOutput.includes(:repositories)
+ .find_by(id: params[:id])
+ return true if @research_output.present? &&
+ @plan.research_outputs.include?(@research_output)
+
+ redirect_to plan_research_outputs_path, alert: _('research output not found')
+ end
+end
diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js
index 1c83822daa..f61459d552 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';
@@ -58,6 +59,7 @@ import '../src/plans/index.js.erb';
import '../src/plans/new';
import '../src/plans/share';
import '../src/publicTemplates/show';
+import '../src/researchOutputs/form';
import '../src/roles/edit';
import '../src/shared/createAccountForm';
import '../src/shared/signInForm';
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/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/models/application_record.rb b/app/models/application_record.rb
index 3fd5039392..fc9717e959 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -8,6 +8,31 @@ 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 537d24bbda..3ec73d2725 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/license.rb b/app/models/license.rb
new file mode 100644
index 0000000000..e59f74250d
--- /dev/null
+++ b/app/models/license.rb
@@ -0,0 +1,67 @@
+# 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
+ }
+
+ # varchar(255) NOT NULL
+ validates :name,
+ presence: { message: PRESENCE_MESSAGE },
+ length: { in: 0..255, allow_nil: false }
+
+ # varchar(255) NOT NULL
+ validates :identifier,
+ presence: { message: PRESENCE_MESSAGE },
+ length: { in: 0..255, allow_nil: false }
+
+ # varchar(255) NOT NULL
+ validates :uri,
+ presence: { message: PRESENCE_MESSAGE },
+ length: { in: 0..255, allow_nil: false }
+end
diff --git a/app/models/metadata_standard.rb b/app/models/metadata_standard.rb
new file mode 100644
index 0000000000..02eaccfd1f
--- /dev/null
+++ b/app/models/metadata_standard.rb
@@ -0,0 +1,89 @@
+# 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
+ # =============
+ # = Constants =
+ # =============
+
+ # keep "=>" syntax as json_schemer requires string keys
+ SCHEMA_RELATED_ENTITIES = {
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'type' => 'array',
+ 'items' => {
+ 'type' => 'object',
+ 'properties' => {
+ 'id' => { 'type' => 'string' },
+ 'role' => { 'type' => 'string' }
+ }
+ }
+ }.freeze
+
+ SCHEMA_LOCATIONS = {
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'type' => 'array',
+ 'items' => {
+ 'type' => 'object',
+ 'properties' => {
+ 'url' => { 'type' => 'string' },
+ 'type' => { 'type' => 'string' }
+ }
+ }
+ }.freeze
+
+ # ================
+ # = 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}%"))
+ }
+
+ # varchar(255) DEFAULT NULL
+ validates :title,
+ length: { maximum: 255 }
+
+ # varchar(255) DEFAULT NULL
+ validates :rdamsc_id,
+ length: { maximum: 255 }
+
+ # varchar(255) DEFAULT NULL
+ validates :uri,
+ length: { maximum: 255 }
+
+ # json DEFAULT NULL
+ validates :related_entities,
+ json: {
+ schema: SCHEMA_RELATED_ENTITIES,
+ message: ->(errors) { errors }
+ },
+ allow_nil: true
+
+ # json DEFAULT NULL
+ validates :locations,
+ json: {
+ schema: SCHEMA_LOCATIONS,
+ message: ->(errors) { errors }
+ },
+ allow_nil: true
+end
diff --git a/app/models/plan.rb b/app/models/plan.rb
index e8842d5365..0bf4e69a58 100644
--- a/app/models/plan.rb
+++ b/app/models/plan.rb
@@ -20,6 +20,7 @@
# org_id :integer
# funder_id :integer
# grant_id :integer
+# api_client_id :integer
# research_domain_id :bigint
# funding_status :integer
# ethical_issues :boolean
@@ -37,10 +38,12 @@
#
# 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)
#
# Object that represents an DMP
+# rubocop:disable Metrics/ClassLength
class Plan < ApplicationRecord
include ConditionalUserMailer
include ExportablePlan
@@ -84,6 +87,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 +116,14 @@ 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
+
# =====================
# = Nested Attributes =
# =====================
@@ -608,3 +617,4 @@ def end_date_after_start_date
errors.add(:end_date, _('must be after the start date')) if end_date < start_date
end
end
+# rubocop:enable Metrics/ClassLength
diff --git a/app/models/repository.rb b/app/models/repository.rb
new file mode 100644
index 0000000000..344dcb889b
--- /dev/null
+++ b/app/models/repository.rb
@@ -0,0 +1,153 @@
+# 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)
+#
+# Object that represents a research output repository (e.g. GitHub or Zenodo)
+class Repository < ApplicationRecord
+ # =============
+ # = Constants =
+ # =============
+
+ # keep "=>" syntax as json_schemer requires string keys
+ SCHEMA_INFO = {
+ '$schema' => 'http://json-schema.org/draft-04/schema#',
+ 'type' => 'object',
+ 'properties' => {
+ 'types' => {
+ 'type' => 'array',
+ 'items' => {
+ 'type' => 'string'
+ }
+ },
+ 'keywords' => {
+ 'type' => 'array',
+ 'items' => {
+ 'type' => 'string'
+ }
+ },
+ 'subjects' => {
+ 'type' => 'array',
+ 'items' => {
+ 'type' => 'string'
+ }
+ },
+ 'access' => {
+ 'type' => 'string',
+ 'enum' => %w[open restricted closed]
+ },
+ 'provider_types' => {
+ 'type' => 'array',
+ 'items' => {
+ 'type' => 'string'
+ }
+ },
+ 'upload_types' => {
+ 'type' => 'array',
+ 'items' => {
+ 'type' => 'object',
+ 'properties' => {
+ 'type' => { 'type' => 'string' },
+ 'restriction' => { 'type' => 'string' }
+ },
+ 'required' => %w[type restriction]
+ }
+ },
+ 'policies' => {
+ 'type' => 'array',
+ 'items' => {
+ 'type' => 'object',
+ 'properties' => {
+ 'name' => { 'type' => 'string' },
+ 'url' => { 'type' => 'string' }
+ },
+ 'required' => %w[name url]
+ }
+ },
+ 'pid_system' => {
+ 'type' => 'string'
+ }
+ }
+ }.freeze
+
+ # ================
+ # = 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}%")
+ }
+
+ # ===============
+ # = Validations =
+ # ===============
+
+ # varchar(255) NOT NULL
+ validates :name,
+ presence: { message: PRESENCE_MESSAGE },
+ length: { in: 0..255, allow_nil: false }
+
+ # text NOT NULL
+ validates :description,
+ presence: { message: PRESENCE_MESSAGE }
+
+ # varchar(255) NOT NULL
+ validates :uri,
+ presence: { message: PRESENCE_MESSAGE },
+ length: { in: 0..255, allow_nil: false }
+
+ # varchar(255) DEFAULT NULL
+ validates :homepage,
+ length: { maximum: 255 }
+
+ # varchar(255) DEFAULT NULL
+ validates :contact,
+ length: { maximum: 255 }
+
+ # json DEFAULT NULL
+ validates :info,
+ json: {
+ schema: SCHEMA_INFO,
+ message: ->(errors) { errors }
+ },
+ allow_nil: true
+end
diff --git a/app/models/research_output.rb b/app/models/research_output.rb
index 7d90efd8f4..9ecfdf49fe 100644
--- a/app/models/research_output.rb
+++ b/app/models/research_output.rb
@@ -6,12 +6,12 @@
#
# 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
@@ -19,13 +19,17 @@
# title :string 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_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)
#
# Object that represents a proposed output for a project
@@ -43,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
@@ -59,36 +71,17 @@ 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/user.rb b/app/models/user.rb
index 86fa017a75..c3ff357961 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -142,7 +142,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)
diff --git a/app/policies/research_output_policy.rb b/app/policies/research_output_policy.rb
new file mode 100644
index 0000000000..defe86a0c9
--- /dev/null
+++ b/app/policies/research_output_policy.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+# Security policies for research outputs
+class ResearchOutputPolicy < ApplicationPolicy
+ attr_reader :user, :research_output
+
+ def initialize(user, research_output)
+ raise Pundit::NotAuthorizedError, _('must be logged in') unless user
+
+ raise Pundit::NotAuthorizedError, _('are not authorized to view that plan') unless research_output.present?
+
+ @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/presenters/api/v1/api_presenter.rb b/app/presenters/api/v1/api_presenter.rb
new file mode 100644
index 0000000000..692adef0cf
--- /dev/null
+++ b/app/presenters/api/v1/api_presenter.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Api
+ module V1
+ # Generic helper methods for API 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/research_output_presenter.rb b/app/presenters/api/v1/research_output_presenter.rb
new file mode 100644
index 0000000000..28b7325312
--- /dev/null
+++ b/app/presenters/api/v1/research_output_presenter.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+module Api
+ module V1
+ # Helper methods for research outputs
+ 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('
')
+ end
+
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
+ def fetch_q_and_a(themes:)
+ return [] unless themes.is_a?(Array) && themes.any?
+
+ ret = themes.map do |theme|
+ qs = @plan.questions.select { |q| q.themes.collect(&:title).include?(theme) }
+ descr = qs.map do |q|
+ a = @plan.answers.select { |ans| ans.question_id = q.id }.first
+ next unless a.present? && !a.blank?
+
+ "Question: #{q.text}
Answer: #{a.text}"
+ end
+ { title: theme, description: descr }
+ end
+ ret.select { |item| item[:description].present? }
+ end
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
+ end
+ end
+end
diff --git a/app/presenters/research_output_presenter.rb b/app/presenters/research_output_presenter.rb
new file mode 100644
index 0000000000..3a77f1be80
--- /dev/null
+++ b/app/presenters/research_output_presenter.rb
@@ -0,0 +1,158 @@
+# frozen_string_literal: true
+
+# Helper methods for the research outputs tab
+class ResearchOutputPresenter
+ attr_accessor :research_output
+
+ def initialize(research_output:)
+ @research_output = research_output
+ end
+
+ # Returns the output_type list for a select_tag
+ def selectable_output_types
+ ResearchOutput.output_types
+ .map { |k, _v| [k.humanize, k] }
+ end
+
+ # Returns the access options for a select tag
+ def selectable_access_types
+ ResearchOutput.accesses
+ .map { |k, _v| [k.humanize, k] }
+ end
+
+ # Returns the options for file size units
+ def selectable_size_units
+ [%w[MB mb], %w[GB gb], %w[TB tb], %w[PB pb], ['bytes', '']]
+ end
+
+ # Returns the options for metadata standards
+ def selectable_metadata_standards(category:)
+ out = MetadataStandard.all.order(:title).map { |ms| [ms.title, ms.id] }
+ return out unless category.present?
+
+ MetadataStandard.where(descipline_specific: (category == 'disciplinary'))
+ .map { |ms| [ms.title, ms.id] }
+ end
+
+ # Returns the available licenses for a select tag
+ def complete_licenses
+ License.selectable
+ .sort { |a, b| a.identifier <=> b.identifier }
+ .map { |license| [license.identifier, license.id] }
+ end
+
+ # Returns the available licenses for a select tag
+ def preferred_licenses
+ License.preferred.map { |license| [license.identifier, license.id] }
+ end
+
+ # Returns whether or not we should capture the byte_size based on the output_type
+ def byte_sizable?
+ @research_output.audiovisual? || @research_output.sound? || @research_output.image? ||
+ @research_output.model_representation? ||
+ @research_output.data_paper? || @research_output.dataset? || @research_output.text?
+ end
+
+ # Returns the options for subjects for the repository filter
+ def self.selectable_subjects
+ [
+ '23-Agriculture, Forestry, Horticulture and Veterinary Medicine',
+ '21-Biology',
+ '31-Chemistry',
+ '44-Computer Science, Electrical and System Engineering',
+ '45-Construction Engineering and Architecture',
+ '34-Geosciences (including Geography)',
+ '11-Humanities',
+ '43-Materials Science and Engineering',
+ '33-Mathematics',
+ '41-Mechanical and industrial Engineering',
+ '22-Medicine',
+ '32-Physics',
+ '12-Social and Behavioural Sciences',
+ '42-Thermal Engineering/Process Engineering'
+ ].map do |subject|
+ [subject.split('-').last, subject.gsub('-', ' ')]
+ end
+ end
+
+ # Returns the options for the repository type
+ def self.selectable_repository_types
+ [
+ [_('Generalist (multidisciplinary)'), 'other'],
+ [_('Discipline specific'), 'disciplinary'],
+ [_('Institutional'), 'institutional']
+ ]
+ end
+
+ # Converts the byte_size into a more friendly value (e.g. 15.4 MB)
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
+ def converted_file_size(size:)
+ return { size: nil, unit: 'mb' } unless size.present? && size.is_a?(Numeric) && size.positive?
+ return { size: size / 1.petabytes, unit: 'pb' } if size >= 1.petabytes
+ return { size: size / 1.terabytes, unit: 'tb' } if size >= 1.terabytes
+ return { size: size / 1.gigabytes, unit: 'gb' } if size >= 1.gigabytes
+ return { size: size / 1.megabytes, unit: 'mb' } if size >= 1.megabytes
+
+ { size: size, unit: '' }
+ end
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
+
+ # Returns the truncated title if it is greater than 50 characters
+ def display_name
+ return '' unless @research_output.is_a?(ResearchOutput)
+ return "#{@research_output.title[0..49]} ..." if @research_output.title.length > 50
+
+ @research_output.title
+ end
+
+ # Returns the humanized version of the output_type enum variable
+ def display_type
+ return '' unless @research_output.is_a?(ResearchOutput)
+ # Return the user entered text for the type if they selected 'other'
+ return @research_output.output_type_description if @research_output.other?
+
+ @research_output.output_type.gsub('_', ' ').capitalize
+ end
+
+ # Returns the display name(s) of the repository(ies)
+ def display_repository
+ return [_('None specified')] unless @research_output.repositories.any?
+
+ @research_output.repositories.map(&:name)
+ end
+
+ # Returns the display the license name
+ def display_license
+ return _('None specified') unless @research_output.license.present?
+
+ @research_output.license.name
+ end
+
+ # Returns the display name(s) of the repository(ies)
+ def display_metadata_standard
+ return [_('None specified')] unless @research_output.metadata_standards.any?
+
+ @research_output.metadata_standards.map(&:title)
+ end
+
+ # Returns the humanized version of the access enum variable
+ def display_access
+ return _('Unspecified') unless @research_output.access.present?
+
+ @research_output.access.capitalize
+ end
+
+ # Returns the release date as a date
+ def display_release
+ return _('Unspecified') unless @research_output.release_date.present?
+
+ @research_output.release_date.to_date
+ end
+
+ # Return 'Yes', 'No' or 'Unspecified' depending on the value
+ def display_boolean(value:)
+ return 'Unspecified' if value.nil?
+
+ value ? 'Yes' : 'No'
+ end
+end
diff --git a/app/services/external_apis/rdamsc_service.rb b/app/services/external_apis/rdamsc_service.rb
new file mode 100644
index 0000000000..b6299a1a2a
--- /dev/null
+++ b/app/services/external_apis/rdamsc_service.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+module ExternalApis
+ # This service provides an interface to the RDA Metadata Standards Catalog (RDAMSC)
+ # It extracts the list of Metadata Standards using two API endpoints from the first extracts
+ # the list of subjects/concepts from the thesaurus and the second collects the standards
+ # (aka schemes) and connects them to their appropriate subjects
+ #
+ # UI to see the standards: https://rdamsc.bath.ac.uk/scheme-index
+ # API:
+ # https://app.swaggerhub.com/apis-docs/alex-ball/rda-metadata-standards-catalog/2.0.0#/m/get_api2_m
+ class RdamscService < BaseService
+ class << self
+ # Retrieve the config settings from the initializer
+ def landing_page_url
+ Rails.configuration.x.rdamsc&.landing_page_url || super
+ end
+
+ def api_base_url
+ Rails.configuration.x.rdamsc&.api_base_url || super
+ end
+
+ def max_pages
+ Rails.configuration.x.rdamsc&.max_pages || super
+ end
+
+ def max_results_per_page
+ Rails.configuration.x.rdamsc&.max_results_per_page || super
+ end
+
+ def max_redirects
+ Rails.configuration.x.rdamsc&.max_redirects || super
+ end
+
+ def active?
+ Rails.configuration.x.rdamsc&.active || super
+ end
+
+ def schemes_path
+ Rails.configuration.x.rdamsc&.schemes_path
+ end
+
+ def thesaurus_path
+ Rails.configuration.x.rdamsc&.thesaurus_path
+ end
+
+ def thesaurai
+ Rails.configuration.x.rdamsc&.thesaurai
+ end
+
+ def fetch_metadata_standards
+ query_schemes(path: "#{schemes_path}?pageSize=250")
+ end
+
+ private
+
+ # Retrieves the full list of metadata schemes from the rdamsc API as JSON.
+ # For example:
+ # {
+ # "apiVersion": "2.0.0",
+ # "data": {
+ # "currentItemCount": 10,
+ # "items": [
+ # {
+ # "description": "
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 + + # rubocop:disable Metrics/AbcSize + 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 + # rubocop:enable Metrics/AbcSize + 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..35337e4e70 --- /dev/null +++ b/app/services/external_apis/re3data_service.rb @@ -0,0 +1,153 @@ +# 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: + #+ <%= _("Title") %> <%= paginable_sort_link("research_outputs.title") %> + | ++ <%= _("Type") %> <%= paginable_sort_link("research_outputs.output_type") %> + | ++ <%= _("Repository") %> + | ++ <%= _("Release date") %> <%= paginable_sort_link("research_outputs.release_date") %> + | ++ <%= _("Access level") %> + | + <% if @plan.administerable_by?(current_user.id) %> ++ <%= _("Actions") %> + | + <% end %> +
---|---|---|---|---|---|
<%= presenter.display_name %> | +<%= presenter.display_type %> | +<%= presenter.display_repository.join(" ").html_safe %> |
+ <%= rdate.is_a?(Date) ? l(rdate, formats: :short) : rdate %> | +<%= presenter.display_access %> | + <% if @plan.administerable_by?(current_user.id) %> +
+
+
+
+
+ |
+ <% end %>
+
+ <%= _("Please list your anticipated research output(s).") %> +
+
+ <%= _("For guidance on selecting a license:") %>
+ <%= guidance %>
+
<%= sanitize(result.description) %>
+ + <% website = result.locations.select { |loc| loc["type"] == "website" }.first %> + <% if website.present? %> +<%= result.description %>
+ + <% unless selected %> + + <% end %> + +