diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000000..df6cf99753 --- /dev/null +++ b/.env.sample @@ -0,0 +1,32 @@ +# Base Rails config variables +# these settings are accessed during application initialization +# --------------------------------------------------------------- +export RAILS_ENV=development +export HOST=localhost +export RAILS_SERVE_STATIC_FILES=true +export RAILS_LOG_TO_STDOUT=true +export PORT=3000 + +# Puma settings (accessed in config/puma.rb) +export RAILS_MAX_THREADS=5 +export WEB_CONCURRENCY=2 + +# Redis settings (accessed within cable.yml) +# export REDIS_URL=redis://localhost:6379/1 + +# Capistrano settings (accessed in the various deploy targets in config/deploy/) +# export HOST_NAME= + +# Database settings (accessed within config/database.yml) +export DB_ADAPTER=mysql2 +export DB_NAME=my_app +export DB_POOL_SIZE=16 + +# AnywayConfig variables +# these settings are accessed after the application has initialized +# -------------------------------------------------------------------- +export DMPROADMAP_SERVER_HOST=https://localhost:3000 +export DMPROADMAP_NAME=MyApp +export DMPROADMAP_DO_NOT_REPLY_EMAIL=do-not-reply-my-app-dev@example.org +export DMPROADMAP_HELPDESK_EMAIL=help@example.org +export DMPROADMAP_LOCALES=MyApp-Dev diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..cf4f7e2f59 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,6 @@ +# Ignore the locale files +app/javascript/src/locale/ + +config/ +node_modules/ +spec/ \ No newline at end of file diff --git a/.github/workflows/brakeman.yml b/.github/workflows/brakeman.yml index 3ec6812cdd..eb437e0091 100644 --- a/.github/workflows/brakeman.yml +++ b/.github/workflows/brakeman.yml @@ -1,9 +1,13 @@ name: Brakeman on: + push: + branches: + - main + pull_request: branches: - master + - main jobs: brakeman: diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml index f2bd4b3464..648c57b494 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/eslint.yml @@ -9,10 +9,8 @@ jobs: steps: - uses: actions/checkout@v2 + - name: Install modules + run: yarn - # Will run ES Lint checks on javascript files - # https://github.com/marketplace/actions/run-eslint - - name: 'ES Lint checks' - uses: stefanoeb/eslint-action@1.0.0 - with: - args: './app/javascript/**/*.js' + - name: Run ESLint + run: npx eslint './app/javascript/**/*.js' diff --git a/.github/workflows/mysql.yml b/.github/workflows/mysql.yml index 740bd53d1f..0ad512bb1f 100644 --- a/.github/workflows/mysql.yml +++ b/.github/workflows/mysql.yml @@ -1,6 +1,6 @@ name: Tests - MySQL -on: [pull_request] +on: [push, pull_request] jobs: mysql: @@ -17,7 +17,7 @@ jobs: with: fetch-depth: 1 - - name: 'Install MySQL Packages' + - name: 'Install MySQL Packages' run: | sudo apt-get update sudo apt-get install -y mysql-client libmysqlclient-dev @@ -28,18 +28,10 @@ jobs: echo "BUNDLER_VERSION=`cat ./Gemfile.lock | grep -A 1 'BUNDLED WITH' | grep -oE '[0-9]\.[0-9]'`" >> $GITHUB_ENV - name: 'Install Ruby' - uses: actions/setup-ruby@v1 + uses: ruby/setup-ruby@v1.68.0 with: ruby-version: ${{ env.RUBY_VERSION }} - # Copy all of the example configs over - - name: 'Setup Default Configuration' - run: | - # Make copies of all the example config files - cp config/database.yml.sample config/database.yml - cp config/initializers/contact_us.rb.example config/initializers/contact_us.rb - cp config/initializers/wicked_pdf.rb.example config/initializers/wicked_pdf.rb - # Try to retrieve the gems from the cache - name: 'Cache Gems' uses: actions/cache@v2.1.5 diff --git a/.github/workflows/postgres.yml b/.github/workflows/postgres.yml index 5681a97364..887f2e2fc0 100644 --- a/.github/workflows/postgres.yml +++ b/.github/workflows/postgres.yml @@ -50,12 +50,10 @@ jobs: ruby-version: ${{ env.RUBY_VERSION }} # Copy all of the example configs over - - name: 'Setup Default Configuration' + - name: 'Setup Default DB Configuration' run: | # Make copies of all the example config files cp config/database.yml.sample config/database.yml - cp config/initializers/contact_us.rb.example config/initializers/contact_us.rb - cp config/initializers/wicked_pdf.rb.example config/initializers/wicked_pdf.rb # Try to retrieve the gems from the cache - name: 'Cache Gems' diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml deleted file mode 100644 index 085bbb0183..0000000000 --- a/.github/workflows/rubocop.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Rubocop - -on: [push, pull_request] - -jobs: - rubocop: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - - name: 'Determine Ruby and Bundler Versions from Gemfile.lock' - run: | - echo "RUBY_VERSION=`cat ./Gemfile.lock | grep -A 1 'RUBY VERSION' | grep 'ruby' | grep -oE '[0-9]\.[0-9]'`" >> $GITHUB_ENV - echo "BUNDLER_VERSION=`cat ./Gemfile.lock | grep -A 1 'BUNDLED WITH' | grep -oE '[0-9]\.[0-9]'`" >> $GITHUB_ENV - - # Install Ruby - using the version found in the Gemfile.lock - - name: 'Install Ruby' - uses: actions/setup-ruby@v1 - with: - ruby-version: ${{ env.RUBY_VERSION }} - - - name: 'Bundle Install' - run: | - gem install bundler -v ${{ env.BUNDLER_VERSION }} - bundle config path vendor/bundle - bundle install --jobs 4 --retry 3 --without pgsql rollbar aws - - - name: 'Run Rubocop' - run: bin/rubocop diff --git a/.gitignore b/.gitignore index af0d767c65..90e9fb7ada 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,44 @@ +# DMPTool specific rules +# ---------------------- +# Ignore Dotenv file +.env +.*-env +.set_env.sh + +# Version file generated by Capistrano +.version + +# Homepage background images +app/assets/images/homepage +# ---------------------- + # Ignore rbenv files .ruby-version # Ignore bundler config /.bundle +/ruby +/vendor + +# Ignore locally build bundle outputs +/ruby # Ignore all logfiles, tempfiles, public assets, /log/*.log /tmp +# Ignore webpack generate images in public folder +public/*.jpg +public/*.png + +# Ignore .bak files +*.bak + +# Ignore vim backup files +*.swp + # Ignore public subdirectories +# public/* public/apidocs/* public/assets/* public/fonts/* @@ -23,13 +53,9 @@ public/videos/* *.pot.bak # Ignore branded content -app/views/branded/* +app/assets/images/homepage config/locales/static_pages/*.yml - -# Ignore db schema.rb -# db/schema.rb - # Ignore seed DBs db/data.yml @@ -46,22 +72,6 @@ db/test.sqlite3-journal # Ignore the SimpleCov output coverage -# Ignore database configuration and token secrets -config/database.yml -config/secrets.yml -config/branding.yml - -# Ignore some of the initializers -config/initializers/wicked_pdf.rb -config/initializers/fingerprint.rb - -# Ignore enviroments settings -#config/environments/development.rb -#config/environments/production.rb -#config/environments/test.rb - -#config/initializers/contact_us.rb - # ignore IDE files .idea/* @@ -78,6 +88,7 @@ config/initializers/fingerprint.rb # ignore auto-generated gettext files when running gettext:find config/locale/*/app.edit.po config/locale/*/app.po.time_stamp +config/secrets.yml # Front-end related !.keep @@ -89,22 +100,20 @@ yarn-error.log yarn-debug.log* .env +.version + package-lock.json -node_modules -/public/packs -/public/packs-test spec/examples.txt .postcssrc.yml -/public/packs -/public/packs-test -/node_modules -yarn-debug.log* -.yarn-integrity # Ignore master key for decrypting credentials and more. /config/master.key -/config/credentials.yml.enc +/config/master*.key +/config/credentials.yml +/config/credentials*.yml.enc +/config/credentials/ +/config/secrets.yml /public/packs /public/packs-test diff --git a/Capfile b/Capfile new file mode 100644 index 0000000000..65bd77fa6e --- /dev/null +++ b/Capfile @@ -0,0 +1,18 @@ +# Load DSL and set up stages +require "capistrano/setup" + +# Include default deployment tasks +require "capistrano/deploy" + +# Load the SCM plugin appropriate to your project: +require "capistrano/scm/git" +install_plugin Capistrano::SCM::Git + +require "capistrano/bundler" + +require "capistrano/rails/assets" + +require "capistrano/rails/migrations" + +# Load custom tasks from `lib/capistrano/tasks` if you have any defined +Dir.glob("lib/capistrano/tasks/*.rake").each { |r| import r } diff --git a/Gemfile b/Gemfile index 63820fd17c..a4cdf8b5e1 100644 --- a/Gemfile +++ b/Gemfile @@ -40,6 +40,9 @@ gem "jbuilder" # Use Active Model has_secure_password # gem "bcrypt", "~> 3.1.7" +# Use Resque to manage the ActiveJob queue +# gem 'resque' + # Use Active Storage variant # gem "image_processing", "~> 1.2" @@ -54,6 +57,17 @@ gem "bootsnap", require: false # (http://github.com/plataformatec/responders) # gem "responders" +# ============= # +# CONFIGURATION # +# ============= # + +# Anyway Config is a configuration library for Ruby gems and applications. +gem "anyway_config" + +# Our homegrown artisinal SSM gem +gem "uc3-ssm", git: "https://github.com/CDLUC3/uc3-ssm", branch: "0.3.1" + + # ============== # # ERROR HANDLING # # ============== # @@ -90,7 +104,11 @@ gem "devise_invitable" # A generalized Rack framework for multiple-provider authentication. # (https://github.com/omniauth/omniauth) -gem "omniauth" + +# TODO: unlock this once devise creates an official release that includes this commit: +# https://github.com/heartcombo/devise/commit/1d138dd40cdc291a427b89027d16a869818a5c19#diff-59866e40fe6196ebb76fa63d186b09ba0856de17e2e938743e99add37bb83f5c +# or updates to accommodate the new version of omniauth +gem "omniauth", "~> 1.9" # OmniAuth Shibboleth strategies for OmniAuth 1.x gem "omniauth-shibboleth" @@ -106,7 +124,12 @@ gem "omniauth-orcid" # https://nvd.nist.gov/vuln/detail/CVE-2015-9284 gem "omniauth-rails_csrf_protection" +# Doorkeeper is a gem (Rails engine) that makes it easy to introduce OAuth 2 provider functionality +# to your Ruby on Rails or Grape application. https://github.com/doorkeeper-gem/doorkeeper +gem "doorkeeper", "~> 5.5" + # A ruby implementation of the RFC 7519 OAuth JSON Web Token (JWT) standard. +# https://github.com/jwt/ruby-jwt gem "jwt" # Gems for repository integration @@ -176,7 +199,15 @@ gem "autoprefixer-rails" # ========= # # Provides binaries for WKHTMLTOPDF project in an easily accessible package. -gem "wkhtmltopdf-binary" +# ------------------------------------------------ +# Start DMPTool customization +# 0.12.5 does not work on our new linux2 instances. Pegging at 0.12.4 for now +# ------------------------------------------------ +# gem 'wkhtmltopdf-binary' +gem "wkhtmltopdf-binary", "0.12.4" +# ------------------------------------------------ +# End DMPTool customization +# ------------------------------------------------ # PDF generator (from HTML) gem for Ruby on Rails # (https://github.com/mileszs/wicked_pdf) @@ -213,6 +244,10 @@ gem "httparty" # Autoload dotenv in Rails. (https://github.com/bkeepers/dotenv) gem "dotenv-rails" +# A library that retrieves an citation for the specified DOI. +# https://github.com/CDLUC3/uc3-citation +gem 'uc3-citation' + # ================================= # # ENVIRONMENT SPECIFIC DEPENDENCIES # # ================================= # @@ -243,6 +278,10 @@ group :test do # Guard gem for RSpec (https://github.com/guard/guard-rspec) gem "guard-rspec" + gem "capistrano" + + gem "capistrano-rails" + # Library for stubbing HTTP requests in Ruby. # (http://github.com/bblimke/webmock) gem "webmock" @@ -323,7 +362,7 @@ group :development do # Better error page for Rails and other Rack apps # (https://github.com/charliesome/better_errors) - gem "better_errors" + gem "better_errors", "2.8.1" # Retrieve the binding of a method's caller. Can also retrieve bindings # even further up the stack. (http://github.com/banister/binding_of_caller) diff --git a/Gemfile.lock b/Gemfile.lock index b9d51ac32d..288a80fc33 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,13 @@ +GIT + remote: https://github.com/CDLUC3/uc3-ssm + revision: 5af8578418e9f6563f68ad92af54350b25396846 + branch: 0.3.1 + specs: + uc3-ssm (0.3.1) + aws-sdk-ssm (~> 1.84) + logger (~> 1.4) + yaml (~> 0.1) + GEM remote: https://rubygems.org/ specs: @@ -44,29 +54,47 @@ GEM tzinfo (~> 1.1) addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) + airbrussh (1.4.0) + sshkit (>= 1.6.1, != 1.7.0) annotate (3.1.1) activerecord (>= 3.2, < 7.0) rake (>= 10.4, < 14.0) annotate_gem (0.0.14) bundler (>= 1.1) + anyway_config (2.2.2) + ruby-next-core (>= 0.11.0) api-pagination (4.8.2) arel (9.0.0) ast (2.4.2) - autoprefixer-rails (10.3.3.0) + autoprefixer-rails (10.4.2.0) execjs (~> 2) + aws-eventstream (1.2.0) + aws-partitions (1.549.0) + aws-sdk-core (3.125.3) + aws-eventstream (~> 1, >= 1.0.2) + aws-partitions (~> 1, >= 1.525.0) + aws-sigv4 (~> 1.1) + jmespath (~> 1.0) + aws-sdk-ssm (1.128.0) + aws-sdk-core (~> 3, >= 3.125.0) + aws-sigv4 (~> 1.1) + aws-sigv4 (1.4.0) + aws-eventstream (~> 1, >= 1.0.2) bcrypt (3.1.16) - better_errors (2.9.1) + better_errors (2.8.1) coderay (>= 1.0.0) erubi (>= 1.0.0) rack (>= 0.9.0) + bibtex-ruby (6.0.0) + latex-decode (~> 0.0) bindex (0.8.1) binding_of_caller (1.0.0) debug_inspector (>= 0.0.1) - bootsnap (1.9.1) - msgpack (~> 1.0) - brakeman (5.1.1) + bootsnap (1.10.1) + msgpack (~> 1.2) + brakeman (5.2.0) builder (3.2.4) - bullet (6.1.5) + bullet (7.0.1) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundle-audit (0.1.0) @@ -75,25 +103,35 @@ GEM bundler (>= 1.2.0, < 3) thor (~> 1.0) byebug (11.1.3) - capybara (3.35.3) + capistrano (3.16.0) + airbrussh (>= 1.0.0) + i18n + rake (>= 10.0.0) + sshkit (>= 1.9.0) + capistrano-bundler (2.0.1) + capistrano (~> 3.1) + capistrano-rails (1.6.1) + capistrano (~> 3.1) + capistrano-bundler (>= 1.1, < 3) + capybara (3.36.0) addressable + matrix mini_mime (>= 0.1.3) nokogiri (~> 1.8) rack (>= 1.6.0) rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - capybara-screenshot (1.0.25) + capybara-screenshot (1.0.26) capybara (>= 1.0, < 4) launchy - capybara-webmock (0.6.0) - capybara (>= 2.4, < 4) - rack (>= 1.4) - rack-proxy (>= 0.6.0) - rexml (>= 3.2) - selenium-webdriver (~> 3.0) - webrick (>= 1.7) - childprocess (3.0.0) + capybara-webmock (0.1.0) + childprocess (4.1.0) + citeproc (1.0.10) + namae (~> 1.0) + citeproc-ruby (1.1.14) + citeproc (~> 1.0, >= 1.0.9) + csl (~> 1.6) coderay (1.1.3) concurrent-ruby (1.1.9) contact_us (1.2.0) @@ -101,6 +139,11 @@ GEM crack (0.4.5) rexml crass (1.0.6) + csl (1.6.0) + namae (~> 1.0) + rexml + csl-styles (1.0.1.11) + csl (~> 1.0) database_cleaner (2.0.1) database_cleaner-active_record (~> 2.0.0) database_cleaner-active_record (2.0.1) @@ -108,17 +151,19 @@ GEM database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) debug_inspector (1.1.0) - devise (4.8.0) + devise (4.8.1) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) - devise_invitable (2.0.5) + devise_invitable (2.0.6) actionmailer (>= 5.0) devise (>= 4.6) - diff-lcs (1.4.4) + diff-lcs (1.5.0) docile (1.4.0) + doorkeeper (5.5.4) + railties (>= 5) dotenv (2.7.6) dotenv-rails (2.7.6) dotenv (= 2.7.6) @@ -131,7 +176,7 @@ GEM dragonfly (~> 1.0) fog-aws erubi (1.10.0) - excon (0.86.0) + excon (0.90.0) execjs (2.8.1) factory_bot (6.2.0) activesupport (>= 5.0.0) @@ -140,26 +185,30 @@ GEM railties (>= 5.0.0) faker (2.19.0) i18n (>= 1.6, < 2) - faraday (1.8.0) + faraday (1.9.3) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.1) + faraday-net_http_persistent (~> 1.0) faraday-patron (~> 1.0) faraday-rack (~> 1.0) - multipart-post (>= 1.2, < 3) + faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) faraday-em_http (1.0.0) faraday-em_synchrony (1.0.0) faraday-excon (1.1.0) faraday-httpclient (1.0.1) + faraday-multipart (1.0.3) + multipart-post (>= 1.2, < 3) faraday-net_http (1.0.1) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - ffi (1.15.4) + faraday-retry (1.0.3) + ffi (1.15.5) flag_shih_tzu (0.3.23) fog-aws (3.12.0) fog-core (~> 2.1) @@ -180,13 +229,15 @@ GEM font-awesome-sass (5.13.1) sassc (>= 1.11) formatador (0.3.0) + forwardable (1.3.2) fuubar (2.5.1) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) - gettext (3.3.7) + gettext (3.4.2) locale (>= 2.0.5) + prime text (>= 1.3.0) - globalid (0.5.2) + globalid (1.0.0) activesupport (>= 5.0) guard (2.18.0) formatador (>= 0.2.4) @@ -203,7 +254,7 @@ GEM guard-compat (~> 1.1) rspec (>= 2.99.0, < 4.0) hashdiff (1.0.1) - hashie (4.1.0) + hashie (5.0.0) highline (2.0.3) htmltoword (1.1.1) actionpack @@ -212,60 +263,69 @@ GEM httparty (0.20.0) mime-types (~> 3.0) multi_xml (>= 0.5.2) - i18n (1.8.10) + i18n (1.8.11) concurrent-ruby (~> 1.0) ipaddress (0.8.3) - jbuilder (2.11.2) + jbuilder (2.11.5) + actionview (>= 5.0.0) activesupport (>= 5.0.0) - json (2.5.1) - jwt (2.2.3) - kaminari (1.2.1) + jmespath (1.5.0) + json (2.6.1) + jwt (2.3.0) + kaminari (1.2.2) activesupport (>= 4.1.0) - kaminari-actionview (= 1.2.1) - kaminari-activerecord (= 1.2.1) - kaminari-core (= 1.2.1) - kaminari-actionview (1.2.1) + kaminari-actionview (= 1.2.2) + kaminari-activerecord (= 1.2.2) + kaminari-core (= 1.2.2) + kaminari-actionview (1.2.2) actionview - kaminari-core (= 1.2.1) - kaminari-activerecord (1.2.1) + kaminari-core (= 1.2.2) + kaminari-activerecord (1.2.2) activerecord - kaminari-core (= 1.2.1) - kaminari-core (1.2.1) + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) + latex-decode (0.3.2) launchy (2.5.0) addressable (~> 2.7) ledermann-rails-settings (2.5.0) activerecord (>= 4.2) - listen (3.7.0) + listen (3.7.1) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) locale (2.1.3) - loofah (2.12.0) + logger (1.5.0) + loofah (2.13.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) lumberjack (1.2.8) mail (2.7.1) mini_mime (>= 0.1.1) marcel (1.0.2) + matrix (0.4.2) method_source (1.0.0) - mime-types (3.3.1) + mime-types (3.4.1) mime-types-data (~> 3.2015) - mime-types-data (3.2021.0901) + mime-types-data (3.2022.0105) mimemagic (0.3.10) nokogiri (~> 1) rake - mini_mime (1.1.1) - mini_portile2 (2.6.1) - minitest (5.14.4) + mini_mime (1.1.2) + mini_portile2 (2.7.1) + minitest (5.15.0) mocha (1.13.0) msgpack (1.4.2) multi_json (1.15.0) multi_xml (0.6.0) multipart-post (2.1.1) mysql2 (0.5.3) + namae (1.1.1) nenv (0.3.0) + net-scp (3.0.0) + net-ssh (>= 2.6.5, < 7.0.0) + net-ssh (6.1.0) nio4r (2.5.8) - nokogiri (1.12.5) - mini_portile2 (~> 2.6.1) + nokogiri (1.13.1) + mini_portile2 (~> 2.7.0) racc (~> 1.4) notiffany (0.1.3) nenv (~> 0.1) @@ -276,27 +336,29 @@ GEM multi_json (~> 1.3) multi_xml (~> 0.5) rack (>= 1.2, < 3) - omniauth (2.0.4) + omniauth (1.9.1) hashie (>= 3.4.6) rack (>= 1.6.2, < 3) - rack-protection - omniauth-oauth2 (1.7.1) + omniauth-oauth2 (1.7.2) oauth2 (~> 1.4) omniauth (>= 1.9, < 3) omniauth-orcid (2.1.1) omniauth-oauth2 (~> 1.3) ruby_dig (~> 0.0.2) - omniauth-rails_csrf_protection (1.0.0) + omniauth-rails_csrf_protection (0.1.2) actionpack (>= 4.2) - omniauth (~> 2.0) + omniauth (>= 1.3.1) omniauth-shibboleth (1.3.0) omniauth (>= 1.0.0) options (2.3.2) orm_adapter (0.5.0) parallel (1.21.0) - parser (3.0.2.0) + parser (3.1.0.0) ast (~> 2.4.1) pg (1.2.3) + prime (0.1.2) + forwardable + singleton progress_bar (1.3.3) highline (>= 1.6, < 3) options (~> 2.3.0) @@ -304,19 +366,17 @@ GEM coderay (~> 1.1) method_source (~> 1.0) public_suffix (4.0.6) - puma (5.5.0) + puma (5.5.2) nio4r (~> 2.0) pundit (2.1.1) activesupport (>= 3.0.0) pundit-matchers (1.7.0) rspec-rails (>= 3.0.0) - racc (1.5.2) + racc (1.6.0) rack (2.2.3) rack-mini-profiler (2.3.3) rack (>= 1.2.0) - rack-protection (2.1.0) - rack - rack-proxy (0.7.0) + rack-proxy (0.7.2) rack rack-test (1.1.0) rack (>= 1.0, < 3) @@ -348,19 +408,19 @@ GEM method_source rake (>= 0.8.7) thor (>= 0.19.0, < 2.0) - rainbow (3.0.0) + rainbow (3.1.1) rake (13.0.6) rb-fsevent (0.11.0) rb-inotify (0.10.1) ffi (~> 1.0) recaptcha (5.8.1) json - regexp_parser (2.1.1) + regexp_parser (2.2.0) responders (3.0.1) actionpack (>= 5.0) railties (>= 5.0) rexml (3.2.5) - rollbar (3.2.0) + rollbar (3.3.0) rspec (3.10.0) rspec-core (~> 3.10.0) rspec-expectations (~> 3.10.0) @@ -369,7 +429,7 @@ GEM rspec-expectations (>= 2.99.0.beta1) rspec-core (3.10.1) rspec-support (~> 3.10.0) - rspec-expectations (3.10.1) + rspec-expectations (3.10.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.10.0) rspec-mocks (3.10.2) @@ -383,34 +443,34 @@ GEM rspec-expectations (~> 3.10) rspec-mocks (~> 3.10) rspec-support (~> 3.10) - rspec-support (3.10.2) - rubocop (1.22.0) + rspec-support (3.10.3) + rubocop (1.25.0) parallel (~> 1.10) - parser (>= 3.0.0.0) + parser (>= 3.1.0.0) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml - rubocop-ast (>= 1.12.0, < 2.0) + rubocop-ast (>= 1.15.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.12.0) + rubocop-ast (1.15.1) parser (>= 3.0.1.1) rubocop-dmp_roadmap (1.1.2) rubocop (>= 0.58.2) rubocop-rails_config (>= 0.2.2) rubocop-rspec (>= 1.27.0) - rubocop-minitest (0.15.1) + rubocop-minitest (0.17.0) rubocop (>= 0.90, < 2.0) rubocop-packaging (0.5.1) rubocop (>= 0.89, < 2.0) - rubocop-performance (1.11.5) + rubocop-performance (1.13.2) rubocop (>= 1.7.0, < 2.0) rubocop-ast (>= 0.4.0) - rubocop-rails (2.12.2) + rubocop-rails (2.13.2) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.7.0, < 2.0) - rubocop-rails_config (1.7.3) + rubocop-rails_config (1.8.0) railties (>= 5.0) rubocop (>= 1.19) rubocop-ast (>= 1.0.1) @@ -418,8 +478,9 @@ GEM rubocop-packaging (~> 0.5) rubocop-performance (~> 1.11) rubocop-rails (~> 2.0) - rubocop-rspec (2.5.0) + rubocop-rspec (2.7.0) rubocop (~> 1.19) + ruby-next-core (0.14.0) ruby-progressbar (1.11.0) ruby2_keywords (0.0.5) ruby_dig (0.0.2) @@ -434,8 +495,9 @@ GEM sprockets (> 3.0) sprockets-rails tilt - selenium-webdriver (3.142.7) - childprocess (>= 0.5, < 4.0) + selenium-webdriver (4.1.0) + childprocess (>= 0.5, < 5.0) + rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2) semantic_range (3.0.0) shellany (0.0.1) @@ -451,6 +513,7 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.3) + singleton (0.1.1) spring (2.1.1) spring-commands-rspec (1.0.4) spring (>= 0.9.1) @@ -460,22 +523,31 @@ GEM sprockets (4.0.2) concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (3.2.2) - actionpack (>= 4.0) - activesupport (>= 4.0) + sprockets-rails (3.4.2) + actionpack (>= 5.2) + activesupport (>= 5.2) sprockets (>= 3.0.0) + sshkit (1.21.2) + net-scp (>= 1.1.2) + net-ssh (>= 2.8.0) text (1.3.1) - thor (1.1.0) + thor (1.2.1) thread_safe (0.3.6) tilt (2.0.10) tomparse (0.4.2) - translation (1.26) - gettext (~> 3.2, >= 3.2.5, <= 3.3.7) + translation (1.28) + gettext (~> 3.2, >= 3.2.5, <= 3.4.2) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) tzinfo (1.2.9) thread_safe (~> 0.1) + uc3-citation (0.0.5) + bibtex-ruby (~> 6.0) + citeproc-ruby (~> 1.1) + csl-styles (~> 1.0) + httparty (~> 0.19) + logger (~> 1.4) unicode-display_width (2.1.0) uniform_notifier (1.14.2) warden (1.2.9) @@ -485,10 +557,10 @@ GEM activemodel (>= 5.0) bindex (>= 0.4.0) railties (>= 5.0) - webdrivers (4.6.1) + webdrivers (5.0.0) nokogiri (~> 1.6) rubyzip (>= 1.3.0) - selenium-webdriver (>= 3.0, < 4.0) + selenium-webdriver (~> 4.0) webmock (3.14.0) addressable (>= 2.8.0) crack (>= 0.3.2) @@ -504,10 +576,12 @@ GEM websocket-extensions (0.1.5) wicked_pdf (2.1.0) activesupport - wkhtmltopdf-binary (0.12.6.5) + wkhtmltopdf-binary (0.12.4) xpath (3.2.0) nokogiri (~> 1.8) - yard (0.9.26) + yaml (0.2.0) + yard (0.9.27) + webrick (~> 1.7.0) yard-tomdoc (0.7.1) tomparse (>= 0.4.0) yard @@ -519,15 +593,18 @@ PLATFORMS DEPENDENCIES annotate annotate_gem + anyway_config api-pagination autoprefixer-rails - better_errors + better_errors (= 2.8.1) binding_of_caller bootsnap brakeman bullet bundle-audit byebug + capistrano + capistrano-rails capybara capybara-screenshot capybara-webmock @@ -535,6 +612,7 @@ DEPENDENCIES database_cleaner devise devise_invitable + doorkeeper (~> 5.5) dotenv-rails dragonfly dragonfly-s3_data_store @@ -555,7 +633,7 @@ DEPENDENCIES mimemagic (~> 0.3.7) mocha mysql2 - omniauth + omniauth (~> 1.9) omniauth-orcid omniauth-rails_csrf_protection omniauth-shibboleth @@ -584,12 +662,14 @@ DEPENDENCIES text translation turbolinks + uc3-citation + uc3-ssm! web-console webdrivers webmock webpacker wicked_pdf - wkhtmltopdf-binary + wkhtmltopdf-binary (= 0.12.4) yard yard-tomdoc zaru diff --git a/README.md b/README.md index 748dd47aa6..7f2960c3a4 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,69 @@ -## DMP Roadmap +# DMPTool -[![Actions Status](https://github.com/DMPRoadmap/roadmap/workflows/Brakeman/badge.svg)](https://github.com/DMPRoadmap/roadmap/actions) -[![Actions Status](https://github.com/DMPRoadmap/roadmap/workflows/Rubocop/badge.svg)](https://github.com/DMPRoadmap/roadmap/actions) -[![Actions Status](https://github.com/DMPRoadmap/roadmap/workflows/ESLint/badge.svg)](https://github.com/DMPRoadmap/roadmap/actions) -[![Actions Status](https://github.com/DMPRoadmap/roadmap/workflows/Tests%20-%20PostgreSQL/badge.svg)](https://github.com/DMPRoadmap/roadmap/actions) -[![Actions Status](https://github.com/DMPRoadmap/roadmap/workflows/Tests%20-%20MySQL/badge.svg)](https://github.com/DMPRoadmap/roadmap/actions) +[![Actions Status](https://github.com/CDLUC3/dmptool/workflows/Brakeman/badge.svg)](https://github.com/CDLUC3/dmptool/actions) +[![Actions Status](https://github.com/CDLUC3/dmptool/workflows/ESLint/badge.svg)](https://github.com/CDLUC3/dmptool/actions) +[![Actions Status](https://github.com/CDLUC3/dmptool/workflows/Run%20Tests%20%28mySQL%29/badge.svg)](https://github.com/CDLUC3/dmptool/actions) -DMP Roadmap is a Data Management Planning tool. Management and development of DMP Roadmap is jointly provided by the Digital Curation Centre (DCC), http://www.dcc.ac.uk/, and the University of California Curation Center (UC3), http://www.cdlib.org/services/uc3/. +The DMPTool is a free, open-source, online application that helps researchers create data management plans (DMPs). These plans, or DMPs, are now required by many funding agencies as part of the grant proposal submission process. The DMPTool provides a click-through wizard for creating a DMP that complies with funder requirements. It also has direct links to funder websites, help text for answering questions, and resources for best practices surrounding data management. -The tool has four main functions: +The DMPTool is based on the [DMPRoadmap](https://github.com/DMPRoadmap/roadmap) open source project. DMPRoadmap is being collaboratively developed by members of the University of California Curation Center (UC3), the Digital Curation Centre (DCC) and contributions from the community. -1. To help create and maintain different versions of Data Management Plans; -2. To provide useful guidance on data management issues and how to meet research funders' requirements; -3. To export attractive and useful plans in a variety of formats; -4. To allow collaborative work when creating Data Management Plans. +Click here for the latest [releases].(https://github.com/CDLUC3/dmptool/releases/) -Click here for the latest [releases](https://github.com/DMPRoadmap/roadmap/releases/). +## Support + +Issues should be reported on [Github Issues](https://github.com/CDLUC3/dmptool/issues) +Please be advised though that we can only provide support for the [DMPTool](https://dmptool.org). This code is offered as open source and we can only provide limited support for your local installation. + +Issues will be triaged by our team and if applicable will be moved/opened in the DMPRoadmap repository. + +## Translations + +See the [Translations Guide](https://github.com/DMPRoadmap/roadmap/wiki/Translations) #### Pre-requisites Roadmap is a Ruby on Rails application and you will need to have: -* Ruby = 2.4.0 - 2.4.4 -* Rails = 4.2 +* Ruby = 2.6.3 +* Rails = 5.2 * MySQL >= 5.0 OR PostgreSQL -Further detail on how to install Ruby on Rails applications are available from the Ruby on Rails site: http://rubyonrails.org. +## Installation -Further details on how to install MySQL and create your first user and database. Be sure to follow the instructions for your particular environment. -* Install: http://dev.mysql.com/downloads/mysql/ -* Create a user: http://dev.mysql.com/doc/refman/5.7/en/create-user.html -* Create the database: http://dev.mysql.com/doc/refman/5.7/en/creating-database.html +If you would like to install and run this application, we encourage you to start with a basic [installation of DMPRoadmap](https://github.com/DMPRoadmap/roadmap/wiki/Installation). Follow the instructions and determine if the functionality it provides meets your requirements. -You may also find the following resources handy: +If the basic DMPRoadmap system does not provide the functionality you require please review the list of customizations that we have made below. If our additional changes do not meet your needs, you are encouraged to fork the DMPRoadmap codebase and customize it to your needs. If you do customize it please contact the DMPRoadmap team to let the community know about the additional functionality you plan to offer. It may be useful to the larger community. -* The Getting Started Guide: http://guides.rubyonrails.org/getting_started.html -* Ruby on Rails Tutorial Book: http://www.railstutorial.org/ +If DMPTool meets your organization's needs, you should install it following the [installation instructions on our wiki](https://github.com/CDLUC3/dmptool/wiki/installation) then perform the folowing tasks: -#### Installation -See the [Installation Guide](https://github.com/DMPRoadmap/roadmap/wiki/Installation) on the Wiki. +- **Homepage images:** While you are free to use the images provided along with this repository, it is advisable to replace them with ones more relevant to your user base. The system randomly serves up one of five images that are located in `app/assets/images/homepage/`. Follow the image sizes in the provided examples for your images. You must also retain the file names as the `app/assets/stylesheets/dmptool/blocks/_home_welcome.scss` references them by name +- **Rotating news on the homepage:** Update the blog url value in `config/dmproadmap.yml` with the address of your blog's RSS feed. +- **Styles:** The system loads the base DMPRoadmap stylesheet first then the DMPTool stylesheet. We recommend that you add your own additional stylesheet if your changes are extensive or update `app/assets/stylesheets/dmptool/**/*.scss` directly. Do not make changes to the other stylesheets as they are managed as part of the DMPRoadmap project. +- **Static Content:** Update the files in `app/views/branded/static_pages/` so that they are appropriate for your installation. +- **Shibboleth:** Setting up your own Shibboleth service provider (SP) is beyond the scope of this application. If you have an SP available and want to use it, make sure that you enable the shibboleth settings in `config/dmproadmap.yml` and then add your organization's entity id (found in your Shib SP's list of registered IdPs) within the UI. Then log out and log back in via your institution's credentials to test that things are working properly. Note that the DMPTool only allows users to authenticate via Shibboleth if the organization is regsitered within the system (meaning that it appears in the application's `orgs` table) -#### Troubleshooting -See the [Troubleshooting Guide](https://github.com/DMPRoadmap/roadmap/wiki/Troubleshooting) on the Wiki. +## Variations between DMPRoadmap and DMPTool -#### Support +The following is a list of customizations that we have made to the base DMPRoadmap codebase: + +- **Homepage:** A complete redesign of the homepage +- **Navigation:** A complete redesign of the header and footer navigation. All of the custom menus have been placed in `app/views/branded/layouts/`. +- **Static Content:** Added a gem to allow the `app/views/branded/static_pages` to be managed via Markdown instead of HTML. Changed the content of existing pages and added new pages +- **Sigin in/Create account:** The sign in and create account workflows has been overhauled to place more emphasis on logging in via institutional credentials (Shibboleth). All of the DMPTool code for these items have been separated into its own `app/views/branded/shared/` subdirectory. The corresponding JS file can be found at `app/javascript/dmptool/`. +- **Public participating institutions page:** A new Participating Institutions page has been added and is accessible to the public. +- **Styling:** We have added `app/assets/stylesheets/dmptool/**/*.scss` files that gets loaded after the base DMPRoadmap stylesheets +- **Text/Labels:** Various text and labels have been updated. + +## Troubleshooting +See the [Troubleshooting Guide](https://github.com/DMPRoadmap/roadmap/wiki/Troubleshooting) on the DMPRoadmap Wiki + +## Support Issues should be reported here on [Github Issues](https://github.com/DMPRoadmap/roadmap/issues) Please be advised though that we can only provide limited support for your local installations. Any security patches and bugfixes will be applied to the most recent version, and we will endeavour to support migrations to the current release. -#### Contributing -If you would like to contribute to the project. Please follow these steps to submit a contribution: -* Comment on the Github issue (or create one if one does not exist) and let us know that you're working on it. -* Fork the project (if you have not already) or rebase your fork so that it is up to date with the current repository's '_**development**_' branch -* Create a new branch in your fork. This will ensure that you are able to work at your own pace and continue to pull in any updates made to this project. -* Make your changes in the new branch -* When you have finished your work, make sure that your version of the '_**development**_' branch is still up to date with this project. Then merge your new branch into your '_**development**_' branch. -* Then create a new Pull Request (PR) from your branch to this project's '_**development**_' branch in GitHub -* The project team will then review your PR and communicate with you to convey any additional changes that would ensure that your work adheres to our guidelines. - -See the [Contribution Guide](https://github.com/DMPRoadmap/roadmap/blob/development/CONTRIBUTING.md) on the Wiki for more details. +## Contributing -#### License -The DMP Roadmap project uses the MIT License. +See the [Contributing Guide](https://github.com/DMPRoadmap/roadmap/wiki/Get-involved) -foobar +## License +The DMPTool project uses the MIT License. diff --git a/Rakefile b/Rakefile index 700a8a33cf..7253c54d30 100755 --- a/Rakefile +++ b/Rakefile @@ -12,6 +12,11 @@ require_relative "config/application" +# Resque setup for ActiveJob +# require 'resque' +# require 'resque/tasks' +# require 'your/app' + DMPRoadmap::Application.load_tasks task default: :test diff --git a/app/assets/images/BridgetThrasher.jpeg b/app/assets/images/BridgetThrasher.jpeg new file mode 100755 index 0000000000..7b4b3ed58a Binary files /dev/null and b/app/assets/images/BridgetThrasher.jpeg differ diff --git a/app/assets/images/DMPTool_logo_blue_shades_v1b3b.svg b/app/assets/images/DMPTool_logo_blue_shades_v1b3b.svg new file mode 100644 index 0000000000..da09154c4f --- /dev/null +++ b/app/assets/images/DMPTool_logo_blue_shades_v1b3b.svg @@ -0,0 +1 @@ +DMPTool_logo_blue_shades_v1b3b \ No newline at end of file diff --git a/app/assets/images/DMPTool_logo_blue_shades_v1b3b_no_tag.svg b/app/assets/images/DMPTool_logo_blue_shades_v1b3b_no_tag.svg new file mode 100644 index 0000000000..a18c0c7434 --- /dev/null +++ b/app/assets/images/DMPTool_logo_blue_shades_v1b3b_no_tag.svg @@ -0,0 +1,29 @@ + + + + +DMPTool_logo_blue_shades_v1b3b_no_tag + + + + + + diff --git a/app/assets/images/ExnerHeadshot.JPG b/app/assets/images/ExnerHeadshot.JPG new file mode 100755 index 0000000000..36d6d5603c Binary files /dev/null and b/app/assets/images/ExnerHeadshot.JPG differ diff --git a/app/assets/images/GeoffHamm.png b/app/assets/images/GeoffHamm.png new file mode 100755 index 0000000000..ac864b6491 Binary files /dev/null and b/app/assets/images/GeoffHamm.png differ diff --git a/app/assets/images/HeatherBarnes.jpg b/app/assets/images/HeatherBarnes.jpg new file mode 100755 index 0000000000..e47912dd08 Binary files /dev/null and b/app/assets/images/HeatherBarnes.jpg differ diff --git a/app/assets/images/Megan_O_Donnell.jpg b/app/assets/images/Megan_O_Donnell.jpg new file mode 100755 index 0000000000..45b9084678 Binary files /dev/null and b/app/assets/images/Megan_O_Donnell.jpg differ diff --git a/app/assets/images/Nick_Ruhs.jpg b/app/assets/images/Nick_Ruhs.jpg new file mode 100755 index 0000000000..8f1744224a Binary files /dev/null and b/app/assets/images/Nick_Ruhs.jpg differ diff --git a/app/assets/images/Raj_Kumar_Bhardwaj.jpg b/app/assets/images/Raj_Kumar_Bhardwaj.jpg new file mode 100755 index 0000000000..058ab11900 Binary files /dev/null and b/app/assets/images/Raj_Kumar_Bhardwaj.jpg differ diff --git a/app/assets/images/Renata_Curty.JPG b/app/assets/images/Renata_Curty.JPG new file mode 100755 index 0000000000..f24f75eaa4 Binary files /dev/null and b/app/assets/images/Renata_Curty.JPG differ diff --git a/app/assets/images/Sackmann_Anna_hr.jpg b/app/assets/images/Sackmann_Anna_hr.jpg new file mode 100755 index 0000000000..9a597e5b93 Binary files /dev/null and b/app/assets/images/Sackmann_Anna_hr.jpg differ diff --git a/app/assets/images/VarnerDouglas.jpg b/app/assets/images/VarnerDouglas.jpg new file mode 100755 index 0000000000..114d45b561 Binary files /dev/null and b/app/assets/images/VarnerDouglas.jpg differ diff --git a/app/assets/images/android-chrome-192x192.png b/app/assets/images/android-chrome-192x192.png new file mode 100644 index 0000000000..16324127bd Binary files /dev/null and b/app/assets/images/android-chrome-192x192.png differ diff --git a/app/assets/images/apple-touch-icon.png b/app/assets/images/apple-touch-icon.png new file mode 100644 index 0000000000..90144dbfd2 Binary files /dev/null and b/app/assets/images/apple-touch-icon.png differ diff --git a/app/assets/images/browserconfig.xml b/app/assets/images/browserconfig.xml new file mode 100644 index 0000000000..4be1679dbb --- /dev/null +++ b/app/assets/images/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #0e5682 + + + diff --git a/app/assets/images/doty-jen-resized.jpg b/app/assets/images/doty-jen-resized.jpg new file mode 100755 index 0000000000..66f3363bf7 Binary files /dev/null and b/app/assets/images/doty-jen-resized.jpg differ diff --git a/app/assets/images/favicon-16x16.png b/app/assets/images/favicon-16x16.png new file mode 100644 index 0000000000..ccf7f81c0d Binary files /dev/null and b/app/assets/images/favicon-16x16.png differ diff --git a/app/assets/images/favicon-32x32.png b/app/assets/images/favicon-32x32.png new file mode 100644 index 0000000000..2552ce3258 Binary files /dev/null and b/app/assets/images/favicon-32x32.png differ diff --git a/app/assets/images/favicon.ico b/app/assets/images/favicon.ico index 4ddb00d5f7..164c5e6455 100644 Binary files a/app/assets/images/favicon.ico and b/app/assets/images/favicon.ico differ diff --git a/app/assets/images/homepage/1-large.jpg b/app/assets/images/homepage/1-large.jpg new file mode 100644 index 0000000000..02e4750aa2 Binary files /dev/null and b/app/assets/images/homepage/1-large.jpg differ diff --git a/app/assets/images/homepage/2-large.png b/app/assets/images/homepage/2-large.png new file mode 100644 index 0000000000..72ed949cd2 Binary files /dev/null and b/app/assets/images/homepage/2-large.png differ diff --git a/app/assets/images/homepage/3-large.jpg b/app/assets/images/homepage/3-large.jpg new file mode 100644 index 0000000000..b6cecbac21 Binary files /dev/null and b/app/assets/images/homepage/3-large.jpg differ diff --git a/app/assets/images/homepage/4-large.jpg b/app/assets/images/homepage/4-large.jpg new file mode 100644 index 0000000000..e518fdd6e4 Binary files /dev/null and b/app/assets/images/homepage/4-large.jpg differ diff --git a/app/assets/images/homepage/5-large.png b/app/assets/images/homepage/5-large.png new file mode 100644 index 0000000000..3af503eb6f Binary files /dev/null and b/app/assets/images/homepage/5-large.png differ diff --git a/app/assets/images/jhermer.jpg b/app/assets/images/jhermer.jpg new file mode 100755 index 0000000000..a5162020e6 Binary files /dev/null and b/app/assets/images/jhermer.jpg differ diff --git a/app/assets/images/mstile-150x150.png b/app/assets/images/mstile-150x150.png new file mode 100644 index 0000000000..74112739d3 Binary files /dev/null and b/app/assets/images/mstile-150x150.png differ diff --git a/app/assets/images/rotsuji-portrait.jpg b/app/assets/images/rotsuji-portrait.jpg new file mode 100755 index 0000000000..6b5bb67eb9 Binary files /dev/null and b/app/assets/images/rotsuji-portrait.jpg differ diff --git a/app/assets/images/safari-pinned-tab.svg b/app/assets/images/safari-pinned-tab.svg new file mode 100644 index 0000000000..2925c520a4 --- /dev/null +++ b/app/assets/images/safari-pinned-tab.svg @@ -0,0 +1,55 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + + + + + + + + + + diff --git a/app/assets/images/site.webmanifest b/app/assets/images/site.webmanifest new file mode 100644 index 0000000000..0e0e858dc7 --- /dev/null +++ b/app/assets/images/site.webmanifest @@ -0,0 +1,14 @@ +{ + "name": "", + "short_name": "", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 4e5640a461..2ba2c93542 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -15,3 +15,10 @@ @import "font-awesome-sprockets"; @import "font-awesome"; + +/* START DMPTool customization */ +/* ---------------------------------------- */ +@import "dmptool/blocks/*"; +@import "dmptool/utils/*"; +/* ---------------------------------------- */ +/* END DMPTool customization */ diff --git a/app/assets/stylesheets/blocks/_floats.scss b/app/assets/stylesheets/blocks/_floats.scss new file mode 100644 index 0000000000..77631b4ecb --- /dev/null +++ b/app/assets/stylesheets/blocks/_floats.scss @@ -0,0 +1,12 @@ +/* Shortcuts to float a div */ +.float-none { + float: none; +} + +.float-left { + float: left; +} + +.float-right { + float: right; +} 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/blocks/_selectpicker.scss b/app/assets/stylesheets/blocks/_selectpicker.scss index d1a561e7c6..02b50151b2 100644 --- a/app/assets/stylesheets/blocks/_selectpicker.scss +++ b/app/assets/stylesheets/blocks/_selectpicker.scss @@ -1,8 +1,8 @@ .bootstrap-select { + display: block; border: 1px solid #D3D3D3; } - .btn.dropdown-toggle:hover, .btn.dropdown-toggle:focus { color: black; -} \ No newline at end of file +} diff --git a/app/assets/stylesheets/blocks/_spinner.scss b/app/assets/stylesheets/blocks/_spinner.scss index a812084c90..8b38432161 100644 --- a/app/assets/stylesheets/blocks/_spinner.scss +++ b/app/assets/stylesheets/blocks/_spinner.scss @@ -1,6 +1,6 @@ .spinner-border { position: fixed; - top: 48%; + top: 45%; left: 43%; } diff --git a/app/assets/stylesheets/dmptool/blocks/_accordions.scss b/app/assets/stylesheets/dmptool/blocks/_accordions.scss new file mode 100644 index 0000000000..9feef1edd1 --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_accordions.scss @@ -0,0 +1,4 @@ +.inverse-heading-button > .panel-heading, .panel-default > .panel-heading { + background-color: $color-seccondary-background; + color: $color-seccondary-text; +} diff --git a/app/assets/stylesheets/dmptool/blocks/_alerts.scss b/app/assets/stylesheets/dmptool/blocks/_alerts.scss new file mode 100644 index 0000000000..ed78593704 --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_alerts.scss @@ -0,0 +1,62 @@ +#notification-area.alert-info { + background-color: $color-green; + color: $color-primary-text; +} + +#notification-area.alert-warning { + background-color: $color-red; + color: $color-primary-text; +} + +#notification-area.alert-error { + background-color: $color-red; + color: $color-primary-text; +} + +#global-notification-area .alert-info { + background-color: $color-light-blue; + border-color: $color-light-blue; + color: $color-seccondary-text; + + /* Override the color of the notification area's close/dismiss button */ + button[data-dismiss="alert"] { + color: $color-seccondary-text; + opacity: 1; + } +} + +#global-notification-area .alert-warning { + background-color: $color-alert-warning-background; + color: $color-alert-warning-text; + + /* Override the color of the notification area's close/dismiss button */ + button[data-dismiss="alert"] { + color: $color-alert-warning-text; + opacity: 1; + } +} + +#global-notification-area .alert-error { + background-color: $color-alert-error-background; + color: $color-alert-error-text; + + /* Override the color of the notification area's close/dismiss button */ + button[data-dismiss="alert"] { + color: $color-alert-error-text; + opacity: 1; + } +} + +/* Changes color of 'Saving ...' and answer status */ +.status, .label-info, .bg-info { + background-color: $color-light-blue; + color: $color-dark-blue; +} +.status { + padding: 10px 15px 10px 5px; +} + +/* Overrides the write Plan pages's progress bar */ +.progress-bar { + background-color: $color-primary-background; +} \ No newline at end of file diff --git a/app/assets/stylesheets/dmptool/blocks/_buttons.scss b/app/assets/stylesheets/dmptool/blocks/_buttons.scss new file mode 100644 index 0000000000..684df97286 --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_buttons.scss @@ -0,0 +1,16 @@ +.btn { + border-radius: 5px; + padding: 3px 12px 4px 12px; +} + +/* Override the grey color of the magnifying glass on table search boxes */ +.input-group-addon .fa:not(.small) { + color: $color-primary-text; +} + +/* Restrict the width of the buttons on the Get Started page */ +#get-started-page .btn { + max-width: 650px; + margin-left: auto; + margin-right: auto; +} diff --git a/app/assets/stylesheets/dmptool/blocks/_citations.scss b/app/assets/stylesheets/dmptool/blocks/_citations.scss new file mode 100644 index 0000000000..a71a2aaf43 --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_citations.scss @@ -0,0 +1,4 @@ +.citation { + margin-top: 10px; + margin-bottom: 10px; +} \ No newline at end of file diff --git a/app/assets/stylesheets/dmptool/blocks/_editorial_board.scss b/app/assets/stylesheets/dmptool/blocks/_editorial_board.scss new file mode 100644 index 0000000000..9c2b313bfd --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_editorial_board.scss @@ -0,0 +1,12 @@ +#editorial-board { + .row { + margin-bottom: 20px; + } + img { + max-height: 150px; + } + .board-bio { + text-align: left; + margin-top: 55px; + } +} diff --git a/app/assets/stylesheets/dmptool/blocks/_font_awesome.scss b/app/assets/stylesheets/dmptool/blocks/_font_awesome.scss new file mode 100644 index 0000000000..37809dd606 --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_font_awesome.scss @@ -0,0 +1,24 @@ +/* Override to just allow fontawesome to inherit size/color */ +.fab:not(.small), +.fas:not(.small) { + font-size: inherit; + color: inherit; +} + +/* Override to increase the size of the social media icons */ +.fab.large, +.fas.large { + font-size: 2rem; +} + +/* Override the size of the DOCX and PDF icons */ +a i.fab.fa-file-pdf-o:not(.small), +a i.fas.fa-file-pdf-o:not(.small), +a i.fab.fa-file-word-o:not(.small), +a i.fas.fa-file-word-o:not(.small) { + font-size: 2.3rem; +} + +.fa-question-circle:not(.small) { + font-size: 1.8rem; +} diff --git a/app/assets/stylesheets/dmptool/blocks/_forms.scss b/app/assets/stylesheets/dmptool/blocks/_forms.scss new file mode 100644 index 0000000000..8cfc46a691 --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_forms.scss @@ -0,0 +1,6 @@ +.radio input[type="radio"], +.radio-inline input[type="radio"], +.checkbox input[type="checkbox"], +.checkbox-inline input[type="checkbox"] { + margin: 5px 0 0 -17px; +} \ No newline at end of file diff --git a/app/assets/stylesheets/dmptool/blocks/_hacks.scss b/app/assets/stylesheets/dmptool/blocks/_hacks.scss new file mode 100644 index 0000000000..39be9fb6af --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_hacks.scss @@ -0,0 +1,5 @@ +.question_container { + .fa-question-circle { + font-size: 1.8rem; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/dmptool/blocks/_headings.scss b/app/assets/stylesheets/dmptool/blocks/_headings.scss new file mode 100644 index 0000000000..a13c0e8429 --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_headings.scss @@ -0,0 +1,16 @@ +h1 { + font-size: 2.8rem; +} + +h2 { + font-size: 2.4rem; +} + +h3 { + font-size: 1.9rem; +} + +h1, h2, h3, h4 { + margin-top: 5px; + margin-bottom: 5px; +} \ No newline at end of file diff --git a/app/assets/stylesheets/dmptool/blocks/_home_stats.scss b/app/assets/stylesheets/dmptool/blocks/_home_stats.scss new file mode 100644 index 0000000000..eca00a330b --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_home_stats.scss @@ -0,0 +1,78 @@ +.home-page { + display: flex; + flex-flow: column; + + .metrics, .communications { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-items: baseline; + + width: 100%; + margin-top: 35px; + } + + .metrics > .overview, .metrics > .top-5 { + display: flex; + flex-flow: column; + + .bottom { + display: flex; + flex-flow: row wrap; + justify-content: space-around; + + border-top: 1px solid $color-black; + + .metric { + position: relative; + display: flex; + flex-flow: column; + align-items: center; + margin: 25px 45px; + + .fa { + margin-bottom: 10px; + font-size: 2.5rem; + } + + .numbers { + font-size: 2rem; + } + } + } + } + + .top-5 { + .bottom { + position: relative; + + ol { + list-style: none; + padding-right: 40px; + margin-top: 15px; + } + } + + .more { + right: 0; + } + } + .top-5 > .bottom > ol { + list-style-position: inside; + padding-left: 10px; + } + + .more { + position: absolute; + bottom: -8px; + right: -15px; + + a { + font-size: 1.2rem; + } + } + + .communications { + border-top: 1px solid $color-black; + } +} \ No newline at end of file diff --git a/app/assets/stylesheets/dmptool/blocks/_home_welcome.scss.erb b/app/assets/stylesheets/dmptool/blocks/_home_welcome.scss.erb new file mode 100644 index 0000000000..2e81fa551c --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_home_welcome.scss.erb @@ -0,0 +1,64 @@ +.homepage-img-1 { background-image: url(<%= asset_path('homepage/1-large.jpg') %>); } +.homepage-img-2 { background-image: url(<%= asset_path('homepage/2-large.png') %>); } +.homepage-img-3 { background-image: url(<%= asset_path('homepage/3-large.jpg') %>); } +.homepage-img-4 { background-image: url(<%= asset_path('homepage/4-large.jpg') %>); } +.homepage-img-5 { background-image: url(<%= asset_path('homepage/5-large.png') %>); } + +.welcome { + flex: 1 100%; + width: 100%; + min-height: 493px; + margin: 0 auto; + padding-top: 55px; + + background-color: $color-seccondary-background; + background-position: top; + background-size: cover; + background-repeat: no-repeat; + + > div { + width: 100%; + margin: 0 auto; + padding: 0 5px; + background: $color-translucent-blue; + + .welcome-message { + padding: 5px; + margin: 0 auto; + width: 97%; + max-width: $max-width-breakpoint; + + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-items: flex-end; + + color: $color-white; + font-size: 1.7rem; + + div { + flex: 1 auto; + } + + h1 { + color: $color-primary-text; + margin-top: 15px; + font-weight: bold; + font-size: 3rem; + } + + div > div.get-started { + float: right; + + .btn-inverse { + color: $color-seccondary-text; + background-color: $color-seccondary-background; + padding: 10px 45px; + border-radius: 7px; + font-size: 1.8rem; + margin-bottom: 10px; + } + } + } + } +} diff --git a/app/assets/stylesheets/dmptool/blocks/_html.scss b/app/assets/stylesheets/dmptool/blocks/_html.scss new file mode 100644 index 0000000000..8998f6944c --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_html.scss @@ -0,0 +1,264 @@ + +/* LAYOUT */ +/* ----------------------------------------------------------------------- */ +body { + position: relative; + width: 100%; + + font-family: Arial; + padding-bottom: 50px; + + .dmptool { + position: relative; + display: flex; + flex-flow: column nowrap; + + /* Hide the mobile menus by default */ + header > .fixed-menu-mobile, footer > .footer-mobile { + display: none; + } + + header, main, footer, .notifications { + flex: 1 100%; + width: 100%; + margin: 0 auto; + } + + header > div, main > div, footer > div, + .notifications > div, .fixed-menu > nav { + padding: 5px; + width: 97%; + margin: 0 auto; + max-width: $max-width-breakpoint; + } + + /* offset header to accomodate height of fixed menu */ + header { + margin-top: 30px; + + hr { + background-color: $color-primary-background; + color: $color-seccondary-text; + height: 1.2px; + margin-top: 8px; + } + + .fixed-menu { + ul[aria-labelledby="learn-menu-button"] { + min-width: 520px; + } + + ul[aria-labelledby="signin-menu-button"] { + width: 500px; + padding: 10px 15px; + text-align: center; + + h3 { + margin-top: 25px; + font-size: 1.6rem; + } + a { + margin-left: auto; + margin-right: auto; + } + a.btn { + width: 80%; + } + } + + ul[aria-labelledby="language-menu-button"] { + li.active > a, li.active > a:hover, li.active > a:focus, li.active > a:visited, li.active > a:active { + color: $color-primary-text !important; + } + } + + .learn-menu { + div.menu-col { + padding: 5px 15px; + + display: flex; + flex-flow: column nowrap; + justify-content: space-between; + + div { + flex: 1 0 auto; + + div { + padding-left: 20px; + } + div:last-child { + padding-bottom: 5px; + } + } + } + } + } + + .branding { + .branding-links > ul { + list-style: none; + } + } + + .app-menu { + font-size: 2rem; + border-bottom: 1.5px solid $color-seccondary-text; + } + } + } + + .branding, .app-menu, .footer-top, .footer-bottom, .learn-menu, app.menu { + display: flex; + flex-flow: row wrap; + } + + footer { + padding-top: 10px; + position: relative; + + .nav > li > a:hover, .nav > li > a:focus { + background-color: inherit; + text-decoration: underline; + } + + .footer { + padding-top: 5px; + border-top: 2px solid $color-seccondary-text; + } + + .footer-top, .footer-bottom { + display: flex; + flex: row wrap; + justify-content: space-between; + align-items: flex-start; + } + + .footer-menu > .navbar > .container-fluid { + padding-left: 0; + } + + .footer-social > .navbar > .container-fluid { + padding-right: 0; + + ul > li { + padding-top: 0; + + a { + padding-top: 0; + padding-left: 0; + } + } + + .fa { + font-size: 24px !important; + } + } + .footer-menu .container-fluid .navbar-nav > li > a { + padding: 0 15px 0 0; + } + + .footer-bottom { + width: 100%; + + .footer-copyright { + p, a, a:hover, a:focus, a:visited, a:active, a:active:hover { + font-size: 1.1rem; + } + } + + .footer-logo { + float: right; + + img { + width: 150px; + } + } + } + } + footer.blue .footer { + background-color: $color-light-blue; + border-top: none; + border-radius: 5px; + padding-left: 5px; + } +} + + +/* FIXED MENU (Learn, Sign in/out, Language) */ +/* ----------------------------------------------------------------------- */ +.navbar-fixed-top { + min-height: 30px; + padding: 5px 0 5px 5px; + background-color: $color-seccondary-background; + box-shadow: 0 2px $color-light-grey; + + .navbar-right { + margin-right: 0; + } + + .navbar-nav > li > a { + padding: 0 0 0 25px; + } + + .learn-menu ul > li > ul { + padding-left: 20px; + } +} + +/* BRANDING (DMPTool Logo, Org Logo, Org Name, Org Links) */ +/* ----------------------------------------------------------------------- */ +.branding { + margin-bottom: 20px; + align-items: center; + justify-content: space-between; + + .branding-name { + font-size: 2.2rem; + max-width: 300px; + } + + .branding-links { + .fa { + color: $color-primary-text; + font-size: 1.5rem; + } + } + + .app-logo { + height: auto; + min-width: 250px; + max-width: 300px; + } + + .org-logo { + min-width: 100px; + max-width: 300px; + min-height: 50px; + max-height: 150px; + } + + #org-sign-in-logo { + margin: 0 auto; + } +} + +/* APP MENU (Dashboard, Create Plan, Admin Features) */ +/* ----------------------------------------------------------------------- */ +.app-menu { + margin-top: 25px; + .navbar { + margin-bottom: -20px; + + .navbar-nav > li > a { + padding: 0 25px 0 0; + } + } + + a, a:visited, a:focus, a:active, a:hover { + font-size: 2rem; + + .fa-sort-desc { + font-size: 1.5rem; + } + } +} diff --git a/app/assets/stylesheets/dmptool/blocks/_labels.scss b/app/assets/stylesheets/dmptool/blocks/_labels.scss new file mode 100644 index 0000000000..71787f9701 --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_labels.scss @@ -0,0 +1,5 @@ +/* Override the default colors of the 'Example answer' label on Write Plan */ +.label-default { + background-color: $color-primary-background; + color: $color-primary-text; +} \ No newline at end of file diff --git a/app/assets/stylesheets/dmptool/blocks/_logos.scss b/app/assets/stylesheets/dmptool/blocks/_logos.scss new file mode 100644 index 0000000000..7417a57c0e --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_logos.scss @@ -0,0 +1,13 @@ +.app-logo { + height: auto; + min-width: 250px; + max-width: 300px; +} + +.org-logo { + height: auto; + min-width: 100px; + max-width: 300px; + min-height: 50px; + max-height: 150px; +} \ No newline at end of file diff --git a/app/assets/stylesheets/dmptool/blocks/_navs.scss b/app/assets/stylesheets/dmptool/blocks/_navs.scss new file mode 100644 index 0000000000..cb9edc95f0 --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_navs.scss @@ -0,0 +1,131 @@ +/* Override the default nav tab background color so that it's white */ +.nav-tabs, .nav-pills { + background-color: $color-seccondary-background; + color: $color-seccondary-text; + + li { + background-color: $color-primary-background; + color: $color-primary-text; + } +} + +/* Overrides the default removal of the right hand border of the nav pills on Write plan */ +.nav-pills > li:last-child > a:hover, +.nav-pills > li:last-child > a:focus, +.nav-pills > li.active:last-child > a, +.nav-pills > li.active:last-child > a:focus, +.nav-pills > li.active:last-child > a:hover { + border-right: 1px solid $color-black; +} + +/* FIXED MENU (Learn, Sign in/out, Language) */ +/* ----------------------------------------------------------------------- */ +.navbar-fixed-top { + min-height: 30px; + padding: 5px 0 5px 5px; + background-color: $color-seccondary-background; + box-shadow: 0 2px $color-light-grey; + + .navbar-right { + margin-right: 0; + } + + .nav > li > a:hover, .nav > li > a:focus { + background-color: inherit; + } + + .navbar-nav > li > a { + padding: 0 0 0 25px; + } + + .learn-menu ul > li > ul { + padding-left: 20px; + } +} + +/* BRANDING (DMPTool Logo, Org Logo, Org Name, Org Links) */ +/* ----------------------------------------------------------------------- */ +.branding { + margin-bottom: 20px; + align-items: center; + justify-content: space-between; + + .branding-name { + font-size: 2.2rem; + max-width: 300px; + } + + .branding-links { + .fa { + color: $color-seccondary-text; + font-size: 1.5rem; + } + } + + .app-logo { + height: auto; + min-width: 250px; + max-width: 300px; + } + + .org-logo { + min-width: 100px; + max-width: 300px; + min-height: 50px; + max-height: 150px; + } + + #org-sign-in-logo { + margin: 0 auto; + } +} + +/* APP MENU (Dashboard, Create Plan, Admin Features) */ +/* ----------------------------------------------------------------------- */ +.app-menu { + .navbar { + margin-bottom: -20px; + + .navbar-nav > li > a { + padding: 0 25px 0 0; + } + } + + a, a:visited, a:focus, a:active, a:hover { + font-size: 2rem; + color: $color-seccondary-text; + + .fa-sort-desc { + color: $color-seccondary-text; + font-size: 1.5rem; + } + } + + .dropdown-menu > li > a, + .dropdown-menu > li > a:focus, + .dropdown-menu > li > a:hover, + .dropdown-menu > li > a:active, + .dropdown-menu > li > a:visited { + font-size: 1.8rem; + color: $color-seccondary-text; + } + + .nav > li > a:hover, + .nav > li > a:focus, + .dropdown-menu > li > a:hover, + .dropdown-menu > li > a:focus { + background-color: inherit; + color: $color-seccondary-text; + text-decoration: underline; + } +} +body .dmptool header > div.app-menu { + margin-top: 25px; +} + +/* Override the right border on caret dropdown menu icons */ +.caret { + border-top: 4px solid; + border-left: 4px solid transparent; + border-right: 4px solid transparent; +} \ No newline at end of file diff --git a/app/assets/stylesheets/dmptool/blocks/_new_window_popup.scss b/app/assets/stylesheets/dmptool/blocks/_new_window_popup.scss new file mode 100644 index 0000000000..4adcdf8ea7 --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_new_window_popup.scss @@ -0,0 +1,23 @@ +a.has-new-window-popup-info, +button.has-new-window-popup-info { + + & > span.new-window-popup-info { + left: auto; + right: 10px; + } + + &:hover, + &:focus, + &:active { + + & > span.new-window-popup-info { + border:1px solid $color-dark-blue; + background-color: $color-light-blue; + color: $color-dark-blue; + left: auto; + right: 10px; + } + + } + +} \ No newline at end of file diff --git a/app/assets/stylesheets/dmptool/blocks/_panel.scss b/app/assets/stylesheets/dmptool/blocks/_panel.scss new file mode 100644 index 0000000000..c3f264c5e9 --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_panel.scss @@ -0,0 +1,11 @@ +div.email-preview-panel { + border: 1px solid $color-grey; +} + +div.email-preview-panel .panel-header { + font-weight: 600; +} + +div.email-preview-panel .panel-body { + background-color: $color-light-grey; +} \ No newline at end of file diff --git a/app/assets/stylesheets/dmptool/blocks/_tables.scss b/app/assets/stylesheets/dmptool/blocks/_tables.scss new file mode 100644 index 0000000000..29c3d74cf0 --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_tables.scss @@ -0,0 +1,25 @@ +/* Override the table heading background color so that it is blue w/white text */ +thead th { + background-color: $color-primary-background; + color: $color-primary-text; + + .fas, .fas:not(.small) { + color: $color-primary-text; + } +} + +/* Makes ever other row within a table have a light-grey background */ +tbody tr:nth-child(even){ + background-color: $color-light-grey; +} + +/* Makes the background of a row light-blue when hovered over */ +.table-hover tbody tr:hover td, +.table-hover tbody tr:hover th { + background-color: $color-light-blue; +} + +/* Makes the magnifying glass icon white */ +#search-addon .fa-search { + color: $color-white; +} diff --git a/app/assets/stylesheets/dmptool/blocks/_tabs.scss b/app/assets/stylesheets/dmptool/blocks/_tabs.scss new file mode 100644 index 0000000000..f9381f0c35 --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_tabs.scss @@ -0,0 +1,45 @@ +.nav-tabs { + background-color: $color-seccondary-background; + border-bottom: 1px solid $color-black; + margin-bottom: -1px; + margin-top: 5px; + + li, li:hover, li:focus, + li.active, li.active:hover, li.active:focus { + border: 1px solid $color-black; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + padding: 3px; + margin-right: 2px; + + a, a:hover, a:focus, a:visited, a:active, a:active:hover { + color: $color-primary-text; + border: none; + margin: 0; + padding: 5px 10px; + } + } + li { + background-color: $color-primary-background; + border-bottom: none; + a { + color: $color-primary-text; + } + } + li.active, li:focus, li:hover, li.active:focus, li.active:hover { + background-color: $color-seccondary-background; + border-bottom: 1px solid $color-white; + a, a:hover, a:focus, a:visited, a:active, a:active:hover { + color: $color-seccondary-text; + cursor: pointer; + } + } +} +div.tab-content { + border: 1px solid $color-black; + padding: 10px 10px 15px 10px; + + .panel { + border: none; + } +} diff --git a/app/assets/stylesheets/dmptool/blocks/_third_party_apps.scss b/app/assets/stylesheets/dmptool/blocks/_third_party_apps.scss new file mode 100644 index 0000000000..5bf3882108 --- /dev/null +++ b/app/assets/stylesheets/dmptool/blocks/_third_party_apps.scss @@ -0,0 +1,4 @@ + +#third-party-apps div { + margin-bottom: 20px; +} \ No newline at end of file diff --git a/app/assets/stylesheets/dmptool/utils/_colours.scss b/app/assets/stylesheets/dmptool/utils/_colours.scss new file mode 100644 index 0000000000..d15a881405 --- /dev/null +++ b/app/assets/stylesheets/dmptool/utils/_colours.scss @@ -0,0 +1,4 @@ +.bg-primary { + background-color: $color-primary-background; + color: color-seccondary-text; +} \ No newline at end of file diff --git a/app/assets/stylesheets/dmptool/utils/_media.scss b/app/assets/stylesheets/dmptool/utils/_media.scss new file mode 100644 index 0000000000..a2a67e0cd0 --- /dev/null +++ b/app/assets/stylesheets/dmptool/utils/_media.scss @@ -0,0 +1,213 @@ +/* MEDIA SPECIFIC CHANGES */ + +/* Tablets and Phones */ +/* ----------------------------------------------------------------------- */ +@media all and (max-width: $hide-org-logo-breakpoint) { + body > .dmptool .org-logo { + display: none; + } +} + +@media all and (max-width: 768px) { + body > .dmptool .welcome > div .welcome-message div > div.get-started { + float: none; + } +} + +/* Phones */ +/* ----------------------------------------------------------------------- */ +@media all and (max-width: $mobile-layout-breakpoint) { + body { + .dmptool { + /* Hide the normal menus and display the mobile ones */ + header > .fixed-menu, header > .branding, header > .app-menu , footer > .footer { + display: none; + } + header > .fixed-menu-mobile, footer > .footer-mobile { + display: block; + } + + .navbar-fixed-top { + .container-fluid { + margin-right: 0; + margin-left: 0; + } + + .navbar-toggle, .navbar-toggle:visited, .navbar-toggle:hover, .navbar-toggle:focus, .navbar-toggle:active { + margin: 2px 5px 0 0; + background-color: $color-primary-background; + + .icon-bar { + background-color: $color-seccondary-background; + } + } + .navbar-toggle:not(.collapsed) .icon-bar:nth-child(2) { + transform: rotate(45deg) translate(5px, 4px); + transition: ease all .2s; + } + .navbar-toggle:not(.collapsed) .icon-bar:nth-child(3) { + opacity: 0; + transition: ease all .2s; + } + .navbar-toggle:not(.collapsed) .icon-bar:nth-child(4) { + transform: rotate(-45deg) translate(4px, -4px); + transition: ease all .2s; + } + + ul[aria-labelledby="language-menu-button"] > li, + ul[aria-labelledby="org-menu-button"] > li, + ul[aria-labelledby="profile-menu-button"] > li, + ul[aria-labelledby="signin-menu-button"] > li { + margin-left: 15px; + position: relative; + + .fa-envelope { + display: none; + } + .fa-envelope ~ a:after { + content: '\f0e0'; + font-family: FontAwesome; + font-size: 1.3rem; + color: $color-seccondary-text; + margin:0px 0px 0px 10px; + text-decoration:none; + } + } + + .org-logo, .org-logo:hover, .org-logo:focus, .org-logo:active, .org-logo:visited { + font-size: 1.6rem; + padding-left: 0; + } + .app-logo { + height: 50px; + min-width: auto; + max-width: auto; + padding-right: 15px; + } + + .learn-menu { + display: block; + + .menu-col { + margin-left: 45px; + + div > div { + margin-left: 15px; + } + } + .menu-col > div { + margin-bottom: 5px; + } + } + + a.dropdown, .dropdown-menu > li > a { + background-color: $color-seccondary-background; + color: $color-seccondary-text; + } + .dropdown-menu > li > a:focus, .dropdown-menu > li > a:hover { + text-decoration: underline; + } + } + + .notifications { + padding-top: 25px; + } + + .notifications { + padding-top: 25px; + } + + /* Homepage */ + .welcome { + background: none; + padding: 0 5px; + + min-height: auto; + + > div { + background: none; + margin-top: 0; + } + + .welcome-message { + color: $color-seccondary-text; + + h1 { + color: $color-seccondary-text; + } + + .get-started { + float: none; + + .btn-inverse { + background-color: $color-primary-background; + color: $color-primary-text; + margin-top: 10px; + } + } + } + } + + /* Create Plan hacks */ + #new_plan { + h2 { + font-size: 1.8rem; + } + /* Force the checkboxes below the inputs */ + .col-md-1 { + max-width: 15%; + } + .col-xs-5, .col-xs-6 { + width: 100%; + + input[type="text"] { + margin-bottom: 5px; + } + } + .col-xs-5 > .create-plan-mock, .create-plan-checkbox { + margin-left: 0; + } + .create-plan-mock { + margin-top: -30px; + } + } + + .communications > div { + margin-top: 5px; + flex: 1 100%; + } + + footer { + width: 100%; + border-radius: 0; + + .footer-mobile { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + } + + ul { + padding-left: 5px; + + li { + list-style: none; + + .fa-twitter-square, .fa-rss-square { + font-size: 2rem; + } + } + } + + img { + margin-top: 10px; + max-width: 100px; + } + p, p > a { + font-size: 1.2rem; + } + } + } + } +} + diff --git a/app/assets/stylesheets/tinymce.css b/app/assets/stylesheets/tinymce.css new file mode 100644 index 0000000000..eb3e43a0ed --- /dev/null +++ b/app/assets/stylesheets/tinymce.css @@ -0,0 +1,5 @@ +.mce-content-body { + a { + color: #000; + } +} diff --git a/app/assets/stylesheets/variables/_colours.scss b/app/assets/stylesheets/variables/_colours.scss index 17cc9034aa..e2e0423f55 100644 --- a/app/assets/stylesheets/variables/_colours.scss +++ b/app/assets/stylesheets/variables/_colours.scss @@ -2,9 +2,11 @@ Colors - to be used as utilities within this file to define text+bg colors */ + $color-black: #000; $color-white: #FFF; $color-red: #b94a48; +$color-green: #4c8d3f; $color-grey: #4F5253; $color-grey-darkest: #222; $color-grey-darker: #333; @@ -14,43 +16,57 @@ $color-blue: #337ab7; $color-blue-alice-darkest: #107896; $color-muted: #CCC; +$color-dark-blue: #0E5682; +$color-light-blue: #D8E3EE; +$color-light-grey: #F0F0F0; +$color-green: #008000; +$color-translucent-blue: rgba(14,86,130,.9); +$color-info-blue: #d9edf7; + +/* Screen dimension breakpoints (based on screen width) */ +$mobile-layout-breakpoint: 766px; +$hide-org-logo-breakpoint: 1024px; +$max-width-breakpoint: 1280px; +$color-alert-error-background: $color-red; +$color-alert-error-text: $color-white; +$color-alert-warning-background: #f2dede; +$color-alert-warning-text: #a94442; +$color-alert-info-background: $color-green; +$color-alert-info-text: $color-white; /* Variables to define look and feel */ // Backgrounds -$color-primary-background: $color-grey; +$color-primary-background: $color-dark-blue; $color-seccondary-background: $color-white; $color-muted-background: $color-muted; -$color-navbar-background: $color-grey-darkest; -$color-navbar-background-active: darken($color-navbar-background, 10%) ; -$color-footer-background: $color-navbar-background; -$color-tooltip-background: $color-primary-background; +$color-navbar-background: $color-white; /*$color-dark-blue;*/ +$color-navbar-background-active: $color-white; +$color-footer-background: $color-white; +$color-tooltip-background: $color-dark-blue; $color-icon-bar-background: $color-white; // Text $color-primary-text: $color-white; -$color-seccondary-text: $color-grey; +$color-seccondary-text: $color-dark-blue; $color-heading-text: $color-black; -$color-navbar-text: $color-grey-light; -$color-navbar-text-hover: $color-white; -$color-navbar-text-disabled: $color-grey-darker; -$color-link-text: $color-blue-alice-darkest; +$color-navbar-text: $color-dark-blue; +$color-navbar-text-hover: $color-dark-blue; +$color-navbar-text-disabled: $color-muted; +$color-link-text: $color-dark-blue; $color-navbar-link-text: lighten($color-navbar-text, 15%); -$color-org-navbar-links: $color-grey-light; -$color-org-navbar-links-hover: $color-grey-darker; -$color-dropdown-inverse-text: $color-grey-lighter; +$color-org-navbar-links: $color-dark-blue; +$color-org-navbar-links-hover: $color-dark-blue; +$color-dropdown-inverse-text: $color-white; $color-dropdown-inverse-text-hover: $color-white; -// $color-footer-links:; -// $color-footer-links-hover:; -// $color-tooltip-text:; $color-text-red: $color-red; $color-text-grey: $color-grey; $color-text-black: $color-black; // Borders colors -$color-border-default: $color-grey; +$color-border-default: $color-black; $color-border-light: #ddd; $color-dropdown-inverse-border: #080808; $color-navbar-border: darken($color-navbar-background, 10%); diff --git a/app/assets/stylesheets/vendor/bootstrap-select/sass/bootstrap-select.scss b/app/assets/stylesheets/vendor/bootstrap-select/sass/bootstrap-select.scss new file mode 100644 index 0000000000..58a251aab6 --- /dev/null +++ b/app/assets/stylesheets/vendor/bootstrap-select/sass/bootstrap-select.scss @@ -0,0 +1,521 @@ +@import "variables"; + +@keyframes bs-notify-fadeOut { + 0% {opacity: 0.9;} + 100% {opacity: 0;} +} + +// Mixins +@mixin cursor-disabled() { + cursor: not-allowed; +} + +@mixin box-sizing($fmt) { + -webkit-box-sizing: $fmt; + -moz-box-sizing: $fmt; + box-sizing: $fmt; +} + +@mixin box-shadow($fmt) { + -webkit-box-shadow: $fmt; + box-shadow: $fmt; +} + +@function fade($color, $amnt) { + @if $amnt > 1 { + $amnt: $amnt / 100; // convert to percentage if int + } + @return rgba($color, $amnt); +} + +// Rules +select.bs-select-hidden, +.bootstrap-select > select.bs-select-hidden, +select.selectpicker { + display: none !important; +} + +.bootstrap-select { + width: 220px \0; /*IE9 and below*/ + vertical-align: middle; + + // The selectpicker button + > .dropdown-toggle { + position: relative; + width: 100%; + // necessary for proper positioning of caret in Bootstrap 4 (pushes caret to the right) + text-align: right; + white-space: nowrap; + // force caret to be vertically centered for Bootstrap 4 multi-line buttons + display: inline-flex; + align-items: center; + justify-content: space-between; + + &:after { + margin-top: -1px; + } + + &.bs-placeholder { + &, + &:hover, + &:focus, + &:active { + color: $input-color-placeholder; + } + + &.btn-primary, + &.btn-secondary, + &.btn-success, + &.btn-danger, + &.btn-info, + &.btn-dark { + &, + &:hover, + &:focus, + &:active { + color: $input-alt-color-placeholder; + } + } + } + } + + > select { + position: absolute !important; + bottom: 0; + left: 50%; + display: block !important; + width: 0.5px !important; + height: 100% !important; + padding: 0 !important; + opacity: 0 !important; + border: none; + z-index: 0 !important; + + &.mobile-device { + top: 0; + left: 0; + display: block !important; + width: 100% !important; + z-index: 2 !important; + } + } + + // Error display + .has-error & .dropdown-toggle, + .error & .dropdown-toggle, + &.is-invalid .dropdown-toggle, + .was-validated & select:invalid + .dropdown-toggle { + border-color: $color-red-error; + } + + &.is-valid .dropdown-toggle, + .was-validated & select:valid + .dropdown-toggle { + border-color: $color-green-success; + } + + &.fit-width { + width: auto !important; + } + + &:not([class*="col-"]):not([class*="form-control"]):not(.input-group-btn) { + width: $width-default; + } + + > select.mobile-device:focus + .dropdown-toggle, + .dropdown-toggle:focus { + outline: thin dotted #333333 !important; + outline: 5px auto -webkit-focus-ring-color !important; + outline-offset: -2px; + } +} + +// The selectpicker components +.bootstrap-select { + &.form-control { + margin-bottom: 0; + padding: 0; + border: none; + height: auto; + + :not(.input-group) > &:not([class*="col-"]) { + width: 100%; + } + + &.input-group-btn { + float: none; + z-index: auto; + } + } + + .form-inline &, + .form-inline &.form-control:not([class*="col-"]) { + width: auto; + } + + &:not(.input-group-btn), + &[class*="col-"] { + float: none; + display: inline-block; + margin-left: 0; + } + + // Forces the pull to the right, if necessary + &, + &[class*="col-"], + .row &[class*="col-"] { + &.dropdown-menu-right { + float: right; + } + } + + .form-inline &, + .form-horizontal &, + .form-group & { + margin-bottom: 0; + } + + .form-group-lg &.form-control, + .form-group-sm &.form-control { + padding: 0; + + .dropdown-toggle { + height: 100%; + font-size: inherit; + line-height: inherit; + border-radius: inherit; + } + } + + &.form-control-sm .dropdown-toggle, + &.form-control-lg .dropdown-toggle { + font-size: inherit; + line-height: inherit; + border-radius: inherit; + } + + &.form-control-sm .dropdown-toggle { + padding: $input-padding-y-sm $input-padding-x-sm; + } + + &.form-control-lg .dropdown-toggle { + padding: $input-padding-y-lg $input-padding-x-lg; + } + + // Set the width of the live search (and any other form control within an inline form) + // see https://github.com/silviomoreto/bootstrap-select/issues/685 + .form-inline & .form-control { + width: 100%; + } + + &.disabled, + > .disabled { + @include cursor-disabled(); + + &:focus { + outline: none !important; + } + } + + &.bs-container { + position: absolute; + top: 0; + left: 0; + height: 0 !important; + padding: 0 !important; + + .dropdown-menu { + z-index: $zindex-select-dropdown; + } + } + + // The selectpicker button + .dropdown-toggle { + .filter-option { + position: static; + top: 0; + left: 0; + float: left; + height: 100%; + width: 100%; + text-align: left; + overflow: hidden; + flex: 0 1 auto; // for IE10 + + @at-root .bs3#{&} { + padding-right: inherit; + } + + @at-root .input-group .bs3-has-addon#{&} { + position: absolute; + padding-top: inherit; + padding-bottom: inherit; + padding-left: inherit; + float: none; + + .filter-option-inner { + padding-right: inherit; + } + } + } + + .filter-option-inner-inner { + overflow: hidden; + } + + // used to expand the height of the button when inside an input group + .filter-expand { + width: 0 !important; + float: left; + opacity: 0 !important; + overflow: hidden; + } + + .caret { + position: absolute; + top: 50%; + right: 12px; + margin-top: -2px; + vertical-align: middle; + } + } + + .input-group &.form-control .dropdown-toggle { + border-radius: inherit; + } + + &[class*="col-"] .dropdown-toggle { + width: 100%; + } + + // The selectpicker dropdown + .dropdown-menu { + min-width: 100%; + @include box-sizing(border-box); + + > .inner:focus { + outline: none !important; + } + + &.inner { + position: static; + float: none; + border: 0; + padding: 0; + margin: 0; + border-radius: 0; + box-shadow: none; + } + + li { + position: relative; + + &.active small { + color: $input-alt-color-placeholder !important; + } + + &.disabled a { + @include cursor-disabled(); + } + + a { + cursor: pointer; + user-select: none; + + &.opt { + position: relative; + padding-left: 2.25em; + } + + span.check-mark { + display: none; + } + + span.text { + display: inline-block; + } + } + + small { + padding-left: 0.5em; + } + } + + .notify { + position: absolute; + bottom: 5px; + width: 96%; + margin: 0 2%; + min-height: 26px; + padding: 3px 5px; + background: rgb(245, 245, 245); + border: 1px solid rgb(227, 227, 227); + @include box-shadow(inset 0 1px 1px fade(rgb(0, 0, 0), 5)); + pointer-events: none; + opacity: 0.9; + @include box-sizing(border-box); + + &.fadeOut { + animation: 300ms linear 750ms forwards bs-notify-fadeOut; + } + } + } + + .no-results { + padding: 3px; + background: #f5f5f5; + margin: 0 5px; + white-space: nowrap; + } + + &.fit-width .dropdown-toggle { + .filter-option { + position: static; + display: inline; + padding: 0; + } + + .filter-option-inner, + .filter-option-inner-inner { + display: inline; + } + + .bs-caret:before { + content: '\00a0'; + } + + .caret { + position: static; + top: auto; + margin-top: -1px; + } + } + + &.show-tick .dropdown-menu { + .selected span.check-mark { + position: absolute; + display: inline-block; + right: 15px; + top: 5px; + } + + li a span.text { + margin-right: 34px; + } + } + + // default check mark for use without an icon font + .bs-ok-default:after { + content: ''; + display: block; + width: 0.5em; + height: 1em; + border-style: solid; + border-width: 0 0.26em 0.26em 0; + transform-style: preserve-3d; + transform: rotate(45deg); + } +} + +.bootstrap-select.show-menu-arrow { + &.open > .dropdown-toggle, + &.show > .dropdown-toggle { + z-index: ($zindex-select-dropdown + 1); + } + + .dropdown-toggle .filter-option { + &:before { + content: ''; + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-bottom: 7px solid $color-grey-arrow; + position: absolute; + bottom: -4px; + left: 9px; + display: none; + } + + &:after { + content: ''; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid white; + position: absolute; + bottom: -4px; + left: 10px; + display: none; + } + } + + &.dropup .dropdown-toggle .filter-option { + &:before { + bottom: auto; + top: -4px; + border-top: 7px solid $color-grey-arrow; + border-bottom: 0; + } + + &:after { + bottom: auto; + top: -4px; + border-top: 6px solid white; + border-bottom: 0; + } + } + + &.pull-right .dropdown-toggle .filter-option { + &:before { + right: 12px; + left: auto; + } + + &:after { + right: 13px; + left: auto; + } + } + + &.open > .dropdown-toggle .filter-option, + &.show > .dropdown-toggle .filter-option { + &:before, + &:after { + display: block; + } + } +} + +.bs-searchbox, +.bs-actionsbox, +.bs-donebutton { + padding: 4px 8px; +} + +.bs-actionsbox { + width: 100%; + @include box-sizing(border-box); + + & .btn-group button { + width: 50%; + } +} + +.bs-donebutton { + float: left; + width: 100%; + @include box-sizing(border-box); + + & .btn-group button { + width: 100%; + } +} + +.bs-searchbox { + & + .bs-actionsbox { + padding: 0 8px 4px; + } + + & .form-control { + margin-bottom: 0; + width: 100%; + float: none; + } +} diff --git a/app/assets/stylesheets/vendor/bootstrap-select/sass/variables.scss b/app/assets/stylesheets/vendor/bootstrap-select/sass/variables.scss new file mode 100644 index 0000000000..7729e5846b --- /dev/null +++ b/app/assets/stylesheets/vendor/bootstrap-select/sass/variables.scss @@ -0,0 +1,17 @@ +$color-red-error: rgb(185, 74, 72) !default; +$color-green-success: #28a745; +$color-grey-arrow: rgba(204, 204, 204, 0.2) !default; + +$width-default: 220px !default; // 3 960px-grid columns + +$zindex-select-dropdown: 1060 !default; // must be higher than a modal background (1050) + +//** Placeholder text color +$input-color-placeholder: #999 !default; +$input-alt-color-placeholder: rgba(255, 255, 255, 0.5) !default; + +$input-padding-y-sm: .25rem !default; +$input-padding-x-sm: .5rem !default; + +$input-padding-y-lg: 0.5rem !default; +$input-padding-x-lg: 1rem !default; \ No newline at end of file diff --git a/app/controllers/answers_controller.rb b/app/controllers/answers_controller.rb index 48980290bf..c08663bc64 100644 --- a/app/controllers/answers_controller.rb +++ b/app/controllers/answers_controller.rb @@ -65,6 +65,7 @@ def create_or_update ) @answer.save! end + # TODO: this is madness. Why not just do a find_or_initialize_by above? rescue ActiveRecord::RecordNotFound @answer = Answer.new(args.merge(user_id: current_user.id)) @answer.lock_version = 1 diff --git a/app/controllers/api/v0/plans_controller.rb b/app/controllers/api/v0/plans_controller.rb index 5e30069bda..a431e4794f 100644 --- a/app/controllers/api/v0/plans_controller.rb +++ b/app/controllers/api/v0/plans_controller.rb @@ -106,7 +106,7 @@ def index private def extract_param_list(params, attribute) - list = params.fetch(attribute + "[]", []) + list = params.fetch("#{attribute}[]", []) val = params.fetch(attribute, []) list << val if val.present? list diff --git a/app/controllers/api/v1/plans_controller.rb b/app/controllers/api/v1/plans_controller.rb index 649f987d97..9f60b7809d 100644 --- a/app/controllers/api/v1/plans_controller.rb +++ b/app/controllers/api/v1/plans_controller.rb @@ -12,7 +12,6 @@ class PlansController < BaseApiController def show plans = Api::V1::PlansPolicy::Scope.new(client, Plan).resolve .where(id: params[:id]).limit(1) - if plans.present? && plans.any? @items = paginate_response(results: plans) render "/api/v1/plans/index", status: :ok @@ -33,6 +32,7 @@ def create # Convert the JSON into a Plan and it's associations plan = Api::V1::Deserialization::Plan.deserialize(json: dmp) + if plan.present? save_err = _("Unable to create your DMP") exists_err = _("Plan already exists. Send an update instead.") @@ -146,6 +146,9 @@ def invite_contributor(contributor:) surname: surname, org: contributor.org }, client) + user = User.create({ email: contributor.email, firstname: firstname, + surname: surname, org: contributor.org, + password: SecureRandom.uuid }) contributor.identifiers.each do |id| user.identifiers << Identifier.new( identifier_scheme: id.identifier_scheme, value: id.value diff --git a/app/controllers/api/v2/base_api_controller.rb b/app/controllers/api/v2/base_api_controller.rb new file mode 100644 index 0000000000..c811410cff --- /dev/null +++ b/app/controllers/api/v2/base_api_controller.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +module Api + + module V2 + + class BaseApiController < ApplicationController + + # We use the Doorkeeper gem to provide OAuth2 provider functionality for this application. An + # ApiClient is able to access this API via: + # - :client_credentials - which allows them to access publicly accessible data + # - :authorization_code - to gain authorization from a User to access their data + # + # See the API wiki for full details: https://github.com/CDLUC3/dmptool/wiki/api-documentation + include ::Doorkeeper::Helpers::Controller + + respond_to :json + + # Skipping the standard Rails authenticity tokens passed in UI + skip_before_action :verify_authenticity_token + + # Parse the Doorkeeper token to get the APIClient and User + before_action :authorize_request, except: %i[heartbeat] + before_action :parse_doorkeeper_token + + # Prep default instance variables for views + before_action :base_response_content + before_action :pagination_params, except: %i[heartbeat] + + # Parse the incoming JSON + before_action :parse_request, only: %i[create update] + + # Record the API access + after_action :log_access, except: %i[heartbeat] + + attr_reader :client, :resource_owner + + # GET /api/v2/heartbeat + # --------------------- + # Used as a status check for external systems to determine if we are online (does not require auth) + def heartbeat + render "/api/v2/heartbeat", status: :ok + end + + protected + + # Generic handler for sending an error back to the caller + def render_error(errors:, status: :bad_request) + @payload = { errors: [errors] } + render "/api/v2/error", status: status + end + + # Paginate the response + def paginate_response(results:) + results = Kaminari.paginate_array(results) if results.is_a?(Array) + results = results.page(@page).per(@per_page) + @total_items = results.total_count + results + end + + private + + attr_accessor :json + + # ============= + # = Callbacks = + # ============= + + # Only requests with a valid Doorkeeper token are acceptable + def authorize_request + return true if doorkeeper_token.present? + + render_error(errors: "token is invalid, expired or has been revoked", status: :unauthorized) + end + + # Extract the ApiClient (aka Application), User (aka Resource Owner) and Scopes from Doorkeeper AccessToken + def parse_doorkeeper_token + return nil unless doorkeeper_token + + @client = ApiClient.find_by(id: doorkeeper_token.application_id) + + @resource_owner = User.includes(:plans, :access_grants) + .find_by(id: doorkeeper_token.resource_owner_id) + + @scopes = doorkeeper_token.scopes + end + + # Set the generic application and caller variables used in all responses + def base_response_content + @application = ApplicationService.application_name + @caller = request.remote_ip unless @client.present? + @caller = @client.is_a?(User) ? @client.name(false) : @client.name if @client.present? + end + + # Retrieve the requested pagination params or use defaults + # only allow 100 per page as the max + def pagination_params + @page = params.fetch("page", 1).to_i + @per_page = params.fetch("per_page", 20).to_i + @per_page = 100 if @per_page > 100 + end + + # Parse the body of the incoming request + def parse_request + return false unless request.present? && request.body.present? + + body = request.body.read + @json = JSON.parse(body).with_indifferent_access + rescue JSON::ParserError => e + Rails.logger.error "API V2 - JSON Parser: #{e.message}" + Rails.logger.error request.body + render_error(errors: _("Invalid JSON format"), status: :bad_request) + false + end + + # Record the activity + def log_activity(subject:, change_type:) + return false unless @client.present? && subject.present? && change_type.present? && + @client.is_a?(ApiClient) && + ApiLog.change_types.keys.include?(change_type.to_s) + + case change_type.to_sym + when :added + activity = "Created a new #{subject.class.name}:
%s" + when :removed + activity = "Deleted a #{subject.class.name}:
%s" + else + activity = "Modified a #{subject.class.name}:
%s" + end + + activity = activity % { + subject: subject.inspect, + changes: subject.changed? ? subject.previous_changes&.inspect : subject.changes&.inspect + } + + ApiLog.create(api_client_id: @client.id, logable: subject, change_type: change_type, + activity: activity) + end + + # Record the timestamp + def log_access + return false unless @client.present? + + @client.update(last_access: Time.now) if @client.is_a?(ApiClient) + @client.update(last_api_access: Time.now) if @client.is_a?(User) + true + end + + # ========================= + # PERMIITTED PARAMS HEPERS + # ========================= + + def plan_permitted_params + %i[created title description language ethical_issues_exist + ethical_issues_description ethical_issues_report] + + [dmp_ids: identifier_permitted_params, + contact: contributor_permitted_params, + contributors: contributor_permitted_params, + costs: cost_permitted_params, + project: project_permitted_params, + datasets: dataset_permitted_params] + end + + def identifier_permitted_params + %i[type identifier] + end + + def contributor_permitted_params + %i[firstname surname mbox role] + + [affiliations: affiliation_permitted_params, + contributor_ids: identifier_permitted_params] + end + + def affiliation_permitted_params + %i[name abbreviation] + + [affiliation_ids: identifier_permitted_params] + end + + def cost_permitted_params + %i[title description value currency_code] + end + + def project_permitted_params + %i[title description start_on end_on] + + [funding: funding_permitted_params] + end + + def funding_permitted_params + %i[name funding_status] + + [funder_ids: identifier_permitted_params, + grant_ids: identifier_permitted_params] + end + + def dataset_permitted_params + %i[title description type issued language personal_data sensitive_data + keywords data_quality_assurance preservation_statement] + + [dataset_ids: identifier_permitted_params, + metadata: metadatum_permitted_params, + security_and_privacy_statements: security_and_privacy_statement_permitted_params, + technical_resources: technical_resource_permitted_params, + distributions: distribution_permitted_params] + end + + def metadatum_permitted_params + %i[description language] + [identifier: identifier_permitted_params] + end + + def security_and_privacy_statement_permitted_params + %i[title description] + end + + def technical_resource_permitted_params + %i[description] + [identifier: identifier_permitted_params] + end + + def distribution_permitted_params + %i[title description format byte_size access_url download_url + data_access available_until] + + [licenses: license_permitted_params, host: host_permitted_params] + end + + def license_permitted_params + %i[license_ref start_date] + end + + def host_permitted_params + %i[title description supports_versioning backup_type backup_frequency + storage_type availability geo_location certified_with pid_system] + + [host_ids: identifier_permitted_params] + end + + end + + end + +end diff --git a/app/controllers/api/v2/datasets_controller.rb b/app/controllers/api/v2/datasets_controller.rb new file mode 100644 index 0000000000..1cb1fce614 --- /dev/null +++ b/app/controllers/api/v2/datasets_controller.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Api + + module V2 + + class DatasetsController < BaseApiController + + respond_to :json + + # If the Resource Owner (aka User) is in the Doorkeeper AccessToken then it is an authorization_code + # token and we need to ensure that the ApiClient is authorized for the relevant Scope + before_action -> { doorkeeper_authorize!(:create_dmps) if @resource_owner.present? }, only: %i[create update] + before_action :fetch_plan + + + # POST /api/v2/plans/[:id]/datasets + # ------------------ + def create + datasets = @json.with_indifferent_access.fetch(:items, []).first.fetch(:dmp, {}).fetch(:dataset, []) + + # Do a pass through the raw JSON and check to make sure all required fields + # were present. If not, return the specific errors + errs = [] + datasets.each do |dataset_json| + errs += Api::V2::JsonValidationService.dataset_validation_errors(json: dataset_json) + end + render_error(errors: errs.flatten.uniq, status: :bad_request) and return if errs.flatten.any? + + # Convert the JSON into a Plan and it's associations + unless errs.any? + ResearchOutput.transaction do + datasets.each do |dataset_json| + object = Api::V2::Deserialization::Dataset.deserialize(plan: @plan, json: dataset_json) + # This is a create endpoint so only allow inserts! + next unless object.new_record? + + errs << object.errors.full_messages unless object.valid? + object.plan = @plan if object.respond_to?(:plan_id) && object.plan_id.nil? + object.save if object.valid? + + # Record this API activity + log_activity(subject: object, change_type: :added) + end + end + + # If we cannot save for some reason then return an error + if errs.empty? + @items = [@plan.reload] + render "/api/v2/plans/index", status: :created + else + # rubocop:disable Layout/LineLength + errs += _("Unable to add the datasets to this DMP! %{specific_errors}") + render_error(errors: errs.flatten.uniq, status: :bad_request) + end + end + end + + # PUT /api/v2/plans/[:plan_id]/datasets/[:id] + # ------------------ + def update + render_error(errors: "This API functionality has not yet been implemented.", status: :server_error) and return + end + + private + + def fetch_plan + @plan = Api::V2::PlansPolicy::Scope.new(@client, @resource_owner, nil).resolve + .select { |plan| plan.id = params[:id] }.first + return true if @plan.present? + + render_error(errors: _("Plan not found"), status: :not_found) + end + + end + + end + +end diff --git a/app/controllers/api/v2/plans_controller.rb b/app/controllers/api/v2/plans_controller.rb new file mode 100644 index 0000000000..d242788ee1 --- /dev/null +++ b/app/controllers/api/v2/plans_controller.rb @@ -0,0 +1,247 @@ +# frozen_string_literal: true + +module Api + + module V2 + + class PlansController < BaseApiController + + include ::ConditionsHelper + + respond_to :json, :pdf + + # If the Resource Owner (aka User) is in the Doorkeeper AccessToken then it is an authorization_code + # token and we need to ensure that the ApiClient is authorized for the relevant Scope + before_action -> { doorkeeper_authorize!(:public, :read_dmps) }, only: %i[index] + before_action -> { doorkeeper_authorize!(:read_dmps) }, only: %i[show] + before_action -> { doorkeeper_authorize!(:create_dmps) }, only: %i[create] + before_action -> { doorkeeper_authorize!(:edit_dmps) }, only: %i[update] + + # GET /api/v2/plans + # ----------------- + def index + # Scope here is not the Doorkeeper scope, its just to refine the results + @scope = "mine" + @scope = params[:scope].to_s.downcase if %w[mine public both].include?(params[:scope].to_s.downcase) + + # See the Policy for details on what Plans are returned to the Caller based on the AccessToken + plans = Api::V2::PlansPolicy::Scope.new(@client, @resource_owner, @scope).resolve + + if plans.present? && plans.any? + plans = plans.sort { |a, b| b.updated_at <=> a.updated_at } + @items = paginate_response(results: plans) + @minimal = true + render "api/v2/plans/index", status: :ok + else + render_error(errors: _("No Plans found"), status: :not_found) + end + end + + # GET /api/v2/plans/:id + # --------------------- + def show + # See the Policy for details on what Plans are returned to the Caller based on the AccessToken + @plan = Api::V2::PlansPolicy::Scope.new(@client, @resource_owner, "both").resolve + .select { |plan| plan.id.to_s == params[:id] }.first + + if @plan.present? + respond_to do |format| + format.pdf do + prep_for_pdf + + render pdf: @file_name, + margin: @formatting[:margin], + footer: { + center: _("Created using %{application_name}. Last modified %{date}") % { + application_name: ApplicationService.application_name, + date: l(@plan.updated_at.to_date, format: :readable) + }, + font_size: 8, + spacing: (Integer(@formatting[:margin][:bottom]) / 2) - 4, + right: "[page] of [topage]", + encoding: "utf8" + } + end + + format.json do + @items = paginate_response(results: [@plan]) + render "/api/v2/plans/index", status: :ok + end + end + else + render_error(errors: _("Plan not found"), status: :not_found) + end + end + + # POST /api/v2/plans + # ------------------ + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def create + dmp = @json.with_indifferent_access.fetch(:dmp, {}) + + # Do a pass through the raw JSON and check to make sure all required fields + # were present. If not, return the specific errors + errs = Api::V2::JsonValidationService.validation_errors(json: dmp) + render_error(errors: errs, status: :bad_request) and return if errs.any? + + # Convert the JSON into a Plan and it's associations + plan = Api::V2::Deserialization::Plan.deserialize(json: dmp) + + if plan.present? + save_err = _("Unable to create your DMP") + exists_err = _("Plan already exists. Send an update instead.") + no_org_err = _("Could not determine ownership of the DMP. Please add an + :affiliation to the :contact") + + # Skip if this is an existing DMP + render_error(errors: exists_err, status: :bad_request) and return unless plan.new_record? + + # Try to find the owner based on the :contact + owner = determine_owner(plan: plan, json: dmp.fetch(:contact, {})) + + # Try to determine the Plan's org + plan.org = owner.present? ? owner.org : client.user&.org + render_error(errors: no_org_err, status: :bad_request) and return unless plan.org.present? + + # Validate the plan and it's associations and return errors with context + # e.g. 'Contact affiliation name can't be blank' instead of 'name can't be blank' + errs = Api::V2::ContextualErrorService.contextualize_errors(plan: plan) + # The resulting plan (our its associations were invalid) + render_error(errors: errs, status: :bad_request) and return if errs.any? + + # If we cannot save for some reason then return an error + plan = Api::V2::PersistenceService.safe_save(plan: plan) + # rubocop:disable Layout/LineLength + render_error(errors: save_err, status: :internal_server_error) and return if plan.new_record? + # rubocop:enable Layout/LineLength + + # If the plan was generated by an ApiClient then add a subscription for them + dmp_id_to_subscription(plan: plan, id_json: dmp[:dmp_id]) if client.is_a?(ApiClient) + + # User the Owner if one was found otherwise invite the :contact + owner = notify_owner(client: client, owner: owner, plan: plan) + plan.add_user!(owner.id, :creator) + + # Record this API activity + log_activity(subject: plan, change_type: :added) + + # Kaminari Pagination requires an ActiveRecord result set :/ + @items = paginate_response(results: Plan.where(id: plan.id)) + render "/api/v2/plans/index", status: :created + else + render_error(errors: _("Invalid JSON format!"), status: :bad_request) + end + rescue JSON::ParserError + render_error(errors: _("Invalid JSON"), status: :bad_request) + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + private + + def dmp_params + params.require(:dmp).permit(plan_permitted_params).to_h + end + + def plan_exists?(json:) + return false unless json.present? && + json[:dmp_id].present? && + json[:dmp_id][:identifier].present? + + scheme = IdentifierScheme.by_name(json[:dmp_id][:type]).first + Identifier.where(value: json[:dmp_id][:identifier], identifier_scheme: scheme).any? + end + + # Get the Plan's owner + def determine_owner(plan:, json:) + return nil unless plan.present? && json.is_a?(Hash) && json[:mbox].present? + + user = User.find_by(email: json[:mbox]) + return user if user.present? + + id_json = json.fetch(:contact_id, {}) + orcid = id_json[:identifier] if id_json[:type]&.downcase == "orcid" + identifier = Identifier.by_scheme_name("orcid", "User").where(value: orcid) if orcid.present? + return identifier.identifiable if identifier.present? + + names = json[:name]&.split || [""] + firstname = names.length > 1 ? names.first : nil + surname = names.length > 1 ? names.last : names.first + + org = Api::V2::Deserialization::Org.deserialize(json: json[:affiliation]) + + user = User.new(firstname: firstname, surname: surname, email: json[:mbox], org: org) + return user unless orcid.present? + + scheme = IdentifierScheme.find_by(name: "orcid") + user.identifiers << Identifier.new(identifier_scheme: scheme, value: orcid) + user + end + + # Send the owner an email to let them know about the new Plan + def notify_owner(client:, owner:, plan:) + if owner.new_record? + # This essentially drops the initializer User (aka owner) and creates a new one via + # the Devise invitation + User.invite!({ email: owner.email, + firstname: owner.firstname, + surname: owner.surname, + invitation_plan_id: plan.id, + org: owner.org }, client) + else + UserMailer.new_plan_via_api( + recipient: owner, plan: plan, api_client: client + ).deliver_now + owner + end + end + + # Convert the dmp_id into an identifier for the ApiClient if applicable + def dmp_id_to_subscription(plan:, id_json:) + return nil unless id_json.is_a?(Hash) && id_json[:type] == "other" && @client.is_a?(ApiClient) + + val = id_json[:identifier] if id_json[:identifier].start_with?(@client.callback_uri || "") + val = "#{@client.callback_uri}#{id_json[:identifier]}" unless val.present? + + subscription = Subscription.find_or_initialize_by( + plan: plan, + subscriber: @client, + callback_uri: val + ) + subscription.updates = true + subscription.deletions = true + subscription.save + end + + def prep_for_pdf + return false unless @plan.present? + + # We need to eager loadd the plan to make this more efficient + @plan = Plan.includes(:org, :research_outputs, roles: [:user], + contributors: [:org, identifiers: [:identifier_scheme]], + identifiers: [:identifier_scheme]) + .find_by(id: @plan.id) + + # Include everything by default + @show_coversheet = true + @show_sections_questions = true + @show_unanswered = true + @show_custom_sections = true + @show_research_outputs = @plan.research_outputs.any? + @public_plan = @plan.publicly_visible? + @formatting = + + @hash = @plan.as_pdf(nil, @show_coversheet) + @formatting = @plan.settings(:export).formatting || @plan.template.settings(:export).formatting + @selected_phase = @plan.phases.order("phases.updated_at DESC").first + + # limit the filename length to 100 chars. Windows systems have a MAX_PATH allowance + # of 255 characters, so this should provide enough of the title to allow the user + # to understand which DMP it is and still allow for the file to be saved to a deeply + # nested directory + @file_name = Zaru.sanitize!(@plan.title).strip.gsub(/\s+/, "_")[0, 100] + end + end + + end + +end diff --git a/app/controllers/api/v2/related_identifiers_controller.rb b/app/controllers/api/v2/related_identifiers_controller.rb new file mode 100644 index 0000000000..bdfa4550cb --- /dev/null +++ b/app/controllers/api/v2/related_identifiers_controller.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Api + + module V2 + + class RelatedIdentifiersController < BaseApiController + + respond_to :json + + # Ensure that the Client is able to perform the necessary operation + before_action -> { doorkeeper_authorize!(:edit_dmps) }, only: %i[create] + + # POST /api/v2/related_identifiers + # ------------------ + def create + json = @json.with_indifferent_access.fetch(:dmp, {}) + render_error(errors: _("Invalid JSON!"), status: :bad_request) and return unless json.present? + + plan = Api::V2::DeserializationService.plan_from_dmp_id(dmp_id: json[:dmp_id]) + render_error(errors: _("Plan not found"), status: :not_found) and return unless plan.present? + + plan = Api::V2::PlansPolicy::Scope.new(@client, @resource_owner, nil).resolve + .select { |p| p.id = plan.id }.first + render_error(errors: _("Plan not found"), status: :not_found) and return unless plan.present? + + related_identifiers = json.fetch(:dmproadmap_related_identifiers, []) + + errs = Api::V2::JsonValidationService.related_identifiers_errors( + json: related_identifiers + ) + + if errs.empty? + RelatedIdentifier.transaction do + related_identifiers.each do |related_identifier| + id = Api::V2::Deserialization::RelatedIdentifier.deserialize( + plan: plan, json: related_identifier + ) + errs += id.errors.full_messages unless id.valid? + next unless id.valid? && id.new_record? + + # TODO: Remove this once RSpace has updated their call to us + id.relation_type = 'documents' + + id.save + # Record this API activity + log_activity(subject: id, change_type: :added) + end + end + end + + if errs.flatten.any? + render_error(errors: errs.flatten.uniq, status: :bad_request) + else + @items = paginate_response(results: [plan.reload]) + render "/api/v2/plans/index", status: :created + end + + rescue StandardError => e + Rails.logger.error "API::V2::RelatedIdentifierController - create - #{e.message}" + Rails.logger.error e.backtrace + render_error(errors: "Unable to process the request at this time", status: 500) + end + + end + + end + +end diff --git a/app/controllers/api/v2/templates_controller.rb b/app/controllers/api/v2/templates_controller.rb new file mode 100644 index 0000000000..c8efd362a6 --- /dev/null +++ b/app/controllers/api/v2/templates_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + + module V2 + + class TemplatesController < BaseApiController + + before_action -> { doorkeeper_authorize!(:public) }, only: %i[index] + + # GET /api/v2/templates + # --------------------- + def index + templates = Api::V2::TemplatesPolicy::Scope.new(@client).resolve + + templates = templates.sort { |a, b| a.title <=> b.title } + @items = paginate_response(results: templates) + render "/api/v2/templates/index", status: :ok + end + + end + + end + +end diff --git a/app/controllers/api_clients_controller.rb b/app/controllers/api_clients_controller.rb new file mode 100644 index 0000000000..311c5406c4 --- /dev/null +++ b/app/controllers/api_clients_controller.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class ApiClientsController < ApplicationController + + respond_to :html + + # POST /api_clients + def create + attrs = api_client_params + # If this is a regular user signing up, just use their email as the api_client.name + attrs[:name] = attrs[:contact_email] unless attrs[:name].present? + @api_client = ApiClient.new(attrs) + + # Allow all available scopes by default + attrs[:scopes] = @api_client.available_scopes + @api_client.org = current_user.org if current_user.org.present? + + authorize(@api_client) + if @api_client.save + UserMailer.new_api_client(@api_client).deliver_now + @msg = "API Registration complete. Use your new client_id and client_secret to access the API." + else + @msg = "Unable to register for the API - #{@api_client.errors.full_messages.join(', ')}" + end + render "devise/registrations/api_client_save" + end + + # PATCH/PUT /api_clients/:id + def update + @api_client = ApiClient.find(params[:id]) + authorize(@api_client) + + attrs = api_client_params + attrs[:scopes] = @api_client.available_scopes unless @api_client.scopes.present? + + if @api_client.update(attrs) + @msg = "API Registration updated" + else + @msg = "Unable to update the API registration - #{@api_client.errors.full_messages.join(', ')}" + end + render "devise/registrations/api_client_save" + end + + # GET /api_clients/:id/refresh_credentials/ + def refresh_credentials + @api_client = ApiClient.find(params[:id]) + return unless @api_client.present? + + authorize(@api_client) + original = @api_client.client_secret + @api_client.renew_secret + @api_client.save + @success = original != @api_client.client_secret + render "devise/registrations/api_client_refresh_credentials" + end + + private + + def api_client_params + params.require(:api_client).permit(:name, :description, :homepage, :logo, :remove_logo, + :contact_name, :contact_email, + :client_id, :client_secret, + :user_id, :org_id, :redirect_uri, :callback_uri) + end + +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 53dad81fb4..3ebda5114f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -17,14 +17,15 @@ class ApplicationController < ActionController::Base include Pundit helper_method GlobalHelpers.instance_methods + # Reroute errors to the root_path or plans_path (if user is signed in) with an + # appropriate message rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized - # When we are in production reroute Record Not Found errors to the branded 404 page rescue_from ActiveRecord::RecordNotFound, with: :render_not_found - rescue_from StandardError, with: :handle_server_error + rescue_from ActionController::InvalidAuthenticityToken, with: :ignore_error - rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized + # rescue_from StandardError, with: :handle_server_error private @@ -67,10 +68,31 @@ def store_location def after_sign_in_path_for(_resource) referer_path = URI(request.referer).path unless request.referer.nil? + # --------------------------------------------------------- + # Start DMPTool Customization + # Added get_started_path` to if statement below and check for oauth-referer in the session + # if its present then this was an OAuth sign in to authorize an ApiClient so continue on + # with the OAuth workflow + # --------------------------------------------------------- if from_external_domain? || referer_path.eql?(new_user_session_path) || referer_path.eql?(new_user_registration_path) || + referer_path.eql?(get_started_path) || referer_path.nil? + # End DMPTool Customization + # --------------------------------------------------------- + oauth_path = session["oauth-referer"] + session.delete("oauth-referer") if oauth_path.present? + + oauth_path.present? ? oauth_path : root_path + # --------------------------------------------------------- + # Start DMPTool Customization + # Catch user's coming in from the Org branded sign in /create page + # --------------------------------------------------------- + elsif referer_path =~ %r{#{shibboleth_ds_path}/[0-9]+} root_path + # --------------------------------------------------------- + # End DMPTool Customization + # --------------------------------------------------------- else request.referer end @@ -78,10 +100,28 @@ def after_sign_in_path_for(_resource) def after_sign_up_path_for(_resource) referer_path = URI(request.referer).path unless request.referer.nil? + # --------------------------------------------------------- + # Start DMPTool Customization + # Added `new_user_registration_path` to if statement below + # --------------------------------------------------------- if from_external_domain? || referer_path.eql?(new_user_session_path) || + referer_path.eql?(new_user_registration_path) || referer_path.nil? + + # End DMPTool Customization + # --------------------------------------------------------- + root_path + + # --------------------------------------------------------- + # Start DMPTool Customization + # Catch user's coming in from the Org branded sign in /create page + # --------------------------------------------------------- + elsif referer_path =~ %r{#{shibboleth_ds_path}/[0-9]+} root_path + # --------------------------------------------------------- + # End DMPTool Customization + # --------------------------------------------------------- else request.referer end @@ -135,6 +175,7 @@ def obj_name_for_display(obj) Org: _("organisation"), Perm: _("permission"), Pref: _("preferences"), + ResearchOutput: _("research output"), User: obj == current_user ? _("profile") : _("user"), QuestionOption: _("question option") } @@ -185,14 +226,35 @@ def render_not_found(exception) render_respond_to_format_with_error_message(msg, root_url, 404, exception) end + # Logs the error but then just redirects the user to the root path + def ignore_error(exception) + Rails.logger.error exception.message + Rails.logger.error exception&.backtrace + redirect_to root_path + end + def handle_server_error(exception) - msg = exception.message.to_s if exception.present? - render_respond_to_format_with_error_message(msg, root_url, 500, exception) + # We don't care about general Pundit errors! + unless exception.is_a?(Pundit::NotAuthorizedError) || Rails.env.development? + # DMPTool customization to notify admin of 500 level error + message = "#{ApplicationService.application_name} - #{exception.message}" + message += "
----------------------------------------

" + message += "Referrer: #{request&.referer}" + message += "
----------------------------------------

" + message += "Params: #{params.inspect}" + message += "
----------------------------------------

" + message += "Backtrace:" + message += exception&.backtrace&.to_s if exception.present? && + exception.respond_to?(:backtrace) + UserMailer.notify_administrators(message).deliver_now + end + + end def render_respond_to_format_with_error_message(msg, url_or_path, http_status, exception) Rails.logger.error msg - Rails.logger.error exception&.backtrace if exception.present? + Rails.logger.error exception&.backtrace if exception.present? && exception.respond_to?(:backtrace) respond_to do |format| # Redirect use to the path and display the error message diff --git a/app/controllers/concerns/org_selectable.rb b/app/controllers/concerns/org_selectable.rb index 5aac93cc48..041cad9947 100644 --- a/app/controllers/concerns/org_selectable.rb +++ b/app/controllers/concerns/org_selectable.rb @@ -114,14 +114,16 @@ def create_org(org:, params_in:) return org unless org.present? && org.new_record? # Save the Org before attaching identifiers - org.save - identifiers_from_params(params_in: params_in).each do |identifier| - next unless identifier.value.present? - - identifier.identifiable = org - identifier.save + if org.save + identifiers_from_params(params_in: params_in).each do |identifier| + next unless identifier.value.present? + + identifier.identifiable = org + identifier.save + end + org.reload end - org.reload + org end def prep_org_partial diff --git a/app/controllers/concerns/paginable.rb b/app/controllers/concerns/paginable.rb index aea376ed12..7b77a25028 100644 --- a/app/controllers/concerns/paginable.rb +++ b/app/controllers/concerns/paginable.rb @@ -8,7 +8,7 @@ module Paginable ## # Regex to validate sort_field param is safe - SORT_COLUMN_FORMAT = /[\w_]+\.[\w_]/.freeze + SORT_COLUMN_FORMAT = /[\w_]+\.[\w_]+$/.freeze PAGINATION_QUERY_PARAMS = %i[page sort_field sort_direction search controller action].freeze @@ -128,7 +128,8 @@ def paginable? # rubocop:disable Metrics/AbcSize def refine_query(scope) @args = @args.with_indifferent_access - scope = scope.search(@args[:search]) if @args[:search].present? + scope = scope.search(@args[:search]).distinct if @args[:search].present? + # Can raise NoMethodError if the scope does not define a search method if @args[:sort_field].present? frmt = @args[:sort_field][SORT_COLUMN_FORMAT] @@ -142,13 +143,14 @@ def refine_query(scope) parts = @args[:sort_field].partition(".") table_part = parts.first column_part = parts.last + if scope_table == table_part.singularize order_field = ActiveRecord::Base.sanitize_sql(column_part) scope = scope.order(order_field.to_sym => sort_direction.to_s) else order_field = ActiveRecord::Base.sanitize_sql(@args[:sort_field]) scope = scope.includes(table_part.singularize.to_sym) - .order(order_field + " " + sort_direction.to_s) + .order("#{order_field} #{sort_direction}") end end if @args[:page] != "ALL" diff --git a/app/controllers/contacts_controller.rb b/app/controllers/contacts_controller.rb index 9857fb6c1b..d10e2f1e5d 100644 --- a/app/controllers/contacts_controller.rb +++ b/app/controllers/contacts_controller.rb @@ -4,6 +4,7 @@ class ContactUs::ContactsController < ApplicationController def create @contact = ContactUs::Contact.new(params[:contact_us_contact]) + flash[:alert] = nil if !user_signed_in? && Rails.configuration.x.recaptcha.enabled unless verify_recaptcha(model: @contact) && @contact.save diff --git a/app/controllers/dmptool/home_controller.rb b/app/controllers/dmptool/home_controller.rb new file mode 100644 index 0000000000..5793863b61 --- /dev/null +++ b/app/controllers/dmptool/home_controller.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "httparty" +require "rss" + +module Dmptool + + module HomeController + + def render_home_page + # Usage stats + @stats = statistics + + # Top 5 templates + @top_five = top_templates + + # Retrieve/cache the DMPTool blog's latest posts + @rss = feed + + render "home/index" + end + + private + + # Collect general statistics about the application + def statistics + cached = Rails.cache.read("stats") + return cached unless cached.nil? + + stats = { + user_count: User.select(:id).count, + completed_plan_count: Plan.select(:id).count, + institution_count: Org.participating.select(:id).count + } + cache_content("stats", stats) + stats + end + + # Collect the list of the top 5 most used templates for the past 90 days + def top_templates + cached = Rails.cache.read("top_five") + return cached unless cached.nil? + + end_date = Date.today + start_date = (end_date - 90) + ids = Plan.group(:template_id) + .where(created_at: start_date..end_date) + .order("count_id DESC") + .count(:id).keys + + top_five = Template.where(id: ids[0..4]) + .pluck(:title) + cache_content("top_five", top_five) + top_five + end + + # Get the last 5 blog posts + def feed + cached = Rails.cache.read("rss") + return cached unless cached.nil? + + resp = HTTParty.get(Rails.configuration.x.application.blog_rss) + return [] unless resp.code == 200 + + rss = RSS::Parser.parse(resp.body, false).items.first(5) + cache_content("rss", rss) + rss + rescue StandardError => e + # If we were unable to connect to the blog rss + logger.error("Caught exception RSS parse: #{e}.") + [] + end + + # Store information in the cache + def cache_content(type, data) + return nil unless type.present? + + Rails.cache.write(type, data, expires_in: 60.minutes) + rescue StandardError => e + logger.error("Unable to add #{type} to the Rails cache: #{e}.") + end + + end + +end diff --git a/app/controllers/dmptool/org_admin/plans_controller.rb b/app/controllers/dmptool/org_admin/plans_controller.rb new file mode 100644 index 0000000000..9428c67944 --- /dev/null +++ b/app/controllers/dmptool/org_admin/plans_controller.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Dmptool + + module OrgAdmin + + module PlansController + + # POST /org_admin/plans + def create + template = Template.find_by(id: plan_params[:template_attributes][:id]) + # Just piggyback off of the Template create policy since only + # an OrgAdmin is allowed to do this + authorize template + + template.update( + email_subject: plan_params[:template_attributes][:email_subject], + email_body: plan_params[:template_attributes][:email_body], + ) + + # Find or initialize the User and then create a new Plan for the template and + # either the user's org or the org admin's org if the user is new + user = User.find_or_initialize_by(email: plan_params[:user][:email]) + plan = Plan.create(template: template, title: "#{template.title} DMP", + org: user.org || current_user.org) + + if user.new_record? + # The email address was unknown so send the user an invitation. + # See User.deliver_invitation for handling of the email + user = User.invite!({ email: user.email, invitation_plan_id: plan.id }, + current_user.org) + else + UserMailer.new_plan_via_template( + recipient: user, sender: current_user, plan: plan + ).deliver_now + end + + # Attach the user to the plan + plan.add_user!(user.id, :creator) + + msg = _("A new DMP has been created and an email sent to '%{email}'.") % { + email: plan_params[:user][:email] + } + redirect_to organisational_org_admin_templates_path, notice: msg + end + + private + + def plan_params + params.require(:plan).permit(user: [:email], + template_attributes: %i[id email_subject email_body]) + end + + end + + end + +end diff --git a/app/controllers/dmptool/org_admin/templates_controller.rb b/app/controllers/dmptool/org_admin/templates_controller.rb new file mode 100644 index 0000000000..d03525f1ef --- /dev/null +++ b/app/controllers/dmptool/org_admin/templates_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Dmptool + + module OrgAdmin + + module TemplatesController + + # GET /org_admin/templates/132/email (AJAX) + #------------------------------------------ + def email + @template = Template.find_by(id: params[:id]) + authorize @template + + subject = _("A new data management plan (DMP) for the %{org_name} was started for you.") % { + org_name: @template.org.name + } + body = _("An administrator from the %{org_name} has started a new data management plan (DMP) for you. If you have any questions or need help, please contact them at %{org_admin_email}.") % { + org_name: @template.org.name, + org_admin_email: helpers.link_to( + @template.org.contact_email, @template.org.contact_email + ) + } + + @template.email_subject = subject unless @template.email_subject.present? + @template.email_body = body unless @template.email_body.present? + end + + end + + end + +end diff --git a/app/controllers/dmptool/orgs_controller.rb b/app/controllers/dmptool/orgs_controller.rb new file mode 100644 index 0000000000..d62b9cf458 --- /dev/null +++ b/app/controllers/dmptool/orgs_controller.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Dmptool + + module OrgsController + + # GET /org_logos/:id (format: :json) + def logos + skip_authorization + org = Org.find(params[:id]) + @user = User.new(org: org) + render json: { + "org" => { + "id" => params[:id], + "html" => render_to_string(template: "shared/org_branding", + formats: [:html]) + } + }.to_json + end + + # GET /orgs/shibboleth_ds/:id + # POST /orgs/shibboleth_ds/:id + # rubocop:disable Metrics/AbcSize + def shibboleth_ds_passthru + skip_authorization + + org = Org.find_by(id: params.fetch(:id, params[:org_id])) + if org.present? + entity_id = org.identifier_for_scheme(scheme: "shibboleth") + if entity_id.present? + shib_login = Rails.configuration.x.shibboleth.login_url + url = "#{request.base_url.gsub('http:', 'https:')}#{shib_login}" + target = user_shibboleth_omniauth_callback_url.gsub("http:", "https:") + # initiate shibboleth login sequence + redirect_to "#{url}?target=#{target}&entityID=#{entity_id.value}" + else + @user = User.new(org: org) + # render new signin showing org logo + render "shared/org_branding" + end + else + redirect_to shibboleth_ds_path, + notice: _("Please choose an organisation from the list.") + end + end + # rubocop:enable Metrics/AbcSize + + private + + def sign_in_params + params.require(:org).permit(:org_name, :org_sources, :org_crosswalk, :id) + end + + def convert_params + # expecting incoming params to look like: + # /orgs/shibboleth/173?org[id=173] + # /orgs/shibboleth/173?shib-ds[org_name=173]&shib-ds[org_id=173]] + args = sign_in_params + + # POST params need to be converted over to a JSON object + if args.is_a?(String) + JSON.parse(args).with_indifferent_access + else + # For some reason when this comes through as a GET with query_params + # it includes the closing bracket :/ + args = args.with_indifferent_access + args[:id] = args[:id].gsub(/\]$/, "") + args + end + end + + end + +end diff --git a/app/controllers/dmptool/paginable/orgs_controller.rb b/app/controllers/dmptool/paginable/orgs_controller.rb new file mode 100644 index 0000000000..bde8668301 --- /dev/null +++ b/app/controllers/dmptool/paginable/orgs_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Dmptool + + module Paginable + + module OrgsController + + # /paginable/orgs/public/:page + def public + skip_authorization + ids = Org.where.not(Org.funder_condition).pluck(:id) + + paginable_renderise( + partial: "public", + scope: Org.participating.where(id: ids), + query_params: { sort_field: "orgs.name", sort_direction: :asc }, + format: :json + ) + end + + end + + end + +end diff --git a/app/controllers/dmptool/public_pages_controller.rb b/app/controllers/dmptool/public_pages_controller.rb new file mode 100644 index 0000000000..180fece933 --- /dev/null +++ b/app/controllers/dmptool/public_pages_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Dmptool + + module PublicPagesController + + # The publicly accessible list of participating institutions + def orgs + skip_authorization + ids = Org.where.not(Org.funder_condition).pluck(:id) + @orgs = Org.participating.where(id: ids) + end + + # The sign in/account creation options page accessed via the 'Get Started' button + # on the home page + # rubocop:disable Naming/AccessorMethodName + def get_started + skip_authorization + render "/shared/_get_started" + end + # rubocop:enable Naming/AccessorMethodName + + protected + + # Clean up the file name to make it OS friendly (removing newlines, and punctuation) + def file_name(title) + name = title.gsub(/[\r\n]/, " ") + .gsub(/[^a-zA-Z\d\s]/, "") + .gsub(/ /, "_") + + name.length > 31 ? name[0..30] : name + end + + end + +end diff --git a/app/controllers/dmptool/static_pages_controller.rb b/app/controllers/dmptool/static_pages_controller.rb new file mode 100644 index 0000000000..0731c0bf6a --- /dev/null +++ b/app/controllers/dmptool/static_pages_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Dmptool + + module StaticPagesController + + def promote + end + + def faq + end + + def general_guidance + end + + def editorial_board + end + + end + +end diff --git a/app/controllers/dmptool/users/omniauth_callbacks_controller.rb b/app/controllers/dmptool/users/omniauth_callbacks_controller.rb new file mode 100644 index 0000000000..5d09374d23 --- /dev/null +++ b/app/controllers/dmptool/users/omniauth_callbacks_controller.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +module Dmptool + + module Users + + # rubocop:disable Metrics/ModuleLength + module OmniauthCallbacksController + + # rubocop:disable Style/FormatString, Metrics/AbcSize, Metrics/MethodLength + def process_omniauth_callback(scheme:) + # There is occassionally a disconnect between the id of the Scheme + # when the base controller's dynamic methods were defined and the + # time this method is called, so reload the scheme + scheme = IdentifierScheme.find_by(name: scheme.name) + + @provider = provider(scheme: scheme) + @omniauth = omniauth_from_request.with_indifferent_access + + # if the user is already signed in then we are attempting to attach + # omniauth credentials to an existing account + if current_user.present? && @omniauth[:uid].present? + identifier = attach_omniauth_credentials( + user: current_user, scheme: scheme, omniauth: @omniauth + ) + + if identifier.present? + msg = format(_("Your account has been successfully linked to %{scheme}."), + scheme: @provider) + redirect_to edit_user_registration_path, notice: msg + else + msg = format(_("Unable to link your account to %{scheme}"), + scheme: @provider) + redirect_to edit_user_registration_path, alert: msg + end + + else + # Attempt to locate the user via the credentials returned by omniauth + @user = User.from_omniauth(OpenStruct.new(@omniauth)) + + # If we found the user by their omniauth creds then sign them in + if @user.present? + flash[:notice] = _("Successfully signed in") + sign_in_and_redirect @user, event: :authentication + + else + # Otherwise attempt to locate the user via the email provided in + # the omniauth creds + new_user = omniauth_hash_to_new_user(scheme: scheme, omniauth: @omniauth) + @user = User.where_case_insensitive("email", new_user.email).first + + # If we found the user by email + if @user.present? + # sign them in and attach their omniauth credentials to the account + identifier = attach_omniauth_credentials( + user: @user, scheme: scheme, omniauth: @omniauth + ) + + # rubocop:disable Metrics/BlockNesting + if identifier.present? + flash[:notice] = format(_("Successfully signed in with %{scheme}."), + scheme: @provider) + sign_in_and_redirect @user, event: :authentication + end + # rubocop:enable Metrics/BlockNesting + + else + # If we could not find a match take them to the account setup page + redirect_to_registration(scheme: scheme, data: @omniauth) + end + end + end + end + # rubocop:enable Style/FormatString, Metrics/AbcSize, Metrics/MethodLength + + private + + # Return the visual name of the scheme + def provider(scheme:) + return _("your institutional credentials") if scheme.name == "shibboleth" + + scheme.description + end + + # Extract the omniauth info from the request + def omniauth_from_request + return {} unless request.env.present? + + hash = request.env["omniauth.auth"] + hash = request.env[:"omniauth.auth"] unless hash.present? + hash.present? ? hash : request.env + end + + # rubocop:disable Layout/LineLength + def redirect_to_registration(scheme:, data:) + session["devise.#{scheme.name.downcase}_data"] = data + redirect_to Rails.application.routes.url_helpers.new_user_registration_path, + notice: _("It looks like this is your first time logging in. Please verify and complete the information below to finish creating an account.") + end + # rubocop:enable Layout/LineLength + + # Attach the omniauth uid to the User + def attach_omniauth_credentials(user:, scheme:, omniauth:) + return false unless user.present? && scheme.present? && omniauth.present? + + # Create the Oauth access token if available + token = ExternalApiAccessToken.from_omniauth(user: user, service: scheme.name, hash: omniauth) + token.save if token.present? + + ui = Identifier.where(identifier_scheme: scheme, identifiable: user).first + # If the User exists and the uid is different update it + ui.update(value: omniauth[:uid]) if ui.present? && ui.value != omniauth[:uid] + return ui.reload if ui.present? + + Identifier.create(identifier_scheme: scheme, identifiable: user, + value: omniauth[:uid]) + end + + # Convert the incoming omniauth info into a User + def omniauth_hash_to_new_user(scheme:, omniauth:) + return nil unless scheme.present? && omniauth.present? + + omniauth_info = omniauth.fetch(:info, {}) + names = extract_omniauth_names(hash: omniauth_info) + + user = User.new( + email: extract_omniauth_email(hash: omniauth_info), + firstname: names.fetch(:firstname, ""), + surname: names.fetch(:surname, ""), + org: extract_omniauth_org(scheme: scheme, hash: omniauth_info) + ) + + # Get the Oauth access token if available + token = ExternalApiAccessToken.from_omniauth(user: user, service: scheme.name, hash: @omniauth) + user.external_api_access_tokens = [token] if token.present? + user + end + + # Extract the 1st email + def extract_omniauth_email(hash:) + hash.present? ? hash.fetch(:email, "").split(";")[0] : nil + end + + # Find the User names from the omniauth info + def extract_omniauth_names(hash:) + return {} unless hash.present? + + out = { + firstname: hash.fetch(:givenname, hash.fetch(:firstname, "")), + surname: hash.fetch(:sn, hash.fetch(:surname, hash.fetch(:lastname, ""))) + } + return out if out[:firstname].present? || out[:surname].present? + + names = hash[:name].split(" ") + { + firstname: names[0], + surname: names.length > 1 ? names[names.length - 1] : nil + } + end + + # Find the Org associated with the omniauth provider + def extract_omniauth_org(scheme:, hash:) + return nil unless scheme.present? && + hash.present? && + hash[:identity_provider].present? + + uid = hash[:identity_provider].downcase + idp = Identifier.where(identifier_scheme: scheme) + .where("LOWER(value) = ?", uid).first + idp.present? ? idp.identifiable : nil + end + + end + # rubocop:enable Metrics/ModuleLength + + end + +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index e4ec75806a..c89a6a1926 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -2,7 +2,13 @@ class HomeController < ApplicationController - include OrgSelectable + # -------------------------------- + # Start DMPTool Customization + # -------------------------------- + include Dmptool::HomeController + # -------------------------------- + # End DMPTool Customization + # -------------------------------- respond_to :html @@ -23,9 +29,15 @@ def index else redirect_to plans_url end - elsif session["devise.shibboleth_data"].present? - # NOTE: Update this to handle ORCiD as well when we enable it as a login method - redirect_to new_user_registration_url + + # ------------------------------------------------- + # Start DMPTool customization + # ------------------------------------------------- + else + render_home_page + # ------------------------------------------------- + # End DMPTool customization + # ------------------------------------------------- end end diff --git a/app/controllers/identifiers_controller.rb b/app/controllers/identifiers_controller.rb index 9b90f2c8a3..f46fbf72ca 100644 --- a/app/controllers/identifiers_controller.rb +++ b/app/controllers/identifiers_controller.rb @@ -24,7 +24,15 @@ def destroy } end - redirect_to edit_user_registration_path + # TODO: While this works for ORCID it might not for future integrations. We should consider + # moving it to a different place on the Edit Profile page + # Revoke any OAuth access tokens for the identifier + tokens = user.external_api_access_tokens.select do |token| + token.external_service_name == identifier.identifier_scheme.name.downcase + end + tokens.each(&:revoke!) + + redirect_to users_third_party_apps_path end end diff --git a/app/controllers/org_admin/phases_controller.rb b/app/controllers/org_admin/phases_controller.rb index 3ef617119a..7cc376ea19 100644 --- a/app/controllers/org_admin/phases_controller.rb +++ b/app/controllers/org_admin/phases_controller.rb @@ -122,7 +122,7 @@ def create flash[:alert] = failure_message(phase, _("create")) end rescue StandardError => e - flash[:alert] = _("Unable to create a new version of this template.") + "
" + e.message + flash[:alert] = "#{_("Unable to create a new version of this template.")}
#{e.message}" end if flash[:alert].present? redirect_to new_org_admin_template_phase_path(template_id: phase.template.id) @@ -146,7 +146,7 @@ def update flash[:alert] = failure_message(phase, _("update")) end rescue StandardError => e - flash[:alert] = _("Unable to create a new version of this template.") + "
" + e.message + flash[:alert] = "#{_("Unable to create a new version of this template.")}
#{e.message}" end redirect_to edit_org_admin_template_phase_path(template_id: phase.template.id, id: phase.id) @@ -176,7 +176,7 @@ def destroy flash[:alert] = failure_message(phase, _("delete")) end rescue StandardError => e - flash[:alert] = _("Unable to create a new version of this template.") + "
" + e.message + flash[:alert] = "#{_("Unable to create a new version of this template.")}
#{e.message}" end if flash[:alert].present? diff --git a/app/controllers/org_admin/plans_controller.rb b/app/controllers/org_admin/plans_controller.rb index d04739b5fc..7e3523cce8 100644 --- a/app/controllers/org_admin/plans_controller.rb +++ b/app/controllers/org_admin/plans_controller.rb @@ -2,6 +2,8 @@ class OrgAdmin::PlansController < ApplicationController + include Dmptool::OrgAdmin::PlansController + # GET org_admin/plans def index # Test auth directly and throw Pundit error sincePundit @@ -65,9 +67,9 @@ def download_plans csv << [ plan.title.to_s, plan.template.title.to_s, - (plan.owner.org.present? ? plan.owner.org.name : "").to_s, - plan.owner.name(false).to_s, - plan.owner.email.to_s, + (plan.owner&.org&.present? ? plan.owner.org.name : "").to_s, + plan.owner&.name(false)&.to_s, + plan.owner&.email&.to_s, l(plan.latest_update.to_date, format: :csv).to_s, Plan::VISIBILITY_MESSAGE[plan.visibility.to_sym].capitalize.to_s ] diff --git a/app/controllers/org_admin/questions_controller.rb b/app/controllers/org_admin/questions_controller.rb index f3061c764e..1d066d8dfe 100644 --- a/app/controllers/org_admin/questions_controller.rb +++ b/app/controllers/org_admin/questions_controller.rb @@ -91,6 +91,11 @@ def create begin question = get_new(question) section = question.section + + # Briley patch for production issue that is preventing new question creation + # Was receiving `question.annotations cannot be blank` + #question.annotations = [] + if question.save flash[:notice] = success_message(question, _("created")) else diff --git a/app/controllers/org_admin/sections_controller.rb b/app/controllers/org_admin/sections_controller.rb index 0d5b9b2b2f..4bad9112e2 100644 --- a/app/controllers/org_admin/sections_controller.rb +++ b/app/controllers/org_admin/sections_controller.rb @@ -104,7 +104,7 @@ def update flash[:alert] = failure_message(section, _("save")) end rescue StandardError => e - flash[:alert] = _("Unable to create a new version of this template.") + "
" + e.message + flash[:alert] = "#{_("Unable to create a new version of this template.")}
#{e.message}" end redirect_to edit_org_admin_template_phase_path( @@ -128,7 +128,7 @@ def destroy flash[:alert] = failure_message(section, _("delete")) end rescue StandardError => e - flash[:alert] = _("Unable to create a new version of this template.") + "
" + e.message + flash[:alert] = "#{_("Unable to create a new version of this template.")}
#{e.message}" end redirect_to(edit_org_admin_template_phase_path( diff --git a/app/controllers/org_admin/templates_controller.rb b/app/controllers/org_admin/templates_controller.rb index 3f26367365..ff15639844 100644 --- a/app/controllers/org_admin/templates_controller.rb +++ b/app/controllers/org_admin/templates_controller.rb @@ -9,6 +9,8 @@ class TemplatesController < ApplicationController include Versionable include TemplateMethods + include Dmptool::OrgAdmin::TemplatesController + after_action :verify_authorized # The root version of index which returns all templates @@ -19,7 +21,7 @@ def index templates = Template.latest_version.where(customization_of: nil) published = templates.select { |t| t.published? || t.draft? }.length - @orgs = Org.managed + @orgs = Org.includes(identifiers: :identifier_scheme).managed @title = _("All Templates") @templates = templates.includes(:org).page(1) @query_params = { sort_field: "templates.title", sort_direction: "asc" } @@ -43,7 +45,7 @@ def organisational .where(customization_of: nil, org_id: current_user.org.id) published = templates.select { |t| t.published? || t.draft? }.length - @orgs = current_user.can_super_admin? ? Org.all : nil + @orgs = Org.includes(identifiers: :identifier_scheme).all if current_user.can_super_admin? @title = if current_user.can_super_admin? _("%{org_name} Templates") % { org_name: current_user.org.name } else @@ -80,7 +82,7 @@ def customisable end published = customizations.select { |t| t.published? || t.draft? }.length - @orgs = current_user.can_super_admin? ? Org.all : [] + @orgs = Org.includes(identifiers: :identifier_scheme).all if current_user.can_super_admin? @title = _("Customizable Templates") @templates = funder_templates @customizations = customizations @@ -329,7 +331,7 @@ def template_export begin # rubocop:disable Layout/LineLength - file_name = @template.title.gsub(/[^a-zA-Z\d\s]/, "").gsub(/ /, "_") + "_v" + @template.version.to_s + file_name = "#{@template.title.gsub(/[^a-zA-Z\d\s]/, '').gsub(/ /, '_')}_v#{@template.version}" # rubocop:enable Layout/LineLength respond_to do |format| format.docx do diff --git a/app/controllers/orgs_controller.rb b/app/controllers/orgs_controller.rb index f302afd450..e2c1bdf797 100644 --- a/app/controllers/orgs_controller.rb +++ b/app/controllers/orgs_controller.rb @@ -2,6 +2,14 @@ class OrgsController < ApplicationController + # ===================================== + # Start DMPTool Customization + # ===================================== + include Dmptool::OrgsController + # ===================================== + # End DMPTool Customization + # ===================================== + include OrgSelectable after_action :verify_authorized, except: %w[ @@ -91,58 +99,63 @@ def admin_update end end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength - # rubocop:enable - - # This action is used by installations that have the following config enabled: - # Rails.configuration.x.shibboleth.use_filtered_discovery_service - def shibboleth_ds - unless current_user.nil? - redirect_to root_path - return - end - - @user = User.new - # Display the custom Shibboleth discovery service page. - @orgs = Identifier.by_scheme_name("shibboleth", "Org") - .sort { |a, b| a.identifiable.name <=> b.identifiable.name } - .map(&:identifiable) - - # Disabling the rubocop check here because it would not be clear what happens - # if the ``@orgs` array has items ... it renders the shibboleth_ds view - # rubocop:disable Style/GuardClause, Style/RedundantReturn - if @orgs.empty? - flash.now[:alert] = _("No organisations are currently registered.") - redirect_to user_shibboleth_omniauth_authorize_path - return - end - # rubocop:enable Style/GuardClause, Style/RedundantReturn - end - # This action is used to redirect a user to the Shibboleth IdP - # POST /orgs/shibboleth_ds - # rubocop:disable Metrics/AbcSize - def shibboleth_ds_passthru - if !shib_params[:org_id].blank? - session["org_id"] = shib_params[:org_id] - - org = Org.where(id: shib_params[:org_id]) - shib_entity = Identifier.by_scheme_name("shibboleth", "Org") - .where(identifiable: org) - - if !shib_entity.empty? - # initiate shibboleth login sequence - entity_param = "entityID=#{shib_entity.first.value}" - redirect_to "#{shib_login_url}?#{shib_callback_url}&#{entity_param}" - else - failure = _("Your organisation does not seem to be properly configured.") - redirect_to shibboleth_ds_path, alert: failure - end - - else - redirect_to shibboleth_ds_path, notice: _("Please choose an organisation") - end - end - # rubocop:enable Metrics/AbcSize + # -------------------------------------------------------- + # Start DMPTool customization + # Commenting out so that our customization is used + # -------------------------------------------------------- + # # This action is used by installations that have the following config enabled: + # # Rails.configuration.x.shibboleth.use_filtered_discovery_service + # def shibboleth_ds + # unless current_user.nil? + # redirect_to root_path + # return + # end + # + # @user = User.new + # # Display the custom Shibboleth discovery service page. + # @orgs = Identifier.by_scheme_name("shibboleth", "Org") + # .sort { |a, b| a.identifiable.name <=> b.identifiable.name } + # .map(&:identifiable) + # + # # Disabling the rubocop check here because it would not be clear what happens + # # if the ``@orgs` array has items ... it renders the shibboleth_ds view + # # rubocop:disable Style/GuardClause, Style/RedundantReturn + # if @orgs.empty? + # flash.now[:alert] = _("No organisations are currently registered.") + # redirect_to user_shibboleth_omniauth_authorize_path + # return + # end + # # rubocop:enable Style/GuardClause, Style/RedundantReturn + # end + + # # This action is used to redirect a user to the Shibboleth IdP + # # POST /orgs/shibboleth_ds + # # rubocop:disable Metrics/AbcSize + # def shibboleth_ds_passthru + # if !shib_params[:org_id].blank? + # session["org_id"] = shib_params[:org_id] + # + # org = Org.where(id: shib_params[:org_id]) + # shib_entity = Identifier.by_scheme_name("shibboleth", "Org") + # .where(identifiable: org) + # + # if !shib_entity.empty? + # # initiate shibboleth login sequence + # entity_param = "entityID=#{shib_entity.first.value}" + # redirect_to "#{shib_login_url}?#{shib_callback_url}&#{entity_param}" + # else + # failure = _("Your organisation does not seem to be properly configured.") + # redirect_to shibboleth_ds_path, alert: failure + # end + # else + # redirect_to shibboleth_ds_path, notice: _("Please choose an organisation") + # end + # end + # # rubocop:enable Metrics/AbcSize + # -------------------------------------------------------- + # End DMPTool customization + # -------------------------------------------------------- # POST /orgs (via AJAX from Org Typeaheads ... see below for specific pages) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize @@ -216,6 +229,7 @@ def org_params :remove_logo, :managed, :feedback_enabled, :org_links, :funder, :institution, :organisation, :feedback_msg, :org_id, :org_name, :org_crosswalk, + :api_create_plan_email_subject, :api_create_plan_email_body, identifiers_attributes: %i[identifier_scheme_id value], tracker_attributes: %i[code id]) end diff --git a/app/controllers/paginable/api_logs_controller.rb b/app/controllers/paginable/api_logs_controller.rb new file mode 100644 index 0000000000..29ef655aad --- /dev/null +++ b/app/controllers/paginable/api_logs_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Paginable + + class ApiLogsController < ApplicationController + + after_action :verify_authorized + respond_to :html + + include Paginable + + # GET /paginable/api_logs/:page + def index + authorize(ApiClient) + @api_logs = ApiLog.all + paginable_renderise( + partial: "index", + scope: ApiLog.all, + query_params: { sort_field: "api_logs.created_at", sort_direction: :desc }, + format: :json + ) + end + + end + +end diff --git a/app/controllers/paginable/contributors_controller.rb b/app/controllers/paginable/contributors_controller.rb index b0a153f6f4..55cfcd29bc 100644 --- a/app/controllers/paginable/contributors_controller.rb +++ b/app/controllers/paginable/contributors_controller.rb @@ -13,7 +13,7 @@ class ContributorsController < ApplicationController # GET /paginable/plans/:plan_id/contributors/index/:page def index @plan = Plan.find_by(id: params[:plan_id]) - authorize @plan + authorize @plan, :show? paginable_renderise( partial: "index", scope: Contributor.where(plan_id: @plan.id), diff --git a/app/controllers/paginable/orgs_controller.rb b/app/controllers/paginable/orgs_controller.rb index e35944a54b..5674f2298b 100644 --- a/app/controllers/paginable/orgs_controller.rb +++ b/app/controllers/paginable/orgs_controller.rb @@ -2,6 +2,14 @@ class Paginable::OrgsController < ApplicationController + # -------------------------------- + # Start DMPTool Customization + # -------------------------------- + include Dmptool::Paginable::OrgsController + # -------------------------------- + # End DMPTool Customization + # -------------------------------- + include Paginable # /paginable/guidances/index/:page diff --git a/app/controllers/paginable/research_outputs_controller.rb b/app/controllers/paginable/research_outputs_controller.rb new file mode 100644 index 0000000000..266d22ac42 --- /dev/null +++ b/app/controllers/paginable/research_outputs_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Paginable + + class ResearchOutputsController < ApplicationController + + after_action :verify_authorized + respond_to :html + + include Paginable + + # GET /paginable/plans/:plan_id/research_outputs + def index + @plan = Plan.find_by(id: params[:plan_id]) + authorize @plan + paginable_renderise( + partial: "index", + scope: ResearchOutput.where(plan_id: @plan.id), + query_params: { sort_field: "research_outputs.title", sort_direction: :asc }, + format: :json + ) + end + + end + +end diff --git a/app/controllers/paginable/templates_controller.rb b/app/controllers/paginable/templates_controller.rb index 680e7eb8ca..dacd967ccf 100644 --- a/app/controllers/paginable/templates_controller.rb +++ b/app/controllers/paginable/templates_controller.rb @@ -44,6 +44,7 @@ def organisational template_ids = templates.select { |t| !t.published? && !t.draft? }.collect(&:family_id) templates = Template.latest_version(template_ids) end + paginable_renderise( partial: "organisational", scope: templates, diff --git a/app/controllers/plan_exports_controller.rb b/app/controllers/plan_exports_controller.rb index 27b98c24c1..73358c3906 100644 --- a/app/controllers/plan_exports_controller.rb +++ b/app/controllers/plan_exports_controller.rb @@ -13,26 +13,34 @@ def show if privately_authorized? && export_params[:form].present? skip_authorization - @show_coversheet = export_params[:project_details].present? - @show_sections_questions = export_params[:question_headings].present? - @show_unanswered = export_params[:unanswered_questions].present? - @show_custom_sections = export_params[:custom_sections].present? - @public_plan = false + @show_coversheet = export_params[:project_details].present? + @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? + @show_related_identifiers = export_params[:related_identifiers].present? + @formatting = export_params[:formatting] + @formatting = @plan.settings(:export)&.formatting if @formatting.nil? + @public_plan = false elsif publicly_authorized? skip_authorization - @show_coversheet = true - @show_sections_questions = true - @show_unanswered = true - @show_custom_sections = true - @public_plan = true + @show_coversheet = true + @show_sections_questions = true + @show_unanswered = true + @show_custom_sections = true + @show_research_outputs = @plan.research_outputs&.any? || false + @show_related_identifiers = @plan.related_identifiers&.any? || false + @formatting = @plan.settings(:export)&.formatting + @formatting = Settings::Template::DEFAULT_SETTINGS if @formatting.nil? + @public_plan = true else - raise Pundit::NotAuthorizedError + raise Pundit::NotAuthorizedError, _("are not authorized to view that plan") end @hash = @plan.as_pdf(current_user, @show_coversheet) - @formatting = export_params[:formatting] || @plan.settings(:export).formatting + @selected_phase = if params.key?(:phase_id) @plan.phases.find(params[:phase_id]) else @@ -40,6 +48,11 @@ def show .detect { |p| p.visibility_allowed?(@plan) } end + # Bug fix in the event that there was no phase with visibility_allowed + unless @selected_phase.present? + @selected_phase = @plan.phases.order("phases.updated_at DESC").first + end + respond_to do |format| format.html { show_html } format.csv { show_csv } @@ -94,7 +107,8 @@ def show_pdf end def show_json - json = render_to_string(partial: "/api/v1/plans/show", locals: { plan: @plan }) + json = render_to_string(partial: "/api/v2/plans/show", + locals: { plan: @plan, client: current_user }) render json: "{\"dmp\":#{json}}" end @@ -125,9 +139,10 @@ 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, :related_identifiers, + 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 437a5093ae..e3d15bbdf8 100644 --- a/app/controllers/plans_controller.rb +++ b/app/controllers/plans_controller.rb @@ -9,7 +9,7 @@ class PlansController < ApplicationController helper PaginableHelper helper SettingsTemplateHelper - after_action :verify_authorized, except: [:overview] + after_action :verify_authorized # GET /plans def index @@ -79,9 +79,9 @@ def create @plan.title = if plan_params[:title].blank? if current_user.firstname.blank? - _("My Plan") + "(" + @plan.template.title + ")" + "#{_("My Plan")}(#{@plan.template.title})" else - current_user.firstname + "'s" + _(" Plan") + "#{current_user.firstname}'s#{_(" Plan")}" end else plan_params[:title] @@ -137,7 +137,14 @@ def create # Set new identifier to plan id by default on create. # (This may be changed by user.) - @plan.identifier = @plan.id.to_s + # ================================================ + # Start DMPTool customization + # We are using this field as a Funding Opportunity Number + # ================================================ + # @plan.identifier = @plan.id.to_s + # ================================================ + # End DMPTool customization + # ================================================ @plan.save respond_to do |format| @@ -163,7 +170,9 @@ def show @plan = Plan.includes( template: { phases: { sections: { questions: :answers } } }, plans_guidance_groups: { guidance_group: :guidances } - ).find(params[:id]) + ).find_by(id: params[:id]) + raise ActiveRecord::RecordNotFound unless @plan.present? + authorize @plan @visibility = if @plan.visibility.present? @@ -191,12 +200,13 @@ def show if @all_ggs_grouped_by_org.include?(current_user.org) @important_ggs << [current_user.org, @all_ggs_grouped_by_org[current_user.org]] end + @all_ggs_grouped_by_org.each do |org, ggs| @important_ggs << [org, ggs] if Org.default_orgs.include?(org) # If this is one of the already selected guidance groups its important! - unless (ggs & @selected_guidance_groups).empty? - @important_ggs << [org, ggs] unless @important_ggs.include?([org, ggs]) + if !(ggs & @selected_guidance_groups).empty? && !@important_ggs.include?([org, ggs]) + @important_ggs << [org, ggs] end end @@ -262,20 +272,18 @@ def update params[:guidance_group_ids].map(&:to_i).uniq end @plan.guidance_groups = GuidanceGroup.where(id: guidance_group_ids) + @research_domains = ResearchDomain.all.order(:label) # TODO: For some reason the `fields_for` isn't adding the # appropriate namespace, so org_id represents our funder - funder_attrs = plan_params[:funder] - funder_attrs[:org_id] = plan_params[:funder][:id] - funder = org_from_params(params_in: funder_attrs, allow_create: true) + funder = org_from_params(params_in: attrs, allow_create: true) @plan.funder_id = funder&.id + @plan.grant = plan_params[:grant] attrs.delete(:funder) - - process_grant(grant_params: plan_params[:grant]) attrs.delete(:grant) attrs = remove_org_selection_params(params_in: attrs) - if @plan.update(attrs) # _attributes(attrs) + if @plan.update(attrs) format.html do redirect_to plan_path(@plan), notice: success_message(@plan, _("saved")) @@ -307,12 +315,13 @@ def update end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength - # GET /plans/:id/share - def share + # GET /plans/:id/publish + def publish @plan = Plan.find(params[:id]) if @plan.present? authorize @plan @plan_roles = @plan.roles.where(active: true) + @orcid_access_token = ExternalApiAccessToken.for_user_and_service(user: current_user, service: "orcid") else redirect_to(plans_path) end @@ -396,37 +405,31 @@ def duplicate # POST /plans/:id/visibility # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def visibility - plan = Plan.find(params[:id]) - if plan.present? - authorize plan - if plan.visibility_allowed? - plan.visibility = plan_params[:visibility] - if plan.save - deliver_if(recipients: plan.owner_and_coowners, + @plan = Plan.find(params[:id]) + if @plan.present? + authorize @plan + if @plan.visibility_allowed? + @plan.visibility = plan_params[:visibility] + if @plan.save + deliver_if(recipients: @plan.owner_and_coowners, key: "owners_and_coowners.visibility_changed") do |r| - UserMailer.plan_visibility(r, plan).deliver_now + UserMailer.plan_visibility(r, @plan).deliver_now end - render status: :ok, - json: { msg: success_message(plan, _("updated")) } + flash[:notice] = success_message(@plan, _("saved")) else - render status: :internal_server_error, - json: { msg: failure_message(plan, _("update")) } + flash[:alert] = failure_message(@plan, _("copy")) end else # rubocop:disable Layout/LineLength - render status: :forbidden, json: { - msg: _("Unable to change the plan's status since it is needed at least %{percentage} percentage responded") % { + flash[:alert] = _("Unable to change the plan's status since it is needed at least %{percentage} percentage responded") % { percentage: Rails.configuration.x.plans.default_percentage_answered - } } # rubocop:enable Layout/LineLength end else - render status: :not_found, - json: { msg: _("Unable to find plan id %{plan_id}") % { - plan_id: params[:id] - } } + flash[:alert] = _("Unable to find plan id %{plan_id}") % { plan_id: params[:id] } end + render "publish" end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength @@ -450,18 +453,28 @@ def set_test # rubocop:enable Layout/LineLength end - # GET /plans/:id/overview - def overview - plan = Plan.includes(template: [:org, { phases: { sections: :questions } }]) - .find(params[:id]) + # GET /plans/:id/mint + def mint + @plan = Plan.find(params[:id]) + authorize @plan - authorize plan - render(:overview, locals: { plan: plan }) - rescue ActiveRecord::RecordNotFound - flash[:alert] = _("There is no plan associated with id %{id}") % { - id: params[:id] - } - redirect_to(action: :index) + dmp_id = DmpIdService.mint_dmp_id(plan: @plan) + dmp_id.save + @plan = @plan.reload + + @orcid_access_token = ExternalApiAccessToken.for_user_and_service(user: current_user, service: "orcid") + + # If a DMP ID was successfully acquired and the User has authorized us to write to their ORCID record + if @plan.dmp_id.present? && @orcid_access_token.present? + ExternalApis::OrcidService.add_work(user: current_user, plan: @plan) + end + + render js: render_to_string(template: "plans/mint.js.erb") + rescue StandardError => e + Rails.logger.error "Unable to add plan #{params[:id]} to the user #{current_user.id}'s ORCID record - #{e.message}" + Rails.logger.error e.backtrace + + render js: render_to_string(template: "plans/mint.js.erb") end # ============================ @@ -481,7 +494,8 @@ def plan_params :research_domain_id, :funding_status, grant: %i[name value], org: %i[id org_id org_name org_sources org_crosswalk], - funder: %i[id org_id org_name org_sources org_crosswalk]) + funder: %i[id org_id org_name org_sources org_crosswalk], + related_identifiers_attributes: %i[id work_type value]) end # different versions of the same template have the same family_id @@ -538,26 +552,5 @@ def render_phases_edit(plan, phase, guidance_groups) }) end - # Update, destroy or add the grant - 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 - end # rubocop:enable Metrics/ClassLength diff --git a/app/controllers/public_pages_controller.rb b/app/controllers/public_pages_controller.rb index d6dd911315..56e158d862 100644 --- a/app/controllers/public_pages_controller.rb +++ b/app/controllers/public_pages_controller.rb @@ -2,6 +2,14 @@ class PublicPagesController < ApplicationController + # -------------------------------- + # Start DMPTool Customization + # -------------------------------- + include Dmptool::PublicPagesController + # -------------------------------- + # End DMPTool Customization + # -------------------------------- + # GET template_index # ----------------------------------------------------- # rubocop:disable Metrics/AbcSize diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 352fa8481b..cc90f1a618 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -28,16 +28,28 @@ def new @user = User.new - # no oath, no provider or no uid - bail out - return if oauth.nil? or oauth["provider"].nil? or oauth["uid"].nil? - - # Connect the new user with the identifier sent back by the OAuth provider - flash[:notice] = _("Please make a choice below. After linking your - details to a %{application_name} account, - you will be able to sign in directly with your - institutional credentials.") % { - application_name: ApplicationService.application_name - } + # rubocop:disable Style/GuardClause + unless oauth.nil? + # The OAuth provider could not be determined or there was no unique UID! + if !oauth["provider"].nil? && !oauth["uid"].nil? + # Connect the new user with the identifier sent back by the OAuth provider + # rubocop:disable Layout/LineLength + flash[:notice] = _("Please make a choice below. After linking your details to a %{application_name} account, you will be able to sign in directly with your institutional credentials.") % { + application_name: ApplicationService.application_name + } + # rubocop:enable Metrics/LineLength + + # If this is part of a Shibboleth workflow, determine which Org Idp we came from and make + # it available to the regsitration form (which will hide the Org textbox) + entity_id = oauth.fetch("info", {})["identity_provider"] + if entity_id.present? + identifier = Identifier.where(identifiable_type: "Org", + value: entity_id).first + @user.org = identifier.identifiable if identifier.present? + end + end + end + # rubocop:enable Style/GuardClause end # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockNesting, Layout/LineLength @@ -53,7 +65,8 @@ def create blank_org = if Rails.configuration.x.application.restrict_orgs sign_up_params[:org_id]["id"].blank? else - sign_up_params[:org_id].blank? + # DMPTool hack to accommodate Org coming from IdP + sign_up_params.fetch(:org_id, sign_up_params[:default_org_id]).blank? end if sign_up_params[:accept_terms].to_s == "0" @@ -105,7 +118,16 @@ def create if resource.active_for_authentication? set_flash_message :notice, :signed_up if is_navigational_format? sign_up(resource_name, resource) - UserMailer.welcome_notification(current_user).deliver_now + + # ---------------------------------------------------------- + # Start DMPTool customization + # Comment out the welcome email. DMPTool does not send one! + # ---------------------------------------------------------- + # UserMailer.welcome_notification(current_user).deliver_now + # ---------------------------------------------------------- + # End DMPTool customization + # ---------------------------------------------------------- + unless oauth.nil? # The OAuth provider could not be determined or there was no unique UID! unless oauth["provider"].nil? || oauth["uid"].nil? @@ -289,8 +311,10 @@ def do_update_password(current_user, args) def sign_up_params params.require(:user).permit(:email, :password, :password_confirmation, :firstname, :surname, :recovery_email, - :accept_terms, :org_id, :org_name, - :org_crosswalk, :language_id) + :accept_terms, :org_id, :org_name, :default_org_id, + :org_crosswalk, :language_id, + external_api_access_tokens: [:external_service_name, :access_token, + :refresh_token, :expires_at]) end def update_params @@ -302,17 +326,26 @@ def update_params # Finds or creates the selected org and then returns it's id def handle_org(attrs:) - return attrs unless attrs.present? && attrs[:org_id].present? + return nil unless attrs.present? + + # DMPTool hack to deal with Org via IdP + if attrs[:default_org_id].present? + attrs[:org_id] = attrs[:default_org_id] + attrs.delete(:default_org_id) + return attrs + else + return attrs unless attrs.present? && attrs[:org_id].present? - org = org_from_params(params_in: attrs, allow_create: true) + org = org_from_params(params_in: attrs, allow_create: true) - # Remove the extraneous Org Selector hidden fields - attrs = remove_org_selection_params(params_in: attrs) - return attrs unless org.present? + # Remove the extraneous Org Selector hidden fields + attrs = remove_org_selection_params(params_in: attrs) + return attrs unless org.present? - # reattach the org_id but with the Org id instead of the hash - attrs[:org_id] = org.id - attrs + # reattach the org_id but with the Org id instead of the hash + attrs[:org_id] = org.id + attrs + end end end diff --git a/app/controllers/research_outputs_controller.rb b/app/controllers/research_outputs_controller.rb new file mode 100644 index 0000000000..67955689bc --- /dev/null +++ b/app/controllers/research_outputs_controller.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +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 + 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 + + # 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 + 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 + + # 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 + + 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 + + # 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/controllers/roles_controller.rb b/app/controllers/roles_controller.rb index 58e583eb65..2f960039a9 100644 --- a/app/controllers/roles_controller.rb +++ b/app/controllers/roles_controller.rb @@ -43,7 +43,8 @@ def create User.invite!({ email: role_params[:user][:email], firstname: _("First Name"), surname: _("Surname"), - org: current_user.org }, + org: current_user.org, + invitation_plan_id: @role.plan.id }, current_user) message = _("Invitation to %{email} issued successfully.") % { email: role_params[:user][:email] @@ -73,7 +74,7 @@ def create else flash[:alert] = _("Please enter an email address") end - redirect_to controller: "plans", action: "share", id: @role.plan.id + redirect_to controller: "contributors", action: "index", plan_id: @role.plan.id end # rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockNesting # rubocop:enable @@ -109,7 +110,7 @@ def destroy deliver_if(recipients: user, key: "users.added_as_coowner") do |_r| UserMailer.plan_access_removed(user, plan, current_user).deliver_now end - redirect_to controller: "plans", action: "share", id: @role.plan.id + redirect_to plan_contributors_path(plan) end # This function makes user's role on a plan inactive diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 5763e95917..aab8935187 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -11,8 +11,8 @@ def new # rubocop:disable Metrics/AbcSize def create existing_user = User.find_by(email: params[:user][:email]) - unless existing_user.nil? + unless existing_user.nil? # Until ORCID login is supported unless session["devise.shibboleth_data"].nil? args = { @@ -30,6 +30,7 @@ def create super do if !@ui.nil? && @ui.save + # This is a user attempting to link their account via the Edit Profile page # rubocop:disable Layout/LineLength flash[:notice] = _("Your account has been successfully linked to your institutional credentials. You will now be able to sign in with them.") # rubocop:enable Layout/LineLength diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 8a3a138616..42aa9a4c7d 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -2,6 +2,14 @@ class StaticPagesController < ApplicationController + # -------------------------------- + # Start DMPTool Customization + # -------------------------------- + include Dmptool::StaticPagesController + # -------------------------------- + # End DMPTool Customization + # -------------------------------- + def about_us end diff --git a/app/controllers/super_admin/api_clients_controller.rb b/app/controllers/super_admin/api_clients_controller.rb index d483020731..027cf0b595 100644 --- a/app/controllers/super_admin/api_clients_controller.rb +++ b/app/controllers/super_admin/api_clients_controller.rb @@ -28,31 +28,6 @@ def edit authorize(@api_client) end - # POST /api_clients - def create - authorize(ApiClient) - - # Translate the Org selection - org = org_from_params(params_in: api_client_params, allow_create: false) - attrs = remove_org_selection_params(params_in: api_client_params) - - @api_client = ApiClient.new(attrs) - @api_client.org = org if org.present? - - if @api_client.save - UserMailer.api_credentials(@api_client).deliver_now - msg = success_message(@api_client, _("created")) - msg += _(". The API credentials have been emailed to %{email}") % { - email: @api_client.contact_email - } - flash.now[:notice] = msg - render :edit - else - flash.now[:alert] = failure_message(@api_client, _("create")) - render :new - end - end - # PATCH/PUT /api_clients/:id def update @api_client = ApiClient.find(params[:id]) @@ -90,7 +65,7 @@ def refresh_credentials return unless @api_client.present? original = @api_client.client_secret - @api_client.generate_credentials + @api_client.renew_secret @api_client.save @success = original != @api_client.client_secret end @@ -105,10 +80,12 @@ def email_credentials # Never trust parameters from the scary internet, only allow the white list through. def api_client_params - params.require(:api_client).permit(:name, :description, :homepage, + params.require(:api_client).permit(:name, :description, :homepage, :logo, :remove_logo, :contact_name, :contact_email, - :client_id, :client_secret, - :org_id, :org_name, :org_sources, :org_crosswalk) + :client_id, :client_secret, :redirect_uri, + :callback_uri, :callback_method, + :org_id, :org_name, :org_sources, :org_crosswalk, + :trusted, scopes: []) end end diff --git a/app/controllers/super_admin/api_logs_controller.rb b/app/controllers/super_admin/api_logs_controller.rb new file mode 100644 index 0000000000..36019917de --- /dev/null +++ b/app/controllers/super_admin/api_logs_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module SuperAdmin + + class ApiLogsController < ApplicationController + + respond_to :html + + helper PaginableHelper + + # GET /api_clients + def index + authorize(ApiClient) + @api_logs = ApiLog.all.page(1) + end + + end + +end diff --git a/app/controllers/usage_controller.rb b/app/controllers/usage_controller.rb index 1ab8c0c29e..2d4241fca8 100644 --- a/app/controllers/usage_controller.rb +++ b/app/controllers/usage_controller.rb @@ -14,6 +14,7 @@ def index plan_data(args: args, as_json: true) total_plans(args: min_max_dates(args: args)) total_users(args: min_max_dates(args: args)) + total_dmp_ids @separators = Rails.configuration.x.application.csv_separators @funder = current_user.org.funder? @filtered = args[:filtered] @@ -185,6 +186,10 @@ def total_users(args:) @total_org_users = StatJoinedUser.monthly_range(args.except(:filtered)).sum(:count) end + def total_dmp_ids + @total_org_dmp_ids = current_user.org.plans.select { |plan| plan.dmp_id.present? }.length + end + def first_plan_date StatCreatedPlan.all.order(:date).limit(1).pluck(:date).first \ || Date.today.last_month.end_of_month diff --git a/app/controllers/users/invitations_controller.rb b/app/controllers/users/invitations_controller.rb index 4b03586e1f..7ee019a364 100644 --- a/app/controllers/users/invitations_controller.rb +++ b/app/controllers/users/invitations_controller.rb @@ -59,6 +59,13 @@ def handle_org end resource.update(org_id: lookup.id) + return true unless resource.invitation_plan_id.present? + + # Set the plan associated with this invitation org_id + # If the user was invited via the 'create_dmps' scope for API V2 or an OrgAdmin + # using the 'Email template' workflow. + plan = Plan.find_by(id: resource.invitation_plan_id) + plan.update(org_id: lookup.id) end end diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 67057c1555..966fd64795 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -2,6 +2,14 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController + # -------------------------------- + # Start DMPTool Customization + # -------------------------------- + include Dmptool::Users::OmniauthCallbacksController + # -------------------------------- + # End DMPTool Customization + # -------------------------------- + ## # Dynamically build a handler for each omniauth provider # ------------------------------------------------------------- @@ -20,66 +28,83 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController # # scheme - The IdentifierScheme for the provider # - # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def handle_omniauth(scheme) - user = if request.env["omniauth.auth"].nil? - User.from_omniauth(request.env) - else - User.from_omniauth(request.env["omniauth.auth"]) - end - - # If the user isn't logged in - if current_user.nil? - # If the uid didn't have a match in the system send them to register - if user.nil? - session["devise.#{scheme.name.downcase}_data"] = request.env["omniauth.auth"] - redirect_to new_user_registration_url + # -------------------------------------------------------- + # Start DMPTool customization + # -------------------------------------------------------- + process_omniauth_callback(scheme: scheme) - # Otherwise sign them in - elsif scheme.name == "shibboleth" - # Until ORCID becomes supported as a login method - set_flash_message(:notice, :success, kind: scheme.description) if is_navigational_format? - sign_in_and_redirect user, event: :authentication - else - flash[:notice] = _("Successfully signed in") - redirect_to new_user_registration_url - end + # DMPTool -- commented out the entire block below - # The user is already logged in and just registering the uid with us - else - # If the user could not be found by that uid then attach it to their record - if user.nil? - if Identifier.create(identifier_scheme: scheme, - value: request.env["omniauth.auth"].uid, - attrs: request.env["omniauth.auth"], - identifiable: current_user) - flash[:notice] = _("Your account has been successfully linked to %{scheme}.") % { - scheme: scheme.description - } - - else - flash[:alert] = _("Unable to link your account to %{scheme}.") % { - scheme: scheme.description - } - end + # user = if request.env["omniauth.auth"].nil? + # User.from_omniauth(request.env) + # else + # User.from_omniauth(request.env["omniauth.auth"]) + # end + # + # # If the user isn't logged in + # if current_user.nil? + # # If the uid didn't have a match in the system send them to register + # if user.nil? + # session["devise.#{scheme.name.downcase}_data"] = request.env["omniauth.auth"] + # redirect_to new_user_registration_url + # + # # Otherwise sign them in + # elsif scheme.name == "shibboleth" + # # Until ORCID becomes supported as a login method + # set_flash_message(:notice, :success, kind: scheme.description) if is_navigational_format? + # sign_in_and_redirect user, event: :authentication + # else + # flash[:notice] = _("Successfully signed in") + # redirect_to new_user_registration_url + # end + # + # # The user is already logged in and just registering the uid with us + # else + # # If the user could not be found by that uid then attach it to their record + # if user.nil? + # if Identifier.create(identifier_scheme: scheme, + # value: request.env["omniauth.auth"].uid, + # attrs: request.env["omniauth.auth"], + # identifiable: current_user) + # flash[:notice] = _("Your account has been successfully linked to %{scheme}.") % { + # scheme: scheme.description + # } + # + # else + # flash[:alert] = _("Unable to link your account to %{scheme}.") % { + # scheme: scheme.description + # } + # end + # + # elsif user.id != current_user.id + # # If a user was found but does NOT match the current user then the identifier has + # # already been attached to another account (likely the user has 2 accounts) + # rubocop:disable Layout/LineLength + # flash[:alert] = _("The current #{scheme.description} iD has been already linked to a user with email #{identifier.user.email}") + # rubocop:enable Layout/LineLength + # end + # + # # Redirect to the User Profile page + # redirect_to edit_user_registration_path + # end + # -------------------------------------------------------- + # End DMPTool customization + # -------------------------------------------------------- + end - elsif user.id != current_user.id - # If a user was found but does NOT match the current user then the identifier has - # already been attached to another account (likely the user has 2 accounts) - # rubocop:disable Layout/LineLength - flash[:alert] = _("The current #{scheme.description} iD has been already linked to a user with email #{identifier.user.email}") - # rubocop:enable Layout/LineLength - end + def failure + msg = _("Unable to authenticate or authorize access.") + path = root_path - # Redirect to the User Profile page - redirect_to edit_user_registration_path + # Otherwise if the user denied authorization then + if params["error"].downcase.include?("denied") || params["error_description"].downcase.include?("denied") + msg = _("Authorization was not given. Did you clicked \"deny\" by mistake? You can reauthorize by clicking the link below.") + path = edit_user_registration_path end - end - # rubocop:enable Metrics/AbcSize, Metrics/MethodLength - # rubocop:enable - def failure - redirect_to root_path + # Default to root with a generic message + redirect_to path, alert: msg end end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 37a48fd3ba..00c35ab9c9 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -5,6 +5,7 @@ class UsersController < ApplicationController helper PaginableHelper helper PermsHelper include ConditionalUserMailer + after_action :verify_authorized respond_to :html @@ -170,6 +171,38 @@ def refresh_token @success = current_user.api_token != original end + # DELETE /users/:user_id/oauth_credential_tokens/:id + def revoke_oauth_access_token + user = User.includes(:access_tokens).find_by(id: params[:user_id]) + authorize user + token = Doorkeeper::AccessToken.find_by(id: params[:id]) + if token.present? + token.update(revoked_at: Time.now) + redirect_to users_third_party_apps_path, notice: _("The application is no longer authorized to access your data.") + else + redirect_to users_third_party_apps_path, alert: _("Unable to revoke the authorized application.") + end + end + + # GET /users/third_party_apps + def third_party_apps + # Displays the user's 3rd party applications profile page + authorize current_user + + @identifier_schemes = IdentifierScheme.for_users.order(:name) + @tokens = current_user.access_tokens.select { |token| token.revoked_at == nil } + end + + # GET /users/developer_tools + def developer_tools + # Displays the user's developer tools profile page + authorize current_user + + @api_client = ApiClient.find_or_initialize_by(user_id: current_user.id) + @api_client.contact_name = current_user.name(false) unless @api_client.contact_name.present? + @api_client.contact_email = current_user.email unless @api_client.contact_email.present? + end + private def permission_params diff --git a/app/helpers/conditions_helper.rb b/app/helpers/conditions_helper.rb index a674017fdc..81d7972e52 100644 --- a/app/helpers/conditions_helper.rb +++ b/app/helpers/conditions_helper.rb @@ -191,21 +191,21 @@ def condition_to_text(conditions) conditions.each do |cond| opts = cond.option_list.map { |opt| QuestionOption.find(opt).text } return_string += "" unless return_string.empty? - return_string += "
" + _("Answering") + " " + return_string += "
#{_("Answering")} " return_string += opts.join(" and ") if cond.action_type == "add_webhook" subject_string = text_formatted(JSON.parse(cond.webhook_data)["subject"]) return_string += _(" will send an email with subject ") + subject_string else remove_data = cond.remove_data - rems = remove_data.map { |rem| '"' + Question.find(rem).text + '"' } + rems = remove_data.map { |rem| "\"#{Question.find(rem).text}\"" } return_string += _(" will remove question ") if rems.length == 1 return_string += _(" will remove questions ") if rems.length > 1 return_string += rems.join(" and ") end end - return_string + "
" + "#{return_string}" end # rubocop:enable Metrics/AbcSize @@ -227,7 +227,7 @@ def text_formatted(object) def conditions_to_param_form(conditions) param_conditions = {} conditions.each do |condition| - title = "condition" + condition[:number].to_s + title = "condition#{condition[:number]}" condition_hash = { title => { question_option_id: condition.option_list, action_type: condition.action_type, diff --git a/app/helpers/feedbacks_helper.rb b/app/helpers/feedbacks_helper.rb index 94e4857c78..e79a7194ce 100644 --- a/app/helpers/feedbacks_helper.rb +++ b/app/helpers/feedbacks_helper.rb @@ -3,15 +3,13 @@ module FeedbacksHelper def feedback_confirmation_default_subject - _("%{application_name}: Your plan has been submitted for feedback") + _("DMP feedback request") end def feedback_confirmation_default_message - _("

Hello %{user_name}.

"\ - "

Your plan \"%{plan_name}\" has been submitted for feedback from an - administrator at your organisation. "\ - "If you have questions pertaining to this action, please contact us - at %{organisation_email}.

") + _("

Dear %{user_name},

"\ + "

\"%{plan_name}\" has been sent to your %{application_name} account administrator for feedback.

"\ + "

Please email %{organisation_email} with any questions about this process.

") end def feedback_constant_to_text(text, user, plan, org) diff --git a/app/helpers/identifier_helper.rb b/app/helpers/identifier_helper.rb index 300b43a50e..8315b29bdd 100644 --- a/app/helpers/identifier_helper.rb +++ b/app/helpers/identifier_helper.rb @@ -4,12 +4,26 @@ module IdentifierHelper def id_for_display(id:, with_scheme_name: true) return _("None defined") if id.new_record? || id.value.blank? + # Sandbox DOIs do not resolve so swap in the direct URL to the Minting service + return sandbox_dmp_id(id: id) unless Rails.env.production? && + id.identifier_scheme == DmpIdService.identifier_scheme without = id.value_without_scheme_prefix - prefix = with_scheme_name ? id.identifier_scheme.description + ": " : "" + prefix = with_scheme_name ? "#{id.identifier_scheme.description}: " : "" return prefix + id.value unless without != id.value && !without.starts_with?("http") link_to "#{prefix} #{without}", id.value, class: "has-new-window-popup-info" end + def sandbox_dmp_id(id:, with_domain: false) + return _("None defined") if id.new_record? || id.value.blank? + + url = DmpIdService.landing_page_url + without = id.value_without_scheme_prefix + + return id.value unless url.present? && without != id.value && !without.starts_with?("http") + + link_to(with_domain ? id.value : without, "#{url}#{without}", class: "has-new-window-popup-info") + end + end diff --git a/app/helpers/super_admin/api_client_helper.rb b/app/helpers/super_admin/api_client_helper.rb new file mode 100644 index 0000000000..51661ac2d3 --- /dev/null +++ b/app/helpers/super_admin/api_client_helper.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module SuperAdmin + + module ApiClientHelper + + # Helper that gives human readable context to Doorkeeper OAuth scopes + + def label_for_scope(scope) + case scope + when "read_dmps" + _("Read and download PDF copies of plans") + when "edit_dmps" + _("Edit plans") + when "create_dmps" + _("Create plans") + when "public" + _("Retrieve a list of templates and plans") + else + scope.humanize + end + end + + def tooltip_for_scope(scope) + case scope + when "read_dmps" + _("Access to all publicly visible plans and, if associated with an org, the organisationally visible plans. They can also access a user's plans through OAuth autorization.") + when "edit_dmps" + _("Edit any plans created through the API and edit a user's plan after gaining OAuth authorization from the user") + when "create_dmps" + _("Create a plan (will be associated with the Org defined here if applicable) and create plans on behalf of a user once OAuth auuthorization has been granted") + else + "" + end + end + + # This one is used on the app/views/doorkeeper/authorizations/new.html.erb for user's authorizing + def user_label_for_scope(scope) + case scope + when "read_dmps" + _("Read and download your DMPs") + when "edit_dmps" + _("Edit your DMPs") + when "create_dmps" + _("Create DMPs on your behalf") + when "public" + _("Retrieve a list of your DMPs") + else + scope.humanize + end + end + end + +end diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 1c83822daa..4a972f72eb 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -26,6 +26,7 @@ import 'bootstrap-select'; import '../src/utils/accordion'; import '../src/utils/autoComplete'; import '../src/utils/externalLink'; +import '../src/utils/modalSearch'; import '../src/utils/outOfFocus'; import '../src/utils/paginable'; import '../src/utils/panelHeading'; @@ -38,6 +39,7 @@ import '../src/utils/tooltipHelper'; // the js.erb templates in the `window.x` statements below import { renderAlert, renderNotice } from '../src/utils/notificationHelper'; import toggleSpinner from '../src/utils/spinner'; +import { Tinymce } from '../src/utils/tinymce.js.erb'; // View specific JS import '../src/answers/conditions'; @@ -48,16 +50,26 @@ import '../src/devise/invitations/edit'; import '../src/devise/passwords/edit'; import '../src/devise/registrations/edit'; import '../src/devise/registrations/new'; +import '../src/doorkeeper/authorizations/new'; import '../src/guidances/newEdit'; import '../src/notes/index'; import '../src/orgs/adminEdit'; -import '../src/orgs/shibbolethDs'; +// ---------------------------------------- +// START DMPTool customization +// ---------------------------------------- +// import '../src/orgs/shibbolethDs'; +// ---------------------------------------- +// END DMPTool customization +// ---------------------------------------- import '../src/plans/download'; import '../src/plans/editDetails'; import '../src/plans/index.js.erb'; import '../src/plans/new'; +import '../src/plans/publish'; import '../src/plans/share'; import '../src/publicTemplates/show'; +import '../src/relatedIdentifiers/edit'; +import '../src/researchOutputs/form'; import '../src/roles/edit'; import '../src/shared/createAccountForm'; import '../src/shared/signInForm'; @@ -83,6 +95,16 @@ import '../src/superAdmin/notifications/edit'; import '../src/superAdmin/themes/newEdit'; import '../src/superAdmin/users/edit'; +// ---------------------------------------- +// START DMPTool customization +// ---------------------------------------- +import '../src/dmptool/home/index'; +import '../src/dmptool/shared/orgBranding'; +import '../src/dmptool/shared/signinCreateForm'; +// ---------------------------------------- +// END DMPTool customization +// ---------------------------------------- + // Since we're using Webpacker to manage JS we need to startup Rails' Unobtrusive JS // and Turbolinks. ActiveStorage and ActionCable would also need to be in here // if we decide to implement either before Rails 6 @@ -103,3 +125,4 @@ window.jQuery = jQuery; window.renderAlert = renderAlert; window.renderNotice = renderNotice; window.toggleSpinner = toggleSpinner; +window.Tinymce = Tinymce; diff --git a/app/javascript/src/dmptool/home/index.js b/app/javascript/src/dmptool/home/index.js new file mode 100644 index 0000000000..0d8dfec49b --- /dev/null +++ b/app/javascript/src/dmptool/home/index.js @@ -0,0 +1,30 @@ +/* eslint-env browser */ // This allows us to reference 'window' below + +import getConstant from '../../utils/constants'; + +$(() => { + // Rotate through the news items every 8 seconds + const articles = $('#home-news-array').val(); + if (articles) { + const news = JSON.parse(`${articles.replace(/\\"/g, '"').replace(/\\'/g, '\'')}`); + const updateNews = (item) => { + const text = $('#home-news-link'); + const span = `${getConstant('OPENS_IN_A_NEW_WINDOW_TEXT')}`; + text.hide(); + text.html(`${news[item].title} ${span}`); + text.fadeIn(100); + }; + const startNewsTimer = (item) => { + setTimeout(() => { + updateNews(item); + startNewsTimer((item >= news.length - 1) ? 0 : item + 1); + }, 8000); + }; + updateNews(0); + startNewsTimer(1); + } + + $('#get-started-options a').click((e) => { + $(e.target).closest('.modal').modal('hide'); + }); +}); diff --git a/app/javascript/src/dmptool/shared/orgBranding.js b/app/javascript/src/dmptool/shared/orgBranding.js new file mode 100644 index 0000000000..35929c21f3 --- /dev/null +++ b/app/javascript/src/dmptool/shared/orgBranding.js @@ -0,0 +1,22 @@ +// This is the branded Org sign in/create account page +$(() => { + const orgControls = $('#create-account-org-controls'); + + // We already know what org to use, so hide the selector and pre-populate + // the field with the org id the user selected in the prior page + if (orgControls.length > 0) { + const orgId = orgControls.find('#new_user_org_id'); + + if (orgId.length > 0) { + const id = $('#default_org_id'); + const name = $('#default_org_name'); + + if (id.length > 0 && name.length > 0) { + if (id.val().length > 0 && name.val().length > 0) { + orgId.val(JSON.stringify({ id: id.val(), name: name.val() })); + orgControls.hide(); + } + } + } + } +}); diff --git a/app/javascript/src/dmptool/shared/signinCreateForm.js b/app/javascript/src/dmptool/shared/signinCreateForm.js new file mode 100644 index 0000000000..8226f83cf9 --- /dev/null +++ b/app/javascript/src/dmptool/shared/signinCreateForm.js @@ -0,0 +1,149 @@ +/* eslint-env browser */ // This allows us to reference 'window' below +import * as Cookies from 'js-cookie'; +import { initAutocomplete } from '../../utils/autoComplete'; +import { isObject, isString } from '../../utils/isType'; +import getConstant from '../../utils/constants'; + +$(() => { + initAutocomplete('#create-account-org-controls .autocomplete'); + initAutocomplete('#shib-ds-org-controls .autocomplete'); + const email = Cookies.get('dmproadmap_email'); + + // Signin remember me + // ----------------------------------------------------- + // If the user's email was stored in the browser's cookies the pre-populate the field + if (email && email !== '') { + $('#signin_create_form #remember_email').attr('checked', 'checked'); + $('#signin_create_form #user_email').val(email); + } + + // When the user checks the 'remember email' box store the value in the browser storage + $('#signin_create_form #remember_email').click((e) => { + if ($(e.currentTarget).is(':checked')) { + Cookies.set('dmproadmap_email', $('#signin_create_form #user_email').val(), { expires: 14 }); + } else { + Cookies.remove('dmproadmap_email'); + } + }); + + // If the email is changed and the user has asked to remember it update the browser storage + $('#signin_create_form #user_email').change((e) => { + if ($('#signin_create_form #remember_email').is(':checked')) { + Cookies.set('dmproadmap_email', $(e.currentTarget).val(), { expires: 14 }); + } + }); + + // Signin / Create Account form toggle + // ----------------------------------------------------- + // handle toggling between shared signin/create account forms + const toggleSignInCreateAccount = (signin = true) => { + const signinTab = $('a[href="#sign-in-panel"]').closest('li'); + const createTab = $('a[href="#create-account-panel"]').closest('li'); + const signinPanel = $('#sign-in-panel'); + const createAccountPanel = $('#create-account-panel'); + + if (signin) { + signinTab.addClass('active'); + signinPanel.addClass('active'); + + createTab.removeClass('active'); + createAccountPanel.removeClass('active'); + } else { + signinTab.removeClass('active'); + signinPanel.removeClass('active'); + + createTab.addClass('active'); + createAccountPanel.addClass('active'); + } + }; + + const clearLogo = () => { + $('#org-sign-in-logo').html(''); + $('#user_org_id').val(''); + }; + + $('#show-create-account-via-shib-ds, #show-create-account-form').click(() => { + clearLogo(); + toggleSignInCreateAccount(false); + }); + $('#show-sign-in-form').click(() => { + clearLogo(); + toggleSignInCreateAccount(true); + }); + + // Shibboleth DS + // ----------------------------------------------------- + const logoSuccess = (data) => { + // Render the html in the org-sign-in modal + if (isObject(data) && isObject(data.org) && isString(data.org.html)) { + $('#org-sign-in-logo').html(data.org.html); + $('#signin_user_org_id').val(data.org.id); + $('#new_user_org_id').val(data.org.id); + toggleSignInCreateAccount(true); + $('#sign-in-create-account').modal('show'); + } + }; + const logoError = () => { + // There was an ajax error so just route the user to the sign-in modal + // and let them sign in as a Non-Partner Institution + $('#access-control-tabs a[data-target="#sign-in-form"]').tab('show'); + }; + + // Toggles the full Org list on/off + + $('#show_list').click((e) => { + e.preventDefault(); + const target = $('#full_list'); + if (target.is('.hidden')) { + target.removeClass('hidden').attr('aria-hidden', 'false'); + $(e.currentTarget).html(getConstant('SHIBBOLETH_DISCOVERY_SERVICE_HIDE_LIST')); + } else { + target.addClass('hidden').attr('aria-hidden', 'true'); + $(e.currentTarget).html(getConstant('SHIBBOLETH_DISCOVERY_SERVICE_SHOW_LIST')); + } + }); + + // Only enable the Institutional Signin 'Go' Button if the user selected a + // value from the list + $('#shib-ds-org-controls').on('change', '#org_id', (e) => { + const id = $(e.target); + const json = JSON.parse(id.val()); + const button = $('#org-select-go'); + clearLogo(); + if (json !== undefined) { + if (json.id !== undefined) { + button.prop('disabled', false); + } else { + button.prop('disable', true); + } + } + }).on('ajax:success', (data) => { + logoSuccess(data); + }).on('ajax:error', () => { + logoError(); + }); + + // When the user selects an Org from the autocomplete and clicks 'Go' + // Update the form's target with the selected org id before submission + $('#org-select-go').on('click', (e) => { + const json = JSON.parse($('#shib-ds-org-controls #org_id').val()); + if (json !== undefined && json.id !== undefined) { + const go = $(e.target); + const form = go.closest('form'); + form.attr('action', `${form.attr('action')}/${json.id}`); + } else { + e.preventDefault(); + } + }); + + // Hide the vanilla Roadmap 'Sign in with your institutional credentials' button + $('#sign_in_form h4').addClass('hide'); + $('#sign_in_form a[href="/orgs/shibboleth"]').addClass('hide'); + + // Get Started button click + // ----------------------------------------------------- + $('#get-started').click((e) => { + e.preventDefault(); + $('#header-signin').dropdown('toggle'); + }); +}); diff --git a/app/javascript/src/doorkeeper/authorizations/new.js b/app/javascript/src/doorkeeper/authorizations/new.js new file mode 100644 index 0000000000..ebfc8b1cee --- /dev/null +++ b/app/javascript/src/doorkeeper/authorizations/new.js @@ -0,0 +1,5 @@ +import { initAutocomplete } from '../../utils/autoComplete'; + +$(() => { + initAutocomplete('#shib-ds-org-controls .autocomplete'); +}); diff --git a/app/javascript/src/orgs/adminEdit.js b/app/javascript/src/orgs/adminEdit.js index c393c39c27..dae022deb4 100644 --- a/app/javascript/src/orgs/adminEdit.js +++ b/app/javascript/src/orgs/adminEdit.js @@ -59,4 +59,17 @@ $(() => { initAutocomplete('#org-merge-controls .autocomplete'); scrubOrgSelectionParamsOnSubmit('form.edit_org'); + + Tinymce.init({ selector: '#org_api_create_plan_email_body' }); + + // JS to update the email preview as the user edits the email body field + const emailBodyControl = Tinymce.findEditorById('org_api_create_plan_email_body'); + const emailPreview = $('.replaceable-api-email-content'); + + // Add handlers to the TinyMCE editor so that changes update the preview section + if (emailBodyControl && emailPreview) { + emailBodyControl.on('keyup', (e) => { + emailPreview.html($(e.target).html()); + }); + } }); diff --git a/app/javascript/src/orgs/shibbolethDs.js b/app/javascript/src/orgs/shibbolethDs.js index 02ccc4566d..20d7679df1 100644 --- a/app/javascript/src/orgs/shibbolethDs.js +++ b/app/javascript/src/orgs/shibbolethDs.js @@ -1,4 +1,5 @@ import getConstant from '../utils/constants'; +import { initAutocomplete, scrubOrgSelectionParamsOnSubmit } from '../utils/autoComplete'; $(() => { $('#show_list').click((e) => { @@ -11,4 +12,11 @@ $(() => { $(e.currentTarget).html(getConstant('SHIBBOLETH_DISCOVERY_SERVICE_SHOW_LIST')); } }); + + if ($('#shibboleth-ds-org-controls').length > 0) { + initAutocomplete('#shibboleth-ds-org-controls .autocomplete'); + // Scrub out the large arrays of data used for the Org Selector JS so that they + // are not a part of the form submissiomn + scrubOrgSelectionParamsOnSubmit('#shibboleth_ds'); + } }); diff --git a/app/javascript/src/plans/editDetails.js b/app/javascript/src/plans/editDetails.js index f32b817240..f973a002f5 100644 --- a/app/javascript/src/plans/editDetails.js +++ b/app/javascript/src/plans/editDetails.js @@ -2,6 +2,7 @@ import { initAutocomplete, scrubOrgSelectionParamsOnSubmit } from '../utils/auto import { Tinymce } from '../utils/tinymce.js.erb'; import toggleConditionalFields from '../utils/conditionalFields'; import getConstant from '../utils/constants'; +import toggleSpinner from '../utils/spinner'; $(() => { const grantIdField = $('.grant-id-typeahead'); @@ -150,5 +151,9 @@ $(() => { toggleCheckboxes($('#priority-guidance-orgs input[type="checkbox"]:checked').map((i, el) => $(el).val()).get()); setUpTypeahead(); + + form.on('submit', () => { + toggleSpinner(true); + }); } }); diff --git a/app/javascript/src/plans/publish.js b/app/javascript/src/plans/publish.js new file mode 100644 index 0000000000..73fb2e1734 --- /dev/null +++ b/app/javascript/src/plans/publish.js @@ -0,0 +1,12 @@ +import getConstant from '../utils/constants'; + +$(() => { + // Clear out the existing message/response when the user clicks the 'Register' a DMP ID button + $('body').on('click', 'input.mint-dmp-id', () => { + const mintMessage = $('.dmp-id-minter-response'); + + if (mintMessage.length > 0) { + mintMessage.html(getConstant('ACQUIRING_DMP_ID')); + } + }); +}); diff --git a/app/javascript/src/plans/share.js b/app/javascript/src/plans/share.js index e230cd5972..10b0e15127 100644 --- a/app/javascript/src/plans/share.js +++ b/app/javascript/src/plans/share.js @@ -1,22 +1,7 @@ import * as notifier from '../utils/notificationHelper'; -import { isObject, isString } from '../utils/isType'; +import { isObject } from '../utils/isType'; $(() => { - $('#set_visibility').on('ajax:success', (e) => { - const data = e.detail[0]; - if (isObject(data) && isString(data.msg)) { - notifier.renderNotice(data.msg); - } - }); - $('#set_visibility').on('ajax:error', (e) => { - const xhr = e.detail[2]; - if (isObject(xhr.responseJSON)) { - notifier.renderAlert(xhr.responseJSON.msg); - } else { - notifier.renderAlert(`${xhr.statusCode} - ${xhr.statusText}`); - } - }); - $('.toggle-existing-user-access') .on('ajax:success', (e) => { const data = e.detail[0]; diff --git a/app/javascript/src/relatedIdentifiers/edit.js b/app/javascript/src/relatedIdentifiers/edit.js new file mode 100644 index 0000000000..1273b4ef24 --- /dev/null +++ b/app/javascript/src/relatedIdentifiers/edit.js @@ -0,0 +1,65 @@ +// JS to handle the '+ Add a related work' link +$(() => { + const relatedIdentifierBlock = $('.related-works'); + + if (relatedIdentifierBlock.length > 0) { + const addRowLink = relatedIdentifierBlock.siblings('.add-related-work'); + + // Replace the unique record identifier on the :id and :for attributes + const replaceId = (element, id) => { + const regExp = /_[0-9]+_/; + if (element.attr('for')) { + element.attr('for', element.attr('for').replace(regExp, `_${id}_`)); + } else { + element.attr('id', element.attr('id').replace(regExp, `_${id}_`)); + } + }; + + // Replace the unique record identifier on the :name attributes + const replaceName = (element, id) => { + const regExp = /\[[0-9]\]\[/; + if (element.attr('name')) { + element.attr('name', element.attr('name').replace(regExp, `[${id}][`)); + } + }; + + // Replace the unique record identifier for each label and input/select + const replaceIdsAndNames = (row, id) => { + row.find('label').each((_idx, label) => { + replaceId($(label), id); + }); + row.find('input, select').each((_idx, field) => { + replaceId($(field), id); + replaceName($(field), id); + }); + }; + + const addNewRow = () => { + // Find the hidden empty row which will be used to clone the new row + const emptyRow = relatedIdentifierBlock.find('.related-work-row.hidden'); + if (emptyRow.length > 0) { + const newRow = emptyRow.clone(); + + // Set the the new row's id + replaceIdsAndNames(newRow, new Date().getTime()); + + newRow.removeClass('hidden'); + relatedIdentifierBlock.append(newRow[0].outerHTML); + } + }; + + // Add a new row if the user clicks the '+ add a related work' link + if (addRowLink.length > 0) { + addRowLink.on('click', (e) => { + e.preventDefault(); + addNewRow(); + }); + } + + // Remove the entire row if the user clicks the 'X' delete link + relatedIdentifierBlock.on('click', '.remove-related-work', (e) => { + e.preventDefault(); + $(e.target).closest('.citation').remove(); + }); + } +}); diff --git a/app/javascript/src/researchOutputs/form.js b/app/javascript/src/researchOutputs/form.js new file mode 100644 index 0000000000..b454b9eb3d --- /dev/null +++ b/app/javascript/src/researchOutputs/form.js @@ -0,0 +1,45 @@ +import getConstant from '../utils/constants'; +import { isUndefined, isObject } from '../utils/isType'; +import { Tinymce } from '../utils/tinymce.js.erb'; + +$(() => { + const form = $('.research_output_form'); + + if (!isUndefined(form) && isObject(form)) { + Tinymce.init({ selector: '#research_output_description' }); + } + + // Expands/Collapses the search results 'More info'/'Less info' section + $('body').on('click', '.modal-search-result .more-info a.more-info-link', (e) => { + e.preventDefault(); + const link = $(e.target); + + if (link.length > 0) { + const info = $(link).siblings('div.info'); + + if (info.length > 0) { + if (info.hasClass('hidden')) { + info.removeClass('hidden'); + link.text(`${getConstant('LESS_INFO')}`); + } else { + info.addClass('hidden'); + link.text(`${getConstant('MORE_INFO')}`); + } + } + } + }); + + // Put the facet text into the modal search window's search box when the user + // clicks on one + $('body').on('click', '.modal-search-result a.facet', (e) => { + const link = $(e.target); + + if (link.length > 0) { + const textField = link.closest('.modal-body').find('input.autocomplete'); + + if (textField.length > 0) { + textField.val(link.text()); + } + } + }); +}); diff --git a/app/javascript/src/superAdmin/apiClients/form.js b/app/javascript/src/superAdmin/apiClients/form.js index d9b50d2171..fbfe05d159 100644 --- a/app/javascript/src/superAdmin/apiClients/form.js +++ b/app/javascript/src/superAdmin/apiClients/form.js @@ -1,7 +1,36 @@ -import { initAutocomplete } from '../../utils/autoComplete'; +import { initAutocomplete, scrubOrgSelectionParamsOnSubmit } from '../../utils/autoComplete'; $(() => { if ($('#api-client-org-controls').length > 0) { initAutocomplete('#api-client-org-controls .autocomplete'); + scrubOrgSelectionParamsOnSubmit('form.api_client'); + scrubOrgSelectionParamsOnSubmit('#new_api_client'); + + // Toggle the visibility of the Scopes sections based on the status of the 'Trusted' checkbox + const toggleScopesBlocks = (context) => { + const scopesBlocks = $('.oauth-scopes'); + + if (scopesBlocks.length > 0) { + scopesBlocks.each((_idx, el) => { + // If the API Client is 'trusted' then hide the Scopes and check them all + if (context.prop('checked')) { + $(el).addClass('hidden'); + $(el).find('input[type="checkbox"]').prop('checked', true); + } else { + $(el).removeClass('hidden'); + } + }); + } + }; + + // If the 'trusted' checkbox is checked then hide the scopes blocks and auto-check all scopes + const trusted = $('#api_client_trusted'); + if (trusted.length > 0) { + toggleScopesBlocks(trusted); + + trusted.on('click', (e) => { + toggleScopesBlocks($(e.target)); + }); + } } }); diff --git a/app/javascript/src/utils/modalSearch.js b/app/javascript/src/utils/modalSearch.js new file mode 100644 index 0000000000..2bc8bf1888 --- /dev/null +++ b/app/javascript/src/utils/modalSearch.js @@ -0,0 +1,39 @@ +$(() => { + // Add the selected item to the selections section + $('body').on('click', 'a.modal-search-result-selector', (e) => { + e.preventDefault(); + const link = $(e.target); + + if (link.length > 0) { + const selectedBlock = $(e.target).closest('.modal-search-result'); + const resultsBlock = $(e.target).closest('.modal-search-results'); + + if (resultsBlock.length > 0 && selectedBlock.length > 0) { + const selectionsBlockId = resultsBlock.attr('id').replace('-results', '-selections'); + + if (selectionsBlockId !== undefined) { + const selectionsBlock = $(`#${selectionsBlockId}`); + + if (selectionsBlock.length > 0) { + const clone = selectedBlock.clone(); + clone.find('.modal-search-result-selector').addClass('hidden'); + clone.find('.modal-search-result-unselector').removeClass('hidden'); + clone.find('.tags').remove(); + selectionsBlock.append(clone); + selectedBlock.remove(); + } + } + } + } + }); + + // Remove the selected item + $('body').on('click', 'a.modal-search-result-unselector', (e) => { + e.preventDefault(); + const selection = $(e.target).closest('.modal-search-result'); + + if (selection.length > 0) { + selection.remove(); + } + }); +}); diff --git a/app/javascript/src/utils/panelHeading.js b/app/javascript/src/utils/panelHeading.js index 534cad03c7..ae7ae93ca6 100644 --- a/app/javascript/src/utils/panelHeading.js +++ b/app/javascript/src/utils/panelHeading.js @@ -1,5 +1,5 @@ $(() => { - $('body').on('click', '.heading-button', (e) => { + $('body').on('click', '.heading-button, .panel-title', (e) => { $(e.currentTarget) .find('i.fa-plus, i.fa-minus') .toggleClass('fa-plus') diff --git a/app/javascript/src/utils/passwordHelper.js b/app/javascript/src/utils/passwordHelper.js index 6285937a9b..94d85096fb 100644 --- a/app/javascript/src/utils/passwordHelper.js +++ b/app/javascript/src/utils/passwordHelper.js @@ -62,7 +62,6 @@ export const togglisePasswords = (options) => { if (isObject(options) && isString(options.selector)) { const toggle = $(`${options.selector} .passwords_toggle`); const pwds = $(`${options.selector} input[type="password"]`); - if (pwds && toggle) { toggle.on('change', () => { if (isArray(pwds)) { diff --git a/app/javascript/src/utils/tinymce.js.erb b/app/javascript/src/utils/tinymce.js.erb index ce1ba13742..ead275a956 100644 --- a/app/javascript/src/utils/tinymce.js.erb +++ b/app/javascript/src/utils/tinymce.js.erb @@ -29,7 +29,7 @@ export const defaultOptions = { target_list: false, elementpath: false, resize: true, - min_height: 230, + autoresize_min_height: 130, autoresize_bottom_margin: 10, branding: false, extended_valid_elements: 'iframe[tooltip] , a[href|target=_blank]', @@ -43,6 +43,7 @@ export const defaultOptions = { table_default_attributes: { border: 1, }, + // skin: false, // editorManager.baseURL is not resolved properly for IE since document.currentScript // is not supported, see issue https://github.com/tinymce/tinymce/issues/358 skin_url: '/tinymce/skins/lightgray', diff --git a/app/jobs/notify_subscriber_job.rb b/app/jobs/notify_subscriber_job.rb new file mode 100644 index 0000000000..23194738f5 --- /dev/null +++ b/app/jobs/notify_subscriber_job.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# This Job sends a notification (the JSON version of the Plan) out to the specified +# subscriber. +class NotifySubscriberJob < ApplicationJob + + queue_as :default + + def perform(subscription) + # TODO: We're currently only 'subscribing' the DMP ID service to plans. + # We can build out the rest of this if we add other subscriber types + # e.g. allowing an api_client associated with an Org's internal + # data curation or research project management systems + case subscription.subscriber_type + when "ApiClient" + notify_api_client(subscription: subscription) + else + # Maybe just use HTTParty for this if we ever want to subscribe a different model + # like a User or Org + true + end + + subscription.update(last_notified: Time.now) + rescue StandardError => e + # Something went terribly wrong, so note it in the logs since this runs outside the + # regular Rails thread that the application is using + Rails.logger.error "NotifySubscriberJob.perform failed for \ + Subscription: #{subscription.inspect}" + Rails.logger.error "NotifySubscriberJob.perform - #{e.message}" + Rails.logger.error e.backtrace + end + + private + + def notify_api_client(subscription:) + return false unless subscription.present? && subscription.subscriber.present? + + api_client = subscription.subscriber + dmp_id_svc = api_client.name.downcase == DmpIdService.identifier_scheme&.name&.downcase + + # If the ApiClient is the DMP ID service then update the DMP ID metadata + if DmpIdService.minting_service_defined? && dmp_id_svc + Rails.logger.info "Sending #{api_client.name} the updated DMP ID metadata \ + for Plan #{subscription.plan.id}" + + DmpIdService.update_dmp_id(plan: subscription.plan) + + elsif !dmp_id_svc + # As long as this isn't the DMP ID service, send the update directly to the callback + # Maybe just use HTTParty for this + true + end + end + +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 86a52ad1dc..032796e436 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -8,7 +8,7 @@ class UserMailer < ActionMailer::Base helper MailerHelper helper FeedbacksHelper - default from: Rails.configuration.x.organisation.email + default from: Rails.configuration.x.organisation.do_not_reply_email || Rails.configuration.x.organisation.email def welcome_notification(user) @user = user @@ -108,7 +108,7 @@ def feedback_notification(recipient, plan, requestor) I18n.with_locale I18n.default_locale do mail(to: @recipient.email, - subject: _("%{user_name} has requested feedback on a %{tool_name} plan") % + subject: _("A new DMP is awaiting your feedback") % { tool_name: tool_name, user_name: @user.name(false) }) @@ -205,11 +205,12 @@ def admin_privileges(user) end end + # Sent out to the API contact when the Super Admin creates a record or refreshes the secret def api_credentials(api_client) @api_client = api_client return unless @api_client.contact_email.present? - @api_docs = Rails.configuration.x.application.api_documentation_urls[:v1] + @api_docs = Rails.configuration.x.application.api_documentation_overview_url @name = @api_client.contact_name.present? ? @api_client.contact_name : @api_client.contact_email @@ -222,4 +223,69 @@ def api_credentials(api_client) end end + # Sent out to admins when a user self registers for the API via the Developer Tools' tab on Profile page + def new_api_client(api_client) + @api_client = api_client + + @name = @api_client.contact_name.present? ? @api_client.contact_name : @api_client.contact_email + @name = @api_client.user.name(false) unless @name.present? + @email = @api_client.contact_email || @api_client.user.email + + I18n.with_locale I18n.default_locale do + mail(to: Rails.configuration.x.application.admin_emails, + subject: _("%{tool_name} new API registration") % { tool_name: tool_name}) + end + end + + # Sends the error message out to the administrators + def notify_administrators(message) + administrators = Rails.configuration.x.application.admin_emails + return false unless administrators.present? + + @message = message + + I18n.with_locale I18n.default_locale do + mail(to: administrators, + subject: _("%{tool_name} error occurred") % { tool_name: tool_name }) + end + end + + # Sends an email to the Plan's owner letting them know that the Plan was created by the ApiClient + def new_plan_via_api(recipient:, plan:, api_client:) + return false unless recipient.is_a?(User) && plan.is_a?(Plan) && api_client.is_a?(ApiClient) + + default_subject = _("A new data management plan (DMP) has been started for you by %{external_system_name}") % { + external_system_name: api_client.description + } + subject = plan.template&.org&.api_create_plan_email_subject || default_subject + + @message = plan.template&.org&.api_create_plan_email_body + @api_client = api_client + @user = recipient + @plan = plan + I18n.with_locale I18n.default_locale do + mail( + to: Rails.env.production? ? recipient.email : api_client.contact_email, + cc: plan.template.org&.contact_email, + subject: subject + ) + end + end + + # Sends an email to the recipient notifying them of the new Plan created for them by + # the sender + def new_plan_via_template(recipient:, sender:, plan:) + return false unless recipient.is_a?(User) && sender.is_a?(User) && plan.is_a?(Plan) + + subject = plan.template.email_subject + + @message = plan.template.email_body + @user = recipient + @plan = plan + @sender = sender + I18n.with_locale I18n.default_locale do + mail(to: recipient.email, cc: sender.email, subject: subject) + end + end + end diff --git a/app/models/annotation.rb b/app/models/annotation.rb index 3552a246f5..644ea4c65a 100644 --- a/app/models/annotation.rb +++ b/app/models/annotation.rb @@ -5,8 +5,8 @@ # Table name: annotations # # id :integer not null, primary key -# text :text -# type :integer default(0), not null +# text :text(65535) +# type :integer default("example_answer"), not null # created_at :datetime # updated_at :datetime # org_id :integer @@ -19,11 +19,6 @@ # index_annotations_on_question_id (question_id) # index_annotations_on_versionable_id (versionable_id) # -# Foreign Keys -# -# fk_rails_... (org_id => orgs.id) -# fk_rails_... (question_id => questions.id) -# class Annotation < ApplicationRecord diff --git a/app/models/answer.rb b/app/models/answer.rb index c994391b04..eb9ccfac61 100644 --- a/app/models/answer.rb +++ b/app/models/answer.rb @@ -6,7 +6,7 @@ # # id :integer not null, primary key # lock_version :integer default(0) -# text :text +# text :text(65535) # created_at :datetime # updated_at :datetime # plan_id :integer diff --git a/app/models/api_client.rb b/app/models/api_client.rb index ad521b497c..3ae79dfa54 100644 --- a/app/models/api_client.rb +++ b/app/models/api_client.rb @@ -2,46 +2,72 @@ # == Schema Information # -# Table name: api_clients +# Table name: oauth_applications # -# id :integer not null, primary key -# name :string, not null -# homepage :string -# contact_name :string -# contact_email :string, not null -# client_id :string, not null -# client_secret :string, not null -# last_access :datetime -# created_at :datetime -# updated_at :datetime -# org_id :integer +# id :integer not null, primary key +# callback_method :integer default(0) +# callback_uri :string(255) +# confidential :boolean default(TRUE) +# contact_email :string(255) +# contact_name :string(255) +# description :string(255) +# homepage :string(255) +# last_access :datetime +# logo_name :string(255) +# logo_uid :string(255) +# name :string(255) not null +# redirect_uri :text(65535) +# scopes :string(255) default(""), not null +# secret :string(255) default(""), not null +# trusted :boolean default(FALSE), not null +# uid :string(255) default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# org_id :integer +# user_id :bigint(8) # # Indexes # -# index_api_clients_on_name (name) +# index_oauth_applications_on_name (name) +# index_oauth_applications_on_uid (uid) UNIQUE +# index_oauth_applications_on_user_id (user_id) # -# Foreign Keys -# -# fk_rails_... (org_id => orgs.id) class ApiClient < ApplicationRecord + self.table_name = "oauth_applications" + include DeviseInvitable::Inviter + include Subscribable + include ::Doorkeeper::Orm::ActiveRecord::Mixins::Application + include ::Doorkeeper::Models::Scopes + extend Dragonfly::Model::Validations extend UniqueRandom + # Allows an ApiClient to invite a new user via the 'create_dmps' scope + devise :invitable + + enum callback_methods: %i[put post patch] + + LOGO_FORMATS = %w[jpeg png gif jpg bmp svg].freeze + + dragonfly_accessor :logo + # ================ # = Associations = # ================ belongs_to :org, optional: true - has_many :plans + # TODO: Make this required once we've transitioned away from the old :contact_name + :contact_email + belongs_to :user, optional: true - # If the Client_id or client_secret are nil generate them - attribute :client_id, :string, default: -> { unique_random(field_name: "client_id") } - attribute :client_secret, :string, - default: -> { unique_random(field_name: "client_secret") } + # Access Tokens are created when an ApiClient authenticates themselves and is then used instead + # of credentials when calling the API. + has_many :access_tokens, class_name: "::Doorkeeper::AccessToken", + foreign_key: :application_id, + dependent: :delete_all # =============== # = Validations = @@ -54,8 +80,26 @@ class ApiClient < ApplicationRecord validates :contact_email, presence: { message: PRESENCE_MESSAGE }, email: { allow_nil: false } - validates :client_id, presence: { message: PRESENCE_MESSAGE } - validates :client_secret, presence: { message: PRESENCE_MESSAGE } + validates_property :format, of: :logo, in: LOGO_FORMATS, + message: _("must be one of the following formats: %{formats}") % { + formats: LOGO_FORMATS.join(", ") + } + + validates_size_of :logo, maximum: 500.kilobytes, message: _("can't be larger than 500KB") + + # ============= + # = Callbacks = + # ============= + + before_validation :ensure_scopes + + # ================= + # = Compatibility = + # ================= + + # These aliases provide backward compatibility for API V1 + alias_attribute :client_id, :uid + alias_attribute :client_secret, :secret # ========================= # = Custom Accessor Logic = @@ -76,15 +120,26 @@ def to_s name end - # Verify that the incoming secret matches - def authenticate(secret:) - client_secret == secret + # Returns the scopes defined in the Doorkeeper config + def available_scopes + (default_scopes << Doorkeeper.config.optional_scopes.to_a).flatten.uniq + end + + # Shortcut to fetch all of the plans the client subscribes to + def plans + subscriptions.map(&:plan) + end + + # Returns the default scopes as defined in the Doorkeeper config + def default_scopes + Doorkeeper.config.default_scopes.to_a end - # Generate UUIDs for the client_id and client_secret - def generate_credentials - self.client_id = ApiClient.unique_random(field_name: "client_id") - self.client_secret = ApiClient.unique_random(field_name: "client_secret") + private + + # Set the scopes + def ensure_scopes + self.scopes = default_scopes.sort { |a, b| a <=> b }.join(" ") unless scopes.present? end end diff --git a/app/models/api_log.rb b/app/models/api_log.rb new file mode 100644 index 0000000000..1141d7ef0c --- /dev/null +++ b/app/models/api_log.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: api_logs +# +# id :integer not null, primary key +# oauth_application_id :integer not null +# change_type :integer not null +# activity :text not null +# logable_id :integer not null +# logable_type :string not null +# +# Indexes +# +# index_api_logs_on_api_client_id (api_client_id) +# index_api_logs_on_change_type (change_type) +# index_api_logs_on_logable_and_change_type (logable_id, logable_type, change_type) +# +class ApiLog < ApplicationRecord + enum change_type: %i[added removed modified] + + # ================ + # = Associations = + # ================ + + belongs_to :logable, polymorphic: true + + belongs_to :api_client + + # =============== + # = Validations = + # =============== + + validates :activity, presence: { message: PRESENCE_MESSAGE } + + validates :change_type, presence: { message: PRESENCE_MESSAGE } + + validates :logable, presence: { message: PRESENCE_MESSAGE } + + validates :api_client, presence: { message: PRESENCE_MESSAGE } +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 0059011d4d..fb6fbc9b8f 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -8,6 +8,33 @@ class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + class << self + + # Indicates whether the underlying DB is MySQL + def mysql_db? + ActiveRecord::Base.connection.adapter_name == "Mysql2" + end + + def postgres_db? + ActiveRecord::Base.connection.adapter_name == "PostgreSQL" + end + + # Generates the appropriate where clause for a JSON field based on the DB type + def safe_json_where_clause(column:, hash_key:) + return "(#{column}->>'#{hash_key}' LIKE ?)" unless mysql_db? + + "(#{column}->>'$.#{hash_key}' LIKE ?)" + end + + # Generates the appropriate where clause for a regular expression based on the DB type + def safe_regexp_where_clause(column:) + return "#{column} ~* ?" unless mysql_db? + + "#{column} REGEXP ?" + end + + end + def sanitize_fields(*attrs) attrs.each do |attr| send("#{attr}=", ActionController::Base.helpers.sanitize(send(attr))) diff --git a/app/models/concerns/acts_as_sortable.rb b/app/models/concerns/acts_as_sortable.rb index 637e50f6df..9e9c47e95d 100644 --- a/app/models/concerns/acts_as_sortable.rb +++ b/app/models/concerns/acts_as_sortable.rb @@ -11,12 +11,10 @@ def update_numbers!(ids, parent:) ids = ids.map(&:to_i) & parent.public_send("#{model_name.singular}_ids") return if ids.empty? - case connection.adapter_name - when "PostgreSQL" then update_numbers_postgresql!(ids) - when "Mysql2" then update_numbers_mysql2!(ids) - else - update_numbers_sequentially!(ids) - end + update_numbers_postgresql!(ids) if ApplicationRecord.postgres_db? + update_numbers_mysql2!(ids) if ApplicationRecord.mysql_db? + update_numbers_sequentially!(ids) unless ApplicationRecord.postgres_db? || + ApplicationRecord.mysql_db? end private diff --git a/app/models/concerns/dmptool_org.rb b/app/models/concerns/dmptool_org.rb new file mode 100644 index 0000000000..2ee658f0f0 --- /dev/null +++ b/app/models/concerns/dmptool_org.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module DmptoolOrg + + extend ActiveSupport::Concern + + included do + # DMPTool participating institution helpers + def self.participating + includes(identifiers: :identifier_scheme).where(managed: true) + end + + def shibbolized? + managed? && identifier_for_scheme(scheme: "shibboleth").present? + end + end + +end diff --git a/app/models/concerns/exportable_plan.rb b/app/models/concerns/exportable_plan.rb index 5d8d66a1b8..6a90876030 100644 --- a/app/models/concerns/exportable_plan.rb +++ b/app/models/concerns/exportable_plan.rb @@ -95,15 +95,10 @@ def prepare(user, coversheet = false) # rubocop:disable Metrics/AbcSize def prepare_coversheet hash = {} - # name of owner and any co-owners - attribution = owner.present? ? [owner.name(false)] : [] - roles.administrator.not_creator.each do |role| - attribution << role.user.name(false) - end hash[:attribution] = attribution # Org name of plan owner's org - hash[:affiliation] = owner.present? ? owner.org.name : "" + hash[:affiliation] = (owner.present? && owner.org.present?) ? owner.org.name : "" # set the funder name hash[:funder] = funder.name if funder.present? @@ -127,11 +122,7 @@ def prepare_coversheet # rubocop:disable Metrics/MethodLength, Metrics/AbcSize def prepare_coversheet_for_csv(csv, _headings, hash) - csv << [if hash[:attribution].many? - _("Creators: ") - else - _("Creator:") - end, _("%{authors}") % { authors: hash[:attribution].join(", ") }] + csv << [_("Creator:"), _("%{authors}") % { authors: hash[:attribution].join(", ") }] csv << ["Affiliation: ", _("%{affiliation}") % { affiliation: hash[:affiliation] }] csv << if hash[:funder].present? [_("Template: "), _("%{funder}") % { funder: hash[:funder] }] @@ -223,5 +214,23 @@ def sanitize_text(text) ActionView::Base.full_sanitizer.sanitize(text.to_s.gsub(/ /i, "")) end + # Use the name of the DMP owner/creator OR the first Co-owner if there is no + # owner for some reason + def attribution + user = roles.creator.first&.user + user = roles.administrator.not_creator.first&.user unless user.present? + return "" unless user.present? + + text = user&.name(false) + orcid = user.identifier_for_scheme(scheme: "orcid") + if orcid.present? + text += " - ORCID: %{orcid}" % { + orcid_url: orcid.value, + orcid: orcid.value_without_scheme_prefix + } + end + text + end + end # rubocop:enable Metrics/ModuleLength diff --git a/app/models/concerns/identifiable.rb b/app/models/concerns/identifiable.rb index 3aea3fc227..aa7305bd60 100644 --- a/app/models/concerns/identifiable.rb +++ b/app/models/concerns/identifiable.rb @@ -6,6 +6,7 @@ module Identifiable # rubocop:disable Metrics/BlockLength included do + # ================ # = Associations = # ================ diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb new file mode 100644 index 0000000000..64a593c771 --- /dev/null +++ b/app/models/concerns/subscribable.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Subscribable + + extend ActiveSupport::Concern + + included do + # ================ + # = Associations = + # ================ + + has_many :subscriptions, as: :subscriber, dependent: :destroy + + # ===================== + # = Nested Attributes = + # ===================== + + accepts_nested_attributes_for :subscriptions + + # ==================== + # = Instance Methods = + # ==================== + + # Returns the Subscription for the specified subscriber or nil if none exists + def subscriptions_for(plan:) + plan = plan.is_a?(Plan) ? plan.id : plan + subscriptions.select { |subscription| subscription.plan_id == plan } + end + end + +end diff --git a/app/models/condition.rb b/app/models/condition.rb index 6fd4859b6d..2f0d438659 100644 --- a/app/models/condition.rb +++ b/app/models/condition.rb @@ -4,15 +4,15 @@ # # Table name: conditions # -# id :integer not null, primary key -# question_id :integer -# number :integer -# action_type :integer -# option_list :text -# remove_data :text -# webhook_data :text -# created_at :datetime not null -# updated_at :datetime not null +# id :integer not null, primary key +# action_type :integer +# number :integer +# option_list :text(65535) +# remove_data :text(65535) +# webhook_data :text(65535) +# created_at :datetime not null +# updated_at :datetime not null +# question_id :integer # # Indexes # @@ -20,8 +20,7 @@ # # Foreign Keys # -# fk_rails_... (question_id => question.id) -# +# fk_rails_... (question_id => questions.id) # class Condition < ApplicationRecord diff --git a/app/models/contributor.rb b/app/models/contributor.rb index f177935ad9..d55506dec7 100644 --- a/app/models/contributor.rb +++ b/app/models/contributor.rb @@ -4,27 +4,23 @@ # # Table name: contributors # -# id :integer not null, primary key -# firstname :string -# surname :string -# email :string -# phone :string -# roles :integer -# org_id :integer -# plan_id :integer -# created_at :datetime -# updated_at :datetime +# id :integer not null, primary key +# email :string(255) +# name :string(255) +# phone :string(255) +# roles :integer not null +# created_at :datetime +# updated_at :datetime +# org_id :integer +# plan_id :integer not null # # Indexes # -# index_contributors_on_id (id) -# index_contributors_on_email (email) -# index_contributors_on_org_id (org_id) +# index_contributors_on_email (email) +# index_contributors_on_org_id (org_id) +# index_contributors_on_plan_id (plan_id) +# index_contributors_on_roles (roles) # -# Foreign Keys -# -# fk_rails_... (org_id => orgs.id) -# fk_rails_... (plan_id => plans.id) class Contributor < ApplicationRecord @@ -38,7 +34,7 @@ class Contributor < ApplicationRecord belongs_to :org, optional: true - belongs_to :plan, optional: true + belongs_to :plan, optional: true, touch: true # ===================== # = Nested attributes = @@ -51,10 +47,14 @@ class Contributor < ApplicationRecord # =============== validates :roles, presence: { message: PRESENCE_MESSAGE } + validates :roles, numericality: { greater_than: 0, + message: _("You must specify at least one role.") } validates :roles, numericality: { greater_than: 0, message: _("You must specify at least one role.") } + validates :email, uniqueness: { scope: :plan_id } + validate :name_or_email_presence ONTOLOGY_NAME = "CRediT - Contributor Roles Taxonomy" diff --git a/app/models/department.rb b/app/models/department.rb index 8733cf0174..70166b691d 100644 --- a/app/models/department.rb +++ b/app/models/department.rb @@ -5,8 +5,8 @@ # Table name: departments # # id :integer not null, primary key -# code :string -# name :string +# code :string(255) +# name :string(255) # created_at :datetime not null # updated_at :datetime not null # org_id :integer diff --git a/app/models/exported_plan.rb b/app/models/exported_plan.rb index f26d2b140b..a1614a3e4a 100644 --- a/app/models/exported_plan.rb +++ b/app/models/exported_plan.rb @@ -5,7 +5,7 @@ # Table name: exported_plans # # id :integer not null, primary key -# format :string +# format :string(255) # created_at :datetime not null # updated_at :datetime not null # phase_id :integer @@ -159,14 +159,14 @@ def as_csv(sections, unanswered_questions, question_headings) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength def as_txt(sections, unanswered_questions, question_headings, details) output = "#{plan.title}\n\n#{plan.template.title}\n" - output += "\n" + _("Details") + "\n\n" + output += "\n#{_("Details")}\n\n" if details admin_details.each do |at| value = send(at) output += if value.present? - admin_field_t(at.to_s) + ": " + value + "\n" + "#{admin_field_t(at.to_s)}: #{value}\n" else - admin_field_t(at.to_s) + ": " + _("-") + "\n" + "#{admin_field_t(at.to_s)}: -\n" end end end @@ -183,7 +183,7 @@ def as_txt(sections, unanswered_questions, question_headings, details) output += "\n* #{qtext}" end if answer.nil? - output += _("Question not answered.") + "\n" + output += "#{_("Question not answered.")}\n" else q_format = question.question_format if q_format.option_based? diff --git a/app/models/external_api_access_token.rb b/app/models/external_api_access_token.rb new file mode 100644 index 0000000000..0ba441e679 --- /dev/null +++ b/app/models/external_api_access_token.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: external_api_access_tokens +# +# id :bigint(8) not null, primary key +# access_token :string(255) not null +# expires_at :datetime +# external_service_name :string(255) not null +# refresh_token :string(255) +# revoked_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# user_id :bigint(8) not null +# +# Indexes +# +# index_external_api_access_tokens_on_expires_at (expires_at) +# index_external_api_access_tokens_on_external_service_name (external_service_name) +# index_external_api_access_tokens_on_user_id (user_id) +# index_external_tokens_on_user_and_service (user_id,external_service_name) +# + +class ExternalApiAccessToken < ApplicationRecord + # This class works in conjunction with Devise OmniAuth providers. If a provider returns an + # acess token along with the :uid, then the access token gets stored in this table. It expects + # the following to be passed back as part of the "omniauth.auth" response: + # + # "credentials": { + # "token": "c805b0b6-d66f-46ed-b2f2-250b7610c78b", + # "refresh_token": "6de08c52-74dd-4a7b-aae7-c2a6795dbb3d", + # "expires_at": 2250680850, + # "expires": true + # } + # + # Note that the app/controllers/users/omniauth_callbacks_controller.rb creates these records. They + # are 'revoked' when the User 'disconnects' themselves from the integration on their Profile page. + # + # The lib/tasks/utils/housekeeping.rb has a task called "cleanup_external_api_access_tokens" that + # will delete any revoked or expired tokens + # + + include ValidationMessages + + # ================ + # = Associations = + # ================ + + belongs_to :user + + # =============== + # = Validations = + # =============== + + validates :user, :external_service_name, :access_token, presence: { message: PRESENCE_MESSAGE } + + # A User may only have one active token per external service! + validate :one_active_token, on: %i[create] + + # ================= + # = Class Methods = + # ================= + + class << self + + # Fetched the active access token for the specified User and External API service + def for_user_and_service(user:, service:) + where(user: user, external_service_name: service) + .where("revoked_at IS NULL OR revoked_at > ?", Time.now) + .where("expires_at IS NULL OR expires_at > ?", Time.now) + .first + end + + # Generates an instance based on the contents of an OmniAuth hash + def from_omniauth(user:, service:, hash:) + return nil unless user.is_a?(User) && + service.present? && + hash.present? + + token_hash = hash.fetch(:credentials, {}) + return nil unless token_hash[:token].present? + + # revoke any existing tokens for the user + scheme + where(user: user, external_service_name: service.downcase).each(&:revoke!) + + # add the token for the user + scheme + expiry_time = (Time.now + token_hash[:expires_at].to_i.seconds).utc if token_hash[:expires_at].present? + new( + user: user, + external_service_name: service.downcase, + access_token: token_hash[:token], + refresh_token: token_hash[:refresh_token], + expires_at: expiry_time + ) + end + + end + + # ==================== + # = Instance Methods = + # ==================== + + def revoke! + update(revoked_at: Time.now) + end + + def active? + (revoked_at.nil? || revoked_at > Time.now) && expires_at > Time.now + end + + private + + # Validator to prevent multiple active access tokens for a user + service + def one_active_token + return true if self.class.for_user_and_service(user: user, service: external_service_name).nil? + + errors.add(:access_token, _("only one active access token allowed per user / service")) + end + +end diff --git a/app/models/guidance.rb b/app/models/guidance.rb index 9443e522c0..493c6a4615 100644 --- a/app/models/guidance.rb +++ b/app/models/guidance.rb @@ -10,7 +10,7 @@ # # id :integer not null, primary key # published :boolean -# text :text +# text :text(65535) # created_at :datetime not null # updated_at :datetime not null # guidance_group_id :integer diff --git a/app/models/guidance_group.rb b/app/models/guidance_group.rb index c4c2ba04c7..982b8d5dde 100644 --- a/app/models/guidance_group.rb +++ b/app/models/guidance_group.rb @@ -8,8 +8,8 @@ # Table name: guidance_groups # # id :integer not null, primary key -# name :string -# optional_subset :boolean default(FALSE), not null +# name :string(255) +# optional_subset :boolean default(TRUE), not null # published :boolean default(FALSE), not null # created_at :datetime not null # updated_at :datetime not null @@ -26,7 +26,7 @@ class GuidanceGroup < ApplicationRecord - attribute :optional_subset, :boolean, default: true + attribute :optional_subset, :boolean, default: false attribute :published, :boolean, default: false # ================ diff --git a/app/models/identifier.rb b/app/models/identifier.rb index bd300f7004..2d8aa97eed 100644 --- a/app/models/identifier.rb +++ b/app/models/identifier.rb @@ -5,17 +5,19 @@ # Table name: identifiers # # id :integer not null, primary key -# attrs :text -# identifiable_type :string -# value :string not null +# attrs :text(65535) +# identifiable_type :string(255) +# value :string(255) not null # created_at :datetime # updated_at :datetime # identifiable_id :integer -# identifier_scheme_id :integer not null +# identifier_scheme_id :integer # # Indexes # # index_identifiers_on_identifiable_type_and_identifiable_id (identifiable_type,identifiable_id) +# index_identifiers_on_identifier_scheme_id_and_value (identifier_scheme_id,value) +# index_identifiers_on_scheme_and_type_and_id (identifier_scheme_id,identifiable_id,identifiable_type) # class Identifier < ApplicationRecord @@ -23,7 +25,7 @@ class Identifier < ApplicationRecord # = Associations = # ================ - belongs_to :identifiable, polymorphic: true + belongs_to :identifiable, polymorphic: true, touch: true belongs_to :identifier_scheme, optional: true diff --git a/app/models/identifier_scheme.rb b/app/models/identifier_scheme.rb index 3be538fe68..c9aa8b43ba 100644 --- a/app/models/identifier_scheme.rb +++ b/app/models/identifier_scheme.rb @@ -4,20 +4,22 @@ # # Table name: identifier_schemes # -# id :integer not null, primary key -# active :boolean -# description :string -# context :integer -# logo_url :text -# name :string -# user_landing_url :string -# created_at :datetime -# updated_at :datetime +# id :integer not null, primary key +# active :boolean +# context :integer +# description :string(255) +# external_service :string(255) +# identifier_prefix :string(255) +# logo_url :string(255) +# name :string(255) +# created_at :datetime +# updated_at :datetime # class IdentifierScheme < ApplicationRecord include FlagShihTzu + include Subscribable ## # The maximum length for a name @@ -46,11 +48,18 @@ class IdentifierScheme < ApplicationRecord ## # Define Bit Field values for the scheme's context # These are used to determine when and where an identifier scheme is applicable - has_flags 1 => :for_authentication, - 2 => :for_orgs, - 3 => :for_plans, - 4 => :for_users, - 5 => :for_contributors, + # for_authentication => identifies which schemes can be used for user auth + # for_orgs => identifies which ids will be displayed on Org pages + # for_plans => identifies which ids will be displayed on Plans pages + # for_contributors => identifies which ids will be displayed on Contributor pages + # for_identification => identifies which ids are object identifiers (e.g. ROR, ARK, etc.) + has_flags 1 => :for_authentication, + 2 => :for_orgs, + 3 => :for_plans, + 4 => :for_users, + 5 => :for_contributors, + 6 => :for_identification, + 7 => :for_research_outputs, column: "context" # ========================= diff --git a/app/models/language.rb b/app/models/language.rb index a62b03cfd7..91045dd2d1 100644 --- a/app/models/language.rb +++ b/app/models/language.rb @@ -5,10 +5,10 @@ # Table name: languages # # id :integer not null, primary key -# abbreviation :string +# abbreviation :string(255) # default_language :boolean -# description :string -# name :string +# description :string(255) +# name :string(255) # class Language < ApplicationRecord diff --git a/app/models/license.rb b/app/models/license.rb new file mode 100644 index 0000000000..cc8bd067da --- /dev/null +++ b/app/models/license.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: licenses +# +# id :bigint not null, primary key +# deprecated :boolean default(FALSE) +# identifier :string not null +# name :string not null +# osi_approved :boolean default(FALSE) +# uri :string not null +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_license_on_identifier_and_criteria (identifier,osi_approved,deprecated) +# index_licenses_on_identifier (identifier) +# index_licenses_on_uri (uri) +# +class License < ApplicationRecord + + # ================ + # = Associations = + # ================ + + has_many :research_outputs + + # ========== + # = Scopes = + # ========== + + scope :selectable, lambda { + where(deprecated: false) + } + + scope :preferred, lambda { + # Fetch the list of preferred license from the config. + preferences = Rails.configuration.x.madmp.preferred_licenses || [] + return selectable unless preferences.is_a?(Array) && preferences.any? + + licenses = preferences.map do |preference| + # If `%{latest}` was specified then grab the most current version + pref = preference.gsub("%{latest}", "[0-9\\.]+$") + where_clause = safe_regexp_where_clause(column: "identifier") + rslts = preference.include?("%{latest}") ? where(where_clause, pref) : where(identifier: pref) + rslts.order(:identifier).last + end + # Remove any preferred licenses that could not be found in the table + licenses.compact + } + +end diff --git a/app/models/metadata_standard.rb b/app/models/metadata_standard.rb new file mode 100644 index 0000000000..d8a3f1e756 --- /dev/null +++ b/app/models/metadata_standard.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: metadata_standards +# +# id :bigint not null, primary key +# description :text +# locations :json +# related_entities :json +# title :string +# uri :string +# created_at :datetime not null +# updated_at :datetime not null +# rdamsc_id :string +# +class MetadataStandard < ApplicationRecord + + # ================ + # = Associations = + # ================ + + has_and_belongs_to_many :research_outputs + + # ========== + # = Scopes = + # ========== + + scope :search, lambda { |term| + term = term.downcase + where("LOWER(title) LIKE ?", "%#{term}%").or(where("LOWER(description) LIKE ?", "%#{term}%")) + } + +end diff --git a/app/models/mime_type.rb b/app/models/mime_type.rb deleted file mode 100644 index 7c35a15b27..0000000000 --- a/app/models/mime_type.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -# == Schema Information -# -# Table name: mime_types -# -# id :bigint not null, primary key -# category :string not null -# description :string not null -# value :string not null -# created_at :datetime not null -# updated_at :datetime not null -# -# Indexes -# -# index_mime_types_on_value (value) -# -class MimeType < ApplicationRecord - - include ValidationMessages - - # ================ - # = Associations = - # ================ - - has_many :research_outputs - - # =============== - # = Validations = - # =============== - - validates :category, :description, :value, presence: { message: PRESENCE_MESSAGE } - - # ========== - # = Scopes = - # ========== - - # Retrieves the unique list of categories - scope :categories, -> { pluck(:category).uniq.sort { |a, b| a <=> b } } - -end diff --git a/app/models/note.rb b/app/models/note.rb index 6e4b3cffd9..b18a2ff88d 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -7,7 +7,7 @@ # id :integer not null, primary key # archived :boolean default(FALSE), not null # archived_by :integer -# text :text +# text :text(65535) # created_at :datetime # updated_at :datetime # answer_id :integer diff --git a/app/models/notification.rb b/app/models/notification.rb index 4ec71198a9..6e1595857a 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -5,14 +5,14 @@ # Table name: notifications # # id :integer not null, primary key -# body :text +# body :text(65535) # dismissable :boolean +# enabled :boolean default(TRUE) # expires_at :date # level :integer # notification_type :integer # starts_at :date -# title :string -# enabled :boolean +# title :string(255) # created_at :datetime not null # updated_at :datetime not null # diff --git a/app/models/org.rb b/app/models/org.rb index de2526de38..7156809c81 100644 --- a/app/models/org.rb +++ b/app/models/org.rb @@ -15,30 +15,50 @@ # logo_name :string # logo_uid :string # managed :boolean default(FALSE), not null -# name :string +# name :string(255) # org_type :integer default(0), not null -# sort_name :string -# target_url :string +# sort_name :string(255) +# target_url :string(255) +# users_count :integer # created_at :datetime not null # updated_at :datetime not null # language_id :integer # region_id :integer -# managed :boolean default(false), not null +# +# Indexes +# +# fk_rails_5640112cab (language_id) +# fk_rails_5a6adf6bab (region_id) # # Foreign Keys # # fk_rails_... (language_id => languages.id) +# fk_rails_... (region_id => regions.id) # class Org < ApplicationRecord extend FeedbacksHelper include FlagShihTzu include Identifiable + include Subscribable + + # ---------------------------------------- + # Start DMPTool Customization + # ---------------------------------------- + include DmptoolOrg + + # Allows an Org to invite a user via the 'Email template' link on the Templates page + devise :invitable + + has_many :plans_sponsors, dependent: :destroy + # ---------------------------------------- + # End DMPTool Customization + # ---------------------------------------- extend Dragonfly::Model::Validations validates_with OrgLinksValidator - LOGO_FORMATS = %w[jpeg png gif jpg bmp].freeze + LOGO_FORMATS = %w[jpeg png gif jpg bmp svg].freeze HUMANIZED_ATTRIBUTES = { feedback_msg: _("Feedback email message") @@ -120,16 +140,16 @@ class Org < ApplicationRecord validates_property :format, of: :logo, in: LOGO_FORMATS, message: _("must be one of the following formats: " \ - "jpeg, jpg, png, gif, bmp") + "jpeg, jpg, png, gif, bmp svg") validates_size_of :logo, maximum: 500.kilobytes, message: _("can't be larger than 500KB") - # allow validations for logo upload - dragonfly_accessor :logo do - after_assign :resize_image - end + dragonfly_accessor :logo + + validates_property :format, of: :logo, in: ['jpeg', 'png', 'gif', 'jpg', 'bmp', 'svg'], message: _("must be one of the following formats: jpeg, jpg, png, gif, bmp, svg") + validates_size_of :logo, maximum: 500.kilobytes, message: _("can't be larger than 500KB") # ============= # = Callbacks = @@ -203,6 +223,14 @@ def self.default_orgs count(users.id) as user_count") } + # Returns all Org's with a Shibboleth entityID stored in the Identifiers table + # This is used on the app/views/shared/_shib_sign_in_form.html.erb partial which + # is only used if you have `shibboleth.use_filtered_discovery_service` enabled. + scope :shibbolized, lambda { + org_ids = Identifier.by_scheme_name("shibboleth", "Org").pluck(:identifiable_id) + where(managed: true, id: org_ids) + } + # EVALUATE CLASS AND INSTANCE METHODS BELOW # # What do they do? do they do it efficiently, and do we need them? diff --git a/app/models/perm.rb b/app/models/perm.rb index 78462904ed..6c0d288a1a 100644 --- a/app/models/perm.rb +++ b/app/models/perm.rb @@ -5,7 +5,7 @@ # Table name: perms # # id :integer not null, primary key -# name :string +# name :string(255) # created_at :datetime not null # updated_at :datetime not null # diff --git a/app/models/phase.rb b/app/models/phase.rb index f45e2294fc..5e3879aa62 100644 --- a/app/models/phase.rb +++ b/app/models/phase.rb @@ -5,10 +5,10 @@ # Table name: phases # # id :integer not null, primary key -# description :text +# description :text(65535) # modifiable :boolean # number :integer -# title :string +# title :string(255) # created_at :datetime # updated_at :datetime # template_id :integer diff --git a/app/models/plan.rb b/app/models/plan.rb index ddbf275cd8..fe131ece37 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -16,10 +16,9 @@ # visibility :integer default(3), not null # created_at :datetime # updated_at :datetime -# template_id :integer -# org_id :integer # funder_id :integer # grant_id :integer +# api_client_id :integer # research_domain_id :bigint # funding_status :integer # ethical_issues :boolean @@ -28,18 +27,19 @@ # # Indexes # -# index_plans_on_template_id (template_id) -# index_plans_on_funder_id (funder_id) -# index_plans_on_grant_id (grant_id) -# index_plans_on_api_client_id (api_client_id) +# index_plans_on_funder_id (funder_id) +# index_plans_on_grant_id (grant_id) +# index_plans_on_org_id (org_id) +# index_plans_on_template_id (template_id) # # Foreign Keys # -# fk_rails_... (template_id => templates.id) # fk_rails_... (org_id => orgs.id) +# fk_rails_... (api_client_id => api_clients.id) # fk_rails_... (research_domain_id => research_domains.id) # +# rubocop:disable Metrics/ClassLength class Plan < ApplicationRecord include ConditionalUserMailer @@ -84,6 +84,8 @@ class Plan < ApplicationRecord belongs_to :funder, class_name: "Org", optional: true + belongs_to :api_client, optional: true + belongs_to :research_domain, optional: true has_many :phases, through: :template @@ -111,10 +113,18 @@ class Plan < ApplicationRecord has_and_belongs_to_many :guidance_groups, join_table: :plans_guidance_groups - has_many :exported_plans + has_many :exported_plans, dependent: :destroy has_many :contributors, dependent: :destroy + has_one :grant, as: :identifiable, dependent: :destroy, class_name: "Identifier" + + has_many :research_outputs, dependent: :destroy + + has_many :subscriptions, dependent: :destroy + + has_many :related_identifiers, as: :identifiable, dependent: :destroy + # ===================== # = Nested Attributes = # ===================== @@ -125,6 +135,10 @@ class Plan < ApplicationRecord accepts_nested_attributes_for :contributors + accepts_nested_attributes_for :research_outputs + + accepts_nested_attributes_for :related_identifiers + # =============== # = Validations = # =============== @@ -139,6 +153,13 @@ class Plan < ApplicationRecord validate :end_date_after_start_date + # ============= + # = Callbacks = + # ============= + + after_update :notify_subscribers!, if: :versionable_change? + after_touch :notify_subscribers! + # ========== # = Scopes = # ========== @@ -427,9 +448,7 @@ def latest_update # Returns User # Returns nil def owner - r = roles.select { |rr| rr.active && rr.administrator } - .min { |a, b| a.created_at <=> b.created_at } - r.nil? ? nil : r.user + roles.administrator.where(active: true).order(:created_at).first&.user end # Creates a role for the specified user (will update the user's @@ -568,6 +587,42 @@ def landing_page identifiers.select { |i| %w[doi ark].include?(i.identifier_format) }.first end + # Retrieves the Plan's most recent DOI + def dmp_id + return nil unless Rails.configuration.x.madmp.enable_dmp_id_registration + + id = identifiers.select { |i| i.identifier_scheme == DmpIdService.identifier_scheme } + .last + return id if id.present? + + # This is here in the event that the DmpIdService has changed over time and the + # Plan's DMP ID was generated by an older service + identifiers.select { |i| %w[ark doi].include?(i.identifier_format) }.last + end + + # Returns whether or not minting is allowed for the current plan + def registration_allowed? + orcid_scheme = IdentifierScheme.where(name: "orcid").first + return false unless Rails.configuration.x.madmp.enable_dmp_id_registration && + orcid_scheme.present? + + # The owner must have an orcid, a funder and :visibility_allowed? (aka :complete) + orcid = owner.identifier_for_scheme(scheme: orcid_scheme).present? + visibility_allowed? && orcid.present? && funder.present? + end + + # Returns whether or not minting is allowed for the current plan + def minting_allowed? + orcid_scheme = IdentifierScheme.where(name: "orcid").first + return false unless orcid_scheme.present? + + # The owner must have an orcid and have authorized us to add to their record + orcid = owner.identifier_for_scheme(scheme: orcid_scheme).present? + token = ExternalApiAccessToken.for_user_and_service(user: owner, service: "orcid") + + visibility_allowed? && orcid.present? && token.present? && funder.present? + end + # Since the Grant is not a normal AR association, override the getter and setter def grant Identifier.find_by(id: grant_id) @@ -591,14 +646,106 @@ def grant=(params) self.grant_id = current.id end + # Return the citation for the DMP. For example: + # + # Jane Doe. (2021). "My DMP" [Data Management Plan]. DMPRoadmap. https://doi.org/10.12/a1.b2 + # + def citation + return nil unless owner.present? && dmp_id.is_a?(Identifier) + + # authors = owner_and_coowners.map { |author| author.name(false) } + # .uniq + # .sort { |a, b| a <=> b } + # .join(", ") + # TODO: display all authors once we determine the appropriate way to handle on the ORCID side + authors = owner.name(false) + pub_year = updated_at.strftime("%Y") + app_name = ApplicationService.application_name + link = dmp_id.value + "#{authors}. (#{pub_year}). \"#{title}\" [Data Management Plan]. #{app_name}. #{link}" + end + + # Returns the Subscription for the specified subscriber or nil if none exists + def subscription_for(subscriber:) + subscriptions.select { |subscription| subscription.subscriber == subscriber } + end + + # Helper method to convert related_identifier entries from standard form params into + # RelatedIdentifier objects. + # + # Expecting the hash to look like the following, where the initial key is the + # RelatedIdentifier.id or "0" if its an empty entry or an absurdly long value + # indicating that its a new entry. + # The form's JS makes a copy of the "0" entry and generate a long value for an id + # when the user clicks the '+add a related identifier' link. We need to do this so + # that the user is able to add multiple entries at one time. + # + # { + # "56": { + # "work_type": "software", "value": "https://doi.org/10.48321/D1MP4Z" + # }, + # "0": { + # "work_type": "article", "value": "" + # }, + # "1632773961597": { + # "work_type": "dataset", "value": "http://foo.bar" + # } + # } + def related_identifiers_attributes=(params) + # Remove any that the user may have deleted + related_identifiers.reject { |r_id| params.keys.include?(r_id.id.to_s) } + .each { |r_id| r_id.destroy } + + # Update existing or add new + params.each do |id, related_identifier_hash| + next unless id.present? && id != "0" && related_identifier_hash[:value].present? + + related = RelatedIdentifier.find_by(id: id) + related = RelatedIdentifier.new(identifiable: self) unless related.present? + related.work_type = related_identifier_hash[:work_type] + related.value = related_identifier_hash[:value].strip + related_identifiers << related + end + end + private + # Determines whether or not the attributes that were updated constitute a versionable change + # for example a user requesting feedback will change the :feedback_requested flag but that + # should not create a new version or notify any subscribers! + # + # Note that some associated models :touch the plan when they are updated so that a change + # to a contributor for example will constitute a new version of the plan + # + # TODO: We will likely need to change this or break it up into different methods based + # on the use cases we uncover when deciding how to version plans + def versionable_change? + saved_change_to_title? || saved_change_to_description? || saved_change_to_identifier? || + saved_change_to_visibility? || saved_change_to_complete? || saved_change_to_template_id? || + saved_change_to_org_id? || saved_change_to_funder_id? || saved_change_to_grant_id? || + saved_change_to_start_date? || saved_change_to_end_date? || + saved_change_to_research_domain_id? || saved_change_to_ethical_issues? || + saved_change_to_ethical_issues_description? || saved_change_to_ethical_issues_report? + end + + # Sends notifications to the Subscribers of the specified subscription types + def notify_subscribers!(subscription_types: [:updates]) + targets = subscription_types.map do |typ| + subscriptions.select { |sub| sub.selected_subscription_types.include?(typ.to_sym) } + end + targets = targets.flatten.uniq if targets.any? + targets.each(&:notify!) + true + end + # Validation to prevent end date from coming before the start date def end_date_after_start_date # allow nil values - return true if end_date.blank? || start_date.blank? + return true if end_date.blank? || start_date.blank? || end_date > start_date errors.add(:end_date, _("must be after the start date")) if end_date < start_date + start_date < end_date end end +# rubocop:enable Metrics/ClassLength diff --git a/app/models/pref.rb b/app/models/pref.rb index 7edd20b938..5b7acddf49 100644 --- a/app/models/pref.rb +++ b/app/models/pref.rb @@ -5,7 +5,7 @@ # Table name: prefs # # id :integer not null, primary key -# settings :text +# settings :text(65535) # user_id :integer # diff --git a/app/models/question.rb b/app/models/question.rb index 2deb90a5a1..86ac22ac21 100644 --- a/app/models/question.rb +++ b/app/models/question.rb @@ -5,11 +5,11 @@ # Table name: questions # # id :integer not null, primary key -# default_value :text +# default_value :text(65535) # modifiable :boolean # number :integer # option_comment_display :boolean default(TRUE) -# text :text +# text :text(65535) # created_at :datetime # updated_at :datetime # question_format_id :integer diff --git a/app/models/question_format.rb b/app/models/question_format.rb index 6847309d51..6ce4364635 100644 --- a/app/models/question_format.rb +++ b/app/models/question_format.rb @@ -5,10 +5,10 @@ # Table name: question_formats # # id :integer not null, primary key -# description :text -# formattype :integer default(0) +# description :text(65535) +# formattype :integer default("textarea") # option_based :boolean default(FALSE) -# title :string +# title :string(255) # created_at :datetime not null # updated_at :datetime not null # diff --git a/app/models/question_option.rb b/app/models/question_option.rb index cc0787723e..54c1802995 100644 --- a/app/models/question_option.rb +++ b/app/models/question_option.rb @@ -4,17 +4,19 @@ # # Table name: question_options # -# id :integer not null, primary key -# is_default :boolean -# number :integer -# text :string -# created_at :datetime -# updated_at :datetime -# question_id :integer +# id :integer not null, primary key +# is_default :boolean +# number :integer +# text :string(255) +# created_at :datetime +# updated_at :datetime +# question_id :integer +# versionable_id :string(36) # # Indexes # -# index_question_options_on_question_id (question_id) +# index_question_options_on_question_id (question_id) +# index_question_options_on_versionable_id (versionable_id) # # Foreign Keys # diff --git a/app/models/region.rb b/app/models/region.rb index 3c970f05d2..d1a1e4874b 100644 --- a/app/models/region.rb +++ b/app/models/region.rb @@ -4,12 +4,11 @@ # # Table name: regions # -# id :integer not null, primary key -# abbreviation :string -# description :string -# name :string not null -# created_at :datetime not null -# updated_at :datetime not null +# id :integer not null, primary key +# abbreviation :string(255) +# description :string(255) +# name :string(255) +# super_region_id :integer # class Region < ApplicationRecord diff --git a/app/models/related_identifier.rb b/app/models/related_identifier.rb new file mode 100644 index 0000000000..65d572de38 --- /dev/null +++ b/app/models/related_identifier.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: related_identifiers +# +# id :bigint(8) not null, primary key +# identifiable_type :string(255) +# identifier_type :integer not null +# relation_type :integer not null +# work_type :integer not null +# value :string(255) not null +# citation :text +# created_at :datetime not null +# updated_at :datetime not null +# identifiable_id :bigint(8) +# identifier_scheme_id :bigint(8) +# +# Indexes +# +# index_related_identifiers_on_identifier_scheme_id (identifier_scheme_id) +# index_related_identifiers_on_identifier_type (identifier_type) +# index_related_identifiers_on_relation_type (relation_type) +# index_relateds_on_identifiable_and_relation_type (identifiable_id,identifiable_type,relation_type) +# +class RelatedIdentifier < ApplicationRecord + + include Uc3Citation + + URL_REGEX = %r{^http}.freeze + DOI_REGEX = %r{(doi:)?10\.[0-9]+\/[a-zA-Z0-9\.\-\/]+}.freeze + ARK_REGEX = %r{ark:[a-zA-Z0-9]+\/[a-zA-Z0-9]+}.freeze + + # ================ + # = Associations = + # ================ + + belongs_to :identifiable, polymorphic: true, touch: true + + belongs_to :identifier_scheme, optional: true + + # =============== + # = Validations = + # =============== + + validates :value, presence: { message: PRESENCE_MESSAGE } + + validates :identifiable, presence: { message: PRESENCE_MESSAGE } + + # ========= + # = Enums = + # ========= + + # Broad categories to identify the type of work the related identifier represents + enum work_type: %i[article dataset preprint software supplemental_information paper book] + + # The type of identifier based on the DataCite metadata schema + enum identifier_type: %i[ark arxiv bibcode doi ean13 eissn handle igsn isbn issn istc + lissn lsid pmid purl upc url urn w3id other] + + # The relationship type between the related item and the Plan + # Note that the 'references' value is changed to 'does_reference' in this list + # because 'references' conflicts with an ActiveRecord method + enum relation_type: %i[is_cited_by cites + is_supplement_to is_supplemented_by + is_continued_by continues + is_described_by describes + has_metadata is_metadata_for + has_version is_version_of is_new_version_of is_previous_version_of + is_part_of has_part + is_referenced_by does_reference + is_documented_by documents + is_compiled_by compiles + is_variant_form_of is_original_form_of is_identical_to + is_reviewed_by reviews + is_derived_from is_source_of + is_required_by requires + is_obsoleted_by obsoletes] + + # ============= + # = CALLBACKS = + # ============= + + before_validation :ensure_defaults + + # If we've enabled citation lookups, then try to fetch the citation after its created + # or the value has changed + after_save :load_citation + + # Returns the value sans the identifier scheme's prefix. + # For example: + # value 'https://orcid.org/0000-0000-0000-0001' + # becomes '0000-0000-0000-0001' + def value_without_scheme_prefix + return value unless identifier_scheme.present? && + identifier_scheme.identifier_prefix.present? + + base = identifier_scheme.identifier_prefix + value.gsub(base, "").sub(%r{^/}, "") + end + + private + + def ensure_defaults + self.identifier_type = detect_identifier_type + self.relation_type = detect_relation_type + end + + def detect_identifier_type + return "ark" unless (value =~ ARK_REGEX).nil? + return "doi" unless (value =~ DOI_REGEX).nil? + return "url" unless (value =~ URL_REGEX).nil? + + "other" + end + + def detect_relation_type + relation_type.present? ? relation_type : "cites" + end + + def load_citation + # Only attempt to load the citation if that functionality has been enabled in the + # config, this is a DOI and its either a new record or the value has changed + if Rails.configuration.x.madmp.enable_citation_lookup && identifier_type == "doi" && + citation.nil? + wrk_type = work_type == "supplemental_information" ? "" : work_type + # Use the UC3Citation service to fetch the citation for the DOI + self.citation = fetch_citation(doi: value, work_type: wrk_type) #, debug: true) + end + end + +end diff --git a/app/models/repository.rb b/app/models/repository.rb new file mode 100644 index 0000000000..06ffad7588 --- /dev/null +++ b/app/models/repository.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: repositories +# +# id :bigint not null, primary key +# contact :string +# description :text not null +# info :json +# name :string not null +# homepage :string +# created_at :datetime not null +# updated_at :datetime not null +# uri :string not null +# +# Indexes +# +# index_repositories_on_name (name) +# index_repositories_on_homepage (homepage) +# index_repositories_on_uri (uri) +# + +class Repository < ApplicationRecord + + # ================ + # = Associations = + # ================ + + has_and_belongs_to_many :research_outputs + + # ========== + # = Scopes = + # ========== + + scope :by_type, lambda { |type| + where(safe_json_where_clause(column: "info", hash_key: "types"), "%#{type}%") + } + + scope :by_subject, lambda { |subject| + where(safe_json_where_clause(column: "info", hash_key: "subjects"), "%#{subject}%") + } + + scope :search, lambda { |term| + term = term.downcase + where("LOWER(name) LIKE ?", "%#{term}%") + .or(where(safe_json_where_clause(column: "info", hash_key: "keywords"), "%#{term}%")) + .or(where(safe_json_where_clause(column: "info", hash_key: "subjects"), "%#{term}%")) + } + + # A very specific keyword search (e.g. 'gene', 'DNA', etc.) + scope :by_facet, lambda { |facet| + where(safe_json_where_clause(column: "info", hash_key: "keywords"), "%#{facet}%") + } + +end diff --git a/app/models/research_output.rb b/app/models/research_output.rb index 858ef06d10..dbc6730d21 100644 --- a/app/models/research_output.rb +++ b/app/models/research_output.rb @@ -6,26 +6,31 @@ # # id :bigint not null, primary key # abbreviation :string -# access :integer default(0), not null +# access :integer default("open"), not null # byte_size :bigint # description :text # display_order :integer -# is_default :boolean default("false") -# output_type :integer default(3), not null +# is_default :boolean +# output_type :integer default("dataset"), not null # output_type_description :string # personal_data :boolean # release_date :datetime # sensitive_data :boolean -# title :string not null +# title :string(255) not null # created_at :datetime not null # updated_at :datetime not null -# mime_type_id :integer +# license_id :bigint # plan_id :integer # # Indexes # +# index_research_outputs_on_license_id (license_id) # index_research_outputs_on_output_type (output_type) -# index_research_outputs_on_plan_id (plan_id) +# +# Foreign Keys +# +# fk_rails_... (plan_id => plans.id) +# fk_rails_... (license_id => licenses.id) # class ResearchOutput < ApplicationRecord @@ -42,14 +47,22 @@ class ResearchOutput < ApplicationRecord # = Associations = # ================ - belongs_to :plan, optional: true + belongs_to :plan, optional: true, touch: true + belongs_to :license, optional: true + + has_and_belongs_to_many :metadata_standards + has_and_belongs_to_many :repositories # =============== # = Validations = # =============== validates_presence_of :output_type, :access, :title, message: PRESENCE_MESSAGE - validates_uniqueness_of :title, :abbreviation, scope: :plan_id + validates_uniqueness_of :title, { case_sensitive: false, scope: :plan_id, + message: UNIQUENESS_MESSAGE } + validates_uniqueness_of :abbreviation, { case_sensitive: false, scope: :plan_id, + allow_nil: true, allow_blank: true, + message: UNIQUENESS_MESSAGE } # Ensure presence of the :output_type_description if the user selected 'other' validates_presence_of :output_type_description, if: -> { other? }, message: PRESENCE_MESSAGE @@ -58,37 +71,18 @@ class ResearchOutput < ApplicationRecord # = Instance methods = # ==================== - # TODO: placeholders for once the License, Repository, Metadata Standard and - # Resource Type Lookups feature is built. - # - # Be sure to add the scheme in the appropriate upgrade task (and to the - # seed.rb as well) - def licenses - # scheme = IdentifierScheme.find_by(name: '[name of license scheme]') - # return [] unless scheme.present? - # identifiers.select { |id| id.identifier_scheme = scheme } - [] - end - - def repositories - # scheme = IdentifierScheme.find_by(name: '[name of repository scheme]') - # return [] unless scheme.present? - # identifiers.select { |id| id.identifier_scheme = scheme } - [] - end - - def metadata_standards - # scheme = IdentifierScheme.find_by(name: '[name of openaire scheme]') - # return [] unless scheme.present? - # identifiers.select { |id| id.identifier_scheme = scheme } - [] + # Helper method to convert selected repository form params into Repository objects + def repositories_attributes=(params) + params.each do |_i, repository_params| + repositories << Repository.find_by(id: repository_params[:id]) + end end - def resource_types - # scheme = IdentifierScheme.find_by(name: '[name of resource_type scheme]') - # return [] unless scheme.present? - # identifiers.select { |id| id.identifier_scheme = scheme } - [] + # Helper method to convert selected metadata standard form params into MetadataStandard objects + def metadata_standards_attributes=(params) + params.each do |_i, metadata_standard_params| + metadata_standards << MetadataStandard.find_by(id: metadata_standard_params[:id]) + end end end diff --git a/app/models/section.rb b/app/models/section.rb index 4017e7438b..62f6661826 100644 --- a/app/models/section.rb +++ b/app/models/section.rb @@ -5,10 +5,10 @@ # Table name: sections # # id :integer not null, primary key -# description :text +# description :text(65535) # modifiable :boolean # number :integer -# title :string +# title :string(255) # created_at :datetime # updated_at :datetime # phase_id :integer diff --git a/app/models/settings/template.rb b/app/models/settings/template.rb index 2a05c58b64..f9f331e086 100644 --- a/app/models/settings/template.rb +++ b/app/models/settings/template.rb @@ -5,9 +5,9 @@ # Table name: settings # # id :integer not null, primary key -# target_type :string not null -# value :text -# var :string not null +# target_type :string(255) +# value :text(65535) +# var :string(255) # created_at :datetime not null # updated_at :datetime not null # target_id :integer not null @@ -31,13 +31,35 @@ class Template < RailsSettings::SettingObject VALID_FORMATS = %w[csv html pdf text docx json].freeze + # ================================= + # Start DMPTool Customization + # Update margins to 25mm default + # ================================= + # DEFAULT_SETTINGS = { + # formatting: { + # margin: { + # top: 25, + # bottom: 20, + # left: 12, + # right: 12 + # }, + # font_face: 'Arial, Helvetica, Sans-Serif', + # font_size: 10 # pt + # }, + # max_pages: 3, + # fields: { + # admin: VALID_ADMIN_FIELDS, + # questions: :all + # }, + # title: "" + # } DEFAULT_SETTINGS = { formatting: { margin: { top: 25, - bottom: 20, - left: 12, - right: 12 + bottom: 25, + left: 25, + right: 25 }, font_face: "Arial, Helvetica, Sans-Serif", font_size: 10 # pt @@ -49,6 +71,9 @@ class Template < RailsSettings::SettingObject }, title: "" }.freeze + # ================================= + # End DMPTool Customization + # ================================= # rubocop:disable Metrics/BlockLength, Metrics/BlockNesting validate do diff --git a/app/models/stat.rb b/app/models/stat.rb index 5f16bae257..a5217e186a 100644 --- a/app/models/stat.rb +++ b/app/models/stat.rb @@ -7,9 +7,9 @@ # id :integer not null, primary key # count :bigint(8) default(0) # date :date not null -# details :text +# details :text(65535) # filtered :boolean default(FALSE) -# type :string not null +# type :string(255) not null # created_at :datetime not null # updated_at :datetime not null # org_id :integer diff --git a/app/models/stat_created_plan.rb b/app/models/stat_created_plan.rb index 0bd760bdbe..8df188164d 100644 --- a/app/models/stat_created_plan.rb +++ b/app/models/stat_created_plan.rb @@ -7,9 +7,9 @@ # id :integer not null, primary key # count :bigint(8) default(0) # date :date not null -# details :text +# details :text(65535) # filtered :boolean default(FALSE) -# type :string not null +# type :string(255) not null # created_at :datetime not null # updated_at :datetime not null # org_id :integer diff --git a/app/models/stat_exported_plan.rb b/app/models/stat_exported_plan.rb index 2f9e518cf8..be7dfcf60f 100644 --- a/app/models/stat_exported_plan.rb +++ b/app/models/stat_exported_plan.rb @@ -7,9 +7,9 @@ # id :integer not null, primary key # count :bigint(8) default(0) # date :date not null -# details :text +# details :text(65535) # filtered :boolean default(FALSE) -# type :string not null +# type :string(255) not null # created_at :datetime not null # updated_at :datetime not null # org_id :integer diff --git a/app/models/stat_joined_user.rb b/app/models/stat_joined_user.rb index 2910e8b476..65dbd3dd0d 100644 --- a/app/models/stat_joined_user.rb +++ b/app/models/stat_joined_user.rb @@ -7,15 +7,16 @@ # id :integer not null, primary key # count :bigint(8) default(0) # date :date not null -# details :text +# details :text(65535) # filtered :boolean default(FALSE) -# type :string not null +# type :string(255) not null # created_at :datetime not null # updated_at :datetime not null # org_id :integer # class StatJoinedUser < Stat + extend OrgDateRangeable class << self diff --git a/app/models/stat_shared_plan.rb b/app/models/stat_shared_plan.rb index 96230bab20..2b6504b2ba 100644 --- a/app/models/stat_shared_plan.rb +++ b/app/models/stat_shared_plan.rb @@ -7,9 +7,9 @@ # id :integer not null, primary key # count :bigint(8) default(0) # date :date not null -# details :text +# details :text(65535) # filtered :boolean default(FALSE) -# type :string not null +# type :string(255) not null # created_at :datetime not null # updated_at :datetime not null # org_id :integer diff --git a/app/models/subscription.rb b/app/models/subscription.rb new file mode 100644 index 0000000000..6e45680e6a --- /dev/null +++ b/app/models/subscription.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: subscriptions +# +# id :bigint not null, primary key +# callback_uri :string +# last_notified :datetime +# subscriber_type :string +# subscription_types :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# plan_id :bigint +# subscriber_id :bigint +# +# Indexes +# +# index_subscribers_on_identifiable_and_plan_id (subscriber_id,subscriber_type,plan_id) +# index_subscriptions_on_plan_id (plan_id) +# +class Subscription < ApplicationRecord + + include FlagShihTzu + + # ================ + # = Associations = + # ================ + + belongs_to :plan + belongs_to :subscriber, polymorphic: true + + ## + # Define Bit Field values for subscription_types + has_flags 1 => :updates, + 2 => :deletions, + 3 => :creations, + column: "subscription_types" + + # ==================== + # = Instance Methods = + # ==================== + + def notify! + # Do not notify anyone if this is a new record + return false if new_record? + # Do not notify if there is no callback or they've already been notified + return false unless callback_uri.present? && + (last_notified.nil? || last_notified < plan.updated_at) + + NotifySubscriberJob.perform_later(self) + true + end + +end diff --git a/app/models/template.rb b/app/models/template.rb index fb56f8591f..8a7768e0ee 100644 --- a/app/models/template.rb +++ b/app/models/template.rb @@ -7,12 +7,12 @@ # id :integer not null, primary key # archived :boolean # customization_of :integer -# description :text +# description :text(65535) # is_default :boolean -# links :text -# locale :string +# links :text(65535) +# locale :string(255) # published :boolean -# title :string +# title :string(255) # version :integer # visibility :integer # created_at :datetime @@ -85,6 +85,15 @@ class Template < ApplicationRecord has_many :conditions, through: :questions + # ---------------------------------------- + # Start DMPTool Customization + # ---------------------------------------- + has_one :sponsor, class_name: 'Org', foreign_key: 'id', primary_key: 'sponsor_id', + required: false + # ---------------------------------------- + # End DMPTool Customization + # ---------------------------------------- + # =============== # = Validations = # =============== diff --git a/app/models/theme.rb b/app/models/theme.rb index 3764ee558d..d8ed938eda 100644 --- a/app/models/theme.rb +++ b/app/models/theme.rb @@ -5,9 +5,9 @@ # Table name: themes # # id :integer not null, primary key -# description :text -# locale :string -# title :string +# description :text(65535) +# locale :string(255) +# title :string(255) # created_at :datetime not null # updated_at :datetime not null # diff --git a/app/models/token_permission_type.rb b/app/models/token_permission_type.rb index 57b558f81f..ff40c1be85 100644 --- a/app/models/token_permission_type.rb +++ b/app/models/token_permission_type.rb @@ -5,8 +5,8 @@ # Table name: token_permission_types # # id :integer not null, primary key -# text_description :text -# token_type :string +# text_description :text(65535) +# token_type :string(255) # created_at :datetime # updated_at :datetime # diff --git a/app/models/tracker.rb b/app/models/tracker.rb index 39628c5389..4726b4d88f 100644 --- a/app/models/tracker.rb +++ b/app/models/tracker.rb @@ -1,5 +1,23 @@ # frozen_string_literal: true +# == Schema Information +# +# Table name: trackers +# +# id :integer not null, primary key +# code :string(255) +# created_at :datetime not null +# updated_at :datetime not null +# org_id :integer +# +# Indexes +# +# index_trackers_on_org_id (org_id) +# +# Foreign Keys +# +# fk_rails_... (org_id => orgs.id) +# class Tracker < ApplicationRecord belongs_to :org diff --git a/app/models/user.rb b/app/models/user.rb index ff4ac5043a..d178bcde2c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -7,29 +7,30 @@ # id :integer not null, primary key # accept_terms :boolean # active :boolean default(TRUE) -# api_token :string +# api_token :string(255) # confirmation_sent_at :datetime -# confirmation_token :string +# confirmation_token :string(255) # confirmed_at :datetime # current_sign_in_at :datetime -# current_sign_in_ip :string +# current_sign_in_ip :string(255) # email :string(80) default(""), not null -# encrypted_password :string -# firstname :string +# encrypted_password :string(255) +# firstname :string(255) # invitation_accepted_at :datetime # invitation_created_at :datetime # invitation_sent_at :datetime -# invitation_token :string -# invited_by_type :string +# invitation_token :string(255) +# invited_by_type :string(255) +# last_api_access :datetime # last_sign_in_at :datetime # last_sign_in_ip :string # other_organisation :string # recovery_email :string # remember_created_at :datetime # reset_password_sent_at :datetime -# reset_password_token :string +# reset_password_token :string(255) # sign_in_count :integer default(0) -# surname :string +# surname :string(255) # created_at :datetime not null # updated_at :datetime not null # department_id :integer @@ -101,6 +102,29 @@ class User < ApplicationRecord has_and_belongs_to_many :notifications, dependent: :destroy, join_table: "notification_acknowledgements" + # ================================ + # = Dookeeper OAuth Associations = + # ================================ + + # Access Grants are created when a user authorizes an ApiClient to access their data via the + # OAuth workflow. They are sent back to the ApiClient as 'code' which is in turn used to + # retrieve an AccessToken + has_many :access_grants, class_name: 'Doorkeeper::AccessGrant', + foreign_key: :resource_owner_id, + dependent: :delete_all + + # Access Tokens are created when an ApiClient authenticates a User via an access grant code. + # The access token is then used instead of credentials in calls to the API. These tokens can be revoked + # by a user on their profile page. + has_many :access_tokens, class_name: 'Doorkeeper::AccessToken', + foreign_key: :resource_owner_id, + dependent: :delete_all + + # Table that stores OAuth access tokens for other external systems like ORCID + has_many :external_api_access_tokens, dependent: :destroy + accepts_nested_attributes_for :external_api_access_tokens + accepts_nested_attributes_for :plans + # =============== # = Validations = # =============== @@ -141,7 +165,7 @@ class User < ApplicationRecord # MySQL does not support standard string concatenation and since concat_ws # or concat functions do not exist for sqlite, we have to come up with this # conditional - if ActiveRecord::Base.connection.adapter_name == "Mysql2" + if mysql_db? where("lower(concat_ws(' ', firstname, surname)) LIKE lower(?) OR " \ "lower(email) LIKE lower(?)", search_pattern, search_pattern) @@ -184,6 +208,7 @@ def self.from_omniauth(auth) def self.to_csv(users) User::AtCsv.new(users).to_csv end + # =========================== # = Public instance methods = # =========================== @@ -375,8 +400,9 @@ def get_preferences(key) end # rubocop:enable Metrics/AbcSize - # Override devise_invitable email title + # Override to Devise invitation emails def deliver_invitation(options = {}) + # Always override the devise_invitable email title super(options.merge(subject: _("A Data Management Plan in " \ "%{application_name} has been shared with you") % { application_name: ApplicationService.application_name }) @@ -450,6 +476,16 @@ def merge(to_be_merged) to_be_merged.destroy end + # Fetch the access token for the specified service + def access_token_for(external_service_name:) + return nil unless external_service_name.present? && external_api_access_tokens.any? + + tokens = external_api_access_tokens.select do |token| + token.external_service_name == external_service_name && token.active? + end + tokens.first + end + private # ============================ diff --git a/app/policies/api/v2/plans_policy.rb b/app/policies/api/v2/plans_policy.rb new file mode 100644 index 0000000000..1a7b6fcee7 --- /dev/null +++ b/app/policies/api/v2/plans_policy.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Api + + module V2 + + class PlansPolicy < ApplicationPolicy + + attr_reader :client, :plan + + def initialize(client, resource_owner, plan) + @client = client + @resource_owner = resource_owner + @plan = plan + end + + class Scope + + attr_reader :client + + def initialize(client, resource_owner, result_scope) + @client = client + @resource_owner = resource_owner + @result_scope = result_scope + end + + ## Return the visible plans (via the API) to a given client depending on the context + # + # If @resource_owner is present then this is a request on behalf of a User + # - return the Plans specific for the User (resource_owner) + # + # If no @resource_owner is present then this is a 'direct' request so adhere to the following rules: + # - ALL can view: public + # - when @client is an ApiClient can view: + # - anything created by the API client + # - anything belonging to the ApiClient's Org (if applicable - api_clients.org_id) + # - when @client is a User can view: + # - (when a non-admin) any privately_visible or organisationally_visible Plans + # - (when an admin) all Plans from users of their organisation + # + def resolve + return plans_for_public if @result_scope == "public" + + # If this is a :trusted ApiClient then return all plans + return Plan.where.not(visibility: Plan.visibilities[:is_test]) if @client.trusted? + + # If the caller specified that they want both public and user plans + public_plans = @result_scope == "both" ? plans_for_public : [] + + # If the resource_owner is present then return their specific Plans + plans = plans_for_user(user: @resource_owner, complete: true, mine: true) if @resource_owner.present? + return (plans + public_plans).flatten.uniq if plans.present? + + # If the Client is an Org Admin then get all of the Org's plans + plans = plans_for_org_admin + plans_for_user(user: @client.user) if @client.user&.can_org_admin? + return (plans + public_plans).flatten.uniq if plans.present? + + # Otherwise just return the User's plans + plans_for_user(user: @client.user, complete: false) + end + + private + + def plans_for_public + plans = Plan.publicly_visible.order(updated_at: :desc) + end + + # Fetch all of the User's Plans + def plans_for_user(user:, complete: false, mine: false) + plans = Plan.active(user) + plans = plans.select { |plan| plan.complete? && !plan.is_test? } if complete + plans += user.org.plans.organisationally_visible unless mine + plans.to_a.flatten.compact.uniq + end + + # Fetch all of the Plans that belong to the Admin's Org + def plans_for_org_admin + # TODO: Update this to use the new method created by @john_pinto + @client.user.can_org_admin? ? Plan.where(org: @client.user.org).reject { |p| p.is_test? } : [] + end + + end + + end + + end + +end diff --git a/app/policies/api/v2/templates_policy.rb b/app/policies/api/v2/templates_policy.rb new file mode 100644 index 0000000000..62d6aad8ef --- /dev/null +++ b/app/policies/api/v2/templates_policy.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Api + + module V2 + + class TemplatesPolicy < ApplicationPolicy + + attr_reader :client, :plan + + def initialize(client, plan) + @client = client + @plan = plan + end + + class Scope + + attr_reader :client + + def initialize(client) + @client = client + end + + ## Return the templates to a given client depending on the context + # + # - ALL can view: public + # - when @client is a User and an Org Admin can view: + # - (when an admin) all Templates for their organisation + # + def resolve + # Only return publicly visible Templates if the caller is an ApiClient + templates = public_templates + return templates unless @client.respond_to?(:user) && @client.user&.org&.present? + + org_templates(templates: templates).flatten.compact.uniq + end + + private + + # Fetch all of the User's Plans + def public_templates + Template.includes(org: :identifiers) + .joins(:org) + .published + .publicly_visible + .where(customization_of: nil) + .order(:title) + end + + # Fetch all of the Org's templates along with their customizations + def org_templates(templates: []) + + org_templates = Template.latest_version_per_org(@client.user.org).published + custs = Template.latest_customized_version_per_org(@client.user.org).published + return (templates + org_templates).sort{ |a, b| a.title <=> b.title } unless custs.any? + + # Remove any templates that were customized by the org, we will use their customization + templates.reject { |t| custs.map { |c| c.customization_of }.include?(t.family_id) } + + (org_templates + custs + templates).sort{ |a, b| a.title <=> b.title } + end + + end + + end + + end + +end diff --git a/app/policies/api_client_policy.rb b/app/policies/api_client_policy.rb index 8334fdee2a..bf241bd053 100644 --- a/app/policies/api_client_policy.rb +++ b/app/policies/api_client_policy.rb @@ -2,10 +2,11 @@ class ApiClientPolicy < ApplicationPolicy - def initialize(user, *_args) + def initialize(user, api_client) raise Pundit::NotAuthorizedError, _("must be logged in") unless user @user = user + @api_client = api_client end def index? @@ -17,7 +18,8 @@ def new? end def create? - @user.can_super_admin? + # Super admin or the user can do this for themselves + @user.can_super_admin? || @user.id == @api_client.user_id end def edit? @@ -25,7 +27,8 @@ def edit? end def update? - @user.can_super_admin? + # Super admin or the user can do this for themselves + @user.can_super_admin? || @user.id == @api_client.user_id end def destroy? @@ -33,7 +36,8 @@ def destroy? end def refresh_credentials? - @user.can_super_admin? + # Super admin or the user can do this for themselves + @user.can_super_admin? || @user.id == @api_client.user_id end def email_credentials? diff --git a/app/policies/org_policy.rb b/app/policies/org_policy.rb index 8a927ced14..a1fe23ea52 100644 --- a/app/policies/org_policy.rb +++ b/app/policies/org_policy.rb @@ -59,4 +59,13 @@ def merge_commit? user.can_super_admin? end +# --------------------------------------------------------- +# Start DMPTool customization +# --------------------------------------------------------- + def public? + true + end +# --------------------------------------------------------- +# End DMPTool customization +# --------------------------------------------------------- end diff --git a/app/policies/plan_policy.rb b/app/policies/plan_policy.rb index 3ea2637022..1d38431e0f 100644 --- a/app/policies/plan_policy.rb +++ b/app/policies/plan_policy.rb @@ -20,7 +20,7 @@ def show? @plan.readable_by?(@user.id) end - def share? + def publish? @plan.editable_by?(@user.id) || (@user.can_org_admin? && @user.org.plans.include?(@plan)) @@ -82,4 +82,12 @@ def update_guidances_list? @plan.editable_by?(@user.id) end + def mint? + @plan.owner == @user || @user.can_super_admin? + end + + def add_orcid_work? + @plan.administerable_by?(@user.id) + end + end diff --git a/app/policies/research_output_policy.rb b/app/policies/research_output_policy.rb new file mode 100644 index 0000000000..8b79ddf0bb --- /dev/null +++ b/app/policies/research_output_policy.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class ResearchOutputPolicy < ApplicationPolicy + + attr_reader :user, :research_output + + def initialize(user, research_output) + raise Pundit::NotAuthorizedError, _("must be logged in") unless user + + unless research_output.present? + raise Pundit::NotAuthorizedError, _("are not authorized to view that plan") + end + + @user = user + @research_output = research_output + super + end + + def index? + @research_output.plan.readable_by?(@user.id) + end + + def new? + @research_output.plan.administerable_by?(@user.id) + end + + def edit? + @research_output.plan.administerable_by?(@user.id) + end + + def create? + @research_output.plan.administerable_by?(@user.id) + end + + def update? + @research_output.plan.administerable_by?(@user.id) + end + + def destroy? + @research_output.plan.administerable_by?(@user.id) + end + + def select_output_type? + @research_output.plan.administerable_by?(@user.id) + end + + def select_license? + @research_output.plan.administerable_by?(@user.id) + end + + def repository_search? + @research_output.plan.administerable_by?(@user.id) + end + + def metadata_standard_search? + @research_output.plan.administerable_by?(@user.id) + end + +end diff --git a/app/policies/template_policy.rb b/app/policies/template_policy.rb index 7a30916a23..546fccb26e 100644 --- a/app/policies/template_policy.rb +++ b/app/policies/template_policy.rb @@ -88,4 +88,14 @@ def template_options? user.present? end + # DMPTool customizations to allow Org Admins to create a plan from one of their + # templates on behalf of a user + def email? + user.can_super_admin? || (user.can_modify_templates? && template.org_id == user.org_id) + end + + def invite? + user.can_super_admin? || (user.can_modify_templates? && template.org_id == user.org_id) + end + end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 0a404c34a5..217cf5930c 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -80,6 +80,19 @@ def org_admin_other_user? signed_in_user.can_super_admin? || signed_in_user.can_org_admin? end + def revoke_oauth_access_token? + # An OauthToken can be revoked by a SuperAdmin or the Current User (for themself) + signed_in_user.can_super_admin? || signed_in_user == user + end + + def third_party_apps? + signed_in_user == user + end + + def developer_tools? + signed_in_user == user + end + class Scope < Scope def resolve diff --git a/app/presenters/api/v1/api_presenter.rb b/app/presenters/api/v1/api_presenter.rb new file mode 100644 index 0000000000..272b3f5d61 --- /dev/null +++ b/app/presenters/api/v1/api_presenter.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Api + + module V1 + + class ApiPresenter + + class << self + + def boolean_to_yes_no_unknown(value:) + return "unknown" unless value.present? + + value ? "yes" : "no" + end + + end + + end + + end + +end diff --git a/app/presenters/api/v1/contributor_presenter.rb b/app/presenters/api/v1/contributor_presenter.rb index 6970433bd7..4be9b5ba87 100644 --- a/app/presenters/api/v1/contributor_presenter.rb +++ b/app/presenters/api/v1/contributor_presenter.rb @@ -13,11 +13,11 @@ def role_as_uri(role:) return nil unless role.present? return "other" if role.to_s.downcase == "other" - "#{Contributor::ONTOLOGY_BASE_URL}/#{role.to_s.downcase.gsub('_', '-')}" + "#{Contributor::ONTOLOGY_BASE_URL}#{role.to_s.downcase.gsub('_', '-')}" end def contributor_id(identifiers:) - identifiers.select { |id| id.identifier_scheme.name == "orcid" }.first + identifiers.select { |id| id.identifier_scheme&.name == "orcid" }.first end end diff --git a/app/presenters/api/v1/org_presenter.rb b/app/presenters/api/v1/org_presenter.rb index aa430c96fc..32c543c915 100644 --- a/app/presenters/api/v1/org_presenter.rb +++ b/app/presenters/api/v1/org_presenter.rb @@ -8,11 +8,11 @@ class OrgPresenter class << self - def affiliation_id(identifiers:) - ident = identifiers.select { |id| id.identifier_scheme&.name == "ror" }.first - return ident if ident.present? + def affiliation_id(identifiers:, fundref: false) + ident = identifiers.select { |id| id.identifier_scheme&.name == "fundref" }.first if fundref + return ident if ident.present? && fundref - identifiers.select { |id| id.identifier_scheme&.name == "fundref" }.first + identifiers.select { |id| id.identifier_scheme&.name == "ror" }.first end end diff --git a/app/presenters/api/v1/plan_presenter.rb b/app/presenters/api/v1/plan_presenter.rb index a8e2510390..c83a176c18 100644 --- a/app/presenters/api/v1/plan_presenter.rb +++ b/app/presenters/api/v1/plan_presenter.rb @@ -6,9 +6,7 @@ module V1 class PlanPresenter - attr_reader :data_contact - attr_reader :contributors - attr_reader :costs + attr_reader :data_contact, :contributors, :costs def initialize(plan:) @contributors = [] @@ -30,15 +28,34 @@ def initialize(plan:) # Extract the ARK or DOI for the DMP OR use its URL if none exists def identifier - doi = @plan.identifiers.select do |id| - %w[ark doi].include?(id.identifier_format) - end - return doi.first if doi.first.present? + return @plan.dmp_id if @plan.dmp_id.present? # if no DOI then use the URL for the API's 'show' method Identifier.new(value: Rails.application.routes.url_helpers.api_v1_plan_url(@plan)) end + # Related identifiers for the Plan + def links + { + download: Rails.application.routes.url_helpers.plan_export_url(@plan, format: :pdf, "export[form]": true) + } + end + + # Subscribers of the Plan + def subscriptions + @plan.subscriptions.map do |subscription| + { + actions: ["PUT"], + name: subscription.subscriber&.name, + callback: subscription.callback_uri + } + end + end + + def visibility + @plan.visibility == "publicly_visible" ? "public" : "private" + end + private # Retrieve the answers that have the Budget theme diff --git a/app/presenters/api/v1/research_output_presenter.rb b/app/presenters/api/v1/research_output_presenter.rb new file mode 100644 index 0000000000..851e5837da --- /dev/null +++ b/app/presenters/api/v1/research_output_presenter.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Api + + module V1 + + class ResearchOutputPresenter + + attr_reader :dataset_id, :preservation_statement, :security_and_privacy, :license_start_date, + :data_quality_assurance, :distributions, :metadata, :technical_resources + + def initialize(output:) + @research_output = output + return unless output.is_a?(ResearchOutput) + + @plan = output.plan + @dataset_id = identifier + + load_narrative_content + + @license_start_date = determine_license_start_date(output: output) + end + + private + + def identifier + Identifier.new(identifiable: @research_output, value: @research_output.id) + end + + def determine_license_start_date(output:) + return nil unless output.present? + return output.release_date.to_formatted_s(:iso8601) if output.release_date.present? + + output.created_at.to_formatted_s(:iso8601) + end + + def load_narrative_content + @preservation_statement = "" + @security_and_privacy = [] + @data_quality_assurance = "" + + # Disabling rubocop here since a guard clause would make the line too long + # rubocop:disable Style/GuardClause + if Rails.configuration.x.madmp.extract_preservation_statements_from_themed_questions + @preservation_statement = fetch_q_and_a_as_single_statement(themes: %w[Preservation]) + end + if Rails.configuration.x.madmp.extract_security_privacy_statements_from_themed_questions + @security_and_privacy = fetch_q_and_a(themes: ["Ethics & privacy", "Storage & security"]) + end + if Rails.configuration.x.madmp.extract_data_quality_statements_from_themed_questions + @data_quality_assurance = fetch_q_and_a_as_single_statement(themes: ["Data Collection"]) + end + # rubocop:enable Style/GuardClause + end + + def fetch_q_and_a_as_single_statement(themes:) + fetch_q_and_a(themes: themes).collect { |item| item[:description] }.join("
") + end + + 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 + + end + + end + +end diff --git a/app/presenters/api/v2/plan_presenter.rb b/app/presenters/api/v2/plan_presenter.rb new file mode 100644 index 0000000000..3f42481ec8 --- /dev/null +++ b/app/presenters/api/v2/plan_presenter.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Api + + module V2 + + class PlanPresenter + + attr_reader :data_contact, :contributors, :costs, :client + + def initialize(plan:, client:) + @contributors = [] + return unless plan.present? + + @helpers = Rails.application.routes.url_helpers + @plan = plan + @client = client + + @data_contact = @plan.owner + + @plan.contributors.each do |contributor| + # If there is no owner for the plan, use the user with the data_curation role + @data_contact = contributor if contributor.data_curation? && @data_contact.nil? + @contributors << contributor + end + + @costs = plan_costs(plan: @plan) + end + + # Extract the ARK or DOI for the DMP OR use its URL if none exists + def identifier + dmp_id = @plan.dmp_id + return dmp_id if dmp_id.present? + + # if no DOI then use the URL for the API's 'show' method + Identifier.new(value: @helpers.api_v2_plan_url(@plan)) + end + + # Extract the calling system's identifier for the Plan if available + def external_system_identifier + scheme = IdentifierScheme.find_by(name: @client.name.downcase) + + ids = @plan.identifiers.select do |id| + # Do not include the id here if it is the grant id + id.identifier_scheme == scheme && id.id != @plan.grant_id + end + ids.last + end + + # Related identifiers for the Plan + def links + ret = { + get: @helpers.api_v2_plan_url(@plan) + } + + # If the plan is publicly visible or the request has permissions then include the PDF download URL + if @plan.publicly_visible? || + (@client.is_a?(User) && @plan.owner_and_coowners.include?(@client)) || + (@client.is_a?(User) && @plan.org_id == @plan.owner&.org_id) || + (@client.is_a?(ApiClient) && @client.access_tokens.select { |t| t.resource_owner_id == @plan.owner }) + ret[:download] = @helpers.api_v2_plan_url(@plan, format: :pdf) + end + ret + end + + # Subscribers of the Plan + def subscriptions + @plan.subscriptions.map do |subscription| + { + actions: ["PUT"], + name: subscription.subscriber.name, + callback: subscription.callback_uri + } + end + end + + def visibility + @plan.visibility == "publicly_visible" ? "public" : "private" + end + + private + + # Retrieve the answers that have the Budget theme + def plan_costs(plan:) + theme = Theme.where(title: "Cost").first + return [] unless theme.present? + + # TODO: define a new 'Currency' question type that includes a float field + # any currency type selector (e.g GBP or USD) + answers = plan.answers.includes(question: :themes).select do |answer| + answer.question.themes.include?(theme) + end + + answers.map do |answer| + # TODO: Investigate whether question level guidance should be the description + { title: answer.question.text, description: nil, + currency_code: "usd", value: answer.text } + end + end + + end + + end + +end diff --git a/app/presenters/contributor_presenter.rb b/app/presenters/contributor_presenter.rb index 91ce18bab2..e2b1c03689 100644 --- a/app/presenters/contributor_presenter.rb +++ b/app/presenters/contributor_presenter.rb @@ -40,13 +40,13 @@ def roles_for_radio(contributor:) def role_symbol_to_string(symbol:) case symbol when :data_curation - "Data Manager" + _("Data Manager") when :project_administration - "Project Administrator" + _("Project Administrator") when :investigation - "Principal Investigator" + _("Principal Investigator") else - "Other" + _("Other") end end diff --git a/app/presenters/dmptool/org_presenter.rb b/app/presenters/dmptool/org_presenter.rb new file mode 100644 index 0000000000..29784da7b4 --- /dev/null +++ b/app/presenters/dmptool/org_presenter.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Dmptool + + class OrgPresenter + + include Rails.application.routes.url_helpers + + def initialize + @shib = IdentifierScheme.by_name("shibboleth").first + end + + def participating_orgs + # Org.includes(identifiers: [:identifier_scheme]).shibbolized.order(:name) + Org.participating.order(:name) + end + + def sign_in_url(org:) + return nil unless org.present? && @shib.present? + + "#{shibboleth_ds_path}/#{org.id}" + end + + end + +end diff --git a/app/presenters/related_identifier_presenter.rb b/app/presenters/related_identifier_presenter.rb new file mode 100644 index 0000000000..249bb06a38 --- /dev/null +++ b/app/presenters/related_identifier_presenter.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class RelatedIdentifierPresenter + + attr_accessor :related_identifiers + + def initialize(plan:) + @related_identifiers = plan.related_identifiers + @related_identifiers = @related_identifiers.order(:work_type, :created_at) + end + + # Returns all of the work types for the select box + def selectable_related_identifiers + RelatedIdentifier.work_types.keys.map { |key| [key.humanize, key] } + end + + # Return the related identifiers for read only display + def for_display + return [] unless related_identifiers.any? + + related_identifiers.map do |related| + next unless related.is_a?(RelatedIdentifier) + + dflt = "#{related.work_type&.humanize} - #{related.value}" + link = "%{work_type} - %{url}" % { + work_type: related.work_type&.humanize, + url: related.value + } + related.citation.present? ? related.citation : (related.value&.start_with?("http") ? link : dflt) + end + end + +end \ No newline at end of file diff --git a/app/presenters/research_output_presenter.rb b/app/presenters/research_output_presenter.rb new file mode 100644 index 0000000000..74f0d007f1 --- /dev/null +++ b/app/presenters/research_output_presenter.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +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) + 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 + + # 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/api/v1/auth/jwt/authentication_service.rb b/app/services/api/v1/auth/jwt/authentication_service.rb index 59d03dc1c5..e040f476f1 100644 --- a/app/services/api/v1/auth/jwt/authentication_service.rb +++ b/app/services/api/v1/auth/jwt/authentication_service.rb @@ -12,8 +12,8 @@ module Jwt # # ApiClients (aka machines) with the following JSON body: { # "grant_type": "client_credentials", - # "client_id": "[api_clients.client_id]", - # "client_secret": "[api_clients.client_secret]", + # "client_id": "[oauth_applications.uid]", + # "client_secret": "[oauth_applications.secret]", # } # # Users with the following JSON body: { @@ -87,7 +87,7 @@ def authenticate_client return nil unless clients.present? && clients.any? clnt = clients.first - clnt.authenticate(secret: @client_secret) ? clnt : nil + clnt.secret == @client_secret ? clnt : nil end # Tries to find a User whose email matches the :client_id. If found diff --git a/app/services/api/v1/deserialization/plan.rb b/app/services/api/v1/deserialization/plan.rb index 5fbd2101bc..08b4ff984a 100644 --- a/app/services/api/v1/deserialization/plan.rb +++ b/app/services/api/v1/deserialization/plan.rb @@ -85,7 +85,7 @@ def find_or_initialize(id_json:, json: {}) id = id_json[:identifier] if id_json.is_a?(Hash) if id.present? - if Api::V1::DeserializationService.doi?(value: id) + if Api::V1::DeserializationService.dmp_id?(value: id) # Find by the DOI or ARK plan = Api::V1::DeserializationService.object_from_identifier( class_name: "Plan", json: id_json diff --git a/app/services/api/v1/deserialization_service.rb b/app/services/api/v1/deserialization_service.rb index 2291781a27..5d15e459d8 100644 --- a/app/services/api/v1/deserialization_service.rb +++ b/app/services/api/v1/deserialization_service.rb @@ -67,13 +67,13 @@ def app_extensions(json: {}) end # Determines whether or not the value is a DOI/ARK - def doi?(value:) + def dmp_id?(value:) return false unless value.present? # The format must match a DOI or ARK and a DOI IdentifierScheme # must also be present! identifier = ::Identifier.new(value: value) - scheme = ::IdentifierScheme.find_by(name: "doi") + scheme = ::ExternalApis::DmpIdService.identifier_scheme %w[ark doi].include?(identifier.identifier_format) && scheme.present? end diff --git a/app/services/api/v2/contextual_error_service.rb b/app/services/api/v2/contextual_error_service.rb new file mode 100644 index 0000000000..2e2d25686c --- /dev/null +++ b/app/services/api/v2/contextual_error_service.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module Api + + module V2 + + class ContextualErrorService + + class << self + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def contextualize_errors(plan:) + errs = [] + return errs unless plan.present? + + plan.research_outputs.each do |dataset| + d_errs = find_dataset_errors(dataset: dataset) + errs << d_errs if d_errs.present? + end + + plan.contributors.each do |contributor| + c_errs = find_contributor_errors(contributor: contributor) + errs << c_errs if c_errs.present? + end + + p_errs = find_project_errors(plan: plan) + errs << p_errs if p_errs.present? + + plan.identifiers.each do |id| + errs << "identifier: '#{id.value}' - #{id.errors.full_messages}" unless id.valid? + end + errs << "Plan: #{plan.errors.full_messages}" unless plan.valid? + errs.flatten.uniq + errs + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + private + + # Contextualize errors with the Project and its children + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def find_project_errors(plan:) + errs = [] + return errs unless plan.present? && !plan.valid? + + a_errs = find_org_errors(org: plan.funder) if plan.funder.present? + errs << a_errs if a_errs.any? + + unless plan.grant.present? && plan.grant.valid? + g_errs = "grant identifier '#{plan.grant.value}' : #{plan.grant.errors.full_messages}" + end + + errs = errs.flatten.uniq + errs.any? ? ["Project : #{errs}"] : [] + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + # Contextualize errors with the Dataset and its children + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def find_dataset_errors(dataset:) + errs = [] + return errs unless dataset.present? && !dataset.valid? + + errs << dataset.errors.full_messages + errs = errs.flatten.uniq + errs.any? ? ["Dataset : #{errs}"] : [] + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + # Contextualize errors with the Affiliation and its children + def find_org_errors(org:) + errs = [] + return errs unless org.present? && !org.valid? + + id_errs = org.identifiers.map do |id| + next if id.valid? + + "identifier '#{id.value}' : #{id.errors.full_messages}" + end + errs << id_errs if id_errs.any? + errs << org.errors.full_messages + errs = errs.flatten.uniq + errs.any? ? ["Affiliation: '#{org.name}' : #{errs}"] : [] + end + + # Contextualize errors with the ContributorDataManagementPlan and its children + # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + def find_contributor_errors(contributor:) + errs = [] + return errs unless contributor.present? && !contributor.valid? + + a_err = find_org_errors(org: contributor.org) + errs << a_err if a_err.present? + + id_errs = contributor.identifiers.map do |id| + next if id.valid? + + "identifier '#{id.value}' : #{id.errors.full_messages}" + end + errs << id_errs if id_errs.any? + errs << contributor.errors.full_messages + errs = errs.flatten.uniq + errs.any? ? ["Contributor/Contact: '#{contributor&.name}' : #{errs}"] : [] + end + # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + + end + + end + + end + +end diff --git a/app/services/api/v2/conversion_service.rb b/app/services/api/v2/conversion_service.rb new file mode 100644 index 0000000000..650c38786a --- /dev/null +++ b/app/services/api/v2/conversion_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Api + + module V2 + + class ConversionService + + class << self + + # Converts a boolean field to [yes, no, unknown] + def boolean_to_yes_no_unknown(value) + return "yes" if [true, 1].include?(value) + + return "no" if [false, 0].include?(value) + + "unknown" + end + + # Converts a [yes, no, unknown] field to boolean (or nil) + def yes_no_unknown_to_boolean(value) + return true if value&.downcase == "yes" + + return nil if value.blank? || value&.downcase == "unknown" + + false + end + + # Converts the context and value into an Identifier with a psuedo + # IdentifierScheme for display in JSON partials. Which will result in: + # { type: 'context', identifier: 'value' } + def to_identifier(context:, value:) + return nil unless value.present? && context.present? + + scheme = IdentifierScheme.new(name: context) + Identifier.new(value: value, identifier_scheme: scheme) + end + + end + + end + + end + +end diff --git a/app/services/api/v2/deserialization/contributor.rb b/app/services/api/v2/deserialization/contributor.rb new file mode 100644 index 0000000000..aa611c8f1c --- /dev/null +++ b/app/services/api/v2/deserialization/contributor.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Api + + module V2 + + module Deserialization + + class Contributor + + class << self + + # Convert the incoming JSON into a Contributor + # { + # "role": [ + # "http://credit.niso.org/contributor-roles/project-administration" + # ], + # "name": "Jane Doe", + # "mbox": "jane.doe@university.edu", + # "affiliation": { + # "name": "University of Somewhere", + # "abbreviation": "UofS", + # "affiliation_id": { + # "type": "ror", + # "identifier": "https://ror.org/43y4g4" + # } + # }, + # "contributor_id": { + # "type": "orcid", + # "identifier": "0000-0000-0000-0000" + # } + # } + def deserialize(json: {}, is_contact: false) + return nil unless Api::V2::JsonValidationService.contributor_valid?( + json: json, is_contact: is_contact + ) + + json = json.with_indifferent_access + + # Try to find the Contributor or initialize a new one + id_json = json.fetch(:contributor_id, json.fetch(:contact_id, {})) + contrib = find_or_initialize(id_json: id_json, json: json) + return nil unless contrib.present? + + # Attach the Org unless its already defined + contrib.org = Api::V2::Deserialization::Org.deserialize(json: json[:affiliation]) + + # Attach the identifier + contrib = Api::V2::DeserializationService.attach_identifier( + object: contrib, json: id_json + ) + + # Assign the roles + contrib = assign_contact_roles(contributor: contrib) if is_contact + assign_roles(contributor: contrib, json: json) + end + + # =================== + # = PRIVATE METHODS = + # =================== + + private + + # Each plan's contributors are unique records, so if we found a + # match we need to dup it, otherwise initialize a new one + def find_or_initialize(id_json:, json: {}) + return nil unless json.present? + + contrib = Api::V2::DeserializationService.object_from_identifier( + class_name: "Contributor", json: id_json + ) + return duplicate_contributor(contributor: contrib) if contrib.present? + + if json[:mbox].present? + # Try to find by email + contrib = ::Contributor.where("LOWER(email) = ?", json[:mbox]&.downcase).last + return duplicate_contributor(contributor: contrib) if contrib.present? + end + + ::Contributor.new(name: json[:name], email: json[:mbox]) + end + + def duplicate_contributor(contributor:) + return nil unless contributor.present? + + contrib = contributor.dup + contrib.plan = nil + contrib + end + + # Assign the default Contact roles + def assign_contact_roles(contributor:) + return contributor unless contributor.present? + + contributor.data_curation = true + contributor + end + + # Assign the specified roles + def assign_roles(contributor:, json: {}) + return contributor unless contributor.present? && json.present? && json[:role].present? + + json.fetch(:role, []).each do |url| + role = Api::V2::DeserializationService.translate_role(role: url) + contributor.send(:"#{role}=", true) if role.present? + end + contributor + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v2/deserialization/dataset.rb b/app/services/api/v2/deserialization/dataset.rb new file mode 100644 index 0000000000..9b9ff82c94 --- /dev/null +++ b/app/services/api/v2/deserialization/dataset.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +module Api + + module V2 + + module Deserialization + + class Dataset + + class << self + + # Convert incoming JSON into a Dataset + # { + # "type": "dataset", + # "title": "My first test dataset", + # "description": "

This is going to be great!!!

", + # "personal_data": "unknown", + # "sensitive_data": "yes", + # "issued": "2022-05-13T00:00:00Z", + # "preservation_statement": "Question: Which data are of long-term value and should be retained, shared, and/or preserved?
Answer:

I don't know.

\r\n

eebetbet


Question: What is the long-term preservation plan for the dataset?
Answer:

We will definitely do something.

\r\n

eebetbet

", + # "security_and_privacy": [ + # { + # "title": "Ethics & privacy", + # "description": [ + # "Question: Will your project involve sensitive data? Examples include: traditional knowledge, archeological artifacts, endangered species, medical data, and human subject research.
Answer:

Probably.

\r\n

Time will tell.

", + # "Question: How will you manage access and security?
Answer:

Very carefully.

", + # ] + # }, + # ], + # "data_quality_assurance": "Question: How will the data be collected or created?
Answer:

Through various instruments.


Question: What standards and methodologies will be utilized for data collection and management?
Answer:

Only the best.

", + # "dataset_id": { "type": "other", "identifier": "1" }, + # "distribution": [ + # { + # "title": "Anticipated distribution for My first test dataset", + # "byte_size": 60129542144, + # "data_access": "open", + # "host": { + # "title": "Example Repository", + # "description": "The example repository is for DMPTool testing", + # "url": "https://example.org/repo", + # "dmproadmap_host_id": { "type": "url", "identifier": "https://www.re3data.org/api/v1/repository/r3d10000XXXX" } + # }, + # "license": [ + # { + # "license_ref": "http://spdx.org/licenses/Artistic-1.0.json", + # "start_date": "2022-05-13T00:00:00Z" + # } + # ] + # } + # ], + # "metadata": [ + # { + # "description": "Dublin Core - A basic, domain-agnostic standard which can be easily understood ...", + # "metadata_standard_id": { "type": "url", "identifier": "https://rdamsc.bath.ac.uk/api2/m15" } + # } + # ], + # "technical_resource": [] + # } + def deserialize(plan:, json: {}) + return nil unless Api::V2::JsonValidationService.dataset_valid?(json: json) + + json = json.with_indifferent_access + # Try to find the Dataset or initialize a new one + research_output = find_by_identifier(plan: plan, json: json[:dataset_id]) + # TODO: remove this once we support versioning and are not storing these as RelatedIdentifiers + return research_output if research_output.is_a?(RelatedIdentifier) + + research_output = find_or_initialize(plan: plan, json: json) unless research_output.present? + return nil unless research_output.present? && research_output.title.present? + + research_output.description = json[:description] if json[:description].present? + research_output.personal_data = Api::V2::ConversionService.yes_no_unknown_to_boolean(json[:personal_data]) + research_output.sensitive_data = Api::V2::ConversionService.yes_no_unknown_to_boolean(json[:sensitive_data]) + research_output.release_date = Api::V2::DeserializationService.safe_date(value: json.fetch(:issued, Time.now)) + + research_output = attach_metadata(research_output: research_output, json: json[:metadata]) + deserialize_distribution(research_output: research_output, json: json[:distribution]) + end + + private + + def find_by_identifier(plan:, json:) + return nil unless json.is_a?(Hash) && json[:identifier].present? + + # Find by identifier if its available + id = json[:identifier] + if id.present? + if Api::V2::DeserializationService.dmp_id?(value: id) + # Find by the DOI or ARK + # TODO: Swap this out once we support versioning which will allow us to update + # the actual ResearchOutput metadata. For now we will record it as a RelatedIdentifier + # + # research_output = Api::V2::DeserializationService.object_from_identifier( + # class_name: "ResearchOutput", json: json + # ) + id = id.start_with?("http") ? id : "http://doi.org/#{id.gsub("doi:", "")}" + research_output = RelatedIdentifier.find_or_initialize_by( + identifiable: plan, + identifier_type: "DOI", + relation_type: "IsReferencedBy", + value: id + ) + else + research_output = ::ResearchOutput.find_by(plan: plan, id: id) + end + end + research_output + end + + # Find the dateset by ID or title + plan + def find_or_initialize(plan:, json: {}) + return nil unless json.present? + + research_output = ::ResearchOutput.find_or_initialize_by(title: json[:title], plan: plan) + research_output.output_type = json[:type] || "dataset" if research_output.new_record? + + Api::V2::DeserializationService.attach_identifier(object: research_output, json: json[:dataset_id]) + end + + # Add any metadata standards + def attach_metadata(research_output:, json:) + return research_output unless json.is_a?(Array) + + json.select { |h| h.fetch(:metadata_standard_id, {})[:identifier].present? }.each do |hash| + # Try to find the MetadataStandard by the identifier + metadata_standard = ::MetadataStandard.find_by( + uri: hash[:metadata_standard_id][:identifier], description: hash[:description] + ) + next if metadata_standard.nil? || research_output.metadata_standards.include?(metadata_standard) + + research_output.metadata_standards << metadata_standard + end + research_output + end + + # Add any distribution level data to the research output + def deserialize_distribution(research_output:, json:) + return research_output unless research_output.present? && json.is_a?(Array) + + json.each do |distribution| + # Try to locate the hosts from our list of Repositories + research_output = attach_repositories(research_output: research_output, json: distribution[:host]) + research_output = attach_licenses(research_output: research_output, json: distribution[:license]) + research_output.byte_size = distribution[:byte_size] + research_output.access = distribution[:data_access] + end + research_output + end + + def attach_repositories(research_output:, json:) + return research_output unless research_output.present? && json.is_a?(Hash) + + uri = json.fetch(:dmproadmap_host_id, {})[:identifier] + if json[:url].present? || uri.present? + repository = ::Repository.find_by(uri: uri) if uri.present? + repository = ::Repository.find_by(homepage: json[:url]) unless repository.present? + return research_output if repository.nil? || + research_output.repositories.include?(repository) + + research_output.repositories << repository + end + research_output + end + + def attach_licenses(research_output:, json:) + return research_output unless research_output.present? && json.is_a?(Array) + + # Attempt to grab the current license + licenses = json.sort { |a, b| a[:start_date] <=> b[:start_date] } + prior_licenses = licenses.select do |license| + date = Api::V2::DeserializationService.safe_date(value: license[:start_date]) + date <= Time.now + end + + # If there are no current licenses then just grab the first one + license = prior_licenses.any? ? prior_licenses.last : json.first + license = License.find_by(uri: license[:license_ref]) + + research_output.license = license if license.present? + research_output + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v2/deserialization/funding.rb b/app/services/api/v2/deserialization/funding.rb new file mode 100644 index 0000000000..b043d3cca7 --- /dev/null +++ b/app/services/api/v2/deserialization/funding.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Api + + module V2 + + module Deserialization + + class Funding + + class << self + + # Convert the funding information and attach to the Plan + # { + # "$ref": "SEE Org.deserialize! for details", + # "grant_id": { + # "$ref": "SEE Identifier.deserialize for details" + # }, + # "funding_status": "granted" + # } + def deserialize(plan:, json: {}) + return nil unless plan.present? + return plan unless Api::V2::JsonValidationService.funding_valid?(json: json) + + # Attach the Funder + plan.funder = Api::V2::Deserialization::Org.deserialize(json: json) + + opportunity = json.fetch(:dmproadmap_funding_opportunity_id, {}) + plan.identifier = opportunity[:identifier] if opportunity[:identifier].present? + + plan.funding_status = Api::V2::DeserializationService.translate_funding_status( + status: json[:funding_status] + ) + return plan unless json[:grant_id].present? + + # Attach the grant Identifier to the Plan if present + # Attach the identifier + plan.grant = Api::V2::Deserialization::Identifier.deserialize( + class_name: plan.class.name, json: json[:grant_id] + ) + plan + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v2/deserialization/identifier.rb b/app/services/api/v2/deserialization/identifier.rb new file mode 100644 index 0000000000..25a23d1e86 --- /dev/null +++ b/app/services/api/v2/deserialization/identifier.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Api + + module V2 + + module Deserialization + + class Identifier + + class << self + + # Convert the incoming JSON into an Identifier + # { + # "type": "ror", + # "identifier": "https://ror.org/43y4g4" + # } + def deserialize(class_name:, json: {}) + return nil unless class_name.present? && + Api::V2::JsonValidationService.identifier_valid?(json: json) + + json = json.with_indifferent_access + scheme = ::IdentifierScheme.by_name(json[:type].downcase).first + + # If the scheme is present then this is a identifier that must be + # unique (e.g. ROR, ORCID) so try to find it + if scheme.present? + val = json[:identifier] if json[:identifier].start_with?(scheme.identifier_prefix) + val = "#{scheme.identifier_prefix}#{json[:identifier]}" unless val.present? + identifier = ::Identifier.by_scheme_name(scheme, class_name).where(value: val).first + return identifier if identifier.present? + end + + ::Identifier.new(identifier_scheme: scheme, value: json[:identifier]) + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v2/deserialization/org.rb b/app/services/api/v2/deserialization/org.rb new file mode 100644 index 0000000000..d88f429dc3 --- /dev/null +++ b/app/services/api/v2/deserialization/org.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Api + + module V2 + + module Deserialization + + class Org + + class << self + + # Convert the incoming JSON into an Org + # { + # "name": "University of Somewhere", + # "abbreviation": "UofS", + # "affiliation_id": { + # "type": "ror", + # "identifier": "https://ror.org/43y4g4" + # } + # } + def deserialize(json: {}) + return nil unless Api::V2::JsonValidationService.org_valid?(json: json) + + json = json.with_indifferent_access + + # Try to find the Org by the identifier + id_json = json.fetch(:affiliation_id, json.fetch(:funder_id, {})) + org = Api::V2::DeserializationService.object_from_identifier( + class_name: "Org", json: id_json + ) + return org if org.present? + + # Try to find the Org by name + org = find_by_name(json: json) + return org if org.present? && !org.new_record? + + # Org model requires a language so just use the default for now + org.language = Language.default + org.abbreviation = json[:abbreviation] if json[:abbreviation].present? + return nil unless org.valid? + return org unless id_json[:identifier].present? + + # Attach the identifier + Api::V2::DeserializationService.attach_identifier(object: org, json: id_json) + end + + # =================== + # = PRIVATE METHODS = + # =================== + + private + + # Search for an Org locally and then externally if not found + def find_by_name(json: {}) + return nil unless json.present? && json[:name].present? + + name = json[:name] + + # Search the DB + org = ::Org.where("LOWER(name) = ?", name.downcase).first + return org if org.present? + + # External ROR search + results = OrgSelection::SearchService.search_externally( + search_term: name + ) + + # Grab the closest match - only caring about results that 'contain' + # the name with preference to those that start with the name + result = results.select { |r| %i[0 1].include?(r[:weight]) }.first + + # If no good result was found just use the specified name + result ||= { name: name } + OrgSelection::HashToOrgService.to_org(hash: result) + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v2/deserialization/plan.rb b/app/services/api/v2/deserialization/plan.rb new file mode 100644 index 0000000000..88e7807248 --- /dev/null +++ b/app/services/api/v2/deserialization/plan.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +module Api + + module V2 + + module Deserialization + + class Plan + + class << self + + # Convert the incoming JSON into a Plan + # { + # "dmp": { + # "created": "2020-03-26T11:52:00Z", + # "title": "Brain impairment caused by COVID-19", + # "description": "DMP for COVID-19 Brain analysis", + # "language": "eng", + # "ethical_issues_exist": "yes", + # "ethical_issues_description": "We will need to anonymize data", + # "ethical_issues_report": "https://university.edu/ethics/policy.pdf", + # "contact": { + # "$ref": "SEE Contributor.deserialize! for details" + # }, + # "contributor": [{ + # "$ref": "SEE Contributor.deserialize! for details" + # }], + # "project": [{ + # "title": "Brain impairment caused by COVID-19", + # "description": "Brain stem comparisons of COVID-19 patients", + # "start": "2020-03-01T12:33:44Z", + # "end": "2023-03-31T12:33:44Z", + # "funding": [{ + # "$ref": "SEE Funding.deserialize! for details" + # }] + # }], + # "dataset": [{ + # "$ref": "SEE Dataset.deserialize! for details" + # }], + # "extension": [{ + # "dmproadmap": { + # "template": { + # "id": 123, + # "title": "Generic Data Management Plan" + # } + # } + # }] + # } + # } + def deserialize(json: {}) + return nil unless Api::V2::JsonValidationService.plan_valid?(json: json) + + json = json.with_indifferent_access + # Try to find the Contributor or initialize a new one + id_json = json.fetch(:dmp_id, {}) + plan = find_or_initialize(id_json: id_json, json: json) + return nil unless plan.present? && plan.template.present? + + plan.description = json[:description] if json[:description].present? + issues = Api::V2::ConversionService.yes_no_unknown_to_boolean( + json[:ethical_issues_exist] + ) + plan.ethical_issues = issues + plan.ethical_issues_description = json[:ethical_issues_description] + plan.ethical_issues_report = json[:ethical_issues_report] + + # TODO: Handle ethical issues when the Question is in place + + # Process Project, Contributors and Data Contact and Datsets + plan = deserialize_project(plan: plan, json: json) + # The contact is handled from within the controller since the Plan.add_user! method + # requires that the Plan has been persisted to the DB + plan = deserialize_contributors(plan: plan, json: json) + deserialize_datasets(plan: plan, json: json) + end + + # =================== + # = PRIVATE METHODS = + # =================== + + private + + def find_or_initialize(id_json:, json: {}) + return nil unless json.present? + + id = id_json[:identifier] if id_json.is_a?(Hash) + schm = IdentifierScheme.find_by(name: id_json[:type].downcase) if id.present? + + if id.present? + # If the identifier is a DOI/ARK or the api client's internal id for the DMP + if Api::V2::DeserializationService.dmp_id?(value: id) + # Find by the DOI or ARK + plan = Api::V2::DeserializationService.object_from_identifier( + class_name: "Plan", json: id_json + ) + elsif schm.present? + value = id.start_with?(schm.identifier_prefix) ? id : "#{schm.identifier_prefix}#{id}" + identifier = ::Identifier.find_by( + identifiable_type: "Plan", identifier_scheme: schm, value: value + ) + plan = identifier.identifiable if identifier.present? + else + # For URL based identifiers + begin + plan = ::Plan.find_by(id: id.split("/").last.to_i) if id.start_with?("http") + rescue StandardError => e + # Catches scenarios where the dmp_id is NOT one of our URLs + plan = nil + end + end + end + return plan if plan.present? + + template = find_template(json: json) + plan = ::Plan.new(title: json[:title], template: template) + return plan unless id.present? && schm.present? + + # If the external system provided an identifier and they have an IdentifierScheme + Api::V2::DeserializationService.attach_identifier(object: plan, json: id_json) + end + + # Deserialize the datasets and attach to plan + def deserialize_datasets(plan:, json: {}) + return plan unless json.present? && json[:dataset].present? && json[:dataset].is_a?(Array) + + research_outputs = json[:dataset].map do |dataset| + Api::V2::Deserialization::Dataset.deserialize(plan: plan, json: dataset) + end + + # TODO: remove this once we support versioning and are not storing outputs with DOIs as + # RelatedIdentifiers. Once versioning is in place we can update the existing ResearchOutputs + research_outputs.each do |output| + plan.research_outputs << output if output.is_a?(ResearchOutput) + plan.related_identifiers << output if output.is_a?(RelatedIdentifier) + end + plan + end + + # Deserialize the project information and attach to Plan + def deserialize_project(plan:, json: {}) + return plan unless json.present? && + json[:project].present? && + json[:project].is_a?(Array) + + project = json.fetch(:project, [{}]).first + plan.start_date = Api::V2::DeserializationService.safe_date(value: project[:start]) + plan.end_date = Api::V2::DeserializationService.safe_date(value: project[:end]) + return plan unless project[:funding].present? + + funding = project.fetch(:funding, []).first + return plan unless funding.present? + + Api::V2::Deserialization::Funding.deserialize(plan: plan, json: funding) + end + # rubocop:enable + + # Deserialize the contact as a Contributor + def deserialize_contact(plan:, json: {}) + return plan unless json.present? && json[:contact].present? + + contact = Api::V2::Deserialization::Contributor.deserialize( + json: json[:contact], is_contact: true + ) + return plan unless contact.present? + + plan.contributors << contact + plan.org = contact.org + plan + end + + # Deserialize each Contributor and then add to Plan + def deserialize_contributors(plan:, json: {}) + contributors = json.fetch(:contributor, []).map do |hash| + Api::V2::Deserialization::Contributor.deserialize(json: hash) + end + plan.contributors << contributors.compact.uniq if contributors.any? + plan + end + + # Lookup the Template + def find_template(json: {}) + default = Template.find_by(is_default: true) + return default unless json.present? && json.fetch(:dmproadmap_template, {})[:id].present? + + template = Template.published(json.fetch(:dmproadmap_template, {})[:id].to_i).last + template.present? ? template : default + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v2/deserialization/related_identifier.rb b/app/services/api/v2/deserialization/related_identifier.rb new file mode 100644 index 0000000000..7605b25d6f --- /dev/null +++ b/app/services/api/v2/deserialization/related_identifier.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Api + + module V2 + + module Deserialization + + class RelatedIdentifier + + class << self + + # Convert the incoming JSON into an Identifier + # { + # "descriptor": "documents", + # "type": "doi", + # "work_type": "dataset", + # "identifier": "https://doi.org/10.1234/abcd123" + # } + def deserialize(plan:, json: {}) + return nil unless plan.present? && + Api::V2::JsonValidationService.related_identifier_valid?( + json: json + ) + + json = json.with_indifferent_access + r_id = ::RelatedIdentifier.find_or_initialize_by(identifiable: plan, + value: json[:identifier]) + + relation_type = json[:descriptor] + # Note that the 'references' value is changed to 'does_reference' in this list + # because 'references' conflicts with an ActiveRecord method + relation_type = "does_reference" if relation_type == "references" + + work_type = json[:work_type].downcase if valid_work_type?(json: json) + # Default to dataset + work_type = "dataset" unless work_type.present? + + r_id.relation_type = relation_type + r_id.work_type = json[:work_type] if work_type + r_id.identifier_type = json[:type].underscore + r_id + end + + private + + def valid_work_type?(json:) + return false unless json.present? && json[:work_type].present? + + ::RelatedIdentifier.work_types.keys.include?(json[:work_type].downcase) + end + + end + + end + + end + + end + +end diff --git a/app/services/api/v2/deserialization_service.rb b/app/services/api/v2/deserialization_service.rb new file mode 100644 index 0000000000..c326ed4c7e --- /dev/null +++ b/app/services/api/v2/deserialization_service.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module Api + + module V2 + + class DeserializationService + + class << self + + # Retrieves the Plan based on a DMP ID value (either a DMP ID or API URL) + def plan_from_dmp_id(dmp_id:) + return nil unless dmp_id.present? && dmp_id[:type].present? && + dmp_id[:identifier].present? + + if %w[ark doi].include?(dmp_id[:type].downcase) + ::Identifier.find_by(identifiable_type: "Plan", value: dmp_id[:identifier]) + &.identifiable + else + ::Plan.find_by(id: dmp_id[:identifier].split("/").last) + end + end + + # Finds the object by the specified identifier + def object_from_identifier(class_name:, json:) + return nil unless class_name.present? && json.present? && + json[:type].present? && json[:identifier].present? + + clazz = "::#{class_name.capitalize}".constantize + return nil unless clazz.respond_to?(:from_identifiers) + + clazz.from_identifiers( + array: [{ name: json[:type], value: json[:identifier] }] + ) + rescue NameError + nil + end + + # Attach the identifier to the object if it does not already exist + def attach_identifier(object:, json:) + return object unless object.present? && object.respond_to?(:identifiers) && + json.present? && + json[:type].present? && json[:identifier].present? + + existing = object.identifiers.select do |id| + id.identifier_scheme&.name&.downcase == json[:type].downcase + end + return object if existing.present? + + object.identifiers << Api::V2::Deserialization::Identifier.deserialize( + class_name: object.class.name, json: json + ) + object + end + + # Translates the role in the json to a Contributor role + def translate_role(role:) + default = ::Contributor.default_role + return default unless role.present? + + role = role.to_s unless role.is_a?(String) + + # Strip off the URL if present + url = ::Contributor::ONTOLOGY_BASE_URL + role = role.gsub(url, "").downcase if role.include?(url) + role = role.gsub("-", "_") + + # Return the role if its a valid one otherwise defualt + return role if ::Contributor.new.all_roles.include?(role.downcase.to_sym) + + default + end + + # Translates the RDA Common Standard for the funding status + def translate_funding_status(status:) + case status + when "rejected" + "denied" + when "granted" + "funded" + else + "planned" + end + end + + # Retrieve any JSON schema extensions for this application + def app_extensions(json: {}) + return {} unless json.present? && json[:extension].present? + + app = ::ApplicationService.application_name.split("-").first.downcase + ext = json[:extension].select { |item| item[app.to_sym].present? } + ext.first.present? ? ext.first[app.to_sym] : {} + end + + # Determines whether or not the value is a DOI/ARK + def dmp_id?(value:) + return false unless value.present? + + # The format must match a DOI or ARK and a DOI IdentifierScheme + # must also be present! + identifier = ::Identifier.new(value: value) + scheme = DmpIdService.identifier_scheme + scheme.present? && + (identifier.identifier_format.include?("ark") || identifier.identifier_format.include?("doi")) + end + + # Converts the string into a UTC Time string + def safe_date(value:) + return nil unless value.is_a?(String) + + Time.parse(value).utc + rescue ArgumentError + value.to_s + end + + end + + end + + end + +end diff --git a/app/services/api/v2/json_validation_service.rb b/app/services/api/v2/json_validation_service.rb new file mode 100644 index 0000000000..7d7e451526 --- /dev/null +++ b/app/services/api/v2/json_validation_service.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +module Api + + module V2 + + # Service used to validate incoming JSON + class JsonValidationService + + # rubocop:disable Layout/LineLength + BAD_PLAN_MSG = _(":title and the contact's :mbox are both required fields").freeze + BAD_ID_MSG = _(":type and :identifier are required for all ids").freeze + BAD_ORG_MSG = _(":name is required for every :affiliation and :funding").freeze + BAD_CONTRIB_MSG = _(":role and either the :name or :email are required for each :contributor").freeze + BAD_FUNDING_MSG = _(":name, :funder_id or :grant_id are required for each funding").freeze + BAD_DATASET_MSSG = _(":title is required for each :dataset").freeze + BAD_HOST_MSG = _(":host must include either a :url or :dmproadmap_host_id").freeze + BAD_RELATED_IDENTIFIER_MSG = _(":descriptor, :type and :identifier are required for all dmproadmap_related_identifiers").freeze + # rubocop:enable Layout/LineLength + + class << self + + def plan_valid?(json:) + json.present? && json[:title].present? && json[:contact].present? && + json[:contact][:mbox].present? + end + + def identifier_valid?(json:) + json.present? && json[:type].present? && json[:identifier].present? + end + + def org_valid?(json:) + json.present? && (json[:name].present? || json[:affiliation_id].present? || json[:funder_id].present?) + end + + def contributor_valid?(json:, is_contact: false) + return false unless json.present? + return false unless json[:name].present? || json[:mbox].present? + + is_contact ? true : json[:role].present? + end + + def funding_valid?(json:) + return false unless json.present? + + funder_id = json.fetch(:funder_id, {})[:identifier] + grant_id = json.fetch(:grant_id, {})[:identifier] + json[:name].present? || funder_id.present? || grant_id.present? + end + + def dataset_valid?(json:) + return false unless json.present? + + dataset_id = json.fetch(:dataset_id, {})[:identifier] + json[:title].present? || dataset_id.present? + end + + def host_valid?(json:) + return false unless json.present? + + host_id = json.fetch(:dmproadmap_host_id, {})[:identifier] + json[:url].present? || host_id.present? + end + + def related_identifier_valid?(json:) + json.present? && json[:descriptor].present? && json[:type].present? && + json[:identifier].present? + end + + # rubocop:disable Metrics/AbcSize + # Scans the entire JSON document for invalid metadata and returns + # friendly errors to help the caller resolve the issue + def validation_errors(json:) + errs = [] + return [_("invalid JSON")] unless json.present? + + errs << BAD_PLAN_MSG unless plan_valid?(json: json) + if json[:dmp_id].present? + errs << BAD_ID_MSG unless identifier_valid?(json: json[:dmp_id]) + end + + # Handle Contact + errs << contributor_validation_errors(json: json[:contact]) + + # Handle Contributors + errs << json.fetch(:contributor, []).map do |contributor| + contributor_validation_errors(json: contributor) + end + + # Handle the Project and Fundings + json.fetch(:project, []).each do |project| + errs << project.fetch(:funding, []).map do |funding| + funding_validation_errors(json: funding) + end + end + + # Handle Datasets (eventually) + errs << json.fetch(:dataset, []).map do |dataset| + dataset_validation_errors(json: dataset) + end + + errs.flatten.compact.uniq + end + # rubocop:enable Metrics/AbcSize + + def contributor_validation_errors(json:) + errs = [] + if json.present? + errs << BAD_CONTRIB_MSG unless contributor_valid?(json: json, + is_contact: true) + errs << org_validation_errors(json: json[:affiliation]) if json[:affiliation].present? + id = json.fetch(:contributor_id, json[:contact_id]) + if id.present? + errs << BAD_ID_MSG unless identifier_valid?(json: id) + end + end + errs + end + + def dataset_validation_errors(json:) + errs = [] + return errs unless json.present? + + errs << BAD_DATASET_MSG unless dataset_valid?(json: json) + json.fetch(:distribution, []).each do |distribution| + errs << BAD_HOST_MSG unless host_valid?(json: distribution.fetch(:host, {})) + end + errs + end + + def funding_validation_errors(json:) + errs = [] + return errs unless json.present? + + errs << BAD_FUNDING_MSG unless funding_valid?(json: json) + errs << org_validation_errors(json: json) + if json[:grant_id].present? + errs << BAD_ID_MSG unless identifier_valid?(json: json[:grant_id]) + end + errs + end + + def org_validation_errors(json:) + errs = [] + return errs unless json.present? + + errs << BAD_ORG_MSG unless org_valid?(json: json) + id = json.fetch(:affiliation_id, json[:funder_id]) + if id.present? + errs << BAD_ID_MSG unless identifier_valid?(json: id) + end + errs + end + + def related_identifiers_errors(json:) + errs = [] + return errs unless json.present? + + json.each do |related_identifier| + next if related_identifier_valid?(json: related_identifier) + + errs << BAD_RELATED_IDENTIFIER_MSG + end + errs + end + + end + + end + + end + +end diff --git a/app/services/api/v2/persistence_service.rb b/app/services/api/v2/persistence_service.rb new file mode 100644 index 0000000000..6667ac4aa4 --- /dev/null +++ b/app/services/api/v2/persistence_service.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Api + + module V2 + + # Service used to ensure the entire DMP stack is saved + class PersistenceService + + class << self + + def safe_save(plan:) + return nil unless plan.is_a?(Plan) && plan.valid? + + plan.contributors = deduplicate_contributors(contributors: plan.contributors) + + Plan.transaction do + plan.funder = safe_save_org(org: plan.funder) + plan.grant_id = safe_save_identifier(identifier: plan.grant)&.id + + plan.save + + plan.identifiers.each do |id| + id.identifiable = plan.reload + safe_save_identifier(identifier: id) + end + plan.contributors.each do |contributor| + contributor.plan = plan.reload + safe_save_contributor(contributor: contributor) + end + + plan.reload + end + end + + private + + def safe_save_identifier(identifier:) + return nil unless identifier.is_a?(Identifier) + + Identifier.transaction do + identifier.save if identifier.valid? + return identifier unless identifier.new_record? + end + + Identifier.where(identifier_scheme: identifier.identifier_scheme, + value: identifier.value, + identifiable: identifier.identifiable).first + end + + def safe_save_org(org:) + return nil unless org.is_a?(Org) + + Org.transaction do + organization = Org.find_or_initialize_by(name: org.name) + if organization.new_record? + # Now that we know its a new record make sure its valid first + return nil unless org.valid? + + organization.update(saveable_attributes(attrs: org.attributes)) + org.identifiers.each do |id| + id.identifiable = organization.reload + safe_save_identifier(identifier: id) + end + end + organization.reload + end + end + + def safe_save_contributor(contributor:) + return nil unless contributor.is_a?(Contributor) && contributor.valid? + + Contributor.transaction do + contrib = Contributor.find_or_initialize_by(email: contributor.email) + + if contrib.new_record? + contrib.update(saveable_attributes(attrs: contributor.attributes)) + contrib.update(org: safe_save_org(org: contributor.org)) if contributor.org.present? + + contributor.identifiers.each do |id| + id.identifiable = contrib.reload + safe_save_identifier(identifier: id) + end + end + contrib.reload + end + end + + # Consolidate the contributors so that we don't end up trying to insert + # duplicate records! + def deduplicate_contributors(contributors:) + out = [] + return out unless contributors.respond_to?(:any?) && contributors.any? + + contributors.each do |contributor| + next unless contributor.is_a?(Contributor) + + # See if we've already processed this contributor + existing = out.select { |c| c == contributor }.first + out << contributor unless existing.present? + next unless existing.present? + + existing.merge(contributor) + end + out.flatten.compact.uniq + end + + def id_for(model, scheme) + return nil unless model.respond_to?(:identifier_for_scheme) && scheme.present? + + model.identifier_for_scheme(scheme: scheme) + end + + def saveable_attributes(attrs:) + %w[id created_at updated_at].each { |key| attrs.delete(key) } + attrs + end + + end + + end + + end + +end diff --git a/app/services/dmp_id_service.rb b/app/services/dmp_id_service.rb new file mode 100644 index 0000000000..4173455cd3 --- /dev/null +++ b/app/services/dmp_id_service.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +# Simple proxy service that determines which DMP ID minter to use +class DmpIdService + + class << self + + # Registers a DMP ID for the specified plan. + def mint_dmp_id(plan:) + # plan must exist and not already have a DMP ID! + return nil unless minting_service_defined? && plan.present? && plan.is_a?(Plan) + return plan.dmp_id if plan.dmp_id.present? + + svc = minter + return nil unless svc.present? + + dmp_id = svc.mint_dmp_id(plan: plan) + return nil unless dmp_id.present? + + dmp_id = "#{svc.landing_page_url}#{dmp_id}" unless dmp_id.downcase.start_with?("http") + Identifier.new(identifier_scheme: identifier_scheme, identifiable: plan, value: dmp_id) + rescue StandardError => e + p e.message + Rails.logger.error "DmpIdService.mint_dmp_id for Plan #{plan&.id} resulted in: #{e.message}" + nil + end + + # Updates the DMP ID metadata + def update_dmp_id(plan:) + # plan must exist and have a DMP ID + unless minting_service_defined? && plan.present? && plan.is_a?(Plan) && plan.dmp_id.present? + return nil + end + + svc = minter + return nil unless svc.present? + + dmp_id = svc.update_dmp_id(plan: plan) + return nil unless dmp_id.present? + rescue StandardError => e + Rails.logger.error "DmpIdService.update_dmp_id for Plan #{plan&.id} resulted in: #{e.message}" + Rails.logger.error e.backtrace + nil + end + + # Returns whether or not there is an active DMP ID minting service + def minting_service_defined? + Rails.configuration.x.madmp.enable_dmp_id_registration && minter.present? && + minter.api_base_url.present? + end + + # Retrieves the corresponding IdentifierScheme associated with the + def identifier_scheme + svc = minter + return nil unless svc.present? && svc.name.present? + + # Add the DMP ID service as an IdentifierScheme if it doesn't already exist + scheme = IdentifierScheme.find_or_create_by(name: svc.name.downcase) + if scheme.new_record? + scheme.update(description: svc.description, active: true, for_plans: true) + end + scheme + end + + # Return the inheriting service's :callback_path (defined in their config) + def scheme_callback_uri + svc = minter + return nil unless svc.present? + + svc.respond_to?(:callback_path) ? svc.callback_path : nil + end + + # Return the inheriting service's :landing_page_url (defined in their config) + def landing_page_url + svc = minter + return nil unless svc.present? + + svc.respond_to?(:landing_page_url) ? svc.landing_page_url : nil + end + + private + + # Fetch the active DMP ID minting service + def minter + # Use Datacite if it has been activated + return ExternalApis::DataciteService if ExternalApis::DataciteService.active? + # Use the DMPHub if it has been activated + return ExternalApis::DmphubService if ExternalApis::DmphubService.active? + + # Place additional DMP ID services here + + nil + end + + end + +end diff --git a/app/services/external_apis/base_dmp_id_service.rb b/app/services/external_apis/base_dmp_id_service.rb new file mode 100644 index 0000000000..45ed4ffefe --- /dev/null +++ b/app/services/external_apis/base_dmp_id_service.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module ExternalApis + + # This service provides an interface to minting/registering DOIs + # To enable the feature you will need to: + # - Identify a DMP ID minting authority (e.g. Datacite, Crossref, etc.) + # - Create an account with them and gain access to their API + # - Add a `config/initializers/external_apis/[service_name].rb`. Copy one of the + # existing ones as reference. + # - Create a new service in this directory that inherits from this class. + # Then define use the service's API documentation to build mint/update/delete functions + # - Also make sure that the `madmp.enable_dmp_id_registration` is set to true in + # config/initializers/_dmproadmap.rb + class BaseDmpIdService < BaseService + + class << self + + # The API endpoint to call to authenticate and receive an auth token to be used + # with all subsequent communications + def auth_path + nil + end + + # The API endpoint to call to register the Plan with the service and mint a + # new DMP ID (aka DOI, ARK, etc) + def mint_path + nil + end + + # The callback_path is the API endpoint to send updates to once the Plan has changed + # or been versioned. Use the `%{dmp_id}` markup to have the Plan's DOI appended to the path. + # For example: `update_dmp/%{dmp_id}` would become: `updated_dmp/10.123/1234.ABC` + def callback_path + nil + end + + # The HTTP method to be used when using the callback_path + def callback_method + :put + end + + # The name of the associated ApiClient + def api_client + nil + end + + # Ping the DOI API to determine if it is online + # + # @return true/false + def ping + return true unless active? && heartbeat_path.present? + + resp = http_get(uri: "#{api_base_url}#{heartbeat_path}") + resp.is_a?(Net::HTTPSuccess) + end + + # Implement the authentication for the DOI API + def auth + true + + # You should implement any necessary authentication step required by the + # DOI API + end + + # Implement the call to retrieve/mint a new DOI + # rubocop:disable Lint/UnusedMethodArgument + def mint_dmp_id(plan:) + SecureRandom.uuid + + # Minted DOIs should be stored as an Identifier. For example: + # val = "#{landing_page_url}#{dmp_id}" + # Identifier.new(identifiable: plan, value: val) + + # When this service is active and the above identifier is available, + # the link to the DOI will appear on the Project Details page, in plan + # exports and will become the `dmp_id` in this system's API responses + end + # rubocop:enable Lint/UnusedMethodArgument + + # Implement the call to register an associated ApiClient as a Subscriber to the Plan + # rubocop:disable Lint/UnusedMethodArgument + def add_subscription(plan:, dmp_id:) + true + end + # rubocop:enable Lint/UnusedMethodArgument + + # Implement the call to update the DOI + # rubocop:disable Lint/UnusedMethodArgument + def update_dmp_id(plan:) + true + end + # rubocop:enable Lint/UnusedMethodArgument + + # Implement the call to delete the DOI + # rubocop:disable Lint/UnusedMethodArgument + def delete_dmp_id(plan:) + true + end + # rubocop:enable Lint/UnusedMethodArgument + + end + + end + +end diff --git a/app/services/external_apis/base_service.rb b/app/services/external_apis/base_service.rb index bd13298aa8..6cf7862c24 100644 --- a/app/services/external_apis/base_service.rb +++ b/app/services/external_apis/base_service.rb @@ -78,6 +78,29 @@ def log_error(method:, error:) Rails.logger.error error.backtrace end + # Emails the error and response to the administrators + # rubocop:disable Metrics/AbcSize + def notify_administrators(obj:, response: nil, error: nil) + return false unless obj.present? && response.present? + + message = "#{obj.class.name} - #{obj.respond_to?(:id) ? obj.id : ''}" + message += "
----------------------------------------

" + + message += "Sent: #{pp(json_from_template(plan: obj))}" if obj.is_a?(Plan) + message += "
----------------------------------------

" if obj.is_a?(Plan) + + message += "#{name} received the following unexpected response:
" + message += response.inspect.to_s + message += "
----------------------------------------

" + + message += error.message if error.present? && error.is_a?(StandardError) + message += error.backtrace || "" if error.present? && error.is_a?(StandardError) + + UserMailer.notify_administrators(message).deliver_now + true + end + # rubocop:enable Metrics/AbcSize + private # Retrieves the application name from dmproadmap.rb initializer or uses the App name @@ -104,15 +127,96 @@ def http_get(uri:, additional_headers: {}, debug: false) nil rescue HTTParty::Error => e handle_http_failure(method: "BaseService.http_get #{e.message}", + http_response: nil) + nil + end + + # Makes a POST request to the specified uri with the additional headers. + # Additional headers are combined with the base headers defined above. + def http_post(uri:, additional_headers: {}, data: {}, basic_auth: nil, debug: false) + return nil unless uri.present? + + opts = options(additional_headers: additional_headers, debug: debug) + opts[:body] = data + opts[:basic_auth] = basic_auth if basic_auth.present? + HTTParty.post(uri, opts) + rescue URI::InvalidURIError => e + handle_uri_failure(method: "BaseService.http_post #{e.message}", + uri: uri) + nil + rescue HTTParty::Error => e + handle_http_failure(method: "BaseService.http_post #{e.message}", + http_response: nil) + nil + end + + # Makes a PUT request to the specified uri with the additional headers. + # Additional headers are combined with the base headers defined above. + def http_put(uri:, additional_headers: {}, data: {}, basic_auth: nil, debug: false) + return nil unless uri.present? + + opts = options(additional_headers: additional_headers, debug: debug) + opts[:body] = data + opts[:basic_auth] = basic_auth if basic_auth.present? + HTTParty.put(uri, opts) + rescue URI::InvalidURIError => e + handle_uri_failure(method: "BaseService.http_put #{e.message}", + uri: uri) + nil + rescue HTTParty::Error => e + handle_http_failure(method: "BaseService.http_put #{e.message}", + http_response: nil) + nil + end + + # Makes a POST request to the specified uri with the additional headers. + # Additional headers are combined with the base headers defined above. + # rubocop:disable Metrics/MethodLength + def http_post(uri:, additional_headers: {}, data: {}, basic_auth: nil, debug: false) + return nil unless uri.present? + + opts = options(additional_headers: additional_headers, debug: debug) + opts[:body] = data + opts[:basic_auth] = basic_auth if basic_auth.present? + HTTParty.post(uri, opts) + rescue URI::InvalidURIError => e + handle_uri_failure(method: "BaseService.http_post #{e.message}", + uri: uri) + nil + rescue HTTParty::Error => e + handle_http_failure(method: "BaseService.http_post #{e.message}", + http_response: resp) + resp + end + # rubocop:enable Metrics/MethodLength + + # Makes a PUT request to the specified uri with the additional headers. + # Additional headers are combined with the base headers defined above. + # rubocop:disable Metrics/MethodLength + def http_put(uri:, additional_headers: {}, data: {}, basic_auth: nil, debug: false) + return nil unless uri.present? + + opts = options(additional_headers: additional_headers, debug: debug) + opts[:body] = data + opts[:basic_auth] = basic_auth if basic_auth.present? + HTTParty.put(uri, opts) + rescue URI::InvalidURIError => e + handle_uri_failure(method: "BaseService.http_put #{e.message}", + uri: uri) + nil + rescue HTTParty::Error => e + handle_http_failure(method: "BaseService.http_put #{e.message}", http_response: resp) resp end + # rubocop:enable Metrics/MethodLength # Options for the HTTParty call def options(additional_headers: {}, debug: false) hash = { headers: headers.merge(additional_headers), - follow_redirects: true + follow_redirects: true, + limit: 6 } hash[:debug_output] = $stdout if debug hash diff --git a/app/services/external_apis/datacite_service.rb b/app/services/external_apis/datacite_service.rb new file mode 100644 index 0000000000..d69132e848 --- /dev/null +++ b/app/services/external_apis/datacite_service.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +module ExternalApis + + # This service provides an interface to Datacite API. + class DataciteService < BaseDmpIdService + + class << self + + def name + Rails.configuration.x.datacite&.name + end + + def description + Rails.configuration.x.datacite&.description + end + + # Retrieve the config settings from the initializer + def landing_page_url + Rails.configuration.x.datacite&.landing_page_url || super + end + + def api_base_url + Rails.configuration.x.datacite&.api_base_url || super + end + + def max_pages + Rails.configuration.x.datacite&.max_pages || super + end + + def max_results_per_page + Rails.configuration.x.datacite&.max_results_per_page || super + end + + def max_redirects + Rails.configuration.x.datacite&.max_redirects || super + end + + def active? + Rails.configuration.x.datacite&.active || super + end + + def client_id + Rails.configuration.x.datacite&.repository_id + end + + def client_secret + Rails.configuration.x.datacite&.password + end + + def mint_path + Rails.configuration.x.datacite&.mint_path + end + + def update_path + Rails.configuration.x.datacite&.update_path + end + + def shoulder + Rails.configuration.x.datacite&.shoulder + end + + # The callback_path is the API endpoint to send updates to once the Plan has changed + # or been versioned + def callback_path + Rails.configuration.x.datacite&.callback_path + end + + # Create a new DMP ID + def mint_dmp_id(plan:) + return nil unless active? + + data = json_from_template(dmp: plan) + + resp = http_post(uri: "#{api_base_url}#{mint_path}", + additional_headers: { "Content-Type": "application/vnd.api+json" }, + data: data, basic_auth: auth, debug: false) + + unless resp.present? && [200, 201].include?(resp.code) + handle_http_failure(method: "Datacite mint_dmp_id", http_response: resp) + notify_administrators(obj: plan, response: resp) + return nil + end + + json = process_response(response: resp) + return nil unless json.present? + + dmp_id = json.fetch("data", "attributes": { "doi": nil }) + .fetch("attributes", { "doi": nil })["doi"] + + add_subscription(plan: plan, dmp_id: dmp_id) if dmp_id.present? + dmp_id + end + + # Update the DMP ID + def update_dmp_id(plan:) + return false unless active? && plan.present? && plan.dmp_id.present? + + data = json_from_template(dmp: plan) + id = plan.dmp_id.value_without_scheme_prefix + resp = http_put(uri: "#{api_base_url}#{update_path}#{id}", + additional_headers: { "Content-Type": "application/vnd.api+json" }, + data: data, basic_auth: auth, debug: false) + + unless resp.present? && resp.code == 200 + handle_http_failure(method: "Datacite update_dmp_id", http_response: resp) + notify_administrators(obj: plan, response: resp) + return false + end + + update_subscription(plan: plan) + end + + # Register the ApiClient behind the minter service as a Subscriber to the Plan + # if the service has a callback URL and ApiClient + def add_subscription(plan:, dmp_id:) + client = api_client + path = callback_path + Rails.logger.warn "DataciteService - No ApiClient defined!" unless client.present? + return nil unless plan.present? && dmp_id.present? && path.present? && client.present? + + Subscription.create( + plan: plan, + subscriber: client, + callback_uri: path % { dmp_id: dmp_id.gsub(%r{https?://doi.org/}, "") }, + updates: true, + deletions: true + ) + end + + # Update the subscriptions for the Plan and Datacite + def update_subscription(plan:) + client = api_client + Rails.logger.warn "DataciteService - No ApiClient defined!" unless client.present? + return false unless plan.present? && + plan.dmp_id.present? && + callback_path.present? && + client.present? + + subscriptions = plan.subscriptions.select do |sub| + sub.subscriber == client && sub.updates? + end + return false unless subscriptions.any? + + subscriptions.each { |sub| sub.update(last_notified: Time.now) } + true + end + + # Delete the DMP ID + def delete_dmp_id(plan:) + return nil unless active? && plan.present? + + # implement this later if necessary and if reasonable. Is deleting a DMP ID feasible? + plan.present? + end + + private + + def auth + { + username: Rails.configuration.x.datacite.repository_id, + password: Rails.configuration.x.datacite.password + } + end + + def json_from_template(dmp:) + ActionController::Base.new.render_to_string( + template: "/datacite/_minter", + locals: { prefix: shoulder, data_management_plan: dmp } + ) + end + + def process_response(response:) + json = JSON.parse(response.body) + unless json["data"].present? && + json["data"]["attributes"].present? && + json["data"]["attributes"]["doi"].present? + log_error(method: "Datacite mint_dmp_id", + error: StandardError.new("Unexpected JSON format from Datacite!")) + return nil + end + json + # If a JSON parse error occurs then return results of a local table search + rescue JSON::ParserError => e + log_error(method: "Datacite mint_dmp_id", error: e) + nil + end + + end + + end + +end diff --git a/app/services/external_apis/dmphub_service.rb b/app/services/external_apis/dmphub_service.rb new file mode 100644 index 0000000000..ad1e686503 --- /dev/null +++ b/app/services/external_apis/dmphub_service.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +module ExternalApis + + # This service provides an interface to a DMPHub system: https://github.com/CDLUC3/dmphub. + class DmphubService < BaseDmpIdService + + class << self + + # Retrieve the config settings from the initializer + def landing_page_url + Rails.configuration.x.dmphub&.landing_page_url || super + end + + def api_base_url + Rails.configuration.x.dmphub&.api_base_url || super + end + + def active? + Rails.configuration.x.dmphub&.active || super + end + + def name + Rails.configuration.x.dmphub&.name + end + + def description + Rails.configuration.x.dmphub&.description + end + + def client_id + Rails.configuration.x.dmphub&.client_id + end + + def client_secret + Rails.configuration.x.dmphub&.client_secret + end + + def auth_path + Rails.configuration.x.dmphub&.auth_path + end + + def mint_path + Rails.configuration.x.dmphub&.mint_path + end + + def update_path + Rails.configuration.x.dmphub&.update_path + end + + def delete_path + Rails.configuration.x.dmphub&.delete_path + end + + def caller_name + ApplicationService.application_name.split("-").first.to_sym + end + + def api_client + ApiClient.find_by(name: name.gsub("Service", "").downcase) + end + + def callback_path + Rails.configuration.x.dmphub&.callback_path || super + end + + def callback_method + Rails.configuration.x.dmphub&.callback_method&.downcase&.to_sym || super + end + + # Create a new DMP ID + def mint_dmp_id(plan:) + return nil unless active? && auth && plan.present? + + hdrs = { + "Authorization": @token, + "Server-Agent": "#{caller_name} (#{client_id})" + } + resp = http_post(uri: "#{api_base_url}#{mint_path}", + additional_headers: hdrs, debug: false, + data: json_from_template(plan: plan)) + + # DMPHub returns a 201 (created) when a new DMP ID has been minted or + # a 405 (method_not_allowed) when a DMP ID already exists + unless resp.present? && [201, 405].include?(resp.code) + handle_http_failure(method: "DMPHub mint_dmp_id", http_response: resp) + notify_administrators(obj: plan, response: resp) + return nil + end + + dmp_id = process_response(response: resp) + add_subscription(plan: plan, dmp_id: dmp_id) if dmp_id.present? + dmp_id + end + + # Update the DMP ID + def update_dmp_id(plan:) + return nil unless active? && auth && plan.present? + + hdrs = { + "Authorization": @token, + "Server-Agent": "#{caller_name} (#{client_id})" + } + + target = "#{api_base_url}#{callback_path}" % { + dmp_id: plan.dmp_id&.value_without_scheme_prefix + } + resp = http_put(uri: target, additional_headers: hdrs, debug: false, + data: json_from_template(plan: plan)) + + # DMPHub returns a 200 when successful + unless resp.present? && resp.code == 200 + handle_http_failure(method: "DMPHub update_dmp_id", http_response: resp) + notify_administrators(obj: plan, response: resp) + return nil + end + + dmp_id = process_response(response: resp) + update_subscription(plan: plan) if dmp_id.present? + dmp_id + end + + # Delete the DMP ID + def delete_dmp_id(plan:) + return nil unless active? && plan.present? + + # implement this later once the DMPHub supports it + plan.present? + end + + # Register the ApiClient behind the minter service as a Subscriber to the Plan + # if the service has a callback URL and ApiClient + def add_subscription(plan:, dmp_id:) + client = api_client + path = callback_path + Rails.logger.warn "DMPHubService - No ApiClient defined!" unless client.present? + return nil unless plan.present? && + dmp_id.present? && + path.present? && + client.present? + + Subscription.create( + plan: plan, + subscriber: client, + callback_uri: path % { dmp_id: dmp_id.gsub(%r{https?://doi.org/}, "") }, + updates: true, + deletions: true, + last_notified: Time.now + ) + end + + # Bump the last_notified timestamp on the subscription + def update_subscription(plan:) + client = api_client + Rails.logger.warn "DMPHubService - No ApiClient defined!" unless client.present? + return false unless plan.present? && + plan.dmp_id.present? && + callback_path.present? && + client.present? + + subscriptions = plan.subscriptions.select do |sub| + sub.subscriber == client && sub.updates? + end + return false unless subscriptions.any? + + subscriptions.each { |sub| sub.update(last_notified: Time.now) } + true + end + + private + + attr_accessor :token + + # Authenticate with the DMPHub + def auth + data = { + grant_type: "client_credentials", + client_id: client_id, + client_secret: client_secret + } + + resp = http_post(uri: "#{api_base_url}#{auth_path}", + additional_headers: {}, data: data.to_json, debug: false) + unless resp.present? && resp.code == 200 + handle_http_failure(method: "DMPHub mint_dmp_id", http_response: resp) + return nil + end + @token = process_token(json: resp.body) + @token.present? + end + + # Process the authentication response from DMPHub to retrieve the JWT + def process_token(json:) + hash = JSON.parse(json).with_indifferent_access + return nil unless hash[:access_token].present? && hash[:token_type].present? + + "#{hash[:token_type]}: #{hash[:access_token]}" + # If a JSON parse error occurs then return results of a local table search + rescue JSON::ParserError => e + log_error(method: "DMPHub authentication", error: e) + nil + end + + # Prepare the DMP for transmission to the DMPHub (RDA Common Standard format) + def json_from_template(plan:) + payload = ActionController::Base.new.render_to_string( + partial: "/api/v2/plans/show", locals: { plan: plan, client: api_client } + ) + + { dmp: JSON.parse(payload) }.to_json + end + + # Extract the DMP ID from the response from the DMPHub + def process_response(response:) + hash = JSON.parse(response.body).with_indifferent_access + return nil unless hash.fetch(:items, []).length == 1 + return nil unless hash[:items].first[:dmp].present? + + hash[:items].first[:dmp].fetch(:dmp_id, {})[:identifier] + # If a JSON parse error occurs then return results of a local table search + rescue JSON::ParserError => e + log_error(method: "DMPHub parse response: ", error: e) + nil + end + + end + + end + +end diff --git a/app/services/external_apis/doi_service.rb b/app/services/external_apis/doi_service.rb deleted file mode 100644 index ac57dcd56b..0000000000 --- a/app/services/external_apis/doi_service.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -module ExternalApis - - # This service provides an interface to minting/registering DOIs - # To enable the feature you will need to: - # - Identify a DOI minting authority (e.g. Datacite, Crossref, etc.) - # - Create an account with them and gain access to their API - # - Update the `config/initializers/external_apis/doi.rb` - # - Update this service to mint DOIs (based on their API documentation) - class DoiService < BaseService - - class << self - - # Retrieve the config settings from the initializer - def landing_page_url - Rails.configuration.x.doi&.landing_page_url || super - end - - def api_base_url - Rails.configuration.x.doi&.api_base_url || super - end - - def active? - Rails.configuration.x.doi&.active || super - end - - def heartbeat_path - Rails.configuration.x.doi&.heartbeat_path - end - - def auth_path - Rails.configuration.x.doi&.auth_path - end - - def mint_path - Rails.configuration.x.doi&.mint_path - end - - # Ping the DOI API to determine if it is online - # - # @return true/false - def ping - return true unless active? && heartbeat_path.present? - - resp = http_get(uri: "#{api_base_url}#{heartbeat_path}") - resp.is_a?(Net::HTTPSuccess) - end - - # Implement the authentication for the DOI API - def auth - true - - # You should implement any necessary authentication step required by the - # DOI API - end - - # Implement the call to retrieve/mint a new DOI - # rubocop:disable Lint/UnusedMethodArgument - def mint(plan:) - SecureRandom.uuid - - # Minted DOIs should be stored as an Identifier. For example: - # doi_url = "#{landing_page_url}#{doi}" - # Identifier.new(identifiable: plan, value: doi_url) - - # When this service is active and the above identifier is available, - # the link to the DOI will appear on the Project Details page, in plan - # exports and will become the `dmp_id` in this system's API responses - end - # rubocop:enable Lint/UnusedMethodArgument - - end - - end - -end diff --git a/app/services/external_apis/orcid_service.rb b/app/services/external_apis/orcid_service.rb new file mode 100644 index 0000000000..6f53db68e7 --- /dev/null +++ b/app/services/external_apis/orcid_service.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +module ExternalApis + + # This service provides an interface to the ORCID member API: + # https://info.orcid.org/documentation/features/member-api/ + # https://github.com/ORCID/ORCID-Source/tree/master/orcid-api-web + # https://github.com/ORCID/ORCID-Source/blob/master/orcid-api-web/tutorial/works.md + # + # It makes use of OAuth access tokens supplied by ORCID through the ORCID Omniauth gem for Devise. + # The tokens are created when the user either signs in via ORCID, when the user links their account + # on the Edit profile page or when the user tries to submit their DMP to ORCID but no valid token exists + class OrcidService < BaseDmpIdService + + class << self + + # Retrieve the config settings from the initializer + def landing_page_url + Rails.configuration.x.orcid&.landing_page_url || super + end + + def api_base_url + Rails.configuration.x.orcid&.api_base_url || super + end + + def active? + Rails.configuration.x.orcid&.active || super + end + + def name + Rails.configuration.x.orcid&.name + end + + def work_path + Rails.configuration.x.orcid&.work_path + end + + def callback_path + Rails.configuration.x.orcid&.callback_path + end + + # Create a new DOI + def add_work(user:, plan:) + # Fail if this service is inactive or the plan does not have a DOI! + return false unless active? && user.is_a?(User) && plan.is_a?(Plan) && plan.dmp_id.present? + + orcid = user.identifier_for_scheme(scheme: name) + token = ExternalApiAccessToken.for_user_and_service(user: user, service: name) + + # TODO: allow the user to reauth to get a token if they do not have one or theirs is expired/revoked + + # Fail if the user doesn't have an orcid or an acess token + return false unless orcid.present? && token.present? + + target = "#{api_base_url}#{work_path % { id: orcid.value.gsub(landing_page_url, "") }}" + + hdrs = { + "Content-type": "application/vnd.orcid+xml", + "Accept": "application/xml", + "Authorization": "Bearer #{token.access_token}", + "Server-Agent": "#{ApplicationService.application_name} (#{Rails.configuration.x.dmproadmap.orcid_client_id})" + } + + resp = http_post(uri: target, additional_headers: hdrs, debug: true, + data: xml_for(plan: plan, dmp_id: plan.dmp_id, user: user)) + + # ORCID returns a 201 (created) when the DMP has been added to the User's works + # a 405 (method_not_allowed) when the DMP is already in the User's works + unless resp.present? && [201, 405].include?(resp.code) + handle_http_failure(method: "ORCID add work", http_response: resp) + return false + end + + add_subscription(plan: plan, callback_uri: resp.headers["location"]) if resp.code == 201 + true + end + + # Register the ApiClient behind the minter service as a Subscriber to the Plan + # if the service has a callback URL and ApiClient + def add_subscription(plan:, callback_uri:) + return nil unless plan.is_a?(Plan) && callback_uri.present? && identifier_scheme.present? + + Subscription.create( + plan: plan, + subscriber: identifier_scheme, + callback_uri: callback_uri, + updates: true, + deletions: true + ) + end + + # Bump the last_notified timestamp on the subscription + def update_subscription(plan:) + return false unless plan.is_a?(Plan) && callback_path.present? && identifier_scheme.present? + + subscription = Subscription.find_by(plan: plan, subscriber: identifier_scheme) + subscription.present? ? subscription.notify! : false + end + + private + + def identifier_scheme + Rails.cache.fetch("orcid_scheme", expires_in: 1.day) do + IdentifierScheme.find_by("LOWER(name) = ?", name.downcase) + end + end + + def xml_for(plan:, dmp_id:, user:) + return nil unless plan.is_a?(Plan) && dmp_id.is_a?(Identifier) && user.is_a?(User) + + orcid = user.identifier_for_scheme(scheme: name) + + # Derived from: + # https://github.com/ORCID/orcid-model/blob/master/src/main/resources/record_3.0/samples/write_samples/work-full-3.0.xml + # + # Removed the following because ORCID sends a 400 Bad Request error with a complaint about the + # Error: "The client application made a bad request to the ORCID API. Full validation error: unexpected + # element (uri:\"\", local:\"p\"). Expected elements are (none)" + # + # It works sometimes though + # + # + # + # + # #{orcid.value} + # #{orcid.value_without_scheme_prefix} + # orcid.org + # + # #{user.name(false)} + # + # author + # + # + # + <<-XML + + + #{plan.title.encode(xml: :text)} + + #{plan.description.encode(xml: :text)} + + formatted-unspecified + #{plan.citation.encode(xml: :text)} + + data-management-plan + + #{plan.created_at.strftime("%Y")} + #{plan.created_at.strftime("%m")} + #{plan.created_at.strftime("%d")} + + + + doi + #{dmp_id.value_without_scheme_prefix} + #{dmp_id.value} + self + + + + XML + end + + end + + end + +end \ No newline at end of file diff --git a/app/services/external_apis/rdamsc_service.rb b/app/services/external_apis/rdamsc_service.rb new file mode 100644 index 0000000000..dbadb74a34 --- /dev/null +++ b/app/services/external_apis/rdamsc_service.rb @@ -0,0 +1,146 @@ +# 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 + + def process_scheme_entries(json:) + return false unless json.is_a?(Hash) + + json = json.with_indifferent_access + return false unless json["data"].present? && json["data"].fetch("items", []).any? + + json["data"]["items"].each do |item| + standard = MetadataStandard.find_or_create_by(uri: item["uri"], title: item["title"]) + standard.update(description: item["description"], locations: item["locations"], + related_entities: item["relatedEntities"], rdamsc_id: item["mscid"]) + end + end + + end + + end + +end diff --git a/app/services/external_apis/re3data_service.rb b/app/services/external_apis/re3data_service.rb new file mode 100644 index 0000000000..f37bbc212f --- /dev/null +++ b/app/services/external_apis/re3data_service.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +module ExternalApis + + # This service provides an interface to the Registry of Research Data + # Repositories (re3data.org) API. + # For more information: https://www.re3data.org/api/doc + class Re3dataService < BaseService + + class << self + + # Retrieve the config settings from the initializer + def landing_page_url + Rails.configuration.x.re3data&.landing_page_url || super + end + + def api_base_url + Rails.configuration.x.re3data&.api_base_url || super + end + + def max_pages + Rails.configuration.x.re3data&.max_pages || super + end + + def max_results_per_page + Rails.configuration.x.re3data&.max_results_per_page || super + end + + def max_redirects + Rails.configuration.x.re3data&.max_redirects || super + end + + def active? + Rails.configuration.x.re3data&.active || super + end + + def list_path + Rails.configuration.x.re3data&.list_path + end + + def repository_path + Rails.configuration.x.re3data&.repository_path + end + + # Retrieves the full list of repositories from the re3data API as XML. + # For example: + # + # + # r3d100000001 + # Odum Institute Archive Dataverse + # + # + # + def fetch + xml_list = query_re3data + return [] unless xml_list.present? + + xml_list.xpath("/list/repository/id").each do |node| + next unless node.present? && node.text.present? + + xml = query_re3data_repository(repo_id: node.text) + next unless xml.present? + + process_repository(id: node.text, node: xml.xpath("//r3d:re3data//r3d:repository").first) + end + end + + private + + # Queries the re3data API for the full list of repositories + def query_re3data + # Call the ROR API and log any errors + resp = http_get(uri: "#{api_base_url}#{list_path}", additional_headers: {}, + debug: false) + + unless resp.present? && resp.code == 200 + handle_http_failure(method: "re3data list", http_response: resp) + return nil + end + Nokogiri.XML(resp.body, nil, "utf8") + end + + # Queries the re3data API for the specified repository + def query_re3data_repository(repo_id:) + return [] unless repo_id.present? + + target = "#{api_base_url}#{repository_path}#{repo_id}" + # Call the ROR API and log any errors + resp = http_get(uri: target, additional_headers: {}, + debug: false) + + unless resp.present? && resp.code == 200 + handle_http_failure(method: "re3data repository #{repo_id}", http_response: resp) + return [] + end + Nokogiri.XML(resp.body, nil, "utf8") + end + + # Updates or Creates a repository based on the XML input + def process_repository(id:, node:) + return nil unless id.present? && node.present? + + # Try to find the Repo by the re3data identifier + repo = Repository.find_by(uri: id) + homepage = node.xpath("//r3d:repositoryURL")&.text + name = node.xpath("//r3d:repositoryName")&.text + repo = Repository.find_by(homepage: homepage) unless repo.present? + repo = Repository.find_or_initialize_by(uri: id, name: name) unless repo.present? + repo = parse_repository(repo: repo, node: node) + repo.reload + end + + # Updates the Repository based on the XML input + # rubocop:disable Metrics/AbcSize + def parse_repository(repo:, node:) + return nil unless repo.present? && node.present? + + repo.update( + description: node.xpath("//r3d:description")&.text, + homepage: node.xpath("//r3d:repositoryURL")&.text, + contact: node.xpath("//r3d:repositoryContact")&.text, + info: { + types: node.xpath("//r3d:type").map(&:text), + subjects: node.xpath("//r3d:subject").map(&:text), + provider_types: node.xpath("//r3d:providerType").map(&:text), + keywords: node.xpath("//r3d:keyword").map(&:text), + access: node.xpath("//r3d:databaseAccess//r3d:databaseAccessType")&.text, + pid_system: node.xpath("//r3d:pidSystem")&.text, + policies: node.xpath("//r3d:policy").map { |n| parse_policy(node: n) }, + upload_types: node.xpath("//r3d:dataUpload").map { |n| parse_upload(node: n) } + } + ) + repo + end + # rubocop:enable Metrics/AbcSize + + def parse_policy(node:) + return nil unless node.present? + + { + name: node.xpath("r3d:policyName")&.text, + url: node.xpath("r3d:policyURL")&.text + } + end + + def parse_upload(node:) + return nil unless node.present? + + { + type: node.xpath("r3d:dataUploadType")&.text, + restriction: node.xpath("r3d:dataUploadRestriction")&.text + } + end + + end + + end + +end diff --git a/app/services/external_apis/spdx_service.rb b/app/services/external_apis/spdx_service.rb new file mode 100644 index 0000000000..363d2e5b4e --- /dev/null +++ b/app/services/external_apis/spdx_service.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module ExternalApis + + # This service provides an interface to the SPDX License List + # For more information: https://spdx.org/licenses/index.html + class SpdxService < BaseService + + class << self + + # Retrieve the config settings from the initializer + def landing_page_url + Rails.configuration.x.spdx&.landing_page_url || super + end + + def api_base_url + Rails.configuration.x.spdx&.api_base_url || super + end + + def max_pages + Rails.configuration.x.spdx&.max_pages || super + end + + def max_results_per_page + Rails.configuration.x.spdx&.max_results_per_page || super + end + + def max_redirects + Rails.configuration.x.spdx&.max_redirects || super + end + + def active? + Rails.configuration.x.spdx&.active || super + end + + def list_path + Rails.configuration.x.spdx&.list_path + end + + # Retrieves the full list of license from the SPDX Github repository. + # For example: + # "licenses": [ + # { + # "reference": "./0BSD.html", + # "isDeprecatedLicenseId": false, + # "detailsUrl": "http://spdx.org/licenses/0BSD.json", + # "referenceNumber": "67", + # "name": "BSD Zero Clause License", + # "licenseId": "0BSD", + # "seeAlso": [ + # "http://landley.net/toybox/license.html" + # ], + # "isOsiApproved": true + # } + # ] + def fetch + licenses = query_spdx + return [] unless licenses.present? + + licenses.each { |license| process_license(hash: license) } + License.all + end + + private + + # Queries the re3data API for the full list of repositories + def query_spdx + # Call the ROR API and log any errors + resp = http_get(uri: "#{api_base_url}#{list_path}", additional_headers: {}, debug: false) + + unless resp.present? && resp.code == 200 + handle_http_failure(method: "SPDX list", http_response: resp) + return [] + end + json = JSON.parse(resp.body) + return [] unless json.fetch("licenses", []).any? + + json["licenses"] + rescue JSON::ParserError => e + log_error(method: "SPDX search", error: e) + [] + end + + # Updates or Creates a repository based on the XML input + def process_license(hash:) + return nil unless hash.present? + + hash = hash.with_indifferent_access + license = License.find_or_initialize_by(identifier: hash["licenseId"]) + return nil unless license.present? + + license.update( + name: hash["name"], + uri: hash["detailsUrl"], + osi_approved: hash["isOsiApproved"], + deprecated: hash["isDeprecatedLicenseId"] + ) + end + + end + + end + +end diff --git a/app/services/org_selection/hash_to_org_service.rb b/app/services/org_selection/hash_to_org_service.rb index ef722429a1..fe80d9cf44 100644 --- a/app/services/org_selection/hash_to_org_service.rb +++ b/app/services/org_selection/hash_to_org_service.rb @@ -81,15 +81,15 @@ def lookup_org_by_identifiers(hash:) # Lookup the Org by its :name def lookup_org_by_name(hash:) clean_name = OrgSelection::SearchService.name_without_alias(name: hash[:name]) - org = Org.search(clean_name).first - exact_match?(rec: org, name2: hash[:name]) ? org : nil + orgs = Org.search(clean_name) + orgs.select { |o| exact_match?(rec: o, name2: hash[:name]) }.first end # Initialize a new Org from the hash def initialize_org(hash:) return nil unless hash.present? && hash[:name].present? - org = Org.new( + Org.new( name: hash[:name], links: links_from_hash(name: hash[:name], website: hash[:url]), language: language_from_hash(hash: hash), @@ -98,7 +98,6 @@ def initialize_org(hash:) is_other: false, abbreviation: abbreviation_from_hash(hash: hash) ) - org end # Convert the name and website into Org.links diff --git a/app/services/org_selection/search_service.rb b/app/services/org_selection/search_service.rb index 891aa9537c..ea072f134c 100644 --- a/app/services/org_selection/search_service.rb +++ b/app/services/org_selection/search_service.rb @@ -21,6 +21,8 @@ def search_combined(search_term:) matches = orgs.select do |org| exact_match?(name1: org[:name], name2: search_term) end + # Convert the Org models to Hashes + orgs = orgs.map { |org| OrgSelection::OrgToHashService.to_hash(org: org) } return orgs if matches.any? externals = externals_search(search_term: search_term) diff --git a/app/views/api/v1/_standard_response.json.jbuilder b/app/views/api/v1/_standard_response.json.jbuilder index 7541daca0e..b778ad4812 100644 --- a/app/views/api/v1/_standard_response.json.jbuilder +++ b/app/views/api/v1/_standard_response.json.jbuilder @@ -12,6 +12,7 @@ json.prettify! json.ignore_nil! json.application @application +json.api_version 1 json.source "#{request.method} #{request.path}" json.time Time.now.to_formatted_s(:iso8601) json.caller @caller diff --git a/app/views/api/v1/contributors/_show.json.jbuilder b/app/views/api/v1/contributors/_show.json.jbuilder index 757f5db7c9..27c3e1b367 100644 --- a/app/views/api/v1/contributors/_show.json.jbuilder +++ b/app/views/api/v1/contributors/_show.json.jbuilder @@ -4,16 +4,14 @@ is_contact ||= false -json.name contributor.name +json.name contributor.is_a?(User) ? contributor.name(false) : contributor.name json.mbox contributor.email -unless is_contact - if contributor.selected_roles.any? - roles = contributor.selected_roles.map do |role| - Api::V1::ContributorPresenter.role_as_uri(role: role) - end - json.role roles if roles.any? +if !is_contact && contributor.selected_roles.any? + roles = contributor.selected_roles.map do |role| + Api::V1::ContributorPresenter.role_as_uri(role: role) end + json.role roles if roles.any? end if contributor.org.present? diff --git a/app/views/api/v1/datasets/_show.json.jbuilder b/app/views/api/v1/datasets/_show.json.jbuilder index 3a3bb0b3b3..d8772fa390 100644 --- a/app/views/api/v1/datasets/_show.json.jbuilder +++ b/app/views/api/v1/datasets/_show.json.jbuilder @@ -1,29 +1,88 @@ # frozen_string_literal: true -# locals: plan +# locals: output -presenter = Api::V1::PlanPresenter.new(plan: plan) +if output.is_a?(ResearchOutput) + presenter = Api::V1::ResearchOutputPresenter.new(output: output) -json.title "Generic Dataset" -json.personal_data "unknown" -json.sensitive_data "unknown" + json.type output.output_type + json.title output.title + json.description output.description + json.personal_data Api::V1::ApiPresenter.boolean_to_yes_no_unknown(value: output.personal_data) + json.sensitive_data Api::V1::ApiPresenter.boolean_to_yes_no_unknown(value: output.sensitive_data) + json.issued output.release_date&.to_formatted_s(:iso8601) -json.dataset_id do - json.partial! "api/v1/identifiers/show", identifier: presenter.identifier -end + json.preservation_statement presenter.preservation_statement + json.security_and_privacy presenter.security_and_privacy + json.data_quality_assurance presenter.data_quality_assurance json.distribution [plan] do |distribution| json.title "PDF - #{distribution.title}" json.data_access "open" - json.download_url plan_export_url(distribution, format: :pdf) + url = Rails.application.routes.url_helpers.plan_export_url(distribution, format: :pdf) + json.download_url url json.format do json.array! ["application/pdf"] end -end -if plan.research_domain_id.present? - research_domain = ResearchDomain.find_by(id: plan.research_domain_id) - if research_domain.present? - json.keyword [research_domain.label, "#{research_domain.identifier} - #{research_domain.label}"] + json.distribution output.repositories do |repository| + json.title "Anticipated distribution for #{output.title}" + json.byte_size output.byte_size + json.data_access output.access + + json.host do + json.title repository.name + json.description repository.description + json.url repository.homepage + + # DMPTool extensions to the RDA common metadata standard + json.dmproadmap_host_id do + json.type "url" + json.identifier repository.uri + end + end + + if output.license.present? + json.license [output.license] do |license| + json.license_ref license.uri + json.start_date presenter.license_start_date + end + end + end + + json.metadata output.metadata_standards do |metadata_standard| + website = metadata_standard.locations.select { |loc| loc["type"] == "website" }.first + website = { url: "" } unless website.present? + + descr_array = [metadata_standard.title, metadata_standard.description, website["url"]] + json.description descr_array.join(" - ") + + json.metadata_standard_id do + json.type "url" + json.identifier metadata_standard.uri + end + end + + json.technical_resource [] + + if output.plan.research_domain_id.present? + research_domain = ResearchDomain.find_by(id: output.plan.research_domain_id) + if research_domain.present? + combined = "#{research_domain.identifier} - #{research_domain.label}" + json.keyword [research_domain.label, combined] + end + end + +else + json.type "dataset" + json.title "Generic dataset" + json.description "No individual datasets have been defined for this DMP." + + if output.research_domain_id.present? + research_domain = ResearchDomain.find_by(id: output.research_domain_id) + if research_domain.present? + combined = "#{research_domain.identifier} - #{research_domain.label}" + json.keyword [research_domain.label, combined] + end end end diff --git a/app/views/api/v1/plans/_funding.json.jbuilder b/app/views/api/v1/plans/_funding.json.jbuilder index 725cda1358..a095f7b8c5 100644 --- a/app/views/api/v1/plans/_funding.json.jbuilder +++ b/app/views/api/v1/plans/_funding.json.jbuilder @@ -5,7 +5,7 @@ json.name plan.funder&.name if plan.funder.present? - id = Api::V1::OrgPresenter.affiliation_id(identifiers: plan.funder.identifiers) + id = Api::V1::OrgPresenter.affiliation_id(identifiers: plan.funder.identifiers, fundref: true) if id.present? json.funder_id do @@ -14,7 +14,7 @@ if plan.funder.present? end end -if plan.grant_id.present? && plan.grant.present? +if plan.grant.present? json.grant_id do json.partial! "api/v1/identifiers/show", identifier: plan.grant end diff --git a/app/views/api/v1/plans/_show.json.jbuilder b/app/views/api/v1/plans/_show.json.jbuilder index 6f8d226f5f..7d89a1f30f 100644 --- a/app/views/api/v1/plans/_show.json.jbuilder +++ b/app/views/api/v1/plans/_show.json.jbuilder @@ -2,7 +2,7 @@ # locals: plan -json.schema "https://github.com/RDA-DMP-Common/RDA-DMP-Common-Standard/tree/master/examples/JSON/JSON-schema/1.0" +json.schema "https://github.com/RDA-DMP-Common/RDA-DMP-Common-Standard/tree/master/examples/JSON/JSON-schema/1.1" presenter = Api::V1::PlanPresenter.new(plan: plan) # A JSON representation of a Data Management Plan in the @@ -51,8 +51,10 @@ unless @minimal json.partial! "api/v1/plans/project", plan: pln end - json.dataset [plan] do |dataset| - json.partial! "api/v1/datasets/show", plan: plan, dataset: dataset + outputs = plan.research_outputs.any? ? plan.research_outputs : [plan] + + json.dataset outputs do |output| + json.partial! "api/v1/datasets/show", output: output end json.extension [plan.template] do |template| @@ -61,6 +63,23 @@ unless @minimal json.id template.id json.title template.title end + + json.related_identifiers plan.related_identifiers do |related| + next unless related.value.present? && related.relation_type.present? + + json.descriptor related.relation_type + json.type related.identifier_type + json.identifier related.value + json.work_type related.work_type + end end end + + json.dmproadmap_privacy presenter.visibility + + # DMPRoadmap specific links to perform special actions like downloading the PDF + json.dmproadmap_links presenter.links + + # DMPHub extension to send all callback addresses for interested subscribers for changes to the DMP + json.dmphub_subscribers presenter.subscriptions end diff --git a/app/views/api/v2/_standard_response.json.jbuilder b/app/views/api/v2/_standard_response.json.jbuilder new file mode 100644 index 0000000000..350569667a --- /dev/null +++ b/app/views/api/v2/_standard_response.json.jbuilder @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# locals: response, request, total_items + +total_items ||= 0 +path = @scope.present? ? "#{request.path}?scope=#{@scope}" : request.path +paginator = Api::V1::PaginationPresenter.new(current_url: path, + per_page: @per_page, + total_items: total_items, + current_page: @page) + +json.prettify! +json.ignore_nil! + +json.application @application +json.api_version 2 +json.source "#{request.method} #{request.path}" +json.time Time.now.to_formatted_s(:iso8601) +json.caller @caller +json.code response.status +json.message Rack::Utils::HTTP_STATUS_CODES[response.status] + +# Pagination Links +if total_items.positive? + json.page @page + json.per_page @per_page + json.total_items total_items + + # Prepare the base URL by removing the old pagination params + json.prev paginator.prev_page_link if paginator.prev_page? + json.next paginator.next_page_link if paginator.next_page? +else + json.total_items 0 +end diff --git a/app/views/api/v2/contributors/_show.json.jbuilder b/app/views/api/v2/contributors/_show.json.jbuilder new file mode 100644 index 0000000000..220cfcb983 --- /dev/null +++ b/app/views/api/v2/contributors/_show.json.jbuilder @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# locals: contributor, is_contact + +is_contact ||= false + +json.name contributor.is_a?(User) ? contributor.name(false) : contributor.name +json.mbox contributor.email + +if !is_contact && contributor.selected_roles.any? + roles = contributor.selected_roles.map do |role| + Api::V1::ContributorPresenter.role_as_uri(role: role) + end + json.role roles if roles.any? +end + +if contributor.org.present? + json.affiliation do + json.partial! "api/v2/orgs/show", org: contributor.org + end +end + +orcid = contributor.identifier_for_scheme(scheme: "orcid") +if orcid.present? + id = Api::V1::ContributorPresenter.contributor_id( + identifiers: contributor.identifiers + ) + if is_contact + json.contact_id do + json.partial! "api/v2/identifiers/show", identifier: id + end + else + json.contributor_id do + json.partial! "api/v2/identifiers/show", identifier: id + end + end +end diff --git a/app/views/api/v2/datasets/_show.json.jbuilder b/app/views/api/v2/datasets/_show.json.jbuilder new file mode 100644 index 0000000000..976e544f83 --- /dev/null +++ b/app/views/api/v2/datasets/_show.json.jbuilder @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +# locals: output + +if output.is_a?(ResearchOutput) + presenter = Api::V1::ResearchOutputPresenter.new(output: output) + + json.type output.output_type + json.title output.title + json.description output.description + json.personal_data Api::V1::ApiPresenter.boolean_to_yes_no_unknown(value: output.personal_data) + json.sensitive_data Api::V1::ApiPresenter.boolean_to_yes_no_unknown(value: output.sensitive_data) + json.issued output.release_date&.to_formatted_s(:iso8601) + + json.preservation_statement presenter.preservation_statement + json.security_and_privacy presenter.security_and_privacy + json.data_quality_assurance presenter.data_quality_assurance + + json.dataset_id do + json.partial! "api/v2/identifiers/show", identifier: presenter.dataset_id + end + + json.distribution output.repositories do |repository| + json.title "Anticipated distribution for #{output.title}" + json.byte_size output.byte_size + json.data_access output.access + + json.host do + json.title repository.name + json.description repository.description + json.url repository.homepage + + # DMPTool extensions to the RDA common metadata standard + json.dmproadmap_host_id do + json.type "url" + json.identifier repository.uri + end + end + + if output.license.present? + json.license [output.license] do |license| + json.license_ref license.uri + json.start_date presenter.license_start_date + end + end + end + + json.metadata output.metadata_standards do |metadata_standard| + website = metadata_standard.locations.select { |loc| loc["type"] == "website" }.first || { url: "" } + website = metadata_standard.locations.select { |loc| loc["type"] == "website" }.first + website = { url: "" } unless website.present? + + descr_array = [metadata_standard.title, metadata_standard.description, website["url"]] + json.description descr_array.join(" - ") + + json.metadata_standard_id do + json.type "url" + json.identifier metadata_standard.uri + end + end + + json.technical_resource [] + + if output.plan.research_domain_id.present? + research_domain = ResearchDomain.find_by(id: output.plan.research_domain_id) + if research_domain.present? + combined = "#{research_domain.identifier} - #{research_domain.label}" + json.keyword [research_domain.label, combined] + end + end + +else + json.type "dataset" + json.title "Generic dataset" + json.description "No individual datasets have been defined for this DMP." + + if output.research_domain_id.present? + research_domain = ResearchDomain.find_by(id: output.research_domain_id) + if research_domain.present? + combined = "#{research_domain.identifier} - #{research_domain.label}" + json.keyword [research_domain.label, combined] + end + end +end diff --git a/app/views/api/v2/error.json.jbuilder b/app/views/api/v2/error.json.jbuilder new file mode 100644 index 0000000000..c639c4eae8 --- /dev/null +++ b/app/views/api/v2/error.json.jbuilder @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +json.partial! "api/v2/standard_response" + +json.items [] +json.errors @payload[:errors] diff --git a/app/views/api/v2/heartbeat.json.jbuilder b/app/views/api/v2/heartbeat.json.jbuilder new file mode 100644 index 0000000000..67614e4a72 --- /dev/null +++ b/app/views/api/v2/heartbeat.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +json.partial! "api/v2/standard_response" + +json.items [] diff --git a/app/views/api/v2/identifiers/_show.json.jbuilder b/app/views/api/v2/identifiers/_show.json.jbuilder new file mode 100644 index 0000000000..c219222aee --- /dev/null +++ b/app/views/api/v2/identifiers/_show.json.jbuilder @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# locals: identifier + +json.type identifier&.identifier_format +json.identifier identifier&.value diff --git a/app/views/api/v2/orgs/_show.json.jbuilder b/app/views/api/v2/orgs/_show.json.jbuilder new file mode 100644 index 0000000000..caf523b449 --- /dev/null +++ b/app/views/api/v2/orgs/_show.json.jbuilder @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# locals: org + +json.name org.name +json.abbreviation org.abbreviation +json.region org.region&.abbreviation + +if org.identifiers.any? + json.affiliation_id do + id = Api::V1::OrgPresenter.affiliation_id(identifiers: org.identifiers) + json.partial! "api/v2/identifiers/show", identifier: id + end +end diff --git a/app/views/api/v2/plans/_cost.json.jbuilder b/app/views/api/v2/plans/_cost.json.jbuilder new file mode 100644 index 0000000000..ad36e3540e --- /dev/null +++ b/app/views/api/v2/plans/_cost.json.jbuilder @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# locals: cost + +json.title cost[:title] +json.description cost[:description] +json.currency_code cost[:currency_code] +json.value cost[:value] diff --git a/app/views/api/v2/plans/_funding.json.jbuilder b/app/views/api/v2/plans/_funding.json.jbuilder new file mode 100644 index 0000000000..77334b936a --- /dev/null +++ b/app/views/api/v2/plans/_funding.json.jbuilder @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# locals: plan + +json.name plan.funder&.name + +if plan.funder.present? + id = Api::V1::OrgPresenter.affiliation_id(identifiers: plan.funder.identifiers, fundref: true) + + if id.present? + json.funder_id do + json.partial! "api/v2/identifiers/show", identifier: id + end + end +end + +if plan.grant.present? + json.grant_id do + json.partial! "api/v2/identifiers/show", identifier: plan.grant + end +end +json.funding_status Api::V1::FundingPresenter.status(plan: plan) + +# DMPTool extensions to the RDA common metadata standard +if plan.identifier.present? + json.dmproadmap_funding_opportunity_id do + json.partial! "api/v2/identifiers/show", identifier: Identifier.new(identifiable: plan, + value: plan.identifier) + end +end +json.dmproadmap_funded_affiliations [plan.org] do |funded_org| + json.partial! "api/v2/orgs/show", org: funded_org +end diff --git a/app/views/api/v2/plans/_project.json.jbuilder b/app/views/api/v2/plans/_project.json.jbuilder new file mode 100644 index 0000000000..7a6208cd1e --- /dev/null +++ b/app/views/api/v2/plans/_project.json.jbuilder @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# locals: plan + +json.title plan.title +json.description plan.description + +start_date = plan.start_date || Time.now +json.start start_date.to_formatted_s(:iso8601) + +end_date = plan.end_date || Time.now + 2.years +json.end end_date&.to_formatted_s(:iso8601) + +if plan.funder.present? || plan.grant_id.present? + json.funding [plan] do + json.partial! "api/v2/plans/funding", plan: plan + end +end diff --git a/app/views/api/v2/plans/_show.json.jbuilder b/app/views/api/v2/plans/_show.json.jbuilder new file mode 100644 index 0000000000..6577f197fa --- /dev/null +++ b/app/views/api/v2/plans/_show.json.jbuilder @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +# locals: plan + +plan = plan.first if plan.is_a?(Array) +@client = client unless @client.present? + +json.ignore_nil! + +presenter = Api::V2::PlanPresenter.new(plan: plan, client: @client) +# A JSON representation of a Data Management Plan in the +# RDA Common Standard format +json.title plan.title +json.description plan.description +json.language Api::V1::LanguagePresenter.three_char_code( + lang: LocaleService.default_locale +) +json.created plan.created_at.to_formatted_s(:iso8601) +json.modified plan.updated_at.to_formatted_s(:iso8601) + +json.ethical_issues_exist Api::V2::ConversionService.boolean_to_yes_no_unknown(plan.ethical_issues) +json.ethical_issues_description plan.ethical_issues_description +json.ethical_issues_report plan.ethical_issues_report + +id = presenter.identifier +if id.present? + json.dmp_id do + json.partial! "api/v2/identifiers/show", identifier: id + end +end + +if presenter.data_contact.present? + json.contact do + json.partial! "api/v2/contributors/show", contributor: presenter.data_contact, + is_contact: true + end +end + +unless @minimal + if presenter.contributors.any? + json.contributor presenter.contributors do |contributor| + json.partial! "api/v2/contributors/show", contributor: contributor, + is_contact: false + end + end + + if presenter.costs.any? + json.cost presenter.costs do |cost| + json.partial! "api/v2/plans/cost", cost: cost + end + end + + json.project [plan] do |pln| + json.partial! "api/v2/plans/project", plan: pln + end + + outputs = plan.research_outputs.any? ? plan.research_outputs : [plan] + + json.dataset outputs do |output| + json.partial! "api/v2/datasets/show", output: output + end + + # DMPRoadmap extensions to the RDA common metadata standard + json.dmproadmap_template do + json.id plan.template.family_id + json.title plan.template.title + end + + # If the plan was created via the API and the external system provided an identifier, + # return that value + json.dmproadmap_external_system_identifier presenter.external_system_identifier&.value + + # Any related identifiers known by the DMPTool + if plan.related_identifiers.any? + json.dmproadmap_related_identifiers plan.related_identifiers do |related| + next unless related.value.present? && related.relation_type.present? + + json.descriptor related.relation_type + json.type related.identifier_type + json.identifier related.value + json.work_type related.work_type + end + end + + json.dmproadmap_privacy presenter.visibility + + # TODO: Refactor as we determine how best to fully implement sponsors + if plan.template&.sponsor.present? + json.dmproadmap_sponsors [plan.template&.sponsor] do |sponsor| + json.name sponsor.name + json.type 'field_station' + + ror = sponsor.identifier_for_scheme(scheme: 'ror') + if ror.present? + json.sponsor_id do + json.partial! "api/v2/identifiers/show", identifier: ror + end + end + end + end + + # DMPHub extension to send all callback addresses for interested subscribers for changes to the DMP + # json.dmphub_subscribers presenter.subscriptions +end + +# DMPRoadmap specific links to perform special actions like downloading the PDF +json.dmproadmap_links presenter.links diff --git a/app/views/api/v2/plans/index.json.jbuilder b/app/views/api/v2/plans/index.json.jbuilder new file mode 100644 index 0000000000..b21f41f5b5 --- /dev/null +++ b/app/views/api/v2/plans/index.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +json.partial! "api/v2/standard_response", total_items: @total_items + +json.items @items do |item| + json.dmp do + json.partial! "api/v2/plans/show", plan: item + end +end diff --git a/app/views/api/v2/plans/show.erb b/app/views/api/v2/plans/show.erb new file mode 100644 index 0000000000..068bc28cfd --- /dev/null +++ b/app/views/api/v2/plans/show.erb @@ -0,0 +1 @@ +<%= render partial: 'shared/export/plan', locals: local_assigns %> \ No newline at end of file diff --git a/app/views/api/v2/templates/index.json.jbuilder b/app/views/api/v2/templates/index.json.jbuilder new file mode 100644 index 0000000000..99736de76e --- /dev/null +++ b/app/views/api/v2/templates/index.json.jbuilder @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +json.partial! "api/v2/standard_response", total_items: @total_items + +json.items @items do |template| + presenter = Api::V1::TemplatePresenter.new(template: template) + + json.dmp_template do + json.title presenter.title + json.description template.description + json.version template.version + json.created template.created_at.to_formatted_s(:iso8601) + json.modified template.updated_at.to_formatted_s(:iso8601) + + json.affiliation do + json.partial! "api/v2/orgs/show", org: template.org + end + + json.template_id do + identifier = Api::V2::ConversionService.to_identifier(context: @application, + value: template.family_id) + json.partial! "api/v2/identifiers/show", identifier: identifier + end + end +end diff --git a/app/views/branded/contact_us/contacts/_new_right.html.erb b/app/views/branded/contact_us/contacts/_new_right.html.erb new file mode 100644 index 0000000000..2f4a8c12d5 --- /dev/null +++ b/app/views/branded/contact_us/contacts/_new_right.html.erb @@ -0,0 +1,26 @@ +<%# + DMPTool customization overview: + ------------------------------------------ + 1. Removed org phone/email contacts + 2. increased height oof google map + %> + + +
+ <%= Rails.configuration.x.organisation.name %>
+ <%= sanitize(Rails.configuration.x.organisation.address.values.compact.join("
")) %> +
+ + <% + link=Rails.configuration.x.organisation.google_maps_link + if link.present? %> + + <% end %> diff --git a/app/views/branded/contact_us/contacts/new.html.erb b/app/views/branded/contact_us/contacts/new.html.erb new file mode 100644 index 0000000000..54796b1f37 --- /dev/null +++ b/app/views/branded/contact_us/contacts/new.html.erb @@ -0,0 +1,28 @@ +<%# + DMPTool customization overview: + ------------------------------------------ + 1. Adjusted column widths + %> +<% title _('Contact Us') %> +
+
+

<%= _("Contact Us") %>

+

+ <%= sanitize _('You can find out more about us on our website (new window). %{application_name} is provided by the %{organisation_name}.
If you would like to contact us about %{application_name}, please fill out the form below.') % { + organisation_name: Rails.configuration.x.organisation.name, + organisation_url: Rails.configuration.x.organisation.url, + application_name: Rails.configuration.x.application.name, + open_in_new_window_text: _('Opens in new window') }, + tags: %w( a br span em ) %> +

+
+
+
+
+ <%= render :partial => "contact_us/contacts/new_left" %> +
+
 
+
+ <%= render :partial => "contact_us/contacts/new_right" %> +
+
diff --git a/app/views/branded/home/index.html.erb b/app/views/branded/home/index.html.erb new file mode 100644 index 0000000000..10bbc38d11 --- /dev/null +++ b/app/views/branded/home/index.html.erb @@ -0,0 +1,93 @@ +
+
+ +
+
+

<%= _('DMPTool by the Numbers') %>

+
+ +
+
+
+
<%= number_with_delimiter(@stats[:user_count]) || '?' %>
+

<%= _("Users") %>

+
+ +
+
+
<%= number_with_delimiter(@stats[:completed_plan_count]) || '?' %>
+

<%= _('Plans')%>

+ +
+ +
+
+
<%= number_with_delimiter(@stats[:institution_count]) || '?' %>
+

<%= _('Participating Institutions')%>

+ <% sr_text = _("View the list of participating institutions") %> + +
+
+
+ + +
+
+

<%= _('Top Templates') %>

+
+ +
+
    + <% @top_five.each do |title| %> +
  1. <%= title %>
  2. + <% end %> +
+ <% sr_text = _("View the list of funder requirements") %> + +
+
+
+ +
+
+

<%= _('DMPTool News') %>

+
+ + +
+ <% if @rss.present? %> + + <% + json = [] + @rss.each do |i| + json << { "title": "#{truncate(i.title, length: 120)}", "link": "#{i.link}" } + end + %> + + <% else %> +

<%= _('News is currently unavailable') %>

+ <% end %> +
+ + + +
+
diff --git a/app/views/branded/layouts/_analytics.html.erb b/app/views/branded/layouts/_analytics.html.erb new file mode 100644 index 0000000000..f33d4cfbf8 --- /dev/null +++ b/app/views/branded/layouts/_analytics.html.erb @@ -0,0 +1,42 @@ + +<% if Rails.env.stage? && Rails.configuration.x.dmproadmap.usersnap_key.present? %> + +<% end %> + + +<% if (Rails.env.stage? || Rails.env.production?) && Rails.configuration.x.dmproadmap.google_analytics_tracker_root.present? %> + <% root_abbr = Rails.configuration.x.dmproadmap.google_analytics_tracker_root %> + <% root_org = Org.where(abbreviation: root_abbr).first %> + <% if root_org.tracker.present? %> + <% root_code = root_org.tracker.code %> + + + <% end %> +<% end %> + diff --git a/app/views/branded/layouts/_app_menu.html.erb b/app/views/branded/layouts/_app_menu.html.erb new file mode 100644 index 0000000000..61c49015b2 --- /dev/null +++ b/app/views/branded/layouts/_app_menu.html.erb @@ -0,0 +1,5 @@ +<% if user_signed_in? %> + +<% end %> diff --git a/app/views/branded/layouts/_app_menu_links.html.erb b/app/views/branded/layouts/_app_menu_links.html.erb new file mode 100644 index 0000000000..a672a9e618 --- /dev/null +++ b/app/views/branded/layouts/_app_menu_links.html.erb @@ -0,0 +1,55 @@ + + + +<% if current_user.can_org_admin? || current_user.can_super_admin? %> + +<% end %> diff --git a/app/views/branded/layouts/_branding.html.erb b/app/views/branded/layouts/_branding.html.erb new file mode 100644 index 0000000000..32352d8c92 --- /dev/null +++ b/app/views/branded/layouts/_branding.html.erb @@ -0,0 +1,17 @@ +<% if user_signed_in? && !current_user.org.is_other? %> +
+ <%= current_user.org.name.split("(").first %> +
+<% end %> + + + +<% if user_signed_in? && !current_user.org.is_other? %> + +<% end %> diff --git a/app/views/branded/layouts/_constants.html.erb b/app/views/branded/layouts/_constants.html.erb new file mode 100644 index 0000000000..f842d4bc99 --- /dev/null +++ b/app/views/branded/layouts/_constants.html.erb @@ -0,0 +1,47 @@ +<% +constants_json = { + HOST: (Rails.env.development? || Rails.env.test? ? 'localhost' : Socket.gethostname), + PASSWORD_MIN_LENGTH: 8, + PASSWORD_MAX_LENGTH: 128, + MAX_NUMBER_ORG_URLS: 3, + MAX_NUMBER_GUIDANCE_SELECTIONS: 6, + + REQUIRED_FIELD_TEXT: _('This field is required.'), + + SHOW_PASSWORD_MESSAGE: _('Show password'), + SHOW_SELECT_ORG_MESSAGE: _('Select an organisation from the list.'), + SHOW_OTHER_ORG_MESSAGE: _('My organisation isn\'t listed'), + + VALIDATION_MESSAGE_PASSWORDS_MATCH: _('The passwords you provided do not match.'), + + PLAN_VISIBILITY_WHEN_TEST: _('N/A'), + PLAN_VISIBILITY_WHEN_NOT_TEST: _('Private'), + PLAN_VISIBILITY_WHEN_NOT_TEST_TOOLTIP: _('Private: restricted to me and people I invite.'), + + SHIBBOLETH_DISCOVERY_SERVICE_HIDE_LIST: _('Hide list.'), + SHIBBOLETH_DISCOVERY_SERVICE_SHOW_LIST: _('See the full list of participating institutions.'), + + NO_TEMPLATE_FOUND_ERROR: _('Unable to find a suitable template for the research organisation and funder you selected.'), + NEW_PLAN_DISABLED_TOOLTIP: _('Please select a research organisation and funder to continue.'), + + OPENS_IN_A_NEW_WINDOW_TEXT: _('Opens in a new window'), + + AJAX_LOADING: _('Loading ...'), + AJAX_UNABLE_TO_LOAD_TEMPLATE_SECTION: _('Unable to load the section\'s content at this time.'), + AJAX_UNABLE_TO_LOAD_TEMPLATE_SECTION_QUESTION: _('Unable to load the question\'s content at this time.'), + + AUTOCOMPLETE_ARIA_HELPER: _("%{n} results are available, use up and down arrows to navigate suggestions. Use the Enter key to select a suggestion or the Escape key to close the suggestions."), + AUTOCOMPLETE_ARIA_HELPER_EMPTY: _("No results are available for your entry."), + AUTOCOMPLETE_SEARCHING: _("Searching ..."), + + MORE_INFO: _("More info"), + LESS_INFO: _("Less info"), + + ACQUIRING_DMP_ID: _("Acquiring your DMP ID. This may take a few seconds ..."), + + # The following are here so that we can translate them before they get pushed into DMPRoadmap + PUBLISH_TEXT: _("Finalise / Publish") +}.to_json +%> + + diff --git a/app/views/branded/layouts/_fixed_menu.html.erb b/app/views/branded/layouts/_fixed_menu.html.erb new file mode 100644 index 0000000000..06a6c78606 --- /dev/null +++ b/app/views/branded/layouts/_fixed_menu.html.erb @@ -0,0 +1,31 @@ + diff --git a/app/views/branded/layouts/_footer.html.erb b/app/views/branded/layouts/_footer.html.erb new file mode 100644 index 0000000000..6ca2c3abc5 --- /dev/null +++ b/app/views/branded/layouts/_footer.html.erb @@ -0,0 +1,63 @@ +<% + # Override the default Rails route helper for the contact_us page IF an alternate contact_us url was defined + # in the branding config file + contact_us = Rails.configuration.x.organisation.contact_us_url || contact_us_path +%> + + + + diff --git a/app/views/branded/layouts/_header.html.erb b/app/views/branded/layouts/_header.html.erb new file mode 100644 index 0000000000..cbb553916a --- /dev/null +++ b/app/views/branded/layouts/_header.html.erb @@ -0,0 +1,14 @@ +
+ <%= render partial: "layouts/fixed_menu" %> +
+
+ <%= render partial: "layouts/mobile/fixed_menu" %> +
+
+ <%= render partial: "layouts/branding" %> +
+<% if user_signed_in? %> +
+ <%= render partial: "layouts/app_menu" %> +
+<% end %> \ No newline at end of file diff --git a/app/views/branded/layouts/_homepage_image.html.erb b/app/views/branded/layouts/_homepage_image.html.erb new file mode 100644 index 0000000000..06cf5912f8 --- /dev/null +++ b/app/views/branded/layouts/_homepage_image.html.erb @@ -0,0 +1,17 @@ +
+
+
+
+

<%= _('Welcome to the DMPTool') %>

+

<%= _('Create data management plans that meet institutional and funder requirements.') %>

+
+ +
+
+
\ No newline at end of file diff --git a/app/views/branded/layouts/_language_menu.html.erb b/app/views/branded/layouts/_language_menu.html.erb new file mode 100644 index 0000000000..16c534679b --- /dev/null +++ b/app/views/branded/layouts/_language_menu.html.erb @@ -0,0 +1,19 @@ + +<% if Language.many? %> + +<% end %> diff --git a/app/views/branded/layouts/_learn_menu.html.erb b/app/views/branded/layouts/_learn_menu.html.erb new file mode 100644 index 0000000000..dde3037b93 --- /dev/null +++ b/app/views/branded/layouts/_learn_menu.html.erb @@ -0,0 +1,37 @@ + + diff --git a/app/views/branded/layouts/_logo.html.erb b/app/views/branded/layouts/_logo.html.erb new file mode 100644 index 0000000000..6d475ab74c --- /dev/null +++ b/app/views/branded/layouts/_logo.html.erb @@ -0,0 +1,15 @@ + + <% if user_signed_in? && current_user.org.present? && current_user.org.logo.present? %> + <%= image_tag(current_user.org.logo.thumb('100x100%').url, + alt: "#{current_user.org.name} logo", + class: "org-logo", + title: current_user.org.name) %> + + <% else %> + <%= link_to(image_tag("DMPTool_logo_blue_shades_v1b3b.svg", + alt: "#{ApplicationService.application_name} #{_('logo')}", + class: "app-logo", + title: ApplicationService.application_name), + root_path) %> + <% end %> + diff --git a/app/views/branded/layouts/_notifications.html.erb b/app/views/branded/layouts/_notifications.html.erb new file mode 100644 index 0000000000..b979391448 --- /dev/null +++ b/app/views/branded/layouts/_notifications.html.erb @@ -0,0 +1,29 @@ +<% +has_alert = (alert || flash[:alert] || flash[:error]) +has_notice = (notice || flash[:notice]) +%> + +
+ + <%= has_alert ? _('Error:') : _('Notice:') %> + <%= sanitize (has_alert ? alert : notice) %> +
+
+ <% Notification.active_per_user(current_user).each do |a| %> + <% unless a.acknowledged?(current_user) %> +
+ + <%= "#{a.level.capitalize}:" %> + <%= sanitize a.body %> + <% if a.dismissable? %> + + <% end %> +
+ <% end %> + <% end %> +
diff --git a/app/views/branded/layouts/_org_links.html.erb b/app/views/branded/layouts/_org_links.html.erb new file mode 100644 index 0000000000..3ef9d47dfc --- /dev/null +++ b/app/views/branded/layouts/_org_links.html.erb @@ -0,0 +1,17 @@ +<% if user_signed_in? %> + <% if current_user.org.links.present? && current_user.org.links['org'].present? %> + <% current_user.org.links['org'].each_with_index do |link, i| %> + <% if !link.blank? && i <= Rails.configuration.x.max_number_links_org %> +
  • + <%= link_to sanitize("#{link['text'].truncate(40)} (new window)"), link['link'], target: '_blank', class: 'org-a' %> +
  • + <% end %> + <% end %> + <% end %> + <% if !current_user.org.contact_email.blank? %> +
  • + + <%= mail_to current_user.org.contact_email, current_user.org.contact_name, class: 'org-a' %> +
  • + <% end %> +<% end %> diff --git a/app/views/branded/layouts/_profile_menu.html.erb b/app/views/branded/layouts/_profile_menu.html.erb new file mode 100644 index 0000000000..283c30466e --- /dev/null +++ b/app/views/branded/layouts/_profile_menu.html.erb @@ -0,0 +1,13 @@ +<% if user_signed_in? %> + + +<% end %> diff --git a/app/views/branded/layouts/application.html.erb b/app/views/branded/layouts/application.html.erb new file mode 100644 index 0000000000..2ab1e255a8 --- /dev/null +++ b/app/views/branded/layouts/application.html.erb @@ -0,0 +1,122 @@ + + + + + <%= render partial: 'layouts/analytics' %> + + <%= content_for?(:title) ? yield(:title) : _('%{application_name}') % { + application_name: ApplicationService.application_name } %> + + <%= favicon_link_tag 'favicon.ico', rel: 'icon', type: 'image/png' %> + <%= favicon_link_tag 'apple-touch-icon.png', rel: 'apple-touch-icon', type: 'image/png' %> + <%= favicon_link_tag 'favicon-32x32.png', rel: 'icon', sizes: "32x32", type: 'image/png' %> + <%= favicon_link_tag 'favicon-16x16.png', rel: 'icon', sizes: "16x16", type: 'image/png' %> + <%= favicon_link_tag 'safari-pinned-tab.svg', rel: 'mask-icon', color: "#0E5682" %> + <%= favicon_link_tag 'site.webmanifest', rel: 'manifest' %> + + + + + + <%= favicon_link_tag "favicon.ico" %> + + + + + + + + <%= stylesheet_link_tag(stylesheet_manifest_file) %> + <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %> + <%= csrf_meta_tags %> + + + + + + + +
    +
    + <%= render partial: "layouts/header" %> +
    + +
    + <%= render partial: "layouts/notifications" %> +
    + + <% if active_page?(root_path, true) && !user_signed_in? %> + <%= render partial: "layouts/homepage_image" %> + <% end %> + +
    +
    + <%= yield %> +
    + + <%# Generic UJS/Ajax spinner. Bootstrap 5+ has built in spinner for this class %> + + +
    + > + + + +
    + + <% unless active_page?(user_shibboleth_omniauth_callback_path) || + active_page?(user_orcid_omniauth_callback_path) %> + + <% unless user_signed_in? %> + <% if Rails.configuration.x.dmproadmap.shibboleth_use_filtered_discovery_service %> + + <% end %> + + + + + <% end %> + <% end %> + + <%= render partial: "layouts/constants" %> + + + diff --git a/app/views/branded/layouts/mobile/_fixed_menu.html.erb b/app/views/branded/layouts/mobile/_fixed_menu.html.erb new file mode 100644 index 0000000000..2e6f7a770d --- /dev/null +++ b/app/views/branded/layouts/mobile/_fixed_menu.html.erb @@ -0,0 +1,100 @@ + + + diff --git a/app/views/branded/layouts/mobile/_footer.html.erb b/app/views/branded/layouts/mobile/_footer.html.erb new file mode 100644 index 0000000000..e2a8fd9bec --- /dev/null +++ b/app/views/branded/layouts/mobile/_footer.html.erb @@ -0,0 +1,42 @@ +<% + # Override the default Rails route helper for the contact_us page IF an alternate contact_us url was defined + # in the branding config file + contact_us = Rails.configuration.x.organisation.contact_us_url || contact_us_path +%> + + diff --git a/app/views/branded/org_admin/templates/email.js.erb b/app/views/branded/org_admin/templates/email.js.erb new file mode 100644 index 0000000000..6351d1f874 --- /dev/null +++ b/app/views/branded/org_admin/templates/email.js.erb @@ -0,0 +1,47 @@ +var emailTemplateModal = $('#modal-invite'); + +toggleSpinner(false); + +if (emailTemplateModal) { + // Redraw the modal based on the selected Template + emailTemplateModal.find('.modal-body').html('<%= + escape_javascript( + render( + partial: "paginable/templates/invite_modal_content", + locals: { template: @template } + ) + ) %>'); + + emailTemplateModal.on('show.bs.modal', (e) => { + // We need to do this in the event that the user opened the modal, then closed it, + // and then reopens it. + Tinymce.destroyEditorById('plan_template_attributes_email_body'); + // Make the email body editor a TinyMCE editor + Tinymce.init({ selector: '#plan_template_attributes_email_body' }); + + var emailControl = emailTemplateModal.find('#plan_user_email'); + var emailBodyControl = Tinymce.findEditorById('plan_template_attributes_email_body'); + var emailWelcomePreview = emailTemplateModal.find('.replaceable-template-email-welcome'); + var emailPreview = emailTemplateModal.find('.replaceable-template-email-content'); + + // Add handlers to the email address and TinyMCE editor so that changes update + // the preview section + if (emailControl && emailBodyControl && emailPreview) { + emailControl.on('keyup', (e) => { + emailWelcomePreview.html(`Hello ${$(e.target).val()},`); + }); + + emailBodyControl.on('keyup', (e) => { + emailPreview.html($(e.target).html()); + }); + } + + // Hide the modal and display the spinner after the user submits the form + emailTemplateModal.find('form').on('submit', () => { + emailTemplateModal.modal('hide'); + toggleSpinner(true); + }); + }); + + emailTemplateModal.modal('show'); +} diff --git a/app/views/branded/paginable/orgs/_public.html.erb b/app/views/branded/paginable/orgs/_public.html.erb new file mode 100644 index 0000000000..5fa1b74df4 --- /dev/null +++ b/app/views/branded/paginable/orgs/_public.html.erb @@ -0,0 +1,18 @@ +
    + + + + + + + + + <% scope.each do |org| %> + + + + + <% end %> + +
    <%= _('Institution') %> <%= paginable_sort_link('orgs.name') %><%= _('Institutional Signin Enabled') %>
    <%= org.name %>
    +
    diff --git a/app/views/branded/paginable/templates/_invite_modal.html.erb b/app/views/branded/paginable/templates/_invite_modal.html.erb new file mode 100644 index 0000000000..5c50d60e8f --- /dev/null +++ b/app/views/branded/paginable/templates/_invite_modal.html.erb @@ -0,0 +1,27 @@ + diff --git a/app/views/branded/paginable/templates/_invite_modal_content.html.erb b/app/views/branded/paginable/templates/_invite_modal_content.html.erb new file mode 100644 index 0000000000..c388db716f --- /dev/null +++ b/app/views/branded/paginable/templates/_invite_modal_content.html.erb @@ -0,0 +1,64 @@ +<%# locals: template %> + +<% plan = Plan.new(template: template) %> + +

    + <%= _("Create a new plan for the specified user based off of %{template_name}. If the user does not already have an account, they will receive an invitation to sign up.") % { + template_name: template&.title + } %> +

    + +<%= form_with model: plan, url: org_admin_plans_path, method: :post, local: true do |f| %> +
    + <%= f.fields_for :user, User.new do |user_fields| %> + <%= user_fields.label :email, _("User's email"), class: 'control-label' %> + <%= user_fields.email_field :email, class: 'form-control' %> + <% end %> +
    +

    + <%= _("You can use the default email subject and body below or replace them with your own message. Refer to ther preview section below to see what the email will look like.") %> +

    +
    + <%= f.fields_for :template, template do |tmplt_fields| %> + <%= tmplt_fields.hidden_field :id, value: template.id %> + + <%= tmplt_fields.label :email_subject, _("Email subject"), + class: 'control-label' %> + <%= tmplt_fields.text_field :email_subject, class: 'form-control' %> + + <%= tmplt_fields.label :email_body, _("Email body"), + class: 'control-label' %> + <%= tmplt_fields.text_area :email_body, row: 4, class: 'form-control tinymce' %> + <% end %> +
    + +
    +
    + Email Preview: +
    +
    +

    + Hello jane.doe@example.org, +

    +

    + <%= sanitize(plan.template.email_body) %> +

    +

    + <%= sanitize(_("%{click_here} to setup your account (or copy %{link} into your browser). Once you have signed in, you can begin filling out your DMP.") % { + click_here: link_to(_('Click here'), '#'), + link: "#{accept_user_invitation_url}/abc123" + }) %> +

    +
    +

    <%= _("Thank you,") %>

    +

    <%= _("The %{org_name} DMPTool team") % { org_name: template.org.name } %>

    +

    <%= _("Please do not reply to this email. If you have any questions or need help, please contact us at %{org_admin_email}") % { + org_admin_email: link_to(template.org.contact_email, template.org.contact_email) + } %>

    +
    +
    + +
    + <%= f.submit _("Send email"), class: "btn btn-default" %> +
    +<% end %> \ No newline at end of file diff --git a/app/views/branded/paginable/templates/_organisational.html.erb b/app/views/branded/paginable/templates/_organisational.html.erb new file mode 100644 index 0000000000..17fa12d2c1 --- /dev/null +++ b/app/views/branded/paginable/templates/_organisational.html.erb @@ -0,0 +1,113 @@ +<% # locals: templates %> +<% export_as_pdf = _('Download') %> + +
    + + + + + <% if action_name == 'organisational' %> + + <% else %> + + <% end %> + + + <% if action_name != 'index' %> + + <% end %> + + + + <% scope.each do |template| %> + + + + + + <% if action_name != 'index' %> + + <% end %> + + <% end %> + +
    <%= _('Template Name') %> <%= paginable_sort_link('templates.title') %><%= _('Description') %> <%= paginable_sort_link('templates.description') %><%= (action_name == 'customizable' ? _('Funder') : _('Organisation')) %> <%= paginable_sort_link('orgs.name') %><%= _('Status') %><%= _('Edited Date') %> <%= paginable_sort_link('templates.updated_at') %> 
    + <%= link_to "#{template.title}", edit_org_admin_template_path(template.id), + class: 'c-template-title' %> + + <%= action_name == 'organisational' ? sanitize(template.description) : template.org.name %> + + <%# Leaving this line here as a placeholder for determining how to notify user of changes now that dirty flag is removed %> + <%# if template.dirty? %> + <%# _('Unpublished changes') %> + + <% if template.published? %> + <%= _('Published') %> + <% elsif template.draft? %> + <% tooltip = _('You have unpublished changes! Select "Publish changes" in the Actions menu when you are ready to make them available to users.') %> + <%= _('Published') %> <%= tooltip %> +    + <% else %> + <%= _('Unpublished') %> + <% end %> + + <% last_temp_updated = template.updated_at %> + <%= l last_temp_updated.to_date, formats: :short %> + + +
    +
    + +<%= render 'shared/copy_link_modal' %> +<%= render 'paginable/templates/invite_modal' %> diff --git a/app/views/branded/public_pages/orgs.html.erb b/app/views/branded/public_pages/orgs.html.erb new file mode 100644 index 0000000000..4f4acad518 --- /dev/null +++ b/app/views/branded/public_pages/orgs.html.erb @@ -0,0 +1,15 @@ +
    +
    +

    <%= _('Participating Institutions') %>

    +

    <%= raw _("Participating institutions/organizations can configure the tool to point to their resources and services, provide customized help, and provide suggested answers to the questions asked by funding agencies. DMPTool users affiliated with participating institutions can login with their own institutional accounts. For more information visit the %{about_us_link} page.") % { about_us_link: link_to(_('About'), about_us_path) } %>

    + + <% if @orgs.count > 0 %> + <%= paginable_renderise( + partial: '/paginable/orgs/public', + controller: 'paginable/orgs', + action: 'public', + scope: @orgs, + query_params: { sort_field: "orgs.name", sort_direction: :asc }) %> + <% end %> +
    +
    \ No newline at end of file diff --git a/app/views/branded/public_pages/template_index.html.erb b/app/views/branded/public_pages/template_index.html.erb new file mode 100644 index 0000000000..e7a421cef8 --- /dev/null +++ b/app/views/branded/public_pages/template_index.html.erb @@ -0,0 +1,25 @@ +<% title _('DMP Templates') %> +
    +
    +

    <%= raw _('Funder Requirements') %>

    + + <% if @templates.count > 0 %> +

    <%= _('Templates for data management plans are based on the specific requirements listed in funder policy documents. The DMPTool maintains these templates, however, researchers should always consult the program officers and policy documents directly for authoritative guidance. Sample plans are provided by a funder or another trusted party.') %>

    + <% else %> +

    <%= _('There are currently no public Templates.')%>

    + <% end %> +
    +
    +
    +
    + <% if @templates.count > 0 %> + <%= paginable_renderise( + partial: '/paginable/templates/publicly_visible', + controller: 'paginable/templates', + action: 'publicly_visible', + scope: @templates, + remote: false, + query_params: @templates_query_params) %> + <% end %> +
    +
    \ No newline at end of file diff --git a/app/views/branded/shared/_get_started.html.erb b/app/views/branded/shared/_get_started.html.erb new file mode 100644 index 0000000000..90db5f367b --- /dev/null +++ b/app/views/branded/shared/_get_started.html.erb @@ -0,0 +1,39 @@ +
    +
    +
    +

    <%= _('Sign in options') %>

    +
    +
    + <% step_counter = 1 %> + <% if Rails.configuration.x.dmproadmap.shibboleth_enabled %> + + <% if request.fullpath != "/users/sign_up?nosplash=true" && session[:shibboleth_data].nil? then%> +

    <%= _('Option %{number}: If your institution is affiliated with DMPTool.') % { number: step_counter } %>

    + <% step_counter += 1 %> + <% if Rails.configuration.x.dmproadmap.shibboleth_use_filtered_discovery_service %> + <%= link_to _('Your institution'), '#', class: 'btn btn-default btn-block', + 'data-toggle': 'modal', 'data-target': '#shib-ds-form' %> + <% else %> + <%= link_to _('Your institution'), user_shibboleth_omniauth_authorize_path, class: 'btn btn-default btn-block' %> + <% end %> +

    <%= _('- or - ') %>

    + <%else%> + <%= f.hidden_field :shibboleth_id, :value => session[:shibboleth_data][:uid] %> + <%end%> + <% end %> + +

    <%= _('Option %{number}: If your institution is not affiliated with DMPTool.') % { number: step_counter } %>

    + <% step_counter += 1 %> + <%= link_to _('Email address'), '#', class: 'btn btn-default btn-block', id: 'show-sign-in-form', + 'data-toggle': 'modal', 'data-target': '#sign-in-create-account' %> +

    <%= _('- or -') %>

    +

    <%= _('Option %{number}: If not affiliated and you need an account.') % { number: step_counter } %>

    +
    + <%= link_to _('Create an account'), '#', id: 'show-create-account-form', class: 'btn btn-default btn-block', + 'data-toggle': 'modal', 'data-target': '#sign-in-create-account' %> +
    +
    +
    +
    +
    + diff --git a/app/views/branded/shared/_shib_ds_form.html.erb b/app/views/branded/shared/_shib_ds_form.html.erb new file mode 100644 index 0000000000..105a85f09b --- /dev/null +++ b/app/views/branded/shared/_shib_ds_form.html.erb @@ -0,0 +1,66 @@ +<% + presenter = Dmptool::OrgPresenter.new + participating = presenter.participating_orgs + %> + + + + diff --git a/app/views/branded/shared/_sign_in_form.html.erb b/app/views/branded/shared/_sign_in_form.html.erb new file mode 100644 index 0000000000..7cb53679d1 --- /dev/null +++ b/app/views/branded/shared/_sign_in_form.html.erb @@ -0,0 +1,36 @@ +<%# The only DMPTool customization here is commenting out the shib section %> +<%= form_for resource, as: 'user', namespace: 'signin', url: user_session_url, html: {id: "sign_in_form"} do |f| %> +
    + <%= f.label(:email, _('Email'), class: 'control-label') %> + <%= f.email_field(:email, class: 'form-control', "aria-required": true) %> +
    +
    + <%= f.label(:password, _('Password'), class: 'control-label') %> + <%= f.password_field(:password, class: 'form-control', "aria-required": true) %> +
    +
    + <%= link_to _('Forgot password?'), new_password_path('user') %> +
    +
    + <%= label_tag 'remember_email' do %> + <%= check_box_tag 'remember_email' %> + <%= _('Remember email') %> + <% end %> +
    + + <%= f.button(_('Sign in'), class: "btn btn-default", type: "submit") %> + + <%# if Rails.configuration.x.dmproadmap.shibboleth_enabled %> + <%# if session['devise.shibboleth_data'].nil? %> + + + + <%# target = (Rails.configuration.x.dmproadmap.shibboleth_use_filtered_discovery_service ? shibboleth_ds_path : user_shibboleth_omniauth_authorize_path) %> + <%#= link_to _('Sign in with your institutional credentials'), target, method: :post, class: 'btn btn-default' %> + + + <%# else %> + <%#= f.hidden_field :shibboleth_id, :value => session['devise.shibboleth_data']['uid'] %> + <%# end %> + <%# end %> +<% end %> diff --git a/app/views/branded/shared/_signin_create_form.html.erb b/app/views/branded/shared/_signin_create_form.html.erb new file mode 100644 index 0000000000..5b4f07804c --- /dev/null +++ b/app/views/branded/shared/_signin_create_form.html.erb @@ -0,0 +1,49 @@ + diff --git a/app/views/branded/shared/org_branding.html.erb b/app/views/branded/shared/org_branding.html.erb new file mode 100644 index 0000000000..82e7889c2d --- /dev/null +++ b/app/views/branded/shared/org_branding.html.erb @@ -0,0 +1,34 @@ +
    +
    + <% if @user.org.logo.present? %> + <%= image_tag(@user.org.logo.thumb('100x100%').url, + alt: @user.org.name, + class: "org-logo", + title: @user.org.name) %> + <% else %> +

    <%= @user.org.name %>

    + <% end %> + +
    +
    +
    +

    <%= _("Sign in") %>

    +
    + <%= render partial: "shared/sign_in_form", locals: { resource: @user } %> +
    +
    +

    <%= _("Create account") %>

    +
    + <%= hidden_field_tag "default_org_id", @user.org.id %> + <%= hidden_field_tag "default_org_name", @user.org.name %> + <%= render partial: "shared/create_account_form", + locals: { + resource: @user, + org_partial: (@org_partial || "shared/org_selectors/combined"), + orgs: Org.participating + } %> +
    +
    +
    +
    +
    diff --git a/app/views/branded/static_pages/about_us.html.erb b/app/views/branded/static_pages/about_us.html.erb new file mode 100644 index 0000000000..1f1eef33e4 --- /dev/null +++ b/app/views/branded/static_pages/about_us.html.erb @@ -0,0 +1,32 @@ +
    +

    <%= _('About') %>

    +
    + +

    <%= _('What is the DMPTool?') %>

    + +

    <%= _("The DMPTool is a free, open-source, online application that helps researchers create data management plans. These plans, or DMPs, are now required by many funding agencies as part of the grant proposal submission process. The DMPTool provides a click-through wizard for creating a DMP that complies with funder requirements. It also has direct links to funder websites, help text for answering questions, and resources for best practices surrounding data management.") %>

    +
    + +

    <%= _('DMPTool Background') %>

    + +

    <%= sanitize _("The original DMPTool was a grassroots effort, beginning in 2011 with eight institutions partnering to provide in-kind contributions of personnel and development. The effort was in direct response to demands from funding agencies, such as the National Science Foundation and the National Institutes of Health, that researchers plan for managing their research data. By joining forces the contributing institutions were able to consolidate expertise and reduce costs in addressing data management needs. Representatives from these institutions participate on the DMPTool Steering Committee that maintains monthly calls.") % { steering_committee_url: 'https://github.com/CDLUC3/dmptool/wiki/Steering-Committee' } %>

    + +

    <%= sanitize _("The original contributing institutions were: %{uc3_url} at the %{cdl_url}, %{data_one_url}, %{dcc_url}, %{smithsonian_url}, %{ucla_url}, %{ucsd_url}, %{uoi_url}, and %{uva_url}. Given the success of the first version of the DMPTool, the founding partners obtained funding from the %{sloan_url} to create a second version of the tool, released in 2014.") % { uc3_url: 'University of California Curation Center (UC3)', cdl_url: 'California Digital Library', data_one_url: 'DataONE', dcc_url: 'Digital Curation Centre (DCC-UK)', smithsonian_url: 'Smithsonian Institution', ucla_url: 'University of California, Los Angeles Library', ucsd_url: 'University of California, San Diego Libraries', uoi_url: 'University of Illinois, Urbana-Champaign Library', uva_url: 'University of Virginia Library', sloan_url: 'Alfred P. Sloan Foundation' } %>

    + +

    <%= _("More recently the proliferation of open data policies across the globe has led to an explosion of interest in the DMPTool and the UK-based version, DMPonline. In 2016 UC3 and DCC decided to formalize our partnership to codevelop and maintain a single open-source platform. The new platform—DMPRoadmap—is separate from the services each of our organizations runs on top of it. By providing a core infrastructure for DMPs we can extend our reach and move best practices forward, allowing us to participate in a truly global open science ecosystem.") %>

    + +

    <%= sanitize _("Future enhancements will focus on making DMPs machine actionable so please continue sharing your use cases. We invite you to peruse the DMPRoadmap GitHub wiki to learn how to get involved in the project.") % { get_involved_url: 'https://github.com/DMPRoadmap/roadmap/wiki/get-involved' } %>

    +
    + +

    <%= _('How to Participate in the DMPTool') %>

    + +

    <%= sanitize _("DMPTool participants are institutions, profit and nonprofit organizations, individuals, or other groups that leverage the DMPTool as an effective and efficient way to create data management plans. Our community of participating organizations helps to sustain and support the DMPTool in the following ways:

    • Establish institutional authentication with the DMPTool (Shibboleth)
    • Customize the tool with resources, help text, suggested answers, or other information
    • Contribute to the maintenance and enhancement of the DMPTool codebase
    • Help ensure that the DMPTool maintains its relevance and utility by notifying the DMPTool Helpdesk if:
      • Funders release new requirements that are not reflected in the tool
      • Errors, mistakes, or misinformation are discovered in the tool
      • The tool's functionality is compromised (i.e., slow response times, poor performance, or software bugs)
    ") %>

    + +

    <%= sanitize _("See a current list of DMPTool participants.") % { participants_url: public_orgs_path } %> + <%= sanitize _("Use the contact form below if you are interested in adding your organization.") %>

    +
    + +

    <%= _('DMPTool Principles') %>

    + +

    <%= sanitize _("Our work on the DMPTool is guided by the following principles. Organizations that participate in the DMPTool community are expected to understand and abide by these principles.

    • Continuous improvement of the DMPTool's utility and features
    • User-driven requirements and priorities for development
    • Enthusiasm for the DMPTool
    • Commitment to an open process for development, enhancement, and improvement (see Panton Principles)
    • Quality of code and materials created (Contributor guidelines)
    ") % { panton_url: 'https://pantonprinciples.org/', contributing_url: 'https://github.com/DMPRoadmap/roadmap/blob/development/CONTRIBUTING.md' } %>

    +
    \ No newline at end of file diff --git a/app/views/branded/static_pages/editorial_board.html.erb b/app/views/branded/static_pages/editorial_board.html.erb new file mode 100644 index 0000000000..c08d6c7604 --- /dev/null +++ b/app/views/branded/static_pages/editorial_board.html.erb @@ -0,0 +1,89 @@ +
    +

    <%= _('Editorial Board') %>

    +
    + +

    <%= _("The DMPTool Editorial Board works to ensure the tool provides current information about grant requirements and corresponding guidance. Our Board includes representation across disciplines with varied areas of expertise, from a wide range of institutions committed to supporting effective research data management.") %>

    +
    + +
    +
    +
    <%= image_tag "HeatherBarnes.jpg" %>
    +
    + Heather L Barnes, PhD
    Digital Curation Librarian, Wake Forest University +
    +
    +
    +
    <%= image_tag "Raj_Kumar_Bhardwaj.jpg" %>
    +
    + Raj Kumar Bhardwaj, PhD
    Librarian, St Stephen's College, University of Delhi, India +
    +
    +
    +
    <%= image_tag "Renata_Curty.JPG" %>
    +
    + Renata G. Curty, PhD
    Social Sciences Data Curator, University of California, Santa Barbara +
    +
    +
    +
    <%= image_tag "doty-jen-resized.jpg" %>
    +
    + Jennifer Doty
    Research Data Librarian, Emory University +
    +
    +
    +
    <%= image_tag "ExnerHeadshot.JPG" %>
    +
    + Nina Exner
    Research Data Librarian, Virginia Commonwealth University +
    +
    +
    +
    <%= image_tag "GeoffHamm.png" %>
    +
    + Geoff Hamm, PhD
    Scientific Publications Coordinator, Lawrence Berkeley National Laboratory +
    +
    +
    +
    <%= image_tag "jhermer.jpg" %>
    +
    + Janice Hermer
    Health Sciences Liaison Librarian, Arizona State University +
    +
    +
    +
    <%= image_tag "Megan_O_Donnell.jpg" %>
    +
    + Megan O'Donnell
    Data Services Librarian, Iowa State University +
    +
    +
    +
    <%= image_tag "rotsuji-portrait.jpg" %>
    +
    + Reid Otsuji
    Data Curation Specialist Librarian University of California, San Diego +
    +
    +
    +
    <%= image_tag "Nick_Ruhs.jpg" %>
    +
    + Nick Ruhs, PhD.
    STEM Data & Research Librarian, Florida State University +
    +
    +
    +
    <%= image_tag "Sackmann_Anna_hr.jpg" %>
    +
    + Anna Sackmann
    Science Data & Engineering Libraria, University of California, Berkeley +
    +
    +
    +
    <%= image_tag "BridgetThrasher.jpg" %>
    +
    + Bridget Thrasher, PhD
    Data Stewardship Coordinator, Associate Scientist III, National Center for Atmospheric Research +
    +
    +
    +
    <%= image_tag "VarnerDouglas.jpg" %>
    +
    + Douglas L. Varner
    Assistant Dean for Information Management / Chief Biomedical Informationist, Georgetown University Medical Center +
    +
    +
    + +
    diff --git a/app/views/branded/static_pages/faq.html.erb b/app/views/branded/static_pages/faq.html.erb new file mode 100644 index 0000000000..15adbd9350 --- /dev/null +++ b/app/views/branded/static_pages/faq.html.erb @@ -0,0 +1,179 @@ +
    +

    <%= _('FAQ') %>

    + + +

    <%= _('About the DMPTool') %>

    + + +

    <%= _('For Researchers') %>

    + + +

    <%= _('For Administrators') %>

    + + +

    <%= sanitize _("Can't find what you’re looking for? Contact us") % { contact_us_url: contact_us_path } %>

    + +
    + +

    <%= _('About the DMPTool') %>

    +
    + +

    <%= _('Q: What is a data management plan (DMP)?') %>

    +

    <%= _("A: A data management plan is a formal document that outlines what you will do with your data during and after a research project. Most researchers collect data with some form of plan in mind, but it's often inadequately documented and incompletely thought out. Many data management issues can be handled easily or avoided entirely by planning ahead. With the right process and framework it doesn't take too long and can pay off enormously in the long run.") %>

    + +

    <%= sanitize _('Read our Data Management General Guidance for more information about data management plans.') % { general_guidance_url: general_guidance_path } %>

    +
    + +

    <%= _('Q: What can I do with the DMPTool?') %>

    +

    <%= sanitize _('A: The DMPTool helps researchers create data management plans (DMPs). It provides guidance from specific funders who require DMPs, but the tool can be used by anyone interested in developing generic DMPs to help facilitate their research. The tool also offers resources and services available at participating institutions to help fulfill data management requirements.') % { participating_url: public_orgs_path } %>

    + +

    <%= sanitize _('Use our Quick Start Guide to begin creating a plan.') % { help_url: help_path } %>

    +
    + +

    <%= _('Q: How much does it cost to use the DMPTool?') %>

    +

    <%= _('A: The DMPTool is FREE. Anyone can create data management plans using the DMPTool.') %>

    + +

    <%=sanitize _('A login is required to access the DMPTool. If you are a researcher from a participating institutions, you can log in as a user from your institution. If your institution does not participate, you can create your own account.') % { participating_url: public_orgs_path } %>

    + +

    <%= sanitize _('Use our Quick Start Guide to begin creating a plan.') % { help_url: help_path} %>

    +
    + +

    <%= _('Q: Who can use the DMPTool?') %>

    +

    <%= sanitize _('A: Anyone can create data management plans. If you are a researcher from one of the participating institutions, you can log in as a user from your institution and you will be presented with local guidance to help you complete your plan.') % {participating_url: public_orgs_path } %>

    + +

    <%= _('If your institution does not participate, you can create your own account.') %>

    + +

    <%= sanitize _('Use our Quick Start Guide to begin creating a plan.') % { help_url: help_path } %>

    +
    + +

    <%= _('Q: How can my institution participate in the DMPTool?') %>

    +

    <%= sanitize _('A: First, check this participating institutions to make sure your institution is not already participating. Learn more about becoming a participating institution.') % { participating_url: public_orgs_path, about_url: about_us_path } %>

    + +

    <%= sanitize _('If you are a researcher or potential user, we suggest you talk to a librarian at your institution. If you are an administrator (librarian or otherwise) and interested in joining, please contact us.') % { contact_url: contact_us_path } %>

    +
    + +

    <%= _('Q: What are the benefits of participating in the DMPTool?') %>

    +

    <%= _('A: Participating institutions can incorporate information about their resources and services to aid researchers with data management. Participating institutions can also provide customized help and suggest answers to the questions asked by funding agencies. Users from particpating institutions that have configured the tool with Shibboleth can log in with their own institutional accounts.') %>

    + +

    <%= sanitize _('For more information, see About participating.') % { about_url: about_us_path } %>

    +
    + +

    <%= _('Q: Have funders endorsed the DMPTool?') %>

    +

    <%= _('A: No funders have endorsed the use of the DMPTool, although some provide links to the tool or resources within the tool in their public access plans (e.g., NEH, DOT).') %>

    + +

    <%= _('Despite the lack of formal endorsements, the DMPTool templates incorporate specific data management planning requirements from a range of funders including foundations and government agencies. We are in close contact with some funders as we create templates and for all funders we monitor public notices and websites for changes.') %>

    +
    + +

    <%= _('Q: Who owns the data management plans created with the DMPTool?') %>

    +

    <%= sanitize _('A: Data management plans are the intellectual property of their creators. The California Digital Library makes no claim of copyright or ownership to the data management plans created using the DMPTool. You can, however, choose to share your plan publicly and it will appear in our library of public plans on the DMPTool website. This will benefit other DMPTool users and promote open research.') % { public_plans_url: public_plans_path } %>

    + +

    <%= sanitize _('See the Quick Start Guide for more information on setting your plan\'s visibility.') % { help_url: help_path } %>

    +
    + +

    <%= _('Q: Why am I getting email notifications from the DMPTool?') %>

    +

    <%= _('A: There are multiple actions that generate automatic email notifications from the DMPTool. Users can turn these notifications on/off on the profile page. Navigate to "Edit profile" by clicking your name in the upper right dropdown menu, then select the "Notification preferences" tab, check/uncheck the appropriate boxes, and click the button to save your changes.') %>

    +
    + +

    <%= _('For Researchers') %>

    +
    + +

    <%= _('Q: I\'m in a hurry! Is there a quick start guide for creating a data management plan?') %>

    +

    <%= sanitize _('Yes! The Quick Start Guide is available as a website or a PDF you can download.') % { help_url: help_path } %>

    +
    + +

    <%= _('Q: Where can I read more about funder requirements for data management plans?') %>

    +

    <%= sanitize _('A: The Funder Requirements page provides direct links to funder guidelines, as well as sample plans if provided. You do not have to be logged into the DMPTool to access this page.') % { public_templates_url: public_templates_path } %>

    +
    + +

    <%= _('Q: Are there examples of data management plans?') %>

    +

    <%= sanitize _('A: The DMPTool hosts a collection of public plans. The collection contains actual plans created by DMPTool users who have opted to share their plans publicly. Please note that these plans have not been vetted for quality. Some funders provide sample plans on their websites; links to these plans are available on the Funder Requirements page.') % { public_plans_url: public_plans_path, public_templates_url: public_templates_path } %>

    +
    + +

    <%= _('Q: What\'s the difference between “sample” and “public” data management plans?') %>

    +

    <%= sanitize _('A: The sample plans on the Funder Requirements page are created by funders and offered as guidance on their websites. The Public Plans are actual plans created by users of the DMPTool (please note that these have not been vetted for quality). Both provide helpful examples for researchers creating their own data management plans.') % { public_plans_url: public_plans_path, public_templates_url: public_templates_path } %>

    +
    + +

    <%= _('Q: What are the visibility options for my data management plan?') %>

    +

    <%= sanitize _('A: There are three visibility options for each plan you create:

    1. Private. Your plan will only be visible to you and any specified plan collaborators. Basic plan details (from the project details page, but not the plan content) will be available to administrators at your institution.
    2. Organization. If your institution/organization participates in the DMPTool, this setting allows administrators and users from your institution to see your plan.
    3. Public. Your plan will be available on the Public Plans page of the DMPTool website. Choose this option to allow others to see your plan without restrictions (under a CC-Zero license).
    ') % { public_plans_url: public_plans_path } %>

    +
    + +

    <%= _('Q: I created a test plan. How can I delete it (or hide it)?') %>

    +

    <%= sanitize _('A: Follow these steps to remove your test plan:

    1. Log into the DMPTool.
    2. On "My Dashboard" tick the box in the "Test" column next to the title of the appropriate plan. This action will remove the plan from the public list if the visibility was set to public (it will become test/private).
    3. To delete the plan: select "Remove" from the Actions menu next to the title of the appropriate plan. You will be asked to confirm this action.
    ') %>

    +
    + +

    <%= _('Q: How long will you save my plans?') %>

    +

    <%= _('A: We do not plan to delete any plans created with the DMPTool. As a plan owner, however, you can delete plans by going to “My Dashboard” and selecting “Remove” from the Actions menu next to the plan name.') %>

    +
    + +

    <%= _('Q: What if I move to a new institution?') %>

    +

    <%= _('A: Since the DMPTool account is tied to an email address, the information will not automatically follow a user if they change institutions. However, we can connect users to their plans from previous institutions if they contact us. Users who change institutions and assume new institutional credentials must create a new DMPTool account.') %>

    +
    + +

    <%= _('Q: Who can help me at my institution?') %>

    +

    <%= sanitize _('A: If your institution participates in the DMPTool, you can log into the tool and click on the “Contact” link in the top banner. Your email will be sent directly to an expert on your campus who can follow up with you. If your institution does not participate in the DMPTool, check with a librarian to see if someone on campus can help.') % { participating_url: public_orgs_path } %>

    +
    + +

    <%= _('Q: I have a collaborator. How can we work on the same plan?') %>

    +

    <%= _('A: First you must create an account in the DMPTool and begin creating a plan. Then you can add your collaborator(s) to your plan as co-owner(s), or grant editor or read only permissions. You can do this on the “Share” tab for your plan. Enter an email address in the field to "Invite collaborators,” select the desired level of permissions, and click "Submit" to send an email invitation.') %>

    + +

    <%= sanitize _('For more information, see the Quick Start Guide.') % { help_url: help_path } %>

    +
    + +

    <%= _('Q: What happens when I "request feedback" on a plan?') %>

    +

    <%= _('A: The request feedback functionality of the DMPTool is an optional feature that institutions may configure to help researchers create data management plans. Submitting a plan for feedback is within-institution only. If this feature is enabled, a button to "Request feedback" will be displayed on the "Share" tab when writing a plan. If a user clicks the "Request feedback" button, the DMPTool will send an email to the plan owner and the institutional administrator contact. The institutional administrator will be granted read only permissions and be able to provide comments on the plan within the tool.') %>

    + +

    <%= sanitize _('Administrators: if you are interested in enabling this functionality for users at your institution, see the "Request feedback" section of the help for administrators wiki.') % { admin_help_url: 'https://github.com/cdluc3/dmptool/wiki/Help-for-Administrators' } %>

    +
    + +

    <%= _('For Administrators') %>

    +
    + +

    <%= _('Q: Where can I find help customizing the DMPTool for my institution/organization?') %>

    +

    <%= sanitize _('A: There is an extensive list of help topics for administrators customizing the DMPTool located on GitHub. You will find detailed instructions for:

    1. Setting up the DMPTool
      • Enabling Shibboleth
      • Granting administrator privileges
      • Customizing your organizational profile
      • Providing feedback on plans
      • Usage information
    2. Quick overview of terms
    3. Creating guidance
    4. Customizing funder templates
    5. Creating templates
    ') % { admin_help_url: 'https://github.com/cdluc3/dmptool/wiki/Help-for-Administrators' } %>

    +
    + +

    <%= _('Q: Can I see customized templates and guidance created by administrators at other organizations?') %>

    +

    <%= _('A: Yes! To view guidance created by others, create a new plan in the tool. On the "Project details" tab you will see "Plan guidance configuration" on the right. Click to "See the full list" and you can select up to 6 different organizations at a time. You will see the selected guidance when you navigate to the "Write plan" tab.') %>

    + +

    <%= _('To view templates created by others (if available), create a new plan in the tool and choose any organization for the second step: "Select the primary research organization." For the third create plan step, if you tick the box "No funder associated with this plan" you will be presented with any available organizational templates. If the field remains gray and no templates with organizational names appear, then the selected organization has not created any of their own templates and you will be presented with the default DMPTool template after clicking the button to "Create plan."') %>

    +
    + +

    <%= _('Q: I created a test template. How can I delete it?') %>

    +

    <%= sanitize _('A: DMPTool administrators can delete templates. This option is only available if no plans have been created using that template. If you cannot or do not want to delete a template, you can "Unpublish" the template so that it will not appear to users. See our help documentation on how to do this.') % { admin_help_url: 'https://github.com/cdluc3/dmptool/wiki/Help-for-Administrators' } %>

    +
    + +

    <%= _('Q: How do I create guidance? And what are themes?') %>

    +

    <%= sanitize _('A: The help menu for administrators contains detailed instructions for creating themed guidance. The basic steps include:

    1. Creating a guidance group (you will already have a default guidance group for your organization; it is optional to create additional groups or subgroups, for example, for a specific department)
    2. Creating guidance by entering text, assigning one or more themes, and attaching it to a guidance group
    3. Publishing the guidance
    ') % { admin_help_url: 'https://github.com/cdluc3/dmptool/wiki/Help-for-Administrators' } %>

    + +

    <%= _('There are 14 themes that represent the most common topics addressed in data management plans (e.g., Data format, Metadata). Themes work like tags to associate questions and guidance. Questions within a template can be tagged with one or more themes, and guidance can be written by theme to allow organizations to apply their advice over all templates at once. This also alleviates the need to update guidance each time a new template is released.') %>

    +
    + +

    <%= sanitize _("Can't find the answer you’re looking for? Contact us") % { contact_url: contact_us_path } %>

    +
    diff --git a/app/views/branded/static_pages/general_guidance.html.erb b/app/views/branded/static_pages/general_guidance.html.erb new file mode 100644 index 0000000000..1547c62e9c --- /dev/null +++ b/app/views/branded/static_pages/general_guidance.html.erb @@ -0,0 +1,341 @@ +
    +

    <%= _('Data management general guidance') %>

    +
    + +

    <%= _('Table of Contents') %>

    + + +
    + +

    <%= _('Introduction') %>

    +
    +

    <%= _('What is a data management plan?') %>

    +

    <%= _('A data management plan is a formal document that outlines what you will do with your data during and after a research project. Most researchers collect data with some form of plan in mind, but it\'s often inadequately documented and incompletely thought out. Many data management issues can be handled easily or avoided entirely by planning ahead. With the right process and framework it doesn\'t take too long and can pay off enormously in the long run.') %>

    + +
    +

    <%= _('Who requires a plan?') %>

    +

    <%= sanitize _('In February of 2013, the White House Office of Science and Technology Policy (OSTP) issued a %{memorandum_url} directing Federal agencies that provide significant research funding to develop a plan to expand public access to research. Among other requriments, the plans must:

    "Ensure that all extramural researchers receiving Federal grants and contracts for scientific research and intramural researchers develop data management plans, as appropriate, describing how they will provide for long-term preservation of, and access to, scientific data in digital formats resulting from federally funded research, or explaining why long-term preservation and access cannot be justified"
    ') % { memorandum_url: 'memorandum' } %>

    + +

    <%= _('The National Science Foundation (NSF) requires a 2-page plan as part of the funding proposal process. Most or all US Federally funded grants will eventually require some form of data management plan.') %>

    + +
    +

    <%= _('We can help') %>

    +

    <%= _('We have been working with internal and external partners to make data management plan development less complicated. By getting to know your research and data, we can match your specific needs with data management best practices in your field to develop a data management plan that works for you. If you do this work at the beginning of your research process, you will have a far easier time following through and complying with funding agency and publisher requirements.') %>

    + +

    <%= _('We recommend that those applying for funding from US Federal agencies, such as the NSF, use the DMPTool. The DMPTool provides guidance for many of the NSF Directorate and Division requirements, along with links to additional resources, services, and help.') %>

    +
    + +

    <%= _('Types of Data') %>

    + +

    <%= _('Research projects generate and collect countless varieties of data. To formulate a data management plan, it\'s useful to categorize your data in four ways: by source, format, stability, and volume.') %>

    + +
    +

    <%= _('What\'s the source of the data?') %>

    +

    <%= _('Although data comes from many different sources, they can be grouped into four main categories. The category(ies) your data comes from will affect the choices that you make throughout your data management plan.') %>

    + +

    <%= sanitize _('Observational

    • Captured in real-time, typically outside the lab
    • Usually irreplaceable and therefore the most important to safeguard
    • Examples: Sensor readings, telemetry, survey results, images
    ') %>

    + +

    <%= sanitize _('Experimental

    • Typically generated in the lab or under controlled conditions
    • Often reproducible, but can be expensive or time-consuming
    • Examples: gene sequences, chromatograms, magnetic field readings
    ') %>

    + +

    <%= sanitize _('Simulation

    • Machine generated from test models
    • Likely to be reproducible if the model and inputs are preserved
    • Examples: climate models, economic models
    ') %>

    + +

    <%= sanitize _('Derived / Compiled

    • Generated from existing datasets
    • Reproducible, but can be very expensive and time-consuming
    • Examples: text and data mining, compiled database, 3D models
    ') %>

    + +
    +

    <%= _('What\'s the form of the data?') %>

    +

    <%= sanitize _('Data can come in many forms, including:

    • Text: field or laboratory notes, survey responses
    • Numeric: tables, counts, measurements
    • Audiovisual: images, sound recordings, video
    • Models, computer code
    • Discipline-specific: FITS in astronomy, CIF in chemistry
    • Instrument-specific: equipment outputs
    ') %>

    + +
    +

    <%= _('How stable is the data?') %>

    +

    <%= sanitize _('Data can also be fixed or changing over the course of the project (and perhaps beyond the project\'s end). Do the data ever change? Do they grow? Is previously recorded data subject to correction? Will you need to keep track of data versions? With respect to time, the common categories of dataset are:

    • Fixed datasets: never change after being collected or generated
    • Growing datasets: new data may be added, but the old data is never changed or deleted
    • Revisable datasets: new data may be added, and old data may be changed or deleted
    ') %>

    + +

    <%= _('The answer to this question affects how you organize the data as well as the level of versioning you will need to undertake. Keeping track of rapidly changing datasets can be a challenge so it is imperative that you begin with a plan to carry you through the entire data management process.') %>

    + +
    +

    <%= _('How much data will the project produce?') %>

    +

    <%= _('For instance, image data typically requires a lot of storage space, so you\'ll want to decide whether to retain all your images (and, if not, how you will decide which to discard) and where such large data can be housed. Be sure to know your archiving organization\'s capacity for storage and backups.') %>

    + +

    <%= sanitize _('To avoid being under-prepared, estimate the growth rate of your data. Some questions to consider are:

    • Are you manually collecting and recording data?
    • Are you using observational instruments and computers to collect data?
    • Is your data collection highly iterative?
    • How much data will you accumluate every month or every 90 days?
    • How much data do you anticipate collecting and generating by the end of your project?
    ') %>

    +
    + +

    <%= _('File Formats') %>

    + +

    <%= _('The file format you choose for your data is a primary factor in someone else\'s ability to access it in the future. Think carefully about what file format will be best to manage, share, and preserve your data. Technology continually changes and all contemporary hardware and software should be expected to become obsolete. Consider how your data will be read if the software used to produce it becomes unavailable. Although any file format you choose today may become unreadable in the future, some formats are more likely to be readable than others.') %>

    + +
    +

    <%= _('Formats likely to be accessible in the future are:') %>

    +
      +
    • <%= _('Non-proprietary') %>
    • +
    • <%= _('Open, with documented standards') %>
    • +
    • <%= _('In common usage by the research community') %>
    • +
    • <%= _('Using standard character encodings (i.e., ASCII, UTF-8)') %>
    • +
    • <%= _('Uncompressed (space permitting)') %>
    • +
    + +
    +

    <%= _('Examples of preferred format choices:') %>

    +
      +
    • <%= _('Image: JPEG, JPG-2000, PNG, TIFF') %>
    • +
    • <%= _('Text: plain text (TXT), HTML, XML, PDF/A') %>
    • +
    • <%= _('Audio: AIFF, WAVE') %>
    • +
    • <%= _('Containers: TAR, GZIP, ZIP') %>
    • +
    • <%= _('Databases: prefer XML or CSV to native binary formats') %>
    • +
    + +

    <%= _('If you find it necessary or convenient to work with data in a proprietary/discouraged file format, do so, but consider saving your work in a more archival format when you are finished.') %>

    + +

    <%= sanitize _('For more information on recommended formats, see the UK Data Service guidance on recommended formats.') % { recommended_formats_url: 'https://www.ukdataservice.ac.uk/manage-data/format/recommended-formats' } %>

    + +
    +

    <%= _('Tabular data') %>

    +

    <%= sanitize _('Tabular data warrants special mention because it is so common across disciplines, mostly as Excel spreadsheets. If you do your analysis in Excel, you should use the "Save As..." command to export your work to .csv format when you are done. Your spreadsheets will be easier to understand and to export if you follow best practices when you set them up, such as:

    • Don\'t put more than one table on a worksheet
    • Include a header row with understandable title for each column
    • Create charts on new sheets- don\'t embed them in the worksheet with the data
    ') %>

    + +
    +

    <%= _('Other risks to accessibility') %>

    +
      +
    • <%= _('Encrypted data may be effectively lost if it was encrypted with a key that has been lost (e.g., a forgotten password). For this reason, encrypted data representations are strongly discouraged.') %>
    • +
    • <%= _('Data that is legally encumbered may also be considered lost. So may data bound by ambiguous or unknown access and archiving rights, because the cost of clarifying the rights situation is often prohibitive. See data rights and licensing for guidance.') %>
    • +
    +
    + +

    <%= _('Organizing Files') %>

    +
    +

    <%= _('Basic Directory and File Naming Conventions') %>

    +

    <%= sanitize _('These are rough guidelines to follow to help manage your data files in case you don\'t already have your own internal conventions. When organizing files, the top-level directory/folder should include:

    • Project title
    • Unique identifier (Guidance on persistent external identifiers is available below)
    • Date (yyyy or yyyy.mm.dd)
    ') %>

    + +

    <%= _('The sub-directory structure should have clear, documented naming conventions. Separate files or directories could apply, for example, to each run of an experiment, each version of a dataset, and/or each person in the group.') %>

    + +
      +
    • <%= _('Reserve the 3-letter file extension for the file format, such as .txt, .pdf, or .csv.') %>
    • +
    • <%= _('Identify the activity or project in the file name.') %>
    • +
    • <%= _('Identify separate versions of files and datasets using file or directory naming conventions. It can quickly become difficult to identify the "correct" version of a file.') %>
    • +
    • <%= _('Record all changes to a file no matter how small. Discard obsolete versions after making backups.') %>
    • +
    + +
    +

    <%= _('File Renaming') %>

    +

    <%= _('Tools to help you:') %>

    +
      +
    • [Bulk Rename Utility](http://www.bulkrenameutility.co.uk/Main_Intro.php) <%= _('(Windows; free)') %>
    • +
    • [Renamer](https://renamer.com/) <%= _('(Mac; free trial)') %>
    • +
    • [PSRenamer](http://www.powersurgepub.com/products/psrenamer/index.html) <%= _('(Linux, Mac, Windows; free)') %>
    • +
    +
    + +

    <%= _('Metadata: Data Documentation') %>

    +
    +

    <%= _('Why document data?') %>

    +

    <%= _('Clear and detailed documentation is essential for data to be understood, interpreted, and used. Data documentation describes the content, formats, and internal relationships of your data in detail and will enable other researchers to find, use, and properly cite your data.') %>

    + +

    <%= _('Begin to document your data at the very beginning of your research project and continue throughout the project. Doing so will make the process much easier. If you have to construct the documentation at the end of the project, the process will be painful and important details will have been lost or forgotten. Don\'t wait to document your data!') %>

    + +
    +

    <%= _('What to document?') %>

    + +

    <%= sanitize _('Research Project Documentation

    • Rationale and context for data collection
    • Data collection methods
    • Structure and organization of data files
    • Data sources used (see citing data)
    • Data validation and quality assurance
    • Transformations of data from the sanitize data through analysis
    • Information on confidentiality, access and use conditions
    ') %>

    + +

    <%= sanitize _('Dataset documentation

    • Variable names and descriptions
    • Explanation of codes and classification schemes used
    • Algorithms used to transform data (may include computer code)
    • File format and software (including version) used
    ') %>

    + +
    +

    <%= _('How will you document your data?') %>

    +

    <%= sanitize _('Data documentation is commonly called metadata – "data about data". Researchers can document their data according to various metadata standards. Some metadata standards are designed for the purpose of documenting the contents of files, others for documenting the technical characteristics of files, and yet others for expressing relationships between files within a set of data. If you want to be able to share or publish your data, the DataCite metadata standard is of particular signficiance.') % { datacite_standards_url: 'https://schema.datacite.org/' } %>

    + +

    <%= _('Below are some general aspects of your data that you should document, regardless of your discipline. At minimum, store this documentation in a "readme.txt" file, or the equivalent, with the data itself.') %>

    + +

    <%= _('General Overview') %>

    +
      +
    • <%= sanitize _('Title: Name of the dataset or research project that produced it') %>
    • +
    • <%= sanitize _('Creator: Names and addresses of the organizations or people who created the data; preferred format for personal names is surname first (e.g., Smith, Jane)') %>
    • +
    • <%= sanitize _('Identifier: Unique number used to identify the data, even if it is just an internal project reference number') %>
    • +
    • <%= sanitize _('Date: Key dates associated with the data, including: project start and end date; release date; time period covered by the data; and other dates associated with the data lifespan, such as maintenance cycle, update schedule; preferred format is yyyy-mm-dd, or yyyy.mm.dd-yyyy.mm.dd for a range') %>
    • +
    • <%= sanitize _('Method: How the data were generated, listing equipment and software used (including model and version numbers), formulae, algorithms, experimental protocols, and other things one might include in a lab notebook') %>
    • +
    • <%= sanitize _('Processing: How the data have been altered or processed (e.g., normalized)') %>
    • +
    • <%= sanitize _('Source: Citations to data derived from other sources, including details of where the source data is held and how it was accessed') %>
    • +
    • <%= sanitize _('Funder: Organizations or agencies who funded the research') %>
    • +
    + +

    <%= _('Content Description') %>

    +
      +
    • <%= sanitize _('Subject: Keywords or phrases describing the subject or content of the data') %>
    • +
    • <%= sanitize _('Place: All applicable physical locations') %>
    • +
    • <%= sanitize _('Language: All languages used in the dataset') %>
    • +
    • <%= sanitize _('Variable list: All variables in the data files, where applicable') %>
    • +
    • <%= sanitize _('Code list: Explanation of codes or abbreviations used in either the file names or the variables in the data files (e.g. "999 indicates a missing value in the data")') %>
    • +
    + +

    <%= _('Technical Description') %>

    +
      +
    • <%= sanitize _('File inventory: All files associated with the project, including extensions (e.g. "NWPalaceTR.WRL", "stone.mov")') %>
    • +
    • <%= sanitize _('File formats: Formats of the data, e.g., FITS, SPSS, HTML, JPEG, etc.') %>
    • +
    • <%= sanitize _('File structure: Organization of the data file(s) and layout of the variables, where applicable') %>
    • +
    • <%= sanitize _('Version: Unique date/time stamp and identifier for each version') %>
    • +
    • <%= sanitize _('Checksum: A digest value computed for each file that can be used to detect changes; if a recomputed digest differs from the stored digest, the file must have changed') %>
    • +
    • <%= sanitize _('Necessary software: Names of any special-purpose software packages required to create, view, analyze, or otherwise use the data') %>
    • +
    + +

    <%= _('Access') %>

    +
      +
    • <%= sanitize _('Rights: Any known intellectual property rights, statutory rights, licenses, or restrictions on use of the data') %>
    • +
    • <%= sanitize _('Access information: Where and how your data can be accessed by other researchers') %>
    • +
    +
    + +

    <%= _('Persistent Identifiers') %>

    +
    +

    <%= sanitize _('If you want to be able to share or cite your dataset, you\'ll want to assign a public persistent unique identifier to it. There are a variety of public identifier schemes, but common properties of good schemes are that they are:

    • Actionable (you can "click" on them in a web browser)
    • Globally unique across the internet
    • Persistent for at least the life of your data
    ') %>

    + +

    <%= _('Here are some identifier schemes:') %>

    +
      +
    • <%= sanitize _('ARK (Archival Resource Key) – a URL with extra features allowing you to ask for descriptive and archival metadata and to recognize certain kinds of relationships between identifiers. ARKs are used by memory organizations such as libraries, archives, and museums. They are resolved at "%{nt2_url}". Resolution depends on HTTP redirection and can be managed through an API or a user interface.') % { ark_url: 'http://n2t.net/e/ark_ids.html', nt2_url: 'http://www.nt2.net' } %>
    • +
    • <%= sanitize _('DOI (Digital Object Identifier) – an identifier that becomes actionable when embedded in a URL. DOIs are very popular in academic journal publishing. They are resolved at "%{doi_resolver_url}". Resolution depends on HTTP redirection and the Handle identifier protocol, and can be managed through an API or a user interface.') % { doi_url: 'http://www.doi.org/', doi_resolver_url: 'http://dx.doi.org' } %>
    • +
    • <%= sanitize _('Handle – an identifier that becomes actionable when embedded in a URL. Handles are resolved at "%{handle_url}". Resolution depends on HTTP redirection and the Handle protocol, and can be managed through an API or a user interface.') % { handle_url: 'http://www.handle.net/' } %>
    • +
    • <%= sanitize _('InChI (IUPAC International Chemical Identifier) – a non-actionable identifier for chemical substances that can be used in printed and electronic data sources, thus enabling easier linking of diverse data compilations.') % { inchi_url: 'https://iupac.org/who-we-are/divisions/division-details/inchi/' } %>
    • +
    • <%= sanitize _('LSID (Life Sciences Identifier) – a kind of URN that identifies a biologically significant resources, including species names, concepts, occurrences, and genes or proteins, or data objects that encode information about them. Like other URNs, it becomes actionable when embedded in a URL.') % { lsid_url: 'https://en.wikipedia.org/wiki/LSID' } %>
    • +
    • <%= sanitize _('NCBI (National Center for Biotechnology Information) ACCESSION
    • +
    • a non-actionable number in use by NCBI.') % { ncbi_url: 'https://www.ncbi.nlm.nih.gov/Sequin/acc.html' } %>
    • +
    • <%= sanitize _('PURL (Persistent Uniform Resource Locator) – a URL that is always redirected through a hostname (often purl.org). Resolution depends on HTTP redirection and can be managed through an API or a user interface.') % { purl_url: 'https://archive.org/services/purl/' } %>
    • +
    • <%= sanitize _('URL (Uniform Resource Locator) – the typical "address" of web content. It is a kind of URI (Uniform Resource Identifier) that begins with "http://" and consists of a string of characters used to identify or name a resource on the Internet. Such identification enables interaction with representations of the resource over a network, typically the World Wide Web, using the HTTP protocol. Well-managed URL redirection can make URLs as persistent as any identifier. Resolution depends on HTTP redirection and can be managed through an API or a user interface.') %>
    • +
    • <%= sanitize _('URN (Uniform Resource Name) – an identifier that becomes actionable when embedded in a URL. Resolution depends on HTTP redirection and the DDDS protocol, and can be managed through an API or a user interface. A browser plug-in can save you from typing a hostname in front of it.') %>
    • +
    +
    + +

    <%= _('Security and Storage') %>

    +
    +

    <%= _('Data Security') %>

    +

    <%= sanitize _('Data security is the protection of data from unauthorized access, use, change, disclosure, and destruction. Make sure your data is safe in regards to:

    • Network security
      • Keep confidential data off the Internet
      • In extreme cases, put sensitive materials on computers not connected to the internet
    • Physical security
      • Restrict access to buildings and rooms where computers or media are kept
      • Only let trusted individuals troubleshoot computer problems
    • Computer systems and files
      • Keep virus protection up to date
      • Don\'t send confidential data via e-mail or FTP (or, if you must, use encryption)
      • Set passwords on files and computers
      • React with skepticism to phone calls and emails that claim to be from your institution\'s IT department
    ') %>

    + +
    +

    <%= _('Encryption and Compression') %>

    +

    <%= sanitize _('Unencrypted data will be more easily read by you and others in the future, but you may need to encrypt sensitive data.

    • Use mainstream encryption tools (e.g., PGP)
    • Don\'t rely on third-party encryption alone
    • Keep passwords and keys on paper (2 copies)
    ') %>

    + +

    <%= sanitize _('Uncompressed data will be also be easier to read in the future, but you may need to compress files to conserve disk space.

    • Use a mainstream compression tool (e.g., ZIP, GZIP, TAR)
    • Limit compression to the 3rd backup copy
    ') %>

    + +
    +

    <%= _('Backups and storage') %>

    +

    <%= sanitize _('Making regular backups is an integral part of data management. You can backup data to your personal computer, external hard drives, or departmental or university servers. Software that makes backups for you automatically can simplify this process considerably. The UK Data Archive provides additional guidelines on data storage, backup, and security.') % { storage_guidelines_url: 'https://www.ukdataservice.ac.uk/manage-data/store' } %>

    + +

    <%= _('Backup Your Data') %>

    +
      +
    • <%= _('Good practice is to have three copies in at least two locations (e.g. original + external/local backup + external/remote backup)') %>
    • +
    • <%= _('Geographically distribute your local and remote copies to reduce risk of calamity at the same location (power outage, flood, fire, etc.)') %>
    • +
    + +

    <%= _('Test your backup system') %>

    +
      +
    • <%= _('To be sure that your backup system is working, periodically retrieve your data files and confirm that you can read them. You should do this when you initially set up the system and on a regular schedule thereafter.') %>
    • +
    + +
    +

    <%= _('Other data preservation considerations') %>

    + +

    <%= _('Who is responsible for managing and controlling the data?') %>

    +
      +
    • <%= _('Who controls the data (e.g., the PI, a student, your lab, your university, your funder)? Before you spend a lot of time figuring out how to store the data, to share it, to name it, etc. you should make sure you have the authority to do so.') %>
    • +
    + +

    <%= _('For what or whom are the data intended?') %>

    +
      +
    • <%= _('Who is your intended audience for the data? How do you expect they will use the data? The answer to these questions will help inform structuring and distributing the data.') %>
    • +
    + +

    <%= _('How long should the data be retained?') %>

    +
      +
    • <%= _('Is there any requirement that the data be retained? If so, for how long? 3-5 years, 10-20 years, permanently? Not all data need to be retained, and some data required to be retained need not be retained indefinitely. Have a good understanding of your obligation for the data\'s retention.') %>
    • +
    + +

    <%= _('Beyond any externally imposed requirments, think about the long-term usefulness of the data. If the data is from an experiment that you anticipate will be repeatable more quickly, inexpensively, and accurately as technology progresses, you may want to store it for a relatively brief period. If the data consists of observations made outside the laborartory that can never be repeated, you may wish to store it indefinitely.') %>

    +
    + +

    <%= _('Sharing and Archiving') %>

    +
    +

    <%= sanitize _('Why share your data?') %>

    +

    <%= sanitize _('

    • Required by publishers (e.g., Cell, Nature, Science)
    • Required by government funding agencies (e.g., NIH, NSF)
    • Allows data to be used to answer new questions
    • Makes research more open
    • Makes your papers more useful and citable by other researchers
    ') %>

    + +
    +

    <%= _('Considerations when preparing to share data') %>

    +
      +
    • <%= sanitize _('File Formats for Long Term Access: The file format in which you keep your data is a primary factor in one\'s ability to use your data in the future. Plan for both hardware and software obsolescence. See file formats and organization for details on long-term storage formats.') %>
    • +
    • <%= sanitize _('Don\'t Forget the Documentation: Document your research and data so others can interpret the data. Begin to document your data at the very beginning of your research project and continue throughout the project. See data documentation and metadata for details.') %>
    • +
    • <%= sanitize _('Ownership and Privacy: Make sure that you have considered the implications of sharing data in terms of copyright, IP ownership, and subject confidentiality. See copyright and confidentiality for details.') %>
    • +
    + +
    +

    <%= sanitize _('Ways to share your data') %>

    +

    <%= sanitize _('

    • Email to individual requesters
    • Post online via a project or personal website
    • Submit as supplemental material to be hosted on a journal publisher\'s website
    • Deposit in an open repository or archive
    • Deposit in an open repository and publish a "data paper" describing the data
    ') %>

    + +

    <%= sanitize _('While the first three options above are valid ways to share data, a repository is much better able to provide long-term access. Data deposited in a repository can be supplemented with a "data paper"—a relatively new type of publication that describes a dataset, but does not analyze it or dsanitize any conclusions—published in a journal such as Nature Scientific Data or Geoscience Data Journal.') % { nature_url: 'https://www.nature.com/sdata/', geoscience_url: 'http://rmets.onlinelibrary.wiley.com/hub/journal/10.1002/(ISSN)2049-6060/' } %>

    + +
    +

    <%= _('Finding a data repository') %>

    +

    <%= sanitize _('You should select a repository or archive for your data based on the long-term security offered and the ease of discovery and access by colleagues in your field. There are two common types of repository to look for:

    • Discipline specific: accepts data in a particluar field or of a particluar type (e.g., GenBank accepts nucleotide sequence data)
    • Institutional: accepts data of any type produced within the institution that maintains it (e.g., the University of California\'s Dash)
    ') % { dash_url: 'https://dash.ucop.edu/stash' } %>

    + +

    <%= sanitize _('A searchable and browsable list of repositories can be found at these websites:

    • re3data.org: a REgistry of REsearch data REpositories
    • Data Repositories in the Open Access Directory: a list of repositories hosted by Simmons College
    • FAIRSharing: a directory of life sciences databases and reporting standards, now expanded to include all disciplines
    ') % { re3data_url: 'https://www.re3data.org/', open_access_url: 'http://oad.simmons.edu/oadwiki/Data_repositories', fairshare_url: 'https://fairsharing.org/' } %>

    +
    + +

    <%= _('Citing Data') %>

    + +

    <%= sanitize _('Citing data is important in order to:

    • Give the data producer appropriate credit
    • Allow easier access to the data for repurposing or reuse
    • Enable readers to verify your results
    ') %>

    + +
    +

    <%= _('Citation Elements') %>

    +

    <%= sanitize _('A dataset should be cited formally in an article\'s reference list, not just informally in the text. Many data repositories and publishers provide explicit instructions for citing their contents. If no citation information is provided, you can still construct a citation following generally agreed-upon guidelines from sources such as the Force 11 Joint Declaration of Data Citation Principles and the current DataCite Metadata Schema.') % {force11_citation_url: 'https://www.force11.org/datacitationprinciples', datacite_standards_url: 'https://schema.datacite.org/' } %>

    + +

    <%= _('Core elements') %>

    +
      +
    • <%= sanitize _('There are 5 core elements usually included in a dataset citation, with additional elements added as appropriate.
      • Creator(s) – may be individuals or organizations
      • Title
      • Publication year when the dataset was released (may be different from the Access date)
      • Publisher – the data center, archive, or repository
      • Identifier – a unique public identifier (e.g., an ARK or DOI)
      ') %>
    • +
    • <%= sanitize _('Creator names in non-Roman scripts should be transliterated using the ALA-LC Romanization Tables.') % { romanization_url: 'http://www.loc.gov/catdir/cpso/roman.html' } %>
    • +
    + +

    <%= _('Common additional elements') %>

    +
      +
    • <%= sanitize _('Although the core elements are sufficient in the simplest case – citation to the entirety of a static dataset – additional elements may be needed if you wish to cite a dynamic dataset or a subset of a larger dataset.
      • Version of the dataset analyzed in the citing paper
      • Access date when the data was accessed for analysis in the citing paper
      • Subset of the dataset analyzed (e.g., a range of dates or record numbers, a list of variables)
      • Verifier that the dataset or subset accessed by a reader is identical to the one analyzed by the author (e.g., a Checksum)
      • Location of the dataset on the internet, needed if the identifier is not "actionable" (convertable to a web address)
      ') %>
    • +
    + +

    <%= _('Example citations') %>

    + <%= sanitize _('
    • Kumar, Sujai (2012): 20 Nematode Proteomes. figshare. https://doi.org/10.6084/m9.figshare.96035.v2 (Accessed 2016-09-06).
    • Morran LT, Parrish II RC, Gelarden IA, Lively CM (2012) Data from: Temporal dynamics of outcrossing and host mortality rates in host-pathogen experimental coevolution. Dryad Digital Repository. https://doi.org/10.5061/dryad.c3gh6
    • Donna Strahan. "08-B-1 from Jordan/Petra Great Temple/Upper Temenos/Trench 94/Locus 41". (2009) In Petra Great Temple Excavations. Martha Sharp Joukowsky (Ed.) Releases: 2009-10-26. Open Context. https://opencontext.org/subjects/30C3F340-5D14-497A-B9D0-7A0DA2C019F1 ARK (Archive): http://n2t.net/ark:/28722/k2125xk7p
    • OECD (2008), Social Expenditures aggregates, OECD Social Expenditure Statistics (database). https://doi.org/10.1787/000530172303 (Accessed on 2008-12-02).
    • Denhard, Michael (2009): dphase_mpeps: MicroPEPS LAF-Ensemble run by DWD for the MAP D-PHASE project. World Data Center for Climate. https://doi.org/10.1594/WDCC/dphase_mpeps
    • Manoug, J L (1882): Useful data on the rise of the Nile. Alexandria : Printing-Office V Penasson. http://n2t.net/ark:/13960/t44q88124
    ') %> +
    + + +
    +

    <%= _('Sharing data that you produced/collected yourself') %>

    +
      +
    • <%= sanitize _('Much data is not copyrightable in the United States because facts are not copyrightable. However, a presentation of data (such as a chart or table) may be.') %>
    • +
    • <%= sanitize _('Data can be licensed. Some data providers apply licenses that limit how the data can be used to protect the privacy of study participants or to guide downstream uses of the data (e.g., requiring attribution or forbidding for-profit use).') %>
    • +
    • <%= sanitize _('If you want to promote sharing and unlimited use of your data, you can make your data available under a Creative Commons CC0 Declaration to make your wishes explicit.') % { creative_commons_url: 'https://creativecommons.org/choose/zero/' } %>
    • +
    + +
    +

    <%= _('Sharing data that you have collected from other sources') %>

    +
      +
    • <%= _('You may or may not have the rights to do so, depending upon whether that data were accessed under a license with terms of use.') %>
    • +
    • <%= _('Most databases to which the UC Libraries subscribe are licensed and prohibit redistribution of data outside of UC.') %>
    • +
    + +

    <%= _('If you are uncertain as to your rights to disseminate data, UC researchers can consult with your campus Office of General Council. Note: Laws about data vary outside the U.S.') %>

    + +

    <%= sanitize _('For a general discussion about publishing your data, applicable to many disciplines, see the ICPSR Guide to Social Science Data Preparation and Archiving.') % { icpsr_url: 'https://www.icpsr.umich.edu/files/ICPSR/access/dataprep.pdf' } %>

    + +
    +

    <%= _('Confidentiality and Ethical Concerns') %>

    +

    <%= _('It is vital to maintain the confidentiality of research subjects both as an ethical matter and to ensure continuing participation in research. Researchers need to understand and manage tensions between confidentiality requirements and the potential benefits of archiving and publishing the data.') %>

    + +
      +
    • <%= sanitize _('Evaluate the anonymity of your data. Consider to what extent your data contains direct or indirect identifiers that could be combined with other public information to identify research participants.') %>
    • +
    • <%= sanitize _('Obtain a confidentiality review. A benefit of depositing your data with ICPSR is that their staff offers a Disclosure review service to check your data for confidential information.') % { icpsr_url: "https://www.icpsr.umich.edu/icpsrweb/content/deposit/guide/chapter5.html" } %>
    • +
    • <%= sanitize _('Comply with UC regulations. Researchers concerned about confidentiality issues with their data should consult the UC policy for Protection of Human Subjects in Research.') % { uc_policy_url: 'http://policy.ucop.edu/doc/2500499/ProtectnHumanSubj' } %>
    • +
    • <%= sanitize _('Comply with regulations for health research set forth in the Health Insurance Portability and Accountability Act (HIPPA).') % { icpsr_url: 'https://www.icpsr.umich.edu/icpsrweb/', hippa_url: 'https://www.hhs.gov/hipaa/for-professionals/special-topics/research/index.html' } %>
    • +
    + +

    <%= sanitize _('To ethically share confidential data, you may be able to:

    • Gain informed consent for data sharing (e.g. deposit in a repository or archive)
    • Anonymize the data by removing identifying information. Be aware, however, that any dataset that contains enough information to be useful will always present some risk.
    • Restrict the use of your data. ICPSR provides a sample Restricted Data Use Contract and Restricted-Use Data Management Guidance.
    ') % { icpsr_user_url: 'https://www.icpsr.umich.edu/files/DSDR/04701-User_agreement.pdf', icpsr_restricted_url: 'https://www.icpsr.umich.edu/icpsrweb/content/ICPSR/access/restricted/index.html' } %>

    +
    diff --git a/app/views/branded/static_pages/help.html.erb b/app/views/branded/static_pages/help.html.erb new file mode 100644 index 0000000000..607914e605 --- /dev/null +++ b/app/views/branded/static_pages/help.html.erb @@ -0,0 +1,77 @@ +
    +

    <%= _('Quick start guide') %>

    +
    + +

    <%= _('Contents') %>

    + + +
    + +

    <%= _('Who can use the tool?') %>

    + +

    <%= sanitize _('DMPTool is free for anyone to create data management plans. As a user, you can:

    • Create your own plans.
    • Co-author a plan with collaborators.
    • If you are a researcher from one of the participating institutions, you can log in using your institutional credentials. You may then be presented with institution-specific guidance and have the option to get feedback from local data experts.
    ') % { participating_url: public_orgs_path } %>

    +
    + +

    <%= _('How do I log in or create an account?') %>

    + +

    <%= _('Click on Sign in at the top-right of the home page. You can also click the white “Get started” button.') %>

    + +

    <%= sanitize _('If your institution/organization is affiliated with DMPTool:

    • Click the first button to sign in with “Your institution.”
    • Select your institution from the list and click “Go.”
    • Researchers at some institutions will be presented with the institution\'s authentication page. Log in as you usually do for your institution\'s web services.
    • If your institution has not configured single sign-on, you need to create an account with DMPTool (last bullet).
    • If you have already created an account with DMPTool, click the second button to sign in with the email address and password you previously chose. Once logged in, use Edit profile in the top-right menu bar to manage your password and other information.
    • If your institution is not affiliated with DMPTool (or has not configured single sign-on), you need to create an account with an email address and password.
    ') %>

    + + Screenshot of the UCR sign in page +
    + +

    <%= _('Overview of My dashboard') %>

    + +

    <%= _('When you log in you will be directed to “My dashboard.” From here you can create, edit, share, download, copy, or remove any of your plans. You will also see plans that have been shared with you by others.') %>

    + +

    <%= sanitize _('If others at your institution/organization have chose to share their plans internally, you will see a second table of organizational plans. This allows you to download a PDF and view their plans as samples or to discover new research data. Additional samples are available in the list of public plans.') % { public_plans_url: public_plans_path } %>

    + + Screenshot of the My Dashboard page +
    + +

    <%= _('How do I create a data management plan?') %>

    +
    +

    <%= _("Create a plan") %>

    + +

    <%= _('To create a plan, click the “Create plan” button on My dashboard or the top menu. This will take you to a wizard that helps you select the appropriate template:') %>

    + + Screenshot of the Create plan page + +

    <%= sanitize _('

    1. Enter a title for your research project. If applying for funding, use the project title as it appears in the proposal.
    2. Select the primary research organization. If you are associated with a participating institution/organization, this field will be pre-populated. You have the option to clear the field and select another organization from the list. Based on your selection, you will be presented with institution-specific templates and guidance. You can also check the box that “No organization is associated with this plan.”
    3. Select the primary funding organization. If you are required to include a data management plan as part of a grant proposal, select your funder from the list. You may be presented with a secondary dropdown menu if your funder has different requirements for specific programs (e.g., NSF, DOE). See the complete list of funder requirements supported by DMPTool. If your funder is not in the list or you are not applying for a grant, check the box for “No funder associated with this plan;” this selection will provide you with a generic template.
    ') % { public_templates_url: public_templates_path } %>

    + +

    <%= _('If you are just testing the tool or taking a course on data management, check the box “Mock project for test, practice, or educational purposes.” Marking your plans as a test will be reflected in usage statistics and prevent public or organizational sharing; this allows other users to find real sample plans more easily.') %>

    + +

    <%= _('Once you have made your selections, click “Create plan.”') %>

    + +

    <%= _('You can also make a copy of an existing plan (from the Actions menu next to the plan on My dashboard) and update it for a new research project and/or grant proposal.') %>

    + +
    +

    <%= _('Write your plan') %>

    + +

    <%= sanitize _('The tabbed interface allows you to navigate through different functions when editing your plan.

    • “Project details” includes basic administrative details. The right-hand side of the page is where you can select up to 6 organizations to view additional guidance as you write your plan. The more information you provide here, the more useful your plan will be to you and others in the future (e.g., for data reuse and proper attribution). On the Edit profile page you can create or connect your ORCID iD; this is required by some funders and a growing list of publishers (Learn more at orcid.org).
    • “Plan overview” provides an overview of the questions that you will be asked. The following tab(s) present the questions to answer. There may be more than one tab if your funder or institution asks different sets of questions at different stages, e.g., at grant application and post-award. Guidance and comments are displayed in the right-hand panel beside each question. If you need more guidance or find there is too much, you can make adjustments on the “Project details” tab.
    • “Share” allows you to invite others to contribute to or comment on your plan. This is also where you can set your plan visibility (details below).
    • “Download” allows you to download your plan in various formats. You can adjust the formatting (font type, size, and margins) for PDF files, which may be helpful if working to page limits (e.g., NSF data management plans are limited to 2 pages).
    ') % {orcid_url: 'https://orcid.org/' } %>

    + +
    +

    <%= _('Share plans') %>

    + +

    <%= _('Input the email address(es) of any collaborators you would like to invite to read or edit your plan. Set their permissions via the radio buttons and click to "Add collaborator." Adjust permissions or remove collaborators at any time via the drop-down options in the table.') %>

    + +

    <%= sanitize _('The "Share" tab is also where you can set your plan visibility.

    • Private: restricted to you and your collaborators.
    • Organizational: anyone at your organization can view your plan.
    • Public: anyone can view your plan in the public plans list.
    ') % { public_plans_url: public_plans_path } %>

    + +

    <%= _('By default all new and test plans will be set to Private visibility. Public and Organizational visibility are intended for finished plans. You must answer at least 50% of the questions to enable these options.') %>

    + + Screenshot of the Share plan tab +
    + +

    <%= _('How do I get help from someone at my institution?') %>

    + +

    <%= _('After logging in, you will find an email address and URL for help at the top of the page.') %>

    + +

    <%= _('There may also be an option to request feedback on your plan (on the “Share” tab). This is available when research support staff at your institution have enabled the service. Click to “Request feedback” and your local administrators will be alerted to your request. Their comments will be visible in the “Comments” field adjacent to each question. You will receive an email notification when an administrator provides feedback.') %>

    +
    diff --git a/app/views/branded/static_pages/promote.html.erb b/app/views/branded/static_pages/promote.html.erb new file mode 100644 index 0000000000..0bc416a0c2 --- /dev/null +++ b/app/views/branded/static_pages/promote.html.erb @@ -0,0 +1,22 @@ +
    +

    <%= _('Promote the DMPTool') %>

    +
    + +

    <%= _('Help spread the word about the DMPTool! Use the materials below to inform researchers, librarians, administrators and others about the tool. All materials are available under a CC-Zero license.') %>

    +
    + +

    <%= _('DMPTool logo') %>

    + <%= sanitize _('
    • DMPTool Logo (blue), with tagline - EPS, SVG, PNG
    • DMPTool Logo (blue), no tagline - EPS, SVG, PNG
    • DMPTool Logo (green v2), with tagline - PNG
    • DMPTool Logo (green v2), no tagline - PNG
    • Original DMPTool Logo - PNG
    ') % { logo_tagline_eps_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/logos/DMPTool_logo_blue.eps', logo_tagline_svg_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/logos/DMPTool_logo_blue.svg', logo_tagline_png_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/logos/DMPTool_logo_blue.png', logo_eps_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/logos/DMPTool_logo_blue_no_tag.eps', logo_svg_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/logos/DMPTool_logo_blue_no_tag.svg', logo_png_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/logos/DMPTool_logo_blue_no_tag.png', vintage_logo_tagline_png_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/logos/DMPTool_logo_v2.png', vintage_logo_png_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/logos/DMPTool_logo_v2_no_tag.png', original_logo_png_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/logos/DMPTool_logo_v1.png' } %> +
    + +

    <%= _('Postcards') %>

    +

    <%= sanitize _('Advertise the DMPTool to students and researchers at your institution.

    ') % { advertise_pdf1_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/postcard/DMPTool_postcard1.pdf', advertise_pdf2_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/postcard/DMPTool_postcard2.pdf' } %>

    +
    + +

    <%= _('Talking points') %>

    +

    <%= sanitize _('Need to get others on your campus interested in the DMPTool? Use our Talking Points to guide your discussions. In general, a talking points document is designed to help you stay on track during meetings with those outside of your department. It ensures that your major points are at hand and helps you make progress towards the goals of the meeting.

    ') % { talking_points_overview_pdf_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/talkpoints/overview.pdf', talking_points_it_docx_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/talkpoints/IT.docx', talking_points_it_pdf_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/talkpoints/IT.pdf', talking_points_admin_docx_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/talkpoints/admin.docx', talking_points_admin_pdf_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/talkpoints/admin.pdf', talking_points_researcher_docx_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/talkpoints/researchers.docx', talking_points_researcher_pdf_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/talkpoints/researchers.pdf' } %>

    +
    + +

    <%= _('General purpose slides') %>

    +

    <%= sanitize _('We created a generic slide deck that you can use to introduce researchers and others to the DMPTool.

    ') % { dmptool_slides_ppt_url: 'https://github.com/CDLUC3/dmptool/blob/master/docs/genericslides/DMPTool-Generic-Slides.pptx' } %>

    +
    diff --git a/app/views/branded/static_pages/termsuse.html.erb b/app/views/branded/static_pages/termsuse.html.erb new file mode 100644 index 0000000000..0d61d07318 --- /dev/null +++ b/app/views/branded/static_pages/termsuse.html.erb @@ -0,0 +1,47 @@ +
    +

    <%= _('Terms of use & Privacy policy')%>

    + +
    + +

    <%= sanitize _('The California Digital Library (CDL) is supported by the University of California (UC). Our primary constituency is the UC research community; in addition, we provide services to the United States and international higher education sector.') % { cdlib_url: 'http://www.cdlib.org' } %>

    +
    + +

    <%= _('DMPTool') %>

    + +

    <%= sanitize _("DMPTool ('the tool', 'the system') is a tool developed by the CDL and the Digital Curation Centre (DCC) as a shared resource for the research community. It is hosted at CDL by the University of California Curation Center (UC3).") % { dcc_url: 'http://www.dcc.ac.uk/' } %>

    +
    + +

    <%= _('Your personal details') %>

    + +

    <%= _('In order to help identify and administer your account with the DMPTool, we need to store your email address. We may also use it to contact you to obtain feedback on your use of the tool, or to inform you of the latest developments or releases. The information may be transferred between the CDL and DCC partner organizations but only for legitimate CDL purposes. We will not sell, rent, or trade any personal information you provide to us.') %>

    +
    + +

    <%= _('Privacy policy') %>

    + +

    <%= sanitize _('The information you enter into this system can be seen by you, people you have chosen to share access with, and—solely for the purposes of maintaining the service—system administrators at the CDL. We compile anonymized, automated, and aggregated information from plans, but we will not directly access, make use of, or share your content with anyone beyond CDL and your home institution without your permission. Authorized users at your home institution may access your plans for specific purposes—for example, to track compliance with funder/institutional requirements, to calculate storage requirements, or to assess demand for data management services across disciplines. For a detailed description of what information (other than the plans) we collect from visitors to this website and how it is used and managed, please see the CDL Privacy Policy and Baseline Supporting Practices listed at %{policies_url}') % {policies_url: 'https://cdlib.org/about/policies-and-guidelines/privacy-policy/' } %>

    +
    + +

    <%= _('Freedom of Information') %>

    + +

    <%= _('The CDL holds your plans on your behalf, but they are your property and responsibility. Any FOIA applicants will be referred back to your home institution.') %>

    +
    + +

    <%= _('Passwords') %>

    + +

    <%= _('Your password is stored in encrypted form and cannot be retrieved. If forgotten it has to be reset.') %>

    +
    + +

    <%= _('Google Analytics opt-out') %>

    + +

    <%= sanitize _('As noted in the CDL privacy policy, this website uses Google Analytics to capture and analyze usage statistics. You may choose to opt-out of having your website activity tracked by Google Analytics. To do so, visit the Google Analytics opt-out page and install the add-on for your browser.') % { opt_out_url: 'https://tools.google.com/dlpage/gaoptout' }%>

    +
    + +

    <%= _('Third party APIs') %>

    + +

    <%= _('Certain features on this website utilize third party services and APIs such as InCommon/Shibboleth or third party hosting of common JavaScript libraries or web fonts. Information used by an external service is governed by the privacy policy of that service. CDL does not control how information may be used by these services.') %>

    +
    + +

    <%= _('Revisions') %>

    + +

    <%= _('This statement was last revised on October 5, 2017 and may be revised at any time. Use of the tool indicates that you understand and agree to these terms and conditions.') %>

    +
    diff --git a/app/views/contributors/_form.html.erb b/app/views/contributors/_form.html.erb index 3dcd50d396..db74c92707 100644 --- a/app/views/contributors/_form.html.erb +++ b/app/views/contributors/_form.html.erb @@ -38,19 +38,21 @@ roles_tooltip = _("Select each role that applies to the contributor.") <% end %> -
    +
    diff --git a/app/views/contributors/index.html.erb b/app/views/contributors/index.html.erb index ddd3911313..db672dd097 100644 --- a/app/views/contributors/index.html.erb +++ b/app/views/contributors/index.html.erb @@ -10,6 +10,7 @@ <% content_for :plan_tab_body do %>
    +

    <%= _('Project Contributors')%>

    <%= _("Please list the project’s Principal Investigator(s) and those responsible for data management.") %>

    @@ -29,10 +30,23 @@

    <%= _("No contributors have been defined.") %>

    <% end %> - <%= link_to _("Add a contributor"), new_plan_contributor_path(@plan), - class: "btn btn-primary" %> + <% if @plan.administerable_by?(current_user.id) %> + <%= link_to _("Add a contributor"), new_plan_contributor_path(@plan), + class: "btn btn-primary" %> + <% end %>
    + + <% unless Rails.configuration.x.show_collaborators_on_share_tab %> +
    + +
    +
    + <%= render partial: "plans/collaborator_form", + locals: { plan: @plan, plan_roles: @plan.roles } %> +
    +
    + <% end %> <% end %> <%= render partial: "plans/navigation", locals: { plan: @plan } %> diff --git a/app/views/datacite/_contributor.json.jbuilder b/app/views/datacite/_contributor.json.jbuilder new file mode 100644 index 0000000000..8c5e50c7ad --- /dev/null +++ b/app/views/datacite/_contributor.json.jbuilder @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# locals: contributor, orcid_scheme, ror_scheme + +if contributor.is_a?(Hash) + if contributor[:name].present? + json.name contributor[:name] + json.nameType "Organizational" + json.contributorType "HostingInstitution" + + if contributor[:ror].present? + json.nameIdentifier contributor[:ror] + json.nameIdentifierScheme "ROR" + end + end + +elsif contributor.is_a?(Org) + json.name contributor.name + json.nameType "Organizational" + + json.contributorType "Producer" + + ror = contributor.identifier_for_scheme(scheme: ror_scheme) + if ror_scheme.present? && ror.present? + json.nameIdentifier ror.value + json.nameIdentifierScheme "ROR" + end + +else + if contributor.is_a?(User) + json.name [contributor.surname, contributor.firstname].join(", ") + elsif contributor.is_a?(Contributor) && contributor.roles.positive? + json.name contributor.name + + datacite_role = "ProjectManager" if contributor.project_administration? + datacite_role = "ProjectLeader" if datacite_role.nil? && contributor.investigation? + datacite_role = "DataCurator" unless datacite_role.present? + json.contributorType datacite_role + end + + json.nameType "Personal" + + if contributor.org.present? + json.affiliation do + json.name contributor.org.name + + ror = contributor.org.identifier_for_scheme(scheme: ror_scheme) + if ror_scheme.present? && ror.present? + json.affiliationIdentifier ror.value + json.affiliationIdentifierScheme "ROR" + end + end + end + + orcid = contributor.identifier_for_scheme(scheme: orcid_scheme) + if orcid_scheme.present? && orcid.present? + json.nameIdentifiers [orcid] do + json.nameIdentifier "https://orcid.org/#{orcid.value}" + json.nameIdentifierScheme "ORCID" + end + end +end diff --git a/app/views/datacite/_minter.json.jbuilder b/app/views/datacite/_minter.json.jbuilder new file mode 100644 index 0000000000..5b9807f5f0 --- /dev/null +++ b/app/views/datacite/_minter.json.jbuilder @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +json.ignore_nil! + +# rubocop:disable Metrics/BlockLength +json.data do + json.type "dois" + + json.attributes do + json.prefix prefix + json.schemaVersion "http://datacite.org/schema/kernel-4" + + json.types do + json.resourceType "Data Management Plan" + json.resourceTypeGeneral "OutputManagementPlan" + end + + ror_scheme = IdentifierScheme.where(name: "ror").first + fundref_scheme = IdentifierScheme.where(name: "fundref").first + orcid_scheme = IdentifierScheme.where(name: "orcid").first + + creators = data_management_plan.owner_and_coowners + + if creators.present? && creators.any? + json.creators creators do |creator| + json.partial! "datacite/contributor", contributor: creator, + orcid_scheme: orcid_scheme, + ror_scheme: ror_scheme + end + end + + contributors = data_management_plan.contributors.to_a + contributors << data_management_plan.org + contributors << { + name: Rails.configuration.x.datacite.hosting_institution, + ror: Rails.configuration.x.datacite.hosting_institution_identifier + } + + json.contributors contributors do |contributor| + json.partial! "datacite/contributor", contributor: contributor, + orcid_scheme: orcid_scheme, + ror_scheme: ror_scheme + end + + json.titles do + json.array! [data_management_plan.title] do |title| + json.title title + end + end + json.publisher ApplicationService.application_name + json.publicationYear Time.now.year + + json.dates [ + { type: "Created", date: data_management_plan.created_at.to_formatted_s(:iso8601) }, + { type: "Updated", date: data_management_plan.updated_at.to_formatted_s(:iso8601) } + ] do |hash| + json.date hash[:date] + json.dateType hash[:type] + end + + json.relatedIdentifiers [data_management_plan] do + url = Rails.application.routes.url_helpers.api_v1_plan_url(data_management_plan) + json.relatedIdentifier url + json.relatedIdentifierType "URL" + json.relatedIdentifierType "IsMetadataFor" + end + + if data_management_plan.description.present? + json.descriptions [data_management_plan.description] do |description| + json.description description + json.descriptionType "Abstract" + end + end + + if data_management_plan.funder.present? + json.fundingReferences [data_management_plan.funder] do |funder| + json.funderName funder.name + + fundref = funder.identifier_for_scheme(scheme: fundref_scheme) + if fundref_scheme.present? && fundref.present? + json.funderIdentifier fundref.value + json.funderIdentifierType "Crossref Funder" + end + + if data_management_plan.grant.present? + if data_management_plan.grant.value.start_with?("http") + json.awardURI = data_management_plan.grant.value + end + json.awardNumber = data_management_plan.grant.value + end + end + end + end +end +# rubocop:enable Metrics/BlockLength diff --git a/app/views/devise/mailer/invitation_instructions.html.erb b/app/views/devise/mailer/invitation_instructions.html.erb index a9e0989c4d..a7cc781393 100644 --- a/app/views/devise/mailer/invitation_instructions.html.erb +++ b/app/views/devise/mailer/invitation_instructions.html.erb @@ -4,36 +4,63 @@ helpdesk_email = Rails.configuration.x.organisation.helpdesk_email contact_us = (Rails.configuration.x.organisation.contact_us_url || contact_us_url) email_subject = _('Query or feedback related to %{tool_name}') %{ :tool_name => tool_name } - user_name = User.find_by(email: @resource.email).nil? ? @resource.email : User.find_by(email: @resource.email).name(false) - inviter_name = @resource.invited_by.name + user_name = @resource.email + inviter = @resource.invited_by + plan = Plan.find_by(id: @resource.invitation_plan_id) + org = plan.template.org %> <% I18n.with_locale I18n.default_locale do %>

    <%= _('Hello %{user_name}') %{ :user_name => user_name } %>

    - <%= _("Your colleague %{inviter_name} has invited you to contribute to "\ - " their Data Management Plan in %{tool_name}") % { - tool_name: tool_name, - inviter_name: inviter_name - } %> -

    -

    - <%= sanitize(_('%{click_here} to accept the invitation, (or copy %{link} into your browser). If you don\'t want to accept the invitation, please ignore this email.') % { - click_here: link_to(_('Click here'), link), link: link - }) %> -

    -

    - <%= _('All the best') %> -
    - <%= _('The %{tool_name} team') %{:tool_name => tool_name} %> -

    -

    - <%= _('Please do not reply to this email.') %>  - <%= sanitize(_('If you have any questions or need help, please contact us at %{helpdesk_email} or visit %{contact_us_url}') % { - helpdesk_email: mail_to(helpdesk_email, helpdesk_email, - subject: email_subject), - contact_us_url: link_to(contact_us, contact_us) - }) %> + <% if inviter.is_a?(Org) %> + <%# This was an inivitation generated by an OrgAdmin using 'Email template' %> + <%= sanitize(plan.template.email_body % { + dmp_title: plan.title, + org_name: inviter.name, + org_admin_email: link_to(inviter.contact_email, inviter.contact_email) + }) %> + + <% elsif inviter.is_a?(ApiClient) %> + <%# This was an inivitation generated by a 'create_dmps' scope in API V2+ %> + <%= sanitize(plan.template.org.api_create_plan_email_body % { + external_system_name: inviter.description + }) %> + + <% else %> + <%# This was an inivitation on the Collaborator section of the Plan pages %> + <%= _("Your colleague %{inviter_name} has invited you to contribute to "\ + " their Data Management Plan in %{tool_name}") % { + tool_name: tool_name, + inviter_name: inviter&.name(false) + } %> + <% end %>

    + + <% if inviter.is_a?(User) %> +

    + <%= sanitize(_('%{click_here} to accept the invitation, (or copy %{link} into your browser). If you don\'t want to accept the invitation, please ignore this email.') % { + click_here: link_to(_('Click here'), link), link: link + }) %> +

    + <%= render partial: 'user_mailer/email_signature', + locals: { + tool_name: ApplicationService.application_name, + helpdesk_email: Rails.configuration.x.helpdesk_email, + allow_change_prefs: false + } %> + <% else %> +

    + <%= sanitize(_("%{click_here} to setup your account (or copy %{link} into your browser). Once you have signed in, you can begin filling out your DMP.") % { + click_here: link_to(_('Click here'), link), link: link + }) %> +

    +
    +

    <%= _("Thank you,") %>

    +

    <%= _("The %{org_name} DMPTool team") % { org_name: org&.name } %>

    +

    <%= _("Please do not reply to this email. If you have any questions or need help, please contact us at %{org_admin_email}") % { + org_admin_email: link_to(org&.contact_email, org&.contact_email) + } %>

    + <% end %> <% end %> diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb index b658a735bd..392ad7497b 100644 --- a/app/views/devise/passwords/edit.html.erb +++ b/app/views/devise/passwords/edit.html.erb @@ -24,7 +24,7 @@ <%= _('Show passwords') %>
    - + <% if Rails.configuration.x.recaptcha.enabled %>
    <%= label_tag(nil, _('Security check')) %> diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb index 3b08103ac5..c38f2d4296 100644 --- a/app/views/devise/passwords/new.html.erb +++ b/app/views/devise/passwords/new.html.erb @@ -4,7 +4,7 @@ <% unless @user.errors[:email].empty? %>

    <%= _('The email address you entered is not registered.') %>

    <% end %> - +

    <%= _('Forgot your password?') %>

    <%= _('Please enter your email below and we will send you instructions on how to reset your password.') %>

    diff --git a/app/views/devise/registrations/_api_client_form.html.erb b/app/views/devise/registrations/_api_client_form.html.erb new file mode 100644 index 0000000000..eb6d4d116d --- /dev/null +++ b/app/views/devise/registrations/_api_client_form.html.erb @@ -0,0 +1,123 @@ +<%# locals: api_client, user %> + +
    + +<% + url = api_client.new_record? ? api_clients_path : api_client_path(api_client) + method = api_client.new_record? ? :post : :put + + name_tooltip = _("The name of your application/system that will interact with the %{application_name}'s API") % { application_name: "DMPTool" } + description_tooltip = _("Please provide a brief description of your system and the purpose of the intgeration.") + redirect_uri_tooltip = _("A redirect URI is the address that will be sent the user's OAuth authorization code. If you have multiple URIs, pllease place them on inidividual lines. The redirect_uri you specify in your calls to the API must match one of the ones you define here.") + callback_uri_tooltip = _("An API endpoint on your application/system that we can use to notify you when a DMP is updated. You will receive notifications of updates to any DMPs you create, download or update.") +%> +<%= form_for api_client, url: url, method: method, remote: true, html: { class: 'api_client' } do |f| %> +

    <%= _("Standard Access") %>

    +
    +
    + <%= f.label :contact_name, _('Contact Name'), class: 'control-label' %> + <%= f.text_field :contact_name, class: 'form-control', aria: { required: true } %> +
    +
    + <%= f.label :contact_email, _('Contact Email'), class: 'control-label' %> + <%= f.email_field :contact_email, class: 'form-control', aria: { required: true } %> +
    +
    + <% unless api_client.new_record? %> +
    +
    + <%= f.label :client_id, _('Client ID'), class: 'control-label' %> + <%= f.text_field :client_id, class: 'form-control', disabled: true %> +
    +
    + <%= f.label :client_secret, _('Client Secret'), class: 'control-label' %> + <%= f.text_field :client_secret, class: 'form-control', disabled: true %> +
    +
    + <% end %> + +
    + +

    <%= _("Protected Access") %>

    +
    +
    + <%= _("This system supports the OAuth2 authorization standard. Please assign a name for your system and provide a homepage and your OAuth2 compliant redirect URIs below to acquire user authorization codes. You can leave this section blank, if you will do not require 'protected' access (see above).") %> +
    +
    +
    +
    + <%= f.label :name, _('Application Name'), class: 'control-label' %> + <%= f.text_field :name, class: 'form-control', data: { toggle: "tooltip" }, + title: name_tooltip %> +
    +
    + <%= f.label :homepage, _('Homepage'), class: 'control-label' %> + <%= f.url_field :homepage, class: 'form-control' %> +
    +
    +
    +
    + <%= f.label :description, _('Description'), class: 'control-label' %> + <%= f.text_area :description, class: 'form-control api-client-text' %> +
    +
    +
    +
    + <%= f.label :logo, _('Logo'), class: "control-label" %> + + <% if f.object.logo.present? %> +
    + <%= image_tag logo_url_for_org(f.object), alt: "#{f.object.name} #{_('logo')}", class: "org-logo" %> +
    + <%= f.label :remove_logo do %> + <%= f.check_box :remove_logo, + title: _("This will remove your logo") %> + <%= _('Remove logo') %> + <% end %> + - <%= _('or') %> - + <%= f.file_field :logo %> +
    + <% else %> + <%= f.file_field :logo %> + <% end %> +
    +
    +
    +
    + <%= f.label :redirect_uri, _('Redirect URI(s)'), class: 'control-label' %> + <%= f.text_area :redirect_uri, class: 'form-control', rows: 3, + placeholder: 'http://localhost:3000/oauth2/callback', + data: { toggle: "tooltip" }, title: redirect_uri_tooltip %> +
    +
    +
    +
    + <%= f.label :callback_method, _('Callback method'), class: 'control-label' %> + <% http_methods = ApiClient.callback_methods.map { |k, _v| [k.humanize, k] } %> + <%= f.select :callback_method, options_for_select(http_methods, f.object.callback_method), + { selected: f.object.callback_method || 0 }, { class: "form-control" } %> +
    +
    + <%= f.label :callback_uri, _('Callback URI'), class: 'control-label' %> + <%= f.url_field :callback_uri, class: 'form-control', rows: 3, + placeholder: 'http://localhost:3000/dmps/', + data: { toggle: "tooltip" }, title: callback_uri_tooltip %> +
    +
    + +
    +
    + <%= f.hidden_field :user_id %> + <%= f.hidden_field :org_id, value: user.org_id %> + + <%= f.button _('Save'), class: 'btn btn-default', type: 'submit' %> + + <% unless api_client.new_record? %> + <%= link_to _("Refresh client secret"), + refresh_credentials_api_client_path(id: api_client), + class: "btn btn-default", remote: true %> + <% end %> +
    +
    +
    +<% end %> \ No newline at end of file diff --git a/app/views/devise/registrations/_api_token.html.erb b/app/views/devise/registrations/_api_token.html.erb index 704be320b1..e9d7fd388d 100644 --- a/app/views/devise/registrations/_api_token.html.erb +++ b/app/views/devise/registrations/_api_token.html.erb @@ -14,8 +14,6 @@ <%= label_tag(:api_information, _('Documentation'), class: 'control-label') %>
    <%= _('See the documentation for v0 for more details on the original API which includes access to statistics, the full text of plans and the ability to connect users with departments.').html_safe % { api_v0_wiki: api_wikis[:v0] } %> -

    - <%= _('See the documentation for v1 for more details on the API that supports the RDA Common metadata standard for DMPs.').html_safe % { api_v1_wiki: api_wikis[:v1], rda_standard_url: 'https://github.com/RDA-DMP-Common/RDA-DMP-Common-Standard' } %>
    <%= link_to _("Regenerate token"), diff --git a/app/views/devise/registrations/_external_identifier.html.erb b/app/views/devise/registrations/_external_identifier.html.erb index 884dba3d91..0f5df9073d 100644 --- a/app/views/devise/registrations/_external_identifier.html.erb +++ b/app/views/devise/registrations/_external_identifier.html.erb @@ -21,6 +21,11 @@ <% titletext = _("ORCID provides a persistent digital identifier that distinguishes you from other researchers. Learn more at orcid.org") %> <% unlinktext = _("Disconnect your account from ORCID. You can reconnect at any time.") %> <% unlinkconf = _("Are you sure you want to disconnect your ORCID ID?") %> + + <% token = ExternalApiAccessToken.for_user_and_service(user: current_user, service: "orcid") %> + <% if id.present? && !token.present? %> +

    <%= _("The DMPTool now allows you to add your DMPs to the works section of your ORCID record. Please disconnect your account by clicking the 'x' below and then reconnect your account to enable this functionality.") %>

    + <% end %> <%= link_to id.value, id: 'orcid-id', target: '_blank', diff --git a/app/views/devise/registrations/_personal_details.html.erb b/app/views/devise/registrations/_personal_details.html.erb index 6e2ab54c67..5db4f0f66d 100644 --- a/app/views/devise/registrations/_personal_details.html.erb +++ b/app/views/devise/registrations/_personal_details.html.erb @@ -66,26 +66,6 @@
    <% end %> - <% @identifier_schemes.each do |scheme| %> -
    - <% if scheme.name.downcase == 'shibboleth' %> - - <% elsif scheme.name.downcase == 'orcid' %> - <%= label_tag(:scheme_name, 'ORCID', class: 'control-label') %> - <% else %> - <%= label_tag(:scheme_name, scheme.name.capitalize, class: 'control-label') %> - <% end %> - -
    - <%= render partial: "external_identifier", - locals: { scheme: scheme, - id: current_user.identifier_for(scheme.name)} %> -
    -
    - <% end %> -
    <%= f.button(_('Save'), class: 'btn btn-default', type: "submit", id: "personal_details_registration_form_submit") %>
    diff --git a/app/views/devise/registrations/api_client_refresh_credentials.js.erb b/app/views/devise/registrations/api_client_refresh_credentials.js.erb new file mode 100644 index 0000000000..374f712ab8 --- /dev/null +++ b/app/views/devise/registrations/api_client_refresh_credentials.js.erb @@ -0,0 +1,12 @@ +var apiClientMsg = '<%= @success ? _("Successfully regenerated the client credentials.") : _("Unable to regenerate the client credentials.") %>'; + +var apiClientContext = $('.api-client-controls'); + +if (apiClientContext.length > 0) { + var apiClientForm = apiClientContext.closest('form'); + apiClientForm.find('#api_client_client_secret').val('<%= @api_client.secret %>'); + apiClientContext.find('.refresh-response').html(apiClientMsg); + renderNotice(apiClientMsg); +} + +toggleSpinner(false); diff --git a/app/views/devise/registrations/api_client_save.js.erb b/app/views/devise/registrations/api_client_save.js.erb new file mode 100644 index 0000000000..37cf4d3789 --- /dev/null +++ b/app/views/devise/registrations/api_client_save.js.erb @@ -0,0 +1,11 @@ +var apiClientMsg = '<%= @msg %>'; +var context = $('.api-client-form'); + +if (context.length > 0) { + context.html('<%= escape_javascript(render partial: "/devise/registrations/api_client_form", locals: { api_client: @api_client, user: current_user }) %>'); + var controlsContext = context.find('.api-client-controls'); + controlsContext.find('.refresh-response').html(apiClientMsg); + renderNotice(apiClientMsg); +} + +toggleSpinner(false); diff --git a/app/views/doorkeeper/authorizations/error.html.erb b/app/views/doorkeeper/authorizations/error.html.erb new file mode 100644 index 0000000000..41b47b4555 --- /dev/null +++ b/app/views/doorkeeper/authorizations/error.html.erb @@ -0,0 +1,7 @@ +
    +

    <%= t('doorkeeper.authorizations.error.title') %>

    +
    + +
    +
    <%= @pre_auth.error_response.body[:error_description] %>
    +
    diff --git a/app/views/doorkeeper/authorizations/new.html.erb b/app/views/doorkeeper/authorizations/new.html.erb new file mode 100644 index 0000000000..0d27edf1ab --- /dev/null +++ b/app/views/doorkeeper/authorizations/new.html.erb @@ -0,0 +1,72 @@ + + +
    + <%# Set a session variable so we can notify the application_controller that this is an OAuth workflow %> + <% session["oauth-referer"] = oauth_authorization_path(client_id: @pre_auth.client.uid, + redirect_uri: @pre_auth.redirect_uri, + state: @pre_auth.state, + response_type: @pre_auth.response_type, + scope: @pre_auth.scope, + code_challenge: @pre_auth.code_challenge, + code_challenge_method: @pre_auth.code_challenge_method) %> + + <% if @current_resource_owner.present? %> +

    + <%= sanitize(_("%{application} wants to access your DMPs. Please confirm this action.") % { application: "#{@pre_auth.client.name.humanize}" }) %> +

    + <% else %> +

    + <%= sanitize(_("%{application} wants to access your DMPs. Please sign in to verify your account.") % { application: "#{@pre_auth.client.name.humanize}" }) %> +

    + <% end %> + + <% if @current_resource_owner.present? %> + <% if @pre_auth.scopes.count > 0 %> +
    +

    <%= t('.able_to') %>:

    + +
      + <% @pre_auth.scopes.each do |scope| %> +
    • <%= user_label_for_scope(scope) %>
    • + <% end %> +
    +
    + <% end %> + +
    + <%= form_tag oauth_authorization_path, method: :post do %> + <%= hidden_field_tag :client_id, @pre_auth.client.uid %> + <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri %> + <%= hidden_field_tag :state, @pre_auth.state %> + <%= hidden_field_tag :response_type, @pre_auth.response_type %> + <%= hidden_field_tag :scope, @pre_auth.scope %> + <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge %> + <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method %> + <%= submit_tag t('doorkeeper.authorizations.buttons.authorize'), class: "btn btn-success btn-lg btn-block" %> + <% end %> + <%= form_tag oauth_authorization_path, method: :delete do %> + <%= hidden_field_tag :client_id, @pre_auth.client.uid %> + <%= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri %> + <%= hidden_field_tag :state, @pre_auth.state %> + <%= hidden_field_tag :response_type, @pre_auth.response_type %> + <%= hidden_field_tag :scope, @pre_auth.scope %> + <%= hidden_field_tag :code_challenge, @pre_auth.code_challenge %> + <%= hidden_field_tag :code_challenge_method, @pre_auth.code_challenge_method %> + <%= submit_tag t('doorkeeper.authorizations.buttons.deny'), class: "btn btn-danger btn-lg btn-block" %> + <% end %> +
    + + <% else %> + <% if Rails.configuration.x.shibboleth_enabled %> + <%= render partial: "shared/shib_sign_in_form" %> + +

    - <%= _('Or if your institution is not listed') %> -

    + <% end %> + + <%= render partial: "shared/sign_in_form", locals: { resource: User.new } %> + +

    <%= flash[:alert] %>

    + <% end %> +
    diff --git a/app/views/layouts/_navigation.html.erb b/app/views/layouts/_navigation.html.erb index 95e0798168..f46922436a 100644 --- a/app/views/layouts/_navigation.html.erb +++ b/app/views/layouts/_navigation.html.erb @@ -58,6 +58,10 @@ + + <% unless user_signed_in? || ((controller_name.eql? "home") && (action_name.eql? "index")) %> + <%= render partial: 'shared/access_controls', layout: 'shared/modal', locals: { id: "header-signin", title: _('Sign in')} %> + <% end %>
    diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 079d6e3b34..c554fedca9 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -61,7 +61,7 @@ <%= stylesheet_link_tag(stylesheet_manifest_file) %> - <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %> + <%= javascript_pack_tag "application", "data-turbolinks-track": "reload" %> <%= csrf_meta_tags %> @@ -144,7 +144,12 @@ AUTOCOMPLETE_ARIA_HELPER_EMPTY: _("No results are available for your entry."), AUTOCOMPLETE_SEARCHING: _("Searching ..."), - CURRENT_LOCALE: LocaleService.to_gettext(locale: @current_locale.to_s) + ACQUIRING_DMP_ID: _("Acquiring your DMP ID. This may take a few seconds ..."), + + CURRENT_LOCALE: LocaleService.to_gettext(locale: @current_locale.to_s), + + MORE_INFO: _("More info"), + LESS_INFO: _("Less info") }.to_json %> diff --git a/app/views/layouts/doorkeeper/application.html.erb b/app/views/layouts/doorkeeper/application.html.erb new file mode 100644 index 0000000000..0e2bd5503c --- /dev/null +++ b/app/views/layouts/doorkeeper/application.html.erb @@ -0,0 +1,47 @@ + + + + <%= ApplicationService.application_name %> + + + + + <%= stylesheet_link_tag(stylesheet_manifest_file) %> + <%= stylesheet_link_tag "doorkeeper/application" %> + + + + <%= javascript_pack_tag "application", "data-turbolinks-track": "reload" %> + <%= csrf_meta_tags %> + + +
    + + + <%# Comment out generic DMPRoadmap logo in favor of the DMPTool SVG version %> + <%#= image_tag("logo.png", alt: "#{ApplicationService.application_name} #{_('logo')}", + class: "app-logo", title: ApplicationService.application_name) %> + + <%= yield %> +
    + + diff --git a/app/views/layouts/modal_search/README.md b/app/views/layouts/modal_search/README.md new file mode 100644 index 0000000000..74b5fa2f33 --- /dev/null +++ b/app/views/layouts/modal_search/README.md @@ -0,0 +1,155 @@ +# Modal Search + +This modal search allows your user to search for something, select the results they want and then places those selections on your form so that they are part of the form submission. + +To add it to your page, you must render 2 partials: +1. The first adds a 'selected results' section to your form. +2. The second adds the modal dialog which should be placed #outside# of your form, typically at the bottom of the page. + +You must also define the following: +1. Determine a namespace to use that will be unique on your page. You can add multiple modal searches to your page. Using a unique namespace allows the JS to properly manage this functionality. +2. A controller action to perform a search. You will specify this path and method when rendering the modal partial. +3. A `js.erb` that will use the namespace to replace the `modal-search-[namespace]-results` section of the modal window. +4. A partial that defines how an individual result should be displayed. The display of a search result is up to you, this partial will be used in the modal search results section as well as the selected results section. The 'Select' and 'Remove' links will be managed by the modal search code. +5. (Optional) A partial that contains additional filter/search options. The modal search contains a 'search term' box. You can define additional facets/filters as needed. + +## Define an area to display selections + +As noted above, you must add a call to render the `layouts/modal_search/selections` partial. This should live within your form element so that any selections the user makes within the modal search window are passed back to the server upon form submission. See `views/research_outputs/_form.html.erb` for an example of rendering this section and `controllers/research_outputs_controller.rb` (and `models/research_output.rb`) for an example of how to process the user's selections. + +The contents of this section will be populated by the JS in `app/javascript/src/utils/modalSearch.js` when a user clicks on the 'Select' link next to item's title/name. Once the item appears in this section, a 'Remove' link will appear that allows the user to remove it from this section. + +Example screenshot of selected repositories: +![Screenshot of some repositories selected via a modal search](../../../../docs/screenshots/modal_selections.png) + +Example render of this section: +```ruby +var resultsDiv = $('#modal-search-repositories-results'); + +resultsDiv.html('<%= + escape_javascript( + render( + partial: "layouts/modal_search/selections", + locals: { + namespace: "repositories", + button_label: _("Add a repository"), + item_name_attr: :name, + results: research_output.repositories, + selected: true, + result_partial: "research_outputs/repositories/search_result", + search_path: repository_search_plan_path(research_output.plan), + search_method: :get + } + ) + ) %>'); +``` + +Locals: +- #namespace# - a unique name to identify the modal. This value can be used to match a selected result to a section of the parent page. +- #button_label# - the text for the button that opens the modal search window +- #item_name_attr# - The attribute that contains the title/name of the item. +- #results# - any currently selected items +- #selected# - this should be 'true' here. This will ensure that the 'Remove' link gets displayed for the selected items contained in the results. +- #result_partial# - The partial you have defined to display the item's info +- #search_path# - the path to controller endpoint that will perform the search +- #search_method# - the http method used to perform the search + +## Define the modal dialog + +This should be placed outside any form elements you may have defined on your page because it uses its own form element to process the search. + +To add the modal search to your page you must render the form partial. For example: +```ruby +<%= render partial: "layouts/modal_search/form", + locals: { + namespace: "repositories", + label: "Repository", + search_examples: "(e.g. DNA, titanium, FAIR, etc.)", + model_instance: research_output, + search_path: repository_search_plan_path(research_output.plan), + search_method: :get + } %> +``` + +Locals: +- #namespace# - a unique name to identify the modal. This value can be used to match a selected result to a section of the parent page. +- #label# - the text to display on the modal window. This will be swapped in so that it reads: '[label] search' +- #search_examples# - Helpful text that will appear in the search term box as a placeholder to givethe user some suggestions. +- #model_instance# - An instance of the parent object that the search results will be associated to. (e.g. an instance of ResearchOutput if the user will be searching for a license or repository). This is used to help define the `form_with` on the modal search form. +- #search_path# - the path to controller endpoint that will perform the search +- #search_method# - the http method used to perform the search + +Example of the modal window: +![Screenshot of the modal search dialog for repositories](../../../../docs/screenshots/modal_search.png) + +Note that the 'search term' text field box is added by default. The two select boxes are custom filters. See below for info on defining custom filters. + +Once the user clicks the search button, your controller/action will be called and the `layouts/modal_search/results` partial will be rendered by your `js.erb`. The results will be paginated, so be sure to include `.page(params[:page])`in your controller! + +Example of the `js.erb`: + +For example: +```ruby +var resultsDiv = $('#modal-search-repositories-results'); + +resultsDiv.html('<%= + escape_javascript( + render( + partial: "layouts/modal_search/results", + locals: { + namespace: "repositories", + results: @search_results, + selected: false, + item_name_attr: :name, + result_partial: "research_outputs/repositories/search_result", + search_path: repository_search_plan_path(@plan), + search_method: :get + } + ) + ) %>'); +``` + +Locals: +- #namespace# - a unique name to identify the modal. This value can be used to match a selected result to a section of the parent page. +- #results# - any currently selected items +- #selected# - this should be 'false' here. This will ensure that the 'Select' link and pagination controls are displayed. +- #item_name_attr# - The attribute that contains the title/name of the item. +- #result_partial# - The partial you have defined to display the item's info +- #search_path# - the path to controller endpoint that will perform the search. +- #search_method# - the http method used to perform the search + +As the user selects results, the JS will move the result from the modal window to the selections sections described above. + +Note that the modal_search results can work with either an ActiveRecord Model or a Hash! + +## Adding additional search criteria + +By default the modal search will only display the 'search term' text field and an 'Apply filters' button. You can add additional custom filters by supplying content to `yield :filters`. In the screenshot above, you can see 2 additional select boxes that allow the user to further refine the search. + +Example definition of the :filters content: +```ruby +<% content_for :filters do %> + <% + by_type_tooltip = _("Refine your search to discipline specific, institutional or generalist repositories.") + by_subject_tooltip = _("Select a subject area to refine your search.") + %> + + + <%= select_tag :"research_output[subject_filter]", + options_for_select(ResearchOutputPresenter.selectable_subjects), + include_blank: _("- Select a subject area -"), + class: "form-control", + title: by_subject_tooltip, + data: { toggle: "tooltip", placement: "bottom" } %> + + + + <%= select_tag :"research_output[type_filter]", + options_for_select(ResearchOutputPresenter.selectable_repository_types), + include_blank: _("- Select a repository type -"), + class: "form-control", + title: by_type_tooltip, + data: { toggle: "tooltip", placement: "bottom" } %> + +<% end %> +``` diff --git a/app/views/layouts/modal_search/_form.html.erb b/app/views/layouts/modal_search/_form.html.erb new file mode 100644 index 0000000000..a9b469c7bf --- /dev/null +++ b/app/views/layouts/modal_search/_form.html.erb @@ -0,0 +1,89 @@ +<%# +This partial is the entry point for adding the modal search dialog to a page. +See the README.md within this directory for more info: + +Locals: + :namespace - a unique name to identify the modal. This value can be used + to match a selected result to a section of the parent page. + :label - the text to display on the modal window. This will be swapped + in so that it reads: '[label] search' + :search_examples - Helpful text that will appear in the search term box as a + placeholder to givethe user some suggestions. + :model_instance - An instance of the parent object that the search results + will be associated to. (e.g. an instance of ResearchOutput + if the user will be searching for a license or repository). + This is used to help define the `form_with` on the modal search form. + :search_path - the path to controller endpoint that will perform the search + :search_method - the http method used to perform the search +%> + +<% +search_examples = search_examples || "" +results = results || [] + +search_placeholder = _("- Enter a search term %{examples} -") % { examples: search_examples} +no_results_msg = _("No results matched your filter criteria.") +%> + + diff --git a/app/views/layouts/modal_search/_result.html.erb b/app/views/layouts/modal_search/_result.html.erb new file mode 100644 index 0000000000..b7c4a5f736 --- /dev/null +++ b/app/views/layouts/modal_search/_result.html.erb @@ -0,0 +1,37 @@ +<%# +This is calledd by the layouts/modal_search/_results.html.erb partial. + +Locals: +:item_name_attr - The attribute that contains the title/name of the item. +:result - an instance of a result (can be either a Model or a Hash) +:selected - indicates whether this item is within the 'selections' (true) + partial or the 'results' (false) partial +:result_partial - The partial you have defined to display the item's info +:search_path - the path to controller endpoint that will perform the search +:search_method - the http method used to perform the search +%> + +<% title = result[item_name_attr] %> + + diff --git a/app/views/layouts/modal_search/_results.html.erb b/app/views/layouts/modal_search/_results.html.erb new file mode 100644 index 0000000000..c237b76644 --- /dev/null +++ b/app/views/layouts/modal_search/_results.html.erb @@ -0,0 +1,56 @@ +<%# +This is the entry point for the results that are rendered by a `js.erb` file. +See the README.md within this directory for more info: + +Locals: +:namespace - a unique name to identify the modal. This value can be used + to match a selected result to a section of the parent page. +:results - the paginated results of the search +:selected - this should be 'false' here to ensure that the 'Select' link and + pagination controls are displayed. +:item_name_attr - The attribute that contains the title/name of the item. +:result_partial - The partial you have defined to display the item's info +:search_path - the path to controller endpoint that will perform the search +:search_method - the http method used to perform the search +%> + +<% +results = results || [] +selected = selected || false +no_results_msg = _("No results matched your filter criteria.") +%> + +<% unless selected %> + <% if results.any? %> + + <% else %> +
    <%= no_results_msg %>
    + <% end %> +<% end %> + +<% results.each do |result| %> + <%= render partial: "layouts/modal_search/result", + locals: { + item_name_attr: item_name_attr, + result: result, + selected: selected, + result_partial: result_partial, + search_path: search_path, + search_method: search_method + }%> +
    +<% end %> + +<% if results.any? && !selected %> +
     
    +
    + + <%= paginate results, remote: true, method: :post %> + +
    +<% end %> diff --git a/app/views/layouts/modal_search/_selections.html.erb b/app/views/layouts/modal_search/_selections.html.erb new file mode 100644 index 0000000000..2ace4f1fec --- /dev/null +++ b/app/views/layouts/modal_search/_selections.html.erb @@ -0,0 +1,35 @@ +<%# +This partial is the entry point for displaying the selected results section of +a modal search window. See the README.md within this directory for more info: + +locals: + :namespace - a unique name to identify the modal. This value can be used + to match a selected result to a section of the parent page. + :button_label - the text for the button that opens the modal search window + :item_name_attr - The attribute that contains the title/name of the item. + :results - any currently selected items + :selected - this should be 'true' here. This will ensure that the 'Remove' + link gets displayed for the selected items contained in the results. + :result_partial - The partial you have defined to display the item's info + :search_path - the path to controller endpoint that will perform the search + :search_method - the http method used to perform the search +%> + + +
    +
    + <%= button_tag button_label, type: "button", class: "btn btn-default", + data: { toggle: "modal", target: "#modal-search-#{namespace}" } %> +
    +
    diff --git a/app/views/org_admin/sections/_index.html.erb b/app/views/org_admin/sections/_index.html.erb index ef6816c6f4..0f8eff2fcb 100644 --- a/app/views/org_admin/sections/_index.html.erb +++ b/app/views/org_admin/sections/_index.html.erb @@ -3,7 +3,6 @@
    -
    diff --git a/app/views/org_admin/templates/_form.html.erb b/app/views/org_admin/templates/_form.html.erb index 29c17a05a1..ff31751d67 100644 --- a/app/views/org_admin/templates/_form.html.erb +++ b/app/views/org_admin/templates/_form.html.erb @@ -5,11 +5,14 @@ <%= f.text_field(:title, class: "form-control", spellcheck: true, "aria-required": true) %>
    -
    - <%= description_tooltip %> - <%= f.label(:description, _('Description'), class: "control-label") %> - <%= f.text_area(:description, class: "template", spellcheck: true) %> -
    + +<% if f.object.description.present? %> +
    + <%= description_tooltip %> + <%= f.label(:description, _('Description'), class: "control-label") %> + <%= f.text_area(:description, class: "template") %> +
    +<% end %> <% if current_user.org.funder? && !current_user.org.funder_only? %> diff --git a/app/views/org_admin/templates/container.html.erb b/app/views/org_admin/templates/container.html.erb index ff6136e8c0..b3fa993c3d 100644 --- a/app/views/org_admin/templates/container.html.erb +++ b/app/views/org_admin/templates/container.html.erb @@ -3,7 +3,7 @@

    <%= template.id.present? ? template.title : _('New Template') %>

    - <%= link_to _('View all templates'), referrer, class: 'btn btn-default pull-right' %> + <%= link_to _('View all templates'), referrer, class: 'btn btn-default pull-right', id: 'view-all-templates' %>
    diff --git a/app/views/org_admin/templates/index.html.erb b/app/views/org_admin/templates/index.html.erb index 9de826dfe5..ab7cb10330 100644 --- a/app/views/org_admin/templates/index.html.erb +++ b/app/views/org_admin/templates/index.html.erb @@ -117,5 +117,6 @@ <%= _('Create a template') %> +
    \ No newline at end of file diff --git a/app/views/org_admin/users/plans.html.erb b/app/views/org_admin/users/plans.html.erb index 25f05671a3..bb282aa270 100644 --- a/app/views/org_admin/users/plans.html.erb +++ b/app/views/org_admin/users/plans.html.erb @@ -15,4 +15,3 @@ query_params: { sort_field: 'plans.updated_at', sort_direction: 'desc' }) %>
    - diff --git a/app/views/orgs/_api_emails.html.erb b/app/views/orgs/_api_emails.html.erb new file mode 100644 index 0000000000..87b5beaf4a --- /dev/null +++ b/app/views/orgs/_api_emails.html.erb @@ -0,0 +1,79 @@ +<%# locals: org %> + +<% +default_subject = _("A new data management plan (DMP) for the %{org_name} was started for you.") % { + org_name: org.name +} +default_message = _("A new data management plan (DMP) has been started for you by the %{external_system_name}. If you have any questions or need help, please contact the administrator for the %{org_name} at %{org_admin_email}.") % { + org_name: org.name, + org_admin_email: link_to(org.contact_email, org.contact_email), + external_system_name: "%{external_system_name}" +} + +org.api_create_plan_email_subject = default_subject unless org.api_create_plan_email_subject.present? +org.api_create_plan_email_body = default_message unless org.api_create_plan_email_body.present? +%> + +<%= form_for(org, url: admin_update_org_path(org), html: { multipart: true, method: :put, + id: "edit_org_api_emails" } ) do |f| %> + +

    <%= _('Plan creation via the API') %>

    + +

    <%= _("The following email will be sent to users when an external system creates a new DMP (via the API) using one of your templates.") %>

    + +
    +
    + <%= f.label :api_create_plan_email_subject, _("Subject"), class: "control-label" %> + <%= f.text_field :api_create_plan_email_subject, class: "form-control", + placeholder: default_subject %> +
    +
    +
    +
    + <%= f.label :api_create_plan_email_body, _("Message"), class: "control-label" %> + <%= f.text_area :api_create_plan_email_body, class: "form-control tinymce", + placeholder: default_message, rows: 6 %> +
    +
    +

    <%= _('Sample Message') %>

    + <%= sanitize default_message %> +
    +
    +

    <%= _("Note that you can use the '%{external_system_name}' variable in the subject and/or body. The system will insert the name of the external system before sending the email (for example 'Example University - RDMS').") %>

    +
    +
    +
    +
    + Email Preview: +
    +
    +

    + Hello jane.doe@example.org, +

    +

    + <%= sanitize(org.api_create_plan_email_body) % { + external_system_name: "[placeholder for external system name]" + } %> +

    +

    + <%= sanitize(_("%{click_here} to setup your account (or copy %{link} into your browser). Once you have signed in, you can begin filling out your DMP.") % { + click_here: link_to(_('Click here'), '#'), + link: "#{accept_user_invitation_url}/abc123" + }) %> +

    +
    +

    <%= _("Thank you,") %>

    +

    <%= _("The %{org_name} DMPTool team") % { org_name: org.name } %>

    +

    <%= _("Please do not reply to this email. If you have any questions or need help, please contact us at %{org_admin_email}") % { + org_admin_email: link_to(org.contact_email, org.contact_email) + } %>

    +
    +
    +
    +
    +
    +
    + <%= f.button(_('Save'), id:"save_org_submit", class: "btn btn-primary", type: "submit") %> +
    +
    +<% end %> diff --git a/app/views/orgs/_feedback_form.html.erb b/app/views/orgs/_feedback_form.html.erb index dbd13889af..afa09c3d7d 100644 --- a/app/views/orgs/_feedback_form.html.erb +++ b/app/views/orgs/_feedback_form.html.erb @@ -1,6 +1,5 @@ <%# locals: org %> -<% title _('Request Feedback') %> <%= form_for(org, url: admin_update_org_path(org), html: { multipart: true, method: :put, id: "edit_org_feedback_form" } ) do |f| %> diff --git a/app/views/orgs/admin_edit.html.erb b/app/views/orgs/admin_edit.html.erb index df5f67f0c8..feb3519311 100644 --- a/app/views/orgs/admin_edit.html.erb +++ b/app/views/orgs/admin_edit.html.erb @@ -26,6 +26,12 @@ <%= _('Schools/Departments') %> + <% if org.templates.any? %> +
  • + <%= _('API Emails') %> +
  • + <% end %> + <% if current_user.can_super_admin? %>
  • <%= _('Merge') %> @@ -68,6 +74,18 @@ + <% if org.templates.any? %> +
    +
    +
    +
    + <%= render partial: 'orgs/api_emails', locals: { org: org } %> +
    +
    +
    +
    + <% end %> + <% if current_user.can_super_admin? %>
    diff --git a/app/views/orgs/shibboleth_ds.html.erb b/app/views/orgs/shibboleth_ds.html.erb index 64114ac85c..66d4764f60 100644 --- a/app/views/orgs/shibboleth_ds.html.erb +++ b/app/views/orgs/shibboleth_ds.html.erb @@ -8,28 +8,27 @@
    <%= form_for 'shibboleth_ds', url: shibboleth_ds_path, namespace: 'shib-ds', html: {id: 'shibboleth_ds'} do |f| %> -
    +
    <%= f.label(:org_name, _('Look up your organisation here'), class: "control-label") %> - <% if @orgs.length <= 10 %> + <% if @orgs.present? && @orgs.length <= 10 %> <% else %> - <%= render partial: "shared/accessible_combobox", - locals: { name: "shib-ds[org_id]", - id: 'shib-ds_org_id', - default_selection: nil, - models: @orgs, - attribute: 'name', - required: true, - classes: '' } %> + <%= render partial: "shared/org_selectors/local_only", + locals: { + form: f, + orgs: @orgs, + default_org: nil, + required: true + } %> <% end %> <%= f.button(_('Go'), class: "btn btn-default", type: "submit") %> - <% if @orgs.length > 10 %> + <% if @orgs.present? && @orgs.length > 10 %>

    - <%= _('or') %> -
    diff --git a/app/views/paginable/api_clients/_index.html.erb b/app/views/paginable/api_clients/_index.html.erb index 798cff9e7e..b9098f43ae 100644 --- a/app/views/paginable/api_clients/_index.html.erb +++ b/app/views/paginable/api_clients/_index.html.erb @@ -14,6 +14,9 @@ <%= _('Contact') %> <%= paginable_sort_link('api_clients.contact_email') %> + + <%= _('Redirect URI') %> + <%= _('Last Accessed') %> <%= paginable_sort_link('api_clients.last_access') %> @@ -27,6 +30,9 @@ <%= client.org&.name %> <%= client.homepage.present? ? link_to(client.homepage) : "" %> <%= client.contact_email.present? ? link_to(client.contact_email, "mailto:#{client.contact_email}") : "" %> + + <%= client.redirect_uri&.split(/\n/)&.join(", ") %> + <%= client.last_access.present? ? l(client.last_access.to_date, formats: :short) : _("Never") %>